From 8ddf200e8f637eaf9a4f0d3036ece8f1d3869d10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 16:01:44 -0700 Subject: [PATCH 001/680] fix(server): one failing RPC handler no longer ends the client's other requests (#15515) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/observability/DefectReporter.test.ts | 102 +++++++++++++++ .../src/observability/DefectReporter.ts | 24 ++++ apps/server/src/ws.ts | 14 +- packages/effect-acp/src/_internal/shared.ts | 17 +++ packages/effect-acp/src/agent.test.ts | 38 ++++++ packages/effect-acp/src/agent.ts | 13 +- packages/effect-acp/src/client.test.ts | 123 ++++++++++++++++++ packages/effect-acp/src/client.ts | 16 ++- packages/effect-acp/src/protocol.test.ts | 69 ++++++++++ packages/effect-acp/src/protocol.ts | 54 ++++++-- 10 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/observability/DefectReporter.test.ts create mode 100644 apps/server/src/observability/DefectReporter.ts diff --git a/apps/server/src/observability/DefectReporter.test.ts b/apps/server/src/observability/DefectReporter.test.ts new file mode 100644 index 0000000000000..28c81180ba672 --- /dev/null +++ b/apps/server/src/observability/DefectReporter.test.ts @@ -0,0 +1,102 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Rpc, RpcClient, RpcGroup, RpcServer } from "effect/rpc"; + +import { WS_RPC_SERVER_OPTIONS } from "../ws.ts"; +import * as DefectReporter from "./DefectReporter.ts"; + +class TestRpcs extends RpcGroup.make( + Rpc.make("subscribe", { success: Schema.Number, stream: true }), + Rpc.make("boom", { success: Schema.Void }), +) {} +type TestRpc = RpcGroup.Rpcs; + +/** Runs `body` with every error log captured. */ +const withErrorLogs = ( + body: (logs: Queue.Queue>) => Effect.Effect, +) => + Effect.gen(function* () { + const logs = yield* Queue.unbounded>(); + const logger = Logger.make(({ cause, logLevel }) => { + if (logLevel === "Error") Queue.offerUnsafe(logs, cause); + }); + return yield* body(logs).pipe( + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); + +const errorMessage = (cause: Cause.Cause) => (Cause.squash(cause) as Error).message; + +describe("DefectReporter", () => { + it.effect("a dying RPC handler fails alone, and its defect is logged", () => + withErrorLogs((logs) => + Effect.gen(function* () { + const subscription = yield* Queue.unbounded(); + // The same client/server pairing as RpcTest.makeClient, with ws.ts's options. + let client!: Effect.Success< + ReturnType> + >; + const server = yield* RpcServer.makeNoSerialization(TestRpcs, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => client.write(response), + }).pipe( + // Provided to the handlers, as ws.ts does. + Effect.provide( + TestRpcs.toLayer({ + subscribe: () => Stream.fromQueue(subscription), + boom: () => Effect.die(new Error("handler bug")), + }).pipe(Layer.provide(DefectReporter.layer)), + ), + ); + client = yield* RpcClient.makeNoSerialization(TestRpcs, { + supportsAck: true, + onFromClient: ({ message }) => server.write(0, message), + }); + + const received = yield* Queue.unbounded(); + const sibling = yield* client.client.subscribe().pipe( + Stream.runForEach((value) => Queue.offer(received, value)), + Effect.forkScoped, + ); + yield* Queue.offer(subscription, 1); + assert.equal(yield* Queue.take(received), 1); + + const boom = yield* Effect.exit(client.client.boom()); + assert.isTrue(Exit.hasDies(boom)); + // The server reports before it answers, so the log is already written. + const logged = yield* Queue.clear(logs); + assert.deepEqual(logged.map(errorMessage), ["handler bug"]); + + // The sibling subscription on the same client keeps delivering. + yield* Queue.offer(subscription, 2); + const next = yield* Queue.take(received).pipe( + Effect.raceFirst(Fiber.await(sibling).pipe(Effect.as("subscription ended"))), + ); + assert.equal(next, 2); + assert.equal(yield* Queue.size(logs), 0); + }), + ).pipe(Effect.scoped), + ); + + it.effect("logs defects, not typed failures or interrupts", () => + withErrorLogs((logs) => + Effect.gen(function* () { + yield* ErrorReporter.report(Cause.fail(new Error("expected"))); + yield* ErrorReporter.report(Cause.interrupt()); + yield* ErrorReporter.report(Cause.die(new Error("bug"))); + + // Reporters log synchronously, so the logs are already written. + assert.deepEqual((yield* Queue.clear(logs)).map(errorMessage), ["bug"]); + }).pipe(Effect.provide(DefectReporter.layer)), + ), + ); +}); diff --git a/apps/server/src/observability/DefectReporter.ts b/apps/server/src/observability/DefectReporter.ts new file mode 100644 index 0000000000000..220b2696d831d --- /dev/null +++ b/apps/server/src/observability/DefectReporter.ts @@ -0,0 +1,24 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; + +/** + * Logs the defects of WebSocket RPC handlers. RpcServer reports every failed + * handler exit; typed failures are expected responses, so only dies are logged. + */ +const reporter: ErrorReporter.ErrorReporter = { + [ErrorReporter.TypeId]: ErrorReporter.TypeId, + report: ({ cause, fiber }) => { + for (const reason of cause.reasons) { + if (reason._tag !== "Die" || ErrorReporter.isIgnored(reason.defect)) continue; + // Reporters are called synchronously from the failing fiber. Logging with + // its context keeps its loggers and annotations, and a fork cannot throw + // back into the server that reported. + Effect.runForkWith(fiber.context)( + Effect.logError("Unhandled defect", Cause.fromReasons([reason])), + ); + } + }, +}; + +export const layer = ErrorReporter.layer([reporter]); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 99a05b8e205b2..dcf9fad568352 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -210,6 +210,7 @@ import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; @@ -3787,6 +3788,14 @@ const layerWsRpc = ( }), ); +// A defect in a handler's effect fails only its own request. RpcServer's default +// sends a socket-level Defect frame instead, and the client ends every pending +// request on the socket with it. DefectReporter logs these defects. +export const WS_RPC_SERVER_OPTIONS = { + disableTracing: true, + disableFatalDefects: true, +} as const; + export const layer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -3830,7 +3839,7 @@ export const layer = Layer.unwrap( yield* analytics.record("client.connected", clientAnalyticsProps); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; - yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe( + yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), Effect.provide(RpcAuthorization.layer(session.scopes)), Effect.forkScoped, @@ -3847,6 +3856,9 @@ export const layer = Layer.unwrap( serverBrowser, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + // Request fibers run in the handlers' context, so this reporter sees + // their defects, not the rest of the server's. + Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index e4a98d51d8bf5..527fb99304c64 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { RpcClientError } from "effect/rpc"; @@ -29,6 +30,19 @@ export const callRpc = ( }), ); +/** + * Runs a notification handler so it cannot stop the caller: a typed failure is + * dropped, as notifications have no reply, and a defect is logged. + */ +export const isolateNotificationHandler = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasDies(cause) + ? Effect.logError("ACP notification handler failed", cause) + : Effect.void, + ), + ); + export const runHandler = Effect.fnUntraced(function* >( handler: ((payload: A, ...args: Args) => Effect.Effect) | undefined, payload: A, @@ -39,6 +53,9 @@ export const runHandler = Effect.fnUntraced(function* + Effect.logError(`ACP request handler failed for '${method}'`, defect), + ), Effect.mapError((error) => AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(), ), diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 9b69ecb5918d6..416c4808a3edb 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -35,6 +35,14 @@ const SessionCancelNotification = jsonRpcNotification( const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number })); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); +/** A response whose cause is a handler's defect, as RpcServer encodes it. */ +const DieResponse = Schema.Struct({ + id: Schema.Number, + error: Schema.Struct({ + _tag: Schema.Literal("Cause"), + data: Schema.Tuple([Schema.Struct({ _tag: Schema.Literal("Die") })]), + }), +}); const decodeRequestPermissionRequest = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionRequest), ); @@ -296,3 +304,33 @@ it.effect("effect-acp agent uses distinct ids for RPC calls and extension reques }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); }), ); + +it.effect("effect-acp agent answers a request whose handler dies with an error for it", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope); + const agent = yield* Effect.service(AcpAgent.AcpAgent).pipe(Effect.provide(context)); + yield* agent.handleInitialize(() => Effect.die(new Error("handler bug"))); + + yield* Queue.offer( + input, + yield* encodeJsonl(InitializeRequest, { + jsonrpc: "2.0", + id: 7, + method: "initialize", + params: { + protocolVersion: 2, + capabilities: {}, + info: { name: "effect-acp-test", version: "0.0.0" }, + }, + headers: [], + }), + ); + const response = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(DieResponse))), + ); + assert.equal(response.id, 7); + yield* Scope.close(scope, Exit.void); + }), +); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 153fea8428eeb..2ddf5cc8cc0dc 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -18,6 +19,7 @@ import { callRpc, decodeExtNotificationRegistration, decodeExtRequestRegistration, + isolateNotificationHandler, runHandler, } from "./_internal/shared.ts"; @@ -280,7 +282,11 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( ), ), Effect.flatMap((decoded) => - Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), + Effect.forEach( + cancelHandlers, + (handler) => isolateNotificationHandler(handler(decoded)), + { discard: true }, + ), ), ); } @@ -421,7 +427,10 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( }), ); - yield* RpcServer.make(AcpRpcs.AgentRpcs).pipe( + yield* RpcServer.make(AcpRpcs.AgentRpcs, { disableFatalDefects: true }).pipe( + // runHandler logs handler defects with their method. A reporter inherited + // from the caller (a WebSocket request, say) would log them again. + Effect.provideService(ErrorReporter.CurrentErrorReporters, new Set()), Effect.provideService(RpcServer.Protocol, transport.serverProtocol), Effect.provide(layerAgentHandler), Effect.forkScoped, diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index d25df6ccf5e75..6209dcffa41f1 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -5,11 +5,13 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -50,6 +52,16 @@ const PermissionRequest = jsonRpcRequest( const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ElicitationRequest = jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest); const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); +/** A JSON-RPC error response; only its id and the presence of an error matter here. */ +const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); +/** A response whose cause is a handler's defect, as RpcServer encodes it. */ +const DieResponse = Schema.Struct({ + id: Schema.String, + error: Schema.Struct({ + _tag: Schema.Literal("Cause"), + data: Schema.Tuple([Schema.Struct({ _tag: Schema.Literal("Die") })]), + }), +}); const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); const XAiPromptCompleteNotification = jsonRpcNotification( "_x.ai/session/prompt_complete", @@ -1130,6 +1142,117 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect("answers each request whose handler dies, and keeps reading", () => + Effect.gen(function* () { + const errorLogs = yield* Queue.unbounded(); + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Error") Queue.offerUnsafe(errorLogs, String([message].flat()[0])); + }); + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.provide(Logger.layer([logger])), + ); + const bug = () => Effect.die(new Error("handler bug")); + yield* acp.handleRequestPermission(bug); + yield* acp.handleExtRequest("x/dies", Schema.Unknown, bug); + yield* acp.handleExtNotification("x/notification-dies", Schema.Unknown, bug); + yield* acp.handleSessionUpdate(bug); + const updates = yield* Queue.unbounded(); + yield* acp.handleSessionUpdate((notification) => + Queue.offer(updates, notification.sessionId).pipe(Effect.asVoid), + ); + yield* acp.handleExtRequest("x/test", Schema.Struct({ hello: Schema.String }), () => + Effect.succeed({ ok: true }), + ); + const send = (line: Effect.Effect) => + Effect.flatMap(line, (bytes) => Queue.offer(input, bytes)); + const decodeDie = Schema.decodeEffect(Schema.fromJsonString(DieResponse)); + // A stopped reader leaves these waiting; fail instead of hanging. + const next = (queue: Queue.Dequeue) => + TestClock.withLive(Queue.take(queue).pipe(Effect.timeout("2 seconds"))); + + yield* send( + encodeJsonl(PermissionRequest, { + jsonrpc: "2.0", + id: "permission-a", + method: "session/request_permission", + params: { + sessionId: "session-1", + title: "Tool", + subject: { + type: "tool_call" as const, + toolCall: { toolCallId: "tool-1", title: "Tool" }, + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], + }, + headers: [], + }), + ); + assert.equal((yield* next(output).pipe(Effect.flatMap(decodeDie))).id, "permission-a"); + + yield* send( + encodeJsonl(jsonRpcRequest("x/dies", Schema.Unknown), { + jsonrpc: "2.0", + id: "ext-a", + method: "x/dies", + params: {}, + headers: [], + }), + ); + const extDied = yield* next(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ErrorResponse))), + ); + assert.equal(extDied.id, "ext-a"); + + yield* send( + encodeJsonl(jsonRpcNotification("x/notification-dies", Schema.Unknown), { + jsonrpc: "2.0", + method: "x/notification-dies", + params: {}, + }), + ); + yield* send( + encodeJsonl(SessionUpdateNotification, { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } }, + }, + }), + ); + + // The next session handler still ran. + assert.equal(yield* next(updates), "session-1"); + + // The reader survived all three: a later request is still answered. + yield* send( + encodeJsonl(ExtRequest, { + jsonrpc: "2.0", + id: "ext-b", + method: "x/test", + params: { hello: "world" }, + headers: [], + }), + ); + const answered = yield* next(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ExtResponse))), + ); + assert.deepEqual([answered.id, answered.result], ["ext-b", { ok: true }]); + + // Every defect was logged at Error, once. + assert.deepEqual((yield* Queue.clear(errorLogs)).toSorted(), [ + "ACP extension request handler failed for 'x/dies'", + "ACP notification handler failed", + "ACP notification handler failed", + "ACP request handler failed for 'session/request_permission'", + ]); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect("answers elicitation/create with the flat action shape", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 2df7f9409f4ec..732e2d26650f6 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -22,6 +23,7 @@ import { callRpc, decodeExtNotificationRegistration, decodeExtRequestRegistration, + isolateNotificationHandler, runHandler, } from "./_internal/shared.ts"; import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; @@ -838,9 +840,12 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( registration: BufferedNotificationHandler, notification: A, ) => - Effect.forEach(registration.handlers, (handler) => handler(notification).pipe(Effect.ignore), { - discard: true, - }); + // One handler failing or dying does not stop the others, or the reader. + Effect.forEach( + registration.handlers, + (handler) => isolateNotificationHandler(handler(notification)), + { discard: true }, + ); const flushBufferedNotifications = (registration: BufferedNotificationHandler) => Effect.suspend(() => { @@ -1099,7 +1104,10 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }), ); - yield* RpcServer.make(AcpRpcs.CompatClientRpcs).pipe( + yield* RpcServer.make(AcpRpcs.CompatClientRpcs, { disableFatalDefects: true }).pipe( + // runHandler logs handler defects with their method. A reporter inherited + // from the caller (a WebSocket request, say) would log them again. + Effect.provideService(ErrorReporter.CurrentErrorReporters, new Set()), Effect.provideService(RpcServer.Protocol, transport.serverProtocol), Effect.provide(layerClientHandler), Effect.forkScoped, diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 91677618e0705..9c3f41e5f4c95 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -11,6 +11,7 @@ import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { it, assert } from "@effect/vitest"; @@ -257,6 +258,42 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("terminates when a callback on the reader dies", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + transformSessionUpdate: () => { + throw new Error("normalizer bug"); + }, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { sessionUpdate: "plan", entries: [] }, + }, + })}\n`, + ), + ); + + // Pending requests are failed through termination instead of hanging. + const error = yield* TestClock.withLive( + Deferred.await(termination).pipe(Effect.timeout("2 seconds")), + ); + assert.instanceOf(error, AcpError.AcpTransportError); + assert.equal((error as AcpError.AcpTransportError).operation, "read-input-stream"); + }), + ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -500,6 +537,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("answers an extension request whose handler also dies as an internal error", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + // The typed failure must not hide the defect from its cleanup. + onExtRequest: () => + Effect.fail(AcpError.AcpRequestError.invalidParams("bad params")).pipe( + Effect.ensuring(Effect.die(new Error("cleanup bug"))), + ), + }); + + yield* Queue.offer( + input, + yield* encodeJsonl(ExtRequest, { + jsonrpc: "2.0", + id: 9, + method: "x/test", + params: { hello: "world" }, + headers: [], + }), + ); + + const response = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(JsonRpcErrorResponse), + )(yield* Queue.take(output)); + assert.equal(response.id, 9); + assert.equal(response.error.code, -32603); + }), + ); + it.effect("preserves numeric ids for inbound extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index fb926cfca0535..12e85a9e75426 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -3,6 +3,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as PlatformError from "effect/PlatformError"; @@ -20,6 +21,7 @@ import * as AcpSchema from "./schema.ts"; import * as AcpSchemaV1 from "./_generated/schema-v1.gen.ts"; import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; import * as AcpError from "./errors.ts"; +import { isolateNotificationHandler } from "./_internal/shared.ts"; const isAcpError = Schema.is(AcpError.AcpError); export interface AcpProtocolLogEvent { @@ -385,8 +387,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const dispatchNotification = (notification: AcpIncomingNotification) => Queue.offer(notificationQueue, notification).pipe( Effect.andThen( + // A failing or dying handler must not stop the reader, or every later + // message on the connection goes unanswered. options.onNotification - ? options.onNotification(notification).pipe(Effect.ignore) + ? isolateNotificationHandler(options.onNotification(notification)) : Effect.void, ), Effect.asVoid, @@ -458,12 +462,38 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi method: message.tag, }) .pipe( - Effect.matchEffect({ - onFailure: (error) => - respondWithError( - message.id, - AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag), - ), + Effect.matchCauseEffect({ + // A dying handler answers its own request, like a core handler, and + // leaves the reader running. A defect wins over a typed failure in + // the same cause, so it is never hidden behind an expected error. + onFailure: (cause) => { + const failure = Cause.hasDies(cause) ? Option.none() : Cause.findErrorOption(cause); + if (Option.isSome(failure)) { + return respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionHandlerError(failure.value, message.tag), + ); + } + return Effect.logError( + `ACP extension request handler failed for '${message.tag}'`, + cause, + ).pipe( + Effect.andThen( + respondWithError( + message.id, + AcpError.AcpRequestError.internalError( + `ACP extension request handler failed for method '${message.tag}'`, + undefined, + { + method: message.tag, + operation: "handle-extension-request", + cause: Cause.squash(cause), + }, + ), + ), + ), + ); + }, onSuccess: (value) => respondWithSuccess(message.id, value), }), ); @@ -685,8 +715,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), ), ), - Effect.matchEffect({ - onFailure: (error) => { + // Anything that ends the reader, including a defect in a callback it runs, + // terminates the connection so pending requests fail instead of hanging. + Effect.matchCauseEffect({ + onFailure: (cause) => { + // The reader's own interruption never gets here: an interrupted fiber + // skips failure handlers. An interrupt raised inside it ends it too. + const failure = Cause.findErrorOption(cause); + const error = Option.isSome(failure) ? failure.value : Cause.squash(cause); const normalized: AcpError.AcpError = isAcpError(error) ? error : new AcpError.AcpTransportError({ From 13aabf3d7d0ec13e902234e86d3df27149c0e4a9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 16:20:55 -0700 Subject: [PATCH 002/680] fix(contracts): a context record that cannot be encoded no longer fails the send (#16398) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/git/GitOverviewSheet.tsx | 12 ++++++ .../src/lib/composerContextRecords.test.ts | 4 +- packages/contracts/src/baseSchemas.test.ts | 32 +++++++++++++-- packages/contracts/src/baseSchemas.ts | 20 ++++++++- .../contracts/src/composerContext.test.ts | 41 +++++++++++++++++++ packages/contracts/src/composerContext.ts | 29 ++++++++----- 6 files changed, 122 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 828c8838e31a8..31ad85873d1c5 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -55,6 +55,18 @@ type GitOverviewSheetProps = StaticScreenProps<{ }; export function GitOverviewSheet(props: GitOverviewSheetProps) { + const navigation = useNavigation(); + const { environmentId, threadId } = props.route.params; + // A hand-typed deep link can carry a blank ID, which the branded IDs reject. + const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; + useEffect(() => { + if (isBlankLink) navigation.goBack(); + }, [isBlankLink, navigation]); + if (isBlankLink) return null; + return ; +} + +function GitOverviewSheetContent(props: GitOverviewSheetProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index e5a83030a5fa2..3634cea6d4484 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -514,7 +514,7 @@ describe("composerContextRecords", () => { expect( isSameComposerContextPayload(base, { ...base, - elements: base.elements?.map((element) => ({ + elements: base.elements!.map((element) => ({ ...element, htmlPreview: '', })), @@ -523,7 +523,7 @@ describe("composerContextRecords", () => { expect( isSameComposerContextPayload(base, { ...base, - elements: base.elements?.map((element) => ({ + elements: base.elements!.map((element) => ({ ...element, source: { functionName: "Checkout", diff --git a/packages/contracts/src/baseSchemas.test.ts b/packages/contracts/src/baseSchemas.test.ts index 3e2c05c35058c..d1bcd4c153a00 100644 --- a/packages/contracts/src/baseSchemas.test.ts +++ b/packages/contracts/src/baseSchemas.test.ts @@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => { ]); }); - it("sends an element it cannot encode as a hole instead of failing the array", () => { - const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); + const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); + + it("drops an element it cannot encode instead of failing the array", () => { const wire = JSON.parse( JSON.stringify( Schema.encodeUnknownSync(Schema.toCodecJson(Named))([ @@ -59,9 +60,34 @@ describe("ForwardCompatibleArray", () => { ]), ), ); - expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]); + expect(wire).toEqual([{ name: "a" }, { name: "b" }]); expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]); }); + + it("drops it too when a wrapper reads the encoded array as JSON values", () => { + // How context records are bounded before forward-compatible decoding. + const Wrapped = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Named)); + expect( + Schema.encodeUnknownSync(Schema.toCodecJson(Wrapped))([{ name: "a" }, { name: " " }]), + ).toEqual([{ name: "a" }]); + }); + + it("still drops the null holes a server on an earlier build sends", () => { + expect(fromWire(Named)([{ name: "a" }, null, { name: "b" }])).toEqual([ + { name: "a" }, + { name: "b" }, + ]); + }); + + it("does not accept holes as a decoded value", () => { + expect(Schema.is(Named)([undefined])).toBe(false); + // A sparse array's missing index is a hole too. + const sparse: Array<{ name: string }> = [{ name: "a" }]; + sparse.length = 2; + expect(Schema.is(Named)(sparse)).toBe(false); + expect(() => Named.make(sparse)).toThrow(); + expect(Schema.is(Named)([{ name: "a" }])).toBe(true); + }); }); describe("ForwardCompatibleUnion", () => { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index adb64a1a4a6fb..3ca88df66b19d 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -140,13 +140,31 @@ export const ForwardCompatibleArray = (element: Elem Schema.UndefinedOr(Schema.toType(element)).pipe( Schema.catchEncoding(() => Effect.succeedSome(undefined)), ), + ).check( + // The holes above are an encoding detail: a decoded value has none, so + // `Schema.is` and `make` still reject an array that does. Aborts, so a + // later check on the array never sees a hole. + Schema.makeFilter( + (values) => { + // Every index, not `every`, which skips the holes of a sparse array. + for (let index = 0; index < values.length; index++) { + if (values[index] === undefined) return false; + } + return true; + }, + { expected: "an array without holes" }, + true, + ), ), SchemaTransformation.transform< ReadonlyArray, ReadonlyArray >({ decode: (values) => values.filter((value) => value !== undefined), - encode: (values) => values, + // An element that fails its own checks is dropped before the wire, so + // a wrapper that reads the encoded array as JSON values never meets + // the hole it left. + encode: (values) => values.filter((value) => value !== undefined), }), ), ) as unknown as ForwardCompatibleArray; diff --git a/packages/contracts/src/composerContext.test.ts b/packages/contracts/src/composerContext.test.ts index 87aa403cd6f78..b43e59c2840be 100644 --- a/packages/contracts/src/composerContext.test.ts +++ b/packages/contracts/src/composerContext.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => { ).toThrow(); }); + it("sends a message without a record it cannot encode", () => { + const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ + version: 1, + records: [ + decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], + { ...knownRecords.terminal, contextId: "ctx_2", terminalLabel: " " }, + ], + }); + expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); + }); + + it("sends a message without the records the wire cannot carry", () => { + const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ + version: 1, + records: [ + decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], + { + ...base, + contextId: "ctx_2", + kind: "future-kind", + label: "x", + payload: { count: Number.NaN }, + }, + { ...knownRecords["review-comment"], contextId: "ctx_3", fenceLanguage: undefined }, + // JSON.stringify throws on a bigint on every engine. + { ...base, contextId: "ctx_4", kind: "future-kind", label: "y", payload: { n: 1n } }, + ], + }); + expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); + }); + + it("reports a hole as a schema issue, even when collecting every issue", () => { + const result = Schema.decodeUnknownExit(Schema.toType(OrchestrationMessageContext))( + { version: 1, records: [undefined] }, + { errors: "all" }, + ); + expect(Exit.isFailure(result) && Cause.hasFails(result.cause)).toBe(true); + }); + it("normalizes decoded record identifiers", () => { const context = decodeContext({ version: 1, diff --git a/packages/contracts/src/composerContext.ts b/packages/contracts/src/composerContext.ts index 6d8975a0100dc..116c141c9dfbb 100644 --- a/packages/contracts/src/composerContext.ts +++ b/packages/contracts/src/composerContext.ts @@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({ }); export type ElementContextRecord = typeof ElementContextRecord.Type; +// Optional record fields are `optionalKey`: a record holding an explicit +// `undefined` cannot be sent as JSON, so it fails its own check and is dropped +// alone instead of failing the whole message. export const PreviewAnnotationContextRecord = Schema.Struct({ ...recordBase, kind: Schema.Literal("preview-annotation"), @@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ targetSummary: ShortString, styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)), /** Picked elements inside the annotation, with the detail the agent needs to find them. */ - elements: Schema.optional(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), + elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), /** Original target ids and edits allow pasted annotations to retain exact style changes. */ - elementIds: Schema.optional(Schema.Array(ShortString).check(Schema.isMaxLength(50))), + elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))), /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary, so a pasted annotation still says what it marked. */ - regionCount: Schema.optional(NonNegativeInt), - strokeCount: Schema.optional(NonNegativeInt), - styleChangeDetails: Schema.optional( + regionCount: Schema.optionalKey(NonNegativeInt), + strokeCount: Schema.optionalKey(NonNegativeInt), + styleChangeDetails: Schema.optionalKey( Schema.Array( Schema.Struct({ targetId: ShortString, @@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ ).check(Schema.isMaxLength(200)), ), /** The screenshot travels as its own image record; this links the two. */ - screenshotContextId: Schema.optional(ComposerContextId), + screenshotContextId: Schema.optionalKey(ComposerContextId), }); export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type; @@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({ rangeLabel: ShortString, text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS), diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS), - fenceLanguage: Schema.optional(BoundedString(64)), - pullRequest: Schema.optional(PullRequestContextMetadata), + fenceLanguage: Schema.optionalKey(BoundedString(64)), + pullRequest: Schema.optionalKey(PullRequestContextMetadata), }).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex)); export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type; @@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({ }); export type ThreadContextRecord = typeof ThreadContextRecord.Type; +const isJson = Schema.is(Schema.Json); + /** * Catch-all for kinds this build does not know. Known discriminators are excluded so a * malformed known record fails its own schema instead of sliding through unchecked. @@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({ kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)), payload: Schema.Unknown.check( Schema.makeFilter((payload) => { + let encoded: string | undefined; try { - const encoded = JSON.stringify(payload); - return encoded !== undefined && encoded.length <= 64_000; + encoded = JSON.stringify(payload); } catch { + // Cycles, bigints, and payloads nested deeper than this engine's stack. return false; } + // Only JSON values, so a payload the wire cannot carry (a Date, NaN, an + // undefined field) fails this record alone instead of the whole message. + return encoded !== undefined && encoded.length <= 64_000 && isJson(payload); }), ), }); From d8d037eae1b77a77f372fd1afd2662a31ebe37c8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 6 Oct 2026 19:21:17 -0400 Subject: [PATCH 003/680] fix(web): open pull request row actions on right-click (#16612) --- .../pullRequest/ThreadPullRequestsPanel.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 643c17d4b7759..42b09f4387e71 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -11,7 +11,7 @@ import { MoreHorizontalIcon, PlusIcon, } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; @@ -84,6 +84,15 @@ function LinkRow({ onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; }) { const openPrLink = useOpenPrLink(threadRef); + const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); + const menuAnchor = useMemo( + () => + menuPosition + ? { getBoundingClientRect: () => new DOMRect(menuPosition.x, menuPosition.y, 0, 0) } + : undefined, + [menuPosition], + ); const { link, depth, stack } = line; const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; @@ -91,6 +100,12 @@ function LinkRow({ return (
{ + event.preventDefault(); + event.stopPropagation(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + setMenuOpen(true); + }} // Each layer steps in under the one it targets. The step is capped: beyond a few layers // the indent only says "still in the stack", which the connector line already does, and // a sixteen-layer stack would otherwise stair-step off the right edge. @@ -221,7 +236,13 @@ function LinkRow({ )} > - + { + setMenuOpen(open); + if (!open) setMenuPosition(null); + }} + > } /> - + void writeTextToClipboard(link.url, "link")}> Copy link From 876f8656e310f365eab98678e68bcc45615fec7f Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:50:48 -0300 Subject: [PATCH 004/680] fix(web): show attempted paths in file preview errors (#15628) --- .../components/files/FilePreviewPanel.test.ts | 47 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 27 ++++++++++- .../src/components/files/filePreviewMode.ts | 22 +++++++++ .../files/projectFilesQueryState.test.tsx | 39 +++++++++++++++ .../files/projectFilesQueryState.ts | 5 +- 5 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index ebebf510994bc..84fb69e79cf7b 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -1,3 +1,5 @@ +import { ProjectReadFileError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { @@ -6,12 +8,57 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; +const decodeReadError = Schema.decodeSync(ProjectReadFileError); + +describe("file preview read errors", () => { + it.each([ + ["path_not_file", "The path is a directory or special file, not a regular file."], + ["binary_file", "The file is binary and cannot be displayed as text."], + ["workspace_path_outside_root", "The requested path is outside the workspace."], + ["resolved_path_outside_root", "The path resolves to a location outside the workspace."], + [ + "operation_failed", + "The file could not be accessed or read. It may be missing or inaccessible.", + ], + ] as const)("describes %s without revealing the platform cause", (failure, message) => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "workspace/outline.md", + failure, + operation: "realpath-target", + resolvedPath: "/workspace/workspace/outline.md", + cause: new Error("EACCES: sensitive platform detail"), + }); + expect(filePreviewReadErrorMessage(error)).toBe(message); + }); + + it("distinguishes an inaccessible workspace from an inaccessible file", () => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "outline.md", + failure: "operation_failed", + operation: "realpath-workspace-root", + operationPath: "/workspace", + }); + expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed."); + }); + + it("preserves the public message from older servers", () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + message: "Legacy file read failure.", + }); + expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure."); + }); +}); + describe("file comment annotations", () => { it("normalizes and formats selected line ranges", () => { expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 560c9a1336a53..5ae0753429fd1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && relativePath !== null, ); + const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} /> ) : relativePath && file.error && file.data === null ? ( -
- {file.error} +
+
+

+ {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} +

+ {attemptedPath ? ( +

+ Attempted path + + {attemptedPath} + +

+ ) : null} + {!isHostFile ? ( +

+ Workspace folder:{" "} + + {file.readError?.cwd ?? cwd} + + . Check the link's path or locate the file in Files. +

+ ) : null} +
) : relativePath && file.data === null ? (
diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 12a3eb95a3e81..764d44bfd83d4 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,4 +1,5 @@ import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; +import type { ProjectReadFileError } from "@t3tools/contracts"; import { isAbsolutePath } from "~/terminal-links"; /** Resolve workspace links before choosing between the explorer and a file preview. */ @@ -9,6 +10,27 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); +/** Describe existing failure codes without exposing the underlying platform cause. */ +export function filePreviewReadErrorMessage(error: ProjectReadFileError): string { + switch (error.failure) { + case "path_not_file": + return "The path is a directory or special file, not a regular file."; + case "binary_file": + return "The file is binary and cannot be displayed as text."; + case "workspace_path_outside_root": + return "The requested path is outside the workspace."; + case "resolved_path_outside_root": + return "The path resolves to a location outside the workspace."; + case "operation_failed": + // A realpath failure can mean a missing path, permissions, or another I/O error. + return error.operation === "realpath-workspace-root" + ? "The workspace folder could not be accessed." + : "The file could not be accessed or read. It may be missing or inaccessible."; + default: + return error.message; + } +} + export function shouldShowFileExplorer(input: { readonly relativePath: string | null; readonly explorerOpen: boolean; diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index a33465f6cb19e..49bade395980d 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -5,6 +5,7 @@ import { type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom, AtomRegistry } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-1"); +const decodeReadError = Schema.decodeSync(ProjectReadFileError); function deferred() { let resolve!: (value: A) => void; @@ -137,6 +139,7 @@ describe("project query refresh", () => { const render = (mutationId: string | null) => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + expect(query.readError).toBeNull(); renderedContents = query.data?.contents ?? null; useWorkspaceMutationRefresh({ mutationId, @@ -245,6 +248,7 @@ describe("project query refresh", () => { await flushEffects(); expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(query.readError).toBeNull(); expect(requests).toHaveLength(0); } finally { registry.dispose(); @@ -273,6 +277,7 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); expect(query.data).toBeNull(); } finally { unmount(); @@ -302,6 +307,40 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("retains decoded read failure context for the connected environment", async () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + cwd: "C:\\workspace", + relativePath: "workspace/outline.md", + failure: "operation_failed", + operation: "realpath-target", + operationPath: "C:\\workspace\\workspace\\outline.md", + resolvedPath: "C:\\workspace\\workspace\\outline.md", + message: "Failed to read workspace file.", + }); + const readAtom = Atom.make(Effect.fail(error)); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md"); + expect(query.readError).toBe(error); + expect(query.error).toBe(error.message); + expect(query.isNotFile).toBe(false); expect(query.data).toBeNull(); } finally { unmount(); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index fa4657ca815f1..0b3ca2040e88c 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -37,6 +37,7 @@ interface ProjectQueryState { } interface ProjectFileQueryState extends ProjectQueryState { + readonly readError: ProjectReadFileError | null; /** The path exists but is not a regular file, typically a directory. */ readonly isNotFile: boolean; } @@ -217,11 +218,13 @@ export function useProjectFileQuery( ); const optimisticFile = relativePath === null ? null : optimisticResult; const cause = failureCause(result); + const readError = isProjectReadFileError(cause) ? cause : null; return { data: optimisticFile?.data ?? data, error: errorMessage(cause), - isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", + readError, + isNotFile: readError?.failure === "path_not_file", isPending: result.waiting, refresh, }; From 5055de3ae483d5ece45695ad682b9766ff0cf423 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:53:27 -0300 Subject: [PATCH 005/680] fix(vcs): passive sidebar rows stop retaining remote pollers (#15666) --- .../connection/background-activity-scopes.ts | 3 +- .../connection/background-activity.test.ts | 11 ++ apps/server/src/git/GitManager.test.ts | 124 ++++++++++++++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 51 +++++-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 25 ++-- apps/web/src/components/Sidebar.tsx | 4 +- .../src/components/ThreadStatusIndicators.tsx | 2 +- .../lib/backgroundActivityReporter.test.ts | 9 ++ .../web/src/lib/backgroundActivityReporter.ts | 3 +- packages/contracts/src/git.ts | 7 + packages/contracts/src/rpc.ts | 3 +- 11 files changed, 213 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/connection/background-activity-scopes.ts b/apps/mobile/src/connection/background-activity-scopes.ts index da013a572191d..2e2a343ff0d88 100644 --- a/apps/mobile/src/connection/background-activity-scopes.ts +++ b/apps/mobile/src/connection/background-activity-scopes.ts @@ -45,7 +45,8 @@ function scopeForSubscription( if (observation.method !== WS_METHODS.subscribeVcsStatus) { return null; } - const input = observation.input as { readonly cwd?: unknown }; + const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; + if (input.includeRemote === false) return null; return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; } diff --git a/apps/mobile/src/connection/background-activity.test.ts b/apps/mobile/src/connection/background-activity.test.ts index 7a2d902557ed3..170ba4efa9c90 100644 --- a/apps/mobile/src/connection/background-activity.test.ts +++ b/apps/mobile/src/connection/background-activity.test.ts @@ -12,6 +12,12 @@ describe("mobile background activity", () => { it.effect("retains VCS demand only while the mobile subscription is active", () => Effect.gen(function* () { const environmentId = EnvironmentId.make("mobile-environment"); + const releasePassive = yield* observeMobileBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "/workspace", includeRemote: false }, + }); + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); const release = yield* observeMobileBackgroundActivitySubscription({ environmentId, method: WS_METHODS.subscribeVcsStatus, @@ -22,6 +28,11 @@ describe("mobile background activity", () => { { type: "vcs-status", cwd: "/workspace" }, ]); + yield* releasePassive; + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ + { type: "vcs-status", cwd: "/workspace" }, + ]); + yield* release; expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); }), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 95b99fb2c4335..adef6ab42afef 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -6,7 +6,10 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -16,11 +19,13 @@ import * as References from "effect/References"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, + GitManagerServiceError, GitPreparePullRequestThreadInput, ModelSelection, } from "@t3tools/contracts"; @@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { @@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { + it.effect("passive worktree status streams do not start remote refreshes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-passive-vcs-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + const worktreeDir = NodePath.join(repoDir, "worktree"); + yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]); + yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]); + + const { manager } = yield* makeManager(); + let remoteReads = 0; + const workflowContext = yield* Layer.build( + GitWorkflowService.layer.pipe( + Layer.provide( + Layer.succeed(GitManager.GitManager, { + ...manager, + remoteStatus: (input, options) => + manager.remoteStatus(input, options).pipe( + Effect.tap(() => + Effect.sync(() => { + remoteReads += 1; + }), + ), + ), + }), + ), + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), + Layer.provide(VcsProcess.layer), + ), + ), + ), + ); + const broadcasterContext = yield* Layer.build( + VcsStatusBroadcaster.layer.pipe( + Layer.provide( + Layer.succeed( + GitWorkflowService.GitWorkflowService, + Context.get(workflowContext, GitWorkflowService.GitWorkflowService), + ), + ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + }), + ), + ), + ); + const broadcaster = Context.get( + broadcasterContext, + VcsStatusBroadcaster.VcsStatusBroadcaster, + ); + const passiveScope = yield* Scope.make(); + const snapshots = yield* Deferred.make(); + const localUpdated = yield* Deferred.make(); + const remoteUpdated = yield* Deferred.make(); + let snapshotCount = 0; + for (const cwd of [repoDir, worktreeDir]) { + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd, includeRemote: false }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, + ), + (event) => { + if ( + cwd === worktreeDir && + event._tag === "localUpdated" && + event.local.hasWorkingTreeChanges + ) { + return Deferred.succeed(localUpdated, undefined); + } + if (cwd === worktreeDir && event._tag === "remoteUpdated") { + expect(event.remote?.hasUpstream).toBe(true); + return Deferred.succeed(remoteUpdated, undefined); + } + if (event._tag !== "snapshot") return Effect.void; + expect(event.local.isRepo).toBe(true); + expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive"); + snapshotCount += 1; + return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void; + }, + ).pipe( + Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)), + Effect.forkIn(passiveScope), + ); + } + yield* Deferred.await(snapshots); + expect(remoteReads).toBe(0); + yield* TestClock.adjust("1 minute"); + expect(remoteReads).toBe(0); + + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n"); + yield* broadcaster.refreshLocalStatus(worktreeDir); + yield* Deferred.await(localUpdated); + expect(remoteReads).toBe(0); + + const activeScope = yield* Scope.make(); + yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe( + Effect.forkIn(activeScope), + ); + yield* Deferred.await(remoteUpdated); + expect(remoteReads).toBe(1); + yield* Scope.close(activeScope, Exit.void); + yield* TestClock.adjust("1 minute"); + expect(remoteReads).toBe(1); + yield* Scope.close(passiveScope, Exit.void); + }), + ); + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 480ed8458fe30..35d013433c499 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -594,7 +594,7 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("streams a local snapshot first and remote updates later", () => { + it.effect("passive streams retain cached remote status", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const snapshotDeferred = yield* Deferred.make(); const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }).pipe(Effect.forkScoped); + yield* Stream.runForEach( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkScoped); const snapshot = yield* Deferred.await(snapshotDeferred); - yield* broadcaster.refreshStatus("/repo"); + yield* TestClock.adjust("1 minute"); + assert.equal(state.remoteStatusCalls, 0); + yield* broadcaster.getStatus({ cwd: "/repo" }); + state.currentRemoteStatus = remoteStatusWithPr; + yield* broadcaster.refreshPullRequestStatus("/repo"); const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); assert.deepStrictEqual(snapshot, { @@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => { } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", - remote: baseRemoteStatus, + remote: remoteStatusWithPr, } satisfies VcsStatusStreamEvent); + const cachedSnapshot = yield* Stream.runHead( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ); + assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), { + _tag: "snapshot", + local: baseLocalStatus, + remote: remoteStatusWithPr, + }); }).pipe(Effect.provide(layerTestFor(state))); }); @@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTest)); }); - it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + it.effect("releases remote demand while passive observers remain", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => { const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); const secondScope = yield* Scope.make(); + const passiveSnapshot = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(passiveSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkScoped); + yield* Deferred.await(passiveSnapshot); + assert.equal(state.remoteStatusCalls, 0); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => event._tag === "snapshot" ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 1f43ade5a28cb..468bc3301bfcb 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -19,6 +19,7 @@ import type { VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, + VcsStatusSubscriptionInput, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; @@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service< cwd: string, ) => Effect.Effect; readonly streamStatus: ( - input: VcsStatusInput, + input: VcsStatusSubscriptionInput, options?: StreamStatusOptions, ) => Stream.Stream; } @@ -708,15 +709,19 @@ export const make = Effect.gen(function* () { const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, - input.cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - - const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); + if (input.includeRemote !== false) { + yield* retainRemotePoller( + cwd, + input.cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); + } + const release = + input.includeRemote === false + ? Effect.void + : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 667f87b8df96a..370d4f335e8ac 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); @@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index b17d8f324c67d..ad0c9c99a2921 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({ gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts index e32d991f9f531..23e9044833277 100644 --- a/apps/web/src/lib/backgroundActivityReporter.test.ts +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => { Effect.gen(function* () { const environmentId = EnvironmentId.make("environment-observation-test"); const scope = { type: "vcs-status" as const, cwd: "/repo" }; + const releasePassive = yield* observeBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: scope.cwd, includeRemote: false }, + }); + expect(retainedBackgroundScopes(environmentId)).toEqual([]); const release = yield* observeBackgroundActivitySubscription({ environmentId, method: WS_METHODS.subscribeVcsStatus, @@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => { expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); + yield* releasePassive; + expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); + yield* release; expect(retainedBackgroundScopes(environmentId)).toEqual([]); }), diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts index bf0e7def102d4..5a65dab380950 100644 --- a/apps/web/src/lib/backgroundActivityReporter.ts +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -118,7 +118,8 @@ function scopeForSubscription( if (observation.method !== WS_METHODS.subscribeVcsStatus) { return null; } - const input = observation.input as { readonly cwd?: unknown }; + const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; + if (input.includeRemote === false) return null; return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6e2efd0404b96..b2cb2e902d8e7 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -111,6 +111,13 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsStatusSubscriptionInput = Schema.Struct({ + ...VcsStatusInput.fields, + /** Passive observers receive cached remote status without retaining its refresh loop. */ + includeRemote: Schema.optional(Schema.Boolean), +}); +export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type; + export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4d6ec6e472236..f05935309235f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -121,6 +121,7 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, VcsStatusInput, + VcsStatusSubscriptionInput, VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; @@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, }); const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { - payload: VcsStatusInput, + payload: VcsStatusSubscriptionInput, success: VcsStatusStreamEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, From 72d5c32ba67953805feb6fe9ad3b70b632a64c47 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:53:52 -0300 Subject: [PATCH 006/680] feat(web): group keybindings settings by area with a page toolbar (#12822) --- .../KeybindingsSettings.logic.test.ts | 20 ++ .../settings/KeybindingsSettings.logic.ts | 47 +++ .../settings/KeybindingsSettings.tsx | 272 ++++++++---------- 3 files changed, 182 insertions(+), 157 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index f790f3681316d..2e5f880790842 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -7,6 +7,7 @@ import { buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, + groupKeybindingRows, keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, @@ -58,6 +59,25 @@ describe("KeybindingsSettings.logic", () => { ); }, ); + it("groups rows by command area in page order and drops empty groups", () => { + const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "")); + expect(groups.map((group) => group.title)).toEqual([ + "Navigation", + "Threads", + "Composer", + "Terminal", + "Preview & diff", + "Appearance", + "Other", + ]); + const composer = groups.find((group) => group.id === "composer"); + expect(composer?.rows.map((row) => row.command)).toEqual( + expect.arrayContaining(["composer.host", "modelPicker.toggle"]), + ); + expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual( + [expect.objectContaining({ id: "terminal" })], + ); + }); it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.open", diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index cbdd456a0172c..fde8f3419c090 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -201,6 +201,53 @@ export function keybindingConflictLabels( return [...new Set(conflicts)].toSorted(); } +export interface KeybindingGroup { + readonly id: string; + readonly title: string; + readonly rows: ReadonlyArray; +} + +/** Page sections in display order; a command joins the first group listing its prefix. */ +const KEYBINDING_GROUPS = [ + { + id: "navigation", + title: "Navigation", + prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"], + }, + { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] }, + { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] }, + { id: "terminal", title: "Terminal", prefixes: ["terminal"] }, + { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] }, + { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] }, + { id: "scripts", title: "Project scripts", prefixes: ["script"] }, +] as const; +const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const; + +function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } { + const prefix = String(command).split(".")[0] ?? ""; + return ( + KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray).includes(prefix)) ?? + OTHER_KEYBINDING_GROUP + ); +} + +/** Splits sorted rows into the page's sections, dropping sections with no rows. */ +export function groupKeybindingRows( + rows: ReadonlyArray, +): ReadonlyArray { + const rowsByGroup = new Map>(); + for (const row of rows) { + const group = keybindingGroupFor(row.command); + const bucket = rowsByGroup.get(group.id); + if (bucket) bucket.push(row); + else rowsByGroup.set(group.id, [row]); + } + return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => { + const groupRows = rowsByGroup.get(group.id); + return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : []; + }); +} + export function buildKeybindingRows( keybindings: ResolvedKeybindingsConfig, query: string, diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index bf14e709dda17..b2660f8943e27 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -59,13 +59,16 @@ import { keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, + groupKeybindingRows, type KeybindingCommandOption, + type KeybindingGroup, type KeybindingRow, type WhenVariableOption, unknownWhenVariables, whenAstToExpression, whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; +import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) { ); } -function ExpandableHeaderSearch({ +/** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */ +function KeybindingsSearchInput({ query, onChange, - isOpen, - onOpenChange, inputRef, - collapsedAccessory, }: { query: string; onChange: (next: string) => void; - isOpen: boolean; - onOpenChange: (next: boolean) => void; - inputRef?: RefObject; - collapsedAccessory?: ReactNode; + inputRef: RefObject; }) { - if (!isOpen) { - return ( - <> - {collapsedAccessory} - - onOpenChange(true)} - aria-label="Search keybindings" - > - - - } - /> - Search keybindings - - - ); - } - return ( - + - + onChange(event.currentTarget.value)} - onBlur={() => { - if (query.length === 0) onOpenChange(false); - }} onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - onChange(""); - onOpenChange(false); - } + // The settings route treats an unhandled Escape as "go back"; + // inside the search box it clears, then leaves the field. + if (event.key !== "Escape") return; + event.preventDefault(); + if (query.length > 0) onChange(""); + else event.currentTarget.blur(); }} placeholder="Search keybindings" aria-label="Search keybindings" @@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { return ( @@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { ); } -interface KeybindingsListProps extends KeybindingRowActions { - rows: ReadonlyArray; - commandOptions: ReadonlyArray; +interface KeybindingsGroupsProps extends KeybindingRowActions { + groups: ReadonlyArray; + anchorIds: ReadonlyMap; savingCommand: KeybindingCommand | null; - isAddingBinding: boolean; - onCancelAdd: () => void; } -/** The add-binding row, one settings row per binding, and the empty state. */ -function KeybindingsList(props: KeybindingsListProps) { - const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = - props; - const newProps: NewKeybindingProps = { - commandOptions, - allRows: rows, - variables: rowActions.variables, - isSaving: savingCommand !== null, - onSave: rowActions.onSave, - onCancel: onCancelAdd, - }; - // Settings search jumps to a command, so only its first row anchors. - const anchorIds = useMemo(() => { - const ids = new Map(); - const seen = new Set(); - for (const row of rows) { - if (seen.has(row.command)) continue; - seen.add(row.command); - ids.set(row.id, keybindingSearchAnchorId(row.command)); - } - return ids; - }, [rows]); - return ( -
- {isAddingBinding ? : null} - {rows.map((row) => ( +/** One titled section per command area, each holding its binding rows. */ +function KeybindingsGroups(props: KeybindingsGroupsProps) { + const { groups, anchorIds, savingCommand, ...rowActions } = props; + return groups.map((group) => ( + + {group.rows.map((row) => ( ))} - {rows.length === 0 && !isAddingBinding ? ( -
- No keybindings match your search. -
- ) : null} -
- ); + + )); } /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { + // The label carries the whole sentence so assistive tech reads it without + // opening the tooltip. + const message = "The browser may claim some shortcuts first. The desktop app receives them all."; return ( -
- - - Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app - for better keybinding support. - -
+ + + + + } + /> + + {message} + + ); } @@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() { availableEditors, ); const [query, setQuery] = useState(""); - const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); const [savingCommand, setSavingCommand] = useState(null); const [isAddingBinding, setIsAddingBinding] = useState(false); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); + const groups = useMemo(() => groupKeybindingRows(rows), [rows]); + // Settings search jumps to a command, so only its first row anchors. + const anchorIds = useMemo(() => { + const ids = new Map(); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.command)) continue; + seen.add(row.command); + ids.set(row.id, keybindingSearchAnchorId(row.command)); + } + return ids; + }, [rows]); // The search-target context is provided by this panel's own page container, // so the jump target is read from the route hash here. const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); @@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() { } event.preventDefault(); - setIsSearchOpen(true); - requestAnimationFrame(() => { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); @@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() { const cancelAdd = useCallback(() => setIsAddingBinding(false), []); - const bindingsCount = ( - - {rows.length + (isAddingBinding ? 1 : 0)}{" "} - {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"} - - ); - - const listProps: KeybindingsListProps = { - rows, + const rowActions: KeybindingRowActions = { allRows: rows, - commandOptions, variables: whenVariables, - savingCommand, - isAddingBinding, - onCancelAdd: cancelAdd, onSave: saveKeybinding, onReset: resetKeybinding, onRemove: removeKeybinding, @@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() { - - - setIsAddingBinding(true)} - aria-label="Add keybinding" - > - - - } - /> - Add keybinding - - - - - - } - /> - Open keybindings.json - -
- } + headerAction={!isElectron ? : null} > - {!isElectron ? : null} - - +
+ + + + + + + } + /> + Open keybindings.json + +
+ + {isAddingBinding ? ( + + + + ) : null} + + {groups.length > 0 ? ( + + ) : ( + +
+ No keybindings match your search. +
+
+ )} ); } From c5c6f9cb485b2db0f205797394bc95f9f575d139 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:00:21 +0300 Subject: [PATCH 007/680] feat(web): stop T3-owned subagents from Lineage (#15211) --- ...ThreadRelationshipsControl.agents.test.tsx | 111 +++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 53 ++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index af1d42f3876b4..6c7534d04884d 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ projects: [] as unknown[], configs: new Map(), showTooltips: false, + command: vi.fn().mockResolvedValue({ _tag: "Success" }), })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); @@ -29,7 +30,7 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../lib/archivedThreadsState", () => ({ useArchivedThreadSnapshots: () => ({ snapshots: [] }), })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); vi.mock("../ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => children, TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => @@ -48,8 +49,116 @@ afterEach(async () => { state.projects = []; state.configs.clear(); state.showTooltips = false; + state.command.mockClear(); + state.projection = null; }); +it.each(["codex", "claudeAgent"])( + "stops only active app-owned %s subagents without opening their thread", + async (driver) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const parent = { + id: "parent", + lineage: { relationshipToParent: null }, + activeProviderThreadId: null, + }; + const child = { + id: "child", + title: "Worker", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + }; + const agent = { + id: "agent", + childThreadId: "child", + origin: "app_owned", + driver, + providerInstanceId: "codex", + title: "Worker", + prompt: "Check the change", + model: "gpt-5.4", + status: "running", + progress: null, + result: null, + startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + }; + state.shells = [{ environmentId: "test", source: child }]; + const projection = { + thread: parent, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [agent], + }; + state.projection = projection; + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + expect(state.navigate).not.toHaveBeenCalled(); + + for (const status of ["starting", "running", "waiting"] as const) { + state.command.mockClear(); + state.shells = [ + { + environmentId: "test", + source: { + ...child, + activityRunStatus: status, + activityRunStartedAt: DateTime.makeUnsafe("2026-09-16T12:05:00Z"), + }, + }, + ]; + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", status: "completed" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; + await act(async () => renderer.update(cloneElement(panel))); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledTimes(1); + expect(state.command).toHaveBeenLastCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + } + state.shells = [{ environmentId: "test", source: child }]; + for (const status of ["completed", "failed", "interrupted"]) { + state.projection = { ...projection, subagents: [{ ...agent, status }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + } + state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + }, +); + it("shows the matching child agent details and refreshes them when the agent settles", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const agent = { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 6d52368186997..cca32d57ab045 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -36,6 +36,7 @@ import { LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, + SquareIcon, UnplugIcon, } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; @@ -55,6 +56,7 @@ import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadR import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, @@ -234,7 +236,9 @@ export function ThreadRelationshipsPanel(props: { const navigate = useNavigate(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); + const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); + const [stoppingThreadId, setStoppingThreadId] = useState(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = useMemo( @@ -310,6 +314,19 @@ export function ThreadRelationshipsPanel(props: { setBusyAction(null); }; + const stopSubagent = async (childThreadId: ThreadId) => { + if (stoppingThreadId !== null) return; + setStoppingThreadId(childThreadId); + const result = await interruptTurn({ + environmentId: props.environmentId, + input: { threadId: childThreadId }, + }); + setStoppingThreadId(null); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not stop subagent" }); + } + }; + const parentTitle = mergeTargetThreadId === null ? null @@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: { isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, node?.thread, ); + const canStop = + agent?.origin === "app_owned" && + agent.startedAt && + ["pending", "running", "waiting"].includes(agent.status); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, @@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: { {agent ? ( agent.startedAt ? ( - + ) : null @@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? (
    @@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: { {relationshipTooltip} )} + {canStop && agent ? ( +
    + + void stopSubagent(threadId)} + /> + } + > + {stoppingThreadId === threadId ? ( + + ) : ( + + )} + + Stop subagent + +
    + ) : null}
  • ); }) From ab28039ef77ca8847c6bfe845c3502ff7e348c30 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 21:00:50 -0300 Subject: [PATCH 008/680] feat(web): add fast actions to linked pull requests (#16627) --- .../pullRequest/PullRequestSpeedActions.tsx | 21 ++++--- .../pullRequest/ThreadPullRequestsPanel.tsx | 60 +++++++++++++++++-- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index ee1bc5dcc02c8..319e73d20a319 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -11,13 +11,18 @@ import { usePullRequestDefaultMergeMethodResolver, } from "./usePullRequestActions"; -export interface PullRequestSpeedActionResult { - readonly entry: EnvironmentPullRequestEntry; +type PullRequestSpeedActionEntry = Pick< + EnvironmentPullRequestEntry, + "environmentId" | "projectId" | "host" | "repository" | "number" | "state" | "isDraft" +> & { readonly stack?: object | undefined }; + +export interface PullRequestSpeedActionResult { + readonly entry: Entry; readonly action: PullRequestAction; } /** No detail or stack reads until a merge is clicked, even on a long list. */ -export function PullRequestSpeedActions({ +export function PullRequestSpeedActions({ entry, visible, onActed, @@ -25,12 +30,12 @@ export function PullRequestSpeedActions({ sweeping = false, onCloseSweepStart, }: { - entry: EnvironmentPullRequestEntry; + entry: Entry; visible: boolean; - onActed: (result: PullRequestSpeedActionResult) => void; + onActed?: (result: PullRequestSpeedActionResult) => void; closing?: boolean; sweeping?: boolean; - onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; + onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void; }) { const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( entry.environmentId, @@ -45,7 +50,7 @@ export function PullRequestSpeedActions({ const { actionPending, perform } = usePullRequestActionRunner({ environmentId: entry.environmentId, reference, - onSuccess: (action) => onActed({ entry, action }), + onSuccess: (action) => onActed?.({ entry, action }), resolveMergeMethod: (detail) => { const allowed = detail.capabilities.mergeMethods.filter( (method) => detail.mergeCapabilities[method], @@ -102,7 +107,7 @@ export function PullRequestSpeedActions({ {action === "merge" && entry.stack ? "Open this pull request to merge its stack" - : action === "close" + : action === "close" && onCloseSweepStart ? "Close immediately, or drag across rows to close several" : `${label} immediately`} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 42b09f4387e71..237528702b4d1 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -1,4 +1,5 @@ -import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveThreadPullRequestChains, visibleThreadPullRequests, @@ -14,9 +15,10 @@ import { import { useCallback, useMemo, useState } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; -import { useServerConfigs, useThreadShell } from "~/state/entities"; +import { useShortcutModifierState } from "~/shortcutModifierState"; +import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -42,6 +44,7 @@ import { pullRequestChecksStatePresentation, } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; const SOURCE_LABELS: Record = { manual: "Linked by you", @@ -74,11 +77,15 @@ function ChecksGlyph({ function LinkRow({ line, threadRef, + projectId, + speedMode, onUnlink, onSetWatching, }: { line: PullRequestListLine; threadRef: ScopedThreadRef; + projectId: ProjectId | null; + speedMode: boolean; onUnlink: (link: ThreadPullRequestLink) => void; /** Null when the environment cannot watch pull requests. */ onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; @@ -97,6 +104,22 @@ function LinkRow({ const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; const watching = link.watch !== undefined; + const actionEntry = + projectId !== null && + snapshot !== null && + snapshot.state !== "merged" && + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github" + ? { + environmentId: threadRef.environmentId, + projectId, + host: link.host, + repository: link.repository, + number: link.number, + state: snapshot.state, + isDraft: snapshot.isDraft, + ...(link.stack === null ? {} : { stack: link.stack }), + } + : null; return (
    + {actionEntry !== null ? ( + + ) : null} {/* Out of the row's flow, so no row reserves a column for a button only the hovered one shows. It sits over the right end of the second line on the row's own hover color, fading in from the left, so it covers the time and leaves the diff counts alone. */} @@ -233,6 +259,8 @@ function LinkRow({ "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100", "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100", "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100", + "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden", + speedMode && actionEntry !== null && "hidden", )} > @@ -301,12 +329,24 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { const thread = useThreadShell(threadRef); + const projects = useProjects(); + const environmentProjects = useMemo( + () => + projects + .filter((project) => project.environmentId === threadRef.environmentId) + .toSorted((left, right) => + left.id === thread?.projectId ? -1 : right.id === thread?.projectId ? 1 : 0, + ), + [projects, threadRef.environmentId, thread?.projectId], + ); + const modifiers = useShortcutModifierState(true); + const speedMode = + modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true }); - const supportsWatch = - useServerConfigs().get(threadRef.environmentId)?.environment.capabilities - .threadPullRequestWatch === true; + const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities; + const supportsWatch = capabilities?.threadPullRequestWatch === true; const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); const handleUnlink = useCallback( @@ -377,6 +417,14 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread key={`${line.link.host}/${line.link.repository}#${line.link.number}`} line={line} threadRef={threadRef} + projectId={ + capabilities?.pullRequests === true + ? (findProjectForChangeRequest(environmentProjects, line.link)?.id ?? + thread?.projectId ?? + null) + : null + } + speedMode={speedMode} onUnlink={handleUnlink} onSetWatching={supportsWatch ? handleSetWatching : null} /> From 7202ba666080cfca1351c080d7aaa2d81bfa9a66 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:04:00 +0300 Subject: [PATCH 009/680] feat(web): open right panel tab menu with Mod+T (#15686) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 5 + .../RightPanelTabs.keyboard.test.tsx | 221 ++++++++++++++++++ .../src/components/RightPanelTabs.test.tsx | 10 + apps/web/src/components/RightPanelTabs.tsx | 33 ++- apps/web/src/routes/_chat.pull-requests.tsx | 2 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 7 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/RightPanelTabs.keyboard.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89b74c063da48..e705a42d5976f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11603,6 +11603,8 @@ export default function ChatView(props: ChatViewProps) { ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) })); + +let root: Root; +let container: HTMLDivElement; +const addFiles = vi.fn(); +const noop = () => undefined; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + addFiles.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function renderPanel(overrides: Partial> = {}) { + await act(() => + root.render( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddDiff={noop} + onAddFiles={addFiles} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDevice={noop} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + {...overrides} + > + content + , + ), + ); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); +} + +async function press(key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options }); + await act(() => (document.activeElement ?? document.body).dispatchEvent(event)); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); + return event; +} + +describe("right panel new-tab shortcut", () => { + it.each(["MacIntel", "Win32", "Linux x86_64"])( + "opens the menu on %s and chooses a tab", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel(); + const event = await press( + "t", + platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true }, + ); + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')?.textContent).toContain( + "Linked pull requests", + ); + expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull(); + expect( + ( + await press("t", { + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + repeat: true, + }) + ).defaultPrevented, + ).toBe(true); + await press("f"); + expect(addFiles).toHaveBeenCalledOnce(); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("leaves the shortcut alone while the mounted panel is closed", async () => { + await renderPanel({ open: false }); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); + + it.each(["Win32", "Linux x86_64"])( + "leaves Ctrl+T for the focused terminal on %s", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel({ + getShortcutContext: () => ({ + terminalFocus: true, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }); + const terminal = document.createElement("textarea"); + const onKeyDown = vi.fn(); + terminal.addEventListener("keydown", onKeyDown); + container.append(terminal); + await act(() => terminal.focus()); + const event = await press("t", { ctrlKey: true }); + expect(event.defaultPrevented).toBe(false); + expect(onKeyDown).toHaveBeenCalledWith(event); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("uses a custom binding from a text field and closes on Escape", async () => { + const input = document.createElement("input"); + await renderPanel({ + keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]), + }); + document.body.append(input); + input.focus(); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + await press("y", { metaKey: true }); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + await press("Escape"); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Add panel surface"]'), + ); + input.remove(); + }); + + it("does not open over another popup or during text composition", async () => { + await renderPanel(); + expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); + const dialog = document.createElement("div"); + dialog.dataset.slot = "dialog-popup"; + container.append(dialog); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }); + + it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => { + const panel = { + mode: "sheet" as const, + surfaces: [{ id: "files" as const, kind: "files" as const }], + }; + await renderPanel(panel); + await press("t", { metaKey: true }); + await press("ArrowDown"); + await press("ArrowDown"); + await press("ArrowDown"); + expect(document.activeElement?.textContent).toContain("Files"); + await press("Enter"); + expect(addFiles).toHaveBeenCalledOnce(); + await press("t", { metaKey: true }); + await renderPanel({ ...panel, open: false }); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + await renderPanel(panel); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 25b0997813e1b..823028eddff1e 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { describe, expect, it } from "vite-plus/test"; import { @@ -98,6 +99,15 @@ function renderTabs( return renderToStaticMarkup( ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} environmentId={null} activeSurfaceId={previewSurface.id} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 914028f7c8cd2..4f6f3607f470c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import type { PreviewSessionSnapshot, ProjectId, PullRequestState, + ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -32,6 +33,7 @@ import { type ReactNode, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -41,6 +43,7 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; +import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -83,6 +86,8 @@ interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; + keybindings: ResolvedKeybindingsConfig; + getShortcutContext: () => ShortcutMatchContext; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ @@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const addSurfaceTriggerRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ @@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) { canScrollRight: false, }); + if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); + + const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if ( + resolveShortcutCommand(event, props.keybindings, { + context: { ...props.getShortcutContext(), rightPanelOpen: true }, + }) !== "rightPanel.new" + ) + return; + if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) { + addSurfaceTriggerRef.current?.focus(); + setAddSurfaceMenuOpen(true); + } + }); + useEffect(() => { + if (props.open === false) return; + document.addEventListener("keydown", onNewSurfaceKeyDown, true); + return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); + }, [props.open]); + const updateTabScrollState = useCallback(() => { const viewport = tabScrollViewport(tabListRef.current); if (!viewport) return; @@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
    ); })} - {props.surfaces.length > 0 ? ( + {props.open !== false ? ( = [ { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, + { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, From 4b5c60485366421794787aee7513913f92eced29 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:12:49 -0700 Subject: [PATCH 010/680] fix(server): provider sessions clean up when their start is interrupted (#15571) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/device/LocalDeviceHost.ts | 156 ++-- .../Adapters/ClaudeAdapterV2.ts | 3 +- .../Adapters/CodexAdapterV2.ts | 4 +- .../Adapters/OpenCode2AdapterV2.test.ts | 19 + .../Adapters/OpenCode2AdapterV2.ts | 16 +- .../Adapters/OpenCodeAdapterV2.test.ts | 46 +- .../Adapters/OpenCodeAdapterV2.ts | 8 +- .../orchestration-v2/Adapters/PiAdapterV2.ts | 3 +- .../ProviderSessionManager.test.ts | 847 +++++++++++++++++- .../ProviderSessionManager.ts | 321 ++++--- 10 files changed, 1213 insertions(+), 210 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 2e3cb3d0f71a4..65ddf6895742e 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -383,62 +383,63 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); const origin = `http://127.0.0.1:${port}`; const scope = yield* Scope.make("sequential"); - const child = yield* spawner - .spawn( - ChildProcess.make( - nodePath, - [ - hubTool.entryPath, - "--port", - String(port), - "--host", - "127.0.0.1", - "--hide-sidebar", - "--hide-boot-device", - ], - { - detached: false, - shell: false, - stdout: "pipe", - stderr: "pipe", - env: yield* hubEnvironment(hostEnvironment).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(HostProcessPlatform, hostPlatform), - ), - }, - ), - ) - .pipe( - Effect.provideService(Scope.Scope, scope), - Effect.mapError( - (cause) => - new DeviceHost.DeviceHostError({ - hostId, - step: "starting the device hub", - cause, - }), - ), - ); - const startedAtMillis = yield* Clock.currentTimeMillis; - const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; - yield* Effect.forkIn(observeHubOutput(hub), scope); - yield* waitForHttpReady({ - baseUrl: origin, - path: "/readyz", - timeoutMs: HUB_READY_TIMEOUT_MS, - makeError: (info) => - new DeviceHost.DeviceHostError({ - hostId, - step: "waiting for the device hub to answer", - cause: info.cause, - }), - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.tapError(() => stopHub(hub)), - ); - yield* recordHub(hub, hubTool); - yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); - return hub; + // On failure or interrupt, from the spawn on: the hub is not recorded yet, + // so nothing else stops it. + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make( + nodePath, + [ + hubTool.entryPath, + "--port", + String(port), + "--host", + "127.0.0.1", + "--hide-sidebar", + "--hide-boot-device", + ], + { + detached: false, + shell: false, + stdout: "pipe", + stderr: "pipe", + env: yield* hubEnvironment(hostEnvironment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(HostProcessPlatform, hostPlatform), + ), + }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "starting the device hub", + cause, + }), + ), + ); + const startedAtMillis = yield* Clock.currentTimeMillis; + const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; + yield* Effect.forkIn(observeHubOutput(hub), scope); + yield* waitForHttpReady({ + baseUrl: origin, + path: "/readyz", + timeoutMs: HUB_READY_TIMEOUT_MS, + makeError: (info) => + new DeviceHost.DeviceHostError({ + hostId, + step: "waiting for the device hub to answer", + cause: info.cause, + }), + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + yield* recordHub(hub, hubTool); + yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); + return hub; + }).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); }); const observeHubOutput = (hub: HubProcess) => @@ -622,25 +623,30 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); - yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.ignore, - ); - const candidate = helperPaths(hubTool); - const [axExists, cliExists] = yield* Effect.all([ - fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), - fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), - ]); - const next: RunningHost = { - hub, - agentDevice: null, - helpers: { - serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, - serveSimCli: cliExists ? candidate.serveSimCli : null, - }, - }; - yield* Ref.set(runningRef, next); + // Until the hub is in runningRef, nothing else stops it, so publishing it + // is part of the guarded step. + const next = yield* Effect.gen(function* () { + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); + const candidate = helperPaths(hubTool); + const [axExists, cliExists] = yield* Effect.all([ + fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), + fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), + ]); + const next: RunningHost = { + hub, + agentDevice: null, + helpers: { + serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, + serveSimCli: cliExists ? candidate.serveSimCli : null, + }, + }; + yield* Ref.set(runningRef, next); + return next; + }).pipe(Effect.onError(() => stopHub(hub))); yield* Ref.set(restartDelayRef, 0); yield* Effect.forkDetach(superviseHub(hub, hubTool)); return next; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9af1c0efde4bd..81f9abc001a37 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7148,7 +7148,8 @@ export function makeClaudeAdapterV2( options: queryOptions, }) .pipe( - Effect.tapError(() => + // An interrupted open leaves the old process just as dead. + Effect.onError(() => // Same-native-thread replacement: the old process is already // dead, so its process-scoped roster is not authoritative. // First-ever failed open (no prior live query) must not invent diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19517c3491161..d562f876df496 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -6174,8 +6174,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi yield* Ref.update(pendingRootTurns, (current) => new Map(current).set(threadId, turnInput), ); + // Cleared on interrupt too, as startTurn does: an interrupted start + // must not adopt the native turn that a late turn/started reports. yield* client.request("thread/compact/start", { threadId }).pipe( - Effect.tapError(() => + Effect.onError(() => Ref.update(pendingRootTurns, (current) => { const next = new Map(current); next.delete(threadId); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 46792897e7893..7274de8c386f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -428,6 +428,25 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("ends a turn on the provider thread it started on", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + // A forked run starts on its own row for the same native session, while + // the adapter tracks the session under the id it minted for the fork. + const forkedRow = { + ...thread, + id: ProviderThreadId.make("provider-thread:opencode2-adapter:forked-run-row"), + }; + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(forkedRow)); + assert.equal((yield* Fiber.join(terminal))?.providerThreadId, forkedRow.id); + }).pipe(Effect.scoped), + ); + it.effect("ends the turn when its terminal event is one this build cannot decode", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 7716b75baaf9f..102eb721bb1a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -1786,7 +1786,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const base = { type: "turn.terminal" as const, driver, - providerThreadId: state.providerThread.id, + // The provider thread the turn started on. After a native fork the + // session's own thread has a fresh id, and the terminal must name the + // thread the start was recorded against. + providerThreadId: turn.providerTurn.providerThreadId, providerTurnId: turn.providerTurn.id, runOrdinal: turn.input.runOrdinal, threadDisposition, @@ -2825,12 +2828,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr * reconciles. Retried a few times; the caller fails everything after that. */ const reconnect = Effect.gen(function* () { + // Returned on any failure, including the interrupt a closing session + // sends a reconnect still in flight, so a spawned server can idle-stop. const scope = yield* Scope.make(); - const next = yield* borrow.pipe( - Effect.provideService(Scope.Scope, scope), - Effect.tapError(() => Scope.close(scope, Exit.void)), - ); - const stream = yield* next.events.pipe(Effect.tapError(() => Scope.close(scope, Exit.void))); + const { next, stream } = yield* Effect.gen(function* () { + const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); + return { next, stream: yield* next.events }; + }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); const previous = currentScope; connection = next; client = next.client; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 44632e02c66d5..a8e28a377d1ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -182,7 +182,10 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct runtimePolicy: policy, }); const now = yield* DateTime.now; - const startTurn = (text = "hello") => + const startTurn = ( + text = "hello", + startProviderThread: OrchestrationV2ProviderThread = providerThread, + ) => runtime.startTurn({ appThread: { id: threadId, @@ -213,7 +216,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct providerTurnOrdinal: 1, attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), rootNodeId: NodeId.make(`node-opencode-${suffix}`), - providerThread, + providerThread: startProviderThread, message: { createdBy: "user", creationSource: "web", @@ -1291,6 +1294,45 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); + it.effect("ends a turn on the provider thread it started on", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness( + "forked-row", + "native-opencode-forked-row", + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, + }), + summarize: async () => ({ data: true }), + }, + }, + ); + // A forked run starts on its own row for the same native session, while + // the adapter tracks the session under the id it minted. + const forkedRow = { + ...harness.providerThread, + id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), + }; + yield* harness.startTurn("/compact", forkedRow); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + ); + assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index aad31d6363258..510a4c859d362 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -279,6 +279,8 @@ interface ActiveOpenCodeTurn { readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + /** The provider thread the turn started on, which its terminal names. */ + readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; readonly providerTurnOrdinal: number; readonly runOrdinal: number; readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; @@ -2118,7 +2120,7 @@ export function makeOpenCodeAdapterV2( ? { type: "turn.terminal", driver: OPENCODE_PROVIDER, - providerThreadId: state.providerThread.id, + providerThreadId: turn.providerThreadId, providerTurnId: turn.providerTurnId, runOrdinal: turn.runOrdinal, failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), @@ -2134,7 +2136,7 @@ export function makeOpenCodeAdapterV2( : { type: "turn.terminal", driver: OPENCODE_PROVIDER, - providerThreadId: state.providerThread.id, + providerThreadId: turn.providerThreadId, providerTurnId: turn.providerTurnId, runOrdinal: turn.runOrdinal, status, @@ -2256,6 +2258,7 @@ export function makeOpenCodeAdapterV2( modelSelection: state.appThread.modelSelection, runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, providerTurnId, + providerThreadId: providerTurn.providerThreadId, providerTurnOrdinal: providerTurn.ordinal, runOrdinal: state.parentSubagent.parentTurn.runOrdinal, runAttemptId: null, @@ -3192,6 +3195,7 @@ export function makeOpenCodeAdapterV2( modelSelection: turnInput.modelSelection, runtimePolicy: turnInput.runtimePolicy, providerTurnId, + providerThreadId: turnInput.providerThread.id, providerTurnOrdinal: turnInput.providerTurnOrdinal, runOrdinal: turnInput.runOrdinal, runAttemptId: turnInput.attemptId, diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 87e24f92b0e8d..9270253d5c74b 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -2679,8 +2679,9 @@ export function makePiAdapterV2( } // Pi fork replaces the session file, including for rollback. Persist // its new identity before any later request can fail or restart. + // An interrupted read leaves the identity just as unknown as a failed one. const forkState = yield* request({ type: "get_state" }).pipe( - Effect.tapError(() => + Effect.onError(() => Effect.sync(() => { threadState = null; }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 61a3dedafd53f..bfb076ee3bf3b 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -128,6 +128,30 @@ const layerFlakyReleaseEventSink = (flaky: FlakyReleaseWrites) => }), ).pipe(Layer.provide(layerTestEventSink)); +// Once armed, holds the next attach write until the writer is interrupted. +const layerPausingAttachEventSink = (pause: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; +}) => + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + Effect.gen(function* () { + const attach = input.events.some((event) => event.type === "provider-session.attached"); + if (attach && (yield* Ref.getAndSet(pause.armed, false))) { + yield* Deferred.succeed(pause.paused, undefined); + return yield* Effect.never; + } + return yield* delegate.write(input); + }), + }); + }), + ).pipe(Layer.provide(layerTestEventSink)); + const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexCapabilities, @@ -244,11 +268,13 @@ function makeProviderThread(input: { readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly now: DateTime.Utc; + readonly nativeThreadId?: string; }): OrchestrationV2ProviderThread { + const nativeThreadId = input.nativeThreadId ?? "native-thread"; return { id: input.idAllocator.derive.providerThread({ driver: CODEX_DRIVER, - nativeThreadId: "native-thread", + nativeThreadId, }), driver: CODEX_DRIVER, providerInstanceId: modelSelection.instanceId, @@ -257,7 +283,7 @@ function makeProviderThread(input: { ownerNodeId: null, nativeThreadRef: { driver: CODEX_DRIVER, - nativeId: "native-thread", + nativeId: nativeThreadId, strength: "strong", }, nativeConversationHeadRef: null, @@ -294,9 +320,29 @@ function makeProviderAdapter( }) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + /** Registers the process's closeCount finalizer before `beforeOpen` runs. */ + readonly spawnBeforeOpen?: boolean; + /** Completed when a hanging scope close reaches its wedged finalizer. */ + readonly scopeCloseReached?: Deferred.Deferred; } = {}, ): ProviderAdapterV2Shape { + const countClose = Effect.addFinalizer(() => + Ref.update(state, (current) => ({ + ...current, + closeCount: current.closeCount + 1, + })), + ); + // Registered after countClose so it runs first on scope close, wedging the + // close before the closeCount finalizer, like a provider process that never + // yields its message stream. + const hangClose = Effect.addFinalizer(() => + (options.scopeCloseReached === undefined + ? Effect.void + : Deferred.succeed(options.scopeCloseReached, undefined) + ).pipe(Effect.andThen(Effect.never)), + ); return { instanceId: ProviderInstanceId.make("codex"), driver: CODEX_DRIVER, @@ -304,10 +350,20 @@ function makeProviderAdapter( planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (input) => Effect.gen(function* () { + if (options.spawnBeforeOpen === true) { + yield* countClose; + if (options.hangSessionScopeClose === true) yield* hangClose; + } + if (options.mcpConfigs !== undefined && options.spawnBeforeOpen === true) { + yield* Ref.update(options.mcpConfigs, (configs) => [ + ...configs, + McpProviderSession.readMcpProviderSession(input.threadId), + ]); + } if (options.beforeOpen !== undefined) { yield* options.beforeOpen(input); } - if (options.mcpConfigs !== undefined) { + if (options.mcpConfigs !== undefined && options.spawnBeforeOpen !== true) { yield* Ref.update(options.mcpConfigs, (configs) => [ ...configs, McpProviderSession.readMcpProviderSession(input.threadId), @@ -329,17 +385,9 @@ function makeProviderAdapter( eventQueues, }; }); - yield* Effect.addFinalizer(() => - Ref.update(state, (current) => ({ - ...current, - closeCount: current.closeCount + 1, - })), - ); - if (options.hangSessionScopeClose === true) { - // Registered last so it runs first on scope close, wedging the - // close before the closeCount finalizer, like a provider process - // that never yields its message stream. - yield* Effect.addFinalizer(() => Effect.never); + if (options.spawnBeforeOpen !== true) { + yield* countClose; + if (options.hangSessionScopeClose === true) yield* hangClose; } return { @@ -365,7 +413,7 @@ function makeProviderAdapter( ...current, resumeCount: current.resumeCount + 1, })).pipe(Effect.as(threadInput.providerThread)), - startTurn: () => Effect.void, + startTurn: () => options.startTurn ?? Effect.void, steerTurn: () => Effect.void, interruptTurn: () => Ref.update(state, (current) => ({ @@ -408,18 +456,29 @@ function layerTest(input: { }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; readonly flakyReleaseWrites?: FlakyReleaseWrites; + readonly pauseAttachWrite?: Parameters[0]; + /** Once armed, holds the next credential lookup until it is interrupted. */ + readonly pauseResolve?: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; + }; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly spawnBeforeOpen?: boolean; + readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { const layerConfiguredEventSink = input.flakyReleaseWrites !== undefined ? layerFlakyReleaseEventSink(input.flakyReleaseWrites) - : input.failReleaseEventWrites - ? layerFailingReleaseEventSink - : layerTestEventSink; + : input.pauseAttachWrite !== undefined + ? layerPausingAttachEventSink(input.pauseAttachWrite) + : input.failReleaseEventWrites + ? layerFailingReleaseEventSink + : layerTestEventSink; const layerRegistry = ProviderAdapterRegistry.layerSingle( makeProviderAdapter(input.state, { failEventStream: input.failEventStream ?? false, @@ -432,9 +491,18 @@ function layerTest(input: { ...(input.hangSessionScopeClose === undefined ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), + ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), + ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), + ...(input.scopeCloseReached === undefined + ? {} + : { scopeCloseReached: input.scopeCloseReached }), }), ); + const layerConfiguredMcpRegistry = + input.pauseResolve === undefined + ? layerTestMcpRegistry + : layerPausingMcpRegistry(input.pauseResolve); const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( @@ -449,7 +517,7 @@ function layerTest(input: { layerTestStores, layerConfiguredEventSink, IdAllocator.layer, - layerTestMcpRegistry, + layerConfiguredMcpRegistry, ProviderSessionManager.layerWithOptions({ idleTimeoutMs: input.idleTimeoutMs, ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), @@ -460,7 +528,7 @@ function layerTest(input: { layerConfiguredEventSink, IdAllocator.layer, layerProviderEventIngestorTest, - layerTestMcpRegistry, + layerConfiguredMcpRegistry, layerTestStores, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), @@ -489,6 +557,28 @@ const layerTestMcpRegistry = Layer.effect( Layer.provide(NodeServices.layer), ); +const layerPausingMcpRegistry = (pause: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; +}) => + Layer.effect( + McpSessionRegistry.McpSessionRegistry, + Effect.gen(function* () { + const delegate = yield* McpSessionRegistry.McpSessionRegistry; + return McpSessionRegistry.McpSessionRegistry.of({ + ...delegate, + resolve: (rawToken) => + Ref.getAndSet(pause.armed, false).pipe( + Effect.flatMap((armed) => + armed + ? Deferred.succeed(pause.paused, undefined).pipe(Effect.andThen(Effect.never)) + : delegate.resolve(rawToken), + ), + ), + }); + }), + ).pipe(Layer.provide(layerTestMcpRegistry)); + function makeBrowserAccessProject(projectId: ProjectId): Project { return { id: projectId, @@ -997,6 +1087,498 @@ it.effect("ProviderSessionManagerV2 opens a duplicate session only once", () => }), ); +it.effect("ProviderSessionManagerV2 cleans up an open interrupted mid-handshake", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const handshakeStarted = yield* Deferred.make(); + const holdHandshake = yield* Ref.make(true); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-interrupted-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + const opening = yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkScoped); + yield* Deferred.await(handshakeStarted); + const issued = (yield* Ref.get(mcpConfigs)).at(-1); + const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + assert.isDefined(yield* registry.resolve(token!)); + + // A Stop while the provider is still starting. + yield* Fiber.interrupt(opening); + + // The process started for this open is stopped, and the credential minted + // for it revoked. + assert.equal((yield* Ref.get(state)).closeCount, 1); + assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + + // Nothing of the interrupted open is left behind: the next open starts a + // fresh process with a fresh credential that a later release revokes, + // which a leaked reservation would prevent. + yield* Ref.set(holdHandshake, false); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const replacement = (yield* Ref.get(mcpConfigs)).at(-1); + const replacementToken = replacement?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(replacementToken); + assert.notEqual(replacementToken, token); + const projection = yield* projectionStore.getThreadProjection(threadId); + assert.equal(projection.providerSessions.at(-1)?.status, "ready"); + + yield* manager.close(providerSessionId); + assert.isUndefined(yield* registry.resolve(replacementToken!)); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + mcpConfigs, + beforeOpen: () => + Ref.get(holdHandshake).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(handshakeStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + ), + // The process is spawned before the handshake that is interrupted. + spawnBeforeOpen: true, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 cleans up an interrupted open whose scope close hangs", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const handshakeStarted = yield* Deferred.make(); + const scopeCloseReached = yield* Deferred.make(); + const releaseRetry = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-interrupted-hung-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + const opening = yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkChild); + yield* Deferred.await(handshakeStarted); + const issued = (yield* Ref.get(mcpConfigs)).at(-1); + const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + + // The interrupt starts cleanup; the scope close then never finishes. + const interrupter = yield* Fiber.interrupt(opening).pipe(Effect.forkChild); + yield* Deferred.await(scopeCloseReached); + + // The session cleanup already ran, ahead of the stuck close. + assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + // The close is time-boxed, so the interrupter returns and the session's + // open lock is free for the next open. + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(interrupter); + yield* Deferred.succeed(releaseRetry, undefined); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + assert.equal((yield* Ref.get(state)).openCount, 1); + + // Its close hangs as well; release it while the test clock can still move. + const stopping = yield* manager.shutdown.pipe(Effect.forkChild); + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(stopping); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + mcpConfigs, + // The first open hangs in its handshake; the retry completes. + beforeOpen: () => + Deferred.isDone(handshakeStarted).pipe( + Effect.flatMap((retry) => + retry + ? Deferred.await(releaseRetry) + : Deferred.succeed(handshakeStarted, undefined).pipe( + Effect.andThen(Effect.never), + ), + ), + ), + spawnBeforeOpen: true, + hangSessionScopeClose: true, + scopeCloseReached, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 releases an idle session whose turn start was stopped", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const startTurnReached = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-stopped-turn-start"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const starting = yield* runtime + .startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "stopped before the provider accepted it", + attachments: [], + }, + modelSelection, + runtimePolicy, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(startTurnReached); + + // Stop lands while the provider is still accepting the turn. No terminal + // event follows, so the session must count itself idle again. + yield* Fiber.interrupt(starting); + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + startTurn: Deferred.succeed(startTurnReached, undefined).pipe( + Effect.andThen(Effect.never), + ), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session busy when another thread's start is stopped early", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const projectId = ProjectId.make("project-provider-session-manager-shared-stopped-start"); + // Blocks the next project read, so a re-attach can be stopped before its + // start marks the session busy. + const holdProjectRead = yield* Ref.make(false); + const projectReadHeld = yield* Deferred.make(); + const projectServiceLayer = Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId) => + Ref.get(holdProjectRead).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(projectReadHeld, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(Option.some(makeBrowserAccessProject(requestedProjectId))), + ), + ), + }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make("thread-provider-session-manager-shared-stopped-a"); + const threadB = ThreadId.make("thread-provider-session-manager-shared-stopped-b"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now, projectId }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now, projectId }), + ], + }); + const runtime = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const startTurn = (threadId: ThreadId) => + Effect.gen(function* () { + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + return yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }); + + // B's turn is accepted and still running. + yield* startTurn(threadB); + + // A detaches, and its next start is stopped while re-attaching, before + // it marks the session busy. + yield* manager.detach({ providerSessionId, threadId: threadA }); + yield* Ref.set(holdProjectRead, true); + const startingA = yield* startTurn(threadA).pipe(Effect.forkChild); + yield* Deferred.await(projectReadHeld); + yield* Fiber.interrupt(startingA); + + // B's running turn keeps the session busy past the idle timeout. + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + projectServiceLayer, + serverSettingsLayer: ServerSettings.layerTest({ + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: true } }, + }), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session busy when a stopped start still ends its turn", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + // A's adapter start installs its turn, then is stopped mid-request; like + // OpenCode2, the adapter still ends that turn with turn.terminal later. + const holdStart = yield* Ref.make(false); + const startHeld = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make("thread-provider-session-manager-stopped-ended-a"); + const threadB = ThreadId.make("thread-provider-session-manager-stopped-ended-b"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThreadOf = (threadId: ThreadId) => + makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const startTurn = (threadId: ThreadId) => + Effect.gen(function* () { + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + return yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: providerThreadOf(threadId), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }); + + // B's turn is accepted and still running. + yield* startTurn(threadB); + + // A's start is stopped after the adapter began the turn. + yield* Ref.set(holdStart, true); + const startingA = yield* startTurn(threadA).pipe(Effect.forkChild); + yield* Deferred.await(startHeld); + yield* Fiber.interrupt(startingA); + + // The adapter ends A's turn anyway. + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(queue); + yield* Queue.offer(queue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThreadOf(threadA).id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "native-turn-stopped-a", + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + + // B's running turn keeps the session busy past the idle timeout. + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + startTurn: Ref.get(holdStart).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(startHeld, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + ), + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 stops a session still opening when its layer shuts down", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const handshakeStarted = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-shutdown-during-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + // Detached, like an open the layer does not own: only the session + // scope's parent can stop its process when the layer closes. + yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkDetach); + yield* Deferred.await(handshakeStarted); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + beforeOpen: () => + Deferred.succeed(handshakeStarted, undefined).pipe(Effect.andThen(Effect.never)), + spawnBeforeOpen: true, + }), + ), + ); + + assert.equal((yield* Ref.get(state)).closeCount, 1); + }), +); + it.effect("ProviderSessionManagerV2 releases live sessions when its layer shuts down", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -1368,6 +1950,228 @@ it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persist }), ); +it.effect( + "ProviderSessionManagerV2 keeps a thread attached when an earlier attach of it is interrupted", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-attach-race-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-attach-race"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const resume = runtime.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-attach-race", + }), + }); + + // The first attach of the thread is stopped after attaching it, while + // a second attach of the same thread is already on its way. + yield* Ref.set(armed, true); + const first = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + const second = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Fiber.join(second); + + // The second attach owns the thread's attachment and credential. + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.equal((yield* registry.resolve(token))?.thread.threadId, threadId); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseAttachWrite: { armed, paused } }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a replacement session's attachment when a stale attach is interrupted", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const eventStore = yield* EventStore.EventStoreV2; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-stale-attach-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-stale-attach"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + // An attach of the thread to this session stalls mid-write... + yield* Ref.set(armed, true); + const stale = yield* runtime + .resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-stale-attach", + }), + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + + // ...while the session is replaced and the thread opens the new one. + yield* manager.release({ providerSessionId, reason: "runtime_error" }); + const replacement = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + + yield* Fiber.interrupt(stale); + + // The replacement keeps the thread and its credential. + assert.equal( + McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, + config!.providerSessionId, + ); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.equal((yield* registry.resolve(token))?.thread.threadId, threadId); + // Still attached: resuming on the replacement does not attach the thread again. + const attachedEvents = eventStore + .read({ threadId, eventType: "provider-session.attached" }) + .pipe( + Stream.runCollect, + Effect.map((events) => events.length), + ); + const attachedBefore = yield* attachedEvents; + yield* replacement.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-stale-attach", + }), + }); + assert.equal(yield* attachedEvents, attachedBefore); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseAttachWrite: { armed, paused } }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-resolve-stop-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-resolve-stop"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const resume = runtime.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-resolve-stop", + }), + }); + // The thread gets a credential, then detaches and keeps it for a re-attach. + yield* resume; + yield* manager.detach({ providerSessionId, threadId }); + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + + // A re-attach is stopped while it checks whether that credential is reusable. + yield* Ref.set(armed, true); + const stopped = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + yield* Fiber.interrupt(stopped); + + // Nothing holds the credential now, so a terminal release revokes it. + yield* manager.release({ providerSessionId, reason: "manual_shutdown" }); + assert.isUndefined(yield* registry.resolve(token)); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseResolve: { armed, paused } }), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP credentials", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -3011,17 +3815,20 @@ it.effect( providerInstanceId: modelSelection.instanceId, threadId: firstThreadId, }); + // Each thread has its own native thread on the shared session. const firstProviderThread = makeProviderThread({ idAllocator, threadId: firstThreadId, providerSessionId, now, + nativeThreadId: "native-thread-a", }); const secondProviderThread = makeProviderThread({ idAllocator, threadId: secondThreadId, providerSessionId, now, + nativeThreadId: "native-thread-b", }); const firstRunId = idAllocator.derive.run({ threadId: firstThreadId, ordinal: 1 }); const secondRunId = idAllocator.derive.run({ threadId: secondThreadId, ordinal: 1 }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 5730432aad87e..f84441129cc3a 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId, ProviderSessionId, ThreadId, + type ProviderThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -59,6 +60,10 @@ import * as ProjectionStore from "./ProjectionStore.ts"; const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; + +/** The identity a turn's start and its `turn.terminal` share. */ +const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => + `${providerThreadId}#${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; export const ProviderSessionReleaseReason = Schema.Literals([ @@ -213,7 +218,12 @@ interface LiveSessionEntry { readonly requestEventPermit: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly idleGeneration: number; - readonly busyCount: number; + /** + * Turns this session is running, keyed by `busyTurnKey`. A turn's start adds + * it and its `turn.terminal` (or a failed start) removes it, so a turn can + * only clear itself and the session is idle when the set is empty. + */ + readonly busyTurns: ReadonlySet; readonly lastActivityAtMs: number; readonly idleFiber: Fiber.Fiber | null; /** Set when idle release is deferred for pending background work; bounds total deferral. */ @@ -472,7 +482,17 @@ export const layerWithOptions = ( // revoke the credential between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* mcpSessionRegistry.resolve(rawToken); + // The caller only learns of the reservation once this returns, + // so a stop while resolving must drop it here. + const resolved = yield* mcpSessionRegistry + .resolve(rawToken) + .pipe( + Effect.onInterrupt(() => + Effect.sync(() => + dropMcpCredentialReservation(threadId, existing.providerSessionId), + ), + ), + ); if ( resolved !== undefined && resolved.thread.threadId === threadId && @@ -842,7 +862,8 @@ export const layerWithOptions = ( } if ( input.onlyIfIdleGeneration !== undefined && - (existing.busyCount > 0 || existing.idleGeneration !== input.onlyIfIdleGeneration) + (existing.busyTurns.size > 0 || + existing.idleGeneration !== input.onlyIfIdleGeneration) ) { return ["kept", current] as const; } @@ -861,6 +882,45 @@ export const layerWithOptions = ( ] as const; }); + // Scope close can wedge on a misbehaving adapter finalizer (e.g. a + // provider process that never yields its message stream). Time-box it so + // the caller, and any lock or worker it holds, moves on and leaves a + // diagnosable trail. A close that finishes late is still logged. + const closeScopeWithin = ( + scope: Scope.Closeable, + annotations: { readonly providerSessionId?: ProviderSessionId; readonly reason: string }, + ) => + Effect.gen(function* () { + const closeFiber = yield* Scope.close(scope, Exit.void).pipe( + Effect.exit, + Effect.forkDetach({ startImmediately: true }), + ); + const closeExit = yield* Fiber.join(closeFiber).pipe( + Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), + ); + if (Option.isNone(closeExit)) { + yield* Effect.logWarning("orchestration-v2.provider-session-scope-close-timeout", { + ...annotations, + timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, + }); + yield* Fiber.join(closeFiber).pipe( + Effect.flatMap((exit) => + Exit.isFailure(exit) + ? Effect.logWarning("orchestration-v2.provider-session-scope-close-failed", { + ...annotations, + cause: exit.cause, + }) + : Effect.logInfo( + "orchestration-v2.provider-session-scope-close-completed-late", + annotations, + ), + ), + Effect.forkDetach, + ); + } + return closeExit; + }); + const releaseEntry = (input: { readonly providerSessionId: ProviderSessionId; readonly reason: ProviderSessionReleaseReason; @@ -889,49 +949,10 @@ export const layerWithOptions = ( input.detail ?? `Provider session released: ${input.reason}.`, ); } - // Scope close can wedge on a misbehaving adapter finalizer - // (e.g. a provider process that never yields its message - // stream). Time-box it so release still persists released - // events and leaves a diagnosable trail instead of silently - // parking the session as "ready" forever. - const closeFiber = yield* Scope.close(entry.scope, Exit.void).pipe( - Effect.exit, - Effect.forkDetach({ startImmediately: true }), - ); - const closeExit = yield* Fiber.join(closeFiber).pipe( - Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), - ); - if (Option.isNone(closeExit)) { - yield* Effect.logWarning( - "orchestration-v2.provider-session-scope-close-timeout", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, - }, - ); - yield* Fiber.join(closeFiber).pipe( - Effect.flatMap((exit) => - Exit.isFailure(exit) - ? Effect.logWarning( - "orchestration-v2.provider-session-scope-close-failed", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - cause: exit.cause, - }, - ) - : Effect.logInfo( - "orchestration-v2.provider-session-scope-close-completed-late", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - }, - ), - ), - Effect.forkDetach, - ); - } + const closeExit = yield* closeScopeWithin(entry.scope, { + providerSessionId: input.providerSessionId, + reason: input.reason, + }); const records = { entry, reason: input.reason, @@ -1022,7 +1043,7 @@ export const layerWithOptions = ( const entry = current.get(key); if ( entry === undefined || - entry.busyCount > 0 || + entry.busyTurns.size > 0 || entry.idleGeneration !== input.generation ) { return; @@ -1044,7 +1065,7 @@ export const layerWithOptions = ( const latestEntry = latest.get(key); if ( latestEntry === undefined || - latestEntry.busyCount > 0 || + latestEntry.busyTurns.size > 0 || latestEntry.idleGeneration !== input.generation || latestEntry.runtime !== probedRuntime ) { @@ -1076,7 +1097,7 @@ export const layerWithOptions = ( } // hasPendingBackgroundWork yields to the adapter, so the idle // decision above can go stale; the generation guard revalidates - // busyCount and idleGeneration inside releaseEntry's atomic + // busyTurns and idleGeneration inside releaseEntry's atomic // entry removal. yield* releaseEntry({ providerSessionId: input.providerSessionId, @@ -1113,7 +1134,7 @@ export const layerWithOptions = ( const key = sessionKey(providerSessionId); const current = yield* Ref.get(sessions); const entry = current.get(key); - if (entry === undefined || entry.busyCount > 0) { + if (entry === undefined || entry.busyTurns.size > 0) { return; } @@ -1126,7 +1147,7 @@ export const layerWithOptions = ( const lastActivityAtMs = yield* Clock.currentTimeMillis; yield* Ref.update(sessions, (latest) => { const latestEntry = latest.get(key); - if (latestEntry === undefined || latestEntry.busyCount > 0) { + if (latestEntry === undefined || latestEntry.busyTurns.size > 0) { return latest; } const updated = new Map(latest); @@ -1164,6 +1185,7 @@ export const layerWithOptions = ( }), ); + /** Returns the runtime the thread was attached to, or undefined if it already was. */ const attachThread = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; @@ -1173,25 +1195,34 @@ export const layerWithOptions = ( Ref.modify(sessions, (current) => { const entry = current.get(sessionKey(input.providerSessionId)); if (entry === undefined || entry.attachedThreadIds.has(input.threadId)) { - return [false, current] as const; + return [undefined, current] as const; } const updated = new Map(current); updated.set(sessionKey(input.providerSessionId), { ...entry, attachedThreadIds: new Set([...entry.attachedThreadIds, input.threadId]), }); - return [true, updated] as const; + return [entry.runtime, updated] as const; }), ); + /** + * Undoes an attach to `runtime`. A replacement session that reopened under + * the same id since is left alone. + */ const removeThreadAttachment = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; + readonly runtime: ProviderAdapterV2SessionRuntime; }) => Ref.update(sessions, (current) => { const key = sessionKey(input.providerSessionId); const entry = current.get(key); - if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { + if ( + entry === undefined || + entry.runtime !== input.runtime || + !entry.attachedThreadIds.has(input.threadId) + ) { return current; } const attachedThreadIds = new Set(entry.attachedThreadIds); @@ -1245,6 +1276,7 @@ export const layerWithOptions = ( readonly providerInstanceId: ProviderInstanceId; }) => Effect.suspend(() => { + let attachedTo: ProviderAdapterV2SessionRuntime | undefined; let preparedForCleanup: PreparedMcpCredential | undefined; let reservationDropped = false; const dropReservation = () => { @@ -1253,12 +1285,17 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, preparedForCleanup.mcpCredentialId); } }; - return Effect.gen(function* () { - const attached = yield* threadAttachment.withLock( - threadAttachmentKey(input), - attachThread(input), + // The whole attach, including undoing a failed one, holds the + // thread's lock: a concurrent attach of the same thread waits, so it + // never sees an attachment that this call is about to roll back. + const attach = Effect.gen(function* () { + const attached = yield* attachThread(input).pipe( + // Recorded with no gap for an interrupt: cleanup undoes only an + // attach this call made, never one an earlier open made. + Effect.tap((runtime) => Effect.sync(() => (attachedTo = runtime))), + Effect.uninterruptible, ); - if (attached) { + if (attached !== undefined) { const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); preparedForCleanup = prepared; if (prepared.mcpCredentialId !== undefined) { @@ -1288,22 +1325,46 @@ export const layerWithOptions = ( } } }).pipe( - Effect.tapError(() => - removeThreadAttachment(input).pipe( - // Revoke only a credential this attach freshly minted: a REUSED - // credential is by definition held by another live provider - // process, and revoking it thread-wide would break that - // process's MCP client mid-conversation. - Effect.andThen( - Effect.suspend(() => { - dropReservation(); - return preparedForCleanup?.issued === true - ? clearMcpSession(input.threadId, preparedForCleanup.mcpCredentialId) - : Effect.void; - }), - ), - ), + // An interrupted attach is undone too, so the next attach writes + // the attachment instead of finding the thread already attached. + Effect.onError(() => + attachedTo === undefined + ? Effect.void + : removeThreadAttachment({ ...input, runtime: attachedTo }).pipe( + Effect.andThen( + Effect.suspend(() => { + dropReservation(); + // Revoke only a credential this attach freshly minted: a + // REUSED credential is by definition held by another + // live provider process, and revoking it thread-wide + // would break that process's MCP client mid-conversation. + if (preparedForCleanup?.issued !== true) return Effect.void; + const mcpCredentialId = preparedForCleanup.mcpCredentialId; + const attachedRuntime = attachedTo; + // As in release: a replacement session (or an open + // configuring one) may have taken the credential up. + return Ref.get(sessions).pipe( + Effect.flatMap((current) => + (mcpCredentialId !== undefined && + isMcpCredentialReserved(input.threadId, mcpCredentialId)) || + Array.from(current.values()).some( + (other) => + other.runtime !== attachedRuntime && + (other.attachedThreadIds.has(input.threadId) || + (mcpCredentialId !== undefined && + other.mcpCredentialIdByThread.get(input.threadId) === + mcpCredentialId)), + ) + ? Effect.void + : clearMcpSession(input.threadId, mcpCredentialId), + ), + ); + }), + ), + ), ), + ); + return threadAttachment.withLock(threadAttachmentKey(input), attach).pipe( // The entry's own record (written above while the thread is // attached) guards the credential from here on; the reservation // is only needed until then. Ensuring covers defects/interrupts. @@ -1311,7 +1372,7 @@ export const layerWithOptions = ( ); }); - const markBusy = (providerSessionId: ProviderSessionId) => + const markBusy = (providerSessionId: ProviderSessionId, turnKey: string) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1325,7 +1386,7 @@ export const layerWithOptions = ( const updated = new Map(current); updated.set(key, { ...entry, - busyCount: entry.busyCount + 1, + busyTurns: new Set(entry.busyTurns).add(turnKey), idleFiber: null, lastActivityAtMs: now, pinnedSinceMs: null, @@ -1336,7 +1397,10 @@ export const layerWithOptions = ( }), ); - const markIdle = (providerSessionId: ProviderSessionId) => + // Clearing a turn that is not marked busy (one whose failed start already + // cleared it, or a subagent turn the manager never started) only + // records activity. + const markIdle = (providerSessionId: ProviderSessionId, turnKey: string) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1347,10 +1411,12 @@ export const layerWithOptions = ( if (entry === undefined) { return current; } + const busyTurns = new Set(entry.busyTurns); + busyTurns.delete(turnKey); const updated = new Map(current); updated.set(key, { ...entry, - busyCount: Math.max(0, entry.busyCount - 1), + busyTurns, lastActivityAtMs: now, }); return updated; @@ -1536,13 +1602,32 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( - Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), + // A start that fails or is stopped may never emit turn.terminal, + // so it clears its own turn or the session never goes idle. If + // the adapter emits the terminal anyway, clearing the same turn + // again changes nothing, so another thread's turn on a shared + // session stays busy either way. Effect.andThen( - runtime.startTurn(input).pipe(turnMetrics("send", input.modelSelection.model)), - ), - Effect.catch((error) => - observeActivity(providerSessionId, markIdle(providerSessionId)).pipe( - Effect.andThen(Effect.fail(error)), + Effect.acquireUseRelease( + observeActivity( + providerSessionId, + markBusy( + providerSessionId, + busyTurnKey(input.providerThread.id, input.runOrdinal), + ), + ), + () => + runtime.startTurn(input).pipe(turnMetrics("send", input.modelSelection.model)), + (_, exit) => + Exit.isFailure(exit) + ? observeActivity( + providerSessionId, + markIdle( + providerSessionId, + busyTurnKey(input.providerThread.id, input.runOrdinal), + ), + ) + : Effect.void, ), ), ), @@ -1605,7 +1690,10 @@ export const layerWithOptions = ( return observeActivity( entry.runtime.providerSessionId, event.type === "turn.terminal" - ? markIdle(entry.runtime.providerSessionId) + ? markIdle( + entry.runtime.providerSessionId, + busyTurnKey(event.providerThreadId, event.runOrdinal), + ) : touchActivity(entry.runtime.providerSessionId), ).pipe( Effect.andThen( @@ -1695,6 +1783,12 @@ export const layerWithOptions = ( ); }; + // Parent of every session scope. On layer close, shutdown releases the + // live sessions first, then closes any session whose open is still in + // flight, time-boxed so a stuck adapter cannot hold up server shutdown. + // Parallel, so one session whose close hangs does not stop the rest from + // closing within the time box. + const sessionScopes = yield* Scope.make("parallel"); const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; yield* Effect.forEach( @@ -1714,7 +1808,11 @@ export const layerWithOptions = ( { discard: true }, ); }); - yield* Effect.addFinalizer(() => shutdown); + yield* Effect.addFinalizer(() => + shutdown.pipe( + Effect.ensuring(closeScopeWithin(sessionScopes, { reason: "server_shutdown" })), + ), + ); return ProviderSessionManagerV2.of({ shutdown, @@ -1783,7 +1881,7 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, mcpCredentialId); } }); - const sessionScope = yield* Scope.make(); + const sessionScope = yield* Scope.fork(sessionScopes); const runtime = yield* adapter .openSession({ threadId: input.threadId, @@ -1805,21 +1903,30 @@ export const layerWithOptions = ( }) .pipe( Effect.provideService(Scope.Scope, sessionScope), - Effect.tapError(() => - Scope.close(sessionScope, Exit.void).pipe( - Effect.ignore, - Effect.andThen(dropReservation), - // Revoke only a credential this open freshly minted: a - // reused credential is held by another live provider - // process and must survive this open's failure. + // Any failure, including a Stop that interrupts a slow + // handshake, stops the provider process this open started. + // The session cleanup runs first, and the close is + // time-boxed: this runs under the session's open lock, so an + // adapter finalizer that never finishes must not hold the + // interrupter, the lock, or later opens. + Effect.onError(() => + dropReservation.pipe( + // Clear only a session this open freshly set up: a reused + // one is held by another live provider process and must + // survive this open's failure. Effect.andThen( prepared.issued ? clearMcpSession(input.threadId, mcpCredentialId) : Effect.void, ), + Effect.ensuring( + closeScopeWithin(sessionScope, { + providerSessionId: input.providerSessionId, + reason: "open_failed", + }), + ), ), ), - Effect.onInterrupt(() => dropReservation), Effect.mapError( (cause) => new ProviderSessionOpenError({ @@ -1854,7 +1961,7 @@ export const layerWithOptions = ( requestEventPermit: yield* Semaphore.make(1), scope: sessionScope, idleGeneration: 0, - busyCount: 0, + busyTurns: new Set(), lastActivityAtMs: now, idleFiber: null, pinnedSinceMs: null, @@ -1876,12 +1983,22 @@ export const layerWithOptions = ( payload: runtime.providerSession, }), ).pipe( - Effect.tapError(() => - releaseEntry({ - providerSessionId: input.providerSessionId, - reason: "runtime_error", - detail: "Failed to persist the provider-session attachment.", - }).pipe(logReleaseFailure(input.providerSessionId)), + // Released on interrupt too: this entry has no event pump or + // idle timer yet, so nothing else would ever release it. + Effect.onError((cause) => + releaseEntry( + Cause.hasInterruptsOnly(cause) + ? { + providerSessionId: input.providerSessionId, + reason: "manual_shutdown", + detail: "The provider session start was interrupted.", + } + : { + providerSessionId: input.providerSessionId, + reason: "runtime_error", + detail: "Failed to persist the provider-session attachment.", + }, + ).pipe(logReleaseFailure(input.providerSessionId)), ), ); yield* startEventPump(entry); From 517188b3caa135bc09ec0a4f93474ef17c8b5c4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:17:32 -0700 Subject: [PATCH 011/680] fix(web): show "No project" near the top of the new thread picker (#16628) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/CommandPalette.tsx | 197 ++++++++++----------- 1 file changed, 90 insertions(+), 107 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f7b78b5ae676a..a0738dc853440 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -163,6 +163,7 @@ import { findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, + type CommandPaletteProject, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -1309,89 +1310,94 @@ function OpenCommandPaletteDialog(props: { ], ); - const projectThreadItems = useMemo( - () => - enumerateCommandPaletteItems([ - ...buildProjectActionItems({ - // The no-project home shows once, as the "No project" item below. - projects: pickerProjects.filter( - (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)), - ), - valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, - renderDescription: (project) => { - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", - label: "Remote", - machine: "server" as const, - }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} + const projectThreadItems = useMemo(() => { + const isScratch = (project: CommandPaletteProject) => + isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const projectItems = enumerateCommandPaletteItems( + buildProjectActionItems({ + // The no-project home shows once, as the "No project" item below. + projects: pickerProjects.filter((project) => !isScratch(project)), + valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => { + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote", + label: "Remote", + machine: "server" as const, + }; + return ( + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} + + {project.workspaceRoot} + + ); + }, + icon: projectFaviconIcon, + runProject: async (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, ); - }, - icon: projectFaviconIcon, - runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const contextualRefBelongsToGroup = - contextualProjectRef !== null && - group?.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === contextualProjectRef.environmentId && - projectRef.projectId === contextualProjectRef.projectId, - ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); - }, - }), - ...(scratchTargetEnvironmentId === null - ? [] - : [ - { - kind: "action" as const, - value: "new-thread-in:no-project", - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, - ]), - ]), - [ - contextualProjectRef, - handleNewThread, - pickerProjects, - projectEnvironmentLocationById, - projectGroupByTargetKey, - scratchTargetEnvironmentId, - scratchWorkspaceRootFor, - startScratchThread, - ], - ); + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + ); + if (scratchTargetEnvironmentId === null) return projectItems; + + // "No project" goes right after the current project: visible without + // scrolling past every project, while Enter still starts in the current + // one. When the current thread has no project, it is the current entry and + // goes first. It keeps its own shortcut, so the projects' mod+1..9 hold. + const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; + return [ + ...projectItems.slice(0, noProjectIndex), + { + kind: "action" as const, + value: "new-thread-in:no-project", + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ...projectItems.slice(noProjectIndex), + ]; + }, [ + contextualProjectRef, + handleNewThread, + pickerProjects, + projectEnvironmentLocationById, + projectGroupByTargetKey, + scratchTargetEnvironmentId, + scratchWorkspaceRootFor, + startScratchThread, + ]); const allThreadItems = useMemo( () => @@ -1848,35 +1854,12 @@ function OpenCommandPaletteDialog(props: { setNewProjectFlow(null); setViewStack([]); setQuery(""); - const currentPrefix = - currentProjectEnvironmentId && currentProjectId - ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` - : null; - const prioritized = currentPrefix - ? [ - ...projectThreadItems.filter((item) => item.value === currentPrefix), - ...projectThreadItems.filter((item) => item.value !== currentPrefix), - ] - : projectThreadItems; + // projectThreadItems already lists the current project first. pushPaletteView({ addonIcon: , - groups: [ - { - value: "projects", - label: "Projects", - items: enumerateCommandPaletteItems(prioritized), - }, - ], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); - }, [ - clearOpenIntent, - browseNavigation, - currentProjectEnvironmentId, - currentProjectId, - openIntent, - projectThreadItems, - pushPaletteView, - ]); + }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]); const actionItems: Array = []; From d021f57bf3a500e419e800c3eb957568bed8f713 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:51:52 -0700 Subject: [PATCH 012/680] refactor(server): instrument WS RPCs in group middleware (#15548) Co-authored-by: Claude Opus 5.5 (1M context) --- .../observability/RpcInstrumentation.test.ts | 587 ++-- .../src/observability/RpcInstrumentation.ts | 335 ++- apps/server/src/ws.ts | 2503 ++++++----------- docs/internals/effect-services.md | 16 +- docs/operations/observability.md | 4 +- 5 files changed, 1437 insertions(+), 2008 deletions(-) diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index c7fb7d12b0958..32baf29d068ca 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -1,313 +1,336 @@ import { assert, describe, it } from "@effect/vitest"; -import { WS_METHODS } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + type AuthEnvironmentScope, + ScheduledTaskError, + ScheduledTaskId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as RpcTest from "effect/rpc/RpcTest"; import * as TestClock from "effect/testing/TestClock"; -import { - observeRpcEffect, - observeRpcStream, - observeRpcStreamEffect, -} from "./RpcInstrumentation.ts"; - -const hasMetricSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.some( - (snapshot) => - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -const findHistogramSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.find( - (snapshot): snapshot is Extract => - snapshot.type === "Histogram" && - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -const collectSpanNames = ( - effect: Effect.Effect, -): Effect.Effect, E, R> => - Effect.gen(function* () { - const spanNames: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - - span.end = (endTime, exit) => { - end(endTime, exit); - if (span.sampled) { - spanNames.push(span.name); - } - }; - - return span; - }, - }); - - yield* effect.pipe(Effect.withTracer(tracer)); - - return spanNames; +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; +import { RpcInstrumentation, rpcInstrumentationLayer } from "./RpcInstrumentation.ts"; + +type WsRpcMethod = keyof typeof RPC_REQUIRED_SCOPES; + +/** The server group narrowed to `tags`, so a test only implements the handlers it calls. */ +const groupOf = >(...tags: Tags) => + WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tags as ReadonlyArray).includes(tag), + ), + ).middleware(RpcInstrumentation); + +/** The middleware ws.ts installs for a connection with `scopes`. */ +const connectionMiddleware = (scopes: ReadonlyArray) => + Layer.merge(RpcAuthorization.layer(scopes), rpcInstrumentationLayer); +const readOnlyConnection = connectionMiddleware([AuthOrchestrationReadScope]); +const taskId = ScheduledTaskId.make("scheduled-task:instrumented"); +const rpcSpanDefaults = { "rpc.transport": "websocket", "rpc.system": "effect-rpc" }; + +/** Runs a test with a fresh metric registry and a tracer that keeps every span it ends. */ +const withTelemetry = ( + test: (ended: ReadonlyArray) => Effect.Effect, +) => { + const ended: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + ended.push(span); + }; + return span; + }, }); - -describe("RpcInstrumentation", () => { - it.effect("records success metrics for unary RPC handlers", () => - Effect.gen(function* () { - yield* observeRpcEffect("rpc.instrumentation.success", Effect.succeed("ok"), { - "rpc.aggregate": "test", - }).pipe(Effect.withSpan("rpc.instrumentation.success.span")); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.success", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.success", - }), - true, - ); - }), + return test(ended).pipe( + Effect.scoped, + Effect.withTracer(tracer), + Effect.provideService(Metric.MetricRegistry, new Map()), ); +}; - it.effect("records failure outcomes for unary RPC handlers", () => - Effect.gen(function* () { - yield* Effect.exit( - observeRpcEffect("rpc.instrumentation.failure", Effect.fail("boom"), { - "rpc.aggregate": "test", - }).pipe(Effect.withSpan("rpc.instrumentation.failure.span")), - ); +const rpcSpans = (ended: ReadonlyArray) => + ended.filter((span) => span.name.startsWith("ws.rpc.")); - const snapshots = yield* Metric.snapshot; +// RpcTest keeps Effect's own RpcServer/RpcClient spans, which ws.ts turns off with +// `disableTracing: true`. Everything else comes from the middleware or the handlers. +const appSpans = (ended: ReadonlyArray) => + ended.filter((span) => !/^Rpc(Server|Client)\./.test(span.name)); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.failure", - }), - true, - ); - }), - ); +const exitTag = (span: Tracer.NativeSpan | undefined) => + span?.status._tag === "Ended" ? span.status.exit._tag : undefined; - it.effect("records subscription activation metrics for stream RPC handlers", () => - Effect.gen(function* () { - const events = yield* Stream.runCollect( - observeRpcStreamEffect( - "rpc.instrumentation.stream", - Effect.succeed(Stream.make("a", "b")), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.span")), - ); - - assert.deepStrictEqual(Array.from(events), ["a", "b"]); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream", - }), - true, - ); - }), - ); - - it.effect("records failure outcomes for direct stream RPC handlers during consumption", () => - Effect.gen(function* () { - const exit = yield* Stream.runCollect( - observeRpcStream( - "rpc.instrumentation.stream.failure", - Stream.make("a").pipe(Stream.concat(Stream.fail("boom"))), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.failure.span")), - ).pipe(Effect.exit); - - assert.equal(Exit.isFailure(exit), true); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream.failure", - }), - true, - ); - }), - ); - - it.effect("records direct stream durations from nanosecond clock readings", () => - Effect.gen(function* () { - const duration = Duration.nanos(1_500_000n); - const events = yield* Effect.gen(function* () { - const fiber = yield* Stream.runCollect( - observeRpcStream( - WS_METHODS.serverGetProcessDiagnostics, - Stream.fromEffect(Effect.sleep(duration).pipe(Effect.as("ok"))), - { - "rpc.aggregate": "test", - }, - ), - ).pipe(Effect.forkChild); +const parentSpanId = (span: Tracer.NativeSpan | undefined) => + span?.parent._tag === "Some" ? span.parent.value.spanId : undefined; - yield* Effect.yieldNow; - yield* TestClock.adjust(duration); - return yield* Fiber.join(fiber); - }).pipe(Effect.provide(TestClock.layer())); - - assert.deepStrictEqual(Array.from(events), ["ok"]); - - const snapshots = yield* Metric.snapshot; - const snapshot = findHistogramSnapshot(snapshots, "t3_rpc_request_duration", { - method: WS_METHODS.serverGetProcessDiagnostics, - }); - - assert.equal(snapshot?.state.count, 1); - assert.equal(snapshot?.state.sum, 1.5); - }), - ); - - it.effect("records failure outcomes when a stream RPC effect produces a failing stream", () => - Effect.gen(function* () { - const exit = yield* Stream.runCollect( - observeRpcStreamEffect( - "rpc.instrumentation.stream.effect.failure", - Effect.succeed(Stream.fail("boom")), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.effect.failure.span")), - ).pipe(Effect.exit); - - assert.equal(Exit.isFailure(exit), true); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream.effect.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream.effect.failure", - }), - true, - ); - }), - ); - - it.effect("records spans for traced stream RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStream( - "rpc.instrumentation.traced.stream", - Stream.fromEffect( - Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.traced.stream.child")), +const requestCount = ( + snapshots: ReadonlyArray, + method: string, + outcome: string, +) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Counter" && + snapshot.id === "t3_rpc_requests_total" && + snapshot.attributes?.["method"] === method && + snapshot.attributes?.["outcome"] === outcome, + )?.state; + +const requestDuration = (snapshots: ReadonlyArray, method: string) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Histogram" && + snapshot.id === "t3_rpc_request_duration" && + snapshot.attributes?.["method"] === method, + )?.state; + +describe("WS RPC instrumentation middleware", () => { + it.effect("records one span and request metric per call, including rejected calls", () => + withTelemetry((ended) => + Effect.gen(function* () { + const group = groupOf( + WS_METHODS.serverProbe, + WS_METHODS.scheduledTasksList, + WS_METHODS.serverRetryResourceTelemetry, + WS_METHODS.pullRequestsSubscribeRefreshes, + WS_METHODS.scheduledTasksSubscribe, + WS_METHODS.serverGetSettings, + ); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverProbe, () => + Effect.succeed({}).pipe(Effect.withSpan("serverProbe.child")), + ), + group.toLayerHandler(WS_METHODS.scheduledTasksList, () => + Effect.annotateCurrentSpan({ "scheduled_task.id": taskId }).pipe( + Effect.andThen(new ScheduledTaskError({ message: "List failed." })), + ), + ), + group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => + Effect.die("authorization let a rejected call through"), + ), + group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => + Stream.make(1, 2), + ), + group.toLayerHandler(WS_METHODS.scheduledTasksSubscribe, () => + Stream.concat( + Stream.make({ tasks: [] }), + Stream.fail(new ScheduledTaskError({ message: "Subscription failed." })), + ), + ), + group.toLayerHandler(WS_METHODS.serverGetSettings, () => Effect.die("broken")), + readOnlyConnection, ), - { "rpc.aggregate": "test" }, ), - ), - ); - - assert.equal(spanNames.includes("ws.rpc.rpc.instrumentation.traced.stream"), true); - assert.equal(spanNames.includes("rpc.instrumentation.traced.stream.child"), true); - }), - ); - - it.effect("does not create spans for disabled unary RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - observeRpcEffect( - WS_METHODS.serverGetTraceDiagnostics, - Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.disabled.unary.child")), - { "rpc.aggregate": "test" }, - ), - ); - - assert.deepStrictEqual(spanNames, []); - }), + ); + + assert.deepStrictEqual(yield* client[WS_METHODS.serverProbe]({}), {}); + const listError = yield* client[WS_METHODS.scheduledTasksList]({}).pipe(Effect.flip); + assert.equal(listError._tag, "ScheduledTaskError"); + // Retrying telemetry needs operate scope, so the handler must not run. + const rejection = yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe( + Effect.flip, + ); + assert.equal(rejection._tag, "EnvironmentAuthorizationError"); + const refreshes = yield* Stream.runCollect( + client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), + ); + assert.deepStrictEqual(Array.from(refreshes), [1, 2]); + const subscribeError = yield* Stream.runDrain( + client[WS_METHODS.scheduledTasksSubscribe]({}), + ).pipe(Effect.flip); + assert.equal(subscribeError._tag, "ScheduledTaskError"); + const settingsExit = yield* Effect.exit(client[WS_METHODS.serverGetSettings]({})); + assert.isTrue(Exit.hasDies(settingsExit)); + + assert.deepStrictEqual( + rpcSpans(ended).map((span) => [span.name, Object.fromEntries(span.attributes)]), + [ + [ + "ws.rpc.server.probe", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverProbe, + "rpc.aggregate": "server", + }, + ], + [ + "ws.rpc.scheduledTasks.list", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.scheduledTasksList, + "rpc.aggregate": "scheduledTasks", + "scheduled_task.id": taskId, + }, + ], + [ + "ws.rpc.server.retryResourceTelemetry", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverRetryResourceTelemetry, + "rpc.aggregate": "server", + }, + ], + [ + "ws.rpc.pullRequests.subscribeRefreshes", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.pullRequestsSubscribeRefreshes, + "rpc.aggregate": "pull-requests", + }, + ], + [ + "ws.rpc.scheduledTasks.subscribe", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.scheduledTasksSubscribe, + "rpc.aggregate": "scheduledTasks", + }, + ], + [ + "ws.rpc.server.getSettings", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverGetSettings, + "rpc.aggregate": "server", + }, + ], + ], + ); + assert.deepStrictEqual(rpcSpans(ended).map(exitTag), [ + "Success", + "Failure", + "Failure", + "Success", + "Failure", + "Failure", + ]); + const probeSpan = rpcSpans(ended)[0]; + const child = ended.find((span) => span.name === "serverProbe.child"); + assert.equal(parentSpanId(child), probeSpan?.spanId); + + const snapshots = yield* Metric.snapshot; + assert.deepStrictEqual( + [ + requestCount(snapshots, WS_METHODS.serverProbe, "success"), + requestCount(snapshots, WS_METHODS.scheduledTasksList, "failure"), + requestCount(snapshots, WS_METHODS.serverRetryResourceTelemetry, "failure"), + requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "success"), + requestCount(snapshots, WS_METHODS.scheduledTasksSubscribe, "failure"), + requestCount(snapshots, WS_METHODS.serverGetSettings, "failure"), + ].map((state) => state?.count), + [1, 1, 1, 1, 1, 1], + ); + for (const method of group.requests.keys()) { + assert.equal(requestDuration(snapshots, method)?.count, 1); + } + }), + ), ); - it.effect("does not create spans for disabled direct stream RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStream( - WS_METHODS.serverGetTraceDiagnostics, - Stream.fromEffect( - Effect.succeed("ok").pipe( - Effect.withSpan("rpc.instrumentation.disabled.stream.child"), + it.effect("keeps a stream's span and duration open until the subscription is interrupted", () => + withTelemetry((ended) => + Effect.gen(function* () { + const waiting = yield* Deferred.make(); + const group = groupOf(WS_METHODS.pullRequestsSubscribeRefreshes); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => + Stream.concat( + Stream.make(1), + Stream.fromEffect( + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Effect.never), + Effect.withSpan("refreshes.wait"), + ), + ), + ), ), + readOnlyConnection, ), - { "rpc.aggregate": "test" }, ), - ), - ); - - assert.deepStrictEqual(spanNames, []); - }), + ); + + // Wall and monotonic time disagree before the call starts, so a duration that mixes + // the two clocks is caught. + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.setTime(0); + const consumer = yield* Stream.runDrain( + client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), + ).pipe(Effect.forkChild); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.millis(250)); + // A backward wall-clock correction must not shorten the measured duration. + yield* TestClock.setTime(0); + assert.deepStrictEqual(appSpans(ended), []); + + // The client waits for the server to stop the call, which ends the RPC span. + yield* Fiber.interrupt(consumer); + + const [rpcSpan] = rpcSpans(ended); + assert.equal(rpcSpan?.name, "ws.rpc.pullRequests.subscribeRefreshes"); + assert.equal(exitTag(rpcSpan), "Failure"); + const waitSpan = ended.find((span) => span.name === "refreshes.wait"); + assert.equal(parentSpanId(waitSpan), rpcSpan?.spanId); + + const snapshots = yield* Metric.snapshot; + assert.equal( + requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "interrupt")?.count, + 1, + ); + const duration = requestDuration(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes); + assert.equal(duration?.count, 1); + assert.equal(duration?.sum, 250); + }), + ), ); - it.effect("does not create spans for disabled stream effect RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStreamEffect( - WS_METHODS.serverGetTraceDiagnostics, - Effect.succeed( - Stream.fromEffect( - Effect.succeed("ok").pipe( - Effect.withSpan("rpc.instrumentation.disabled.stream.effect.consume"), - ), + it.effect("records metrics but no spans for methods with tracing disabled", () => + withTelemetry((ended) => + Effect.gen(function* () { + const group = groupOf(WS_METHODS.serverSignalProcess); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverSignalProcess, (input) => + Effect.succeed({ + pid: input.pid, + signal: input.signal, + signaled: true, + message: Option.none(), + }).pipe(Effect.withSpan("signalProcess.child")), ), - ).pipe(Effect.withSpan("rpc.instrumentation.disabled.stream.effect.create")), - { "rpc.aggregate": "test" }, + connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + ), ), - ), - ); + ); + + const input = { pid: 4242, startTimeMs: 0, signal: "SIGINT" } as const; + assert.equal((yield* client[WS_METHODS.serverSignalProcess](input)).signaled, true); - assert.deepStrictEqual(spanNames, []); - }), + assert.deepStrictEqual(appSpans(ended), []); + const snapshots = yield* Metric.snapshot; + assert.equal(requestCount(snapshots, WS_METHODS.serverSignalProcess, "success")?.count, 1); + }), + ), ); }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1f3d172b57635..f38e1aeff1d9d 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -1,14 +1,198 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; +import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type WsRpcGroup } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Metric from "effect/Metric"; +import * as Layer from "effect/Layer"; import * as References from "effect/References"; -import * as Stream from "effect/Stream"; +import type * as RpcGroup from "effect/rpc/RpcGroup"; +import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; -import { outcomeFromExit } from "./Attributes.ts"; -import { metricAttributes, rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; +import { rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; + +type WsRpcMethod = RpcGroup.Rpcs["_tag"]; + +/** + * The `rpc.aggregate` span attribute of every WebSocket RPC. Trace queries and dashboards group by + * these labels, so they keep their historical values even where they differ from the method + * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error. + */ +const RPC_AGGREGATES = { + [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2", + [WS_METHODS.projectsMutate]: "orchestration", + [WS_METHODS.serverProbe]: "server", + [WS_METHODS.serverGetConfig]: "server", + [WS_METHODS.serverRefreshProviders]: "server", + [WS_METHODS.serverUpdateProvider]: "server", + [WS_METHODS.providerAuthStart]: "provider", + [WS_METHODS.providerConsumeResetCredit]: "provider", + [WS_METHODS.providerAuthComplete]: "provider", + [WS_METHODS.chatGptReconnectProfile]: "provider", + [WS_METHODS.chatGptImportProfile]: "provider", + [WS_METHODS.chatGptHandoffSubscribe]: "provider", + [WS_METHODS.codexAuthCallbackSubscribe]: "provider", + [WS_METHODS.providerAuthRespond]: "provider", + [WS_METHODS.providerAuthCancel]: "provider", + [WS_METHODS.providerAuthLogout]: "provider", + [WS_METHODS.providerAuthSubscribe]: "provider", + [WS_METHODS.providerInstallStart]: "provider", + [WS_METHODS.providerInstallCancel]: "provider", + [WS_METHODS.providerInstallSubscribe]: "provider", + [WS_METHODS.providerInstallRemove]: "provider", + [WS_METHODS.serverUpdateServer]: "server", + [WS_METHODS.serverUpdateServerWithProgress]: "server", + [WS_METHODS.serverCommitDesktopUpdate]: "server", + [WS_METHODS.serverUpsertKeybinding]: "server", + [WS_METHODS.serverRemoveKeybinding]: "server", + [WS_METHODS.serverGetSettings]: "server", + [WS_METHODS.serverUpdateSettings]: "server", + [WS_METHODS.serverSearchAcpRegistry]: "server", + [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", + [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: "server", + [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: "server", + [WS_METHODS.serverListAcpRegistrySessions]: "server", + [WS_METHODS.serverImportAcpRegistrySession]: "server", + [WS_METHODS.serverDeleteAcpRegistrySession]: "server", + [WS_METHODS.serverListAcpRegistryProviders]: "server", + [WS_METHODS.serverSetAcpRegistryProvider]: "server", + [WS_METHODS.serverDisableAcpRegistryProvider]: "server", + [WS_METHODS.serverLogoutAcpRegistry]: "server", + [WS_METHODS.serverDiscoverSourceControl]: "server", + [WS_METHODS.serverGetTraceDiagnostics]: "server", + [WS_METHODS.serverGetProcessDiagnostics]: "server", + [WS_METHODS.serverGetHostResources]: "server", + [WS_METHODS.serverGetProcessResourceHistory]: "server", + [WS_METHODS.serverGetResourceTelemetryHistory]: "server", + [WS_METHODS.serverRetryResourceTelemetry]: "server", + [WS_METHODS.serverGetUsageSummary]: "server", + [WS_METHODS.serverRefreshUsageRates]: "server", + [WS_METHODS.serverSignalProcess]: "server", + [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.serverReportHostPowerState]: "server", + [WS_METHODS.serverGetBackgroundPolicy]: "server", + [WS_METHODS.scheduledTasksList]: "scheduledTasks", + [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", + [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", + [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", + [WS_METHODS.scheduledTasksDelete]: "scheduledTasks", + [WS_METHODS.scheduledTasksRunNow]: "scheduledTasks", + [WS_METHODS.scheduledTasksRotateWebhookToken]: "scheduledTasks", + [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", + [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", + [WS_METHODS.secretsAnswerRequest]: "secrets", + [WS_METHODS.cloudGetRelayClientStatus]: "cloud", + [WS_METHODS.cloudInstallRelayClient]: "cloud", + [WS_METHODS.pullRequestsList]: "pull-requests", + [WS_METHODS.pullRequestsListStats]: "pull-requests", + [WS_METHODS.pullRequestsSummary]: "pull-requests", + [WS_METHODS.pullRequestsRouting]: "pull-requests", + [WS_METHODS.pullRequestsRoutingIdentity]: "pull-requests", + [WS_METHODS.pullRequestsStack]: "pull-requests", + [WS_METHODS.pullRequestsLinkedThreads]: "pull-requests", + [WS_METHODS.pullRequestsDetail]: "pull-requests", + [WS_METHODS.pullRequestsPreview]: "pull-requests", + [WS_METHODS.pullRequestsChecks]: "pull-requests", + [WS_METHODS.pullRequestsActivity]: "pull-requests", + [WS_METHODS.pullRequestsThreadComments]: "pull-requests", + [WS_METHODS.pullRequestsDiffFileContents]: "pull-requests", + [WS_METHODS.pullRequestsFilesViewed]: "pull-requests", + [WS_METHODS.pullRequestsRunAction]: "pull-requests", + [WS_METHODS.pullRequestsUpdate]: "pull-requests", + [WS_METHODS.pullRequestsComment]: "pull-requests", + [WS_METHODS.pullRequestsUpdateComment]: "pull-requests", + [WS_METHODS.pullRequestsSubmitReview]: "pull-requests", + [WS_METHODS.pullRequestsReplyToThread]: "pull-requests", + [WS_METHODS.pullRequestsSetThreadResolution]: "pull-requests", + [WS_METHODS.pullRequestsSetReaction]: "pull-requests", + [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", + [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", + [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", + [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", + [WS_METHODS.pullRequestsLabelCandidates]: "pull-requests", + [WS_METHODS.pullRequestsSetLabels]: "pull-requests", + [WS_METHODS.sourceControlLookupRepository]: "source-control", + [WS_METHODS.sourceControlCloneRepository]: "source-control", + [WS_METHODS.sourceControlPublishRepository]: "source-control", + [WS_METHODS.projectCloneStart]: "source-control", + [WS_METHODS.projectCloneCancel]: "source-control", + [WS_METHODS.projectCloneRetry]: "source-control", + [WS_METHODS.subscribeProjectClones]: "source-control", + [WS_METHODS.projectsListEntries]: "workspace", + [WS_METHODS.projectsReadFile]: "workspace", + [WS_METHODS.projectsSearchContents]: "workspace", + [WS_METHODS.projectsSearchEntries]: "workspace", + [WS_METHODS.projectsWriteFile]: "workspace", + [WS_METHODS.projectsEnsureScratch]: "orchestration", + [WS_METHODS.projectsCreateNew]: "orchestration", + [WS_METHODS.shellOpenInEditor]: "workspace", + [WS_METHODS.filesystemBrowse]: "workspace", + [WS_METHODS.agentSessionsScan]: "workspace", + [WS_METHODS.agentSessionsImport]: "workspace", + [WS_METHODS.assetsCreateUrl]: "workspace", + [WS_METHODS.assetsPersistChatAttachments]: "orchestration", + [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", + [WS_METHODS.attachmentsDelete]: "workspace", + [WS_METHODS.providerUploadFeedback]: "provider", + [WS_METHODS.subscribeVcsStatus]: "vcs", + [WS_METHODS.subscribeWorktreeSetup]: "vcs", + [WS_METHODS.worktreeSetupCancel]: "vcs", + [WS_METHODS.subscribeResourceTelemetry]: "server", + [WS_METHODS.vcsRefreshStatus]: "vcs", + [WS_METHODS.vcsPull]: "git", + [WS_METHODS.gitRunStackedAction]: "vcs", + [WS_METHODS.gitResolvePullRequest]: "git", + [WS_METHODS.gitPreparePullRequestThread]: "git", + [WS_METHODS.vcsListRefs]: "vcs", + [WS_METHODS.vcsCreateWorktree]: "vcs", + [WS_METHODS.vcsRemoveWorktree]: "vcs", + [WS_METHODS.vcsCreateRef]: "vcs", + [WS_METHODS.vcsSwitchRef]: "vcs", + [WS_METHODS.vcsInit]: "vcs", + [WS_METHODS.reviewGetDiffPreview]: "review", + [WS_METHODS.reviewGetDiffFileContents]: "review", + [WS_METHODS.terminalOpen]: "terminal", + [WS_METHODS.terminalAttach]: "terminal", + [WS_METHODS.terminalWrite]: "terminal", + [WS_METHODS.terminalResize]: "terminal", + [WS_METHODS.terminalClear]: "terminal", + [WS_METHODS.terminalRestart]: "terminal", + [WS_METHODS.terminalClose]: "terminal", + [WS_METHODS.subscribeTerminalEvents]: "terminal", + [WS_METHODS.subscribeTerminalMetadata]: "terminal", + [WS_METHODS.previewOpen]: "preview", + [WS_METHODS.previewNavigate]: "preview", + [WS_METHODS.previewResize]: "preview", + [WS_METHODS.previewAdjust]: "preview", + [WS_METHODS.previewRefresh]: "preview", + [WS_METHODS.previewClose]: "preview", + [WS_METHODS.previewList]: "preview", + [WS_METHODS.previewClearProfile]: "preview", + [WS_METHODS.previewReportStatus]: "preview", + [WS_METHODS.subscribePreviewEvents]: "preview", + [WS_METHODS.subscribeDiscoveredLocalServers]: "preview", + [WS_METHODS.deviceConfigure]: "device", + [WS_METHODS.deviceTestHost]: "device", + [WS_METHODS.deviceList]: "device", + [WS_METHODS.deviceOpen]: "device", + [WS_METHODS.deviceClose]: "device", + [WS_METHODS.deviceShutdown]: "device", + [WS_METHODS.deviceDetail]: "device", + [WS_METHODS.deviceAction]: "device", + [WS_METHODS.subscribeDeviceState]: "device", + [WS_METHODS.subscribeServerConfig]: "server", + [WS_METHODS.subscribeServerLifecycle]: "server", + [WS_METHODS.subscribeAuthAccess]: "auth", + [WS_METHODS.subscribeBackgroundPolicy]: "server", +} as const satisfies Readonly>; const RPC_SPAN_PREFIX = "ws.rpc"; const DEFAULT_RPC_SPAN_ATTRIBUTES = { @@ -22,117 +206,36 @@ const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ WS_METHODS.serverSignalProcess, ]); -function shouldTraceRpc(method: string): boolean { - return !RPC_METHODS_WITH_TRACING_DISABLED.has(method); -} - -const rpcSpanAttributes = ( - method: string, - traceAttributes?: Readonly>, -): Record => ({ - ...DEFAULT_RPC_SPAN_ATTRIBUTES, - "rpc.method": method, - ...traceAttributes, -}); - -const withRpcEffectTracing = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, -): Effect.Effect => - shouldTraceRpc(method) - ? effect.pipe( - Effect.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { - attributes: rpcSpanAttributes(method, traceAttributes), - }), - ) - : effect.pipe(Effect.provideService(References.TracerEnabled, false)); +/** + * Records each WebSocket RPC's span and request metrics. `ws.ts` adds it to the server's group + * after `RpcScopeAuthorization`, so it wraps authorization and also records rejected calls. + */ +export class RpcInstrumentation extends RpcMiddleware.Service()( + "t3/server/RpcInstrumentation", +) {} -const withRpcStreamTracing = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, -): Stream.Stream => - shouldTraceRpc(method) - ? stream.pipe( - Stream.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { - attributes: rpcSpanAttributes(method, traceAttributes), - }), - ) - : stream.pipe(Stream.provideService(References.TracerEnabled, false)); - -const recordRpcStreamMetrics = ( - method: string, - startedAt: bigint, - exit: Exit.Exit, -): Effect.Effect => - Effect.gen(function* () { - yield* Metric.update( - Metric.withAttributes(rpcRequestDuration, metricAttributes({ method })), - Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt), - ); - yield* Metric.update( - Metric.withAttributes( - rpcRequestsTotal, - metricAttributes({ - method, - outcome: outcomeFromExit(exit), - }), - ), - 1, - ); - }); +/** + * Wraps each WebSocket RPC call in its `ws.rpc.` span and records its request counter and + * duration. For a stream RPC, the middleware receives the whole stream run, so the span and the + * metrics cover the subscription until it ends, fails, or is interrupted. Methods in + * `RPC_METHODS_WITH_TRACING_DISABLED` record metrics but no spans, for the call or anything it runs. + */ +export const rpcInstrumentationLayer = Layer.succeed(RpcInstrumentation)((effect, { rpc }) => { + const method = rpc._tag; + const measured = effect.pipe( + withMetrics({ counter: rpcRequestsTotal, timer: rpcRequestDuration, attributes: { method } }), + ); -export const observeRpcEffect = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, -): Effect.Effect => { - const instrumented = effect.pipe( - withMetrics({ - counter: rpcRequestsTotal, - timer: rpcRequestDuration, + if (RPC_METHODS_WITH_TRACING_DISABLED.has(method)) { + return measured.pipe(Effect.provideService(References.TracerEnabled, false)); + } + return measured.pipe( + Effect.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { attributes: { - method, + ...DEFAULT_RPC_SPAN_ATTRIBUTES, + "rpc.method": method, + "rpc.aggregate": RPC_AGGREGATES[method as WsRpcMethod], }, }), ); - - return withRpcEffectTracing(method, instrumented, traceAttributes); -}; - -export const observeRpcStream = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, -): Stream.Stream => { - const instrumented = Stream.unwrap( - Effect.gen(function* () { - const startedAt = yield* Clock.monotonicTimeNanos; - return stream.pipe(Stream.onExit((exit) => recordRpcStreamMetrics(method, startedAt, exit))); - }), - ); - - return withRpcStreamTracing(method, instrumented, traceAttributes); -}; - -export const observeRpcStreamEffect = ( - method: string, - effect: Effect.Effect, EffectError, EffectContext>, - traceAttributes?: Readonly>, -): Stream.Stream => { - const instrumented = Stream.unwrap( - Effect.gen(function* () { - const startedAt = yield* Clock.monotonicTimeNanos; - // onError also runs when the stream is interrupted before it is produced. - const stream = yield* effect.pipe( - Effect.onError((cause) => recordRpcStreamMetrics(method, startedAt, Exit.failCause(cause))), - ); - return stream.pipe( - Stream.onExit((streamExit) => recordRpcStreamMetrics(method, startedAt, streamExit)), - ); - }), - ); - - return withRpcStreamTracing(method, instrumented, traceAttributes); -}; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dcf9fad568352..ad1b9815acc3d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -159,11 +159,6 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; -import { - observeRpcEffect, - observeRpcStream, - observeRpcStreamEffect, -} from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; @@ -215,6 +210,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; +import { RpcInstrumentation, rpcInstrumentationLayer } from "./observability/RpcInstrumentation.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -541,7 +537,8 @@ function projectFileFailureContext( const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const ServerWsRpcGroup = WsRpcGroup; +// Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. +const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1808,851 +1805,595 @@ const layerWsRpc = ( const handlers = ServerWsRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.dispatchCommand, - startup - .enqueueCommand( - // A retry also restarts the preparation work the launch owns. - (command.type === "prepared-run.retry" - ? threadLaunch.retryPreparation(command) - : ThreadMessageIntake.dispatchCommand( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: - "creationSource" in command ? command.creationSource : "web", - }), - ) - ).pipe(Effect.provide(intakeContext)), - ) - .pipe( - Effect.tap(() => recordClientCommandAnalytics(command)), - Effect.map((result) => ({ sequence: result.sequence })), - Effect.mapError((cause) => { - const detail = userFacingDispatchErrorMessage(cause); - return new OrchestrationV2DispatchCommandError({ - commandId: command.commandId, - commandType: command.type, - message: detail ?? "Failed to dispatch orchestration V2 command", - ...(detail === undefined ? {} : { detail }), - cause, - }); - }), - ), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.command_id": command.commandId, - "orchestration_v2.command_type": command.type, - "orchestration_v2.thread_id": - command.type === "thread.fork" || command.type === "thread.merge_back" - ? command.targetThreadId - : command.type === "delegated_task.request" || - command.type === "delegated_task.wake-policy" || - command.type === "delegated_task.completion-delivery.acknowledge" || - command.type === "delegated_task.completion-delivery.dispose" || - command.type === "thread.created.record" - ? command.parentThreadId - : command.threadId, - ...(command.type === "thread.fork" || command.type === "thread.merge_back" - ? { "orchestration_v2.source_thread_id": command.sourceThreadId } - : {}), - }, + Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": command.commandId, + "orchestration_v2.command_type": command.type, + "orchestration_v2.thread_id": + command.type === "thread.fork" || command.type === "thread.merge_back" + ? command.targetThreadId + : command.type === "delegated_task.request" || + command.type === "delegated_task.wake-policy" || + command.type === "delegated_task.completion-delivery.acknowledge" || + command.type === "delegated_task.completion-delivery.dispose" || + command.type === "thread.created.record" + ? command.parentThreadId + : command.threadId, + ...(command.type === "thread.fork" || command.type === "thread.merge_back" + ? { "orchestration_v2.source_thread_id": command.sourceThreadId } + : {}), + }).pipe( + Effect.andThen( + startup + .enqueueCommand( + // A retry also restarts the preparation work the launch owns. + (command.type === "prepared-run.retry" + ? threadLaunch.retryPreparation(command) + : ThreadMessageIntake.dispatchCommand( + ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: + "creationSource" in command ? command.creationSource : "web", + }), + ) + ).pipe(Effect.provide(intakeContext)), + ) + .pipe( + Effect.tap(() => recordClientCommandAnalytics(command)), + Effect.map((result) => ({ sequence: result.sequence })), + Effect.mapError((cause) => { + const detail = userFacingDispatchErrorMessage(cause); + return new OrchestrationV2DispatchCommandError({ + commandId: command.commandId, + commandType: command.type, + message: detail ?? "Failed to dispatch orchestration V2 command", + ...(detail === undefined ? {} : { detail }), + cause, + }); + }), + ), + ), ), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, - readWorkflowScript({ scriptPath: input.scriptPath }), - { "rpc.aggregate": "orchestration" }, - ), + readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getTurnItem, - threadManagement.getTurnItem(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: "Failed to load turn item", - cause, - }), - ), + threadManagement.getTurnItem(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "Failed to load turn item", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), - ), + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), - ), + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.searchThreads, - threadSearch.search(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationSearchThreadsError({ - message: "Failed to search threads", - cause, - }), - ), + threadSearch.search(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadsError({ + message: "Failed to search threads", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, - getOrchestrationV2ArchivedShellSnapshot, - { "rpc.aggregate": "orchestration" }, - ), + getOrchestrationV2ArchivedShellSnapshot, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getThreadProjection, - // Pre-pagination clients still call this compatibility endpoint. - // Keep stale clients from materializing an unbounded transcript. - threadManagement - .getThreadSnapshotWindow(input.threadId, { - rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - }) - .pipe( - Effect.map((snapshot) => - projectThreadProjectionForWire( - buildBoundedThreadProjection({ - projection: snapshot.projection, - snapshotSequence: snapshot.snapshotSequence, - }).projection, + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen( + // Pre-pagination clients still call this compatibility endpoint. + // Keep stale clients from materializing an unbounded transcript. + threadManagement + .getThreadSnapshotWindow(input.threadId, { + rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + }) + .pipe( + Effect.map((snapshot) => + projectThreadProjectionForWire( + buildBoundedThreadProjection({ + projection: snapshot.projection, + snapshotSequence: snapshot.snapshotSequence, + }).projection, + ), + ), + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to load orchestration V2 thread ${input.threadId}`, + cause, + }), ), ), - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: `Failed to load orchestration V2 thread ${input.threadId}`, - cause, - }), - ), - ), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.thread_id": input.threadId, - }, + ), ), [ORCHESTRATION_V2_WS_METHODS.launchThread]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.launchThread, - startup - .enqueueCommand( - ThreadMessageIntake.launchThread({ - commandId: input.commandId, - ...(input.threadId === undefined ? {} : { threadId: input.threadId }), - ...(input.reuseExistingThread === undefined - ? {} - : { reuseExistingThread: input.reuseExistingThread }), - projectId: input.projectId, - title: input.title, - ...(input.generateTitle === undefined - ? {} - : { generateTitle: input.generateTitle }), - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - workspaceStrategy: input.workspaceStrategy, - ...(input.initialMessage === undefined - ? {} - : { - initialMessage: { - ...(input.initialMessage.messageId === undefined - ? {} - : { messageId: input.initialMessage.messageId }), - text: input.initialMessage.text, - attachments: input.initialMessage.attachments, - ...(input.initialMessage.context === undefined - ? {} - : { context: input.initialMessage.context }), - }, - }), - createdBy: "user", - creationSource: input.creationSource ?? "web", - }).pipe(Effect.provide(intakeContext)), - ) - .pipe( - Effect.tap(() => - analytics - .record("client.thread.started", originProps) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), + Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": input.commandId, + "orchestration_v2.project_id": input.projectId, + }).pipe( + Effect.andThen( + startup + .enqueueCommand( + ThreadMessageIntake.launchThread({ + commandId: input.commandId, + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + ...(input.reuseExistingThread === undefined + ? {} + : { reuseExistingThread: input.reuseExistingThread }), + projectId: input.projectId, + title: input.title, + ...(input.generateTitle === undefined + ? {} + : { generateTitle: input.generateTitle }), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + workspaceStrategy: input.workspaceStrategy, + ...(input.initialMessage === undefined + ? {} + : { + initialMessage: { + ...(input.initialMessage.messageId === undefined + ? {} + : { messageId: input.initialMessage.messageId }), + text: input.initialMessage.text, + attachments: input.initialMessage.attachments, + ...(input.initialMessage.context === undefined + ? {} + : { context: input.initialMessage.context }), + }, + }), + createdBy: "user", + creationSource: input.creationSource ?? "web", + }).pipe(Effect.provide(intakeContext)), + ) + .pipe( + Effect.tap(() => + analytics + .record("client.thread.started", originProps) + .pipe( + Effect.andThen( + input.initialMessage === undefined + ? Effect.void + : analytics.record("client.turn.requested", originProps), + ), + Effect.ignore, ), - Effect.ignore, - ), + ), + Effect.map((result) => ({ + ...result, + projection: projectThreadProjectionForWire(result.projection), + })), + Effect.catchTags({ + AttachmentClaimError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: cause.message, + cause, + }), + ThreadLaunchError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Failed to launch thread", + cause, + }), + ServerRuntimeStartupError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Failed to launch thread", + cause, + }), + }), ), - Effect.map((result) => ({ - ...result, - projection: projectThreadProjectionForWire(result.projection), - })), - Effect.catchTags({ - AttachmentClaimError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: cause.message, - cause, - }), - ThreadLaunchError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: "Failed to launch thread", - cause, - }), - ServerRuntimeStartupError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: "Failed to launch thread", - cause, - }), - }), - ), - { - "rpc.aggregate": "orchestration", - "orchestration_v2.command_id": input.commandId, - "orchestration_v2.project_id": input.projectId, - }, + ), ), [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: (_input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell, - subscribeOrchestrationV2ArchivedShell(), - { "rpc.aggregate": "orchestration" }, - ), + Stream.unwrap(subscribeOrchestrationV2ArchivedShell()), [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeShell, - subscribeOrchestrationV2Shell(input), - { - "rpc.aggregate": "orchestrationV2", - }, - ), + Stream.unwrap(subscribeOrchestrationV2Shell(input)), [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeThread, - subscribeOrchestrationV2Thread(input), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.thread_id": input.threadId, - }, + Stream.unwrap( + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(subscribeOrchestrationV2Thread(input)), + ), ), [WS_METHODS.scheduledTasksList]: (_input) => - observeRpcEffect( - WS_METHODS.scheduledTasksList, - scheduledTasks.list().pipe(Effect.map(withVisibleWebhookUrls)), - { "rpc.aggregate": "scheduledTasks" }, - ), + scheduledTasks.list().pipe(Effect.map(withVisibleWebhookUrls)), [WS_METHODS.scheduledTasksSubscribe]: (_input) => - observeRpcStream( - WS_METHODS.scheduledTasksSubscribe, - scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), - { "rpc.aggregate": "scheduledTasks" }, - ), - [WS_METHODS.scheduledTasksUpsert]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksUpsert, scheduledTasks.upsert(input), { - "rpc.aggregate": "scheduledTasks", - }), + scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), + [WS_METHODS.scheduledTasksUpsert]: (input) => scheduledTasks.upsert(input), [WS_METHODS.scheduledTasksSetEnabled]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksSetEnabled, scheduledTasks.setEnabled(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.setEnabled(input)), + ), [WS_METHODS.scheduledTasksDelete]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksDelete, scheduledTasks.delete(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.delete(input)), + ), [WS_METHODS.scheduledTasksRunNow]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksRunNow, scheduledTasks.runNow(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.runNow(input)), + ), [WS_METHODS.scheduledTasksRotateWebhookToken]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksRotateWebhookToken, - scheduledTasks.rotateWebhookToken(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), [WS_METHODS.secretsAnswerRequest]: (input) => - observeRpcEffect(WS_METHODS.secretsAnswerRequest, secretRequests.answer(input), { - "rpc.aggregate": "secrets", - "orchestration_v2.thread_id": input.threadId, - }), + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(secretRequests.answer(input)), + ), [WS_METHODS.scheduledTasksListWebhookDeliveries]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksListWebhookDeliveries, - scheduledTasks.listWebhookDeliveries(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.listWebhookDeliveries(input)), ), [WS_METHODS.scheduledTasksGetWebhookDelivery]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksGetWebhookDelivery, - scheduledTasks.getWebhookDelivery(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, - ), - [WS_METHODS.serverProbe]: (_input) => - observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetConfig, - loadServerConfig({ usageLimitsCommand: false }), - { - "rpc.aggregate": "server", - }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), + [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), + [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => - observeRpcEffect( - WS_METHODS.serverSearchAcpRegistry, - acpRegistryCatalog - .search(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { "rpc.aggregate": "server" }, - ), + acpRegistryCatalog + .search(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), [WS_METHODS.serverPrepareAcpRegistryAgent]: (input) => - observeRpcEffect( - WS_METHODS.serverPrepareAcpRegistryAgent, - acpRegistryCatalog - .prepare(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { - "rpc.aggregate": "server", - "acp_registry.agent_id": input.agentId, - }, + Effect.annotateCurrentSpan({ "acp_registry.agent_id": input.agentId }).pipe( + Effect.andThen( + acpRegistryCatalog + .prepare(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), + ), ), [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => - observeRpcEffect( - WS_METHODS.serverUninstallAcpRegistryManagedBinary, - acpRegistryCatalog - .uninstallManagedBinary(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { - "rpc.aggregate": "server", - "acp_registry.agent_id": input.agentId, - }, + Effect.annotateCurrentSpan({ "acp_registry.agent_id": input.agentId }).pipe( + Effect.andThen( + acpRegistryCatalog + .uninstallManagedBinary(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), + ), ), [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: (input) => - observeRpcEffect( - WS_METHODS.serverAcceptAcpRegistryUrlAuth, - acpRegistryRuntimeCoordinator - .acceptUrlAuthentication(input) - .pipe(Effect.map((accepted) => ({ accepted }))), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - }, + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId }).pipe( + Effect.andThen( + acpRegistryRuntimeCoordinator + .acceptUrlAuthentication(input) + .pipe(Effect.map((accepted) => ({ accepted }))), + ), ), [WS_METHODS.serverListAcpRegistrySessions]: (input) => - observeRpcEffect( - WS_METHODS.serverListAcpRegistrySessions, - listAcpRegistrySessions(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(listAcpRegistrySessions(input))), [WS_METHODS.serverImportAcpRegistrySession]: (input) => - observeRpcEffect( - WS_METHODS.serverImportAcpRegistrySession, - importAcpRegistrySession(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(importAcpRegistrySession(input))), [WS_METHODS.serverDeleteAcpRegistrySession]: (input) => - observeRpcEffect( - WS_METHODS.serverDeleteAcpRegistrySession, - deleteAcpRegistrySession(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(deleteAcpRegistrySession(input))), [WS_METHODS.serverListAcpRegistryProviders]: (input) => - observeRpcEffect( - WS_METHODS.serverListAcpRegistryProviders, - listAcpRegistryProviders(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(listAcpRegistryProviders(input))), [WS_METHODS.serverSetAcpRegistryProvider]: (input) => - observeRpcEffect(WS_METHODS.serverSetAcpRegistryProvider, setAcpRegistryProvider(input), { - "rpc.aggregate": "server", + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId, "project.id": input.projectId, - }), + }).pipe(Effect.andThen(setAcpRegistryProvider(input))), [WS_METHODS.serverDisableAcpRegistryProvider]: (input) => - observeRpcEffect( - WS_METHODS.serverDisableAcpRegistryProvider, - disableAcpRegistryProvider(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(disableAcpRegistryProvider(input))), [WS_METHODS.serverLogoutAcpRegistry]: (input) => - observeRpcEffect( - WS_METHODS.serverLogoutAcpRegistry, - Effect.gen(function* () { - const { instance, manager } = yield* acpSessionManager(input.instanceId); - const snapshot = yield* instance.snapshot.getSnapshot; - if (snapshot.auth.canLogout !== true) { - return yield* new AcpRegistryOperationError({ - reason: "logout_unsupported", - message: "The ACP agent does not advertise logout.", - }); - } - if (instance.auth) { - yield* providerAuth.logout(input).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "logout_failed", - message: "Could not sign out of the ACP agent.", - cause, - }), - ), - ); - } else { - yield* providerSessionManager.closeInstance(input.instanceId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "logout_failed", - message: "Could not stop live sessions before ACP logout.", - cause, - }), - ), - ); - yield* manager.logout(config.cwd); - } - yield* providerRegistry.refreshInstance(input.instanceId); - return { loggedOut: true } as const; - }), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - }, - ), - [WS_METHODS.serverRefreshProviders]: (input) => - observeRpcEffect( - WS_METHODS.serverRefreshProviders, - Effect.gen(function* () { - // Only explicit catalog refreshes bypass T3's caches. Workspace - // discovery and background status checks retain their timers. - if (input.refreshModels) { - yield* modelManifest.forceRefresh; - const instances = yield* providerInstances.listInstances; - yield* Effect.forEach( - instances.filter( - (instance) => - input.instanceId === undefined || input.instanceId === instance.instanceId, - ), - (instance) => - Effect.gen(function* () { - yield* instance.invalidateCaches ?? Effect.void; - const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: true, - }); - if (maintenance.packageName) - providerVersionCache.delete(maintenance.packageName); - }), - { concurrency: "unbounded", discard: true }, - ); - } - // An untargeted refresh is "re-read everything's status", which - // includes quota from configured usage-limit sources. Awaited, - // not forked: the RPC scope closes on return and would - // interrupt a fork before the hub answered. - if (input.instanceId === undefined) { - yield* usageLimitSources.refresh; - } - let providers = yield* input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh === true, - }) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh(); - if (input.refreshModels) { - const instances = yield* providerInstances.listInstances; - for (const instance of instances) { - if ( - !instance.refreshModels || - (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || - !providers.some( - (provider) => - provider.instanceId === instance.instanceId && - provider.enabled && - provider.installed, - ) - ) - continue; - yield* instance.refreshModels().pipe( + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId }).pipe( + Effect.andThen( + Effect.gen(function* () { + const { instance, manager } = yield* acpSessionManager(input.instanceId); + const snapshot = yield* instance.snapshot.getSnapshot; + if (snapshot.auth.canLogout !== true) { + return yield* new AcpRegistryOperationError({ + reason: "logout_unsupported", + message: "The ACP agent does not advertise logout.", + }); + } + if (instance.auth) { + yield* providerAuth.logout(input).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "logout_failed", + message: "Could not sign out of the ACP agent.", + cause, + }), + ), + ); + } else { + yield* providerSessionManager.closeInstance(input.instanceId).pipe( Effect.mapError( - (error) => - new ProviderSetupError({ - instanceId: instance.instanceId, - operation: "refresh-models", - detail: error.detail, + (cause) => + new AcpRegistryOperationError({ + reason: "logout_failed", + message: "Could not stop live sessions before ACP logout.", + cause, }), ), ); - providers = yield* providerRegistry.refreshInstance(instance.instanceId); + yield* manager.logout(config.cwd); } - } - return { providers }; - }), - { "rpc.aggregate": "server" }, + yield* providerRegistry.refreshInstance(input.instanceId); + return { loggedOut: true } as const; + }), + ), ), - [WS_METHODS.providerUploadFeedback]: (input) => - observeRpcEffect( - WS_METHODS.providerUploadFeedback, - Effect.gen(function* () { - const projection = yield* threadManagement.getThreadRecords(input.threadId, [ - "providerThreads", - ]); - const providerThread = - projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ) ?? projection.providerThreads.at(-1); - const providerSessionId = providerThread?.providerSessionId ?? null; - if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", + [WS_METHODS.serverRefreshProviders]: (input) => + Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); }), + { concurrency: "unbounded", discard: true }, + ); + } + // An untargeted refresh is "re-read everything's status", which + // includes quota from configured usage-limit sources. Awaited, + // not forked: the RPC scope closes on return and would + // interrupt a fork before the hub answered. + if (input.instanceId === undefined) { + yield* usageLimitSources.refresh; + } + let providers = yield* input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh === true, + }) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); + if (input.refreshModels) { + const instances = yield* providerInstances.listInstances; + for (const instance of instances) { + if ( + !instance.refreshModels || + (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || + !providers.some( + (provider) => + provider.instanceId === instance.instanceId && + provider.enabled && + provider.installed, + ) + ) + continue; + yield* instance.refreshModels().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refresh-models", + detail: error.detail, + }), + ), ); + providers = yield* providerRegistry.refreshInstance(instance.instanceId); } - const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); - if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ + } + return { providers }; + }), + [WS_METHODS.providerUploadFeedback]: (input) => + Effect.gen(function* () { + const projection = yield* threadManagement.getThreadRecords(input.threadId, [ + "providerThreads", + ]); + const providerThread = + projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) ?? projection.providerThreads.at(-1); + const providerSessionId = providerThread?.providerSessionId ?? null; + if (providerThread === undefined || providerSessionId === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "No provider session has run in this thread yet.", + }), + ); + } + const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); + if (runtime === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }), + ); + } + if (runtime.uploadFeedback === undefined) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }), + ); + } + return yield* runtime.uploadFeedback({ + providerThread, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + }).pipe( + Effect.mapError((cause) => + isProviderUploadFeedbackError(cause) + ? cause + : new ProviderUploadFeedbackError({ threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", + cause, }), - ); - } - if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); - } - return yield* runtime.uploadFeedback({ - providerThread, - ...(input.reason === undefined ? {} : { reason: input.reason }), - }); - }).pipe( - Effect.mapError((cause) => - isProviderUploadFeedbackError(cause) - ? cause - : new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause, - }), - ), ), - { "rpc.aggregate": "provider" }, ), [WS_METHODS.serverUpdateProvider]: (input) => - observeRpcEffect( - WS_METHODS.serverUpdateProvider, - providerMaintenanceRunner.updateProvider(input), - { - "rpc.aggregate": "server", - }, - ), + providerMaintenanceRunner.updateProvider(input), [WS_METHODS.providerConsumeResetCredit]: (input) => - observeRpcEffect( - WS_METHODS.providerConsumeResetCredit, - Effect.gen(function* () { - if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); - const instance = yield* providerInstances.getInstance(input.instanceId); - // A disabled instance must not spend anything on its account. - if (instance === undefined || !instance.enabled) { - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: instance ? "This provider is disabled." : "Provider instance not found.", - }); - } - if (instance.consumeResetCredit === undefined) { - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: "This provider does not bank reset credits.", - }); - } - const outcome = yield* instance.consumeResetCredit().pipe( - Effect.mapError( - (error) => - new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: error.detail, - cause: error, - }), - ), - ); - return { outcome }; - }), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerAuthStart]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthStart, - providerAuth.start(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), + Effect.gen(function* () { + if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); + const instance = yield* providerInstances.getInstance(input.instanceId); + // A disabled instance must not spend anything on its account. + if (instance === undefined || !instance.enabled) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: instance ? "This provider is disabled." : "Provider instance not found.", + }); + } + if (instance.consumeResetCredit === undefined) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: "This provider does not bank reset credits.", + }); + } + const outcome = yield* instance.consumeResetCredit().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: error.detail, + cause: error, + }), + ), + ); + return { outcome }; + }), + [WS_METHODS.providerAuthStart]: (input) => providerAuth.start(input, currentSessionId), [WS_METHODS.providerAuthRespond]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthRespond, - providerAuth.respond(input, currentSessionId), - { - "rpc.aggregate": "provider", - instanceId: input.instanceId, - }, + Effect.annotateCurrentSpan({ instanceId: input.instanceId }).pipe( + Effect.andThen(providerAuth.respond(input, currentSessionId)), ), [WS_METHODS.providerAuthComplete]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthComplete, - providerAuth.complete(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), + providerAuth.complete(input, currentSessionId), [WS_METHODS.chatGptReconnectProfile]: (input) => providerAuth.reconnectProfile(input), [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input), [WS_METHODS.chatGptHandoffSubscribe]: (input) => subscribeChatGptHandoff(input, currentSessionId), - [WS_METHODS.codexAuthCallbackSubscribe]: (input) => - observeRpcStream( - WS_METHODS.codexAuthCallbackSubscribe, - subscribeCodexAuthCallback(input), - { - "rpc.aggregate": "provider", - }, - ), - [WS_METHODS.providerAuthCancel]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthCancel, - providerAuth.cancel(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerAuthLogout]: (input) => - observeRpcEffect(WS_METHODS.providerAuthLogout, providerAuth.logout(input), { - "rpc.aggregate": "provider", - }), + [WS_METHODS.codexAuthCallbackSubscribe]: (input) => subscribeCodexAuthCallback(input), + [WS_METHODS.providerAuthCancel]: (input) => providerAuth.cancel(input, currentSessionId), + [WS_METHODS.providerAuthLogout]: (input) => providerAuth.logout(input), [WS_METHODS.providerAuthSubscribe]: (input) => - observeRpcStream( - WS_METHODS.providerAuthSubscribe, - providerAuth.subscribe(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerInstallStart]: (input) => - observeRpcEffect(WS_METHODS.providerInstallStart, providerInstallation.start(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.providerInstallCancel]: (input) => - observeRpcEffect(WS_METHODS.providerInstallCancel, providerInstallation.cancel(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.providerInstallSubscribe]: (input) => - observeRpcStream( - WS_METHODS.providerInstallSubscribe, - providerInstallation.subscribe(input), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerInstallRemove]: (input) => - observeRpcEffect(WS_METHODS.providerInstallRemove, providerInstallation.remove(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.serverUpdateServer]: (input) => - observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { - "rpc.aggregate": "server", - }), + providerAuth.subscribe(input, currentSessionId), + [WS_METHODS.providerInstallStart]: (input) => providerInstallation.start(input), + [WS_METHODS.providerInstallCancel]: (input) => providerInstallation.cancel(input), + [WS_METHODS.providerInstallSubscribe]: (input) => providerInstallation.subscribe(input), + [WS_METHODS.providerInstallRemove]: (input) => providerInstallation.remove(input), + [WS_METHODS.serverUpdateServer]: (input) => serverSelfUpdate.update(input), [WS_METHODS.serverUpdateServerWithProgress]: (input) => - observeRpcStream( - WS_METHODS.serverUpdateServerWithProgress, - Stream.callback((queue) => - serverSelfUpdate - .update(input, (stage) => + Stream.callback((queue) => + serverSelfUpdate + .update(input, (stage) => + Queue.offer(queue, { + type: "progress", + stage, + }).pipe(Effect.asVoid), + ) + .pipe( + Effect.flatMap((result) => Queue.offer(queue, { - type: "progress", - stage, - }).pipe(Effect.asVoid), - ) - .pipe( - Effect.flatMap((result) => - Queue.offer(queue, { - type: "complete", - result, - }), - ), - Effect.catchTags({ - ServerSelfUpdateError: (error) => Queue.fail(queue, error), + type: "complete", + result, }), - Effect.andThen(Queue.end(queue)), - Effect.forkScoped, ), - ), - { "rpc.aggregate": "server" }, + Effect.catchTags({ + ServerSelfUpdateError: (error) => Queue.fail(queue, error), + }), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), ), [WS_METHODS.serverCommitDesktopUpdate]: (input) => - observeRpcEffect( - WS_METHODS.serverCommitDesktopUpdate, - serverSelfUpdate.commitDesktopUpdate(input.requestId), - { "rpc.aggregate": "server" }, - ), + serverSelfUpdate.commitDesktopUpdate(input.requestId), [WS_METHODS.serverUpsertKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverUpsertKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; - }), - { "rpc.aggregate": "server" }, - ), + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), [WS_METHODS.serverRemoveKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverRemoveKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; - }), - { "rpc.aggregate": "server" }, - ), + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetSettings, - serverSettings.getSettings.pipe( - Effect.map(ServerSettings.redactServerSettingsForClient), - ), - { - "rpc.aggregate": "server", - }, - ), + serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => - observeRpcEffect( - WS_METHODS.serverUpdateSettings, - Effect.gen(function* () { - const deviceHosts = patch.deviceHosts - ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( - Effect.provide(deviceHostContext), - ) - : undefined; - const nextPatch = { ...patch, ...(deviceHosts ? { deviceHosts } : {}) }; - const settings = yield* providerInstanceMutation === undefined - ? serverSettings.updateSettings(nextPatch) - : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); - }), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverDiscoverSourceControl]: (_input) => - observeRpcEffect( - WS_METHODS.serverDiscoverSourceControl, - sourceControlDiscovery.discover, - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetTraceDiagnostics]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetTraceDiagnostics, - TraceDiagnostics.readTraceDiagnostics({ - traceFilePath: config.serverTracePath, - maxFiles: config.traceMaxFiles, - }), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetProcessDiagnostics]: (_input) => - observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { - "rpc.aggregate": "server", + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const nextPatch = { ...patch, ...(deviceHosts ? { deviceHosts } : {}) }; + const settings = yield* providerInstanceMutation === undefined + ? serverSettings.updateSettings(nextPatch) + : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); + return ServerSettings.redactServerSettingsForClient(settings); }), - [WS_METHODS.serverGetHostResources]: (_input) => - observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { - "rpc.aggregate": "server", + [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, + [WS_METHODS.serverGetTraceDiagnostics]: (_input) => + TraceDiagnostics.readTraceDiagnostics({ + traceFilePath: config.serverTracePath, + maxFiles: config.traceMaxFiles, }), + [WS_METHODS.serverGetProcessDiagnostics]: (_input) => processDiagnostics.read, + [WS_METHODS.serverGetHostResources]: (_input) => hostResources.read, [WS_METHODS.serverGetProcessResourceHistory]: (input) => - observeRpcEffect( - WS_METHODS.serverGetProcessResourceHistory, - processResourceMonitor.readHistory(input), - { - "rpc.aggregate": "server", - }, - ), + processResourceMonitor.readHistory(input), [WS_METHODS.serverGetResourceTelemetryHistory]: (input) => - observeRpcEffect( - WS_METHODS.serverGetResourceTelemetryHistory, - resourceTelemetry.readHistory(input), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetUsageSummary]: (input) => - observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRefreshUsageRates]: (_input) => - observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRetryResourceTelemetry]: (_input) => - observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverSignalProcess]: (input) => - observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { - "rpc.aggregate": "server", - }), + resourceTelemetry.readHistory(input), + [WS_METHODS.serverGetUsageSummary]: (input) => usage.readSummary(input), + [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates, + [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry, + [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input), [WS_METHODS.serverReportClientActivity]: (input, metadata) => Ref.update(rpcClientIds, (clientIds) => { const next = new Set(clientIds); @@ -2660,583 +2401,303 @@ const layerWsRpc = ( return next; }).pipe( Effect.andThen( - observeRpcEffect( - WS_METHODS.serverReportClientActivity, - backgroundPolicy.reportClientActivity( - currentSessionId, - RpcClientId.make(metadata.client.id), - input, - ), - { "rpc.aggregate": "server" }, + backgroundPolicy.reportClientActivity( + currentSessionId, + RpcClientId.make(metadata.client.id), + input, ), ), ), [WS_METHODS.serverReportHostPowerState]: (input) => - observeRpcEffect( - WS_METHODS.serverReportHostPowerState, - backgroundPolicy.reportHostPowerState(input), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverGetBackgroundPolicy]: (_input) => - observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, { - "rpc.aggregate": "server", - }), - [WS_METHODS.cloudGetRelayClientStatus]: (_input) => - observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { - "rpc.aggregate": "cloud", - }), + backgroundPolicy.reportHostPowerState(input), + [WS_METHODS.serverGetBackgroundPolicy]: (_input) => backgroundPolicy.snapshot, + [WS_METHODS.cloudGetRelayClientStatus]: (_input) => relayClient.resolve, [WS_METHODS.cloudInstallRelayClient]: (_input) => - observeRpcStream( - WS_METHODS.cloudInstallRelayClient, - Stream.callback( - (queue) => - relayClient - .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) - .pipe( - Effect.flatMap((status) => - Queue.offer(queue, { - type: "complete", - status, + Stream.callback((queue) => + relayClient + .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) + .pipe( + Effect.flatMap((status) => + Queue.offer(queue, { + type: "complete", + status, + }), + ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: error.reason, + message: error.message, }), ), - Effect.catchTags({ - RelayClientInstallError: (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), - }), - Effect.andThen(Queue.end(queue)), - Effect.forkScoped, - ), - ), - { "rpc.aggregate": "cloud" }, - ), - [WS_METHODS.pullRequestsList]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { - "rpc.aggregate": "pull-requests", - }), - [WS_METHODS.pullRequestsListStats]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { - "rpc.aggregate": "pull-requests", - }), - [WS_METHODS.pullRequestsRoutingIdentity]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRoutingIdentity, - pullRequests.routingIdentity(input), - { - "rpc.aggregate": "pull-requests", - }, + }), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), ), - [WS_METHODS.pullRequestsRouting]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsRouting, pullRequests.routing(input), { - "rpc.aggregate": "pull-requests", - }), + [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), + [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), + [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), + [WS_METHODS.pullRequestsRouting]: (input) => pullRequests.routing(input), [WS_METHODS.pullRequestsSummary]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSummary, - withPullRequestViewer(input, pullRequests.summary(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.summary(input)), [WS_METHODS.pullRequestsStack]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsStack, - withPullRequestViewer(input, pullRequests.stack(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.stack(input)), [WS_METHODS.pullRequestsLinkedThreads]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsLinkedThreads, - resolvePullRequestSyncKey(input).pipe( - Effect.flatMap((key) => - key === null - ? Effect.succeed({ threads: [] }) - : listLinkedPullRequestThreads(key).pipe( - Effect.provideService(SqlClient.SqlClient, sql), - ), - ), + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null + ? Effect.succeed({ threads: [] }) + : listLinkedPullRequestThreads(key).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ), ), - { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsDetail]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsDetail, - withPullRequestViewer(input, pullRequests.detail(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.detail(input)), [WS_METHODS.pullRequestsPreview]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsPreview, - withPullRequestViewer(input, pullRequests.preview(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.preview(input)), [WS_METHODS.pullRequestsChecks]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsChecks, - withPullRequestViewer(input, pullRequests.checks(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.checks(input)), [WS_METHODS.pullRequestsActivity]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsActivity, - withPullRequestViewer(input, pullRequests.activity(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.activity(input)), [WS_METHODS.pullRequestsThreadComments]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsThreadComments, - withPullRequestViewer(input, pullRequests.threadComments(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.threadComments(input)), [WS_METHODS.pullRequestsDiffFileContents]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsDiffFileContents, - withPullRequestViewer(input, pullRequests.diffFileContents(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.diffFileContents(input)), [WS_METHODS.pullRequestsFilesViewed]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsFilesViewed, - withPullRequestViewer(input, pullRequests.filesViewed(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.filesViewed(input)), [WS_METHODS.pullRequestsSetFilesViewed]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetFilesViewed, - withPullRequestViewer(input, pullRequests.setFilesViewed(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.setFilesViewed(input)), [WS_METHODS.pullRequestsRunAction]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRunAction, - withPullRequestViewer(input, pullRequests.runAction(input)).pipe( - Effect.tap(() => - resolvePullRequestSyncKey(input).pipe( - Effect.flatMap((key) => - key === null ? Effect.void : pullRequestSync.requestSync(key), - ), + withPullRequestViewer(input, pullRequests.runAction(input)).pipe( + Effect.tap(() => + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), ), ), ), - { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsUpdate]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsUpdate, - withPullRequestViewer(input, pullRequests.update(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.update(input)), [WS_METHODS.pullRequestsComment]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsComment, - withPullRequestViewer(input, pullRequests.comment(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.comment(input)), [WS_METHODS.pullRequestsUpdateComment]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsUpdateComment, - withPullRequestViewer(input, pullRequests.updateComment(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.updateComment(input)), [WS_METHODS.pullRequestsSubmitReview]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSubmitReview, - withPullRequestViewer(input, pullRequests.submitReview(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.submitReview(input)), [WS_METHODS.pullRequestsReplyToThread]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsReplyToThread, - withPullRequestViewer(input, pullRequests.replyToThread(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.replyToThread(input)), [WS_METHODS.pullRequestsSetThreadResolution]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetThreadResolution, - withPullRequestViewer(input, pullRequests.setThreadResolution(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetReaction, - withPullRequestViewer(input, pullRequests.setReaction(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.setReaction(input)), [WS_METHODS.pullRequestsInvalidate]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsInvalidate, - pullRequests.invalidate(input, { notifyReaders: true }).pipe( - // A reader asking for fresh host state also wants the thread badges it feeds to - // catch up, including a merged link the sweep would otherwise never revisit. - Effect.andThen( - input.reference === undefined || input.filesViewedOnly === true - ? Effect.void - : resolvePullRequestSyncKey(input.reference).pipe( - Effect.flatMap((key) => - key === null ? Effect.void : pullRequestSync.requestSync(key), - ), + pullRequests.invalidate(input, { notifyReaders: true }).pipe( + // A reader asking for fresh host state also wants the thread badges it feeds to + // catch up, including a merged link the sweep would otherwise never revisit. + Effect.andThen( + input.reference === undefined || input.filesViewedOnly === true + ? Effect.void + : resolvePullRequestSyncKey(input.reference).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), ), - ), + ), ), - { "rpc.aggregate": "pull-requests" }, - ), - [WS_METHODS.pullRequestsSubscribeRefreshes]: () => - observeRpcStream( - WS_METHODS.pullRequestsSubscribeRefreshes, - pullRequests.subscribeRefreshes, - { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => pullRequests.subscribeRefreshes, [WS_METHODS.pullRequestsReviewerCandidates]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsReviewerCandidates, - withPullRequestViewer(input, pullRequests.reviewerCandidates(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.reviewerCandidates(input)), [WS_METHODS.pullRequestsRequestReviewers]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRequestReviewers, - withPullRequestViewer(input, pullRequests.requestReviewers(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.requestReviewers(input)), [WS_METHODS.pullRequestsLabelCandidates]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsLabelCandidates, - withPullRequestViewer(input, pullRequests.labelCandidates(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetLabels, - withPullRequestViewer(input, pullRequests.setLabels(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.setLabels(input)), [WS_METHODS.sourceControlLookupRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlLookupRepository, - sourceControlRepositories.lookupRepository(input), - { - "rpc.aggregate": "source-control", - }, - ), + sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlCloneRepository, - sourceControlRepositories.cloneRepository(input), - { - "rpc.aggregate": "source-control", - }, - ), + sourceControlRepositories.cloneRepository(input), [WS_METHODS.projectCloneStart]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneStart, - projectCloneTracker.start(input, { - createProject: (project) => - projectService - .create({ - commandId: CommandId.make(`project-clone-create:${project.projectId}`), - projectId: project.projectId, - title: project.title, - workspaceRoot: project.workspaceRoot, - createWorkspaceRootIfMissing: true, - }) - .pipe( - Effect.asVoid, - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ - message: "Failed to create clone project.", - cause, - }), - ), - ), - onCloned: (project) => - repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( - Effect.andThen( - projectService.update({ - commandId: CommandId.make(`project-clone-done:${project.projectId}`), - projectId: project.projectId, - }), + projectCloneTracker.start(input, { + createProject: (project) => + projectService + .create({ + commandId: CommandId.make(`project-clone-create:${project.projectId}`), + projectId: project.projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, + }) + .pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create clone project.", + cause, + }), ), - Effect.andThen(refreshGitStatus(project.workspaceRoot)), - Effect.ignoreCause({ log: true }), ), - }), - { "rpc.aggregate": "source-control" }, - ), - [WS_METHODS.projectsEnsureScratch]: () => - observeRpcEffect( - WS_METHODS.projectsEnsureScratch, - managedFolders.ensureScratchProject.pipe( - Effect.mapError( - (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), + onCloned: (project) => + repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( + Effect.andThen( + projectService.update({ + commandId: CommandId.make(`project-clone-done:${project.projectId}`), + projectId: project.projectId, + }), + ), + Effect.andThen(refreshGitStatus(project.workspaceRoot)), + Effect.ignoreCause({ log: true }), ), + }), + [WS_METHODS.projectsEnsureScratch]: () => + managedFolders.ensureScratchProject.pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), - { "rpc.aggregate": "orchestration" }, ), [WS_METHODS.projectsCreateNew]: (input) => - observeRpcEffect( - WS_METHODS.projectsCreateNew, - managedFolders - .createNamedProject(input) - .pipe( - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ message: cause.message, cause }), - ), + managedFolders + .createNamedProject(input) + .pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), - { "rpc.aggregate": "orchestration" }, - ), + ), [WS_METHODS.projectCloneCancel]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneCancel, - projectCloneTracker - .cancel(input.projectId) - .pipe(Effect.map((applied) => ({ applied }))), - { "rpc.aggregate": "source-control" }, - ), + projectCloneTracker.cancel(input.projectId).pipe(Effect.map((applied) => ({ applied }))), [WS_METHODS.projectCloneRetry]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneRetry, - projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), - { "rpc.aggregate": "source-control" }, - ), - [WS_METHODS.subscribeProjectClones]: () => - observeRpcStream(WS_METHODS.subscribeProjectClones, projectCloneTracker.stream, { - "rpc.aggregate": "source-control", - }), + projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), + [WS_METHODS.subscribeProjectClones]: () => projectCloneTracker.stream, [WS_METHODS.sourceControlPublishRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories.publishRepository(input).pipe( - // A new remote can change the cached identity. Only the `cwd` entry - // refreshes, so after a publish from a linked worktree the project - // root entry waits for its TTL. - Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { - "rpc.aggregate": "source-control", - }, + sourceControlRepositories.publishRepository(input).pipe( + // A new remote can change the cached identity. Only the `cwd` entry + // refreshes, so after a publish from a linked worktree the project + // root entry waits for its TTL. + Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.projectsSearchEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsSearchContents]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchContents, - workspaceEntries.searchContents(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchContentsError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsListEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsListEntries, - workspaceEntries.list(input).pipe( - Effect.mapError( - (cause) => - new ProjectListEntriesError({ - ...input, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + ...input, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsReadFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsReadFile, - workspaceFileSystem.readFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectReadFileError({ - ...input, - ...projectFileFailureContext(cause), - cause, - }), - ), + workspaceFileSystem.readFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsWriteFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - cwd: input.cwd, - relativePath: input.relativePath, - ...projectFileFailureContext(cause), - cause, - }), - ), + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsMutate]: (mutation) => - observeRpcEffect( - WS_METHODS.projectsMutate, - startup.enqueueCommand(mutateProject(mutation)).pipe( - Effect.mapError( - (cause) => - new ProjectMutationError({ - commandId: mutation.commandId, - message: - cause._tag === "ProjectNotEmptyError" - ? cause.message - : "Failed to mutate project.", - cause, - }), - ), + startup.enqueueCommand(mutateProject(mutation)).pipe( + Effect.mapError( + (cause) => + new ProjectMutationError({ + commandId: mutation.commandId, + message: + cause._tag === "ProjectNotEmptyError" + ? cause.message + : "Failed to mutate project.", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { - "rpc.aggregate": "workspace", - }), + [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), [WS_METHODS.filesystemBrowse]: (input) => - observeRpcEffect( - WS_METHODS.filesystemBrowse, - workspaceEntries.browse(input).pipe( - Effect.mapError( - (cause) => - new FilesystemBrowseError({ - ...input, - ...filesystemBrowseFailureContext(cause), - cause, - }), - ), + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.attachmentsCreateUploadUrl]: (input) => - observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { - "rpc.aggregate": "workspace", - }), - [WS_METHODS.attachmentsDelete]: (input) => - observeRpcEffect( - WS_METHODS.attachmentsDelete, - deletePendingAttachment(input.attachmentId), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.agentSessionsScan]: () => - observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { - "rpc.aggregate": "workspace", - }), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => issueAttachmentUploadUrl(input), + [WS_METHODS.attachmentsDelete]: (input) => deletePendingAttachment(input.attachmentId), + [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => - observeRpcEffect( - WS_METHODS.agentSessionsImport, - agentSessionImporter.importRecentAgentThreads(input), - { "rpc.aggregate": "workspace" }, - ), + agentSessionImporter.importRecentAgentThreads(input), [WS_METHODS.assetsCreateUrl]: (input) => - observeRpcEffect( - WS_METHODS.assetsCreateUrl, - Effect.gen(function* () { - const path = yield* Path.Path; - // An absolute media path can be linked from a thread on another environment. - if ( - input.resource._tag === "attachment" || - input.resource._tag === "native-app-icon" || - input.resource._tag === "tool-output-image" || - // GitHub media names the repository it authenticates through itself. - input.resource._tag === "github-media" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) - ) { - return yield* issueAssetUrl({ resource: input.resource }); - } - if (input.resource._tag === "draft-workspace-file") { - // A project draft names its workspace directly; there is no - // thread to resolve one from. - return yield* issueAssetUrl({ - resource: input.resource, - workspaceRoot: input.resource.cwd, - }); - } - if (input.resource._tag === "project-favicon") { - const project = yield* projectStore - .findActiveByWorkspaceRoot(input.resource.cwd) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - // A cloned project exists before its files do. Clients ask again - // when the clone lands (see createProjectFaviconUrlAtomFamily). - const clone = yield* projectCloneTracker.get(project.value.projectId); - return yield* issueAssetUrl({ - resource: input.resource, - ...(project.value.faviconPath - ? { projectFaviconPath: project.value.faviconPath } - : {}), - projectCheckoutPending: - clone !== null && - clone.phase !== "done" && - clone.destinationPath === project.value.workspaceRoot, - }); - } - const thread = yield* threadManagement - .getThreadRecords(input.resource.threadId, []) + Effect.gen(function* () { + const path = yield* Path.Path; + // An absolute media path can be linked from a thread on another environment. + if ( + input.resource._tag === "attachment" || + input.resource._tag === "native-app-icon" || + input.resource._tag === "tool-output-image" || + // GitHub media names the repository it authenticates through itself. + input.resource._tag === "github-media" || + (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + ) { + return yield* issueAssetUrl({ resource: input.resource }); + } + if (input.resource._tag === "draft-workspace-file") { + // A project draft names its workspace directly; there is no + // thread to resolve one from. + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: input.resource.cwd, + }); + } + if (input.resource._tag === "project-favicon") { + const project = yield* projectStore + .findActiveByWorkspaceRoot(input.resource.cwd) .pipe( Effect.mapError( (cause) => @@ -3246,7 +2707,28 @@ const layerWsRpc = ( }), ), ); - const project = yield* projectService.getById(thread.thread.projectId).pipe( + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + // A cloned project exists before its files do. Clients ask again + // when the clone lands (see createProjectFaviconUrlAtomFamily). + const clone = yield* projectCloneTracker.get(project.value.projectId); + return yield* issueAssetUrl({ + resource: input.resource, + ...(project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), + projectCheckoutPending: + clone !== null && + clone.phase !== "done" && + clone.destinationPath === project.value.workspaceRoot, + }); + } + const thread = yield* threadManagement + .getThreadRecords(input.resource.threadId, []) + .pipe( Effect.mapError( (cause) => new AssetWorkspaceContextResolutionError({ @@ -3255,357 +2737,187 @@ const layerWsRpc = ( }), ), ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - return yield* issueAssetUrl({ + const project = yield* projectService.getById(thread.thread.projectId).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - workspaceRoot: thread.thread.worktreePath ?? project.value.workspaceRoot, }); - }), - { "rpc.aggregate": "workspace" }, - ), + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.thread.worktreePath ?? project.value.workspaceRoot, + }); + }), [WS_METHODS.assetsPersistChatAttachments]: (input) => - observeRpcEffect( - WS_METHODS.assetsPersistChatAttachments, - persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), - { "rpc.aggregate": "orchestration" }, - ), + persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), [WS_METHODS.subscribeVcsStatus]: (input) => - observeRpcStream( - WS_METHODS.subscribeVcsStatus, - vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, - }), - { - "rpc.aggregate": "vcs", - }, - ), - [WS_METHODS.subscribeWorktreeSetup]: (input) => - observeRpcStream( - WS_METHODS.subscribeWorktreeSetup, - worktreeSetupTracker.stream(input.threadId), - { "rpc.aggregate": "vcs" }, - ), + vcsStatusBroadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), + [WS_METHODS.subscribeWorktreeSetup]: (input) => worktreeSetupTracker.stream(input.threadId), [WS_METHODS.worktreeSetupCancel]: (input) => - observeRpcEffect( - WS_METHODS.worktreeSetupCancel, - worktreeSetupTracker - .cancel(input.threadId) - .pipe(Effect.map((cancelled) => ({ cancelled }))), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.vcsRefreshStatus]: (input) => - observeRpcEffect( - WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), - { - "rpc.aggregate": "vcs", - }, - ), + worktreeSetupTracker + .cancel(input.threadId) + .pipe(Effect.map((cancelled) => ({ cancelled }))), + [WS_METHODS.vcsRefreshStatus]: (input) => vcsStatusBroadcaster.refreshStatus(input.cwd), [WS_METHODS.vcsPull]: (input) => - observeRpcEffect( - WS_METHODS.vcsPull, - gitWorkflow.pullCurrentBranch(input.cwd).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Effect.failCause(cause), - onSuccess: (result) => - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), - }), - ), - { "rpc.aggregate": "git" }, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), ), [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: (result) => - (input.threadId === undefined - ? Effect.void - : linkCreatedPullRequest({ - threadId: input.threadId, - result, - commandId: serverCommandId("pr-created-link"), - }).pipe( - Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), - Effect.provideService(ProjectService.ProjectService, projectService), - ) - ).pipe( - Effect.andThen( - refreshPushedPullRequests(input, result).pipe( - Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), - Effect.provideService(ProjectStore.ProjectStoreV2, projectStore), - Effect.provideService( - PullRequestService.PullRequestService, - pullRequests, - ), + Stream.callback((queue) => + gitWorkflow + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: (result) => + (input.threadId === undefined + ? Effect.void + : linkCreatedPullRequest({ + threadId: input.threadId, + result, + commandId: serverCommandId("pr-created-link"), + }).pipe( + Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), + Effect.provideService(ProjectService.ProjectService, projectService), + ) + ).pipe( + Effect.andThen( + refreshPushedPullRequests(input, result).pipe( + Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), + Effect.provideService(ProjectStore.ProjectStoreV2, projectStore), + Effect.provideService( + PullRequestService.PullRequestService, + pullRequests, ), ), - Effect.andThen(refreshGitStatus(input.cwd)), - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), - }), - ), - ), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect( - WS_METHODS.gitResolvePullRequest, - gitWorkflow.resolvePullRequest(input), - { - "rpc.aggregate": "git", - }, + Effect.andThen(refreshGitStatus(input.cwd)), + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), ), + [WS_METHODS.gitResolvePullRequest]: (input) => gitWorkflow.resolvePullRequest(input), [WS_METHODS.gitPreparePullRequestThread]: (input) => - observeRpcEffect( - WS_METHODS.gitPreparePullRequestThread, - gitWorkflow - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.vcsListRefs]: (input) => - observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { - "rpc.aggregate": "vcs", - }), + gitWorkflow + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.vcsListRefs]: (input) => gitWorkflow.listRefs(input), [WS_METHODS.vcsCreateWorktree]: (input) => - observeRpcEffect( - WS_METHODS.vcsCreateWorktree, - gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - observeRpcEffect( - WS_METHODS.vcsRemoveWorktree, - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsCreateRef]: (input) => - observeRpcEffect( - WS_METHODS.vcsCreateRef, - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => - observeRpcEffect( - WS_METHODS.vcsSwitchRef, - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => - observeRpcEffect( - WS_METHODS.vcsInit, - vcsProvisioning - .initRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.reviewGetDiffPreview]: (input) => - observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { - "rpc.aggregate": "review", - }), - [WS_METHODS.reviewGetDiffFileContents]: (input) => - observeRpcEffect( - WS_METHODS.reviewGetDiffFileContents, - review.getDiffFileContents(input), - { "rpc.aggregate": "review" }, - ), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), + vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), + [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), + [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input) => - observeRpcStream( - WS_METHODS.terminalAttach, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), + [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), + [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), + [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), + [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), + [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), [WS_METHODS.subscribeTerminalMetadata]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalMetadata, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.previewOpen]: (input) => - observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewNavigate]: (input) => - observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewResize]: (input) => - observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewAdjust]: (input) => - observeRpcEffect(WS_METHODS.previewAdjust, previewManager.adjust(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewRefresh]: (input) => - observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewClose]: (input) => - observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewList]: (input) => - observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewClearProfile]: (input) => - observeRpcEffect( - WS_METHODS.previewClearProfile, - serverBrowser.clearProfile(input.profileId), - { "rpc.aggregate": "preview" }, - ), - [WS_METHODS.previewReportStatus]: (input) => - observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.subscribePreviewEvents]: (_input) => - observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { - "rpc.aggregate": "preview", - }), - [WS_METHODS.deviceConfigure]: (input) => - observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceTestHost]: (input) => - observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { - "rpc.aggregate": "device", - }), + [WS_METHODS.previewOpen]: (input) => previewManager.open(input), + [WS_METHODS.previewNavigate]: (input) => previewManager.navigate(input), + [WS_METHODS.previewResize]: (input) => previewManager.resize(input), + [WS_METHODS.previewAdjust]: (input) => previewManager.adjust(input), + [WS_METHODS.previewRefresh]: (input) => previewManager.refresh(input), + [WS_METHODS.previewClose]: (input) => previewManager.close(input), + [WS_METHODS.previewList]: (input) => previewManager.list(input), + [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), + [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input), + [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, + [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), + [WS_METHODS.deviceTestHost]: (input) => deviceService.testHost(input), [WS_METHODS.deviceList]: (input) => - observeRpcEffect( - WS_METHODS.deviceList, - input.inspectOnly && !input.updateTool - ? deviceService.inspect - : authorizeEffect( - requiredScopeForDeviceList(input), - input.updateTool - ? deviceService.updateTool(input.updateTool) - : input.retryHostId - ? deviceService.retryHost(input.retryHostId) - : deviceService.list, - ), - { - "rpc.aggregate": "device", - }, - ), - [WS_METHODS.deviceOpen]: (input) => - observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceClose]: (input) => - observeRpcEffect(WS_METHODS.deviceClose, deviceService.close(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceShutdown]: (input) => - observeRpcEffect(WS_METHODS.deviceShutdown, deviceService.shutdown(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceDetail]: (input) => - observeRpcEffect(WS_METHODS.deviceDetail, deviceService.detail(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceAction]: (input) => - observeRpcEffect(WS_METHODS.deviceAction, deviceService.action(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.subscribeDeviceState]: (_input) => - observeRpcStream( - WS_METHODS.subscribeDeviceState, - DeviceService.stateStream(deviceService), - { "rpc.aggregate": "device" }, - ), + input.inspectOnly && !input.updateTool + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), + [WS_METHODS.deviceOpen]: (input) => deviceService.open(input), + [WS_METHODS.deviceClose]: (input) => deviceService.close(input), + [WS_METHODS.deviceShutdown]: (input) => deviceService.shutdown(input), + [WS_METHODS.deviceDetail]: (input) => deviceService.detail(input), + [WS_METHODS.deviceAction]: (input) => deviceService.action(input), + [WS_METHODS.subscribeDeviceState]: (_input) => DeviceService.stateStream(deviceService), [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => - observeRpcStream( - WS_METHODS.subscribeDiscoveredLocalServers, - Stream.callback((queue) => - Effect.gen(function* () { - const configuredUrls = input.configuredUrls ?? []; - yield* portDiscovery.retain; - const initial = yield* portDiscovery.scan(configuredUrls); - const initialScannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers: initial, - scannedAt: initialScannedAt, - configuredUrlProbing: true, - }); - yield* portDiscovery.subscribe( - { configuredUrls, initialSnapshot: initial }, - (servers) => - Effect.gen(function* () { - const scannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers, - scannedAt, - configuredUrlProbing: true, - }); - }), - ); - }), - ), - { "rpc.aggregate": "preview" }, + Stream.callback((queue) => + Effect.gen(function* () { + const configuredUrls = input.configuredUrls ?? []; + yield* portDiscovery.retain; + const initial = yield* portDiscovery.scan(configuredUrls); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers: initial, + scannedAt: initialScannedAt, + configuredUrlProbing: true, + }); + yield* portDiscovery.subscribe( + { configuredUrls, initialSnapshot: initial }, + (servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers, + scannedAt, + configuredUrlProbing: true, + }); + }), + ); + }), ), [WS_METHODS.subscribeServerConfig]: (input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerConfig, + Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; const config = yield* loadServerConfig({ usageLimitsCommand }); @@ -3705,11 +3017,9 @@ const layerWsRpc = ( withLateEditorConfig(config, liveUpdates, externalLauncher), ); }), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeServerLifecycle]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerLifecycle, + Stream.unwrap( Effect.gen(function* () { const liveBuffer = yield* Queue.unbounded(); yield* Effect.forkScoped( @@ -3727,11 +3037,9 @@ const layerWsRpc = ( ); return Stream.concat(rpcInitialItems(snapshotEvents), liveEvents); }), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeAuthAccess]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeAuthAccess, + Stream.unwrap( Effect.gen(function* () { const initialSnapshot = yield* loadAuthAccessSnapshot(); const revisionRef = yield* Ref.make(1); @@ -3761,27 +3069,18 @@ const layerWsRpc = ( liveEvents, ); }), - { "rpc.aggregate": "auth" }, ), [WS_METHODS.subscribeBackgroundPolicy]: (_input) => - observeRpcStream( - WS_METHODS.subscribeBackgroundPolicy, - Stream.unwrap( - Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), - ), + Stream.unwrap( + Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), ), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeResourceTelemetry]: (_input) => - observeRpcStream( - WS_METHODS.subscribeResourceTelemetry, - Stream.unwrap( - Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), - ), + Stream.unwrap( + Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), ), - { "rpc.aggregate": "server" }, ), }); return handlers; @@ -3841,7 +3140,9 @@ export const layer = Layer.unwrap( const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), - Effect.provide(RpcAuthorization.layer(session.scopes)), + Effect.provide( + Layer.merge(RpcAuthorization.layer(session.scopes), rpcInstrumentationLayer), + ), Effect.forkScoped, ); // @effect-diagnostics-next-line returnEffectInGen:off diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index 49a7d2424cac7..4da6357c0a91d 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -24,15 +24,17 @@ message) are fine next to the service; the capability itself is the method. ```ts // ws.ts: a thin handler [WS_METHODS.projectsCreateNew]: (input) => - observeRpcEffect( - WS_METHODS.projectsCreateNew, - projectFolders.createNamedProject(input).pipe( - Effect.mapError((cause) => new ProjectCreateNewError({ cause })), - ), - { "rpc.aggregate": "orchestration" }, - ), + projectFolders + .createNamedProject(input) + .pipe(Effect.mapError((cause) => new ProjectCreateNewError({ cause }))), ``` +Handlers don't add their own spans or request metrics. Group middleware authorizes every call +([`RpcAuthorization.ts`](../../apps/server/src/auth/RpcAuthorization.ts)), and the server's group +also instruments it +([`RpcInstrumentation.ts`](../../apps/server/src/observability/RpcInstrumentation.ts)). A handler +with per-call context, such as a thread id, adds it with `Effect.annotateCurrentSpan`. + ## Shape of a service module One module per service, in this order: imports, errors and schemas, the `Context.Service` tag with diff --git a/docs/operations/observability.md b/docs/operations/observability.md index c97a187eb08ae..c199f6aba7319 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -635,8 +635,8 @@ wins for its signal. `otlp` is the default, and any other exporter name, such as Current high-value span and metric boundaries include: -- Effect RPC websocket request spans from `effect/rpc` -- RPC request metrics in `apps/server/src/observability/RpcInstrumentation.ts` +- WebSocket RPC request spans (`ws.rpc.`) and metrics in + `apps/server/src/observability/RpcInstrumentation.ts` - startup phases - orchestration command processing - provider session and turn operations From f8ed2a039b7deaa324beda29b44cc74c846311f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:06:57 -0700 Subject: [PATCH 013/680] chore(deps): upgrade @pierre/diffs to 1.5.2 and @pierre/trees to beta.6 (#16644) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 2 +- .../mobile/src/features/review/reviewModel.ts | 5 +- apps/web/package.json | 2 +- .../components/diffs/AnnotatableCodeView.tsx | 2 +- .../diffs/StyledDiffCodeView.test.tsx | 7 +- .../components/diffs/StyledDiffCodeView.tsx | 8 +- .../src/components/files/FilePreviewPanel.tsx | 286 +++++++++++------ .../files/ReadOnlySourcePreview.tsx | 2 +- .../files/fileContentRevision.test.ts | 25 +- .../components/files/fileContentRevision.ts | 18 -- .../files/fileEditorHighlight.test.ts | 299 ------------------ .../files/fileEditorLanguageReadiness.test.ts | 199 ------------ .../files/fileEditorVirtualization.test.ts | 56 ++-- .../files/projectFilesQueryState.ts | 12 + .../pullRequest/PullRequestCodeTab.tsx | 4 +- apps/web/src/lib/diffRendering.test.ts | 4 + apps/web/src/lib/diffRendering.ts | 15 +- apps/web/vite.config.ts | 2 +- patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 238 -------------- patches/@pierre%2Fdiffs@1.5.2.patch | 146 +++++++++ pnpm-lock.yaml | 118 +++++-- pnpm-workspace.yaml | 6 +- 22 files changed, 505 insertions(+), 951 deletions(-) delete mode 100644 apps/web/src/components/files/fileEditorHighlight.test.ts delete mode 100644 apps/web/src/components/files/fileEditorLanguageReadiness.test.ts delete mode 100644 patches/@pierre%2Fdiffs@1.3.0-beta.10.patch create mode 100644 patches/@pierre%2Fdiffs@1.5.2.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 602ffb66bacea..53b5476d3a2da 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -131,7 +131,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@pierre/trees": "1.0.0-beta.4", + "@pierre/trees": "1.0.0-beta.6", "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 86ded9a6264e8..d2e6c12546179 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -2,7 +2,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -374,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray total + hunk.additionLines, 0); const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; diff --git a/apps/web/package.json b/apps/web/package.json index 53c53c1c7cf9e..d17795f15c5d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", - "@pierre/trees": "1.0.0-beta.4", + "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 52b538f92b0fc..ab4f545ed07b4 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -35,7 +35,7 @@ interface DiffCommentAnnotationGroup { } type DiffCommentLineAnnotation = DiffLineAnnotation; -export type AnnotatableCodeViewHandle = CodeViewHandle; +export type AnnotatableCodeViewHandle = CodeViewHandle; const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; function annotationSide(range: SelectedLineRange): AnnotationSide { diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index ee249888a415e..3f01a62e549ff 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -98,7 +98,7 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), - CodeView: (props: CodeViewProps) => { + CodeView: (props: CodeViewProps) => { return props.items?.map((item) => item.type === "file" ? : null, ); @@ -115,6 +115,7 @@ function FileOutput({ file }: { file: FileContents }) { testState.renderPools.push(pool); const renderer = new FileRenderer( { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" }, + undefined, render, pool, ); @@ -301,8 +302,10 @@ describe("code-view worker lifecycle", () => { expect(pool.getStats().totalWorkers).toBe(2); expect(testState.terminations).toHaveLength(0); await act(async () => renderer!.update(renderViews(0))); + // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down. + const timedOut = expect(pending).rejects.toThrow("worker initialization timed out"); await vi.advanceTimersByTimeAsync(30_000); - await pending; + await timedOut; await Promise.all(testState.terminations); await act(async () => { for (const deliver of testState.heldResponses) deliver(); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index cdd9fe2722253..e18d9ca4ba82e 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -261,16 +261,16 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} `; export type StyledDiffCodeViewOptions = Omit< - NonNullable["options"]>, + NonNullable["options"]>, "unsafeCSS" | "itemMetrics" | "layout" >; type StyledDiffCodeViewProps = ( - | Omit, "options"> - | Omit, "options"> + | Omit, "options"> + | Omit, "options"> ) & { readonly options?: StyledDiffCodeViewOptions; - readonly viewerRef?: Ref>; + readonly viewerRef?: Ref>; /** * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has * to restyle chrome the viewer owns — such as replacing its per-file line counts. diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5ae0753429fd1..5ce7a4cc2b2a9 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -11,9 +11,23 @@ import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; -import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; -import { Editor } from "@pierre/diffs/editor"; -import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import { + DEFAULT_TOKENIZE_MAX_LENGTH, + VirtualizedFile, + getFiletypeFromFileName, + type File as FileInstance, + type FileContents, + type PostRenderPhase, + type SelectedLineRange, +} from "@pierre/diffs"; +import { + Editor, + type EditorChangeEvent, + type EditorFactory, + type EditorOptions, +} from "@pierre/diffs/edit"; +import type { WorkerPoolManager } from "@pierre/diffs/worker"; +import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { isAtomCommandInterrupted, @@ -80,7 +94,7 @@ import { import SourceFilePreview from "./ReadOnlySourcePreview"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; -import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey } from "./fileContentRevision"; import { filePreviewReadErrorMessage, isMarkdownPreviewFile, @@ -91,6 +105,7 @@ import { import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, + getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -117,7 +132,12 @@ const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; const RENDER_TABLE_STORAGE_KEY = "t3code.renderTable"; -type FilePostRender = NonNullable["onPostRender"]>; +// Shared by the read-only and annotated surfaces, so it is generic over annotation metadata. +type FilePostRender = ( + fileContainer: HTMLElement, + instance: FileInstance, + phase: PostRenderPhase, +) => void; function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; @@ -547,6 +567,67 @@ function useFileLineReveal( ); } +const createFileEditor: EditorFactory = ( + editorType, + options, + editStateKey, +) => new Editor(editorType, options, editStateKey); + +function editableFileContents( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, + contents: string, +): FileContents { + return { + name: relativePath, + contents, + cacheKey: `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`, + }; +} + +function needsWorkerHighlight(workerPool: WorkerPoolManager | undefined, file: FileContents) { + if (workerPool?.isWorkingPool() !== true) return false; + if ((file.lang ?? getFiletypeFromFileName(file.name)) === "text") return false; + let lines = 1; + for ( + let index = file.contents.indexOf("\n"); + index !== -1; + index = file.contents.indexOf("\n", index + 1) + ) { + lines += 1; + } + return lines <= DEFAULT_TOKENIZE_MAX_LENGTH; +} + +/** + * Pierre highlights an active edit session on the main thread, so each version + * of the file becomes editable only once it has rendered the worker's + * highlight. A failed worker highlight falls back to main-thread highlighting. + */ +function useEditableAfterHighlight(file: FileContents) { + const workerPool = useWorkerPool(); + const [highlightedFile, setHighlightedFile] = useState(null); + const needsHighlight = useMemo(() => needsWorkerHighlight(workerPool, file), [file, workerPool]); + const ready = !needsHighlight || highlightedFile === file; + + useEffect(() => { + if (ready || workerPool === undefined) return; + workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file)); + }, [file, ready, workerPool]); + + const onPostRender = useCallback( + (renderedFile: FileContents | undefined, phase: PostRenderPhase) => { + if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return; + // The pool caches a result just before the instance renders it, so a + // render that sees the cache has painted highlighted rows. + if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file); + }, + [file, ready, workerPool], + ); + return { ready, onPostRender }; +} + interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; @@ -597,46 +678,65 @@ function EditableFileSurface({ relativePath, onPendingChange, }); - const editor = useMemo( - () => - new Editor({ - persistState: true, - persistStateStorage: "inMemory", - onChange: (file, nextLineAnnotations) => { - setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); - saveCoordinator.change(file.contents); - if (nextLineAnnotations) { - const remapped = remapFileCommentAnnotations( - nextLineAnnotations as FileCommentLineAnnotation[], - ); - setLineAnnotations(remapped); - for (const annotation of remapped) { - for (const entry of annotation.metadata.entries) { - if (entry.kind !== "comment") continue; - addReviewComment( - composerDraftTarget, - buildFileReviewComment({ - id: entry.id, - filePath: relativePath, - startLine: entry.startLine, - endLine: entry.endLine, - text: entry.text, - contents: file.contents, - }), - ); - } - } - } - }, - }), - [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], + // The editor owns the draft, so its own edits echoing back through the file + // query keep the file identity. Only a change from elsewhere replaces it. + const [externalFile, setExternalFile] = useState(() => + editableFileContents(environmentId, cwd, relativePath, contents), + ); + const [editedContents, setEditedContents] = useState(null); + if (contents !== (editedContents ?? externalFile.contents)) { + setExternalFile(editableFileContents(environmentId, cwd, relativePath, contents)); + setEditedContents(null); + } + const { ready: editable, onPostRender: onEditablePostRender } = + useEditableAfterHighlight(externalFile); + const editorRef = useRef | null>(null); + const editorOptions = useMemo>( + () => ({ + onAttach: (editor) => { + editorRef.current = editor; + }, + onComplete: () => { + editorRef.current = null; + }, + }), + [], ); - useEffect( - () => () => { - editor.cleanUp(); + // Mirrors the draft out to the save queue, the optimistic file query and the + // composer's review comments. + const handleEditChange = useCallback( + ({ + file, + lineAnnotations: nextLineAnnotations, + }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => { + // Adopting an external change reports it as an edit; it is already on disk. + if (file.contents === getProjectFileContents(environmentId, cwd, relativePath)) return; + setEditedContents(file.contents); + setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); + saveCoordinator.change(file.contents); + if (!nextLineAnnotations) return; + const remapped = remapFileCommentAnnotations(nextLineAnnotations); + // The editor hands back the array it was given until an edit moves an annotation. + setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped)); + for (const annotation of remapped) { + for (const entry of annotation.metadata.entries) { + if (entry.kind !== "comment") continue; + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text: entry.text, + contents: file.contents, + }), + ); + } + } }, - [editor], + [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], ); const removeAnnotationEntry = useCallback( @@ -695,47 +795,44 @@ function EditableFileSurface({ ], ); - const beginComment = useCallback( - (range: SelectedLineRange) => { - editor.setSelections([]); - editor.blur(); - const { startLine, endLine } = normalizeFileCommentRange(range); - const draftEntry: FileCommentAnnotationEntry = { - id: nextFileCommentId(), - kind: "draft", - startLine, - endLine, - text: "", - }; - setLineAnnotations((current) => { - const withoutDraft = current.flatMap((annotation) => { - const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); - return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; - }); - const existingIndex = withoutDraft.findIndex( - (annotation) => annotation.lineNumber === endLine, - ); - if (existingIndex < 0) { - return [ - ...withoutDraft, - { - lineNumber: endLine, - metadata: { entries: [draftEntry] }, - }, - ]; - } - return withoutDraft.map((annotation, index) => - index === existingIndex - ? { - ...annotation, - metadata: { entries: [...annotation.metadata.entries, draftEntry] }, - } - : annotation, - ); + const beginComment = useCallback((range: SelectedLineRange) => { + editorRef.current?.setSelections([]); + editorRef.current?.blur(); + const { startLine, endLine } = normalizeFileCommentRange(range); + const draftEntry: FileCommentAnnotationEntry = { + id: nextFileCommentId(), + kind: "draft", + startLine, + endLine, + text: "", + }; + setLineAnnotations((current) => { + const withoutDraft = current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; }); - }, - [editor], - ); + const existingIndex = withoutDraft.findIndex( + (annotation) => annotation.lineNumber === endLine, + ); + if (existingIndex < 0) { + return [ + ...withoutDraft, + { + lineNumber: endLine, + metadata: { entries: [draftEntry] }, + }, + ]; + } + return withoutDraft.map((annotation, index) => + index === existingIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, draftEntry] }, + } + : annotation, + ); + }); + }, []); const hasOpenCommentForm = lineAnnotations.some((annotation) => annotation.metadata.entries.some((entry) => entry.kind === "draft"), ); @@ -744,11 +841,11 @@ function EditableFileSurface({ if (!root) return; return installFileEditorDismissal({ root, - editor, + editor: { setSelections: (selections) => editorRef.current?.setSelections(selections) }, isBlocked: () => hasOpenCommentForm, onDismiss: () => setSelectedRange(null), }); - }, [editor, hasOpenCommentForm, setSelectedRange]); + }, [hasOpenCommentForm, setSelectedRange]); const handleLineSelectionEnd = useCallback( (range: SelectedLineRange | null) => { setSelectedRange(range); @@ -762,6 +859,7 @@ function EditableFileSurface({ const handlePostRender = useCallback( (fileContainer, instance, phase) => { onPostRender(fileContainer, instance, phase); + onEditablePostRender(instance.file, phase); if (selectionFrameRef.current !== null) { cancelAnimationFrame(selectionFrameRef.current); @@ -775,11 +873,11 @@ function EditableFileSurface({ instance.setSelectedLines(selectedRange, { notify: false }); }); }, - [onPostRender, selectedRange], + [onEditablePostRender, onPostRender, selectedRange], ); return ( - +
    - file={{ - name: relativePath, - contents, - cacheKey: projectFileEditorCacheKey( - environmentId, - cwd, - relativePath, - contents, - editor.getFile(), - ), - }} + file={externalFile} + edit={editable} + editorOptions={editorOptions} + onEditChange={handleEditChange} options={{ disableFileHeader: true, enableGutterUtility: !hasOpenCommentForm, @@ -832,7 +923,6 @@ function EditableFileSurface({
    )} className="min-h-full" - contentEditable />
    diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index 8a59982c6f953..a4438ebf72a05 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -17,7 +17,7 @@ export default function ReadOnlySourcePreview(props: { readonly name: string; readonly text: string; readonly cacheKey?: string; - readonly onPostRender?: FileOptions["onPostRender"]; + readonly onPostRender?: FileOptions["onPostRender"]; }) { const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index 4a8bb3d5522b5..e54f28e0d2355 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey } from "./fileContentRevision"; describe("file cache identity", () => { it("changes for same-length edits", () => { @@ -8,27 +8,4 @@ describe("file cache identity", () => { projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); - - it("keeps editor identity stable for locally edited contents", () => { - const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "after", undefined); - - expect( - projectFileEditorCacheKey("local", "/repo", "file.json", "after edit", { - cacheKey, - contents: "after edit", - }), - ).toBe(cacheKey); - }); - - it("rotates editor identity for external contents and environments", () => { - const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "before", undefined); - const editorFile = { cacheKey, contents: "before" }; - - expect( - projectFileEditorCacheKey("local", "/repo", "file.json", "external edit", editorFile), - ).not.toBe(cacheKey); - expect(projectFileEditorCacheKey("remote", "/repo", "file.json", "before", undefined)).not.toBe( - cacheKey, - ); - }); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index b4e1698a34dc8..f376f7fde9ecc 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -10,21 +10,3 @@ function fileContentRevision(contents: string): string { export function projectFileCacheKey(cwd: string, relativePath: string, contents: string): string { return `${cwd}:${relativePath}:${fileContentRevision(contents)}`; } - -interface EditorFileIdentity { - readonly cacheKey?: string; - readonly contents: string; -} - -export function projectFileEditorCacheKey( - environmentId: string, - cwd: string, - relativePath: string, - contents: string, - editorFile: EditorFileIdentity | undefined, -): string { - if (editorFile?.contents === contents && editorFile.cacheKey) { - return editorFile.cacheKey; - } - return `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`; -} diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts deleted file mode 100644 index 39624d953c096..0000000000000 --- a/apps/web/src/components/files/fileEditorHighlight.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { - FileRenderer, - getSharedHighlighter, - type BaseCodeOptions, - type DiffsHighlighter, - type FileContents, - type HighlightedToken, - type RenderRange, -} from "@pierre/diffs"; -import { TextDocument } from "@pierre/diffs/editor"; -import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; -import * as NodeWorkerThreads from "node:worker_threads"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -type DocumentChange = NonNullable["applyEdits"]>>; -interface Tokenizer { - readonly themeType: "light" | "dark"; - tokenize(change: DocumentChange, range: RenderRange): Map; - cleanUp(): void; -} - -// This dependency-internal tokenizer is the one used by Editor.#rerender. -const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); -const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { - EditorTokenizer: new (options: { - codeOptions: BaseCodeOptions; - highlighter: DiffsHighlighter; - textDocument: TextDocument; - setStyle: (style: string) => void; - onDeferTokenize: (lines: Map, theme: "light" | "dark") => void; - }) => Tokenizer; -}; - -const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); -const source = Array.from( - { length: 7_000 }, - (_, index) => - `export const section${index} =

    Long wrapped source line ${index} for the file editor.

    ;`, -).join("\n"); -const options = { - theme: "pierre-dark", - themeType: "dark", - preferredHighlighter: "shiki-wasm", - useTokenTransformer: true, - overflow: "wrap", - disableFileHeader: true, -} as const; -const range: RenderRange = { - startingLine: 6_950, - totalLines: 150, - bufferBefore: 0, - bufferAfter: 0, -}; - -interface HeldResponse { - data: WorkerResponse; - deliver: () => void; -} -let responses: HeldResponse[]; -let responseWaiters: ((response: HeldResponse) => void)[]; -let terminationPromises: Promise[]; -let pool: WorkerPoolManager; -let renderer: FileRenderer; -let tokenizer: Tokenizer; -let file: FileContents; -let document: TextDocument; -const animationFrames = new Set>(); - -function nextResponse(): Promise { - const response = responses.shift(); - return response - ? Promise.resolve(response) - : new Promise((resolve) => responseWaiters.push(resolve)); -} - -class WorkerTransport { - private readonly worker = new NodeWorkerThreads.Worker( - `const { parentPort, workerData } = require("node:worker_threads"); - globalThis.self = { - addEventListener(type, listener) { - if (type === "message") parentPort.on("message", data => listener({ data })); - if (type === "error") process.on("uncaughtException", listener); - } - }; - globalThis.postMessage = data => parentPort.postMessage(data); - import(workerData.moduleUrl);`, - { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, - ); - - addEventListener( - type: "message" | "error", - listener: (event: { data: WorkerResponse } | Error) => void, - ) { - if (type === "error") { - this.worker.on("error", listener); - return; - } - this.worker.on("message", (data: WorkerResponse) => { - const response = { data, deliver: () => listener({ data }) }; - if (data.type !== "success" || data.requestType !== "file") { - response.deliver(); - return; - } - const waiter = responseWaiters.shift(); - if (waiter) waiter(response); - else responses.push(response); - }); - } - - postMessage(message: WorkerRequest) { - this.worker.postMessage(message, []); - } - - terminate() { - terminationPromises.push(this.worker.terminate()); - } -} - -function applyChange(change: DocumentChange) { - // Keep the installed editor's non-DOM order, including the existing contents patch. - renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType); - file.contents = document.getText(); - if (change.lineDelta !== 0) renderer.applyDocumentChange(document); -} - -function append(text: string) { - const position = document.positionAt(document.getText().length); - const change = document.applyEdits([ - { range: { start: position, end: position }, newText: text }, - ]); - expect(change).toBeDefined(); - applyChange(change!); -} - -function undo() { - const change = document.undo()?.[0]; - expect(change).toBeDefined(); - applyChange(change!); -} - -function renderContents() { - const result = renderer.renderFile(file, range); - expect(result?.totalLines).toBe(document.lineCount); - return renderer.renderFullHTML(result!); -} - -beforeEach(async () => { - responses = []; - responseWaiters = []; - terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - const frame = setImmediate(() => { - animationFrames.delete(frame); - callback(0); - }); - animationFrames.add(frame); - return frame; - }); - vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => { - animationFrames.delete(frame); - clearImmediate(frame); - }); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); - pool = new WorkerPoolManager( - // Adapt browser transport only; Pierre's real worker produces each response. - { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, - options, - ); - await pool.initialize(["tsx"]); - const highlighter = await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["tsx"], - preferredHighlighter: "shiki-wasm", - }); - file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" }; - document = new TextDocument(file.name, source, "tsx"); - renderer = new FileRenderer(options, () => {}, pool); - tokenizer = new EditorTokenizer({ - codeOptions: options, - highlighter, - textDocument: document, - setStyle: () => {}, - onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme), - }); - renderContents(); -}); - -async function cleanUpFixture() { - tokenizer?.cleanUp(); - renderer?.cleanUp(); - pool?.terminate(); - await Promise.all(terminationPromises); - // Pool termination can queue a final broadcast after its workers have exited. - for (const frame of animationFrames) clearImmediate(frame); - animationFrames.clear(); - vi.unstubAllGlobals(); -} - -afterEach(cleanUpFixture); - -describe("editable file highlighting", () => { - it("cleans up an already terminated worker pool", async () => { - (await nextResponse()).deliver(); - expect(pool.getStats().totalWorkers).toBe(1); - pool.terminate(); - await Promise.all(terminationPromises); - expect(pool.getStats().totalWorkers).toBe(0); - - const animationFrame = globalThis.requestAnimationFrame; - const cancelFrame = globalThis.cancelAnimationFrame; - const window = globalThis.window; - try { - await cleanUpFixture(); - // Deliver the real Immediate queue after cleanup has removed the browser globals. - await new Promise((resolve) => setImmediate(resolve)); - } finally { - vi.stubGlobal("requestAnimationFrame", animationFrame); - vi.stubGlobal("cancelAnimationFrame", cancelFrame); - vi.stubGlobal("window", window); - } - }); - - it("still accepts an asynchronous highlight when the file has not changed", async () => { - expect(renderContents()).not.toContain('style="color:'); - (await nextResponse()).deliver(); - expect(renderContents()).toContain('style="color:'); - expect(pool.getFileResultCache(file)).toBeDefined(); - }); - - it.each([1, 60])( - "ignores a dispatched highlight after %i Enter edits and highlights the new version", - async (count) => { - const oldResponse = await nextResponse(); - for (let index = 0; index < count; index += 1) append("\n"); - append("export const EDITED_MARKER = 1;"); - oldResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - const currentResponse = await nextResponse(); - currentResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); - expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); - expect(document.lineCount).toBe(7_000 + count); - }, - ); - - it("does not replace a same-line edit with stale tokens", async () => { - const oldResponse = await nextResponse(); - append(" EDITED_MARKER"); - oldResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - expect(document.lineCount).toBe(7_000); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - }); - - it("keeps undo edits after an older highlight arrives", async () => { - const oldResponse = await nextResponse(); - append("\nexport const RETAINED_MARKER = 1;"); - append("\nexport const UNDONE_MARKER = 2;"); - undo(); - oldResponse.deliver(); - const html = renderContents(); - expect(html).toContain("RETAINED_MARKER"); - expect(html).not.toContain("UNDONE_MARKER"); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("RETAINED_MARKER"); - undo(); - expect(document.getText()).toBe(source); - expect(renderContents()).not.toContain("RETAINED_MARKER"); - const redone = document.redo()?.[0]; - expect(redone).toBeDefined(); - applyChange(redone!); - expect(renderContents()).toContain("RETAINED_MARKER"); - }); - - it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => { - (await nextResponse()).deliver(); - expect(pool.getFileResultCache(file)).toBeDefined(); - append("\nexport const EDITED_MARKER = 1;"); - expect(pool.getFileResultCache(file)).toBeUndefined(); - expect(renderContents()).toContain("EDITED_MARKER"); - const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); - expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); - }); - - it("reopens the edited file with the same cache key while an old response is pending", async () => { - const oldResponse = await nextResponse(); - append("\nexport const REOPENED_MARKER = 1;"); - renderer.cleanUp(); - renderer = new FileRenderer(options, () => {}, pool); - file = { ...file }; - expect(renderContents()).toContain("REOPENED_MARKER"); - oldResponse.deliver(); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("REOPENED_MARKER"); - expect(renderContents()).toContain('style="color:'); - }); -}); diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts deleted file mode 100644 index d6f64a55c9b18..0000000000000 --- a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - FileRenderer, - disposeHighlighter, - getSharedHighlighter, - type BaseCodeOptions, - type DiffsHighlighter, - type FileContents, - type HighlightedToken, -} from "@pierre/diffs"; -import { TextDocument } from "@pierre/diffs/editor"; -import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; -import * as NodeWorkerThreads from "node:worker_threads"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -type DocumentChange = NonNullable["applyEdits"]>>; -interface Tokenizer { - tokenize(change: DocumentChange): Map; - cleanUp(): void; -} - -const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); -const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { - EditorTokenizer: new (options: { - codeOptions: BaseCodeOptions; - highlighter: DiffsHighlighter; - textDocument: TextDocument; - setStyle: (style: string) => void; - onDeferTokenize: () => void; - }) => Tokenizer; -}; - -const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); -const options = { - theme: "pierre-dark", - themeType: "dark", - preferredHighlighter: "shiki-wasm", - useTokenTransformer: true, -} as const; -const source = "export const View = () =>
    Ready
    ;"; -let pool: WorkerPoolManager; -let renderer: FileRenderer; -let terminationPromises: Promise[]; -const pendingAnimationFrames = new Set>(); - -class WorkerTransport { - private readonly worker = new NodeWorkerThreads.Worker( - `const { parentPort, workerData } = require("node:worker_threads"); - globalThis.self = { - addEventListener(type, listener) { - if (type === "message") parentPort.on("message", data => listener({ data })); - if (type === "error") process.on("uncaughtException", listener); - } - }; - globalThis.postMessage = data => parentPort.postMessage(data); - import(workerData.moduleUrl);`, - { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, - ); - - addEventListener( - type: "message" | "error", - listener: (event: { data: WorkerResponse } | Error) => void, - ) { - if (type === "error") this.worker.on("error", listener); - else this.worker.on("message", (data: WorkerResponse) => listener({ data })); - } - - postMessage(message: WorkerRequest) { - this.worker.postMessage(message, []); - } - - terminate() { - terminationPromises.push(this.worker.terminate()); - } -} - -function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { - const document = new TextDocument(file.name, file.contents, language); - const tokenizer = new EditorTokenizer({ - codeOptions: options, - highlighter, - textDocument: document, - setStyle: () => {}, - onDeferTokenize: () => {}, - }); - try { - const end = document.positionAt(file.contents.length); - const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); - expect(change).toBeDefined(); - // This is the synchronous first edit, before the tokenizer's debounced prebuild. - const dirtyLines = tokenizer.tokenize(change!); - expect([...dirtyLines.keys()]).toEqual([0, 1]); - expect(document.getText()).toBe(`${file.contents}\n`); - } finally { - tokenizer.cleanUp(); - } -} - -beforeEach(async () => { - terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - const handle = setImmediate(() => { - pendingAnimationFrames.delete(handle); - callback(0); - }); - pendingAnimationFrames.add(handle); - return handle; - }); - vi.stubGlobal("cancelAnimationFrame", (handle: ReturnType) => { - pendingAnimationFrames.delete(handle); - clearImmediate(handle); - }); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); - await disposeHighlighter(); - pool = new WorkerPoolManager( - // Adapt transport only. The installed Pierre worker resolves and highlights the file. - { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, - options, - ); - await pool.initialize(); - renderer = new FileRenderer(options, undefined, pool); -}); - -afterEach(async () => { - renderer?.cleanUp(); - pool?.terminate(); - await Promise.all(terminationPromises); - await disposeHighlighter(); - // Drain the pool's final state broadcast before removing the animation frame stubs. - await new Promise((resolve) => setImmediate(resolve)); - for (const handle of pendingAnimationFrames) clearImmediate(handle); - pendingAnimationFrames.clear(); - vi.unstubAllGlobals(); -}); - -describe("editable file language readiness", () => { - it.each(["hydrate", "renderFile"] as const)( - "%s prepares the inferred language before the first edit of a worker-highlighted file", - async (method) => { - const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; - await pool.primeFileHighlightCache(file); - expect(pool.getFileResultCache(file)).toBeDefined(); - const mainHighlighter = await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["text"], - }); - expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); - renderer[method](file); - // Read-only worker rendering must not load editor grammars on the main thread. - expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); - const highlighter = await renderer.initializeHighlighter(); - firstEnter(highlighter, file, "tsx"); - }, - ); - - it.each(["hydrate", "renderFile"] as const)( - "%s respects an explicit language when the filename suggests plain text", - async (method) => { - const file: FileContents = { - name: "source.txt", - lang: "tsx", - contents: source, - cacheKey: "explicit-tsx", - }; - await pool.primeFileHighlightCache(file); - renderer[method](file); - firstEnter(await renderer.initializeHighlighter(), file, "tsx"); - }, - ); - - it("loads a newly opened language after reusing a worker-backed renderer", async () => { - const previousFile: FileContents = { - name: "previous.ts", - contents: "export const value = 1;", - cacheKey: "previous-ts", - }; - await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); - renderer.renderFile(previousFile); - firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); - const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; - renderer.renderFile(nextFile); - firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); - }); - - it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { - renderer.cleanUp(); - renderer = new FileRenderer(options); - const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; - renderer.hydrate(file); - firstEnter(await renderer.initializeHighlighter(), file, "tsx"); - }); - - it("keeps plain text editable without loading an unrelated grammar", async () => { - const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; - renderer.renderFile(file); - const highlighter = await renderer.initializeHighlighter(); - firstEnter(highlighter, file, "text"); - expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); - }); -}); diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts index cf293dd47254c..111ff291a7a8c 100644 --- a/apps/web/src/components/files/fileEditorVirtualization.test.ts +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -4,7 +4,7 @@ import { Virtualizer, type FileContents, } from "@pierre/diffs"; -import { Editor, TextDocument } from "@pierre/diffs/editor"; +import { Editor, TextDocument } from "@pierre/diffs/edit"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const renderingManagerUrl = new URL( @@ -22,10 +22,21 @@ class MeasuredElement { children: MeasuredElement[] = []; dataset: Record = {}; nextElementSibling: MeasuredElement | null = null; + shadowRoot: MeasuredElement | null = null; width = 283; constructor(readonly height = 0) {} + attachShadow() { + this.shadowRoot ??= new MeasuredElement(); + return this.shadowRoot; + } + + appendChild(child: MeasuredElement) { + this.children.push(child); + return child; + } + getBoundingClientRect() { MeasuredElement.geometryReads += 1; return { top: 0, height: this.height, width: this.width }; @@ -108,8 +119,10 @@ class LayoutVirtualizer extends Virtualizer { class MeasuredFile extends VirtualizedFile { override top = 0; + // Rows are measured by hand here; the virtualizer only needs to reconcile them. + override onRender = () => false; - override attachEditor(editor: Parameters[0]) { + override __attachEditor(editor: Parameters[0]) { this.editor = editor; return () => { this.editor = undefined; @@ -117,7 +130,9 @@ class MeasuredFile extends VirtualizedFile { } async initialize(file: FileContents) { - this.prepareCodeViewItem(file, 0); + this.updateCodeViewLayout(file, 0); + // Document changes require the session an attached editor installs. + (this as unknown as { installEditSession(file: FileContents): void }).installEditSession(file); await this.fileRenderer.initializeHighlighter(); expect( this.fileRenderer.renderFile(file, { @@ -180,7 +195,7 @@ class MeasuredFile extends VirtualizedFile { } const instances: MeasuredFile[] = []; -const editors: Editor[] = []; +const editors: Editor<"file", undefined, undefined>[] = []; beforeAll(async () => { await getSharedHighlighter({ @@ -225,7 +240,7 @@ async function makeFixture( cacheKey: `wrapped:${overflow}`, lang: "text", }; - const document = new TextDocument(file.name, contents, "text"); + const document = new TextDocument<"file", undefined>(file.name, contents, "text"); const instance = new MeasuredFile( { overflow, @@ -274,7 +289,7 @@ describe("wrapped editor document changes", () => { const before = instance.getLinePosition(previousLastLine); expect(before).toEqual({ top: 120328, height: 60 }); const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; - expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 }); append(); @@ -282,7 +297,7 @@ describe("wrapped editor document changes", () => { expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); expect(instance.getVirtualizedHeight()).toBe(120376); - expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 }); }); it("invalidates changed and shifted rows after an insertion in the middle", async () => { @@ -373,7 +388,7 @@ describe("wrapped editor document changes", () => { const { instance, file, append } = await makeFixture(); append(); instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); }); @@ -381,7 +396,7 @@ describe("wrapped editor document changes", () => { const { instance, file, append } = await makeFixture(); append(); instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); }); }); @@ -476,11 +491,11 @@ describe("wrapped measurement widths", () => { "document", Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), ); - const first = new Editor(); + const first = new Editor("file"); editors.push(first); first.edit(instance); first.cleanUp(); - const second = new Editor(); + const second = new Editor("file"); editors.push(second); second.edit(instance); instance.resizeContent(482.25); @@ -531,12 +546,16 @@ class EditorElement extends MeasuredElement { style: Record = {}; parentElement: EditorElement | null = null; - appendChild(child: EditorElement) { + override appendChild(child: EditorElement) { child.parentElement = this; this.children.push(child); return child; } + append(child: EditorElement) { + this.appendChild(child); + } + prepend(child: EditorElement) { child.parentElement = this; this.children.unshift(child); @@ -630,14 +649,15 @@ async function makeEditorFixture(lineCount: number) { langs: ["text"], preferredHighlighter: "shiki-wasm", }); - const editor = new Editor(); + const editor = new Editor("file"); editors.push(editor); editor.edit(instance); - editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { - startingLine: 0, - totalLines: 1, - bufferBefore: 0, - bufferAfter: 0, + editor.__syncRenderView({ + highlighter, + fileContainer: measuredElement(host), + file, + lineAnnotations: undefined, + renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 }, }); const append = (count: number) => { const lines = editor.getText().split("\n"); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0b3ca2040e88c..ad33483ecd539 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -89,6 +89,18 @@ export function getOptimisticProjectFileQueryData( return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null; } +/** The contents the Files panel shows, read outside React so it is current within a frame. */ +export function getProjectFileContents( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +): string | undefined { + const optimistic = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); + if (optimistic) return optimistic.contents; + const result = appAtomRegistry.get(getProjectFileQueryAtom(environmentId, cwd, relativePath)); + return Option.getOrUndefined(AsyncResult.value(result))?.contents; +} + export function confirmProjectFileQueryData( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index ae8a2b2d344e8..e1c6f1de04533 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -254,7 +254,9 @@ function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); - const [viewer, setViewer] = useState | null>(null); + const [viewer, setViewer] = useState | null>( + null, + ); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 7b8857f32a12a..20114f6ef1b58 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -402,6 +402,10 @@ describe("a file whose name a patch header cannot carry plainly", () => { expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt"); }); + it("keeps a backslash the name really has", () => { + expect(pathOf(quotedPatch("back\\\\slash.txt"))).toBe("back\\slash.txt"); + }); + it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => { expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts"); }); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index c99349a7dafd8..dcc6bb2b79d7d 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,7 +1,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import { parseDiffFromFile } from "@pierre/diffs"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -201,17 +200,11 @@ export function getRenderablePatch( } /** - * What the patch called the file, as the file's own name. Git writes a name holding a tab, a - * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still - * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, - * and the host knows the file only under the name it really has. + * The file's own name. Git quotes and escapes a name holding a tab, a newline, a quote or a + * backslash, and the parser decodes it, so this is the name the host knows the file by. */ -function fileDiffPath(raw: string): string { - return unquoteGitPatchPath(raw); -} - export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); + return fileDiff.name ?? fileDiff.prevName ?? ""; } /** @@ -219,7 +212,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); + return fileDiff.prevName ?? fileDiff.name ?? ""; } /** diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd60c4f2a3975..cf343e26a4fcf 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -190,7 +190,7 @@ export default defineConfig(() => { "@clerk/clerk-js", "@clerk/react/internal", "@pierre/diffs", - "@pierre/diffs/editor", + "@pierre/diffs/edit", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js", "effect/Array", diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch deleted file mode 100644 index 5cf80feb33568..0000000000000 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ /dev/null @@ -1,238 +0,0 @@ -diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts ---- a/dist/components/VirtualizedFile.d.ts -+++ b/dist/components/VirtualizedFile.d.ts -@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { - private computeApproximateSize; - setVisibility(visible: boolean): void; - rerender(): void; -- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; -+ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; - protected renderPreparedFile({ - fileContainer, - file, -diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js ---- a/dist/components/VirtualizedFile.js -+++ b/dist/components/VirtualizedFile.js -@@ -20,6 +20,7 @@ - cache = { - heights: /* @__PURE__ */ new Map(), - checkpoints: [], -+ codeWidth: void 0, - fileAnnotationHeight: 0 - }; - isVisible = false; -@@ -31,6 +32,8 @@ - super(options, workerManager, isContainerManaged); - this.virtualizer = virtualizer; - this.metrics = metrics; -+ const simpleVirtualizer = this.getSimpleVirtualizer(); -+ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); - } - setMetrics(metrics, force = false) { - if (!force && areObjectsEqual(this.metrics, metrics)) return; -@@ -70,10 +73,12 @@ - if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); - super.setThemeType(themeType); - } -- resetLayoutCache(recompute = false, resetRenderRange = true) { -+ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { - this.layoutDirty = true; -- this.cache.fileAnnotationHeight = 0; -- if (this.cache.heights.size > 0) this.cache.heights.clear(); -+ if (startLine === 0) this.cache.fileAnnotationHeight = 0; -+ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. -+ if (startLine === 0) this.cache.heights.clear(); -+ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); - if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; - if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; - if (recompute && this.isSimpleMode()) this.computeApproximateSize(); -@@ -91,6 +96,13 @@ - if (this.code == null) return hasHeightChange; - const content = this.code.children[1]; - if (!(content instanceof HTMLElement)) return hasHeightChange; -+ const codeWidth = this.code.getBoundingClientRect().width; -+ if (!(codeWidth > 0)) return hasHeightChange; -+ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { -+ this.resetLayoutCache(false, false); -+ hasHeightChange = true; -+ } -+ this.cache.codeWidth = codeWidth; - const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); - if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { - const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; -@@ -287,11 +299,11 @@ - this.forceRenderOverride = true; - this.virtualizer.instanceChanged(this, false); - } -- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { -+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { - const previousRenderRange = this.renderRange; - super.applyDocumentChange(textDocument, newLineAnnotations); - this.getSimpleVirtualizer()?.markDOMDirty(); -- this.resetLayoutCache(this.isSimpleMode(), false); -+ this.resetLayoutCache(this.isSimpleMode(), false, startLine); - if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { - const windowSpecs = this.virtualizer.getWindowSpecs(); - const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); -diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index ff78e2a..f9df318 100644 ---- a/dist/editor/editor.js -+++ b/dist/editor/editor.js -@@ -146,14 +146,11 @@ var Editor = class { - const file = fileInstance.__getCurrentFile?.(); - if (file !== void 0) requirePersistedCacheKey(file); - } -- const { useTokenTransformer, enableGutterUtility, enableLineSelection, lineHoverHighlight = "disabled", ...rest } = fileInstance.options; -- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled") { -+ const { useTokenTransformer, ...rest } = fileInstance.options; -+ if (useTokenTransformer !== true) { - fileInstance.setOptions({ - ...rest, -- useTokenTransformer: true, -- enableGutterUtility: false, -- enableLineSelection: false, -- lineHoverHighlight: "disabled" -+ useTokenTransformer: true - }); - fileInstance.rerender(); - } -@@ -908,6 +905,7 @@ var Editor = class { - return lineNumber - 1; - }; - this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => { -+ if (this.#fileInstance?.options.enableLineSelection === true) return; - const gutterRow = resolveGutterTarget(e.composedPath()[0]); - if (gutterRow?.dataset.lineType === "change-deletion") { - const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,12 @@ var Editor = class { - if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; - } - fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); -+ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); -- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); -+ if (didLineCountChange) { -+ const previousLineCount = change.lineCount - change.lineDelta; -+ // A wider or narrower line-number gutter can rewrap unchanged rows. -+ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; -+ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); -+ } - if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); - if (newLineAnnotations !== void 0) { -@@ -1788,6 +1787,7 @@ var Editor = class { - } - } - #setSelectedLinesSafe(range, lineNumberOnly = false) { -+ if (this.#fileInstance?.options.controlledSelection === true) return; - try { - this.#fileInstance?.setSelectedLines(range, { - notify: false, -diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts ---- a/dist/managers/ResizeManager.d.ts -+++ b/dist/managers/ResizeManager.d.ts -@@ -5,6 +5,8 @@ - columnVariables?: ResizeManagerColumnVariableMode; - } - declare class ResizeManager { -+ /** Schedule owner measurement after an observed code or gutter size change. */ -+ onResize?: () => void; - private static resizeObserver; - private static managersByElement; - private static getResizeObserver; -diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js ---- a/dist/managers/ResizeManager.js -+++ b/dist/managers/ResizeManager.js -@@ -19,6 +19,7 @@ - for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); - } - observedNodes = /* @__PURE__ */ new Map(); -+ onResize; - setup(pre, { disableAnnotations, columnVariables = "apply" }) { - const annotationUpdates = /* @__PURE__ */ new Set(); - const applyColumnVariables = columnVariables === "apply"; -@@ -212,6 +213,7 @@ - this.applyAnnotationUpdates(annotationUpdates); - annotationUpdates.clear(); - this.applyColumnUpdates(codeUpdates); -+ if (codeUpdates.size > 0) this.onResize?.(); - codeUpdates.clear(); - } - applyAnnotationUpdates(annotationUpdates) { -diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js -index e9f62f5..af82a46 100644 ---- a/dist/react/utils/useFileInstance.js -+++ b/dist/react/utils/useFileInstance.js -@@ -91,10 +91,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu - }; - if (needsEditorOptions) merged = { - ...merged, -- useTokenTransformer: true, -- enableGutterUtility: false, -- enableLineSelection: false, -- lineHoverHighlight: "disabled" -+ useTokenTransformer: true - }; - return merged; - } -diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js ---- a/dist/renderers/FileRenderer.js -+++ b/dist/renderers/FileRenderer.js -@@ -107,10 +107,10 @@ - result: massiveFile ? void 0 : cache?.result, - renderRange: void 0 - }; -+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - if (this.workerManager?.isWorkingPool() === true) { - if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); - } else if (this.highlighter == null) { -- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - this.initializeHighlighter(); - } - } -@@ -163,6 +163,8 @@ - if (this.renderCache == null) return; - const { file, result } = this.renderCache; - if (result == null) return; -+ this.workerManager?.cleanUpTasks(this); -+ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey); - const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; - for (const [line, tokens] of dirtyLines) { - if (lineCache != null && line < lineCache.lines.length) { -@@ -268,6 +270,7 @@ - const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); - const newContent = !areFilesEqual(file, this.renderCache.file); - const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); -+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - if (this.workerManager?.isWorkingPool() === true) { - if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { - this.renderCache.file = file; -@@ -278,7 +281,6 @@ - } - if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); - } else { -- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - const hasThemes = this.highlighter != null && areThemesAttached(options.theme); - const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); - const canHighlight = !forcePlainText && hasLangs; -diff --git a/package.json b/package.json -index ff61c90..1e170e5 100644 ---- a/package.json -+++ b/package.json -@@ -55,6 +55,18 @@ - "./worker/worker-portable.js": { - "types": "./dist/worker/worker-portable.d.ts", - "import": "./dist/worker/worker-portable.js" -+ }, -+ "./types": { -+ "types": "./dist/types.d.ts", -+ "import": "./dist/types.js" -+ }, -+ "./utils/getFiletypeFromFileName": { -+ "types": "./dist/utils/getFiletypeFromFileName.d.ts", -+ "import": "./dist/utils/getFiletypeFromFileName.js" -+ }, -+ "./utils/parsePatchFiles": { -+ "types": "./dist/utils/parsePatchFiles.d.ts", -+ "import": "./dist/utils/parsePatchFiles.js" - } - }, - "publishConfig": { diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch new file mode 100644 index 0000000000000..f1984ea41835a --- /dev/null +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -0,0 +1,146 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -49,7 +49,7 @@ + setVisibility(visible: boolean): void; + rerender(): void; + syncGhostTextRows(): void; +- applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + render({ fileContainer, file, forceRender, lineAnnotations, ...props }: FileRenderProps): boolean; + protected finalizeRender(): void; + private updatePendingRender; +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -23,6 +23,7 @@ + heights: /* @__PURE__ */ new Map(), + checkpoints: [], + fileAnnotationHeight: 0, ++ codeWidth: void 0, + ghostTextRows: NO_GHOST_TEXT_ROWS + }; + pendingRender; +@@ -35,6 +36,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + const nextMetrics = computeVirtualFileMetrics(metrics); +@@ -86,10 +89,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; +@@ -135,6 +140,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; + if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { +@@ -358,12 +370,12 @@ + codeView.instanceChanged(this, true); + } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const { renderRange: previousRenderRange } = this; + this.getAdvancedVirtualizer()?.capturePendingLayoutAnchor(); + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + const file = this.getRenderedFile(); + if (!this.isSimpleMode()) this.computeApproximateSize(true); + else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/editor/editor.js b/dist/editor/editor.js +--- a/dist/editor/editor.js ++++ b/dist/editor/editor.js +@@ -1899,7 +1899,11 @@ + changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, + documentLineCount: textDocument.lineCount + }); +- if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(change.previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } + if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); + if (newLineAnnotations != null) { + this.#lineAnnotations = newLineAnnotations; +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -58,6 +58,18 @@ + "./worker/worker-portable.js": { + "types": "./dist/worker/worker-portable.d.ts", + "import": "./dist/worker/worker-portable.js" ++ }, ++ "./types": { ++ "types": "./dist/types.d.ts", ++ "import": "./dist/types.js" ++ }, ++ "./utils/getFiletypeFromFileName": { ++ "types": "./dist/utils/getFiletypeFromFileName.d.ts", ++ "import": "./dist/utils/getFiletypeFromFileName.js" ++ }, ++ "./utils/parsePatchFiles": { ++ "types": "./dist/utils/parsePatchFiles.d.ts", ++ "import": "./dist/utils/parsePatchFiles.js" + } + }, + "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff5ba44f197d7..17a19d1ffbeb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ catalogs: specifier: 1.8.0 version: 1.8.0 '@pierre/diffs': - specifier: 1.3.0-beta.10 - version: 1.3.0-beta.10 + specifier: 1.5.2 + version: 1.5.2 jose: specifier: 6.2.2 version: 6.2.2 @@ -103,7 +103,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 + '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -277,7 +277,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -502,8 +502,8 @@ importers: specifier: 4.0.1 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@pierre/trees': - specifier: 1.0.0-beta.4 - version: 1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.0.0-beta.6 + version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) '@types/react': specifier: ~19.3.0 version: 19.3.0 @@ -666,10 +666,10 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': - specifier: 1.0.0-beta.4 - version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 1.0.0-beta.6 + version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -4444,18 +4444,27 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.3.0-beta.10': - resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==} + '@pierre/diffs@1.5.2': + resolution: {integrity: sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true '@pierre/theme@1.1.0': resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} - '@pierre/theming@0.0.2': - resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + '@pierre/theme@2.0.0': + resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==} + engines: {vscode: ^1.0.0} + + '@pierre/theming@1.0.0': + resolution: {integrity: sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g==} peerDependencies: '@pierre/theme': ^1.1.0 '@shikijs/themes': ^3.0.0 || ^4.0.0 @@ -4474,8 +4483,28 @@ packages: shiki: optional: true - '@pierre/trees@1.0.0-beta.4': - resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==} + '@pierre/theming@1.0.1': + resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==} + peerDependencies: + '@pierre/theme': ^1.1.0 || ^2.0.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + + '@pierre/trees@1.0.0-beta.6': + resolution: {integrity: sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -14869,65 +14898,96 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) + '@pierre/theme': 2.0.0 + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 + shiki: 4.2.0 + optionalDependencies: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theme': 2.0.0 + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 + shiki: 4.2.0 + optionalDependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' - '@pierre/theme@1.1.0': {} + '@pierre/theme@1.1.0': + optional: true + + '@pierre/theme@2.0.0': {} - '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + '@pierre/theming@1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 1.1.0 + '@shikijs/themes': 4.3.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + shiki: 4.2.0 + + '@pierre/theming@1.0.0(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + optionalDependencies: + '@pierre/theme': 2.0.0 '@shikijs/themes': 4.2.0 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) shiki: 4.2.0 - '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.1.0 + '@pierre/theme': 2.0.0 + '@shikijs/themes': 4.2.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + shiki: 4.2.0 + + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + optionalDependencies: + '@pierre/theme': 2.0.0 '@shikijs/themes': 4.3.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) shiki: 4.2.0 - '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': dependencies: + '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) preact: 11.0.0-beta.0 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@pierre/theme' + - '@shikijs/themes' + - shiki - '@pierre/trees@1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/trees@1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': dependencies: + '@pierre/theming': 1.0.0(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) preact: 11.0.0-beta.0 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@pierre/theme' + - '@shikijs/themes' + - shiki '@polka/url@1.0.0-next.29': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e88eb115e7cb..92373445f5edb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.3.0-beta.10 + "@pierre/diffs": 1.5.2 "@tailwindcss/node": 4.3.3 "@tailwindcss/oxide": 4.3.3 "@tailwindcss/vite": 4.3.3 @@ -270,7 +270,9 @@ patchedDependencies: "@opencode/client@2.0.23": patches/@opencode__client@2.0.23.patch "@opencode/protocol@2.0.23": patches/@opencode__protocol@2.0.23.patch "@opencode/schema@2.0.23": patches/@opencode__schema@2.0.23.patch - "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch + # Exports the dependency-light subpaths mobile and the web diff parser import, and keeps + # measured wrapped-row heights above an edit (fileEditorVirtualization.test.ts). + "@pierre/diffs@1.5.2": patches/@pierre%2Fdiffs@1.5.2.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch From c4481b9b07d9108d3aa93494a0ec5dd28ed8fd80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:10:51 -0700 Subject: [PATCH 014/680] fix(relay): a host restarting onto a deleted tunnel gets a new one (#16649) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ManagedEndpointProvider.test.ts | 29 +++++++++++++++++-- .../environments/ManagedEndpointProvider.ts | 26 +++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index b48a01cc6d031..9896fc324b369 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1118,7 +1118,7 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); - it.effect("does no Cloudflare work when the registered origin is unchanged", () => { + it.effect("only confirms the tunnel exists when the registered origin is unchanged", () => { const tunnelCalls: TunnelCall[] = []; const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); @@ -1137,7 +1137,31 @@ describe("ManagedEndpointProvider", () => { endpoint: provisioned.endpoint, }), ).toBe("ready"); - expect(tunnelCalls).toEqual([]); + expect(tunnelCalls).toEqual([{ operation: "get", input: "tunnel-id" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks for recovery when the recorded tunnel was deleted", () => { + const tunnelCalls: TunnelCall[] = []; + const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; + const provisioned = yield* provider.provision({ ...key, origin }); + // A shutdown release deletes the tunnel but keeps the recorded id; the + // host was killed before it dropped its stored config. + expect(yield* provider.release(key)).toBe(true); + + expect( + yield* provider.reconcileOrigin({ + ...key, + tunnelId: provisioned.runtime.tunnelId!, + origin, + endpoint: provisioned.endpoint, + }), + ).toBe("recovery_required"); }).pipe(Effect.provide(layer)); }); @@ -1163,6 +1187,7 @@ describe("ManagedEndpointProvider", () => { }), ).toBe("ready"); expect(tunnelCalls).toEqual([ + { operation: "get", input: "tunnel-id" }, { operation: "putConfiguration", input: { diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index aba8b426ab5f7..eb6b7b7b73a97 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -55,6 +55,7 @@ const ManagedEndpointProvisioningStage = Schema.Literals([ "mark-allocation-ready", "load-allocation", "verify-endpoint", + "verify-tunnel", "sync-origin", ]); @@ -208,6 +209,7 @@ export interface ManagedEndpointTunnel { readonly status?: string | null; readonly createdAt?: string | null; readonly connsInactiveAt?: string | null; + readonly deletedAt?: string | null; } export interface ManagedEndpointTunnelListRequest { @@ -581,6 +583,30 @@ export const make = Effect.gen(function* () { hostname: allocation.hostname, }); } + // A release keeps the recorded tunnel id, so the record alone cannot + // tell a live tunnel from one deleted by a shutdown whose host was + // killed before it dropped its config. Ask Cloudflare, or the host + // starts a connector that can never connect. + const recorded = yield* tunnels.get(input.tunnelId).pipe( + Effect.asSome, + Effect.catchTags({ + ManagedEndpointTunnelClientError: (cause) => + isManagedEndpointNotFound(cause.cause) + ? Effect.succeedNone + : Effect.fail( + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "verify-tunnel", + tunnelId: input.tunnelId, + cause, + }), + ), + }), + ); + if (Option.isNone(recorded) || recorded.value.deletedAt) { + return "recovery_required"; + } if ( allocation.origin?.localHttpHost === input.origin.localHttpHost && allocation.origin.localHttpPort === input.origin.localHttpPort From 740bda40c92e1ded65dd2b65ef7c583357b1d258 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:11:46 -0700 Subject: [PATCH 015/680] fix(server): recover a deleted tunnel when Cloudflare says "Tunnel not found" (#16648) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/cloud/ManagedEndpointRuntime.test.ts | 91 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 41 ++++++++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 42c5d9b20e1dd..4b06ae2124ff5 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -185,11 +185,27 @@ describe("CloudManagedEndpointRuntime", () => { '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Invalid tunnel secret" connIndex=0', ), ).toBe(true); + // Seen in production on 2026-10-06 after the relay deleted an idle tunnel. + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-10-06T12:00:00Z ERR Register tunnel error from server side error="Unauthorized: Tunnel not found" connIndex=0 event=0 ip=198.41.200.23', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-10-06T12:00:00Z ERR Register tunnel error from server side error="Tunnel not found" connIndex=0', + ), + ).toBe(true); expect( ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="connection timed out" connIndex=0', ), ).toBe(false); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Failed to serve tunnel connection error="Unauthorized: Tunnel not found" connIndex=0', + ), + ).toBe(false); }); it.effect("keeps recovery requests sent before the server starts consuming them", () => @@ -318,6 +334,81 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("requests recovery while the connector never registers a connection", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 800, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const requests = yield* Queue.unbounded(); + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((config) => Queue.offer(requests, config)), + Effect.forkChild, + ); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "silently-deleted", + }; + yield* runtime.applyConfig(config); + + yield* TestClock.adjust(Duration.minutes(2)); + expect(yield* Queue.size(requests)).toBe(0); + yield* TestClock.adjust(Duration.minutes(1)); + expect(yield* Queue.take(requests)).toEqual(config); + // Still unconnected, so it asks again. + yield* TestClock.adjust(Duration.minutes(3)); + expect(yield* Queue.take(requests)).toEqual(config); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not request recovery once the connector has connected", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 801, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const requests = yield* Queue.unbounded(); + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((config) => Queue.offer(requests, config)), + Effect.forkChild, + ); + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + yield* Queue.offer( + output, + new TextEncoder().encode( + "2026-10-06T00:00:00Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Stream.runHead(runtime.tunnelConnected); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.size(requests)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6b54b7ee3bdfd..6c601fad80f38 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -59,6 +59,7 @@ interface ActiveConnector { readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; readonly startedAtMillis: number; + readonly connected: Ref.Ref; } // A connector that exits before running this long is treated as part of a @@ -71,6 +72,11 @@ const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; // Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; +// A connector that never registers a connection may hold a token for a tunnel +// that no longer exists, rejected in wording the output check does not know. +// Ask for recovery after this long, and again at this interval while it stays +// unconnected; the relay hands back the same tunnel when it is still live. +const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -84,14 +90,16 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" /** * Cloudflare's edge rejects a connector whose tunnel was deleted or whose - * token no longer matches. Current edge output is - * `error="Failed to get tunnel"` with no prefix; older edges prefixed the - * same messages with `Unauthorized:`. Match both so recovery fires on either. + * token no longer matches. The edge words this differently over time + * (`Failed to get tunnel`, `Tunnel not found`, ...), sometimes prefixed with + * `Unauthorized:`. Treat any `Unauthorized:` registration error as a rejection, + * plus the unprefixed messages seen so far. Transient rejections while a new + * tunnel's token propagates are absorbed by requiring several in a row. */ export function isRejectedRelayClientTunnelOutput(line: string): boolean { return ( /\bRegister tunnel error from server side\b/iu.test(line) && - /error="(?:Unauthorized:\s*)?(?:Failed to get tunnel|Record for tunnel not found|Invalid tunnel secret)"/iu.test( + /error="(?:Unauthorized:[^"]*|Failed to get tunnel|Tunnel not found|Record for tunnel not found|Invalid tunnel secret)"/iu.test( line, ) ); @@ -220,6 +228,24 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + // Requests recovery while the connector has not registered a connection, + // once per timeout, until it connects or is replaced. + const watchConnectorRegistration = (connector: ActiveConnector) => + Effect.gen(function* () { + yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT); + if (yield* Ref.get(connector.connected)) return true; + yield* Effect.logWarning( + "Relay client has not registered a tunnel connection; requesting recovery", + { + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }, + ); + yield* Queue.offer(recoveryRequests, connector.config); + return false; + }).pipe(Effect.repeat({ until: (connected) => connected })); + const observeConnectorOutput = (connector: ActiveConnector) => { let rejectedRegistrations = 0; @@ -239,7 +265,10 @@ export const make = Effect.gen(function* () { switch (classifyRelayClientOutput(line)) { case "connected": rejectedRegistrations = 0; - return Effect.logInfo("Relay client tunnel connection registered", attributes).pipe( + return Ref.set(connector.connected, true).pipe( + Effect.andThen( + Effect.logInfo("Relay client tunnel connection registered", attributes), + ), Effect.andThen(Queue.offer(tunnelConnections, undefined)), Effect.asVoid, ); @@ -370,10 +399,12 @@ export const make = Effect.gen(function* () { configKey: nextConfigKey, config, startedAtMillis: yield* Clock.currentTimeMillis, + connected: yield* Ref.make(false), } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); yield* Effect.forkIn(superviseConnector(connector), connectorScope); + yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); return { status: "running", providerKind: "cloudflare_tunnel", From 2991331515ed5b7e4df0751e5119fa04954e803a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:25:49 -0400 Subject: [PATCH 016/680] fix(web): iPhone Duo fold controls follow the phone's orientation (#16630) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/device/DeviceDuoControls.tsx | 112 ++++++++++++------ .../src/components/device/DeviceDuoGlyph.tsx | 30 +++-- .../src/device/duoControl.test.ts | 51 +++++++- .../client-runtime/src/device/duoControl.ts | 29 +++-- 4 files changed, 163 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/device/DeviceDuoControls.tsx b/apps/web/src/components/device/DeviceDuoControls.tsx index 1759ab07123b7..57922a0243d35 100644 --- a/apps/web/src/components/device/DeviceDuoControls.tsx +++ b/apps/web/src/components/device/DeviceDuoControls.tsx @@ -1,5 +1,5 @@ import { - DUO_POSES, + duoFoldState, type DuoCommand, type DuoControlState, } from "@t3tools/client-runtime/device/duo-control"; @@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph"; import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -/** Physical presets live beside the device. Pinching supplies continuous hinge control. */ +const FOLDS = [ + { id: "closed", angle: 0 }, + { id: "half", angle: 90 }, + { id: "open", angle: 180 }, +] as const; +const STANDS = [ + { id: "laptop", label: "Laptop stand" }, + { id: "tent", label: "Tent stand" }, +] as const; + +/** + * Fold shapes move only the hinge, so the device opens around whichever edge it + * currently rests on: a vertical phone opens as a book into a landscape tablet, + * a horizontal one as a laptop into a portrait tablet. Stands are native presets + * that also place the device. Pinching supplies continuous hinge control. + */ export function DeviceDuoControls(props: { screen: DeviceScreenSize; state: DuoControlState; enabled: boolean; onCommand: (command: DuoCommand) => void; }) { - const angle = props.screen.hingeAngle; - const fold = angle == null ? null : angle === 0 ? "closed" : angle === 180 ? "open" : "book"; - const selected = (id: (typeof DUO_POSES)[number]["id"]) => - id === "laptop" || id === "tent" ? props.screen.hingePose === id : fold === id; + const { screen } = props; + const { fold, stand, phoneVertical } = duoFoldState(screen); + const foldLabels = { + closed: "Closed", + half: phoneVertical ? "Book" : "Laptop", + open: "Open", + }; + const button = ( + key: string, + label: string, + pressed: boolean, + onClick: () => void, + glyph: React.ReactNode, + ) => ( + + + } + > + {glyph} + + {label} + + ); + const group = + "pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm"; return (
    - {([DUO_POSES.slice(0, 3), DUO_POSES.slice(3)] as const).map((poses, index) => ( -
    - {poses.map((pose) => ( - - props.onCommand({ control: "pose", value: pose.id })} - /> - } - > - - - - {pose.label} - {pose.id === "book" ? " / bookshelf" : ""} - - - ))} -
    - ))} +
    + {FOLDS.map(({ id, angle: value }) => + button( + id, + foldLabels[id], + !stand && fold === id, + () => props.onCommand({ control: "angle", value }), + , + ), + )} +
    +
    + {STANDS.map(({ id, label }) => + button( + id, + label, + screen.hingePose === id, + () => props.onCommand({ control: "pose", value: id }), + , + ), + )} +
    {props.state.error ? ( {stance ? : null} {pose === "closed" ? ( <> - - - + + + ) : null} {pose === "book" ? ( - + ) : null} {pose === "open" ? ( <> - - + + ) : null} diff --git a/packages/client-runtime/src/device/duoControl.test.ts b/packages/client-runtime/src/device/duoControl.test.ts index 019c383a56dd5..f325d3a25de74 100644 --- a/packages/client-runtime/src/device/duoControl.test.ts +++ b/packages/client-runtime/src/device/duoControl.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import { createDuoControl, createDuoPinch, type DuoCommand } from "./duoControl.ts"; +import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts"; afterEach(() => vi.useRealTimers()); it("keeps a failed send visible, including a disconnect while draining queued motion", () => { @@ -107,3 +107,52 @@ it("pinches only a hit device, accumulates independently of native readback, cla expect(change).toHaveBeenCalledTimes(count); expect(pinch.active).toBe(false); }); + +// Screen configs as an iPhone Duo simulator reports them for each way of holding the device. +it.each([ + [ + "vertical phone, closed", + { screenId: 1, orientation: "portrait", hingeAngle: 0 }, + "closed", + true, + ], + [ + "horizontal phone, closed", + { screenId: 1, orientation: "landscape_right", hingeAngle: 0 }, + "closed", + false, + ], + [ + "vertical phone opened as a book", + { screenId: 3, orientation: "landscape_left", hingeAngle: 180 }, + "open", + true, + ], + [ + "horizontal phone opened as a laptop", + { screenId: 3, orientation: "portrait_upside_down", hingeAngle: 180 }, + "open", + false, + ], + ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true], +] as const)("reads a %s", (_name, screen, fold, phoneVertical) => { + expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical }); +}); + +it("marks native stands so the fold group does not also claim them", () => { + expect( + duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }), + ).toEqual({ fold: "half", stand: true, phoneVertical: false }); +}); + +it("falls back like the 3D view when hinge fields are missing", () => { + expect(duoFoldState({ screenId: 1, orientation: "portrait" })).toMatchObject({ + fold: "closed", + phoneVertical: true, + }); + // Without a display ID the 3D view draws the open inner panel, so the controls do too. + expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({ + fold: "open", + phoneVertical: true, + }); +}); diff --git a/packages/client-runtime/src/device/duoControl.ts b/packages/client-runtime/src/device/duoControl.ts index b9f61ec997d9e..e5be353ca625b 100644 --- a/packages/client-runtime/src/device/duoControl.ts +++ b/packages/client-runtime/src/device/duoControl.ts @@ -1,12 +1,8 @@ // @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout. -export const DUO_POSES = [ - { id: "closed", label: "Closed", angle: 0 }, - { id: "book", label: "Book", angle: 90 }, - { id: "open", label: "Open", angle: 180 }, - { id: "laptop", label: "Laptop", angle: 90 }, - { id: "tent", label: "Tent", angle: 80 }, -] as const; -export type DuoPose = (typeof DUO_POSES)[number]["id"]; +import type { DeviceScreenSize } from "./stream.ts"; + +/** Native hinge presets. Each one also sets the device's physical orientation. */ +export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent"; export type DuoOrientation = | "portrait" | "landscape_left" @@ -24,6 +20,23 @@ export type DuoControlState = { error: string | null; }; +/** + * The fold the device is in and the way it is held. Missing hinge fields fall back the same way + * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a + * quarter turn from the cover, so its landscape orientation means a vertical phone. + */ +export function duoFoldState( + screen: Pick, +) { + const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180); + const landscape = screen.orientation.startsWith("landscape"); + return { + fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half", + stand: screen.hingePose === "laptop" || screen.hingePose === "tent", + phoneVertical: screen.screenId === 1 ? !landscape : landscape, + } as const; +} + /** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ export function createDuoControl(options: { send: (request: { requestId: number; command: DuoCommand }) => boolean; From 847c53df0126a225fb1a64d4bda9acfffa2b68ed Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 22:32:14 -0300 Subject: [PATCH 017/680] fix(web): keep workspace options when expanding lineage (#16635) --- .../src/components/chat/ThreadDetailsCard.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index dfee1453eda98..74cbf60cc9ec6 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -53,6 +53,7 @@ export function ThreadDetailsCard({ const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, + fullContentHeight: 0, }); const contentHeights = measurements.key === measurementKey ? measurements.heights : { full: 0, compact: 0 }; @@ -64,8 +65,8 @@ export function ThreadDetailsCard({ ? preferredPlacement.x + preferredPlacement.width : undefined; const cardBottom = - preferredPlacement && contentHeights.full > 0 - ? preferredPlacement.y + Math.min(contentHeights.full, preferredPlacement.height) + preferredPlacement && measurements.key === measurementKey && measurements.fullContentHeight > 0 + ? preferredPlacement.y + Math.min(measurements.fullContentHeight, preferredPlacement.height) : undefined; useLayoutEffect(() => { reportDetailsCard?.( @@ -88,11 +89,27 @@ export function ThreadDetailsCard({ const measure = () => { const frame = element.closest("[data-thread-details-card]"); const next = element.offsetHeight + (frame ? frame.offsetHeight - frame.clientHeight : 0); + // Lineage scrolls as it expands. Counting it toward density would hide + // the section and workspace controls when the user asks to see more rows. + const lineage = element.querySelector("[data-thread-relationships-panel]"); + const fittingHeight = next - (lineage?.offsetHeight ?? 0); setMeasurements((current) => { const heights = current.key === measurementKey ? current.heights : { full: 0, compact: 0 }; - return current.key === measurementKey && heights[density] === next + const fullContentHeight = + density === "full" + ? next + : current.key === measurementKey + ? current.fullContentHeight + : 0; + return current.key === measurementKey && + heights[density] === fittingHeight && + current.fullContentHeight === fullContentHeight ? current - : { key: measurementKey, heights: { ...heights, [density]: next } }; + : { + key: measurementKey, + heights: { ...heights, [density]: fittingHeight }, + fullContentHeight, + }; }); }; measure(); From 5886bd8f19c8d4b01d7a0b36ad62a9d35f02552d Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 22:32:40 -0300 Subject: [PATCH 018/680] fix(web): preserve bare anchor placeholders in markdown (#16637) --- apps/web/src/components/ChatMarkdown.test.tsx | 78 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 50 ++++++++++++ 2 files changed, 128 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 5585dc3ddf400..7f655acb1cc30 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { EnvironmentId } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -73,6 +75,82 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown bare anchor placeholders", () => { + it.each(["", "", "", "", "", ""])( + "preserves unmatched %s without linking later blocks", + (token) => { + const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`); + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "the link", + ]); + expect(document.querySelectorAll("li")).toHaveLength(2); + expect( + [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent), + ).toContain("The deferred move continues on B."); + }, + ); + + it.each([" ", "
    more
    \n"])( + "preserves a paired anchor closing in the raw block %s", + (closing) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + label\n\n${closing}\n\nfinish`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label"); + }, + ); + + it("preserves a paired anchor after comment-looking raw text", () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label -->"); + }); + + it.each(["", '
    more
    ', ''])( + "ignores apparent closing anchors inside %s", + (html) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + .\n\n${html}\n\nAfter.`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("Before ."); + expect(document.querySelectorAll("a")).toHaveLength(0); + }, + ); + + it("preserves paired HTML anchors, details, markdown links, and inline code", () => { + const text = + 'Bare label, , ``, and [docs](https://example.com).\n\n
    MoreDetails
    '; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "label", + "", + "docs", + ]); + expect(document.querySelector("code")?.textContent).toBe("
    "); + expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More"); + }); +}); + describe("ChatMarkdown context references", () => { it("renders text and image references through the chip renderer, with readable fallback", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a56db444d2005..5389cae83d675 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -394,6 +394,7 @@ function orderedListGutterStyle( type MarkdownImageHastNode = { type?: string; + value?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; @@ -448,6 +449,54 @@ function markStandaloneImages(node: MarkdownImageHastNode) { }); } +/** Keep unmatched inline `` placeholders from opening an HTML link over later blocks. */ +function rehypePreserveBareAnchorPlaceholders() { + return (tree: MarkdownImageHastNode) => { + const anchors: Array = []; + let rawTextTag: string | undefined; + const visit = (node: MarkdownImageHastNode) => { + if (node.type === "raw" && typeof node.value === "string") { + // Raw blocks can contain several tags. Consume whole tags, quoted attributes, + // and comments so text resembling a closing anchor cannot pair a placeholder. + const tags = /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g; + let offset = 0; + while (rawTextTag !== "plaintext") { + // Raw text ends at its closing tag even inside comment-looking text. + const matcher = rawTextTag ? new RegExp(``, "gi") : tags; + matcher.lastIndex = offset; + const match = matcher.exec(node.value); + if (!match) break; + const [tag] = match; + offset = matcher.lastIndex; + if (rawTextTag) { + rawTextTag = undefined; + continue; + } + if (tag.startsWith("=M}X9yl@RiT(0go&Xd zpEYHjXK&d)#tByUfHQsygd0~kemMMem2~lc`?(5Os}^2ID?D5I-%S4VHNHswf6IXX zvA)yVf1myN|Lpv7aCUmZG{TQY8-OaBa;JagC1-+M6C{gcmkf%?kQswz^TthifWcgt z+78*v2}m|HGs>WQD`W=+GLo(q*Q*{pSpDnP>(}{4d>;dg<;?aKs8+D_rlbK@=a!BL zJqvVvaDD>Ev>rxR||m{-<9iq5k-Tx;)H zu~5Hxi|1&BI8cZtY#5plyFSq?Q^i^(oydia+u|l{F>)-i8D9kp|B!x2=snxXG#p+F zF`QZVJa~(zRMwdxJ0B!2na=gpso|ad1;8f{B0)*VH9T0+`_0}rW*ai_zr%R2Qz;## z&)8_OY4s>d=t}!f*8aP`y}j1P|2>KPuL{hN&R*c-C9i>1_leT{4Fe1359lZ?GIIjS zPW1FH4XP$N?U@i-n|7$zD+4zsm z^{?OlKc{0Q?ZHnd{|mhGZvGW`ukgQSXSoG}!DOf7e1I@DCiOg^FvX2qFfh!Y9o zyHzVF2%~`@@TNxGjUH74F){5{^e5Z@$F9wm0|@n>@mG5P^d5;b<~0IU$p77)EdR^; z&gOQD|MZmizjN}xndgn>%)?_{*qd0rQ3W$_OhGIk2dDO(cZ+v-hqh9h6=E3}4sxC5 zJ@$slwH1%UneV`jqaCp7;33OzM-SG$myB7lBi{xu$7nWW62|QTK2_veOs|PNH-@`f z3Z-$Nbk+{KRkRcK`ev56anEvBZ&$7B#7lhlEju;7Wzx%kxb9ik@$5!EMIX&;jGb{O z)8wItG!Il5m&!3kt?20YMO|K22;~vxUG_^O*;Tr$=77h2eaz`f*m9j)_pdzuczF7HM9q>r3Ix>yjddSaka829YkP~J-~M~V`9HI}&B6R$NB`T}-q^^+f3EGe@!wB){?DpN zAUY|ckf|S8=4X*m2@EqOz>CAUh(4Oq3=(%T2<~j(Baic<)KVN8nv-)cRA0igx3C2e zMzUIdGaH9tZ5W*Htk=bL_8!H%SN4GxRC3YBSWxl$e0X%B>Xg`DY5?tjdU26Npj9uU z5uraQtU(Wv(G5ob0mBdy_Y?Z|4qa@N*{SnvxEE{gfKTj#RItkG< z97YrP>3RTysh`$0VYvi%;M(g8FyuzMsQ?nuoxLh0>GFDRjkN_8`G)kr`8fX?_J40cH8*xC)NL2{=Z7wZ%z6?*o#2;0@z3lOnhQP?oRldfzvl-xC~Dr8<+(Irx1+2o1-=J&U|s zFE|^GAoi0@O5fr$83`rez?gOE>!)8MEfyUE$>XY{Ee`&W|J7AW5xFuRN)`T6=qxsk zygRV5OOM3q)PJCBa&QGl&4vWxzC}}& zyR35OSUO+u=$hj5NhzTuJF9pB*!+er09_))InV~F1wfw=gSYtRak)FrWJm{rAO>U6 z1@iX%UUbz9I9k9Z(}%#FHeki^SFy!}+{ndnl!Oh5C1NoB2MfRqDV^lRDW;u7F)LXo zF<08Y4{$FW6CV(Su5u%wCq`D3>A0w<Q&xSQMcRDrB5Pgnc z^-L4|%Jl8iJl$Sq@H3eaio(R=svKY|7Rpus`5s3r&VMixZ*3q2dISV?#s0s$wVS*D zTiLN~H}7rO|a8Gy=$@cobn&R>JXazb`^1Qu7a zWm>icw}Cw|B^FIdbEfpNMkUHb1o^)^qf9hXK6k6~h_qz`NwNz5BX=duJ)!*&&W4ivdG7<%3muz&L0$>rM@WBZ{%9EUlzvoh zjJS^gBf`P4UBH(dJ>v6a#=^#hfg57P7lvtEYAuf?gfDR%F~5QOf#;>=W1UApWWk#<#!=LX#P3|A-T`<&ufDz-~XF)z0I@#?{4S#AGWsI_|IqR z|J^Vyw2q!2+&34s<2T&M3t8ukrr--SfgLGa?<8l7>D}u^@TiO3FuMEPQk*_?3%$F| zMHk6tlRWRw(df+HYPs6q%}UhvQcK@U(*P#)E>oW19U_h0xwKm6bATv?FF9~DP$lVI zlrQbuQ zB}D34;#^%h;zrFs8##WF1LYuKb1ng{MI14k|0$rJwRi`;z{@c7Z|W7LXFN1}!|Po- zAil0NE%i==0nr*)r4^Lz|5?%fW%mEI?adti1N)yA|M%;-|IG((>C+}lt&MZp0e2oZ zj)miDsb8$fK|7Rmi+xLyYoUIz+4iYNC6k2y`ma3={BN{xr(%F?*EIV0ZJSDhD*m61 zwH*H^lM3zne~R^AoBs`G!ZGI+c7bmm#Xt=%IHMMxn`9b?T2Zs=;)A>v4X$onk=lQ9 z$DJj04M{vDZ}m(27G9suCO3Et5D&Q(hdQ~}aqxbnt3K>M5p7x7{y$(R4my@SdTEr# z|7_*rf3|iu+x!2g-2V&Xf0}Cu3Sscc>BskMd8lN;=}ZG&6G;=SKfV}UV5Cvc^zwg( zfa{nT-{CRxBAi7-*ZBU@olJf3kK#$-n$9Gh#7fW%1XbaYx!Np`|0p^O55wt0RU9u< zOR%x0=(N?m92GkFUvU^v*NaMulPH8JQ8lC$2bCJ)-!aVz{_GbEPUNfU!`m#Hl<`O|n zITLLNe-{H_1&9GFeh7wRrlXJ-JUiG;Lm;yD-5y=0-+QqO@c~hSd2C2;`K)ae zvkKsbnx&Z7USR!u=FVKsI|0|-aNXh1)khD?hMQ+k47#R#L4iyIMv(3aAcM2QA58oc zTyyL4+%VV~%}kN7@N?<$&JTJKI5{6FjKE&tDx ztpBf%#0MSY=BMx}n80%r_~^}9Fn!PB!w9PO|HrdGRN}wx?(F3D|LxtD|M3a;|G8ZF z>kv2mIphqviCaX?P|!zIWs2P8ni&28_X`yGi9C|PulWExh10=Yc^y`*H}c#+3-s1- zI)TH&6t$c_{fJ`^_eds>oWl z@<+pRQV%qAduRV^-Q^w?dZ!l70@%mG&!K{!R&8?+@2 zMv4z4w;zx}N!CbV2jD(Q^`VIfVvewdj^%g&q$6xWy1Sv`^(mZD%RN6cHXxo?4^mJH z-S4s<^WSDajrlx0pBYm@;rVC3?$%VvQlM*;3+5=O(*GWh{$CmYvA$-||2A6uzpvB& zUt~HgN+tE9W}zWv`&@;>nDf`a|^cq7nlELqwYfQ z|F?Gy@jqLw{C`g8zow@t)3h;00*=!InCHk`uE8Xg3yV&(;*miF2+oz2Kv}`5^oUGx z%}U|G9BL1~5{;L_v{y#1Mi=5qy zWPj1Fe`;{Q=*#Po%rCjhdqt^A`CSYL!@_jWhec&hev{fXC0{AwNR|Hgc=nGP{2zJ# z?~U!&|MTlO|CPB@ma~7DrdMMI5CSx9L<3@~w*d(V-I-_KO8>##MOyNY z_TTfA|7h8GoELC~{9nt*|LpFz{vS^%|C>AZHbU~wc>y=Se=*Feakh`|tKv?*4ywr{({9R{DRL zsMkFB8s8KdCfNMmcTXyvze>?rZ~Z^+7N{!zdn*s_kxXQc^rnE)vF>$5)&6gau2}T`zrB&y|F_%t@2`9R&rckK z3Xo6qF1+tmr(&y+wJCCHB>maD9k)ur-1`5oNF5eE0{x--{J)X+|KDi+KcCb2|BInC zDASAWlN7+Uv=~++M~mk+Ug)LlbqaiFT^hS@!&F+f{=qkP5gc&k>wgPJGh;H_Wj|u38sxF+Q6Tt(lZAkS216u6O4` zVOR+FK%fYyS?orfkR}%R4!~VGWT%%;v1OA75i6dZqFl-!xULgJX+#oBtYwL1j}nTl ziljS4EXZ)6M0!u~qlk?P5TXbj3Z1w{ZX9`S?;P2_53rWk2qO`Bw@ig&=O=)WK+sYf zne5m*_wy7Pa(II{85!Z0BFM#DGysQ-qi2 zxtD2fesZQXXG;qJMJ@OuCOlLe)-F+lDOpm-jUyl%gef*SM(S8r78_M)Karn;Z;5Te zNtr!Kr_I=NBZxzOKok-9{t15BTQ;8qY@qzIX8xpGH#zWq8Y%cv@xX}(!3EYMshWNk zwV3Av#d#O?MC%2z!?L42hc;mg!SkJnHI=jFHfeXZ(Zv&1Z=j^E{Ww5q7L4E1E4N^Z zuUa1=4ia3Ju0IY^qB*E>H4&VBg3Iw*p@_v|m%A7|Nq-^SIV-bzK`nTBENX>3203`K z>x4t^#2k|+1Jr}E4<=QL7kn+jaIRBmotp>iIt94Fk7s@0=)X_~JA2-usfu=lfF!nq zPF-PIHdW<;W7{ln1cne0JhK+b8BsJRi)ZVSf3hh&;fS3ou)%2)v zyqFW&)=Tcv?#wi)S}->1S49=Ej9x3vNlFzTPr6v*_-XarL;^4IY+uoSYG(Z8y3?4n zJ{O#}i^oL}Z#6<&Dl~LuHhpbkn!)+-8=Kj;@RcE#m>K3=;;kX0m?NAaS^Swzsmu+L zGJRqwR!j%fk~Co=-k7@{6IFJ8j%dW$mSGm~X60YVJoyR0^W!5ptY zUCJ)w^lPMRIeoU2ODn&Eo?JdQ^g|4CO6XkjMY-aceJU}|KEDx%<(1p|F+jR z^ZZ{MYpws^^Su8zdje+ygsLtBijM@a#WICH6;0B8=TcbAxTT4R-I?nDPk zdeJZ5^u%tC*ozx~p zews~1jO`+(I$S@z*Uap~p!~^wz;-|z`CR}7`f@untKrsTwLO%zrk1{{%9WA@r_O|o z1=cuMy6R*prK}edLY`Q%Yf2<2DJA{(Ij@GI`J%)smo`s)qYCLEk`&p0Up?e3rW-*0#q$ z?HB#?g7j;~W?eY{q4)VcYTT$Qd331p?PWSOq{)Rc!IFI?yC6>ow;2EPrXMb9b2B&)m=ZLfU3?faiU zf&YDbecizSzTKYxzLN7_y@OrR=3STnJtKN*^S?`n3eA!`;pSlc3(6wVSzx-xAK#|1 zt6Kji2ff9v|MfLP{O9&&i~saI*MH*$ZtxK@;iXH5ELxHwxrdwW+}!lT?^z62FZ@nU z7M@5~uhJ3~Y+q9As@nk0Ujs+cz;!n>cJ82r+`mk#A<17I(aCmHqxZSqVKFEFp4omp zPhqVh*Xn;J`A!f-npZ7^ZR#`_i#TG-^-=3w|76Tdd`)Q+ay;54zo<%y#I9!%u%1 z|Id>3|1-Y)0`{McEd&1RT8sbvH0!^JepE^Z#MgfH;pkEc<<$bi^aQku$#SG=eEGR$ zbl@3>Gv5Ie1Oz1DO6J=W}@J&TdoA##1!WkA!c%7QQEubl z6B*ve{CnY4yVh68HJlU>hopK^(CO^aUmCByGv8Hjl=uRMuezXbSoe#4x$l!@F*b_7 zzlQz~5D>!$*!bV!uajUli60yMUAg~n?XKtSzgru-E&k&Z?Eh^3a~xF{zCD^1E{0L) z`zOw=domiay%`<;5B*t0e_kXHzKhS!*iOqORL^pg;c8bi2lLtVqAHf)0wIQvUBENd zLcg;w-n))B6LAFDmq^-K@b19(DR`hxLoR@AiU(!dT3|!L`}0{m?vHXKsCTTzw*Vsb4LvZ_5%Ui6b)_m z^?9gwDfb>5S^KNrdsFw=<8+GIud2QLIgGsjz!c6*K`qfQKv_1lYprqsiMS(3*(8-U z+S88#R|>2**2;R%kYW(n7=^9?QE2aRtzqN_(?QOnL)Q=QFT*KIZY=IBpqCqUMv!Hg z#fNr;f;b&J?J*!_^h_@XfaJ{-M&rwO-t9OszB#s|Pjv?OS1&*|JKIYM0F-C6cjO++ zS(JbEv*$Rj;kml^bto42+fyK{kDtNQ;e@qDCq zco*I-K<%$<|KG^tziw>pwEq83WB-Fm%%;HA@yX%GpRV4W{q)n|^rxl&qKRqI2bz!%r7iS4`h`VIHzm1lI>B-s$x0?A%zwKiFh;LhE%SrYlTlPi5RhY+rSql&GrsEnZ!OFJw7Isu)yDrk z$@%du7KlJ295>J|S7u5i2or6W2H?l*}*mIYeaTr=dfH5_$F_ zraGq&Gt^O)(~;<-mWY*!7^JL{hQ2Op+rEi`s-&R3y^@zm^Mv;qtoUitNvpA5#`1TU z^F4*^z#e(*-S0~T@SFQK9p{0KkT<*<+Hq3Y1#5Y~qZZ%oV|g|^jInC7iQg=#=y;W7 zzufOAMPmoeO_jxJZT>C1wcptf;VglBoNSvjmFX?_P1)WwtOlB&t%Y|2YoWj@bxeSA zj0R>J;^s#+Nbn-#Fr~AC2+2Cyl~+1|tG8~#smav7Vk^)??+yK4*=GsiK}WRK8(AHh zY*q4yts-Zs_1zn5&3gILGNl;1U}(T3T|U(;%QU#FF>4%9n<>yZUgMhxaC-|0%sYQ%Nmu+rtUCp zN*k^+$NE?9Q6+5LqoqNW{dX(R|FOH%#{WLC{Wta3su%oWnmQtIUwPMcAqo(j9MWfP zaM`n1|04c_BNq}Bz&Uz|39m7PNoWcNQgQy<*vR5PZLIIK=f9_1|8vHF$yuWEtxW`I ztu+pPa0wS+zSnAwYbyq$Ca3X}GjJtvG49x!xDavd#S?I5hl0-n%T5w|IJUgRy0`sL z1hff9!WGC4POA_pAqfs=(|bDt?9LDlTNDTqxNLUE0vQ|K>eWWUWWPp?Si%@)i$iZ7 z-EVtI-{PSf)SOvRkRED~bb*mOaVIy(x=HnOqR~tCPI&-+{cL4A2nX#yn4+gk)@T+* zh4?vf;ADE>I)Ss$ksP+y>L?(Og7RfWDb(M2OKAoEGrs>nccUR#TO&ZJ1xKcc{2Nn0 z#wJQ(h5nb1|Jzt!YyJP9ll;&71t?C(VblcqvwCVF5qx#BB3K9wX2H-{YuOh5rKza; z%L*iQ{ZGJ_sJH>DzWNjbCdg`=9SJM+zpWhq%f?!3|NmO_KYei*&KP5GFB^GO?xad| z4H)XW62bJZI1Yy%xZHy7Hl+d`;8Z3b@9Q4uNfA=6C*vrb-6F&~YI8DzH(MyvCcS_u zYHY59#x_D&cN^75OHm&Y$sOZ*6M49k6k4&SqCrn?35knPI3?H@){hU#o^(egbM{dKu92b{u*>|=_p_ZW2 zPnD0~148-$#+DR%UT+BW3o#+(XAAR?@}A|%L*i!@OAhR>KYSZT>hv1~)V)2m7~bRM zF14psLpAw-6M4=pn}{I$F^V59M2OJoOWOao*K+vJ>`|-#J%Rjx^~(Agj^Rf?{9wfo zlbg_wNjM*21hxljZw6Rm_hUD(ke?m|*TuazN93ye>9f-w>(2L?lOI!z zywL*zHp$BP0V{@cEP!i>QXKSfy`l49ZV*<0%_dFWLZXMak0eLq%vKz4qxk|!36RVmU)c>t7BT>B~Pwo0Lr>Py*P!& zL8vy^=P<~5{M^8Ziy$4)Yt)oDBSG!aO9*EktgC}?9v zDhPPP>V8hH_u$AlPLgT7_v+OxTamMyRW@N>9ZaVOf%BGa0>#ukN;2cUITg z{!Hh{3 z&BE<}n}+j$>;L;S_P^(}8`QXs&e*Bz-$NJ9ZwgM=%Ke6|3bodMMq6I+`d{BQtpA$L9cCdXPm;I-haHE+agVZ(dH>2iUu z@USfAHT(7O^gMUKYkKVFXXb+A;}ZmlE=c~<44+H?+t`6vPhI|RZSA!Ff35squ&lm~ z?CIDWvf1kn6Q+auF5IMK^4zg^VYDx?5D!j|Kb#GYl@z1U_a9zyi&bfb6MK3y8;#hn z_xAJF{i2tGDE8xL%)y92xuPL@Ohz5wA(MSO$%|Dq znVH@|=>8x5qL=9n7{5`w>atyges<&&C?NY83kqB+7X5tRd^wHWJ1?9Ot)$p5a4zia z@yTF0c4CW=W!WBAHWpyHk11&flK%Y#C3*4BF11#CV#~_)(A*ghk#W`3=Gv$anby7} zK1OMl#klxUy z!t@cs4P|rS)51JA2$-JBM2}6m{vcy(?z2HcK2Y3d4GHBMg!l3fkwPD}?0VhXJ9&S8 z`5WO#;FqH>`|MyK%<(@9gZ`}E|JS#-^Y{;&n``a<|0Mn&Rrn9mbyKR|X7Vo(pB&mV z-Mg}WlbR)0@jTUyQV=Q*4hkzLGH<3$8(ThtPdGJ4&>XEs>1Xzn{6N(s0{ymJO0!y zex($lVT#B<(mPxUfZNYqGYFjATnq!KWBQ|G7bI}v8pg`1`>$6&X%S{a(L}6W@IE-w zXf(!N;NeLZ`YYmut>x|=ju`qh83R0y#%weF<>+k~u~#GSmK`Ywbg7f!mO!K>4jRR} zfS_2hU=-7zVtx2xY{#c=%#J!39@v-OKSSFe&VYf8RfqQP2O>lG84(J}dOe7-G)Y>9 zf2Bo`ht*rw?RczlfAl#ZMml3yS7T-R$PjJExd`lOJPwn~P`*0)=`CtHvWaTTird5= zd~he>o$G*JND<@N2A_*KBV|r{@x+T`Hzk11PJ4weg1%D08YsA1qhpCdK#SGTU`D5i zWrYHIj?%K5&%6yv_ZC@H>RE}dcI^L9hZQgg5Z`LqK;RBvz`7wH!dc9AFY7V{wbXL4 zK%kzXoU!XN5e-qZjwQll(Uj-KDTNcV<(!C|KHGF5KFYb4ewNv&+OC0f!Ww1Dqe80K z(|gctHaXtZXb@*wA62F6FBP=fl$}g-rLE-|Z<%zMMQ)v>OqR$!h!|!r+@y96+~}HQ zNZFpMGa1-#YUEOOhLY3;98?tJDF3XNTMgc$0@NqfWoQggR;>M5nLEp01>~>VgYpuz z)osxMH2b(o32?N2Ony8!Kc)b7RIitv7WRGQ0eKcq9)(tymKt$rO&ti(tsx}Tve>Cq znTUg6%p@CX6>q)INjts3<+Sk=Wscsa?gMe)$9(*hze!cWbxzz9+_iygQ zO=w5X8Lh|C^h0*^4kvPj)yV=jIK1tFK62HhU-A7@C470P;60c#4a4a}H^Xksw*l@& z57punENUn~o7yUyTfY*h0?s@b0lrMC;6NW}0RCag(<(?^ew{z~3Y}@+TE-*Z#cT@U zR(LMSA1aE)fwnQB?_~OCQNK;6urzC>AT2(JSu`jbUR=o>iS>QBe9Gqc#y)z%;bnr1 zPKQs!x(8I1St?DMP6c(GUK~V`{qQ;!LF~iCPz=V$xDIiW#{I(lA|GZ#PSZH~$QnGE zh|J|<0oi_v>>F0dFN73Ol%5|gD9ce8vDx&&cDz|h<2b_^Wf`y|o@e`Y8+Gz|Y6nit z#ArYpjq6;7u-Wv>3?W742V-Tp0`~Ukp7p)UR@gAMLf_FI^qXW3-1|&svjwGW?G~S# zP(I=Lsn(_hs-1S~sx9K*+tDX>3=E@>!33fv2f-0VMJW-Lbc=x%O==|1Z;Tkc#80pi z@-5w!Sg8(OOa1r7$~0sLKF6X6ur$ypu|F}XZI8g~40p~MDG?UMFd1 zjwtN5$l&u9_#O5-M?b-pYZuub(y4OkG9BoeR>L)iT1nV_gd0?Dx#53VsL!!w2e5_wmHMdQ zLmk040C5V#f2@3@g3nfDERwdMgP7DUp2Ga`($641BdEkTpmjxLbs~QPGDQ=A3g9Zj z=K-Gp|A7qxTZ^+Plnod-q`tWja~UI&P^^E9R4WDlg|uDo!thg!3xAC3%p#%66@HjY zVxmISBG-~A_*6hWQe39TboRkWMgEbmt^?0cLk7zv`qimW^1z;%v6Ar?e-Neqc5-no z0dvR*rVb+^O0p4wR(gpLl+Zw%N%qMUj%_j>Kn0QF#O3Oki57s_2?RJ2cwWd(;8z!U zbwk`9NJ3CntpZCOM|M;m@IOh$oiH6JvG@2Tt&4x9*2L6O$j!p)t%2DzOyHRM$ANnNYhYrL9+GVCOn{_2{G?xl=3DVNQo2X`_l9Be3fMjt z7+P97Fm&-O5^z{XCUHKpUyV3y$UjSYAqJqYFy}3jG|$YJ7kPJVyWY!tF@dL=#df3v z&?6?GJU~}alVInp+greo2my8h3%q1ygWgN?=GsD&P>)IwIt841Jo4;a^i=?&D|#b8)y| z4RfH{@IR3_@K5Co%q>g{W+_V`evVulOrdXm`!-&6KeLtPf#dKhTXqqfbRS$G15{d! zEjUw6cXZ&D<-wVNTW634OUAzC z>{w={@hw>YSjtWpezI{@2bNlIo-RezRo9@z@g|y)P^R6v_Ou?A1BcaT)kPkp$*_O zQe%O8c)Fm0qp|>~O$#_8*Y(uh%o?!L*p7oebV5A-G8LdF;95ssgs?)aMBpJ)dpDW| zL%GEu+Cci1-zf7DCB?-4BO1~CI)340ZT4)KXQjITtGP>8Da<+ zrd9smzq}`k2X2%hvw$_V9mHiqR7v*jmG2%#{Pwno+d9t|gJ&_!P6ROGt%3I;(#I*; zV!b^45YUa)^x5Uv@!1~WK8l~mHPFElY^tntJ8za!7nFQ$0vs$e^SN~9XzPDxX@s2@T!#p*%WoW7}qfB?#j@o zWzlvc(UnOw_P{)*^^>WXXvX5{U-IfqpCVlvB&cc1e+mFbn0v6`O{N*0~C{5|MwOg5sV=w!TbOlkM_-s6^u@3_HLrs%3t)Uv+AQs0Vs9-sVs zX8T}6J7MxTQ&+C7zIrQgMXO~sX2oQEg$ygIn=T{EdUy6y=j6kOvkyA)+3VNVi@&oK zPg?swQPk1!Vlm>&b0eEN#Cz6@Y)QrI9;*5Lq9~0vE7Eqcc?gd0aXQbKPHKU2Fic#> zRDbQ!)0*{&VhUQmD!r}{_RaS`fgRGe^oS)IbN2Qog6m**_FT2jebm99j*@#OuU95E3d2{XvR|=XX6D8(`n-jIC~+Xh>BfZ1Imyk=?`u7a|DWJaHmfcL zYiS0d9_svVzK;HsT8SdmQ0t$_NsValD6E!B)-#{9Z>smuH`zTDwKoe+7kHY8QIlXD z7T%sQVkJ_KSFMk+JDU0Ojtfu=)1pwob6=3Wq84*YxA0vdbqR7HxC0X;4{oCFlVj{R zQ!1R4$_c>k!B;WhqKFJ1v{ysz9wO&Q*e5T5y_`mBp{NMG>7XRaU$A8rd@R}E?ON06~ySSzI z?BJh*ks{=i;bi?9j&iJXTKNSrIQjd@T-s()J^M=~kuC@5(9YvsWIG#OX3B2PajR*_ zTkm?aw6J;+AG+gWl{asG{HmQgQ%~Ucv}>bRo^+9<%m2PQp&o98cd7Cpzp({ef4cnNS#SCOz9sozhTwRN_2puI8$`P18~4_}iM*jyj^lHG#!ioMNhEUd zUX;5aRTo8HzCXH5!_G83$$sH}mXCF_UJJ3CMur^`+&T~b;+{3qKL`L@FysKUsD>F=pV}@y!sLXt>27|Jd?aNjI0b^`lZ@_okSZQAbc4^nfHRB; z9OX^XHkv*=#%QsB7szSGj?^o@7R&Z*>>3ivi0xxT>u|27PhM<4#G&wuM% zS^qEgq{V;!tL*>JWuq5c6nf8e{`Xlx9v&d1A?~ircQ+Ei-GUv3mP~`41Om>~P_S&Z z?8DH5`Gh95XAWVR&G?rVl7CVA->H4)-O5O}`7MDJ_}?h=Gx*=@+gq*t|N7;BbC4#C zp}<{0mr635J}IRkjwLa@DYH`YA~V}FV33mj|I=x1{UctC?Z1PV zkjLBtRM>wuH*@y?oz3-j{Xfb2uX2N@5TWSdmEq>`tquNk59JW2oV@duVDaS8Jgzvf z*2_)zu97i0%`l7;hpkVw4B=vD`|8ulEO9^Y=Y8XIAZbh7W>K^(Qm6Q$E|IyTAbY24 zy#bkPlbM3wj)Th;7;L~J(uOcKaF#17|Jph@AM_~d(Tin#8#1axaZK?9Ho_HeGGW5j z_VJ27zPG33PcRg!rbk|k;3$9N!l>}*NC$l@sjToG*c`{+)Jj5#&i&-NQ)nWq(noLm z&kx_n7Aq!V-zOlSAc*Wkcmo^!Hr+_J3e);RywKwd*PY*yX3F&FS6f@_o8U;#roqnE zRRXpWIoRk%DJH~gQ1vk?a^HISQnceOe~Y)~co@L;%OYgCH~{3L%O;G^R#gj0grqptEJZe@ethCU64Rp8W9d;jb$>9;UC8{Wh3d zehAL#c?sX+>i4xj5Jf504zum*J))o- zV@J~hir77xmXp$d6vgP(fA`1vIqI2XSEtI^!G_W#YD%{>3}&UzdF{|wImBfMUDMYk+6 zKJL&92()~^D8qkea+L1IF+}LieD>=?{A!9Zpia6PLG`sa>gwS5d<4B|DWqGjp~?E* zQd&+&JGq%Bgjz36f8oo)S zk(4hDrCGoKQC{B1AbM1ZdSK|@-p7Cj$ui*w=tFuTQ;2wZ_^lt9v&D1Xo0p;Q=&#DN z$yU7#-21YOX`86Ugu~3XF6-6uP<^ues^Hrx8xzGP3*H!12d;SF4%^HqHrVOp8cs3G z&_zu#%Owan*P4ixpac|PFVXXkF!+{D&Ln0kkjS#?XJ7VnMM(5My$I1PQxPglOB$PD zYRl@x{_IU=6D=}C(;Q9J8*=@h|5Lvn8DG+@qOs6xctkJ%tc7zdH^q>5j&6Eo$Rmay zNh+kGq9W}Di56ZxE@g0M=9eO8Np-A{$fFkV zJF7S?GT7;*c*`6o0@2kEk?BTwk|_q3l@)7{g5y?=f2S**nE%0btAhxGYS3TVa}aGy zgwDgAA$MR$_z2iltN}~Ic1W(8ag^5(@$fx}c8;tta**f*JlFB62{ok~#I6Li%LjTF zBXoMqLyXDM9g;)g!mm!CYH~nKE##RU-n`oYVKuG6aXqwr4v48Dd24Zq3A_088b*<@uBdlwn=n&t8z!EBYJNrHI zFxm+MncO>qk&nQWMWr~gCA|c~J%V=zq97?RnEY9-kiBUPPYSG0O+V+^Wbc-};H?k4 zk9a~6Faj3Yh=J4=l8H! zff7j5hN%?l7%F2H%>tN+tN10Qp5UwXD`&z=JKJ@M2(^&*?{d=i6NY(l- z9W(ls=Ti;;zkVDL#gS&1L1T?qwv16HgcPNC8fCBuqLYc2_-?;h^0#b5duvZT|Dj)> zh)ra?z-Z9M_5I3eQmUXL_(2Nu@k3MHx?mT*Y&IM+%RED(WiA^Q$>MV;a&TfNJ{N8> zk86+8P4;|QGvBZ{HE)s+yj2YcGtD#4XzIC}%`!c2!>N{LKCCmGxdHl_DI0X3F-Miv z6#+#bH0*GeB=xLDY3lcGFsm%QtnEA*s$l+XSVa?dBWni2DbS`!5Ein}xf44`AQ%+g zo(>ZBJ#S_SAkkK^fU5fWAaN&YpN~DZm)Q^C5u~(7d0N7$dFam~vh5O!-i`dbUo#do zH7=!ego>r9$F$XbG&qs3(+8NTY$N1@#yS#zp_<;gqnZ`XM-EzGIf9K_5;0wj$s0XA zP^COI29Tw@VS!;<@PBdhtN7EH`W`8;U}raQ;uzR}y2Aph9|TVHMk6aGr3Q|Ax9ofp zSl4ifyteREjHE+gkc6z(@-jyq4Z_6bMB!I57;};_1O5!YPr)W4dS0yPzA00=h6$0jBN`5xFWa;-hCHfsC z=t8Lmf+QHW>+HB;3$B4N_nRFySZ_sqQFz?Fj$MDW3ThyLio)}rb$xnvZG}+xy%%SQ zo~XSv?uV(SP%_{G6aIntXIa;?!^4A)SjTN*>q~*TkkgIm)&On<#`2EpBx}VD3KB5V zkJzVz=ceYSVp%qMpafnrfuv~es!V5Z*jfziyF(zo6P-i~PDHe4^98OSz!g@Z1_Y!M z4%bcq*g*e^Smgn12dod|AS8LTv>73AcLN?EWdjqtNxatqfzEe78@Zh6MK?`TP|7F;-Gxfgsko~=OvmwK{hork4lr-TDp20R z{f)&z>r-i%`HHKYV3JLE`kFA?fs0Kub2*`l*E?gh?&QaniyLU}AV{671_5{0I2+AB zsXn;>p1Ez$C8M)_+@YsET|v?R7)_nZ#*nMJxG8{NS7)uw&$?IK!O}3cC#RY-b9L3} zS1AeIFq_y+(p9UTFUd}qZD%8Px_Z^TL|!Pp76Uz9F)f{}FYAI`JZ8m&b&n*gGqKfG z^E}ifaVTSLFmO#2mnL~ymP{l-oc-zo91K-A&9z=x@{_3lq6f-SJ8Drbp8f z4ve`-Fwn17=-<2~;#tB84_9#L(+@&z-|{AG`+gVVS@w-O@pWFcm3=#Tos9~>681YU zn4&&Ni+RzN5iR<@!#b-Z>nJsAaiHI&Q{Bksk=c}%9;g46LOLuG|7mMIi~q2>w!6F5 z>VGZ%!`FiUkXbn;fDknm#(F3a#d>5NScnomr$qNG#)_!ftX{1wIsAv>q(w*%6(8uU z!F2dS=m|x;p}fP#I`)P!x&9%yZ|OEG(!Y8Oh%6kavB0 zfV8JKGhiHj_1>Ny;;*LZ4`-h5sJOKiJ9U#ylqh!*XUVX?aIAng1#!exTNWPHD-vY@ zC5yyqRVhm;g`p*nb5~-2O|KG_>qhyOAc&OP!Ye^%yO);rds^xr>9(Dg_PbKBl0Yf- zceRE;I0yt2@bB$UF0fC6mkaC-HF{fX_^p?5IE#j^sWbE@J@}ft#8ahD3Oc5iMvt(2 zR&@(0mLQDYWH20R_KY9&;ZAX7KSyb5WqV04`o=thCiB1@B=Q@<@yX1iO&q^QP`MM;D5&v|Cchg5FoShQ?%RCslIkd>=(}Ezg zw5R!geYenleV6Z-0&bD(*LQ|~<WrOB29Vyf`lWd;^9(ocbo3*TPu((qpXA@Osy`I_C)_x4WS zpI`pgAz+j~*+F&g-1&|-W)tD9UW{m>z~Jed4VU_s`uiUxo3wG8WjUb4|F{vvHhGC> z`>Y&i$*}MK{zt#~Yv32vr_S2y=#PH(70d*BVdzfgnE4ZWr^o7nuc&VogMMYSe71+U zr-}cP!8c#x{(n8s|G2xg-Jbuxn)t8BlLNoQsdp00CN+p4YofgrP2$>7SzW8bPuU_r z<%PPwmxYH3{zFb&%aYwuY*>*irsBqI@VT7Vht*V6O(#~JD00L(A{WwpqXQ|pB*)-Q zfzoRc9fUG|#DHm8IDV8By)I}?*c*;}*dxKm-eKLDW<4n1(+B?n)^`Qa*qH5h?0 z28Jd0KC#@-UYzuDMZgF%v8H+eXOk><5vNak_y`mnO`1Q>w>DQurE~2gcALe5>y*^F zFbB{I{Fl7`x4F6g)#LyD1^hqNgWy;03d*Pa)B1uIue+wspcS{^x!yh>$51wN($`uW}`m!k7L zh$eAn?Bm#Mr$vN5L{(<{?G4+!tts1lVK0tXfs$%Z6!$o4s*V4DZF@f7wFSC-)s_DX za{#OG|5{(m-T$s{thN4Mt^e0o=l>;E&J#F*WjBvz9$>j8zc?2#Q^R`o^3-wy<7u0C zfvKPAIkY@|6&?IYyWTkzy3V`uUeb`V-I~b zH{pf5kZJo}p%0mwD#uD(nIY>a#xJ9XoP-YP=S*&3oitCkrzh`ZGF#%VyxJo~YuZvh zO<(~&Asx-9Bkv^w|2nuDy5-&>y&Bm}P9c9NIrXvB3dC8?#cj%I zW;Q32zXjoappxod{jLrDsyUJ3MLOmlx7xzVmSRryho!nLn*1e=8*cwus1OY~iRn43 z_nm1#4IZVBJNUo?w<4`c~8ab~%Q>wl{%m358LuF6HB1h}$yw^Y#_y23T`2Y2tt&JA{|9Sg= z;*0;IoW)c@)Qh>dD{N&FZWfGYp5?VSJr_Rdag|9=wsUz`6+ogAvnmLToqmZ~P} z8+cz4p&@}E z;64xCV>kAgGJ4F8Cqq}^JMCx6`Z=7q16Icnf-MhbN%*tpI6xL^U~T;Yd{|}Wh!YE3 zqRO94{vtVz`i#)g%X0OpWQjnlG`Gub_R1qxhw|5ZJN~p+kQv@}L;dfvx;(=}pwG?? zB2<`r$eL;z>yoBT*ngn>Ov%Q!g=(?lEct_mCw>rrqNxmgUN}Q>=#?4U1?u%AzoU|Y zi0z;3bYc53rw+(GUCQUm@)a0)3i@Tuq+d!$2XzZA*x^Z)WYk>pQzUt^9vl`7eX9&|4jqd&@8zQbOnzt7CZI zy$O>foMa#Oi;@G8L+!*7m;`F4PQqzEZ2_HE!HidTI=M-K^UP&PnRuXuBqB+l0*9FV zV=2cgBA5o#%%1bQY`piof-ABA2<=^6|9ssiA^X4S%I%Du4AS*~8D56dpWsIbkJo7a&2O`6;O6UaFnZ{^7kz7>|zC30j{%?>)Mj{ zjjPrmvF;;#8e3k1u~tJ)%}zuTNn#JjE;7Sj8>Z<`L_PY!9fr4o_a8Sx)&*9Q>yNkdua}SNKyRk-?U6 z#&UxIOUJ?-^4gMgxBupl|G9-#wINi<|Lt7-*T&|?Myvllq5OaSaqu@Ud4>PQd;2QU zkFmRg>^YJ%0~wgzp&Q3`gi$wRCb@mu8e#~s8B(Y#2)R_gS|DH9q24Aj4dv^SS{2g? z(*bXRxjHMaZyppm3v4z}pdzAi6Co)LH3&hyYMlwrtez|&o(;!>)`~c+kgv$Lv^p)Z z9YB0DMhOeGUT)9R3 zssra=Y|P;#Af}iISV2ikOo{-JA4U^er^xEauNO&T<_!|&DVq~|f{l@bNMKzu0=1%* zBaFk1z!@~{nHTV9jvEh|V6X#>(eeU>eqaZfAv>PjK#-{n`Dev&s0c5(3;jFnb~te@ zKfLvZ&}GME;&kG{@nNJ?_C3%=^2YGC-=rZzfr&Gb4~3f+a#Y&MRKSXy@nVMsx7v|x zblrAw{~KaX)!{GlMTty<;4QS_ST^AZS|BL!`t1p*iTxJ__N^B*S~A(u24t{tHv2PBCwgDA2D6#2+TC6!4^C%mWg z!q{UfC(HQ2ePT)8aMYQe6?+rUXXgkePO%$ObPBLu^O-!_gJ&zcxz-~yjI9?8 zSVZ){Y}*PDng}jHQ!y8d^Neg?j|yi}Pc5Gb^C?_hCZeJTHxV{1N5B~o*D2P=nNEA4 zy>j7i`*A276A^|;DzOUCk7j@#v5$~GFv~}{VKYGm=}=?u!tfJ)F@4bRP{fD+-o`WE zb=_K7=YZ|hhb}@M!dEJta@VWu=;;PHDh@yD(4io)<4;1(7l!g4o&n@mGIqsbZj}zO zY_EnYVMiIlhRpM_VPJ=$P_jrTAjDuG(8Vg?04U|#536cq2nvy67NR(@_&!dB#_51 zbpe4Z*8j#<*8a1xvANdj|6kAjZ;^B`Oqlj2^6Opy7q0th9y@Tw`rp|!?Ejms{r4&N z|F`U{;^1Z6tMn&n0xEuC@jCA>6z47 z+$L@wESjnW5XjC@D2v=UL7xiVBC8X*L)T-PL02~(io#Q!;1OQj5+xY+Ir2jV}1`z&Rh138U6|xS4fJjgW zoI-6xdPs?NxSv$fImUkFcn30+1ghW@t0O1m#EVnu%K#wN;tpODc|rokBG0ks5X`!s znZf}?B9K06?I!0{nRc%{fX?(W;LFPbM7w*CR4tjC;IQBO3fVY6JDJKzhrXELpl69w zSXVf|mJjH@;xwW@?-wC#BN4-x9S*_1MxvN)Mg0;yZqa~q@Ku4+DtWEIuYOr2Y$FTr zFT>Eku_FjjxQ%!$LLplO@QDM(168T|?+@i~9%jNCb~QNv`Rw!rV`ff1K+w$9!7m4c zcL#^>PCDy5S8Lr&WX zVaz@?M2v>9M%a2Zc88zP007P*`){oEoqYQnLcNQ|Hp6z$=gNS&e_JL2P5+hRos&T+av;BWNAOF3(w%PjsJxl#xixAYO$x#?aju+58d67@~a~LP$iXpe9iUdch ztgCW5`SB+5+!5fKVmVCfXFFN!ff)}(33_qCQ3FmDvnkUR;15A9!j2YL>o44bDBT#T zQNUoq8GF3{Kt3j!MRG+r50O)FM$#R9JYvr30dV6shHMB|Z{*<(wIArZD&O zG>qwp125A&@;=MxKUlPOO-5QifNy63c`jCrV1D3;+6BQjEcW%8P^Ez!kQBGJM)Fs}wD9eimX` z=>}||I#IuCdf=ovz)zjA?32xQ4p!d;H+Z(n!t4({BAcm4!#Gskl_6~vf>af7RhPT9nQq$ zCE;JQ*Wo&x7}PY0kOFB4lYWpkdi!)EJ<*qT?x92ldXaCm( z`@;AC_04?z|8DF5|Ml(vbb5X3+qZG8a51@iAMh;~y+^(LJ}%`QLf?tiu1a(+URTrI zX9#j&$abF*vF! zw}RP_JHMw=7190*G&!c9;(0?H6w>nsdWi}p8$~EUbpga6TjgixmxHs@i_VNm)&F&s z^vY!-5#E<3`N<93$oA=p^~PGaUca{1dR5sjTy{)w?C60oI!^7oLp#z7S+6Vv0M*z@ z08k6{tQ(bC6;Kd8>t+uS4I8ijqtKsCg7o{$e-%`L|Gc)dmW%({*lzispV9q~ru6}O zKFx96^SjtRM7tv~%`L@nFXW4E8p>u+M9?XnpTqlr4*ND&^oE#aFB@B`oaGVskNuJ% zs6{3oD5xjl0igfr4NTWf;FNn0*UsRe3pyku3U6XJy0e8)1Q&5cuvIkFu?>FT7T$QV zI>0wLVkiFuJ({gCXt+ZjaOM8!T4chISAbP?Y#*AB1?<5PeRAEY-V8Q$ zGzw@zqA?U6+FnF{(=qsl(=Q6-WE;+%$F$)I&?F66-)>#>7iYUbXu3hCvk##X1#yhC%NF1AbW$;Ti{`x1z0!rDWn^eviy5bnWEiZq*{sUbL# ztm+;i6SfeVHdQ$!(Su{pIl@VKz$Yqf>5axYR`YA0;{8AG!QPcw6y04njH^7tP+yKVf}*CGE41gkc0Y98;kJgVF^dUwzf8za9V zpOD7s7b#mV$Jq`Ztmoo|4$Om>*Ie=i#n6Q-u#@-?AlIiAFmKhFVerP-5YAoxQ|29Nt zQ=)pIFWXPs3@0@@5<#me4|kr4-Nc$r!EfQZq^#?VD??CP@3*);Hj@8y9sw%uf3|b@ zPaA8SE&t0`tp971rr&UrFnY<4)RbARR+rJs-B4~JASQ(_tmwyQqXh`*Z$lmwuYb+9 zi)=V26AuWo799V#wYk2QTmS3r`Tu$Q|KubDP`m+%+R+a`Snn})VYd5KRH^?H(9z}H55UqxP}jt$%EOjW;B~d z#)k+}z)%qH8V#D_7@B&Qa=R|wm7Mq#fN{w>U8utrwx`4y7*dc@1=5abmebg!wm}2X z0`s7{!KOa1LLKHH*P3T6+;$ClX%gNj7w#!dE2>K^WkRzyU<(DezPzDAGm0~FxsN~$ zOp!=c7}*|-Wr%Z52kTBdK?BJqfIlH44r>T@RJ89$jkHkIePCOd|vh9dz z-w*T^!Ll>5eKuYhR8!&41gXyCs49+5^g_A)|JCz-v2OuBh4E@qN#7mLqKGrH@kg~z z5o|bW6dt&r)sp)qQ|9FJ6s{x|GGSaSt7^X3syjL$1lj?a(CBQ)SCgE4aQlihfmk=A9 zi5JI&x1W|c0j?WZXJbTo@e(4kvnLZKz2GQl98HjWX>^4vF6G!~xdC=3x~FS4&U|RK z@vb$_+8|1$E<+7UEKf9>aLXzQLu)38JKgLTzHs>mR$%{OSEatXJRk0*FpduAU{tn{ zgu4~2>hTdG>w!g#(bhD?mXc&uo2s=?tWuNPznXBbLoBgoPHNm|_x6fv*|%PHdHd3D zVBC{i>+}=o|FyAxn(P1DyK7r{{O9%7|NjZ~|B|qH84zEJ6;vbmudYcU8x@#&O)eB! z?2#JkIrk-%4i@}|PAVsn3yA&f7l!YJ(-ogdS6^u5X*wBNpav3Vm3$2NLJ&w*-=JGf zu!P#Gg2byZYGr6`eb2+kl!!doFZd-X_aXA!xFTA`&oVuhr{J7|?KvKo7)V8Rt4aI!v49j$C%e zakR(%`HDV|JT}G;K&u9{_2T!Or71_Fw3*s@>9NHg&XObye({(@@qBQ5bc)~g4OFCa z)en4Lw-K0skakoIROYd2J84!a0%dR^8^hHZXD{j3%L#tB(&_#(yuJ1LP&CM|DE_tb zuv6&~(-^|ZWjN)==P=^u`9dU-%7LbmV3RSFNss!~YlwKmtqBtali7sNaLWCw=Op8P z?Go(Ik1Rnhf_xcg;HWYrER<$Fx|mI;Wb}GmzaCvqDv1h8Qu|m7uw?@PTbd4snEH?F zf}xc8KapHckN7-~Dns_mREpZKRA$xtHkA!v#V8Rtmc~C^EfhY+ZX|h(N!l{i4fuY? zY<_*gtEL9$KMBXR*z@1o)~*5nalPe#f0FZGZJ4(#KwHE6le!>IWizoRqEkf?8F;5$ z7rt$u$g1`K_~^f?^}oBbmBWA7-re46*Z=eJ|4_|2$}XqH|K;pK?;#8ah$Y}chW6bv z;j>rIMpP^8@X;VrIp-9;2((hiWzOm5utDD&xxC1mlvzHrPxK+hPWognY2D( zuq}7vic3Os(=|rKJ|Hbreox`(T4Aly=Gb0AU9aoNwVqWVA@}D987*7$`~0pib*-MO zcmpPv^8LiKISbEOdZnE)EjV-cIRG=J?c+Qul#w)>_+)|me0E$)5&T?wAf1>+fRLE$ z9Fu|rn;{E55C8T=Lwhd1^3hrmB9cm+DiGDsw37&-^bq=_~ zkS2#vVQ4VhZ)T(t&Kd#Xw1V1iNZfUVvNYiz_vVJY* z3dN5Li=YgG<|T;8EK!IEQZ|Q3+af4Uzc!yU650#s8>?Uc|FQR`+ie@^qUinYr$D^- zib#i|M2*=};%-@zW2SA%N1~I?&B_uXvM8br0o(v6S?%q+&O@9hJWq1Jp$aI>AVp0! zw7O#nKvjJ;ee)m@aek2W?LwLh#@tY_pcQ)s9E^2w`RIqA*0dXIP_nB{UdS%~F&uwc! zG#f~8$}45;Ui*=wPu#DF_pvR#bg)0)=w^5w5wu&0KN8@nSe4?w_fc8vaWY5jTLZ^0kFuKC7Ch)JJ^H{*FCum=0s4~HG%JwN4v zRTwHhFJEdtdwA}L;@k|kn+t$uE)2!v61N-q1>SBw5QZHiTn?OhVmab-$L}l6{XrB> z#rhvj8vlRpO8>LIx}Ea>+1%W&^*@hP|5N4{R5kDCeoPXSJXN;n6%q6z^+;NlNh(Il zy$qUpUIuxsHAjMG%#k3kfv!Tx_7$kmGHLQ}&gc%1H$=j;-<;LruRLorjrv+;`aHY6 z{)tY3;W-=lng!Nv?W24HcJxt28+B^veDiS%fGVHfl`%ODcc#5K3y>0%aH{8Qi*0H( z%09b0+bxU{SIabG=2^N8X$x#K%K{t6VWD#p`Z!PRAW~&zBQm9riqZuaA+)6aB#F-G zs+4-91 z%FC$n^Z2>I0Kkh)0T?<32QtGp!+EgpqH3O znA`6_Z!CcpbhNWm@DlK>VNgzV=zbD3qG62h{qXlDhc<@wv?-IZ!GSerEaaY{Qrim} zQagP%F#RBR3dW0<4ZZv3F7;?tygqLPO-z6 zjhKxxTFLf>2s4(zc4U`w}mf4?DSy6FEEiWh<9>vLCuvc zNdJ>Sp|{8YQKJ9Z+}=**f7qz=zdX+T4|Sf0lDl!+Ob^_((O}hZbB?4B1;ABI`+&&@ zSeup#D%V4cVpa3_LyEsAL!8LV=74H+^y8soWJ5IIhxv+W6~>nEum(yA&{ohg^nme!FES;=uCF`#2PAt{$eY$9*1*aIR%%GP*3-^sj9{)c_ zpuag1dvIlP-NNT69a9p(pPLRKD__7HVGdF6jdsUs2?H|jK)UiwQQiQq+`{f4=&+OH z#!7N@UhV)>b93dwvB(3qWUH)be%VZV2dOd`#FZnrVGPE=m6MC@D1T|I@|T540g@+z zQvqaogUwO=ByZ1+7rGmDk?SgUdhJ<((7jmmBiWuH)ohJMAhEhOUgN zR!*fkMOQ=jr3}j*6=f61xGCZ3i+qlFR{sz;wQ;ZhnUVjSGXl6Q{&#CVrT<&QoUk?j zSI7SrpTrp=fD1cG!~lOon6do+-*x@Z&CQhkzq7TrQTu;BQvXjp5hmPQuN#QVtO(r9 zjBIm=X<>=Rxq^6;BUGp^g@ROG)b6cnP36H-t=5#d5;4Y_t8&OXCkbD6e%DKWEaRjG zYE6N;B{PYh<3(>Rh)mZ;2&HB_0A;2eOb zK>*i|n^Q2`rA#B4Ky3MnDzzPZiLi3B2=}*e8Dp{BvFw*|tlJ8MxZ%S~8PkbFD$iL~ zcd2~tT3~sETViIBLN$k0**CRRKP5@sJQ3V^h(A{JS1VXQ_sBck>rUJDByJos)N!%e^-5Tu1N%r~ACQ7P!~Pn?zpI z@#ni?yHm|Ex~z}z+YII-nvZh9_tBI2J$iT>7lO}{6Q~+1G}+a4G#y=YQR({eK>(|95Wvzd3xmfe1_5djG!L z$#bm0qQ%lb;x(<;F{Wkv~MfD+-XS1RsL(|l*@fF)A4|ks6btL zXHH(xZ?k^?3PZ<|&Lt|`yh<#1zFeHVs`8^$}ogI0m3?N}9Lj5fD21A+o*M<_GKkMQtPv?vB;Hf3lJIDWM zu4RjntiP6*2BgIQZ)-ED|6O0*TCe^89#sF!hhQkqrxz<385_#gt23qiNFAm5GJjM# zk@F+~DFBc1fZRI$$HQT(Mc)5BGtlhzpRKLU+Wu49e;(5QlRn)GG6Ur!sn8mf$-fp{ zx9awlpR|-R<-oMczTR_&o@)&?Q&RcIY4cJ+8)mN`g)u0R8teXSLn=2E>!%vAb1NUp zznXfh1caO<&5CK+*6Z@eM35}eatvb$rj0Wlor2g?z-mxb1~$s`k(Di zD*w;sc4xh&|Br&HwrQ zFU*B^K>33k&DIP}|A;sJ!fRyYYQie$qmai4PZPcm|H#%@B~RYY&f&ZFy+0aFU6FOy zR5xb-$Gu)bAJz!Jr?LVp-2T71na=;ez22$q{|{*YFHiLm#i=KbM`1RPxM4H%fMukT z$g!#J$L#ZDP}abzIGS!j^|eW{B{^QtNV>`!1@lLb;po*9@D!f){?8KvUKanona=;X zRo8!hX#3yei2p7+gcYN|OS<@WW51L4e=O_?@Z}N@sHKaQNSiANyhQ%%tZk;~|LWFO zeg7Yh{-?bA%6#miLtvaG@{`%O=bbCu3d0a3Qo*B`^Unu8LX`F>D(>b23r>Y}#P>+0-#Xz`*LG?cA(NO#?&8s3n)-c?a+u zl1Jh&ztT^gJLsFl|5_kuby85~|FxF#|LUx5*Zkj;!2e}L5Nt-feF8IQB-yqk`7iH_QGO!en(0ZKoVF4`_e?)&_E8oMJBUuGnp zgXpP7YL9Zv2|~?W6E)0Y;KT{3h14K>WfQpwMDQvLgUczhhxu?pYg#}SqB_T_karB| z#7H;gKeL?2B5tozTrj4pn5Grg;$x8R#H$Dn6K^kOvhUdCLm?8BB6*cD64%#Im zglXIEwSS2H(|jtd!37l?|Jm}X0X3DK`QZaCFOzWodn%^l{nQ^HL$|adkzTe!z4@4K zHrcB?SzxEKQhRvtIG(9HIuc??tOk*=CxHLMHJLjBV5$FaO8>L9-l^k19vJ`6k*qz9 zsl&q)WB#kum+W7G z|65&MOY?uLHUIY*?teD(U$Lq~5k4r>3z);<{8TOXI;uBYoi*ZJQc$o@ZP{pSY_9lK4n=)zMxZ?M?otQMW}0IgU&I%`J7#iOg& zjn3>lSvPu-WuxzDp zex5SWW$~Y#RQzXWtJeQKsQtID{~Y&k>WzgjFg4WJ9s0(=#C%}z59PK;Y$_98nG@7( zd6Y6btOqNLHmjP=#tFIqL&Gc6o(fn9$HhcfuwIztSu5H)HhmZdvrg-kTZa}*F~Dr} z*g2;fM@ApBPWO$W3j@NiFeB4xaW?qUaiQ-klpk{)rm7a6 z&acCqTbj7ALhsDgYLkx>3HJ+LzV~Q}A_e{;6MdDf;esmJM#Fqnm2B&u`=I~x!~m7h z|Lu*V1|f@}K!kow=5SJXUjLP=#uUh%H90?azxSgezZAsDD>M!SfV0i=e{|2iuVpH~E$ke8oQV2~797Kfe6{l8LD+k)?ZXM1Zq zeg8M>{2!0#{#Uu;1u}N=MaFx9c4-*n@aV%kxGzDgKsaKHV*dLB!L?eApN-X4;g@?s z_+_U>mcDoyi#;g;k<@^JUdX|l$}gTOLeo`=Dtbs?dJtj;W5h3jP~6bna}dqCrN?2D zxi|>H8;?uY8eN(<*vO(RV@^h-6u7wVs5{wBBcerJayBQ;0oYg;*c=cg?aQ zHxCw(i~O<`1?K2F5e#aqfFoDFNrNjhL0YH@DVf)@QkCdh_Z& zW_|UDGV)eQ!JG1VTSP|QuP7nzV(|ZM>i=Zrf8SW&+NkmWBflYP!j#d zGQOtk4dv&0&8XOu1?$||U85pJF?Uq*=`7C9N-cj!7tfiJx>B<@TDp)Bi{5|EpU9 z;GFnOHR7rm4*m>ve)w62cg{SvLD}biuX_oH_N|Lb4;?>cyID%D-igXBXba4PWB}wa zEg-<(bLk7x?uCb5RnLV>fti@1B!%oe8<-yj7SnJIb1;Av3NZVHA?*m3kUr6vOE;;R z`<}X#dZOn6ydx&;?I z=AfIq0dghxA|Mo~I-Ix)eq#Lyc+BpIbw#eC$Ea5HTktEfLYTGCL76Mk{-K>_2v2d` z327MNPb%yimJ0429~|!(2QGOu)5drLsF?s*2#nMQ=&L@458KB3X;3s9f$my9t}%jX zQ;B6%TSH&0ou$(daAz5XgQF!s9`PpZ=q?`o$av8V=$B`SDB#rP*@@``Gw&Db1e4EV ztO^X3G?)21x)PHkiwmwfM-@l?1fK0@hca8yZPh`KU1R9Vn&VQll5`3uD_qBDv6XWZ z^Vz8r8XhNEQ<_edI#m7WbJCmi4S(!T?IG%lO$XNJz@iE{n3t1skB=?knce^d7846` zd(#0hSAu=(>1HDK8Pb6yyULPJzF2uc@8H|z3@}iUFE0jtW8hb$wid&F%jjDU#sKu` z!u9d!12@){&pwJ9pCJMP2q77&Nmx|yTtP|cL^I(;i)jj0-)Qu$*b9cW*-`+)3G^6a z#8v|baY;ANJ3>e^XQ;-9Zfu>Ja`2WR+7}e^&_^51g@^QN-?d$@-;!&X1)IK(M&Ci4 zW8?Dq!X~o-{w_29(eER7fMG2qYls8pEn{qABvR!rWa6#9nsPdsU&Q8Au;e+5oedL> zic4(tgYeVt0J9n38Z)~4L87~Xx{k?Ht>;`wop^=#qDKilf}kcQtLH+{uHuVk;=80}BO1j%h~2`- zM?s-cjUNlMt`o{Xr=))UpVSyH`@~)!pGNKqmam)REiL`J6p@e}z9cV6a{T_Dgd8T= zu}tmEJzOHaBdP*l;0PnskGdn^ug1th6RxNC{WSFbHp*52gKb?}0l5VrW6eSYa8h+-k8lHkd$#?lrA7dB`K&vp25H~x0qd}`^(QtH~P(H_4X!wGM zFv63%$NiA`5fI7+$Q~&64mhYqG<*@=xty>2C|C9K@N!=}Qe9M1pmjt)RF~(t z&)KI)Z_uX`glx+)9ic`)53%-}bSEz-9LK_^Vkm%`*>rf(+evOYZkRg_P@`ZC;0{aW z0!Dl*3>O{F0s0%{s#_B7sHulPRlzH*Y{9zhM<>@AR4DA%ul!qnM(cB|6iavRmxh%&3h`z`2bhThhPg1&9Xm!MlGLEB?~U)GUJu$k$Ugv3f^@Vdt*jl}0^Sr#*-!e> zq4FtouuyYF_5t@BIX8+k!$0V>WZ`+_jhHkbfDvl|o6QWzwaNJ>Bxhh;0u`_T_k|~v zm8w4O0CHx;q<}aSu^ieFH=~>usmbk`ev+DwnNOyoe3o-%+7_E4 znA^}V18HYXsh^E}ZAn?|ld9M_a+lo+=Ouy5HKsm&V`#wvkIy1wx%CsvL4K$OlqA+8 zW;w-b2#%bjQy}?E7M!pfMJ0VEU&0>B>tx^TGMy=-#c+{fSP=q&y$%H?;U3WGk-QOY zBpV{&9ZtF*fWUR&UOOZaM0<>nhwxY(YED$*1JXbqmUuKZUp(YCB7z$|oDv3w4`o^u zow+pWq=l{3AvYR;RjrXM;RstXrSl*#oEMi7SH=_nB7IyG8_6|0^7)tsr*1FcOkOq> zD4IOwc;Utx%JIRDPfs?gus+u&=T~`{#VI~GGpt!@TwrdxqPZdd=$wW2tgJCkc zwQ5F=Ajh~Ucl8$$M8+T`O=el;NMweyFUh8|0}WDD%HC0GGwC@&cnmFXrq3CCq%5OR zW9pr4185zfnr3)1Oci{9`dAYQk(Rbc0ZfEXn!prxV1gE8xUe(fXJJ*XSHBu1Pf_rU zwJureUfOh~MLdL5$q@{%q?Q>5HE!8+~b^=2^5^exI-%#CmW zj7HH0zym_%E#L|}iP4pYDB||;{;&l)kLPE#7<4tp6XURXiMa-Ns7?st?_W=ue^B|j zT3855QDrL#gLzmIz(I-Mg88(dD^{b){&+0n6+U!PI|Q36N1pmrw9qE=kZK0|*fw6f zsPjXizzU{JC5Ypdc*tEo7t4wj&0FKKIpo|0T3WH|71zL=U+jJMiBx#7Xj7Az(Ns@( zt8u*o_X1fj6`?cG2mDlN1aBQAusyHXmxA4R213UeDqlnE-MN} zOdDC-8BgKv5_+eej0Rh%O9uHB?*(ktucymHj}8zD|AY$UQS7VrZl>z%$sNr^A!%kc z^D}kcslteQsFB~H0M?2U)`YSc+z~j;cJD`vxzqJfraC}Q%o8_ZXn6girp z72;Mx8329^7s>;n5?8YDK8o-we9!n8d21qQ3Nf!lq|T4tN=3gQ#LOhSEtzlmLcxLQ40<5i&Ycc)>7Ze9Tv`sEZ@f4xDMnkf%Kr?I+ByrwQ3^3;^SBZ3^ zD<82{ewN!=7GcmTP{~btjTcnSR&qS6Cnia~y zSZ>)Q$#~(g*TB>*`I=M^TS(d%H?#)I)nh7HObGg4MJ_*$<@qW#mM68M&?aiNR}u<9Hcq@vjWeb znbE0_IX~3w$i*jop?mWcoosCZvlotQbx4hCY|TQ23)VGP$e=#J#RoXKHe^o5%=+lD zOinB8nP;f5Xjm1?B|fFI&DKJG-`W15#cW6bpa;h)&q13v>_F{TUnw(mSe5!b<3PNP zH-CkWS>5B)&UW$rn){eM;k)$c_A&_&s$vO_s6quzsLxiWglnWSM>&=~)4?dv3dEKh zwUC*rfybh0m7ZOsSd}g_d_rgqY2|eF9NA=Vaz%?aCRZjUV40ktG-VYP*_4^2RO?$o zk?p&eBEj8x;==SeSS7(0GFOQBS}p$f3cYDuBi>~TBd{VZ>s&L+XSn8@O*Z+D&xZBk zx1E7TP?I{n`9M!$){0Pv$N=x%W1urLEMfxJ@IuBgP94Z|kMaMhV2itn|H1n2s~Pb> zTXp=;laBwdSS3E&Pa&%!G==Mn=zIUQ>-Fv=hyhDmYbVH^08Fo*s#*u>Mt5Ne z-MV7#x5@vX)2UfD)v+lf^%V*N7SsRr&2;_W_082f|JMWZ|E2Z+GEPKM`M*q$@-XK9 z_N9PlU`Jn;W)>>UO9msI_4Zz!p1nHm^^V`2?d|U$o}M0`{BgGT+g|tW-mABV$pHTZ zRBx!t#%}RPjI4>20ORxtIvkGr;MtQ>-<{SCJkSZA@A-35tE-?s~z=%tj zZ3`#Q3b3*13~lsbQxF+G=D|b@oN%rz&vhv2fDD;$%j|396stlUU&#EJtcLZktzba* zrfb*xXi_2|@;`)G7%zc7wtrkJ(n=Lo_$%5=to*`zI>SmZ-IQF)1ot?;DkzXm9U1+@ zu2+00qUw8#nnm)k887@>AHTeXV0FH-2&u}`mVOEnkvW(pz`4M@Sw*wv8kn<$vBYZW zB!Zmu%7?Su``PZ?J(OfD!PCVR8E4z^#TCM?v~V!b1Y1eft!H83cXl}SGCSjD%)2Um zc*6SU`T0LWyp`C%QvL5%s{Y67My>ySVE*rb4!@sdtRylQtozjh&wKyl-2QFsKb=ji^^lPN*4J0-`~PU}e{~M1 zx&bLwVh*m&8`Mee|L}w1-&~%%woFmEPaL6zb`#9N$vzpF&VwxF#9N?S#?%%Ik|t9n zj5IwmYma|Ustb{dp*C<}o#bwO+IeK*?5 z1|WpS+E#lN6`9#g@?tr>*eoT3r*IXzddREUywjFlY=Z*F<1CLOC%}cMOC@aa&?_n6?C6I zPG-s^jTE^OSJGx-8w!A~Fmn>?^N@2nVwF0o1mVcc!;)wjmL@J0$X_(i(@oPUlYHYJ zza7(Zk-f>sXhW48Ti1}$@?Cr6ff#xYqw66f zKa5@^Qmi0G!DUnBK0s3C>)~+W5AssB0(3bR(oz1JLSa1Mkq3Bdv{h&2qB$7v7;6Sk zgY!x`oOH#M&Z~iS$t7~#30_*vEI`5cn2e-t96RK{j~5$H@YFWFer&-^nXkte1582h zYTv7J1={Crtk~;hp-r3$WkI+$C!Aq`4;1N-<+fj0jFmCm9W#iP*UEXiu^`^uHK$KSCiAs?hW#)f z=uEF-JuEjLnf!kU9rA{sQ|F{LK2pmPUQW)qqGiA)s2&n;XqY6SKof7q3K+<~G1x)Z zbw4n*K@k=d<7`rFMikmV`kR!HTRlQaIO87|6Hkd*O>TgD9$$}uGmP8kisL3J8$^2BsZZqfA@sRPN{H$NcTTlqke%FZSlhUTX{z ze3Bl_Sn*|T=(FB(Mk^02HTjn>9UwXvN`JU=KE;JG1c%lM$abEmh=rqg)~W{|KP_5< z)dzk#qC8!eWJ8X3l=YDOG2iZryAdqf=KA12GVo)fcByH#4Q|wPfZpVGf#)Yc z(_N1LSj^?Eb6rc}8qhgy03V860haK^E|*7Jd(B?i*H3x3Af5%pcP_*n?|@^ntFDO* zaOjz#3q2zIO=eF|BCldNBI z2+MB@iXG*4_Hr5FKX!T;K9;@<@nYHCl=OL?jc+r^zS3bSbG@SjPFt9*N?eosmJZo0 z?*R`=V)#uZud$$_%56P!HK}U=H?1X(xK@DLiv9*zmpuelRyIoCLB!F(<=M5t%jh>P zY>m#bVHCwgArdu_7mpGB&rb97uJ7Y6oT_nBXJkpiZc;qvLfCC!ikNsiIvsQ|0PI zU09W{KmWRTAlJhhFyR(ey)4@E&UuA|{>!_(K$0kVoM5rl8QPUMG1|(II%?Rzx*uj< zo3TVK1!s^tFMIaW_4wGz8WXIi-fvlA^_?G?YN>e#yiW`C-|VppM1sVwPk*jH5h7(~ zAi=adI}mAQD)2Z^W3m2Kmx55#P5-9!*zKpvErN)EScFW<@IraVzmwqw{== z0Lrgo308JN!U&k9qV-*JIj5RN zm&TQHR$kk2J|x%vmnL`00sB9C3KyCu4!-m{e}*0hjjv+u$qN7ZJlEAUs-f&nMA!Kd zY$v-7MZ#*K&2G8+(!(^}nk4LDu^E@O$$pQ`zv*A5uB8&EST;=6^|5NHClW!)DCeVR1|HD|*AwhN3Eg$2g3M7)}x{J2`Kf3u-O7k~bll+~AfA;Namv zTN=Ds`YuE?2iD*L+!9s6^QMkdu#B8WUoXe;@h?2T89nO3&HfpsCG|;+MVa6vIka_L zrcsk$jNzH0(oBPXroMX+Gvk+nS5cP0hmGnpuDw=TE7s14YHqYM?k_KHH}aE3X@*yS z=wFWA>zmV98x`ssuRMG3sWtY}HZ@aXr!5bm4uBI4jxRoHJ-Kp3RhJ^06hfblb1rAL%#C|ezM4r8(Ha1LYB$XiF(=xL;fJiyp z?+OIuQrQ{Hhx%a#I8O+Bmjv$L{_^qW`_6F>){Za}pf6gNigHN17D2mydb!tGDK(uV zw?IZ>7f)dMd{7o~i~eh0lDqzz zhq^kz6~e4ZTBN!xQZtu4bRb-=ySQ)`{*z*)*gh;2#7!WRb;^(tU34Fxh>oa_`*kPA zYPs#Y?CbkRo=;_zELCau?#eMcSahh$|9t8S5d}L-n2HxkX!=Uav7tARPVd64l6s_? zlMtP_=BsDYmceAr@~vW#70k}f107LR!M*$vk{(x(lijCEIpf6XQd73xmgQi3)~B^e zQdfT&#BmH|s;2?s;G$AwlFq^7V&jYIR`6eT^P?-r*~AwphPGJFE_re!YfkE#b2+g@ zf3{2b@3#54<;P_PFh_I!oqWZ;IdO*6mx@GdJY1GF=+v+xnst-)u={!x)za%x{bm(C zm2HDX^L1(o_NC8Xi&sCyU(h}pA4E0I88Cpa)A0Ui^bT~EK3yh1PVVG3o3!T6Or8JH zKn(ss9Ln^bsnJ^1{hh|`|oVf-eS75GKpg#Mt=lfHD*B;T&pN&NbWfL8p)j{b&n)_93Lxl`lb^{UEk7DBBy(uTT}7rbA21ah6) zLgWlk(5}thRgD6OJ?u8X4ho%F| z@G=pl>jFiA9WN8&gRn@+p`MGfz_M$AKa+*~Q>J<5N$^d~=QHouJZ?)0H#3XFtM%6n zViZQ|M*QnfpOJ5;V{EE6!`- zWVV)q&vCE|Objf%sE>e~YLr$1qJsA-W5MX;aFxF;IZF{e(l+Z+HxoDfizWhPn15ed z5yWYdB@(NH&s_88Dj^QHr-R)h^HiL@+8{dkM@eQ?J+=Bi@_k(ORi?d(TgRqGD6&xs zc2LHCjN!NGes6rkOo9;=@V=IGClB zC(3i1JPae%+jFDa_}%E|-tKH^2yqs(27f1yzX>1rVYeVgX*%T6&B$ylF^i!YXBQ@d z7=wR0h~ulPa6&^G-TkU(NUKBQR{F%ZdSRIVkD_(H%L49r33FrB+;6!KSNZ38FNqu* zJpXCB8N?iSDyq~41~=v0TH=@OAD`(l+{2R;n@8mwv!9Ik*+7hg7_f&6eFa z(@x1Ea^wHpxo>#n*O>{_>ZW`48)nQ8@g$WW4wp)uq-I;LW@Vy+gRL|B)is0gqxHN( zJZ!G`o#<`}oIcC!CgY{_XkCb+Qk3FSYt+2UPN?ZD8Np8@&Eo1LU-7axDZS#@#y$9V z=j69k9HDqUTl?HcM-WHI6H9tLZr5A<NJT7?~EyvLPu&2U}1s<{vQKuM^CA7e;FzL{}*CUcfjse1*lpv4*-c)Aa zR(&~bWu`q*F3NjpKawC!_(cVCjQWONzEx-MAcR+L;up2^aAab3iZ)cmE#VQjB{nK& zwVtHZ>rNf<7bc&CsbhIzBf1djX#yU^W?C_~8^O}HCOgj$$MORUG*gJYtgmg8O}WwE zXdR=E2QNOq7Yxcg(<^?DsvrqJ&at_Z&gPS5g>F}YT3iv{MN@9? zlY&Y;WtL4c+f@CR?0|;&p2+ynqsDV_EwEIf-CEZ5Xg-}(C{>3aoQdZ6rd6Y_8O$$x zgKc6Wb0Fcyj(GTEL)pXc(((aG)6ZjpB#1y?&a7gYJv6bH%${i}A>!L|0lchguv7rS zE{2-amF%LFnZr1VYrC-pUr-gISxBX!`}K|P_w*^s?~pYl7VXk^bo--8enzMkg8hRM znW6KfH1^;!ibkq-fHv~Oq_+UROP;lZs$o>FVVtLlZsj+jYjp@wF?LHhxz1h(sPRdr zTvRX?@=(_#OFWc&(3YLGV5)Ejc0=yGD;0tddqJFqaU5SPK^?(N>n~CUl1_Z0qUQ87rM^}y%eHsYOQpp2L*A^nasH!!4DL2SU$L2<>`RLyMfiQ9tBQ(5wR@C2u-=dF% zpW1XU;M`zFfp0lPe%*)lZ;WBz6TyM@MU+42Pf6s3 z%(mkcv|D1|3XB+nv#OA^j%L1Pgu%Krv;@kJjQ??}jS?$>(8ao?))m@a!qv#S$0Pm1 zIDP0olfYv=E*Ho<#Kftb2pP%wTUb{fQi~hzN1gJGV~LljxX*F_=t#%ZA-W8t(v}Nf znpgl|bn#F!uX0+`)OZAcI2Qk4o~%0ec>saV>4(ZpS=g-ymtEKE^vM}7CB*e)Z;&wl z2VZEo+)Ez|wqDPckvbz}xS39R0p#l+KDJuM!t$=G8c78yJsOErvFKzB=s2`7PX|Gm*&AKn!0171p1_hY~QsxS=veFN=asfxZsFG1*HM}Y?FXA zhip~?Y22>%WX6XesEgfQl6SdS{MF8TSRu=~EBXf^@ltt;^)f3o`eC51CL!YE!ee8* z2+mUA1Xs~7AL1)})ip~zwi@ zl1qZIvhhQ=)01G^XC+mtuw8H& zZyStA$NGB_`S{C^X%gzd-~uBQ5bY0-IaOh6GkgNO36DG?7@EU&1zv|A2WgL=4!*O} zR5C#d(O6LxGL;QVE8_4_wwAXkVZyt*SEKv?G;&mlq8*}kYhg_c>6Dhe+LPbUY>NT? zqJ!I~E3>PKxWu7wSlbU+ca4HWvdE_6otuQeRTBLQa#p#1$X-ndz(g|lP^T=Ap8eiJl(a;!1t1v94_ z-c}S(iNiqCbd9_@L4|f1eL=vcoOjo{)39GXhAF?~Hzd3>tI98bR%op))9$Ht+4c6J zIQSP6Z2k3!b$Zy^yOT#UVrS8)j8BY@rRai_wsnv2$IltGj3dpz zGqRG}O0}(kEWt(U#|Px@nJ=yC_gRL`bWWPmUspntg%HDqzyq)Qb6p;`ME( z&+SZi2?AjxR{SbA$qhE*7+QtN@>Zc}svj%Km~KRnmikJt++#qrKJODk(T>LL2!8?p zYTX$quY|ScV52Z;QaW;K`Y%UQbm;}r^7!QVcHq-^ebIgjZ5`iMdHA}p?t@?F8PVBB zEsp#p4P9_2$M$zssT>-O4kWnRU{t=uH?yX1gx6FKiOZg5QXss?^_$#BC~GPu(~p8KjA0JsEdv7}+@p z7SXGZV$kV`(@zzK3Nm_3%xr|hQ9#+>NaJJ&$}Wn&=d)XC-jOIaXqedTNl)9?=fJm zFkVmHlgpVUx7~?x1;BC8QPE!Pm*;YA{^i|Eiv*bFfHhV)8?BFI5c3cA?ey{7OO_-a z@O)0`mf)K#JpT9C*^pN=Pi!T2sA(84ZZTA1p=Zt_E|bQQYBt*_f*P^D8a*l;X0eI` z&E^+AkCrvUv@<@P^F&{KXO2@p@dp@ z4d&Z5pmmpHCn#`eovj*V7rKlu@A$L5!igeSxQsDnYX)1*mj|;#mky;anj_k zvPy+-y`K=w_%|6;Yb_!_d&KV%)+gyEu{@}&5r)&l2z;u|aHLD}BA$_vZXK%`A(=C# zhxt5-lwF?uIYIG_by2?;BEs)yBOcBEjc(EpfiJ!f19y+ly@99m6B2If6c_usB&#WH8$?5rFi>h*1D^oc)40C8Nu1Rs) zvn@dRD;S3f$BI4p*w$|yJFYD00vnU{FJBV&C1u^9W%Lb_;6Q~NeHtvolQb;cw=|BdpZJ=24@m$4f~yws$|yUXI5tN@9~pBlA{IE1kljh+MZepw6%Ar zpMr=!0|xVKRD;))SWqc$t-0u*$fl22$Yc}RsS&#l-R)&si^xS%nlTKg$?Zf?`i-y3 zPGQFbosg9{N!DTA`A@_N${~%ZSAApeRDZ%A7rI5%+`kH8bJ{O~5!XVxCBH+%Z(2|O z!yG%K`NBho`mF^xwLTqwB34COd<1^1f!?hS(~~qU>x_FV=bGRaVuYd@Lw(~7Qi#0z zBteU^V>`zHhoR_W>QJX@)othP!CSd6u*k)w{L=ZM(K!cRAs&TTv%5U5H2KB{jV$04 zq3~HXCEni7QtS(pSiQDs;vydD(?=f%UeSu|vE6z*efRs;m)*pagg>nsoq{Sm5-`q}OG$2N&O737gsGIX`N zKivPlIa?*YUfVD1f*pNY+b^z_9*>ghb}_oD?4m(JAA8#24N5-Q)mCjD4=5}B%1S(X z%10_|3-%$IdL6WjRkH(|T*EC*V zKAth4wI7}ZXH!f7S!aQzkoL5ePTb#TW{2Qz#56n-;K50)xdQ}2jTWn~CZj~2Yffi` z?Mq0^*7FMkcrS~n{nYQ|ybyP4yD4Gxw84E1!E=QiV)$^l5;nc@NeogOLP%@TkWRtI zR6n;Y6rh9LJW2m|5+Xys(;725ScLBx3gyWO1sL6-gJ&lIAV(M=t5r zD;1?pCQ95S3#b-!w}7fnTIV@W5Uj*;Ju ziA1db7T1}PXdMqs=0Y2t)o~Ds0oiJ*3|3Svp2Xy_-zIn$Bt1WPBrpa!cq2oe)CuIF z`ggtg?vVl|yeWD#%q-I^rr7!)Lw;(Wf0IALJW}!UO)bJ!ThB3_NHYUn>jnxmjYLGbJQ=)G)fbN=w_g^5glIN@dwc8qHqRBE zHPP~$N&HDH)nVj%wem#eUT={?i;Il26Cg=~qr+o_yOh>u4~`K(WbsA6{#HW7HaYD- zH#QuN?HPf29&do`9&%wXQY7~fd@@!(t3De2NDIU=-X%(GeGtS4tfYaa|&?^cK4VaD5Mcq>+5v+3n=w{RwPF zHt+(ECLjO7ZO`}Qv8YFv%h6Dwop*|`&$^*oj$jp;C&2^~~Uiqj4yTk{UO`SH3W?SGQ zZx_rCu#Dwb0NxyaQLwfyD{BB>rd9Bty6}x3F-VISz>Jjr{HNmP8^Qkp_^ASGcR`1$U-nh?~*fpJMd!6I* z65z=d;Bqw_kRxh*P8U3B-sHV#>YL8PezWSX`Y4A{+Cdd&ODipTt`|?Pk-yDuHW|b_ zrEfL|v}u9O7tajfcr_mQMgB6cY`}28FXK~(;H{B}BP-YT|9AL5+x*{hi60OAIYt?% zlD%A@^pidT+5Rdz1#BwjZ#FTmKQ!p34yZusAO&?*CeuJc=KurNB~YOz-2VtNv32^8 zsxNPpp8`HI;7vS>iq>8?iCw$--q#XdOADKAF2m%iz(sBM3IRLo$<>wHM(jS~WMJ@R zbGEDYJ>mQHKCz*gU>6cybBe}i#jy|DQk$y6^G+A=VemF!Q3=>~k3UGb`^T%b;ZG0$ z6!dDm+39E74Xm-ydy&}sO5SxkQ1DYpO>*qfhUA5FE%tLn;IzWsyq%bi%?7?shXYMO zP5AW{=M^DDiwHA!p{?d%k%VX;ZdlhJ6I*s~(&EdfSE>gqGWvynTsdlEOLU%q%o^Gv z4#;h&?F?8Ig((P)p4-?HBYjH~!yUUyJrxumNDejt#AARel>Eb)pdG78ajICff6+tQ$+E9C}e$V zgYJnmK@Mf|>nPGRU`OJly9yKt0BcmM@{!q;%Dni)r(EZL*dq7}_A<;#tc-7%5bO}d zL^uedK;rJM^pfT154+w<^a4EvC<9bVp7bjpAJ#mdc^As8YX*|8*VA}mG^5-lq^lLo zgG&gEePqdGnSg^&_(8_m$y*T7GgmcJ()~0Yk7t$TwBhO#j-Ug?HheQIE&N3MUutv+ zIElHhn#^kH%&97FHIrk+)_*rFh`Hm1@7Dt!Upl0!2}Y^Xm5$Ln12*s;HlOWBcHi8# zk?RyUx2L=7s$#*QBgj)2T>*I@I0mh);iXd+=&Z>I`><0yn!5vzr-7p<^y|ahSyfa2 z%`ax~YoVW$i9h3sc`Y)-L48LH;(1%d{2WI?r#Fc6N#6wB_R098Unfz%Dq-4Pfi@+9 z?Rk{DEHsOdT`gMtO7ax?^EW2zKNi0L5>EeVv9+s{r>BY_)aK31RVV6ASjeFrVE(<6 za@F7-TxC~y(rMFWaQ_ba{s>%_7YQw2X2>5mKCT@bzQMw`MY>$l?F6H_fdhfb_80^CJCI9|9aU}FBrhc+#hcOK9qi?guyU$v%dIk_?fT!!wYl!=H zZ^Xx}hV$^fce*uT!u90inZFhvfFie#aq!$D7-0jb2hf%sHg2uW>}+(zysqCssbIUR zs*lD`-GIDZ4_@qBaDcyhLjtI_J=XChjL+Hqtp3AH z{W!+4)LZKBZHQ9IZN1ZwNuTBkcy-|!QS^)#xY3X}Xt6-qy3A=tN2RrXd+2;?YyA)g z(EtF0r}`$)26zbG2m0$P|K57Os7puyn7!RjQ2O|sSp8Li?Y;BKP?M<+c506Gx0NHI z4M0Y;H@xG0|4}I8Ba7Fmq7ZnzQ89Ra0T&$!@nWI19p@?+s`;NN%x>8Xzf&Yo8zhOE4<1Y8=Q-9r zrjW_USAP75@D8-C6Nm!Vy{W1Q|3KF|P?O1t7(NK>o2*49_x4~eg#XJ7{)|rZT zNOYy*5uPPlfb}DUDLY(7|J-h;YgS|-D$m(rmGo|pxZO7dlTlnnc9AzChL=4XM4V>(9f`TH_TqbT_K^t4)DyyX*e8ULETx7kY`Gmc@5q1 zBh$vehJ`O@V zL-f%(@rmt%kubEy^RQ8ef?eXchW?{NUy&kR?~;R z;#qd$TR$@Qv@bkf92E{S7j_gFz;pkvygM;H2pwCy$ee;w-WHF`i*`W^izhFa3M*hZ z{ut(5NLZ%K72HLe zsWkRLHI8l5n7&dIo|CjLLt7avDj%=b1r0xC0W{Ae&Ot>eJXKw`sJZ+Qc6b zH-j&Y>8^6hSXT@}gk`1?^NmgQ*b_y+)XmK`e6M6!31|Snt!(*lCA)~;chq542W>W{ zM;kA3ZmdFLG{hn%&7k!oTixX2Q3rd$U815S6W#42B=AMaeJcL4zTY;5!IgsgQwly^~ppTZ&{0U?`_bilI z4>mZ7!Z>-4w>bx2CJGQFZ!pWW3xywvZ(hhzZHrV`NyYe-|7(~KxJlKThJ4kyBO*`6 zts9XoV0Ew$yk4QxLb;rZi(RyCKFT%A7DLZjJUlr)6t<4sGACP$y>4R`f;6`J%59fU z&MPz=9BDpk9~bS4^*1t|CHC95YU5oH&eXrSc@ z&i9P0I|#1rDlNM6Q~lK-PHsAic|vtF^8=s@`IgQweO(Z4qf$gHXb>uc*kjrL@EYvq zx}BAUJlmG6OjF&+kREP`tN?oYBs6e|KKh6FjRw3D*?MKaZWI?eX^@&Siah(^u9q$( zknRovOkE5gb6cxrru~}7)3qpG{6Aoh@G&3GXHm{jBQpQ$e5#=po zrBo^Pm{>*I`%oV=cOz{rM0~#DXQrsO{;Qw`L5%~A0kSaL??D`xz2&^C5AgU=DW^bh zkh*tX8`nQnAq%mRFcEE?wuHfm;^f#>1XbO59Ha2n;)g^cqdahZMtYM}fG__WU&rtV zp~3siuO@+QIN{Gzh}kuQ1+f0I^`*r>>JLy3?k&3rcNK`ZVbQ3{o&$dU^7U)JAH87v zSL{d2tmCZ1t#O{W`@6TUH*fQ3g`qob`)^eG9g?&hPrcQVzSsyIdkZEq;X>0M|9ySt zG}1X;gZf!GZSZtPsH%TN7EZfo!6}Z8$bOxU2pfV1HHTZvhzdyeE|dGE20fOISWQWJ zo}@>2)7f;h6{l)V;ihZ^PPXV^dgkgp0V(@=Gl+0hgF@r1FiO(P^4Z?@S2Z-D&7AVv z`4`q_&-&^@=%~!T)xD(><%lPS{oiD-m&VNiD;VVCVk^)OxShOZJ>IIspR+HkYggk0 z9b0G*sA$*n&o7nV8Qc@Z8_fu4#Hf&cW825XN{he$vJF0Xjwuf;S&kN`U-8x*oHb#; zTXiRX3se=R_1zI^4t+U1y#Xe6A*%YHk##>{AO#B_z69WBdnIt!a0H4EIpXx((mvEa z)wRyB);|2&X>tZy)y?0C^crhWGmi^SO>*A>1K~N~eK1h=^L_H+LyzE+1qRGYqdfk? zBlw&857FvOlsn7?JOie%+002>5r|u??@TW*)E6HU0UscX9bkI4=qNGTYp=o++`|dh zDA!evu5&~%>kp}&Q1Q)?7C+dxl}q0{9EULlnFX<0V22JiD<8C6u6!K(PJ^1ZKS2N~ zkXU^`hV3+9C8MKiUz5NeX6kKT`@VDl*SU#XNZkVsr%gnvwUyC+tymC& z)lnrPy?rp`)rDF8?P#th@Y2b`TmBqhwwT;Ht4{ZmO`NW^gB1{_r-)CXDtodqL%Z|?cQp|+cNIA#nGg?! zr=6~FS4=ff$!S_F4P+hD6Td{`->(qdJ3$8n|?9x26$FCpq(E;j09tc1cP4p1~dW zD7O+RDD8W~n}Q7L*!xAX88|Jcf@;OrAr|)!GlH@>j^jDO-ivp?lDk}<^^3vB>%qEb z8V9O7QfdL$Vk$#9;8G6Ebmy4#hl_EJZNQlLF_nKpnrOK+Lj$i|Mfrs5Ngim7kEZh&G0(8R?{18j~@Xn#OrKcB0%(oHG0 z#JrQHAv(r9ocOqmzh7MkRGedhiQ8$uP+FsHto(NKC;G=a^=tF4Sen(52b<{LOr;IR zw3j(ZQzOKkD#DWW!-G=Rj`;wd(t+|HzF3!Bwm2$Y>CT{}yjJ+@l{Hw#aiL_G_V zOembg7QrB;=O;&GLjSj^o(X?(hZ@y6)w{@#XwRb$L@_u>)*9=G3J?fli=J7c)M28>k-iP-aeHrKTTALkSe za6=7GTyN4P=}tOCA~MA-irl~e<%WS<@ts1Py9LRM zQ`3dXV70us0)z?^mAO!L)Onk$8L;`YC4Ii5BD~LJ21O_GW)1`0BV|gT<7P)LmUM?X zjZCz@oGoH-;mK>ag0W6_^iJDVgqWrno^*vuH)}k5@09TGroN|+FDADmjx@C)jJjUX zWrFv8)-ONab|96OOU>#xLPkgu)4ysoPyC_El6-ILwcP~V z2d5YkdG;}dR*<_&EP{iP7xh6xcF?rard3tMU-{bM}Vr7P4x!zughg&gI(@5B4`hqVqwZ~ zGW`pc#+cAM;+aXk_2?u-JRjF$hV}?a5Y|DeZxDV}L#Ss#8-bB2A1+^R*lH!|Zb;O$ z4*l=qqW=nFg|sov^jg>wGh7Bk-h!NYt!bXC>Q=NW_B&snw8G8+q>3ju(tz0-1p3#{ z%+RdjgPCk+DL7Rkj{Ki30~y~Xstz7gQHkdtw+&ciN=Us|9Rtilw?nm1J$~F9{d*d5 z+tgdSX!+Ts5Cy(Xu;_jlP2^K|Eyt{cg{Ra0k$P1Htqn;^xg1&FIJ)>rqKd(dovSPc zHx|y%WaXbSQKfk9V?;-T#CaXtJ7` zoQK+%uscwA54hkttFdwBx)Wc=1$-IY1uTE#pedLP(~3X0hJLj~Kl}H#Nzb!2asg@u zYgjn7JpkS_;H<}PAYqHVWGFSXCfp?^99B4@2vh(dUAbtSS*E5hg z1nKJ-R9yl-Z>r?y+iIy9aUiD;8Ept)Skx4l;K%q6u*N@48-Cr?GB7B+7g9(C53a$~ ziYClsB>Wqwt=N+AsoW7uRukgk72tc{wFJh8ucxnrRkU1^Y(p%FH;B80iY#VozahaM za`-qkyV<)tHiEqGT3<(PJ-OenPMR*ZFB969_;|T_E{M|G1>Vf;fato7>({!A>0_hI zv-$0`_RZEoYHgiR+u2A3)SAFla9aCs6PT{+>E-Jmk5};Eqn^uGyN4)W9(sr(FYm9` zQv#k&?lb4-gy+PW?HAy>?EUWTGPKT*&B6Og7t>6it1)3cz3u;*{HtT+ua6le5vmso z{6!jEecW*+@}=j6u8{RPs*p2SyLFK{f_hA*m!+y43EWj^EXk$G9h!%ja0rujYZu{v zj7uDtY*<`;sZ3#>OgbErQH4DcFSc#sOS!Lc!{?csU4kkApHDh)V^&I0zY^Pj+2d5j za`R_aHixNq=*t88Mc-1p1Y4&OtW9?}B7=XwJh~-?^D?q!HJ#rv92dbnZ#pXUS4amX zdkW2l?Ya7CsY8g%{z8yi0QGJZranoSX+H-SM3{!L;SL8}w%o3_r+>h=1pFO(wWjj* z`i7eRUQ=x3PHnp?m%a3$X7w4}#=TkW`|>XJ;c9Q^Quse;6roCSPTNcn9p@Yf29x_k zrYj~cf^SbdJMJKjj1r9I+!snA$|V)&zy1aPIDgW$n6`54IFZNuq)_-F1tC&1NJ6DuyGOK=c_Vjtm#(nZ(8E3Pk9C$2!tI2DAnn3GpMRhw zG%~Y^<`~QyU9f3MIX{C*I30%4-Gz8Ld4IQ(X-Y@xfg3JRRa(HjWvImPv!BE!MB$w$ zqGH)YEKiZgxB=u3;n z#k=ozPkI)P2gd{N?+n6EjypNA8{~CP!%u#CDV^T!f*4ARfRi^|G20^Ng3q7N<>)Cr zX|UhvN!>}wag9m|3;bIgU`-}nAZ8sB2hrk5ug<_Z6+9rltznwM* z2q(@0$9DuW@#+NGiu${s5i_ve%;R^A1u`Kd5}xI>v?LG|qB2Y-mGeVNY;C?YVZPu( zj@~yYwW6Y{h1;lARVdU<(<1))n-MsBl4*jXASf?JUvab0-V71Y5}){G_yRjqF@;1T zFS9C-yp5}?HEpkZ<>}k6o_gp#ngsftk>4gGe#JpWrMl_6zzenWOVUWttt{2pPhE%0 zQnY%W8FFa`#U-p(Gi;EbULSQ%kdaKjbvP#$?5{iEPr_~*kaV4um2;1?KS1~6A78yh z;vF{oCr3*micFIvvuK$;2W0$8xG#4ly)MBuiHwvOH?JS|^t1w2f4jhJY~e}`GfZv6 zKBlJ5hkQea=L>B`F7X>Y@B1n%9E2A=grmJLD~W2Y>5&CrUTWr2L;cv;?ThJ29g9{Yg|43oi~?oHzF=Cm^kvuN*TFm9;YLrb$bv0{>Lj{<+Ex zi)f#x`P_J>;_@D55*3O>td*tRl{xD0!XGMFV$1S`YjPlH1RsTk>qDfSrfP6+xw43> zf^#zeT4>vu3QJ6tT#r5Ka~BzO?~DAs=x6zqj;A4NS}7IMXF9iUs?nIAD#kx^p_u2x z@s1`~hCGPl|E@5yoU9KpTP5^{#+6#FY{iH2|K7kk=!7RM@@AffBEtRUE4(S=YT~xS zCn6OWH0y8txkl<+Wfy+Q%0F?zrhzZa)bW&?M{XIht3g<|zXf3gZvre;L^4{5A#$aD zjlRZb=$wpka%wt-wZHoqr+rFIiyeV%bM1wTn0u7vXl8${0OoAraTaVHI2iN`#uh?o<+Mtb^TIWVVIo7DhxiJ!No!m;nEiaO*U zLP^rHPR9!7X?i|%1~KU0_UULHV>u!Z1HSWOw1hq{sUc*O`6tag)Bk#Jn;SM-vTmCj z#aoWrRyc{L7$qC3ONj?*GsKIOju*v-uT_zI@R@8|_A*MAU}$zqF`uj!atsN6jK!wo zYHaN0yBzZiG!WDX1^4v7NXOLv%T%6|NkNvT%REwclV^0_AJ=|`(YOT+svqr2fXfq4 zR&ZNgV|$I(E3h%BaBigIwd6ZX)L3(--oRvgE~E20v(QohzgwPH`1~~STwHN$e8{Nq zv9R7;{-2kJcNqy^8gCLR1k6se!k$45Go49)ZZ%@ybRPyqq3No&7ggSDMVf677g2TU3c~5@AJpN1O0XgoUOGt>dYj=e#RY)ezcXgPgCq_4BPbK(iOl(J0 zEU&;DHhWm*CnUMswy<4I=jvhNZ^r}qzX=`w{o0{tT=tdjy1Z>ylL|q{da|+Mx2uiz zi~%hC@7r*1xShR`@Ip^2Zw>e^+p&|{vzFfA8JlQ1;AusvDNlgew(WC0a#bjX9XH6+ zk`H++i%M2mZqR>IXFGpIm7-Rp!77&}twtQdEyw>p=H2;yxqGQLIMZHx#sQ*fJ*fi` z)n2mDM|iSzLeV=8hSZ4OFiMD9Y$sFY(w~h0VG^U)XVK}8n=xud$=OUSHG=8SE|iU* z_9mk{oae6iGPa{+jnL)lYAT%Jtzy;>R&*YaHAsscUnEN|(;{VyQ-@jHSrb~5vN-V_ zr|UcR;(gqtao85_H}^`qSzF4KFqZGD)3oo5HD@-t@bU0*ph{ARI8E5X2Yu4K;8P<5 zpyJ?y_d6Q{GU={SD*?sxzDXf`8S?l2ICV1`b^6qrHpH_ywLG-t?s}z=3AMB%$6>?@ z!OST_@St7Z4Wp})>yB5No1H6SW7CIWBj&kMci`%9Ge=4i`uZ#rKRsRB<;sy}g3AwJ zK`;~i< zQE1TsUj5IJu`JeV=daDVY&X1C+$KA<(4noLv~Y)iI_F= z<);1@PiGYrSJMVzBxnd4+=9Ei1ef3z+#xsw51xepAq01KcXwGNxVyW%`?BZfyZLYC zYHO=bovodDyPxSk(_tnGWL#c-Dnb6jvx>1g6j`%OSJD!-=W)bV0Buxz5+-OPv-@IA z&-^K3zyz6@V5qp#D!_kXJCd$EARD#kQ?J)K&0uw<9EPHLMcR4`Zglf;q)h%6uUd9O z!`>Wo!bHQWprj_Dk?xjr>%}^G*6n?yw^0Ah=N736bGvWd`LJP&PEPigJ#gBB32B~* zJyCtpzZf=QB)HCBS6^o>EdoXS9vd=c8DH}#nR`R-W0P&R`JjE#Ti&Y zi^);dSLlh`{LEcepWQ#CAPrMU~6N?tdQ%RE=wi$eyq#6gpf_AG%IX$>9?dsv3dJp+st@UZ88uz#(>Mdow zb&K&0gAbo5_Q+EEP$UaNF1mNgdN@!TTMug&)W;3K2n4sO{&_u@FD&Ux)(d}Io)0)y zfM02$#j*6o`A|nIAsV2CtzuEd;dES6n9`BRZ0nPT4&=>IArOb;1 zS=8Yy@T_)IA;2{aVr}|)@)p$F8Pf^8p=l3gSAg1F`_RSi@@=}g;(*5a?XN7>yfjv? ziq(kss9|e@^%3mVnTm&~Mi&3yz+d?wv$l z!PR-*zBTjENmK~Iepr2DOO&?_c!?GKlU>Fy)l05(Pe(=N>f;gspPD9cI@E(#vH` z`f`8fg^9+C|C)95->ZlSe*w>zoPdNWo<#xNj1c$7)bkCTdB9as4FT8W>Wx}mR$O^2 zd4CJn$-e_ylryB!vr6`#(9<;21vS=4$96^2X4)5x6PptrGB&RV^&zd4Y zCgP-FGh;_{pJ9-cu_{bm`WiV^NwV67g#27R7lnkDPuxMh>9HMk>m2)>DbK7QjE|5iF?)|a{|(3Pn0BhoGe1c65%N6^O0&h2UT%lXgYSPKroTz_py_o+?WY^ zlZ{T;oOPsDKW;EXkgFmLxO76G=q%O&E?Aj+ROGX_;~-YHv8{5E0RiJ_ViU3K-@ITC zOLjzcXbD!@b?uZ*ce#Kxszb$|Mp$K?V_PRdripFGXaPJS1&2J>@UVJxSL;a^OZ7+8 zWY6;io_NH_f?IwCnKxjhh#X{7dabELJ4EptN#iy>Y?Ek)rXn`%9Q0FJ?6JV1o!23) zcFxqLpo>RqMeW+ok4=-&J|T+*p6Q0}sze0b_w|k;_^wmf%f9v#sFFBPtFBU9<}q;K z3Vlobfx3U7Zg=A`pgF#9XIIWbDpfGa+s6xUYLuBA$UpyT$;U_>P0eiF^R9yqO+-i? z@Yego!ax#4T0s-*R{bxMXVYEV%LhQ`pJ^Zcu~=lFHyg3ah=Zfcp%H~CRmfdYv?%R8 zaz)B%;bh!-LR@%|!iR{r(rqqB3iTfDD6UuYaZyc%jU*N{VhUnQ87H-`x4%t#3o@OW zkbHi%RPbv`d{iEI*|Ms(tb|t|LWr{5`s+fwy{neWBcG4lV(AD+PLX^XdnF2(?}Fk& z0&nJ}MC%c#l1!}po;WMTr{d3HB@gVo4L==0%DxN=-rQ|p@)&)f!3`<7CE~;(##mC! zyUopu>AFq1+`wMKXVyiWb{x{5WbA<^%u~pGx?T{=BTfkkD||;c)Pc9o6i#hRsKL!Q zd7;#?+>fP{9nmp~SZ41ahUsZ&1TA;R`3ya>s*rtGtfP_h?Nn$+Y4i-x(0yi00EF}z zUN)8m`-2wVQbvH;>0_!ytvIz&6)o$rlrJo8^R*{@^lf-XCwf5@(u*n5i|II;3bhT? z`u{C2N?&U4CVEZ*gF#vCckP+W_ZRUE+mG^|jb2=m&rR4*AO{B@o(#a@O@#BAz6U6y zz(UHe8N#JEmMe)rovu0K-F-qh{xUK3tNrS}&5}ca1Al=HiDM4bIg3I3Q7UKDT5o4Z z5H6rQs$Rt8S1y7u$9{hH{A~i; zd7Wo5T*K+QMqk#=o^Kf^$=xnSy6{^ge`>v^F-}yWmT%}houwVERNnFD{Zl>4l-Vq= zPzO!2tOdM%*01e3jdaVEFhQUp)Z?!l{{)-tnVM1JthRhxMH23pwrm=T(6t2wm>jwV zDw;K!(=^0#x?tHqkE)> zNY#S|G1{e0FEnR#w;`OHk4qevNlE&lU}K?+A4VNR$b^UWFC;%eR}QmGy{)_vH*_^F z*AB(;u1KYCKM^P`0?|hRqk}-B-U|rSalHXY*{Q+owHKyc^hf6%z}I`mHUT6@zTwk9 z1n*wKvd{~z8du!jwv(6pgcFd}PL%?dxGv;jS?~I7n{a$X9bpfFya=EWyDQR0LCc@Z zHm?a+@5;i#ShK<7!mp|0aPndo_Yqj~nK^GvpjLAddC|_F`>ZrAEuKHmSZc_+lvs6_ z>F}h}y5K%AKqdZ7{U6u6OPK6$2?__JQJ&z-*VxcOgfB3m!C}QLb8`#WoLnvuJXBPc z-aqvsWJl=Y`Yyou!LM@{V4lYfjw|D&o4V$Q4UN1*ZWS;&3v|uumHd^Q7S@n-X}znU zd)X1<=oWk^`|~p8Md6+6HNeX5^8w&{2B@Zh_sV0w_&v$yfza@2udoW9CxJ!^C+)0J z%;Qy`S?7puR!xkkO^5E zAjWtTVz4OOa2igV!)IU^?=E4sgCLOoO$w(Y8j=NEfa17>vUg{#;sj{?h-nEagAz76 z$TLBEu(X2s=o1JYp9^;%|ENGf(vLc_L-Qd3BY1DNlB(iGN^kJj*J1_1XkUh=&cZ#Sxx?oO)N%Hn$&N@D6 z{(Kte^{ugNLfd1|dY;1ahn}{@!IFN#N9A=(mVg;h2v=4xVj5b>OQZ@r1BK;66K+8r zX%m3iw&(`e3po|y8R6)!-->jJ<0K99mDdu;&VDPU6tyxy6$P-9(}w%Jv9x}^zXNhp zgsQj#KHzOo(d8!Z&^LC$70lO43tUKU z19j}qfqoB(0F#O+jL@?vHH#C(Y3%5QENTx9#6k%@cpe`wlmYml8y;N%eii_)JYqyJ zefTG*B9@0rKi2Lquaa{^qW(G(Sa4JX$zZEhfT6gHHS&6v~NDdRFt zXTz-D9?yNW1?KB+G*`}m)I(rkzRW!?2~#Xnc6f|OuD=$1oXsX}cfT9egw%ShrZalS z?YrGD!##Z!J`D8wZ<_wKQ2})EY@^Pl({6Tg=VDF5HitLms+OECG1FN-2s83*321M4 z>ZFC#pqX6UwP@@NqX@l%AH{e4ZE3BtBXkd8RJD?Cu*@Ua3EOe7Fh0S_1wU4%$A!a| zuh7C00~dPaN+<4ixQ6z%`N{12k?PXyo@My2#9XzM1n;sJrZSU4b+@awWssT7^i4F<@#lp}B%4QG^urW9Q9G=?~0N?`1KR8jU7~JdE*ZtqW zVG9X1H8#Bv4z5G%I%73CNZ1EpBcP&&P!+Nt-l;Gqnhzl2mWQmh@!u_gPxLbg*PoxP z+FC^y;?d5b`2AwsW>%qCn~O>?of>w1XM^88oK$t^qU69-?mRVtQa@LCMJ0sy>oRak zVb~x}k`8-uvJGh6L&^UEAIt%t_(#yY0~?G;oc`#PjCaMV%y&^}6slp<^`Cem=@ii! zufXJwm2E^%m*A`8r+?^(8XCVCAsa($fX4@@s20TP;Am0wegM2>ni`1s?UU{jhOS;T z!}w(2X>%sBQgJAdBu1;vcDjkQ3|oZz&aw-*Q@VlfVC{&ytVNx2kA(p!)J!kpVSro> zI*{N@2swBZh1&wLhg=%>LbJkv*|qg0PN*oxGZbWxM6tHBE@a&*bpNEu-1E=wY5JP* z;!wfV@|-r44n)g4xHx<~{YTQ=iM^XtJz(wm;p0>P`iv~5(Yet^ zB=CwQBT>YxhCeIc`Ydq<%Sn`dg!hD2=KmJ4YN4bu1cR<8>Qd#^#LjeU>ngl%CG?Zm zlW}~P0g=1+{PDS@>~~HqZp-^CQm!sUvX&9PrmOTlL~VWjSEs0p36e5s?ql6S>&=zP)}h z3mRkqGneo*Z*msb`}6P9jB;HZKkmS7;bbGQd-_9R2QXg<-8Tmkt~LZSNPvxXb_gJT z15gR`>4HG_R~_Usca+sb%W{i+_n|mx&!0SB(J=4CuRrFg>8+VD^Z#sMOk!`#Z$52i z{%j$>)Ae2TzTblm--xY2&)JjL=+9i_WK&T#9+P>(;zO?9sY>~e&0ur9^x|wwgUEzA z=~J6V$wu!V=HG43GoeMoU6$tXnipWco2M*6TIc{(1F-2OA2YS|K}=UwPNKJdi%!3a ztYhNCEiaDnI$=UtD9~kQi4RwH39id%1J*dN0CFy9o+=;RI+H{c-iDn*%9TGE%}+K& zz)=Hu%ZPB?y#rTz0k73}qHoXl>i924y}J`jQdo^dzE8OX6Ln8X!URnKKG3h1QNj`1 z&?+SNP-(FO*Zima)E4P=k5u>2A#=L|6CIVXa6$nY^hUacv|I3-%x87(AfYM zw;+)lm4;Wk1E8+X<{B(w^eme7Br8Fmliv^_dS#u-EM682+@ydQUqO5~4?x=f>qc)Q z@X3;87Sih=1ZQ8ERBj=g+b~`oXaPxUF|G{z@57~MqFzw&49_EA{RFh{XT6gNMPm=X zk|mspi-WI!j54YV2y`a0_b}Qwev5xx9^3$6P}m;<%&RO{m#b#pqzoRQ+?OVYQ1sA! zF?^-5Jcs;rFJoqTUWjK1q!`lT8z7U){p&@L@Rrs^{ zZol5`(f3<%Ph%A1f-a{9 zSKX8_XQeT{;(gJupO{(c7U?7IN4IZ-02zfy^`(*8O;=c8ZcVL^?R;+$7ybNyd(Qiki6ZfdCeXH0(}U?XLW{mdF?lowL8Ch3hO%%D_2Fv z45sw$T+G>r)uk*za~(L{-2m(zi2*>?3%DT`)o`@=w+;{5=lHab46g}Oy=hsT{iDJz zH#bN0P$-WRVjNFc{Sjzet4Iex9sa6UkZ@gK5=6JibdfLTEIr>*|MiUuCea_PRu_L)>YZ+W>PU}@ zHai(Rv`v1>SACx-5Hvc~He z*ZI)WUaZWhPpVCMvWGQ=q?8!fq)yACYqAIqO5H$^(P%=)7dwLqBJ;Uq|n_=3p9!xIPXck=;E)o7c| zu0OwuyV&XPsyxp*Bl!staNqUM8(a!}{>;EVkLSEgkSW!!M~jB*6n>jh(^m_b#pDtU z{ytvUy$e^2PKMnsV8Yo$+&jh)KO-^D)Re;ED&X4)`(8<`PCvAi>m`w4SbH@P&(GF9 zXJBWAD;`%oA||d8#S(Zs?K9QhqZZ!wjv#@)bgI7$$oi~O^1B+yqB${%@aj8kTZo3Z0>qbjzlsqdU#r-OV3R$86+!@2`3#)OKOQ z5vc7eW1~HI(&AiRZuKs@_c~_=8E+-94}G`VuRS;5HI(TEFWUIA`aL-KkvJR= z+HAV@GY! +rxB8TXQN(7ewdZ_KwWMBb^cx^6GXj>e0}vSN}Tg@Pspdz%OcIQPep zJ6V&Y2R1l4kdQdMFFfE2QAHbB{pE!YmfpCsz=Fh`vXl=fg~5B}N5zetf?*pzAM`|i z=YxVgtR!&&kAYP{SpMZA=~2;noeMxv#rk)|gQfHL9YIp#y2jVcgIKYnXb;cWS4$5X^()|)!)b1vsP;C9?*Z!5QP<+I4r1Mt-Tezl$EFYQ z34@g-<&sy~W3b7NR0N=GlrOG4(5izwaE3!1VA1lJC7+NU4RcqQ1E+!x;77l<5CN|D zS?-f4jc8Cde4vg4;UiHr1Ea5_HE)z&3<}9W1Voym_afV(T; z(F73<*&o)Xf)~bu7)#x6$U}@bmWK8K3(kOUpsgEZ$hjPZZY_`V-3UQ;KZ^x{j**bj zRrC^MbO+W0CiXzfNA^T7K*lym1}8cTOV%P8r5_*7w z>|93%0fi@BpQ~*`FY$X3-%qljpT2&NTYpZ0o13jEsQ&f&$d>F|XVm5&Ut!;umk__& z2MhA|4`34CRlu$NA>h={TS0^jat|dCZ(paA1%sjFFOHEzR}@~a7N83dneTH%yBGN3 zsOgpJ`fl^_b?ahacV_FVJI&AKG1!;i^VQ;FBx`qP9qsLl78iqUd-OlKEIr;BIaJ1d@}|$9rwiYHZB{^eI54|3J_MWytCW4@im}-8s>OE zJ|>5$wA!w*RPfMIN0aWspek3KwKF8j5I-2_G?B6-`7#{(AIz+8@;Pm_6HrT$hiWp*C2z}_suqJxYh&f%6`cSb!d-Qnz ziL8X<04KFevY2Ulo?~XC*>(Wz`{b5i>>0c(`{;{BGo+rDk9|TPPmjN;*OA4FT)a*z zO-Ra3!wLJRkbbB&mm!?X*?O2$*V1iA&DAVkN&6tL%5awdM>|_@_tPoaEqG44R1ln4 z{mbXQrgs$rf%lP6g9^a$;1(Dg```++F~6|k4^Jh(1Po9qtA@+!YDIsMVz#{gml{nI zdz|{%SIQSE;#uQgKo2XE+)NCa=mRBnHy-4}YCehbx;|w!@yzp+MJ4s=%8kx<&;CB!8cu5#xBaB>?==it{sxSguk7(awY zIf3$PdAm$7s)<3-g;ZIiN5oYj2%9;2o@M+O(sApZ4S!eHcV|50 zPB-k}$T*ZER;y&1>y2N-4A9s@ zQvP{Cwbrq44ZhsK`rQ?ENZm6gyjn%8HD_D<8}${`mUai|8CyB_e0Vi(glu$ypDTNf zjaik6qYGQZ1-o)buek~s5o|AYGncIyIh`$i%2w6l-D7Vv$d&45lp}hMU*Nqng$^n# z;rTOvA$<@rOq_DCemKfGFk+gSSO2Zf-O6Q_69eFdzs}tQ!cdPUMQG6;B|ng1Hs4{utK=^RFQR-dEc^IZZyXgEGlgo1!__B%kL=@EL z;HWHz@jf1-jp0kO+imim1Wm2odvJ#tq56z_@7B~^U&@8qyhd>rxAgp{_r&b6Xw-@k zsJ(ycY729qr*-YEdfB=5sL9FRIzy4mw3F|C&|`x}Y!Af?QyiUTw)uh67)9CiSkCvZ zI7Na})C|fNg)Ag$-q{Ko(%;s zj;-7^5+;sfnr7A*t;;@XkvZbJ+4f<6uPuvCOZ{Q?Y-2-LkfA;mq@x1=dvw^zQ46@i zPs4y>PaN*w>e{07@iRTXTJkAPpdx$SFyBe*mL`8h*`^>mMF{Zw;7$C(H`9vHoCbxn zPJ6mE7Dmn%9mT6Q-{-pT+Z{*LvNJI{QtZ>-)qfs&V0OlA8~6?RJEKP@fKGUzPUku) zp524wG9<2@U9!~W?qJasJmG?c%dR78WN@9H(|Xayj!UZUg|krMN}q8XF#18v>HZdr zfgBJDX$^!xnA$AP3Xd;(ei)GDZ}T@{&voqU-E_vGN)AL=`+v+C_%R_b(nX<;B2)NP zmXD~NK&F_aF`tdrHstG(;#2O462trty5X7Eqxl9r{BV3Q8ITtIB0!U_GBzlJJCPQ% zBe1s`$wpFS7lYGsOW*nUvL87}NPVzUK$?Qu8Q+=t-y`y02o$HDZ|ln4)=D zgKcQv&=9YSwb8#+I;uEwqq$R+Sw~!VXG@InozNT6YgfySJvT{`pKMV{?TNUh3S4vB z^k>GQdvcrU0f|0jkL)3QToR*V)& z2jG1pRC5XN;9YQi6Hth0ftlDdfbuwf7-^*aHG*M0d7CVk;hao&BfNWzU{xKlX%@Gv zrj4*I?Z0K^^hc8rZ^IhhNh;$U>w02g;7=81tEU_#E4ngk$8xiN#vfG`X8rk^Gtj)! zM=?BhTS$+(zvdq3aQ_-`u>YP4F;;lm;uTfUi9+B&AHa{D@(f4ZUQvSH=1nDbry;t9 z2N-lf8_I?7psA)k1@?}ZkK4{^{7K(U3kxY%{>3}kX(lcAaVzLF(W>aNukBNNp&Zw1Z2y=oVZ~8eAd3B* z?<4jEEpEGP-n|Rqyh}+w;%erzaB7du6xiPAiX^^nN$=4Rn^06I&I%A`KhZBb7$1EOVe93 z{Wq+>os>+T5|%~9o*l7BU>PsTI%3JwDtNDIvwjqmPB99&ie z8#26_5Zio05wa&)gk@bpMqe60(&!j*S}mvVY>_2>nq7_EiM#E{uJ^d}+uu_*wTJo_ zuaQ5P60yDKD_bKB_&et2Rsx+$^jwpncKOG6zBKgZYG>wU_yNXHY=S><^rm(sL`VlF zEC$3#vSIu7V1Xbr9ZT*mwEqUzsZakOTmv>P(XYwXnvc_ceu_=KljFEJBkW5@gx~f# z=c$}6DW$ETy}gQGm@7ic#MjRn*f8BLTu|kx&zX%`jCNMHgb5*{R8QWEnXexK?Z z1i9~tVj)vb1?~R>&gXx42t@&Dw7>*aJkUJiUNG+knc`)q#$~ad0er4Y16=#vuBx|Q>+nb2;V*U zAk%4_=xgjYx|>qatmT4V_wt@}yKlW}biG7%i(kVLlNXo$TE7)OzNA>)$#_FR|36jLYXJ)(fp>89^5zHd2u>KDR9-3wp{0+adYe?!eZe9wM-uvqL* zWzwnO?|RE)fqiI?wmuth2II(3Q!9z}w(g5FPj$RO!*lmI@}p)IEnr?s<|pfedMF*2 zqc^rUl|Xbfr9~Nae!a=Z#8~y#K0keMZH`%!xfuo`yEOsOa+0;gD$qtdq$79e=|^BG zCGnS4`HP$>z6>up-)(Qf5nC3si zU9Wq(JfoXnp7-}{l@+nRRR+uR!d>0nR;yA7e4CK)%gXrp26Ubr+MvKU@nd{EnUK$> zz4QLX69&9l8KU(OIuJ7Ez_12C^ZU-wLQV+z;-2(9+`~(^>+4q)y=$tgI3-v*`mJJQ{)eT`CL_zdv?!Mh@+i*l#|4r}f<;j{rY$ z{V^>#hU)<&GR)4S&1zZ-nA&VVxKR|-;5@mFK#eUhSCADjdDx;X3v)|mX z*xER{TE^I)CS{MJlmt#2Yd@a-f@0n1h!BWJ4gFzZh6HB&hrDznsFv6%OKxsv^stqF zah7KX=l2D)7r|owM4}rZ{zSO@4b4s3Y(Sna&PdmWAMPE=C5BdQNV^eZ9&_5sim$jv zhJ|Io@)P{rcshjz_~i#EUe27U#K8Aamu$>bgG&rylKumwkLO3T(1YI+Y_^M{4bHJOP{u&Ij`WXNKh&?1;bAGg%;SW`hU1Krz)?_voWYjzJFw_rAI z6j`n`wbUPYG&ESSe~LS7@%ii8%le;g4Q4gN_m_yRU(yRwE_J&Tzh+UtC+4a_`Z9!I z?g0PoD7fcOQ03{Z=4Nl_rvdZeJ=lYP35?I7JZ>vfpFS@kC;a7 z%nH9^anPO>{;v#ZEV~^c5vJ#vTw-7yLul`nyw%)0e3wEeME!GsE7u2u0@X%U6|3>P5r@t4J zbq32St#)-qJ~YZ1Lfb@Lb&CX+Z4@jSfIprgJj32^UDlZpuGPrAu;DFA@a|0vC?GbM zICZ_`nbE2_$9*Es{kLBTM>z(Qc?IC->Y^?h*AgR1QA<40NKVdIgk$Z3SvY_Egu879Q^}hvd>_W{@$jhag6oJ?O-}wBjt)-w0(0zRM6^NGvZ!gnfXKmwL;IIV zBm@M~a^IsMbfVkXg6df>+(qj6rMr(o)s-GI{7H(>`?^cgdtE34T|xiS_GIMdWFp*f zX~vk?irtfnJzPJ(1so2bHGwrBz{G~5ckx3&YeU`lb5PQi`FHkfJ_647iR0vjKiW^J zt7Tlk+54IkYhN}B>mmrk>rDeH(Beu&7k4b2{}I))w1xAbZ)Fx#O!#-*F; zi;k}vHhynz9XW-F@iY*%NqeYXOKv&DwODx5Hz}?@ydeXu7BvN%=ky^G`?iP91|P0) zghnD%Q*Rf*cSH3v8>jJKhga9F?T)`&e+5s<`f^ZhrH&+_wmsu?I;QN03l9q=_;^I* zQYr5x(bXBI#y@EIYMNDk8Ls?N*o@18Px0_XNey~1trQ}ga}R0RV&@}iAx3vCQ9Dql zy4gQ>HZV=jvUo#@L1!9#&>2e+sAQ6HLGlav4fFzUSAoqty;~6yC^;)(g>af55c6wKWV(b9URj;Dw zIr_2%JN;(q_{Yx8dJBh9*O87KkaAT`W9ElcH6YFdrGZ~u;Ps%!s1^z`!~ynV4nfkg zRwYEHEx-h~v!ctHJFKzguX2B`;r(e(zl~t8J@k@4DvGQa3S?*0}HWZH~^FK9P(%tutrutD8qse{%YS)rev zVf&BKQ;+oJ#{x8A1an;Gw+PbXQ*#hHAFi!Gji~>IKDXNAr4M^JE)V;!9FKh~aKp4> zhbbI0xs=8iNcpEl&#xiXKl>rkH25(%P*BnfL0l4_-5YmNblA0wZ1mfk;6=bD$XC0_ zSJpnAdXP7ge{!2)J$GN35RxyALEGoe6w}DF0Oc5C<-eIm2qgdY=uIw?$F8Ars`?M! zmG1KK7AVOF+r9w(mdenR?}Seihroj_pn|o;_+IQBMZySmOjK;raMO|-+SBM9=)L`6 zp(QK-t41ST$EXCp0{h-u&ND^xw^O7C#mK3OBrJWho>x!}$R>wfH3gus0yd5bUM9@j z0lp`4MPHpbVC()`G#<<9vuv6=kQVT)eE?b*@d{-?UN`RFwBrzF;As3WB^s)dp>YWuBIvM+ zg(Vm5IneHlI3ZyC@&3(6@em>hto(W}fdU9JtHIwW#H~yMKwELqSuBHxNB7(fJsbBy zRR6>an5g&=2i%}PigsqU6S(>?gXrurPMuHnJoU)zj_T=eC)YjO4?e87K@sE%mX0nwPyPg$2aQmwM>5BOGZfe1AP;>(#KwQf% zhtQl1E9gGWykl2i@6+drcMP>*H7fiQW9WkYEpD_!<9L`MywXrC#PM`vlBo5zs9nM| zORC>V5XPoDM5Wn_{N<6a5}UleZ&=qFZT~9O`!>U&`=oRR7e4^nJg~!eDKY_}zh+a< zG7t<`Cx`R3Pk%Pk^<{_rzTAJi9u+(@g4iR{Q+0aQuA{j>7-sh=&+Kxe4I*DLcphyU zj-fC(H3+=lhbBP4b#*a2z-8JNF!Jc-M6$u2m_nj8{4aXZ-9lcCY7ZS}W;)luSYjqU z6v=iC;V+UUzfsQ>S{#>X*aDD}q+%$L{nq#tHv_b-2+;yBf%Skk4u+Q-8j4r-HSl%O zI26dpNPh*l)qID56Kw#%-CT9y5_pCHe=}Lo-VB>JR>5>z^s^$=*hFOlDWH^B(N&rj zz8L1`aOFV6{oHOi`F4@gsO2`GVG5#wR(ZHUK^Ykzdf)Ux9AYN|O5Eh{^~r-Zio+O3 z)5L*4F;V~(^LORq$c&dDDNw@4zaKwj}apKkvhqg_%XY>*CbBMj|NK)!MG`>an z4^IU4)bF;+MTz5Tt6)>zMO?tWoHGFUWL+&ur!z*7g>JxI3@C?etQ{povOKy0$B{PH z3DTJ@oY)InoxZ+*k(CTC)vbM@%yuouxV$PdH;JwHJf!V`DA2HZR7~Dn)^eZbMB5aY z)19_*Hv(?FAj&V6eMzp$2a@7A_0_Rc)zs8sr&R1Gf*C4vV&Ru!8;NOLa@|+ZXl7hg(v-o!`?;GX=uwwF*?j>%qOl&8#&q$=OjB>|44|lp()|eUyaEdNSnc z7DV{10!YO=8u&u({cF)5Cb#msz*j)P{f7+KXu?GTkth5}tG47A+FFh}vdAF1t(_T+ zL-zVX8`_U!_<|6}t3*;$=<6-v$jO|?nSWT>ZBHmWY1)NR?XHM+vS4rr_noh!yRd+3 zMyEd`#HG2~M_$L8(NJZbV8pvTJIK@NTmZQF;iCkF#S;rpNXk3;FD2l?2Ck-NEC{~Wnwl|vo zg`2C5@JJUI+qer;&!C2n%o9iI9uWQXOC1Bt_Y>cH3xqH#)8>gVJ*Ajnm%;o9$%_Ud z6!kkV@kYUkl7G_iv2KIH{x>x$toB_(Zv~({LU~lgk@EgINjsyK!hVK~Q6vwJ=2)4q zE4r2}nf*8eaq>`nPNKjvcX6cgT{Cx>Zm<5BycW;EH;neS2AN!u+xZ&N(YHwbR5$RQ+pvxNVAKpgK2F8AF0aHz8C zq17ko4F-dJukSZ^240)!e8@Y`U%m3h9$Bg<#;q?y2Bq)R@6f*Cu0O)&MtyJ{z$))S zj-P(bG9T4`aF_c0iM{iEn%8OG0i}yx^!8jSYe)Q2=>lKEFL(?oqZK6#qqE&V8Gm~f zK2=wf4amtHpXvn9-UR<12fynLgx>G$q=qBcd71WTttV!z1&Zru847*x-uAq?+3Yoe zvl5doo$I(tUJg)ff1XDUoJUQR%{5Qu**s9y8$zh3Uz~4DoZO*B(H1#dK9M*{r zw@s0~wmatjk^Oe3NaE}4tQ`-JRaJxjZ0J2Q-a|nJYb&$J*ja5TN=!_7?Ds47hLCcT z@TXQY|EFIJh_otGD=6;+IIgSS@Vl0bK{8Iq5(zZ|)7YgTD(TCQDYxun`BoUo#XSJF zTQYZo+dniS-aiyWH9=Iw)MVBo*`^9gtpg}m7deb3grJ)NF4uyY=qzEa(5z8lg| z(Pbr;c$)5D#M<=HlQz@lcagP?2YW=mzozZp4OJxA=H!oxf%92!k{sn&YnmdXiksLNdv5D9-E^kQBRyqAEPfy{M3C_cl}a6}XOTi2~gyy(oh~8tC<; z(ZG{8pz%2={%qTzhms@JuFTYCxDaqs_2JaHyN<<)+7F`Q?i-BlSe*p39Z<+jW0V z)${VDcOzsAruCcnE{4X@=292$KjAIZ^aQ_qH;7nHEuStGiZ@0zoMAX}qANWMt17~L zW%Yd&zG6*cNBm2{bd=lv>cJ0DW!2cOj23KF#)UkinKB!rHt8LwA#gTDNv~o? z9h7613I!L_mEB6&*0~wT;)K$B$wqWRFCCKNs7FziF*)+eVK$S57~&$MUfc%=6yF8^ z68V3{`aTP8zVFxNj5Ct#bWp5i##cExOUG1O;NxxjNcMZHZi4>M<5R>k8NLpbCccZw zGW1;IdJjjV)+rtTx`t0oJpHzxhxRF=oH<@aGu_#L7>Mr5? zgR)+ zTht85O0s4ZQl(-Qt&8ArbV$g@S74wx$N<5RDUE7pfh?hr44dOpbQ>G7S-8L6ShZRE zDuHaa7L%3+Z(yBX8P;r=O<%(T63_e%-4+&?HfQIR5SL*P$FnQOUmchN(E)BJ)#v)z z`%|=nG|N$tKO;9bH^tG}q!1d3jhb)oA}Au$>fMs^XpwvZJjP>d@8m6`I(GdG7ihvotUdlszqda{owrCOrsu4! zEUD|oY`JLNmaSJycLc3yF|u&s>mB}lcK1Q;QR0{K8oVsN)-85og2{YXa|h2iA|(AS zEifXLTUKtK$vit-`_HAS)=majW$5q+2yQeJ@efKC{6GDvgC%`oc_Jz63gmq0@b>r?t`ig>LL!--5>xKBAWw4a~8jkHa*#~Q4)7yBsq#&uDa8^e0 z?sd%329Gnm6_CEyxFJDhyFs){^jgp(^i)1Ut3(md-Z#k%@A%icQ& zkFlVn*@NhUS^YFUj+N<3mf>n_C@Lbwd{%9)KKol^oUSo8yX0neoPVczj;S(muVcZTg7{S@9_=u;)La5Q9-zQ0_BL|oZ*K?jc~zt+-mU7Xm`Q)lXm zw7MQktA6CigA}y=1PYX-Teq~jt?|#`r5Zgk~c0~NkAgP_LBP^3ABd=J$AJX zSn~*tx9izzoXL65d;9$sn=yzVOm{@=S3@FRs-4L@J|>flR0)*RU*d!ih?QXR z^5>ke#8yGbtRQ;VpUqIv=as+1U=~^W)5l<4I-&1>e7!|f9DVqvjk~)OoZt?@36kIt z+$}i42~LyX4#9&v1b26LcXxNUuIYFF-^}d3O)Yv+i>h<#^zV7D>%MU=yuY2`L(Mkk zxrFtotRDYWhDLj%+_G)A9}%!a>>{+x^F6~u3B2(q!p|W z7^?&!T$TAR=C|8()!e$~G;633fAWdLeAvMtxIq}-Gvj`yl-~r#>EE@60n+sgB_%Kf zG~~SL^aR{1H-M6uw~-q21y45ZLmh+R*VH1msgZ(@Nw(aTh4#wn^PO6rOk8CXp_cjc zEu{%@^4!#ib>9wrl6F~Atk3dgoP9_hHu-mmr8zQ)wAp}i2NNAOpm7*@W<2kF9CaoMpuXM1hgKY5Fe;IMywf9=;eFCmkSRjPh`USFVe~^S5xve7w8UnF9n=!t-8UH) z+S0+TH)gJnxYNs^-IBH+MxC`Mqqx?xiKgT;CbrEABP#dO2+>|x%a|wA5ulY0OkFqB za=7|vZJlG$oz{X-=Mh?P6FWkG*{Z@5^lmA0VKfZn@C*X{4T|8+)+(S60Yx_bfZuJ+ z&oSq7rVhS&aj^;4+saw9V74lL=)eV;f~rGMN+RwC6DEtPcYSh;R`q$S+6pry#J8U# zLPk3vUgIyC{ayIW?ykzgA;k%xW&odf3rrjwcqKuwPKb=MOAeWLN$*asaMZuxcEUB& z4ZoM9@L2ub0UsHO7+ZXE$#~Oj3fNY@7OiYRQHcip@0wqMi;0Ff zfU+Dc_RiXI@x=O)*@1P#x)DX{t{2r>en3FGOnT4sJu~8e1W>m=G-=O1vTSw$Aw`{o(Rv%^QRV3_fQt0D}^e=e{3 zOS&LZ{Va?mBRPVfLa6i1b-+3yiKUlUj@rR5K$AD*5)pi#yBl|^-Q1^zfgjB43t7vJzWN!>(HzjU+MzzMQ9?@=x z%Ng(0jeRUN=18QpwHv2e@t#$by$;@-PY&N7!?Ac_`HcHXVlw+8F3v zl>LVlOn}t57p)Nx%Yqs`_)ux^31CqJm%6%5N&m@|o+D_>faUhR|{4I~rd zmn|jfQBP2?m2ahM;f;9X|3S9(@6kxe6X;Z0TmwXLUX`n*1TM$1LW(=FckXX|31TcB zYztnBJ%Nh)qC;Tz3v{IqkmwC8FRyXEY`(VvVR02?lXCV!v@M}MM$HTSn7E0Vk?%#| zaR)_kr6*8tasfgIKt6B4hJo#57f{p(CV3Nc2UgdYIA1!q0Zi0H3QS0)(n!e{xPNbA z4bD%U9o5!XK-?AZx&w4$I!2Wq=Ej2=j};Vwm`_!mD}b3i4RD$Ag!$i-KLt!;FQHF> zL!QY0<3j)X0N;6C>0k|)-h+8?4;XGPO>_dAM`dY`Sush#;X6l%;6=6rV!-Fh=o0S! ztCpRzjzh$z;(eQg?9&gvVe$L;pJi{3`=JDw-$k6)I1&Rb6hDSC!&dL9uEj8Pr?Rg} zek*t7t=aijNb~GQCmRZjqGv2**iiUQC23xGgK1^J3awL{=UzT7c+|qxt>47iRdEo zDd6CcnUz>xR^IW<$Ij5eq2s`UpVA|FfVD)s%aq*RA}b5B-QBX%;}H2DxP$<0PcP6! zfbsvbCD_ZqqQBq~WVzt(1g0d#SW<`jIPW8hg_1wvIzUNp2mWDZAc=Dlm(=_y1ywOQ zV}$VzrmY*oF8Gcoy<64T@Dn4E!7`gnzbpvM(NY6F%x=dXxUMU|T``nZ#TU$KqzBkO zI`mXkgE*rrT-5VZ`m}eHPcu*@Z5mkOG;)S7Ubr7d(HQ#6Ro~G$%z8arMoznk(`#uH z4#BotYcMc=Tii<_ru#Ho+zJGk43iW{op(rO-}a2i4kK_lwh0Bl6VtrJL&@M=UsIq> z!SC#ECJN(iBPF?3frg_*b;?<&{t{HB(r7+nekUroP^e(prXMt}NT{8PbHSRTsd^eiOy^&w|o z!NKNQrew75Cw>lGhXeM-^Z$zcgn#B&zQ2Go`td8CofuqU~PNfZ#c)IOo%zG zR3{J0cos72v#b=ACxIh-c9qJ|X@RF`m9e^YhA+xDC-~NZS`#ULP7_~LCC}f>T_lL< zki2Q#m7=*cJS3Mm23>mMek+U2VERpZ|pDclLPp1{Hc?)m7b#CAqX*nuH1Lz7vt*GcZ!+!ZLIK3sKrkK zm?QZrrU>Fb)Y^{6l)s9GeBHKoz} zUDJ{?11b8LVEfaPG}ViASkSGI+D}^5o|f%I{Na7Xc9~`AW7ds41@H&MAaYWJdk3iF z*NVb^J)s&mOJXawtDs+?f=H)H(yppacrd(wuo+G3SUPFPZ)6WCr9_rgA6z#iT-(J<%n5YFa?!QF|`X#Pz2O z0|(XoFbB6n++@$lf^s*Gf_xkw$`cV{Qfv~B5~o-0RmPq;W34fF1JSMvG5<4zPK2_q zu<+>!v43CdU(z)5tnP(g9IzS=yq~~`%RD{|UI`%snri;d(jSlb34u9=|Ikvvg z9o?kzzN~w#okyo}#phZ(WN_K+<`8B&$OOwaS=HOOlk(gu&^YZyWQsDJx5m(RAUV6| z(>w7iptjh#6wv}3TJhH8;MF29orm8DiW8I1@#CYpqDTa9%gh8B{_Hl3yXb=%KV@7`Ajhvz zr~5)vn$>@6iSLeDfP2Q78e4S)#E9FdJ9aD=4tDQMpJsCMLxM=1T9?`FaO1!!VQALo{ zzDPW4H@2*57O|QHB|H>15?HVgG|f9&w$gO3FgnGgV-b+g)x_!qA@`S~4~HVmQLk0W zr4aq9yBx`C;*blcpMMHTClA$!4Py^0Q9`C2TI_F}wbdXN9B;l$F$K2n~m{w_q)Qgv7QxxnE5 zXwvF``wI)<#L-%w1-z3+kaS@JJ}PF>9SQrHroddePrB2UCSjF0*#EbUixQ8{FZaq*els+7@%BZtGu>dd$_%_7a4mwJ(>wpuq29D?_zt~ueZ_dz3t!k)8Q+YK zliPq(Fb!g_oq2JFWU|Nj>a#U%0jS{?)_y#Out3AbVkh0W28n)kfz5`Qt}g+j)>zp! z3;ojfcUy8+oA52GkUD3OC4tatL^+h(g@V|sVXe7rl=fQ$C0trk=N7|v(z8z1)J{=% z=#}tIeVWWpF#*v}FOE;odq-Qqa>%t^L1d`6Lo_CMNk~-S%@wl3IP$ohB`vF+PVMe0 zQWAXb{XN_}h8fZuD+5{kz1Zr|FbF!oJ8^Sfwzv}Ahl&$BJtn=RsU8`6IbZ+i9<%7y zRnsp26aqzPg&aRbLTszBGuiSQOU`NO+4^aS%g=6_&KT1VQ$^VIh~h~CL(7pF^#p|%7n%RbbzJYC3H?lOqS?f{d z=RV*`hz01L3f^S#LpDYWbBLSG+>{kaF_O9t|8RrTt~j%JCGEG%8B10pr#{C!hT_YV zT{o(%w=%RMWh-dBJNK1$itYN36|PfnC8YxOb;)jVo2iUvhPQg3v6^2t6Gu6-)r_20 z`U+M5a)}~}zAm1}%&T-zT(xGMY_-h-eUPZ497Fiu#UO618-d8ppsbtYU+dKTSc<$q|#;il(LJ&x|Pj_Zz+tugtm!0C-X zm#-Iw=(4B?dsf!TA$LDz6I1f+23Yt^MPmNFmf#k(8dx3kNE0!4;5HfZdZI8Na35PU z+FW_BUl@7)@Yc(Pun@9^Zt>O;xw-iHtt0#rhax^X`TCIS&2jln1=Rj2Z!*bgj4N6P z6Vg}wWXd`~D4)D&LF}iw($$SQSbBmz0Az4JSVlc%t!C(h2kqTQ`+)X7^jG z{Gv!0f^dPhE5yFhA8U=`=s_1%qo>3?7ZrgkQA%|}mTgIvP_%{I1NOCa7j$?{Y4v*; zs7}a28<-@*@c4n0^qT}Nhf5Q|e*QPxGczIi=#h5ANIBe;Hrbl@avh#1dnP@d4Y zGky`3hcDg6FOph>{+>Yqzm{Bey+RvpnHA-#Y=~V~0UnX6?o`e0nNj)w2wfb^4GhV{ zHCU^X&T{TSQXZYJj{ zgR*(_xDF@f-{_zkv2}{}gXpz5D(;r^qV17Y+~FRA=}uqwyS5qd(2mOLbcwE=(qmpw zJ`SG*G%)2)1onju>T#GHT{k{X-4}2hJ0Sekjc^{Q6~1*HLFS&PYg6{?6RU33X)j-? zWoJY^*?c;0zgn=}YJ;${hbNG@4}vP;TbzB}aY0Gz_yN+@US=imhZsDbZq}N9R-Q1K zpHfSSXVx~~iwK^r0H}Yz^V$$Jm4qjnp1C`j`SPHDGaUsr=d7O<{@g?NE>3J;M7s{G zN;yB7<@*Lk%C;#rHh8*c^dY^XIWpXRjoR5)rZA(O)X;uFddehLc=KooW`!~xTLk31c%PpWH}d(bV{7QAxnjJht!=F_D{JATEVNeK=zse4NZ2;ML$^9B{-m*KhY(><(Si3h*d2Rf*?f}TWf1g zEY3#@o#TJy>3kpv(Pf{zs0T&8{^U?<4jr-Dv41uX+`diDwBtKe1IF*803rbs5RU11 zeD>?2y=80bdt>L1FpLE`ZSl%TZm682Z4gnp${UHwKxYZ5c<{Mv38&P_j?0Qr;SeB_ z_h3{4xHr^3Kn6SuUV}$~yD`9hVrppMQ#dg65hWaVRQ^ZqZV4p6qE9J~Ck7qAP0212 zi@(&qO|08u1z^;7*$o~YS3;j34G z*{eYt*t|b)11?*_058Nx;IV&K8UJo6XA~~B5$t$0e~}wy{bnr2dc#-$?qCB!VnAIF zWRTk0v_~`P3@)JD%g*2j2$qecD#&DKD4W`^ZQG*=R;?xR`zPR|_67adk1C z@uochHBBd(VEbNR+$=1|HY?w%35uOKQs7e~4~fX#cY$61Xo=S3 zSF+F_(MQrNq;5Z?47v!W?jlJ0T6w##0AfqcCrSt#^Eq$w7vYSRLnM`$DZfXXzT7=a zW%7cYE~JBQ)_cc65Z64ibWR$x1SjlO3h24EBRpV-`BWV%*u+7dE1iYg0~MebuVxP$ zY2usH3k)EDV~71gV$!kyY0Um161jvJf3GDs+w-Z2pR_0%GgNn;H%?O0mIc&B6-=~+ z;U{2U9jMww*gdzA&ydI6&o-id?W_82v8Pq(eYQx#{f#!@VFp23@XH<1x!7$JJde7( z43?pT&FyYhsd+pwxEt-(p28S>`v+f% zmL^yET^~<7$FOL=TdQYp>Egu~kEJUODCmpvp@)g;=c(r~2~ljD20WST@O1EDMa8Jy-sXg_PmV`jCXl0uv-fh4U31 zLz|Lu#Vm@iG=xF^T4Lq{$wEy!&%bL{`>IM#I@JhIp2cBeJ{f)(G8zrJ{%mdMj^vRo zBI-VxRSjL^kTSoPv?c7eG-oz2A9aCD4M%I{C^!OqS)bM{gU+PufR^Z~}X-w?>{ zr8sP~WBG|{Q~Xf`dR*%MIm;aMS=M$?;z#^bY*uTH z@iZhEB4;Oq3i-3~6U;i9b2BIT34Pd_`L7y2caJ`O8{A>fPa_ja%H=Zk=wC)15i%LiDe)x@g&ia_{Gn!$r}cbB#Nnq(%FCy9s#A zl0fUjhb4fuZXiObe6x9Bd!k7P>r3LEy0r-m>9$1 zR(|LAUw&Oa$Hy|<1?1tY#$_949R{9<$)`Li&?EMJTPZ!{ME_vE6(8J~xsi}kPyBGA zi|u`n9w9Q`*zh$nL{s4twe|SfK6?5XDYgjKHW0yPet)CUd8p1 z@4$f$EPC%U-=>if54{KP!I+&ujQr&s}Xa~yv;q)Du3UKwtn3Ix;)QR z`GGLQJ>g#30tP%C`mH%LGN4dq7T5CU(M*;Dxx!P=lO@enr4*Ojs@p`t2ewNik73|% zU>tXw1!1FMLPE`=;BxenM03=|3TI!Q>XwI?SAH4=bn@8uMKhzqe1{>adYHhkOfyG6 z1%XBw4G6Mt)x%k=UTVqHVp^SD;B>&ef9zIa$8LHFTFQRrNj;SqT4bQLs{JG!etS-) zD)`8qS$@9uw-6(gk6FlS$Jtr~J$ZkpI5eQdr8j*(;-@ZfdE72m`X<(zM*0a*MuReW z6H|N(3Bd&<7*%oRS82CVqz&(L&#|-zXtOUrrN%wLcs%LyEn%E-7A46i;{6KbKvM!8=^LG?7lW^iP`UV7Nq3KIgt|MZ%^#`Ez49 z4nR)`bP*#8>_S&rus z%HXPW@d4m;djR0Y1`1=mgw0&Vafg0bXZkljAF&W_wkSSfWuDbmG%rNQfSND=+Pil( zUez4_MD(HDs2k(DSf&WU<4!hbZ9J>Y{_f66#c*r#RD@HmICY6g4uHIlJLSMemlu1x zAO{mAEE-@F9D{uuPU+KEDLCRxlOT-I+^(1Q`TDBEAb2ia_HSdHj&y~mP%#zl_-g-I z@J0#!H#V;^kuYr%4wx>pI$-?fbD0!Y@eoiO4+5H-mpPxsC{)2Q&o#fw%dtsXq6y2x z-K9$@u4*)%J`ams@iCtvP6+7kCoW9bIVAdKw40clauTF!&j(6<8%Fz9_cux)$a7bDvs#8XJr$;<1d_RW0OAvKl9PldER zbNa^=zrIHbXvz-&`w-{1uQ30vK+KaslFM1gPv4#*;u?6%%4&+0p$qD z7PRwMwILT|yR^>v1UNncWMxmXW}A|CfJvnxVKU(D+aA9N2(1nPhbFJU(I+rryUXbR z=)+a9Ysp$ax5!N+(`+b7%34k&*Yk@S@9dnTDdPB8#8nu|_jFri%_+uR8wP&MFBjzEZ-AF&KUTbJHSxU6<`CAYWXV9_H?i7+?PMR@j0!0M>6({ zi;s8O`T`!7RbKNiAcV@sCBw@G-2F!dY8%gSN-iyJ`ud zD#!50R*5;8ZweMQnY3Q%g?4sZEwAUj_~1KmK!d}Hu23Q_x(K!wqaNhqEAb4i`N-#7 z-uCGE6mxaxRAFFMq1y2g@Wi&J1OyOmtH0t3tPm(nI$-1wC8Zkd`bAv9B=(36Z}fPr z)d^tje$aMwA)BP8;i#5h1S3^ZWg9M(Qvw5oy~#?htuj^XGV&98tO0oW^%@fDX7uW}!-baC6Pvz^KB*ZiapWLWlHw0gPBDa=NSlLM#DKyoD7zRF_ruxr@?7eoJLIH&{g`GFgaQbnU~Z6g}>b` zU6?0L4v8Sx<{MP9pm?cH6V{{}D)j7MFL-C%KOuqhY#EAa=^gD4XQ+stUA!um+5SZL z!TdIXdpOvePQRFFV4J%P(1#OYbK%jj5aEnqy|$G(z%!pf$o>6pDL78=Se-C-Pxvie z&C+v%_}w4=1sicb89aza`jn=aEPMGm6zsHr#G~Y77=>R-4bRfZIr>2KX%?#2qC#R6 z6hdWD)Iiz&YJufs7Yf<+^N_#Gd0N%SW$F5=8{68hlcsaeot^Zs>6Z%I*S$ z-yiMN1b^)ZB8Gf9T@9%UIHd8pT7E+4i=J({+;eZZxAfzoE*jQWT5c8?FQ5B}oPkOd za;_=>=ZME{ClJkNa{#78*SEi>PFQT1yC=zaRUWu9nHO((WywVPEcsAhf{QN~^Mwrk zl6**YVp}y$U)8v_Y9&tDKN_)k#O%BOtv~$dlUFwNK4PxxP=T}Z4@Ni`R#2~OJ3WvN zQwbr)baVtX|(OS7bqvmv3bYK&l9(QaYX z9Og#`&mqlX)T=xFOAKFO$m<9kR*`~>)keZKJ2V$AMs4jiJ}pw9vMX%Cjyt_k+ixOn z43|9aSIqfcVJpLd(=nVsx+(crwQp3pnVS8I;;!0tHro7cwY5vM(|Fs$#)~yc&d%KY z!-|n0tdwkrQ1U*EX+};ytFUZtlTma3<^K1}r{P)z4RgKeR7R)sJGR#B9a>1-H6VB=qHzVyZ~_{~`u`%=Zie%B1s-kxhZxPj_JhW)3^L`=xEBs8t#3fXQ8Gpq6tJNo2 zcZe)43bO;Cp{=+Lz{x|twm{JiJ<-?*%cVGG)vR=-|wAc?mO)L%U9KUiTq7x~Co zNW&BL9mP5G1nzmos*tZBBJ8jdqe;I>FuTj~&k*QM*-d3LU*_GHpIlr~N88{B&-aq+ z5fAc?PFa>J1;;N+Y{h5;{(SD>VdPJ!W~CJb`DG~WZE;jTEROagF~a(@lWWKo6zilI zS%uz=5f7;qoY-mBo3&lfq^wpFyvm8plw6PTQ$@L^KdlH7yohA#>SCPkyEbWsM=sCm z$=n4eFsZcgrRV3S!X4>#Jgf8Yj^@pn@W1>)oj3Q_nYlZ~P}(UAVdxx%e`Q=KW^9a) zth>g*>#B)j-_WHZsUvE{>%0_gEqQSGIH?uO$@}jSMNqnH;t0wN-b|yHEdHIToQRP| z{#Y-l@4g}sQQnKC$>iaK+;KvGMqjkKFM$~+uPO9$ShQ~rh2uv9TD(&1++cQXK|aME zb~%}pyo=+Oo4^n2uX`@LdN_zL!6jJ9Rr4o_8&P(9bveK_R)azGVEfZ%yj9V_-yrY zSFdsCR|sw|)5)6-+OiDJ3VpPlWJN6AR{Co~Z`TCwGK{($=>tLi9Ao}PEU3#tT+Wwg zFO*ljdx3f|MJRB#F*cy~S9OfUOs*k7BDwUzqkwk;Il*WOWL)pUpsfE|y}!4$(H|m8 z3oGnHv`gQon$f+|<=GY}vafYfHEQQ#vQ_<7# zjJ(B8af$DjqnbtMzm2zPE7hbRe6uTl5iDTM)}CL1#Fej$n}XggH5a z!h(N5{Wr$VcwB1tj8)3?%_E>8Mu157*FlUIXw7;d$&VJ+&{{$awE{vrYwd#-#c<0*Rx;=p0gy*Nh0AHb~bBX74H)XeKTSNjeJp!pv8%8 zyy%JXzkun#sEK=FGyLK|;;a81AWV#^scDvP9M452P6RNetKW>1{&-QOGx~nQmEWXS zPMCvIviFkP-lXtnfbbanOpR~u%#$g@?MWi?1IT6A{zm(3KKq1D+?gYEOB$|hg04)p zOL9k~9}vkuCGQ+^AvSt{C3~ugIa_^Lq3?N-F*w2ca&*;9vPic3*G%6f(pJfLkqrKp zU^k8idD`q}pHBFtBE55ehS$oP*&)fUhFP0Uq*fG!V;CtN5SZB-E_ulCY`NeXkUTKy z2c^@W*uFo-tUkYCt}EgK!+oH1e(-HMd~~0co7>m^_ZTUD#kaxbDoC!18H84Yq!zy7 zh5^EAM?;Wsug5px*wOS22ivjgw5`3+z9RwL{>Q4@W5KxS-fa@#Z zENol$o8j<24!#=+b@D;#?GZ<7y05rko!vzlzk zMr|qzCR#K_V$_6I-(;BO1WZm*Q0QQU0^*-w%k9T7Lxaa02h)ACnY^C$Zz6c>iqROX zr0S=jf*M`b_tZkd{oP>$2kcb%ps2FH!(1$N^ETfEQ1ZFwi^otUlUnvT{s!nI4;dPd z(*FrR*I`pPO%Zdfrk7Ew^bKaReH60I80qU6SP8ThPi<^n;I0cQVJrCf-Rh=Kbx*6? z+{UwJ6lHEENGdjL_)n|XV*Q}P-O;rk;@qJgtG&XkJhCAD!`vQh)LW)0M> zlkeH+LJG`ZR`~Sk>#%$*(>2X~g|P*8z(?KSbJnbLWO6r)3oXY0qlU%N9XHfzfC)`v z|E{yOKECL`Xq(I4XaJN(vUzT$6I{cVQA6G5-c>63Q!#=@OtBR;Ye!&U$BPG#e-pb5 zbwfj|g!Y7+Umy+AfpFYYM(J|)Q7`LZgPq(A`UbSmD-Qo|G`R}!39g<7Luk<=sRmxS z>oAn?@OnjSq zf3G$P|DW-?irR`Bp`9E5=EJo#Ho7!*gw)!)Ikpfl0Zzil$__wpauaZCkpYZGVzxzH z1bp!1DM>x-ecPL7`I{ZAF}Qzye0rpX{*T*AUOw| zY=;YT%JBI--5BBj=W$yySOV*3_K-8@NK9K@J7=-&Sts`mWI@4)3BKHW7vpaw9ROyR zK#=rb-pgo*Ws5YmX+M!!89^ag_{^+PLREdITaGLR*^mZPJzETD7^C_R}lleiXZ5M0-Pi^*_blffK1^IdtC99;Va^!(4~C3!u)Z_=&ShZ; z-S&;WhKT3KF7G2d!K9n?f*}>V5l&tEi{uR?A$e=`* z7xksjzvX(oeFbnynr5^lvhJskH{e6+R7~gV9y9738Xktkl|vG;7$}enq8M1n;e0Hq z5QPPZ64ziPHZ#*XElOgH4o3LhpwO@Po`OW}D9Pde*5PYCl)HovWEwoN z_H7#1jq&<76JCwFCMBb8Rfa-WRynrYLHn&+iC{d*~Z=>{5V}yr)NNXrq==yW6#=E4zmvhpll^Q;tksD);D}BzLo&2;59g8oVILuR)edJswjjllKGz zahFWNQx?7!!3*xtdz@td^mLPn{H9l+B(~RU?Ia>7{rxM;U`9t$b>JHe$(DYw2F^(P zv3~Ev3k!$Ou&KU5SMPsp9v8i2CT<%#jN{Sd6wV+DquY(mllvp%6e=6WCm|z9h38)g z)i??B=rSyGCqLnjMPFI=3#A4R&ywgK)Pv)OHpCmnExI1M7JHKIS@VS+#j$zRxyR%D zk?qG||KQr?+ZK9Ox=&Z0#ejUUSJ#}p&nS6s7vaL0BHvK-gm@uUmj=Ekkw2n=pf6KA z>43$~+K;b-MdX(oVyGtb$EO0Q{$EXY(T*5iXFge`@)+ZBm5d4HGu>b+oHBw4cg>Na z!xDDnm=20imu%gn;KAlE>JXEkvb+)oA~OH2miDeEG*OKc5*l?a%#7*NsxYYA6S2?Q zEefF%@i^~|t7gzmbTwhzrQqNp{w(j5ncx2IC=v6@YWTa6~d~J zvNx-LGM_RImUXBCbVHeRpO+J0Uq{{zC=_KT9FsbZqZU@eoNBe}ut z2f+Q9AnFZp`SYjqCCEbwDFBF>1eE>4(8J}GkXfu!XWA{K5-*Xp`w2j+Jj7<+g0Weg z&U7nCq(5ei4}{+68ceq-JzGiq@aY*e^*Euzj*?ORV5wAFgyU>DcSvX6J@z}{)d0C? z2XF2oz-@7H{C(444Z!aJjPuLe@9L&L*_4wnav@*iDH~A0#FbkE#HxfTpeBN7#wY?? zU|DCf6M)ndJpq`jV3#{T5b(6O-hDlMy*-@Pt33Fc6&-`u9hBTD^7P~coDXk=RCp848gH~OSX-b14@>2% zABz6`O_DoOKX!H%@9;ImP<4KnQ(tP{tpu2SK*@20dfHWByW(GUBh{>t?8nQtE&@wg zIDGL5bHZG**TvY>@HKE7{dSVsHqn3)vo!lpz#FDT{8&6J0Lr^P&W;P#`{pMA2^`!r zd$8AE_uw0jE$1E+?m&7BTfd}{H6}cW=e!sW)|N#5XV8m-Xy#5m97?XT^IxDL;`R9_ zG18|zQqey)>t*79&eYU{`4eC@IjXtTXIMK>RMsp{PpVt}E}*(O7KjGT-uaN2j;TYkES(88PYDkPX%xEeBJIxeh4QFR|!*|O4Co@hINNG%Mzlpb=_)r z^366fx-!fNm{$S&7$t`OrJG@9s`xGJj=bja!4YF%1qqd5RM3tCKtFim3ajcSZuuN2 zJImu~i$qZSr`LF?iQR`Le+P~Q+8C4G?p(Tj&afcqbAV@+O=>Mcr7)Y`gPnO&O>qe# ziZZN`;eZj!q_w^OOODl5m7cm@jCqEQaCS0JlctLQ<@XFuLKAVr6Fb4DO&mDKrmMfJ z(AVvzsNW%d#7&6`cm8o-4%8%HXjXvkCN46`!aOucvO2;Zpy)Oq2iEM@X0Vw3IKagk zJsxQC+@FqHYo9sa|8I_1?yZWf19s%VmN0A_Rud^0r_F-RrfY0)=TUvGLOF6p2Z=8C zkY`IV6_(E^va?gYi#7{*Er6-!NKdX4B5J*bd?6;y-%Dx$JNH&kAZsEi45(gv1=dCb zB3a$(>&49bSr;#OHSWE9ochap4E4Dy(``>q>aOgXB=aIS)y#!kRYzxrqe$%M9P@%F zvxAeV%BBaR_`u)muDPT*$fY;2GY-ay-9@mK?E|`Q< zhz)nH56-fOd^1JufS6-yzk^;Ii_d=1OUn=`D=^~}(B741rmrasD{DR*`b#e(M@D0Q z-XCGP2fqv)9=@|9Qc8T;c=cZh{r@{KiSl&uSykXXp$|^c`)+3Fd!+Ed?eWV?$wIx) znAGr)#D9K;VU>$#gt}JaIp^+>Qi_RX)Mly_3>x;w3c8##iaq%FHQ463dwcrh7*sd? zFT^X=PTZikdqGJo3lbB%D(IUnd@@UR7-G;F>`I z&|73v{qbM+y~8f-Gi~M<-4a4Z7T&*miof73Pf&)I35k5T`RhM#YDO&?2ll`E0kx1i zV#r!?0C2bM9RkTebB>i!Z)v3|OJa52ou`n*F@H&zSJC;GAMS1)ti$$O$EJ61Y=V@V zUFe$u9m7_fQ>V^7a8lM;zX>XLcfOmck>!_BoA81%65)^1I!M+DC3{JeL5xpLuz$FAcw zHl^?(N67teU`XIWJ0`m%UMrbxES-_8(h$en<>sgQ652!pp$WO?WBLYl?~HP#jDl58 za8`^0FsXP7N1LOmC8NN~Y1eZzVgif7J2Rb@nz02vEh5;!3ej`ih;(m}SXasFP{L)0 z&~Zc#QP|z8dH7>?2$Xglkq*TL;OCH;=?hilxMqi(9m33g!JRA*T8F^K%J3wXU0S%Ayz5Fj@54$>Hy4Ep1Vq7892C(({hojvKv zxn{IWW6%qJ4MM<9@r z(l|>9lcD~=3wTuI|0d0Ua6y_`OsZe(1Zf`Dj4)u5pTDcL?IKK zc%f8(Vjb33#7>K%oVa|R+HSD#{|X%S!-e{$|&UgLMQfYH4Dv&9k9PfF5w#x*4R z9vJrutBrP4Ra`*hk@17!J+h%ESVZ4Z+`CP)kkgU^Q7z>fOZPRfVY+UL_Uh`Ze=Je= zsFja&BN}hSq$Bq)Vg)Nr$~8Qc4*x8z&`v|g3yf>=F79M05H-))zLxH9ZWR-AniXr~ zWJ~frY-=9l3k1_A^!_LE5LTn?+LftmY>ZIc<`jy+z>6n2!#^YEN|Ge0sy#!{TrJ_I zT=0&GC8~HV^@Xlm)m`kr3{`ouxKt3yRD3(Ah}|8~;FOfE$k-cNW4?E^Ik#;mVyua`J#v- zTw^V#trsa;#>DI*%Amo6%!asgVK_`8B~3Nm__Z_73lCqeQ4VZi^@{)F)24dbW2y&S z!R=i_#BgW%bq zNUCNR++WoZsiCMM&UzW>C#3D(G-eD& z^@p<0va|&H)EMixVcH-i$2Mlyvi@wCv+G|c-x&yyDB`9|zn|_iDQ4)XP4H4Nnl}uQ z#RTQxmn$mEquLoJ!CZ4Pb3ct5WfrU}6AoLHyN=0**gfLwxuglf2LA{m^QHR0Rjbpb zb=r@5nh<66UgY2!x~aTc!=dFdeF!7DTGlYZnbDE`S<`kHZ(Ulyf=9aXyZdJgUywLy zPaApVUbm{%OiIp&+gO>}&%ch|*7~Aj;G7T+6f@}8K|(BG`1*HT7!Ev&8Fdr!AC*3q zCm1D57u`Oe^&0BY53(%B)a1m^OTCZYJ1fT6mA)t2T1>Kglq!F?(kU!L?7b z&TaCCDB!M~b^MA8G)+boCxLLNSV&G??Ndjl3OkDVPKXESUkU??w)w0m33pX=;2iDA zm21j1ekR>9W%f?hV)SWztUM9%AMtYv&j$@%sx<3kqO5O=W*EteY>u^>h(6!VVtmN8 z4Gy~3bdSmZH8(bYdF+%g04kb*IEAMj;nHNgJ>Jbv1C|4bY~Z4nl^3Xdk7<|JfP zLA(3&d0oBGg&xNQ1piHSCd7-s|F<8pNW-)l>Hl0FU>D6s?XEuF*ZX+I>z=|aJoFa- zi#Pzp?`Lr=B6U}a3$zN8EL7Yu>!<(eUMnR|orqI3U!+1y8YI?QgjAGTFT4yYZ|kdjAHXpOAnjv$y`Fmy)W@^M(Ya@Wh$`we$4}h;_%t+ z0MJw~M5^^80rr6Rc`!f%Y-U6c18&S&A^wT-%m@@n7#l)hqtT&!QL5rlU}g9aIJN&_ zPyE*D3*PK98nY{K;-2yMpdL?ig@*Yj^3Ko4g5z01SsAm2lvsBv`b<*Ui);Fxk*Qvc zc`wLZ=XCINdUuS=7R25rQi{8~OVLm$QlLO_Enc8F6e-f+?(VL|-3jhq++B*hg`79P|DAc~e!O4L zWG9oEv-e)>S&v*6{whn_ZeSN9XQ}n+KuuNJGu4o*bJbUPw;f?tn!T3KP>Xd4en5HN z=HC3z!LKg87Z{I<1tc_ocMDU%k1L36+H`%F^*5JQssEH4|>Ebic0vT6VudK((^(!&GsMuw8c3DPk&;^>*Yv! zazQe&tf}ClLnYlAp2KFEBYCcw*IY*z(T?nA6AWU<41*@AT%l`a8*|A@@;XaW5&rQ8dGskADcp#BtTQ{8_vt~l2PAB|#s9uW@Cu%+ zOgXhD;Bb&&(Prz+KT58WI`EPopCJyaau;*kYxQ>6!C<2u^c0R(JM-+WEf$&wQ&y?P z-jQh{_ltf+IOO0<2)vb@$IrBx4)WgaqEK-&JyFJn5_|qUlKEtyu}HBUsbkMX@9DQ_ zmXz8Sx;Jf);+Tox5kSR$x|wezkH;CQ(T9>}F!gPEMe1frRaC<4AOfNd(Os*jM9Y$s zBSOvHm+Qf3av65jSW@pSPJ4;{YkRoLX7}1pt@6)p+uPqzZ1hThbWEdR=kZ&IhgNlX zZhZNgAWi#6QzWue^67Hl*J7WL%mc31ur1LtFPX`F2Tku;g(qq4q{<}1i)W{?1{B4# zkwg)BIiXfQ)XmwD+zsmgl$4Xo3esIBV)qYAno0%Hq)h=s zZ=kIi*P##qufy_J1n;tOuV77au0(>{Xca(nD`@h`~8-R8mV{1(jQW~UhP z$_a}4UJ_iqVL1Ka?sm0X;SvJD0W2;dxQ1}3oa4nX)$IUnffOgxp`PS^f-S-6{z~2u zZh4wwRb-O+xhD5J+VEi_=HNB2qKEe2^iO}<1)0v)uC{`3%k2QOf=V3H51yT_Sau~S z)}a_1)#mjLy6*}X$`2;3STc;TgTV(_F-J-L5nkDP;%vBJIBkm^^n92~WDa)%D2Z95 zE_(q*9<|rTTMt`-^8L}5lU5qJES3e2uMc#H`nnG$U)fnDenUum!pc%JZ^V&$yfd%{jca0_7N>KJ416@X%Pc~xe*=E?>MP0ej+Z#Pj&->Z zp~BX3^MBkePRpn*em33)Vt^>WLBRKa+$}Hr8kvYD=a!~xml?e8%sTNaquoC_5aWjq z$%%P;#bPd2skTGqroRa~4fM6;jZ_g6*WiT@`TVXHzxREPm^WU^Q+$$A+DLiiy&3IX zZ1mp$g#Vh>yQep%NH{=q?z3@E?`%Mpxkqn7g{9tc^apSAHRCMpHxz17_EcEi@@g_$=lnIh#PKFi&xt%;L8^KU{(c0e4ThDe5X~~L*2R93Lk_+u z#v)N#Do;h8%e>|Up>^U}aAT}rZN=62UjYsEW{rlq!dD_3Y33M~3AoCCY5Wu@}jL=TAtz8%SsWQKEA8Y!fE*vs^V=G5Pl6mJB_J`&xMcK!cSiTXVuIYrd zRgb5=xo)}BlR!QTyE;@`2A=c3=aq6i!L_LNPtqom8!1=R{CI8d-$q`02~QBZs@0eS z-+1`tKS$*X?}4f;RU7kk!X`=X=0c};_eS$B-??%=NAeTJJNfsx6jT2>cwT543E%*jgUqY?;@8fRJL6)^S) zPrG0!>nK7=oOwl2s;cfWt~d9-wu$?oatF;SHy_p;{bCHfUw9Uujbqr6q#fHs>IrXk zCmmNmX0^_4jri;8lN!@2+n~}1$f+p@5BqzdPm1yu(4ox~p>8hgL@mwdYKTOWp0dR> zE&oEq-8Wz(YMa?ro3#53W2?@eIYPVV{zf7}4)meG$7~M>tu2MUcRC z7w*Q0OjFnQLy{Zx3Bq&#O0(e4!=Vz6tEOf$`hncGv0o^i%*ObqO>CJG?)C2acwHti z<~?Oo2)?QCu8emMQzYF<9^f%2>h4daGO$NrVsfEo7=)U@+?|he@CpL8BrCj58#XoR ztJcY1E(Z@5z$dV~U-rQ5*)vEf3Iu^>Pcj16uG!g=dCVC`s3>D%<9~lfw6_MB?Mh2K zlR=a^uitN2FT9uX-ON%ma*PJEI(r|5dE$!&06=}tJKEI_F zLY}7GV;Bo%`GTj9ltg*7^-5_62QxKNS%_|wGwEb zwV}AV7j`OD!k_7^TC-N{#2rR@n_s4j9JK8?umxbN_ty5I9={oQCD@Ysgy#Ee2cG9A_VFqPTBYzs^ntPej zsJ0a;^WRE$>Ps6Ti(l$Zn(?hwY`_hp*$Xg0fBVIJW#x!Imi)J}IupA_rOzKL$6C2q z?k`qcDWS-IWa5O?NwwAH9n4KKiduLP?BynZ4HL$^p#=kqIg_^QxEgqcPwK6?lkewL zS_N|l@m_|R@LDJWdzdQ{e-zO2=fu4?+vCx|wP!5bH_n_|W>J#QCCjoFV792zZ`4s9y-iv zLZ}C4-O1Gu{rq|;4Ia`U+%HN=h24qE1gF^4uB7m{>6?LP<@KwBxFXpu`TP%h|Hw01 z{9kQE8Vtt&i!V@-Co-`diAJe^WP%`(r3PwdM0>=@h{{J<@p`fTWu$naR#>1tXVY=H z%Q^c|HcPAe>ytd;7}DI(+^;xe+(dJJS&OxL6eh;H(yqArr?jwMBu`|Xl0T<%&=Gxm zr@6+kl2Th`L3W}I1xrLkzB1kY)5aMCT$Uy-ksIH{93}dChYEK;iJxX%KcbLlrRc&C zaVmFN7(X}=k<~m0(th}r-Fs(jTeuo_o)JH{yFhX(O4Q;7g%b8E3w(%qZ=&k1Tw!DS z<6XQ`@^SHe*1?6Q>^NR0J~tXZU#k#J( zjhVIdNl`cSclcp8^4W(@+DyL$e?KMhJ#`rC70(-%t7Z7PR1?-XC|Dq)V@`}YZR)?} z29cF)nZ$4xxE7aoDg3OTuq=qW(|Ipd!0?Iqb+TfO*{}D%L&(b(zDtuPn9xbL`I~AC zi&82CS|(!)HP|yhRu*0znhQj`jJa{n)vrnj_tU#_2{jEFC`OC1dyGhr*y-S;@^z>u zI;r}yQSB+4U;kBAdW}A^i?X+Sj3PDo?uF?h_e6kp@nDE%GJeh56$?wMDq17stwnIL z;_%uCMVwmQ=V?50b)=!x0 zPgx<4xK2yhSI0h}UXi}YFb}Mr%s9kYE+fZ8 z9V;By;^;;9MPX3C)E9#4))2?%daos!zDg;LXQBp-4Ga+cbz%1@z@OHObKTkx;Kym&;)b_DDo4&0+A6Piceo|DQG{Zwf9xd@pNnSv zB2)YbH?Tvto>$k9IFZ>zPfmAiks}L!=qd8C!dLk%S7c{1UNTI=-|ts@PHVC^R zo}y0|U`{>3gyr!x%uwd!ys3rNiRUm@F^OpKZh=q^1!tzYiU`Z6k_2m7uidi#h+dP! z9x+GcjcUnW!GCB7$Ejb zwF%@9syBoZlja?#W%XH0esrm=Hw9@pdl9*HZOiZv?Yrzq9BY6zYs&W|Lq74CIYj@p z%?b${g7F}+&G+$r#PuU{+91aolls)K85|SE7C_saWhwOqX&Q{DwX=-0#m4|9-E_^U!sQw z=OQubYF>EultIl#-7?v~P%RJwQh3>|j`@fxkTrXHa?hxdNKlaa(^0oY%EIZgmwbj} zmqs9kl60@}Q~_hHFak0hV_>CLyI{QQ#PJay)(4vBvy9{aK% zJ{kK4@Ti*eYhHP>0JCu)U)TG;mw9M^A@Rjl4cB0pFq0Xl%NeL+93Wo)AVq3nxr1$+ zksHZd?-(R7F*QKkN(0d~n7$IOoE%tr%<>S?k_dRSewJu|t!nx$lJQ#AplZPWS1BAv zN_IM0x^NU>;N*4L3AgczjpVGolose5a@!9dQ}2Zj4`skMUQv;gHPElhzwF5(BlBw< z`2?Z{2BsH3kdVrn?pWIVzdhWUC2oXbcJwuA^KTn2#y2-Rkwdn&mmAsx!plgrH{0$p zlun~A5dg8=;l*!(0|_yJNE z-w&+sH+BQ?Nb)Zyse@g>|5{!*SEoxukF{@uo50I1fa-z+BzJA1e&xqu>Ic3RL;eo; zgZ~*RGWi3z(ZGS}l7SId>;x~tP1JypUvRd;PkhYp=R?56T8x)N$Mh72?0C$JUFEc& zb;;NKTlE}~55g1fr3+SEqc$?ubKYUY+XaZUz(bi*=0U4 zS}OV*$r3AL_4AdB^LmIxq-seWai=AK==fDHPxOyY`l-VMp}M+c32v~4fMv-w)>Pu$-- z1}FveY)2=>Js4#EQ#)n8Gn?XV0E)RYwOpOpg-$JgzWKP~v_jw!=3OTa&Iz8 zBIm*KsZed;{g)Sdn+<4ND~5IkEzEcY)k~S5jgT(grZl`air`Z>?==ZFSw!UZvmWm8 z^;UA|&FXGKhs&L;)cN}>QQsde!bm7DNIC>_ur5y91hh%@n6c;Qe{!2z#H_w83dM!f z)Vd@Rg6^8fUEb8rYddo!^)xs-)*o^Z@C{y&B&DL3JQJB(en0}pbp+l%teZp@bdUA* z#dBZUiFBr+OSvd;&zosCs8{pbNvPnNMz$fmV8H$d{E+q}?dTq8@gx-+XYx6XC=hu}%}z!&$eatnGiqHW4JJ z(yG4A&^$fnT*-wjclW-5MWqex^B5%})kzObIClqEZDQj&-#M;*tIhZ8gL_VG{a$xl zx*{@m2gBAsm@LL0#~n~5$~dWf>e{B8A*K_wxhU78d@To)1)3n zNcGW-j|E2}k^bD(d|}Oe3CNepRL%cVV4N&AudM8c8ub{frE1)z@{wkixQnU#3wJ*o zPXx_4T|$x5+w0ogs~NpZOaTlGPIRHxO(#sVkU#1-P1C0WJNBLuN3OF!!n_xxr{CS3 zqWuYuU&d-W+2Cx9P_F*UUJ_k{R>mX~Z z#{=GPg_K7|mEeSj;9^Nq|B$3a3inJKalf8-HsNcnTRYrUE-)J2Vs>%x=x$;D*Yi8@ z{i9^`2XO1|6~E0Zm-wz^+p=zJeqFy6RTPpL8H2*zdNcmRdsKBbT&+NUHAb#7>fa_W zt6$5jW1+b?3_0S~^*pK)FM)677Uh}pdXc_V>qgo*beV+w#QUJQsxcefx`_4UvxDg* z`_2Mip9RhrrIcpiQ z>uZ}of(&~3bRNH8I@1pPAIImq_w_~OFS2#DiVY@7nDhlyrVpK3gTU`&dGnfxQ9gc- zlhf(>DjPDqC{HsXZaY$`Q|4Ea&mwp&A1AaX@)@EU8?gHsc0;Qn^}F`97mE+e?N=Xj zXJTz@kas+1XVquv_R)-|IE1oL-akCA%>zhQ?of6=y)=A{b+^o|>yyxN6e{?5l^7X3 z99f~c>=?t^G}AiCc(m4fM~4}lP13FqO*ee*SIYEmxRqmW%0?8~jXrbXjVE=Yo`6dN z^M&$Mw$nvG9jP>(c+nk&!{XPKAM|#z+ej>h!g>`eL$?O9GbDBGSi}5la+-(owIL#* zc|T0u$a&kfk&t$$9zxzBm4s-jTkGZI?(ry@Ui+ry%DNoik1GGk; zihE$iZEG7R2dCg^hQ&o-6*{=;!kGnpv1C2MEy841j)UJ;Lf9#BCaW~_}$=2-KfO(KNsT(Sk=Y`_a%gq7Z60= z<-P!P7Y%7)2d?6<4R~^y=g9x&e4M^paTx}rfM65fC8%v53Q#?QGas_&5Sddcc+l*0 zWa-;o|7@uzSQiUam~OtLFt0T{fZ0!~l7Q#Tc3`q7^gnq1Xx^t;eOK;lL=^Tb-eoU; zd)y4bS(ZBJrNv-d=%Ds%co@b0~?I`jd$4HHQ~+1u0`=tF;SWxkX1kzB!&=lZ+5ZctD#4%2WRHLtaUP z+RFgW3fb8G$=f+envLvYh^%p>E&3_6^UDLt#c&@-HIR&yX;l@#d@BRC|` zTq1LFIn=|Qwiu73Ft#d-9xACTj5dm97&&YWC;ctB^fuMJJ{m+R8`r8^hDz|I;vGYx zE2!N*6()^U#J44j`iY7{hgr{3c#fII5w4}R$49A}nA5`=8IKTzhBVsw4H}ts!$Ed6M47XE+ zusIiX;=f+4Mjx(A2MFCgPO0`if=aQjhB~+cRybd~#OTf=Pob4+xW_fkMq`W-(>WM> z+8YR07h3918d3F0TJ$-YNOWO5q54SJ8z>=U<#H+hc)jUB-(0&lkQWXAuPQVoD3)rt zZ$c=P@CTMWcp*adyZ1;;T=~_gIb`|WAOx?IF-tYusD-!XgG|C0QC??7Pd8SW-LF78 z0|~Tcsxk-2qvRUITM|>YYX&NiKiAPtid;_xXkI|m?ori0=)%G-aB^ys`V7%~Bzmln zfkJG?KxV!CCXeqkRC&KX5(tO>@jb)~`$mjlUz*~Q>h40!o*wIX?i*1yaIO=opZ=Op zZI?h-&Gn0*Kn1#XU9*>NFSuD$F7oYXdp$aS)kCR9fGrMz^`wjS1=M(TN2%vc3y6Js z0o(HNecz4VgSr_2=XS;M_rO`oGkg%mwh0*jmp&I~bUu0oe6x@p1aM%WiDxh>*PbRq zi}kZ4EyDbAWGWIqKcxC&*O!e;(mjK{J=PTk4KbHYkt&)Yrsl`A(zo_l%9Hm{JVxnb zoNK`=F#DO|5m3`o7z)ht!YKD)2jZ*1Z0jB~zSO^sTYUbS#Yp9Gg*1=$YFD{wdG_b4 zlSzkmG-nv=>?@5Ua9hg*NN>eJ>OZr0tyjD7BNcf!G2Aq;SM?Vk1j~Un@G}|IUrL^l3deTFm zEC4Q~fA#(zqNgVWgEW|LgF*15Jx18ZHeqLYv%!I{8l{JE3+uMNdcutOA@ms4oi#kwy&^WtLa(!Z-LA0=d^BovlnXD#6M)TAzE8=$@?+ zn(+-lSklkd$={i_N4zfa2Pg&@yCWXS3p{=_?2UZ&256>59)(5qwxdKvYX7yp+Qm{Z zq}W~B7JHvW5CbId!shM_S6ZG83EmA{0OIb8K zG3O&2`=H}BnUN*EdUid^eEZ-cm z%(k%7v`Rql>1!aufRH(vx7_A_?Cx2bSsMxXm zYG;Jlm5Y(fUW5FXI@A=8(wm;2)JyG_Cxl^YYy%7sTuPHTN65IkE=J2keMBen&^rso2&58AglF-g@>dd}P@w97K#8@`ZV?m+5{T#V^~E1`~4T7RgVF-VQ>LZ4#IFZiP@w6Xb?oNYL{faY`FX< z{sU@>QeA+Wvzip@;v4tQ#RQ!aWIfL;1z&izsM1>5PA$6#ArI3lG5cNg z`DU}G_g)lX2@u{paSgN_%^Hsw^55u=__^vtpRok^_v{#UDn%L>Y14h_3@}$BRbLw1 zw^la|7r(S5@$Iqe628Hm;w@ECRWEvYKN_!BWmYzu(GfQfp*5wJcXsb@fwNHb?}wJ4 z&@M`}itoRL638IMm_$0`+3@ijq)ExxVFW>Ufs1I?B(J2~6$U2@o@G6Mlq+}XA|QfW zTVZjoB$;UCDv6sLePuj}m77Z643euY+a96{=Ej`Bj;qS!4+Z6MzmG;0Dwn68rV9Tm zR8D$0#TEYR6%GT-l;DtYC{vE?z{f38a}+gqmZHku1z(DA<{Qqp+F{+QsYEP$nX=hH zt{mVgc2qS3bG8sU2Wr*?FDdQRfVLhLS&!+mnZB=jU&y7ih=-@mP%7RBN$}9b%Fvmh zMn>!7jr8iNO?WoYDCc^Y5HM=eZW5*LQ;+bqTGt+b=2AEY6I3IW)dOQkU{CK}fU*cy zjE5OXykr^!5Y{4A)B>x+d*H(}BH(=GakduVE3rNU*Ww!?vR1P^M}J#~d0PC1ZwXI8 z%{w+fCG;|Wjvrgl8fyrY$G#K?1yhqwjX>>xK{rlPp!Q9r0BaL4n`>ILR@B`TJONn$ zu+Zv*pKp)Zo|#cKK<6tM7<3By{~2TYB#YoRM#CK!-0>+u375WiDzpE%d}<(dr|NI9 zpz(*1H@P3Q4cO_2#A<((nFGJzoAE8ceGYWASUmt0UYe#ri)a|f58K7ZVvfcbtr^HRL=fVGk%io`|sq~+$t$_ zmqxj{UIXH#wvX}_;)1LIVLGzyHsLKyA#5S%%=7?I_?*?xAQ655EUq40w77~xrq-4&gXV?3egiNCF=m} zAC$6#4A?j!eFR*^o5+C`qmAAJAY9l_;}PC49RR<P5@ui=BxSNnn%bJc|33syaj-`?!?}_fW9DvV9BdhUiKR% z0{7pmvM1p+mF)$9wgm{IJz67<-g+t{ z+~ZL|b!Y(jIj>+%q!&A(Ns$OY;opV#Tkn60HvrddE)vmup7owZ9qvLmYf)a_ZMr+6 z$6!Yl%YtdvkqJ}QQNFe`h z7$Nq;Cfc!0&T%zjEOyHH*~R17V!zn`=rWO}9;3lGj2RcQKBT@xN2ia5JH;As%HlKXr(BY$*>H`2CL~pUekr`aSc?WSxU-q^7h8|{{4giLPiXd zMw~1b@jbxT)?F$s7~qow@O@`~ZQOL_Z8lVR+4Be5e)8Z-3soBpwopiCCs5Cz`FSbRo_hE+`nQH)#Ez3G$2&d!P~=a6(SrY*ZZHJ)yD?qZln@z_%6pco&TB5#v=B3m3*VDyr` zFeW2!Ujkjw;`zsnPX7xuQ1Lr^$zxXTs|=5^vmvePe*a#I6qWzyb$kcKg;3=Gih-p( z{nmVUo@$acr31jaTK3&`ma@quHkv+1rA2`PZN^6QGhi(bBt8O-9&2!a^U6@bmGA&e zi@F{pWI60x(1TjvTqI1y5Svsu__gkzT&hNrRy0|p#hyuZ>~G4N8`gL0<&uXQK0l;s z?BF5W+(|^fcONn8i94KfAAZoyi%bFY6Nt6V05}@HSvS~^!N-WL+)%y)$oqZZpUuGz z_&(xg@YD-?1TH&|0ip>;VS29s=R3ngM4Jns^Aen&zz+pGo>UD09($x7yQ(rF02$XEP4>AA_gU>{2kpuR;WR(+70<76uoxE%=WZUGoLhx=L_ z9Q_jrYB}e^7$uAzl34h6)iiI|#&MRME-;tfke1iGl8ULKG>63|S7RC**l9DBuMJir z5x^cRveh3C-r<>q9-}lX#zb?6CI*Zul9=bnL$L(0y&9*fRx>BX81+prB4uO7+S75q z6-@tF8wt^;B>uYOqm4nNd_1jfQCpWijSg*of&?l98V5kdnJZ%zup#jDU;p?#DN+l} zGSU55{Jn3@Zhl|-S7=DsMd`_+7+4{TP7VVUObJ@IF(?C!02%0gCZB%6R3Cs=gc=E^dIo$vK{%j)Z z5@_>(-XM*Dd%BZL;ZT&rET~Va8iA1&@J~U{0ob*sxXU(}be11#n2UgA0*3c!qtC!O z(aSmQE)e7+>JEc&-N75p#hFDg909oxhc^)X(Cm*r^g?>fFnd@2hYjurIQ#?rp!vV>&tzON2HLjy3+rX4kS0##{@N*;G&0Zqjh);&lj^s#E;^gm|xV>tt$Kpa|` zGku>T$C)y}|50^w`6_z=rRt2k8WXs_1J)|CrU9Zy`@pO0AG*3KdV0Ixk@+Qx(nmcs zLMnvuzQzNFa2x$=(7_Ji@;?wRdJt$J(neEA-kiNhLpcXtl=-kRE}6MM6d4kZBK;@f zQsT4+mX2-HAF@H{0Oaujg#BGB8?mx{&8&VEC-nV1YOQhAZcKjYyPhQmLiS)W!MFBj z9NRc6Omor~#&qe;c6}*gZuMAy9eZ4_jBvlld)#&3wy&+mE7sJ%GD&DA;@B4+LfvmM z(zGeIfX#+3t_OYgvlh&hBP4#FS` z&9zVCNk<~EV;p`@a~2b*QTg^~aYX(5pmk!@tb;M8nBJe{oHVEK0jcXca2Y=^>aZr9 z+8m1l(7z7i8ZA>OF3L8h_^>?O!x6iH)Q1e-4Itwp1EcdI8FM`9RD&Kp*bR!XK3j|| zE2bIw9WVY6*Py)W$a;=2jjl!S)qlmCmGg&l>!V`Pxjt3Ur(Y<~+%BS>iOh_fj$EHq z>Dxo|Gt}rCn?(AE$sTCk4*NcqoL=|A4{PCz9o*SAw&$lpUSY=fy+!6q2vmtp66%t8 z{TVEDtc9EXf(=G(?un?6{^D3y-S&>Y^Lx8YVRD?Gyplwgy13Ai=-YbLFC z^irmwhHqEs+NP=TtuYZbQddKIOOsqe*XXUwoZfq%4@|$!#k^sn-F$ESqdtb*O{NFF zS7C_TJC=Ara{jAfpJ+OiS1}rv;AQ?3 z;}~f)wQ`Y#>>TOKi16b$GagmSY78FXq1O`LU)`J1H^qxby+xcK{QDM4W%TMbq~nao z$yhVl=JjLwdQFWLGo6JS+ybnOQAe6jSM`*_;T*rC@FzIOujrWIz5Kgr|2)_n24SQW zU#%8*mb#ww4qn^0QHHo-*D?$q0W6wi>F<^jqrQUC829fC8z_G%U79Jrwi||Br!7NR zUVw@37I3IxjrR%CHU*H9Pc`g-8`u*S7D7-p|74YiU}$P;y~7G;rFNdKUUY7dWHz1n zv~AKZO8VvaE%WHVfI<&T;o8{=VDqUuuS?8)(Y)SJ=K1!O6+}I&F``mIZNsv0gkKLu z;SS+e8--U#-dbZmY5J?iVF@NdRj16K9N+XgP~u2lgSyflI}!U|6J3VL4MSh0;gVR2 z0yw=KN2S7&3U12Mp~L}Lga1B&R|8)ba0UQo1hV{o@2;nfv>G3l9pH5LB6|Rt=K*}3 zEqn>ahAUUHa}o02!_8CSx99)V6aN*D8gyOgV?h9C*vs4lBykdGH{S(T9*GhJA0Wtp zArqE<3!nvQO1uYFPe`Am|GOoZ0P6kCZqfs~3-{s`tU=iR)k_Z# zXECA8(~K;{+n-q)yyo!(FpsfuozzUW9>?saTD+Uk(VGI*ygG02ts`&gs!Q&q$(bpx zmidATS%23~`{Pq=;%^S4gecQ;`)9O}P)*%7KWPrIzqqIim*l! zCAcVgYt~Oj>G49D{vUw;8p7am^U?+w9NrcGgB3R|O&)*?g^b5vCda`Mz`58ZFsH9P zcl9#9pf_-S&%F^wbiOwD>+L57*Y(-iK2MLnOv)&_GqEuT?U;F=R9l2QvLp5H=9M7p2k`6-tnw{DoeWep znL|P6J;2WX4yeS(l;f7-;>FPRzn+;o8|YXNd;qg%qilnS{;M*&dXN?kLxHtSeJJA_ zj0oT$|9^D`5)AU@ZXpDyg+tuye3z7G@ZnO4^#jBwP{FB@2j1mdZG(P#q5_mOK-Ig; zT;ZRu)Ch}u7z@f1r?a+~Oa>-3W+Ok|;L5HfZ39@wV4N323P2=%1r^RQqGgwUTNoCh z!GAc!RsCtl{^E4&3XrbFo})tEd%$g8AI*eo!6RM(@(Z9X1W;$kv9Zwx80ik&U0|e_ zpAX&V1Khb%4u}014lJS|O4&047zjU&>+%L|gfrAr3j%vg5+&s)QhyOe3<+KGy3hX* zSSP1AsIKWI$nqlhxPD9AahpL@<&p1uTeC$$E+{@XJ%Vr2NIeP4fi{`^Gh_yA?f18V zI|0vqs2l75nJ#6azsCqMhmVs<3_ccEe?4k76J{OxcK|$CULaj%Gx%tCNErn}7f$!j z!6K+YB-D*_7c|!*?_DKMsXbVgda0jY7+#w(SV79vahG$#>J&1L-aBpzL z6Be(7pss@{RFfG%K^=N*FH(unrp#zydg+Mh@qBs|e15aF0cu{iJPe>UfZpdx9M!(I zGF6GOpXE3GlJKoA3sjt?*}eEAk-_Ps${vpL_LtHN<3sQFGTX-`*~tK0+5hQP`h>nd zgGv6XoG!Va&WOZ|!WAn%Bn|8l>X+kB&|d_oju(&DT_&+|Gu&%68vh!jI~kBO?-!6- zWv%Dcgrt^pdEE9QPdvt!F}c{nZjl(ox{R1@77>Hgtm2Z`$QBQfO{YNq_HTdtPtQoO z@DX9Pk%e)SVbwzh(As4TrVf$3o+~3Kdo(e3>i7&2Z2alU6-hQNsbQ$7d3^PQIifXh z6ZmJ7u(v7Tg7#ovy|4+|1pW{KGY`0f;9mHcD1Fl45YY5`s`3SNfO8J~|7}keK-nJ( zf+wIn+(58l_IHi&7wf-|l5YZG%D~)1_BHU&&Mxi2zUi7<+x`ON53q{AaQXw@4|W$D zn~=v<2AIV;fG>?8=+)o;)n3YJ3-x-gAH4kUU!cH54Sdk~4N2ERO58 zN3aQtA-b@Fh_4Ewzz`67)q>U(l2mdoXI0tV-@cA zs0VMnj7J~9>qSt*!;AP`an7wqnlg8*()RnQ5hisjND!BpT#uRf{T(#CAJLnD@^`H2o4Ayq!VY}Oz$ij_B zO@7nI)Dn6~K);Gn5`C!ux=Ed4hx#DO)aduGODhIuHF8AxQEbX6qK1b@`>$t)nQNNA_jk5L6hKqF99$n8=Kxen>)_3x+#7ToyG%L$!}AkubLA6owOF$*RQ~2<$N$L zI16zLY%#hC_%{JV2$3H!3n;#qfBb`d8*yXE`Y( zt<(C;+#NlOzBFSK*BlANb~Rm8zoQ2!zdi=^ulM+9e0=%1o}gS3j+#%Um$|K&h9gNN zv3qC|9SmG-(Tl{TJPmDMbTHm6mnGBpXckI-V>)OwY{(G#%hG22IgGqV}Urg3lJwXQJT!iPrfAx4GL3zK`lHLr( z&fdm;f9tTDtI#v5W8QIWfV5$CV8EV%F`V1TA??N<8AUM!8@8?UT>kRp^al}zy5uJVLPv2BeU0s*ETv&n? z%BGw5$&s_uM%4G6FzLsV6Sv?&F!F%_)Fumd!>;ktc6(WD{~2ooqyzHaiSz>5CSqae z$k_39$^d2efKKdV-D)G(7B4oq*}f{=7mwq>EMVv)GXro1GSca-2J*K4iTk=gW!zpT52 zhgPInDB}1+3>0i)#N9mqKKVV#NB1XUdoc-CSq}Y7Fd5ij-$rDcKUUS0=N4-;E1mP& zb6+Q(`f!>Tn*625jHc!T(oa5bmOAj>8kvd#=BR1RS+W^K8GD4NP#V5Z+O{m{6s zuTh#M`r+Lf9Ak&9!1+5H6upnp-SSRIW)SccFIBo1M(~QWY+D_%qvWIWWF*<%FxtYk zD=$P%ny3ioub-;6+t6ZJyI3an$ti=$qCBCHffiwt6i++hYY$(1GD%iyyW-?$$wh-N zpI=Mx(mpEjQa^G)WbqKsoYHrxFkR(4ITzU;#hVP>#Kl~&pA1;jGoI4MPd%jK@EF@l z-IHQO6NLA0ISh(ENs~`EuuY=Wy7J%b;>o=FEUx~E#PYMGUQ)sv;i)Yhk-0qm*pF|q ziO_<{-RFfpt|q~vJrTCW*-lQHnqLOLi^CL(O;11+ zjc|7$0P>>>IDUL0#Rczd0k7O23DY*Q#F%3NIFSEAy6(uX?gKTOs<8C}zoY++x(fqifvR5pHz zKBb^m-x>Llt7_P{N( z3le{{eE%{a9OZlB8L(9T(R^OegFlZlpOjlpisgO2r)dI*?H7GK;VlQa)~ZP9q2I|= zPt#4*XeXbwJI|+}sfPNpE6HX)^7T(CU$L3jZ*iHGvuD0cU>_{SqagULCvVnaTax#g zEz8{zgxvt7Oy0c0rndAQhjzDq0oPhlR&6w?T}Ld+^c0NZL{?YEvYELc2r+>L|T6zy8G&6KWa4O$=w zmwq{0D(Nl~`?z#x1|0z@Bi2B_qUAv_n_rJ@|6tdF<~d6mjGmM`7uua zBB|&`#{|*&r4#O6IIicMdE|8vzG7EufZQ>9F%YEkdKUpmJ)NXO zpr{df82}Y?{FC5lhUn^e`~e##E(Fx9S#mKmRl$H4o^{O%9~*8EHQK=9{80!~Z+x;{ zf9TCeBxwj0Q{i(M9+wSJ|zkQSc z?^ZrpQ@qEa3#l?P3iJnA8RsU{2z50F*|8V}g8AGn{RfC7 zp^Ik@$J^i!VebO}#_4~j-^uC!H+!4L{`*!wNIBM~j_a=tESeBOy$4DG1)NOD*R?h+ z&u##c8eDQHDxcDMvszI&EG&*vd8D*Xe^Y~5T@@fdPYfPk7yr-UEHMwsI*VKzu9O5+ z;eS78|GBx{H}`*ckN;Vqs6hfssuWNGZf;ysv4kGzYXM0xn7tVOubAc6$A9$o%IW{R zTbt(mcYFBnJ5!30Du%8zbLNPl-~|aIbjpE3TXx6ptjB$OhrJqcT;#9$jkhoJS;G;@ zox1pt8B^1A*UZ$MmxUvP&DvruA+EC%%~~`+V}ym`|G67m@`*K{p`OWFUw3_YzP=v9 zc@9Q-L6~lHWQpm>k-+mSCrtjH%~1)9`n-_^w57GiTv^wK6Egg=F3C~T{EVO7DZy9O@ld%4U7E?en2CbjNpSCPY#0N*igi!o4 zbmzdt*m5RK2~y7kwjL04PDGCLE*JhqD>hqz3Zvd&7do2jH>(-`daiw)@Y zntKBE^QExZf2^-gcph0p&+*UKS&vXCKLqTaZeC_o>4J=f$)U2J6{BTit2YpJ9pL*4 zTjtB3>VZF_>=JqP2E1HZkJ$zNS0wQxJ*4boSo!QDw$8byFj{?+>dtwtFED^a_ygb` z9ZVVkiETTk|9@@3!?F9-f|W?sQ=po5!!TI%;yG79zizY`CD!~T+p34Nr@U#R4bi1vvi$}EW7 zI0&y2VF=-#i$S9DJoNaJzf9tI77aEw;QM5LX5&EqHTT?kGw}E16Yp$nOrp0l zk0(Kx^hjW0w1Y3dnE4 zqz0oYc?~!#8c47G+S&YE5eE5Wmz(H@8%1-Xkg`cI1uA(?uF+%+e>UPi-F_hq&oI`D zKXi#Qa8`K?rT?TSsyRhRbcb)v|lx(6uv}6CZ zs86<3U6^Hp<>?Z@o2M=#)UZz7q&F%(^4WK62?Hu@Y$uOtldu%$gfGM<&QOt1lf~=- zZ2poWV)&SXQa?@GM!<6Nkdvi*X|zZY3@W357y@E3r?$O*vJk1F8Tl{SC2i?MD&z&b ze=Qh7^ZwVaXU6%u(}ME1rVQFzPObha?_@oVDVXk3*{8t3W($dKxR4ZEB z-{9Z2HOW=foMJ@-w7)NLK@C;B6(gIrr&I0lgoZ<|e+fhjwV8U35}gI}cs7suAxiGF z6P}YeIWo!hmFu0H>csDl(#TB(l(u}D7{O6Y4``qtrblD4HajT@fj4bV_UVcEmra%t zr-O5!NhthahbPs~Tr-$H(n$$=2u03`|4MMnZ)s6gismZJ;|t|z{IBRZ97cOC#VcDm z8D(~REeXT7hNl32=s4tE!GhZIOkgJ+GsCw86x3!CBYfp4JLtTgd)^016^`IA<~diX z5?I%BqDXYEt&psGJJs}?VB~o0BvsX@TC}J+_8Oyd04kBNs>w|8KV5QJqiI;94XU-75Ez$+6Qm>;VSZa+flEU>H z#~rKLbp6f6(l3KJ4yL=qA&DYY)m73#rpnTNaW=42m$YV0)R=j0TnjZs!3El_iOxyU znr*`gWfz3ZKkHCQrEJaCDPO}PZ=CQ;E#{IcU$2+fAUhvV2&uL9slp4kznBP8P_3xX zyzdrM3NrcfjpM_=p*S!6eN&#+*Wy0(^hHsf&Y~E02`Sf<9R-Oq{$k zxPvnU`Q@zp%l0qh@!94t{j;sDtxjju`zz^p`=imA)w{;|q{KeikcHa#znC@CICRd@ zybbNg0Bo2I!R|Z`-jQi=0Wk8K7qGv?mGS?!HnaS{-`O$o|88~vt%5r$TkN#0?DP{6 z6<(N%2a)}9j<@957pO0{|8TB?d3@lHJz(3Rwg{>{3Sayd2J>0w?bl>r=sh67?a7mk zpMQSxGxOc1x)&=VA$lG-7(?U|IGh8m`6DY1EDfEDf^mH5gk)WUe6c0jMt8@4B0Ceq zX3g8zOpvZ@56J4%ryhcJ7H|&xW?8f@L?dBU?y%vFqDaN;iB8f(4l6~G)vrQ&T7K| z<*Z#XrS=mgqX7-1!b~J+xPNGzMx&8KNjl$n!k|V+%dCMVzBYx+eE^In#ON#Ixerag zqAS*noC`7%%X^G_Jg^voyih~LM02zREveOdBFl<^1TG6fFm^+XyA{xJ!NP~cx+E4X z6MdfUz35UbJ{CkbX2}V=kvb11n9F z+G^Auc5^wfBTR;b15y|7qRuKO>5EXV)32@XZXjh9YD_VF9~N5#~(c@g=) z+s9Zh>HH7d{cgwb|NFrI*-qOmz=>eOSa=K!;VcNg*en(K8KB-du_?jjX;?~1&s|P} z?Dq$pRxrExT6O4PBAde8o*T`a7+7rxqr)_3zT;WW8Js4N@-t$d1yDv1E*y_}2=d_$ zEOqd0Sq9~h6X$#Y{1ToD`i<$%$Pk&2Y$r3Gft7h+Jx|CCx(Xcz@gdNNW@ZU63s|P+ z>ma0KgJCq)A-BcgTCLQz6C8-tfMeK;8%-?EH`AMs!(ZSg@&CRT^q=jtt?cxZ$`c&6 zbshv5u2Gahjcw>Up4yPD0U3peqZs$M6N?E{zgn8Ye>?frW=`O52u84v7-63}U#X}_ zM!ysHd=gU-nlm(<1U1RK`d-i!N`(<2CLPG*_s-z`$9CWS|`r#oxKV?&zZr&n)jQ$ zb)(mAd=hX=j7WWt;Xm_JXk|Bp9p9hpMq(eIz~TM#{{MZud$_m1r)<&E*RD{EBcdzI6d^h|!gFpRl-0rXTLmy06|WYA(Fyad zPXiPC`#n4TRM{CJUnG1eg~~y!8no^^SWSQ_-4no?INn%Lhj*yM8d$Hrz*&OP+(R%; z3fw51=GHJ(IrkRY$Um!Qpo=ih2Zcb~Hl`;2tb9xfGz!Lu%6H1cezrawz5B=RyQBAq zdjsnOL5(A{#wC5gc@`bBA~-aU$a?7b=O7@7 zMjg3Mm??n4Hp{(SZgqaYIGH0K=d;wjBKvs&CWNeLDMZ!1uY1? z=XjAY_b(kkrZ){UYvzn-f1`Pbe{k_Us_C?FF|#Z1^2(Y=qFjT@uDVmXlbCzM#sBb+2a0JM%r z4hJLKU^XUHrrOp%0v#I-tU+>BBO2s-e+CkK(dYuwpVyl`3lmK1^4{kH;G(dY2Me4kK-Cx>dMT&M=;HxWp?1x^NRl_*}frqLXkL zg@QhaH?p&YD+{Zd^seWoLPniUxkh z$&`0N6Jd;+YzhO56;jXpn!(7k93}0H9n%T!V~@!!5615>*9>nwb!S;Vw$!9b#-^G7 z3dW{Nm$h5aF5TE_KFzHkCd$bUyu}$sa_!ApwK01lFwGM-3}91lgD6IinNemm%AGQ= z%7P}Q8wbbHBI2>-^1rq%3`FRLKi_x?TgwqHKgde-~j45(;z-+D(lOR0R zl?}t9?H))Irglaw79NgHY(u@0mgw>949*oG9HW&Hy<%-+ z#cOLfLUW%{K(kEo5PcT#;%NXa(4`}Ij)gw42jJBtq$ z3LM!ioZiLrrf98n6e^pQ-P_QNtX^g!m1{P!l)7MN&9-7Xg#`vy9wnvWQ~s4QI-2@U z0f`&gT^-XWI)Mq#H+w;Ky+Y0DXRS0CL(Y z4u3WHe{VB){_FI5KQjMkk!X=`ME8UL(*aSY94&VKqY*zu4V=1-X`yVqjbQozqGG$P zS3E1G4m{BcNZ4_OTpc%3@0jTc5bd=FrS0hKcv)*rl`!!R)RUEtrjFdGq-t_{)zx*Q zeO8iMQ1MzRH6UA+cLbeX2wqqmpa)!H%|?xw=`(4pHmF`ittm5_W?wZ&yv~d;!+)Uv zZxY7#H)V%y(fsUJ&CyRzEKBS8aZf|aaaV^dwc8(L%x1+do%j@WCEuZ1Q54M$-iGDl zb!BCc$UwAf=6d>DzRB#^6%YnAaAx_{YUgIq)-;=HG&hjHJh!^K63SH9)gs?D>f;pL zPyl$iaFO5Dkj?8yJ;1Mm{}+J4ir9ap{dX_x{|Tr6PRH>7$HV^;~NDHh$QUdGepD}zbdMf?=bNagOx}@t#b~uc3vEiJ%7sR(Bfi%_!^n(&y zf1O=1ih2F~^Arj9&p*=>V$2+Dk|~Jh=?wA8mYs`F>DW)LDTyb+C_gnJu;hlh$K(;+ zIALUXpUS+pLAqFJZOc#QViO(-u)qu#}x z;dM8g9$#{Gr%LD7%1={hW9o9R9xl?%;%}hyFPD?RWllZ};6l+6Ge} zG5%-G2TJiDkiNgE@&D)={J*#OpT7SYGBYBK5MZ<5t8*SFU}0DPx73R`r7ptMWzh84 z{y>~B?+Cru7|K8cUxL_keat5W!bR-D2=<7_?u8UOg?{ah=Q@#fQ~7^&Qs6J9t|Tc6uj;En!7qro=1zSD0or_@1Ql(3#Ymu0dC zaw?$7^-4NxwsM+>taS$S3}@?r*BQFSk^a(I0hR#-N}sg0^~Db^eP;FtU*Oz7ac6B| zkvI#jOrQ>}O&APtzF(UDN_Q|%6NsQ7im4m4?j_uNpA{wJX z_KkLc?vUOvHi><1TaI*%Z%D~K3_t*;K9!Dl>0Cu; z^FZ+!%_4|obP0CFNBj}msL~CC<(r2WG`Hu+m^k4GR_{|b>c>POs*3utgB|af6IB-w{*ac|AJV(MNmqYrAo-Ube)M zTxELy?~WNAp%iigEQGD}$firee((q`GY9GNNFg$DFk45IV0Y&wcoFFM(x9AOrHN)r zO?ysaQCew07y>_6)ZYL{O@-TNl-!c9P#GQ>Vhy~~)knd%HK=lo(STHgVQ_`@AI)cqni|tI@A(lPJPSr;hjnc+B(sUdQx(VPzWEu%EXKp(M zpo_3Z`DkJ}Am*7hASj(^6{K?;12rrFMo_>z1WGwe!yo8HwMN87&XrDy_Nr=W#L6^V zd}-En_Y^sZQm{^4CX||KvYv=1q7-;BcWEuOt|>#9H@nCGc%nxG=HsMcw-i5M_yG8r zUYNbi@mvx%#UNWxiV)2ijWj$i6T?Tl^)R|pkWegd1#_fVuWHPqcbH|5@@*DNiUp^b z6Bf(}9fv5YrieQi%LI*QC`2K_)&*Pg2}VsrqF4sO-m(f(xG1TNYVEiQn=2jSb^4Wg}cZiFD<{3QZ&*6Vd`!9O!Ir5yED(2 zOs8-|m~p2tX^_UBbOf$EmRhOGFE}2)z(@woDnz6xRZFB^rysS4|0v7vhy@>q$8lzpxoalzq2~RtKotKANb0gVV8H3X9*17Oo!GVG=~E zNMrC#X92?Mu(nY?mTZ<$r2@MTs`&u=lSKxKLcE9@X~!WpLJUA>tARv?eajBxd)9PO zQo-A@W4HR_Btm1>%GeZr)^nTeG=;Qilk`Ob7FA`FsZ(*4EgXW z*mC%fHrx?I`S+)}W@{;$`~#{cW}clzf3_g>&X-RZ(B-%O-e@%lV;W)pXa*TDcG z*|*dI;E&*3`~2i;M%Z;^&M(mM{eT5%VeMkzQ5gd#7bY|T&b3S=4SvF*1$N_@hl3r8 zD6pvjunxTE#`HAYl;~JQj-kJk`zH8dpS;^WJbt_TZvXIvhG$AbZ(Q)JXnx)L+)5_P z4}h7&A|I0-(-S*?fkSrB<}qpF;Mz)yZK@&TWR03w%fp;gQ z#|f(JzdO13-&@<;CjRUFlK=SrKB-94krEDtkwea!dsnf5z>8s;xL^vT!ku~OCrqXh z3pzwtt_w0vilI$LaOtb&3#em>Xqs4h5r+58jmV-GuRx__a4)bcSPFU8C$8w zNFSw4XjvgTXaXg5{40%BvS1`kx5J|;CFyFLEj5Wi?jCVBk~jG@T#{;saXATK9isUe zwOqkli!;1}!mwz>V%bQ%E3}H9x@be4#I-`R6l!qJ(sQ8tONP`+mK?9EgV_u(vER`N z^Ehj`&S;P#Ed@v%@LNva7mbJRSf@M~*C8Fs9(#A%Mp2z6oS?G;Zaq;#qq)qyMoelH zWMgXKl42~g=p=Z{nx}U7sXm&mK>}BpC;Nrr8uyC+l>O+oPfX z2op*4xsaTrdBh=!(Bv&)5;96do>`oxnmWbAIXDJ1cd(`b-u{Xp9X@L{sc?!U?i|nS zp&F*qiU>?;q|iimBY<{E^pZHlphk48SQU+dc!&;850L=6!8}q7nrwM#>@fYPk7pI2 zm?ILo=lJNt30(&-Ddc!5$%Pu^>x?B=hyxiwkxq@M zp`!gjo5H0kat4^KrP64@_cqXMkZt{w3?y*IX)(@8S*FqpcQ$l;J)DiHD@8Kt=Te&X1O-K#OxuFL^B21n%_HZRM@|`HNqz=jm5XYJ`z% zZF#r#Bu%7jg%%dcluk2inCo(wh+~-^7nJ5Q z7Gs%yGsvoRo3)k6Rvdc735%@8>I#o&7no_6ny%&qtarso#xP7+V=IU?P|=qP{$nN9 zctn%Arez>3#NB~L=!z9jS@1nI>Q z&Ng)gR@#gTzp4PM>=Xz`ujGV_A)KwU$a)~cs>P~-Gqm(pS&Hm3C|3dcZGK@_s?S9K z33_F1|M|$00yw7AB+Qed@ze$?i4go65+gc zEZ;KSf60Qg`S|o5#Y9?MfL#v1k@+ke+cOr(h_%!a2|KFS65NOF+$( zUujoJCT-2P^};TZmr^Z0PIJZO=zr{-MJn~D$yQiQ-9zWXJ*QS06bY*He{ODVl)* zs0k}F8G9btJbZOA!j%~_QHS+}K%T(Xrii3SC6{TDH`Zc}=oe6<1#oC=%%jjfcYW5` zuJWWX;sYwO0qAI|DB#NzcQhhC&ORb>%#Lmt$Uvr?!ti0g9UbnU+IUe!ze#Uc%zHOu zYo}$i9FkuOOrd%rkbacE!#tfF7G=sB<=-Ct>~$R2`^l%bo%GP@xjYK_D@?`Utqcs z^6CLq>KqcDJ$JE-}hX6vKxCqGy zf^ZmI!m6pW4`vBSVf%FKFb1(^VhPT$6T166goEY5l-U(dev7iZd<;uGiQ`!`*w{FS zD)Td&GUZ?#UjiJjOWusou+GM}F4(jiMRO8uY=~fsnZ`=X_}hN?2dA? zq|vQ(VSD%e$&p38h*ou`&T&|`6cmB<^jFhIv)^hkCcl%G9NF z2Xm? z`-|HDZ*BGaIr~rQ6KLfByO;mjPT!Q=kEn71Qk;F{zl4Fj4JpZ#As}+aXR{&8z;o%2 zfFu3??7jPT+eVf*I)CdaVE3$qbW^Zywx`vx)wD#%?AGlQ>7?^zc}WlfN*IFx2LLT= zy0g}Ki1UQ!Nlxv(s|s}kNJ?~KC(4>NZ4q_9?Aq7g_N$|3l@+j8P|1J8|5%JYKLMuY zMq$6nX@g99aY?WcA2E%|7*K9Qoh`2h)x?a8@$GtIaKwTuwFToX{bwbeF*^dFCrA-A zLDoAa%wI%FQ!XbkUbm1ej#8|^GXLDYu zI3hOX|1)EPW$W?d4z;H^4?q%bC`iQ~NdUDw{zfli)S=<)UXv?S0jIbYAjBdSwR5mXjUNq4cW7C88l=HZ}KR+w{*RwbYojwKo*I6EA7)ov>iM7+iHWWA_)6aZqk{{&d64KTiohDg0OAyzQ zRYz^Tb4rcosv$&vhg4Z#!#UY^FmB(k8|mDNY&i}hHgFIEhS?O`S*ii<+(iR8$^UPZ zX9i~bBq*=h|NA6;b&mo>%*8$64yO`fn1UFk`QbVW2u~L)Dp_&?A)8|!qfw2c`LVC; ziNNqr?tljb3-i;meNKvubMpn|4t+WeLWDBOBb4vC-*60=%N#%y7+jQ`X(+rO@|yij zItC`~rJky2;OBJ#lOG$J$jpv`6mN0Rgj7gs2ho!U2>AiRu)_gZ8L0z+bjC5`8FDyK^^HP&iD6pajPQMUjZlW8yS zY$2L5iTWu#!B59AJN|!y%+c$YA$y5;p8e(7<;Ko+-)g|7&~vUpv9` zt!JZ+=*6>VTft5cz1X-MN8?dA-o6|KJK^?=@#w|b_71mJs!YIV!^x}0oJGSa%>O2uWuO~jB4J^B=bEf{8J>?5ZNQ0S5` zaNY*Wn0{LloG>i|TM(_^ZIKr~K&`I|bj){-&2R+y*I?NwrcmEYIl_)N%`i?C7ly5! zS4`MHweEI!MmzfyVqo43s|wulj&=qy*PW8xeVuz;gfp3QpkQa1qv+S2)7L=ENv|o+ z7+dWbf;4pC4dr%N?;^tc0z;Gz2z0s%bk^X5EX=7ejv%?U(VwqokRG9oaE$}t6A~Qm zdKj^nIHZIal*14SKWA0g?2Nmh`kAP`kbH?gEx=ji@N$VQ3?ak9L~t5-EYT|^x{6bX zV5o)*Yq3_NKct>3ov6luf>mS^HEBkhiZ^IR!widpHgXaT<4JTJq*<}+9cG{}b7@Fx zD5+TxcZT(mB}s-U1v!=RifJ?|a1(?n9M0EGmVY1*6xG?n>z?)t@4J`Y)?a*A5BKu8 z;P2`7U#yn-P+QIv1V{97z^1`i;t!GXck2ce4IQmWcX)nuG8ncyOE*TRYth#1d-Z(0uz1Y-5$Lud*Zz)nn5Vt;Z8X4zRbBqj5Gs*N&Aev&$AdV34r$dQP**^S~T?LfWg@|v0UWR;Bc<=Dt zpi9&C*DP8@a1RiBnNTzsdR%pAmFUn6w2g}k>H$*n{5KQgP7x|X%!=)1JdTRe(bKGe zr|w&}-i-dtBp)Ll8op^yo?t$MCr`9|KJ-y8npmLPk1UeUj=26<)nRhiJQ$W;jj!Ad z3R>QAci8`-ciuhjA9aVl13(jfci3a^xAMSw*qF@o5aWvvS-@s!86S}cWwoxZ?ve~_ zviLv1_LuC$13IR_WQa7x=aVr4Y+dY!%Arqky#=7iVd78; zCa9Y9H$J;3rS`VF-zaylzNiFPhQ525JGJfUTkbAb|E8?oWTdBMury%(@iF6NO`FRK z4M1wtWW)tplunhsL#yE_DX827vT_tOOvFkUfod#L<%!a-BOupV?*04U-XmTD;Y1jB zCj@i$saDLD$qFoIK6q{+>tgj{@424rJ@bhMQ;(4V;Ufa=@Og4aNt6 zlWx%-?0V{tdu`)ybk?nb^|w5^rKgmUux^(HFj67KGoKyYwxbEd^4{TP!qbugz?0!6X=qjvjZjK z^2tiU)gURdJ(62T8b|6NwRMVs07>sD%6Lyn#KY?=vTd=;zs?OHQboa7!fXOpSUm%f zW;Ie-ZecXY$9e*C`>=#aSWCE2(oC);6C zxH2yGr~aP*7$Hg<3H11g>2FsDuGSOvJ ze$#hS@HUm81=Nc;{rwuA6}N!HmA_ST*`OH2|0^;`Nsr~K>%<5VERDZ&gJIRz0WrZ1P?0ZP-0TBxgr6qnbf&D$o37#8Q4 zOkARKV4~p{1PX!B%_?rZVqvFfy33<*zqy9$0IhX<1}->kIa_lZ>@@b-OT%U*iTg&u z^)b&>HZ@NbToLnR;iafKUeY(cX_IuXa51uG(~DzrHaEDS{IA>K=6G1tbnfOg4W$!* zaN$v9q;UgIzorZ^e+7oi{0$i-{BgcIW&_FoMn`?XeyLx+g|mt0Tv<;b(2m>k zjCvA{+)%_YAvONQI8y^dm1o3jP%_Jmw|dT~SXmcFGpZE@>&{u0m73zUQdw78a>gR7 z2xP39>|AXeAglO&NR2`-qAY|>T~wJ_F+{-J8n9z= z0T$VRM7Nh&kcS+|;v`250ed4jgSofb-KgRi1(2k3xg^fUV#^gn=4q81T;%mw3H|>V zkD`V=fOGPH?!4Ht;{Ug|9^yZ~eEL5pyhD7RPW)5qMucLlaThb?=qPDJdcOI9|Mx}Q|9f40CVKjS%spe` zP_bOPPldM>oZtZ*w(?eZg@s5vzAZdBu9D$fS0XG_gfv#};uN?tII9;xC_r0Rpm`S> zZ(VTt>j?PGQ2~hnqWnT6Qk&4PZAf<#P<|QIF{5&q6uDY=#QLeOxG+lI1)s>S_a@2s zdoLp5Ss!8gs@fXS4nmmV19ydfmMghpqFfD-UO^F6vb36Ew&rw>!N4gB0}y_VutCg07!C? zRun3b^z01*Vs66jD2GFYdGh=|)HLJ43UZUeM*m7P#nGd3eG(Vz#VAO){6Icc5G|43 z%B8ru&I;KA&N&{BG0lyT8gR1ETx$?aiWytbgXo_f_B{f#(u7hMu9J|^@z{+Zeshwg zahc^s+xJdVXqeJ7C@TSr$t9bc4DATf|IC7Q~P50g5u(kPoqkVyif(ocuCb$sfLisVO zY^~ya!g+u-6psJ6=YQpqYZn_AWUqs2fk*?puE1H&8~1zKS_pEYFt}+polz_v99N#Z zv4YXO4yF(thy6j@iGb;&a1$|iaMu}Mpl5$bkr@y?lSE{jfstN;@(a!+%wdFKu3i;L z21f(1Co@d&l`(;54E~L`PmhCRGzfts>1@jH5}j&1;(sM)zW`iT3p7KH!8WR#cxYU` zQ-BG`2i^MFCLZtGBEliY{2z=FVwdD5N|JT9+<>X*kUpoMhS|ZC<0^mR^oO{cP@_qR z8IAwqMj3Ve+N>@+LEKsCb8l71aHF6@EE+O>GCsIWQ>cI`nGEw!)djMq;L3jX#E;Tr4&=gOE=-MwwSKkUW;%#%uYYi z!3(E%KX`1;PxOO_zbtm}8W-&AJ9)IGwI`1+xm;~!UFD~{oxIc=oZfdOp+#jey;hu# zl36I)5T5Jf4AZ!<=bJsD??kj4tQjHhR^x{T8Qs1{PlF(J9B^}BIs>!FWiDvl3dfRG ze5_9%w?Z^(DPO?7{;>DY^LNMn;rZZ{or{JYXuGr4%Z3-pTrUw*mrO%1fKnI8KM!D- zRt9?28luCdV!*PRWB!Q4YkSxS+YdfH|FNAoL0L)wZb%~7E>QuQd$6HaqP>w{zGJEh z{JOR02D?8(_P~4T|H$&lxSXg2WdL~nxnMx&;QzmP?#TbU^&tO$N%}u!r#}-G491hd z%d?*)nH%rrD1%NAQ+n|%gw2@%Kz|O<<9Ddqm_(p1_aRDm;Q4Syo@K+VH!Z4cg;5TS zKmc&yc$+xRI>`+fScRw&0G`42?J9?%BmYIvpR%xYDI*W_aZI@e#p8+DIaTl!tvd*a zL(v&{I>k3`OsdmegfdSufOO}+^aXG;039DB)9V0uwdH|OVvJHMA%AZLg<>WkRESK| z+sDJsr8xgQsjYgrAM$%Rb%Re%Uj1F_zOC! zmdjFscN*piLxfdqnQvZ4as8+&SHCt+ow6y4*U<%9VNb-@e(JMln+>t97x;`w-kR$R zLO25B(BjaF8sHs2Am~g8r}BG#CM~qdM|e8jSFpA5#yug3E09+WNmC2|zyJEMyZ?`B zm~`@AYOAV_Q}{{a4TILE!jl!Vcyi35>wvLDpjHvU0v$*^fC>;1I}(dD(Sg#|BEEB@ z&*U`sl5ur$DQEd3IJDx#D%W5>DGubp^%EYcvsifugr(8~4H8#|U_FtZp-ceqgIv)g zKurt+g9wFiP~c#7N5K5^G$D`Mt|dZ9P&|1p#Io4+lqW{DFzNnE+A#yie5}03f!~h; zO!ocqrP`R^-5)|k_q$WmJqaX44xMQsOe(yueYD*%^=u5o;Ob456Wpn6M%VVhf4AB% z!g`O{LvFl&0uO(J-b=ktqX={1wlrS;KZo7#r3wy@C>%4NEi}!7;i@jlXgV>bL81nj?wKby&@zk#!wfO9AH^W zX{KpF#JUut+R22f`DC(t1auH~=B4*op|X$b9Lhci|1a#Q3+4abWc zgx+d5b{$dNJW(Z%T!0Z)sTnmsF08uIm~n6`M?5E04xkp>b}N(Mlf@Ztk1cdIrLzhs z$+3^M8OsJTY>=Z~Ao|C3R&Z2A+8-3FVs0GuuHz^N9Lie*q)kcmOxf4oih}!8u6iKN zm6Qxamg(s~E$4Gq03uj$^_1Cb{?Hh`c*EpIw$zN9U=JiZ(udI}zTh1`03RB$$|@`k@LUTcgWa@7(W>vrC5+8IPg5jK5xHEK8Mk}mWtms)Dq2E7)g1eC=5 z-Qf-A+7T1v<2x$-%HA_k4ff4(Hd|a{>-r?N3lTf<6j6W%X=-a$u3LQIN^-pAaHLVU z4(agNq2O_JX_l`4Zm!QZ@B+Mzsz zjC4i$E`>uBg-Rb)pi7)GQW1lDerutv(O~Tzq(fu~7kRzB^;dH9_5|{{W0yqvcI{zC z@sWqU(JIS6;sPTT8UKd>0%!%NM;SsYac`tGrlR@2GnoewdV0iFI%|W&UcO>)nxi!feMKVrVVWhRLyY**=eWNg z6^JAqot0WSjM6Ja^tV=)4b6F69L**C;;|d3?}Nq~``H-xsQEQgNx*^o!g zv$1U=*lLcnRRDom<=GoS5l1!o6yzOLFQ|N736JD1OS&Kbf2b@G7Dx=v`cl`C!-`_RbH^y5E1_KmHy9#}=zG=naR5 ztcEL&+Ic99eP1aNF&ni{>K!_-1?O5JF_=RX?noSJh(rL4Ky$x6dL?_=qP%R`%l7J} zdo$ZLH%C*1^;68b#zx7`4WaJtI<}_upVn_}?>o`2QPe$}$8)Zr6oKwX&Yaktu6qp+ z*isi7pFHikYQEL(8}^;r4uWGUe}3mVmf7A#Ak~cP7n!PBY7^^qhvBKQy7mDR1RkQ8 z$%o|)@1!MqYtLV2$@M9mAIDcj%O1bap2|V6DS!)$gLU%xPQ8cZenlJEL*mQTP9)1t zoHWNKq;dfCnQJJ7Po7$jABOuj5t@o^Z@YOFl$%?%M8LI;o4{WSE8?aQHv|%Moaz&B z-eu|)2UmC9;lFk4YJKw!q8DLa=-RRyP?Sm%9~XU^2CRdywgyVRRr|A))j%BMj_{s! zR#nc7_;1V1==8bgxq&4|S&{_PqG5`g)uJr39E;9-`-`5pdZ-15@#v9%9VFu(UyL(& zIk0o~V{L0&pLZ4Os?>(Z*XiSgNZ2+McrHV8OI01tV~7d&tdLeliPDZ&jjMJDyZ0MU zz+Ded4o=`r2J|!U2D}k7r6PzAYc?z?p(#69Dydu3V3*yTkGrgY)E(}>JwNT9^^S*~ z)m7Br7M@s5%lKyV3uB$+VluVjglwZHv$9|>N8P;<6{Y%&i3$$YGzPFt;-VNt$=HEH z`^#bpF$Ee!jh<_++DKD?Dba%jbRo^4jc1nJ&Mym3 zoBXn5T3^eBV7GC>_CD)+ScBQqT^s-r!8!T=c=+$Di2u%m8}PQ86aGDy{_j}$4?Ej0 z9`Jv@-uQ3n%N_C);)|o=3<5vQu9gc9S95o&vF6?BlmJnxpmQ06ZF@(lr}uG9v<)w_ zY_N@}x3_S}4cgo98gIk6PFK*22yf}5H{yn6RA%e$-?Z3rbaaL0n;=d^$}Wo15&P)L zd^i5%w#jiSu4>A!_MTvJQQn|%YVNql-F|oszDW9C1mh@R4BXRRc*TL=?@s?2(YNUT z){Ey4^#9lR@99$yI5SMtNJhpW3=mtHQX%RX>}=cDVc*yJwY}I~b7Q%)Ti;E?H%WH$ zw7G?+B|A}1KR)xH`!k z!+sjS3gAx>&nzQ3Y8`i>*8lCDo$M;ZVQ+*oHt>_`c;^3Ctl{ux!G;-WKm^9HuER1X zt(%V|YOr5sWTkf4*J6nsyg(^jrv=K<%GDJ#W(y$Jy1i&qT|d2UyygDZ)|YwBM9Gdf z2|t-Nfd4Rt#ETGo_7?oFy8s7jWkv8`?m`?z8A@?PR} zO42q3Bw@9aaqDre^bQ3cW2$ql-kDLP)o%Zec+wunDtZm^Z9#sE9C*Ge0|9qc#uk`$nL?(6 z{AxBKXMmI(DWDUuAwnJ+vo#E~=Bb>?gHU?*$)H@AGwYw&1&U`%p2jQ5r5<1r*2nj!Pe1&_q|= z?}KYRBj~izYD^oH6Fs_1Sq8u>4)#yFld2P0vt6~JTkVVi35%U+ptFiO7E)=e!2)Nr zRA)iI!rTNjAF&woXQM4!-CT@9*6zwy8fKxjQ5$&5@2BDoCfuDYKQG6gRUfSdkY-t; z4TjXjWWl654wS^vc|6)yHL>PY$=U%u62|POGWP0Cxwpa8kGW3^hN8-K6wB8!=Gf7C zyuVyPz>Ph`z@n5z#UhMoH?Z0DO>_s?$FD(9ZSp$GOq_fiASBNq1 z;^8$wsy3L2*_tsC-TyInLp|LhssZ7x55B_5g5Ci!sE9N*8SX$mfx z(8T^<2^X1bo916Ow~=A54s(PP8a13@qE2GhQ~z?msuT@Vls}@+)Jtz;?EL%|U8(B7 z^W-W>spghq^oz>KI4nxyBZu0$aZW&`tlC6bYrKX#laadElm$n&gT7)$gaP@D89z>y z8M;5QY1JhBp;1uJ*kMQdrm4+#l$Ny6yuZnNVzLiv%fIfNo<0?zfZp`e`m1otbL9${ z6Asa5!2Xh-GTocD2G=VAc&qlE=)D|Q)d3U?+G`9-1VL+Ebf{tc3FKUZ8~BkH%)sWv zWvuj(HWTqSQdGcIJzDCfil^GTn~C?KhFLUc?G;7`3m__RYHCs1!MAog1Z!q$gE6%to zHI@sYeU-$xnymKSAjamzTxE$Q>yubB!ZoM6p7IJCRt4+HZPiD#G1>San}PlJG2UsM z;p0vs+8M+Z8)5M}?Dah;&xU`YnEOCaB?69DG$|IfF#ZU4__&mZ{z2mb%}&;Og3?duf) zDCp~-^9ua*YcT%PJS#DLN@bC0rQg7daaVSkLhb(P{Pm~hS%2P~_{UETEdTvk{kx3) zfyKWYi_h2kAf8$k`KRWZKh@X#>4BlIuOn#Cy0QZcR{BX+`bn+yo3QT{DXpgADDaak z07L%$GwYn`;H&evqpZ%In)pdJ@l#V1%GUmtocgcl|G7Au`c&g+>Li9{{{Pwb_J)oB z{_Fw&{Y&QmKL>+f2j}?55C`fc0CiQq1~Gud8nu+ckt603rK0ak@U_s&H!s=+U=o2X z-32%Y${E80y#W;;B8xrpFXoZk9P7G(3|UFUIi1NQF1>5E&k)rB9=3qOqAW#2xZG_bpJX+Wk*(z5tzDg4w9?7UX@O6&Ip znH1h?8)#LpKK`}>M362bLtxfE9Km|5+^=yGTv2>Ioew(liwEi)@Tvu1t_;&*2AJcGrZ-0*9liY}fPScl~&OG&-V zwVjVNe5X}t+QC(>hX53)NJb5h1oGf8yF$eJmUdh^`hw~#h61x}T~l|e0oQ%~6qld< zx`HYsxdEY<)qw2mLRt}dbCol>>s>|#<^u_M$|dNLvBMGkyP9c-gc(G5QU!7*)?9*6 z6Rsm}-gTbMuCAej(Fc(#Zk~b%;U&!f)W(Io90+IX~jgO<|eF)vN-AB zAB@B_Gyxl~LNlcxX>a}@1oSsupe%ECYAPGQEIu~Xq>2dU_)C)Fc<|?j(Y2@!` zIioIbf=Qg*E?!F{As@hBCREk@>R7G1KSx;@CG+FuUF$l5s=mCF6tA$D=_$nI^bJCg z>wep!kOJUN;EBq9?NtJhYW3kv2QB?s5XLh=FEJ+J4}Ka9dq?L7y*J%=hr{#zle6RA z+4))bp#N@A*QAko70nX$#;dqchtLHAej26VhO4Rm^eRRd&RRm`JC35T;MrG4q$ehf ztP(OMOO}T{9bR!0m#hHgX@*i4#VvdJCIc#BcLvp?aD9Xe3n^HqQ&<_EO$ZO|w}r=Bgv2{piWx7H7~#S)cvi7j zuY20}wzvQ6d#8B}VcZ%2r6c;fIGKUDH1bCQfEG_%?F;@i7v2SK#06zGDsCr}sLbOL zxJyx;qQf?1hB7ydahUP8_-4gdDchk?^mJL}*X7~cY$@1^p@Ke}2LCgQO(4*qf8KORDY3+)5De+RvRKkT1% zkNSgm4soVnOFz}^on&=zcHB1g?*<*&^YwkP4W(M=q}Ek?YB|@6+qsq=WtB74NxPhV zty#&fAOo#FO~nwQl~%H~b6H3EB`Eeh_Y0&Y9j+=oa)%k?xzg6Gs?%Q3(gLY&?OZrq zwk4aJbEt50z@Dz;EB7vEC6NQ3LJutNnkhD`hzPAGhg*P#C`XpaV!aR7wF5T0(Y2S) zQlK+D@ovb5l3fAmDXy~{ywL2nX)cS9P1>8zE|YkKL7X)>ay4)EfMD9a-plz#=Lv2-CgSvwAbe5Flf3syEXk8%G1Tk0o%+$8cf+? zC2Mq+$5%1XJ>(_vM06aR$-x#nBKbQUScUnBOFT5WzQ+wbMIBZ_V-@v%D_l&kgQCDV zoK$u>7IBGjiWH1<28*fA46Q)337RfpKRx{HZ3tbFpIA7-0A6w znZy7ry@+3C$QnesBQ*mgG8$&n!)VNB*HR@c|Lo-5@j>sPqbyBr8)u*$yL%&-FveDU z4$ELLy{%CtH>fAbEnh%-Vff##zFl?Ui(lgH+>u@3I1G1qekK&J5RVym750R>a(I zo;tO}KDwTrjA=UU-&~>k)%t&Y4B`Y1F*)K0h^SR)fSdh4wzpsGSpFY7kQ3%X|I78i zB;))h{{L=0B@O7#QNa@74`0%liHtlwd!ewrsG<>?Ci21kw(u@QV`qNV;YEjBEg=0h zdpS7bo*>bz78epPpgh}yD8KO5K`y#RaWhqmN>5La_8?m~sB&! zvk9s_hs1n=4xKP^vI@d@epdNi8peC*gx#|EBlkIlew133e2f_&gJPD$GF=6*X5=Kx zf3*ZuE{EQLG)#gnZniYCIXH@VayGokcFh9Y=E*!9qwZ+_0@7!d7Ak=s#b+N*A{HJ*i#;W`YcE~ z-iIifl4uPc;v9C76)$en5$W~lc!4w(|1T&LBmc?=$xMQxlLs&1FxG0=-4wJgZ-j{C zFoQTWt``4*02S{fHQGPo6L;ixGxCS_(~sE)I5%{lE+Zyzrd*91@k6Z~BJR}hK7_#~ z*khE&NglP%rZZJ2rH!qbM@tDyaI^WK+z#~0V)DE5*GE@wg9ExM!^ezv#0f+Wy!86V zKlBIv*N44|T|z<@q$S(z?r0PhYU!iXle1y>czFKiqYB6-YRPxUgVWxA z|4qMl04aI-0A6_;mhj@WOh*47Iv{&Rch+4Bef?<=+cS6tr04ggIib|v$m?_ji6VrS=Rrt6L8_T{iG=Y`W| z-m%JMDRsAxEJe8Mwbabm0E7LGy-jWLT)AxS343z6W#*;JUgHBR*^&cluhkjY`Na1j zy*BpG+S_!7b@nE4Sw^9f&s&sduinx5;BBurJb!omkK>ackIxT!!ybDN`^N{pe>z(m zV5|!Y_M#crGzP=&uy?+Ha(LK19rO;?n!FXNY^oKu2?gHoI@@-okTstwGv7Uomv5V$ zIYk+Z>FO!|OQV}=&PH|V6Q5> z@&HraM|1ZS9(Y;SeLCt=2D?jxE*}2ZS;e?8dwz`kEKljZxSyYEN5P5jPFsoYe-y`L zAzX{hGH{PgJ)OaKihJzG#aq@Onsp2{s{0hkibb`Zzbs_Nmoj0#2& zZ`~cRk+b!_cOTrwb*R$~%QZn?5jdIkB;{@Y;GlQBcmSf&_Dhq9t<|QhcI{muX@JSL z$HfmIh$PV&n=^qBLU$%WeTh)x_0#M9^Rx>l6*Vv564fQay<`2i-NQF>45qokF+p=5 zjKw7g{xWiF?WL^y#Or;UQslmH(L$w?bdFbrT+PI4YgiyKSG0ODPr$!uOO*#Hhrk%0 zWH%nU3Z=ztLP;q|HFK4rE<&g$xTGv6@wX`?dPkoENL8`UhMQw53jV_gZKd3O*f(kw z+As%GP#jQ{@)+>yxtc@D_2B)0%}TWXLf5J2()<(eT_Ic)usz;T0xF|GJrJZrNP_$- z5zi%(lYprf;gM+5F`U=Yr;?hCx||k zZ31;CKMNVc8?apf&7NXmQ}>4YW_wd~l<@gDMb|*SUX0?wk--BHr=m|%%#9;BU4ajL zwLEbc<4!vicaWE4maulE&L$@P7D%96HZqs`&i9b-A8MyTUdW3ZjMLf)5JTCe6O#z6PA2%5; zgQMc3{JH52y!W~CIVY;=L408qKJWyVjkb@DDO<=Q(@!GD(5RNA3ff__2E|%YGhs?? z(-B<(WWmp-X@b#E7r>xhkec1N z^i`rH!X9CgWLD4}gn5XzpVm1rXzyvoner>5hNjXlN?sLv^9(tokv%Q#JnSkKk{e3voS@-@UX|mCddbqJy~57Se1)K73j! zTj&VQ4Q<$1yHMlaIwaACt+h+5J8LH8Y&8AjYpv+8?nWhzSsC-y+-2Qc&TVWj37@4&)Ao&rlE&1$h`S^5`2O+T?`rq)OWcvx63@yCSK0OMtYnRsz7GwY z*+N{|9rjT7>YTHQAn#ebSpDeUzXh($st*TZU0ciKNG^#-h)b$S-QQr^c2Lec-Wi>X zZ7)HQ-H-6YA>)V402}>Gr|ddN#=TF|00i?h(AX)>Ti=#BZV|6Y76_$P{R5n>VK$xL zGHc<)&SX|1hwU>u3&weHv&MEQt)Yi)F_*4h^Cg(jozyF@e;&lY_3RN0GU?P{1PVJ~8Kz!@*u zp;IiOxyd2a86`KGCs-MvU6tpoj&2NbH6R%OB`0j#I_EcJ&!qN(xb4S^>56M_*7UV@ z=wh75iDzV9)RNU^dxX%pE3`7KiMOxO14G+ku=l83p;hJ;)F8;OZ?a zoJ16o_&0UPt;g`=euZZ9xV>h-Z**bm0=$gar9>$9e^9_+AqY&JPklz8#Jdu29pDkH zIrwIMMzSdsBZ&GVA-3K8VG)`aV2R0Z*gtLh8)MUqzNSE> z;_fNC-L^g|lW27P)!C!gM}tjLB|)V>Ut4pK)Op3W#{=U?R+78%@XY@{-hXzaAV82D z?GGZbK$p?|`GMy5e`Cv*|DHX2zV-0_%lBVK0T*A$`(F)+3xOD-JpU|ldPBh%`>LH= zgw>?c&094N_<^weR`-8(2q1Ir|2FXY#{I|g5BLA~a{t9yQtp4zKqgut|HtkEG-dCB zNUZSwBmcKz4L~#g`_9gD>;2zi|M!Qy|CoZ-CIHu2QGTB3&%6JW6W1aDFrd#V{vTz~ zOb?SsE~*}oA0Rd8$z>7cAN9Pw?%crd`aHwL5=8}QMMD+HKZ(gH#F`ZTI{-Su*f&;y zbg-(jV<@k+T=L_rJVxA|znx6We%f+hgWX-!ueI-_hvN@dlO|4w zV5sI7iU8zYucahQR+A)4PzKW1>ANe{Y}Ado2lSUkFz9EKNckt}X^tq82AGoueKe}u z{e`%snzRWC#4Z_^ao2NPP}7verwwsqHKo-~{<|=U3Twe4G%W~QY+Ha4|6N#wY}a#8 zxB4kY`X%1=j9a~D&CVb-aH@?uEqmQMo(APlWrV2sCJagOv%ANX&k_lx;zSOB866sx7R>%b(2v{s@HE`80wn-=nbrRx_9TT@2yiYiqbBu>HPKnj#G z8(LJ9geQ#SLYfDydVEkfk=T4V8yvP-No$1pz%riF2r`NRDl#Q&8AVLk1%#6#`kxsP zRnjs*!nB|iNG$);`X?nrO^E5^ap}`m)UwY z_13EnRAN??jLVTEjngqexp6&(YO7F`C~%QY1dI};f8zr~8dy6+x~~-`0;;R>%u2xG zkZw^?3pr-u5vt_H?`D%{jB|=7Y9H<2D{8bU7XFwA)9H$xfam%DK7VfEKR@5zd9eSf z0MNKOH?%olfc;M&(4vVz{(x4K8fyi=W=**v8qEv~yISapf$v~1aKSg9MT9V;cxqAf znN6)%=;oo(_PFpxZH!FkJ6g{XaQ|wB0pRf48pZ&+Mn@KSL77dBU-DWonmMW~;pa`= zcd$3e>NxFCn*vwBNFd0yGcp$cQi4NfQ)jY8UXlYe>pSKGT-8fd8N}#sBT*UO62HcY zPpkTnlItsc|AFpsMbYQ>|J!o-|2=>4?8U?T|Mk89jt8Dqoncf?PJ{d5hS6Ht@z_Xy^}p#Hrd04?UYd(>O2 zuXPq(#YIW^C8z~i^Q`xMe=t1j4*Ms^^O`vb$^bf7wpDp^<+;!6b=E_)+qAs;`&2C>JWosj*EaESZ4GPrpt_OW z=y4^?H|(inFITgl5AW$8`~5FI2LM!a{Liz^O)LL5`)T_j{_o4jf1+3SLslkn3V<8e zK0+4_vp#YMHQ5w3RN6eaIdB0#x$_>@VMJA}_+>^|QBE&vFptK0q~vZm&dMGDOtspq zKOIZG3#p9)M#8gH<|>)6^8zVAcyjB|f2=tN=&eIGX-F(_)cq%iY8zOc90i}^$!r2C zQT|gYCzIkxIQNcsm8mf9(D*Ody=q z(EJ47VAkzhZl~3nLRT1`ALS$T3%ev1Mou8e^WfIkRMd~=q|6XgWCyMnjIN!j5ox6% zWl-vEurd2=;C0A`+Jf_3F+2^p*iXt=1TA5Xan=}L@3{0IFPCG`I0XQ3DJB%gkiTcd zPN!esrsAnvTVTA^NU0*`FNb7fCF7U^#_6OXX7mR0yfNS!9Dfa(#@LX01TpPsvzcuI zd>H@n=Z*IWZ`&W94^ITX%Rqn>{?I*q*IRR;;>hHqu1;)ZQi{xZ!!tE?OErD!u17SX zYUF49`kp6?d+NQ_Qh!m>9+6gwCrH1F!9{`UcdBG)VRn;>yg}Uj5;4I=%_2qsS~Vl? zmd*IWvH@qNgF5tlVSQ`0-0RZzo|rAS6)Mfv`_DR5GlviEmPc7ZctmPdE9o9z&uc`T zhUJmzxPrpFs4%oI{OU^yJU@9IEBU;jf|8C+{t79p)I?%@?RoYrc3FckEXZ$;e#11d ztqT{TsPvdVtp?T>*~}uyu_byUdPfQUSYr|g`=aArSkT=U0Nc!-%=j`Ylyjw?816C+ zC&mXo8Xs#cvy~}%&(Jf`nXD`>B-XP?&c~ChO~5O0%qpg%NUWqIMVZ)xZ+R{~w%U+~ z&`Jq*6D3KNFbxpG_0()ZlCcYe4~LQ)j`Gsg=eSZJ)fYVWOJL1cSdP`DkT-9mV|HX9 zlzZ!HU+gdUr8oqJptaF<;4N9O=y$!@j)};5j0BMPy!k9JKr0=|f&*SrLKbV9iu2i% z>IHk^{TRU&$8&a}w10t@AAeQbz7RL=LL27=9cE7AB`H#fgxV!L>`A~FDTt}CQy`{{ zm|PJzbs4Y=mTT9Cu6Mx`EXt)+ZyKes?5I6f2^G(dB?*K(Cmwm!Cgji*l38PRzS7oH z-5)?L=S*|PUd8T5CZmWni!#asiI|coRZoq19YCT}M$_an&o$GL=a_QiQZ80r2;?h~L08=hIR77_X$g!ON@Aj)pO5+n z2ZueuS|0U}&%f`UzW1h|KptWIG2F-?)n>&yX?U6JY#b}0-UJ=2C=sut(Fab%Up5Pd zJ;+qc*2^gsLY7`GXJBqid;FSU3Vb%LEJ+i>$w#}R@{O7Qwpfvy+7+}O!nrT6b2}Tl zp;vgbZITUpj((Zp9xy)lsgwM;kQ3cWR2DoC?7mdb-@+L(ttaraofPNf3^fKWCcike znyd^}n75gKmJ6U!-lyI{OeW>d#V&3jj`AkBEj*r4x{`}8*$tZ&k(CA>?kgj@ z5O{)fLvj~jzV&YlwJSy<83!Y?w(}9|q1%i+3hjZ~H|C?hyv1t41YeG77_7()$%&vA z+S(!z45OL!4J2n}(eCsRG@K1_(3a;Dlp!v56n|O5?MQt+UwYe)5>1*-bb$9av}3ar zzt0ZI7`a78iAOP*VI*T-=ChO{mf%#xDe>|VE4#^9UbROiMUJk$8&t_pXQhLsMp}}k zIs+~S;Wy--aq?(v)>{uo#^#G>mi>1FetWS0er5R|<{&#}h5vd6 zk(TC%8Dob0L)PHZP>))unD&)Gk{Ui4Ug1WPi8laXqUV_4B|g!bC@t;7>vFGD6nr^R zI(XuBy%9T=jNh>30_ov1D~LtNqZ9*juNC-Ro>K^4y)1r9~sF{*)B364D1l= zWwwqMENXcC8>^~v&7&Jz3$x`q=*(?{M590|qRC#RN zOpri55ss5j1!9P&U|}S+5gTTl3o{9+Q0-J3$nY?UC1lc9O6-hg1*{dPb7n0+9V?lgfWxa>=}>|0rsw%|AW;ZD}8_miQgl4aMQH&NOP#f?w z6;jfuD$f-%Ra7KoJOkTswWmNF2F86&koBz^Ksh!b2GI9<)D`2ZJIdrSuWzBY__MVy zJ}t}pj>i3zQIM;u1+>K#w~V{avoxC($*o&uUWJ%z8`)~t*v@mlEB1!()*b~NsZh}g zpxZr@jY4)^K9#C!`CCQY2PKNKM1bRjPLymFDTz?+#g#9xSRDn}aL4UaF10HW8G&k2-BFDrZaE%VAYFg`z8AphB_EP*~Z`a&pmFWV+ z!h>jphU{X1&p&-G_|>x@qsHuKxW}(=`{8%+k5{cGj_u6nd@b@H?LB{Nc4Q@!zgrJk z9>(mA&F1&vNY>m$sdo8-?X~5i?)jsHJ&y<>+tRpUU(0Ou=CJ$y`9c5a{B`eb|M;M7 zcf0`cbN0`+%_v<+rwX#5tZp;sy#NL%aLv2wld%{809-V+o^UFe>Q1SNm-dmysl9561(df$ zLtIl-&!Q#wV3_FVN3CJ^`;Knd`cWr52ojWLfO5$m_P?r*R`Dc4$+ckIU;1sSvMv7O zMV}1tcCAi|;Asl7wNwakRYSir6nFDNl7xah#mZ zsJ?3Huoo%AlWvQ^P^+*O(Wknne|a>|SmKpozAT~t-`KMAKW#sI$p8Em=6{|}{qBfe zl(@WI(l%gx<^T^)^*t|wSQJg=%ydLwJc^u6}(+|GJl%_hYUUxc(zX~mLFsZV)?~KP=^#? zZXa=9cr$3iT>au6@FDk*I3v|eB2D5!_6*I z==+{u%P_W^LQIYBHRhDNRJB}vPi->DmE&3ytvh!vIpfx%Q*UOh>*84Lz;NwJQqQ&i zShY-Y)NVdAvO(E-)0?8x4Yi#&0c5YcWdqyYrE9HL*GY>t7yXBhk&i}OK_sSg*O@Ob zkXOB}d9BpfZ#yibY74tf(%c@nEy`!t&oGY_D7;!+UFe;6;Qtr}`GLaJU#&Hr>A)=( zzLX8r214H}$zCH}nU@s3=6WrXCEan=T;x&KT8J)i5Lfk>)&joDmKVIS71*5GJb?8$ zZ`Zur=X$a5DeJiDf7@p~V`k=nDhYq6pT;FSpq!5|IE{|Iu$IrT<`;rZ^0rdUWXb-u zYqBe*JtIp($=MhqP>r<)4)r)s#Z`?<>^eq5YPqZEw|qAdT- zJe;l(@uUTeBwsN$8|*w>_->Aqt?#v z7uyjYJ13~)7lF?xHeq>e#;WG;6PHmvsE{=3b36g_=>Lmt3;%cP`9uDn2m1fp(trIr z-0KO@n|gQ!eydkN?!#Rk0;Tfr=Pgjxq}+vWzw$+0%^NTW|8?7@|IfBJ9{hjQ^e=Kp zz6|@nGN85PV)K>?uY*?f;JVrvJd9N@>`1(2eH#3E!6>U~TpHU}(;N>V{8F7A42J}c zXxab}?4m_2hs_6OZ5b9i_4dwPhq==(e#y}yp-7$qRE1LSwe@k2Uj6xtXQb|{Ibi-t zYJ553!Bv239Hq|GexQ{e3d9C5gt`j6R^UtO%Je5ueF3gUlz7eNj@Bj(=H7MwU1$}gqTOYBNR-Tk?%TiKC@B;)Ty%*Lz9POz-V|>zmRe9 zeUw2Tw+C@v-qx(24%!1z7nz{vC?>j3YJL8{4cXj4Ng`Q}TUS>I+tSIAfmR1Oi7?4#iP6eE} zIJ&JSWFcCv#qxqLXH8WsF}gAJkz!3X*Uwp@&idLo)q3;R3@u}h2ea}(2y3ZyM+uUw zCCF8wU_K4D)1B&1MFq!g7O- zYA*z#>G8Kg0NLdn(OE_UaftoP1R>eSMhG_R3_+WssDqAr;G>r^_KWd29rEtNcY3BEO*Z3UB%=L$ky#91q3BlU>xTFn2-m6M+=eJ zV{neRfFiB-MHTmi;h+GEe+3x;ZUlTX^lL7U~|>R&ga-a;}5gp0e8 zppZ^*TYmT+7pT~IjM#IC5R7yO4utUf!#n^8U4>9=63AeTiH!lgR*fp_EC_YTU**66 zJ)~ei*x7NC-BfsoWl+uv0mU4~#T1b6Mza!P-w3J%!AT05JEEpkP=|yv2?wYHt2s+> zer!tb`Y@}}2Si2@eTB1JKqrQIaOD9o+6)N5Yu2NJtq6=AVXfd_I-DZmP*- zlF#Pa5Z2gHA?MOiqUq0a<&Xgt6^Icsj*=(jpd-LB@S)%owg66^;IuJF9Rcqp39An` zH}6NGO|N2|>))?=#4%V_-0`bjRYOQL zf!-^R5RFN(TOrUFDJpZUJ&uNGF`0IK6~B*_SO4rdxIPCfP|GIR^#d1EP{%Lm)phQ& zfydB^v=KG!!a6lDmd&Hp43FAy4myo2duJY_?l>?D9fv|!8q#{kMTSCKP zxfaAKs{!}s>Pp5yEo4oC4YW2odV$dBj&q|8*En?mp!6)SL1tK>b&%Aw%GXljaCU1j z9FBe$2P1GcA|VZ2-7m2GNGBY-@kk#(H1Nuar{a1-G`Stui zH`)JL@!vZen-B5dmHk(BT>m2Mzv_UNiUPmNgXwiVD*XK{$@1E-0I&u$fQlXPzwQhh zdk08XA-B!Tak}CFVS$;-IT0G%`XmF0MjYZCP*W|`&Eh2HhT>Mywh-cZa&WTi{jJ=X zFm5H!7+(RtGB~a16Cj3zVgodt5ISu~QX#tg^g0kpaCi=r{?SqY`1|wj;pyA%`J1!u zJ~*~GV#nfQ2&i`mOD;je&Sq)Kx~;Kc@vu~Jcq&+1;^d6r>kcFpt8n%S7XKDjo)>rv zt4@%ju{7x^I*$tnP}oHUx+~IxAv_(Tl8J0n5Xg;c4vy(Aun#QL9Uq7|{}5TCT!&g~ zt1^BH#Mbdf9buDDlthE-z15yt|0JdFWHYHO(RhG6JUq|*KT}s?%m-oMUSkFkK&9Fs zXE>5t_`k2`gjgxlH4fXxST#GgP`hLbOXE07LRR*p3<*@Az%}@$_1TFAoJ^2Ku$hjx_3RmM#BW|o zYJ&$@aKgnH#B~?FsDu=< zjjgNA3!+;>-7COPU>B080ugC`aU}ryS-Ti;GDWK6|*%tR?!`8AH#0bVAh!Ot#x-rBRi||(jVEkIf z&>uZ&>t$IPH&t!bQss6-yrX)`ba_iQw&&;3gbnd--8#t**Nv-v@HG&fwc@g^ER(+1 znoMWyb@ka9T8kHJm`N48P{nK5?c(cqPhP33BFe%pD5XV+ur5MN=VMn&VOMRJONC*O z!~j>}f#A$qu0V~dcq*2TKDyy>*ZtI?u?79myEHD><_;)f|Gw8q3TT0n1Mzp*H5_+} zcJ?T8@CTv5BO^*dR(3vCWI6Ey(+iptKPW)v;i<`=MxP*SreGQUy6%2N#Ae&(08dBC zs0R{wfGxRZw_augcbovQr{kM}D_GYZg)5Nyk}-T>Su6?m1Z-oJ&VbL2#tz;9z+;Gq z1`ZaJU8XRoG2{EM-7};7WA(gq-3B-)bOQ#6`dW>7#&J*xvd)!L;Jw0BW1C|yVoW;x z@1NB};%rgn=77ByjX#kB#hN`f?XMc-QioO0@s3suvnedK+Izl$FAhb8CDKdTV5q9F z_IRx+^-`_i1P__NJ)T$B$xKMil=LwK85T>Q znT$Fh>pl0+2H}Nq4vbCH1hjX@gVWxA|4qMl&~X|dm2QGUj7qrt{e1wgDC_~lFr%Y9Jc1 zR?Tz)sI7`P%^@~mz|+rpPzKilW3^HJdYWgSZdV!2eJ^x%Hw3)-$iWS5tqDC|uFQYe zv)6=PIJu22wLE{x+yQ`Kk8h~&Q5TTl| zX<Kc**OWy#NhcE{>#Jn6SG}^bOsJI&(Yf zCW+ct(ORGbR_6yQTiZHglGSGSp{thkH@6P}WQ=@tz_qsgF(d7afE^!$za5T5Ya6o8;xdDh;b}1IEK7fBwz%%hKa;;eIk!qz1>M8#L1^<7VU<=HZ7fBP^#XB;jM)m3gITEL|wSY64)Fld*?-?{Pk zrFMN)qqlY{vJ^I%)0S=+s*k;XiZR`ND-I#_=J@2ie*iCu<};&l4U}I6K|C-IaBu}G zVsc2zQRX8ZEE%~Y-s5A%dKu|h^|d^S#(6RN6SfWK`Xg0EQZ=alS$hMsvZ1e$w}=iO10-1 zpOP6%!Lu(c?1>*EVWa`~yW4bhoo8vr&Jni2DF(QGVAk#`U|or0m!2+wY?R#qbVEnM zgiwm1gG<#%NAQ8*fAX$Ryc3Ur?~?q^YNX$mzWNHI4YV@aq+~m;M{xuVJGmH{|>BVCRiia1Nv!lta65cM_t8gx+ z*Fh0!^Ocj8J)tKfhVw-uz+Vzwr@&O>sH%0y#LY#Qa~bEKRCoexc$Z1WniT~Qego&m zmiNLXwp`dC8};W%xgIKU=gC?IY?IzVT{WA|FH41K)u%~#FrEFr?0+Vcu|gYQv;FVc z*2Xgn|9NA3^TGb7E7reU{cpWZ?+z+eveCU}l=}XOFjtdgv&jT%f?9Hi-i3^k3%08z z5Lj88IwJL|nysvzt7*|rTJ{;*)sxPxrUU)yT}_)mGS+0}M*$i&&O&W?p(Tg%>_Mv8 zKsOi&8~HG}QjFy0G5UZHKkr8;teJ8b06GzaMZ=I^4c2lxc8txLh;IcWbhNwpIIH2AOyeqsX0&;)<*RPNYQ|>Vs{0L266;kR zx^QJf(=Phzmd;3$aNpHi@d)iK4)+`20%>0FK30o?a12&h8}3MM%>1e=r#nUm3$6Bp zEcvC#e~M=OSM&eh+}_^Yu>3!tZ$9{cel_x6Bko46U>Jh;U^mZ$+lJz-;~AoMq!~pU z6eLp~;qQz41q~n{dvOqt$BVj`4pcvq`#TZUR+IiT4u7ANi zp6?%a2ZOfD>rW}`?XC%w7nKfur_V1G-bDqZ8q~ zR7DI2l(br3zq@-&QG3428>!Y`N*kf#^K|DyItEnhhPB8@RR6I#V_9Hr8ho|p4Mw_9 zIY&|7m+h`q+mUd8BHt@_Kf)o4cjETiYH0gD>sfhjxqCOsO+Q5N!9&FgyE^_ z9I=!P-z3cs1~^{n(o(@l{o?MM!#*$9xyCB*)r>(p%$O-=W)h<%fcmEIDy4bDlEoIrmuL|j8S7y8WSO@p(M*;gqj8tOYl* z7>vNHggv{xPg5qJ7J&9bzeV9N>oFO0MMv(w>v!sK@LKV0a5jH;!f^V(~j-$vTkLO=U7Cvc$<)S9jLR zsV%3VWk4h^P}>%&mTyCsZ^*ZIUK?&RK8HcB*cH>NYr=)B0ZsG)s|~AuQWe0z7^&A+5^(^ADb7V&&Pt=lTD= zc=pW3e|oX~>;eDj>&*ZCc{6-VIKzA71$Pt;s92V5blGX0jC1B^!^b|OYedZr?{U#KwZJmy{8^PU{>%?0t(X0ZqmZL zz#k|U;HL}U8$v(AH(hqsx`0fL7i}>bNJW_>*$sNcC$|{Zq$R5pXv=Zxou2guz2jl; zfS&vsY-9=Yj=lF%V7$Y**_{)f0Hrzy!gK$nCpM_>E@4F=yHg!AfzAc;DMGjqq+2#| z;Xa$Z#|s`kptem5g|PoTmmoxoQA*B>Oak-9NeFv`XrW?;AzOvW+&~PpfJIb0p@R~M zoJka92RQ_xJ@Sf;0JBu$k0L7PI@>#rr0RfAefo4wLr3?>7ASHxvQ~;Hf$=HWXaSqp zCrsvt!7`A>)RBN4A`&6HVUVgljMy=tYoX>D6l;M=948`)k_1#`7k~+X$!*=RF%EOW zZd?{pyj~7m9p*o+GNUR<6svZOi>*)$15j1ZOw)KiT5;K_JI{+`0d~c0EYrO)Cp|%q zcJbmA(Ef=a&belup2o-^*uL!ipg%ap1Y}1i2fg#b@Z?leU#qF$*$WM+p)}NPyMS^l z2>)N*_=9K^he*6ekq4rTO;kG=lar-PSOd+nN*dYG{1zb5m1LBYI9!)i)t*FVfNTtL zyErrtvSlCzrpt=sj1WxFcTT9Hi#i>&Xkmd7_>~e!JBcDan9|xe-`F&_2CqmiL`zPy zac50nVN9W0(UG_i=SJ3JgZuZty+_u7ToA<$Xkr7B7;-o>QVjvlJkf_-b)K&jV;7-- zo>H#R!4H(vX5RUd=Q*i@S(RwH&bpWBjF!XN@p!!0GV;tOCkCQ*uD6Qnty(>;&0oA<8g4Mnyk+E^hmCh-o72x2 zM^~4-E6`mUj?b&wb5?(%7C0?5;-=H0CQ6iJg2-QU4nDr|C;2gC3;7p79k|c&FO+Yi zuKQ)Tk+I3G&9Rc|v|HUHc+#gCJPF|&4tMU#oM8^5e%pI#_!)`z-N-ZH(%0|woCQ_V z`Ph)}j_1-b3N@s*i2^z&tqLaP?KCT8YS#a3J>S|k z^*_&E>^$WE`#SYMpL~fab&`TP?-U-OC=`7EDk1Xfbv1RTxmAPTwQI(ib!^R!mFQim z^xDn>Z?_;pMZ2U)5$X2;sivW{G#=^YA(GA*G!w3vc$1tF1OxiyjiM==w!;n2Wfzv) zRVh{0tQFgO za_kl1(u~!{FAJ%m_=z9Y?dVGd`@SlksmrSb@&sYERSS*(E?^N&gVho}Ew+L@eN?^C z%6U?hQDHV3$7!&Rsddv#p54CPY;*;iS#%f_s4gLo!3jvyk z|Ng?lf8Tz#^??8IfdBp*;=enhsqc&eZ@2Y;1^=61!D}0_G$OoF`}cthzlgumeIJ53 z^#8?Fq5noy`)VEnz0rpk;kSPgs6bgz!KPl+z>(&Jrob654uBe8zFF4r%+IM@CatV!|D z%5PE*Pa->3rIZJsd$iY~HLV#1wF6Y?s2Ockm)&|M((|2d=^F1mX%`-negCkDR1zU_ytrbp(?XMcrJ5nrX})b=@EvbLk|LZ#)qE=SYvl+nD} zTQhyRjKPcGUPnVdxRd(D6vRZ?~hMflSXy1OJDe_6ILH&kLkb%6fwYOvd`LaXugH zNBNFtz~vZkuXWs0LCyxSvt4+@U`)1A=O(Dplp^WK_t`ZTqUjFIYiiL^n!qfMQJPo680EtEY+k$bhdc*1LmT^?%eTZN z%-g+iAM+nqsWVv`CJ9NHwz;;nLVX(_Nd5Nl+>A%G;*GW3K z&ThcXKVtjXssh54Vj)MVX|Jsr!5X%P$75n_f?Kowtv-ROYvJQk^{`r6DG>lx*=^0Pd3j24W&ie7#Sg^nnltqv1n#aw7YQ zQWGjZwx6TN{gg?EaafJ3N;UkS%943+0)%_=M098Zpjo`E?`Sxxd(51Ruz-6=Q%m+W)q8b~Y{h-?Pm>sQ=&Z!TwiC=v^*C&o3b!oNgFQ zm6S{i+YIMGJFvrjz=*}KVlq@bO%OU%@>6_USN z@wE3TF4Z_mC)h^raaJ!#PV)FFPSwF3=%4&|$Ai<}e*aCM4bE2M^Lx$@tJ+Ri|J2e? zX4@JPUt6s%K$p?KYR&|4YG|0knAl(fGao70G+XKdIu&zOH~|$h85}aW@jYv)%u>YG zwx)13mIKLEJGlv!=QMg!nkG5TIyWujbfB)u%a+;G_PoJ3X0rUi@E9&KveUDZ@6WnN zN8Mq6|NQTRdQ~J1PlwmEc9kyPA~&yp{5^!YFHxg+;O638T=}Y3>D{1rR=aU^ohccY z9XFz4o2mQV+TPt*sZ2uSY;PXc4IQ+1C=-d5qK_C0W6KT-We! zSnX+qh&T9IHij54X;xP5baCl>CjbHxRK$(r7L5}jcR@-MJCiDK~;$+a4tDFF}zOOkrpw-xT(06x( zvJ6Jo!%UP?)JPSQEi3YYd%NEH0(*<+Zm?CA8dvDsi2~hYb=kOEySs4VPsU^7=-U3!C^VnR6>)lKT88%JEJCRkBttwF^?l-PvKKE`AlK=_qO~<44MmVk% z>0lDv3Zjc`ncaklp`6@O1Ke^7=WX->9vxBlAYwum8utMV4hca81E->%3Ahzp?)3@i zM4~)HSRzHq)B?E zB%mVxl|j)ZoHQ7#DYSddppe*K(Y>rfbon+PMufs7&y0tt^)VX_J5uaugi#V-MmhVt z1B(-+XiTq;4qtxuxN?9zJXwlOUJA6;BaQDo^~MNP;5{xd{NOQfmCXC>&;ia|m;sm< zFo`L;4)6*21P;gV4*OSd3E*z%TcGyF=p)_&kIsyUyt^v;A!$%DCO@C^ih8;2o0 zwN3s_E}tFvugO=;myE5e)L_^h_Rin-4-R_AcK!zhz*^JsUX;OF4|9}*Ldh0;n$LXo z-dkd9O%DV?TrS%YQ46(w!(-*C$D1UiLpo`&ka*Z8Y|;s$z*QH4V`=gO2Y{MX3(wj| z;7vQC=}DaDSuXBTd@|5q?a_4rWSJchJc-_bH^bOX-a5L)OxLS}>Y!KJ9aa{viQe+! zc%c1&@sjL8K_~}M5B3<*HW7tNpF}XeSZ~+@?FYpu2&2VCU02;NRfenAv1kZ!bRxzHo=h3nP&^S> zvy4DbBDN@35!hDalw`!>HXV7F0h9Bs}U6BS;sDCLzW}mRt9j_CEEF9p(m#zR=J6JG9B|ltcYfO}P(;14UHo(Rrk;vX8b`cE?yRcMJ+P9)?mjwLWU_P&F1kC)867e4$(o1IJJ_ z-PF|b3vIyby8dgrp1=tG4VSIm_yjh!ztF+2hd-3R+FlNJ6L`CwM4^@x=sfVRK?&|C z8$3_|7X^^p_do!?R07a=-hYdWZw^RJO*F@bT(b#bq-ylq&Dtp2lhLh*))rPe8yE!C zF}*s1dk2}a(y^PTa&ZMcH|O6~L8pg2&Q~zWENv|-j5JD%SswABUk3$f%1Ba`+BnVk z$Kh;VXW(f(Z;rjYmwbPRz0h4N7G6oGrVjo?DW+vu(p;3QQPc;g8N=nqK^_)0lT&MV zXh(4baHWuK@b6sE&r=pt{5++3mfKaimoI2#!Qjh;bfM4CFZV9Y zaFh!uO9dgMIttrDkLMj{m7bN%B%F#cVTY%PkY(SHRuHV&B^oKwoI`{V5XvaZSV^l^ zX5P;g8~9$dgABlhPq|e;l)p3a^!^S?g|sLMmpj$>7(K>U>k2z%`(e@Gu^} zAcbpJVK5+l7s8ar4iT)mT&?$VI!~Tdq8{k{q3n|r_KW>)4e zhxQ{7IRT`D8T!&$1G-A+T|>z6G!H+&zyL>Ckt%-}p(c@Y;O`=zonK(5rr^WGN2+t# zu%~(o`}(}tPbuoa^bGP9a?VYnT#Sfos%@xl?IjU$n`F}<7k*Lzz&ZG@kDos9@c*7Z zzK#F+^WFbq0}RttAO{6|MLdr#XXLH3OF?$e+CpJo1e17b9fn4?-Wp8f+7<55AYuvh zEV+qpzh)ysYCJ11K5g!YMe;vA_3*zRJbC=!7XSPA!2e!b zi+0escBxG%3BA`0S==~H-%siV+jP$Nj@vZWQkHLo-)zwarH!*R0qOfSc@UQ9JNR{pjhxcx{_iZErR(b)^!6GWTwk9} z*jx5wM>*=ADoDsaQ*SiPQ`XFJ!^?Dq{9vPhM^gSFuf{M8kn1Vq?Ny8)kN3Cs*P}zu z&`hd`Jy~lK{evAA?xz&l12ibzQd0raQ+vqAtB7tGz}Z8yhnx7)de zY+n?z-KqfFoE91JoAF6j9@SLgWLebD-=7rmUGL%2An!)ae~jE8*8N|H%Y1SHU|#;G zC;t8a^y%&Wf1Cf~56u7J9EY3V8BRmDm&PBG=cC?*rP;n&^&f}t2l>DDW%$GM|2=y8 z^p^kM^8Y`U|J%pm$+A5bu2KXhQ#GH7@3JNmFX|*~FX2X#22#_=oom{3!mPzOVEhu>HPKVQA<7Ano6G`TwJ* zkDuQ1|1XFC$C$@&L8d-O6qg3s6`ULd>g&LI+I^*4F9U^1IwEhOq>Mt1{eQRr{ciH# z_xX6(0v9~z{6Dz&)I0xA9zMA}|6j8F*C5c6CfJ z*n9>a38%MMcN??4-inBmVsI7S7y%ZsvVHudp>u5F=NYty82BtYa zsMs(%VlBj@H;tuN2NzCBZxzmwiAmv%>iRexBCvVxhpfuZX0#sI|`?%x84SV1N zYSLD<5Z(YfPmNUhWC-dtJ9T>_GV;|TSX~MffUARXC<5Dje*zbx|sIui|RnLQN#Y6gZzLT~|!p=o?2qvM`owQwV|@Hp7tJu865eM>)Et=?PvYZwaWBs206q zp?l<$7lQ6gb2p})(Uf7K6V){>dF*yZ%aLHB%`%$N(PM(a^K^-TP{R~>Y&J#zuqZ`; zIE@>o!bK7Ujza2Stm#2gI^7U?`*5p{kqTg)#)+sBcI#X2Q(h@L*DotRE(0On@ z`sz1f%Ijn>jZrp>C&^{{VbzISmLnBOgFYp%nN_jR zD-v~vSk4xH==1;)Pf{MxY=T9Bmfd?zb$rKQvU+HL`HLPHe71WCrN!fAlfU9K7`fC5 zU06)ZM*D8}ZVwjm?ieR}Mj-38_oYCpmGoc-CkesNuMz7G2L5$fFQ8f+=pW%%XmE|lg*X}>Q_XJn;3T^}PEQVQ>W-o-adxp}VU=v~h zAYuE)p_m9onB{DWBCx{;w1nM~^NfW}7M0L%crwH^z!oha`VQVo_)L7YmMeTko+13t zyX>4l;_)DYsrwBbsD{zb6&#Fl=9j4&24y>r{(|&zl`zl8(?hF^82qI#kzo`1R`zba zQ&ZeOg&VE9$cGfc2-l#bW1~=Oor{fKu;Er6CE1wnN#W?YbVUxV9!k62Xzd4sgf9t$ zQRED2Esf9PrXy*dKdp+naNr;lnagCKgAx>oF|N}Pgb6%OxMXys$~3(!VRg?z4ze_$S~phbdhOgbPw%^hO~u)W>J( zc{VYA?{pE=sID#v(*@4x)-?A8+y5xMZ4ECZZ35h%rtG`oKLfPe{~tbm^vJXSJ-C1G z*8cY;*#9{85qEm!Mz*x}b_GJ1UhY`T3_J#wpiNU^AJbPvlbf4LE;&OdD!7z5)`ai^ zYVB=gkJ3|2YW0n)=0*|S<+gm11V-}jb@B$K;cuyD8 zxo3Yg{R`FCWH>y|(b0k0aMCkz^>+yI@T#q|4Wm8ezI4Cr=9sU~_F`ebMsFpwYlaiy z^E<4%ardc8v2+|cd<3=tEfEMfQI+#&M@u2X*che>5uA1D2m42@z0|p*2*0@{Y;n2rX>1GEAv;U;-YJCDG>wdspc zTdH1uEmZx_c|?GK#FzeKNrh=!GpEG~M+urYT%}sJp1)3WSz^mDn*`hCN9?!ckKAAM zHeT;BwD}a_S1D!_H8LayS6J`b=f&Q9%qi>?aOOx+zR0IxMbv9%A7TfI0sEkMDc>-{VL3AKdDHUxNO} zJH4>~KS2h6Q$xxj02yUsMBljD2eNh|LtnFt^g0^m(M5vq(e+z+q8$jeGtXsDvK)Rx zP{?N~y%6(zc2#64G(hjF{)0Nb1g9nCgvzyg7q1{*t{=2eJ8srKwhbzN zhY@dWzv{o)Jq{(@A%_Z>-6r|YxYKk0JB$&G9GCTEn2wU!xH=(>@{?@RTdo4;XAR^U zYn#R1{@(T{ox&tP(ctSR5AO9ox_tZiU-h>>?f6H@FkNKjsoV_~{AhP)Yy0HY;r{EB z!=3NG|FrZ|jx!ql33`v86zCP!TdWSuOHK-pi8H6>Ik;4A4z+Pu4U;e33r=bZpOqA%pGG%f;)cIe&w zu5GCQMckG*pQd}+&Yk5a1*Q5p{3n9oT>Cx+!Yld`x#VNV&cXk?|L~z_|GD?{*8ls9 z!hfQj{(=Bs1v)T5dQ0Q92HFVWvwjo`>)Kk>A4^(d4oga+^9=sQafM>c9taw$q{VZ) z60lV0;fM5^y1T$ddmi2Uq!_IpKYnz} z|8M#Kg8bhiy&JN9tBqT3zcja3>vzlE|4I4(_bJ-zHZPLNd1~JQbMrrV_@7UoJiX=r zxBP!m{_m0Af_H$MIO4)+ZD+DIU5~c%8O55qw}sc^&2Ef_o1!G>nG%Y~kl=p^e}|F( zI0H>oU1fu`N5D9CDM2?=mqqk8yP@8Z!{1{cnHs+A=6L|J8a-X%)4AvCI>?JOFrq4V$7Ee%D)XEK z9hWf%N2SH_1@OM13PoCbRUm}{EG_}ZCHhGjg>rs+7Fr12}DfI%LtlOdDP1?gY)REUEtUkxK@ zvVl!CEwzjEzh(&JM!g;KKVHr<%uJwa6Q-(c{s6AUl@%o!)ECBa|2w=X46e7TLmmzp zP!F!&Nt6u;Vlcm?tmkEwUPg*u2zU>dH$wbY0>Fmy*kCj(GD*7*k`CseQed`2Y`^mh zx1^};P})I3;@7B3-lkYS1?3S5qZ>!RP&nRnoL2CiU~;dLYmCBpm%mMiQTje1guWa~ zNB{fZ|AT!P8QS3sYN_6^OF}tK5vnngUl3`}s+dn|;MIx<9oxbV^h8VSvc_l?G{T|o zuZJshikQcz>463qwrj`MT9^6ODYHM7x0CyzIitg2ndX==4-(%i2CEq6T_>Yh61n4dmW zUtw_eTH5a#K2mB@MxV5xyllELRjg+WqL#zRnGY%&1H`rr6c{--c#g{Y5YVal1t{d# zq&)Xn`Y8Z)6Mo&uWTZlAw`Gs>6&V^gN=@jvPXS z4!VdD4@K0ET^&nd)n^hk{XXSF@9_1Ft-OX!=YMs&-FTXp7`W~do&kfv@U&*YD5B?P zNJdPAxJaltS)iu`GhDm*2$iYoa81V=e0M~b;W#^s^WuDMm=DS|*n}d#OU>Wa*V09G zIbNx;V>yT?lSJ@;hFEEp% zaWu{Uj7^oUV4;mB_-F)sa;y0arGcvJ>&pGFVUQnIyr1A)Z{|}*(&lRcgc$l)eGAPh zJmqZiVcLnb%#Y!&DatKySCp7LBn~c(ZtQt*y!H~rXfXQCUCw}Kq}jv3+E`e8r%j;t zPPefI0s^;NKy3SvFyFK>=3rzcpF5+9fh(uQ_!{`7CW^7S%`SInan~P)ye_dr@WcK z6vku54*bM(o--`{SM4|)GBnVwG(ix3Ou260*P_*Vm`p2`7ABBJ0F>e}MdN^=G&@V6 zH9L_2d!65{lCyH{Y@DC58By#@WpmkP%VRO9padGz*9<8#ep}V5IXwcHBBp244+|G& z2_O)bbqy`fO$v=0oRwr5P-+v(u1ZGPu+rDVuNyH+ahK@^0!bBBiSk5e70&l+7rBR# z;9e}}h9s|M+{VTzR zD$?mVftAMDtgpe*T2-bq#lC=UzWPn^G$8;~GC!=XcAGLryV5!K+XCt0%ze(1l{9wN zNAzyvK1g50FWmw7IREwP*XyO5?z$-megw&VbR97r;C)x7sDkIL2*Q=lNfWTa4xw0_ zT}P}z*2b1DQQHz=Jt|+p$$He7 zqF|YEe3-NUx)EJtVpwQ;D^5xThXW`|c^;`VvNM$YmFJk1pguQRk;3K|<(BFDY0m$T zcwsqwd-w5!nj7L5)&?8+A631~Q4a*=kqUa_G#H`i18xy7wLOT@!`CD`UCz|w-2Uj9 zEE&FvzEFzIx29dcv$~amWI8KgAjAGNTkU$gf>+CsQbO?^QK|CFZfmx^0qRfNke}8nF|M`o@f2tEW2S5CjO@{duQ%CqU-7AJV zZc?qR;N1nhKLOp&_#GHx!)v^B;#NBV8G8iRNL|sbLhe@Yl59-ovy$Ebj<$Y?9{j6* zpn45T9b8qT*%-O=j70fFSIH-w<&093v6X(1X7YY(CiAm~oK@GM><#NSofx=WUbi7FNiH{QQi#LhJQ4krM#CKQ`%>~tm9F=0g2@#le_g!9 zx0rxAn;=1Q=Az^*pJA@b5@eTa)d|{ZC|fWz6rH80sGeU zj-td|d?U`y88}fYb0C`I9^5;PG6m4cBY_s#G6Xe1+@M|T5J}VCZtWZ$?Dl^?*?e=n z{|2Nj{qS++Px^VF`?$*e9|L>C7qua3+;KPGLR9nn(Ua(C8tgWp%d}M9!A@b}>u$Z~ z{$V7DkGlVbJ)D(k0UYn3$lqUwgn&8tj}N@~&j*j6KDoXBzo7enWkD~wMv1_@@b@Qu zboMn5K4!~ECxNUXbuDaEnAuXY#9TM@-E5Q&t_NfG<90{VT#r*1*O_M>7f13Z8^cA5 z9(?gnY4W!A+FKEGP2l`Q2=?B%KD{VB+JK#BET7<>{XQu#KpuG3((ns(`sQN$qfXap zt>C~VHnDSpZ)-x&@HcG2`c)n()ZKHyZi>`*47-Hk3E^EFWI?#3LTPbhw!6ZC3Wk`3LV(Ohf&4l3$He*tZ@h zt}Rl=(ZO`U1>C_S8gvysEGRd7bqx!6RwRWQ&_zBTmP*>wSGej;<#VQ~CmP6MJh6#a}|{dcvNhqt_C>GsY+&p3Tm&Zbq^wIli4oAdaBGT57QrM32j? zL=!XT+s%h5%(1$_(If>vmTBQbGJ}tS%Bl>*576w4z~0_yGof4fD3re|l^TX^R%TBZ zkThVdLy;VFO6tx@yUd`u{EC;IeC4iEhKWYvprx%BG`a?XNwW4*P~BDfb&<+$pctjL zV?`%=hjWq*i3WxU>GI@ynhWdQ>bpDDQ#FYRb_R@a@%7+c4w(g!BLfd0w-LDe?WR z)U0b_5H(SKGMy4j9D=-GfUdbcprgH&3v*#Q~c}9Vu4VO_9=V zOnZ^wB9?H{VFv^UIrDL|%i+tw@e+cSYGE2Ekd4S%zozU@b-PXEcH4Wfk3fnR^NPMXxxFJYtIuNt0eQkO)|*e57F)2K5scrxt0+iy^)+H0 zVehJUbjDS6+30r{3 zM%WzwIWPau!>3;S|0DS8*8cM)*niYcudJk#*`;#0Q@bvVp})J!^3c)GN5|W*`R|>* z;gY*CBk&plM_};zyx99&0w*NNUdBcgp+)~=cQk!2FDWkMS1^?j?V89G|iGw`O zb-~xkbUk{leczt3)}TM55fja&42R@3cBm!K-Q+r-2}lG>DzW{eHqAcwgLc}Ax5nj| zHGGA;7KD9mJ2T#5t>5oAeR$CH;bGHOCChFw$<&oE!3Mw^H zSw-oBVohex%s3Y6ymSMe%(a>Gxs0PeaaQ5_9HJsCEp})?sIvK;*((<~fxlo~^f&Z7 z^c)PI9ly})D=T^2MLn`eqhmQsJ^ITVi0M&?M*1|Gq~eT`WECh#RW{aZcA3M!;cLcr zq>C&ql45XijZ-u&dGHrTLXgF2d>yed&+O(hr^JKuNYC(Y2y}yd6^J^bgu0xvkY{D= zgtiG9V@1-?Z~bOR*aOO7OfVFePXk3TIU1V;uIl)CHC29Vp~16%N{p@z5>#yV&}UZs zb*Who%^iba#WkTSvh(xWP^o)4NAoI>3s1pW(8TEWxwfz_hy|LNj#co0D22!EY$0qs~x$rsu;J;Sd+&651A`6H|Jpzo$@7Vgq6u)n*kt@T>Eu;j=&Zbo8vq0MnghnDs)%e(DPWMd;%~Zcd?}?6 z$!}wwOQFGCvkDps`Ux7j;r+_Jh00fYocQN)R*h9Q`IRs5{EPnoIv*xu9yMM$uLY>l zWdU>Ze|z@7Cl4Oq=KuW?_5Xl76bGppfN&+dM!lxrS603?{zb7PAQdza&f#`{^Y~ea{|BOw?_`hqgzMu;9 z@#&l%t-a@fIp_b;BR~K5{YSU?KfZ|bukZ`s*%xu9?Cw!$_vIWJ@4&nRq{D;;A zLg#;!oE6!iodfKVS4^YJ_Y(L2V>8<_$OE~{?9ltGs;Qvjs`1@=0k4D6k zV3BE&pU;wFXvqs(`CeY(X=?)lXR3@}(f_>Jkz0SBY9aA!d|NN#o9)4Yf+R4Q6@{SK z%AfK^pxz;j(;&R>TVqaEG<|1vHbJ{!7v5p+gG|5c)#3+kyjPQ|v=yXF4 z7U_FKfiesG{a8iW_pLu1;)@CGl{Tx5?0r~|oQ>%QnnS0m$!xm)ewr2O@HJXo$%`sS zCuBsFDLbKRwVNNW^8$$~*xZY*;@+M`WY6PJKUmVMY&^!?8w43CPK5t{B-naAvq3bl zUKLqYp`kNxz~OWElwz|OalsqOGkoH3Jn5NGUuYBt!-tGN9q0HqWf`xk zc>xb;U4|XzTgQQA)EJeAd(pl4>Ax@}t}2e?E}^I7M<*crOzezf$!@db9L@GQA`>%D zG)?hW6OJ`&lcw{t0y1*G9HL{K118AD6^0Xw33Dlol>n~bFS7&F%i5Gt^f`n!Mp^xS zLvpBG7P2}SFA)F8Yf#2EM8g%F5}Gp{6|teyA=2CN>xwz3Q~mp?^daEIby$Jw4%SWn z;*rf@3E{9H&@tA7`NA5N6eesu1=`~Y8_pfnLI-q0I(=?U&4KkJ*MBb8X#9uS@M1h`eg&hu15O?J zVMfClL$(568p4V42cI$wFb4P%{~3*8gKY?HBzb#KVjjIrino1hBo6K(;l-3kdbU0! zq_h;kbahIE*D`?f_`!=1xnm5W3BvEHV@C)?tB4uMFNPWex8Uv#jv%aJi2D5Qe=mrDW7O%#6QpM?ifxw1-I)`bAcA@}lK$O4Ze0wS(bvZ^StaCYL z-08HGf5YX7Gkt~Vkk2Dn0CE$G?sBDW^z1nn{*xJt_9vY5h=FWpYyjh2-b&S*Yu{Pm zXteu(yZL_}K78=x&;I^%L&tiQHrF`4)f%G>|H1y>IG~sZB=E=1du(kAUVxV5;*}^f zqqIsN-!ZgkN`6wH9TvtAX!?Lk)M+;eHa$g)PnBA=w&cf+8u9kl^IM&NW1Ws>gtlut zP<{0ApLhNrJbZ9_{zK=#2^m|Mwq0y7y0c{^em^J6wG!@4s_Az|aljyCR=WwN7w!k)XA0=zb?fSTEH`OQm_eox?*eM@A->Gr@ty+ZSpwQ{>7YeDf)$- zn}4kxlj}J^i{koRD)(f@10x&%FT>QR3Hk>An-vs8q2Q;VmILI4qbRpqt29I8>VIOu zYYhaV9FuvtoiZJd0#3=a^z50e+cHcXfld)^&%lTXOoICAMfm?cS7}}l8I7)G+Ieb6 zYJ3?8H6Iw1*<16tweRjNjO!om|96nnVIT*J@@4*hFP~7t59j8X!~gF+_WXaJJh}D% z`?L7})d{*Sa#*MaxdmC(ezfIF;js;L773V**Mf3D;vVqX}kb(1c_=KnmJ1&pdUo`%tsMTFFJ=yYgVXCnWIsP@H`k?qh1tf zDDoU)zCYCfKp+vr?I-u3yqPI$HztcfFj~0$PA}0D@f7aYQ-mZtx=7QC{HaQX?Adh^ ze^bzzxmxS?;sSrQ{vE4jy5+UE{NGhm`Cn@XOxFA~vXzyHHMc1*cXSi9^Z$oWJ^b&7 z_nx3l;FkaY@%*3le{mI9I9IktB-@|7g!%{Q_>>M6QGBXsaAHw9Bz%$Z@hjF~oZF>^ ze%Ty3wi+x<&L+o|!}I2E!8wpI zMV0e?a{JNSY&^bO!WD#m8YYCLj`E}@c4!&zxs=C^E((*pildzoKVjzzhFjX5AnbL8 zw=F%4px!lPUk<17E4pp#mTm7`y-TP!1s4vJq8Pu|*;mr4L+?%Q%kwBp$B42-AFVH- z(WJ#CXc^KLrX-w|oo-XqH|;V#Q~BL1^ha}S7&uufKZ4L^sYXJ+em42V;dtzJgg8zg zWzm@BQiU(zS!Y#0faCjj+qux5QuQnRSHCH@0s5aG((4b#Yqxwu=W3%l=^}O979G?B z+>BJ22J03jxnAfnYCtaIb4hwrpM@ol-Mh8_eG%thcG^?@bRH#@)Gk)pto0Mn{;V409Gv%yM<|kD=vS5Q z=$AJWMzbFNivC>;+ib$h6zPA>(y}5Ddz2JV$KVGXM17v9?yapZA)eraKeF>A{rJ*dzn$l-)S`MJ58H9V8$K zptUa=LH8E~^3UIN+QA;`Os6P+oazZ-domL#FFxTs4yb^6s!K+~Nrq7;i_@6qK;LV3 zI|_hEhh67&s!WqH`K&5Nt(SjBhDV8&=D-=f_@Ic|i_e0?8H1>&C3Z8@izo>?qs}k()Z?1SyA*@*4vrE5J&i#n6GAK3M$qV6 zjG{mTG90&QiSMXmBAEpJ#Bp_I1K z$}Z^H!%z$Q zbr(l5=ZFN(#|qmQ8jeVrrW~m9i^^YyiF1GLh97PnmgJ`n$41xdgrBM~j%m+GV!F}e zT=V$-4JHKFKi5gyp&5NJx~OCz^t%4T&o_=X4`g7flGUH7%eWP2#^xy63U4!aT)Gpf z*VvaI{Vf}q$Z4h6i9~O{PI%3+;Ye2#UQ^6BO%Xss@|*=>j0yIX#pN5^|6gcppun$^ zqh%DnG%yW!l~HnrRG1E39vwPh_UTg1V|F(@g;Qp-9vC^zt!9Vw$)ygc36f2X7b$ZB z;<5RNK{&28!zCKnlBNM!b<|#BFsU)BVCGx5A&fX`AgAarf3agjjJBO>Y6Oj;ifTDJ zO-!}h9`9sMH#F1*SIQY~(}E%MXk0OyW57h~qj2|y7fds6-JJywF0vYKC>W<{Vf9t7 z2GH%^f~MW-f3=$$Ccjvt7x-iNs}CF!gfoy8mh%623}1c!kK6aZFGK$m|G&zN15!F5 zrLCDzM*6sq`L-QQ22WQ+Y#7u<+yZ$Szno>`VT;d*jDv`66S0|+b{`WdPiepfkm?l- zS0PQQksMXm^;=`08BVQA$$6O0rQRFLhiu11NeVmA1!EO(&{71N8~#*)LUJIP@3dC* zi+z}WjYQ8myA~K7Y^97NoDWFAml+7&(N#(@-l#E@I46=?-lo%v@oQ7eD!-zEc<2F) z9aQj=WIYjeiC@$Ne~nS5+B8&h))iPICw)25N&86bA5<#C)6=6kET63(AAtqdwT2{EtPPv zMe18`m{|W6ZC5QY-=K+~Xs17_38MN%TkxMdMC4VZ(m6{?XifdITmR{xT?>sqkNl&A z0I_BIN>8s-&$MgDsS|OxH$l_u=!qIg8YoTG+n^%lQD#zOla@7$iQnuUZ6DL~E%zKQ z5DvDFaAiUf_iJlWJ4=cCX?0}gH*jb1g@^8gmT+sVE~}SROm2v%?w2Zfc{zJ|~!Nh*qhzaZqq9JwI6?^>C+Y-}p1V z{m}{taWf5d{pMsR8rzfaUV}o{PKz}$+P^dRGhkb^fW2bA2cm0uVG$R|3jW4nN(VEAm-%%dHTS^|9tfH$*unPMZfF!H>bkJk9U$NldJ*ViKDRk*^|C`2lsz%4c&lWmSN zZKK|42f!LtvFqXf6wPPJnC0uv+f)nu-CF48ONOv=E|T(x^cu@`F zJC;6s*iDxnbP;)N$mhj8I$Nwh+-3Ne1<#KVtUnKSnWGl1$?=~Q(-=B^1m7?9=5(!S zY);>`=eFsL+;D7`=zh6vwT6TnT<`Sr`Maz%J0iZuy=@<_dR16#uQrU$Y9mJLFx_zu z6Lf7V+d`Xac?~_AfY5M!jJ|n}R-`GlX&qiEu@Tpta&=Z@tJbcyQ239GP)WbGtQBcA zprqjjwA>4#hS=rF_cgXTxw!@=a9Q*feAR|Ie&uK2M>@~30<3SG&jC;Z4ulM4EmJZm zW?42QSvPcmbnS(0?!0SKaj5_`CSQ2KW#Um88{Si+>o&K@Od1=7iZINYQd~97@*|d) zFHlA?h@z9ON89fQ>D0|J%?NWKYV_>g+9XR0ZV;^}uR8g>;5L4ZoB8#6^wn=^e3_Od zkgDlZZ$v`d+qs1Nr^lkwqr3vqDgT7v5A*$hpFZ^PpYA{UC-whT`C!%x%I8IV=LG*h4@Cy8;b#u ziFc81hS4ueL`Uc}Ee=_M7?LX9-FbP~Km7URpuhP;|GVvzz5eTMkB9YVRZf7KFg~5E zsM`)sS(R%*x-+%PrsfYjgPy@0Xze zw*~&cOv;qQ@41cnsyO@oOJbl!6!!tX=8=nKmQNg0!_ujU5QA!dW%!?TY*yQujPe^+ zI}ixFg|HBeR7MLXHJ~)?r3$kkP}&7+hO;Uyl}kaxvV2Sf!2W(J(5bq(Nei#?yt-LU z4^i-@lkg$TZrsXEhj4>>DFP+EOqN`_8NI%7r(3hj%WDS1;KYyf+R%q3fi@7VqC~I? zWk$SWB8Xop6bqX;mY8BgKNopf)vbRI|5~EWG8v(ZeG(tRAAR^^nYx1_ACHkR5V;$s zctk&N3;4q_ZTVQ7@yAed2-`3ADSG)9ak06bB^TNVHj9nm`6Zg5RFX6R%>1}y|KU88 zW&@~a0oxfz2}p9|Y(j8msIKK>%<2qmxRgNmq*5skK!BrO-TN}e#v~ntBT!3;C`0e@ zO9Ey&o)u@gSsld0Z=KtX<>iTS%Oar5FIq5^*7?)=I(5oo@~@zsuQUu|-5a?U+!aaRxSX<$flfIq|1&Z>NahJ4O4Z3x3S#|5 zRZYwFwY75)$!BLV$)@?J!pviLwTOzNyvvQ;RnqDd^yIY%_n-W$=geM)nVCwP;(f3^ z+%2_*a(t%-t(2WY90b@d(jd-sF+{zro*6ZtebbA+dGW%|{Xy%Qu`d^Jqj83=>sRQ+ zejOnIHb(eU)CGeOiNPZ_n|O^G7Tzh0hdSMfn ziWO0Hm19T^se?urq^69~Q-p#_Hvjt9=q#HcMlS+ompSG5#`HVDNeR9$1rCjV0h}V8 zoKj$5{OV*jEMq*}WBC8Fu25bih}<^;0j#v93qM|_ecULqJdbf{qI`NKb$}S z1Aj^@6FhQ~I^ns1Rb@ub_!?Ia*Eb!H>`dyB0$xEMEn25~MFP@dY+?Y)K$}J__R=ww z-ya32)po^cmJwYKm_Qy+yFgf04L9QoQZ1z#?##J0Y%$L(?ub_5<}ATATqGzRyF~+6 zT&T$W#q;GFuSnoJ50+VEr>K}GI#Wt#pc z!$Q|o;x{h^&ZRB{3ys<9_}Su_thw9L3t;1tmCZp-F&g^CU$|cb`E{J8JHZq?ep9`V zD#s8^lwvEH?8};OW2z{au2@{!>(OnrjWoRjmwm8sRm`|7$Og3&etWZ7x%vKm%K&tn zSxF*pL(#@faouYd%IU0pJ{(bZ^`9?{>1Os{G6kR&7~i$#z7);F|9jx&e|_-u(QW>p zFT($ec6y;KuyYIiAbteQqhEGoBE0Q;9_wME%Fn?wbhtQDs@-s}Wf@vLJ z6gfxh&zo_tF8lc=--AIlEC7LJEjvvF?X#At?g*%6eO(2^+^F$}HEij#Dgync%TLPo zLP#Cj`S1~KH_oZ4%X-I*ZnIClPg(aP8vlrWGW&h^sVDXBV;cX6Ntyj#eJt@^H51>} zXCl6$K(8}tdpt8TStCY4lqY!iHKhmjFpcVSLgyR_yPnv3Z+IHrX+S_ z8!)}A2{I9xxZ!J+5ArdKf{a^RM&Eo>16gHc$o%G;s3S?K3GJzeF^5%txtF}l&dFAR zRvLo-iH@#I%pJlNrP^spKfX+g5~avtR!+ys_2#U~XVqz!1WE3Ei10=E?EE6?^BLiG zuwAS~et(xKM&V`UWz?bU!ubSkCf7evQW-Y9Qj%esCb=)cob=<4Hhb&2WA8;;PC;R% zESDq3T;>SB=+v~=!Qd$yGPbPDxh16lCtGbw>9~_Kn3ayqhCMLW6BbhMge;K}wM{GT zo^l^c`p_Ay8VL@!y)QjV1+Al+{1&JvxzwMXS&N(4K(c~j$_6o|a}IP`EiqcvNIuIg zLnM2-qss73a-id>4+R7t_rrx%`m>Ij&AMfC?XJvQ&)V+)yRT?jW@i#fy=2q%)Y;wGoHg1)>yAFJ zHR==H|F{p0hM;!;zo+*ed-$J^9zS|=d;k9)|E;Y>JL(?d{ALS~zG;bu3> zesMm#TsUJBL3YYB*g_SKFMzg&hsLAwT(}*Y3e~9d3eGV!vTxe-{c*mf%OVGh$8do_ zYeGQ@mDi9f#vICQ0{tU(s3i!WeS~_wy=TY zDH|)$9gpEe>+EiUe?nRPcii9IT`_Nda~PejKP+?5E2f}nRcx7>mm0t<>vmw+E}Rr3 zHTQ{0%55Y`idE}H?b%3FR`7IqoO2^q3WIkpL1Ug1m!>5lgar;XNw4ZhV{~2`_JO1^ zZl!)^wye)q1>VzcF#IAk;&!yjI!deVBVIs5u=89r9aD4U#v+6dW%S${L`dMQ6L-=f z(m$^(xD4T4-r~fmL(Bmh*ypJ+qE2@sUQ7va_uVBH)k>IN-1&rt*pz)EOi=5ddc0xb zUe&yI6K?EGcodyQPH?(n_-kaKv2^_Mcfz!ZBLZ)y&K zHdNTS*GvKo)E%K~H*L_mk?vcr9QSp}FO5qXJ|*rNwVfsB#}#i^^hKaDCzrZPryJUh z&^?B(5liQy37DQb)?wk4!3GGitfDVFYX2htlY{IAOJd&Pj#G?izQlKEJbrtGeiqjz zIp7nZw>OoujCHV{EAFOSgQ<7i{JCMf?P-|e2kJvIey4}-BKyx3KGEh|?1fVSp!5oI z{Fb>iUY;gKK%|1h;8q0`2zRioLILN?Wb$4C0%!|N^Ja@<(wQ3jktUSjEZqMnCfuAg za{HrnoDM*kCyB1pT0cm{b8Cdx)2H;_+?M7PapGqN>WM6MNyH^_MnIpZ>U?;oxOWXcA4l@J9SD@t!=*=so@9nEjq zQO_OIgJ=F&v0+<{!VUTlLHsD9)ppF7t#rOCw_HWjnS$Y39jBvewHLA7uw(B~j2{uD zOA2@AHCE-*#k=oTC#!-|7$wwVeq9b&GRtHFBxO?7yv(gpAKe~PG>z& zzx!YnFK4$!*wB>LXu2O2cUMXnv6lACE!&m*y?bGFU$uYHv zjWH~>P+q)jsM`w5J9dU8Rousur-kG2(oReG)g9f$$_;~J%#*%)o7d+y{%2AD54sjY zP9W|6pZA|Uec<_jK7Mfj*8k(y|6`v2$6^rCD@OWKk6=3*EEJ_T1KL&n46<3^*MgI< zc)znqWg^51uWez$SPzw_fRyE^i4CK-Vq{Q270uVz^H2zF{8&hOmWIzFOa{LjVg8mZVi?68oISaMGI65<=p#pH}WUs|0|KFVd|ND`F zTCg)l2(z)WTWEydPnBt)9d~dcH#sWLNsYF$(Wt$@-^(4(1}7vBwE&XVB8Y}6wa^^j zKdD(_C}@DSe419Yg>=ON(CSP$32ca^9d7qGk54vt`$tFJfb*Xei`KP2p1D@f%%U&U ztH+$N*_Av8zAt9*92M!B|3LnOYIDb|liE zGtWoTkrtfY2|*RPV^dS4@u@Wn6xH@F$5+By8B=1LrM6(>2kH5`krs=nem5lKkngJ= zOjdOlaQU#zM$3%f$~f)5R~V)xYlI9c!!ro^GR(%~UbLEw$EzIpZMr_Xg;&{-nd94PUx(@nY^9kR})?U%2gJ9MblmFHn~fwT?<(6 zn*HNUL-#_vMtMKQW!SnZjGW;uuAthT2lKu;?v`w#^^b70bp9?|0fTgJoNHEKfM3+HvadEi2voCzG=XX$`Urc zjQfI2Xu@fG38D{@C8Bn6EME*#%FPu-s5zpf8vUY3u7i<1uXCKiHohJ#9};B6rC49t zH>4pM_EmcJHml;F(z8(OSEzMVG3C_>e1C5){Gp`D>`${mNKjr3Gf=wnVoYxas^!i4 z13xCTL=&mdZLdf$k%CvMyvpDLNJlWl3wsbp_~nql*rS+(2EUBTa*(}G$CxqpbLvHe zmWqG*DAronf)md!UhB2|^9cWi?_TD?n_r!()!DgUBVzu%xGqWKu4wE)1@bNkS#B;E zEmeO4+rU7E`W)P}t*x|j!{!#OKFX@&Tp%S&#-Zf2vCB7=as5|ji#KRSGRba^lgnv3 zyzyvQBz&EMUNN}IFlJ?X1iL#torHS!+#YiZ!mx{P+2Sje||HO?k@A}`RvY_a&qeF9^XT$%WRQ7Ty0&nkR*=h(<> zG8!l3@gTMl8cF@KvREi2<4+3|QENzK2qHJ$pYT<(399EAEEgB`IEUyQI4pIIt?N=7 zD~9VcnG94Jr9|nwuTC;onmia^emutfj0gO}Z@$A9;{gIQloS}_UC{BaQKY*OpGl)s zx}`G}$0zyQ^hiT25H!W6PtnCYOT?A{ds6yH%{TFm<^Pg5>({M+) zF>cIgd0gNb#x>c81N*5$MEyR@ukMJk8Ybd;%K_4kWR4NqsKY+aSQ>dvsv6X~owTzJst ztbCaiJU~ozuhY?*@ch8RHaSH++HqiB=v{P~&lqsWkdVJd3?ocu8Fk7ujZQIBN56cT zLNQ9#l@3q4J<9H>T6wc0HQ0OT+aGh_f7v)SMrl^}Jdw?rtFiu&rqePyCD`_wA`a8R ztSGa0>1nhUou-pv@DsXimC-Z-bzm5)xOJE~+=et}JzhdpiUTxABTdG2>qj^aW9Tb2 zbUh3cQC0A;Ae1+*CM8@!@Adk=H8u?S#&<<>c81W|1j}NkO|{z}kZ2ke$uOIhoWBw$ zLW0Fm+{n;LjciTy|K(%JFIGuZk^})12~)vSDq|to;&cg9(P(Y%EH!7>6QNly9QhY=9OIuI zPBPHBAKw$#W?g+ZDb9iQyqOHixsWUDOor)uVlmLr-TQWp9nM@4D0$D>nQ;!XiW)W_*N;c9CC9z1$wm*FFbINYOges!Er$ub-!3CWRsn3mb!Y6k7F zRu|%EeW#m&-`EAT`X z>7W`@Qi&=2Rcgo2S)nq0q$p?bnIrbmQ|*HM2zp*}P0iNdBV@zmE_0#AjVQj-uy;^x z*rfXG9Tyow!XD&hHO(if!5#a^DOhx}c?^jMlz?H~O2^<`Ppv1B6Iu&q0EXM>qZtZR z;In54Y4Y^LweH-BYkV(E#bL>F_z2_#FiD=sdK}AkOSR&qx?GvhWgM@3dPFKYH-V8F zLNf?Y`qR=Eg|OF(PY{s;*tvN23i(Wx3>{haH%oj*-(QToNM?BkwkUXHysF(GeHu>MtPj^>B4`XWFFxY*JSR(Xfl}_o&WZy= zn)INeNJEqYH6Ll-fhcb2;BATRK#4*%OV3~#C8J4D6e@G#PjF9zUQ4q{jiZB`BlV$0 zKB@xUU9XQ4AW9aywuc$WVllou{2#b`5m#Un9(3ev3zjAbvn$gfZt zWqyb%0gQDzBQ+UW159(k(%?ABKn|K!iZ`+*tCkW&RLo9jM+ONTH@z(&m@>*85U#a8 zI_)+|A~+6A3kDKQDv1~(MoZ9;jb`E`R<5Zh9F80Sc3bQrS@Srt)dCn6ZW{j0^XFk= zJwoEMsl&;NRH>2Bfxu3$fIVj%mB>_qLq;lx=b0z+dCPI_G=GYZe?Hhg=^q^I?riptclP&Mldts4G9O?h!m!S| zYiRw3p6^|>AzOohAf}Yli~=L@bXK58h@Csyq0LB3lpr%SHNa|^c%Q2EQ_B>1O65k% ze8t}dbcIBM$-&e+jCG4Tu?+SUCDNdN zpJfA<o?^p8^ngk%R}byTzO62#fF`bLi5@}I3vp*ZeG}p zhCv^7fPx_dc2&5fD6ZD*vgPK*@xl53Ud_U)*k}Xpbb}3Lr3kXnkkP1;V%_2whUIc- zRe=HX$CY3Z=pCzO6CQ(ucGLhMQ?I3ry{S34#b;9~W2Re@6|oksC&Txb$&iZX%hioT zX|i!CcL#5Hcnj$Y?pXD@&mDGnIBlK6`E2aBv9m?+Wc-lkBg(P2mk(1B%V0%o-f)!- zs|)9F^8mabYpZ(0xi_6ks9iOeo>jX$Z@hCCtY$-tSZ-S;W{!UCn|gh}iGE7IE+n{( zhQ-+mQ&MpFFrvx>?0$e>=dQPT_RCYpYQF0T9%QZNSY2*fUoTcQ`B)f#OQ5tB zLJ4=uAVsikrG=E*g5;Um6lDHf{<` zY~dSntlh=Vr_o;{bC=^P-08O=m+?wlY^f2HtgHm~>l%=~auvL?-&kxp(p)>0xp5%J5OLAra z3SI-9PW^3mg0+DYlKW%H%0C$q)6oc>9YGc^2s@B$W@WAG@7Q`OoO05aE4FP}yPsBf zNwt-OPHH6Zfoo*HBeY{1iZ7)vfWG3n3w%h!`q(wj&L=tNEl_h*fbz5=n5aQ6Aw}k8 zLXh&w1*&i>*cV6sGJFJg^8Tk<|h*uANb{2 zR`J>E$#D;`o~>-i(>XOe<0lTJMs3{@rUQzcwq59#cpD|CBkK|U{qOQp2D=IuE&(qN zNq-5L`(0m)OcQNO+Zni* zFnnU|M$?Ro8rilKxUhEuOXvseH{roHYnN^tY^|%Wc{=E18dtk4mc(oV%!6gJDPJYA z+fYbM5oshWhRl&Y*n+$uMu9c=<_&S@zXYMo5gwdju=#%@&L;;X#mps@UL@jTKJ7&! z5ej%pZMD90C%*8N6Gj#Lgl{!S6`$j+h7IbU?5)N+qe2?;S_9+I4>sFatbVoWcxSQY zIn9G=Ug}9Gfb$h%FO}1rGvFG?!x{JzgZa35_!wuzH_)t$vW<-2+jP#a1S5sEqbs)}V4Ok>gvnzu2$vV1kv`MlSgGP3)wLCP69Mil1gH?f8{RF6I|$Z+^; zJ(htbtA-`m<%Z%Iip_D@pFBhJPXzv4S-wEZ-p;QIqUZzL&Zkv&i5et~V#qmxv|CtS zV;JHo75HVHsYkJh2qd@(ImG1@_f%?&eIjl(^0c6g*?6C^H%T%BegkZFK!`hM*Y!)* zH^v(tE0Wf9pp_wTI(opuv8g6UBOfa$lwHYklpAAkmLDA`avr5y0Yoz3wQ#8LVd>8EXt)3K_TdS=dVJqM-1@11xV`Yu zIbN$o_X%my$2YTg3I#%MOx@5R*m`MvK-5zk#ehNrS8P0k4*~k;2ScKHdDW^Z9$yG4 z%aWLxW=QuF(4a5i`1whdWV6PzfxPu-r<4^tj?we3a~IWjwL3oS?;Rb$)c1~0Uhi-9 zcYAXxyxKo}eRA~u_V%$|5(5*pZ-*PQXtVWz^~0d2cgk6Iy$(JOLQ>%o?_{Wv+>=XP zSLJArO|%h&#C`gTFaEkb0`B3D{i>67J#ymmu#+;zki9(wxak=3bu;z60u5nFV>zf& zxBczH?{&U;ybT7_4ag0gG<}>M#dC^5PwNrIK5Yc`PtmOJaufP3UOC|Lm3?ZR;8VIb z0swQCqiUzki`C_%R$1bsXcJAEs89>{$BrqxRueziY53zWWd#~liX087*_ zl`%qoe>vRa0hGTwjpNfQNlLJs=jbnwhsV(VRzp~9fzdy=+co1}#laY~5js!oo#)%( zFhbY$tL+3lZN+calSkc>O$kppON?LIc8+S@T^=Vt-UA;7or5vqL7zHGD|7tam3iF; zV$Z!&p`)%N(9OWZ_-ONRdwcI>|8NJmH~9^myn3^{dvdhBceH=#H-f zrlN@Oxs>L2W3fgWm+T?PK!aIHo@lgOq=@K`n9~!~B5IwA84Ih;FADEy`4xtpf2F$b zz;)mK=q5=f!gjZUBdyT0**x7cdXkR@dzjyMxu!Up3yZFIdO&+Aqf zqJ?lxyK2LhK5rQ4LjB&PO`jysL_TMyFQc91Ns{b6m!Lr9BrS{5Ka@BPOi!Q=3HxrA zEYx%`k3j!mCwlfgdhoa#|9<2+hs-#oLXgFz>X-&FUDCOVb>kiw=2 zIwlxjrM%wlq9OM?po!AHhZh+aVcVN6N7ro%an z{HuFTK?z#yEC~1L?4%I__kA`T8ZPziyUB%Hx08$ff&0k=SYSgNHjwMjm#MrZ-AmqO z=OmCVxU3VuLTG6N#csa5)*zQ$;=n*Y-MI;@-e);Q3!Dyi?05;f`O8^V<&&+f#1M>R zES{g?05!u9`8p}ymbMwPWDj;pQ$MWz`^AfdtcWqec4-uSv}#+iqW`8kprFQLHc=0L zIxf|WS>(R-BY=!V;Ozrx};T11$yIr(|RR!jX)a2y0zxFx)2v^n3HZ!2}Rwu{H5LiT5ZVly6i6N zIo(LE-p*|JTNC2GzVSY*TCDc2@+$HT@U|CFMe zCxy79&q&14XnAxtyJY{x$?WnhEl_V%M_Xr5$t2@kQ`;y_z`%z+@eeOkVrfYgoo1)& z(B~+_5IpnF8qnS;^q0~KXmncVH95^EuR;Bs6$Du@xJdcYIa2{~*un(e+s1HC*o|4} zt>Ta;4AX0z!SVjq{yHUTM*q9CLVpK|UK2`@n>3vyMV2=SA+W;qio{Pzf;+Qh*anSS z2ir0?#kK=vK#zCbP=>K9p{R(3zX;~NIgsH{qy<589yI1xFHCX?%k#XE8ZEBGu)VdZD6 zpAyL#Lq{$OClbUF8@_3CoDJThbc=JgjH2>B<#^E&#sMMgbjXuSR-#uZ`v@@dfnOFL4ZRdt2EhIywHxUe*I~L>F0HdzSGV(Et~OoLeSF zGz2Is>3C!_5Zetbbf{ITcz4k}*FwvtMgG9oK8{$&3xe_zMb_Rz?YJ znQ_j?VHiZ|NPX^Djn^}9aSYA*&p~0bT4Wd|v-U=;yv%v&yi?TxwH8(%^*kGN$cEn~ z@1eQ$z*h*p14!3Md>GI;-9#tNe#k!FTgNa`+V~?z{&lqG_?AtRDs&)(-+|F?PdC+d z0{)AXS#+IhmUEJoLy6d( zKXyHzd5F#-lhynAxc1>yQw+NBqNDu%`_}>kRKm%`^KshvICR>JPPMi6lq~G0VY{jA zj1N;};bEv_vt^nL7bHAp$h?W~`VLIg`hjD!N{akKGJ#;H#MCxI!ac@ML?M>FB#~- z1Y;n~h(?DF)Ndlo$6{N-SeW(`AroaOv;7Tr2gKb=~+p1xsBF`ik4?>Y0rc+?7S&3eHau-|JDtNE4OL?8J z(XgwH3e3rY_yhm|6=}oNioh~ z1wp=)oyx}LdMIO12}^MW?Dnoko44`?3Gn=MHXg4%_}BaQZz`A84(vf})ef)~0T199 z#U?X2FU9^DsL4Q12Sxt=8a2m#f4O!sdJ-5|r$OOd#>m}ZYHE=WK8*mfl!$3(WvQLT zd_>Sz*{}gU*I&jKnJ^>|ni7*=LDs?%`Wn2KoqOd~HldhMf=E@+wZHo6pcjJg<75yT?j2&dE`JX*pR z7&;4hnY{04oOo0Nb`J0^q@Ow-USqcx-5WWs&J}~3JFw=C+ai;50R23~5jfca@QZHv zGd%}3STy8q@;IOFrXv}9#{_$i5BJ~fZEbJ$f-BOU)94}TXrHFl5Ahk!vxzR#$?Q_4 z?DS}@o;n;Thw{FW0czD&{GX8b6yR)@@^6GH^^~uZq zey~O+}O_kQLrifd)<&fRTmq$aeWW=51QJrJjaa&cJ}(4 z$2&i6JG-)jUX?q0-<|9qp1j=N-T&$2ShbD+?eEq9_WLp)pN*~ze@$a8kAL1ZI{$uW zYiry1+~3=I-9HA(t#`BkN^!c)Xcw$z#NiuB`G3um6D;B?{rz|ChP-q#+(OT14 z`NH%6FbaXB0n+57L?@jWs6GGZ<3~>(x%q#dK7I1|Hvi9;lmACD8U)e+DZQdEs>XuJ z4FaOKj*FDA_w$?r>GT5@9gW!pw1J{D@*k42rJycqMW>F}AhjJ7nsI)f4PtD)FYpP1 ze#y9t{4SZJ>j$|;lD=h_drUZJJI{ef_#F=h)qzL}&AaDSqBY;X%qjz_cH8)E!>^*J z&tXP=^Pik#LG6>?Fr}Ud7c*J9S=(T8+n8>zW|-Umv18vXCPMaLR^WTGgct2*qjYdR z7^f;G#{MDR*@Jtbzq|9F+grYpqc&7DdXwuKodg3$%mSgupypxVyPfm(=(vA)e6qCz zlEv}n_f9-Opf$IApvmpMttA??OE1^lhc-iq+HZ1a>1bN2iN@6e?1g{cwx8~@JG;3O z0KOn zC7JKYQ+}vWc7Th`Fw_}&he=xkXOvIq)y-Z#XGTni*6|DxqIKkAI%v4RCMT$M z=KaY}*?7zlTd5ULn{Y3Z@0byMO=wV^{xs^5p(){?{*9 z|7%Xusq$%;^NapeWR+x3UpjB*imidwq{pmSKM5F59r#YJe?V1;%0Kg3c+sgJ6r_^6 z|6EvkD!-Rpa(4OcAJNdb*MGfTNBWJ&N{B=?HHpqMeB`7jd&x#H05uo=5X7;|?P8NZ zgKqmndW}FPM;EwIi2L0DEzE0|AlyCJwarVRX*iayG_ac z&z=8U=g4bH1m4R3A3c8X;Hk_1AK!a;%m2UR{NE~}gp(_|;j=RBg_31&0zXT0$^hO_ z;)Yqc@hihS20xR7g}Brb0Pk!1JDjz9(UjN0QtD}BvC>No;MmA<*L;PNtL9;zSLRB7 zbf)dgd^m%viSvbXozPbP&RMrVLlS9~&6j?aXhN0BcO{>y96fHv(9Vj&_o1w}GQSO$ zJ?iwfD#Ac++;9K=-Q_Ec*c3%67r4CIF<1^$Nf&yNT%+FqESTQ(g}U9F8?7^?%8J%m zapVUp_SMcAsl10QPXx0Fx|@n4L}Qt(d zSj3t3;*Kk-1(BUo(GGc{9cP#6UNXtc^=LPr1Vb%ar(^zi$$>CouONw%Ihv+}7#}~3 ze=7$;v&i;;^f!-pe{P66Z);4s87|hEZ@rO>3Qg#^?Vd~~8F1X$kxAk6lJ2Bji1Qs$8wGcmK~y-lyr@}wBnk8t>NHqo4=jH9izoMx5f z`l&EVm18ku*0Xb?1a0UB7%Ct3?_Fx-JYzIv-95;0*HCquPMo}Ii5V7`l&O~w&zv7W zIATtg^zH>NoNiwy#xOIkVFV$M8L>^(-4J!IthqAispM!g_W_?W*u~WNjS|w9k~RVt z<(-82%_eubFQy^PT3`Z?QnET@z_=7$+xwncLc=GulMad0ToP1Q5_$DqmsNUslos!@ zLE4c!EZ*5WcyoO6di(hM{jHOu?ZY2;Hn+Pt7hjO_Sw@V@dX$ei?SbKX%(&Ob`CHst zzoqzl^orTrKR)U2{?z~ZsM`p1>d<+&`7<(}Vf6Qa3}zUr(Z4BGbY_R-PF=Kkx0 z{^8Ei{@!XA_u|q{n4$E2!Z4(ej55RA2$g}{qf7$qfeVy^X+zxLdgp#&oLIA?26TzQ z&E$pHb9e`O_U`r3Yv)Ay$Dsezo84_-XvQrXQA1`5Wx;Emm5hQ=aY(0npmn<%=NO_( z|3kSVC_nWmN_l`Pb&3QiWD~)jM~`u=ft}KmKly^Qr#PXP+b?|q$<(9Bb4(M=xheuL z6!A1KD@$MqU)p|8v8q7N3*jXV-`Vz-w%x=YzIImb5sY_pp4GrztB9sXO>odC%?Nc4 zX5=sBo(>w&&;LriB-t3-N`}d_l0Y$at9Ki&cgzKC{Vw&o_0W>xP+X?!^J=wi_%=KK z&aP9lx?=Ek$QWeTX*eEvgVYHl?UG75M`!R(#$j2isMl&8++t+H5jS2|Cae%B#lqCG zGM^IzX4R?ZYH>1aSiexAU*o_4Y=9ekGTf8uqkE%q_Qzu#oZ~_gm)4HsS+@t^IYwLM z=g#Is)4T>_`zf(?K4xs@Y{K%Qceb1kP{gYV_gq1^+PkI5)~jysR-hf{?1Y>34_L4l zW7FPIoXzyeI#e6;=1K`l_lrHe;C`d5s}|HS=9EU!gIkNOk1;!ZMjtk;xjM+pOXDcVc`}ro3wp#+ zhcl8(o)=Vb^c;*$K&rL!F{|ogM1wuSNKWYyh47py%0Rt8K&Y$ISmHpB6o6c3Jko0F-Hmnl>x1Z&Uue(EjTN;FQ<$_=V2xM#6i^= z!3Ir+sJ>aWK&f>kx!BcOt}tsPOtOY{i|`x}!PNbRPENyU+kp`5apsq)8U`g|jQ(O` zJ5VkI>vI(pnmy$4y7lJb&82?=+8Lp5`*y=b)@GJr8Q(add>oX;v~ZVy=tJ z5?1H@0@|NV#@Sn-J-6{Pr93{BNUU`%AJB6dh(3GHgg!nz;@FN)HIr0WV6u6@r$?BXXS zS77I(Q56Y#r+axM;czfu7w%G4hx!ZH7I_BXx=}s%(!66}a@lgg-~S%fpS;%%O!gN- zaFZ%`92wbjJyi3wss%)9MtZ3Sf`}04Unepx9LY;iLUrCpQ1sFKlE6h(vIDO$hO#PV z13Sko=1#*~=m1KR0daL&SwhFO$LxP`k662*uP`J}warfict zORFm!BBn&H)ycmQG#ki#o|CDkWBBZ5!XiSvs7~ki<@zZZP?n$*OX)&Q;We7Ps=ia z>xd8&CT>4kKqsbJpZ+V%g9}1OX7VLm4M~(ep!~Yi9DNir>PfDB0#L!b-|b1~ZR8YY zEWm0M*qYN*Rx>p7Q~#XD7b(5Y%LpSyL5`&(9J1RLc_&0inANkMAVokC;LcS_My+&6 z5%nZTOGDNdixDpMw*)RR77xLkA$ z_1^f|U;+|JqK^N_Ggd^azF^FxDmZ4?Ct1Sl_T8z+XX$x1vBqV8hqXCOiIkwFK5lk8 z?4a$x>&rvD=B|0w=sh^)o@aEaiv=+F>xyh99lADB1HG3A^fKkVpPg`8Bg@_vJ&PWM zvp*f86n5E^_~{MdLzC(-Gcy+wrH?U`5Ki_adJrl9kYMg7vYna45TYBqH1NphwY|*< z|Fu~v-Ik=-@R}G^fNRN#pHOpx%Ecf#uyQ5}NWHzrFzNAL-}E?ySDw+@A`PpqV^0k2 z$=&F|ukmP!Hs_DWBqMb3$*R5lipU$tob-s@ye$1hTD%7Ts1((_!#Cfho31otAd2IZ z+?wt)kQ*^oo9Ilpuuw|Eg#l>>E*8x^#KQ$p4;w%k)hfqX06)})y9s%ena<~RQ!VJ` zyv9JtdV$?HuKE1sA7bH=VSI{Jmv$CFUbl|g?{HW0J6b1rRhr}So?gn(J5a;(kbX9@ zUuZTAsV3N*dW5sn9NG_o7aHqgnGsodwB8FI3yo#C#qK9Xe5*Yv49`c)hazlEauVJ~ zoC}~d?17|7c#iD8vvQY(C zb*{~L{yP+!!3RZBUYMRlS2@}N&Y-eLP^9AP9bNGhW_+1I?Olzi)|e3&z;-9J&r>Q< zSq9W7L8MZ`4*Ll$bV`%5eh$H>B)a*qmB>H!X~S+Th`??ER>p>%Fdt$1@XMrR+~igoxHo zGdIJTrZmg!y+bTcod;b(^aR>zJNq+`aaKF?mnxwGr7tFoQPLh0+bb2*Ne`p33cZdx z!x^L7YE7l=(gSdUuW7R}oP92kPtJ4AHMpR4#H{C3p#H%{j*rbqoJ7?);o7D)Wba`5 zLCi$;a&qD&d~*k?O@aQ^x_T#5w`V-O(5nk2GpLm>RFmH!Ch3lx!i3MEcJQq`2X<$@ z@f7J@%{vtEt=BfMP0vVQ!A40k2J)-lS*lntc}}#Pf9X$lu5gi2g%-ryo<6+zC@y=hQ+m~8L6^rV@P||KYK>R?Y(I4n z0qE!VBh2`GoJaS3=uGAH%;|j=5f)y%K`0bsU<89WK@c2!5dIz#qE-8Dvpv71?n-l> zqvj+>cDTglO}cAT?>uKfTa5Y%BLRSHX2J!;Pg#sT48?3XKS!^UvmzTrEZ}kA00A|l zeH*JfuKaE`7B|+G0vCXK*@Nwr;mwr6nQZ!X6M`9UQFE@3$(gv9uxpooa!?NeoJh$) zRwHz4b?+HBfa6PMg2?~xfBz5hE|MZI=yDtZAtvLVdIol;SJGkS15EJPc&sc>BjlVz zg$840B{I0Nx;6ZWk5v1OD3z+feT4r#^*jSl>nt~tW`wU?5ojztb9(Bbr)ich*?Yvf zl{ADuzG@W-`AKP9NQpMot=chkmcn7$Li|X|E{9F_YtJDSz5(>PKY5e=U-Ys+rqqN% zN1qUN%OfdsHjXFI=r8o%bZWU?!9VD0Iz09LS1waDohoU3O*UJLIQjs|!Ql*v(+j++ z@${(8bs(OFG)z|{IhfYXPMM~Fut*9ZTRCk5N$@@Y^^bJ!p>T4>A|r%#eM`{?`{0`c zrVH}a*h_Ccr~%_hlDug@q}i0&S?JL!;OFYGF=wH&OQ58T9Nub~DD+HKrj)oVr4TE2 zkRt!1Gs?SZiJEL}lOQR!>cO>~7hIhWzu`p5AVB(@s7M)vgcCRox$7N)W3hwB1cK{L z0x6$q^suxzG(L6W4lZ;QgIi-*Y2ELR)%%Xw^xyxE4zgPHfPZ>k2CU8mK5dv99G0VJ zzC}T{o8ooZHC}~+)Wym4ta-Ne-HidLtDDdw{yRoMH{}32lQ$UJF5G()+C8Kk-#;TS z;QUQ680Phipiqud!&xohuJJvH%1|cksWJCCw-G;k&NHu&hmns(U!ZzQ;PB2Pzyz(o>AFpsl~ZpVL-bMX-!^CTA_hcfQd6g9x%a%EQ35|6>MsGUT!xc zT_GLUDA?ZC2hXgc+sT0aRt+w|F1_{(Ozf_!RyjtH4JqC~5TWK7RgGqv)~el{+D6M} z&5)_JF}L+XTh)v~|6ygZUq7@V1Df_vG;B_yRm|)*UDerZl)s`QOQOg@2{Jt%Qy0SX zYreEScg;?lH{tOF+h!An-hz%YA}8%V}i}duk$Cr0r}nOBYQ*Igg#Sok(M= z+fL9&FuGb|sR@R>aOSRP!)a^FgSy0&ZaAK{-pRqlIy>Hbja9Vzs6}0p5jDg;n#&Mc zE^m-dC}h*E4yIXa~)MSaW6NTVoMB6GXju9ii$w#byG7RpSnWxiYludURA3tXdeG8K}YL6zo}}nV7lw)K%1>S4AXM|&C;~_87Vpttwd8w1TYwyu$wJ-0bqE&Gb##-WsLmh9TJabJC zdWpWNF-53;FEWrW+Of3y)e;kLo1=^*gz7PG9O~J$(=dSlpS^eAZrjSz#Q*10z^eXL z$b^C=U#HZO6&cB0o?j8T)bl$bnOYC(2*sDcAa&d4w$0Zrr%gSNE-Wl8&Is}b z^`m;&)~|M$ulAqi&wq~rsNwwg@V`8N{N(HT|0SOPU(Gzh6@R~_wKn*;JFnK_;y!2)EuFx$**JQ$3%6etaf;3@$ z)fWDr=6{paj1C1{6aU*I*Z=c)^U2rq|11AnOa3==>8(fsXBP661+FyFen*H#fq?{W2yJTr`-d&N;P2LsTo`6xUT5DX}|Au2!3 zYwel3H`UGXY;~NTWS`Q>TQ;2f+gC+#g~nVORY=_|vt$B;$c79i^kw21gqoG3r2)TA zlTljkB65NF8pKRIN%AY!zH$66O+K7LYH*r^<&(R539}W?d!Cdm7_*3H3XiX71OkW? zCq-S1|CwgfY$%ij0%z*=3Eif^!)gJT@ZTynjcaV7@UwCh4oC-?bF7YJ2#B--`*rub z@1Q~i{Yav}9vy&d7FY^aCn=aMCXSP6>X3OO{(yf0B%h!Pn;`xV9oL!NTRH<76?JzhWhyW&cdKqF0k5L3ME)dY8B2 zAZNRTZPs%Bj;-7y$txj(yEdd!dwBYm;7BkGV$&T<$X;dR2miC5&oQ4({UhFgb8`N( zEDWJQFjntI?X@zkW^6@d001SxhQ%C9%$gX$=QE%$fsp`;52Ey7bIDC&r@gkvBMvU^um$Q^0@87Z3%YZ85DFY#yh;xG3`1 zMK$M1#ux53;QD}+3nIFs!c8H1DD?*SJv&^)2=t}J0wf&Rxk+|e0zP(P9@@h<|Z&T}%u@7riR=HNoV>oiHf}adXBn7Kbzp-NS>I zr-Rd<@ph%dv|rDu}Z5k6N-mf{$U>=z3n_Bre4K2-SfeZeG_N?6`euzg2w;f z^xDKsm=LxKSB8z6@NKc_H5ao9-!)BcFVh1y>)P1_!icm7Yvr5ELO`RzTul7u5`rqh zSW3z)+7Xa6iwpZSt=P5L12d$!iW0=AzfIXu^N|e+EVej0Mj!~Y&}zYsqI6XC&9Y05 z|A?z-B9uKB&Wf$)S&;!jNG`T02qI$49RY^PWKpp_$rk42Z4};6I%Hho;56x*GBP$d z2Y?#RE0T3c9tOxQIn-+K%`9>`_ME7*M2xb;#7w)Pyj$4PdHRvP%twgB;?a)w&Knza z%G$+DKKj@JPSe*0M0ERg6E~zE^-TDW>6&GhR-`TnN;R%oT38Y&P8{u^ZyfvtQiES> zTvp%0QiXdAl#Oe^Rfu>1V3&!Bh@JF8t>xU$w)V-nk1C99mII>tq*2Tg%Mp+yNxnCg z3@usWEPO;9S4q56Q#$CNL|%(6Waj~Z7J~F+-Kri~U;e;Zv@UtXwcrfHWk4OiL3Bl$ z2_&{E73zlv`E&`{5maln5O5QuLlEd5D6&02vWA&vYBIf@6cuSSqhy}2O)}(|I_n12 zUY7rZE+lOC7gB9ud-v@DA54?i`GK+Wuajz*K&OOEZ~+H+OU-0*v#IYiM!!m<0)+PB zac3+Gi`50t(-1bU%r4owu9Ws~62_U^MXWnOX+zz;;NW!~Uvjtw-(jJ17(+5N$HSLd zYaDe0g~5jajG~VQ^@nJO!79R;L{MOIv_zQYbyma(liuUCP-<>w|Ziym#0c&|HzCBfVEgu4wz$9jmJjH9m5ftc6#f82 zfm5+spnn*+LleQ|69FodwV^WZGi);Iw~eU6Jei;feFw!s$rv7cyR}=92l^ehT@DfE z)_Dh)I{P=LL~T}iImWIxcd`D+Ey?SjP=whhF#eSX)(8G`8X6yJA9aJc0QKL+ZwBYP zuiu{xPWO+_`>Pi6?sRbSe)st3JXlCDp`Q;aw+UE%=f5s7kn?Ol0Tq33|JC5_;raX5 z`-8px)A#2G=ZE{NjJjbm42@39nXt=SWg_a!Xr2e03o&|tzEDgiuO`VAz=3asAs^^G z{Qy*r2rPr6j9Z_;VRcm4ek71XzhNTbA3#T1K@JLhW1fWg(>W`J^5DY6bpb0Zk~p60 zg&gE!$*48eYdkL1%@AR_Ws3xQDNoJe<*fzI2+M2*8F+frVi+!XV1LkHy8?59?2B+b zgkJ7wU}%Pziba;IHZIO4+_MaN)|BA)|If&@=0bup&3Dvzhe?|heX@F?m+T4h8Y_mL zQ_j~S#bRU}XZFg`a(lRl*4Cq1%g)mAEhvptz}3ujfE(}47H|$Lp+17`6Rs4#+igFZ zCrINg?jMjnS8}69I>Eb%C^sNl5I2@X^$BWSV0)a-;L=batvcH@rpLw7rw+ibt6wPL z%FA6XM2ZdehpVz!pz#3ovVV_$IMdYV=xULaBX+gD0+JBCtOz{b3pA;`oe|!&sIpw- zxn?{?4t1_VOJ!5pD0%`~nn1V5lWZ1UcxL7TEddC#EfqPWo@PRt1BoXQM=utDYybvS z7FUqs7tk}uLQujVWQS*RXn~mB+_H_M_SZA(!>mG?k63iZt((sVolZbA4{ul><2F1m zW+*|8ueqg}7ebiA97nB~f&YcQ-<@V)a;!w57vv@s<-JVVD#_R-<=0EZUQC&w7kHDE)7VAs<_iD_W|wVIgm&4#==H6Z3EFQotARzT62=d3RI2rE zT4<(-WeN!B07*HVFVId**`IRWq@~0Bmd)^J79o@^_7syve=e>PLyOmac=&I9eLH$S zwPk`5r;Xu=(kJSDJS z78So$gNXcRS{CRt*F?AFMM(V`2)Gu!4?^w+wz}8RYAmJ`MJBCvXo}^x7irehq5@YT z(5FX=qj__trLY3*j*W zRzC<1L?0{sA$EJ;*x-G0Kleh0L0%?~>uVg=fR#{ll_J$7`QIi$@u2TGbpYa&Ug4=> zUS{}W_SqPQLDft9&@?I-N}2CTkHjl>0i+FV6XK(>!YH?le+7Uis{bfy5xOXRJ42aFPg?P@kVMy?6}N3?IeUy-CTr@>~e@LV2a?QskY zSK7b3W@G#A2mAjy*gZe|*<_z}m@!tambzP4ujNirQpsMl=Bkf{O=@ke?yX^?8DOKH zxjay7qa|$VcC@ow1h=cS_e3k0bM`^@wXE?9u{SN+l{C=`n51J*HB+8~up=pS_W- z9cFcz3qA8P1N1J#ArerC&?z>nWx~c{gq}5~tk6|OP`{9?S1;MPXz^>7c1bK|5ErnR zi#SB>j(1_+5w|&L^+m6*mwz#rH#;VaH^>jlFI{pJt!oxt$T+kPHN>)r7V~7HAEr_W zdk$JIhP4Izojl7rmXuVA5GzZ@&azMU4o-kM3V-Sbs#n{LuKao^+_D;5-)KzFc6T`{ z+h;w>4$R8!qOdSt63iZKdbRkkZ>m7VrxA(CgH<#>$IKIjqPIjuQ;pb#Z#bXa#&yAL z9Xndej9kO!exro|#U6^(ww}eU$mcZH(ZxMu_dw`koN7Z|ro-Qu(v}ZL+n71eb<`%k z<=;wnkgg199MpEzJ_DT3koX~K_!8H?W^i+hiM5cp_6XZ;yS+OCR%6rR1}*C zV;=4{uS`xGvsp8lfz_9I7I7RDGIHD2$dDkrx5p~hoh1LfeUnyIa>e@4W#`!Xi)fJF z_CzRcFBt8li5)9`#=1p%F25GonrcI|vt=9V20E!F)&ne-h^A+R5v@g^b(FRCX@%#5 zAK``&NNZT@cs?=WSJ}7NkoOQ8e3j)cd|mgoY2ZCbZ+t zM~@%;>EG6D$FYdn!9bEMo!AK|eGy?CU9hPUTjPR9S-4H!Xf zJh~D!$Eg8!irz9>;g$l0c13crz&xZ#V-~S10aN6Kv*qmpnz|gvR2=;THv}*!qN2+d z7E^F#0t;SPi2I4G93L}^6XYS71y^rW?B5TfJSPO-?;4~~KObPB3Qe}j;e;U-?+-|9 zT49Vmy7Iuamat8mQseb-rt5?_gZZ@LO3Iax?1Y^FZH)fLGa*H+0PY7o?So?QDJ&68 z@bQ{uzdV=MJf!eWKNV~NMd|2D(0Sj8C}>i4$es@cUII5}T5%vz-gT46mo_X~*!vN> zr0~wjl-hy<98ZtX3k1fYVuqeS;tyn_m4crP9T&m_MKQ;0rCk=E%=pi{xGoHb0)}pQx3y-pW z;c+Opc2$c?E?no{h3>E>zqliLBo{btg_{znwiC$7n+OkdiuqZz^7!2?ZsTjz^?6P4 zF)l3z-=8nALL{*H0x9%oz5ogRxfWevaBjIbZaw)d97>_dOX(C)m$jOkoaLKw?u1qZ zi>5HDEQ%VHacF9YZElw4Kz)`l3mq|=7>rJNq0$=B5xw@fjp64Sw=o832i(RzRjXDZ zSwcE$ps5jCvN@_?-N3=!&yb1wi{#qmj<65<=O7$Jp|NhoWL=8i92~v>X>j-!2nyv} zV6|dJG-{v-X#wjgVAL@x=+mm|6D?;y+8F<9|CPW-1g)zF0KNhL=jqc82mgKZ z+4HaX4__4it3>VgPP(@JhDk1dHxJM*V&H69Op_cSzOvi1BD`KNc; zvHC$kLV4a-q?9K-%+8vLWHP%>YOsH*+7mY@iONh^$5q~{9QHyM`oIo zSd6a?JF$43Bq#M#eMrBW?GuEhaW?9C>s57{m-L3ZO=JWjfwCcC9jk|GmGfM-(tOl& zjMMI@Nj%gQ2F^mVU2^PqFe9muIe?T-ZN0lrT%&S!#~CDVuGMPy6>C;}FX)rvJ~u|PW)c0%i~#p= z|9@2!b1&~(EB)`$)6GYZZT;`bm&uQKt0x9Yz+TnXuYLpvK>U zMXm+pAyHu=A6xtq|2~7iMTXKn6ruknthM)1?AiI?eE-Y`(e{T`twB7YXZv<9veEgx9PqLg!o+h6<%Te`_ zH0^eL?$H%#`gGqgt7$GRF2aZ|u>E{5pCNWM_L9fQ(A&m9yB=tHWXuyJH*elO1iz+{KhHrI;l?6 zVVYsQ8hW)j*DXYw1+I|oo1=VgXSCYV0V-M8aOanpy+&a>fS zl9ZEM71;n#VE~i|Q36HIgSRQ)5@zp01p89u7tx>9PZ$AxL5m#SZ*m)i<&U{ z1YDmp@|24t5j}x~yHI7SrAiiqED&;p66hLd{uJrLShyY2tq(cwJS$a7jIr6nj{n9jAI?J zl|LCKln@Buf$%{hHZ|h1LiqO$BnV#4+0q2a-IUXp!ciXW9h{vY0>GQ&z5VxR=f@`s zI8!5O?Q2c>rsCU$qx5DxD;F9+Pd-qPJ4VCfbg_Eml)X(x)*+M6tiRZW;0U?JSKm2nFi#bIuNzk?RIxe57pDXOsu= z<6>ar{y|%;c{Rad`I*whG6xV0*R3{sGGc&$PtF z4Wxly2W1gdGIfDYa_k%8c2TNCL1&Ow8d8Ugcqou{q5#oDyAs+C6nJrTd`=0Fxc$UP zr2a6wxcCIvcwH&*0uaOxgN;6yKg*d zA)I-v+1{OZkaLwDZ^Ucot5x4by3@M{{xt|+V*N_?^Ax+Zf4Lqk=8f%2@&eZS@< z<8od1V*NYoFM=68jqBC@pke)b?YnJW^e=BYn*GLRd6%|}*5G}Fk+AD8qF;O|O1{eM zTQ#9Q;h&wFWnymyI5Jn-1C=^#9altXBe<9IY4WM7mDI=Ede6Uu8jjk~a$jnNEw#E@ zt*lklm8BO}uVK+Rz#owIi?$|Cmhd4>XLL>rxYAL|ooH>76VS=IxQ@|Y+;R>OEZ+JbeH`#;<^T87p8M?C|w0M8|We2RwZiFbH*-j zjXfabXw0?8zu#qt*6mNuKrkUwb2gn#5ZRLKvPlIwUX@B5q=9Ec(dA>(Q`-&7FXt7c z)7@?f44-eI{;NrGiQy{TNSIb=#LFQ?5r|b`7AvUwRv&7ljKZ2TB4owHKXy!30(0kE zHo=nG1r5|#q)vE()}~o85inB~KaMdwRPxl}@Y8VdmAxqKfzpBZP$yvWWSW#2!br0J zhvYO`Wqk>&!Epjd8RUwx*D&b|+JUKqx*b&1g)q}=V~mV0*P`w}7SkEnYaS8>R>JM+ z(G0*$dLfak-2td?+)}#Cvit2tu&J`U7alH(l~KYnv$D!}wj$*kHt@)2mu)?iJ9akn8iZsJM;EmiUKA><0@PSChRE(P}(;l3pK zEl=fuhjQO@&@53|fo>^H2Iww4wEZZ@9kQ-T>!ftvB||t$k={&}V!{lo1cic6`@g~x z$#Df7kGyNA^c}t1_P6bAiNiY?@K-V;hFF(q+$+Ii$q^;dZv!yMwDNc;AAo#;Gq556 z19aFhp}`bv;xb8CN5OW>+K^vK(B7iV{t5l1=RlGPvK6F1nZ}WT1H5kD^AKHKm+WvB z$+^-SzL=zFCe8+qFtTtV0tBak_&)<4APv9@R0kY5gpV3r0@}^=!<)$FFLRQn;h0l%0b+Q2%EP+X(6@oE}nuSmQ_RDhC$>lWNHkY)&EpT zW!N%f6-x$$K!IXM$xs7s!>6XgIm83R;qPAfL=2UGsrmn`D$pU^C(RD{FCC93JI1B= zR&bJ;wJ0H4D{#URj*zL&}aEDu$eTGL}Qn8W#$TCb`dg`$hw-~bzf^dXq+$6$^Dj2$!%|B39K>HQOF8Q zjWLSDH~2hntM|Y2)S*k|<-vAn#@GJEiOgUB``5;Q^DHA@7Ec_U}UX`2(KvE`VIcxDq|3Na7HbIy47bg>Xom()*tcrMRZ(41Udhb zb?fNNv*Pcklmn4p-|76I4PQ40-sc^Ag?6~Iht8{*pObN_O;HPP0;rW7mAcTX= zk-&x`qS{@1J!`zhyMeaI_V(KKT8z;W$F0^oICiaZyVJ>gAM#)1g@rPJYUTgqC!6rh z%Kv%xoTogL z-+FulsYmmeE%#~4X(jBeCIQ5O9hd3p_m$4X-+r5&Bd!;iCGVP$AN+1ko zO}!zZhA`(aqrPSC4X>vT?PIx;oMiD;J~xx4S1dtGRbHipK+fzcMOiugs}|uO6+1u5 z_j8taIt4H$8G4OC*&&ri{F77{FXRnBP1AfaHA*tUWVADP?_lr!;N)a~aH>oV?+y+R zogd-Fy@NCFyVmb^2moTL@pf*>UO0&#p#I4Cl59*qt;r5tgjf{^Fo-!$neg3 zoL%L_Wd>e6OuV_dzU6klYH@XyR)X71rm1z;{#s=DUdl#{IR6IuXn-X_L%Fw8b_XOd zax+21rwMuC={Oq0=&1t!*^1sQ(3lJui{D#ML1!A2WpWD-yFE9`lnjJq$vDmlKGMnb zq5&jF8yI)8c^?Kr@UBAOTEu;km+4hTn2J1)K_t%8+uR#kX~2x!5hvdxh}g>%$`Ell z-bA*!vt|VkKkoaev_uiO_u=@2{{PY7k1n7!WU)xgb$n$F+k$lG==Rb2$TLGK9vX$_VYoa|(P+5W zn*w}6M2_*zjg)rZK%4;nTZ*o4u%F)~+Vhco=1iytex_uo9y}I!*?dtnlZ~!$3L|qc zEn-*(L*-qUOGu#LmVkeQDN{hl%U-sCyegG0#ip%7T6ivmsyI4J(`X6qje%P<9Ws9aKN4*)X9 z(W_ztPKS(FiS9#msN9jq?cwJ0KjB~OLukYLlSfaUZ#>y}_MFQBcJ@qerCTA5Zoxl^ z*sl@p$_11C-|NkEtXo>-NMi0vo^A4oG-c+6dHg z%lQ3bD>CmJ9>?vLvao1e5RHTp+-9)jOjKM${;b_V8t9&7o0ef_Uiu@`g>l1GuWiTK z%w|KxDe^!ottS+oxDzD979`FR6ALApKD%b9v*7!11bcN7pNylpRRwOg-$-%Ux zPk}TK>3%2S+IC~AWF!>0ly8diLqb^1=!!o2hW)yj7q5!pqLTSpOad@@5xwMLKiv0u zlicccsL#M(EhNaNSRCQ#|61&sS3SPtdMo`;^63~gR_H|hfFOY zeaQHFF8Hpks;)B%oi;N!VJt529=<=y=0KYkg9YqD<2B{3PS?69D|J$rKs0KN2fSg? zW?#YsiCDe7U>-n?AQK{qB(2dV^2-}yG{`gJd)EqFb@^3xG-PZqSyG6mGH^H;LHD(( zbITnXqB()XLIw7pOyJNqFP9Yt7xH@mrQ25*i9}l`S z+l!01qq5^5KDNtmB!PxNxA+@bCDkE!qv=<0iP0ggch2kL0Xv;qOn{rf!mY+CK+Zid?WJCd`hNn-Q4f`U!+UAj2f-HWYO}V`;Ul!uqJWrhoNPQSY_wsnuia z<4}*AQortf9ks;i4gqs(`W>Ys;8#vMf~SmFGpNCR+IweiE=8H@tU}rrVA|@SkOy~Y zO0XcS5Z_d^gf_RPLL2JeVg_oiX3Nb)`-0pVX|$&-H!nepi&wGIwV?not{UqAu6xt) z_al>4rwN^$AUkn9d(CaVV>>l#_>#@sT&kHiYVI9IRoUKG8d&7zXm0S`8*BOxRvKNW zt%gilif7(s#kh(Va!ZT%<#CL>*3#+2)KckQZ8*CeFb4UdFs`I?&!KHM zvq3EPylQ17uxOjGx3fS26Qe_05&qBj-@dR6DlJ6+vd4fQzq?ZK)7c!Lr(Hl%~6eX?8`TMON~lB!HNw)zS>$|N`e}7sM)r| zU5v!N`&iiaDy|)v%8%aPG}!mcPGeaG1f-YenDt$gJzi>dsm2EQjvTL38BH`78k99WvNQzy2z|dXPCGpzPrT zZUwhy*H%doaN6DO5BC9Jx)N?;v0~xHKS9K!^Dg(_bHB0J9&DwQMKQN)#87ewGn29b z_|In$8B}H(n2Z2U)>W`?D!VkOfxEp$d=~;7TxaP=!a|N6r7f$-iRwkxV`YjA_K{+L zG$Fppm(Qsmi_++|giqlyjw4udF9FjMy7slRq~|j}qwecBpf|a{d>r#l z_3Fl1Pu1Gv6{0JAB=QQbjX&#nFCcZ-4f9T+Nx%o{K5?B?Bn0vPCQO-@F`(xWe>WT@ z143i9?FQ>b#n^BGF2ifZN=2z?V6k+TySzA($$Y(qGxa`<0$V9V+pa|gsk~)C%BZf3)!qGN5dYorA1ouilLp*` z|M2vgo&Wpn(dJkDhc7z+*OS=WGQYKx2{?g3&Yqdw_qv#jgo{}tTUJN2BvtljpCKjt zbh^kPCkakKq=qt;nVQ5jZ$_Hpd|kna*3p2mS22UR*rHNz<;*ach6n5pPY z8JQ*jwLog|QIV37W(;Y9@RZlqbH~gI43=g)*)vj4)iBk>2Y0NOLZPAFllV^ZA5B;f z;U3110)_Ffp8^f?fAgt>|MUFOSNxwZN&d$;(=p6+$BUXE@8Yps0S)ir`d6E_U52@N zVDO^^IG>DM9@&Jh15h)4B@?gBvWQGmeJK=Y(`5p=NoK?KWHABKn~u)2Y3lf90c^(j zHpE2atHv(9A8D6cwC1Yk5#$70xQX&&z%7mf2+))EBgZIiaz?w2dZ0d52FR}$fiL$z zzGXC?J)T$?rt@A!G=}B>=A%bXpIh?(@#f~^Cx3AHe-8&M(pP^;@*f-2SrW*{A^ED)svF4r@Mq~_>7&O&bmZi9N%NrCbXMPH?(3pI?L@!iYf(@~M92h-VPsWZ>; z5UoH{y{OXa6+2B3q4|V~aDm0&vMi+l#P%s0I)ry!3fHNKAKiLBzSF(>-av!kV&WtnyFu9Mm(Yq5Fn+y`J*H)J zIVrH#X$ncl>O_B)L4NT+>5`^rY8YQ5^J&9JUfvy0W%SMOrLW!Tng0eKg5cx{6wq@? zl%;4mL;_#9I}coGAs+hJ2hH<4+i5l%OYe% zUV2HnOpJA_%E|HZM^1+Zkt_wvRp~ah<*=Wovor}#{O8*2FLbo4n@7{^f!V_(Euo-= zKbfll%@Gi&fh6V$u2QjM7{$8hlIGUJ(NT=A}FxvKv@Z-rB9wqL>uq4+WZ3MyUWGRIvNs5aw8i ziMmd6?{Kj;v%T>lRpz`or}!qEbT!|iW8iD9oSUb7&&(7uO5S#_@iJarpo2uCMJe7^ z8ln7A+q8OS5nf5B#YcaTkRo~jQ25w>{4V_lta>CbQVZaMEyDyUNZkElBTgYc5XGEu z>nn**aa7Vu}|4X!hg`AGJR*j^}#M(I4sK)#Gp2a#{k$sRqKSF060~;xU_b%;p@k ziE;vDV&Z}=OdwQYKbrI-rkj`j=&~2kqIiT_(4y3BOusI24=d|lHZt4tHI(R!jsM{q zI&oAqx(XWX|4*Mjw*9}4A3fdp>i_)${J)N|e*ux6AfNlj(jO=6{bBqH|3`)p|F)z!tDyD+94(tZ`RV!XEM3jD zzO--RH$bch5kw0P*pA6z9VhJEU{f)`|B+EMVa^;Oq>ct}_Dz}`{=^LuD7B~=?n-di z7Wop6@SbXvcQ#9hUA+iyl=rV~heQWbdw8b*!AMZst#3Mw%qk23O4S$wPP#rSdZwaY zKHH_AZbj2i;2)3)jaXsa@So}N+w;=Cx1He~B!*10!|bw5%G>UmoanWllOOPpO63NW z{?SCu>q*6+2;Fkvz-P7PA(q_9i(2ARyR=^xY z@?pfqPKc0PtO7O`zX)AZYM$-I_!xIEq~swsE1BNwQZ(7HCmjEMy;BQ z8Jctkg<)HbmrPs25ribl+t+nbN$OU-aHF6TVAU~`JP{p|6ENPgl|`9`oE@MN7V~V9 zTE=kN8gOwdZgR3_W@2fooWXff=EOSAge5NHhhRU11S%1@_?op49p$<$p)6(<<|D~k z%5==Gpzs%`0AS*)n!{_*n7^I@v^+cF(c!@U0Bk^$zXjN>)g0-o5%QaeHgv{N+C;z% zCRjD*lxs(Rg9Umja-`i5-MUJ#MPk{Gi29jq7>2zC;PvkcKL6y|U;bhk&=;8?d-Eno z$)Aj4sUI0?Li}p*^7LT02c+i~$S&WNJ`)}_a4;GHfd}e2a_y={u{CCCPHis5KAJZ; z#5qd7rdB#z08AC?T%=x~Cs%ly?hx-gGKy%OF)|;Sj*ymfmHcRd-MdFGoh*>AC+Z5} z91Fl$-h$(CcYU+Z&i9@ty-QOBlnx-~oM}so37n`kMJ=;7^b9w1nTRf#xM`Cm?021(j z$MXVza?*EAAR>$NFqjZY>5%sUN@eawt@HJ$RGoW(;;GHF_1tl4R=xiPW3&Qgux+>O zlLjoEo{ztx#fly?O|R0Tp}VF&Cyy8nj|()EiDSrJb>%;}%h5wO8fP%f%Z2;+ZJzM7 z7_{bJ*n@B(+#X!}(mYal5XWmFwvfiH`iDEaFpZy2xtHJp4-4{x1rCegg_ORf#4YyO z9u|_0f+z+xCz)m#XrXZ^GK7G6&gako-Q7nb6uQEAK0g6h7?LKi8`?bhnh%koMq>rn zOum@yf0|`wI(h@AArli?OlZ($(<+G`G*r&fOXi@pFWYx<5Ba~i6Y87*wf6s~8&5Y~ z{@=~7_Wv(B|F#W!TZcSr)iO-5TDnM#`ORgT z?@ED9VN6&%Co5Tqv~ zODc;8cUVj&DNz>2lKwPAp=P zUcwFWcNEv_Sz22btMheIT|-Ej&xMf^&Ydb%Yn!Jioj}%UB43!Z6Q-h@RJ3Bv&g#o3 zRTSvN zHaJ^(HBW;bk?3sf3quHr(#%l?f$60QhO#X5UY!St{$`4g^m3`E)0TO^sSiely z6=oI$WV6A5yj+2Z0G4nY97~q_!wkq1Z5(r_Se>O%hY8?saI4j;xybjajHPsq{;)7M6*@) z7abQBSPl&W03Mg`i9#t_gF}t6L5ZOB1*G;n3+if}b{slmY#{_|@_0oA+n0_xH~g2ZB*Eufn{R;+?kT z`@tL;Gkm@K5jk(+M-v)2Wv8DACJdd`>n`60JoCds^JHq6%3tEyBzj1tM;3vwV}St+|AZZTk5>u*#^w7aJhihG0@KZv@-`FR30b|OeKGZ-n;1&UmcbUA=}?JV zTc~v5B8V#t$pwGa=e58D;8d@YZs(ZDtd-^;DlK}mdFnZGw)I`xWwX+nfr3Me1}Y9M z9CvhD=sY(1_HpjH;DqcxG7(n65vlJW%kZN{zFoia(1zQ#XYCv7uCtmzcXygVU_MxR zc{;mkFrTMvMG$M;^w@&V#iYb7p9zd#6p)?he=tUh&*85pf?! z1K@^d+8ZSp7Lx@}SS(=2l?bg2*$f~H_!SNU8raIZyyaL@bCr>xDQ(~+;YQR7yw{}H zpbLBFg@i|ydc`!E>0~hce-1|V3}oIVVRNvw#Y{EBOzz;S>IMiadi|)0M5Wg3V9+Yn z(Wj6g<1AAEP%}zDp--k>FXr-3&YY!1iITL~F%rGL>TCvKf#goHB#2-Q;UkEEjdZdS zdzU;WBD)mephXaipe@EsP_}I__67=o#6YH#iVsflsHpI0C511^Z$GY&Lw zHVq!iQmyvz6R6SzDvUKN>%2H+TC0X%X01TbY0*mKE_)oS%U4{ok}|g!{kFeZ<}V9)?Wqsah1_4ar0;KOVaE z8&StDwzrvHNU%Nyl3n?Ba>%&h<+Y{C8%pe9rfAJ|(f^g#o#kO{7rRu;^2S8r#H5ix zP|F1JrLHFW{>|n{=rB!gqw5R|?NK@cYkyK1QF|mRBw%5MDdMKpgWXVhv0242n?Zn2 zl3VryCWOzB@BlOqc+Sq-VTurmdPUc)s0k<9NGQ=QPr(byqXLiK9v+GmauH|<>=IzH zh1iV=E3`sT%Nv6snhI$!T|onQZ4lMRegO@%k8qEufn2ir0P`(cfByfD8P`bJ;T%YP z4*Q;$b&aBvlJ?-_;-nQb?beLplavmEC2h7&Z9bQ2vAOU9)8U`^Yzw?wV|#gj2yaXY;a#)CaQ zV>dsD`t&^&9DCm2kS5#CpZ^>rUkaZW4F`rNGgwPX#bvbZ9W>o8rAH>|tH&BxUoq|w zK_KlrBud9)&;lg&;H(5%qVzdfXm4|j7KS3DZHPmmzG;y49*Z;Uy3wDGu~91F9Xp`Qmdd=IYe32wDG{Dzn4;}{zSSVgoU}27=T1q@rYsC!pTt2^Rpdze6FXORNzR$BUM@=D_uJ>O8 z45y#qUdmK^yiLIA44;V6x=H9QPLtqviq}MK=tHo~B|8Xj*~xG@zd~FL%x{bp9p-aspsVGH1%m^C9|uE(ZOSZa8vj*T)%Jl&F^SZ<_F7 zk9sktD81@rQC*+%5fKYsNPj+3nlw@ez^U~tK6!ig+KuK4NwuZ{`1Mz_Z8d91%%=fz zasgap7ky*J@b$$O!bhfzT0~HNTEaMs#>!+mk3UFF>rRj~$lw2-HO^LX=tT%QmUgVS z+eOtuzK0xYxplbiCPEt7+E;obqV}L7BsMj2I{_hAc(1`8RbW?w1WQc}D&Qor0M-F) zMU7+YKi`mPu2V-t>4R2t5MKPUJ0uFtXY!!%QL!nBFh;65HoV;PNGUdQHF97IF63=- z(`mJtTnn%>h)XXgO>?tG)!z*BJPb6|(m9=x?>HECpuwM}NCpSCKl-9?_0LgeExpmo zqw~xcUOpEUa+u|o00&iwqkRecC_7~*$t>!oS1}v>QTiS(tM4gFDJn|m=$&NdJH$Uo z8>GOf3RC8YiV&4w$}lF099%4F7~px$=t9O0IH3!S(s2UGGsTQsf;ktSAR%JaRwhdv z^qbvmAOvz08_~*I_jEm2{CI;ya}8(*RZ~vg%P!JWgeh9$ddPE7JvW6-Pf8 z3(Rdpwl-1>E{g>us&X=isX@3Xh~PN0-w^Wq*w8O39TkRb^cUxwqS8<*q1Tt0ugdHH1H`SbrVe)9bB`0?fDU&h06`e^uM^kn1dU;grN zl3mtBr1DC{3#ctW)anx9O*W)86!53R&HrKd!RC6?V#Fup`vEFgzu6+gN>Y2ZA-`l3N_Xx8CK9@Jw9J5G3 zGftT_$Yzt&FyPSU)Wi|tUUeLOpAeF6$!Nw#6qf@BSmgVCUCNm1CLyE#6znrx5;@X~ zv~6KagKtQe$3F@t{_wA@JtrGZ+BVKrPq=>q*hfF{G&#R=;b+6EHN-R3S_;0l41vntf;rAu7&%zGJ$^TutKXva7jB}YsVLRld9{K(VXam{{eYHK3wDg2 zEpP!CHfo71%WX>Nyy70T=#rg$3RX^zDto{o)?mke61j0Y)PfkDRr<2EqY8uoBH z-X^zkr+Evtv{Z@^Kzk~r(K2+Ey%BDRUw;j5m>Pp;?Iz=8*O1YBa1)tEu@5kRdY6nf z!kw-Bsf>Q=wVQQ~kI7VK;RX^F0zK~1y?5oR{* zbIq)GZK2m>oyJVRkQsV=yS?91wdEe?i{nKbJMvOgpE01y0OrBqDRUN z;MN^ti=C4_9S7At>guPv<%wq6&wbO5mYAy?HF0eLU#7HrB?2NVf-XPaiU9LM3Lf1( zC+yLJsF%D~f7XoGa4#TqPbQjfIs9fi^7ltB$!7P+>TVFv`U|eXpwI!V=HtENE#L-M zB0kNF#nm<58hKm^`Rp(_dpZRx{gB5W4L!`)zQYVcDl>rUwpTt;WA9S2ztoCQwi1;^ z;S!_h3Z?@D6&63nz58penTDI#TWuFrf--J4z&azsZ~@X?{{b>tnzD> z2FF$O$WBJBh(P?b!Wez zi-B0~;hwK}zxt4pFh;|T2Q-|J`f<-95`Mv*Rd9=X)R!Iw6d1u77yIH0?sb4B1E0oqgZPMBu+Sp@pkyxOVUuJTg0;{6!4CaW0m?Ua09lx zKgrm7+u&Yw-`K1}=v%Gzflc34K><-HEtGMTOkwG#;bfs0733asNn{`h&(6{UArk6i z=mbO}BEDX5FyDY-bvtbIkUjy=MD(JAHz&uZ=Yym3_pgpm-w$?o_s`A_ULG7Cod5j( z==lBXgT4Ltd;4d*`$v21r880r>OLC0JwNs~#_GJ-0lsPvobTisL!SNkyy;e=e{KuW=`@<~3| zht{tFQg3-ImiBhnKRSJOEf3CJa6EXV30c?^COIgcjU7e#x^90I5WJyfV=q@Gf_-A7 zSEHd`vXTDKX{UpJZZ8@EAV&n}@}pg1{#bii=Zj@-BXR|@ z8vw+u0+MdVmj!_V+QP*&7)U=a5zwm-WN;EXrkEGeR~n6-*p)R{NNB5ZA%-A@~ z5x*k-Y5(->;P@y$+xw6A2S?}dz474v?lG$|K6Y3Fbm8hB#=4%$Hc=Y8`+?fp06tOxyTc`~qX!Wbpfna_K>Fr>5R?MooEhY!)0C~g z%8j#5kqtgd*cDYcnx#4Bz-*?4B|ZaJL0Fv#254=OD{4Y>=NAhk7!#>uVn?0v?E z&60fv-~-wg^q|M^0u?h3ULSz@`j}K(5h2{TpunD&HI<+~HxhBHx~gk>E~mAG!;KYd zqN{_^X-)2%jiJ=qzC8)VWDaQ1Tw42%N7YgsO>+t^VRuR#jBYPT1qJ51nA*tZo&E^C z1Pz9F;D7zEXw6rP4E|-Ff~vp0PAKceBS?6}63#889mwuE>`mAk1c87^dfdJ|xB!iH zxY@-P8?lSdq(BCE39~ePDB+D|zymm@AHrPim$E@Js#w@6m<>u>?O(5ae4A zP%iYatfa!YAK6ff%^@R8nN+|Fc`GT3)WVm|VY`oJ@xg;WY(<=|zSxv}KJ0Wk*5q#} zt>MDlrySQispc0nY=Zd?L6KGLbvBHU000sF79vhbxZl}H9X)>fUEEP2*;@@0fMGNb z;SLJ^%!iv#ABu>Wbvh8M_06X{*OTou{}TjA{bTfDJ(*?Vp|g0L9igos!1D#?w9#`d z!&>`_&S~Em4dzw>(!p#K17|r<5D<8fYQrx^RJ|!Rc5Li*i2y?a#QWTlB@q}2l{n|( zs%wjHeVds%s!E*M^+|!|dtge1jXU7{oezElm_N;+yVlX*2i0u*g7rEz7w&J$_8|rs%dZqhYg(owZ{W zdk79EP#!2WkNr!Gq!UsFbd;EQ%yIXljrjQn7Qt@VY^jS~+>b7Niu(D%>EO-5+1m?| zIWS7c8KivQrgNvR*C3QZCVqp)ZMJAi5|>lfwjmH_Q)MWvV&7fS)%Jte3N~5+2fsaS z+o8bVvqP{Jy*fR9^Zw=W`T6miJD2!saL)xE9G_8nrw2d2KEJ=>4)hWF(`S#Lf5rd&g7H69Tq*5}qIBO7)K~G@ zVg?Bn)nS&`AcYnaw-EUh9i5*)Omo)z4xg-_!`8gc<%;4Qs*%n@iejEDfug(;2wsj)*Swd2eTO&bAD%GJyeR!wEC-GVx%FF7X*)lopc-HyI0t%{GPU zfU+Kv&Nf9<*U1bvYRT>vuaaptxo!W|8~)ET_?NxSn?J|T2AC}YDD2>Im^8t%5@!^` zny&!5tBsM0Z_(EI-HOy!sz6)cy=JHL3Z-f;1P^^-%R;bGdnr7br~g8TVgfl4acZj~ z6WS!@s-?c;m#deoIaf#zZ5)81BIw)Gqce?y(*Xmn(gZle=~^$muNVr^ z>F_J9`1Pm|&cbNRd==s>te5w)(Q7a-dfqha;zf2PH0N~+bXc5;xEC(Kqfkaw>9sa2~4ZM zt&o3SqgQ;l$j8~0f%&OF@Wb?ieuA`)1gH=aR$v`y5NF=@p{FMu?Rv~W5{8#8%5r%9 z6GG+fGNqf7z67`CErx2}jDL@fhPM6bmN3>_@TAbMZNMZ{JEZ_I)P zHtX6~Bu21ArHU=sU;~j)z3y<17DZ8o48a5x10%~>^P@YtZTQ^r05aq0 zAw>UHd>%+y8ff_ICyyvyd-IErHEDhDS%DF0+3$DB@{ z(!WJXBobo(!#0HiU+k?uGfv6DH_X}lLBEQ2*M0)JMzMc5X)2m{%f*mM03NXSlLqez zXBWCTv}e7J^P#@|>(HQ0NLn3mSTW#mu>%u4W(Ol~p2IJ;s?K4P<4Mqo9HL{O+s`c%WJ zY6hri7;Hj7+950ena`3W2&v6B`&ucFOoWCB7yxbOJ6< zJ097%**c-XhuVk!yfovAtG^z<8JzFFet$AJ-9I|-FZu1e)4|F6-Q%Nk@3+!l4NH=3 zxL1?p3Y<7)^BaG0{^V37sgte zIRSaxSF!t2$ApM=0nCGB;=vDWq-jla*+$@m9ez-H5+Kf65GiVJ#y-e(=LYA6L&pqa zN(<*88cbKpMgo77sbm;IGD_!Q=D4{|FEAEL3-M zR5dj6U74;<#OTDHDdNS_Dnct4H0-Zu9IAqHtk}yX=w!3SoP)9oEm0JWG~^;7lmowT zqUd7GeqbNNKBMAjlbQJCMs5pF1U*Y9V<(s80iJn^umaMO#cu+A*mnum={c1=(|>j2 z657I;8-%n*F?EpUEnv+}PEkpl>;u8HK+QddN*&8w71fS=07V>G)=!gBwy5YXWy%36 zv*zVG$%e+3Zk4bF#4b{Eu%hFgg&nczLEG`ly3kk6RiQ4aRu*@qZAGS2uCGVnkXWUw zA3Djj0GFDE%lJk3&WP!;M2SRJbFD#L9*Dx%LbunCC@#q!{Te0MH8HKA3G0r%Wi{bd zzYRxjVk$|!Dq$W0%j!Uebqv>ys|hU1x|C{_F749+n(B0P+E!|g{$Cq?sVV=aW2Dq( zJh%UnA7zq&dAukIa}-rraZ9&QpbT(MiEWh^1V5<7Vy(Z3Mhm8eL84Wkz#NLpDlPGZ z(So9!Z6t4sQpTf?jZLtLh_`}TtY1Pr>y498h>N{`^AqGDF2r=lDCyPpdJzDE*6Np zS|Vydcg$SF@K)3}jthz9y{P*{qiF<~Cncy)nuTFwTyqDr%x-?`Qa0D*g;fAs8?Te< z7%iw3#Mg8&`LA6D(tU4VTFn+%n8{7e^@A}}d> z7!|zKKf}(uMm_twxT)ee-qCRMZ!nvc^EvR~P*b=eh7LwDoEHZ3M9p=`T(8ZZJ2P zS^80{-sKMY1E#Gt-G0rrN;YA!R%OqS#Bv2tv(RNhP%j(= zmJOK!kc`n6ry3{~Vq-o+1`-<9#uPw-H?JYOOSQ{R94f;|8C)$b`q5uD{?cw)aTySv zdK?~K+)DTYQL_6y>$k#aHQCf_7VZmwp*>&yHH%6CE# z1L4V~ato$ZFIIeoWtZFq&|2(aL`9m6;@S&oLa>7+{B=c{?5Cb}laagQt2iRhH@m{#FyVn=&)WTi4-piUx;_0r|LcFt2C=d;z+@zC5mUwB9JxZn%6QC2 zn(V1ytib@fl7KTtvBR*5_8npLfDZ^3l6zBSp6`SceCUJsO$Doy(v4JSnY_y8dNtJ? z%*O!sm0wi^Ru+>XAMMI$cI8tI*H|cLh(WG;A?yB?5~~?E-?oP^p6;c?qCD4PlrHt#grh( ztCQ2#YYu`lTs;Jt95Qx5jFb%Xa7b${Nz>>FmEu^*Om4B@c@yGSv}RZ!S_HoP3FEM| z*O_9b5g!_|ZXd}ktlEo`BALORWS;=F0f=@nzeOL4a>ourSl)HSdj3`?l9CzzH;Cnp z|HAF9u_H(vNDsunJvjc;6B>(M?(usIwiHp3<%n@W5B>S4Y_1*$5!r$Ts)4h^b(S;W zE@f-&b=;~nrH}`H7-MMEOF3rt7hNyPPv4LIQ(4G!CU%gm$anzL%r4osF?(_?+jeL= zyd%+SR*hxwbyEMuld8|gDs$?OT-fqiF}|lW@r7xrx1WmH(W~-j7j{i-#vV%99d9fj zzHd#b@XToLW;kVyl>D6@JDD+)(TR=P3Kif@`h|j+wu`6jPRyOflq2bhDWv8G0F%X( zsyBV2T6h6;L{;4t;6aFo`t46@n-DrC+`9SrTfYXfM!UU^C#lOSEVL@a{zjGb@{83{ zh$%Y*i017GXpDl<y}PCB8$R>t%r_5(v)2c+txLa8SaMPrt)euNp4BDGFJ2)(;eifNcoQ*&09A{hbk#%PEf0s z%N=Lc$t0Nr{1^wVI{&H51B)D`(rzDQ^JZ{eFn{iP4J@k7(4v|qbB}-?_`ohoi>(O$ zv@97190gro<`aY3Zd#1kia@(JWnu6;xy?~qaQlRQm)5I+`ek}!#>!1@qqO97iS*Pa ztK>~mu|RYy7U267v|Je`lVMg47gI9tR6W8e1v~K8ofF+2J$kh2=^$_|;+JQS=Gcit#4kQ{sinJ{Wp3 z4zNTCDk_1JkO9MYZ7m$`U19vu&7dOdTUTIQ#b>XN-@PB4y$8ydV3!*_$9&paVS_!;k0 z&gO}eHsi%wZ^w$k{=PZ2I_LP;!-3}cvAa=m=wPQ9))HnQ{h8pQE={4 z*oKvJ7m@CQtkN_cG@uv7TxcG$ygpgv$rpgUO%S6Ec(b8bIU*8fOvv9pmkd=#S9lwq3O1U2t(c30QI=N&XVKfvlLY z8VnA8NYR398Ai8E2!aaClEDxDnE5804T^lrYGdBa3VD>sw$FDzFWet_+=CWh7ET^TA_``=>-f>ar zRRqGI?i!rwiJF{vAaguhv<6^@2FU+^wb7|wK?jBkQ+3em9CSN?2C+5d>$krM zSPcGRy1qhs&5GqxUSAU(J|xE*&j3U735-h3jYCtKFkT@rAPqItLMNCF42!8&t!xlb zSU}_Lvi{sOH0tIB7ka%;9@|>EzKGgMo2&tR%(hV4fMuWw;-b&3qwIc zJ$ZppGEQe*0q+KZ3m{h#c6$Uag4dV{;0E1rg#npSKPK6PIHb5VWT=w8W*yhK8rDQ1lV&*VPp(#xud+ zRDdJL(?&SY)TFq|hOtZZs{k!h3^3u;x0tX>tfrK4#-7a5;k*)aNecv-*JzFbysjos zth|UDl2kD1i)a1zz9#V8DS^kIgFcu*X5)EE3FsAKGrtHOunX`MPwoCr6H{`zZV6mFkB)^})*1jt1M z3-L(M+AK=eV7H-N4}grwsjP9YSVi*$zwnS+r-cP!YSTQMCyrkovX3RRhQ;C_e`JYbhA9%oFEI|B1L{2B{*R`M~21&BGyrAEWhaWA)XhY&t zfWs_9Xk!#0kT;D2Yaw)B|V` z+qiQw+0J-?D^oI9Q3{P)FWUf0?ic|BD^{eHsMuj(Vwn68Kt?c!ay7<;H;7@*BDxa1 zSH*;+dmrMW@Gsq_3YSyy2Kpq#JAeK&YlPTo=iJfU^K%1;IAe4I(4Dy(a3E>DfFSbv zZlFcz3-1NSu>bM9LH2_`a6d@Si=83upntp_Vo+;k^9e8k%^{aMOuz+iiEM=p|KnFo zT2pdGV{xObOoyB(yG6xNGEZ{hfQH^z-jV0R*FY;*f~Q*rNyOml_;SIPYu?Bp){P`4tfs|K5RDI0i@(~-o3IQ?Zq`c`=M+$9z$Xd<9OF{=aD7>;D~Yv31F zw{`zV(5GE?RDJM7R~+Z&6Cb3c(6D_BGtvjif$=L3TbN-v#`m-!b7Ctr;~=wn><@*k z4M1dH)x;p)ODtlz5Wx>mNf>IGb71sj!j--zh!(5VfZ5|tIL&Sq2~nI%vf zpj@z6`nFx6(NM~MKDu)m_@XJC>@qtJ92zJv63zq<+E~Y_zDW^)y1JcCF-Cz0$U+JV zSh&zY5T!XnyG~)sqU`w0iE(B(88pihHUg1PToz1)FDc4`eLrE^ZIpp_g<%^21f6OI z%#>a2K{?^=iopvD5z}^6C3dKCM}jw;^23Hj36TOtPJt1K8=X-mSag}rZ$KvI+NVQc zgVAP4IFn(~#F!Naf3al*ZfC59L%NU$_#^5+V|Jzil5d4Z*Bhw#itV{*3h3%=y$_K! zgaG60nuZATus($}B6jSOHNl6pESIrw!)~+-$!s|Ae6fYZ#)`K_)LSSt>7*<^-A0~l zA?GFCGCv~i!c3zybPgFO?(S##*Eh+sjM<3iQGH*|+B0v;)TDtgPn85xkx6+3dXaHDR+6!9`z3;K zujPTF)x3{*xSSI(Zm#X%W7fMi^rb~^3pyUi<^i^vSO8cyAXaJp`%*{10^+MYn|qsJ zU!uL7aPCXmf$h158~I&Jad4SgtaZFw4V+ut3J)xXr$%s>&AVlDZh2fOI^~wZwbhDs zPdHWJ&QvqTzSrtU*-X3pLk_dyaPkiG=MaOs2o~9Ku2Vb{6QF_UmG&g;ZejH1POOEZ z%5upP(=m~c#NB_(XZP4?WO?V|8lo3&Zn;cUJqucKpq#O6hOArV=#E219H?Qun(0R5 z>W7N{T$3D918+oo$U~IbWEcZoLX0M9qyl6VP)hMbP*63+LS-o=Lbqkt-@b7nWTjp> z(K;9WqL)-vS~4}4DI@78wiyIPBP*DK=HuubLtP<`-s~;HdqSI7lh!~7DQ0UCTs0D& zPQ-K--kFkcVWCS@VMdo$VhWEtu0^mGRLXNfAg%Vin8DDZ36V8NSrGZkG}UR3gaS%O zDu(@~UF?=quO%Yqwqr5Xz&bs*Jy2<^qA3`eTk-31_0>zPziZ`uvR0>F)3s(#eG?rH zem;JC{(kS^bf2&cPR|d0TD4o(WM1C2uEX=wdP|tliOWoyYGD09=FOstJOOsfT!1UI6RDK&}X>hR>na+mszi2p(Ku6YPpmrG_rd3H=wFSjEvA+WJV<9lb6lmPC+^*!pVf7UkjI4~jc6FEW)GM*YA)J|qa7a=I&g>8EbSCQj`0e&nZU{oJuwyY;vR-}Y9wk&-id zhf)r0kzFG`=Wx(o+8yM>y(Ny0I4s<|i;MpJ?IG-?boF$jV zM@sU`I7>P^-wY}Cy@_#Naik>Y5=z0~1zXK<~M! zu>ysT<*Uav}U~?t|aQb56$)ijdrO*&L*XLEkTpmx7R$lkb%FuR z(XG(T*l8BXM)rXjtzn4;Ef}u3SUpF<6QF0IG~;>|ampN$@?8JzAP=4&cq*yr8y|m) zHlVUp0uWUn29J2NZ8eI#72_j=BiN0^j=*{0=Xg;=!6SJC2sduDF9D#P&2J}Z4FeN2 zCkzm?5q@N~zr~9*2H_XEViO{~ANn0o`&v+GO{j8@Bu14(aMBU{1nU114>HmNm(6 zPt@IaN#TYG$6{Jdu8f#YxJd$V*2;8U{*w^<(6fW^fq3WTYz+c)Fa0!Q=L5h`u+!F3 z9RPYj`+R*=vO_u-Sh$2>(UoYDD5EI%y8?X|FT-#`&eHtha8j6PwQ$ev{X6VMc7RsE zJ(h4bn^~5Vl9Om~xYYhCtsuOg7~SE*T`kIwpg5R(XJpus4cix8-1ZzA0#*?~YGF}k zV<7h3HezLDSdEO(la@K!n3m_N(zhpvP0XV2@ok?m^rMbpDoXiV_IKw+@_-RIBK+;H zNqFJG5`d={Py=>bImoRm{H(>B)!Jf zoI$D-mN+MFYXW}6ipVa5vIq3DY##fK-S8TR&i2PN!r*A~h{-K5}9B^2tzQx|c6~6u=VA~QG zAsKy;@GIv$ox+C5Q=&L$cR^gn_7zawJ%h#5jg)K;bTLdSgBZSme~6@*5m_Fn6%~8~ zO{s$6o)7$y)^)L+foK&A*s3`=Rg@#F62Z@Dc|h7Gx9;WSP5c5$>Oc7Y?rqBMZPXA6m%E4G7ix{`K+c!T)4?WN^6pzn7Dy-YpMLltALhs!_EqYoi` zp1VA<>LqabQR@9Dh|+IG12_pR&>$CwogMt2Fj%9v61*R-s-DeFz#lysi3gjK4h={G-XlsIcnR)MMkf*Ot##q1hEJ&W4?O;o;WV z4MgYNC_<}gfO?Lv_o?`njTGjOu9?!LSA-N*u9eqQh`Qu=L&;74f;9`twrww{tBgz! z-Ld?WYVp_RWITCHCTvqybtU)eS_8L#(~__iy_ysztfATIpaH>C1zq}wG8N0|oFZMa z_>yEP{DvMZL?t|h%CKz^sw88uUACT3f$V0&<6i;ig#W2vON`iTD{V}@A1GIOH?VB= z&p`Q}3^wySe?2~o7NcA%&-q&ADq9oO1mfZ;)yQym46^RzxoTP0udR7&#nl_kqw_!T zG(^S^bls#zr#dY_LFGXDPF%|Rx2avb9m~FGrTi;dx)nkU5W?5WWLK&7^u6A4BM6V( ztZt6k593L3gA^fNiUatsD{Y|awzq5f%e9N3ha?PL)US)m6Ji^W(vRG;!UOI+??7Ak z(vO__W{+Lfi>t2B8(f2;56&1pR0_zEvI*BJkB|0kuZAbaT!W&B_x4}D{jujx?9kvI zAx$zcL1w_AlX#+s5C>DP8q!tj%CHh!4BIEGoh{&LQr?n`gn8Bo$2^xgv^af)o2`V1 zu{dSKDgx};RIu(1=4^wp>wnsnFgS6-ej{t|3AKR@)yFDL8iAz_@pEckpBAIVB#o0iFDU+`A`quH z_1+wsywyNTzBBva{AGGDw1u!IE60Iv0PxlAA=kIq92WMx!daNxS zlzFQoP{Z{V@O|LR7hM69)q2E(Me!rk1lyo_Css9UF$4?ps<$HrgWP(#g=*-x7|0hc z$CPo~xPlO&Dd9Zhl?ocBX3WRHhID(RRZ)0ZSC4evirn4a3$K)}871phC(N@Q6Sbn@ z`lT7perjzbLuD8pa}SmX0PwinyprGbx2^5+Y-FM7a;Ms5ilNrv)Le_c3pT{F!9vLR zpRLi09djeLp&w}`QQvAI6StM8NPL5h(}7Xy92L(vb9S^8Y3*cz1*!hUUt-I2klz}N zhumqczueW!3DCCcT|ql4HLxGK-v(0U*z3kFkoEk)MP{3k>DgV8Km)<5r70R&Rl5!Q zL9c*Me$bZPP$ds+S$-$hZ91b(9&3KB=-QPRvBqJ*>Sd~$j|sP?iu>s`$s*MTzkkIi z1i^`0k+p*Sbgzn1+>g;yg60)(%ItV95;)NKM}b4jEE<_tTUS!gp3fH9rWJNyheGTw zXJZp6ms$xmKnk2(*hXL6wu@-dUI}`*dV^&Vty`>+7Pp;3TC`a~9IoDIRYvPpYsiY* zZZR#|tzjCj-f*pu)-Bh;6t_JE)wTW5i(|M#`~853&3y;}DQ*V}tm_MT{FZOJ=_3rb z)buIZWT+5lKYtuyE;guw7h{tEq72GA(hfMvNQktI3dm!8}j2`=U_~91glsYo! zhv^V_wYL&DDEOX0IY#MKny+6$Tzo><8-f#nXZBPwHIMAFb6{d)^{1b*Y92>N#X2R8L{Z}>YA;9?Vx9^@@M$415q)0hVi0UZr=abfSO1RL#oH4W!zr z7Oqf#?v}DtD|o4xm+~?xxmOCJmR4xYb+9m0ewA%!iKcO5_1&7J7qUuIY_I)_BEjS5 zZxjyUSb<)n=#tL%JG7VF^EHtbalLClo)_rb@AlfYmj@Kwp~Y0fawE)ZwaRF2F}ZbC zY^;VB>%WnksX4yf8LKOGi8(c0vE5Y5_s0u?TQ~A{l~w_@Bd>)*tRN8F;EB3+dc!Un zTc-t7tkyzL2u4#YgFze(*O1jFeAsT9txy_>)81$rJqQz7Y1H(j%oUTz&OQ`;LA@3MRc+z6SvHhUs#Yz4f!%|XM? zi$$dY57OPf;#uV`oLrGhg*TZ``=YRGB4~w(a!me_JttTRu0{J*;uh^_Jzko{5mT9+ z2@a}305J6l{3Pj!W|M{xJtz=h0hiW!l0&ZYR`e#BeNVH{2dm`^Epp{Lsjk`MFfR3X=%@o73>o6Tiy)jV%Kh<}9WlR0ZNeP8 ztRACj*=7k#4KkGozJ!qNYssFkYUM(|z`v{wZ!0Z2#Ii)*%hZ4A^vM71|GVG#Pd5Ru zG5&L7bK|KS|M_g=`PcZ*FDd>r5TxdkNcZnqEEk|+fLOae9-;RdBi@TVr=xQTHTD*nNCaA<5RZs&)Hq!HKSZxuOXf_^$0r=Z*FVjhueoVjpmM|DO;Qt#4#besD z2m}Y*^J$t?i&Bv9Vye_=fx*fMB8dPaqPp}`I$RKb`8x>K7#3GKl;uxfT19DE%73LURoC0IcVW{r?>7o*({vfyrWs{5i^| zDK4yvs^py0x6u?w2%)lWSyk*%h5(ctxPo<>70xtdvPV+TEbGP@1|t~^aHRm0l3+2; z6ui4m5upVeB=})qIOs^6+zK=>fnlu}lfJ3Kuw!b-IW$+dO4*ngSYjHY<35GjR6ofK z&x;-*X=*iq*A9TkWZ_^IgqaesZxFKsF*(^sfE)Wi|LcE8#bS;yGsTz|KWp-byttVl zv?{z%QB;tDc!vIm%T&}d%kmtq&8%~@Tl%aY-UZ16hbasb=Y5#2gO{X=F0jKYf%O70 zqeKna;_0HmiUsuPjpETTw|s?uM5 zQiG1_BN_PhDv~Rvd_`T_aOTnnj9v&OX6MFY0-SR&#~0r5C3u?)L5776938!iEpS0= z#oG|u2<*-zAfBGgLw`Vt=&dO!d^4ts>vb%O!X{^>1B=tuNMno?_TCPUYxjnN^Mg&@ zFmX%qU(aUg(4u9cd!GNf9i*eZ1!3)IrnQSlIC1?&et5FcHpbOgmeZC{Vah4#5U2d_lN0VpF4tpg{oj+*`-FPgNK&To=AW$HIeVj4_DXQ zj~;XTGA(+rLMW#roD4C<$-Z2Yo}Sx*IZ>ZOMlv@-j9LjnfJ!X2BHD^(-q)#I`E&SO zZMeJht$;JHENSOMmJr_)5F0E~MDIXI}7$@h6QI*p_KoRXqQ@kk7C zhPa+k0#7zbp{3X3QJOx9rd72ppKNbm0w-9V$7B%5FRClp?pw+(fLn8m8{U#8f+d`z z?T7!*gNK3am*$BLoi!1M#?$fwSBGQLIOCs~;z2ZnHEljw;*6w=)m(tvph94@h^m3! z;tbTM|0W1R{U?I0>c2G9?BI87B>N=x9~1nc)&BF%&bJRe`~QQ75AN+hpRxUCRb{jI zRerf9{6ih??meW#Zw%>RAKbgAs6idNK_=9PbGMoP)&)xt)f&+fYQ|De?DlvGlG058 z6T*vG+s2@6E<58T+yp0Kye33~3eK;GM?mu$T=T823LaW0+MODlgcUly1Gm-;WW9;D zn@vRMZHKh>D|2F?4%Jz9@d;4Yr4IE;*72*OnV>^$6pj4Kr8FLNprFO6T7=_ufZR9V zEYRS&U|ZrJ@c(XxWW|P94Ropobxb?Zt-qnBAuuAIg;Pn(lkTKx-1m+EMLlmV*Y=KE{kWO|q3Ws72Rh9K;L^oIG64Y#= zJ1#O%jH>JPTmInDXgKQOh44wNzY4$4;DP2zzPUf+I^ZeZivI4=Q|>T2KuI2-Dm*|m zuzBwga_|2U(Eoq*)&7qDA76j-^^WKN@$lPw|G(cm{^K2J?!QZo`Ywrd218_EHFCjR z5B88+djSe&IXzWUAqYhbfY_$3cLz0G56G}a9dFXWZ>4mvsb@D(8q^H>lPb|`f(_mY zz~C)F1qED;02WlZf>0#d+!gQToa;}f4nt=sArwm(K(4cC*!+z%fM9-H;4}w;kXwqg ziI>|$WB+mSj-Y>?!QZ+~2xZ)(N){3t)A6&iEQ=Hi9jz*TWP5JfaSKGQj*>ZLX2};t zKF4`uqA+o_8G0J}qTH`7=rrMzOkOFJktdlMHS4EbJhQo(fLQi6KL!U!iDxj5cQJH} z-UvspuNR6(bW6Cr^&s-x<-8R--koS6zT$C<81Yqw>Ssf!_*|86qs1GqGbgm$qEZ{( z9UvA6^rqCS(9tk7dsCj`&1?`*@`#)@%M z4Cg+lhd_F}4#-4%j~grc++%)$tlXRBC8bHTPQP8|2lTpuTlXp{CJhD0c`{4OF_isFiWcc9 z%marg&=|gJ9O3spHM)Tql$4FU>le#=u;%(gtPx$z-ce2LA7OtQA2#Y++)t_m>lusv z%(%$ac8OW5L7B53^Am&<3b(5E^A*BjY{~`N9H_O5t6Y5T_uXnkj362Fia`~2K@uz2 zj~?*z(oVSArLl;ruDP-y0>7-m{tbx)xPO%|{gwWtm%ts^|C&sH?fn1IgGb2oZTsKX z^xM7t@82%}%MBcW)=UB86mPyaUv(=A6K&%W0Zc@d*F#ep!WR)?4u5Um@sQa*2lH$hIQQ5gQg?Uz%H=2h=G3F+u-e$AeEoTHt+-g?z z{^F&bv>MH8?dACrw0l#g?QAdiP31LK@|zmSyW7UY^#}flJ`-bjs~x;dE3^n;h+cz_ zg0=}HW?@TzuuPUNRrVIJDuY0a0%!v^a*K`rPICSz)BGMMnC#b9>T!ue-E?oSAxMtuD*u?fhWVK1e>>`n>qh4F)yY{s#qN>!vTg<+yK@ zbL!v7NeXr{vXXQXC75l8&KhU7fVmShF`;PO%M7kxoxHVM)*o}$)=2SVFE`BwJryLg z?4S7Lv@Aope(vB-sXcmDIgA9|dJu^BJj5JaS0MOcfSZc=BoAhr0HPwK6wsK!rv>N+ zIL&1AMlBFs4$WA2-2!kiipTq}5i&MtCBat<;6q%ux5za;KTK{YcpvqM5{5xXWN6+1 zTfIsa6iqE+tjiUKysK{E3o$ZNmoA24%P?ww5W(@TKm>yRY;k5XB0_U>k~S||Rx@?)nl*E@$s9{OTQkz%8^3QT=~V($?LPx%Y%b}ul+oqBs26gmTz>E*YX#H zdk`|V{Ame-v}!Pzt-7`ouaEY3U-|8mS2*8Duf49Ex*ykWmA2sV?g<(^!#%i_qrbVb z!t3AsX&vu>>iaJ7s1BPnkp(;LP(?PlJr%9c-b5}1OCqw_)3%spOZ?xGB*L@c-c=38 z`v)&}-@FQJJYa!oL%qYJ!-K$vPbN*3UhM7%DwQ*s<}&fLHhm%!-NaR1=!#mUj@ zvzpixgP0Rz(?Ba6W@C_I3sN^?&yP+|k6wr8rXw|+r)FYVUUr+Y;nApZz7(OB*-~_Z zb9Jjjs`j@yq3Z8-j^_{j+}{|aFfb58%+d|PH*`j`(xwhFxlN#{V<#y{I^*>2IY;3T zzA^7u=1X(o-44gcN5}0~qF*a?Bi?GChymZte&KE(6^l&7DQ3Fbl07 zUk{R5N4~visoyq?w)!-p<|2(71x4?JS^A;jtTv{Nu)F&#E-ix4JF23yfon8O6buhvw?<7Sa8^wGYw~jleMiSkI)<{B*u(6P1 z$(kp{`zOi|RJk@%{#I?&s*C*`+$!Kx@|gyw%o}IJP{f0Y(HfgrBP)Z∓WXtOh>m zP+lm7esq;B)2TW5@bAB**XMasOpbU=pI{j5 zq^=J0r)x-P8^Ao4IWR`Jf%p-#V@rmcOShyV`8*viCMbvJjS|GI26bGd>}`nGtGHA@ zK{K=_fy{t+c6KdyDxbMmfVGjis4uFM7E!3EfBU1qt4oG*n4Hl!~rnUZKZadK@xzWR}iRTnewd!KIP6G+RKYYTAU=4Y}NIE)c7gsc`|8s z?#KZlW0#2G%C|u}InD7#xRXgL8=L6d$ssVaEScSYFY8WOB)e}WYHK*@*oPJam>?!n z88N5HN__l3Dh}q$>Kb_wu`*&W&At2ef;R(hrZ-5(E9<+-euIvq>(kc+!VTPtcoSjY zU-f34S2V)S+_s5Y@-@ED>$l(&3O5yY;J#^l9<}iF)x}b--D)5b$Zas}fV_TFKA}&L z%leM7sqT@ee)IS*_JiLJ^Hm)R))N2w;E|jE@!_|RAMf19f8EFbc8vdZwqD1$UpL0q zp>ng>+CWS#sLH!2Xi@h$ZH(cn8jX_$CmW!E+H?}H7l^yFcJOUD==L@-tA<2$&y@d@ z`M)DNcEbOE@ZfO(|NrrQ{>OX%zYhPmx85zT0GrVLEwI)tpmo3di{L*6|MhLQpahOd z274(40MW?*AAI}x;kO?C!`F}R@n1h@{0BJXDiFl|Hs)B-csW-^hKq~bEtZ$@^A!eg zwn0mHAC5Vs(*FDr+AY%fvPhQGY+T0VW>kg?5v3!vwau=1_i|}`uSpjgBq30XGZ6!O ztQrcLG}kP=sK|_6*ws3wO>QmyCOe(N50f`lHrv<`%-ui5g5SfH^;!hK!kUiLVnJ!x zq^nQF7c<@=(G_J$P2OW(OiIRP<^+3(33I6yrL$$wjVB7j-p?>5J8xwc+diM==fImV z!g!m8y<5w(Yy#WdV!6$ofIe(1eJQ7JDH+(K9_} z6nf*)-pRqi;hE9O*_*@B@xk8k#qeO?n=zR;IGxyoGCI?=N=_`z+Up}Ud`8YOMaz!s z7yavBvcCCK_}2X{c?@9h5RLu7mGFwh-x)&7}HsW`g@WyQuAoHoVPN4t;^&`YHb=Sozv5El(mVrDh5CZEqiAGJd z5@7|cm!aJu@dv}Z!`Whi6>GAWXoNzYeaIoYkzCF- zdg(Kk2J9UP8yfZ>MxkU?Z1htlwJAeW3)`J=@L*Xuc{1(!d?`;5l2ykbXy2|VR4x@l zQ>^|B7Uol1&#_c(?+p3St~J{+#JgJdi=?ZEVuVSBwyf99mQK`9$}f!qedk^2ckwQG zh=^Y?w81C@I*Af%}X1X|c`GSM2+bKUjVs&HjIn zzkT54|M>RN!*A~WKRyTl4|k<~PY{(mBOa}m7!h2)$~^B3?L0vU_U_j!^iHT^y^9+B zCB`C)VYrB(bVxRIpI*Q<0)Co9=gk}-Z!n(ie3p_@$S4N8$HTi%(}CkDc~n$G&(Fr6 zkCxSN(F^zF$rJK^>~H+pobui#KaN9{Vl}SvVzZ9|3Ip&&3&M>#$3WYQ8F5vN;4Yz9 zI?>LQ)*poI_1UA+9*YgKuU$(p76~q|=ZN+zoe(yHz|E8i79%^E+4q7l&0ZlL zZlE9Hy*l<8tZ)Ijr~=ZJ*0I1xIy%JTa#`x&C= zU<9SGp=oY>_m8^f3}M4GrEP!p6?fpPXm3@Z+zuit2&|K=9OF1yX4T6cfw=0PoLEvA zR$qchH!hNLsu1d#JFo}qB&`^T1A36nN?A{J0CBssikoC4ZL+b3fFT=J)=w=z^enT!_L6KL{U;6#_vG%vD$qBK7mi0K@aY{~;5ME|+$=ysjeL(H>`oue}o zGFceQWLpqPem%@9jPC8p`HYb|nmqAussv(?{v$I_JH6VOpWHnV{St&qHly@kz?#ES zRXGhyP9pw-xQ+RR*<7~J%Wi#PwzFA>XJG0i2Dzlv6W3%ac##!l<(>T}M*HHUtM-ri z>{wZNH(V{>EYzUto5id}5N(GhT8>*+ZP3^4&KiIDn)^CRQqjae{Y=(sr+T}5!!!bg zm*knsBw|!V!_>eaU7L`)o!vWj-8!b){VAqsy4xf>pgFIE|N;qv~Wwqeuav-VhYe>h@ZIa%~;x24nZ>+V2Y2 z_vw1j836I-UAQ?0NI+@0k2n0y?Y~0)HHF7!`ETdj$G-jd@wX4|?Z3YZ`>$iz-B7Aa zCS?I%$<;w`+pSfY2!JO$lz9sHk1 zJC8m5pPhUBhkO3N4*z$yUdLO&wMen`MQN*nOTuaA-~Osl%s+T##1w_<=Coc_JiOi} z&gbSJvR%`Exy?nR=l}kq`TY3*EH6`E0ce%~cRc-n=j(g^f6xEd;s0vu-CP0CH10Kk z^%*lx-aSwLkLLd`^6{#?0ssHT=l|c{^Z$GPzXt!;Tkq!lU(>kf|2MP%(G=d_0{-IR(_!EQ)E-{ChTApSvlevVc4o{q$TmyF+QT1U%tA;guS3!lqALwiM*)koPLUwn9Uc;E-bT;;{2T!YHnqm%uSPX=?UrWil42B{H<;|GU(2LW>9X;Q%Q z!o9Huy}g&aC$Q5GPJDs{Fam_CX^~$=o$mcG8tLYXyg26=Y+N|dg4E)2PS9mSY5aWP zp%9Q)!^WgbVU0aW<=YZas)7i=CI8*j6HFd0KR&arox-^MezgNDAuU ztRszs*So`4zY4>kCE(yf;uW`J62}OlJk1FsyW37$>KYAuiNJky$-2Nm+Hz*OrzZW4 zPD4P+`;J+Qq}YYEaEifKnaUk~wx>OMVt(#&bsO8{J-`B!HmwV_;m|Izg`m}&oP9Q_=O=C%9UpJlbBnI5r%ja{< zG9EY>=dj;G8rL)TSU!tk?@U0NB+Vd&6 z6x4ZccfHjxD%LEB8QN7Z^UOn8@7I@j%z!RW0h`V8t5aCf^Ms5~mxYr>ps`gEK<4a- zruJUKzu$tGK9T_!UDtX2rjE|b@v0!*<+8|Ei^-Zztuc!@Dhs^q(=}xtsddiD`Ym-+ zhy5l@G3{392lxNo>iv(*-P-{X!hQ#A^Z$70#s7Wt^@Dr=kI%{fgI4-8cK=XY@8;eg zip0J1$J)*xqygM}ef$ppYtz&20t2Wm{^!vHAOGj;$N!P|KbB+Vz5ieL3n09GKUo-1 zxVigipkFx}$TtezUqZiy{~tXF)m|0N{(s**dg#UfKYolcLHF|iXXpR7Ch9XD^;o?R zdWg-yFUFh^NCH69hH3$9UN7^tzCYd!g)`90bhhm1=4ahG>sHv$N*LRC+Spb-2K2oj z-{fE1@oO&l_ z!(3#fQ=re%$QSVMFQq&6cRR7fjS=)6FXbIg34*KJ%KD#Z%wn|kyg_5 zki1L9>0ac;JgF+`w*R9Vr$-?vQkl}d9Hmta1h^^I@>Ii?y2vhN>XH%e^q0nmXG-n@ zhTQ815|;{0;bS{7P#x0ov6I`#XmGST|KPYMh9Qrc24+}-4%VSVhk%R&5WzYjBK72j z$PHx8hy2lKBU%Jjdo^oBxn(k_#x=Ve;y^*t`~W!bC6FbuG_(> z>E@Xqoa{}`xsbjDba#{X3jO>!S|B`~IvP%XQ2XGh8LZ3NS~KA_sXqg3zi0KP-+=dC z%T}x`v+^nncC?v!Jd4>Gp;PkQS|%1^59Wnub4s8Mz`mO0TNAtB;|oeE!-1A*fCV^(J8*(|HBdp{0G!{@IK zTDrn3+}soWUU{RA`~dn_zkTP3qrK+NsWGLmyMZ)iQq83a{j2X?Nj9}@C8D7I;hA%7 zI(D2`nXFX}xJF5tK20uV3>9XHu`*TRY%8)(7oNov@o!@Q)|D3j{Fsh?OI}UKIeTO9qgX$y~G5aH5qk}VPKdVY|4=y zn57i6*1?^cOfX;6RgqO_-2rVS!-p_APtpJV-~T6ik(?LVIGQEb`Kk(0Uy){BMpu*- zkAR%7GPr<%(V?*c1W9aL&Ic!VqF-n+qjR*tl~KM7WTOS@Al;_*WGI409AF*2I52zaCD$V>pYMT1MM->+!^UlcmiR-3_Y;GFc!x)#Xja~I8wkJGa)RhRdMAoZ)H00WX_dK^BnxsP3UTI3i-#^jL+Z z>yR>dOE%BQw4wfgj52XE`5%6t!w;irGRdz-XrMbT@{iXy!T$`){|~>um;XNt`JY#M zqkA+5V7c#y7L*CcAve^ORh7-kc=_=(-&e%n=hZA*#2~*<X;lB!a1l#>s3J^%i-BU~ko`Km^CdIPFJe9%U6y>U9YlfDG{_ zZL+(f89u*SpzWWG^Yi>eT1Mw-bp`uFHeKQq0cUc=f}$fasBvk;dnekC{@ddRU;p>m zz^SA{V%P>6g<301sdEbO>eQ4NTkFxK&`*Ivt{i$L_YXRIs!qyBFdimc@Nt#XhZuG}L)>qXN2zB+!v{v?~1?n5qz8-;}ECN4S1hmtS zBXY6l&_=+kDKIfl^~CtaUYa{{>Tju<0Y5bdTwnMntFW`MxQFJ+(s>Q??z@`!H@P*u zd#3K`Xt}EP^A%9;ZR(@FIna{>TT~S2_d8MGxdRe<-WqBAve7`%KB+*|nBYdLbu@lb zG=6HNVYptImu+lZOFdX7>$SZhs!-m7I=Yb00^E7=gNy$d`F}J`(`t7C0vS5&?&Wzg z$reeK7k3i*-9i3;{AlN!M}GV_=7jmrjsHH+t16$DxHg|h{I}7eKrEoxJu&a2yl5{W zT_krWAO@x#DvZ*qzR-^d3sl#*da5`WS=QMs!|E9zEJe?*Vvb^XRs>aSHYZbN}m(~og zqhA%j2<0v4N`zeN4(3@^rCrC0(WFUv^FdUIbe_?cIrzxB+;g-xCdD;vq4EF$rp!%* zhc%uhi%Za4FXFdp^8O^H9P#5cpljB!Qh`E1eUWiSp|>O%m;_D0HDoo`<1hW18j`Af z+R&QCG#$S`%&S9CC3=c_OvPLQWb`v^Iv!n8S-c(EH!ipPI z-JLC`i5kjNF<1NCCK|tY%>uohi-Fs5xM4n0A@57y?&~a@q+UxjyCeKGT&PxznKGu+ z;vR@!eciKY+VXo`CGXSWMd%I7>Hk$Wo56rzJ7L~@p&ZbJvq?@ZdW)xGHqA2~i{_IBni?wq`vZE?EG zZnwv`zxs+xeifZg(fmuI0?G-ilB~V6>j*o{3yDtb?=G_HG#58z`7WC0lT_Gd6{#W0 z6%l+duD4S-En31r6LT_ynVnoCv8A}72hkNsCD{zBK!a=}KuJXsJQq@SG*7Dn?l%%^ z$g0e00QC|77u7>@H&4i#kkY?NkepHbKS_$ZM_Um(PGiSVG`h$>lE*&&yGX`lahQ-0 zs!Z1;LE-vsWeZ8p5)`O+sSsIVJkaIK%M`8WqPLhSCF-T|WgL+aiu~h~$pruRqR8jb zX!l3-p$7#;dYpf??eipIOTm>9>%q38w=fy%%xNp9oJ16dJpJW6mPWqUX6}48F>K}f z_eFZ;{a)jFacWGwihGZ(<G(7d2yF zg1&(&S!qq(^K0==M}xyzLjUE9S$>7Y^HrfwA02#9VOB+|#{j1|UzR4t^y3oF6p~9n zf@T0`D&fcvwYUS|C8V&bGjyKPv04GiiAf8VWQ%74S8ANBN-QT%W_i6BPm6q!{Zq25 za)se1$O5rF&(2^DeA_+)vjiB>r9MWciyBo!?Xc%V~JNyxDSxqUZy!xzaFTI<{~TH zYerkRtXHOby)^pQznax!SLc^Q6;|}uJm8+Y9UMO=-Wfa{2HV^fw?((=G!L*c1_3?* zjGf{F4%9y-;r^wls96efSe3xu^?Cq%$UQxM{nTg8u1yM`!27cC`f}e#JMPr-;-%sq zJ&^pSC#QVi9lGFQ8dziNH@i65ZH}g}qu$?To3@}QMHMwRb>h^*>W~t&E?f0WUGb5w)x7rQU~~XA&5hhV?J7jv=p;=$~Og56PTK_ z@d~{WOG%knHQvQ|bMxh-d5mUSBq)}i;OZ2U16J%yj+yTdc~@g|$YtSPQXQJ~fnjEs zT0~stY|O-)D*up8m}NvD7vNFHKEXv@Md;s7Qz|Y!82YeMGBb)eET$q`LRL9YGBPo{ zxn`s=U+B7V4Wz^diSmFTpaxG#CY`JKf3C_(i3J!V4$A^WvkQ?CjQh-fJEuTY#D5xU z1hI#S>ZRzvoD7_V{34<$!1vjbMo6q5+MKLXY!@s1WL_b#zZThYH8Z)bxP8rt-MTY< zQ;FC;jgDX(IGUc1ig{A4kAA?iB_~IJdTQ0xjZ*CLUci2dvVY(0oMYj1v}{X@iSr9) zxPk731&P`hbx>`>U@#siOwEf^ukQIh1Vbc8Kf)v4)67i~Zz6W%Yc`D2bK`5b1zx6Y z><5dhA7*r#jaRDn;T_v3d+Vd-`;`>PzElY|7m0Kg< zQ043hXcs%A>8M_msFgi90sQCz3C-ZDu!G4(Cw0J!`X(x{*X04e=UNahq%sTQfn zQ-aoWZRH}NB{>HjZUytYqbyP46*5ZbicS|3*#Z4V+V3n^@Qf(gM}PTJ7%&P~#W2b(Kuhn~F+?b#Fei-gB@>Wb2Bu0ul(sxs+@Sisdogi;i$TF2DfVGuE1=;<$83{+ZnVJ+=?F5 zcZHF-WL5U8lQzsAzhga#e78HZn{_TU1Rji4&Rh40WmcH=RvnYpLUe=*p z%9>@M0R+J+fFZ$6`cHh!fQfNT=llQ?O(+kuHlb7Vj5LphVw2)qyym5_P6n>ZeO02@ z*t&UpXA&bDIPCwdA~7YIU18+SIl*WxNdRU!T0KS@CyI>w?~JELlU=+s`)o%S;Y05d zB+>+gToT?(aa=740>ldRNmCD`^m0>OfgA)H(Ksy!WY&^K_c;o*EQii9ZEcE@-4nCj zt!SS`)!;G=1VUIc9^kCrrlkq1yn_?P`09#f6WhWvc$7{>8w(aB7yNw{uZv1{O;LrKF6u~=?z~W^hp{; zAbHu!5R~8LssNdllnm&h@o=cSILT8seDXUjOYr$JN+*{o3c0QUlqlmCOo^t9iL;4b ztneW}Ifrv>Zx?A=F48a2Je{vDr%|~~#;GEHBth#26f-x?E~?Ua>x9c`3Of$}7e+=R zoJeNlvYdH9tl43FM#!)=1V0KxYM&`H#LZM>=ok2 zWAi09r1vqfBFos%>2`>@Y#6 z&gg(kUud_yaszFxLNPcJ^S4z1%C+9LkuFxv2( z&pOXi0Cu&5i0nU|sP4&#QT0P8Re*K?%F0(sHaUXp{EvS${DQ>c2|OFicwjCE0?7}v z{5+Zc$hH9T!VY+B`>d_SAKaAFb#q0YQwAd0zpapWxR6%Y&IeE@TAafX4A@rM-mjiU zZbf7$QuvS(>ryK*q@bY|3?evUUXWSTsilBjf{hy-R1Z*)>al zCCsRB2NFEpbsfXgVsGuCxu*0UZLDUk-m}%VC;Ilq(bnd7PyO91ndn7`MZp9iFDl^h zrj{9YwY*{>7J2G|kOh!|zE9Jo#r}P1DR!cCKklMpkb+`=CD)F4%X)%|H&)YEBs?5< zy%%stU-E?^AcJT5nC!~J>&;s1fY%$2M-u@>QF~%$x_j=z2i4Mb79Ixm{2Ie~Dyf%< z(amRz)hYiXhGhN+PQfsK|ce$X}^3Q+bdv3NYAIN-C-tx(b zOI6*tF>dIs(8bq2={%*lk!6#eXVw!V)}QHh#e$YV3J&>i#Df1vip+JbwZDMag64}Hv@_eGQM%%JY>6I$l^1#%dJ_H8a;;XDc-F5BXLWQY zP!96q-WFc+rUi=RgL0A3Q$6`KZrG|Z8l7^`qSXVq@}~sFyAZCFnCl#r(N$q+?;Kze z7o3!X$LGjohxCfk`H8#Lf6%0T(>H8<2wc#N|737c&Z{)3S4P6ITcNnHCuUHe=7l1% z%K>1lNnsL9f$2YDo6%^s54|-w0lhBC=lr)jA22}1!w#aXeF;VO3o z1$DI8McvSJ`;hnVjR2%n;aPT(j<3hFG=3%j)ujvxO-JV-ReuQdAW-4xYGLCseOmY` z9<%HqVf1#aDQ(>Q+2keMv1`^k#D@-gQdzSi0S&HMXT6qwB}6ZVQwG^0zo?v!9uKN`aLRf*GCW%MgH( z!dN2IPXlpAmTShP@o*>0RJ>~bIdFIw7dz0d7#p?dzK)rXz5EB2#^+H+&GV*@x`3Pp}>4jXH3d6Mh7yFBkJ|&uc-o@MdnVC1Dr;Y`dH6YM_T? zJVq5F5k=8{G)d9mkWM0zHFwj|2V!@+_~$=6Wii>9QgwV91F?`nP$ z`PX&r`dP!r^p*7ZHG=Jyjnkg|Fg!edb9(mr;PmCu{@Li@c2WuMw(84 zkC?kV+${_zVw5J_5z9)sRdV1n^M>r7>BMtD!U4qI9)YeI=TM&Kp3forjvrx-&=Sz3 ziHuZY^?Z?sr*R9BEHkjPJR6^5YR9*5agt9?`s}FWPTlzE@NDnoVE2@gda_gI%bXyq zX~~Wjd-S^DDB1xkqto4!(>fxffKPdV#>v6x&Fk8pz>!?dJM`pu_syu5$T50GwbR%? z7@eLR{ZvO~pTQ&@I%HUb8k7yX_1UOtvMreBl(v!1V-!eR-A31U#}A0Dz=$qUo+*wN z#JqiOzE+!Zxmp@UK2R@M3D&Sivtof;hl-ChciOPp6Gqbo-y)sYPFT7u?yyL&w9w=R zV}bNbVwIFt-bk6aVn}g>A(sRk?-*It}TaXE7^ECWS*^O)64E;-kdMrUb(;}3ttbPbR7KgZ-}w%v%&1r>Ns| zpnFDo5U|QbfFZWgB(A$9^d6{<@9O9iAU4C8l+YMM)~=ETDXWZC5w{Ump)u%_$e#MJ zhxnnML&BnyL+Ld!PZle5#BHYPERJ&MJ}O=&%5q^rLL5dcD@3TomILbAXR_=1dvfa0jVO8;o-8OkmIVL+b0B4#rjaEH(b z5_JN}3b$D1h!}xK$66y>z}c0n?=qLZ>w!Ul^hG*ZjV1CX0R}=pZ1-8l=SU|60{TJ>;gR8HY1Ig}=Zk)}fM*`YXmhrch)RerSZ)3r0g4$gkIS9{E zePpg=#i@VUtcn{WFsyC6H9T3;83mjMOtN92RXYDQbnM^!vQ&E`Rwq1l}v0wW53Z}Z}PqVk2eUPH}|tqqRyu+tufxjMbd#mY3#6gzmf z@8TQ1memsTFQ4{B={Fa-$coa7zO}k*l<+oZGwr}uy3rA6Zsk|mb^Y+ZdenJeO`+}k zopo0`Ynse14umLqf}%%F3tqY2=doow=n7reJgf(FT6I@h=v2v4DAvCAZo=(5?6u#L z2T1*>nie2g4rlnhIio{(@DX1)tt1xif0h_NT!4g>O@!7QkyIA_;pbY3?UxwQuo(ac zWZZdPR?(w)GdzO+dbU7q21dIQLQ%bRat2Jv}%(`0MfR;r_vXhts@qCo;7?iWZ{3<;p+$yjH%m zEb^JJy*ZB0c=iA7F9_ij8@?d6x1*P6oI#UriCzY5kg-vyk}^s&!nqyKa?IQXQq3$I zXP7GI{F>tQ1|iA<{fy%HCTJ=WfLW!0(z;s22)=_^LJ4k}z8SH3A)4_0T4M*t{+g-- z%Z>@R+B)|(nR}beC$-5SoOCKxAZg*|%zO9toO?6KXKMzzw;tSE5ALl8za!x==rSeo zS}D_{2)~iotpj?GlkfCj3Wy{(irMw~)RXanUHuLGfm)4F*9LKv2BEpd51vb}-f8}$u5U>59>?F;xGl)7D)(OyLD7oTsDu7NSC_ci z1tY5lc1LOvnLN zUf<*z=InCa$et|<_F58$>WU*^w7bA(M+i9%{iJU8ym$dO%{zzLYtVC%HaL{KGx?BY zGjbr3Q2EU7g`=fTI^<*OGk4clSbd71)&a>GZ<;gd$(u)Tpf?~%@6DW=mGW8M~ zLADW-m*TIL3N0x^WA@;YGq(k+xx5Bxx`*XM|i!HU1{ycPR|W(t`PQcUYK`^_biW5#bSlO4)%lk4BP|8Y@4|Q$xG0i&`b1zdtM((b zxto&vc|N%w$W|!1FnL_Uc9AH1_|m38!#HQEO#{@(Z46%+wh5 zM1sU=QmSDLN5{LTdoRyk!_hk%ogN*tXaiEk5)p$bw@b#tO#Dew4Ye*%hc^0kUaFnz z-YUOT>kyhL)pfo@ty^TGx@o70&YE4;bsSMMTP*02453mS#EK~BWFCi}r_5n>3U((u zt|sJKUhqFF^wmK0a8=ujNQGrK3%7emuU_pQj}G?xwQeV-ttO~|1)z@VY-ymP`=xuv zpebL0S)~5eVts>ASIdB**6gE8dse9=AH@Ydvm%Vb3CcCmAxAt>oFfQ}_@D{u$usLg z6qDzYTl`g0q;)Oj7lt@_m0aTw(@Z6C_2KcI`qQdfmQS{~De5*CT1AX-Zxrr`9D4h~ zgPs4r^YG#JG{4#c*1VN%(M#|aMsIFGD_a5*YOBmIsx8^%))IMBGRIRxX%5=DFhMOf z$IvPb7dDr_Zm+U!R5Q!%DqoGK*yI+)U2d!P_)p4}na#55dZVtvp$aBDN4Qrgo3et7 zDyWF_6iai?I;@C7c4u`^J0uYmXCzy?09~gvng*eo@^{J1UJeL;nS%6BoKSu=BTj75)@b|V47r{U<5(Kmn|8wWTqi?_Q z@ISwOe2@S6IpKfuN^h9Zf)~j+J<9kV2~jPo6y~Q%v#D-fy9OrgUzBaVgEn~WHM;f_)Q?27})8Ea7g68t7-Q8o@#O+ zT9Pc?V#A=~Qu)J#5PqIU2R|Mho}L}(#k(mpV4ENd)|X_i*YD{Kz%xff1{vBZbAs<%mV zyE))F?z=_A+(I!i-J2$+jB}lJ;=Fn)kpWlv(qF61dnoHqhX2go3>N%z3;+M-o3D3V z{{PLlJNNwmbH{(~24m|pYT+jXAH+M+ zzy5{VDi9sLJUBQ#doescJ3TtvJ32fCb~>uFc~~RjrulJ8O_a!X2knrW{Q$Fx!gUsG z#mLSwNiPseW)NNGRrH6fiZ{C}pP~0nWA@-SJ-`&=U8iR4v6qd5?>?Z7P~Y@GaIwry zLHj1`?;Yr>?j%>l5hox^i1)~10s_}&7k@IP?y>f@S_t4|iPGxq+kmk*sd`!f1JQ#| zwpI0SRN(J{$|cG)1d0=^$S$e9(0h$S(+O9^=iYEVdNSKJ)K^!047g&Njs&5E*Mhw~ zRg=;8xQZz9MaLs*qlq{NO(Y`LP_*aQYFu#;GDN)e$zHSo5QS{|4bH)4BJihrg3Z*l z(#_we+vd7kL!6y4x1t9(YNRF-LfRzyH4xo50Sa3a9PASSg54De*qs7@-5uE1O#!~l zD~Gb6hu#)FcXdB+Fei-0-S{2K2~BRtVfjSM4}w=rNM-aIk2m|?c?Nz1V#GG`orirh z&28N=ZgEeq=X+$(bg}gf7^k3vlZX0W!$UvS90o@ln#bM);3YhX4a_n6Q`FP=sJ=JX z4$Cf;h5Qv-g=Lz){{*}=gjQkQ%}mw6%C&14U=>_%z#>7Ytq_W>cDf6-HPO^V_3XA9 zj0MVT%kbP{sdbV9qt4D#`FFOEaf@PeBt?-3{Hm*LHbcf*ks)M)DF5#+%sb&-lrQZZ zfYQs#wpfH^;iu6S$cvxcQgOHXKd+Om{kQ1<559iz=#i)YKllRb;i<4fW3_%B) z%Qpyo31d1q8$R^W+GutGjC8|5mS8T1U<^W?*K@K&7}iNPPKECviC@m$*~4%GiYfwe z8~zE~noWEwy^^40NiH66N zZ3sYG5c0$#*X#xoQ^fzJLGduisYj0Ng8Z$~snxE_-}gu^ou!LQr~=J)AfV`wAX`dF zR=&O{VU}dnOhg%SAGfVm;W(e;)3-re@7j9VtDK2`{h-{`g0Oc1@w0CYkQ{M*YSPJY9#j(I-{U zlTaF4sSO+PThODnwupYLrpYls1&zSgxIo1%RouAQIcsn?ADGpccmyda7V@+} za}X9k&dX|qqBJRhKCq1yS!&cN;qlmuSqDD|}=?coj4|t1LPcC#y!RraQv$_ZdfMimsv^%jnj{ z3iD~G&TSYc&-Gp<#U-52Hwz33u&~#0B1qBriXv9&ExhJ^9pm=t z9hzUWF~A@tb85*??<8|f5!>lPWsv@g-_H3Fp7Xw_=Q`MPAf@9fP-48M_i2PPz!gI83OyliO#RtBu*)7$2i}b5jb?RM{A_YANv^JiBnu{0hbyWI4xpA zmhJCgwspWU=fmNX&1EA>Sv8)mChU7f&w&N3w`2(#C8NX%8mL5E_)+zqT)}CxVdR*| z#wA=ls$906e8{s&ATTg6Ubx26Vw~<)uVI7sdi4wTr_cnbhwlEpbM?Qo|2GL4+sMPV z={zcc5by1`_5Jtp!-x3Ie*gXY@wX4}-+w=c_g`A+7+2bR_e~bS3|WdCt(KUAw|oUx zid#&$SH%+4rRFnK$09m&nOkrLqEP%QzpQwT>b1~Wkz>-y z;mBYhc*B8CvA6`@a}mEyllLbnAu5d1jg3E>EUmYR)4oR{+G-5bzS(DW7j!S-bwD2& zUW;>_vO6r}*=3818K*H+BDaW&u`bWvhJkg3am)0NffoxiQK8CKr@ynQ$*K9C0~ZYr zD6yyn2Cu5Ye>&SUK3d>rVcHx&Cpm?z?jL^s(y-r*0^tQU3cTUO*9iYZMk3XU7bc_~ znnmZIxTAR{vup>_hm*y_DNp{o_D_bYLg_sZ^NLciJ52||iGZ^#l}|JFA}gmZ_07Pm z)?4&692W_cMoj_LkR>rKokJ70^eiP$ZhI3ja}!`BAhy?|y^ZZEBCk9~jRpullZvM3 zM^c81ZFqZkX>(i2X@On?AC_?##+>XcPfbEeUM;t(d}~6jplU820Lv zhx@rC4Rr7povdam&8f3G1bKi1d7Z<_e-gc3kqv~R-~uNIL*nR75epn$$hor?fx~=` z^W~B1olV#Ll6sJjf%tTFs!osA^pNugIwr>UZq2(hqYO9bxQF6JC zmIohG1VlDCiXtLEK}Tx81%{|;J5%Kmsaa79r?^C00CV!7iGbdvV8E<;!3ZrmBt?0MLnp-4h!aZcx2EnviEe)1V*SdwK z>~u0mxlTm*mil7cVmxJ9o`^LcJJ&Y5Utkz7-}z<}1m+62H6k41^-pkL2Bj?qgQXx+ z#JiFRhLap?EK-q0NxoFXMjSzKx6q(zA_kmn+#2wU9D~_Vgqp#~7;B1MP!bHHSkB0u zg_1)peZ2M;7|Az~Nuz|sO9=&p4IH@wW+E5T0Sm-%9ge4*5FBGO@`e0l(cZH@(Ve*f z;)pPvRVm-QMLJG1#um;Bjw@8pBkFO<2B(I0%~oJ!>HK^q;HUlV7)Nj`q+*Ct=0aa& z2A|}v{ZVv4gxgsS^w_qGGrUMkln~T5D*Hm1kh0q}pG_DCQQ&>83X(5o$>s3k z5cX?2*t7}I`uZ!Xc?U?s zc`;YVoz)B3=SIf2pE6kkF3p0Y6r3wm%nj%D^bEn@>$NS1t6jKSiALdV;*gUxz#(gR zNR=i9CTwe*6U%02Ad>O#q{Zs*&qCAHOrf=x-3Wv`FIOQ*VH_F(UeGlyjzx2Uu)5F< zWX!}xt%m7onL*=Gd;z=6`SKz!=wX&tx)l`QP-vr`ezGM{dF|t>l^O>lAO=Q~hcZS~ zC`5JM+@Y*BLe}Nu8l9s#z*$oiRI`!`2<^d(AJqp$NXTVcdCKMPVv(;FW5P71JUqZ_ zBoj)A@8G+49Ip|00stX}J zISHZSrwdhVYA=tp37Ene#+uBLjrkhd3}A6m2Q!+ z!&i;hSV-r035dzp1$uS&X+O}4wxVbh7-UTsvsF12ZS$GtIVoW^e`z=wu!gJDvO4ri zZIF3l+BeOCgv}MeAA7a5`u)~!7-o&NdSRJEs1r4m`u)00EB>mq*uZw99A1^&R*A6+lTl7Shr>I4*^k=ixA+8MaX7y<$S^d}y} zb>%9kY&DOT`O;)~r1`{-T+PRSO!~k%xz**%ZNkBytT2HuZtDSBG-o;HV<dLD!-dLLjkn`PA zODY+=*uiSdlj7RE8`P66a^b7=OX&7xPX0A{#JPl3`Xjf6Y{AR|?tWPJ0pBZ_FB!!f z0aK-P7TuZHq2qO2reB#%H0vk#S7TiV*uQ!5K8=KbA!l~F7NSQ1>My2~p%)~|q2#s# zi;9cB$QN6 z7P^6rU4k9mgdT22y+rtk3tbgmLHYW7m%B3KJF)T7N#Ab^R%u3%ZE`j(kqM`s8jK?{ z`N}O!QWsf$P_(C?q@l%|TX{7L2&F`ffGHVjdh}l-ntlFI@`cR4f+(UC*h2>#orGK& z`D!nb$smwNdjj^6=(vQfKB`F>`m@-!aD=oIUGz&B(mX*c5xdoc9xo)ta8Zwm`o)}- zpFy4XFKhVqu;#wF5; z?iBol8>KCRf_iFpk-+}G_p$!Grj7SS+;?;gMBi14Mxwjfgfr)EG*}6B{$NkZ&;5Q& zx7nJpTbaLMmxdgGiBqg-tvNE}YshqYaGUKI^kI3cd_Xd9)~Zh6diVBOg_h7vx+N(tdd0Od_wlkot$ zbDFE&Q9q)(#z1dRCwg)tXRF93=ruTrBjn?J8G{aT9B=0~VuJEWtz?C$GSOwiwr8Pj zZnknYDd#osQnOS*?#vgQQbZ0Bn&=rw`u^*1KLCj89b4m=yAu#-P$5a;ory3E(uSdU z-;!er9I48LPQA%05D~Q&?1gWxL=p*{Il7?T{F#jsqk%*!&F~~!0Hq}gkQ6LJo$$Q{ z>@=FNLsb5dbJ{yp4exnO`mrlsP?jKKNb7VOxS{qnVW7{8=)}O8HtwasSNkp~o%RB4 z+8-?!Gd9!O1o`{ztdi{0I^OsU>#OHo`G%Q(X5$wUFu8!yyYxKk^g`&)_ z+cS*eK-Hb~qq)o}01KI2E~sPqJiZ}+X64FY^}-h_VL?u)-~s)4vxBiNUz|`M9StT_ zk1>3q#&ck|bBFnO>6GcYTiX0_ro2VODHNAnGJ`EMkynjzmC#sX;{i8z%)!L(kBSb@2cfgexAcFk(t=OKJ3YL2UpP1#ug(0t@9^F99h#q%hJ>p}l0?0` zB*vo`bGUe{O%-I%X8w%2rLr^IDyHE4oXw!oA!fF(66Nsgc>lSyOi9|Cd$Apjc)_Pq zeP@%`Xqk?)WOhJ#hD*F`i+qMm{)$Y7!tiUj1w@v|fW1;MZ)EtYMg5^ zq!o)RA?DC>6>wibrS(vCM?>irK-=Y17rD#BwDh7Tf5Qe#BUF z)Eust0)Ko5;3bvow4s!D=RUWof|!a>y()rx`Z2|AHATspAZD2l^04QthkE=>hN zX(_6d8C^Vno~J0Mhz{77xCp3Sk!JS}7W!R82v2w?P}Hvxt!wN>D_&fi?+G~=iEKE4 z>t%(HCL&7M#A=5w!LidX261p(0ari4qR>fqC&>tsm^`STVOBxfO#7VhQtxy{%X|m) zvd0>dgy3|gWkT=_I8A1Y{#sclD6>FbjMEW&;Ryx}yq!7mG0d{X-34BRtrdw{c-zIu zevH3+6fi%qiX8lPcklGoPmW^JM1*7T+o%{qDPIh|S$ctw3i)yn6-i zm*S8xHJx~GspOn|5w9?uif;4$6Y07i=Of3K%pB2ZDS5F7n7L~R zoZk?z#!jX^nf%rPY1oBdIT+1wX+SSD5fLPlnd^ut%nWS18Ay**&Uo&iFmQOT3KbA0%XNXa28PebVuHS6LYIFMxyVo7ho6_f=n_p;Z(|BA ziT&0vPnOZoAP!_j&*@`Kse0g`;JX8_7b4>I9Rrt0TL1i7gkchr$1k0qI+Ryf?`)t~ zxsO&O4euBVk958B`>oLezrn!Q8=iGeJB{zw&gl0t*T2Dk45ywgvT~}^Jiy8yeDL+S zTZl#t@*hZj4?X$s0ha$=%YU~@tV}N(txwbc$LYRRqjJfJ9A^h5GCujFW<7|iqy*C#7J83f#RFQ4~ULR<-cpB z_I*!L8VCaNGIfFNs64*-kg#{~+NUo4~#UOqIM-zVqlLGKo9y0iL(VRCRmhOT@ zGB4xTyQh0E&yIIbfU&Jp@9oL%vCQ5TtjE6OlzyqKNmXzPbA#l)3aPWi z&&X{iKECKrIyud%S^7hfe8{S6bT7Ci-P7x3N343`uDLIhN9|TsVOY}2G(pd6LkjYl zcpaWR3OrUhi3cxk_v9Q?5sfS1rbhGmByuO%wsu%`Ch0}8nn@p1+H50CcSZu&di4$# zET#p4Y-h&VbAt1|GhrRno_W9rk$G(UF=6=x{D&0u$96&#{|D5G@ zHlw0ayLjm={UFjIMmrbYXL~0_UH3}eGFu~PJPqUpNMn%qqb|^yoIE5o#Ne|Hcbr!2 z9E~mf&SKJ!BMBVL+&Wp{v72RMVBb6j;F_axksWi42>$|D&nr6-PN!^!Pz zVMdDn)sm}2c2d*v0wiK?>lp&8pJGZNr~OcB?3&BL5n=?A4ukdbsdONKF#Dvb>`3e} z+GJa>`B8wv4|Mp<+ZA^8`6`=LTbbuu$}kFS(b{^SUZ)drIx0jWq4R;3D8{%!XI9!J z$ayX(jLqD~f~3qtzz)Ds4=r2f9>z_@?e>X{!K5a|WQGv~0)-%rH(C~e62%yE(NkA` zBv*#kBR)OZJsk0+-FHE*3z#?D*C0-oeYGSNjJi!P4pq1NraG^Oia$ zhcd2ZjE0gwzXkS#|L%FD96%$7;kA(qb*|qy{>{y{0dH<@1b4Cxay_c!I=z(tSG|b4 zM^0u9ui;rwBSE0=K3H1YB*5%MJ+CoE&p-Nb7#rD}-(1H41db)W9qr&ttr6|+2*%+Z z!Xo;pLLs5A?@teRoV9-+cR*J79!?*>UD7iql6@9eITPk2=Oi)&`r2Svwu>nrad%7O zt^}p|jJD`zOYP8^kObs5uYsy)%LEtFinKYSO*K-{U=%~*SPBLv_I=nMVb!U%T6`)s z% zgMdWKj4R7&oI=xKTPF98UcK5q9vz$^K-Ssu@UXUpUw)rTZS4t>DQCoy%`h$z7Hx|) zCRm-Q2a4x%bq-PrUSe>NS&KoX$ZX+~X%eCl!!^mgjtnUA*VnH|tYL5*@;t&OGJ#fs zO=ahZV}XT06Ckzd!IK9yO3^sHtWWCJ+c_-$K6lLF!IOdmQAapjL=3ve2lXT^K{)Fn z`x?}*0R>uODk6ce9Ym8XI~{B-@3}&A=$b)Y2*u;~2;{P)jYDswp>jf%mub$AD3`i^ zQLPc(C5WOoCW7L4`qbs=@#};A;hWddzy1}Rg}Pd=c29mda3W>fa8vbk*0FsB-6%Q* z5<4|S-H10EViV=$*X&Y9gQ46XJfTkF4_L>uLG;0%a|ec%cwWuo*fqUsE8$!!@dvW` z!N>7zg$%pbL9)Ui+Ve6kK9E%wPP~NmAcKiDlc*WB?Oy^GhhxtcovfgU+oJ`hx41SR zX4K>`cuH1i_Jtvzz&}iqr7-8^s{&2Rh+A1^GZ-(Nk|JfKHz^i=S;o-`Y=csnwy;O3 z)y2KjS7^o3W+2mmLOR?zYGQ`n*i!B#=#Mw59<>B&xcOt>{?pmgO7`k)?jIbV9PI6$ z4v!9cd?-%NJ=J9m!bRfk}=`pjSE95P`t*-Gusmk&qj3kF2ldG?D04WVZu}1dfGCFPSZ; z2}%G{&XAWxlcac0I0taVCuCXG2@lA>MMOx`U|_aQchRq+cYoWmnbaV1eu(~rG1Rmw ze}9K(9#5cp?M+K-xR$ZI=428`l4udEod`pOpL7}NZ4OSpdYihnqWD**MN01EeB*oTEZ;coi z5LIlN*wucELf2&=F=(X2lY_I-@$TM1Q?q)1hTRKJ1n40Cbf$OYnGmDT5dL5_KSOY% zB0U3kHcoHf`Q0CPhp%>@zxwt1pL|HN8S;RZA^g&5wOK0u_gm~)Qe39L-?irM-)^ai z`)z$6rHH4}vyqM#oIy=EWZ>$?oBc+XXfVbGW~gz(d;Xmgv=Me>j(1`wG?B&wI-|`! zn>nX@jz+I{U%kTFX1|eIOGB934%R!Dh80#B=GqJu!4#DL#!kyEo=kV*2izS0@$lg{ zJFfrl!$*(4z4!n9z4-qskJvkKSzXt~RDlu{=@tm>^JOW$=rD8f7JJ4?pA@bc<*ap$ z$!|FV(H((dccLD}b%G>bB#0SRR+!(Ch`$wnbZo=4nBkPLXwO%d$Ou8R9POPP0f+ln z1ULA>$=U9!x4S=$@Xx;vPtV@Kt^Vpvn&S77G>rE=Tga>_2%7iLw8#lkPNeW=H9eyL zGfzH7PoqCcZISbG9@2PW0{BgFz-hb4|L3b{35vuPP6ZihK&Q>+5eO%jkn;mik{0A5WHj2T`KYH&!w@#}zi#E}Ws* z9&fVE=RIdfKkv+Ojv_ELJgv*b;*cj)5(5vHw z?$`lI^=oT~F*OO0C!ao$$r2{4MPbSC?8U3yAI?nS@RJfZgv~o|CM40`btC-myC-|N zd;GaXr`6RYM_E;o7-HF_UO7$BU?Tn;;a@HntYh|=s~CR3(@tTCyDGNh<~8@(v!`sM zfHgU);6;(oMV0~ve_Jc&PE2@>*ndk>p*N;mM;hWUD~4F%gm$R9{aRs$ljUiC{Bf<2 zMB0iJj<{AJqnNJl6-%pY@AGPwEpVGpWAX$)%_%HU=>N}G879QTCt4M3F-@%q4AKrQI%@sO#U zY|qxb4FZqMbTF#m8>09)?)Y-Jv0=aDGG%lDSQ3-+Q+~MtsO;*7aa(Cou~^fN>>>4HBK>rO8Uku;P0|TkaMyDQRXKjbHtq7 zGZzR$k1m2K{PZ_2Aw72!P+Nt#;uH)Pd|gd zU4Md1Xec#uLoXclB9DZqJ&=R9-Na2D%8vRSM1o7iqto4!(|$OuxMC1AJD>3&4i5Lj zLy#MHkJe3$K%Fzu5&yu+YR^@GT<~oEQh!qH2G>6@3bH`V$(>+twB1b7g6sPF-hNMpZJ7sv1RsH#e8{mGW`GG+wQA=%e{oDSJFVHC0=PM zicv)CYPlqAM3sOhDsf`umBEh59nx?Iw&AbNdUcGbw`crMdf$XL&KC_kN2{m(j`OZ3 zrDg85&xN@FWz=IYn?Xb#82xyiERrbE;8IFcjFXC4v!+loMA~@H>!p*+6jSiw|Mp|A zbR$klC`utbfdg|En($%FzxuVHA8H!IPM)$a8@>ip*t*8fV5390TjvhfuBQ`6=1-DY z0K`U4#I$Gp=*CcVP79<(=;30ELli_NC-r??%}^P6oqT+iEmBNLh$+=FdfU%*pldYA z$ED0NOFrHBik+04nTZZlN$4GPmE`kOx1W>*=KLi=;#5U8R@vH}n9mdSd3Tw)5uXHk zjWbLNou6`mklg8M*Te9*)!!9Wd#4dkkS=ES+``nsus^}#&O6&C5g*}=?88X(Kk z1bSfZNBMI*70I`JLZ$QN6CK``3pD+>OhAaR40SaT;-cSHjhA_m{S$H1XR>Zy1PjV| z3Hi>uW;cTd#pZt6Rhl|#mu%|kdZ)v1H%El>&ag zmy`bJ;3LUSAS=oz{r~nezp+S0@{9Aj7$6!!+kc*;eziS+bcWZ`8Fb|gz5dA{aORRc zyMW0N&trRD;XK$p$q9FldqiG90=i67GuA}_5gvCB55Ig4&W***C?S{0 zNro6C{w8pma~bUq2C0PhTS?PSqhzJ^@rcpQ*(fXga1$R47#}|##6TQ|ozj2?zok`J z+^Uf->TDSH-Atyrjhk)pkmqgRIqe>;DD z-0hve`Ww$HQkPQrpJX>4#uv%xh?G)`Z^z$a#<(WMQg&tT+@p(a%rgz{>$ZE-h77yo z_H@WCb%%a|*PpLqnjuXI_h{k{X>0T3uDg0$wf-j#vVFE{k+1C|^{4DcTP)lsUx&!gc)>kXA_Ly?+GPxD zRadV4XmXBt+kYJ{aNC(A7rFAde)quUdvxcSe&W#wxL+`>TC4G0=xu-(sJm0mYY-?o z{27Ch%C6Ja*S{I+g!(&g4O3Tfo4sMiDc?#U@>9C?F6cWw`qsCB?_O(HO3An24rjNK ziLLYN%Eim)5weXtvkASo|0Owv{@_~Hc9Ey+F8gKACbAU1ard3k_w}o0YxpD+?k@ot z;L^J?i7q37{x5tirSV0kd03i>MN7Ba;m+3C#L4uKvn>pU*!Y5Nt2wzkvzE4l1k#!c zF9#S1X6t32sRXJt1%1TJGCaBY{wDh_pn;kBcXb~i&q?}#83U}yT7CewOAbbR&E2$i zSye{t$?YKQf?F_qslJ}1g;7B4B34HHSyE8$BUTjQ>#cIS%2%`;s&3Me1m7YtNcyJb z>y2KI^8)#ktS&SOt(L!^IuQ`h;al$ZNX|Co)=%fh=vX0Xw5lx@I!bK*H9FL4+8m>= zr^yIiC>+oiVty7mIRA-&`tgA%A-mB>T^Mxe`D*c+u#F043^uvOHN6hHN=vP4KaehSqq)>x6p&>}@d8pOYjZY#n zLlzoyUN8rPw(jHznacnm8)e>}0ifYrRAO&lCIB7Bn8ZLPfRK*JZklbZxhjBavh*p~G2g|Jm;b_YB2aFku+CR`nFb6t|_PGY8YY?bHGn1B_WLVX-f z&!M+Y-AVLTYI+v+%J(PH2fkKveh69SB?M=FW`C}DHsQd4sta=gr_DM@bXSOyfKkx%*JFkPLD`Zjy=E4<6Vc9^9h93Al=)zo#Cl zgV5UjbP7)0+W}eJ9-{e82SRS~;<7dNH$;#U?7hkrBdt_rK-TX}=<&AdeLICxt#Bniqax3Zf2pvI6^u2#v|$C4^;CEjDX=5ZU=o`1j_3eyu^Y|KOsQ zF){;or22&l3WaER3EKt+MG<83PK&5`XRHCtQaDHw2t}a1*D@vvNb=JLD~0Lk;IOd< z#%V6xk_APKJhgWmJBRrd;>`iWAE$L_buwxJvBQW;TwSS}x+{h}X00-YX4* zy?B_Q9V*6|PMYhZ@#H^K%thReRj8EGok|>FPsS zaf=aG8a7LoujRKB_1s*=v-yOl@Ma(AmffIRmiv|e6#UQmcq$HdQXZ(8a6k|4M})Qc zy&I1}=)B?d2}CxTXxDj3aFQ4u&z%g%8$xy<6bantt$0$9sWd^yM-Y;Au^-ca7%4YF zP@-wmB7);hZ?reW9QZxED(hfkW zakpgiRHr>OP{!b~l+5g-)Gp&SRrw5?XGOlF>nB?l;E+}%_8`R=Z~%Qkg1>rIi5)=6 z+&!th@nm40nbJ*)HRyT{eorXR4qk+)oZ<2-tP*DT%OUX1zLe*a0c~e*gq{KvdWe6I z=78K_zHOp~n2D>_zdg5VlC`H~Yu2Esl`^;bByFS)GE(SRh$HQw@m?;2lkhdNa-r2c z{k}ZsCBrB|dR|SeP_9za?+Cf!%*|IHinSQ9X@z$vuTh&w9pS>}MfNfS5Cv94Yfw{* z3K_?38JxHslbCsI$}?B1mP-#&WuAENVZenbPZN_nU5D9s@|u^{2Xz(*<@_OycY2)L zS17WnwB$t=l@iyMg3Y>hht(x}x!XlkN?PP_Q4{Aa`WZtJR5T+7Lua1kcqA>1;~cIV z{_YSZKp%$){+=u1ip|L7m6rbvQ|YlgBMvwhrQEitmTaM_1T}>p(tI9|d?Y8?Mm7}~ z3M8>WOnLEoQlum40u}TzDL@AR!K6ZzCt~wc1zQW)i0li}oCldZ4J_or`vj%a+(`f@ z(NU5-0k1Ji3RugLyc=P0F17HM+>MI(ebOBUfAWKic?Hq!<$18_F_3{o=yJ9)p|Ivc zc32t>PSI~ImItn`IWvJ>3A_DN#z1eErPTx&!CV#m$u0I3lYeuCWyCfY-`jj`HBmnf zG-+#1y+{}t%BVWX=4`)j$XAJalpqfSDX@}VqEo58tHE%2!am)GJ>!V1dKza9l2ciOsKJ&jPXA@2 z_W(>+5;_3=WoLBF1o|3k2CjEgnv%gTKwM8EQ`wBe#p@dJ=BcTi=1DxQn`D67Hl2#7 zV41Yt)W+Im2id^0lbiiy3TRZ50;pDL%B+R*>u+1vE7jBbkF#V;0~p+{v)TIx zIbl)^@WPn`R!<+`@%Vw`z;=p_7qgV-_>KcYqac3}MAsrnu8SK5RT1KU{>r4YK{A+6 zgMWoo%J>TCgCNG0K#cN`&HFzD0uW9H_=s@t^f=IwEx6*4bc8|#=B7k393fl+_yxj1 z10YC}U%^}euEve9N^`W0oR3-zFs5}xn>`7igq6oc>=D?>BSHNY8qz_jonC;qI$qmz zR1AgaUypwDTG)bcTy8PpAju~Wir^~c$+f5pF;}FJ&h#c1hWHT}xXq<4l^QCY79BS+ zc3ldE%C5%PJP_=m^xzcR&Xbpe>-ev-ani|OPrH)=A(*%1rawv{Yi1r?v3+3fYmssc zoSQhRdXQa!eSpwcU}tQDOy*_^K?g5&1|TsSr2rEyV>AeGzR0ro(F}4oMDrByf7UV7O${)k16Qb2 zRKu)q)CSgYQnS{$=$~E1gX|g~F|TIX$D2iRBVvP#rB=KmyGjIVhEu7NYFdl+a4Ktl zNM=pzaa9MZCl*RZ$n%LrClie!@Pq<;sO}T7leZ%45?vCRCMgpQQbJ#&!voS+N9lH{pNtX+AHNkzaEKd~_ZANJZOD@CwuJ5hiSDQI^=mA){1$wB8?LTs#I_pS z#lz_LBz}LIphLPkY&@GJ2O&w5JQ8!8udP|xAAmpXf%0HB?}LV*RgvY9CX)1OplXopDcy1m2ZjS5Vn&(JPg znp0s?G{Yu?!5j=E!NvU2NMMr+4F@HVbUH6`kdP}xSqyhuu?lW`p+rjC}!;|07yL)%-($aR%68p5Y9d!3TEolect|PXa&yu*Y zat}MZMOQQzov*DwQv3OJbjo zhwa??lKAEH$$s-G@KI`z-fLo0Jyv;Ms}VZztZ$~st+1@3E@!^-q?)g6>gSV_d0Ow#M_ zbq9b$c;l zT?k*SYsjcu4r^BC682V*!%%3Mj?+R;1x5DL$ykdGcFJ$N_rB4@p;z!}RX4t5(^ zRtBK~0oHJ3eXorMwt^SRuZ{vUREUEt%GyPTIs_D?@Q{o0YBG2NL82(v`WVu$&k)29 z;wdJOz819LJYRJjK=|lL4bhom=hs<<~SZpgTbyI(^^6UZRJ4V^(^()QP%l+c!LpxzU~4qv5ISm{HdJckQF*9JkkZdJ&Ow?_K~~ZHnut5*A&D~ib^wktKR0uQ zdan1|Kk#Jo_jxi;{EgXCU$uu1oYj9ngMisdk$b#pvI(AoA{k@)0ye$rKyY5Yqe$Rj zoC3gfl(7GvOmt@~R-Yms(t*)Gt1Vp4GuAb{QiLQd8v*t6v@6@!6Cy#CWY8U`xrP$Y zU8t+-j`}(f@@Ct5GmT|y?k|;BD*1UKO)+RCFT!Chc)rF zbf=Fx?60La#Vk%oU&-vyaVc}lyWQi_Y~KsfJQ&ce!&?il^tOEy;)2W>RWNCo&8Ph? zxSD-N$mzz}VYjV8x(Ug{gU){U_!ppO?)=*jBegZqGPdn)NcB!T-9z3nYuA;MQ&N;U z!&$xardLiW-2}5_oB{stt@pt#`e&dNzdK)xNKa{rV!-vMM&ty1o&CybS9)*SpZSEqSBV@aA`-?BXtF89b zVzfs1)p1r}U1wR_)^fGYWoX;e+dzBm@qQ1!4=7xRhXm(0?7=(BCh0$7rHRqC3Aq*A z!v(TRSC z&aHmD+#^)=v9EPc>Qo;NW}cWR_m0?t8;=lh^pJqW{sF>6?G)hn*A5-m|K*s6gJNlQqJAAW2HH;rFt2k;^c z5_%F^CwRmf0JwN^6w#iJo*t!i|JBCH))Z~98bT@ue2 zhnaZhr(i$rv!B9x{f8=A9;G7%pSvJb{@iy%*woVSAvz|0`An5NqW^%(meVPQeewxW;fSLOv^Y6aQ6$bl^)HX9@` z0V{UiXTt?>vgv#_&2l!tn4O66#UKv)@wAxF2w@}519@BlmkV)GFA!f2vVNdRm=M{} zY=M|}dBH)Zha+I^CZtvcXb<9s`Zf6d@@Tr2#c=JqYPf_t(8Co zqy9Zqm|VWNRQ@CJ;GIh|K;n&e<{UQxR^n9 zw5SY;p#mNO1(WDSoF|5Wb(?^3BIReBKFvQixb&8!F@(t128|n%*BWtZJ$T#<8^@Oh z2-hHf;5!5Y$?fU0Bsg9{NM43qR%y)fnSzisHnz_#V{BrCkqo~Nn_OeYEQL8`5(6s? z?<~rlC9k~d0eFo(OEoerX9`NUq<1#FRw{RXnqG%>G4ji}0&4KTo^1W-@c*Ze@A1FB z0sOC0pgiwh67Uhqm-I9No|_YBG9vWF@R0-enV=Gb1oAOl&^zM|q9nZK4x$6o^^%_9 z4yHm4K*K+$1X&9#vIGWDeg2<3diu=4|J&SpdVl`EcKNS4uvZgkti@2V8lpPMZM+(2 zC&dZtxuEO{XM^a$<^yO^zv_@BYnpD^|AT=OBEPjzJQQ&czQ&rKvDJ%w;VI$86{sp_ z1p8MNG$Qal1+CC{S)vy8pt_{LfAtsv-lD$#l}6NiO85fZtBbjT-oEnXVd1`R>e1Tr zcW)5&T1mocYlO-w3lRS2+Qmwy{r%nT}AWyAEu3x^^eB5R`sOLup z>u_R&9Uf9%qCq^bzpO7PbWOwIuMKf6DZBvP#iKekLRFEIb_qR>(VTj9k!=Uo#b&QT zMN$6@H=tb>mseV$Jg%<3jkAUWSsH5Fy{>nK^1o)iT$1{?TK<3X=-DIZ{m=f`x|jdI z75RV8fpdChy-&;&IxqFZl0gB98|fLw5wm)=_76IS23ggOB}W zY6It%Dz68aCJ8c+G%tP>Pb&(Kv%#D_j1*LIPD=74{Js&$F_y;!Kly$-99Flds-5Y6 zM@t7&R@EeaDd!%IEAjF^a9a;@Gq1^}@A%ANUOYM4$|!clEALBS7gxVZkDLWw5rpsB zziAsc1cSKGk3_#lP7gYJz4N`p&e@rUotsUQNtrD~s~v2k@L=z*pPf3-L#_s2U>QAB z`0HHv2^5iD+kt1hdPoT4M5=o*vZo6&VE_(xpEyJ=j@C`PLr;ctYy%SYMi>N z=36Xf*lN0c*ffgG;(#v*58GgpP+IMaB*28k?3j~goTt(mVPa;AVlEGOt z03)N6d+6|!;hh=qSi5#`4emae!zUP|;{@3YWLzBe=cAaX==meNgk<9}29?G6e3qE% zd1HoP9Qhs-!m=^-Cw!C!bc*m-0nqIFM9 z2u(ud1n^+Z@@+~k>kH2(9XmChzNteu*e^759dhRoR_(DZngc7zR8uG+GR1m-G#L{lv zRn3MMo;|ExJtOC-*csq_3bY>A9`kQ zCx*^hE>L$aeZ7DvJH=Qy*xe4+*Q@#oH#N} z>yxwj5&ky-z(XH7_F*2x?Eixy7>9#FGK}Y=0>h}XU}Gx?=U_f={S<7%+c*wz3~fZ6 zOc@8K?v*CJGA-1LZ%wpVmu^gpyu5*n_Ty7;yRf?9% z91q+ln>CRWus<*bFXM4Kx>eWULr421lDzU8Ta1m&tTmsZgKmq{1n#CeAzK{ z6_fTLC=PSkwnGXIZKNeq;i5~os4CJ;dh)`GO+@NdvSL9d9zlCl&GJtMBIm2koRHntk;hUEr{tG{~N`?BmuXW7DF2 z|2#*oEOXTD4&9lBQ?PW;C5wI|HM|eO{$km^(V;b(UhNu;#?pl)jdt^;a?m}NYr*xm(WrYatjKb^=F)HU z#H!4qw)73(JZ)RO-<(Ei@qY67?neDoovMn0jnMrMd!yWiFtiUxQa%)lk8q=37r9|% zGB_iS+@B`^`w83+%Pf*TPNadu{2!r~r4W*chABX9Zw7N@vCzd!1j}RxsE5bUGy`DV5Jr^NbIN+Fi#zFH-5n7WT;+hqX%4ZL!Gm=0;FjZ|JpwU(4fedf zPpth9PKIn+S}jl98>Yco5I`_XFoc6kV_RL>oMUjn-@-I$$y_2IaJpUi~oGm>26$uPf zj)O^FAWvd!gfXS{s_^}FJc6(^ysOJ z|Ni*dJ^uSc#@88(|BCQ`s6*xWpL;PoX?Z<7D2l{3Mkp`-k=<6aL{$PTg1u2XU0XvK zgdYOFO#wnM9TRde#D?eWPDa#VlD;=CFe%rJeL0N*a|EFY*zZKb3xdF_23QrSM@)t; zuITR^vS!UFb4m^hpm;RBih{%NCs@*83T<;(ZZPZ z*CG-cMKRxwXG8%@h(j4jT>jchE*vB)*Rt^7aXVA9BX3mh^S&CQI zsG6Oj$Z?OPNQLFe40#KqWXMEJs|Rh-{tQ#QiBBX7EYP9*G%qXV|982j45q`6eI{^a zKz^vh|J!n9_R*gznJXZQv(fcqT4K~!}`v^^QQc$`N^o!;KB=dU`a?Db;Ty9SWI zBEqzsRLK!om{^1_0fLhg!$iO;3wKYGapXZEq$bghbAoh4x63TML=Zvh+Bk-EfAJ_f zZBmGYw}?HbUc?^S74YmR66Adt4~cGP__x;8yAcu@pzlWIc2MtpRx}>A$OP4pB0V_k z670Jd$rXEDW{W}e5j;+GGVuW7b_!PVs_EqU$ksvaE|cc%md7RP19iLw87KcoLwZ@x z^F5JT>;)*xL?Ycq#z&us6e%i1J-B$IQ9QY13~?A4Dcj{6=rqfUz+n}r`;7x4{UGNV z*tY}dbdF*%+oFL$g5%E(&V7Sx;#6%Wb~8rI>u%7*1u<^5ha9B29WWLOuNRI7%c!K< zbmkBY%UeUu4LhxEY%;r^2+DvSpY(6~qvT=1lZDcY=HK+2BwUA+vj@tM*}TsPt+y(l zJl2GW_YQ{;G;jlr4BWX|2Cmzu4{ZkvlF6mV>=<9-+V|gERu;YF+`BsgP+yZW6p%}c%grXVq&6|N3j!D$r(}S@X(x&;b@*;y-Fsq zvxTuuTVOJXSo2S%k)VmX>$Z1%a(rOvbpYFuiifU-r4}i}Snxn3Zdh}#N-J0QnkNln zNuD@NtDW1t$jW)0FEowy;}WV%&h>M5L@_=MDEYkPqH0|F%YljPRF3jF4m74qRNB() zMCI(kCZImVy!YfIMWBuTy5>Y=lcV_Nf-f-jhIb+?WzlMMdIirIPbS_1n77>j|MtLc z(>+A|#ruNKF>g2L6Jl_GeHnDdMMc4oUa~f!Z+%+EznW!}^dE59 zu@CYDmdQVg(e!4(ML<+!qTbASe}QmG{bo0v@MHrKBrU}KCu51&MFr~ymZdd$LuGw- zg%OuS>leJ3fOqn|nW8G#lHdkJc-J6i4dwCyCWM=CmR!u!(IAH>nqr9?W5Vvv1N6g? z8G>37NwxV^HfPe)MG^qgGn-?OoY)ACxW4Iuw9>rmDp_%WyF%HFV(*w4)TeKfXBz3+VZA*14|hy;UAa;*zdnjeg( z#ZAjD$PU1-8Syge1$RyvXN+lz3x6Ena44&Xd)_)ij?B zxIl+q4e-}EF=yaAcJEqUaYko|aZ=|}?qTaK-auVu@4caCSFhb#wP29ObS|g-M%|&7 zBK=FFG`sN~vBWR0nAez2(C4Eg)l(GT?sL*9QG%4*Xx6PpB_Jx_SKoebaCRMWK?74X zsrQ3f*La=?;m#=GzoP9M+9X=>a`tKxOJ7XJD)15|DnfgmK)j&sz*$S)ncP$h1-)+X z@Sx>eJ#ALu&l>_9nn>IQ=atr~TFPa0^}O@y)j{X9bG&!EUMsYSFVz26CuiMW_vE;H z{7drzyyEGnAjsl#cYE*TxOZ^eJMSK!9rVr`^;yRy&OdvHUv&2Vdj8^duXl3XsJBXP z3gtr&?E<<2!m>%W{es)7hjzAidUAMp{_?Q%OS6?w0i9oF9ez1Id40TpzISqXa(dQy zHMCg6&)@CC?#tf!$?-YOwYSeqo`J~Jjcl?(iX{+RxN9uB?CNkUV}wHo^%M#|lT5e+ z_{iqMnWidVX|p{Mx%C z)R4U$SSoinz{7>R<^+Q*$tMqr;D64?(}=xr_-l%) zrBcahlMgLr8P{S4|M|cEPe77xTbNPlz0_*66FP*K3(o|p9mef7@9`!}EP4c`V=$xU z{Gmrp%ArSWF8Na-Al)s#mRLjVapqWPfg1!i(|^FHImjpRlpKW8LOi~5q5s3?C+zt3 z4YMN~v)#xbxtw7T1}X~q3aYoNaIc6bz~@HU2@2X-Cef za33`6F`woJp8)$u$%cnLt)T{n!+4alM=SoPCQzV}Wlbs0x-#qn7$fFHyozy8LM~Kz zXk)ZVHboUF7`#z6krr~BM6R~Q9cMG1xf`)X;a_mw^XMkr*lG#AKogs^dcw=!I<$9( zv-lFi1s|{z1AcRq_zu&Xa`i6b^KhEzHo#kwd$36@h_Yr#7vdAT;m~y_`N(EQ#pbZ* z9mW8$^Up8Z>v;4&fZ^jME0sSRO5-d{73|!z*V$D%-AE@8QwRp2NfLRi>pn+ZLzC$% z5VnY&27kgfW*jyJCuqxUq|Dh&+d!zyMEOBKTp!YW&NeICGqZ$r`N9`NYV%)DM(KND zr;^=L^D8xJ>;TNC37Dtg*g+CnHsLoAoHL)Y6U0}LvF{Io1sii7`IMz2T$&`{VgEbI$9x}}d#`(c*fu>uy z{yKxfLpp{5d);y<_N2 z!|4>#HKKgm2y3#^j$R}~P$i>MRfIc&V;mDQK#UruGj6{dupUpDfRHO?sonrPm=n5W z7|}2m+tbpP0qb^mr7dJ4;$!PeKh=ok?IYOFl_LZSHo@5)@G;e8Mdj4%ftzh1C za?Tmu=%*zuTH0+3w~DT!>yMInMr*G>HG24Vz*j>y;p%G6q44G_;d`H((a?? z6J$5{%4}{qcIyH}jrz3adDaaSlV+T%A4A41F+v`njE{}{D&q{B7!ZVz>t3ch?HQ9I zMoHc#;fy~*gK0LMPw^-t^h*j%88cXm_ltOM#rX32M*{c4^hP@Ih<6bfu<#<8C4CTG z;Ch%P=o?EfuW|IX*wSUdhI>2>TZrSF#?8zgr)aIt0aD^Sr@-59_QvOk9$o-BQ3%blIH0Vm!@T#f-t30u>_FGDdw=ay2v|ry>s10 z4<5FTXR4NtT|QY+DugP3oXpb8REUM4BIFtmP5h?1soCKptE=Q&Xvc5cSiq94^EuY= zyM}9M?)7C%SLupvww5x(RfkF>d?!tw55a-MYn{a(5>c2u1*N88JJ5ode^&9%&+RCd zOvE>EhfKUiQc`)Ko+BGVqDrp(*ccIQEr^98_41;;Ta zuza5+Q-tM!*gx`#kOl*|Zs7rw<1L&1BY8MTNr0U>QaJqkdq$@zUD6q zwju!ltzbXdoBT2t4whO5Xd#)l8-3=jZiJOzUg8R>#BXTATICqF)V%pV?~ zDt5sscJ!_rp?f*WaJs~(Q)3qbMqL}u?scLjkexoz+fHsMK!*JRFVd*_K2M7^7g)tT zQrm$ZH_vONg*kv@64+28?@1k6(0SE`Eym^;#5m>qi857w6%Qb&Zh)r%?OSL(Hx`Qo zGF}99?Q8;It%T|mRPi9fU0-GJwB~ycBK9UJgv}2q*DL<4iD9|wBq{;}T}dfU=N!9y z#YDyHkwCDkbpTL z4-B7Po%j_d>}V0J&mTLR5h$Tq_u;u_^gM-C$RJCjbe~@3+h~}*@glMidp|S|N z1ELEYZJb@~^@a2qRtxEFSGJPcewFd9Qezqq1}KK=-ix{%%73o8XG{~Jex-&w5JfxC zKefDXF8$tvv&ls0nHys%^gmABv zf9asO(jsUuh8=Gn>q~Lb;vM#f9&@v%HO0GXz9b|KkQfdBTWw^jrH@w2H%4P$7|?9F z_+J7=@O6=na)}*Uga7&Cqo>ySfAs9>vmfuz|5uOy`bRN7B`z5D1%Fqm@I5(U2l+W{Fh@5kUn zGo5A7Jo=L`(UFyhH#B+3n@`yoJ~0#I+@1Nb!sKs_Mi@pf=k6w`eB;luG`)#WsM zi0Gjc0Hp_jSim`D=aG?QuVAoEQGBT&W`cw&Gct_AL}wyoKW(^E*lZAAVUS{D#Z;0^ZhvUgQ@K*bSBYO?lCaG`2vCIQUY|I@3&Lx@2*!l9`B9YL3*yuhA{Px{lNC1@{aU>zMA7|e zx~2?Of{x-dp+l(kO<|NS_x^Wd=41xa*_*Z}bZg6LrnxZU{R!fL}pKvm=HM)IuJY&q&zUoqbD5umPb+Lu)8D8X3HOH&Tbb@!Z7=K zXfM837F>8Z%O7|M6Thwui!2;${FaN>JM_Mw6yaQ~-hsyjch~X+3ct!gZ_h-3G*^a? zT1FY(YIz}kj+xwknDJac6Ydl!>9@>ORlc&SotBr;RIBr}1&azpg@^`|Mu@6 z#@z{H(aD+)qs-(A2b0;^RH1z0vlq0?Li1-`J}syKhQAu)pzh@ZRirOuUA?U!zZwO0 z?bk1U>mK}m)~xcDSs6p>o7J?qr4}UKto~!G+gc=cGXhVnu4^Hs%?SKx5-^?4n~`{G zk}x7fn^Ab?SpXf=pc$3_=A)vJ9hwpO?+H`k?5whNpjI}L0upY)$rOpU@ z!N@Jo{-zzw{?Iohl+pJWl+(8h`)sX#<~pOBiz}Kk)=GYU^e`Ap=&+e_M|pZ#nrN*{6H??_0_LC{rt~ zIL(nSgHc5~5bu5tJt&9!=(?Cp`ZxSZxE*Nz{UoDu|3)aZjeg4#1d+017}($bmW7n- z1lAE@3>S3Xq@-c%qV+7v=c5AOtRgzk(<`B4#TNA` zX=xY)&TNKQJVG!5Ulndzqb}U1`TOSVe_YM+&{c6Y^8b7K=<%jw|9kZK*}eVmtMmWk zE4^qQK%fndeHU5wKEkk$=wb%{bLYX)mb4335wHRys4`O zVZ+4hI=OzYRi)8cA(E6-9zU5gnDuA zLSCVXwk?4W)R9fx3T6fXI|bEAZH?znz9sHquJEj_uo>t{mA9q&Y}0<=8R@kkT0XWs zDLicmJRk}z_l!gP9ZYL-aU(*VSN>G~sn+X9Cof-0T|K1A$VTMn)CRmG{7a8e1SP#F zf$?>`jP3GMzhJuphPS-vz!UyfK80051C@4hYyPE`0<-+olg48$;<=EZ ztcTTP#H~{BMR!OMR-qMpEnK1MT%g)6{<-j*{u`_QvEuhX*zY&4siSG)fRKD6z+2IS zZhn(p#=lE?UPBMTg3vj(Rlz*YL$m~J053r1e#WsAL2?)%A@@Jb0eFR^_DU_jMMO54wNIYT#L5e zG0Ux1wFTgyY2KZjO=gme^H06TV%jgVnEZo$_`+pO?HGIk$$s|v2kgD#6q3=|JHSMpfe3lgG1 z;Ajd~asT}P68c|{h`JOHfExYpV<-OS@uMd{Zr$sDxp49IzW*C2Yxh*{FS_`J-WCK1 zkpit9yy_myw-=#GA`C8LWzY~H%Vo1bd2y{NH%H#5JYtdsM}TJBEcwWJ5&2T4B3+42 zfJrP6>>ghrFdwUxjz$7%A{h<=j^_h#yK(%U2`SJ+?3hY-21FPGWY4$={yYL02EY+Y zN4bjLxn#}HFCtvO zAC#{;r=7#Y&fn{3B5o6U+ej=6iiSb zPbXaYW|1Uvc+lDJ9{)lgNHb4=_f9+AL;U6btbXD14;+*02baVKs?YzAn@^nc|LF1N zv-|V^O}zj7hTVTZ6M0G@JYxh#vQcMtIR_}hgOB}Wib?9q$*`B!9AA~fu$+_S_XxkQ zs5no@eD7lUGr&5|_tW9k|cb6%58 z-|?Ay);qL0S4Xld#KE<-f72E)oPb@&XS3D0&*?#DuXnz8*f~3Etz}aPrYyN~v|0h7 zH`~+uvs>x8$kw#TIDB;YWvAO1Re+Xf$$T`t;D`eS8MeUkdC0e`=j=H>@k@2tmq~HV z77YD}wMFriL=bDlSQDt0q3N;Uo|cw(ADEsC{yvETgWjUyGy|Z7*yS!wfQo0rOK1d| z`5qi?-Fa?~ZxVps-1QjeL@=4zc+9G*mQ#eR zDIzbc>IzZKt*z8+RrQrjSc*|!TtS}pMt4ZK{)Qb5x~$#`!-l76{?Pmj&iJ}7nbIf! zhA0Pbo zkD(p@Wfp%(i<_pQBkBk8dN2$?Ixg}ZaQ>Eq@*}~0gkuxJ?)Rl3i`yxqpj$wiz`Q_Q zAD-w6jFOi$GaOz9C{0J~p>dUs1{?vKAkRx|V}Lp>p@L*WR0Qdd5oNH?c6c_Ho@(6T zgR*j+_72ejMM7iaNXlAb(qaES#~@bdIN_Fb4%7|3Batl<5F7fyq>=&?uFLZnPm&Wtvld9g*Y}@+2gBFMwV;xni4w zV>0oU9)L&OFouAbvtH-4CzePUhk25P9Nw@ZgCu&RGkV#47I4{JPWdGyaD$wZ)B`ad z$O)O#+G2H>vKb`%$`gvPL3%ToF1cDF{em%>tUptpgk9!f!+gwWxW?S8d}k5TJ@0dW zKFc$=2hsQaZF)4YJ3=8ZdUm#r9-0(rf|A8&Zs&Z(2 zR4VZuf0iUZgT3G&8ye=O=erGYd8T;4T=0_>u4#!Qw*$kK-{Myi zj^0r>%piBGOfuAig?puK55phS@<{~_+ZhZ*kd_E-bMpmS1zT(W95NK@)i`a;cWHUQ zLmp*Dv|G(Hhw&scg0Job{O*a|J(h&~H^m6`y?h3>W7aXAVO zFj*kKHPoTRSnwRZXgK2xCv4o8Faz^)?aL~a7B!huGqeLH=4E(o`i6h|Yvx?~y zDG?4nNxAbOJAA(U#ASP}I+&7n)V8sYoq)<6lLmN``kuDmfA7nPB!QQVY4Dd-h)jkc z?nm1Ui5gek*AoJ!n=Q!^xub7r`_g2hBBi} zW*VfpJz)m0b^C(VCMsI@`ZjY0EOL_23bG^F4%q$3uDpvo0Z6%5(#td%4Jb+AXR-fn zJ$mZ+|2+Qj9{*|fkTdUxqzw8B-hTq?m^!4z-scITo_n2t+->zU9E_eYn{aj2DW>_} zuBmxUoSvIY*jKR-t&-yB-t*9eydxECPjIO^;=Sj!sx)A$KlcLE@;yg}7K&DOTlMrU z9WUbU$#_0bzB~*fObV_BHuaxATy3;w!9;phrJvSJD0t{lU5_Jd`eb&@dIxrv-Yy@5 z-!-(FozNO@JU8>>qBv;}8(NmdGg)aXy;J<7=`*F*~e|h%w$vyw) z?Dp%?{}m!bMu`G&mgL~@pUu#40pZ5C&lr;B(F=IAcr%F{W|v-tA?)4KAG$>{_9qfC ziaGALe<5E41o!w$bXgzM0n|B4@L>t_Etn4wZ1E&4MxS$t*FlVeN=T@ zEtjx1(PtLJ$lN)dj=;ODxsi~lj(9t$*juVB>NxjPL=r!s7pLd#%_rq$HUs{%9favXI1aQd3w`Of(5Ow2 zi?Nw`k4RP)F@mhJpY8J8fbtfT5NbiL?Cr`M%R^~l&}+15QX+v?PoIX_>^hzex`U&5 zYJG>toBv>r-VD&i8zB1a;Pk9}avYuQ|Mk3k+zX!X1jY~Ndnfw`XVEKLdNu~dB+p;m z)d)Q+91Bp5;7>dm@V%W^oQBR-`#9=1qG-ep-?%$?i+JRrv2>=m zZv;Y@Vv*}BwaAvnvkt>DzuR*FWk8z0A?clqrL6}I%k_rFra#lRYlEifxl>NVhPo74f)3@s9YW%=?OT1QjXe!QS!DJIsbW z$vb4uV&^JUGt}k_-xgcMOb2V_rx@M1J5sSx(UQ#vLu;F~S~ZKfz#N>-FYpqtoPvCQ zftRS7fiwDMMH9i>!|~o?GD`0$PFJ2|eqouH(*oC$fA&9l?uq<~ds&JSNO!?g-OVckdt64<+Xev0UtKX3iwr?p2h+Evc z^o$ET<>DE;*9?hr$e12-r<7etpJ35u^NEL}9%h$O681X3w9PyHHGvhd(*u;~T_uDK zbd6#6{e!=xg9p)i%OhbJ=5Lep)G}Lx$(ANIDkHO?WJ!XXxOkVgbi8_mb~Ld-Qm!WJ z+7273slnD}$soYk0l=Ll13xACb3>AB7)3hmB_9hx&r~nLDekF4p%MdZ>OS&uzn?IH zkJ#a1%=j>G#n=x3`;`;`_Z7T@i}@YfRU?G@EdZ&4`}u0lPT@No>pSiykZ8^toF3q4 zh&{wd4HCYKX}EkJr}AN{#80)i{UI(n*WxvNx^0Rj$Jiw z#X*|m3(r%Pzx`xYr!J@{e6vh~Y3h|p8(KqDH$V-tV_HWRv6B2Ol!(vm*NS}}MN^(^ zz#5Z?e0C?Fk(yimL4y;F*u@LIg?qh_=qgM)L7fqLez8Z(gfTtV_Z%JCU=HDRtkVO_ zQgAVgCrp4~e3jev0V!)1jORHx3Fb_qVH z=l}XY6{60b6kj3<0>gQbiBcyv27l}2kifZ@f{;DRX0#^3fBc8Bf6B~!?pYDUK#I{$ z3Cc>wZ-R%ne9I!*1_C{uP13FHd(xU>KW-KNm1DUSMJsHVh&9H27$rm^M^?Pxxae&? zqamuEw4{u>HzORhd7k(hVdBbs1Qp2#br{GI3$H7l&yT&8yF=xw8jslSgU(|rM#dD8 zLtc2>m}D?HcuVuX#RcrY$SEwIGB_C8U}3AYR1Ud{aT7wXLRVGGZWtz8nDd57G?I0o_!m-g`h0PJ0YE}*O* zOt_a0n&7xJ9A(#XTSU$-)`>eDoKZc~erF6h5)enm_pMfQ>9;Gi(ynM!dJvmQGc9oS z)a_)umQ0EdEO4kC&kNwnQ08R#vq+uyJZxEsIvFu<@#i{zpU#UUtJ;^BqOs?EcOCT# zwIM0*$;mJj?yu2l6c@#ef}PH}gO&AEr;ZYZqLODBeS8I=*>~uow}(?Ax||-q%=+`C z_i#u=m(#=JY&MQZx9;G=o#63ocvBF?G(XM?OkNZ^?!4x45H*SnLPiIaF0$1YtzwRO z{$)+3c|(?}Ir-v%t9UuaIU!E2&iu6g?MBH}srEll{>ROyn~xm-zpZ=!59R-d_M@-J z|AVaS2;tZr^BbofhzG>Rkkt$d_@8X3x#+R$Y#PsU;Rg&d-n&9=xpzPgLyT zjGaLyUdO1$1la+C%Y$&9le9QU3JUIk5Cm40`#0z8DJqpzx{qMRURN0wZlyrPOPE`d zDytfrcviW!_LZK0@d#SA|JRepk3Igsn~(1CU%wsyujXD|wccC0lh*Qmudb5O6p(8` zaz9N_GhyKnRn>y*B(mm*WOnbWB~}b(@ zik*T8l@>wBdXdaXZ)W+_absVQ3o{@4Mrc4$D8gu1c+#{e{Fqu3G0f6p%ml9D3cCim zK&*(WG{;DCDHZ(03I^<<3K>8Ka=47*v(@xx} zviC;^N3VK+I5j;Ub7qJ!*_|M?H727b2Rnehg(^`DVU^3h9l$Xdk$yME-6z&ckjo2D z%Yy!o$A8X}4{0{fiF}C0Qu!Si0@6X=!gqx5L=}``1yY3XiFuCsCW)) z13ZeOo5(+X`{@khi}kUq@ZZw}Tc#e_Yms# zB)R4hz%#I&puI)VGPoBM*Z}l8CX1#2V6|z z)e^Gz!M7JNCR>UE4PuII5kXDoxT$U@dVsc|DQi6yo-iNMSZ=(Q87geJHDE@W$vh+u zUL~!0Q>uc}3zvLn$8e4H2eI=FRAyJ3!M0~3hIQp`fJplyf!JvF^$n_hVOA781V0UJ zofNN&C!ej_4W5;_ujOlL!WA&+4LYDHmibDZ@aW^*aUq^^(K(OaCpTa-gIwgD(I}+Z z;dk1Lo9+M$xD^G{T$liE#51)IcRa4SSmub41Z9Ev1Rhph#|RGp*ehg;srT~W&mcT+ z3wNwmxz!bakB032gBcCE(uA=|)U1}c;i(R4!z(R`J-bW_?3tmI^_C=8I_DgEE4iMA zpO}PnM1werr&f-jJ=zUm>S4S%zJ#>%g@Y7cmnK!@P8}gLO!4M#EK*+WkKGndPl^*H)KW?u?&HvRXa=786t~BOUuV)SCm=d zdIJ`)F-VwL29g8%rL^T_;_Wk>kJzO#+koE`an3M6Dom;L_$LB@nXTm57xoztYV1>YpT%93rB5wUd&(q0EPRqaJk^#EW(_ay@VuxITCxZ+@5 zwnU0g6DACRXc%0l_zHN(-8kO`=yicPWg(d%TEM|OB1NpH56LJO;x>=?mP^Tg%cdmK zIX69sCIn0Z?uWruHcs5xwQr2F`DyaV*5~Zye1s``NG*hLDY9#@N4RTb(3P^vK#3-- zzF#yc)JAF3WlJ(_5i@ZG%CISr1eDq6y zav8J;k4E_%5b$bn3o}_lfAG`2k&IKU3~wN|jq|?2BtYCK0*TBjA7Gr(A%FBK7#y8K z2@H{M_wpm#Wc@sg_d!12$fq25%V9cUQ~7J0Uum4g@){8{wS)5~V8%2#3BB97uUIO4 zy}kbJDaZm`ca{zuSFse}kS4A$+giY9sSBP;h*PHrqVnEBPv?{OlMIjt!JoUzciQV) zuf4j&ptu_a8yQy{Fup8&pi{P&_;{rowKMGRq$5U-ZV$fU+7}_Xn*)%V=j+{wZu;NT z;z~OHfo~Z9yJ|eVul$G!_bCz2;d!^LU?6v7zm?@<*2uBIGT{=EM8_H82QRr9Cz+Ogct`1ML~HY;MI? zvn;^Y^x0EoEQp9{6nCbSK6;rr?V7i5%OAz$BN%RP0~7tl5NXP;ihWe-h{J^18uB$1 zf@~kte1HNxnHH#|W^6T^0|K0IE|)%;KPb%{i0}3>c`IU$vU!1;M7p2(D~s5_PHKZf z*6E6dAK6!sd>5W;7XVWP-!nlEBxJ5?8&|V-lT9o=cBNTi>vEv^&PCkcj?0LV>ns*N z=yirxj-@;Nc>l+LSk0F_J8d4Oc3|y=pc-W>gj|1H-)2=Vt!CpiSnCCALK0VXiZ|ZCbB8D?Sz2v%8!<-n9;+z9UY)ze8_cW+_2B%olIamW|VWP zEZUYIc!!CIc|V>a;H1cG!1)!TS*n?&X#_1Tfzzf|Hh7Gv;ZPa6WkQ)HN>(7UumNs7 z7p9|vSS!;Qp3Gj&3pP(xdPrMO!Dbe6_%I=8mcmmALKm1okd&!FC9}|IM0`=h${OXD zzv+1p%Ko(4wFFg%tIL)vQT_4jpX8WmTS<=?F?aCgF?N&}NXPLd{`#7AoXzSC&u!H- zQp6FJt6DPnXF9q>uP5R$!;wI$*DAN`OXVD^uCWq4V>F&NAAlVA!ZT<^HuUj19VXj> zOe4}-AjtA%oQ}v@kS`1#CS{0)`n!1TE$2ib50?1?TDcJqBmW2w9dcw!YQOwiyQ zTNU&e7NbFvpmz6^GiH4oR7yLvKeij4Wi95HeYY)Dt-D^p*;ljqgdpaH($`S!B1Sxl z0DVYEHA3YikOfd96a;qZcD!X5z&%nrR zvmTU|(S30BTakW7_m}kY^u9RjUYhm`vx)E@`l8n;{0S{2~|j>_UJ>j)f>r|J@IsUx3tAFBEsBz zoQJro;_!vPm=z@B)UtlBA;P1T%Bk=IHDq{5Q8^i2poWZy?<%JwN|=N+DXQ@csDt7u zKI*^MT)#g5?ECEHe1d|NH+XZx=En!bfrUWzyyZ z-zvjZOTA&3xbTLb*S7(`TxSJ04Di$*dGhzUj%`;EQor?*2@X)%JjqC}oTEa4OySZ& z4o@2SxS>ipX2myr_pV_FpeYQ?Fc@^sY0Wm9Ok=tQjh5d)ZW@GZxnUK$UNs*yzw+3d z%>pJu59{dmw#~<_3Oc+RmS=N3V6wUR*52_=Jw(vu#Axbvnzv6LfR#al%Ur#jGRnNI&5>*rV>_5x?uenYkthS085Vc!C% zsa}dHaX)v2kE3}ZJ!O^sm>uFb9M$uI@Pj4G0y&AP+*j!U#cq|%NBaH>t4kETNj}2O znhHjP-hFQc_|7zE-N*LXUn3urZ0Ne^J@1sFWt>{HKPQkxwSYeKOJ(l z%)STlaucl5#?zs55;yZ8M#Xy=I*=DB_!1 z;F|m@DYJ}wwPv9Bsz(vsBSV|G4TQoAr~Uyl672PcnhM&e5B|c({U8d~gMTg6=&j>W z*nS+OLzFEGZj9&Isn+YPo|;L<0YUoY0?>$V+E)jt7;sR&S!_mM(Y9Tg&0h%s0o(Ff zwQR7|PiEew`dQjvpn4jkT!No2`wdLd`=Gj zqscW=u3%zF!{Q1m6JJ5F)4QRY-Cd));bBoKj(h4^qOV`*^>RC()a#etZsScask?6V zZgc86g`V>O%3K8cG0^7DvsKI~*`t{hGoy=*xvk;&jly7%1%$ zo6ka8enjRwLO28W8i3{?^gY5|Lw6-scbic3IGB{Mm&NERDV-sOo!12Hp}-@sNRd+b zMgj5#VK$S>U2xCIa!E}$de4##sFSw@db&dQ`zd&*t9sv&^+c(TV`VkF=FMCr{df+_ zh9-J%%CKo@yMJh`;r@vngKcm)Zs5^fjdCWXQ*2;w$fz4udNLk}pXrO9@OpCUtC!LJMh6_~N;+X@^=*NP_1~x3xtU9O)AB+E+qAsYvl{k7Tn7rt zFC8K!YTDTe_77fmULW=pD%MMdM}#IYu(iyR;0vZDB!O~z4uyVsB zE43gqRdEv{fgCo-6}7wD^mW@UsV9ijs;I9;UHMJh@rvd=Qc=*pgX-#|S=Z%_l7#0c zM#=rwBuE@m65$_NMf%GuJWwinDlWfZ6$W1IyroE|YQ~qWYI$&nNRx)u@B=IE26y=F zB&wGE8z)j}Iw>)kc!-K0ZK_@~N{RWzLstA~ll7WZ%1kL9y8GIqHL$N3G4e=FeZ!Mg z^~{sfvU9AIs`r8GaAr~pMQNSfmQ?HuNexWsTo%I&hdkiYfM5mFw#ZK>@hKoagRHY8 zvJv#LOdr?gPSRY4(W$gOt^s5d>uqxgXpW3GR;WRl77#{4HUWHd*8 z!YIBWCJXt-bOh8Cfz?X!q^w7rT$8sP{BvD(o+MeIMcyqGmr6()fMn(7J}WYGKTj+{ z6}WX1m7?aKY%v6?sv=WoGH++}z;uA+eo&cM)5%>%AvDuo`t&vmpncgvI7wPxnRie-bP3ZW_QE z`w!;&vh;uK|L*mF--`ZkRjFizEY}fGvOZ0l(8Yl;l@cmzHP|Om)Uhzy0!STUA>h^u zQwZI=X`$$I!G@xIUo`Aoj*tuFvU!n%(dP{pBHwOP$UB7xIRLp#`UJ$tf(c*b7>O|w z1uHSUrQS~-RHgZ|9qu$h!$n-~gevB20rZYtJtrE@mL4&5&&|IX{=W?7U+@3(_=;@|&{vU1L`~Q49{y(+nOxsUZqSdbFs9CYI^6cEp z4xjb>%MiX5-~UfHA3Nv&$F2MO|Lf2HOxOQ@GD|;5e0-6)S$IcK(m!%*gNM$4{_p<> z3$`(13;v1O0IkULww7DOh?89CemC}UXgC}St{EZ6G;z^}R$FQ`8MkDfkq&Oa0XpWUB-asClG>}xvz*dasx175!u zS@u4HTtY>3F-r%RNz|EL&Qa-7QFNZNMp+Ly)jYy|R;OA5-3|K!t3=1~m>s6hbv&a@ z+Pj%1Yt8a`mTq)n7Wo|gnL$YDPPm~0bVt4TQu{{FapM(g^a2&p3E;DfjsLu0XP<|% zqixYYuHN^}4Y##QUu7`bT_y$o4%o!#)^YIL!Ex{WxN~%{R$lrvxnv_CYpPZA^x&87 zS?{#d>z*8!k?F^iJ)VIjG(y|8jGEqd_ko@3{vC3sMQnqxsm7#q5dn^y5pC0XK@%Px z{Pp~o)05Y)&b#|V${&9i2BnO z5Qy<5T(7R7FB{a-CS7Z6v9qz9^YKMu6YJ9y-(3MvWB+;d$kqS-_~X6&fA9aju%+lQ zi#b?2K{gng$OpMV>7Hf)G!ewoP5X`9?t`}ynme6*$N-rs`N-rH#Vu*ne?w_ z*(96i@HJ0#A-`!0#~;dC2J~U!AqfW?22#L`)tCi?bvS{%$23oEpvFDMWfoE1X_-Hy z$iN?mAmwL3eHf=B{2Hiv9CGU7vdp{=G6K`d&akK*NXv91r+1rR#WhIxmdHhLAD;Qu zy#G{m+A<=*>-hiEErb!UGw_a(X&fea^ z*;)5R_psahV6h1D$iO7EX`eutmWu^Q{d$?Ma-1Jvx4u4nhC^9!%=`m|eKtaos9-uvU#LER-< zIp0tq>b&lq9Cdo#J!=PakGs8Y=kUCD^2;x53}?Mg@1SnioOO?}EGpD+SJ>t6Uvl5@ z{jcP_mEu2aZa((l|6uw1`~Sbv`%lZ%sh(CP*)e?&2`x{i_VfLNU{&WIUz@i#`Zt&V zH@95>zh_(b_<#5I|JB?7&5eFrqra}osx#u7E&kJ5?yD{Krj;J-^N^i-#rABuGIudz zYecH;&}6et2mg#l=lg%(!2NH$&sQ1$`Q))1|G9N9|9=DWziIx5>noDE3rvmCdFSx= z&L3wBRq4JwKRr1)TBr``krt`(d$;#1YTY!6ffXIsiVcRMi|!g4WLx`Nk*9C@n2Zm0 z85{%s`I9f5XLgMGO}%}%gPCNaImDs&0LJgzknYqmFuMDf$StcnSXvAG7D^{pr^v_b zAtaXDQ@XMPf@0Gi;PiUvC>7*C)16qOFZ>+%?~fik`X6=yJiX`t-;DmJ5x>-AayoIu z;z;And8W`#x9=s=FM9sjV{Ez%&(8+u|MBC^A07Gc(bm?z{r79m|6mGOkhT~UF_38` z`B>B|hW2kJ?8V-%FZl0g9ff$-9YHdQkoi zGGYfMj}Y7qookxb0AEl6skuOvh1=+Y-u(3}U8D|0vBx7)!8EUl0IA1W0clv9)m_-} zcSVTLtHqPn!k<^-3B5wn!r@`n9^5O3@Loj&O*lk_1ASBph_+n9E=I-5=u0pn9X35r z00@(DS>YkHfgVDT&@c}|ejfx61C3L+2mnO$=%G|)0zeZZgt>4321gme70Va#>LF2w z3#KX^>XGOj;&uWjb6?`f^pA}EAAPz3w*!6^8p%2!b2=YsO+G^-MT$rnhZLeTKgPUu zJegWm{V)OKW{5EiTkSymU?x%>$5RXUk6MazHa8-twhQXF4FL_=bP38g@3mI`9vJtZhp*#C39Svs5B;+gChUsXvs&ms>+e9GUa6qxT9plIB zs@^@m^qFG_Uf0e`vmBYS*{0~&o_JL+s$2(HF7yJ*F98;JUQG@+X3 zfLp6C@~1odgOx>T1~xEE5tp`ngOMAOQ(O0^rflFW>vN>?Y~4~^WP`7iq2HG8liTeH zkvDZ2ch)%+IW zi0bi7V7-I?8Kl_Fw<$*knoK51ir}2@lN<0IpR)O-bUr>F?Y*B2{@ZGZm}!Z6-@$;d z;{3144LhPGPIBw5;QQ|bqo{O}6Xn)hZbn~}m865QnWeZ=lJa@+Nsd#}NxpcOiSXyB z7>>2YG>1QJ3kZFNZ?J93>8x*pMdY}GNus9k=B&69Anj%5P#!hU|L0bogBV~T)Ep5? z1R)vZY<-!K%~wQ|-tEN-H+gnDZTHBI{IP-HV8IX{oqU3`nRdS>qc%<2Dl zbQ+sx{L8DT3MG6*%e@^eKswLN$!2uDp8rOpTg{|ca-gS3I-CH1RJP<%w^`maA%dJ$XOLt|xxH z;sT?U{{sK1);fK%go{_8EmT6L%q#>rhqz!l1v9fBpdBV*<}~8~-67%_OC(L8eRnal z_JMyp3D5iBYb5b`muh#r(NnZM-mXfHEPb3-%au(uKSnTL0}YQ2im+7vs)#pQbf4&y zst27(D;}*-+%0XiJXU_O(JIy8iiaz;h)Wx< z;0}!jY}&0{Y0avlG!kXZxkMY0sz`q+xbUxtCy4?LWfp-fU^nAn@Kh{drM6?;CH47@ zYgL**n6`ksFu8)7CUE&L0ofE2=Uo#mm+BXThH7mu7Ih%OsIc+hFq8aF;Mr8Hi@L^y zcg}uE7nw6ijdH6@U*M_6T^^|Q=A*KM7?6zfU?+^lis!l$!{mu?;8Y;9G)iO(Q!9xv zSWkziSvFpTo-h1?letazoX822Feb5Ggq|54@%f(W-3jT#a1$VGPqPlQ7d&~f)I8y9 zPrSii?|j-7)LCo0nXmlPPq#6kW+&UI()?7@Mb+#ylMr3KAql|-MQ5{iftO2@+H`YR zZO@Br%=T%&QS%0X&ewYu2y<_NGf;I$a)%*)O9tWN4OC?Xc`}YCY~l#|OHZnptD6^L z%)y)rX}zAZ{@Ah;LXu<3+Jifj$g94nL1PO^aVRJXJWS_xKxZ+2b*Y_^4_ zRMYCw)X94Ik^d?Rb$AZ!cfH+r;?(rx37Z`ND5Ff=*<^I10D2MO46}?=FH%4&n`}TI z+k$GI30&X#BpzR+mvcf79y7@)XZ%=X;vJ}0uJYT}D&00*B@*OOcFpMK_- zu{os~g%%gOjA~rjs&6F&y;OlVtZW|b8%UokF%jbJ0CjFN5p}LaUqCAV67%V~c$|&K zvv7N_8ISOu7Or;w6UY~KHx^he-5aQZ%G+7ITGr5=6yGFYP~k($>gHt*#x2dh(`TK# zmYaUthRpzw1<}2TMh5OfWG&qZ^q{8*o$rxM*rH+J1l>nV{?o*NV&Kb?!au9yKewJf z*>v!~9&bLnkN^Bu@PE5w$aKzlN03kAX@13I`D_@(qzWAvM)qwE^ow>$a)WxR|V|`GV$>Z>-NT!he{S9!`w*n71 z4)QtLxrU5`-4Fy5Bq(I{a1_;$h9onh5P}~mz zdcmG{*YRx79XQDn;kH%1oxv*RiB=A<(%g)SoZc^_;I1KhsV8vUZj$8@5ZM0oenz{pdH|fZ=ziO6 z6FRFC@^@8FRa(}|vbpwWFD;`Y@|c#9(Gr@LkvO6+jSQz2xqdc+ewc|_#T@B_ZF@W4 zcKXtC<_yo;X*@`?F4>-BZQfAJTSI8ga#_lm2aO?@uSGp;_D=~fqjrqen$?#|FMT}i zt~QZhx;y+2S0iW+PW!~JeS8VHRV)Uge=M(Lu@3!XZA-?9b-pXFZd@ZJ?%Rzs2$wlz zik(~D4Wm2O&Td#s+xg(YjGFr#+?b1qbiGAJmy?|qvauVu^{RCE@tU?-@&#PGsa;E& zkVnXK|62luD(cD=6ZT>TAt}U%I%2d6DF8Inq44cwqN3=zZi3(rZlYKOs~NHT_&Zi! z@33RG(6#gXbJFoAo*WnuzWd-We_HfEJ%|EW$)LNR%aG^!B>nq53F7`N%PDsStHC-) z!oj*HW(PPE1<2#crMb{}j6Q-lRu|qv1zvZ}cS7HYU*9UGASxRnOLB_yMckY18FhA> zXc9-?LWclE2fy52Pv+b3BKj>;n5rj)4r2_CD{LUH(}(v1+Ep^SjE0DqO8`uFX=^6 z9_7c&Qomcsq0g#CAB{U*dD1RC9r6+SjR&bMS_^Z8D-cPe1RVdJ zZxR|i(H=WYrrG=gt$-2V&R{+G54V;22<@TbWu+=hs8;?)1l17CU=n>y1T5ex_3))n z&h_Kgz{YBL)ws9=`RqEEmnY){P6vf^hHc+`4a0onU)<#+9Zi@~x3W@4C95li0T8s6 zQ+R`>HCx02ZN%ml3)ql?okCH&73%$Wz1|nfJM6;kpP|9Kuf7VSI5zp4u7gzH9x-(i zKb@prPu^=Fy7dxZ+ZX;C?=x9eoP?)KMy_L7fqP~3|JgntK=yc=-G}#XF75SQhN=f5DRpMNi z%i&=9yv#um3i@R*%YbEh$aK8Z>=N!R?l3!@jY4Wf_ye_Wq1NxpjnpHudXrk;iAu+D zt$WaZ7nCqg73U0JFouSFPUiAkd2+dI-vSf0+wc{usn$M3S3Drq!+|&{Ci|QZKU|hr zj9a{EUh+?4Fcjqo&ADmwjlEv@yWt(zdRLy`@~-gVYj*)P;%wc8#$0gkU$#3I4`N-> z>urzl-V-&)KkEx<-@279t*IAWQE;eKG)|$Rjl#xv`I{Nt!o{lauRb5IA-76rGo;GL zvE%plf7hln-<1EmOqREi0>0k?xX7*h!&NW}*U>KF7Ng_)Glfy85$1*TwBNiVBuK=2KL1@NzVwk`f z3Cj+OA-K#1Aj-=k#7KM!ZH1>JVvrDSo)p;>?X%==AgIDO1qE;g9TBBY?J#bu5Iv|) z@g&dY|6XzlGW=im$k%-{iVoexMHp1`w7@-GJo%_it3&HRVaHDN>i zf^gaDVZlGg3A&4y$vdRG5ocZDd7Of*d4{gY7t1J*=4C?u)U-~mOUP!X##`jF5OM)2%UlAKRq8sSU@o=B!Z)V;zG9OgYO*0oq9bB00UND;C?xIWqIVI&rXz}u5?9*+laEjg}7Y+r!i6UUzZ z7fZ>K7J|QusdDuhbYm9*e&OQA66W*2|NTEf3Db-*`vjJzPV!kg$Gbxb%9xusZAvny z{xs+40%t&{h8oBfTv8M|-m$CJ@e~KJ9l6)JRcT|_y%Wf}(>(khZ(1=GDj?WyM5|XE zFR(gX9$oFhmL}ge8<=Ep0Vu?Zs*%WUCwJvX z)*VCB20^YbT;XDa=1BoSA8E*=kq2iN(Qd0D=O4&G2wHXdkA#3`Z*s>mqbS7O%<@J^eIJ&#opO<#in?5 zkYrKG$0Z_PS}bU^VFCmKU2R~vjSM7-w?z^QfHFvflyr=7UFOQI1u6zaYDWgH1eBbG zu3W)%eA!e&o5|$F?3R|C%@U}M9O#Lvlznw%yKa|U z*>JmX;UOTaa=rfbFAHD3QW3_dv~ggnWBD?5w%Es?X{GO0QVG4COaIYW*p_8Venz{O zKnVCrSqKvzDi1%(7Ee7-7#C=536q4*9= z@sgn0>hiBl5QyfQlz@+fYltZR7?Dk?l<&u)q9+t-2W+u;M+~gdZZt3_lo!^%yuLH_ z++Al|H*4S~iN+Z-N&`|G3U~uN7udF6*$wOQ(|2hu1+OY|Z15r!)pzczq;#prX`2ja za|$_1x#FQHIK~ZCWv@dnb|{lV<1z`Jjuo#sv2c_vt_ZaA(pb?(*s|MopYNx}Gu5f1 zLvNMX=>M47_S{wks$%5eFzGPEFMQEAfIT&kkX^!6mk2wWIO=aFO3}%RtJ4 zqgc)y7s>g;uNeULiOZ!K3VA&+CT2_nn9@rcSC{~qDNEH`glntZ#@p3hwieuOpa*QI z|M3Ydy}?^v`T9-&|DJ3;HsAk8j~_pIc7Olx(EW$=wW_|7HHK6X;puXLl~Rf~KpD(tOJL)}H`#^x zJ(>W+AK`z<1F*R)>)XLWG>56?D>QvWLA;wHG= zLVgADGFMyF2_`L@ z@1rSA;3PXs)nLPo>c zFE0O*dMmmAizU{YcMagRT+Prkm~P?ea)=K}U#U@2REW=2^X!9CP18{7>rqzPjUvB! zu60uYcU_|~!br=0?z$){dL1u|$zpCS#)X@GD z-?Ke0eS|zTC)#n|pHFq`bZz2qvP`m|s1VYEF&@*ay|kF6=#XY^1w0Jok;Qn8`8OqZ zBtp-v@@j#Kte_Yr^C(ZVf;`9J&0&F2)Qt4PJ^7 zVxG|hw#XJiCV-*#?(1JQDo6kMdvf)u7erHZi@G954@uVr^M&>{Yt!x6$YA`gJm5sGwePFlIhu2p*YG}L zM_7>Pcmiu0%obA;0SY#8ISr5(K*V6=6#6koJKTUOV=ryYBn8@#k0w`eL|3>(q~*q( zLv~oyUAUUUrUZ08fRTAsnvh8|VvKPf&63M3{{S~LLiPs)FzyWtye$lLNdh+UF3hPJ z*Ra7id_0XV3QE+r%rWehQoo@`A07(2N{eBj=M{7RLr1}Aeub?ITmw0j=;uRT5m6AM zoAEr3Fvr;>%gc>Xnva$!U2z|AntH zP{O3p_0BTonwOnQv*Uhqhsj0CE!3+2hii;DjjuC=&0=!@b?%BRInAQdg7r*U)y45Z zi*^&o%@M9c_y}@?yk`l|S-hrm{Q^qkKGXpbq3l2Oz7Ux->vltF?g&b(q8lCO zsdjxH)SOz?u;*OK3;tx=JP9xdSS7oI9EY+k?v`RWYAt5&XbR^6Kj8QSw5p?Rs>#CL zRnN*`OzZ)K>9h(cBjfc>1)`^DvC?VGwzsWujwczyjxuM2R9NcKDgCm_Mp$;wlv(T-Y{)zOb(;vz5XU)Jrj%_vc4gwc+7kB%* zYh?)VlGT^RDVR1xN?^z$-`H~fbD(tNj&;&_{t>DOK|?i-;lCO^QH;Ch1`^9qiAk2S z-3hDfi91$sw2;c*VS-w#6nO*|l=a{Nvqe34Ahf>^9Qtn9&q2h~)+DR>((1gZG` zkzGV&t^-i0o4yK`g-{O0DX=j33R0qqkFYB=3uS89o*=|B)kZD`i2PhK2PDKRBf_^JQyQHDuah6Nz}iR{ybmmW2N&^ zmgBsnnj9C(2fobEh&g2GCXzS|FzQQ;z;;zMl}ce2iUXI5gTqZC@`RdR78)Z)WL@>h zXbrch=Ep8D=-G^E_+DrEE>};}J6DCGYW-l@3e(atNU4j)swFL7+VrKSawQe{!z6TY zk}0duN=UAF7{Nr9>4byB@AppzMm^Rk>XX{^elz&)d!uh0>~A`>fB^>9`1!Zdl_Aq?SKUl4% z={Ol(ji$f?iNB66=Pea-^~%2*MVOe*F){b(L(s0@=_HD?OUgzJU4OZ1t#=bvG-=hn zeowSv-K;krD)epw0^1pmLGBcr#U#p`XnmihVb#M1VQ1i*mL=xXpt?-^&m0J z{<_n3#HV1ED9T{Qd>*;Snq-MTgvMP__5IhiS>E z`V&?oxun0;R3emdgSVjM*Sw9L>dl@GbG<#HVUzr2=x-BuB-{q?Ubc9br>E{Z1hTtZ z(3ZMR*N;|aqC?C7SkuW+3b#C=%#N}}Z5f6!CQ)(wrD|)GAs{mD?qi8+dMv&!57d;SA@|IK8fHc=@IW&!Oj z##c^aQsB=&4g;j(ufaV1eRrj%!v-XV%PS znH01IXZAu)!bWynCA7*_AbRi4RF2$YEX}KqGU>Hg(@RD@eHqDI7CVu*n9ZJ_f+*+7 zO?hJC!s8iV>=d)j$saDGf;nev?_5RPTFl*gwgm955?rC+121nST7~)$BHFW)Vn*1q zTN4#T^R78Ibr;*ML*l7xIJT!Mm=0T)1L@VF)y2aY?BgmEdnuk)&rUIjJr3C))fgw& zTL7{G`XZi-WtfO+tW~@Bx3pSjSz)Y^I|XqKEwe>Im*tmTsj6*7t(io*6PPk-MOF*> ziz3O?gwZIHLFgE{0_VF;^0BUZiTApJ#7pGBq~{drPIb7Er{AY2uv}Ar?khi7M%TAR z!c2-llYp>qF6Ye1m;@P@7d)wsamZ$2V<$Ln7D)UwKAS6^HT^hNm!ii8U=%+~4Gn8a z3vS|MwpN4@2uCAnz$~K+R!E9PJmRaSlmC>e3>_Z5AMDk#n5iD*Y*%$?4 zpm~|LAxQY-RYp`jx!}3)=#0b-qTHL4z>-r?AGk!UB``_|Od;Ul>UEtP=15aNca@UL z%52&Lth9SQZFODe8FQ?#f*s7T#8#n*t-}w^oe%M7j^t!q0m9x5&*mC_bDYPUtd`QT z2lMr+b3Ex6MK&UD2cc8xba$kF<)w0Tco!UsJfzVeqi1H&2SfVOa6A%ytt+4;3lUbFec{s~BMiVw^sIILHhf&ei zx1g6{Ggu9yU+x50hh#9u)Vy_yx<8QjK{L6&c>$<;`~Ray&z_k1Umreu`0(F2|10}+ z=k0$^qga(KkFg ze1Ce@KRH7`H20=)*5{{mI%PX&P;Cdqll{}T2m6g6_tLm6#N+}3H0>r z=(x=!R{*)Ue|mOu^bdrZ&PDyn4m;Pz&buf3{j>d^>!A21PgMGF!+Mtb1Eq)4{IU zTMawrxyKxy0vvr z|Nl+tzm9~T;R()GS` z`VV(b|L36p?sOj_Y_Mo<`cs}#ZvTU5@tNP13MX4WDYEupq1gZwkFpgWooe*o=)G|g z=e?+m(3Ya?x?)I?#}+b!F#Ou-hAe2;@ETs0d=olsGJoIy$NRx4Oyg|u$4$XiFUrjzxqV7*0dQrp zxg}u6{?)wpnjh2}a}rQPlJsCC@$Q~Q7m74#Vx-|u#XInDB|iqbo@3jhr6u)&Vc2WU z(^>*86-8kg|Ki`|PQ_Bl8T##jmKp5Y(=gPw*O-N2JG;?_7#f7@?a6IS#@1#u%|&w) zb9d6?gs0wp?^2u2Hz@I z&dO@jwcE*qa=ZMWaQ@RZ$My05kDfiT;{TsMdvc%u^X|w0^H?o(DGW7Ff3sm$gs+zK z*zyjlFAn)#9-;fL^EVdmHJFdlr#){>O?s=T2tW>-vlL_D=@j0Ln_K!}?lpTI6;Fl>TFS zK{FhEFyaE;c|RQyy!wnpEba>%%Fs9!cRaB303Fbl&aL%8^`3(R@W@0&8>S0aUoalI z@+i0Rr{<=A+P*K|#N%|Qhj}O=C?^Sd_{Kpr$}_+e=}k^y8gat%7~tF5p?rB|Gz^N% z6nTPz$3}_teWGiZN||6POfd-l3Pb>oG3TK^K}I2|l*kT$9WK*pDZ=r9FZhV*@KA4t z0}v6B+aqF0m@`wMjzM##s1pnZsmgHjK)&;AzK09Su8?S*(=_3`U75x8oGOUrNx97D zSd=6xHqt^5!BNj-;=budV3_f%_avy?*(96B1+9f0c!1NLU$FW+!92SRgI5Wv^Dw<= zPG=V|-!2YD=5tH|1p=(q;=24nb~!JC$Q>WV3ugv@#`LoV?gJ|pBoi&pjL<z}>vFIjrK6g$X5C_@TlzjDVudisHraZn-PyNtun9`%dl3wBTAj)dmH_ zH$j)&B@aJk(Ho-JK+7rC<&@>~ph>31MBi}%VBJosfmtEWvV}P1Dg}mCqvmB!+k)o6+I?P`H1%S839dU(&LsEjib!wT1L4)WgPOuAhnm{!`6S>A{sL z=h}7xor~+)QIl$@i4uG1KX@%+t|K_tByBuMxgwyS){M=nC5;_dW(uw&>KHX<3Rfvw zTCL8S=s)^ielu%=^^7Z$CoFKmZXypsRPujNh695E5s|qQ`dBe3ua;$*&8cNa1K72s z;g>s9Tk?|EmQ<+*&oe+YcSsP~>;hn~ffa~@jere|v>~|2=82k0l!v1E4iqqy4$-Gk z7*ChQgqOHs6A@)+x^1vU?Jhgkpg%?F(iMn1-jv?$P})J!ROkq_7r2O;)qjk1?YxM~$>36Nue-0;xU zP*qjfR#GvEu8jJRUyqS{KctWAnj+9r)bIgh48$*N<`$M0%PUV z;Z*{rz}<7+`Hyf&CrOcB%_434;G>!Q&zJ zG>#^4&A)5zf(Xg)bb*vOWikw7lf$^UtEOdmH16v5wWGgwp^e5f<` z|J#1}@X6!*_>V6j{^N7`|9Ufd(-exZl}FJk4#Uw9-7DdlwX9EuSMOP{6Y#%Yyja1L zSXJ6$87-0bwC7Oi>b{;;%dRrE=}9t(xw@&wfp&8<=mQ7N_K?)`7eJuq%m{&Y1g!Ow zMN=%O9*6D{QtW3K;qS>6x)PsGSPJB=wUykCQK9YhH?9FA9ba+(YT7xSWzAi^k2*2w ze~lx|%^riM_`fGs{NMJohuin`|KBnGuhARp_96jmhWZj?0^Q45IU2$T<~~mFKK`#! z{&&{^Z}9(n`pEMCd-nL@ef_VyFaKY+0{CvUERvW}nl|OX?@kqQR`9jF0v>oT9go{e zEvIT8{y$)~a(4S~6G-cd<&$K(Kq#~5Lt;HFgZVi74XgFs#}R#zaYWwY^!B6XD^G8= ztSwN_T3aRR-4{UXa=kM3qVw~h_QI--d!BWDLFMXsb*h^ctT+92RxVk;xf!U0L>N(F zR94u5*HNCNNgm~+$yM-YG__KW@F6RoJVo;-E7qQk)mep_m6Ej($(VgP{-xm=ryP7M zs(p`SWpGz%B}ai^=M!$n%UDG%tjV=pYOroc+;nT|HSu*hYB>;i#i59mLH6y?i$ClR zF;@_Q{cM#z2D%sUD)P=`k47eHw0N#?flTKo)7VbB$TnEa&&5&f;-gkY!Fj-J@yg`I z`i;IV*4-~#uKZ3=35M0rpOY?EqBLKAH`oXs27msu*jug2$5-x>HIAmjsnUN!RlS$i zC)6+H8MQ`E*BZ6M>C$n)gJE6r;6G@)YB%B`oA*}D$k#^>c1K>C{zBsT>eqr>4mjtwo{umm>sX()u4tA)k5mfYX2AHL6dkF zZeTXsOY?+>pQ65wYH4EXd9-m9QSdATnleH4aaU9ZJAtY| zsC?W3pHu=tNUaqGe6E2p?=!nM>g1UdF-@MMXYt4N zh}VZ&$9&YZV!iB(_VhU`!07PU>vSD>qRgfSk6r|T}m{Aqb zuW2{J^)}BQ9LcGuV0kO&A>7}1xRCo`)J4bwnv6w3R%XazgPg_Z7~;x4YI#l1`dprS zt1MLu{_L7}sJ{4zV|uN^`4yoUO)a`8zWzJeIQ;L)?KuE7=Kr?yzivN#a&Q0rceMYS z`D||4=4*ie679Uz5>~eDs$K8xx%dBU`u|xHfH(R7JhbXRJlfj2_y7F2rT?Ed0k}QF zm--4^KLxna`~EGs_y4&DnZnFJoAnuI=mlwr|9OJBUXA#lr(b;izdw}!51#be8inUP z2+oq}v_KtADN?9p7zECPcP~)yD^y~^5K)4K?cmR-2uS8=)3{17D)RvbwiL@*lCwNA z2rbQn1q-m3!8DhXbeix8)N_nqJ^!mRE+(i>F{=*6<;*bG21cT0^CVc{>mn1lu*m>y zOpxLSyFJ;isu558ig;7kX`ToW7@W2lT?>)@y~ijv5EhCZ2A9fQ%=D9 z`~Umje>!Pz;Gf}}re*a*UMBvLG-YN2y~+bGBpf<{+bgKc39IIdsh(K4UR0KOI$Q!V zS!8fg@frd(iGl)q@)Td^FR|4o&ZP?FJ4?%H@+QhJlJhPRhfL(yg0%rS1^}xEV|By; z=8e_uW0uWmgp5safN5#cX!;E%4jV_yX&KOZ0a{hGv0%Bwk}DBHlP4RDCdD|@d1k z7K@V5eX-LW9H6Iw>QUB!uBs?CYI~zCH_S}4wB5a08K zcar{dr_0ULtD;P1dwFz;=kI$3n(DvU^#94$)BF6dcY^-&lV0@&xX7c$BpnrDEO0|0 zNy`kdzhDYhl9)>^%pA!3E`58#X#`BopASJp&-FE(=z_GWHxMA}NN+mQgEC(d2^J89 zHI`g8cuVxwFg-9h$uo04Y;Fc;`4y5;m|OKSVIIn2lr0iTW zEWOZ<>;|h}=a{yxtl0v7vt^s$w3YkPquFn%m~^#0muWd6k%qjLzZMHz@2;8H)%1Q| zkN%g@u-KGI`ZmwtW1rIR9OdanIyWAI2Kw)a|AGJB=l{49^dC<;lqW5@bau`_Bt~VH zcbR~Xyk!CikGVK7=dO|_J2*)5Jj+=&kHvC0O-I3bq+rZB3rY{*zD_anoCetletvtp zfATK)-~axfASNFx;RyM5z=ZaQC;G+lFja3EExIA^dwg>A-AVu8pno>legDHL1M|b_ zMmmQ{Va_};VSJRCg8UPIcM5_ITl5r~U%>tY;GglT?TX`g7j#gg0#l2ZBNz-GYh+)!iq9>h9KBbHsx%tp3d26&_5oa!X-UA-Jo12JoU;f zL+_QpBEUW)8nBdv>2^lT99;x4F$$X#Oo0hL$$UBE+J2K-aQccz&E8n!;2dAFJ!e1s z)!`vhX6$vQ1O@b$;&#Wpiztl2@L|Czp!3f9vPcxAx?@h%3f2e*HJia%jW+&S)O29; zwL3ASP+ekvYkB7e^uLmT*#jUy3ZJJeZ;}7$(e|^)cKyf4kM83??+*R1PTGhG(!M zN3n@tU|D&>K#-p{kj9!OY4KxPq{G@kRBl=YDw>o$c=dHA^UJr2_B>91Nv5c@Ra;+V z*#$+$vUSb4@+LfK(`cBLWj33$4918Eujq?I`GpxF>;t5Ls5-xjaIsS#tR zn|hIUQ>ecP@lXf0w)Qs_mpkjnOu!V}{(2Y1mY;wPkLHy89G(kIWsWJnl7cG=lqRXvZ!s;n-g^K-k}+S=^2oB*)%Dc@d*LkFHD~beLYBf4?D) z>I4I-a^_?Fo@dGL_HZ}iw4hHu5KaidodA0yK!{H(ff#2a46|Yo_y->V)@YvPvuJAi zb1QD#lL$D$>LfZ^R&rY-K;d7}5b(6>vtyj5;@6!PXVm!nG>)-oiPsa=b;=cy0I~Ng z9aMcsAQ+f;@&d@%JSI%nPupC<%aDCzbaLhSHm^@5|MHV!$;p{#h2&jrmwK)aK!V2d znXBLKvAe@>SFuP&JHezZ7sbxz=4`v7A!&w8%Oaa&Ua8HBqHKV4u!-FY)fE^phAM^; z>e2#?B&hbb2t*efgs%o!8gSk>+@qs8-=!asn9NIMW5i@%GZb$(SAHLUr>UOu``m7> z;`V>m$vS3vNaUd192x2O95Xyk;OiJ^fy5P& zx957Z`}W)Hr|ZU9SlhF=zGvY~&s=kh8;5H=gOTx$`7d2HPyN!piH6CPn1po7BbBt* z6`#*I8x=dkT8(}(**JL9f3^SS{f~pw!K=ZW!P!6F_url!_3W=t_uuUAp6%~Bexfh% z7dTq$$G3<4XMMXrYBM!3f@^9#tO??SW3{gE{B?oz6y;?J7om5RHl!(z*dCBrv;}85 zUlCHZfhH^wbJhX~9;%TOpdKNo_6B1Bl#X92f}Nxwxg-v(RWg=J=g4TmE?zf91F+2MVU_vE_4X*sClAVRnOfkR-)~< zxY+>`i8Gs`s}vDb<1i_LnnMrA>6|i#yt*31-wMsw zi_S?1l6fU=y$5mYJs#G3g8H7w$a_5oUs`nGZJgfvxCB)j*BUr2TL>r-6rYlxM5&( zIL(He$yPFsx0B(M;nVGB!+2|JJbd=#(f^5tkK>1r{xW_#+)f^D$6MQ*T$fwuFi1<- z_p0~xfj60kag%8nH%$#=_@VsMn}<)f%;gSB!A(T-s~{uCVBlcXwGwzXr7%uDZXypt z4tpEq8@53~=YK*`;xb7uCZ#z9R{;ERcJbje3n6j!Khf$PHv#<yErjj3|qBx_`H*kGq+0Zeh6OO7W!Mlm}J|mZ3fQWigHB ztTsk8#G+-WCtLDFn89W84OXnk&{PT}GrJ7!eFZsuhMD2elV4bk>6yhR=k&NoiQTXX zYT0q(ACtTk)mRs3cL2Es%jXWSgf83~L{N5ASmNKO>3;!H8DXk~g2rIJwn2g411*>V zkd*n_5>)g!xC+Y4bd-d_*<^_;3+9QT6sBX!$loN%ptHC37^CJw=%FkX5@nN&g5 zsc0kdCwsfl+K!C@pDu8NP7cvdfQvx+Xzw@}Mg=pwpHp>E!iEtb!u*1J8w9Ue0bPDL z7kD_(wVof0XgbrW+?d;oqGkG6mT(3=*lC#vidiYCHn$aZ!raP#(V2&z2teS zH@s7sXT#m2gX1^*{|WN$;OzbJfA(}7>iVFSoTg*xF-f1TIrZ%sRcvqT8ENO*^+9vb z$eF&Sl0k$a+GT$dyzovST4~Y1I4H%e=+)}n}U ze37hXSiLUF(}u4qnv226skmA%eWT0pxDXLzqilEey}r$AdpGJzs5NQ;+P+RT$*xhu z8Ge1K8Hkkv>be87@19okJY3F(ls~NcN@vy`9c!k3l(RC${v{9uZT5OiboTpb3MY6* zwY6Qpuvx2<(|4b*%0oUFpt-$r*3C>;X&fv7U0-*DM3mM5y7nI)CzJ(L7LWC&|5dDT z+5nN0cX*|BxrwP{AKi9!4W~ow7S{I-#q{p@PPmaUfqWJf(1Pg!07cN>bpw6zfSiW2 z_)qg}z9Bq_KPDUb26@RO#YUctfyhW5L8BR>d7 zOpChBe3MtrC&q(Ze11diGKzf;|4*epc8mD`?Wa#4JL3Ph?)^W%1pm(l$4;kn<*hms zHU}+)n$P^_aU^v25~V%Y>V5r``*Jg9KeKOX&7=TEie;Te9{F2)sxRCwgOP*4V0^L9 zXVEol%hL{euk2=59VBDwK8rLrxhSj&;4i1G535g<2s*@SU&Sc9%2r4J+Q^0Gnfd^?( zpc+r?B`NpPcqjO}g0$WsH+uC95*q}pUbP+7_V$dk>9!XC>QozdKmx3}GLD&7dsI!L z)%7!lV8^bjn;jV&ecKU2pM(EHpO9(W`fpn-*Pl`UZTrbR|940EKXI`EnIk7V)2VWi zxLn97VF{N1MH#$jv^89Q8rwSjzhq>-)s5NV0VZ@LRh&W(tj6L=Y}uUqezt5a99mm8 zuXh1z+$6WdjSM}g>uT6L{d?PJw%G|@PqV0O>}i`k+V-pr%dMv=d7(BvTzY`Uz1LMS zM)R)wIyrEn;{YvvVH+o*d#PKKxX*%fpa18^`5!7t#>xUz`~E+B?8yK2^x=K}x4W7D z;Wr5X#UeyYmOR#}PWmp7urS8;;&@jJ@TFgW0%8uwixH*EW1hzJ_sx1MiocryLSjoRh_dR{xwlgBoYb=7cQjbCW9UfMjV;9|69n>?lhq075 zKc(g5BmwkE@)ieHL}OqilPKJW>#38A#xAGuM<4!R*wMUFt7B`8eI;gwUIlq8K$xzW zKzyGSgglL6$u~tHo_wB~=$K_4!jwZi1wUr!!QeFL zifu8|6DBvxmscX6(n#iy=FW_H0zJ@uC8dneD61`C)Fob0mfEQr>l|8N(3HvJ zH8@Dn&|jdp<~bt5hLp~zF)oUH+YvD9Spx(IlE*u#|7;Yzq7q`nK|HvRax7JRYh&m@hFc7fVyY z94}%v*zhV?79n~B<5tZR%yCNzYYS+f#Nk?{&yhs6Qss^+*tVU@*5G1A28A&anob`0 zZy0V|&k*~?z8vg(lP>jS!R_#9&W7BjtrL%<$jvxUReO4hYJY<&hZpQGR(LwrOF@c7 zr*nkK3s@|Y5pW#)<1yfP@jV>iRE{TXUK9HEBw5gTz^Yj2D0*3Tr7p(d7Npo_*fyZ~ zYLXUzJ8NOXTC?2kEN~&aIByH*+>W!wS$2|TrM5(uv4ZAG5Gjdz;0`~iUQDuroK_tM z^Vex!lzT}rf(sGN@$}NUJ>RY~GD00$@f=jX4i{o69j~!Pq#{=1&^+FEH{Dpq9G_)p z*`n>%a%LKbD$H)wMl?X>)a!&-~+i?Hj`0O7RBrKHuc5rx0 z@R*YYS-cuuD*!BITEgGa`FG8Nwb11mAO16{jj+FZmOaXx;XGm}xv@fQ%2o zTemFE6xzwEsM4xmPos;1A8ECLW&`^;WE~P^+ju%qC)Q{!gnr%jhTtjqkA1L_8)$;A z?6tPf-U(Z~$w@IwGfWer>88cRziPSFlB_=n_7Y%7ld2rdsA3xnH63V@kF$JM6?K7Y zpI%&0&Q;1<3VgYD+;bjh#0k85LTNFW@BhLM{%Daft)Z+d?!<7l`&bwLiC=VM#C#(ZY;h3_i@|0qT`q;Bs; zX@~9id(qV)+S}eAW-OuWt0=e9dTIC4?ajujsJpat>eRiBI@)S`#2ve%KBgoV04I{%txM#Yv zqkL}zWY*A=1h=Uy1_%304c15`rb1Q0P*o9V6$EmLxpxl%>znKRTzj$DgN~6)d#5bf z?*i+h5xt7da^PL0E9^MhIaGNo!_KSPVBCD7sT6hsRmrKp`3~$eay}w1OC?*#E)RvQ z5hCJq@V*yhQ&ECvo?Nm}6UB)Ug%bHa$i1SqDO|8ecc8Du5DzV$DNGAJg-CT?l6|6j z=$5>^K$Vv*-i)WqV!|08&W0Lu_~i2@)O0pcOsPOR%FXOrKi0S)*Ja5(nv?ql=QPDZ zQ@Ze-9kvZ>gKAvI_Q_4^Q^=HF_JXS(70qWy)o&IgN^~uTYnSm*?;ao&pQ)3b*J*qc*WYnzbJ*B6IXBJ+ z75g8PIeDJjsS3>c9o}U7^083aJQc62BV{Ls`CvpjNA-rV^a|=3liEEB(7&1ThjF3} z1O~N25}OaRjcma*Dj<1G2yTIe65!5cOv5kKN2rGfZXmXg)k%nqC~5mX!&;!#pZ^T7 zfi)-4XBo4toAj2;J5t=_$KCaZe*@X_@JSuPR_)YrQ(cvlF&VKCpi3}H97dkZ04qM@ zxNHD(XrO+u_ly9V^w6@qd=a!lb9ZmL^m%I~f{IpN@fh}epAO5vuclQR;H9l8->LUWB3qspn8NDOm!#*L-q~f{Tck+H$H@bk!-p zRCv~EvaG-8)zzYgrbT9|j$6&M?OZ$Vy;U!wcg8$%h%(>c`ExafJ@~{I07UMDy2NJC z3=v21WA|1T2kokb=%lz>0fl&XZsYXff-V<^8buq_(9G1SHl6+>WR5X@d7b54S&J5;hY{5UsQgcE!4iEDz#jw#bkl zm#{k$W>ydZ?0ujEQFb;qYwb0lExfc26@1kj3DhkP-4z!z|`Dr>%7@3CNA#exC ziego?I5@{??7=k7S(&8s3O&xdMAbiLX)MMlCJ{^-$N$;#4O9^8T@BqA<7sbtJDmVqlU~q&Q5-?YrFy`g{bpMKgmpz)EtW`CT%%NNJjSDt zL1uJE#X1KG)Dk`Xggmz43Q`28%>{3zlR}9fB>Ka$mB{my&=9lBSJlq?Ds%cErBY z<}ybd6+9Qs9h@wgSco)Ys=Ele*UJfd6j6~m4$B}agYhBELmz>qmqX{ul+tj}oXX1A zFet9(qe-64;h2E&g|RU49<3T^87(RbMtKC!70?O33)(>|ur#yKFu~#Y$heHbm{qWw z;BA|WsAG^_htq^uZ`w*WwwBcoN5RufFpVFnLzX#S**pVW4TIAp3I6nWYx_y4tImY| zF)iTb*q<%RE897wej{!E#;@s8!d+b&?2s;{s7x0uQ!=hl%58%MBY8wvU*4wJq@y(V z&^?1^dk5%Uld+Wc<{6(YQb=BOc7j%W=T28h(^g3$LW}O z&{FJ0nXPHLn;mvYN;?YVdT_AMdWnR`eosxsBNO=!p= zhIMdK70mNZ@fr9$`f_>WpsW!xX-+k`$H$C~4D6cQ6dSNay8b47tzAc61ei|PgytTo z-F9j3+Ku5iH>~-O!A)>fAJdB6{wUqal%ikLWL%mGl5Amole5rO?K^gQ#+TVNeq#fK zZ_g~4@a2gEEPVaSgsj)OCijMp(4@XrP_>qmPml6%Svt z@qsY}He=nFwGI~ZJx_UEEezyjwjo+mpvAK3P_%=SSThZ3ycM&ku@CJ`YO-&eH<+25 z>I{sXsngPE>#A#D_;L<9EnvV5yS=B4m->^UhN&Gp5G(NR0#gC~MjIi(SVXg76;kn0 zyL)0OtvArMA4FvhL+wsbMee|Cst=v5aBJKP)cXUGZnG}?mc0bVQZ1oWyo%cxNNmHB+q^?sECcUNqe;!4nVgD&lVWU#xY%i(+iK(z zYaRAAyi6X+bYzRAb%csnK@NCKs$~EmwNOf~z&Yt`WFl7K^E;jb;h`L_l|7&JnvbT- zIAL~xA5J$a=U}ci>UfO3JrcO&hol^ZT>%-*3p`3tn~cyvZ;*~Eo`F_OlfRLFXkYoGLHw<)nOLR z71y0=He$VU@)&klfYgjKEXL4G06T)k1Z^!ls8ih#N&To42d$CS-aw+eA$R{6T2}2; z;5nh{^eVv9M6H3oiDL?w6i1Hcl_;1*c`QT+#P6OMC8e@G|6CRnzDSBYSnFIC8IcP` zZN6tdm4$%N3-jL1QMrWr&nd&K3TvP*85<-h*N$qMUg4DvC)y|G@V%)$MTyAEzgTh~{!b)s+ z*Xuaq`q~SH&#$XKR&34CZ~Y<(;(?Oawl*@aY6x7kbht7ioV2h#d#0|zy7t&fS=auDxEaOLo&Zk<2P#WSgy4;%gZB~rRCAwOJg z0>wD%vCP&*|G5+n=nsG#E9cZuEgc+56~XQ_#R@-k7)6W>$6bw)(L%jWWhp!~`H@qQocm9N!C(zQnZ zRr$h>tICkCR1W^8SEDcJ{O6}v^N~TuDU&#rLFD>6rn;bS{`_aBc7nA)%j^K%R5~$|H^&Ac_?G+wHtS^KaU0ZlKZ*l zB5vYWmu7B7XGK@q4#UOA^NJ8Z)Tt$iz#I69gD59uaPDbo>`KSA@{sdc72jk#`V0V8 z8chkvS!TG};LUMD?zTv_q0t)dmSw`xmDZ-E^cP2aAO#*+R+tPbRg$8xcFLOsVQ9#n z%&=1Pj5U*aMRkwCB}%ktn8wO$=j+5>)co!2s;W2?bGL22lAl86_;{FI_OZOKpe}E+ zqDIknU4%nqTaE0lMyP)mY^V;&wY=`{a6B%$ZxJz9q$qoZoUfG;8Dd&OFj3YNb~C=B z(cy9f3mAX3uArv?>rTkfaZuSUzwXsTMitj`&vLIHez5xd+O@JdsHvpX_~u5w$&MuNq*|35Cqq*)j@%N-AaDs0iJc_ABKO zfG0O1lHAkAhE5hQEsog~4#`L?gZz1Yl$)sdj>XzmQFyfDLmL&${%nj=&CDGD4D4$y z$~jq5gFn^=okYc%Fpg^Jh@AK@PPpq%X%yBaQe_Bj6h5Zxoa(v(Bfe6{u_D$0pD&Yd zJ|;mzLj!hT8e-m?qGO_=9!3IuQ^;4-Z1jO;kCqDNqDgpDoNHt-9&^rc2H^qltEMnK3WXzD zjiGRW!eW3bAdaG)tn3OHTCqx)n2eYJ+!kYuGtNjW@yg6ef-p~pzj4O4N=lRF@IWV{ zrQ4MQ76UAm3@?k|LEm6GW#99e{|2kh2{^ZdhjM=xTVq*&w4w6MdcFDadEqPF(Kl^-Wuyup^+r?S7$IN}m z#=Dd>RaH@;a({@=i1jOFPePaJ=epX=Wbw2in^2}IIB$*TMZ1>D9%YW*dV5cTRmwXW z|NVB(PF&@vMYt~`8tW}o$nIc9R?2UntC;`~grn9ZQQt_45GK~M%q{@s3c!N$52Y&>k(^p@`9qIruIRX9+S zYDeoLLJ8`$*)2*+JB+Z-o9xGX#m~o5SgwNnt|Kgz6Z2)pzQI&{d9fQ8*sYCb&C7-or zo99D1D)(t*=G8Kt#!22$2RAGyh1j=_V@hXN3lLr4eeU)G9zuV3bhvMT7~&|VMMhsu zjYY)mRM$EQ-U^m|^cvH3@+yDqRFCm5`E0@=2*t;h#o${LTxCsY8#Pmv#&bm^QtTv8 z>FOxoqSPfZvON4GG)OE=p-LsAsOp)w8Wqrj0g6R7k13Cc#QJ}p?mt|`*ejUW-Cc?jX*OJRm09BIMj|oM+PzH)w zS`^VVwD~uvSt-eb`&)hw*Gv^WgRJmS$t|BHBpUe}Zg? zj&G_(5zBQEX*%nF2NX$nd0yw|^+~Bg?Wq1`*U8`+fH4`A+Y{ePsoqn%wD4YEp}#BU z!O^`dOMj{9EHRs_+SnUe092)&+r6WqYle+wbsuH7E z9l?%`iiU$bh#jUkw5etk$rG{emSYNFNWscgJ*b>TK; zY)PCL@mjVGWo*~}cC-MQx8uHrJ3GYYc9mMU_<(g}Ws6+7dpm)BAMMJvYL9MJwbp|b zRiRpyB!)+G5EYP^36jIwS(J$LQ+hQrOjj@Tp~X(n7i9(&>z`eI&@6TH&qBz#tCzVm zr^E^!spXmEPQNjKGO7YO+jLm&!JVCPmXDIt<*eJ0iN)E4-o!e>bXbgs)D^&3ZMh zIJB>PyC%g$Zg3khzoK%*ck z9EFrP=ygZvnaO5p8c{Dhq*DdG6D&=P;fGiOPS~-X>i&CmDW}^PZ~b>wsDx++oTjFMbicFcGXJJ^M?)cHT~zWo?N<^ zO({pGAMPmhb>qTOdt9u$v$blxk6K2?l}$`h(4udGm3vR6Mr$@bHf!K`Msi`!a_+(% zrRt0pK%+0pEH}eTv?#V@!&qF+*jMRBCqs?fqDMG>T4j}t`tc8SSqj~&7Iu6zo~w|8 zPnOG5f%w?*uU(r-*L0q)e!87K=ufBWHs~3N%(Yh0Y-w3Hyz&;AAVfs;nhy!gDm(rK z5L@=cX>BpO`Kq#lx_nF2P=aSpjAB;RG8#*z=ny~>UZA~3cc~SAqpwkrKyT3*lOi@5 zV@}oPf@qXy6ox>_-$>Z#1+Zz$T=*99)`&I}v`MiI z&?1?|EhdS3-UzZqw_w~>>?d?O+Q&= z?BeB=7tEVnp{8pmI2T^^7z}<6s0+e-RYVWhjjdTt$Z3?u0S>@oY}l)cB2J1%P5bFDwUjcgBJyQm?;o5uK#m6TkxW7 zBGOHaNEa@+oB*fC3d83g#X$*QGxu8rgm0sO^}yBZFq6eAEy;$|yyNC=uv6X;O85l~zWMm!(}y*_%wyP#iHPP`?w?e-bk# z-#t12$#8IbbjWlrmJBa)-XkSqcIP5RGr5{*U!=unX_HbG5vh@R8|+u^lui`!<74Ms zu}~Id!fMv@EIYt7jTZ@(5A3u|Dqj!L#_4uBY8Ai!gDC${ROb8Y*1@`AAK~Wcw~le^ z^78SxPksr_D}OHU;W=}R&y#72QX@}5NwkA1+Y0I=UAE8hcAR8cIhZY`ArCnLOvjQE zzltriFO5D)KQJ8-7Q0Ykt=X{sMLrdnhDku`eFEqXjwU|(j~sE z9mf5)JTwG0ja2tynY~iQ0J_ig){GK4jXW7G^MVCOK|fT(UJ(Y15je4v4_yyV8J|}q z&BmO=Kz#$5N*C~l5T6Brh;kP}OpmY_gOKAg%RgW!3dc@DC{Z2ul9a^695HOdHEeL) zq$n+%CzcE14}I{z|NTFNznO6Jp;}e&{!-G_1Y_OiSJ=A1HOx!vd@yzat`fY?BVbpe zIfXsu<;Ez@N6WO7w}F@kWi2>8dGOH?{_J727oaVRVt~s7w^uxD@HHl*qUyNfbw@uN zHawAMpdpvZ)a4qZpQMRU+OKWUNqu#iG_=}yocYoEWH;`1zEa-4ILMZ9j_#nwl~2mf^~Qsi4%;1E&il8P zE7h)OB;9F|7H+j;qi+B7ou9G1o}pIIR%8BH&E;Bq?T9m9ZL?(%z9(773Ogj{!nVlV z6~|C@1<~RIl;~Jq*=5Q}-%h1}@9K_@TtVQk8ewzZRZ-cW1<6uKHR^@)Y=Pg1NXmDN zC=z`Zg^_YrCscCYN)_Clpoqp)J)Fvz&6d+LT}&B=TdZ0|)>P%xT0ytu?I7*kmMG; z%KjX>G?bsmN6X!-N?4@(Mj4m8W4Xz=>~}lv>V8&VJJ__?2@KyQnYp0y<>&SDd^LsE z%o;~k^k%P>z1B2N5w3?n?w_0vjt;}qy}!R79G(R)o(I|w?{|;(_D{njM@|`5a)f2h zpj0mSY+U9kY(t!kBVZ3$j7*u9Qy$PxaaUAF23ASxggjkTb!3#xFK)$`z_bl~TryN5 z6t3?!1o0nEv*G4nMx!V3lV|a>;gjg`!+2|yMC0huUxt7AKk=hc{4{>N^_R!-!@rC+ zr|GbIB^Zv&XGDy$Sz-5Pg$S=Ainbw(HA48)o2P$ywC1Wz74oEwd)3+lHHJ214kuIx zOTEo+qG2+1mQYRSF~<>dK9K|QoMR#ix2)QjwpFZ{ji)n1MgdQUm_gtpF{xxykXk(p zVnA`KKm*S|GJ(@9`@r%pyhE zy#{-%B)U*Gk@~EZwV9ajocaZa`i9cSKlf|^&X z+}?pC@MkDDyC+!rqKq?RmiiOu?&_Pm>QK`(Z=7C_CeW>&Ug*Hf`b*)0WaqE>BXGura`iT+fVmAjW?#4!Hy}i(|NE99A-9i zy@lUM+cK$}*)5`KTES&>C8|e;D%L0qIo2%eXM*$22m)`7zJbdWZ zLxAo;;0~S|-(Yb|O+&{PsicxM#lF}5$zze3s3b8aI8Cg6ut`7{fKyzOU>Z@yBalV{A*6vOxkHkpuGq}#3x zMvC+C?`kBYeWry7jVoZi@C|X;SbPX<^T5TD<)U%q0_Df)C^b}Xc+ zj2axO$3#?%kjh0eFcXE>DDYI&Wzy0=RbdHjB_NkzQoW2LHP6gZAu@g`x(P@k11OA% zjB}m~XNSx@3MS3f0 z2}mmKiIp#ybd`nH`9)F+jR5-6oiCipzF{nu?z;_A8Ya%)z~*`z804GvXGI}?%TFz5Mrh>#-&OJ$thK_+I||V&uPBhUV^qS-@ls8?1ZBMkglj?SG#w4gwvv9<%X_~3p8xmq|0LyY`qXnw2&m`(x1T&S^Z#vcZQt8}?il~i^z|*QK>hq;$!gOy zHvK72DM8mkv}l#&j;2tAtqcvCf$1>9m%5Ly4|PU@dQ&5lA=J1v(^9vhDBhV@gZc&5 zWvkcXV-u$qU$0DPXy$8cuQ}Q_Y4OoyLW}#>x9S(2N}I$N$~8T%Bj9hB=Jo>5p#fZ%}dY>0I4jR7TiC zxi4y_YI);$QC;}CV&a(b;b?dlw-zz)j>D(t2=81MxY~&$Aoa|ZJzS0a z+ztu4Gn{p^!Jsa)b8FV9j{n=H|qNq#vz>X*Yk8YdZEi<>lo)Nq&8JdRIj3&liyQO2L0 zrBl)D`plgU{9=9b?xS6|G(Mwg?Tvwuzg-|(x&|9$!OKkoUzKR*9=*S*AV z%%AeaR;o{^NYqwMgL9q2M^rDo%^gK8XVcEl#%s?rMONeFA`R#RXl!^SVq@U~@U!8J z_%3YA03&GG+0OzqKh{KQ|Bw}rh>Mseu#!be6|+z&$d*=03vSWGJv*E`KNItOO;^~e z>x!eV4~s#b`(e0c32-WsIXpXXwV8^g$flQi46CW(*3_bg#Wji zI?a?AYvsR(4x_|L*-i?sonsE0vR9&imoCUwPpUrkD$3Dj1OcUq%VbY11aY zIBpgfnKQapj*;Y_1N#1R)?k$B4w}nM>YaFdnFht*4>z6nq!N2FLlZlc+Xgg1uS@Lt z+tic9G`-d%hABgS_ki$!+vw}-WglDnn{wqkbC$Tp^;7_3w0rh`_f7xw)GWxTGz)fQ z$wc*jNzw8}hYO6&h+Oq?)KQH2y%AjJ=wbFTF?ug4$~>VNv=)V9G5+a zzeI7jsTB8&bDs)w%7G>@jXsPHvJU9n}vI+)BrFdzgw3w)*$mw2fk7dsXLw2~T&Y{Cnw0D#O zRnrW$1&?~jb-k+w7|*j+H8v{?K>OCt3@EUG7FkiOMeASG=Q!cah{~vk$0tYMo%9b5 z`e%dP_dlGrODtjH?1@%ySS>W%8htg_c~VijuSZBcIh-$*Zh1O2a5m~1-U8&wJ1 z;Q#&X*&{3e$M*Ktz5e&k=zrx&uid)KODIA{ZXfdLS^v$x^nqa+#D|Hi;QN?4z7)1o z%ilz%ZU=|ND=&Y5KY5mm%I!t8MzEm{sIq4U2N`g!**qPQn@KuHl@6u8m@j9;B*9-cEl7fcP#Yl{`0*5@1= zc|5=bvrFuV`XQW8fy@t2^v2y>;Lk|O(P*4b=SHE9;!)Jn0Hnn}*22~OA}G}f){5Rg zI6nKwnyVMEzJLW_6v(ARU(~yLFS+Q|1BOM!hYm_a5dseC3`n#Y5AG5s6 z^+J?ng(}*XVQ`S1m+N$lD!o-hORXwZF_W8!aqN3yvwpeFk8|9G}k9kk8eatL=V zFPgp!hKE^WII!wq2ox8%*sGevlDK<~UW6OL&qTN1@iGFU_cw(~ozOjRM1mIEInqZ8QJd_QS31 z?R)<3F7ki;!i(cYVrkuXQ9zikB4r7Z3V?Uwo*#&Eco~OO1heQ$RvZnYiwH}QR0XN! z954eELeK%DM%IIZ?}AOUacvPjnj@uhLQ84TkuTXfu87NsT{%@s%alyVSpOuN%k|)s zo+ntsSonFOoAqB2bevtn(ty^?0;V4crs*(`=y}H{?*e%{paL-&1JH?@Z-%j#+K$n~ z9mgrFR9|f?BVwtre+D&o)lPHGYb5fzhw33f2S&RH{izufBGj4$Pcb;52 z(qdP6=rlkALKRnz^>V2s3_aIqWJW?xF9N^R^W6c4)@#b4P|DsECCz4-JkB1ChyEp1->t=fNYKV* zLF9(Ew^K%Nu_9)$CX&joG7ntguJkg;-p{MKt%UCQ>UU;}X!oJRn}Sq2Sk4*Uihnz# zv?DNCwK+>5!cDrIVD+vl#s`5)0Il)t^H(0ecT4`Q;y*p?05{KH-8cfYp8tRPWNX`s z|9rCbxad3?!?^QNQv8)Fag(Tl9s|iQXp%~i>f-kf znWA1y0A7+3yGo8fPEbuc>S5~T9Bag7#K<6Ek|fgk$Ls?#_Qo*6VdGuUVJT{HIt9F5 zz*Xh%x&f~a!?pq&qW~dR|EJ&t4hq@BQNq7ENq`g=6ze%li-HF?!d!=B84iKbNT*n~ zBRIzz2M^9!h4Pfj)s4wqxWlw2ia?<@1z=G+-55ux6T_|kncMVegQP(Z)|0cTuo*;5 zp}fd3Iq7VM=Zb1WQpltLiNfi43??GLMmFBa@>o0nDVDISq?>M!t9M|gBbwN$&LudQoIJ|6he1iWlc#qn&wwHTPAmIloOO9c-SbKi1nbr%~| z@;SfSh5V}P=~i`7=h$7-GZ$BRi0y>hZ|C{7&IMUS#Ma;KP}r^h+~>l-toGR$)>q92 zz3S*;Qujv!fh$u+Ok<$BFR zLso4nKEKtr^Ycrux3LM;EUjH|q`A^JK&|9kO`wB+z5*T<%ca!OP*F)`HLBxaJmLRa z&?1%URPfED`W3TdQz%~R*HN8XF1eE$`C+RuKVh|FV%_xq+FbO<;t!G?{{~_z`>?6# z&pe8P`uN``Pptf(j~+jJd~g4|JNCbVNA(#Iz`Xblut`M-((E|BSTe69vmw=@NJm8& z=g}p~7~vlO!)%!q;c=9YlBw&*WtmRH(?ygQNk7k{D{n+H1qm9z{u0XS+tFeXiYno^ z5D#1v4=YAN7mE}taI}u26}kbbCAfZ1tUZ3S-lxk2_HG--i(wO2zz5I7DW4=ow#=KO zh?xP6g(l}8U>2wJ1q+k5klDKDX-$ zk9jD-%P=io0qT6faA3v^bsZcINm=TM6SjL*^LEG?&100p{_$tw~vE)WR~Lk$?^eJ{wS ztWH)B-3;h2U~1(=nT_=znJ*D9`X#^}E&te}hh7Tqb+mVP7AdAQ zxb?;2_U!}$&KT>VYXW7E75mmJ(jnIy>#aVWe zWo11q3C!&&tZjaAiKwn`}BPzd7 zc|%|qv!Bv=oLy=PdL{vcap$c6T`yR#c*x-AuhaM)PddVK?_N^(bvsz!9-(GBtj?pN z&SbB-Lq9%My!?qfFh1wT=b@Hc<2k7X9a9hqg-@f8iAXQN0%0U$4g{_e-oLHn{1@uh z!ByDH&|ESBQW)x~i_veExkFOr)JdA;0A#|fx*w8cfxfswga+gu&)SEQci=CpTNc3{~)DbWw<6v)F*_ykuGh z7+7xuCA?q*I$}5%52k|a0aOU%UE%hyG_35~0`@aj!4J`|fX|O|vY=N4Z2y?VqY{7Y z1*1GFCS=z^$5fa$369Ho{AyX2=tx(XA#I?ENQUdb4x64W|ER=c!H2Jix?L;=@kH)J%? zPyZ+8)r&qRg|LE>8wK4hk>^VmMTR@1N92@-83Jw!Q1`>5_k%sB@spEVa`c(N6{6b7 z2o?R6$iY+?Yw4CyLg$3fUA0E2$^ahdXKMot_9%^gmST{I)A5zEOcuTL4Xjk6Od#}> z!9`Zmv`3^{<`u#HqrEq^C^0USG<2+fN+ zzzvjj*@=vl6!_1*lj-n&IHh{rl@J-~i4kgQIA?Q#=9=o5W1if9u?F?-ijF-sxF105 z1R5xtIjFk?b+bXd?eRf8S^!(o=twBV5Uy%kEt9D$cLG!4SuL@UzbG0zdxhM4(!X4H zU&k=xbWSY*N0oEzD%jv1#obkTP%yE5MKmYm?qif2+e-_y>7EDUDNxaDOq2;2GGJs< zWIBLh8E5%rl*feti8P3e^oTvcrC^Uai)m5gKFx(*V#2etj9b5f+ZcV&rl9RCF7qX2 zTXJvY%di=o5A|DR69En%vxrpN_hn@dxlQ}8B&voTxZeN!;iG2{t^Cj1PoLe}fBz`< zU!UowyE(rt@tMQ(%NxM*80y_J_6<>DtF<*gLF)y zsAD@WfWFSP4Y60^mvr36qDWBpu|ao-{?oInz5;2?ZmBFE*FgWDJu>q@ZEro@zR&-E z*XaKruL7LqI-%ok%Ys~bsVf(tFRH>-A4Gp1Z-a?#B2LRmFM!HPt=SQc}^GR)$78|e3m!lWh!?-GoO83bQLAb zR?6;~?QBSCaqM)^-e-vnD?e=mvfHiO5ePQpCRMHh0jh6oS;%V?umY8_)1xJu@5&mM z9i^ThLxi0`M~t5Bk)MByFQPjka`P7gZgrN@mjR6PSjo8`3J zxjC>JAMTT+Sk96a##8t~Yd8U{X1wEQStKhiPvL2-AvwI75l_o(vD*6T#$(WGhI3fR zV~h^ibs4|yB7aQ)x@)S3>-Q0&7Bu(eh#f9LhI^-%HZ98*LmCm5E&2<>)rw ze_svw`uq>io;}&N^gmnA?%)3}=KXK3k$zvmy{_&$yFIs3!rcX`brE;L{YUql9TMg?9+Ox)HN&DK1t@gWCS$omGgtjdxUi>^QPj$sAW>A3l)<=YPcifpzdy^aOd4zslK<1 z=v)JUeW$NC3one%Ute5x$^AVmjDs-p|Ji%Dtu~S^QS`mOg6(rox2uaXx*#Z*PxtD~ z$dr&l2oMDlnNpUyFav;^xD z?lFcD0>Y6L(S={Q{D(hk;fH$dDI5x!K}-)B@aEUGzx~PF{KL-mEJ-g#SzxCTQ~7W2 z2ElCYzt?PE8CRtij!AUn1j$& zs6)%dOh&`=4JI06=otnU?Zh@nuux;fDNDt5)xWflJ}%hK(ncN^vM3YsPl*V$yxTaX zqb!3(iGW^liI>%2?RL@^gTNRFJSFBE`d&jufHk4y|6rUz7$r)<#ao`Po)b&73Iqud zTNQV)$nP=D8%xULLy0(l+k*~G&_|~6g%_-Z|X-_&znzFRTmXs7kZ?w%4XQ!k( z;mDDSw+u&Rv{zKH96jWrwmeexX11siEpc6O z%?Q08-1gJB>UkH>>xp*}*@v zzEIZ?=_=YnN|$@>NjG>>m(dn7tmJ8c$!Zo^NmeyOAFbKrAXumitQp?}%9);ciNv*& z((5s8<7xfL7T!dql#$!phCedg-Ti1aD~%izqsXi_8sjhrdf4yScPL z5Q2_Zc;8R8V{?0c>Q!G^41h#;nk8KUY50FL691*RxwV<& z{~Z6X0{<^906?^{{1$i>x4_~d07A82!CkNznqr`}0lduy@TD3PWVtg^@P9Ev|CcIT zl|25}i?ILZNEA(|W7GNpc%gx@NLI$Og$2_!sJAwqJr2N)3DC%NbQ5q)GH%nhMl98| z3;_9VOkrpj2k%4xL#S}Y#b$r5p@8jgmMrKj zWFm}<<-dl+1AMX8F4`cMrfBQG;OX!3!#h|$o(PUbj1=+kIa25ZL(v0#=?{AWMV2C< zI%`cNAdrC@>F3)a&kDf;pwDFB@Zm_}o9P<#HyGG*aASmBvDa^*n?KjY&tKJJ9-Q%q z_y3K@{~iOsY4-RpmC|+u|8JKon>qe}>G(g!|0n)>2>a+N7oT?!oA)S~W;4v^s0an9 zP;stWs3V|VVMTFa@ndV=S0f1eJEc$H)(2AC4aoa~mYvhV2vd(^iyZL+mT#01jsHHq zY=eEmzhPSx)d#))u(S4GGXqv&1b@feHBc3=AETt4-{0XfV@W{69kTW*N!Y{m8EwGR z?GC+LF2AZm_rPn-{#q*PzJu`jo)~$bDP)KA#`^pO4R5^)LSVTHz{cj>>)D-)c?$2NaCtf&qZm zjAZB`?+*jaZ@^%k)_wOzH>=THdO4!{^Gd0ZDLobR5!l*D|KZYuh1S~J|AgNg|L1@G ze*!U}eG_Cxu(oXg*RN~HVqdXNz{ltvco7JEvWEDC(q)kTh|YmR&;+5ERj{>>eoAlu zbBfB+jhpiMB)B>kKyv)l#s#M-7)<7um`CrhZ0#lfOrrX8u{vpssIB#zo|9yOf3U+t|4OlY^PmK|`7H1ttz zQf9pBH0pb2(!^c%#Y7ix`y427pubv-!^OY9DP;DJW9KTx=~(ZrYT*|PbTEgc1QZ*s zV&17H{*bRN2LfSTf?>>eiIc-$%$5Hqq&dNp6vZ5DxhKKp)!-SwgawHAMkXwA&(hL97SJ0(!&l;SHBpbdl?KN7xvm) zJDwES2?SQ!LDhZo!l*NJ#OPg#`P9PRS-1|IzB+mbb9yJ1emzq`7-?!rh40Lwzec8_ zy@nbOx=DJPXemp&@kOGS%TMtgOmZKSnaTP_vkXu}$Z7DL;r@_8HuYWgU*w zF=3&o5025$yet&*>oi9BuIji)bz-=F8Pu)n3hyhB5neFcPhs}+tnwe0`UAX96_sHL z0-OT>Y_J}vvoy=GV?np)P&F2_B!~YD@DF+)*oGK_e=hpJycNm+QYvk2 zS917&3Gkn&_ZN{DOLYMsZgnn_REOjmfz5sHZy%8t5%39}VAiJ0iKb1$QoVjKX-_VDk5H^V z7>w@CY~t{C1~s8Q5aBWjm$n$jIteK#Unk8Un7w%y3_DQ#cmjtHBQU7E^IEYv|6-=c+p{0fA$nuNAI-`9%|KLlFohABD1;_Txq1c1=P++4x*^1(p)vdm*WSUxee5R)|j5z->Aw z&c~ukzeAPfFG?P*J2_cL+g5rQU(aC?f494L=xzc~@pHh{M$zty*&$f2e?gOWYr%6LE?I}e*^Z?uh z?dk1AWQ=`VSYIpry4Dd&z$u=TLAy^;-t7z;z6h=W$Swjved5f(!M~@I7~O}I%yMn| z(+2Md)}CFpo#$TYtJo2iCl`UNQ^`fue@oP5^Zs zAOe$dkKfvE42mWr2=05ZvQLx$!eFa(^+N%F=c4~g<*5DVW~q?pe|nMf-=`6E@0h8` zI~KdiQvHGA&`H1==}-4ZGyi5hd$bqgZSB*`H|RuLOyOwRl}0h!<=0<+;btM~IBT*i zK#{8T`moxcOlMwzIzW2ElOBbZw=7Ty6fhCE8IkRh;e$`IFktvQBsoQ~`(B@3J8!j; zaS>ABrqYX1tgD6zm@4!!yiT@=EBwOJYKFA47+b*7vz+qUzx<0{UE9RB)(B>LJO6If z1P21T7#(N!w(zTM7}3TqC@t|dT`wH=J=Svf@XZz{Bg<}U#aFQ3qU(G&)o$h@;O}|= z*E>5rFm@aXkbM7dZbtRLn_HDae*eGV``@tLJ?S6!!0MC}!%{6&?=U|Qpx4!X8cqy@ zJlZcI{(+f@ilS34zUVN^Qx+BywVjmX8i1D+vJhb1^LEzmPJgAe^|ap|G9H!UAbO)S z9vKD5&$E2(?g$VJd=-}Ohglj%AeO%GI0L!F^VX9{F^ zz`kgmIa&bk0^RgyOjmmm&^Cc72TuB5*1l5SYKuOFFne{irJiCDO$1c?vEmyc8mEQequ}*9dEn4_K#xmA1FP-6gz8l+>H0? zgZN^q%SQfD{Kp!kK3N0N5Lz|zufNJJar9!^AgH>Z^ifWQOzc}en0JNWCztOkzk+ii zWk`9Kbfqtu!BoITCf?HxEUhz3>h=U%L)p$h-iGJ&4r=|jR{ZrJ+KPkx3Yw!FDabNF zjrnfvG+>R1^M(8*+#dh7HtNuGb$fF`J7@ir*Vh7Md_jCb58wm>t>S5fR)v=h*pwC5 z*NXK2CHnthcKME-Cw^4X0&O^2yJ=%`Q`%ARKt~Vz!{@0iyszj$A?^x)@o#Ir*=#)h zb7P}RkHqZ-Xk<5p`eXyuC%+Fd#OTJwV05vuU4GxW>XeF`#X_;t{-ao;sP~VHPJ3&! zU8%gks$5)wbiEX8aLAvUZQ!apv6q3_F)IymTX6%R)c>_tEN{&@ZqPCJXB&T9TwWE+ zn}zM-ALY^?bfA^Zi*k9Z^1fWIZ2$4c`|YjGPEaZr7FiXX#WH3r^<3uMkK0|9`s(ZK zIfh7>%>^5VKt~(E2_+2Way&+{$bU*4Ee-4t)9nZM4uxm*lnrI@TKh8wi7PY4$=Wb@ zn5i6KWvgGW1>HlSL#+9(J(!L_I6N73C&6?oN?F4ahRbMTC~R!)H(?tnLl8|r0o2O% zp=lMSf9E*Fa-SUoa^x#mR&I#iTuXH2wNu3+cS^ijzZ#jNos2TuL*s1X>iw z*Q6B_PT1RDGoKf=SCQeWK;p;;^k`vNqKqk9*)Yp0i^}sQKIXeaV|pg@&EVuKh})z^aWI+G|=&g zxqG~wuy0^psiD&Umf9KY`rRl0s8w(7ziWBB=k@*Lwg33{HRHoMX?lL^-EsZMY1CUM z7QGD$@>uHAi>5>@y{Dln0sr)R*Z?ODy6^q*jM6D!{Yv4#6HwI1Y`Qw{W{~P@ya;F{ z!GEa|&i`B7-rUON|KH``f4|`j$eTaQ>u;|5gW%1dZz9|NX8jHGa>L{hf4-5wr*HoJ zuQ%`BZLt5rDqfc|>pIJ?ez!*Lqy&#B{_t`o;0>`&cs z>gwm4f93+aXS)9Z{+X@s_0+nD*Nee^2KX;-hvk3tzvbNia|Q6Pfv5rRI)t0B9}n&t zjMFOp?vQe-d;RGQn$IQlFA@G(;?-qC|3dIz%Jcu`@Sp$tzVNTn|J=c&8%!zB5&Yi6 z+~y!{976oLKB!HyvjeH|-%1ht?@A%(|5r)>o7G^ym2OspdGf^_Jtba5zMi#R7bss>r76jNG0N1M@`Vf#U?w z^tP)tjpH$zrW4tvd=dN1CxT26p9el4I%nTJqxAy)PXUMiK+@XhkK!}Dbl3Z|fxc_L z{^n*Zd+JQxqMNg5KLG!i0fOft2vg#}6}KYt-{MwI|E-e#dvlIzMwYtrC=eF^6z}uM zCiLl?D^cKfXx)xkZt5C4GVrb&%p9yo8?TNg%Dn@=jllVi7pA|N{z?^*73sH`^(`}f zGa7B(_s78z=9pQ3<4-1|3DBLx%fW322c(Zib$vTzA)#P$=&A358AW^2$2#cuEC#{c z5k8wg&qTfYl^P8JpPj(~8}0O&3(se@dY#W6f2n;x)4rc+-}}>WyVz`_J?qi`v@fPu zMts>BP5R$P!x_ZTF+O~PfTEZ9I+nV(5%`&JbL|TM!2jV@UdO*v7yi;)$iYk6`G9i2 zI{t2nPoDVHU?M>uZ<()z-Cp!w`$pE!=xq&G&=)2uq)(r5?E$@&d_f5WbPvVKeO4u-3l zte-f}36z|8Sae6H6h749{x@oS{5yQw(1f0zy2U>+CA5F1JbZ`~Vi!b$o5whmm zeAc?cvz)c7fxqmo{P?b_@ujR?ebKtY4bR$DFTE?+=&IUh!f9WJ5x*DuPG3R^fe8qn zc7#Mq*^>BA)+0gIN3_U@Q~5AN_|{_(J8;7XMlKzp^t)E`BU=uJHyHFmJF_szT!r^a zH24r|5bMRXdY9CyM#MwA??dfssEDYJ_E|^RYPh$apU|6v`Z_?pWe(MhbGUZ1E=@J# zS{p6$>w<%GrbYQU&!QNe@f%4V77xqOH*jT6#VqF92v}aml{NK|@60!H+RE5Bj(kmw zNe2epAYb9)kK(-pNH-V#y*l~-lDWTh{;ybx@PFlUIp_aY$p2Z`D?9tN0?sdm&C40O zkf2k z|1g!WJF@Y0htI;-9ov|#!)M{^(z;qAU-!wz*BvgIuXA)rHkiB5`gWgvC+4n~z}$5g zV(u>DIhgL^%-uy2bJtxqb9WKX+;tac?yj<~OmzW1I>g#_m(AK;rE+#RIcJyudnNWC zJO{uNY`-(ue^m+*`|qvoVjll@rS>0c+vl+T4xgIY^dXC#hq86RKM~6UEW|A}$h9Yo zJm_bR!R*x}@&kHe-~;M!YG3YMU^_HQS+s`FxAbezlhvzQZC_H0m}>WMu@6-qV(SQb{DHbbAZJhxIn7-`rzZvEKHY>UQZ>9Vn-~BASA25Dc zA=#+E?r1C&*PXOLiNqnJUU7vTyV6o{WyA?1KhEyJ-n}mE-BAuJxHacimTMGEyLd`r zT++h+wrjk`Zs_aE&up}A<^OOyua^S*>F^K1e#7uz#+V>E{IBf(Z(?vF#>@4G!2!MM zgURgCVB_xEgInbPh7kO`O7He>>LF+E2;N#m4YOtAE`N5z)!|?FJf&h>UtECF- zcIXDvDQeTlW(y1f_8oW6KW~vE-`|H9Eb^Kk8jMVs#>^BT+z)?2Ns_C67igBza5_Rh zHsHnuyzMG&`imcG8VMyncRMu`A!RgVsdJl1zo5z-9*Lv4Faj9N;5L1H+~tfX`{OAV z3w3LZwZtY_fvPG{V`K~~&=v)DRDrs+z(-YJ7jZayy{EI0m#W?+E8t&nJMXiI3AoSh z18E;!Gh5@GfTnm%>ZngSxh&GAh2C9ZK8%{QWqd%xfcaLEa6qXxA%7-_$Y-=M~wX7+ucc1YED&uX;s8`PNP zDl9AizvSCL_5P<{Blmx~lHdQUy#KZ9RlhQm?r!@v9Hs{B*Yd(waftSayO}MNg-Mn? z>FPFaZvP9SAcvV<0m|dz2K>#jl|L}#0P^;u*IZTEYt^i`+>X6~y5tfH*zdTicE`OZ z%J8P9U34VrqN}R5{FsaGW9michc3DurNC=^zkBYRs~aAv%Z_Bb>pmq-^HBBYNA5bq z;3@vXI}TlVTD6meTkm6j>*fCzvHuWJ(l@-|;Lp5lT6rwLtH7v z1$A}Jsn?Rm&`&UH5EP$NFqP!q8vjUo{(J-St9Cn;AekLvML$$wM9cd7ngo7>U& zALShXzX<(r$o%KDKj>VxC!s(fri}L78IR>jNwOe1XZ#=Pf`_xy3FJ|kB-<0g{~>;K zqdkTp(Eo#Kc?1+Kuyj~)JQ>YK=jZgf*XFz>Tvqs(zG>bMHEA~ZPXX^5EAFPXwHh9$ zkB}EVhfwsx4)np&&CrL7{tyte*)<-c3zrE?jLn4=z!c>$RQ_Mc zDr-@%)A`X`7k$}=cl7Um)AP^${ZE=kehL}E4rqN^L%7ZHPAsDHPJcQcv>*5B-fLl9 zG*SN|?q#W0f0Ciqt`ZdAMh&fq6P72-xFI$6$yd^A-BfEQ)B=+zizY1PoB}hhsx955 z3*G4)XCHNul-LkF8?)SZFtCO&@@hET#|w!fA?L5F0X3CyAC}}3U$iqvN#(=sx6C>0 zBu%HKrqggZ1;!d9k;h#X&;{s^qwv&`+!1};YR`|_GgNP>4tdQQDj?)zO+E6#9jX;! zDvX-4Xv4QBYV=IIAPam^jcElMs=$SsDIXbFkI?-KFCo6JseL)sbvJeKcoo@@ro%}y z)|#gV)QTvAcCkQ1X?yPF(H*!+Eo;kCSF5S5MP1E_sMYka$VSbFf)z&#s>)cl3Yp7!eElhcLtX@Nm>dsXsMmuOX zTIm@ta%QebHErv^sJZ)UNZLYrk;b&GXq%wcW-G*Ya981i7AvrWh7e~A<=Orqe^7&Q z-ZS>Zk;1a~-0u*czYysUx5rd9AJCnYa4jT$VctQ_AyIZwBO=YEKoIqptByre814h} z1CyW(k3$YMUjTvwl)pVO8v2P2Vb_;Jx0+-Lzv*^XOs1%b1*KmUXRAWKeBc>K4kU-i zJR1K(Vd*aC#?bec{_zjkVBqmkI90LtxEc*U$QrP5g5zQllXh8a{ppP9bH%y!y-$j5ZL@*k#xrMb6daHb5+sR2F`7yBj6c^L z|MHgE5B;b@(P7i*k)tfJURR3 zyWf58ul3CSw>Dgz6${>fpTo^Z1bB_6_31z?GTua~39kBq96_94xA8*8Th zR$&YS$1a7LFhEJiBgD4i;#-awTNoZ#m_yIw1T%!BRi6+V9)SsuIc37kyE~z(L2`+^+CX9P$U^WYrvCpK=}0F7~PH zz*94~6XFhtMbCKN6tFnMZs?4KQLyEJYA76Wa(f#vy57GAwn=))_NRREKg@3Rw0;c$ z&IKSth|eI?%+v(1QcD`=t>F(?;mw@hS}t-OpAWPz&6ZQICpPH3chh$3v_Ws=W#cVh zcA1zO*0)1nbtcRXM*u2rM@1MTHgGD^zBFXYq&{g3(DVdOxv~Ym1E~Bk^(DEI89b9` zuL7&xi1^dpX|b@s*^zUYbEbYd4lTy5Z9Q^#cD2D%O;iS@O zj&Mvs5eX){`!MJ-AETGVQiIh!t{%dz9uqt~zudCDmdU%L&QIwWyb!a-Iv=5_d}ui~ z!Px#Dp3-mj%D0}0THnoq0sYk({EbCzF%?VPB!JGOF^IxDkaOKv)m?YKl7-tHV(gt%30L}lr}jCQw^(@7fF z%hlsrd&%-Z+tNRylI03Zia|dKiXjf?>dT=j6>>XAb+!emud>2$oDUzw;DxX z<0)LvoD$|z?Fi^<;;x?a+y`?`e57Jfol)DjJ$QG%Un^aZVKZ@$_j1J7m6B`sQiic@d>iv(Bu=iC8V#q7`tkkJ1d&n) zp>QSA3}`20_#aB8@Sbm~HiVpw`R7x%oieeizw6+xD1b4rCy=Wa%|pWpvHS~IRwf*R z^zy;fKciAfP)1#{8t{;)c;FVfijs+tVm$|gOig6Oq4bafjt63lvO=%Q&TROgcE`pw zb_{RFvMVJv)D7V+@=9rz=NJ&>nsyU94DyKWlhu0;qx2;7ijpM+KNp4h--XiEJP zn;?HAX$aO|TCaA?D6G+JF?O97)Sg%2UUeF#bZ2hBcI7PnRd^BL_2c_j*Z1;Ie)FM( zk5p<~w1JSP-ngj&oDV`v&)cTIffd-oMsy1wF%1vbL94MdrPv6JNhr7o(#W zpX4xgzD99=o-IJ38sbAgH4OUveVpb{p8f8CjcXF#*nv^c!)j+0ZN(zwc74tlOtYFg z)62J-$p<^y{&$fsS0T$mu=hsELS1(38ap?QdEU{`+A9gNJ0VW}4 z`okD=$0%*>L8OA1WSTKXd=KsP)bfEP7+BT2Leu5xy;yyvtIj|6Wb6oiB2T*lt$bKK zFCS+NR>1j2109P`7X-$712=Ri9D}ce8d70*P-GOrMs#!^$iG#D%jAa(&kc&!=&x|+ z=q+i51{ED*6w;`~hCt+zj7q55gLDz&v|_?xc5=89Nv&~t0(`aR(x)$Hzo`0YVKck$ zNz?nD7li0)M6$RiP^P5c2`lYDRNsJ>!iUXdr?!M!5VJpkr1G$t>-<9cT%0B9WdUm) zD^f+Kv!5?AB69nKIqDbAsXT7QUy4x?v8}hak@d1!_i0+=)nv9uApIWhHrQWg4zQCr1LQIX^dLQaZ}uLU zsTdz6?K!mTPfsKDp+CBEO78Yx%d|F)PZGoct51@_vR^L2>{pZ~IMgW`Gp6D;L-tH@ zUy7S)s!fu*qOEUl4A_@h&&_C18+_`Y8Ki$+%cFhkhubW*f4Rz0#i{}|E7CPpcd9m3 z4WyajOPZAABsp(o*e;ad3k8hD@K`F=AV6sEdSpI#c0PdyWM?n{d-~si9_2gLT}z{4 zoPUzWEl9g^=_2~#Q7YSi2;L<$d)MtZAKpJgn7qStrAiNvyeA`<0J|}%SKv4Qv097W zpIIOIx;>6{tZ=+(UqX$U{s;eN4?iY9wHk*-B7++!iN10|9jL*Sw?nW`xM<`bbYHxj z>6tNxabGg#po2R_@t*~>hiM1KTF?^%!Z{c#ui?k(F9XI?--{|D=ace<<^)9jvyhJ3 zC!ctHym3d7fC)}}e8T~WOXnI)rX`}M#;*--Zco*_b+&N3Rb;7Jxencm8{Dz&!dpVT zCN54vstr`HNME+@vnw}EcJTRB5Ayh4DbKmww=RjJx0a+G3y}<;m_PK@VKPHJ365jA|_Y6GIRqXAtD2s0v(IRiHI1eX#*aQs2$Pprgu zLk@<*LWtq{z)Z1aHv=(JKk(r8{T=OFXF9k85RXA^ui9mPd5_vLW=@cKbc0rui;zmN zVX|*O*X{C8zK&+JJNKVbJeOSAVs8pf{@1m~;Qd7s?yw$uqpOyObXUVTmnoDu_%@0*kPs61(joxEJ5{HJw!A@z= zvU2jgRP=9CZns`|b-9SZfGQ;hP~9p+zjxKs_X=3`xNh*RQS(bpyd5C;Qb~39Or7~n zDWM9y5lQGlW-t_4Z%l>a#(kX65D5w9tdadd@xbOAzEtaOW5`gGURlw}0TMS0|8bo5kBi(+!SXPdBQ?!iwk}m*CqKYQLuPa8x7? zOYIu5;%U!r@K~kZRrJP|f6K2US(L4-8L{$MxGYJv{b#U#AM`5S%h6#4d85gzT}i`D zk7Nza=szf8rjn<5UPe(Ldz(>z)ggX35>Q!FJ{Pw7G;lBa**3MHmY}{!1bK-2WT&_jFFkHtm`U93c)%RrAUasK{k9EK0sP9}9<#}!7 zIwOA>BT~jXIdv9_x@IC3Le-Sku+Qa=u;w06sXmC83l|VuoR1NmY2S{XD%roxSx+19 z1{EBdh^f9a=dvICe;cei!3NFve#O!{m&3iA@5r8J#oNAVK1$dbnM9M|A*GdhuWsQtca9*_l9$EoaCqj-EtWjt!u! zFKcJ6nzEiywGu%qD6z;IKd)x>;(|~IMRe6UXt|A*SmKzN|dPIMDED zZeGJ7G&98fy5F=S?+L+AFLF^>yhPxG$8FxQynGn}Ez@xLVej4-B3|r$qDqPPL;l%T zuoXyBeHh>XDN@KyxY7diu;~uhbU<*>i9U!q4aAY_@O$5pQxIi19R|?*5vs_+6Prup ztCmbO?7|as=dI}KJS>VyeY%V!min<+N^Bo~rQAxTh1+OQ!P`}XO@&W)W_y7nsP6K> z;uyeDwP8;Eo4NF(Tma%KqT2K~-A+8&$;&<{Z4!0`8 zCzE{3wbXvrA3-RI1Zx7Y^P9pi%l=4fSx?j!8b2S-n@p$a(Y81KNv-4ftH6n6)M};s z$L6fD{f!R4d*_HC&#VNLb+@q<(P6sv^eyEgV-D$uf7uif>lN9w)aTJ%&#o?bPgy$S>*+fUZd4`Q&X%FH{Az}m-xg^#&%Z-`_K#&o zPDhu%)Dt0iH)K2r|MQtMZH2lzfK+ZFL{1)UcpPTlBs!S;kKBS0nouT+G_*Vs)Md;W zigX$E<}gmOz4jE{zpH-)B{Uu###FI^z8_oheqQ1X4-*9K{)0bIqT$=W2P~l< zr=ifC@qJiIP4I=*(fRd>)k?={4ReFN=oUrLOx%3EcaOeRsi?WLF`x4L>jWp}`4|0j z$sqc}OoDcis~gNHypIwW%FsljXC(E%eyMc4qM&@P@161rDngNZPz~JuQ+bMVQ4V|m zlunX}$W5;(VwEMqtLSH>9e#MkLv2vjtK=25H%@pM^t+O~m6(4rA17U?5(nQ>J2tT& z#+g4V)*0LG>bw1~tc+do0p}eon#mAvtYWVIZN4Mw9?!mGm;?Nbqa?n-EJ0CfBDbsC{e zh;i~hS^wiRuP(7fbb{JIg zvii7|p@xjr4UaN6Q;E*8fW)x>U+RHh2xBfG^ zE;3tgn4}4n)OeX+m17DB9S`X4iWBoVhmFL30}_gX@*BX(lb!)!){^yvr0PKc%wfQ} z9K>`#RSXd>ng^^uLUJ{lE|1WEG__J_LqAjC>@jQq+GK@(4j>)6#B~Dy`Mm3n%<5+( zea#<=CCn21^i?dJ7?%yKC>=`tH|nZ(hw|y4USO`=l`{h5R{F})4Z)AQ5gKHYy5-I; zcQy2Xq1Aswxc`R0DpT*JMZ%V_S?ZQ8_s>)-QF|vxU#6O=f%~>rA)KYdofjOYn#1%( zB@ny{SndXxA-mehTQ=Rb;b?pIT}299kxUk9*|O$^0GY*Q0H{1|4XB+2hSs;*8sE$X z1O#?%O78sP>*DSU2vA?k63&gShtC|y#UcoWhRZ(Cy;+xC7}nepm`8vrcs^!J|Crj=U+9<(Rr% zlyG!C#y*bMwwI*+=6NMTa4LUa#G4T3D`=$epLWoWcb3a<p=2ets#S>i#)ZEQ^V(ux6-W(dlcZ7m zEI)kY#-mou=epMPG*o2&^)>5xsjJBKSgG=D9U zUGKj2Arkf{>m0gzb<` z8=2XU$^2E-2~nK$S2k%8)n9Br47yY$bY+({XlSHZ5(j1T#85EW@@26^?EP4aFw?Z% zS{+@epf(q=t!(Ic@}NP?YTyW*z7|5ocyKtnG5gGr7icyPanfk_N|uI@_x?h{RaZ3s zz(bM|^yp6=N3&H+_I8g`6lK(Vsb5h1371`k`o`B=_J=PCoAJ}o@@0Mv)n3?ZyojbH zF6r~|S}LJKI(m4AEao76zEO|{-(tAJ>df@+2wG86Q$+*1o-?Wr?Ec)WQnZ>*Xgk@vSFp}U^4^(hx#w4VxR6+W0+_$$%0NXvIe6@^J! zL_kV|*J;7*uX8~q3`Vvm&XD#6QicbHgV76{qUK1?;T?e0_fzWnhLYK8JQSr@18jf0`gXBes48@&ht7}+gZGCU zo#f5`@X&vpHXAOXoyN3Jh4fQ56};(prm${bN)cY<{*c(-^X<=xOV^keAaef(aG>ld z8wGOqw@=*Q)?SMXvKIjrvbs===hpl^%6wsOI(eawc0n-OVs`N2Z^%$TXUHBSG#8c% zrM$dfZW^#{kecZYiL8*|^$F>p(U6RPA^E2K1lr6lm}7u72J8Z`VSd&aNYe%wSrHDp zQ7}GZyLH9G4HZuziRg*gwlsj-<&r<>qv*&8`FhGQs%Ra3ck8Ul9GjltB&9psYENcO zc+z)m@Tr&7&t$%)Nu=LPpM+WY!+rIju=4`|MSw9P(V` z6}R7v>gcxY=cOi6^z~?zTi0J_xdY>A-7|{SOJABH+T&koY3cL*%HUm<&`Pc*5g2wF~DC2~MABvZod5ajT2Z!MKvBNy@sBGI@K z_-hwFuufrrCjQs%+PQ7UAXMSm@m z?O2>%7Zs>~Q{Tp~`Npg>wU3UUbGr+9y_$w^Z0N&wy|K#Lzw&lv1Fr@zhE?$uFp4AZ zX)SgSSZ1)_zs@xbglPS|YP1fh51SXcBGs?ukeo*+nLAs>1fIJpQnr;IRq80W6GN#R zUgVWNm&Mcc{nesa-Nlny_%p9t?4*eW&-i`2aDW1r(sK&i^}Z=S=#nn0_KN=PCV4nI zgdiB^Pt`7SN}4(W4wHV2+^}xLLIDw#D#}rU8uXd~e2bG{G#0PjatF#a%_8Y)cMMq7?&7NXcu(4Y44%&t{e7a^s0T(}Q41f9R#FF)+SL7R7NBi9V zAKC{{u_^`N+}4nO2-3|5Kep}t#IG(z_uy4}#=jTV&Ige`F@JG-n%Wiy9{cu%SDc_q z_JL0h2)%$C6X1z{ll$BVOi;fW%lKftz2hu$^8w;7>2pSoDAGAM_Q`jkMFu0<=Uk)~ zaBk+>J7;AYvJ37O#N!G}73IS5hwkwCck|6weXq-dN>;3&UZ!NWT3pRqdD}*E6mk=t z^j0u-KNvpUwEU$bFL`L1zHfHU{6yd)tu2;Sxum;9oGRyl=tTNa8IH4S)}X$EYV06e zP5-&4^a`}%EK>3e(jP5RN!4Q#O#}RXn~yT)sVml0}e0tZ-U4* zpqBmgmXGp9#Q%%!eq`fIlBe3^p8XPB7Oo@9D{ZUEtvAi_{7-8%YV}rF$T9AlnWzqA z>HSvUx_R=a0#!GVRHk``u*L)QY7FeiKjj%{EyoBoZQ+${sfCKuD51u+{+`xR`+4!< z``lE45{n};pN7;rQyQJ z|Wn`%Xiup=jA^W|g=YRj)73KmHjQVwsYtgWaGTti2$w&Z9x zHJ3X19Hz(q9ksbE8oO9dX*r>diCj!a7hZeegv#ynKX#KxKKo5~gBGrDl|_C!CN5qo zw~hOQHf+VwVAp(YCkCDM}z<7^%_7X`vfA zqrZ2T-67)|`oLK(0Cl%mq`Pn&U6n%gHwC<0;8944r*#mU?G7g8_23U-yN1h)Z#TJZ zdBXaT#;*}TLt9t&DabGnd>A59{-G$WUX{eZHUbU^5IHBcp2jdbgi8U;)c%=%Uv&Aj zh4tEFq7F1_(;YqnNWXwG6hEp@MvJ|j8=DdF-Ad|3_9N_3^AzQ%e@Vz&kVF+Nm~J}` zepbS>x{D7{;OAO5oQXRuaBjRISS%$Piz8mB_`s2-EB)O}yOW;p`ORq|mQvHj>451L z>};kLr`9NXH_f<8;bDcxiZB{mDoe2|X-v{qtYKt_t;?~>tYNF`Ss z{$cgkO<|!;_t?{1lAi19XGcs`KH}HcAx0Ktb@PX;#p|}iew^E&M)#Af=E*Lv!fo$^ zQePkuRMbrYM;3NAAT}7~rb-5{L;e&(e%xHj-Y8NS6Mt{F${~a;R;JTvY=THAI~^whwNzDXmDgq8GZrr> zgg|uTkq)2e0;>IsSd5|||2w3(e_?#M>U5&&TxP4M1~dklRZZJ;k)}%eP2&em-@YR$ zn`njN{0%M^LfSaIecd#Mh9YOZ5R{b4qcAR-N-pLf*fRQ|o<$bmsFA!+Fji<5JWifF zZ|2jLk&bZ@4+9mT6g{g1@vDA7dwk~l6{_0%NJjd}1_fb?&R2Yzuqf{8X6Y>)|F+<>hkN)5 zc2ga$>ryfkSyWSLPimL8Qi0?+0|i&PVIptui8sH1~Fp&mwd&{5e*Np5{Eu9Fg$ zvNJ>~Zf7=*l!h0hG9pb}G*VCDd&MKN`oo#8-@ll^(GV+5JshESu4ct|sM8HykbOe9 z-6}#kXEn|=+I9H46PrfOrc}rVU&AffjDnd6Dr#H*IYr7a_;nK0+gBL!24YhK%I^S8 z4v2eOUGf;n=|#8{Fcf+NOh)`jZ@d+ndJ03c*r9koedfauCdV<77q*0P(j>8j=?;Uq ze|A|*jpL(N?)r+X_}V{t)Gb3hDEi83dUNRV8YepsDQs%aeg&$O0o#A4XLLr>fCaDU zt(iYIS23u~SjymS5QSe)leh(Gv^YH(k^(>xcxt;5qnS)D9A^Mg zY87fl)T^V#hV0*xZ%+_|AM>9i)ufml*s7|f9)!?3SmiUL>6{O*NS;NyuybgvDh$sJ zLia)5j9Mx2tO{mb6%X3@$8wPjrEgJ?JYnyo9 zQw8FQ>eUo4t*Vv;qvc4~!gz+eX}3_mz*F_(|Ne_pC^zjlk}A$GH$4D9+M~BEC&@8I zlw4RkIU8Ud+~7pqet%RZ=sBm5hNI$~SM2$dPHvYySu^5>(Q?OiGnvV?V`%z1AH%#Z zONxwNL$)v%h8a4C!;XKCLn2zeWO&};px-#6*6kXdiY(<9yu}#*f@<;4R-8zYp5PBe z{xJ!2D)yg-p%yfrb2d7N%|gu!ETdB9(pvkF*Lte1s{vB^Z;UqpRm-@6wizxPkAe-C${MUC3qnK4>Mdr*sV9;%0p4$s7PfK%zH2&%1vtlRsF5 zLn-}m5)n2)hG*LOq?I@;XYbUB9Ik;OJsn`clc6;;)%mdd%ErI|j1fV&zd{(VL2_+i z`}><7GbOuygqjUf!#bB6Zc`3-xnEr>7-~w;N`^rc2O=-?AihycQ3we2$dCe%D_0+R z1~mGB$6(X=_`CrH@;es4X4380wfkIjJ}pVWS<1J&-1=iS78eN&G^3du`7&4-}W{|cM0Hp1!$S7#(jqO+X{uAuT|a&^o2s_tARp}w`8P}z(epK&g<*k z6qvVTFrcXp`K|HZP0>OCn9*fXNbVC0ggCY5OZ9c9ODX#%=k>!3Lm=+X^E`Crbg(= z(@gdbPp4`xySV32@SnxT@c{S6TP(3ASB zfA@n*zvrnF26X?lj7-Z@_4(BqaYnJyaxnG5k@3&Ib~szgdmR>PrG}Ao%%6;QIHyPk zd0*h%X*U5_O9VJO^N#{woS!e>ne}zRe7!B94G16N$IG*3qPp@K zI~l@_E{?1Mmk{%Z>tjtj`MPq#>mL)rgYuZ|A%SWOwg+&TJe>QNgVmXJmvfJV>$a8n z;;!5{L`JSEE61rwYhFbFagp6^YiUhof7eRyw_U1SAl< z=#x}DC&d&T08uSVgfULw8p7D0N(FBy7hB_pAux5B%Ee;mIaizPa^6Bl*kJ8eN&Aj% zXc4cu*1DNHvCU40?p)+#4@F5^65GbS9J`ei?g<@A=e2xwWmcG>_`qYe;CbzMd41M% z92wP&`ZR0o5rOklKTH{k9_>hrw6hjU6&-mrEV`%qOw$H5vz!N4EpSnRdrE$CB125LrhCK0?DBMS`%ymTmR%bxkxy;N^Ymo$4rpVGUL&d*5o5qrJE)Qb`&= zjf~YSUCKWP&A(}Zm;Wfu7@{W_Q>sJG|IVeBqky(T5_?t}4+BPYpwoV2AFq{c*gXGZ z+ijHR{c`_Z*oF+_)6=bmd$bwton?-bsVt!rbKr(4A}V7Z-(@#S*V7pSELUagHj3s~ z3aXw@MZ~>o6*Yc01#0VcdboI}FWT$~RIxb%!M}-)sJ+F!27B?_xA16gB18CA_J5dX zx>~S$VGNhe+;aR-cM70C(9&YSj?8C-Z8tQu`m#Vq+3bKrYHiZ@2N@x=6rYza2anb~ zG%u*Q>8oP0ff^ii+HZA9B=rlWJE~>;hyy5*y((7t>=c3!f2BkBQ$n(Lng+LmrlIu` zQ$6K&cB5`oeejxbibJzN@2*@OFg92ts151R4&MWqQg5Gn-Vdd5M3I^Aycq>H7-U`S z2*kNHRt`d!JIMnsg5Ya?R6BlADRHJWCbJ@rxS}0x6a*Dftp!eXyslZH_CQ$jaFjrKj%4^*DI^e$W(i8mV*Z#mh~iEm;I;k1T`99vR;OArnK}QfJsdn(Tc6fpp~YXFb6l$7bzjh72aku>>nQk22I?h(KC9g+ zqOtThKH=l1^S|=l6mX6=lPW<`P|t9|3us>674}6yshMI;eiQ-Scm8Em<2QvK%P&PIO`NI^?zAxUXP=4ccz0XAO7r5>lp zlkr*I^}xE_=DJ;=sDeXB@}jzR!My)87QMm}j;hD93q9RX!HUF6-f#RD78Y z75n0N1s?0~zNp&NDiwS$CC#g@78t66kOk&7d=zR zc-L7+L%PC|YZN+}ia*N)JnDf#3}ptwy+G}Enj}UDs10&Sy$j?%XLIr#z?=XW^8V*VW=}aSZK8SP z;!1xbw71v+npq%NLQFl8Ur*yo2;RnKuFMvR&udC#RF;^wScrq5daqQ-vn)-PP8Atbpk>YZT$l~tbM z;hb&zC%yJIm-gdbsDE}u*=^AE9_%lIz42C8)gqnyt-=A4N#hkx zy3-Ol8;17NcJ;EQ-)~vwz=mDillo#FX$WMK*Vs?=@^u@Yy3HVPk_bS&R7>!nB`Y zm{;IKe+xVjMq`EI41agg!Ml$4Yg>RDEg|#6K#x;IQV4TbCp#w)`!MBAFBus5@!wjw zlIH=pXDja)xY!Nc@CWVK@9LJ<{1KLVsvvQzy__-ARZ_<2RAM@|SQ$JJcu7706EEM|m71y9r;3^W}U~ z%jUa@4&QO{b`3Y#Crp;eV0~}l#{A4B_D~n=W#NDEU$qJ9u~NbGY0?adOl^m;*7!10 z)!ybw9%WI9S(BIhFi>zUQBiIUQi+|N3)Ji6I4>C4rE{5Csb&W1$Y z0L8MTQ$UmK3$u6@W5D{@NN1H9QG&aCRmAUdrun;}tY=U;$3_kjvp)H?P{o7Wu1x1i zgLG#G*>%Mj>GcU;gu7WbW${&$HP>Ou)x$yQl^eT?$17FHVHvp#4|BTvuWtvO8Z*0C zY_-YRJ_E9up5RoC6VM*)RPiGMt?v%1VnqvFeMKz{ zJH=OI$@?!sil}Ws7q;n_Iff%5@TeX}lq|u3USqvKc1sW{fYb-}AuS3dGu*F^F6-PvgKyAdZ6xwS3^7E>+lS8dQiD?oi zoprtob~ZUiU^6jgLm$r)%~Ei`w;o{s19(j53c`SK>Ht`zKZFoNPOxn$6~Pm^PudP2 zV#ht}%w9kil*sVv4GoRA)t>$r-Y1`AU3lMaN)@P4#q=)dd7VKVJZZe0ulTT=gE+L> z{#$|J2{w7!(eaWMDsp_G@_Od=H89X-!qKD=WdVYAmNhT~K@Y$0ABwrq$q+_8eof

    OD#}a(L_6P z`U#PRO`JK7MB8oL|Jr;r*}bu$i|L4G?!p=CIi4RbMAHsn(O1$qrAZzzMIa~p^axiI zvjk&kN)UBuIt#x$CiwH1Xk`noK`6w5^~qFnQSMF(0hBcIVt+Se{}hxU4SPC{KuN5X zi8SUjZfk?Z-tC||5;w?jD9JB`KZ8F230!x2GR45x4S>*u`xNQk4Pir;7f+rNr$u(9 z@(5dc+AO#}?+(4w<%pkleUHLe)e0x6-s&TS=}6z53MmKSqZgA--B=G;#GhSu=2ORt zEC+x)-_OXoAppJ9K4@T~dL4Ki$^)W<8b8C}e!G`kBpevAmN!=Xz=R30JdZja9I)ad z_y^wTF5(ydsz7x34chu9x7rF^>Qn=O?yquP9I(GZqjMV4c#LJ+SC`PE)ZzDR!Jwu~ z*OIfRA+@CphsQGMrIdLRdr+>^3R3*jQecZNJ=!|i6v;N zqt8e;2|CO}H%deQyO%l8yNca}Hx7W9PJ@(ifMDgW_!iFs9u7<#0qJjXQPu;Dvx%G> zY7@D*+<*Uou2@j1e8Y=oi3DPv*MDA%2jPnTy%3JI9S%23~ubfjnbuAIO!A4~OC9fc<`<`L};FdlK zpvcL<+EZ>+1LC28j2L@GL;-Fva~QI8JfPXqcfA>N1h_5}b3Me~Gou)r)ONF@B5e+8C~8Cr1O+qTb6r)>AnP0EitQ2pWJMy#`K; z0J2i5d#<`b6$(TV=_GIcX3a!=c7AXIiZ^FOfduCS?hzanhv^nb;COQeMGy5u8a_~p zn-7fgzCErU3EvJ=MEOOSlF!(zc_Aw)#zasHc6j`AfSDQKH5Q0(40rz1!w6{UeO@}{ z{rn9dj6;4c9Hra#wn?{TxDQm!qS*KP*{<}c&XQ2cw$fQXWxX8_P=pJ?2Y;3fI8JD`x#&NqZZH!+#{ZfhzTl|EzcHDt2^}iZ|Bzc zJU%q&I1!7WdI?y8r%c|9w`QHEAzxvluW^H%%Xj8m{zF$w&j5Yf`Y|H&A#Dn$Tx`;{!5pXUhOXXQfsKSl;#nqYaEG(sh%J=<1o8NL9vjy|=y1CX9p zZCZ(Q;1DW}Lm?D}+ZsJc zG=xG~aC5LF>9Bf?1bsWod&UA8d5aP5)o4M{u)n#*u2mlv0$JFv46h4>vSNeA@C&Rc-BtUjYCPKP+l#MKv- zcEP{{u2KP7dJ`p%kQ)sbkP>~L!!aC3#jH-bjf7A2mxAbgwq^y^lsrXMEbOqr)B zAnXmYFMw3g)1)s-(G`$9VFd=NRTY6+Y@ok8tj;pjn*@7ESkxVtK#EVp=xIUWZ^vXz zEx9a6Cj3TYJ2v{NwxCsy`5y$$^GzC(J3$E1LIcu|dn|)r9iZ~n%YNDr!wT-nbtR^KU4M~R(}Bh^o#i#$C)vk&5c$Xd(@RbE_Q9T+TXgcP^If(v9Zwd{YP+E1z&PKbkwHdxs{|+M)HUpa#d)J#< zAb{R;l8zQi4nAESe~Tdn|rgnRFCT_YQDsS76;fG1G?trtOzZb{*ty zf`t^zs)-v%>|Pl;2y#V3crArd@UysWD}S3Pxm6TT!5Rzoa_17Jsq$9<$Aj=2B<=tx zR(j_S;6t>=AZz__r5oVtz0986P4I0$Ft-e-Km9%e$-OHv9#x+~A}CxXhGKY(T?{}y zjWBTpMJI=wUldDDHv2AmPFKzC*{4Ly9+xaJkAE|sZbuh$r?bQsfk0_JaQp}ghA{uI zJG|<-c?B(;f%KqO-Z#aO4*Nu3`XOO^6JzxD1>pnK?2hoe@*_$&`TYSP0csb3C6wpw z4qq#DD}XQ1HvrKkq1C;=_^ z7d8`R7k0>6hv!oOLfSwH)NQ-Ry0N)$Q;rSdnMmCYoMC==1_ztN*(S$mA=6G4{K-co zkxTC?ZjX-2j1?8~H5eN)^~CS*?|XhMSt#c9Fl@PTBO1!grDIWU`w{ZeH*~pcH#mGna`b7;s4vhJ(~%kv|(oX0`!`HrG9akM((q zcy)jWTSiQ4oK&sg&v)YkFldDcIgis#;>=Xo*%0@4BLoUD*AQh@-zSaL;Lc~1bLLRZ zPG+1Lr$68vEDPIQx5*K2I|y%>Od$a=il=`ijHe;yBETFiToj+S(a5h^HTkX~7U3H5 z644i{W551@d*T-@HQAxL*ID}OR_eaRZeW6u-fnT)mL1a$f^kgklzhER%DBj#bR148 zY?k}+{+O;wMPEw>!^>_}vsv5N5asO4j|Ex+j?Oo5maazB9j26PR{2}2qW-Pi19nE6 z2dAxHNjvx^%hn+74)gcH&*p=mc}}$X40nTxjNclmI8$v09r1oUi(u8lny~Vy4Oh;W zmy-pC*J4RQ_Z;Vc1T(Qa*h0#IS&k>|kNTRcT3UZ>zq<=b1%~b8rFP*RAHtc%`UQeC zAJTkc?@JhVm*S%675!YiH=$HEl0F~v5i0aj#4-dz&)l7l zcWX))ACsVgF^o@?{UA?zI-op>+xfL8`TYcCg%K>!Y1$M}`X+jqAOXK?Uez5`ohR6K z)^;wA)kgs3-RlM#o{m|xII{G`UjI{ha&F`xi+1`BQ&O{A30F92L|Wciv{LRI2k)Rf zK-};Nl~(($L6SHD1#K4;X^M$m3)?`c=&Nm^U?^qaG}#?M4(0VZ%2)y`G&`4FB`wKF z;o3gSK^}3LtdB)Uw{rWM=AVqQ{7&65CWX%CVQy$g;UJ+tH<>g!va>B4yG~7SPSV!DI;B>>Y~Pq5Il%n0Y%wpbs;fP-B-M*uehlpo+GH{Kt0o^` zyW#ZV1kZ`)EIMnXS7OaR-FaCiLs3qlbo;7|tBMuFbA3R{41#)``|e?Z_d5F8x{yQM ze3;VTU(|@|so=)*|0WhH1*%MuS{H$wWzB(5w)7W zdh46RB&uw5DTrwf-cTpxI6TPjML{Ofe~~;M&lCTR{x2H;XEV?Luu}T(NbWr&oaSQy zfvQq1Sm`UX2%{Evf_}F*TQ&n3myRZ#`q50v9y9uebV;@tK82JRJ~!3ph~bk<SU@V`R%H`4z+ThbpP^=mmSz|El$kapc1@?Qk} zFCqI+I{a@(;{O#Zx%_9f@SoW=XL(6~G;F|d0K*3?A9s!qRtW!3%Kno9{!0<~-^}&@ zF97~E?}_J6`h&*zIrzUU_+LWypLGAi>(X)hfKR;laAB|GMXrB~O z-J&M?63kbIK1+4;3ex=Ss7yS&dX}FZ)l@w`%g;_U%TN88vi$7pS$>)+S$>Y@&hpdL zv;1gDf=+&rEI$k5|0QJqN!S0Eqx%0sPXDcz{?qdAgwpL;GW&@1J&{B`nN#yTOBx>G zB-7+#Wdv40hX9d72L6PhH~bVhP5__o#3zn`@FN(|{pDg3oT10C9K0to-g8aFYL;mH zqs%jNwX32;eN+gwZse+w8E^JfiCufbn|)QHVb6DSph`6D>23~w#&kFNzxnY060-kf z$p0IS|5d8w^#3aHzm}S9uE6|DNadpu*N9~qb;VIJiUb%c&T-avga4<6|4o`FgF9jWpKy};inVFIe||z@kysE_{UAJLY~gxvBJBG-}?9>`K>JQ|HSaW zsq}xrlK-!e-~TV@{?8D?dkLw0>@>4E8czQ6{hfc zZuoyH`=8GLTk`*v^85eg-2YE!|1+fWvC{ww!oj>QrvbhL{4Y8CPX_Oy&;BB0V}O-Mmz!VVn;Uuy-{huA-yQy!oc$#g{QAsNfkVKlSk~_~TvS7xi1SA5gv@ z!(gJ!h*UW42UW~!TonG&y1f8*V%w*^;A-|M<{JqhY-QZnBZxd1_YvqC_jWcL4TWE1 zbWnm^5^ZSV&Z9xi&6wuVkmk0C=AnS*G4agp;mns7CftshfsW#ChU7xJn2;}=ZeeNC zu>d8kj$-I4jOu1`sw%;msjUEg%1vjM0xt5OATW?;nz3% zi`^set4x1!4Ka7EVbs?;ccWpHCUp7DZgpVW{~HN!O4HOy=qQ&6`)t8!;( z-lD6cq1-p7xc;KbwKannHb5jSS-A^U?lP&pjw%-zCU9($I6_wPN|o%o8jZ_V3LU9f zL6#$lm87jPdX|-Cj6Cl6uxh*4ueG|Ao@QIf>azXGZ}#th|1T^0b9(-t@>bOUznJ^~ zybSt3ArN$WkmvXq&(9Rd*{($3C?65ZCVk#S#MP@tje>roUw<>v7A^Unm8`0gHF0rZ zN#^7KDv^{0{+As6H$(j2LWKS=6>|CiD&hZVzqL2&3J89tkiSSwV!{`l#YYMc!3xdj zfod2Kt|>6YZaO%_=M66>6P^VB5U_=17LquChk<_Ap}Y5N_CFE(fBN6;JpRK9;Qy*W z2q<9cyZ$Bw?Iwilupc=z4B$1LVmR7YAPjX_3f z32Ke)pNO+NVPRd~{4GKq`nrK}tgG$;1LG>eqdHGSq{AEkZ|Bv2gxJ7wvMzJUelxJTGN^u&QYc{#du>`O;{87za?S+RQ$iS z9p(Qj%emtM$p14F*aM9 zYHS1*RDz&?7eqOV~A zPQZxkt_JssjOh#oROxG19Bg{{)f11eU&ER4l?fklgc4ffiUCp$07>O3vl2HkLdplE;;(+tOJVnXjD z6IxnB%a|2cV?U*p@)Fl376#M+Y&cZAIyi<=ZK(2=p-UsLsp1$7i~;Dfi)K#mSi#&Vd4pswZ(_k^Nr1X!fN5+^AsgEUt7_Ae^2ylY_Zu<%=eW( zeSC);CQap(=jC$)d^Um@W9W8 zEVOJIiZanJ$qXCD&3PSP4M7dsTO2!B@fD}dl3U}2Vqm&F=d2X1p{>gt;DKcE(s1^M zw|4E1eK*Q(T^N=tf<5U}wE(*?L^^}@+BqzYbwFacOXLI z>HlRxe}?-%8vnhL=YL-*{C{sKHGs4xD11Gc1JABrA9$Ry*MzgH0-hh9{^z`!Z42*% zi>$0bTKrd={BJw=|9)BYzu51m7=OmKo5T^Y`}-dCU^)H13i*HBu4N_$(%^ryWXbgBYUakefVQ z%Xg#yQ}=H^IbfRnzpxp(|2Ipy|NkoGf3cGM{ zte%+CPxXb-ILZ0nmI3|g@n4Hk`oB=g<^QXMe+`tQ5Wgbewx)#SYJpIU4-VTDiaiCS zehLupsK@%Ng5hVN|2Z>f*`)C#pj#h^!1xm$7k?Jq#4-gb@qaA&AGeSv$l-rQ^ncnO zXXgeuL$KI!O6C$s#85~^_P3b|fuma}fCSed`siYg6{K?nXc{N3W?S@?m2GKZ{L#M1 zaef1@6~(VeKP~V#>$-qTWIyQX1g_AA8$8cZOFqhw6&I{e~V{%SK(+ct`m!sh^ zB}Mon{m+d3nqIXYZ!SiIeLBiFY_Jfae8_CnAnnk@WqUdeX1qWjlJ&IkVuD738#=z3 zdLeT8OoHiXa2GgGQ+~wZb<~7yC!T)22)}S96U^4rpUNjT->FClbf7H~ds*!VgAT4y zf57qv9gjwX3(OrvZ{%4p;meHxITedP>(2&3Yxaoo^)5#P46HYvjOaByCH&{CKb(o@ zi*^U+JLOd_DCl=5Bf5UXtQ>Us9$m1hb>uiSc7NVv&`%)&=Hu|2Dxpx#;S7Nc&jNhD zNCf|g!79EHwD=Rri|Dob)x%&?9u_*&;`;15>AAcL&PTc<+uNzB;j*bglV#7G6}j5iTIA7&=4lZUZL;(o(o~!| zbwh6(jKKPwFi+O8as`2;PDBd{#=>5R&HMk^`?9Vyy5;Zh@~-zT&smCy#FeY=-OV5( zDh@F>Sttl70s+L~)BktX9;$kx8^mZ5vd-~oy7xTPuB!d3DrS;>9VrwZ>&&vYjy@sC zZwVy2NNox?R5<}#_&Pa163aEF!I7GWW0li_Ilng&;Op2Zu3^w)9A>QS{7UK|HP{bCI+Ee2|x7vDOfEOpvZww@cR zh7mAGdK*_qDZUOSV?x%6NUo(Ea0OysLufqhC^2+!SsK63_V2bEN#d)RdvTkY`E`PV zuGFHEwGPT%mpXk&Wis-+l~8SsG8Pl6B`+mdEslRFZd7sPcaR?_;x2W*DwjuUN>8ZP z+qBaIM?cHyQI8ZFK0Ux&hZj0MnyF3Urw4RcWH~+J8^cczbVMQl`iiHzp>=V@Td&`W zeOFelNoynle&QZbl*Po0t9?m|(@2r3#hRXw&lJhh3|#9)Pq`x;xx_`BQ9(_5i}iW& z^)*AQW=zHB75ntdB^rG}8PCmI@G4ygeRFIABsi?^v`4>3%3_@cKEOT5He`|9dQ+3D z$rErHQ)AAhptOT!N>fU9=*%6g3hk*FdhgA{6Y}I5hmlShnM!piURwfue5Q`*IvvsS zv3*F+MO&%M-Y&$h$--v=r8*bP5B7AT;H@$%6acpDYp zM#Z;A72lAlT7J1)F#7QUI==Cet=TB~l9YU5_s`$grR4JnZgiib=^LhN`tCo0rtdhr zrtg7q@bh?09~ULAQ#5_86iwgtJ8Sx;Z_@N#XV&z!R@U@gFQMsctwGaQWSjUUUelLf zTGQ7_Q}pd8)XEfng?Co;H6j9Vc12(E8B&jwnnlxhlG>I>Es`>81TBUoY=40xr6M8#HsOu!OUIq~r-vd+e1yqP}D!xd9WEI~}WV%6TuBGuk_3Dw>-;XGcf+6z=aj#uq*O8vZM)!vAy_S)H1dmB5_ zHQ~P}oz}4b(3*AHTc`F>$?}y>zuTLM(QwvzP6-2;=KnQn|5ez|Z}5K~hyPBW>*ef0 zmt9S>>GLAwx5JU&M8zl!oFqA#`BoGYKeI?j=oNBHdA#@kZ3yuq{BP#@N-+pqy#IH0 z_agQmyE{9Z{r{2r|5Jefg5yT?_dFV(Dgf|hf6%^ZP1*rSFsj$(|1UeO`#LK6LCFl7 z`=38M+69FCF@jSWRLuBcJQ>YKKYs??NA_A%?PLygi}P?F8+ja=ljj$X)t`-G*e*;b@*i-4^)0=7Q7AwQM!|6h<3cRaL4QG$~5 zpYmUck9#hew$|x&gFISRDIfeUskc`M1W+|zIpnu8Tz=Dj(_jigO&8`5vhFJFp?V8V zsYeQ9k2QRR9Oyt(_W%-Hs05(WFm*h#=NI-2*-xm#l6ovtGvKjIkGl0I^j5XLY#zU> zx98M);b`j|_4tDdV+k6R;F@}>TL#j*a~v7Mt{a@K7WYzGHIKKEfoo9JtTd?qJmT`S zh6(C4yH9q7sR*P=C9`3v(rB`=s8XLo>*kmqSu+KtjShvN1ecCJ-BsgH!;J*)f6Jx) zbMKP>Rc7FQp&sQJmpj)a{2G9YfZ*G(`rbeA_NTx9U)SlM&j05Mk@(+x8~e{sasGdr z<1xtfJ0Z3ocK^PsuY(~ZV#nc{t$%cHzmxp0J$%h%PUYq+%^6hM{*T)K?G|=6@n1fJ z{2xEp>y!SYoY56}oV{beEbNS*q!=6aVcq*#AZq0G<`0S#XD-t?*B^EJ7b>0WH)j z{G@belh$zBpUI|4X}&u_6ry26c*)JyZO4(n+1G{4#h;|Hkj?E3+fYYqI+ffCfc5%A z7^v2y`v`DC_%`SayNI2XgmM5yI=c6ECL9`j#nG7;JhWGJIP6Sp7XQd`5B;Ad$33kA zuhboku3ONP)1&|IPWtU`XCS8pekYR#^%c8)JDNPnDL(DoDgfemifagd-Sp)APcXHd zWgJQ<;#mHW4Qs&)Py>4?JFSs~Fpz!Oe^|Lt(&N;wgVIFdKWC$Hb*LtVeZ^N(^%()1 zvbnRzsoR?1dK*06XpCmcYqg}*^feU`&h~E5dKo=ppt;a=!*972ZdwQie(JYz#rI6U z{-lP!uGj#X&AQNhZc zl_}C{ODi;vRU$tJk+*CD!D;559_JP&woBEaX`AZ$RK~h#*4(AFC@A$WmU^a>T+w>2 z(l$t5RBaknpPIy`$cq%lCb<>d#2qz6YN%^OWtu9%X#rx0o-2W}%q?!eGB=_ym5EUqrO9SYf50GSr@CnAw8IufXf3f3i-kc5QgRh z*awS32%bV6DKs8-2%@Cb2s~G@xrTyrRSonM%ca5~bB8jQ4D`p>CIe*xd+GhOD-6g{ zSVZ>3bd}tpslCO*%j4d^n7CNPmgQ03K!@K#lvey*gFhhTkO)ZtjrhRzNLIylWbtX6*Q!T62=Qh$~7N z#DtRr(9&bz*x;C+XB|#8U~D9>lSlHKaUf~ntl(YpwCAO-cDcd7T88Kn((I`&ZhMxv z3ud8n2357N{G8Xzu$}fjEh7j#i)9+^uAeFpd0qpI-{G-c^QXeD=wk(*wVegXv78(9 zF)4se0+6ghc}-ZvZunh7lF=20j@x~#Az?8712gt$sZ(?xkTe=YK{@6f9C9BFB^1du z-@5!PHf}_(fsmXkvJSH%`5;TMQkH?SIiyws74x)#$pY`=zTQ%=imErr2BAb`zD%pV zLT{_)EEYXuqo=J15Y#SLqCR9Wvj*qNQE~$BwGox1(b4x%_E15EN49W##8!faFJrX$ za-Ll}T*(B%RVE0KR~A9zaYe}AAMACDu*YbVC+Cl*;+ISP1S^He0;!^hSZjfS3ls}1 zvlh#RcspRFQe`g7dSbFtrI_vqrHz)*{5CWM6NxO?>g3WyYbGq8te+= zkG-x~<#vMId!=4fgl{MQvMY(-d;4JV6>$KoS64F(k?0$eq0pc3P{U7JT$GR6SqGLQ z$TmIT69d&{_*Mz2=~L^L;@q7MjRub`-n={MQB5kjrW*Bx=27Jwemn7fb2u ziY{4izqkpyx*84rK1&~~ru*WO@?$G8u~yM_1+}Cn5)xbW+@VsQ14}y*BuC z;GDz`&T*11u0_l7Ad-BsF77V#SgO%t!oySaCH3rah;|Etsn-rxQm+k`Rb2v5dTrOupk8BIk+g|j zq+ZJ<<$Y)UT5qlTwQg4Z+AK}KmUn49etD!{o3Qw4Q!lJwc7)N5Ulm@ zyMTJFMD=;3Ub~N1uiZt|YiC@&wj<)yYrYE9)ya!BDbM~*`;Tl!A4}MOUHr+_Fx>%<{M0g8i zG;Ro~wtpYoeC-(XMg(JCHK1z(l2>BE-Cu-uR`JNd-N{f^K~PqY0GK{OSxX@(YdIFl zDmYNJP9=ulhLEg+;Gf5_K-QC39P1guSkEbKiPD}Di1ji84Q^lu<3OyH2!6Rv5LD$?$uRXK?Z+wvp$$vRTu{6L+_6H1^7N?^dnaoDo!iOnZqENtaQ~lp>A%EXd(6SP#DU59gLkrcPpvOaU{6WW+$5TM6r!mOqN$~oNopyT zr22Or%3?>Km0oW2KYGU)+(ZUFP@4Xy5b^)Ho7>*#e?E`?CotFR)&tqXm(d5It_S~c z5BCu*QD5A7bT^|uA$aOni=dcm0w*S?wK7A;C!n0z!bzToB5Fi9ihR#_)Rz9f$YT`idMtYZa;xVH^b(M#$k2-4Op+4Md>u zsc@g9#sdcF@F}z@01TDD>eyMp0*}L(6sJs8Fl1HDVxS;O4%qf#cq@^2RmkBRC~V+r zgIl&9Pe=o{%~W&;45#5twClAR0M%^m8smb|K@haU2AKuo0Mt|igZB}@@d);G3D!Xe zC;CM5caCE4&Y<8vqKt4(5FMd}Sp^Zxxv3r4;+Burqoj%K2SIMQqiu6n8vjAEe;fOs&!GPca|}!JCdu5@Z|ABO=oJ@m zOrL~9icv-GxkeWWho3s{kSYark^b1zYDf)_!o%nbLd_7XW{fQGNWqU9GN(dhsN;g2 zs)0jdbE>XbPg93X15VMN>9^{($m;x zU(~B;Cu;c3^JwzloNdNvhx!{ckZBx&PbC7dPksXVCvHp6m6gf|Kic z_gGNsoWrcjxX_*xMILaE;+opXcTBTvF!kEbaG5MKL7@<{WDyYCk?apk7{#i!s&;y5 z{crM2B?<$n@t=y3_-}<`ZnOVC0sp^=|MYhDK9#m@qCjoDrf$5Zen=FkW_%PVzsFR@ ze|4moCG~$Roc`(fkLdkxVJEkV|MD5m|4(*2W{m>XTo487-`U%5m;bw+>FjaR`CqR! zY!5nO>TBlG$j}AtF=Q`Q= zW{_wfY#Iw*FU#5TvK6F#&6pNN3mPZ@#ICz|U|1$_``|G*4;?6&T?VL}a`eTIf6F}b zpQ8U^{ZFQi`&R#tlKhoOkYJ< zwDa)2@)2dpxul3CP02RbM_J{+b?yGt{h!~B_iV?Ymy?cP;RRH+@yG z-R*65_j={O8w0eNZcSUao&U9@=)Pqf@?P{iPfNIeNss@%7r}q!Ht|0{Q2vWd^m1}v z`r=8s)U7Ub{UQRcUn0_*b#yo=r0bIJ!KlFxSon;+e^IK5<^R(sa47@u6#E~! z+oSg1#m)Zz$n!t>f|W!71Bm1c!#aVwXR{SryNQs&p@tEp)7cBQ z0{;sMZU>!bXlA?H(HGgO={~%G@~t2D>(OjBdcelqVl%YM@Fo<69>Z;Y%Q0J-NpBck z${bjuPgQhp;Y6+s+3mBsb*mYB7X0IpAm&p{Qgww4nx$)f+n3S8hs=0Nap}tD_WCLk z;?&vx5RWt0G+;N>$#&#zvgOWEtmRH4)^g{RuA`f=mOIB}xpV$emOB-)+&Pc4+!?CN z80C!9F~RC;sOQyATgAjvnAl;wFPIhqP48U2XjC&Bh_Lm^NlOhJ`Ccl$FnEym885(m zw5vz!?eu`7gv}%3T?eAf{VOn!#YY_Wt6eUj%`!MOw+<2#9ff)Xv^|F*$ZHqAvd%;1 zp49f_f_ay>FRxi2?91<*|1mFrs4!N_ztB7vtlAcL72x0ls5 z`^Y~M?_#WEH_XdQ%*!G925yCUm*antcUf8gi;1&ofz#}N_M-PcJA0e^pHHIyrHNj< z78pxjjQr95{?&%Jkp$G%wI-sP?&3=ukATGy!6%A4PH_#00?1!;kCi|%M5qMHXN_|( zs({XiFZ+s!Hnq2nCem3J;U;Y6LtduH%!kMxeWgETt$b)2ypLb>=rN3g!Df=I>zM=b{-3|Z$k^KL-hdJk@VbRdHMZ?NlY!hgWzt?T^X%}4BicD4%}`=1Zh{|Av5gxClv-w6$ZFV@UA zFcPuIM*b>j;!BD@ic1`Xh?@*$1pur5!;HvE{QpwBKmGhKMDbsR+=l=E$o;RkcVM^w zmd$PMx0+h}9=KZ>iqN($D_3v&%7(67Hj>2~`TyJ2^`w83Fm<8(*;Fwg#r`+96TyG) z?&P*N`~Oq$|8bMOoG7r=T3UP?Pz}a_5EqIPkiuMfG(<~(2p(>33}nLSjls+fhYL?t z_4l}{{}QSWDEbQ`py(GRQuKp8@iDvlBtZKKw39A110QbLy=dS=&yBk!0K?>4b4wsf z!CQiM_>zE(gUQzz-LOzc%GI!FJ2ssWOyU3b za?$(W{Ko&==am0qov6{fn^C*d?F^#`F0=)>ZQXPl!@uc7HnnR zQN?+UvJoeq+>TE6Akfyxhhv2qA^}`v>{7$*x6Orkv&mOz?LFt4NTI8op_{EzGCd%DtclReu z>>+buERj1)a>{te3j9@ez8=r*EmV~|QbJLpKebv> z?yW2`>>UKFMgD)H(|EV^-cQ~CJ3CSTZ@J=Leq;apk>~$1_P@mahoi*=qem}g7#x&8 zekMAJgpXcl(0b{#OR}CK; zo7+LFJB>3)eor%GCm47qCMI`s2=1h=2FqllBEKyhBV)F+Adq(-CSQG!18N_t53+w) zUIjhDK)W9&6IJY34!d(BKlW!Y%^BF*U>-IbtR2xwF#Z|I zPbJlOn8;!4dVpg%RZeMq`{EW?Rb%K59w+uEeV+D4j{{nIvUs#Tf+gb&2BW7=+w08q zl;hh&xd>#U^I^OH@L;}Hhi$Y?YF2#z?59_)*M0A&b9m`oHBG+qq;-AWnOyXz{cB~{ z1aHM?0;Ak&_aAKy{_|Ll?DVqN8OrG!4P-;lMquP*6AU`@7VIVSqB@jYt2GF=1-~@0 zdksk=Ze?fAM&s&Gd1lA2dgq>Yru|pF?qsQcXfUhz2w|AyJWgc|rtZ(6v!JUo=t(jbc56#ChP^FG?Og|kB=ACvkb`JE{g&$Sc*og|2kdj6f z6g#@Sl!W1jkw2gEUCDQ#={oL#fg;xZ(KO(rS8kVKdot>`<6?8+P6`CHiU!TPeH`ej zn@{~QTlU(Ulbm_kplL_W&8#)N>8LrEOAeNse|syNHN^=mtMmdA4VZD>jm$m5C%F@$ z1^#3*Qq&NLgWAb+DIZF5f7|b>Q>Wi{X0u8E`f-MaeC_^pEDr!6zk!rLgFkQ09t|4` zz6Mr&(v(Iw3Q&~RWZH2@vIni<*{Cswh20wH7ZlA*t^Wxs&G0Y?nHi3T(en+`&Q&c# zWu~ZqNqVSC_9pVr#FoGel-HHM8z-;fa@7fP2cOt}Tv z*Xpn*tHq^R>OWH|7S>JQbcY>X+iemKTqlR0Qt734B-tje8(qaeTB6Jpi37JT{~V2= z*9?4HRwjrla&ChhDO@h)z(W`{>Q!=x-tCMbdT$m+=+#=?D#gh-FrOOgQ%z?pqv>aI z5p}ef%==`k;=@?0;(DxA@iAEypTt@fACXmY^D{a$k-vNH9Ax(g z*U=G)wFhOm`W4`4OkW7wpo#*_iN$|kU1MDeOT*JZ7agbyGFK8Z_xv*4Lzv94$2_Yj zmHEk#9Iru?I-;lVZfVD`jgD@p%l$z7WUV|Auj-C?pzvZ~b`u@~wG zRBFv?HbySfugGjO?~sz!|C591XmD?Z-J!FRF{mN*<0N#qjpoN@G_?-c#AkHy!dmHw zblgPDb3!-V5En6LvfGH0r|?&4qR;^ORfL<-U~wcd$d}7(0M9AXXyI8i_n~WvNu*Ua zLMf|yC`-AQiS9+P^jFNKJvuNJIK(CDkH)#c2rJh@hop^_ z8}N%FkESor1|LYFRkvI{!+n7PyZQNo;*I$y7*rLrwZ?+Zp~E4Uvu|7OW^?R@o;eQ4<_37F!0x2 z+^It*&vhu2(QBx*a%i`*?`$&f&B%dZJH3 zXsTN5UtEgTP%%^CRG;6oIs&R@I7a8?nfCzbLytLF>9aeqQer2D9&d}uqoPeGbz&Cn z#KMe_=XteG+%p5NN@6F*flin_iepUb#5~%Gg&85w>w-?;LVEUy#GVF^)N4z`^l{@G zk}fWWo~jsvdHd$ksqMK0Pc6E{wtT0}6eN#BU%3^^F?UZ)lHV?qkD)=Cge8<$TNsr4 zrA(kxN5u)3G+om9Svj@Hp{vL;NLmx$x-B}>+#C$h8Hgf3m{8*AEqK*uQEE^O9pSL< z3qh+Gh16}KC>;yxgEHYRzR!7VzoIyLkH^i>|7F7ki61(|dCg#KVJZ*5>HZ1G;8#Lt zg!$vBClT_#J|{ zY0m{hbp661X+W}p7l==l52V|ra~>!NbnKVGRg99a2T?G{-@F*9u9BSR6M^DbG_aAzGtHfjwW4Rw?A}KYh3=g=0A;X9Os{wd%Va|P!?#3>$0LT_AX~y#F3H&G7!oB@NTGY%x&IK&d04RX_Iplp~3;F0Wpg zgCK`k$RE|Y>nUFypv-{fL~#akT2Id`p}4Tqd1RdB#i%9aAEksG(;)I8&?A6K$a|)Q zyf2epZdeT(<`mWX7*$mdFz>J&qrikS$hu<;s4TUcLy=cpS-(s(JZ2^RD$@p>SD7tL z6=Tw%QUlgA@?qdYGHsUkti6)tnJR{7a%vCO(_=uZiWfUorr?B-$6PuCUcbv+o0YAm zSD7_^v$}Bxb1zh5Zif8ZsT$9XIpx1(|Gj4PUmE^zySP_~;Q#X5oA}QkYX9@Q{Xa7p z8$-4%g8hY2e_>?SB8*T3<&}hUTL^*+BGclrTUqc%*-%1Bz?AG5ln^E(0Z^EXB3y(P z3_?AB11#{pfq)_0-+FO=8$@3i(U%Fe*M1}DE)7$c2}nn9=MALp9Uyi88c^=@BI69c z-}r(Ff&1T%n0qgnTnHt%37YU*f+h&-fyXMZ_2UUBpN$Wnxu-)b=v!JWLMex#kg=~n zqfCq+RYJfn4MDFd$Z``dVqXjdK-RDE4JVE`v-9r{U4^w%#cDlZ9o4ftvX?t3K@Uu; zv=Y6JYR9hUI9*S-TJ;TOY3!B0^95yuX_i$UBC5jb;}LOU_1_3A=_^dZ3EZog`(jYw znIU{sDsHR@m0j}V#0(AIdRU_}7?_y>J}|IZk7?^yZ?FptHBv@HL4Oe!T30HQ%B(7}Vq*uVQFYdpMEV~T>%PIsclOy#K z3oPq<0c2U;(s(F!HwjhN4Wi0&mih}QWG9FyyWyCxVj7_AO8`(-u(Jc#Nq%1h&}7Xl zCC8!3x;{sfJ$tl?Uc59o*#*Y~T{1Y(_jQBGuHvv{A_J66Bq7P>B7h_-WkZs6*-h1$ z-BkU0M(IpdN@vS*Jv@#1AelJT~fJeiPWnKLel zB;%df_Oc<#cw0=KOh~fK88act?ih7FUW_E$@d!y)6oe!bgd}@pNU}L&QqIzlWJQ-E z)IF5w-g3(~RLl`y{RGU|pcJ2(1I3CQC?+xh#rE8H28#7R3Me*@2a5F)fMTNrpx7CQ zv2+4BG07Y-oLDi46Z`&|abnv6oLDi46T9SqAxWOK{49Xe`Q>GRh}|=Y*dTzxy3Ym? zi{Q$#K*W~8mDxoS!Ifo&hz%J;>=%QGb)pEdXNC|vB7|6hF@HOm0AhJ>6@b`|w+uk+ zH4M~B0Eh*FWIKrfu|go-ULrtjKalPr86Y+((OEDpWrB!3$7Wdw5_?UkB?1$hErf{; z{CJYxD;aheem#~MAWVetV6T2AJlGd^EqE}_W$%a(9_&?w@L*@}!YcQcWr8Dku)Bm| zA227vI}G^s%79G?r-e%=qaVOD_kAF6nS@u190JOt zeUWFuYR(P$@cY(qTDLa7RQb||m+HAqldINK>_Q99+g8|bAbD9Q%2li!?Lr8c@<2o-yQvo&48_z6O~|BBXxEH=*q9v&%wjnXW&%k}bNYjIZANJ0;xw z`&j?)3%Bsyn*G1-=JSQ9|JU5c|Kp$U|1pFtjB^JW=VCt9Po#11_*?xiY8Z?Gta9Ag zMAnH=7YhK3pc6GXYk{Ld4JkBeNe(^>!>fAzb{of#a9YdxgA8ML*6g_|A#U4;d9S(( zI)~Dj812)ld>wXH74>5k>+Y%)@@Z8jCsY;dylGcx4@>C0$zRQR(;4}0YG!iXbW20q zWuBXQZuI@ebCZQO>5HKsH?@f@JM|mkj2VMp=BbOZ2o(9z+*zAJ4NN0Zz=5I7QXDRxW6Dx1jwc=~btJ>?-Gkf21q^=rKnee)7U#$#O z!zpPHHVKz!2?_3B zi0u%ESnz3mcG%_LfT8gp5*b4O*@X4laC;vX{|nBmmOK|H%UlAc#sAyg3G;tD`CMVc z|9$ZNUvRG1a|uXgrrZRUGe-pTIm9P=RoLixsI6%d29ptAomC3CH$zW}z?jUY{o8N1 zmb(9;*;`5sT)h8xihGgx@3{iVgq!{Uhu#0I;Y#OcXr;x3=Q8d%=KZcRbWTnixgZLa z&LqACH#1U(J$o9V>^LP+agl$K#3lyan>3n>(nhE6>wHu#Py74(NH$J6t<(rB_0-}zunPDqJ z?Xcjsp21CmJeuwjA5v*UImfpqiCo|~FsL~&3s9cUthrn@#tpj?p#vssR)6CvTu=Z-M6oedT>k`#7?AnmP^{tQ7cp7cfF zSNZuXN=L(ru50PD=P+t5@A&nUQ>U`v)H#Z=(T4Yp*i*+3oH{>9Wz~v3bdF8`@zC*% ze-d`+R7iz(MO3`R&lzvQIdc}9{ft>Z)#?k*llbh{bZnf*92?5Ut#gomC~W?1^nYn& zZ3#nw6#ZYm5W#=uw~L$e|MTeo)@uk5EH~XEfN=2W0HNWOfAtBc?mT!FqhA?n^F?L^ z^Y5tgey9F#DJV$F{x3xN|DBz}M*sJr_>Uw_UX%+D>zKHT@SXHd3HW0C=a7yisxGpw zJdh|TfSZDH#yLf2cr#&z>h|BoKdmJHF9`%oJ^!Qjf5nac$A|9!h|f>8+keYGHt?jT z6BUI0p$P9_PhnM*-9Ee(8v*Cd{Qqt1deXNn++sCOsu+-V|6hpQ|LyK>?*Bgz{~tHs z%ZUO@EhkxcR$u_?Zmeq@f`CO0fx&YC!U8TeEbatTe-nVAdUBoGBw|vlb5^!o2oqiI z{*?kmEdRgMbfq%_Df_>eD@Nr1J={v0{r|Vz|5KWqQM=QPM$=e|vsi(geBd3mGxfrf z#bL~KU2SeGUcjl(RJoBB%r%p7iDUibtb@gg;s4jX`_s?=?dbmB+1&qq=>GrsT^fH$ zroDb~k)Sp)xM2NJ{EmI|59|NsPaWJL;{`w}{~wY6^ZAYa*QenBLvy{p3jkJd$_;?C z1QK|G08oAv8V9T?LkJ0sS%0}wP!J3U%+mgy*9S}M|JlT4RRN~pe|EMb^8ZdDzuEsE zum4{J*k#Op+~UZ9#bt6sMr+eIS(?VRl7WcA(Ak^OV9***TPieL%rpK`crYErmre|bedLCSbI*J(Rs!O1&ePNwY+VQKCjM-uCL|wf!lvjp^b{}h` zQ@~=04)fvbYdgQ1_4aEVn4$}Cx;HDw-kS|vLZh~H7dGtopL_qe!s(x`|AVtWbpGdx z8~>jldHx%;Z?>a-$)lAW_xzOoO$Vx#D4cjOt-`-*$J#0Yo#!|&h<|-8Yy4{#V$2|- zRU*I@^4V>?+aS>uaYVP0M07Ql(|a>XeVhEB4Fs5W|6h#Ye|B>_oBO{{CjYM=1Q;rO z86=<%#nf=h(_R7=aC}c#z=8Nj#QEPU|7V2(rQ!ebJ5l}L_GbToy!^iq{}(vwe=7(u zo^Gs&1Y{9Ym&O7P-Ge_}rdgf;&kh1iJ^!QrKZ?7>&Hn$u{U43_O}qW~?c;1nK(>Pk z3wS3q7I33;cu)SnKWvYlrYSN&>ivI&|1WHB?*Bg>|Ig<7ePjS#!A1`FQ}};9VRvtn z{|osj{�*ga6;~|4Y-1jSTP``TvZ&KLh{27sdY;H~8OwivLgC#~T^oBl!O$>}KLE z&>|5ajsGu3?Eee98~Oi}@&E5<1e9FIVoM;gx+Rbe_m^l6RQN~CftIrWNuH^NOh6j{ zzZ=>AyL`&##;(TU}l{}`*BIR4&P<%pnFj&<_{fwli+>w+che^%W6 z8T5a-sQh2poc|xW|3Aq-X3T$f%b&kyZ+=4ke@V{!a?bzL_y1lY>i=^uzq$YaX#Stg z^?LOEy1?IN@AK_j+40NY5Ok+Zf6z5#lHt!p8~)UEutybc2b?zEu^1=JUnGV5E^ENM z4iPTU8>NvqN+2%)K#&3uU>VloCkuBD?Re&a2yBbrN!nOS|F0)-*%)w(_kXSswg1U& zZ~Xs!0{(v~`yaJ9ma_Zd85iJ%Rf2$@`(A(0o~Vdxzr{``NV+1D#tH^HyKahsPU)Qt zbo^W?95oCmR~bo7JfeA~5zVKu)+J>EHy`P4S>qDr7`TQ6^Tg6{47E~kMEux)L*K>U z9L>yWLJ2K+RY>Q?_p}z2tV>~0i$PRNvU;KeRH>$nnF)&@D+->H1&i{M|5q`QOWgmg zNc~gk|915LXM1;VqyPCx`X99aNFwd)rr+;Ns?6^p^@FPT?=zHjZ;x(3NiVQ?E z$qYnSlt>;m8@xP?Jc_MwCBkT3hW~YRZ}aah^8XUfzq8!`MeYB0a~u5MCz1cxYxlzn zT?Xfew5#FNMz^sbzr#V_zm)nWP|J&Q>|3BsZUx?+?IH4$qN0)<4Bws5MFiJ^QME~J-4`Tn|<{;>1m`jAH znsYSRCr?Bo@}{-x70H|}1=%1AVR^b1a-2EB+|HkK*#AfQ|3YDN{(t2D|KNSR zlHJc=w>Ljt|KIIQXOEN4|9Y)qd(fHaNi3_kUT4<1nT;lIjsIDI?GldvxxG36KbrrK zo92}+0J5#3>}EkDH)yrY-r;JVf5YLM|aY1ccHNE$dx;s z3ZG(8zWhk<1pQ{@&1n!*@&5HWfdAc?X@PI zwm(;QL66f;6JK2Dh_Tv=%zTF(IFo(WEjk{l3UQc+$DPUTX!3AAZp(!NwLP@%JN3tz zHu#d`Y#UY@O{lrHmGnxChPVB$s_^FitObwPox$koY&2$#p9Ow}3xU-$Wo5R8Hyt%g zDy7$dkRv0%OsS*I=oi`A{;)soMMqV(>uhu+`EP9`e_oZJs?DDz$33kgCp#_SR-utM zT{@q3{M%c(0EAqg*Ybt=ASa3i?3GKk4Hc-xCtn;7S~D1ASkX<`H;=={5bI6Yh3!5X zY&4(x;|{DZ7C^22@S#eP<8)q~lZwbUfb1=Jorv0TouEek45 zMh_~(45xD%PKG*(;dF$}eripW$;rd`aR$4!JLyc3bm)~hrdMWBvek(DWPr2v9ZKuB zF#uWXS{5(M`yNaJ_j|ogzuTL!H1M+CGTB&--`VpOuJUJGB~55XV2|2WOt17XVUq(Z zRqmf=0b8z=T#m(sM84cB5 zM*7vcxpzl$iL@qt+}w}&DU{yF0C02J&YS-1rPR{YPcFh4>^>N}hyIWy`OdyxvG-SW zY_-38Istok#ol4ts<+{tuC}`BZcOLKts&9X;}*QCRL_1k4;#lno7Eq_{OyRd>V`t~E}_ZT&g6;3$Zwio*3|F_L7Z1uOgqc7;E_FKh#J8^XQZCm6) zsK^?!{yc&=eXFaea%GI&7W~wp5|7QN>Z7a8d%-t=@*5C{D7WX5HnL8y8-yuPowRRN zAF-}*iEy<1X3&z@i!~~)DWl>h_SXiwt49iBk2Oa-MNp_MK?cYdDgkI|OdXHx z`Gq|<9c>P$DlAd(zC(H})1z+v3B6UVFPq2j6bZ0S3F?&K2NlK=G$_F}^;Ea4>ryyE zHiTU_I9sjQ5Q4Xnfoo9Jth6%5E)2(~uLXl9`f3rGfO`u{X2VjY(PU#$r9KH-Up~F{ zgUu9__IOdy!%IiY>Z~fgclB-3h|;wNWpAL-nFC4MSs0?QuxO{!+Jx@cbQd zgFMnOyv-R@k@Ya52lAJIOG_U+0_5q+wy0JFuHw23T^A)=f~xb}=w(?$sZMaH@+!*A{fV7H`1C{I z7Se{a!Ea-iMM(>Oy1h{2E+4;_^+3UoxqHIPyVZSzPZQB~1BxP?qAEZ^Z1WnXyr)B;bFzf)gercuY#$fA-h@i^>1}(kGgn2f+HQhRxY|t$Xal}; zd^L1JVQlQM!e9(=z)J8(u43zA=SD3{)*i5xR?ECZ$?(L(Ykr*efvTvc8?V|$&U!3r z04kPUQ3Zc>waQQp;kOf9fZM``2T+bWcGZuu8)+mCk1TQMhsM{g%p;m+7)MX@1YK7{ z7Ie)rWt&Fohnf=md+yRItg1Z#)prFUAD|UQj|NoJ449cOo*K+RyK+96!3#Us)d7P) z{9>4AD1M^adPD1F7?T>FTT~%r%n415Pz}o__Nz?M7kXx`1En@o?ZTc>Gut60u21bP z(c3cLcQBO$?})C5;Cr|L91sNzic zZtA9K(|z&_1y4Oe81qAhOfjoytS0`eu$}?L%tdnkaqG}hKhudI<4jSHuYruu_LPF;l1dt)I#am3f?mj2B4YL|F%VtPG*29P zRHyPx^B?-*+On0wm#(1UEb$NA8CR8t7xn$nim}I9LCz5B;J1rfVP@B^l4zqKkr%BQ zDjoVORVogXE3Mm+n7aePt2LoYA;Tg6P!EnoE<6%*-lzaAnemZ`X$8DJh*pKiF-3K49&h zPz=?!M-;ad(N%lmDyh|E?Yp>R)f~kRZk5KVM2k=amu_AtlGZ>s zP=;f_1N!MJLf6vXUDOd`XrUZQAOSw3g|FM;kGo?qL(GBc7w{v$vUwge5IY)T_TfmPlm{bEM`~6S14k@SRgVz{Cp?EpTgA|gIsC#aOEXcwT!N*CWCU$$Hun+^ z;U8AiFCxA;-io3jg1X+hMlA70G+*n*$ z`7Eoo#Gl;}N&KU=g23V_$}GNPNsly; z`(m8jITVCYN+U}3RWV|Yk&1$}D(8p!HcU4Wadp^}i^)>hfz`)8mw*$MUTA>DZ zKRmGWw+*be7Q6R|&RZ9;TCHXyW8muqYc0iof0uaIyeL#`W$ncid&i&=qcT$?;fQrtX za})0>ofg7qS9vr;#$jSZ4-@LV;A7;NFp4tAv!8(){__9ZGpJ}JSK&%X4O!Hi6V}o$ zJDm1QOr86s6k?QtZOpp`rciL0au--vw?#yNoyPEO;FhVQPC&)!-zp*04+tSZ1J(9@ z(JKR6W4=8bjV)J;nveUaNvh^c$zttOiMs8WB`n5nmE?NQlx_PUe5M0AL2lTdr~IXD z481Hc@79lSDryFY7D!k;%axKP(+sVT@4_(Bg)%#}?MrW^Efs=lIy55KC)MMIS?a-h0dnKmTv<`z_@=$9l} zH95k94891msju8#%CW{C&lPKu-D+vk`(HzfC)|M*Y9+s3e0#ejWhiM?o1C z?miL2Xb_9P6Py_K9Iv#d$b}!VP1o>2ThhgP(t)1qEjBrQTl-#nt>=n0~ z_dNk69B6L8A}DGibULkL5pByOi(^5zMu=I`!nm)lgtaUrzT1+eY@q>5>Y`aSxo>ac zO{4J-GUZBvsM2Y@0e)2KeE3}Sm_K}7h*pJTO5;6s1F!{z_Pq5%s3=?+ma!^=4IlYF1$%Zjy)3Zyu+*l}B5P^4QCI>`E zuaO1=t8-M)E2~POq_*94L%@y*0ww|wusSdAdjJAQz+2a6~}yyR=|8s7GS>4smd$T0vFjm-mXZ) zd*xY*FAD%)Gfo67)~n2wr=tMas~u_F@>s8)M|aq9Ft1$^!g&qiabCaNwd1_zY*}?; zVO~vI9M93j@OYAj^2*zD1tEj%M*l1p;&o0Me%4fv@p_g)cqK1}6ol7Y zBqO|*Y785S?^(vw?kpaB+ zT)Oz*@;K+v^$9VN6Y-1#0lfCyi~z4<0GM!-1>bcL!gqB8_r^1h@2bU|TJXr5w;10w z58}J(846qy-!)^>&|?;S*E|68c@o+2UC$yG-*v_CUANJLFSM#MgS)mw!tr~N4DQ+! z#90;C&E1aw24I)S2JG^Z%ewQ@SLs0T?eggJfxq)28)h-EYd1iQhJhL17MX!vayN3> z#*d=9a*6vd4%9W}psv@9psu_dQ$-x6Yl~q>n

    _ya~*enM}UA30RjFhb0o$W#;nv zehJL?kbzgW-DE7+Hpg=1Gh(?ij;Drly@+He*AZ>Do|l1F)=;iKgK`z!G$_|EhOF9W zslT`@LAiRVP_Cp3`>R5^eq@7keF-R@t}`LI$P-f;b$~`wDBwgsA@x&tbqrU*TW(z^ zr8!E#a24Eiy0;vNi&c8MDu`>#(>w8O6|h)I=Zk1_|}*qn}$9S zrR{PedTTCLKyT$1$lw{e_a^k#Gwb6QHxqhmT&57hUs-VV*D`^$w#%e695dJ??@rO@ zD6MJ|O3PR7JiUJug7v5P|6jrLe+K+lPW~%I{r~Q6;{SfM|IhdF{J)&f|8mghzm$%( ze$^YpSeqqK$$ZJ9bjeFwfhx)8OmIq#?@QJ(Rq)y#g$2I6DT+?TPCp=34Q7DD`PtP z;4F-<4g5yz#MPF|RvWPl32E9{lF*@?F#d!1)?1MR5@@DF9I1|%LHN|FLzRG1-?>)H z_nkeFs!sMyk4$)pYiU?YWk#kvV>RnTUT!mbM^DMEv79%|Qy^KOO;%3ItWwKw@gfRxk9s|SCK=Y3!B zV{EnFNy-Oyi1#9M8P%Z7uW$fNfyTI#7(y$eN#m-c0JA~tmnO3ernFjIHkm- zoPiA_9{Aaa1yhs~XTlH33=>AT0R(2VjrLZj4zcu#DaeycyL71g>*eEkzR7 zOb7YmQpU1JV7vd8Q$_aY(UauBTT}+L*BSztLNvfkDWV~K*~7~15UA0Xi>z8ph>uw6 z;Ue_=e`kEee@XwlF#h9;vEMSpe<~It@t<N80};2R5OokH%P$9LHyg8krDT z?!W(sfo5)7H=V|C@bdOZbLp`nO@PQN^gAz{h?{NZab9_Q5xLJ1Bgv!+z-n z`Ijg6_AL11VN8Vhua%DWI%R`TN?wJoMZuiN6U?r{;W6))3U`+={^J{Ve|r39_!ip# z`Qm2(f5P~WzkinoHmUz3DJUfDuZai$`C4(p!6Dyk@BDN3zw>^#rLC!FEqN?t3d|PjkIe)c1t7lM?3LCsuwpCd@nF2lQn{?T{{EK!T5id4DuGv}V}L4T=Lg^Do!= z>g5>>of7{G7aEJ=zbDM#Qo6tN{lB-n7m5GAy_etc|9>j~AB`IC*%VA9xRDG%=UD}E z2OonC<#z5TTWV4DV<#NV{iiIVGi*aCt?5)A=HNT7kN)!Du{G&Fg5$U;dY>D1(TU(p zhlWpz>MrTg;6)WEKa?vn#k}7}w}?rOH^W{v`_oVwQ^wI~re2}nMi}pqD@QgIs(yiP zH^c5vr_k(a2G5(3mdF+;Z;0|;TXi0L%f?-~Rm9@Q(1>=^IFOtsdIyd3BRbUcr#Zdm z>;>n{xYMNGDF3F+d+9`$0j^oe4XyHCN$xZ!_*;GuY8`=>*$O=;xj#+x!ugZA;pAR# z!~O?PkD5(8a#pFAB}8N&F+BAQHB+#BXp=K03Ra zyXbP!Cs&rT_$OGBVuGg06>Zn6kv3psC`udyq|M&R(nm!ovX+?IlaufG$}x+E%Yl7~ zII5U(eFX7+wN{<5Vk^vaQJLUt5UFkbt8(|ai}?!z(TXGV+cox%p$T0uTM$LolM{@x zJH?*l#A&#l|Dj*PGC74a&N$E~B66XdTk2~|1dIpWc^ zS7-%tXLoR+d|}GR>pESu)yOr1GPT}v4~+GwxUDfKj6+v2*DT;dlPuZCuJyZFRem>( zE&i!;JP3_BBq-gn@uul3yOjen(YS&IU(>zOP)M=wo^mKxQwbLw1O*NjgMKXqxr?a8 zl<6?9ao?2E0mIU`+{x&JOF66DXWfHq@pZ~+T*N==QCyu*+66_OyuVuaO6aQu+!g{N z3@N*R5Og(HI;Aq3F&+!YZiVlw0}{z<9RGK~yf~mQk5DZ^UsZy;!eSRKkaXtN2$n5p zAmGN>hVfFe8Wz`FOyj(J}Yq5Kj6=-Ty#aC|E)?tu>~=L(uHdR{)g zhN8=s?bv;Bd1oE`F?AIn{5c$J`Uc;-SKO1VgFi;`0jBj2L+t7b8-GlD5f7^?<<5m+ z%khx!;-rW>a7>ietAfynuR38G+HbG+gX|z_cE7sT59sL)Dc>CbE)Z?|UO8fdBS2h` zci(T?S;PUj@Toa>Y~UoJz>yQ^3dpOI=^b_iN#W@s7?~>&s2w+JY*3Y`f1}MC!5C)J%TVz|S?QhCF{+o4TWsOJcjt_28J&@(#^V~4&A1lco8*^H5R9i-d zEiWa*9>vSBjerb$952KAX)ZT~^)eWc z6#PeiH|qauC%3ne|2_r&!_4(MF(6Q8Iu7JS^NgAgYdGb;Hq`hge>e}?J+|D3m8}7} zGJ19YK^VvH#DBo-t<(Oiu$_;w|JvApe5n2>3CSJQJY4iUPeFJ$!9(8RN}JIQeyxrF z9J<<#04*p7g@Y4Fh0Q16|0|tVyE9RW=`3=<;{Bi7D@4x! zd~th&|NIpEe`K!LCBNm^-B#-KG=lAbH_GgU*!Rq>^^;!D-8Ez79!UR$Zw z51TM?L`_Mz%N3+`P-Re(Wh+RJ-aARL%gcX(U7Lv$TuA?Sc6Rq7_W$|q&Heu$ga41L zjppO^Ji*$HUHT;y1M*W50C37$GSi+zPvov)O1^eQ4oT z^ioy~K{@&7?V#12hO8KP`=V%tpGm_PlXN`kj9ZhAY4}99d)9ftIv;2K0Y1RZeswtQ z%$)If(7&h9lw92BMi3hj5MXQMIpQ(*%R zk!-o0UTfaR`~92Ia4H)N%f=+ruW*@#-nk<=nh&hJxM75j_SWEJ#i8zp>G`yt`u*=RJlZcWTfz2e%HKB*0^N+6kl(>YYh4MKXIP zceCiVhFy4n+kcMvfZ5>$oJ}wLxSdDCW+)A$kZZ4#I3o4V@R9YciZA4k2l`Ld^cZ^x zh#MH88@-%oYDOR%jessh_g4Q>pX7rpvvgXa%rUSaxs4}P$YTS3 z+{zr@`vXei+4OuhSvm7L+jPJbUsOHLIXCkOz+0GIx?M>)*cMsfEJ> zshhn?e~5^;k;0+@DzM+ib;~GBqq#<9Rh*FeZZ=goN)r#xklwmN?tY5UN3_ zsuwrLxrmbO)axU9-C)*K$MpIH#k?iVyBbc|>4`P(Y8vyddN%W}N3qbnOIwRo|9cYD z&E)cGs14x5L1ombNk^ei4VLZLx^L;1xMf*$HOn%eEXztE%d&DT9PpU|^Iu$Q^J~oe zWJusqEF`cI3kf_WNMJJ-5_n9I!1GUPU3Pp|g2kuA?q_t@<=mITdyBT5D3-n`G~c5@ z%-^eEP!Q09sG{;Ni($mKlG$MZ4l3`u+)77loXT2r!=O5X1xof&g<^qeg79Wxgc%Du z8%CdDC_kn|P`h&EY5bcB%XCc2h7%JjLtLNI+on;^5bzLki!(}cZnPqSBssw%$X^;= zTp&qae5WK=Mn9vIfL?^KP;4}mf=os96k5$=07I&+DtK>r2((&?P<4HmDY3@lz^LA! z0W}<|J^VB=MuATrcGJfoj9gDgfH_z{UcP5HS%L$6()yVtEQlyDywG}Q_rDE z91L}Zan`+hs2-n?n_?#GVKY}|^~_`HIbhH!pT%K;xJ`a7R8N_y>;QSF-PA)pKC5TT zi`G*yi9=xo04C~HL-kZ?hl#vrn7L44G2!jKp#H=46E$+;q0Y9C9k1zFE}<~o9xZw1G2XL?(7e!d zX>)+CR|UNNVLBa*A*=S1UQfF3(G&%tsiY58X)5OT2}Y@>V9oN6UpC5h@!g>zH^P z@`n)qR5%BbZ^00*Eb2PlH%G^9b-dkFKH#7!5F&xm9qF%7}MySX3 z{I_%5C+DU$eHPUHfJXkBa&#EYJjV+Xbs|umR}e<9fl>k<#WGbJ8WIkZZ1%5Hy>nwX zlCDaT2pi%H%9|+b4b^`r`dZYiGEbMu*Z#kR5MEYi`&I zIKhUglh}qeI*iPHDBIYLJ>edhT>%n2Pq#8KeHRX;>bYT}d=4*h!QiBZR!wPHnmbk- z{9Z>!wG?j12>Hl0x53ym`~H_33im?31d1LAp9ac*5|}kX_rixPz`WWnkiesnuG0bg zod*4q!1DX)BaON9nlQ;)a%zyix@_35(uVyChy5Ie-Hv@60c=}yooi*hu*D42u1G?9 z@(HxR7(W|(B`#LOC zg>%&n+_?8T9NH0Q0~@$wA5`EZ?}*pjz;rvVimBxDTn_s*_a0nsZ{XmMFDjdt7tCS+ z9??SKY{%n*p`931s-T5Z-X+KNN~$#C5ZQSR1nQlxf}$rHs$f)JBqz+oVt^n7B~9K5 zpPLgYSyqXb!#I-;Wy3!af{&1cKa_FQTzv7kHO~M8!ec_9pXP%6C>Z{>bQg$(HsWzs zwcH1&FSTU`_O=2*I zf^Wt1o>1gtnMuRp7?I7-(A_SP%}-vi$wY%t`Synt2Qy3+7#o`X@*@G){Q$OSPYp8)9re#V z%0_K)z1C6gx|~A=Ilb zH%xwTnD{#U=BT)lAVmq_g4gu{oF1u}5 zOH(ebNkc%c4`UnVvnCh~`4+Bh2mg4~m>QaFp6CH4XeEV*w<6yVTYiT&prwB(8ql)# zAIqcv((r$A@joB{zy|;KFZus57z1{VA3zO+kQ15Vb=gs8i*ZTIqMVZ9W6QuLE#Blq zp>P|(+XkYxfvEi>5VhZnNy`AoUKzHw2(GpecdoH)%fZh%$UFfBmj;G^CuH33gu`u| zoc%^8XLaM_tnmt(e&Jgws(dJnm?ciNban!FINF>RT>2g54&pPB&HsqDegO!ZjMcAzJLTuI{$7YRrC0kygl3(s3V3xlSn3c2T zA~Nfj%b{6A!Jt{c1czo#1cPQx1&3zM1cPQh2B2BWi48RCZ-r)k8p^wYW^JHZe+)EBHBbqe5A1SP&b@W6>^K9Lp|G}Hf?+)d zU|4g;IXwkoScR-GtXcqu6|hIB1z=bQyyn_EU|7%bFs!Hbz_4aMIbV77(}7s~-n#;^ zb^<`GeJ>Lb>o9=B@?&vWTOOCkITmZ^2e4Sz@hk7u3*fK%VlDWqq|tA11lFJw-=qPZ zkWQPBR^4)Dq}4nbY2~x=dh!EUs%L)@$mb;$e)1ZHpQH`Cf|GG9#Y1^X{8ZvlzYDa7 z4`1I6V5D-NdiauXQ6D#aa{F`alR^h3V4psE^pfySZymk+0ODzw0rA8~aO4IsO+`O| zX35=(@+Btu zmKVd6#_NVDz1ja`24C z760P;U0PFP{sA5J9B7T?j{P4|muJ>tVfjJ@)Y*jJ_=oJjGR6X0%>VD~$x~dKG zXzW_FOsC2LW5q;libLYK>cnt~%>33t62HaoTxtQ1>X4t)3)}5u6l#6|`bJp?#Rm0d zN9!uK5#Vu-*H!FEfLS?RSFz0i*Kx+Vj<*zhPV7TjA(S?3!sm|vb=$h0^sVYXo3(A? zHPGVoe{V0p7e4>@wzoI;KcDXYCvLu%y9!!qS;XIgte_-(q4wYLMP^3Vq@;<)ItmM#VYh^p~QslOm-EtFo}LeMp} zxtX_@4pb1J1Qgm*1rduY$bt_S734qbf^;Z!y6uR2GPQ4c0jwt`AS4-OI^pNXzRsla z)@G8*;DcFXL&Xm@^=0s9O<#3^a)NawGB2I&k_XbE-{GKl4a}~LS9P{qAVORCJOE+n zxZQVyr3La3Ym(#On!HO<$N+7j@0!X0O9(b6P_!=149S*Wll-cQ)~6l{r_3-Ka1MJc zMBF6|T+>{b0uZ6V+~1h}VkTYJgU`5N=`xMx7cATSwUj}hE$kvD;j3U)O0-_13AZe= zCpGD-g;d6Mfi`|PlU_SI5M(l2i&j;1 z!?T%?@J?h;sMmPvOyq(qCfiKpmMUNGu+$+RdeKr7I+z#P7cb4$q7nnw*NZxocf%)0 zLc)EQ1(RHqa2V-KLc)m%mzX+JlO@Uyt*wL+l}Dr|z^P+eh)Ei^i*%xpL8P5RKT1~O zN?`!DkXZuav3+7U7B+e!LUvROHtqsaPAwq39FYTEg5UGP)00ru%%b}fEAtIdG???* zi*CSdIbS_~9xjtJ=Pe>Ci*gRab&9#0aJXq6%~)Q{ykJRf!$DXZrKEyvpjTss8!kx0 zKUY3=$`e0zJb0_{6YX@ighPvEaXVs_{_xXuep?|;`!{nP0G_HHqv|J~i&+1=3p52XK}mr4hS zwEsh2iE7>pMPa-M1^PPiz9|YT|8AEMo!4=QP1_L9mG6Jzov0>V0HyGMyE_s4pS_*! zz0LmrJoi5f=6kIdKneA{(+$u?{Jl?IM~1Bj?p;WuMVhF9qCMxcoaETX#3;n8iMZ{iP~r7N2B{i!!+Mm_p45~rzT0w2 zZ&Y}IrxyPcN!)*c{m*K!3IyS=la{~P&#J)GsjQ{@8{0M69QBKPmQ za}g)_>l?8>|C$W=L43R(a!@pLWz2A*s`7*e2qNUlAYfU z{M6oy1A<<4W;2UJa|bM?4yzFIi*Ah9hXLm5up3Qs^AbtjGXC*Mb~K)NZ&{TDfFo3( zy%z<~0ovz)D8-=t)Fr4(PJjZwD`M~8LrDqZz`Z|*FD>sp?U_@GVuvaaTi^Tl#HD{j z{!8DzcUftFD*fNd?L_r|x!rtmL;pXJ|2vKM@^IN7v~OCIc8p61EA{<+YE6cXAqL|~ zbQ1B?>s&u}A%;myikINl;b*c1mi`n<1V3*e2+A`aPWn?xo=&>F)@{d;P16QmKW}yD zu%9MM*ZHhJz!!0Wx`Zluu|Es=|7E9jUvG`Ia|&qW2$)8&Y{QJ>$!IqE`BOQrkbk=v z9!Ce(N^oV7EPcz|EQhDrH4Lo74D7-_HDdi~#Q4k55t43ta+)WdVKk-?ghQJ5XD=8! z2)fZ5Jr3F>S-`6bMI;M>@JkT3NE`G`A%L4R7>u4eZLc%at+-LqYa+3Xuq`@Zlv)Lq z4j2GZn5Ub`XfS{o^KWnEAgO>{vf}GO=O@->SAY)VqoSm#j$HJo{cEhr#xxs35Ggg; z7h0hKub_s|>*SDb9~T$xMg^WzbEu-j$!4mDsd+h7Z)r)N9yL{tsr*W>Wt!@PTv)i1 zE3IK0M@m-_H@y&#i}o~MD-|5x;CB;3uR4@lsWk`&`)f392`aCwXWkz@PHm^ud_!C# zqAzj-g_i=wW<&jy;#(h#WvZD=nb93QPHdUi{6~LUZ8UfG8nPwb9xkPJfLfVVD>vh@ z4!0!vR`V7~-mo<=`--pZris<1*Z<{2?m}JQ6qReUiQHq&U}Ck)&ZnIaNxQKjpu)zs zrAe!+9~TohNlE7YHulk|^Foz$WPVMz%okt3N~YLq4|2W1P+9vzps*1R2+Sg_F1<=S zHh5Qmsty!G>g9wTN;0OyGXfohUuXSUFb3KUJ5d7hBU{(9-p5&|)JF=-6q2OS3mBZ{@D1y(Hn3 zT&~^y(ybci4tXllG1~TAJNwujLC%h}s$I>weXCqX;gAnqy@IYB@{kg)pc(8$Wl?{c z)StSe)er%{O6;*lf#^=`l=47yfLS(mJhJB(_S|%g8$qnFL|&l|=#fRU(~Uc!AyFP; z%I5JqxtMD32z55W4=Ri$s8fP#>ZxuSC@P$z$PnV|GEK~7tZ%B@$b>XvL*|vSzVKyC zE$EHQ*PB(S_yr3}X2Vhq`ogNw+ar4GQ(;H+$eJlAZFDFEwc*k+rj^+Evv4D6OLW~> zmt+<0`KM0HA^bMvlI)h8c^$>NB%8XiKFQ{8Y}mvHmU`xrTQW8xrL?L4L4EhBNo*j! zNMUR$TEVT>BDZ8Ua!Xb+Zpkit%7&t>ZvzAdVN#lZ?T`YZB;uWdeEgyA1K{yG@K#6f z^Hta%W#ID-V^;-|84uovfzp*N8T=T#!J;rOQ7{22;84d&0clQAE%&IbP+06U5sEQ& zO`bJwYygG4AXn~!hn9eXEbhaoD4{hT`hk#HpZx>E&alGo-PquRciw<`(|&Mc!w(L; zAxkuJV*?Nly)jGl%MFJhG%86Of>4k#uT#Eb7AN$ym zC38Ti^wpcNq|<<1*eyjC(H=0Er3CwTW&OlS$q%PEWe3K)(aq!tCp&Y(n z6&pJU0#j9EazQsuF5K3B$f)0;B4NGi_7F@-_WsV-zFl;5H!*Mvb{#;o_FUViEtebg z-m~X@H((J3f4;ap_>wLHLNaOB%O*UZ*%SpoNLeJ2$}xvMJHoiP1{aB@MKiDpw08?U zjak0n&!CD96KU?WtA1#XcDA4*cfnrkC{pp?PT3AW+w07mfS|1>ieTewjW6X=e?jVE z3cwPOcq$4Cwb>XD7*-PFU%*8VUos}TVL_A=790KngH4z-JyhZc#W4F;S0jf9MI9+` zl~Z2XY{4l=kqxFY%iRw38B0Q2;k=-5S-bosnc#>yI(#K_+HkA}m~Ge-wT>Y!&i5_m z?59iKew<#J-uKJu&;@r;{#`jXgSabcfdXS-lz8M(8`2{XWcSsaN6?NQI4_7TH&;>u zoC=lPfct(3#qgC;LMITe$yV^F265!7FS$T4rEQF9qP#S0iK+GN0F4>D`|+b9#w8k| zmy(&5Q0!nv{tKSr7SQHYo?{TKQ(T&r;NJqr=fl*Ck#(JLHHU>A+kn>+V}1K&L!H1W zQxWU3Mw)&>?8Po@3`MKW-ND_{(4Qm>{e<=QYe^j`C*Z3jXf7}GLiP*j#H*rqE=Gk( zD*3rY;HPtVM1LeOH|R=C&e!3{rE~Scqu_E7|Ll9vAw!+$@`Esmz=Z}p&N+1oMAw_U zPl9flEwol#{C8gt1duZ^41I@3k;Ai_A!Jn~M@Xh)FFx?#cY&JkFpQqM_AV1XHP0fs zVn|7sorf~R;f@!dSX~>RE7r-PV;`XP#(hm4qjFYtzcBPkscPdq9cw-qcGl1*jEtj# zZlS9P5x?PTbW@GeOH;*gIS6hxT!?!rIJWo1g0@V2Dt78qbXpj-y22|Dd~RXyuuc8S zV`}Q7*ZzJ0qCj20;WXA)4g_Wa8ZdR~8oohy^F@~hS{wRyu_H~1&$91oT82@o_1!-J zy+=Adkd?LyS{2<5^Ah=RZNi*fB2oFm0-^O8D}{6uxlDOd3@cf881Aaw(mLk+!Cz_u zi(YaVy9)L3K{@?C4~5{{a^(VGQ{d20=fo}sdxSME%9g0MVQ2YOi{!ZeW(CqmmplEo zBQzAQD$T zbyqitia5rYFIlK47zD!LwAT6WZ`H10@2;*M7>p)&XFZQMG*#6#?%MNp2fLfIX~5CI zI3$A8I3mdzN0DBT!e#(dFX_k^wHQdtHHJ}7lCcvCV5rf&0TxQ?5SzyY3|62Y0{2r# zcw;#{U^5yprc2cr5sc7-|1qGjuJ)^&2H9S-L=4obZNk2GH2c!jlT!z#4YZVXP*pDR zC)6<5hxW+yz!k8`k)tPinL;_PtJt^zQt+$rL&R6YFEgKP1Bjj22L>qA?W&-WPa%?x zA7sOK@G9!nN#3y#@+%sNzF*fEQ!0zaEGE`VB;=_tr7WmAAzY)R&vSzQ_UpQgV zcFA;W@#L^+UAh3_M;1oB;%IHKWyQa@i~nA;_g9AhSE*Xvj>UhMN~Oa8>m&Go5f6=s zIi2`m#5}pOQfWtvB)g-;od_(~1k3auciTw!eMt7_DO{p*xF_z*4RLlg}b@#44#LT7erti3`FdZNVF zV-Y!`ffFXa0#1NZCgdKZdZvB?;gLl63typ{lmZQ64^)?$Zqe*e<1y&$0H0)(JLF{! z)NdEcW2YqrIidg2ttzG*S_R#V2SyS3qgKBjE5VDXAe3|mKfZ8o`$cDhwtv)(mRPLO zCk8T-5W+$=g-V4VO1-)nvdFMtN?&ZyrV4|5s=-}3DuFqXPxGDcw6^z?tnK%H#3b*w zwp%87x3%3e$uDGWpOJjEbGCrB{h3sJAxfoa&R}V7Kf$7#5nI`nWoXJzPpUIy+N(*y}-E^tQEJm*U0Sm1E;lp$8??@*3-rT zhW4c1M@;4ic@6CiN3As)+FQiX{v0v1zfeOvqxwGcu@X14Z>?@-eJBxd#j zu?#=C&Fr%P7wr=1!^YlAvawf^4h$OEUz3gO z^ZZ8k*O-xgnrLKyB3_mC5^mjn4?Dxd-^D9KF>VQ0d2#J+_=&ryf0X3{E z)v0ZQ9!D+f_sreaP9XUqVF3D-ZBBoPn$x4Ew5NzUePo%_dkLda5vtJttsudMK3mp? zF7epoZ>B@9v=nn!e}0UAm;He{!->~z98#1 zk3U7t;}66*q?v;1c6Vbsk1oMh%65i|EI*=xZq2uMo+O>_fx z{W$1IN($zbtjEKo%*Qj8tT`$Dvd)YwVk@SO(1dH=-Od=|Qx=nmRv?IPro*^12_IF~ zKB~}YxsquzD+?tTv`lKax~rV`siCqb&i!aIyfr0|Y&etU{lIzY;7G1wPpxEmEGt=F z+nFox1XJamekSu_${t~&yz^Lw!!``il_+mFmRzzG%~{xtHO8W&*uy#8#YB1Mm?-a1 zC&~*q8aZ7JWSWZ~#0>7S2QZ~5J{VkcrDsZe;NwKMaPM`{35Uz)cO+S2TlrZSote0U zFQiw9u>FU`2_)2pYQqFn!a}-VMgOi9k7rzgYoLgDTSdgPtFmz$C?Wds|&;aI{2 zN75}d@lxAGhe6M~!HZ0}tA31|Y%TI%h9M|}{+9%;x9ESVR_K4;mHsavz@C6K8}^{K8JZDz!DGQ(tAEsfR%(HpM!^du1Ova+2!v z#95#zsTlv$0B2R>AjF>ig6aN`^8cA`02%uK?P`qvSGKnb{{KDce-c~%F$uV_4?2=R zG+vjbscb=-N&<`sA&u~c$P-CjZw;n+5*w$Fcvd z%?W&&{j`u%Zc^fWIVcyY8*qa7UF{-w)*Yo`7=_`ewauhxqAzjvkoe*=}y=_Sq?!%1MfhZl4+ zUH*&9h_cL_^t>pe4 z#@#;2;g;o9@#|4UuGECq0e|L68*C2s$#6zBhMvH$(F zH^j0IoT;8BD|wmNJtV#(uI%Y5O}ee#9&~SKr&e+GSY}0b{3vrJPehh;$FJ~Qj1!VJ zI||4jR@JBHA-&7nLs?!o&~p7f+(1|Nu{m=3*c>~3Y|hZf=G^IHbAmoL7w_c;N_=cC z96mOUG4%Je9d~}&LWa2(&r_ld?Ap39H)xMsqm2d(7nOio^YR#O^ zj9)kz`2wM9rFyE5;u^^`v0t~4G9OUi#ulN*j1uEWLeu>uVthzTwUdbP2`zP+M2x@D zQWu06fBPGV@n5U|ftrb0pbYw7F30VErBb2){XqI3T;I3V0>Nw24gQ|DK!`6t_;zU; zs}JQ#7Joq$o~dJiq(3r8E6E3d$=MJw!K9~*WN2d)VS#iPGrM74&q#K-M@&>;|7&|M zJ|r^TjUW(kT7M%lUK3E@$cU|X978TxgIADIbQ~K-V$lgBSwd5a?nA4bI0sfHx+2Bg zU#9<)$2M6G$k6|nD_b%9U%6KJf4w9B|E8QUA@YQ>7rfYCD*tDl{$t=2Anpi+Adx!b zI6JXD1;R)F?-O0t!T*beTmJqpm1>Urzf>yD|KDQ&k9vJ)h66`z1uh%G&ty9Rz(&6% zcS4BJ8Z6?MXl~X<@DY0>BY30cyF-Bi!je!l#BQOux4*2Ywr6QpZGUrGY4_0-a3V*^ zJdJk=2zqt#5fk%OitylnRsPGO{@L-r+wuH=kQ1<=|L<=91$w(W;r{a!JIlB#4W~^N zlxtkY8$*_4SY4Tt9wb;&4b5!61IO`%O_*5GWCm{u?UbdBS zXn{j5)yga+Q8UZ6!bEfp1XL&COVuU5R9N6keF*tK=Yd@l{>%1u%>ScYE%Lv=EC2U# z@Sl=G2=m=0ZynU#UOA}yA|BK&uxmKwRv4mh%LFFJ|E}xwXP*B`JpWUj8HORgBa%vF#b}%dbNCDlvuWgua&O017o9PQ7ydkx3vYK}N9Xe@g;! z^8aaTc)!@$&piL7&C+%Z|4n!R6zBgh<^OY94d=7|U>X-1ewwWok2SKIz32~au4Pin zt_lXe=yra!I%5?UJhV-bBf%8#|KGw7dJi(-BJD1S3O;FJB(kwds0UC*ZwT6K3f=rU1$;Lm5Xkr>wPpmQhYWWXz) z@nOqpQ=ew?Ki5!2HZ6P1c!#byy#Xl@UL-!vClj5ue>fe%IG#Ea@s|G7bj*9~^amoJ ze|s)s(l^_4zCACZjnt-|OFW4?B;WWAXaVKs}JtE_M-lH+D+(MXjSg zUke#ga$LiIEa6#B6ronkSl*OBg(zLu^-m4?Q{(fCkFC#5>wCJ~{uIIsxOgp`eRMoD z38UWjO=qA73?s4CpqiNEyKTAvUD{P|y!5PiYtsH{)U+Qst^E7a%72Pb2rW+qaYPy; zANLXFLFgCmeRK&pmL&uWq+c>8pI^x5Q!L&>gop(UuGe-oRzHols)Cf-khiqyW}kLC&}`*I(_ zm)o?a$-Fe9k^1}i^bnth*w_(%B>iMq_9!jZ|H4yDv+Vqd-AT-sa3r@yE6WFEAAL{` zXv5Zq!-nsCXC{U!nci(5&wI`B+{(sq6U$>@Wn*~oo#`0HKJG_`rGJsqLmx9TG;sX& ztc`Vr6B}Ze$SMV~#>hH%q9^$hGcxRBMh2BOV&6NQ1rp_2I`~MXh>7;4SATR{MGOIZ01a=k9pZOCz&0K%pTMy>>|cQcG>w8nXRnCrz&6lV|2IIGTawv13M zM|o*R`RbqKdf7gT>4!MRwi#nDVT`av9b+8$XSv?nmMushG(E@-GDvYC>UKJsWREZufs`s~is}-(HA4>Yka23UEi4VnL4`L{VRiT0 zJ_Yn`67f0&zM?7Kd?i};bb0}{N74(hd*A-1=jMo+8izU$fSdyR8}Ot2^AGXIdzC9C zw~_d?>5EPrUaq)ZbtxZ(ak%LNd6Id6_4f)!@lDX$>CzOQd~! zf&uP`z}1^Cl8lNXCy`_^K=e-`U&9=h<~e{gF>*Fx((5c%;W$#^#IA6Psmct8q&-X&|JmuUQmmdv3^vD_0sbpJ^InyG6S-whk*XheG+8Hv0 z^8~;a!PJSQEWQycyrz*-kG&@67fp_>_9Rd(FCuQAp@ey!B5r@fVi#20-o@w5wTasi zajKnP+-Aw*c0Jd-&T%wBhPWN3irag+UUxyo?K;X{jpfDdgIq7}pt!x4A#U%dh}%cG zUUrSL+5wKSwSu^PlIy*9K8m_WtIFFqxHI^ZOo96h1@1%a#a&F`{$>f>r#M$<83Omk z(gOD&L*PErgbU^ECqKWy{j7vchrs>fzp2DMvnB4iEpb<3688xPxr`V}**!ykLusr) z(fY+lLbr>u^=6W6&EKi61wl-LvY#qgSEHr3Vx^B!sy?Dp^)Vipqdy{5hrkhZD}PpI zUc&z?J=wz&{-0Z=nE%&yrSSj$2>xHoC4R`-J=uO>$BOrC$;g^Er#<&7bp!w6J*YeG zx9{yv9x$iFqyL|F=K2%VJ|VIjA;7DOgBw2RetX-DxFf zVupAehGLi$lP^u8(u84TUqfD6&uX{#iZtg8JVjPJfR3KNkPLxwT#Bf8X=` z+q%=YKF3xv(3ewVZkf=s;a$?|Q(^eGi-15{ut=K=e!^=s@z6InisN~z6$kV*re zCOA;a`gp^t^XyjljvdM+YzTcAzSijSAgzLG2}h1O8B}GRlfmHlrNFoYh6G7CV5Ke~ zx?cSbbcMj0x_#Dq-bDU6cn6qGGm}}K=z7g0!zpBE(C+vle z6A4gcoV~z)S;Ai0AS(aWiN`R8@~}#4gU}|E32X33k9u0c*C^3ieAn{}tQ5%b%zc$^ zup{5&$RJ~`1HKy2A&?xz=JE-Xz?7hWY%e=*RBRi5MWd}e$QVSrVq3LEM*>R2(n}u*m@*UsELM_x?Tv|LEj_C-|K(f+wFhSf+P;Xnl58Jx-PA7_S z;k$D`OvpyGE3*+b{tMZN((?agG41Qg|5Ga0V)@_7m8~NF_wS4UC0=uh3S>L8h^{c5 zk43}X8`{Y7X@gR7lH`*`-!wXs#4;*u(_<@UGOouy_Qcyig_5d)dy5o4y{LbGM zNqeNyIH3e2(L3 z1(v4;c$6wP~clD=nb(Ugx9_LUN9^V8m6>PBRTLL$GIv7D3Sr zPdH*9zY29nGlT|+`7iuGUwq~Gj6=lcqZ%~}S1s_VcX$leG`e!o#!Eh+06FVC~kCclPtZJ86XW!&VqH&s+9W z@8G~Z3Yslmh(EgUE>C6XVRteMx-+pEy~(8W(wv?Gj|uUI{JTo*W#SLR$pn&eO}f+3 z;IVru_N_MjP4^jI44;}qHW*F2Hzy)M&T6tb?9ck0fmKia zS8Ha(=qA1j-s4<3emR^!Tmg)Sc-4%v^ywZx`J8;5H+{@mFVkVK0T4=>6IYj<<(n74 zbB`cRTVW?6E<@VtL$pjsG%?apLYpw3zRHLN*#UL2TN)c_yiKfquWKX1KvYe0=c)~N zVWgd*qZfPFPBYdE&3f$UusF#_knpvxiC!xOs6S`cATUyhDUYjt%nxJOjKDJQ5C;WP zA=bA3>L|oaQ!NpvCtoGnGnN2$_4&>blp|)V4x*n#;F5dDo&(NINp9*1LNIm!yycsk z0n!&^s;kHF9F!eQRB6C2JXUqkTyd(Qu=Z%}U1A!<)>nm{7zcY7?P`_aY z-x7o8f4n38o(4F}ke41?=1tqDMcC>fNJTo{?UFu-IKS_db8SUjKfo9-Yf79Q`JW+;`AJm zgE`-g?@jmvwGT+NS=GmR>8m)iP`Db|8wW>cs1D4$ucCTIj}^*Nv}#~iwcCrVWJ#{1 znGokrP#tw_!xFLXOOld24ttf7Dq6e8>YBBEMp^>%qzDGoY+FfMyUy)Hry(-8V_$)0 zi9YX|8Gf~=qe0BC#>NZ&Dspj2D`tG(#UBrMW`w?mwuK#H0_{yHdKknuWh-%0eriN- z3(+;4R-Ad+Gx6+Bg*EbDZe#?3m}1>knBj^KqnB7i0MZBscsB6Km$R>+<&zuL_-im4 zEsWGzZWD4LgC_&IO64smo{Em`gln;h<4PxOH8?Ch1=ef-&#`ukD1p+?f2CHg#`8aI75-oEZ~sq)+#*n3J=htm z!*HRye||u&TplQb-n!J(3SCW2UIveep@Rbf?_)3lU!dGI5cJu8|!z;znlg_iylPq|p zHWs*A7GA02%ou#T0KC$97P?YMz?BAm7P=C_Q96jtMH9#LPUuR%+#pE1INT;4yS>;` zD^c{lpM`)lbt51>x)G3mlhU(H1f)(DwAShfNZk|!BtEIFae#_N*B=FqBpvgmp3#?r zH^_e?<#Pjak_sPuCgn>ZM7B2wM#wd~*6`&s*-L9aKL6qq=YQ{;PGl-PEsQ<~qQD@~ zdLMPH#HlC1D5<71_1Hwcke|Wu8p=)OYay%2qkn{1Ojitza@haY%>8Bh|8KhdKWjz& z=X>&h8kT`@C2n43agNCFUVnhiF+yZqn*-`Um2h5Yk)@eJ9c}ngB~#|KXPAffuq9~E z0cJ~XA-UP36lNbOOnos56MCPwcst!)jgHL;N4Pe^QT+SoHl!C*RKVHrqdI^d9eg=Dl#&Jm?4a=oG3*hNp&Q1l!3t8@b(*D(GCoM zR6{^nO}M43RSifvV&0`@lD}Q`xUBw9u3A1mFys7}%dz~AilmN{-4G9FaCWr{(m%`{cmS@Ga2=7)@c71yuN+@YeoK-_vZiY z#a;{NPaDm)|3_h9K3{tKH&%|60_x?@@&9z~)@T3UtX8(-_Wx>u|MZ^vU$fw@D2Vj| zzT_toZsWBe4IaWmP-H{U3?w*kZ=x4ssA2Xrq6E82&SU2Uhv^*r&Daz9`Tu24e>VP8 zJpWs%QYy~>2R;99f56}VXv_S--FkrR!tKSOs!-xl!E}Xaj+xK4T zU{5^`{y~XjQT~77;P0&SUn-Z&m00|Dxl;K5zUTd)C!jR z$M!3SumPm=koG8=Pt^ndq4~gP6w2&!ZV<*g?Twy*=N!x*#AII2dBf6^I<~D$!eL&A znF_YF+Ur9n#eYm8{_gPhRxY=iJQ-OZnMBd{{iqME_V`wvz}95=aW>M417v@}kGnsZ zfiFB%5rsHcrMqwTx9LfLI34qTj3%OPQ-3inMuPvlH{{(9utQYm(Ys1#35fZj%PnTw zWFvkOpT#eHUqAMx`~I))3rR3Agfs%}TV}>KoxR9K691Lypog;#KP(gQ4&x6YeSvws zn2rH-$DuM9D$xB4ihm2rx>B=z7 zd!OiwR1zBg;1@F*DfY?$Itu-K`Fw7PJQeH}5(Mn3SaQf+4&tRrYAyi=N$@*psYDDr z2EQKxI?4l6i9`^tN~*n7>J!!r>%3j9_wqC_0OVr{+;I{zQ;(VifsLS+O^L#w|DMv6 zDZkBMgLGH7395((7iAn5DYnB+xA;$IRFFD}to)$uD6vBS({_K?ul6Ib5mx``G;-Z{7W`=9$UqUjG~t?Q0mE;UZoV)r-!mF_x)O zZuRk$-ucF}Qznua$S!vgfRWQubqvMU584rM4X9808sNt2MyKwFI?~EjRKTG6HJ={1 zPJ~t*3)sGM_x#A*_CIt_IwJ z?+L~&zDv?_VJL)v2sK+uEt-LyJafIN#}=Q21bu6e+(IIE5oABg0DmJ!^9n(Kd-4n8 z+h1q@Uq<_%W&f*{%69xusaD%8{Qurl|F5;R5v5*=VxMSZll8Pw6=xS>@W*88Hn2ds8YVpp0Z@ z6$^I^c|Nz9$W16SEU1dWDq2N(Wyn3qpN^AtK&;RK6*{0CI-m)WCZ~R~7Klonb3dX7 zI&|rQ+8KJFJ=8+%+lmzaE+X5^ePq`Uowc%5DUB61K?kc1piBnv?X3rpLl;ydOL@nV zwLw>Tv_TzIHB6S&26eKvL6t0R&~chJ=-X1-pzHN1gL?UuL3czMG$vWNe>0~tXiV1f z*V`(CCJZ4Yr<9S{}IjO9`B z+}SFg8$1bpA2mEbklEnPAR}{Z3VW1C$JPe zVWxsd#a->NLVfe!^#9JE_a&eHXM1xyj{jXQ7x}+Fg8%oL^S)q{3*~#Uq&F1RfSQ?$ zQV^?gm{!w56pv~4X z*>|>&&rDt(=bHqGgz7H)t{EzJiz#>n18U%ah|$yWiXcC5YDh#NYQR$NMI0s#oSe zlymg+K9pXbZ7yo4nsVc1*9`2MbKyXdC>^abla9(Wxd8cjVy4UsYnxE*&UEOO#zDN9JKYRba6vQ3{X`lW2JR^wB{mdeYV2#sKG)VPCXT1VOx3oVBqU7f zII<8it{|aSFGwIGu@SqsjX+xpibN~_sQjNN{&TU+Z@J>X%caeD{)bu-|NV~sA8Rvf z7kS_QDgUo!PXAKpf3s4Y|Kj`?|30Ap|EYVmU=El}{6|Orw{nsH^&{y2^Hq!y`5y_PSZnUbX_5PJdH#Qy)1M>%V+{XgbE{gM z{||cp-}ZpN{n0LRKjz^7Z#!3${E={a}ir5gY9?C6UP#jksd;)M&-F2A>%}Zt%m6 zYndL=)Qn%zYXRMU-lEBv)6t4eK8A6>q7|FGf0HI-UQDYs*{FqaM~@YISqtKxL@PG= z`X;^n=mUr79LZP0DZLQni259b6Bcn3S@eCh_sty^_m*(A=60MSD;2Z&yILX9SB_R{ zq22wgp2VH2c5)IP3_8fMo||oo4H)3JH0TOfaGjNU{Wh81(Swb8z-pll z)&dwZ_(T^|XT2e8@xEZtB2J>;-m-n(;CG_{Xv-q#40;VLAryS_z5UQNF}(IJ-~0Uc z#@)YRcyQ|f^9gOa6@lsUf2mxG$Ny9+h5qk7^?%99&{5;BlWL$ffaH3ylEP!Q`0LS4 z_pUpPC-I(-MZ?`2+6XpnS5uj17E(qoqKk-S;t)g!EDy|!W#%R6q-Ap3eVv0A{sd?` zFbS|s>SpFC(O;BpU)iasO_u7=6L0^$E9q8U)Ohk$E?cW16MM`5irDzyaCh|~;<_4| zYXa{OAgJ&aMk!*)gN&?RCyp_Npp)U0RYc88aPA?~C>zh^X~3XyZcuUov;w!U(`w{-4`%{O?-f|NY+fAID;^!wbx6 zHq{qQdwX1ZkiuhBgr2;o|ChCL_iM`kP};1-?td;47U%yx@BhPg`^e?`Wl}kw&iKX= z|JCU{X*$QZb`W#ZkTf?jm2@lSOLD(5j;Lf9jOB0F6jB+Xnl$N>{%s(C+sAqbcD)wb z6%X}qN(^gfc%_|uMI-^0*-xcT(#eG6$FH8&loqmBLH{%S|CXlynfza^?DGFF=>OkJ z|NjJm|K~`1IFn98K$#nVj&v_0|1F9DoyGsv%JKN$YLWlzW5|DNmit!1fNp3EXqr?3 zK3o2FsI!5i{2WpwXf*yQ8?X4!FVxgLLQaQ&hE8~C5%@Vg0bZrQ4R22|#24jAn z(z$!kmKOC=CmIJIzpgVKJ#+~eM+`kfp>rUQ-sR9Z5%}h^kHD{v zCcL>sI8~s{%`6mJhA6ZFVyA|PLR+^`Xd6xtT1FiSTG*of)B>Sph}?SQ#GpNPLeQQe z0_{1Ltz+2}#Gk#0q58Hx=_3`#pWTf?54R8EnU1ZCbJBj~ke2exMefjuUa(A~u7X7<>(T_2L=Q}QX*9oC;W%bDbC@=+} z(89(Ju|V7CF{(bzASk9|NZ=$#1WqzY#t#S)KLAqizXn7vB7WLO2T8Fze!xT0AEnW- z5@ZTUfk*#~PxUYAoqX=@>J;*1S7!K!<*u&ozdi0S1%*-5_rqA_maGH+(wv5mo@L z8X;bc6@uG&gTeMs(i68ZR=lJBe;EYGbosBcU8%(6KP~|l^4~kkf64fdNrGLnV6`SO ziin3!iRxX2C?kjFm!{rSgjyLqP=&QXy-%DDe**pMtZv@`#A!}3w7_id;)T-ulgxJH zA~VePK@zjQi;zL5c<&zR(T5;R;a$s|EcIR%OWnc?_&dP}dKZrR&^2Q344>Y;FXMdU zMizTp&e?(){3D$6MKa^e7Ui2|1pSJuYV*f8&a<%`OFX44@hpiYK3w`V62|x751c!g zgRaf@{>b8c=TW{_C8Bv>rgjHJ^0Qi;cZRm+9{|HrWZt``9+(&!suKsCmz z;5aGMu`3~q4e>*5$W{iHNHUe3r1E%s0X_<`Vx!etM6UbM{_YZl6Yv?4kSx{^El3kb zJTP%{k&WHS35x)x+y{#_41g&Vn*gJAp*9_V43z+Qf(`1ZanTom5K{=aw*_{{w8 zasRLF+IDgOzn}hRjk<{<1N=Xw|9unu7l;36r8xh^`7i!`Ncn$J1c)sBhj{*XP7(_H z&&QDe*NFf@n#}M6zw`?4W)0W$3WrL9=}UuCPH{~tsCe+vW{cgt(R zfYHb=R|XB{+Zr)oa`*;}mCu+0B}sM)s3x^(a-lEa50DHzkeCb|?bjqnBAEpOmw;jD zD}Wc624~E632-%t2^Db!x`pQ;pL;-K!%u!y{_N}H6qtkjtqfPs;VuzSs58Klt;|Au z0^O~UGzt#?ky$7br8oBz5~bIBOl0i~`-L{{Z`1$fCD}Oz!L0mG)!6-C-rg$i|M!&t zmdpR1nDjk6kvkVfM52M)&ULpv9K5WWGB}OJc+=GGnMvGDTsujoe&9%^zUgG|4*NXff?|J?|)G2kR_I76T zo&@4Qn?7Vp=z0X$=nc-!KP>-KMV!#|ZS=aGo9<)-)PB40nT>|G{kuHLz_ZT(R?Pli zEpHX}|96!CQ&%~^`0sEA$hVntXNhYa#@M<$9u#O6a~0v;!*$J4+c zO3@|!_a&E1`+vR#OXUSp&wpi`U-tI--`Xnjf4%GdpOpXWqCdE~?o4jd6M)&<#FM)u z&P~vwr}L}Fe)lOY=h)9_uQTc1q^A)R|G(&VeziJdl@m;ur$*vN=l{QjisQ*>Hv0Kf zhI;eAcM#t`g}+G|#F9lKUYpbi)Ez1_p<~dxoyyFa#>}KCv!{@R%ioSBPkafwHz!>& z8g&H4_(tOAtIiZUH-$C2m0|U?DOv4`zu;~%nh$Sq;s?AGC$jk9x+_)!zt1L}{vc99 zG~>@_v(d15J%Wkm`{{NtdQxBcT#N>T`k-@n3ZEj6LeaW6cpxI&}HIJ&PL`ijyn_4 zp~y_YGxA6m*A4Y}R|m8}bJl%0nsle)$L^y_6@{>JPbhk&UwaE{5CETcZPN$>6WB1hjFm>k<6x&2>*PMw2#jBl*82KS#iuJDK zBvvYO`2L+lwwWL;LrD#9peGYf8pP7dqc69f{-Cz6Yd*;AKX6_|6glZimQu9lc!o8C zN#{w-2rY2fft(#T$v@F$bG?)Pp7MrMcG5mL_*p;j8b5=u?wuc;o&NMMf1X_)g<^>w z^nOlRij6Mof=n#2CA-x-+A zug3^=V*`Sp)R<#uns0r64(*0~ei@7Y+Qnrb;1^B;Raf~eO?el2XJ1T_!%COg^cj!`>6LQ|WFb_kXgW7yS_dP}(U9r7gS|4BRQ>i=4B)Zb zB<+hM%ULph*N!HKqY2;D_EYE~ob|}#`sjM@cpUVn`p)I`W|Fnx3p9ma%|2|KeRn$< zJ#e02(uZByHJ@$=otb3wesy1nMp|4eH~a8Ny!e}XJRB*e#OaZ$*eY=X)6`m7Zc7UZ zxH+?)&f_Ud+sRo^&vn+*Cx%&19Zg(#3M2HvcO_We)8#H@xqXB#Jurz=x4{(aHD&wZ z+7qW90q~cU?PZCE{J8C-FKP?ZiMCNE-^32J5pGpX*LO7=mmVhIv)ez7j$t=#IHx$A zR zWql=`aeu7EqSDV;rC}3n?x9Irdtr!;rAU zm$;&kZ$X+k#K$XUa)sPR3V>lnWT;}wLL$EOeC_=qzD@YIFZ|mVU#1#ipSOIGb5(q+ zMqdX3TKwa8y9Rl=9FzDm@O2graBlHI?dmp{vy~o$?d(*#S?Wz$}O;+NMR1=ojd5U^0w|!Y%sy zVI(0P_!#M-L*f1Cx~pH%eTR{pSYeYE-qXLBZiIT`K1*mJQ<&A*uK0=7#nc!-XyxwQ zq~381?%QqI>JQ@P6M}n`DBsa9ZpiiKoBBy4Nn?{xCi{>+)NPp`Pc&&d%AWatlZ}cl z_I;f?jc>e2B0=+BIDTUsq)fTChu|>#n4ZmK34`ovJ$8V`f);kiG&S*Ev%yb;08)oU zBd>g0@P;4iVsyPaj*hqQg{Si5w*aN7X}r%G(Zp*_b5I6>a_lx9C#Bvgi@ z7li0;7t;0GjV{qEi!H9;`5yG*-tk}WWSKD|u9ARu3>AK}=ys52TV^Ox#Nu)jr@uS!ez*kiQAq9-DXgRk2TNgp|~`f)(|!M7wh zABcYZsI0Lc$C3coe00lvu==q_`Z16JA<&PT>KgkYe4aVAt@?O{{<8WZIL@$-QQSN5 zSF&I|O*4Q8d-eMuYdrwrzx^tyzszq78wVzisZMAmytx)c;N5ge&qqJ%uCAX~&CL*fK4JQfh&~@Ob)i6-ty6TER`L4?zonNB{rUq}gEnmD$vM1fPMBgmma7ebjlKHFtjWDFj8=i?7%wjL19&-qgIO79!jJB_yq! zORmuw@A-fklzFj0v{~nWLQN%k5BcF2_h-P6S0{}f;zt!|8WkWsvU`ukg{FA>j^tDX zV{UlmCdvJ*x+MG;^(ee>DmtjcaX`M{ovs8kQ-qI2G%*i8mwA~oQ#q?)c8?)U#)`h- zUE1aRTmUSg6y?-xGco3>b57a!7Vp2Fx0cF0UAMSxv-rDEZ!EheHrfx|Xmc&v0M*#= ze+A~g;;icgx(-a}76@S#vfjUgsGtDLKs3K_Mz)5C_mY>L$#p*rFhk@6*`Deqq+w&S zy@`C+n56u!qZ^qQ-KI6kdmuHvf!VP7@D01TO}bD~r!wA$SJ{U;ZXzni;+@n21&H{* z8KBt6yH4cB@7UvGvMF1zEkvVpda)eq36j^CnufHub z_o}tXb$XiS^ni{`P6&M5m6#eW$viXxU!FD4YArHbc74OZ0b9eatKa z!@lO&BesSRKR$#SqC}K1Dt9%XuZ@Tmg1=BtXs8JgoGSAgw%5Q7gKe{us5)O+G~#nk z)sWF6^QEo`3GA$r5A%EnXQmejoCXu3ExN+24G(r0I2?z#({{2iiVk5Vcd^a8Aa;L5 zz&W0MWFX2W6*|AT?gG*24fpjUW|(vKobdwP3kC3a39%y$>_@Ne><6eweX8ts9yIaw zzHt{n$Yh1&{?!hdM3%Gyn;OW$H)$nw8P?6d?4=HIws|@ysIaEzxQL*TCdwJ zj&GdK+?jF8#=7p6-i&j4xYEalNJ(CauC!pE`@tl1GKylQ`F#Gul?H~H|M7miS9%sY z`ynAt$fYmp{&w6^dVcPGoS=F-=cy-Xn3Lax+1L)~B|ab`*EZcKg1cgbVTtaRZ|bUt z-7=JmE_cg4le<8?b>ySK1Wl7=OV}ag9(?gBV2{~ql%yKEyAk~Pc{TbabfFG6sn9gUEe?EuZo zu&BN@LzgHTQK+{T74ZdyC?;fb3$8 z%(F&j3SQwF?2#=gw}6AVij~$Eu2Sv``RRod;}=lbyn_4{p}LmX)RdWD-i?CtTbHnS zV{3=cl6N>i_!0Ajx}g$gksPopFG?=Z@XzZ`@#-_YuT4YV zuXmC`-*X-S;L_OVIg1V;x)#4IYEw#B3tJu7A{r!M;`GK;%EvDe`~A2cpDbtYBz|=pF>ItiOSC70Btkf+snL{+5~3`*-JzU)mkV+@LKpDC8-tFn$6dT@|RkNqXC2g*us@ic?im^Of3Ko}>V{m{DL93eR!__rZnjx1#a0Cd%b= z;}FDp{v-L}i5QWF%wqdS_5*d38^;c!`4^w0``ILn8nD<8P;fs5BF$Dw-U^}CU$7gR z+DkkrspBI3Hk1Z2;feBx13p1_Rk{v@b=^w_87RF2GvVx;06Ww=96Who_F(c#4tMcd z5|#yE4kcK3XztW2fG*q$zVaW#odE85`~!`u!lJDS0b=`lX@sV!m@RGIz2)LOyp&H)MRTqlX!N!SZ8XVu6_>(fkFnt{C!Jn~dcJ70bfINTm6%0>MDJI4QO{C5%*l^B+& zqZX|ot|)YT?vwx|E--fhp)-p>R@7+`Vqb*V7a{gVh&>IlC+Rxe(e@uRz`h8eACM^c zzb8UIN8meVhAAT8SBQX@X?{L@9K0R)ZgLK-Hv;}$;@eHyHcv~-;Wws(v&2Ulb)uA) z$Ht%06JqtL7ltM^xCmH;+Q-8D2O)?;BO!GrMsgM^W5!T}%05pSxIZ3)viDOU+*f(U zQ|ZvsHla%Jepyz;z$;CM351KLovb>;NLdj^T7;1nVWdSEX%R;HPlS=ye3?Q(C^+v( zOk63@w~YuGEe9lEv==xdM*Eo&qdNgct9=Uo)sWF)!i+u!gu37RFYFzHL&rJ#2I~B^ zg@I0%dgCeqpR=Xj_>YHn4nulP{R)%gBSU&6k{Jk-<2rY-R5LlQbG&d|XZ}vNXi(>Y zyVF)s=b_cAg>oL#gK(A-%K02bV)C3r7_Ab@ zd6F-b(;dkfpL2M;?c*@^T#=kRL;<{&5Xo63k(^sgL~;(_G?Melj^un?GLrKy63ID; zMRLyT3A^URj^uoHM{l91^Cd8s-n=e2E8g))o%roDz|naD+SITLyA&ML6>v_f_?@Od#hW z4dm=E9LPDy70B5OQdv7IuIkQruJj_UdIFR#~d!@$<1akHrH&jL#(o0g6 zMZ=IbBUiY}0|38oE*;2ukn=eukn@ku>(YUoa}vn8>n2%Q zah$*7(KEG=5yx4M#BuiW$8q+p$f}bi<2WDl#c^iS8#w?XMQ&!rady(Qyk}3#DG4i$0y4b$9cYN9A|CWI8KZ=jmB{f7LDV~w|MJ~n;EmY zvrNn;(bX^#u^F+Kntd_0!!v*X8}XX6rk-f_ZQgv3Sk0k3R&!*>YHkEe$7=p|uW3rC z=83byPUI*(G~X;nv*NZ6ZN5W{=DjmUv%g@B=3z8OlXpXKV%>35J4o<#JTF2oi+^j! ze}~F$6!_Hm&(e0S5{v)b-YQp$_|FfB|BkEtCs5$wW&BmSpx~ACO1u+d$ne5yW|cNx zy?8mwdg#6?Jv^;?N^gSYxA=oCREs}1>>4zI2h^aPne_OC$1?ChdTYXEfSw7)(Izsg za1fH30>$JB*#BOjt8xxi^Fb$gafzzm=sTJA``lqhkUAhW(H6*SUXhy0YoP(g5~cAI zs&wDjt3-{7PpY7l@*3#L)CCn6DS1UHF@qZ``)d7b^X41qI8EjjU5T0CQ@)~q9yI#E zgu|iqoBkUA*X9eQ+BFSDmL1jr4Ny(sN3izkqMd?2+YHQb!tXX9v#w@QEU2IKIIx}U#s^>V>D-;Gy5)*Um)#2L5q)T;N)Ubbsh zpZJNVw(!hU#eKSBSPhR}q*~3ls-b&NTD1-TYudjyXL#Ma&g=}k#n&*ou_xUahOtmO z6mRU2Mc1Clq=Z&FW`ns_pHR2d+B3B?tx2bsFEVdcam@gO2Vd@pc^;f!*8xPc7 z)qQYPVfu?YE)kDH(WCE<-O1M(3`ou|rp7;ue%zc^tTC?=rPr%(*q+iJZ-S;NB{u z1b!>Ap$#XyL9BtP! zlfbo#zx<@hNN8plxUVH^J0*33CDOln!hTBX2XptsI@SqG`kF}qNpN^-OYY-g+AI4j z2n0zpWJ!4DGLLN<=eA#N!{7{C1UI3!Q#4)iuta+Fb_T(l^~WN~*bdT< z6t*F8TnQ8V7@ydf;G1tKuR&a1h*6GwvpWga-YIM;w$by=@S|gRu}&pSa@8R|#%`*7 z1KM$PBgT#7o8?mQroB?a$D;FGbu_j3EsS+ZSi~YD+E{EvY4cnO-n3UKOO`gzwLBXs zVV*Y}^Bfa!@{I_M2)QOFZJu2tT4S$b!ehR9&b5&e=DFvn8^FRNA1cAi1 zPYn8nKhocr|2_5eFLnOQmE!yt=f61rfAst>asOvxKjb<8QG(Zh?(@G@uEp@5w#r+@`Ts!v|9X|*(Cc5k zLcs=PJNus=I=uUDBQE#s8@lBQn`c~Bg^k81-QV;6q&R+8kXSyB8sZW3$-r*?oX`4$DYS7t8lI~!@XOEcv<18@!UG9R z6#oBI3>uXL2`>!~cxOgO|ln>m*hkV%G_b83WeS z?M$wFerJM^bYhmL1c1(bmuN=jt3XdvD*~St;+Y0=Q$GPx)2ZVb0Jqr%B0nNr+_3Z5 zzv~Dv9N07pgKMVobL7w;#GgU*Y>*}~@l&}3f5>A>)YPoFm)^U3K9 z0mn+>h=n(I-N<8!(_>H()8G;L-qGM-i0ku!&`=UE&HR!AP8C~vdek%&B492hBTr$6 zurquYiw9FVgjV-qWFYYXW(h&$B-)iit&%u$Ht-p5f$;{#Boib%GG2uoXTjJthq?<< z2)hT>yn>aBV%WtY)u1?qtZSM2(*#^P-r*3yo+NrwUm$ylT6z=&J5GWQs{O8>!3(bC zL{h9a8(B`mL`Fg7_;li~qWD+@a1Q<}@#xfPm-{vfHYc9MhjujR%s5Q|u&n9XH-(u6 zFa8B*@>{?AROcs1ozD*4;s{=;&)T%txfwm_Zw5s%_P}7*;gP20qEDSbkmijpu*Mk7 zr~<{~NBmmPcAsEsNnotL-b6OURyML$0;U%30kQtxl%K0p#R|KS<23eI1Qu2964Ck5 zw69Rp2HjgYayU0Kee#uhgPk& zF!;-#j`UC7U0qn%y@u>P#6)_+1h3w#W`p6#&as2=bl+3XqL9GYgY*lYoJrB}88~`K z6TU3bkROG+rAF3nc`5}4$U$BppURmtPwVoj82Oc&>r2zQc>AZ(G3=%d=M;z2@|5Hy z8)LDL`|wfJ063fzd+6YLrr$9qpI^x5Q|xjJYl>Oj!;cO8s5*CyGr~NTN<)79E}x!b z@0q6(o5_zqurX4gg$1r~q^f7I7Y}1o2#*`ME*HX?QBP-6f4q$?NE`Q86Nl7*_qDKv zYdy&UK9w0-DomL|?7nTsZliQ#O9_%w`mlVRETR50wrg{LBuWarQ9PU4BU z^X2Z5hs=P?@8WQqc!u|4Psu!Yah^NazZPyY(ZW={PS$JbUOoD_W5`B(Af;!j$Ypd! z>RC(w1IHd>m&im0vBt zOallw01*&D0ys#XlCthkW2B!r^tgciy;-7<-h_@|(Speiuno1@rxf5CV+{696`P&>A>4fcHl{lt;Kkoh*@;*lA7_f^h)nZ6I4TuB`fC$=eJn)t!d_?{=Hn5d*d;XThC z@SeMv?9#)she=4EgP?__nhvDTSAz5@IwQw^qhpcScWW-Xn3h=$>jl)O6MG6s@Q$(r=igkYr!3Cn2iz7}-&m zG#zSS=egWquDQGSos-v;e3#&>PbCzjzM*fh7gyNV!)T7*3Zx7cbGBvjU-FvK)cDc0 zRHxvO#FgppNVu%uJ{=ykKlTk>-GJ$ASJcfO1u}%fcQOmDL_ZS=o6x-*vj4#YK-?M1 zuH2g<><5_|fG6Ey$oXDMaZ7w0)e+aRR#%d^lm*)gb`eIPK}!d06e8C!LBz-JW8bhx zSDS7G(+a~B*}!;9khXgk$vW!6m#HtqR8oFApjop#_%bqZYbIeA@UrKU3ERWd3rzck zMi?{{3s~oCx!Np}>XJ}E#8rJ{1kwc*huB)(xJMe9?f_%kEv zZbFI7e1)U?#7J*yK1nGSxruehCO%n$XP?a3gz*=WslJy;ufD`cmsx9Nfn$0NLB30q z9*rL(Lh>KTee}i0@^nOuz6tw_CUH18>mVC01G05{ODMF)+Y(CrPv4bsd0DzhjM%ygEJ5Mq!DlC^?W90dsv4P1IQA|1%Mz1Lq|62s z*3P%ArMWAz!-0$*!&qT5ZLIYjM(~oAdJhX9k$g@~dHWh!L8%F`@FB?w2uB7Hkgq)X zQ5?;IkBMBL$c1`HwiM(BM`}6=DT1cydNYwBg*~nEddYPYZiBV6L3tzgR3MxVR)E+8?d|0`Y)AP0CxqWJ7>VwJK4#7=}hg6R&xY z7QOQr-Xd((p@r&oIATal4dD^DJ+dwO-Z-LKNnl94h~nz2(UUXO`!hV;gQon3nt2)9NH|AA5;*$N|GF;vMxFeg1Ch@cY z(D=r*B1||8D6NrDacSM>8AB}%=(V)D_DFHV#~KNVH@`0xSWKsng>>@_G2oca=6vk! zzj9tX-~xk4z}rwFoKcHI%PRdcia<*FL;_j9N?^lNFAN!)#v)?^mE*C{dx6%D|1gni z8m2SNNWHRpGc*F~A%T??s*)+> zU%|^{+oxV8uYpmQjs!)$A;%BJKFZKGl@%yKWOJKakTlHE)8u~Z|sd&&1i z_mWHMUb2C6FjI4o;9Am3aVj$?4{*E^2BTh63p=I za3Jxe=djSRN{o6>gSaam7R)9gaTOsqKMdF5De}whDKh+TdW!TTt{?YVo+5XV;=OcF zk@*UqBIBs1$nS`!$ZgC~L?e{J6&~W2d=A`>BA>|R^|Gv^$b>kGOe4MVVP9Zl=r;y7vI)&6&np4OYThb|Hr|!P`YXP|jye|Q9|9J8LxckRV%>84K(C=zs zPQJGtpr41M)9d#0sIjP@$6kV;$B6oQL@sFb@^Is+KiV!GPfj0?LmX;5pO43h?c;I0 zxQ|DP^hIlvQhYr4E+skJzxeUWZ{Xh1aJqNwWw>|juf@Hi{nqXs2g&XoGveOSBkV@M zVY_$qZ1;}-THQPN*#lhq-Y&tV|K%5M8-Hj1w-oI6Z2ymPZEM?+|9!jg|M+0}-~I^p zJHF^+;=ZGMIFu%L*H_7QSmn`C-GTY;XrHJGsNoMKHw_Ze5Ewq5!H7*p$3)~V3(Q+V zywkfU3Cyg*mWKtmz>l#OOid~(ULqj4tDA6N0$ym!3`tQJp}+sYkepb?Q=__?us?#! zj#Ig8m$l5klRgD%1IJr@jJ2rD)&_!=bhfhywj=|vBMU270qsdpI;G@i-?g%|Aw<)N z>@1_DuygWc6Ab7Ccx?;n(Y)5~VI`n@Q|!)Dx;vxz#$JP*PQ=5(9RVixMR0L>m#2wf z-t@iEHH+a@qXl}L2?9*D6TNF%%Y0Nk!a7I!m z_LTUFI5k}G`Vt5!Rs#1d2x{&EaZdodUJvwn(#(9pnbDIm@t2v6WeCp#V@Du#_+$=> z7-H3roQ8$~Jo3!m!A>qm9kG)}Nez0eXesO;@(Knn-XgWuE1764WdMkq{rpD;U``xt zA3e7XFN~2G1t-=3V}gS6Y=CZ9p+{@-GGQs?CEs{lv|)iwA?NinHUtC5ZNNbrLLp&& z(&MlILmQ$eR)dHn|4Oe4Y!Da0)BZDtNb%W>B7q`!*Mc~{h{|YjJ4tZTEENrW5zNg* zDDp)BK-b)p6`((JG=hXV326=+p}O$}22`<{NkQ#Qo7~5@=}inqGT#Kh=5Ho#iYIvh zMBfi;^=*2!MzGbCrV^)}!SS&8IVuo+9`U9|pzj;g7So7O5{9`|8>b3SWPZ5r*d|Ma z3m=CLMr1KK=C;>L5Rzpg|6QrM z^grd|{C^<%Pp|SDN`K-N8Tj9!!U2EqVG*Dr0zkP4F_6tR?4dpsW zELi^`nD4jZ|HY!MdjD4|)!6-Ctrh409q)e|Ya6k?U3n`-y1XQ;QvK}hI0`~H(tt`Y zqlttuD2~yr8}dstD+96m*zm%hzo+W|oh-~s{C}>~pX2_IpZ{{D$p8DE=U*M^_-X&s z=UBsmo~mGdP;#o|g`rA9&Z5bHn(_dPbT;C~XMgqid?)_@wsSS>DOLbtK6{A1!u;DPALK zT}=j~Z}EuGaOT?vB1~>Y#1d0}7|5b3hRF)DL_|&+t{PTUC4m_F#F8r+JJDB}7e>B` zL1s-Whzu)5f*DGxQd#8wl$l>t0xZCxCDs{;3GqqPgsBo~Q{?IiolzksTtplaX~8mH zF4{tg+3wP@79jdfF1|=OY^-*Cm31q1bm5JU8H1!PpY^y?Yy`E`RcRQ+ecCO6R9CfH z5O-$HQEg}GYH<(vjA|lyd&ClF8f)1z` z3zu1Z{(~%gY^yJ{{}?7);=igiGAaqL9_%wqA!Am6!lEx91SGGDBdksKU?+A=RTo2@ zZprqgw&zxb3A@TO0BgivNK#8I?`G|Vh2HAffF*{N+Cc&dvBW(_rr!}yEp9d3iuWe+ zQCAw!V8;tVNKQ@abD0AyHyzIFg^`Ei;WK16tfb2jdbkGqYrS;Q7HTZ+$+2J?jX_}N z5^%L(g25x19#tFRV_=6iEIN{n)khNYk*L6sG{RJ4n!yW^K3Mk=@f1nC)|h7b06*F! zxjiGz5I3ZxW_EJ)hMbzDW~w=QLo%8sHN!ig_{$JRXqH6W4SJgE@tb#{Q-oeQu@j1JIMyj)_`Fq>m?T>k4RTx=iw(cB(A#s)ROp(i zq_}BEt4UYGLPZJqM>x%v~XWf7y)Qz)ac5z1C5=2)le9)wnud8D^8i@UoarFYd0)xxM? zeM@vXN-fMsj@%s%OpDUJAVEiL&@8B!XEZa`ZV(4cs|V_~GAJRyV8>rvTc9)*cYGG^ z0-30qc3P&Qjw@%qugs{Lg5?5mGUER#)mZ#rwNx$a zzwhb)VZcBoa;rdG?M`Md5!Wt_0*Y>51d=(?f$Vy8)4l5sV;IiU`B*gEy`ha@({?q3 z72O}sx|7?^b+GtaBz(}u(};B3s5@>7dDxNWuMZBQ3wWx^A0*KWeta`;~%J4mR9 zKEQ8OLkH{;f{oq>k*h!MF?Zq^MF<9Yeou+WOPSSk$S^Kbp=MF=Hx2p#05P%d0LZ5Y zs&`f-k6(xX-2)}id0-dC>QIN*s-y8dXCb{kQM^k#XgO4FAu_3NIA>l@S1zw39MIz)1d&PktQfqcM&lY7jI{Sw8Cf z(R?O8C$cUm2T=_fYl>6(J{yGpgra1j_^+fjTPgnoKlkzTzggt}`2g`>LIelSZ8ScS z0{lgPaC6<6+(bnBpST^G?bA4KXtfa-q25vw{On(khUc^XKoA;u@UuH@flv}rGl>6R zbUVLVow4W#|NoHxeOPF{2l3pS@_9K`gdD51%-h6Q@l6zlcMatXi9ufTHJo+BLHEJDkT3kr z^la2N-5(8cp!(T#_5#Za|COSJ3MV`jPfmMXSS#7Qn4QtIKkLIF2HjiO2BUFSIMN~! zNf{SXT5MqpRWHIQ718c^3WC&=oV+eKHRbBMT*Fgp7^BJAwVG&(t4aQ}r+@Ocz+}U3 zCwMj3xdC`yHLtqJuv?~$KtA0^^-Ku;VOk3X4IhHshO> znx2wHQR$i`Ug<34;8H9%8!VhLdPFL#eTWXE_~TR4CgZU_4*nddIfa|ip^ z!fhs6n5x&wdM(|nM;~_#*=P@>^h_1GjLt|sYw3UB*hB0RnaCj47+H!=^r&^@49OMT zM_O>IqOfZcX14(xx2Hf^_Z4K3L=YW@h!M=_M+N*XJ}Thl$VVrL@PX8e2oe z5R14K4d74~^9u<;1@Hd__Sb#sn{r4D(i%ba%9wEjg`BR<-`@=Su{0XDrTv$@>{?lU z5h+UafGI>L;gM^(Ni)yDT%$TE1fK-a!m0I!x_*N|z2*hlhX{#Hf;yUTuFKLxM!fgT z0vk?t@8Rh;1Mz%B1_?OL6NY>L6s-btMxdtk1u4l|8~hi<7VP_S2klFIZ?(GY zq>fPo03_6rAo2(J_!yvT#UDU__TW=9t|$|K07d>-8t%Ka$ae?`kce5AC=61>X0gNC zp8*heNFa8_Ow0+q9K)mozeq88bDyi7ZvpYvEgas)+F*DKOd8w^+$anqtd(vQZXaN~JaCp1AB}IML>LS_?@0iuG-lv;_sLhFThxdqyZ{B)#}KMg zyh8coLP?e1OGdVXHda>nh-~5&#y|PjF$VEqB?hsD?As0TgcLtDs1IywLAH>zduv{< zIKp!mM|eoxrELV$mdy+vCNqOSq!_@Yv|<>*p0D>&jO#mca((-R>)Rub7vTE76Rz)) z7C9kY-)Sb-*UQKCb+Odg$H1_uZ*zTt#r1^+*H`@e$oAh<6o_>GuUv}rfBZiM|M#)% zze^%OENcZ`3JL-)G&J;5!?1Cu$I=QsTbCwRsVHBI8Imd5o5PUIW}#g}MTB6DD8@3I z#Ly;aiUmiFuu@OLaz?ab72CoY34Ep)5Plcqug1bH8XnWsKFZFm12FEHPKkuzc_h|1 zFREW|pu+`FGnWN)x8sxyj-w-N~UJ-rAap9Vi$V)hFX z;WcM$S(N%BiEiQlkp}}VEC2si?EbHoD~12ZJL-S_ga)Dj0{BPtzboUvZaMt_3;(a; z{1@l{Z;<~_c@y19niqIh{{K?hA^#WnuOCMKw^sO#<$v9jmeaaw+0 z7=7+27iurx%V?0gc+)0Re!1X8+g!V6a>MC{$*7lQ3tP0GT5gyOofnUsZkWeTFU&J^ z!aV;>rplB(K^M%6nD1=clPOx_E||M9x8+vU8@3s1j73MWhjX}xE|?eSf_Z3MFe@^1 zPFvk{p1kVdt^;cOm?`&_cMQ>Mf<$qAGsr0+m z_MI}qh@coM?HBtzXFv$yM`gDXxyN^vV^hpYDtu+s@hKN^-b3G-eI&?x`rR@%!Vuv) z65+WAVSWa;c#IA5s7F$Gkvd?_AUuMENNsd258# zkT^V0^S^e590b?~d-k<61uqdx9xBZAE(8vxA<}*nkJOKXWD8XWd&|&|z6%L?O-s2@ zDZnQ+*;2no{ZFDDD9ip+EyeV|mF*(`-#=^rNjTeu73go4|5B{LOPqiHzvBEC=f61r zADjOdfm5sce^*M|4*&1%!v6aq_2}-RF>vm?i2$9lR8y5o#50T*%j+bpJJC}0@?y(<#r~hVt0p)`Z}_HSTUE61 z5E3d5%#@LL_kH)v6LC@dMUT1Fl-g8yg zg>;#K$a&AK$>=Q7cM;wP+dhb`6+b|~e075GM95a7FJAscM1dVwIS{eoTaY0M3pZQG z!r_UsAkGqAI%ctB(Nzt^z-JPgcy#m|)u&O6345e;qs0)+Vt9%OqHH37ZCF>_FvoPi zz#r9>21O5U0OU4-H7Vi+{u%!t>$YC6|9`FE|Nce!&&lBxlHXsb|H*s$m-PQH&VO_yn`pKaL%JToO#O=SO0{`P<=zmruRiSlL*Au&u^s6wA)og4dVgUM9jyv!4gRgel;Ucn?n6fbGXq;rNL zV7o*gtWxH|Pb$S8+wfT=MnOM+^etyw_Cq{woM^OV*7F125jIB;vkd~acuqH@8K2~N z0B!NQqt*Dl=DmE`IV#xeq*pE5YuBi#wcl>fZzeeIB4ea=&?klC?mwpgS(5w9jQ^;` z@qel{Arlt--@EaD82{mp>d0d3*2P#6E-;nHT7a$k@ElZHFiH68Cq;{htY$LKJy`X#|AZM<<0bO%FELDj2Umi>SB-fg>WBuf;WXMF`bYt5;2 z*_L<}t#oBo69I^#?v~_Ry8EOk5|SuWBn^_pvOA{VH$ zWEbfC<$qCAu(bTY5&2(*-T=A%*HX&=9>)wU^Rde21%hxST`dqQI|5g6$2w`Yz#T)H zVE7dvjX#v&ncPZ=94S^^w2^bAjc<@Sodc{iMG6(aQ5{aW_)vI;jEI63q{UTW2BL({ zl%c#^4W}Ln^B7DfVmoB(F)!6IhpdG9PHw4@+kZR+Ov8LYs{Q|ZME_IG?LVH0{&z~$ zo~weNGXHNj1(^Zw$wt=qT;M)JvQ-qy;0aVRLM;b|hE0VNJmml55%9r+%qoZ} zK=uQEaO>@@)p*xZJRXAn={)w=EC`gacabxU&xQY+4M=CS|1Y7hr>6g@)bsp5i`4%F zCO0f2qGCZKO;K*z{fTC?6Sf3WOoU{a&amrVxPz$52n~(S+a32{&}SWW`jf8PJ?yu~ z=R@z>Pt?pr=+TEk<6NmTGxNn5-O}=~ge&n#H&I zw?MJKtG7Vk_N*Ib0MNoni|D`wQD}mUL*NBU|8TVi%9T1^s z$SLrjk^i4F_D{F}u0`>Gv69FCSS0>`Vpuwaqz@RC-Vk1=s*=2Cc{a}=8~CLD)Oa(q z{&(n)`3BLaI8Oh6n+5)>wTS;$sb0(Bf6@HE*ypJfy=Ewx^blGMS% zyuv{OUOVd3AvtBOm2XzVy@6`)Y#Dm>Icq~R>i@zUc$FRY)8W5Tipu{>rD`7kbCLJ| z!~H+#IayYJ4>~lnHKOO9!2bK65f^T3dg=|7VJ30!HDj_iu9Yb zBE2Io6*_yE!^i2?wrLuhh}%!)s59KNcuZI2pP#Hh~0}l zY-^3P@6dM5VTP-9v){->@f)FJ$h{Tz8?ki15zCb(oVe93czyCtv-VWYQ>EG3A>&yb zwj^Xs>_GYO#}=BaN*ZU)k|EjOXU>ZebcLg8N`(m&IMU2brOyd5`kbsupOepRY&w@7 zJx%$~oTbK*`)-#;U+_*p#*0Q8yFcs z$eyqjX?-MP^YWj>bHW}f-M5Cn1G-{7eyQvP=M-5TV|-szLt029 z{+k-{Zz}yyxg62|mdo{A{<}c>f9~XQZsGlW`2Q)Pe-`*J)hjvt=kTBZEvx;d zGRCPC@8(TOD0m~uTp?;$?=+x5lfgfc@)Ty$*-BGyl0dt8wEdj4n={b=Q$l|#{8wuA z2>e&_{BH||e-7Fb+J6ij3uB$*1``#&LrYFn%m4^)5Hk$h%tV`(7_-Zo2qyG(d z`B7!>eDpuX_4R60|5ME2e+lUSNAdSIv^%YTchr^p#NhKiuoC>cBT|1YWuR2wLcB0L z$yM8_5nZQXa@FQNnq0L>gRI%$WxW&UWxd6`tef-pvd-%NYuL1T$^YxcDF0us(kNuSNO)avuL>k?_Aj zD9!B4d2Dv&{4sFzv&sMId)FPXZ9K;3xyk?6tM#b-e?7nd7fk;vyZmtJzv1}I#|zX! zBOoQrHjl?^q1~$}ePhV$rBdv`DsXEl=SUcliD*ys-HGy!r<5n>!U&9fQgDvsM`XFc zTGhF;B;P?iK43n0I^-egTS_jmV1$pTamXhBH*DYB?*B@yY`FjP_}@z)|9^o0Cp#rk z?|?r?Uhjq(;wLzRDoupF2WUsVo8I`I)7V`Up6}x`8+q)TL(jdT6GD%K5qpl-;LjgD=mWYCUKJDHCGCFyj7}2P;f5{E z02^@lub!_r7(nF_x=_$Yj@&`l^=$WSa?u)`582B-_j+jXlVE-q!bKt}C)r~-oW|q- z$8|a_Cp(?;^kYrQplzwlo8phUGe3V+XKb7NHkeItBXAjjXd~#Mf!+aNiOZtXV*(2z zTqVd!^b=ej@OaKdNbvZH2=@lkRqBSyvTF+6L@d_mTjt3Sh_&8Ip&Aj_7So}1gekC$ zD|i!lpR~YZq=il|AfHn#iWM0b5f>)yW)DK~AF&tE{(jiNcY_ZJU0G<^UB$6vH(Zp> zWHY$uZJsn{B*#^{vJ@F( zARBCi#DHYYR(j>`FM)vKofI7SVK3BjaI7&%4LaX;PCD++eLA^lY%AHc8YUa{6!yj<@sqaL`Xq*i06_i6K$ zS@gdTqyAFqzv6m1LjP5Zx&7xN>A(2=UvY$;ksNuZblkk?w@jp3h&uZ*gjhD$)r1UW zMT&5XT>JLbrZ**~$o{?=$~eaA^4wbR-LuX9tWTCMy0j#zBT|UYfigN=BFd;EDWkq6 zv!ByZZpq2`mYFO%pNT3ulup5FKn~B+$RA%Jf3_6zrzOdsJ4?F3-CH{SGx{m%AD;Ej zeg?!%{`Z*j|I9w%$@E{jR4hg8e=sL(PX8@|{C}Pv;IW5jinM+aE1H-Te`~5|G*<_R zbbEc}Zd23>1o>$heL>deQD4J`LF`HTkcVP7jE#=js(ZMqqmYJ1F-Zd1H5JVvv0=xQ zCzw=FI;^W5^Vx78ZcDepP8HT8tfvsC$#TM=JTQ@^dx{1M=N-j!iSKU8d`pIk4!%a~=;SJqtBb>dh znk{I9^hq?=?9e%p;1TtWuaP`Qa=6m?8H8em?Tw zsbT<35C7#_9{)Lq|NL*M`Txu@08;(`BJtlVm1=JPvmE^Y{2~Cv9HA6DK&PR&?W5TK zpwaAIS!TcewZ3HFzgs`fjPB>7|EKT*p5FhzTr1`Fzl+rWCj0*f8r>ff0U#M~OdAD& zYw3dci&Ojmf7pIbnvogl|EXdCq~8Bg|Gzw)V{j#Hw1qRVZQGgHwr$(SBoj=|#GDut z+qNdQor&#CZ1&A}>(>3zReh?uy884v_11o$z1G@uJ{LfP%#-#zz}pD$@Nh|dfptL4 z{4z`SnZ>^acL^ALdlq>SKJBnSHo3v#$cntadG0 z8t2q&$cTO%oMsx=9B#YjQRXpa>+Sh6Ns`u^Cl6eJm`{2U0 zNjpE!lULO$2sD?{kOQz!1TZ~zp@WnfJ#2k8&b`>B#z{e&BDxP4_zB z)q4k^!~%KNDgGvf0hH+G0cfI`p_s~r5>U@_27>gkoxPaS)*O6Vw;Dm3H_2j77G^)$ z27&LWch)0-LCg?9cXR_t_5o_5wnO2h>mffsju>o@<3))fJpGa1w_1N3)?^_K@9o^5 zd4^pS3Ey4(RXWy3Fyu)ooOM@CNKY0j-li!jV~CS$$MYP^IAzcT1hn zDv$Yo)Kw2beU5#u!)F_lgZ3T_PT`hX>F}aDYGaYGed+$UhOLZTI0Dco!@e8$dwp&u zbbwUWkFXsMTyRjZcaWrYAa}Ixq;|Qtg=gryzwds_w1xld04YAoOtQi+nj=(^Jv~`T zViKzUc*IF7RH_1*o62)#GJF1g2n*ZQm4o8_upe}`yYSN;r)jr29dm1LCfDlyL!Y9J zmV^(&H@Q=-45}#06D7;B0iu*ZY(ek-GF_~kSu2G&_??+?i4S|V`mP*?x5H{6XZSoP z9ca>sI0h>24S<%JSK|TDh(}Y{MeoU1t%GXrtvY$vs^UR$6H$~dbcw*H>rE$)3)bUa zL-wx*fwx&B6JnGYMw=J4fC~kKd;~+CK znjta)MF3+G(5l(L&Pi4HU?&m2vPGFU=MU}9OWu^I7m-;**6XTix7eC*d8E7wBVL2o zJ8*>C@X5k89Bsp-Tc-$puN8Nri8N$}2;hitKRDb&t6qno$uxTvU$%V|u!9yBepdb? zuagdFbAkhBi#}fgUDEeQbJ(hNvVYH4AX(%*_8t&jDsXs}(+?!(1EVf1T&uw)FCfW( z-Jh2`eZGpj?)Em};4i2|Q>p}SbMV3RmB(?|1ke^f^J^S-LEbF#a>B-|@OV@DuU4Yq zB{UVt*#z&*591=>g(skbXOh2v-JdV#X92rsl3f&O4Ua#;b|ykgG{s0uGY170ny`00 zk_o>x^H^8LlKYg!=q8deVBTWvYvqwc6on#lhj886;x!r8Tb@=(k+K!QZ!Qf|Qo?z^uDMm5N({tXH0 zD%LMoVNiTY&E#pLPBU#MkCm1kCRXeQEaj&o|BYaZ4f9gL6a54-bh3LJ7#)R65+1^#fsONtN`E z60Ki7pgOg&9l-kxq>!6c5s=LTSZhEiBLTU<=#Fq0K=^?p3Ah_Ou1`!jfHEOm(%-E| zNddaUtM2GKV7)Iu(Yro@4mczX3gRW_!PS1HP7B>!mKAL$m6mgWWqb~JEU=tMTMs-E z2`Y}`SphI}HrsGtDjW;LEzJEcY>2zP@Y;=k4^$o80cx{AtkM(nm%-`T(72PojFS9z zyI{EfvzdQ(y#q?UQhEPXMC*nM8GOsdvk?qcCSe(?8oE8|zn zT@B8`n}?(sI|T2WQ*A_P-DfPeQW3j(AexTkPqV^FQNJ?ZPEFE%_ zemHy9i1HXAdA1{dvG^=&iv@@JFt)G`6qSM_NqP&uBi{Ij5cJP}Twh#)F^{=&-*;Tynw}51SwME}4d7hKIn|G*lQ>1fUo=dkGU21_4 zmE^>{@p9(6oT50{cviZ-@QB{bGHh$EHb}o6S^IuDWmw>Kls(&VtK#F;#qQ&EA%8iw zC+Y3wBzrmaNPRhlzq6So(n}ja6_&7rhY>B>HY}X~Q-50UAqCsx6q}E56cqQzXH866 z;m*e_*5EVIF?RIZ&O&!;*^Fn}SvA^^{NoPkB?|P+j zz%U>xyr&4a{R$TD>0Y-Rg!r(9Quv{M0+{il17Sk@55`f2Km+ua3yGZ4Fdsh64fvNq zqGD~N#^8@%-nEH6L0MaXFA}MF*UGOUZbWaR25(IV)Cx9M!kP)8{6%p8 z&pZV+jc{ezyVI0EG5WDeHBJO4i&-SHHAcE~uOAi35oQ}Hm}^SGW`Zaal8{xaQ*sra zmYD&0Snx08aH3=JviWxmqe@Bs>B+PkaA-yR^r|niRC2;Js6{k%5+~Bhv_!NLC*Q(p zJ2}vc9QH4|lw2M#0~68PML9z1i_|ka@CVx-ljx@2E?16$UGNX(GJrwyynCz-_{(+z z9OB-Solq7HdBtI;+*A|Awd8dNNx@8Vw?Iy%Txf?k7og!S`S0F6pv(YNA#d5xAL9q5 zAKeMBAm)TmQN`xh$2))c2atQ{N7}YOVZy(0n@7*G=z*4Vk{dqTU?^{j;9LxviMa1QB zXIJ+fG311dEI!i#F(jRo!u!L!3tMyYy@{2gey7u4A`a-zGp;{g&~eK71b(5}e@weC zeLVIyw(nhQ&a#_2#aElpypPvb(J)M?9()={H@*J4F|Tn862iW^h@tqiEMM7LnD-{H z6$CqfH=ZJgX+Khzp{*gA;WaR5pULKLJ@M`j7)h=}tVSD?{0LqrA4Bnq!qsDw5r3og zOsams7(1`iG#%{ALEe`@HWy%tvA*qjq7-BivbyPMRa02()^j6y34LtC87lBKEroUr zz{)UNdnhDispfQVzOF>ZHJ-&SPuTdf7l}*65*Y~*D$PR(gN(~)#6Xl(`|Xw3t!HL( zU8tU%BxSIl9Cru{-@9v=s)6>OmhB4tnW@7l_Kye4tg2%1h4^*a18Qvl=b^C`iOE)< zlBABNt8u7{#br1#qu!QFxi(*fZiQvE#t6divUHN%6gmn&3!Ql&-!xPslbI|XUp;9H z#TDtaB@?@9CJIp|OyW@rkB)5QvGrP(b+%{N_;qkr3*JF3>eKT-uU>;-EB$|&7px`w zsCJvrzX)57^jG-km5|?^jGCQfbP{e z>J+eR`y7G%3>;hmtYhHdMk|c4GD#2;Y30$#67nOTJ_3kY_WjQO%m;Lrg*KFhxfhY^ zJ0xG3#0LjCwfxU0fk+h0iV5*PGrW{43}JqswK%dQt{-0%6EhSy8yD1SGrwKcISEg6 z+8LwEz{HM*e+{r+l)0{av9dIiWcSg6qvmL^#&W;etp+0Pldb7aL^1!CUoLScj5!fA zMO|vJM7xKq{egAOpa}UwK<$!|t#+Xg-csgTQ@36`)H&pmfq#iV>}XeM&)9-zWu)nJ zJ2c^#g5`9Z5F6#1@p|aaUJGew>TuB9rejRVY_Z*iP645-j@#;n55Y^_a5tbkxSXw~ zD|unoVoLSAyg?`AnFvd4)ri7b5s$qN;ignhE&*DJv!N72)7z<;v5tUzGP>%Ptcm zLkrB-d@nplyKr2q{ouecIS7=A!eJli{GKhph*cld|hc9n1;6cVPc91@t^xlY1 zX8$-wveLD|n5|dD;@|=tt|(#et`o0vD8d}@j@45S3}NHyEVKXEh!-mNl&DNz4sndv zX4Ly;2L)_`&p^ltKX|{V<``sNhRAot=;sYu6>pm;@;!DjK-S36_QnWqP>Os^e|8dU+2vob1 zubxtN{l;+4ze!+LTqu!RY`*uyabKsk*t zK4}ARhCFIR12y6AUTs>Vd)>kWug>rJ)8{=otsmC($2o8x^7n)v?oZP|N+OUG_^2EP z*g1BhcLMHD#?T+Y>~~=0$)DZ0S{6bBIm^K5)KP-R=c@@0r+{=#?>FfB(7txp-E{dIu=nbfYo_=2$HX3n+>{@84N0DCX$c7TYMUn)&F#9VRTk8Di0OIB4dOdKg@d z?f2z!J@CmiTngysf9eQ+adX{RHLFI?irL;1ygdBs*WoGpo%ApF^k|WjsE;$objI(S zaz|$iB9u5<0I~>Uko?aZ=oNmqSij?^iDwf(WXM5Wl970`6p>|jR9z~TDEix%gpd@b z*;D&w@~?a`jXl$57rOA`naXMN6iRuOGo$kCS}O2k4FR*%qj!17(GrIr4fZ$gHZ~`bZEm9b*9ct^a&9HKbS&l( zzFeK?zZx^v33sL5mI#X}B5~#DWulb1wD@WwToNbuI#355Ux?#5w+7+LlD?t*MTYa* zn<*z0vRBx;+m1_DO)!9=Zc?ApvAMpRCw1>OlUw7k#U9>bovXa`EM+gLFcdJA{gnRe zfe2#<@BVrzyw4&(M!T*BC-Ikw-=j+L6Y}^#8+zO%UCoA$qs`yTopVAd*?#2CeJOUQ zzRaNoJ@^V zxeCR10tfKfu6nIc8p#8tStP^?@9`4#S_(7hRDakY_r>G)jNhSd$ZQI7=Udvnfb5Q^ zT>$Odry6-^fUp_D20(LR^jnP~Q1B~x(dUK87iV#w_a4s_+a98sMi;>m2Xb1b{}_dx z8@h3IAx4X^y=lvZzA+EVIRV|@UxZDE8{8p#kQH1r6CA~Q&9@Sr@piZjoqpugee z<4U)vEc?DrVj(a+BETUC`A$jG;YYB0a3s@(0>Wmqo2-U7cC#s(V|8Z8f+qCJ<^BY? zqU9tq9gR2>Evv3L{b;F_<34U<%wed8p*cpDp%~LQq*V!b@zVucaqXSZbVI`vfrGK1 zaFAYIviHPo4c{hf%h>%H3$s*hC{8^&fJF^)Xp@$$O}$@PM3(YFk$07jhGyGUR?Ikc z0?g0Tn18=vYq6w#FkqdbeblH`W4gaM2fS^G?*jORQjyW6?El(vE=ckJ!iYoXwD4c_ z=1%jS-q9FFg9YXJzDrPZ^aUs>x_#7w6{}t^@IM_D4N_|2zN){?+Z^|m8H&R;`i>vC z%JyEyqDc}h&SAaHITPkX%1UqIT`sZ+VxTgz1V$#8M7~E{38w;+pF7ua;7k?Bx%a6C z^gmm{@c*@fvxHlZ<8G1EzQaB(Hf*^eRun9^^a$1-lOcs1J=xYCVcvAxaCAjWft7s- zqrHQQG^XA`*vvrG%5~WI2O<)4)H*Zcxvp=Rbv7g%zwRTUm3`F|4{e`8A<6ehj(@>jJsmNq&R97otKF(0Jz@u`&c_(mXO zv^)=&rDnG!i>EX+7`LD|cvX!V)fvjWof?xbl{olR3bB@_Nx3KuPIli(MENsjX=Uc& z$1k%BYXutK{)*IFaze-@0{CwL!=S;(Cq+AA(haxd$7XuHmnD6D&oumZhLO0e4X6E+ zJn~;40;LIlIGKChC)>%wcu-CSs#6O-aux-E;c0qcGO(y5<^4rs#2+ulvIfy@E9bLy zaR=nr7<&EG_lSP%S!F>8I;FCW7A1`Oup!&pLi6|D47HAEL- z9pcu9JrZRj84MB5ma93{T7$y+m?inHa{o23d5-M^Sl!NP;Xfswn6b_dY~CKtsQv;% znjg*&Z(ILd((b$E=A5(;jh<2+QQUdnuOK&jaV4@@(g>mt;Q2H7a+){JxTGjr&LfOH zr=7=fNPOS@Dc;nXaxKU(BDxeJ23|VdTimx!I>=RjGbiRkxtm>e?U@muPPvfM$F62y zHXkDZ79-8legSGK@z#rWbvZuJ^;5N5&ar+nkk}bv(NtIkBD}}Vf?cHNqA>I-3KXC- z3Y!Wl8VFA(%l0}OA@+wZPS2WH9}jcG>ufdqzvo?Sc(^;Y;(tq{@HXFEh}ms&X9MCJ zp*M_51c`lSiIsfzx;l6XqlN1;O;5QiHacArL&Knyj=fX7|4M}N$>>sV&MgMa{Z^Rz zqww2mx*(O_L8r0Qdtmmu=P9odTz1^mesq`)Gjt(?Eaf@paU!qa~ths_4wxfLt8 zlQb&0^W?>{q}rEVGU^f*{v;BO=wnjmxS>m;t>zuXeY6N)QAv-Y9=9v9&M+5h3^2-& zS!8M>(@xQF8RZz+udMk~GXIQt`hB)=5`FmxH%;|dhKPdU;Jql)s23=^RUNBA=!Rxh zwxGNi$JmgIx7;gh$U5n(*MgKc3aY7pg<;KMnh$PBx(u zlsvB55`vpsfl1%9ivaY&2XUH=esT+L^-VS?9=m=fQ}CV}_|q+?lN$ZE!Q1_lo@)(q-rO1dRLSV*6ht#Ay@oO+woSXMa|!Hu?3==ORXyGID_f16e7vz4WA|w$3>ws zWGL9&#HY~!s2NwxeR=%2n9SWSrzuy-m_Ut;qv80E3_C!7_G9H@{3fOzE@Rh$=l7r- zCApb0P0m%Eo!MGfzRtyIT{wE20y&c?kyOsoW!mnymoIIq>W|MK2Zb8b{hs` zPv{1^Bq1U-s!+k&P4tC?RY%oCGgVjQVw*=_3JH^hUwvQ9zm)am;)60>Leae!u-aL! zF0#-oSR>f8EAtwqeI)&6%zh-#EkBAlC5+cf{k)F&E8GER@Ym1bs#RJQx{({0@{#zJ za~W}xn<ULeS2pHQNF#-M--OJ`Ako1B73mbcl0$VsK;pYR zS^K!;H)TbaXT9k8hI9z4vWckYa&WWFNvJ=#WW%T8ty~t7}z8}6wX;rqHI2S3__v~*rmq5OoII|2@`!9{x(5P5<{bf!HGlD@k044T$owS z^P5?MVQ1_}_1CJNdfo6UWHB=a>&TsRnYzVd`ervPRQ)XZuWw@UdWQ6FX-3ftV+kv# zQF6^bV*-%tk_E1|bl~Os4L?sQjYRJK;lC`BQs(j@!<7=!<*r7;mkJFdlwKr-{FZ>! z*ns?!oH1T9aZ|=XzOq9s8?Shu+-EGCZy<-kJvd&(;i~@U|4ttk0$(HC$}&vEFr}47 zqS?xtGdG<)(-yp%E`2|vjp^wSj48|NX^%|nORH6}52&N^J-?}Hu*+gLQN^0eXC0@r z&8AXHB7UDKi^Y-B@imDYLQ-q!OKEtTUkleBB;E>??rP2vi0-(4-bI z0@w|zIRPyi58dt)kQtsSR@3kqNpQ`WqjTj(+oAs|r$W=SMrwXq_K zBAl62%ae*p630CZKY=X9pns27z|XTO3D z{u3>ozi<86f7?1sUI$)vdqLb`&qwZn7l|O?)t224LI$3z3H-PBZIf6xu~_M~_#c@E%AFYmLA2^RWM3(;n1=Fez!l z=ZwL{&IeuMi}p~Qi?H1!!Psx!<#*@sY1j|lyZ~$&ceY-)EkywoeKFDDvu_*b|G?F( z63MDqa9g0e_?Id15n~*4id%I$tEYha6h2W<2nn+=nDvK;Kx`G^j}Rf2 z;l9_R)NMu#x=R09`d_-z5DyhNr}OuSMvUf+1R=%xA3R*XEd{mhotJY>FjMkiFg60eMjO-Y8T1&OhFyR?Sq1+j|nE7N$$y=BPZLS zMZ~tYC3MINTan829($>wa$BAs2k%W*y#9MEEjj$|wdwijl|vMSbdbGv<=>T#Rf3sS zEF*#zAw{EQABPf}Sz81okw1byw_xjoNk5Bdw7DKDVB2u$Qq;a#_7>p@@1&e^#}rsy z)mkd5p=kav-`OOD=HwPPtfrG>*5QgpNC6wr3jAt7H~zQ(kwkaO_vW-u$^eP_wXg>B zHL|A;rCQ||e{DI9rR^6!6~DY{$*!+=L9LX&GjFI_U>`EKA$)8vVnh|$ijij+-&b5I z-s5~R@9OFDzH=|xm!T0>u@7R{NH-O&Bl&_?p$(Gd7GbX=^OG$`pd)vbVXr56EB_8K z=MYj>kN8nJyok#GJCgk zzG1=lC&h9t3N6@4-FmWPCilwRRT2j$8*Q1&iai0G1dwjVy214FeMx0XenrMNi* ze(|vkuij-nFwwU}hMh5zw1=~xUgwIi zZI{8f=Ff3S*It9p_A-TaHngMjzk@fLmzI&0!cUtmD>wzvyL|utoFJQuE8W!oj%qq) z?>F{4>+%=YLnmAEi&A+?9o3o&4lDF#v;&hVz1&oXcGoW$U4;w&*rVz3gpG6FXuMUb zRot(EqHzt{i`y>!xXmV$_%{k`=r%2Pv!q7Nn6r=7jCB~O##mwt>(HTYRfl0J+owv82XFgkEQittmrkYkZD{=i_D6K(L|bd>f|dsMC4|w zT_XvXwbqiAwBbvm`Fij^etT>a%$?3S1=mcX021QyQ`WO*;PUopSSesHwx*N@3v>SA57aZyAQP~5HR$;4mrBGGgzUK_y#d7x(=f0&q{LFhd zVSI(xQEcurf#S}OhVf+7JzXtkC0=IvJjMsUo7npZ)FSwvtaDk{1Ha{76Uza*WO>!s znospko6P%k!Vth#9_nE@B#^}E?NDPtcPpO6)`oo;VQ-qEyp|x%q2C;C6+@Pfr!3p_ zcex;Xl=DEOrkCW_kd7=r4MT!MKNEQ`Xg@>vt6Eq*tIdixX)@+&ROjF@V}T>xoP(sd zYne=&uj?2BmDNfodV_;Q1VzyxV}(v)(J$TNV>$szgdKu=%)Q^}D|eV!mX1;LCv^0t zSkejEV(PX7$8;KwL-Gt-%C52+MPUo->y#&SU57rAFvoO_ai*huk&$Yx3dM`cakh6D zM^g6#lGWM2Q+@i_D`?(GYO-32Zl%3H5g; zC9V9`YxV3cb%CINyCAnzC8x6EaI<9W3s4^$mH*ItbDJHo%<#T zv?xk$8H>B>o<@c3EaH$QSoW#%fYdQ8~nC45<|`c12f8t!I$JUJ%MPe-}Hx;A8K*xx9!_YsYk9m`$)TIJd=sg zbjUJ~lQ?$EA+kbGV?-Ex@S16De*V)mTYALp;rZ3E^zb)S6)S^L!vY_gsft1sfBsS= zY?`^rz!wA^()56SA z`G0j9VI@Vk&C#sa-QXBSKb)K`I`m^K#x#&83r<)4V26544FIC{L9~BAyHr~ZyMWu~ zCEh5gIPkNV+MRg>O#H`r&7P+}Id93+WwPEN>3{M?c`^WP?G1L0#D-7sRnaHn?EUtuo4h|xCq1jMx){l^=&6e*sS&wy=+P0_#E3Ow(MXV~6XhL*KY#UxW z(3``fezkF@EC2OfuQ0Z>&1zatF=C6fS?3>^v6LFd4wi}hSwIwD)Iz|y!)du-(`D_T zr$ic|ek1zq(*QP;rBjnGnQFs5qP{;4k3MCkhB(|{ZfUFJUu~s@CNe4JHYpD202MD- z6l(jY6?#S1QrO0UV`*+Hql2=Yc;bNo3|v_UdivqmpB*ZD;l#sC(H(_!b_Xw|kyeYC zz1yk#$9Ai_dU!nT37#ohPK1V%{NfCgbr{ZE=uH^1LXYWKNRIDQN~{ZUz62if^=Z}| z%%hm?A9M?^>N=jshlJ{RA=BZ1%wp9;v14ZEs1p~ZD>ATtjzUMgtjeC1)pkNh{M?q`Tl zvitkiXJ0jJAH*Spgav16z5I=NU6dob#`o`ua=X)G?G|wkN~gWB>ON{b_e77$TP>oc zs_H&#qI6({<&}picj22Fn#2V@c%pVh)SzBXbp(sld1+`Fqn!#*k(`KWr{(q44GZye zj_3!!^}kP<9P2jdC20?U+64{<=Ex||-+BiJ2+w3iS+}*P{l~B9#p`<6Nmp7>z2YVH z?P#O>l_P{p5&)_GOKY|-vY~*FtKcc1UA2D=N)i1aXxO`rd&f^VYF+1cK}k@^*~i4v z!jKpsu%Jh~?)^=>t3jL-rWG%Qp5V$+7l-<9UKSHwnT#w7QdBm%rz&1LuICGcaDffK zesIFRZKpU|OtbZCe>LsgR49^3N3t)DHPovcy4{&{h zoIxEh4Ad%`w2kJSAnz_yzx9@oSsEDcB+AvVKlL zq+F!bL^kut&SDTNRt>gjheK~{+aHUeiBRy0_`_G^?w~Q4w;25;_0p4LNVhnBGLlF+ zc@(=Z{#XBPalsOpkQB2CqnAS#!rcr=!h`Q$4fy6-@M?`Tifn922VI~5 zZ5tESV2o?jP`QEIqCd~mAeUuwxcl(N-Jncj0QEZ%yE%aPA{+#GWdqMNbAV;<+{!l) z$({%3pK(w9ov`Iv;g`1)RGa9JnoFZb{zrQnBj29-RW>s1c^6_tejUQ8B? zMrK8vRBkUKyD=u;^5Xl_(lXocr%!}W#hQJJtHI45bkZyVZR)f@bN>;}IJsEf|IlCmI zlLH~{ScN2=KAG+#4fhKJ!I`IyxP2k~LmC(64~N^HxzUaD7mvDl2c477JPKqr^+msj zvT~eXeA!IcFNfy2(RQgBnqN)-9L4Mlin`(1RR*k}$JD{aX|kPCCrJ^x6kh$j5znbp zEK5%7(I|otSWE(j0d;PQ$ESd#>Fel$U0SKMIpki09y{h#C1a)qn>lc$# z`4ih2%nl^D`27>`eYYz>?hYBKKX2P;qT78}mIt&cUPvM_Zk|x0_5f?RmzqI8gsgO1 zc3*n93q`*_M4af5Yj}xp3)5E9R%7HQTwCA`Qo4HS(6SZUbd6yBHt4BScW6up7O}lw zXIyQc(HcucjsvA7n|@~xwN9Q4FG!3l$+PmIL{sV5IS#(b*1-{&5rt85u!*bX}~ zI5J2P8GuoBkItg&sHo4&iHSO0(N|PNO-|Umx+_3Djx1EkuZh))rn@c6jd-NAsFV>m zOJ~%Zx-J}B1oigEqLPMnl@j!bDc~Vqeenr04Bnw{O4-z)*8Ko}$_t;p8#8Irw*$(y zod#bI9)bBb081C(;O>HGfllp6ENC4NA{-iR-d&}b^90E$UyD1FrtRxD)6e~Yc|?|f zXG#o0VN`b`u?`;PNso&zKr2KLExcr(2#}g#QFEGfW~r8F#E$)wREb%>7cUY{kRnEJ z8c@sYT(~oH#30WtjQoRGpNv5?4Mv*UqjyR?*{&R|TnnlrT&s;TGGBrKTJ&@zxR-*? zKtWlIl_SPAb?z5efra?UtlTDYUYt=vJz~5dnH#IbHdF_!es0=5P1u*9Rcd)aW%#Da zZ&w331ZQQb2mUVt5(CGJ3g2ryR;NrzNjmLq)_{tB0w2O`Qr>{(DYPum(h~$W_V-__ zBkQxH47kSEAWE(S=-@-bhieE8v)^c$O7o&|mxZa4Zx8g|=el;JM5K_6(UY~PRW)?N zSmCKNcrR|Yf~}wN0#$dIe5_F2%Bn2l`P6~yNw(B^lSyHsPMHE~2_U#HN<1MMw}u;Q zpY$?G;yt;lu!N6`MmQ3VKqC@$%gT$Or*}0_X3yuDQgg0cKBLmcqepdPe*{J~Zh5HW zj<_ezNcom3(acszoOPuK2JKSJ8&&b|ms%b@?5gOr1D3o$gxQC|xa!svNkj_n?9pZH z@b?A0_=5}Auu5gT2dlPl^Q-KY;bfYI1BNu!ApSpS>I#}=SgwOAF6wSVUEw883b9l- zaXxnF{WAM?t%Q%BW@a7rGRHU%3`JK#A4+V}g=6d8^ZU@NEd<%Yfd2*weQia_&$;&|qG#>#z7N(A~9V|Q}HHn7!B%m%Om zSSAcO9dzlMwC~v?c7qG$_j`-9-2atw}w?Ks27v5 zL$pZz!di-UOA3pQ*W#Z$0$BSP3ib~ zzqud=;7_epqQS8mp6F(n`?4YNAroe$)w$C{%dGW5)(erXHJM&gn5QJg4h;BqtrK|#ISXRi zeBBOuvJHLmZ=aw-*&)IV)wu)Qd1c&SD?I%Nf3c(|j!c_^ExpM9rJy}=ToCWI2*F(_+T=N*;E5kL2d8ZQOW)l2=cWXS0H=#|Ot0AaCXw~SHV}vqBdR9+S zi!vG$+-?$bgfRJ(0dNWf5pE3}^i;(uP8{q|CxT5v#&Uw2Y#3`qivuYupt-=U@Qc#? z2sH?M{za3>ctk1lpvqq7dxmdzLVBB0Xw_m_IyRCQJXiri7OAR9v13ZU!j&=YYWp*| zu3HOJ{2Y&yBB$Nr=2H_)R&l@onTQq?MP{Z9;L6r#2lzXg9rU{muY_7P1-%vL#hRO#}<%aBQ&F=zR^z%cOwvp&Uv%`|YobSQW> z>ZfO{ng>=E{h`hrRxXqz=h{gePCu<~CYslH8Z_3r(ly1XgMF4}c>-HY8LByBdz|;z z{a;z7MVWTINXK?u9_fZXn?2@K5#CJzA;cYlo^JhUF%G+Z7#ECpM6(O(RF3+ID3XmBC0ryL{zJf z3S?stuMe^f4K%?sJ!vpM)KfUiIBd!ez{>U}R1lq#XdmOz(7wff*|kUxJ`?BK!%zIq zP)-wjQYgiyKYGHS{f*-rzZ~^25x7|+l`>Xs61jJE{o<=qeoMxv&&Kw)Em!)8-%xQb zL<2v(f+-Q}I9aTZLnydUuz)CpIT{a7UJu!qtuHVF=o=6gnM0GH@joyx+^q*)~x<2KC8brQY?lZao6ik`-do`h=>{+teqq zZOT@r*|x&)6H?Kf?$$`nLJKluWxn<)_>cr5Dg`PQ>m+yRRzgE@P*>*rgh79uP|7Z` zIecQKsPUR7?zn{mOGF3i2qOS3y_%GpH(_PQ5BaB&Lg=6INnegCD1FqU#obVi5Z|V0 zuGEK~YBU8ue9QD|4z5XZ;NKvwlwt1j;uP5AI}oQZBO$Rca>{Dnx5<_XlSJJV+Y6w5 z-4{%pk=xZqNL3QPVZ=LOVt?S7lbL^nwenPbeWb9X>2@anQgS+Do@q+0i4w)(0|hc_iG;!FUVPZG)lssqw@ty$$O8 zeFXADzjEGvafiGb*Z~>gfTW(duYe*se}I|jP5P%W%!@S?AOOr~wgG8RfE(2j;QsLm z!yw{a-(RCWHzy$>w4qUR)2y-lXf1Yw%x5(b^L71aB%Z!NDwCeXnT+C8eVDW33cx+ zA|%OvG3pWv zoY<6b@2Yxs)C?s%2Ixj$4%o915#E`(-7)ApRa6_ao@j}ki*8;b<{3KS$4xzFwic67 zLy@1tJcJkZ9ngUI>6!Dk}r?`X)0S#jopz{pzItj&0}FXO3;1 zQRA+BIB`-y>bGa(Hoc)wV`ewYr-rkp!5spgA@0*_!U8!Nx{DquQY0bid;hKB`h$(; z@kj=+UTr}1_)q#dHu#xTK`(-_f7M<43WO&Dpa*F4zXUNcxCB>qe=j{(jkpJJ-)x2O zEMzJ+@$l<5ZXXI!NNOK)s=LF2Y2Ig%gn9wt_VXIwb2JYrK=?DvtWQ%00L5TQZ@LJ) z-73HMtw2|L?1(Xu07cHoPB3AO+J6bQAXMir9#25XQ`NYd{UcaKU{zHMwadbV5&(bsFEX1E&Qpo;1#BAp&R2Y z$E_~esg;UrwFOHXM7Ws>|F_*~Gi~MUg}>ha=gR=DW7<_pe677hfj~I#ZUegFL#q?i zo}^T)*N>k}B;nOW&nds~)@Sjh{l;>FdL`F{k*7@=Z25-W_*jx4R&XN(E;w5Ed?8>L zhvJ34-F4yoP)IuRJ-{m`8AtY>q^{P!T26}fbGh7)td->++@pDT=p+<;YoI`QBO_3U z|2GP5?N;n((SySMKp`Jqli_B}BGs-lmGWI6)?q|6DC~<#)ryCh|KH+^ zQjkNW)d+TD$eDNT70)PiyQTMO|bfLZYwp};l+D)X3FNSYO+%Yl}@ zX58=$B4d`Y5?k#H%7>oC(q$xzT=2o&F7AW4VL?-qTBrz?aFiV&kTcIGHKNK-zZDti zNRIh#od{%pKM&XRXYdj^u*%H|E6;`PO?wEO( zf@^{q293~1{bgeP#h*%=rto*^Hb^hA7yyS3h`M<6An#tFNFv`+ud5#zCO;M;6^chV zwx^tyh@%ZjaadX~fL+&7IHZ_2n}%@(Y$vk_q`&8u(#e7osC9k`R~d)69M4k;HfkDL z`X2y=KzhHjN+J{Ezr}9dbk3p4@L#UiBJ^LWQq1GOFOvQX{6Au)c8dHD%kH?pPzM9`$I<@r?(l-OG#&Ap6+vcnYw;80bDyP%C;SNsOZG`h z>xP8OswRq1Z64CP)(BB|Blw5m(I!W)&>XoW=w5^QK8T0*_zg}x^Q1nFCdzMixR64q zf!-_WD;>47eY~ZB!;lqzk;?oNBvdVW`|^E*eKHxlIEPD>|*hfSEcH5A>CFq#o$c z@I?fz-(}|qBGBVO--Frg@dFxmY3;J{!ODJS1A)g~Wpsivs@PNTbqVob=5+g~-v8A~ zg#ItqbN&BP$bTYtUvS;ca)JNgi+i9o{^-|pz_gg8NZ&0kA;R0^9cH0`M+UO|n{-;^ zSfw@4uFQt&!#3r;xhoO#-Zb~-p{ZWBd-F(EJMuu;Yv^U!!>?x{ECp?Z|xt2gGhc4Hn%GoZG1V}6o1=6iW#Za?(K z{3+yriVyC%edbdn=#K#hp3om#^m&f*zf!dljsH-}@BhWh|3Vu*b^MpqYNsYzLj{pNRi=gxvLjoe-jdUu9!Kx+`uMF#ka=Y!LxG~au zrE~SOn2x69|1yK`T;;#jNpOssmgyj(I(1o_M5TxUqe`|6kN{n7l;@+Q423%HT(2iC&Wu6iNt#c_ut%AF~UPY~4!czw3wQmlGtXwk$bw$Ot}j)``&YwktxIzcsL-8N%c zH_n>#T-i(>(2;ySziCO0;4QsQ0`~mMCI-1AN=%9K!7w&0TROdG$+_}YAvd-q4YCtT zm`-l&U}APxW;0imu4q^j91g5bn+aiPQ@ahxv>u@BARwj4*3ZZUCg=Z{ z$L*hb|Ch>@sQfpN|F!(`-+5f%DHr#2_wiu7h$bakAJpSLp<-f`{KUe~6JV47M1^z}0eN&~t6T8uvNGWi?6{mj|mdS8*ql9#0~yUPE$P zVI5;AGCNl}+KPB~s+w1hRJu-AbIn#1pi|ZCE4~-f$!L3yzdCY}pC;vAh@2ffKf2-wE(cu4)+kgKI@_%-g;sgkGuiF@n+_oov z**(84UwrKK`;G8VwE#P{JMR4>Xb)u9(<%>?St*6J(TBdM^Hx6edvu`poqo`#|6tKZ z0+IH7v=0|npniaR8mc=LW-q77Q1EVp=TBTj1D7&0r-R2oP%7@i<2e$E*P6U#{3A3K zCn^c>ejb{NPlgs3qN(6yMy#fy!lW*@QtAS77^9{FHp--_xVNB^l$sn$W66(UsmYBl zHTfh6P;#yaOsd--C8LM`^v7`+q+P z{oi5<_*CuT6ml6)sG9HoI{GYWXk!IEc!Xg|3jg3k7v+S{t=_8JYJxt zQlXiW(IWn3%CqvbPzMetYS=hHeK zF@^-^wmXOef*SAZPOhhl)&C@J-L%%9sr+9l8vm!bUe4`5mVy7v6#x6VNBy2EqUD?- ze8+{lOhl)%NAh-rlBSrPCFSe?ll~WJQ;t;<0>dYy|d!qxTxN|6Ak-LAJsb+`_3HIJKg@D!bj)p+9f8|Tbqn~ z{iy{&De!O7|5fXG{Qm{Ye}Y|ph~2;8;AAuSr$>8NfDDrf2eMZ(2&H6{aZ`w}y#@xR z2%tvp)OaL{^cI?_Sc(pXCu$@oq3FaV{&!O@_3pAk(FH3rJ!wd;PBXuZM<1l!Rwu-w;Pdnp|sLyVBpyxZmXz)yLH3rV3TycjG$kdv5l+42#CW) zr%Kd|fL~`%EnrSI#S~hB)zE&V4t9hT32x~}FdW&T3vP$vBMa{53^rLPvDqN_tjGuM z{i~R`?P`1i_~T9-h^-{f)0T`Aj1BFW*Fc{3J>eKa&tNdSMr&WSs{_47&ytaf?M?G| ziv*l`lE&M2UX^W%#B4kT_A6f-{2m?mv3fAKCFJah@ku9L1_pq*ayj&D^xuqh-fZ+A zW%lY3{;yQY?SB?Y|7kv*QrX}0kKMM{xwP6|Z<1r<+(i(L;ld;RjWznN6=rnj{R^c8N>x~;D zFdi7SJ>Rv4l!R*!PKE~~pxfGg{(?0FT7M?B9)*O|X1Xpy&ZUxVffo6*)T|3_`T@TL zgYXW4ikqND(-%5VG;kc*0`HMsVb;vU?{ZNx!@Y2cif1*>xpOJZsyT1X6vzis5xUO% zBX<;=N1DOT;UC@>3_(Os&=OwW5N;L@9QH@vh_s3p5^T?kQYuYBsW7g*!Ps)VQJ8@3 zh{qs;8MFHg@IEsSXP%vKZT!JnMniOF^BvF#@cB@Qir)frWI4G#pnUKvlC2na{@Gq9 z$^Jj{;Qll4|B?8=#Y&$4G3Wnt{vYM0J4(uzXUzPE65HewfcZ-RLJORW0dg@wE(XZ| z4D{cex&L(jzo^@Pl<2QK{_`T~zc4?oaNwEwe}(IxFRQ-<%)PKyKW<1*KW+tv|8S3L zNQW12nJSZ?#?2GS&2P%bW|*13vDC%`j-Ima+mSjU&RtYYXW#c_biaeF+r_r|Z0#V(+?7>FPW^iIRC+N3+k%loT^9oHQdF5H3Z^*Sr(y*t;Z z<)B8-D+Fr**U8Xt_X}rUd(gRbee7{ixOTk@7ap&n@nIpD>FUbL$*@32-yXvd3iK)| zblnjolpS>LU(wR^8%RqEryL+D{WYZPPaA0$2JUU)w6%Ys1NSG#RgTd!E~Y zx~Hp!6WSPb4h{DPbd&mhA9`5n4?AD!P=m$k!Q!4xdi}0n*lypnkI_C62h?>t{WjY_ zS_a-g*Vs&f9henXugrBc0Gt*qBduREnmccWG;zHXma-n=fA+_Tqj-n{9)K3i`W&uVXq_4Q(Bbu_s6 zg)U8ZeEIt?rDE}a|Aii*@$bxQpui}v0*g=M7tZbr#~d{j_84mTrSOlHl`sGDrO}lr zz`wnI-F5r4rgx8HyS=?#V}oD&J{-8OFD_r3kiqNHKL@{;UZgZ6UaWP#zV`c*i`CKP z=%3JRulsv)>;D53)WKHRU{_8qdp=-P@Xal>d)Gc-F3=wO0*vW@ zZh99SQPa~)F{r@z0PPx}4W=M;%gJlc#hD&B(eLKBl0g(ENMow!1; z;p52&G^VuS5$A&Zm6g|pFL(c5=p7vMr^cY`4SQYwYkU)Yxak341wTLb2HoMUFMhda z*hPzBTI>~G`~_QHQC}D6|L99tK3L$sJ9hi`g-c`wy9^t)C*$GkesA!VUeNUFIxpN0 zC$t3JC%Ub;GNU2I)&TDuwZod9k6~H6U0R8vKH5B!HoT&{R2Z@~N2xliIB+|V+?}2? zHcm0`m4ktJM%4PDNAE`32VhCI;Uhr5J}e{1?%`=LrZZ@FyrJ(G8nlm_-uRy020-_X z>BVt-+3Q>uI`l%Iw*sD%j@R}t#l;WDutRaYFHRLj(DV*~gMo#I^zeJVuB%)-!3eKi zTAvPtEdgEGNcYeOT|N)Vs^If0y!UVTBw+w-M07FeRZmyWhj|X^?$84b3(83fxF^Sj zb8mRfZ)*v5g|T}L=gI|Ar9GxYqo}yxc6(!}f4WDz7rn1u(R=pv6+P5nVejb`gLfAl z-)WmoY>jq?X5TRCH7LIJcbtW|13`}PkF{u{D=VMrS@AE2lYSQt13L*FN@a+B@JDW^ zciw{~gWJ%DXan>}4DRLHvF(=o+?{yrP`Q!y1-t?WNm)>O(Tj5d#06UoZRb~44(S`f zKf|%hNOeBEa1#h#6s7$gNp>}rto^&E_knv-P2bphG927og}QQS{#VQzjs}V z-Rd8fNE1c(~S_UFP8b&1N9dW@R$RvfSWiR5TvDlWz#J9 z6#H}$jSV{PF`SPVwCe)@yKu)GxrzxP@*q@!vU+ek)gp!w~`d{#gxA?kRuGWjy^-AT%s_|^@ zyX;b(08dq*Lm$(tj2&3PSN&5txqm%$aru za39#aQvi9CDnR&|fF|HXG35}0ix)ZJRm#uPgK!$Ky?+Q z1pq;}?e%-~vgS)kVGTAKf$q8vs_1E>eUG3Q@0o16Ecnw@+8$ zmJ(d_7y)#RL4)M8nQ;}3z;$Bu0uZ=Fj9xusf#+lTK>-!O5o;V)lQLvj`?R`xcM5y% zYTd9C11GQ@jB14zIjsa0MvSWTT^Cohd+ow8##XTq6DK|Z?V@0mHuX>1|L?4a*Tb9F zAR+ooFyk;g&S(6BGg^H8ak`uBGmwI=d|?lAT-X4szuI+g)<7ZH1Nw||e{1Jhn07)r zB+vC;z{R8;H;zXOtPJd!e@_YW>w=G{ytrClr^DHz)&B~7I4&mJVUGG?>E#ZFkj-fT ztQ6V(N8}9rhjG6P|3Qns4*wM*Z}@9kw7OcRcg;(IBWcm%>YJ+ihP`G*YmuVmn4+bP zNYPSU(W+jwz7bP2)ITd)k11M=7F{&}tA&-7e-ys_ zONSz5z$EksyTQd?uoYv_B`@Dsh29us1^wZG5@cfcLp`a-a1Q53VoO8&1^Ukbnfk{) zX4=`=KR78Izdt-YI686c)U#83_}SUWb|7MoSvC7HE1Qp5;k^$x9B#$DZXtI5NaOuU z>tG+w-}Z5#ao7_0obMUa90g?KHDxNgOtxYqZp2lN)lmC#`md3pgv zfXiw%j+DEpvc@Z_7S5mo6hndA!fB6FiPPyz&#!UYq{{H2V?f3`-~ z_^Loa{);u%(hEd@ph6S;6(jq3Eg%~pu14WkT!L4Cjb0VHUi;SdUKQMN=LJ^|4hQGG z3&1RTr-G*ttS$&uL0(0V>!=T6!=rtagZelS1>Iu#!VP?)Tn9ZrAO{sfH-WKqNjL3G zWj+|kaMt!OSN>8s141Z7GxJg_sQH7oO^1cTu@Do(Jb?V8q#7{R^gs^1ujiDcTN#Y* zQ0gya#^#og+1Lj9`x;1KA2G3z&xtLj3p2Mv1cQcIGk$@t8{6~{!}6JzK{YC6?*703 z_y7Dq3jh6I{}nj}7>G8y;pyE?9;o1dVdC$Lw<)KIx(ufpBDAeZ{39Az5sR}@yGh`G1DT=)* zaq`u5k#hl6FOzRiC@!3IF5NCe4`=ScJ?{m=eR>eN9_kf8R6rhyV?{fokPTFS;a7L{ zg5vPTXta9e(<_0~G_<}y=#54$2kK3HylzStISy+)iXv|0KwBMJn**^1Jd&c z$iMA_h>4zpzjnEZa0pUBJRyRl(5_#!H1OS6#KG?jV1fG-Q^SA|SEZk}xU(e)fNY10 zm;Uv<-S>mz1-J=hA@rS`Ad)gZmxySy1;#{P$&8*n1wm8ivNZtJ{DIs%>0L~>@n2e7uC6$iCB8k9Rj{d)T7a;a#|yb z4}16pj)mTy&~?$10?Plw>HC)a2yq%cy1_0T4c7s~!8jG-ZG6iWo3Dfz(fn2Y?6Y zCq>-}=0an@(nxXLdXO=S-kX$yy6K@-9IXr3(VT%HMJ^7L;-C+Sg3Q zOi#-zlv>Sf}^9lX8RNPpHfS%l50b|E&+#fcaLfPH+ z?$%<;(*tp2{vv5&E;+@^pMzRpc%qZpN`1dWZ)tj1hHQKJVk7B}A8;P0e`k1xhz9?G z;&1W#AL>!;4qa~5fTzSoMK~t~e0sVlvB?SRTRZ^*!3ST+Y zm&0MV@GDGE?0%b_j}fTBWP2}=+@OuM&ja$%G%-XWhMD5TekD(=E2dQyDjUearF7m z#`}}4gQMfs>n`6Ug27gTlfo#4!_c{5j=}skMq98=I%c?QeDuKqew-3P3`>C>6o+iF z3t;Nv0kz6odbmD!V`KdVjDrF+B^8k7;^w4aWZ@S&2twyz^NHAmO@Kj?GUz!dmP_R# zj^xOtw6lN7sTYYb5u%w!wSUh>V4}R35pCPMa8G*U{=*?~t+G)=iUZ{1VEv3_+ri7(VPv>xFSITu<8Xy|l9*7bB`Lr5dno6}&uD*Fw-`IG; zm77Sm^v)*0UI1qTc6>kvUjv_a3lN0!=7n5F6-#%C)xt5Dl8}S_lcSdP{-m|voF^5l zwhM%7;UZux(9Ezm051h5g(;j|wg+F4*oN;Y7(~8@0(pTGD`KYqf^*;PbwH5Lxp7@w zCzuc9)v#cfACxcPJGR$45e@wNue&Yc>>oRvur@j&ft5Lm-?}D$B%ysM7fWS&V70Fy z2N-VoN>>%ttCXr0r7439`ZGl}fvh0Fv0$%m-7`c7*TXXuPmf06Xvemk40YXFa5%sL z6RKgW%x3^3J=mgPQor`VVptKQVH4~qVjmzZy>$B{+#)6)z)c!}>0ej4HFhVoGN`V; zZ1qs<6*KuYBtARiHGUHPe@@2Ne=;Ar-}xT?4|sL$c6*a+Zi_^BD37?Yoc`a!D)BM= zz?0*Dl-H|~_@Cu+HIM)CWBk9(idkU`+X5M2AcRN!$`W>JzGOljxP-~A zF1Wi@Ol>&}4%a1crL@YMOR#VV8ZECD z4n{6lvH616rKtZy&xf9l$CGYv2v^od;a3;5ZhTSy4QkM{7 z_vly2HTOEKx!{FE=|Xx3d4+!z8r?4a*Aa5hLr!h1uC7#9@#E=W*meKpR?Gipn#aG1 z(FAKPpH^==cW`fzv`5dnM)7GE|OiAy%O9VIlEB&s*S=YUB`@<2y zU?8&4z^#f%ez->b1Y-4Q^j| z-4TkNKZ_>8&u|Emaa57Z=1)xs;8b`Ft`Txt)Q|Hq%8%=1{!!ZBXk)|J*d=7t^+1{<5<2uVEBY5akNL%7QOeR$dlg0{Ve?cdtuP)3r{@H1q18CXXhBaXzm-kTle`2@u+KhSk^9%?|9kMm11JB(!`18f-(Q#i zd)D2@{&h7x0H7Y{{_l47j*p9^?8-m-opjuVAWG}Fv*EAk31f80Cd2Zn%o>l$CE5B-^Y`AGQ1PX8h{g6NSo!|>)qdY z|BEEcY>9UuI*_`Ofbt%Qf(xzqfX-iew$NvxmOs?&|ND>Z{eS<#>guEG{r`3Lzq|ss zUiJWcaDgDF2tetAT?=C#*ow&l7?%nehTsN=_yxf9@UQFr{Dtp-YzYVR{Fuqf``~~e zq5xmzHduH5uetd@Rv%p7|GxV9U$g9i{qAXH-3{3Whp0W|LNJ0LC=+aw6u8^DT)PZPNV>W0F=XPp0LdGU$BAQ0oZD&aNRrLy1m#Kdj@`cYVYC|KP#uqwk&j|D)^t z4`2KIw^yC){Kxo`MN~t*(+yAI3wA%JB^JY=pHbX@mBsK_@-msg{sfjHV&n0x%@aBl zz*j&OV0?U$XJ`BZ(V@D8=r(twXSaSodm;lw=AgBZhey0*49vll>&&)?M9+((wjkTw_+a5;A4w(NOyxKPp{b>}?GkZqw5mrjJ0- z)qGG=xj;$Ipom)?|1EE6{1ZuV0U_v`;}8=p-YhqR2Ol=*w~*z1nvI6^bIsUJE5^7o&Nw?eiLN_W|h}NRX?*S zFet194+GW+#&YoVCjPvs=82cyFrEvrFWC(LUFSg%O`6A&L3!yw$8Wp~(h09=_Cy5j zeN~6dziv2`Q47WLL5$jTT5f@H=HAi2DF)&z9iy&jjZqW;5fK&RyB+-6rwxaKa=v2_ zZ|rZr{c(H$aBpz9{~jlMcYbyRBwC!?}@rr?ga7s2YkRLS$w3-J488PBXbmM?lwJ|Wd#>nitQ~arW#Gghdvi<@8W1P)d zHwJWPvZG&rjSktr@az5d+riG>?&0grzaMVCdbRh{_H!_LS$hO0q3HB!EVKP|AZj%$ z?5*BmoJ8WGkrx>VRGP)j;te+q=FG~N&YHTE^c#S+w*<~jO*@#i2B6L5lG{g;4a_d% z)Q3B7UheH~(<$A2zq#{j^VzHI&i#jnt6g_8!EzpAk@@HH6zq5hr*WQ)K{P)>+h$`1 zy*22(1wnXc%Uc`fCR2wWidGO!}}d*71)0cFOwwUj#e*^TSnLF zz|%A>w*NDaC;J%(-+y{@l3m;!TEye+^WBaiM80?Gyvi=b>PE?=NLt_k0f3f-#7hQh zK`bOiED4i=0OwMC#7J_P6lI8r=W(8bZJ#C*{y`t!;)5I{pCHl7ot1GmO`I7igrsOq z0!nw!Sp&}s1Us1J5H}7IKVvU#4l;>;&0*FE?Kct)DUB=sM26CM65c={99zsqQ4QB5 z9zjY0K`^i4qhz9|7-72=^n&^tORJSoA1_lmzy2NO+=WVC`a|aKI9RC{}5zRFLHJ5C}ChGjFt1sJ!yTWd)+qG)1nA(}*cyM`kludTQWmb!SnP8}})Yp;#6^!Q{RrvHLV)q?(i_a^$1Njn-_4TB?yEt%EqxHN~a_ zbjmWW=Hxp^&C(}`R56~GW|1D3)e|2YuooAWIc#_bVCTxdsD?Tf6X_??u+K(QWUO;q zh|d3NfA71CzYLcTC1A> zVl|6>J)!Mk+1u9oJ@nF@O}((fk_oAY8V@U9SVj5`l$^VR%{-4`Ta^%^Meio~DfiLq zIYyhtnveNlx49NNy3GcZ@V}w;fmsNfO~7K?P!oJ!u!TNP z^-RJH;Bf&p#5ug9F3S2W-WgMg>)nnxes1&A2NmYhdXIb3ZmI%us31Cv9L=~7)$GLM zG25LGepYv{Dq7-P6iEWZU}vv|5P_I_DDFxaaz&Ep#Z(jfkXKa+h|eqZ-_PRgK0G{v zeG?HiG}kZ--lwb`+9<~xD#aI2hF9soh2C2(iWKU^?=!I$sAcY(s_=SVh5hoeR94nH zlRlfkI=CVTPQT`%36Sm9z`_;JlWAX4gZ&6l%#W;h0QrRoFtUz$RKnEtNPGFGV!}cI zD z(HI4xA0ICO{`j~l{Im2^GH&0)YiQ30?O+paZEiZY*Lj9>y% zf3)jESnvXneZ#XIQB2%2!S?8@qPz^%LHl-6KWB}XQd4wZaf&S*72qkqo=5i~rdCN@pd}_2Ioi?%2KP4y zO{p#usxm;+)@0Y|4Ic`88j70hzaKQcADdWGuh_8fn<>W4e%E#xL|pB=TCVeZ#IL zx4C7h!gVMPzysCY6#v0%E1F~ULD|l*{tgbob^u<0J6xa1yihNifV?1E3=yJ!DdFZt zIC%^b)Kc3GwT{9*2La7zV|H}HpDo2!P<`!(tRqlGl*7bp|1D`fErFmM&!LM_lQsf>5KsMhEI|;wBmO!QWgGu?ye{ zS3)%>WT4i1V)c?_dx{ibxDp@!+&dols6)TBjX6t^HfIKZ8QDf_vZ6W)6f$|j%|o!* zyHi|CHVI83V_M$rC!fd>*XMlfILmp$VgQ4Yj`8O%gx{N(elo3rz@z&FXg?WW5t0-M zuLx!!4DGH6M#ReI&Oyx|rpX~`o5I-VBbvtS9sOIK&oxik*6EJmh3yWfVox!a0uvWk z9N8z@2C$ubb2#+Ajfm%MQoXQ^LGgnYz|t{~u*u(h3U@Oo1*m1g3IAa~y<>Aj1h&A4 z8gf#VD8t}LSzK+oxLAcIR&GldKdVjvKNpgNX+RVz0#cAEM-WM<@Cre04@wX!0y2=C z1qItH%g~kLda#Ag^2N_@JhP9RpVbSU&n?d6M(1$o?Df7yEaePd^c-${7B7At(_LT_ zQ>$axd;<}-5|S6ZEF0;%zK(D?@YI(i%juZ!T> z+HD}1c$t}>Uqeb^xFgx{FvJ|s1r0w}9AQ>r48^|YBU*uk_|rgzof^p99*k)z1?3M9 zxVvY(F?ithc$O8blN&X_+}Tz3mys8sG^06Ar{g!C3R^Kd67k}|L@t~=Iwe}z@B^>G zRz*ex3?$_cs_{*8nQ=7x-X4vq?;F2{P9Q@MDK?)54+))9$~h0Ny$WKe){Dul^IT91 zCPG`US<18c@+dKjaV2^KCWn#^#KM##X%!?Xi6M58Y$9 zFJ4O-uqOY*534^o@xOlf;rna(@0-bgbg3aMhqsa)(nIGre33$;o=}V+UoXg=4i7IN ziCQ{-OlxgKhf{VLO~&er@s^$^Cn+n8>7rfSyBFUeYJN+<=bNi`xr2G*S% znVRN=c(%o}Y3lD7js;W7>TmrZh>=xuK~D^}w(}g4XN=xvS?N3EA^)~I-`P5H6mi)) zWW6$;C@{NV!~e6$)GczW7KkMObQb3y*{aC3vq#~-7}&LxM8MDy*(xwu7GDLu__7ys z>LAzE{Ps;_i$1C}&f+U?8xX^Z(I(Py9c~32}Y? zfA#ZUt~egv)A)=hOwfV&`+ZF$Y#%~=hEX<48GpcZr!Uy{xs)-&&dyfSairjuSor*s zK}|>d+0MAbK(XWK7(Vy=V_p#5Vt;fcTijvLAwN6VXoH#@GNqdW1rPra!%T#PL&h2UP}GZLzC4j*9N66~&yG__xyPXa7SL5% z@@z05I`@F(@QjsFhjrJ3m|z|Ca4~|E@8)3p{oQx3UhVhI8?thD^tOO2uGIkgI!VfB zSvKi(qrXN^*&QhAM^E`JF92s}cmLt{7NDmc0cr>G5OphBKi_-z?A3OtIk#A=hA`NF zyR-YF1>r#FeuyWNY{cEa;S7Iv2?ieke+20y*f@Er4EzMo%Ipi1Uo!CD?C%cV?Z5a7 z2ed6d4l8ikeEW9uAARCHXE^5Fj`5!-h@DYl;kXrxtfU$zeV&2q9w&H6H5T0h2O+jd zK<(uDIF(UOo>XldXxlZPim{dDk;d`X?r7&Ec#Z3drVUr28I3jLowL1X|GmAn@0m3E z;tEE#=ERZG<;-3WJs&$=YZgBym-Tbl!*p15CL2$GuSgg^V`~+l*ITzD&tjmtF)IeU zFf6WJ253RwADOW6c^!C94O?DW`26Q0w5;6>$PKsG{?*C8DQ4;EMh<}cg);3DBM_t1nalF$;+NOv2W*j zmUo`sJWv_@hS7YgBa{IFM+qWyv%7wEBKfSh7THsWTYJ0j4`007-8$TP{pM9?oaTv$ zM^iTx4+_3-aM2ySfBoHe3iNm1>Cz5e5ZrIZg_<)Aqpi~?-RS`9muNZvalAZis4a$K z^*%Sc1(7P4efvoS84-@M>|?)=U)EPvD%S5+atuO9MUDMzR$T$Js8&~bmX(awATn|@ zF=FLA$hUr6L3px;JKfh0FTY3qPr>fAWtGSW<@g}G#P=VF^}c%j{_~rY|I}Jn3Vuss zj^EAl_~a}`MRtT;zs%TOZz$)W1}3-Gl2Mp z0qpQlIwlVM2$LQAfkXxU#dTCf^jrYqI@K7N(5cdhn|zKEOIcDsEwUj|eeAK7nZ%c^ zL5X$*(tbK=j}bIl_sQLcjMf&amZIcM+1o}$WrjnZ5{&w2P)W^lYDhEvcUziJ4Glt| z04W*l=P_y^n@kUZEt`>Pf0baIv(}SNpb8Mx6E19(UuWaQvqMSgqWhC}FxG!BxHkzj zV?iinx*!8o0kH{zXWl0BM>;Ut()~G2o+U*}DzpUb0x+S&M!o`UqdDaU7unVDb^(~s zuY$MT+=MK-e)AhHA!3&YcR7fW;buV# zZ>~3oT6?J9%$yo6;a44x0Np5W1H1NMzgappTEefyy|l_(!mc~q0lrhGO&^W+&&^oY zx~X;qV?TR&N-ZPFPvDG{Karx2hka2%nf|*S4?ErmY`W~)*5R1AfRHo83?Sp?%M^;9#06J-R!P6q$+*5aL3em^4Pm$x3bCn zY`USvlKpJPzrDzya1zg8o3xaF{XnA9`?=k2^n*4wW}q9oM}-vK}EEz#e*)iSKp3UrQ{Yvj-KG|{pu|YGac7^?q zqmBw3w!;7#43b}q9ZfRtOX`y4?DMajzVtrm0uj?|ycX~G6#Yx*{xEYB{Aq)}(0>cU-ZkJE<3 z;x+AvPcmOm^mwD{WZ}eEQYkG^^GqEMN=AfB`5_xy`8eEeqk|36;m5*>PXmVV1r-A3 z7jW^>wb)eYiZl6_Nl^N86;avH`hDYBj~*c&B+2_-WB7pwNPh>OXU7ITWZA@3v6ZbyvEWfxU^pdw#$+n!`*<96Md z`HhlLs=p%b z)sv6IahhfbZvsC+Wi9h=H|fbkTaELKn-&jC&FcLHT=l-V{dXB3RRsaqXT=MJ0I1ae zJb3W^eOLeUVC~xf|C{Q6aIvdQzf}RA1NZU-qG%-fJxr%|2B8=sew4`M2d_#g1d}a{ z7z4{QAZJ^CGS7u`(QP&s`T=2tNt`$6;VrMZNGCH!ZRwOVG+1x9)Z+qaA%*WVpK3;+=1uGCE3H_ii>@ynBae znsTE-Mf2qlUOP;dh{R$DEN=!hS+$-5tLd`HvK=0two4??=grQULAdpD$g>q5%R^zI z?w2-tOKKMU-DbK7@OLtaBrRU>fG-tx_P@L6?;ag;S95_p5HZ>MXIz3{&M$P#*vu(&7 zF3{fafvcvn3e@XpZ0#>sc_>(^dK9p669du@-}P0z#3(CC|6^6VmM()W1&IdB7o*l7 zI1k~5yIzcj?V0q^b5nw}VY%L!4R?kOi0+c66ka*7T8{e2L&Dw_+&*Wpx6jg&iE#b? z-V`>ZOwjf=`8Q@-<^SS`xX7mHn$)Bsp=$kHjmcN7it5R$zABJ?(a?42YQv-!_o7ql z1+5<20R&~YmKwjUw*>SynI-J2^DHYqSCN+L4~eGT>?C8JE`BdhPg3q?6DlN&O+On; zYXFPMkEJcAiPO4etF5~8EF^9B&SL;+8ajF!y~(qaJU+uc`ro#1uUXg}&~6ORA{D@R zg=&C15RG``IEKj2EqyZHO_NVEoN8kU*&S>$W|uHyo~_3VK8f5E1(4hsWy-sP{svlt zzdPpQa}?O}t9GgQqcQzBASj6_Xomj;JW#pT2^3wdLQe@{cU9d=)}ja1@!<08D4SF_ zl(k61zq9`>lCrv;sHE+I#OvE2o9Cm1-ExiJxCZ*?=5#FK$iUq86dS-&{o~Hc!twfS zIx*MV9srb*4~F#NUE9FK%4xLV>GTzt+m?;`qsVdP14ih^S8UUW(-)1@d|-{#3XS~k z?W?!Rf96S1LTGFC!&<=(U6XF>HNo7>TR%dAUP!6iA;9i#Kw zB_>wxo8aKn(63$%*?zNWFlK;04YDT0Pylx;s?alr1GuRc&04GK zhTkTe^-(g5SLT$JPqO#$YALpaf zX03T$Dh_9}x4yfpzqzb^1#Sd}iV?RKBOBevn5wp+H*|zhc&W-8;bFLyJ0WIgbE6uT zQh|6)jDu51IYqCRAKH>r7H%FtSC>Y_{D!T3Id75WaP-SnO z#?zC8eArm=!|=3v6JNrH200KXLieX@c7i2L!c%{ntdTaI@bl8;ND3u`T&-~AG&ui zZ`z-W=;9Q!Rk9{&5lu4&C!c~K4xf4r!u13OM;5%c22j%0Y_|QMd3qjC0O!}-Bn$A?==e7N6>U*>Nr|MQ}WpBu&h z`u_V>JO9tx58pq0aP9y3jq^WWDeI3fJ%QStkT#X^taP4`6WXYv~8 zE)QD7jG!KVd((!c9|$e%GOoI%8E5hEi%J87V8)tjNx!Z9KS@sF(WRFC%PY$L&Emh@ z|9(pj=SjgKbDxCsIHTXv7VpN$pwS81^1{w}G^19`~Psm}z- z8osJY?U(X-Qri$J8CGwTqP`K|ycHDH!Zz~+ zqHQ-gvzbBg@stV0Ijh(XqTSGkoPEww-L9_p5RiYS7Bk;gSzA?FsAS>sLaZYWUIbj#E!(%p*!(3lw`GZeZ_zRu~ zvx#^RGY&b8-$m9EyNSBQo5)SPmwO*32n^QDl9ygg(pfVIP}A@X25I$hR>zn$2H3#j zO@0$^UvtaE`vsFy8sZ&~4M#COt4C-;(Q^K{-Gzlodw9^w1n1v}TpSzBkIo^p8hByV zlrJD{Qs{;L+uWg~PAf^00H+7qt2)YcTHD%PzwW6Df~|uJ&>Kh zr=>U`-Qxf*Xj(55_})Y;GXWLGPm}4JH5+UcqX8voMq59F6dD#6WXDe$jAYc&#@Md@Y>O?~Ml+mv1`KFV8a>>^&Z*m6sTXyxv zL?-6h=5-L(R~8%#$fk>o#MiFPIK1r_9tFzBiwwitu+2F902Us|el}ZV7@lTr2H|VY zormV28uhfr(d`a4^E|$MygS&PpB*K6bbBMZ-5n<-dRXw7Fa=hD0lw!6X+nMRbLKGU5`Ftdo+`w9`+>d@Byd|5;KqoZa;Zr`= zr#Iozv%JY>;b|w(tcO*)cSHjwUsqRW6;$Oin^jk46;x%ekEpKC>tK`5%G1rwx`G$I z{p1s#1*3SDGRZ9cnQJaVB~Vq^K(v=PSpcvl0VLhn_-TlyW>K|h>W5AQ%b&8`=Mftm z5!R=7Et;00PcQbM;Tt7wV5>G%%WxV4BS`W#fHo%+-L6{YRt1Lobj$3af+x#))qx1L zDDIS1Urbz3Dz{;E#>46rC*c(7#d!45n_t>#C91C1M_2SV_XSi8xs183spp_*S|%sC zKOBIsxvcF4!Bqh50BJRm1CHyo+F~YKE&9}|VDB9jBgWl8mj!C{={U?{oT}Z-{$d&* zI`tb-p3}|m^i8$>TCht_qF>pSR@1i@PSx(I{l!=dr?lBxINkgn*TUz9(+#;$Eo5%u zf$w@w8tV~utZZXt;&a}-_RgehUm>vH?#A_3gRLLct$=^QRWzYSw*&VB&z*n+ajpcS z8@LBBnUhyq>=ICY1z_`NMda_gpPIf;8}?2LL|5ku2)m@$LZP088eoGMSJqgpw@;mW ztG1=CTC*M8rVTcO+emMPYFB%ImMp3=mkF(E+p3!Dfy@|0M0YZ<)ItxmbA?xik!V%p$`xt@z8>--P-}6uk|!;)bbi>Lgh&f5 zq93|9$I+q-X>k*O_M!!r(F=D0n00uXX@(YALN8=Db1sxn_|SH7ujTT`TTMkpRv2U48siH`u#Eg3M$jfsY>3-{_ds4 z?tC)obgh(5B9t-77{x1t=?lH7VJl?qU_Blz!u5mIPy>zv>po3pXqD}A=WcC4$?C?s_P!7n4GcPwMvPAn5 z13iHojLQZfRHXH(@4FsY(maarhf|FIe+ZG?^ODHt(TyhojsuxN>gpM~;4a=|AWbw#`=oVvfxiE1-b{X_17YHr#R(+o$=p^l24;# zR)&TUPsepwM;Gh}Iz>T5Yi6!$a)DgM&u*H1JnDffD{~!AQoq@4+ zagXi3v%3YDpo1%*vTEno#(B;?h-Z*mB8K~Qy^mmZ{EtW9-@kA9|J}d;=+U+R-&gnl zt6l2Tn=2mAz)fUL=o}b=1~08T^AUA0G-^J_C)u}2kxkAks=b?ynQ*w3W%)RrO2jH^ zzL>-(7}3P%yjVAAwhe+D%$!Y#6B}#Pch>!qM%nuDjB*54EgjC(f#4C+ZhZ z7~)@hK%A`_|63;B2-MY!6#W{iqtZ|YYwS$Z5-u-D(0IH)+uM5;6{p#JGUl0#j4B|h zkikS#Mpwn=^s?Us{t!Db+Rw4-&M^bQNwiO5c8I>ONWCr*FG!<>yMF!P%Bo~0>c7uJZo)9jA`w8y5 z9ukok&R}~*j#c9fv}V=CxckQ3Eym&EQY45<#yit-^2wREo{0w38LIq9cu>tSeP_Wf z1=H!t_NN)d$YHbOzzS6T=`;}uhLiDr2F8YxyvPYF35@pnV;&zJB{}+hVA5=tb@jYe zVFdDVZzj`^Ze{s2$=}A~bY4`!vZ^jXSd%yh^R;)=ae8)^j6DQaK^S78YhWx&CJsS( zrorE}APv57h_?_epw9!&y<&6Pzhv+;=)Mi76sMYHG%O6Lp(O;`+S($)6PublF zLpb)Mz`oGv)DwJ74P-gPu!4MrN(Mb}XzLv*OC=1A>)+9wq@+7}{>*Cg*FKvK{IfTj zB#@GKlI8zXl|ilaKh`zAX>_Pc{m=ciM{D2P`k%Fj_pkLo-&Ft8P-9b}DXM}D8C-&O zj5r_hlu_pwPzBLChRBTBAQdaA(|A?I$x^mb)mNHMH)4YI<~W`K*9r+65Sy)Rn`!mk zj`A58Whc}0XALP}h0^%H)|ga^QWB@&OKFS$HaHuv)Ad=a;MX1*+NQ(ZYzp~FqQkje zxNf!fkT7jObBl$*xgXD*C4-i%8N8)Mhj0Ln^K9m;h-cVc5O%oR1SCG~g21Ali&2FG zk7DzgxQq;?>;px;zzy$3T$GQ4gWBjQ)Vp3C)6U=0G8)B`36N2NGlZ%p$hf3tLJ!}Xf7%zH z_s<#xGJQyN@F{=iVgxH|&U^wt>I=SRMv0>8r5qSTU;ClvWAdx~4pIKaqh`!mk~LDgyKKVINV7#g(KiJF4&3*+}O+lzaXNkR4;+~52MAMDLqDBvTG># zUw};xl5q%(&=g1o;f)$-w{ArT-XrZao{lHUE?0W+VX7mzb~_u*{GdPU#ma7tsOxCvo3l$XiT! zkl2>XPv+x?5wlM|fJ8ITSBtcaGUP>C^zb$}Uk7}`g|Tik*-nZXp!`2djv;R!jt`g* z?FeOyMC$*6y8WXtRApQ&(bVT9_At$WdS9vaz1@*FA@|T=Eg(MN3s6H_!7Y)oC*}Z6 z-j^qA-r|xoJI=sa9nP~wvwMOAMWf5nB++aPr51T~YQKpI77T1=(zLVuggq$; zPlVv=yO<$>ikBb{l(0?>G`T6LCi&Qbed;Nx=;RLOc^J_fKR5jv!P6&D8oXGh7#xuG zF`lDICGhU&@<50fR}eqAxh0@{!4Q~aMQKg*F~mJ)j2(_4t5;4}cw*oxOhHr^m!6&y z1sL7$;xoF^5OS_VqyrNx*5*f7x4`(ymKhDq1AyvQmJ3^(8@VEkfA=!(&qPzbV1$CG(+`UZ~7>4_}- zr6GF{%bk^HKvR`3B8gXGzW^~n!y|bo=)BtrS*rxvE8XCs?`0pdNE^2f%XMOR&r-hq zV|!prYW_>^W(jxDazDEw>b3WtH=a@WMat^i_!Xx1YhDFE`*QC&wAVx%uQzE$<#f#7 z{Re?3bK%xsV@XxjMT1rDbJdT;#lfLUat^)qBXtC&Xlp{Hz9}l>8DCe%Nc05Os}pgM zVFL5hm+xX}+MC&zInwslttfCK8OeupRP?fXH!5EpB*|7*0#))s;CUmSOtR4sUn4-0 z*$1P6a``7ck5Sft$`?G#JAD6tJRN;bia$PxM@~J+)`x*o`HRCqTe%_@@|pB`QjFMH8Bfc-W2o70 z0&Z9_m*gXglp!JWigYi`*#zzLkAw7VJ}KjAlFf?=daC}|6h1zj_*v@JHcoIVV8*Uu zxz^hL3r%<0jAIv35HC{4-RUllefx&_u8lG9`R6X?TI$U7{28ng*#RG?NHkBoOa?>S>)=jU{enQNH%B=XiU zIP5UUkX<7GI+gEsJ^m=1pg%7&bNbegi` z3XQ`ek({LHX?q*job^DMrjl_5?OQAn;q7iDts^3S$Hc&tN{nJF1Mca7i>L=|Oqjz6 ztO{6|E##8AmV74U6%?XLZs zu>buj2fZ?*OvX2+&CY{r{?FL|s?Gl&JbZ9%|NG7Pzjvt(i&zX%aW7|^l5xiT-bmIg z?x}3c_f&I`5%YA$uHJ=VI@<&Y6Uk@TYK0_pMy~oK0vp?-Ec@8+2U$4!MBDhR4GU>DE?y=O?+jgUNVOi)A;#G0ohC9O znry~^s%CZvzLtXSb#FJGXc&g_MCw6^enfqfcT=&7mI@bz1+mTrhul{k+NzTO64yX} zk9!8!CotO>Q;HM!BrOY_$CFe&GaVA0p42{ZD{{2_eH_{r8&#}jQt-Yg8cn$@S`-5s zzn$~n3eM5u3RrpmuRZ$V`$vxd&)T*B&o?~(tK+ad$Lmc|;lIOKcM;lxt~d% z6H>g$D8jtsXf)4rJbKXg-AO8fuBXK`o_36i-5SLgG&3PTj#hiCU|3>)-iX$EtChe! zTC?CJLxj5#yIOCeE)Rm;HJxyr5}4^jc2j0NbZwpe^EfisfPMR~|DrP^*CJ(t$J#L3 zc%oJ(9kv}*mB?qgTGMnkO(Pm~n*}J~kDaw)GGn@R|bxk(`m6ZAKt3hXI1A#4vtIY=En z2z!sO2cVd-=c*?|hkmKSyZi4;O~ettl@y}CD3%>YSq?<@HK)Of8h^zQBTigR^KIGA z7lnikLUfa4vTF|^b0}Z9r}255qDx)}w$0kZ-s&C3CDdE^;YAXEthcL#TEwPuM$IT) zuDWV)Rm9!ksZk_OrDKthE211#+P|w{r?=Cwh&<(qe+o#)W&i2TKm9zvc_-4%Ve}e) zlOq%tCLFJV#42sD_!TArBSzWT8LtLIAO;{f(pJ_4xi~HQ{}R`vPHSTXw)zbQgvZic zmL#9b4SqBFRMPot_Mg1b2_Zq=>J~7gFbMOn(Sh+hcQ0o{Ci^gMDhRjve_)FqA1BC} zk=X#v&!?k!GMZ1&Ae~=i!Ek(>;BLmRv&9TeiC`rr=kXK3yZuy0_9V+y>z&1)(zCgs z!}q|6*IUB=+;$cPCw{%N+X-WF$HchX4y(^Y#Q{WI;Oou(bvL(I%O)mt4Nj6X!Ll9PCJi6RB;a9jFUvgMk4j6pQ#=SGT2a-yx8N0P5@)6>Z- zR=J|ppN}bkKf~)H4K>etq#`sEvL@LDhz8BXDR(E4-P3^9dQ`x&_G)N`9-H^aD>kUbSPbJz=tI)!=gA{T*`YIT0(_;HS z^LWxodW#IE=xXpA;>+SW zjwg^mX2sx)llizISKI}UY7Ti}wy1g&EK&V#$qoa$vT)4HtMNy!b>mqoq)~CyiE*wY z-dC5h&}!D%fMq&M_Vt*hT#`R5qf`U@pTi{WorU8EYdpuZYGL_P{aGjP9cTI5_(Cz( ziq{gwLnZG^Ni0&2`*n18?aS;I)a=V`3Qmrf9rwlWAf+*>aklXmZwUWwcMpH;v2NU` zQxlts{e=;ArkT*Fkk*>_1;-McKycvuXU-hil(E`G3}acyw+5|LyEQ z+@-FG=h`e%Y}pW6$nLa38|f2w)f7W81KZ&HwSAVBWlhDwX*|v@(2MX*o_)Gp*fNG^ zkVEaEKe+5`kog#Y+t1@^!MX3=Q2_?>(-VJ-!5m}Iq9Y$yh~=$~Z|Ou_!F0D(s~|aQ zE6*0@z}`^24wYjl-kMo%8TYnwuPhkVx1Rd10MHlK>TDT7$#zP1~yIswy2+nq(RwO&ky2bquw@y{zcmRi_OwEUgbc zPvF7m%gb{hkp5R`sY4nD0v`)2XHk=QZ^8cNE&*nkv-wQZAiwIm)Ncm_cD9a;#_Z6& zucfnZoS4EIS00#ndHxg7KlPdj_)I_v-!Lj#!=X0H`{lD}>d)sP>W5O%++FM8Ria{V zL>1aPZ0b?$3x)Y%oAfEdy4AZ|^gwFTY6#1DML6c}h@No!5Ht1@B|2`1KcbHD2i2TX zQQN2@5TaFH5unos6^x>1R}4hQGX^_O$FDPptngDhWmHf7K&+yn(te_g=mmbX64e~g zc?yRg6S@SWwkOmTi5z5mlvWD{I6R^O;sc0ocTd^&A_FD-NsMQ`rZ@Yj$DW$%enfZr zEaU4Sn0MDhokp_|Z?8btFNW#Ww|reX4KF=xCNKDBo_4;9>iJkgMvdUVMfhwI7!K|HIi>N6&|C$KFaBnBTf zJ^%UM4u(z(%rnj0urw0YK~b zgFX6SSiOEgjh&&7#N z+(n}63j}EdTbxh79x?oGW{R2>1u57QF~|k$Pu-EtiXcDbrJ9k>gl3dRcW4JeHXxpy zsF=Ey5oD!$>h@y%ROqR=iVC$=w4*6xsaa}AvL8WJ&PaOVwgM^%yK_;dE|2PBL-Usb zOWo)M+pf1%ap-sxX6Kxrz4~eM9|JbxW7hPP=W&vu5lKb~G>XWRj5Q~ZJgoguGK%Mg z%#|@t3Wm}p&leSlr5GQB6a*D`(j+?}QgCqIJQZ!O)Bb;V6@_&j^^&4<6_#{rJBKcIrCoYgrrE)-_q#4At-A`ar#puetN8&=5|= zfV_OBc1ZREM#Ye z#6(;1$ckYcQDDNKoz{IWaYj6cpkIt}>S>Y2_vY3F0!h_JuIpT)v{FHwTtAbXc z$U`6eokt?0kcWPU#lR~_K=hUo@QtRX)Q47193^D zc}D`~EMiOt2nJ_Y?d5&CVx+X+A=_(CY=zdp|OE9El z)1NW=4seJfM994(EcOnNhqZqxL&Zbt(>xuEgg`1X?P*%>GZqamYj5BV)y10>t`n-b z;1-X6u(~Kx(ENiU3_Fkfi+9%?63gaEbhBhD#9fAOMi9ImsVh-I++)VvW{jtmc5vcg zHtVj)oc9*z5k>^lFM}APA_s#a!>cTZIR)Pw9@BV~%rGnv%m|L6h|ZFDT8iv?Ni+vD zy~!np2SMY!-iqmPQais6A%&lADjAzaQCFoio5vjl?5m`ifyGaGQkVU#hMT&x^Zare zpQWP+ZF?@V{9_RbCvtq28YjoVnGT=-V|VlQ&eq|Jx0|oGf7*Ndw*jalRl07f_=VqA zzM24p1)>_FVy-Thp{u?7Y7q>t|NDCO|At9);|P$|@t?ncbpHp({{Q>6hu8N1-_-s; zWc}{OajY}HHC_jQk%p(-Lho=^<}F^6ycW465r|>4VOhNxXuyFopG;Wqrkx9|bax|@ zpgO;NHa#t6JV+XJf=|g$aXz)&?*|`Kc1;;a3NwDt!b@oZ#{SWGo`5b6FiMM4d_iUX zTXEK8sy%18h9+YfgEjyzYv`t`=F3fhGDKPWOen}-WJ(nDtImzrvW4yrzx77&Rp~0b zhj!KZr6)~VsG5!mp&Q)#q`Ra&MmxRYWH!zJ_bNT+FT+BFwkcHGp)6xsHMC_>qK3HK zovDmqmcxt3%N?}>xZFV-wl~XKZv}9fORj(}ebtvbehP1iYro9V*I4#kLE^d9O^BtB z*C!((l2F?OIql?s$J4RV9iS%v%i6;Sw)}tp{=;kk-)}4b`BnTCjmXeP*A=mzP}3A`P#cA);6Pm(cw3i^^&IE@X- zQF5bbIgHY#0X_QbAwlr(J3I91eD+Ltdfcmf`&cQgd;0`Ej+UT#lL{kk~M~R9o`kf z?|v3`F?=L z@!1e3<#qbD->?4fjhn*0p$f42{D1W52PgmM{pd0`2vQklp|1ac}#HPhi;m=Ji!E1^*=N7Rwsx*8scJYzWP3qzy} zQ-$5KR^tjwK*X6FO=&S8?Pv#I;gn1kQ+xbMk1|=O>Is&=wTF_Va~@#TP^msD8Af!> zdN*6X08bFId!`)S{)P0qb~B{=5ikbSAj;&6IA!7xXcPo&cv?T>X2^Xs7AUYE{xnbC zw@2}Gnqjh*V@xlMFY*|8FycsCsaW*Ob|cpFMp@?I>o)0_;W=Z%yES7XDg;k$f-)-n zHQ*djIzSxM&Y!4Ye(hUMl#e?JkX-1KQ0ek zV0gY*KH)$g%i*~Ac6Ew&eF0+%r4MYl!_g?QRU;?tT6F$2*2s&Zvej=Poh^TI4-{`E^OH1Gc^MI{Jt1GdT3~C->Eb1Kj%f|2VzY)~W%~|rx25ya^RA5Ca#3NQU4s5TT+90RT}%>w_J1!D{L8g=w0l z*Vs9a(;~yqi4b=~>KzGRdh;U5!QNKn(Ab{BllnNibt^hR5ELB_YlOR~M8x_Wv64F< zL4zT|5>azg%Lq$38GXSM!03vuZ}{3Xil>Y&lJuPK(Cu>(oI#vnY4OfG@*xcUhOF~4 z9gmaguq$;rH@r|Q5?0;27kyxZ5W!k5f>4w+(TVF z`-4wDjATW`0Ex1BiOzWdgqwLp*>UuN1MeiglinRb^zLYq&WinvTbDquyL;%JCudgU zq+L^S+S1~UUUA$B`0_=lz+M8r%4&uLk;Zh~Rl-#5orGjx%&eLM%m|8@UHnFe*+iY_lEb*%_{P$!HzI zzMvr}^ugL*O27o3CXQb8?2@5?-i;c7h;lW#l(AD}-*=)5MmLwUxF{$%NVh&xBLK$C zpik1#CF8ukgpm{<{K*_#+{;8dbAt2cWdwY=V60sN%#Ia`=PLG=-uisBcPT|_TI%+q zL6Y<(sOWBVT9&h-fA8LybvDUnJRI^Q>9Mou-XuNBVK6=yl&UxEoq4w#JjPdqM;cF?xBoilqY)5t`ku!?_=t-%pd9H3wk_R<8pVhrOcyLFz4@dHE zZe$4Ow6Gk;6SXpzfSEzy3U~7*L~FOlNF%lfW44=t@|i4jhOZ*CqEWbfQeV1rKNoE2 z9FuIbgL}-ja-`-MxZ$B8b*QB8k|uLfTL%+W z7!%Q0`16A!LypIi3-z_=13yXV1il!g<~fZ#SF;hj3W6CYL9DR(4n>##;Gh~ZuKv)H zlL=w9Q-ueG_xdFq+R4xk0|YB$+z)_Js5w?q?hE(Zf4Q3!A*9J9^ z^CD$~tr)_BMW^g$3a9xfF1S?8T0*KrFmowRvkMu0PR!Ub+Q6vHp3~@qmG$7morp={ zOm;a1pPXqCflRGBxWMk>=)*DF6Wp){$?t=LSU_EPG8Km>2=nY?4BP0lXFU?T39SEv{?MZ%ztqcS~9M68>i zp!$5m{Y%i|58d?`a*X(pTijxz8vNGybSCl7ml3~GKWAxs`dR-Zyo9@Wt37oo9p`(ElG6!d*a?YQXcmv#C z7ohe){Tc7Jz{_ov<>?9AFXQz1m^Bx-=BTrJCtB_5jV2XCeD>i8K+MnLX*xq45wLM~ zpF$5Swv=Pgb-;3hPDF&J%CZvtwICREROWH2<4e(I>^6vTrqJ90cXk1mXi^PVUa*5d z0^cXLySYPGw9kYAP{IOAJu#jqQf0vX-JDC;vAhLSF39ku`evX{KW%OAKL4YJoeo^M zzf9=qKxbC?X*aTB{k@V&Np9%J+GeIy)#yIF0CK~H!$7tOBgS?Me~MP|t5G|c7Ja4I zJ7N2OeuVlwNpAOWaqjYHocGw)p4@xz=>DVce@jux904d4nap{cQ~}8v>Dh#<&>!Aq zk-q<*CNUGF;7Rc%{-uMfzQ#!Ziy8q`zyGh@|6$dB|9|+ygYU23|G&2VS76nxSASv* z5Pa(|1MmEdABJ9{{hi~bi)0wwjnC5)Z1XH8m6?>Pvo&HStP~#?V+f&2vTo_mMhp0? z^ik&L9v6L{ki=M?=f(=eG&s0!hbyWmlU%r;%_f(u0tP@XtPzyS=1!TM^?XB(_~FX#%sJ2D z2BB9`3{F{-KNnGlNySHT{^z30A;Ud+9Oq*)-&}N$Ba9^sH&Qm-BNZ~c7Y!Ig7Olhz=y;9D$*i}$!W zJl?ZvN0{Ocbwwq8-i>pDZxnM}35sy@I3RtfBo?6S-q#4~IN^LDe!p9XKSxv{-gHxh zbo>TdovFxA-i)CN<)O%!edL6CKhS(%Rrjg zIZv604|@QDRAY*0e0$GOLqNzz&|A@ya5kA>e!^8vay06}UV3NdtfR4ztVg&I7^69! z%uZttNcYgwm~XQSGzOOuOeRoG&e)NiTU@avG0FT=+-ibJ6Ee9JkU6mgL9;jVOQ}W; z8m-(8S0&WtP%9Z+nU<5pL|G}mOXG9Dx>bWmHGY>v@0%cpXz8Z%F<}>DCUSl1KfQ@a z-NdyesA4LK{e?yuYT6XXFECzb&>BY=Ifl1Hz08}Jd%N2ZkMaHX+riG>?&0S9&7D`9 z&t7eJ?ms+S?TQWXG(L}0B*;#>H;C>(?5*Bm^g`H+Ozo7Xa2y{oK7uSFPfq5nZ`}Uy z8}Wc=2V4dF1eL&KxjCQfy!F6ah>uq%(=3x=IXh3XKTS5;R&~ zA#@7fU@;qOVuDNwM4amB@lVB4xE!4iG7yy-Se0^77XkoG%NOiuV&~P9$T|QHRBbdX zo1Q$o1O|!89ymlc=Pwihc|>raB39F)AfdSl@F$rkd{K}bfQ4mwIk^&=(B1?o&%8xJ z>f;u3H@}`^Xe>$*rL%KQnv2eOo2_hiIgI|=j}Clb)MW#`aWom|#0Y8`%}+n69PhLq zPNXRgn0Ow51Itu@*aelikVK$BYzz3aZ;r>?auHqu5*{CAUk`Y37gmTv)|ZpCD3}YJ z&Ay)4h4Cq13HoX=7KC#iGhvu50e@$0)&D4-B(LFdo?Q`>yua34HFGEF-BC*??4;DP zte0eL;Z*^5?1xP7>(@c!zM=ki3-8@#T8~9kz?J&nAHdnm*8e`b|L|J>`;GL!zE!tW zz=lJYS=3C2OnV9?ny~Bn0@Av5LGxCLyRJ5XsIYm1%CD2@+;WEYS9$?v5EbQBlL6)2 zRdjDw`ev3)`du~)qmOoloinzPUu7`}Dcq~ft}#d;krcZ+U$5HNS~XlLt+ohQru~l5upR)21Mv;l-H)%&z{2lp1hIp6h|7d}nm+7Rq$KQF!+;&i8G;bU?TDUlX zW(+Q^n?=E9eW!R2SsDdGU<{><%h5E7zf`lK^Xf8c9JiaY^0ilN5T2o(N%Si;cuE}% z-|kV6g6%cm;B{qwY%|W?yJ_-ihMA;!!aKA%xSS>Z=uc1YM5+va{OM`L1j|1?y(uAK z0}fq&svEnvUT}P*wl$irun7Q56LiRh-RQ9l{AX3%*&9%M1>Pg=Z`XJqdM={d zY=pgk!e}%}qvWz-U&L$@j=Y9-N4F1mg_6R_(CBGyBq<n>k|Xs@#)^Yh2ai%Ar!i>QATWG0Z;jxFf8IR_@3;@apnxUY6PP zC9T~=qr3(xM|4p(@&I{N%xjj4`Oth9mRcqJ*%dg*;@(1udk{*INU zU%8qWMrh!r;hPj2x-NO17BjGk%Ems#pZV$ur#TA{h)WtJ=@5tLLlIqK@$S7zU*lDw z?`8do%0_yZ*dkE=3+4y%Z`X5QoPAThS&3U5bnSgdJL#`~f`!Q}toR z(Bo~X2!mU~d!&z;M4f+i*#aOga7M^gL+0M?>cE80jBq%V`B%i_HN_L!=8WC?BN1B{ z^rC1ZJ>k>DD;6@eu7yJf`tk;WlqVFQu;_M5ewbP?vu0n(Dh4u)WwZWuzK zz{=f8<)wm(ZV0h=D&#aBp)XI4=4_#rG$zDg3uo^p!D}r~Ty7jJU(d~`>bmVh(%v%} z6NL8B^)uqb{eS=G|C;&P;aG^rV;->`9{$ZN>^+s^{SvE}IXlgyLoC<|FhLnTI=P>c zp6>A+pLNBo_qg|o?3i6$*jR)Iqh}`Kl4t3}3D7TrK4+_ztU?lArxUK~U7c<(ou0$g zBN+2lWDYhP4y{Y&{LW4$dL*uj3W_EL^5ueE`KqSv6K}-*4pUx5yU!%@VCN z4*ng{?1CtN8Iq;O)rN?}!;S!7+q8ICL(+CHH8lz~8q-8ne4dWEmfmd9?^Iq7L+GIq zB-eO=l7j7kG$}&J>IL8NEThKtRMlh1s;X%6yfcOksLX=EQhZ0Jx8^^+*91=n)XHvT zNCY%6$2-LMj))^QpGFXSL9~}EB<~eXL=5weQQHPk6ocm@G#Q;92Ph8MSh)Q2<)+Ki z&2MD7m^iqPLI|X|@l9J*W29w#1@pHBC+*v}SEAd|0OLFs)fB|NwRv2NLMF61JBuM2 z9tcO^DZm2+Ytfy($zmc_Xuzc>M@{`9{y-I#p z!;U$J;~^PczQ(Q-5UIWZBstF|Yj!3+igi2iXqkCRY1tY<^>s5lX^PmN1BM3bvDmag zQ@7JG2wA8vd98pIeWiHQ)i>~1;8k}-iqL92Rp%U+eno!-urm@Isy;nJw7vu#W;0o< z>P0TP4H!X`&w6=`M$tQfW<9Z0(?VuRx-$z`5`(3Gv@~YRzRu0y`Foi z7EtnBob?i%%Lw)%v2PQfHIio+v|P4y20s*+k%j z5q}kbm5uN;x;+tp(Dz4e$Ag*wNHcLFl9T4D$!^lA-cp4K&a4mDVz`tvJL*lWub;=% ze5x5}Cd7JZf!!H#cDVeKj-!9x{L|Ba-bCdWCL6qYC(={a^$8_B9G5_)$8SztS=y7p zODahJvP1JfXIi2 zafC7U+Zn&;;17Cwnzg!Bvs?jvnp_a*VWd%tl#^@MsU>{$hm3!78m>3$;pJ< zmcT27#5~V3Tt4ZEK#If-KrjeX9WG*HbD$B1b&)ur*sy;&lNpvIp72T3C&p2ohAlVe zWp;+P9=C4kjqGJK5M6AdMNJ5C#Kg@I+hJ@2x3fJVLpkDgb&|{RlCwD=nZwmj&$qdB zy;7G{vZP}!BMZb^@k(Rq`rZX?#0}+SIJ48BHTMNKS{vI$m)n9#!Iuki~u2@U7d+C!~Ney_ z#si3)=j{BMP2v$_Bd9cYSw>)nhUO)wRzI+B2oTLD20|enPqGU+H}LGW_k6D}^gB@| z%5hvayG$e^C*7LQ5QQhIUlt&d7|6@hcw&JLou`eN!E+UQAN}@3TmNeR6ESlIF z3Ze@Lp-KRi;*TSME+Tv?!IMDsBUvkS_6crW;JiMtzx07!$C9iQNiNTC|IB_;eP(+J zpA1l)xHKyK;wOI{KMo28?T?JWiv6x+h)}Qv&uL#NH~^uD-wncq@gMdnZZR>E7L7{{Wxtbm0TB>t!E! zLQ>`bU|4HQ#OBkBJf0cucOBYamok8sgOPK`ru_fD>Ym|7)Cs9TV1L zE)pMWnE~(9cTkhhez424OjL|vz;Pqt6gMSNK+q1aj8TG>VdkG0m zk-;rXMtQC=z=hXzIbdN?UF$;UQI5%g$=^6vTunqAnI?`QimuzmDWp?-mWbSDW7@yY z5KnH*)cQ4;xX7raFUJY_OjE{qAk(VCn9ni3U7?C?qxGV9UJ7KRLviSOZ35rq>AAEX z4QVSY*`1s~;1vD7j6i;i#FK?;o}35pxd)F3oh~ps6F|Se_w2v7xArSCAxIKy&BII- zl=UGuBK1YH+UN?eR{q@zgLGh-#lpVN^0Q(;lX|xP)0z0I6X>C9y~da?F?@+5zQz%! zcX)(}1%J>{i(-iPGWrc zU4^p@A7Qu|b0pCsqXL1?U4c84qGGTIC zjXM@8c(KtG0ReH+c#{5{i1ZZfEJE(VU*ibTnw`16cERXxzQ@CsIPzdeK(AFj-83k( zW*v>;Tqhk9Nyma=xI)Rt!htkZ60+Y<{8uB=<3bt0D&xPd-v43sffN6A_0j$7_^;nG z{%a_{XNxG5B93HTU^~N$s%>05TU7Igm*YnmO1VC7vI>VZ^x<)Ry>2Lf9(8|oD&5`4 z%ziq*^r#;MuqCaCk|+>YiSB2^Fi2hhs0BjMJZbPTW#ZX5vc?*rBnu8y1uR@N=E-C+ zb#ifvj#Z^RG7RZoKgC(oXgj+t*U_%={3Ferv$k3OSjhYUbwh;jXYkfVJ@K!}-3`&=1&#NfMn%a&wI_Zg-tt6sx zS4$T?&l;$pg&CU#H2~3Qd9-JZVhI#ja|gW=`?%Fj$gR{H^e`c8<@X<&zW)4oA~?5} z0cy_w)gRWJ^Z(KJKU|;x-}3x#7PmVPE53!au)OfjjUqPJ$RbXJ)L-1Vww5W;;o(IR ze~b=Kt>CG|rS))@C+BH4FJADx;}y|roe->@>fXC{w-vE%hx$F=YQ*aK(3g>0p)t-$ zZQxc_le14_+?bUe<^!TUdi052UtE9OUj9*di^HECEWcy**!9`*3!%auZ_oDB_P0gb znrXt()W%FU89^xu0O`*>bi|MF7ss*(nP|Fq;QYjZb-Wz^?9eRsTucZcdUu1^nsxL! zE>RPW?FBfd1SnR_4FO>+_;mu7|H=7Z$^}AG!gAKd*ci(&Bo%Od@pepX&9V(4Bv=u8nm7*7 zaJ`}j8WosL0tw;E75&x>8xy=vVl(wdZ0BWXD1K;%$~M_O8|C+mA_D__W3Z9rVenU| zI~|wCwiJrOVgUmDb1EJ0Oec)qp*=2Umb1I${fF3!6CO6RMtWsGykiedNtzi-dv?5~ zx6Y>tP20{{c&0A+EQz@x+p2Qge7QNXwHM9^5o<dp>Cp1GoxJeh{yuhxh6JJ2eSL-$c{jqWk&mWRY1e@csI$!W~w&RlOo=^>=&a+B0kK^OXJ zHjMQ#fMNy$V~xC^bYLjrs8ol@OD1~%%(ibl*_%!-ua1ogZy}W4gH6sTowNeeIH&72=9LcXuY9n6mD4p(idi-t7yFrrl%_8KTwhT! zst78O(5}8gauU(XaT#qfA_3iaop(E%sQ!&^&j1bagE3rH5aT~^ig2$^1cAn7tVU!B9lj}S2Zn2cB@J>o+&!^}7P@4#3LPgp<_<+IIy zpGAw-C;gk8O>%z8hu9};I$W*72`!W|%e`3Fg@|Z=S>twf_a5yf#Y)a|TH*ATWeTy^^8fmA#1Cp}*08 zW>m{28@qW`XI6TtpcVYLai?BhqF`wn{44ppAEHNad{0iZ34F&)Gg)rZrjs%{AVLTQ zdGI2#n8>oZ8I*GD zm;EnVZbupXh%Vww+!bu$f!hlkOA$S!;7~F95?F@wQGoS#5#cyeCLM4PfOL8`V~n)G z(M{q@h8kFyVLt*=2aL&43vh>9?OmX`92lx`>+yd>;-|PQV{Svvb9CTBh%_Gz|88%^ zigimAQ;Y|bA4ZZryQX!xxpz|_WC9K1rcDdTYE-~2hB4;!XNKhKxcIm;Ewe3n$IjUS zRu(Fq+W|!;9*+k+XHTCH7Z~$5)~a0TR_Jk)M6$*mjlpqvxIH~vZ)6E~&-(S(sA&V` zh(|*FHEsLK=ii6q^KR#FX&Du#@r)5+tRew&-;=w^7|a)rj}xH57&G2S+-#BS4GJFo z973F<=R%&0vXg20a}vFXk8%bFa@!-B6+PYOU=AJxoDy;WbH9o|7tu+Qoh4;{3A=#% zRA66X$?vGm&Y`mgXWW@yrL&Q}yxVyN_(iu7@<4ggY~xsdoTr%9??6_Yl2lCL7QK|o zeIq9QrAO%`EiXmf4@H_atz%EyLiza$dy^U%^Mb7`nDle;%FcCI)B0GviP0U%qX;)a z-`b#f;RXPwAMko?k0!yiC%QQYw5!AT4E_GN2fKq;$R{=LKv5K2 z6$m@&KRw{Dy@YM_CJA0bH>ac1Ea#C0u(o)0<=^{s@{AV3wAla&04D8VnoYbwvsoK> zvbGP@QO2Z*vlh^30|Yqau2VuVAyvbg4YXLum1x3a^ZNx95x3tIQ+Rv*?2kX$=9Hvm zxdJ#>uwzjVfvda`hV+z6Oy52D%fr8P`S$_4H=?A5B~mi6=96)5@)p8`7DjhTIXdN0!cJqx^O^9& zb{7*}9z2Y2f~7o`MQ3T5a!HQKJ9j|NV;lpdCMq_f_=MRhMhP%XJWnu|emcc7gvn7% z=mJ-g-;M0UhEHYa)0iE~6G*;x3Bj7!gA=LM1C{?=h>l=4oyF+g zf_vbMQ6&2`pbQJ$3_%;nT(GB^hEg$u7jEn&oMhGm%Dm zxstXRUduU;Z)5kkTCk<)LSaeG!ctNo_4)o%kb4TPCnya94sXOKk*WD5lR&IS4rTbz zKSj~Si$5=FF#y^CM?iqL7y@nOqwf2h6y?S5bbfLIG_5*-YpqD$Bk0`PbfDxBC(ujY zSYpz*QF|*1jRA!>7*#>fA$-;kMZ@(k-kE&=8OjNHASdJu^|_$C26iYM%t}8qIL*`P zN2P@jo0Q!jvJ1Swv%YdTT-f!AU4eSxL2cOcRIK(A)+*beFV%>w1W80_v78ZNe z_Fvl6W?|#6I{UAO_a8l6we7!tU|+B8zrL0ImtkP#8h>%$*Giu+3YA!X-^G*Kw(Sa7 zj_Ev=A=C4KncB^;RySPDbGK@2gXXWq!+(d%MTCjB$q8dU?g%V5T{aIDy@vgaYfR*{ zk2xA_%V#HaZY7Sv`u*jQ8|70kVZ7(ran!&W*o*-pB_a-hpA>f@1}9_ERLX=u9u#Es zQA7n%B0h%y;BY|5KZ4c}b|X#3z36Yrtc;3`OLs!xA`M0{^dvyBD-?zi0d+G}%`T=W z%W{O!#~U4&@O%@xbw;jqV$A4D#>D8rMBG?=AD;~Qn}lZI<~-Tavn`q+FUW_bStR5! zBU-DYT-cxhgnGuPf~u}oP^3cUs$jBg0`E3qC^~co@e#ZeRf9F2f{JICJHim5!sB9K z(X&)17=H01Gn_%4e}%nASiO-EtsrvN(+N9dlY-XD`d6|d3~v-#wwgA+HL7mnpMRqb z35g;b`aDYP_$u?GNjkD!Mhy1&K9%a^O}OKg$+*v*Rb%l}CatarfZ)WVz*T13*jHUe zg0@oRkDuL7!;w|tT#yC+tlrb>=8phonF{Bd2KxLQN>1Z+sB8lQ- z(D%gh^?Cdm-r!*`vYgD*h~k04oBYg+=Lz4OOn4;d zLPUBBu&q#SFT)B{4zQ{)A_g}`S%MEqYM=3SMrcMwz#WzOQam>LOO4Q=v`R$;b(h#H zf~wbrQVIymT@qfs+EyZx*Hxh84L{lN{GFRzKo$GU}7-c!;)iOn6SV zxEXC*Qe{gwG)(CXL)}V*_64b$7zGIUE1|^qL*L7$369=v(;mf;kh>7-G{1*oP2hDK zancmY7mC1j@r$@8_#ToDJs*wL{JhZgNN%y%3|Z{xt4`9_ohQT8W3kEdap$i+V_#s} zJ`-1=#m)+k#-!@R@I78f{LR>Ra(Lgmjr`%&#_(0E6n=8&C29uc2_>Gk8=g@d-qlVj zV=V31zt#PM=h6}pSk5VABh>P=`D~{CTugv9+986+2JI{un2p zptpcvi{*RALX@*%6Y&!7WeAM*w>#L)$`@dgZC=X!og84hAB3R} z>8Z{^jLJB%X?i#BMD{Rj}Yazd7_2n9OAnl(^-I7%sj$+0rR2NICAw!cVHd5+VQ( z#K5JqA$>KVE`JC0q~#FS7a*(8E-euWR8ft0hAN1r&F!T64&RFWsDdxJIeJuyM^0f! z2}m~dUl-8!5s{*?){b=9moG$tG|QC=;dT*}8pcqnno~=g+;CdI$;4Vy`+1=lNlll; z(mj9mb@3^#|0gZZXY&ajSQ~%D6=zBPok!eiOku@+3--V^Zy6RQrW$TR%~TVeUadyT znIJ3s6%Ps8aKLPl?Djiy4oF9M#GIT=WSVTAe5%xebJ+Rynr6B;Rw$8QHQ|)BFQc+i zV(CotEGwZK5?~keoUz%5d^V!gXf%s+)-`YB=IbG?b;X9)XEiGF%|M=fm#9@=R!vhf zJ>OzsqT2$z6tj)>E$yW(t>qMhI*>q0-2r-C6n!3*;Mmw{YLvq!Nb|WY*h(_UGKTzH zHh9NUjvLxWo~mpFf{bhwmK%Z22|0qQpuSwHp)l5zgo(ZsuVn~-!SlQc5%|RNVExi0 z1dR_p(_*qIsC9#6&JD>Dqnt`J7|NWo;i%}WSxZ?~1CrmEl zOHhNVEC|xYoQ5gk!Wu>WAY?raO2o~{gcB%=maM_fbYLR&GEH)@RJ!bY#w|1SNm}HX z`N$6KdLj9QLQS+a*Td5X=37t<$%%7B`Zjo5^(I(%rb+V+v6#j&?3p@y-jM1vlp#A& zpB@nI;vKKASN+W#x;~;+2qOy2Btgp{*dT10bfe0m)XKpjXA46j$eAN=&Xg&g7>i*Z z`UDPU`r_-gyEmkwm5~`lKSav5L-%PB4$H)dlLisHxX7aj>IFaF`gG!aDN;!9t`vdx zXEfxvFnH&2*L{bLTsWT?MoKO8bpeU$M3~VZ)}HSQ#+I&^0F%sxej_NZW7ncyPv#gG zb8mb$Z3tzNH%YNw$Dh$x)H;wz`t^}d4?OpM+Qz4J@-%964_foaqVb2FS+g!hdPXjB z4(-*zKyczymaiKTGklt>7G0`rx$ds3c;0DqF>G)tRO8pF>ea1Emgx-e!O}rRbH6!j z0DxI(0bj=O6FA;|l$#ht*Q+vvsMd5rV~4)wfK+mOYe${3MK1LFp9)Rj&a;us-J!`> zzIy*Zd*9yHHkRc5Kc7O$KHF`li38-eYZ^KPNVjJa$Qwxa?90m-VauS2vAmKT!ld)v z-&0lRevxDYNly>`>`t(BROeo&PF;Sr$5?T$d2b+#+M>9pmsYINab<|nzo6Rz+B`w9 zHw~Vqix*fr!Bu^b94575J!XRK(SOsTs6qKaA~( zIp<_Dnc;Rk9)ov9_wO#k=8+u5;EoEQ+I~595+>J#99YXUuv7n(VHVn*NJC|29FGHV z&_IZx)^9#M7%$Z=QkwsynOC|x8M-H!G9Wu!`M}*EN`ES6`XkcH4dMw{!%h<@Zlpi< z1TVYkx2y6a2YjA1U{AfcCk%AH+e4KTmg5>0%8VWI#j3~&uVz6Q^=>H7^&c_Ab*0~^ zsZeha8ocP`@3yZ+-fK`KE=fL4H*w3aOO|KevhHmVCB;MRTUen!Z*1D)F|R~|$?Fiu zD#0~X-ZBsn1@M<}z=S|p2?g8HDZZlhPH$JU=1&xr1ura|2y z#s_-|Lp7*oj2^$!4Sj=3cf1-hHySxzU)^=0slRv>O#W|6+NzbhCEu5B!3OFlFCkCS z^*J;FUYg=WswXgOc{)NYy8-^aKH}HK+FSQd|S2eKx&Hn;+e`LM2ML2qVY^#7| z^{Of@6kNk8281pj8&zwNwzB|EA$o!wVMpqPQBEb#_kziTAnqhZDVS*TiQUNuY?WPg zmcq_#+N%G^al7`CWLOZ2*0C=>!`6yaM7VryZJeQT^Q{I|U_GS9^R8|!5tnfhl z{DxF>b-J(W&I=6ZpPK2&w$LDXW5(R>T$;IsqGl{kW!^j_i+a!wv8&q?LE%GcfpLei zpKL_3X`O-ZqCv~n{_LLk&JF5d1IPv!v~YyV#H4?~<&$t#9F!tY00I~IDmplV=e&^O zv|gE;$Es1GK?;QMV0+!>N{(Y35pvFcW6Y7TxuI3sC~4|GHFEH+@x^-c8^C-<=eEh5Zbk;br($Ju2etQMROCDZaOHaRz~CM)6I zMuT}j;NAM}J2mlYNdx5brQ5ppVrNT@!K{M0R!ad5_^0s1aZ7GS4?Y@?iV#b+q!;ZA z^7V0p9c;p;6Z*i7uj60puIFe{5RX@a_5}NQ0CZP=K3J_FPjx23tsZ;Miwej)6gr72 zloWq!0&|rxWR(g~sFy3eW!D#5TUF+_z_DwDqE+Gp){P~<{A%vI?m$+RsWv~p^p)e7 z%9W#K>^o09ypnDtM1S)TdC$jlMa_G@pFfcPmNkCPZT9A3F{B)xjBqs+m}mz1xN)sT zT#1MlL;E$-EPf!FPg(3-^faD4P_Hjb6D@gX=-(ozFh9j zGSi9_l3%Kw<-Ty6vYkXJ95C7_%j4%JZZ^2?99{|f*&94xq&JLv{Zm`PpSwO}RCeO^ z9Lk;$RKuiF??2QSdh0Zt!utjw(r_odY8ZRAbp5KX=Z7smKm}g z!=sh+RTUJez~vJ?IK=#l6lsW#1ET2}rB>twye}8jSF@7kN7hkr90VIjHh~{Gq(%j} zWXKsciKerBniWYmI{7Hw8$}Y36LLi?DA6h9dqkb(WPAnG3npV{IVg7+E1h8`)DvM9 zJ6Yy9_o+%$1>f@gRGntC_x!q9I-i?!Y|ez+<7h*jzZ3E~DY~GJ;5i+-cB1WH9TITj z8Z6i?(lT~Q>Tk;Ynxr`f%kf0yu`sN#?mT5K|C@I??4)yvh04@8h^I^jN`?|~U8BY$ zq;9}ZaV}5Ao8ggj2}g7IE6yqNH1Jt8r@$lkeDNiJ_pPfck6y7te3{>?vtoOb0_pjG zNQ?c5=0>FfqU5A>c;Ygem7*8Csyl)r6gh=#2o1=))KcX?_0Zy73+t=^kb9?aLqQ;XEI^qKp|&F3IH3 zF;)${-kV*Va#vM)80l^8wx|bf{h{f-`Zi|rW=nkCmT|k@tRZsO>wP*>KT!3>4+J$V zf;fMnw}rhm$JU=>gSEE5w}060y?Oh3_s#C!;qFfBp*whO9Mio6EpZ3CaAm_QZYCdW3Y9N{!}_;Of=mA9AW z7wjziXVRNYXQf>3xa2x=HMN%o#u6Lpk~T=l{yH z$4{O7e=DnZ`TxGr`7dX^GIg9N6+Une*$)j}1wJ^$_xuk>JkFCMo8^Oq4ryt<)A*`h z!Xu;qqsS%)Sitz)IJi`qMwz@sW8p%@|Kta*-WK@U_^lt=szxZL{gQ=zhx6BB$#whx z4JIPJ9tWtA|Ji1D_W$EM`Ttw)e?IHA9DvH9_~RlX2^ImNcI;0yyvVKC)g$=YL-}0p z%6muZp%rj+WbA2*Fggx}Gua|4P=y#ei!Z3kEFbQYc0as7dT}3q-Ip#n_m1$b(>nfb zP0J*T645YSx?kKcMh90Hr`h`}_W}&O=#4 zj~li0(}49eDQ~W!ehu5U zCXjD2j<2P9PV7B}M4a z@qD1Lp>WF)Pl~GotQGJ#@YiA2Xp&&NCZaacP}Vo!7oPanAVT)5I2~i!m1xWsLv-*e z;!@E6yZ@gr{@;DXeh^W+tgidZ{wf{Z?7&QH&~%xuW%{!(6=TZ9LB_bY zvQ*_U*MQp#cdjp?vlzp#&a*L$JWh3%H#nE=voE|6JjdYn&#&0Z0z`!&g!;2J=Tc!; z3VhA3dt`C@6`X3Y-{SLgVekrkPwy)T6oVL}Cj#H&YwPpqr*5>`Ss^M1V2nhIgw-Sm!s7-hoSsAkEiG9puj14mjK8yx{F+} zW273AJU^b31VUv@;t@!$1uh0%jt{KYB@)n;G4phI087OJj0QVkFnI7t_m zKYYc#hH>+Ht3hwVPbKW>Yyt~88kC>fu*kmLS+Z{a{>Kg*V6EANiTDshu;3-I^8UB> zbZyPC|2%vC^zq&O?;G9!oLSc{flzE)I{y_9F(kC2fc3{hM z@TrQqzrxmN^ZH9NX6;^pj(4T(B{b4#hNJ>w>6-u#J4!d1SwAr7Q`HyTx6(u#H+x0Awy=<6tyKItJ-%4`c z+>H9@bGWW!O8OR+b_(?i>z(tlzHapo&%Gt2lZwx+1o$BuQpp;i0w}~5uGM?L*>B=?7E0g zg_+bIL6kPC$KR4UbO&QdvCQf@HoT*pdC&tDoRLMS&VGO?Q$2g`YrFfxMz zFNpgG;(I@JxEKEKEXh;!V`uF^yq;_d?&yHKla0i5(5&a|c3IM85q*T%gGm9mtpWfZ z5EY1BKHtQ}hu);jw$&#RKf`CZHWJF<3=<~3fVx$H@_MX*03pZ4Au`dx*V4KpU%@Hi zf5QXT)SLcPgSx0rgowq1yhZl7&w+-E!)%|mA}Q2h^h03`{I$=wKC1DHKj5(gP6i!0 zJVw47@svhf{uCjx@b|iWV?H(M%P|h#097^O7gr?6O5lg_%&bCAmNx;<1%FFbh+mAH z_;X!6H=b#ZgKn6U?m$Q91hT;XGy_nMyg!JGvJsOb?$&;|>DFQNGGUyAuM(a~DZq!c zg1)@XT9l;+#w`MGyMwI*;>2JRd;yS61wEQBbRzOUjcLAstIq6o!cj15r zDA1*15-4aJKyjUvF#7PQDy3J>QD1@@G66+Kt*U9n7N^7aI8UGz zyAn94YpSk-eU%@q&sozijD7x-osIDrlCw9h&ircLI)4So*g-sgD{yw2R_lDIr+aZx zEL7$`gQLL_E`$-D8h#T2H1T-shQOLxt1y+HCqPoNp^u`#j>u-i*Z`Mc^#Fc?E;BGO z6%amULjo4SK4Y>u0b*gJnXHP^PbE-1$-9(Vus30qBHWQkj)2#!5=x-p*Exm57vv_C z1&t>06flm!Nz7K0GA+?rRO;W&*5yh-fU7~gfGI&PWE2$m3_pe_Y{HZ~pApUv90VLm zsErTkoFVByRAG=9rKMfxR?00a(LC$vRm2u-5}}}&!e}N#A*`Mz!n<&96yeQH(!$sJ7G=W|y_xG{(NdnMQjaa+x`HnlUEGR? zG2XXu!v5p&lmGhf_L4+g<#;9qyN%`1G^5$YRA%Q6)+(4#+5J`_;}snsw3D&x#34JB z*ehCskfo2&zC_e?u)zYc5pZT?TA)LK*k*xGOov|kHTPOgw;X)dr z@d5#yvGLy*vc-&4X9;6(f8N`Fx7$B>|91CXe|zio>;B8#pL=`OT6wKsWUXajykpD2 zxb*6xC|e)%(6Ni-jOhmmT)h}Gz$X}=%>Hc|wRf;wTe^DUT1<-(Xd99izU;tly@TeQ zHk@YRGThb^IN4CK1?7H;3p!rm+SvCr2dRObKJ;!~CWzFjX_$ovCEAM#iT8pJ7%a95 zjfL5g$f{je>nI5Do`>48LLkPKL9C2U>Q2iS}ZgQ>tiI zB-DZ_C0QVef!&&B6D9)_+jn8-; ztR&~tI1y60hWVW`w&$S4`;^73=$ShE=*q@e zR+GY_m~-)Cw^vkI-N^k*vC#4n zF`T!}%1wwC<6HSkHA2_D&esq{j04>n(}r=mp*Bu~vp2Otj_}49RL3gcnxKOk&K3wg zT0#ybOht^@Z5U_ zeWOguU3-M_smx*Ts@dmS1Vrq3?gfA?V3k$!3PkJM|>yZm!2vWVh7=k0ctp2#8aS9g40<>^Qd% z^ExP}x;C%>+hKCayjIGU7u7UmvK2|Ip8o(XWBuXzR`@(ZGY932iQ88^wdt_46iTV} z+)kDx@d$y3%(U?|^mDLims*(*mz} zq`9FS?blLCE_A}91T&sHz?7NKv!)mWtha9UqojNB;%jkQixWEYTnel$wJ_J&t2otjRnpn)P{lzR^b%^EXC_Hl<@)ys1# z3A+_5$)K-J1z0c@IeNJvj!)1Jdc#$=Kcfp2Av4S--KP=GG;u2pD zvXA6^O2fM>2tb!+kdCvQP1t8}+fTmF6W`EBxK%QI&T9ukY)?Q{~GExj4-V}X;yKlM0{39#Yz zApB0(y>dzeuBok{_@l4Ap7Q9xM9m~80$iRilXuDBYA{X=Bp{ z;s8PgF{H47x;Z_qWT9YkTdlX zhU!+BPQqS$+v9@TpbuMxpD6|aL(}fuvi~st--f^Kf-%6=@xQC9tIr(&@2Ah!?)<;M zo&R^p%X*#|VB5**^IYz^_F)3oL{`e@B#WHd_O(Ii{OpeZe>?u)EPlotDKtk6 z%pWJ%#0f3Mmq8yAmZjOOKnJqQcn&LWp;c9H+pMq=hMoC| z1c%idt?c({iKB2_=iV<%(&=FIR$8_p@Y?4+HN8o|A+cDbRLpZy|WsV(VL&hsjW^`F*qzm=%rt^dA4@1<3KH-KN+9Pa_|0 zYR*644o&$Fc|G; zjbvrapH)`H@1@17aq=lW9c#w3zJM=TSu7^t8#sya{$0a|%bwYwv)Ij7RpT^fdS+?W zJFZ9Gca_dZ*1A~K1qt~_eq)|UbNL@xUD(u3^}#WzaY=Hwi{+T4QzaKt&lJ!L<({T* zHE&T6;42r*0T%0a1)+vM7MFe<(|E0=k{V{1wUvePk7SaQ z(|2c^yLsE3NBXF@(D@|6J3HOK&;9SW9NiGFf&Nr?|9i|Xdyf6@$?Bc{_Z!~-^jQxh zI8|SEG#cT1i_8e9rvvyg9Fx4+9*XGXCaTY&N{~I&GGqHL6^|Y~Sc>+tG93vQK>jHP zp}-T2Rw5^-LPRYsMTdymTgWJ+2x6Gh5yW$kuh6TAQmfhleiTK*2t0VC!(zNdy7DGR z)#zu0Dnsv%Ia;69}R*`+>q)~kP= zp%bevov=~5iZ0`;sK_X0i&PP$)gHtXaHS)8au~&f0b*jDjjwzQRjkd`|1#F>a#W;j zo2rEtCFKB}8R0cfUlt{CjzqO_rwKaX2n2>$W4!qTjJgS{VB;4!RrCDdWPusX@|-R@ zCU^l5zdTPF#g-^yHWBkJh*xGF1gi}sV7ZA9T5el8Jp%?=Hjw{iw(-lKGmpXDe>X4x z+4zXJBL6*EdGg%#|9bNDF8|XvlK*N@F>4V#_1qB)=8d(Qup}?cDpR!=G-~Lii?GvG zx2$aCfMzBVR|m6I8@O5KJ53&~OkCZnO%>ku=C)oe(?uw=wI|614r@D`yfHsvM;?T} zm{A_T{~q<(zj|cIo2|d~w_d;A|7~|C+T4g%pTpvJGtXm$&#D!WqH?W^Lmz$f--xg_ zHksXFI01-vs)h|RiqDx;yZ`|zIbeh_%jg(i^Gj0jmHOOF`Qg>x~Gt!VekFx z*HtaDXSphCdByZPv&bbLrA=CJK1u8LzK0-g58CcDHQbFiy~S>LvxVoo8uZTd#ZtqS zfvQYkLbXMg8m^uBa^c(%ufUia&iL&CjB*%3ev8BH&TcX1X_7<0KA~_ppPVLx>>{CH zF8I7dHS8n66n_>W3`oh59n9~uDgN^?8iSWDz~U;8fu4iSWH#-nQ5s451{lNfL2jLFyErQ*HfW6%>esEuk=^n96g= zj3x_#cH%yEvf!O9$O;+xR;b7b0V@+OF7A3=R|d7qUTqVkQ54v`NnECwf!*J+Y7(4Z zwD|W8vny7W!tL2;{7@z;Nt?9uyG9+-9mcTX!?c*PYin;ZX2MQgvv>5uoaHu%8(qa% zO2&j+b}VHhuH0VohJDg0>pyU<-fb0c<8e}!T1u3fF3K--s~{aOlr{6stb~?e^70>v z0>{*h9&APr`2K*kdSn)kV)$QB32w*iPLh^ac=mS?M~&TL3DHm_u!w?CrV)MsEXMd; z^w4UO*sq2F!r#in4QbTnwz;lEvaom()d98!M5ty5pT7D0L>e4&iyv;+m6Wa_Hqmww z3&c*1btG4EJRI(y{sV9)6Ae68?2RQYg%{S(vk7s$Yq=vOGKQ)|eh2C5#CJ#Dt5&2Z z)06b(S~1rd*s8pGi{xPnBv^9hX)X$kx7pvff+?38XJM*H%%o4Zn!=y7zq_I74CP~z zQ|qK8J>tSSREnuj0ilJ;6CMl>c~`Yb;rL+EQ_lChg8vJ?DHe4h!*|VF0R3uy54iaY zz)ea13(L_rBtVOaoaZ8Zy+fz@DN}s~zV-!pF5WW_7k3h#8Haz{d)kLV(vvEL^xM4L z-pVz%2Iaj5+ZE82;ehw!AajOJ}?*ZWjt_K^k&A(zo*f)`g?nFVX8fE#Z&`p*~b`--w0sS9;XAxNaU zzBFhs9UN^l<85dJ)Z{qIEh}%ZJ*+si59&AHvhjsEcyDIoGM$bSo`NZ$W!w0l-Gk6L zYKlj&`yuW+BEnMU;?8}ude16%23wSU&`2L8QMc5M!sX7?8Ofi;pjS#BZ`I*leNNBI zQcZK3Eo4+M@bg8^h&lM{y!ZJpGMQIes;wNAd=rA}{rA+t?*U~xnM8y;9J3iZn~mc< zin(?g&4;A9E%Ws3EXj)~T z1I9TxgkK_CNaZfsRX));j~FL5q%=%y9f4aH2F2hjzjrSZy@u!z?Ml zSOIYX9P2!`dq%4pZu>)+jNb&bT9L3SrWz6ny4+FQPp_h3Qlw`S^75*^M_D{8C}K2I zM_L^1I@gigxQ*1a#hbaDo!{Pd^2TmBH*~=vX|q77gc!&^$eBlcl%lhw)J73BV&G#; zsCz?42$B~8%-%8dODxCI>a~cJi}ntYg$=h4b_))9Ke?ak#fhk^UBb;*SRsd&jPj|eosN;(TDD(* zZ)&iuFWE^y9J4onZ}s~hz}b7GHBV;WjSZ``{@>mKnK0E3-xS>fzEkute}{LUdnf0o zifo09vu%wzUlPQ76N~CELVEDY>c`$XyVZ5t6(-hOaekMy1>)2fE5Th9zV zV|QenibD|?AlENzGcJGq7#A%O@++(et#zUO(eP+!bOP9YBYv)PR{>|_NKQYQQaTPi zd))Os9*x$QBreb}8Dwy!BKlo}mBYy_tx+@pBE61QteM@#x(P3IU$|w9%O6cdzLWm$ zq`!Xw=`VKVzIi0R@W_Wn!SFX#GGh4FeNEvp_@8qOp~_G&yk?R?#bhrD(ORAg3M-6A9Br9ELpO7; zO@!?WuW`o;qZ7>#=brcO&<;u5Ue(rxNT6ON^re-;p2&+lhEAF_!=a0Hb8qZ2!%w?F z&_5eYVq4cbJS=MaI?s*fUt$Q@Z6z=Ucw|@C67FHOJySHRRW%50)|RDaw$RpTmW?ZR zRYTWWXu0EBOYyB#WC5#nrQN#9W{p*@D;n1@JI`-&p4aHCgos54JOS$VNLdzcjY(os5W=y(#?$h7Y+4@&;rcUu%!hkxtFBqT+d&v4BwL|1)2M~uSLMPn46R>dehRWS-r%@_qC zz|2m`Z&k=J{P(&QaUcf=6FpHTy`*6Mrm=6-#b=9l@tNqNzmv~YC!g7=xaOI-zdBtr z^LA-VI&{p`A3%8mvOe20n9H6kZ#4Cga1H7Wy`>-^D|634CcCC^BI}e3 z(PC#Fqqfb@pGL7H#)&0kNp9@KvyD#$N35pN>n0v`=8@h7#nII;NlvatNu_L4Y<*$w z?Tr(oRCMBuyjYZjdskC<1^KlAVoiC5Brtfq{l+;f`gCuDW>Y^+JtJ!!oHetB6zzZy zOcy^eGp}>05eC$8-SAn%8xDdDXV9i`9vN>SMuF=*{yd;5b@(6TUwCm{XpAcSx5v*O+xQ<( zS03Nte|$sy51jQ%gg0FTX2puDNqL??&V$H23OAy@?ZyCXK$E`^!h*8|jt0z*7@N|f zYhQ;Loe(7bUx~L%UVemPIoC{ypLR)2NEap56Q3ciRzbEDcB1dLTx(8SCGFH4;x+r6 zRIS-B5;3I zuHBl?coTl9@Ij2oxV|F8Ua23|hps!4T3=pg1A@Bq44XqYO>5`Ydi>YK{uK~O}41t5xrXmOu5a`+0! zA$pNU%Saz%1ddfd#e(8v2g`~7xo&6gGcxz_x1u|c355SC&TqYynj<}bAP>%Y)~n4R zYBswlr2B$-moo?5)n3e{fUY(osfAVe;#4%gCdYFTQYKr|mWNMyEf=RXQMa*5 zWL^KltK@J#N4C&1nZrXZyh;>fx%MhyojnxP4)r(ongK+_{hj?TKiHz|W0H@?*(F6> z2gHSaUa0Mcms;mVodn~qGVie=R$R5W5t`IoV@m^*p5L6~YqGUcg_w(@rAe}EIFx(k zH?vb-yIa&YtzX)i@MUQp*P#Y>%p2lLVVkFATl6w2EgO~+@uXof5K|po^x>e?S6P13 z;*v*xYOra1res3v)ZEKy@QFn@EpOUd-W=-~;sQqu^_Sa;BHA(d}$Tpl5B0t>!$&Y1 z_!7@F&5psOG`-m=8Sn>I-4;P&L3{q1TyaPL0{~)4keQJWR?&TNKOI`QP1pVJ#eGn! z-aJvbPTe?Fabn##Ve6#5ar%Zm@V2N+GvbX`0EWgJCQ4VnIX4Gf%D?soxwTDldmDwX zY3}=RE5wE4$n6kUjwiP>GT?S{E0l#-%Gg_; z3jrOQ;;9hlSB5RGJ(SO-Lhas>dT8Hi_6`)G0B$W;B7DIJ0j=}{pbDX3=2NW&GHW`u z;E1!VV{v(7tP#3%KUgC~5qd@`5~15dIOziUFC62+EWn*?DW;51x#T(XpF^!yz?ZD(AV#+W<61sm=n2M5BG(x%#MfrSj zXQv~NSdJc0)}Zr6gY4zl2=8tde!x`jowa7K}p=lN`kG{ajV{Pg z0;)p%geAmP)c%PV()o=`onDPH^{#RED9%A`Us%=zGgi|ry3WjBp(kL%Gi9Dz$J{gL z3A6CjxQFnI0t{CU%j2 zV&hg%?+ui*OnL#pGz~+*xeWLYG|H$u873n(9Q~KC_qYGr-#R$heRtT~-#b`x@c2F? zSD3|)eJcHLB&ztPsSEu{kp#lui8%9SbvLE zdz?FoUGYU)fON(y7UllmeOWQq=@4XxQjk=N(Aj{toe^sg_l*LG1*S)y%U^wpq?Yam zrU`lbHRtNOLmS@QoOS6oZW^i3preTQQNP~8plDz_M<4w+T27x!ozYL6NY^pLYaI0} zG;42mamwU3)hu2RT7h@zW7b4&pY%Xyc@FODu~Gt?aM)iM+e#CKgG34Ov5qJmkQPS` zVRhXZZ2x%nUl+N4ep_%|{P$zW{`>s-+Fkt5H?;qHW?i!d`%Jynx@AxP0|VMkv0u&J zwH!%S3brqbrX|!hU-V0}NDQQ5C|otxFSM?{mIDT#P=9)C;> z>|@GUo<;1wmvok*!}GL&>>SC2i57!o2w6A&a_|uFt1$hCLk+YnM`ZJmiOA6|dc^@p#FiLNL0EB{oZ(<6 zidkZYHU!EnAjT(EW1~5P`P*hACsoj*ZfF(-Cz+Y23R{xiD%tBPZgrXsVhdcD$IMYY z<<>vzc+TbTLPLXLpd^55!{RR{m)`Qw#4`R`lG zf7Yyf9Rb6FVvR6p5^p34)@IWJ0g;CSLD>MBvWL!3I;=lqd19P+7laKR~89yaW`ud0SE)v6qflQ>6uM`}{@ zmg=8|Ja|x>Ms^_1F!b~UVHlVxy}EdJ%9G>j{ko<{%_8D z12^zGH}arKiTk?Zwc;J=VSkB>zb02L*HvISGIZ~4+}lDnL|=joH#OJvg0@y>>-(bn zoO2o~bcxl>b6(IpVRo-?#Il#(h-Y|dSE`YLLGu?r0ay?*Ok6>gGZVaPy)UXLn7VX} z#z*}E?le!0t>fWdn0Y+~>MEQbH-hexBbJ$lIh(r6g)gysVw4jM&D=e-yS`d)wIz#{rY z=^v~mIcpP7vOM*k1dyDt*q{TrxmR+g^C62G)SuF%;p%h zm7-_e@b%T!nSYqc69~uI-E|RPi~{G|=hzo=aH(A!Jfs3A#dW&%@@Cszj~HvY5W<&iM!DJUkgqh;L$Zr*LZO)8M9ea~nb-eoMa36O7PEPG@J)MK)xTMUp2XeYC2%!`WaCjgr9}NFtgQNp$jq(Lp{X z-QyE-`!uRpj!u(73}2<_uQDrHm%yWJBB|ynIvavJ*#P={5l?1JCb;Tg3Avt9o)g~J zGvA!?6zu*5*E;g?np?CBfU9x(q?E_ZBz+A3Z3=W$yFBIv;_)-1C!JRlb@ z50aOll8#ni8srW(H>lb3v9Fy=)w{W3z_=Fki-!|xt#>$-*nRC#zix=fHRWhuhkw;G z3BAMxJJZp(@-%^>c5Io}?{fq%E;&$FcL!x7v6UvVP;{j;&LZleb` z9^qX(B5X|b@e%97MXZq1-mdC}ue>ui4!;r#CiTchhu}2C9yTiP@~Vu7TVr-Ws~&4n zb2mbGKTprnNjz4E#BJRk_50sAIg1BZaPea;iO0kaubcd{KK|=Dyfg2APo6xv!~gv1 z_dk8s{m`DT;?q1G06WSi5DxryJY(y$Ft9#zES%$_PVz?sS@=;tDd>dtuQW+^Ct>B% zX_@7r$TID#r6my4&yxug)IsWf-@}(&iH~0#lvnIR$v6jnfr9bndB(WN$27T&&eJ3Z zsN^dqzRxhl;tbl(jz+d`*)8+reeW=jCzQ6}SN6pT<2Fz$`H~Ld=p2k^z=$IpsqYJf z21n}4tfmxlBAhbW<6}}_d`y|g>~1i~#v+D{vPBoscM)s!uz;Hb6aG)O*ij$npoBhw ztHq2-0j%1OY0NGI1;mpuxg;H>0~(hB>u;WPqQfk@%<>O$j#rBE9Pc1yJUCB=520NT zpDXI_P;nL&~G9 z`{J|r>*kePQ}x&6D(cd(TC?nfgH#d}uElQG!#pW@RFby`;di1Niu|xSFT3MM##jv%~=R;#mCRr zo<9NI5a4;RY3c9$&)(LX-gf`hyRA37zwN*K>w$6g*7q6w#t~MkhX4g`;YEk1A5oFQ z2LTuk8?Q2BY<|-^v$n?t)vFJ`bpWHw6d4XG&%ZbE1Qb_GOVGgGzrEdmci4Zkzw`d} zZmZouY}DmHBFxzP4aj`W{r^{)0BFm9Yinzd@8rL4ApgPSqt1T>%dG$YV7I^hx4*r7 z*W3Ad*CP+U%+ujn^26Ia{m9M<6a&?UY5ct(D*k<$j=5xs;#Cs;_>Ure#Qt9|f8RWA z>{tXkn-6FCX{LI Fp>aPa=kZhvooZ}0u<*Zr5fKlk=RTMYyr*r-K0>~`ZaW31~( z@J8HZa^Mzv+$i}FcdUOaY6-PL0u%9X{hi&n?{>Gh4tIATk}L3P-T3|##|?_pUzCDd zDi=_v&@bB;NqL?PQ9**7hRsS3(7}_7f?|+|vjl{_^X!rx`>0zeP7%KjAS#yc}Wh;MI8i3zHNm8}xC2GHPrE7=Q-80nX`psg}9XD-;74=~W^B6sWi`esbglT`9$Cmb^c~&XJe|kTSXAsm+jD?)osZAXmSomu@V;3}>^iLi} ztH&LhXZ^F&WSm_d)_%LDe`|gN;_9S;TNL|x`{%6@SZEH#vp4aFq_w&nt>FLhKe$TJ zojMFD6{$(xsVG&Q@fpsF-4ymX;>M~YwOVlTQV~^`6T~a46FUKTe0zr8LNzO2s?ocx zVKzG*CvEZIo9u~uwVPrTj|&-kp^X&v)*h;^=#9=ZXri={V)LFB=XE1B06hYDzS2kH z=fBuEQD2>L61|bN-V;v*UBxcA*YCk)I>j5d48OqGGvC&bjq%I?UMFmljP1+4@yFoU zzB_--pkQoB&5`RpY|T!F4^`C<>*RKNgCE?J>FDkq;R82Gn_ZI?{z7?>&;Zu*13w~N za6Da(67+sR-J(NyIXcVAZuFlo?l0pfoUaB05%psph1mjiLj7r(|0r(K-7de}YeMrT zVn(UoU96@>*=U>(nV7Y$7w+~ZY56v0cl{#zu06#+nhI#x$_sXe^r@+CGxzYy&qt7I zJYXaCmxJDBi|R5Fbe!r)GJddxj^@@oF5{w@%3dUdnG`wlCW3XDV+76?4;}?>Qn&`T zKYgP6PxN3Q|J*=bo%X7Y!krNFX&tKKCUzEzChUUrjvdgzxYzGrCh-R<34a}sGdDR@tIXqUy$~@=o`h{EK05~EbR;%k zJ9@auZ(fv7V^S_hYnz&pRTs#(v><6kw%q!nB?%v|+c^b4A8BNcR=|a}q>!`a$auA? zz4{^(QLKCVX^RixjxI+eI!IZr(y!X%!L#)Eav5Il;z+M$Hfh#6vt3Bwgd~l&3%Y->XxEWW^-kh?)XL@CiwC^ zW$oh&+FHyAPQl-JhWJIn|7wNSQd`5EmDf>#IqDDMqWsY}Dx171Z9Sj6>=gYG?93N8 z5zFnzjQRcl-X=MC6Yb=PbPMb|w$xGcn24*-|0k)wO4L_@7_@{I8$&K!8{M zmyxT4+nS^oh*$ogALF0k?4#`n*KfpT7~Yy7Cx*gHhP#%xShvMCuuTyDNWH~yaOJm?Q5}>QMzeL2i5*%OP=!S6f*ge!- zvU3JLA7nY_ql7L`*54HIKE*5#sH2^z$Nr8B^jFMA(S4+o4&rgNy%i0yqmi8S;y%0g z&Db#(7s4(j-*Hni(#hE^IERbutW3x0Kj}TAzMbXCmi%DrZEsn$gQ}uaUXsMI;E`C+ zd}t?-p{m7QR<6#JQOObn>&Al{?BGCw9ll6iYpV#fLUmv%u>C)=v1ZRM!Qg%lh(eQs zOlTdoPAjIi+uHv5)i{gG_LSBMj|NbF?Y`|}OLo38opkbQoSvPRvajqe|0<_+F`|~P zRqL^`hT!_IYPtKVCJ|OFS4}%S0yp^dpZ*HKrCv__Z*j4W>l>6zd;8c<-o}cmEXdgK z2DyHl{%7|OMAOXjkBM+)+>A0ZPH#aRuJfVB_#Fo3ALr-u-t| z^8W>Rl@^cSbVX-SBZdM)5yh^V&q1^X|Nr@#jsLv1^88N!_YLI#@O&?j{V7b%j{I|U z1)y6nAHD+Tb49Pm_}^rhT^a#_^oV_aiGRq0jehzuf2EDwua0oKV3pX2hFK6`;)IObBtxuMh$tllf)h2)B2K2QZ@^u(o@3 zLq-S*QtV|H0X)S}-ku5=HH#=|)L6^XvWVi-A{)=jBx1)zhX`U$tg{&e9F+-^yV>Cb zSCbe6BL!9HTt<;xWj1|yk)bjTkweINLR07Q%sPCgG_6rh_dB5;n4)!qMY3p3b^@F_ zIt!e*I{!x}12(dc5bTR>atNTJz^RLCvc?Mjzp}dG;Qu^bdwj?LzdHXn=RBb5F$&$u zcYh}NuZTyDFZUYMfm@s;2O$WYW!}k54<#I;*CIIlKV9!YCma z6@;Kz6Hnw;LZT@oWVFoLdMotk&BBb3ZxZzGiI1E=avroT^M4f@PBYT}IwUbr7_S18 z_#!E$VC_dT=(lY6Y_6MGU*7k2hi6G|QY59w{-_?Hn&X^_J?GhY*wTxFvnKmzbvg1p zU0aU0(v*pQ{A)xCGgWFROq)lX_sk0}MHB%YmsERb&SNHc?g8RQcbf;-5{hw`VzPNg z1Olkn`qCfFaxfJNV)S@j`6AGbrj2+f0^C!xN}IWbj`wOnX5H=(AI%oG)t798+@_tibert@G-eWwWUWpmI`BFZ4TJ+ zkH2J$a~$_(MazNna5-A-tV9UVz?};KF0a*Y2m0ct0xQxvW}QI}4lWKV0T4A82jrOC zBLN`LU_1gR$t7ZYZ`9KVQlHsqs9^=CEPdQt{xN8OQR&AYZfd=2DX(naj`p(Kj$ObG z!uYXnmoU~5<*YjcL4z@ge(&NU8KyGHw4D_p0G{zjKsl)qo;O?V(8B1poN+Q`KayR( zYdiTc*bbPFg7{#;wP+;fg4i%(kJAj@cZp3lm29a^n^sD3!)i?{1X1~oY3C~^E1M?%_l2XC1 z5XPDnvpi`8m8A0+FHllgHzZ+`?o_+i@c+Fr=+9dH@7m+%PoAyW`rpS-p5OWZepUMq zy8-<*Eg#|kq8hIP7}UjH@;?p62&va>+y0h&vq)1JQo|rrUJ<|~tyTOs0WFnwAV_YS1XF_Bl?#3 zQj7nRz(GfMWgHj(+JPp^*B2bMy`o;TMvu}nPcgm_ zU!{|ww+8;+m(gYK;4nUY8RwcSf<0C{m~p}E6=_R;U~johgY!e*gxFwZ}0CN?(Q8Pu&*Dku2+V{V{heL- z5CVL6`NvC?5iv3u&CBeQ9?utGN6~|U@%u9qvGIC5LTQe_q?Ndv=j=-Sd8IP~G_iF% z;!&-}sMQ8Wy9E}u6ns#XJBONqodotvnJibU^xpS=P6{RuCG47!olV$Bv!%Jm|7YzINi#I|sH zz`DkrdLKparw_~QA>3^#V-2_<=z(Ibe)FLBYM%$++46g)^unT@(n?riS`3Ki=K+^Kd)qhvrTx%a)pcQi zQ4ZNL=X=fbe|XMe_F<|0B;*;XDIpd-hxv|)N;A}T5we`ndV{JQ$sWv3Vfu@&V1v5> zD;joRx1WJ3ViHf;B$Znu#^JaMMj}@QEo}J87y;;nIa;9N6gfSeHN=X4{<3Zv7ug=a z$f$vhOpIE29MJwichWmj$W3+zMM8tp)iDn=@L< zyAwfA0ARU^pK2Op+E0_>~P}OWV`rk@w!4i}T z&VIe0r_yE7di4wAX+3tN3?L-I^EJk_FzG@|@4lr!dOzXmps@5vqKhL)iW)NZ872FS z$1fQhVyw3EL)!1|h*z>E-e}*sOI&aF)0DB80umL_$H|cNOQHrX$(5IY#@IE5pNHqO zw=3$M!Li|0hhBn)CKgqP1ts(Sx>@v&}o7aLkn{A=o0= z!^Alyc&dhFa4Z&t*;g1xkB24%_YYnjs z!hkvi0bP~nAY6bszoVt0yNo9&k6ch%xa>nzq!(-$a;%uUSB=vT3Cb2s^x&T1oDpL_ z2{z&<9o?6kPnhmwT)7?A4YRP}v+qGa)s=P{uO*1&y@P)5=e_-RyZwXrZ+GAIx3?q= zvj#$|ouF;w{4i)cXRDt&VE$M(nwOB(pim}JvVef;nT^7<%q3)B@IrGa&I>tEc(t%Yeo}3~WGpl&gbh2M4bV@cH^w%Ia&~FgDRfHAJa1)GaTp31WPU8rXkY@gz!of zYZyW?wtTcxkcg32fKOt_>JWzWa=b@^zXqikC3?~1h?aEaM|xljf)!;ZnrZ7n#h3z8 zYrUwKb>bdue8>m`sV^TgCXr$0IB5T04wjK);JtuNNs&!DeD<-W&`$xtolZV7ZUf|# zA$5yHJt5mCc!nwVw2$NQKj7Fvvxv)tvRnWDyKidQQ}pLZi?^ctTLmFAqV7;${(g#9 zqTd1W~?EMEjVT&sa9{DR-Mw-qO0!R?LnTBhQ5hC)yY{ zHJq+^%!%J@v0k!|&oH>2A6j_6!SaqpB2*?EL^*7IvO9`Yrh9VR=!X&8OoI}Gj@A0= zOgu&Tje^P}!Zk1kFm)Ehf{qQpIiKWTneZmdFHp0hNhdu8I4}~g3BS+??^#B0pOkhj zv5(O>9elt4#~bbzg>>Nge>c zaE9v;@hW7xTRh-86i3FIk!SJXJYr%$+1c3@W4(;Er`ar*XUQQuO}|%=5UB8BCv+l$+)xGp8iWr4 zJ3`wE)SQU7=W-&|Nm>fC79P}RnYKzwwl`;I=S(ok8PhMgn6wON8$f_by4X25Nl5e* zf+ZqGp{7WOx6%d$ev$}rp2#$^tVSvE0-iRU;h;W4nWf;9%I4gh%ue*+!68S?fMI5< z2e|Kp2eO^ww7$Tb%s*xqQ*4{8vxKY`7%9julEX(lIUB3#5k@lbbtndKnavS2=zAi5 z3TRxcm1nH#oSmcxiIgj@yLFzfSuByUp8|Y0prJ6RF>KQ*rq&?F1pPuy-=z@yv(Pet zH7i$FPYe!3f+moi9nxb6gcF$GY?9z)Lo7d1h8fZ6fn>xks!;~J1asucVW1nN*wHX? z9ZeqtnZ&M8548<2F$2#2z{11i7gm*$bX|vD6=yl;G33O{2ibhRT+q?8cDfe8lO2dmWL zg|1Vei7fLgup|X1j?O__BXMsdQnVE4j8o3%|IJqQUz4l1ae3Z-ai7Gn`vSnHJ4wev zDdC3067 zBZvvuYc~Q7xmdG|%gQYrP$Ct^>_TMuPuep`wZVkM6)ii7S=#ig8_>Sitd*#;*1iH6 zh9rNj?>aP(pMsv=Jh;r;h5N=U{c4S{0SX{9F}{&pC}=rUfj6ceTL`3vX1ilPkr40Z zmn!MNk@aSf0m#xeC@q44y@OU`kCT|jcocsz#1(v8S+F9ex<$ynR6 z5)K=6q1R!0O7nc62$=p#ZGaXV!4d>oHn<RHVjW}v`3#lP)f9UEmkWTa}Nz{*f z2dq-8pZo^cujMEmBG71M0EO6)l-QnNNd*L)SQ!QzZOQdQ?NnagI$to8MpSr|Q0|EE zWF3lgHLX8z=X+g+KMF#445}RbAURgMQv)9UcYG5u;zuyRGoW zz3p7|DKe3r4{TMpoVTK3C}@Ytk}gYR%+Pcb7v7ZB=(z145VBjPMy;kP zX#XXM>KhrTeRi$}2dhDQgcI@bMq0y-+#HYh8m+mFVY+VPz|%yu12kf1lPph$Xm3NU zps;;?NRlbsdoR)nA%`iRr4-vk?uKW#LX$hms1}2^6Ud?OxDfneG$#^@Bq*;)S!YyX zGShef3M;@J0VNmJp;?Dex59OU@&tGWL)@!#LDEbRC;o+b5?#y+R35@&;Hi3PT3{4T z{Bw6AsNx0SGA%&wCY%nmk+}rT9HUKHhlZ#V=-_8G3VL3Pg<2OSGK4sU$jl1GV*G3{LIV;cw8EitRfc{ zV`Z6F@h}N8JB2@Nu{!I;cFt~(J84m5#EjnMctcfO2x~ z$K!T~{MukRID)1lK~^6NaC}w}b>&AVhvuJ1}bS7G6SEw<~_k6~a2ly238;=%)g-={xN8h)pJ# zOoiJ4JEHZeFg!ZBQ>k zvF@tq%#fuAc2fLu{VzpV+b5%?1o*WFngUCV(0sdz%NdUlc0&Cm-WG5QmyXcJJCZN7 z2MCMOk9oguNrF6-fq4o#q!1(JQSCor`Fs^coBR|U^Mk<@Q0jMWvk>xi*!;6!AIK`!v zqf>Wu_E_Vcrae@%0b8E)Rup56)2HL}r`(zhF+laoZM<-=?B2+C&QiOV`?rMQMiJ7i@vwdBEz7TrHL)$CqkbB!fd7R( z{BU(>*4VM9;N{%zZ`YYp9n5Tqk_b?WR%==mrff-GcKq(?bAk zE$&kzrY9||ke*%PR6V}ZuYE!v5s2cl6%$rG*I_=BIteqfCHTp!cwE@uh#z4*WG19TXlp%egFcOEbE*1u7No2q@?a$h!*fvbJ9ow-J4&QBI zq6bc3MZXBjVew=V`+iVnNGswKbZh{@n zqiVs~7Fq+W&5Tyg6pU(3!aLk$L^Rp zh8bZV6_oRs90!VyvoSENq@ZY~>|%gZlTN6dmyM-3 zW6>e%L17Oi+e>7cx0g$qbw&3%JCi9M1&Fe8>exO{2IRmGYkhNrEqBL-!>`WK1U_W; zS3AP-9ma4QRsyNuxI;WX5!|;o()C$mHrB(dXf_cFt&$=ekqL1P^(5mL+JwNSgv)oL z0Z@4YWHInW1G>W3jpTjg1p2d^E6YwlinbmlZHC$LoKV`B43@ zUG=KyXEB^6m@Ne|zDXdauIToejAA?i8O17MKQYxPYKGWc$7+&rz|PYlxK^KLaX#Fq zE1YQ_VGMD1n8JZ5ndO&1)2zucX*6-N^ylDK(6+&!Vvrgx>#dq$G1uOo<)mx4bQ&xh z6j?}uAyw~T)|(8IPhec-h+$x}qZ0UrjoSf6hP7Z zb#bBh)`rsQeZBPHe)8Ek{yL&-`0q0N&Bw>;Eg+q3w#3VJaDrTAe77~sX7J6q_Nwvd z-`ZHZ4XqtZ_mj^Zy;GnZ*M5pIqELtc#%J_^HtU9v){iR6TKHY~a1ZnpFYfy~bbpb% z1`fhueAe|{ZTJ?WpoB6$JD#@&eSEt5k7JdXVwx4D4m}D0D$rBL9;N^*nB*d`rI4f0 z#ZmN~=vZ2l^N1h^%jBY~zLg&iQ!oJoR6|BJ zA>I$x&2#gpw8H+hUwEnKn1$7A@K|301}vqcnX=wSM*(!piIphp$ow!a%Xn~pn3?#7 zM#PNu#V~;{K)qvq!&ie0nhn8iwsmrf%NUj1^QE13a5{C+G8$irINb*fF1_B~L}aDf zhV0Wm_VC*-F89pQ%VyL2@qE!eyXYcee0hdqxCoiS0#4aqj zlqhuQPxZD^hH!gx|C;*BuOL(S{u4vp6@T0WQMs)7zt^Q{><9hF%?d-f(fr>12WTbd z$}RAM37I7V-|I=&5?V=`wk&AY6uhs(o7rFAPCDEo)5BqQ%~pf=%)h56zA=( z2GcbVNH)V{n#VBi6k8MV3_-lv;H=5SGQunTj%}>;jQdBSIgS;6OM~)`V%E3b_TXy) zsv}|jK0fcr>GsI13ZA5DjP=iJRlcj<w#9S@VyzQ0+l0a5i&B=^?GCsUp^=MVrW4H!s#{| z#4D|9$zXjnnAjQ!bQ*}XE+{{Pcv}g+F7w`26~aR}fbDJ_#D@SlRxXgCwdOy8R06#KWOOxm#JUyXD+Iw?c^fB6>$| zLjJqMvj}eZQdFSxzWg~fZah_7dm+AUrm|I6lL{=~wvFB^ud+;PW_NZ3s6noao7?~% z?y$>sNi43?t=~xJ;)8dIT9r+#tXVP*Lp#CY$|CnuL+wx-kxUEZVAa5W883(#RH}YraXLDhraN*@Kx=-{0C^*I5RN*2MJ8B33~aEuZo9!1B-2eFZoW;xHV5i zt-tMvb){TyL=!C{-mi{Uj+NWdk@3ZXKv3Q+)>RX}Y4oRU?7=2DGHcv(OCRnvXSk~} zXsNx)U1fhjbzqzW#1dTvF0vtcGb*U;5a_?{EJnS=a5e*`-1hlYGl}D&xF(+*mx9X> ztFupPb7U~#Ld^Iiaw?In(p`t?oVo5gS!k^k#xgOHYJBcZ%~p>$Bf3=215Ws7GlffM z0iXkmZUB(nu1J@cQIT#XIM|TNT&Flyz^=a5CtT(4y2BR=hQ@@IvMBpu8Vq0fN@SXD zWjPaVq`It&P7-prAL_yFJ|A~A6gGrP)trQ&R2o3eM8E&8-^;E;)COoV@(T$Z*51XG zx}syVia-i3E2IFSXCV@E*iLZj)?%dIL9htQe z5}r7}H6QVOHsP_j@^Yg>&GSU)AlP`cN~wb_qNE&j+A0+s`liBP2b3?1PImTw$hCp} z7(nY=@+TYh{spV8|G$85-)b*MfB&p<3KS&()N@*btma27Un{Pwo;}<&^GwN+K_1$k zC$d-9j6fQzBrmU|dupr3VYfsd)k(x6Qg#rv)gpu9i=xGSeXn>X>YXd-hh)4iA)cS9Inm z)Lu0yfB)>QlE8YEw>AH3-Bb9>QdMjDzi#|6YCXgh+`6 z-zkhfDeAxssRC0eJkN|Zm@_eJ!Dfh7tb0HIvQ*u3HIwds+Rk#uIN!y?bT;p9!pY*9 zVcI!TXf)Uac$kB-TGb6BO*FpBNc-EcQL&782Y-xKqMxcGP9*H+jtClxy8QE-pTD2; z`1BOidKfdl$PD64@tE^D=C3eohWX<}zTyPhjgW1)`GI~7%H*Pa&+aiGMcoSbyFDg2 z2QM7^I{M96VqI%)o--=1*P(Jl1wR(M?s+FEm49jxkCbXLm^?LJmDE!2m!2+@krHC zjY5pMdM0P2wIN>xBg3Hj3gXdV0x%{`35S{zfd++!jlI{}{C%EqEXLxhC=+NWMq+E^ zc}l3d!Ptn_R7|9|2)q8RC^;VJX+6ig;T+534ZE4mE53KMC5DMgM7?W&8k(6 zDi98Cd_PG(O;KXu%R!&||9o+OS^M2KgW9cHbeK?d+Jh-%44u+lbe4Q0VtF90n1X7> zh@?Zhe;fqFEmBD|W9e_hcKW%dg+>Z4py6+V^5MGW>=1P6*F!JEd<~m&ti1dw{00mz z4<0N<4^gLIi+ddQO+2dI&T0%K&5wM(}Y}mM?aB5(F|es(H1(M-&77K@P^@hiz1z$ zwk4xgDP?&O^bc(we7+=SDq2C;3tZHk`O{54lQK%Ou%5t_nQ6CPBMup z^6Yxtfrxhi_v4A?5iSs+2DB~+0{qL4$nH+B`C#-W?+XyzMgX(1zO>XVoO`|iQ$Vc0 zAZ{t#Snjd<%&MESWs{%xfUOBuZ+-^XmSvqm)WM@bTz90OqsI+`yEz{woC&ro%fpOY z>DZp61D;SNct#L;xc>v-fX`E~tY)Y=8^>3Os>LlTMMtlJxImYZtYT^<+@f76OJE+q ziwu*{N;rxfg5)ucEk$ez$LsH8rdM2)XuXB+nXsT&DiR$Ecnne<#^bSA!f1D%<-{Q< zA>cSAZ7Rp_Sxt#Vriz3qP9%O;+Rcw34*^!-H}ZO*s1ym?Ji}&WR=s3G2m2b&9YG7s*V-SwwYTTp zW=l;~1Wni5t*y9QS#$NP&bRL7y0t(aEX3X^D3Q0bG)Tlz824Gu*z*1A3(U=|;?UY^ za&%W+(~8-2ioFnWAUL<9ISkXsGWM!@ORpDzheh8MMaVIb-hxDg;B!9EHYpHSfP~xvh#@Sq2yN2=1guSOoDt!HcUg@k*6DrOe~S>Xlj=5 zVy%^@;6Kxc$UoR?ri;+mUWGT1nak`E=9VAv)UC@wmgqei;BG|Oau8H5{+MCza=_uo zLj$fEml+pQA6hry`(ZCBTW~U4e9w?tYtY6jI;%7_z7y|$TaN$@Zd^^=Mz&xWWXO9X zot*7{nlfHd{Kjr0^eh=3W`O-E55!=Jb=@)^FmTC5MzkzDkiCo~HLtnTqGgx_O-jTB z$FpJdFyfX8h!{qB(a@c|LRN@Z$18;Ri$8FoBa%7BF_(9i3rDC9*DW#XLg=LScr8yV z({4o}1?<+19J5uk~ihgXlSJAev(I#tz(7Zjxxz2C|Nv&zB?TTqKAmhmCm7Io-BN)1O6V@m& zR8+iQtpfj3QYe@09DNXId?+8br?KRtPMZ7VQ;Z?-0A%?crfe;uF$C(eQ6hRX)QQp7E@uPFyx zrdu-j7i6zc$w3UArKzjr$gZ+uzj98PnH+DQihtP98-+FgXAdDaAc7gP%*htd7D`_hOsx1S$( zT9&F$TzG|Y7*wfNe@{@U| zoU8e0?iamiF&B$6OUIzaJc9#vk^tivi30UIS%h(DzlLe=*x?Q{Qg#=!+qOG$ zE@X+OGh_%+?hIK39Y49-nmuh8{_PR-Q!+%Wk`0OIataa?> zDASlg)X2+(39@OHLl{n;4gpj~h7ys*n!ic(sJ01Rm_VO91Zy_fo&_c0u4f{o4I!Xd zUglyH0KxkavqsTWkW_QZ$%s?|C1g3~LCYM94X@W=fzI*^l$dII6mdZH{34x*6bB=? zaB-7-#(NkkBH!x`(XwJGu?wB}#$%ZwLi85pC<1MJ>j`)xTHsXj(H#~9t;6BEN=hTK z7Rb>GHRM-}v(y4^CHfr)>@x60NE5?@Zw=TyA2wbk%E@9YqOCK$*0s5zI0T1`SU#e&U%X6~$01W;!CBw;phq|z zMVsFjb5WEYyV1JF>va<$k|ON{1?jG-V|LMyvXe}Ewhh6%0IGueCHm!neIYKJ))h&e zUwtW@5mawI#*KOCVza?u9UNl>C-Kt)Q~Dn6@9cM_3lH-A_dS7o&U5lm%L>hT)fgSw zL>@8*+b{|g(X`!-#?jk34;qWmI;f-c%mI*TdA?KNFWNKO*zYLe4kFngb`1O>U>|L$ z7#;s-BWi>qM4pw9X%S+%MK*zWEYd8VCNO6lN(K@e<5`&7&4jXo9^;0LXQxxl!90l& z0*6jx30oRts3hVijuN;mlD8I{^R!fc=l5zLY^*e6Jju)Lp*xXaLfM?bt5bFej3-GpE5@iU`MD{5bXE+*n=QUwfnvHu`>ai6u9Ql^GFxj(`;{hi&n?{>Gh4tIAJ#sB076l?ZE1mu|} z{xH!jA$zHfW+gx+$v4)5sleHWtHI0Uy-1$Wo4lV6GIl9BL&yfYu@l4rtea%WRs%2G z6R;9mNr8R9D)=E1II$9O1UNvRPMNeMgq8Ln{7&xxr^LF!E;Jys1;6^y2B-Cu-E6f* z6SYg_OW0G0zo6G_NyZB2K@0yj0U^wIYaA-f%WeR#bU9QEY!~AN>qnUlvN3F*f%6%u zPW8kN_&~|szEAuhUncmJ1to(}2o4-oRoXzOUY=jGHl<=zk8=|@aPV6MMjSMpW(%7C(cmH>m zvXchnEwk+%f3>7#lcs-*E}Mm6cFBv@5H!kKWPdgQt8GFioVE3-f>z(*aXUfB1B-0w ze0glq{>4pCW)tybFtdnIVBQwGHvlk{pU%KrN+TsfsY)rmgGRG}dt(X6rd@4eG?NX_ z{DeT3hGoQ7%R!mX24!GA?C&;-a&j&Ts7vt(pIi4!$&2&hYF*(Q{mNQaGqxqcHyJVfX(9Zu7R~*H2B7T&~5xvvkU*Q(0>(|X7Z`XFXZiILWom7tjw+F~BIoz)r$*g%@OHR@Tg3GH zKCm6Ty%h8Up6`PW>t15P(kdZ??=#CW-?L2JQtm=^%NU2;(1__O(9Hoo639GS->#b7 z=8a(R^}{IS?}kyNTU%3o!-D?wz6N>=`y1*l+EgM9S0;u!;2{w{5?s&=_e3zu82yqw zi4F#{WI$wcurz9=Ci{!pgKqGl=8)40_zpPjbwwv+nPxqZ#jBg2C(|)I9jZED7je4q z&MM&Vgk7q(8+N@-j?>;Sc#8Ihfy1*moLdqS1S?x~nw9JlWgFN%?eiCOTKm#l%rn@= zd+ZNZ=KtQDf~6Zc-~HXGp1pB0DjSTHub3NQ<0;g9lzd-bdyE?Nwq~1xS6hPt@|JTq ztglq_A@Y8H{dLr&%hjV)X43{^G{?TehpHlDlNtu2Q!x`&zPVXLZ#QL_!kA6u~Ay%bITAv*+x-!9K`+ zhx;gZUt}{rGBYxf06~4&)jh5=(-uh}zcVs2;upUtRlaWEhz`_(Adp)+fiE`AeJGe0 zz-G}h1(-wuI_azDb3$f$ni0>>L|N=FFop|~M8Ja%zM@NPT#(sT^QA*TCr0T5ArS>Q zz1`{R;+S621i?r7M5zOSl{wFHNTTzBDCQvoVnCrx#6g);Q5S#|`SC;c-teVo>9B=n2dK*bCCH1%~kNe~<3$n=i}GnjMoFP|T#~ta0|Sy9k}t zQ(mL~7YEuk18EBZfkYY%jir-ixT0lg@m(I}5TBdof<%u>~J98#5Ec1!EB^? zmt!z->N7P3qY=+*cGA*hQIe*Q2($o;%!FrVSmPYtuX;k2K}Fc5-RlS<5=5 z`R0)5N~AP`r_^i#6qq9D;5j6(x?o82B+he>!U`WxIST^R%`s#Uk*vg87R3YN7{Y;9 z4Mx(^8AoO7)G1PWdEA5j_S+rDk44{u6^8{K{3(%megXC=n`V+A0`!D`mUy@K=CD$m zQ=z7ClN+vi?f`1D?S|Uwm1BYjTc`uo#EkXV*>ID}?#5@7tC*x2BnQePCb>b_B>{?U zz$THu^`o<*P$%;8@~OIo&yIV8?0gyQb;nuuVOeJp1u?WdnT18hjLlKXFTym>YH#0t)GFau7%>N#$#@s z&ZC^Flt2?FHi1ryD%>hQrfN;kC!^%t*as}$|8F)uMmmfTs+eZ89I?ja`56+3V(XW; zp!FR>QaJ=E!(^-r6#wCg+L#*Nu(kP&m;qm=;}7b$Sl>1G)b{4`C@k`DK+vGg(c~b*4`^9E$XQ_R%y4e~$Au?HfXG$p=0B;ig&O-p2ay~z>=ZK zuNoR=HHGKd7=;Ywtsf-CPH`=%lV)VIyK)&wZ#k3V1Us>s6DUzhHasono{^Xo~_#Q&uEQ-Eum6kP|P^XZ>nWgh`?7>rJ0 z2dNQmzp1sEUF=O`;O3;GN`B=Z+0q+;rF2XPu1$Y7#8`aY!?z#8byg{Zz*z?PTT`7K=Tm`-t9z zO}CNBm=>(r4ewHoM3Ne64@R1eGD7pCm`bxeT3w4U^`KhY1&{(Loz>iS)aE(E{i5cA zo;n9L7img>yoALQYpyzEjr&N?(j!PA_@`asbH%OF6a4v1>tBGn3#bmhChh{YFZc)?U1~mEV2!1tTVL5oU$(EnhXQjR^OHc=n-0!kx0|G<@LV?W zZnbei@&#=JJ#obp1VMQO4xlM>x!~2}JW;b+y#l&G4x?uiu7I@2@oN(MIP@qS|Fp{? z1mgl%fuyPNha?_gxIadeLrbmpiLw0leQukOr5vF{Dnuaq*#!>as!tmuXw|fRk0lAW{-D7 zx66SoM$VYTSW0ziTqwM$GG~1<)N#=VZoZR>Q^9QY1@?D!zvCd?YZB|M1 zXIjOD9hQ#YZL4u{;#l{qMc`uQ{&idFUVO|3uLa6i#jE$8##5msQqg*yZLy-SN1j!B(HGTnJqxpjSt)O+7PMY&X|`T#S!tL$1ziiGs!q4<@u6 zjZF^Q2=j;F{fQU%8SJ3(C2;`K=C@83+BBDwq-y{7p!9cnqVy)*$#{!Iqs>XF7MV^2 zR4z;nr>~1K9nb1o)01JIJNOR5oN%N?>Urtt{5U{*{HjrNm{#D~D|y|@X~92Na*Kp# z1!JxY27QJe&^w`=KEA3zrCbb(sdn&&u8Qk+H#sXGF3n>jUX7IiEp}Rz3MI;HTDVbD#^DCV!o! zQ}jCIQ5%V4zk{DE&q6u~$lZ8kb?Xg3)dJv8te-$*qmQ9dwO0f zmKD2kX^PwY$y=vt9M1?Ej*C7Af74}YIPFk6$0e#9jB6lG=F+2<;>RzD<0I$|&tV?A zmY}L+;--Uwq6w(YCzZWGR}L~>aMG(Wgl>XOMCUrt!*4(d^o81IcKl0$FEXbgE)?wq zQ!lIfX(>R+CzUmpCG{w|6zEbt8n*RdppIK4QpVJmm`&5ublkgC#bG7^9KqM)Xz#t! z@5Jh$5y|{P>s8jdvR;Q{wtK|({Cin~b01hl4Jz*;-M%6#;*l9;Pr(XpN4sAt?@#4+ z^hGr;4vgM-k&e^+%-*d4Qh_&{3hyg;wzKoZ{Sup0^@0f+HmHv&#HvNC=A94+mAdo6 zWmkhnES<5n4R-nV35gGESV?a|FN?<^QB5z`52z*`Qq<+$Z%!fff-$IQf-~1DGSpOY z_R4{TTkc3!S)WgUu|mJ@x-05tWhx!&0Rw7jiCvXlP2T0p%6VhJ$!C|VYk2jUtDFe6 zLWx!yHRmbPYOvE%DYqDs*kymGL661J$tXT0N@(q5Vg1)ZP+{qyIUmESr(pS^&B<_< zCewI2IJ?C2CoPCWn&_g`)3Y4YkXC!oHGPg_0witjQ_$*_HSjr(e zBb@Z;inEusL<>H%7KRqk0cLN66sgsfg0k6_NFu!kz2(^x7uwQ%J9Iihh)L;4Fn4U3 zxtNdIdBb~b*QWfKT^ zE=+=$)Mpfz);qo1{Mg#N1-YhYIaO=Rfh$ZK4HrJHN?wgH#Y2FAcEhX}gr zKk$gX-i_`*Mm;_MkWNaTZZh7)Q4t>lKA;$!%_$FnDAU=b0Ho4HEx9a%q~ESt{36C* zCrVn@m&W+UKkZsSoshih1|_nV2XbG4!KZ$G&P#F8#OYg+v!Yd<&7yQk^LDePQ`@(b z3l0MyxfEdim-Ll;uxYD_esPl}l9f?dQm<4{FQa@gB1yLdc|$c$nd0i~Jf|Kzk3Y3n zAUo&Dc{*;d;$P0Io%MiMJ>a)?OI{qg^VuKi%9`54Y^JUZ9ixQk%1A{I*2u)O;wv+A zq@8JI+%PpAPOIl_@j|Hx)FE8puyx{*I%J2J5m3_=?7dVD>-q4~>V!qf9+x zCof*#GlE8H-e5!4NdRW52DBSqECw(tnDO22+DZyFoSxFO z1Scc_WC#V-wCu3bc*9L`KrSG3qzLFF+9=WjB_#XyBjP1ouPz2U*r*+F;vGcr2Hi%DadclA0+QiexCI+h zJXsTLN?pmU_@M~!dRwvw3HO@7DZ0KLH2)am9|oD6NLP=rc55Izw+z>_Ysu@;DZE>x4lTMyU6cM-W6ab8v^_RSM@pU6pAG6vS2_B4i>vwqL?RH zji!yzRqtY6VX8nl1)PL07#2g-CSYQX;?o#XD3j?`$uK+2<%NyEy`JJ$f+cOGyxL@e zrB=J|aG%{E<(Ab6y{+qg78XyZKNUt#`Ha8X(-R&=HMZ}!P{_ezIsu4HF!mcq+@1-! z5w+@d0;|A<2!y9t=cRJAwY@yzBG4!-O9c>svj@Y}=%Ww=fMMckA z>9m`l7AGfOD!tW?`9o4guq+z7h~?}Y=7Scv*svSQlffCLZOaZLp!&j+3+fns!~tG!HzxAuRY z@L#aA-N$wA5t(#J;1q;r6>&Nzm>;#W)DpKe&1iXnGhMJeb-H5paS@z~OX?nkUDR=S zFkoHMmelDh|$HA6YUt{)%i~>e9g&N8G-5?4XC=QtSS{}kR*Q^- z7$3*p>SW60^@){RAZBQ}G-#f=E?WxkU5&kJrm)DQTF&tz3!8tg%sm90gXfQZcl!iW zM~C)VU2bQF9S*dL!AC4aaHYr7czUUibrSBRMYQVb`s`yir3eYCv8bSuO($yOH+q~A z9p}D645|M4=X)?zo$j3PCWTDq5-8ZRq%Bgod24GaC{q9x5}gYw1W4VoR` z=4?<$;5Dx$xUT(G$78uUQ1TKTY^UJ0gIV5U zr5`a1vOIije_jBilk4Sx@XLZ_s3q!?G4BDYnd?-WQqhcJVNW29V#)e$J~)Hd%*5{? zVS?-;s?ZSNnb69VUV8OGS6&p=m7MH-NFZSye#~R1Jt2UBU|tF~o`B5|OeyvO;;F-k zB^~-<$R@s{PAJN*7wrKf+b2@YFA`)=aX{_XF~dfFvRLML!;|$gKAmji zrmblBrXC>th480bA_g!TUJJOls%wDFHz5&kx+B~)9V!?PAu5sp!Y(O64-@vi;~Y*} zyg8f;Hy7exy&rOCD1F<=>4*|2VK=F-r0zZbAPAt0>MRgg!OtPsGx(OtneAbxZopA$Ji9h@yauV@{pl;j1%m?W} zKYHJ7v>dN3e;b!0_T_Zv+dnCn|oWo z9`0?wdVaX``nPVhVqDDTQQQe_E~9I|SCaVfJWj`lf1M?>q}}O8e<67$F8I|`0@k2F z@Cb%kYfzZhu<_F5T%Pz)5JHqa91Vcl?pgp#op#a&{xeOXQxu@st$O$jHYaNTrPJ{^gh!|CR;$ily(3y zupP~8uta+;*T6hXiZ~sCr})~qy8Ayqe$-nK-y5*;m7IW;x<-glS8Q@YR@wxJsrJ^q zH(I4-9Kwd(0^AGZ7QsMwrrl|1_a){4cDTT5VjwLm1r9cpcTUm@LxXM?uEtRdVUbcH zKCGm`a1GHw`=^6^`fNVAs7^O>;x^h4B+nEVC6o6(gaKhySmxbHfJY1Sn3?Y^6stOx z-8$%X6hyw4?Upf{f#R*hM9qCgRM=8tggQ}HUeZ-tiwMLACqn07L zVNYbFU6Dt@#vh@BNSZ4wL?G2CB7C6(XLTr;z^;ghyzqIzSB7gH3TtaCKU6VT%ugp* zeN2efBfdmM*TL;D-Uq_aXeA#e6g1qdxAm9*mwoQw=?F@6$|&$pu=O@ z5{RC_TR%Q;V*ljQMwR3OS6I;dEy0w)FqCr}(b#}jz^9ieZKun@H$#QhsFH zx&W;BNTibZK3YPKN7(=%eIQedY^Ej>VHPG% z7$u(oc=lU5R7L&0=80dK7$A~kA%jo2UQ&68i@cH>{1tKTw_3nu{`~)V^ZM=H;j7or z-o1R@?ldNt=ZGKpceQuTqiFTv+5;%){`Cv`@sWujMX66jAgC5$z!-6R9(1$mIZ0$(kC7 zOZ6y@LDxaYsdY+pI4SHiRmW@!BLWo`6=K0AKehTxNDh#=*m?KraO?HX z`{!@>p1%$HRf7>b?Vl^cXnwv6pz*s=Kl(XXn!3Lxoo>~;C+hgBei!_h)$it`wC?3y z__-5$`E$6;s@4Y|PRX$T9%z|6t73vOYFn9 z7Nw8Rr1|r|&f?Kt#*e5`Aa~8(gGD^Up10S4%IYdof`73}8kP%p3AoEGQ1}~u1@)kL z4=vnL-JrwSwkrf0KCdLFxX>X_hYoAbW7RY*P|xfOYTu{5o~4tgJj*TZ=;9 zudLi(d-P}}`j-a}AFZsesz23pE2|G5sN3hitbB<9n1Md8nsVK8ursP7`rm($|Nd1e ze@fUycLTG2cDE&eewLi5uXF!vCZSZ{ny-Jt`_!^gB#~9PclIRk09l4ia;*H#Xr^v7 z?!$P!Qc*VXzW zZWLhGpuYkwRuQ!7yi-RLOzqoh&J}E4m}7Fc)XlzmoJ(*F;|Lr{i_2Zgc#H0#P5Iqx z$P^rQ553w~&6*2xRkpn?N&Qk|UUQzqwsKNB8|c1{=&Dz<0(>dENdbxUaIUBX zvy>LGH~}RL=I-lLM!)|vnPxWW9Qp-0!7y^=@D&4PpiV9fboz%m<|3|0Ds1|O?>%X= z=i+d}#mG|mC#Pf3WT)dI$DNDLk`HboQp-LnvaL8t!icEP4s&g$JBKk!$Y^9`8@RF8 zB}tBf$%6wj^%=9Qw8)AgdJeb}D}}X%`;uhkP}{FeN?l0fTYz^%>BYBSV%U=n;Af&@oHvLMLB9X8^ZH9qBEMJ=x5`7hw#3wCMaqt2!45 zQ3(EKvX@{=651tkF0z_va0chCC11>WHcUpnXq#M3P>>p7dP2h_DP}MMU>cNsK4a$lGjci+8v{`PQd^X1FKr_X=c-ZAq|Zk4xUrT5EdTlzuGKSB1|JG%_S z?C@6g95o@k9b?iL_tQKO-#?=MHY`rW1S-R_zesB;Bm%1_$a^h8zWyzX#4I=?^>;i-PU z^Q`JL4&b4j8U_L;^|>{Vhr-pCaKQQ%pzZm|0i>XsckX{2aOTnt-ngZ+7@+ty?4AO` z2ylmIDA;P-zVG1&LVc&l59aX)t417njt{85!2ps(0b_q%JB-A%Ug2R1{LD(`#*jtJ zWEPk%5-8UxFR9j(74jDRR$2ZhXMjYumoiCHpDLQLD$y zhCo_@Ku`PCD1(gA!8j1AvYhv&(G21~E|t(|T0v}4_p3P0$_3jeE3%G)RB`neSE}e% zi#0{3cKw>Dujk7V6(Xn z4BaQ*B1(;w&(!)p9i`}$^9HG>u^6i5#v$$xP|rE;QgPUoc3eG$^WmU=g{T>;SstFk z(lXZrdYCCyjpB+M5kVDh5XlrErOfuh0V1UMMvW}_YSu`wy9$MvGb{QX#njZ;}4QaIHUDJN1_X%qQO=BW>CT#S#V}o z4uLMh+^wn_Ro^5HI8wg@E)USvK?Zirhx$>3K>ik(r6Af|A><1wz{> zUcwyQ1n=x4jfuEVsOeo%^y6rZ^e8ml-!09# zIE|~A>1;04z|-?mq7sl4V?xhZA32MUQ>8UrYFK#G2J#*VM}p@%CjU00LxGubgsCDC z2wCWKle&1;@QHUY1B_2f++6SyA;W>gJ7;#eC@sDlB3q;2YlG3!HDDSAQ)Gk@P7?yS z4#<2&cAue>q{&HkX-3Tq7pQZ(0v#Ha&wxHoXP<)jt}f(}#(cy!%_3OZlv+nP97i+e za@MNO&}iNy5orad8WP(CU_Bm;rAMp>TZ9Fbqr%AJ`W zRc4zV`Dtby+b`jP9fYt22M0wu8_p_aI@{A}>|Z77AROpuZuh$lWA9SBkMq;IP&%C- zOheyp?SZ8ZlRTi!u6=FOT}NxQ+(Ej=IMw$<-N;5m?;-A$|HFY-0ffw|MVr$@f~i+H zXO;qy2EOwKeA4Xtg(=chb>ggoNO87HhzE5z?N3!DJkdTZ=U37M!uNX8OhWH0!2aw~ zF_oC{#%yW=c6H+@(5Xu4>ARivQ0PNYIeAYO){+^c+zE{HGPe45`GJz%c0=d&lkmUI zqJT(z)IBk+pVm!vK)>amCffw(MCwQX_H&ndOn=fm;GYTPy+V3x^JwR;TQSn4I^UE# z;5D_4HFW%M1M}OasZs*`k+fMT{ZFgI6 z7U(C`fDYM=+>PV%ThKOv0jh4t{DC4aQ9C$TY9nEzS_YO%@r~N^V0;aXpd7oJ)_Rgg z$(WMpkQ6KBk`Cm%jfPLlDF?JsDf>HD^T`DoithxrO{Gb{W)FW4Dd|uO2Ebwz3a$3v zp!zrIpyQI1YN&<+?6;L~{Zt8=2)u*tmT_SbW(vNI@9xU!Dk?+iPf|O(E zpIwgQbES6SIY3GF7#fAn_Z|1PkrAzJki}u?^e*q^YFi_VZTkmP;m_qa4r6K_3_%HRUiNR=mB7Yzu|ws7XDYvxy93%%uwlwXt4#H+myY#pB@o2r_?09K*0 z%ikiCSVHka3el>E4W76>W1LPI#eMi1Y_3w zx(-u7_mH0?o*vVZKAXP~a5ltkC5OSOlD#UrPEv-1IkT!sSbw0rlJ`d4dFU^t1&)C% z(6M`(Z)zwOTrbOBwy9P{UT74^dxOwgzIU$`-HV6`F3Hi>MpS-BX3CBty{^cTtyGOX zI)ao0yJtyK965Q=n4SPyl9S2)l&r4I5jmKhW3KoiR7+P;1U6b|B+xNp>S~=9A_nh! zRH&vzlUNlpoAlJn@c!QGXRrIwF4(s*)ygPWHv%?=4|HmSA@2Ag`3h8B7l3^{&8Dh7 z=Y+#n${s9;Gn5!Q(<$c0P?aoufRoTz?t&i;NCcE_*Kd!R2ugymNlI-M(@_PhtIPRi zo<{p8=8z*oNunosK2;5-w45hs;}~WRX1pr@kd0W&6TG`H|%ln zbk>e|r^)DI3#HhoNUj~4g$i*}WRp@lvEU*qJ-BWR=;bn+NfS5gm<$oA6eXpqI&(;0 zOE|!gSSD+qt>PUsnq0@%9R|5r)kFNXekwuI>^Yi0`U>2kRPE)C`mT6cK)i4c}T-@iewy0VeD$k zIQ~ZI-AO1(kS72;hDBeZ~H@rW0PT^LfX`)({nRTwZg&kdkuw7BQw|)C}KO zH^)QP3|!Qv8sBFc-OY^M8h++N?j5%<5mW_`KF0IujYjny+E)xT8GLmw{|4gAFUwaE zDl0@;GzjPqW$SSyXo+w$Q&ev6+1zfnhS9 zf{Tf?{PS!`9IOUn(7||I4CZ+A>+7B8NN@H0E$~|%Zoc2#e!2Pd<@5IaM~5pN7MDNA zAC)+S6~@&-;^KQ9mklwxhX5$qR>PR9{Tg_iAw#&}Gc3PX-W!!B0e?MIEx}SHrS*7o zYw~xzF6-82X^1|TQGcIH9NlPUZnT(M*BKQ!2q(p~TiJSk;IoqyV~{1&0mT9b#&K7v zuzMN&FIXtx-wtTCDCp3vk~b2JZ{pMjd@LqY57-@8ImwQiR2~+KGj7<<(CDrUb-~s* z@FiFCIzlrfb+2+iLdh9*5NpfQXItk&?z>9w2h_NhbrJ)x4P?}8CF?=*ozs8dsW~~ooy?Js32*yj`MB@TI0EuvC z*A1(EN)tID=mDgiHytPeI~_A&B8f&|zD+`naOHmXjB)`cnZ_0%FX7Czb4V65;$~jL zoWi-Lv4k6I^(y36ZA2@2DUsC@aQH7Y8m{~rU580h>L>AX43DdS400{}B1kTNOWqxH zu)t^8(ffTMYSrmrcxsnKG!xZAP%>x%$1L-cbXjjQ0;~SfP9Zf!No$ezaK@?oE)z6?0|%4MqGC4~ZViK|&UJu=k_CwB>2PQ{Zb8ge zpDSsit~wa_g*O=Say+IS53~+d8KdmNL}Nhi=#l!D1%tt@q6Jl*gQNgnoL$m&j4;%c zRw2(uA30JT4D`u##E?-oM$>RnY-I1!Sk5QQ}-=g7EiRXu>}|#-pLj@^%3jzhnNl7g<(ZoBVAq{^S0mmB&8*W9`w}H~hy} z!hi6rOWemdK!!teS1C7W=%T-s(esC7CBF)d)3u=urBmB$Z)4^rG+2>y3>d68Jw|(V ztI4*Iq*`gfMUE&Db>Y&*lAB;7;BP5mB`nau*pNlCOQlkouciDCy(OR3j0j}OlD{-D zI7e*ajVdA|LQxU2j&<|89f~$FD#FIbYJoD+j_|v@Wao{Cn~`kn!gxybWAZt(SqVir>5odMJJ;UWGdQzYFiadM21!`~S*= z2M-?l_W#EZ*6x3^|9>6(e`wYh2rhJ9`sYgc64=qEw?Pubd6FP7)Rao1E)@ag<0z#g zsBK9(J3)KyEB88nO>l6_P!@_~ffU@#)rd4};p_)qS%C})AtKy*mP9P`rkN6nG8w@5 zg!CULACZlla;_I=F(712WCo(-j)FxV*#bol1&cQ0@{gHg4bv|a@o|2HV3wodG`;}N zJ}379Dp6OQI*-M@|MPx;Tl}CjlUHJotYC#~K-cwq(T)OJvqn|=Mu$;1ZncVGg$!&$ zkr8iJ`1NrP9)ocezX%n#m(2!ezp8^2bd9PfftF~RYrSo-fei9(kT0Ss&YqdD+2&v*^w#0j$=SOB3SfH^!*Psd!; zFvY-{X9N^`m5gVb1Iq8&;N?sY(%yS`^UulU4%{vv_a)`dtXf=^ck1vJ{B{$pmIwiE zJi%(YhOd!ewYs0E*~8`gS;}DjkmWx3fX#YO$ig{14&`K`Ca%qLA>_sgWy~W^meFuc zbbZBjndWL8YFWuU%2M4l&R6jFzuPpYjo&r#@HQCZ&VJ*!)BI@y5{lZM3=cX^x89hP z2^3T++uX7_FPhgT=SHn|uTaKKGT#e^X^jR*spQ67MZ?(ugFt-0lnCr$L(All$R8nW zEfCd5p+hDtX!}IWeL5bDX2S&HzXVH@Fm7nt>k3)X1s*7E^`S(O)L>nk)9x-Obs+wx zv-o2YDJ=(>bTQARjXWyPAh5~q;Inw(VPcnd?F9Y(@4XoUTSL<_x2+jo0>l5LN7&tlY*lBDlf?YQ)AFadKw6gEh}Ts{Dp z`9VC{NgzupSnlk129Z=Hoz!$GRe6~2WxGl#7%0WF|8vfNwF3oqjJ!&RKe_dj845H8 zOs?Suy4O7Yq#bUX!?h9tLrxTE(19O>=2o*kR$DD4%(^{{%R_nd>8}V*{$r@Z&M7_r{eXA zbKB{c2gXlb?nM9ud5c49juCIcfPi2j^n#~^gow7v;Q^HwJ(upH=1J7=Kc?9%r?!db zw#0etQQK6P#*!e^e4VPJrozEUojCXn7~M;yWkb1qzvWc@pZxn{2X|z0I=184!=~f*Ur` z&sHRPf<)$`wJXFtWw;h^s>x)T*~|8_NwCq;Q?p-gkCDU-jEG7) z#*lR)PRGcEwqJEl;?!x-l{r&w1B|WjFt>du=>Z(K^;uuGODYsncG)l9Zv&}K(4_Nx zc$$>#Em5C_h<|?*k4ultbDamvH806_gi6N-ELLsx)B5bHY^ybE5&K-&XX~W=j(ii5 z=LCPEp7)ZF^F>c)7ZYFRFJ`0BZ;)zb_adDr-4TF?sKo97cX)uC#G%R+W^&D{uek_h%QCZ7--GH9Q15wc zmzO(4W--Q10%RMMvCXrc);;PXX{ty~1CLvVZkuK4kZuXa9sN{|@hLLSZDpfus-s^U z%2NTDolSVxvGpAWkmZif5s4-UwJKrc)QJShRPP*TZtG~QwjyM^xumSPu-mDWQ9M0O z_V9+owhmPuxs2ed1AH)xtCcnz0P z+ft;F60ilkX~d=%IDRSLQ;Btye^Fx=et^EG9j4?eJg?cF)TS*u^;vv$@uUPJHP!-e zQ{6V5Hx!V9Uq&}#U449_%TynhmL!(SEqgyj8#Fj5DN!SjO^JC@WaAbj*q`04A7;Lx zy>I0~a-J5i#{uXO`bll{SR&k}Xip{TJ1fMMj2;p^=WvqYaYR~ujj)!k*e7lk9iz}K zJ;_4NYtJpQ-w*cdZ=XA*i&lsk8SYn$@Gw(rVQ221Wfu+ZYOW9o(kU9u7;PKQxGIN=2pF=JfvM@;vbN`Lq5o9 zOLd8P1_F4P0pTwvUBD_xpaf`-QGIYzDlXhVgjv>bdRI(wgXye{5!BOcCb;>#aP&~a z`ZcDj13&py$eWuES>C{M4SRgS#1Q;JTm631(>1h=y@i_~!W{2#0)bMc+6f^vmzV{ex7f&P$JL?hS{M2*>1Ork zk~QQY4eT#FuirjD+eXc0| z`OdTY?9{&PjegQ0+b}0MhcIQ*x0SmH-_7Aro9#BooN`aHxR#&=<+Rvt%$82^Oj$Ty zWYaK2KwBjbht*={fFr`0bmqOK z)-Ee#>XIXV*n;H`sa@#bjo{BczM9e}O3O&k$HO53UN}XHdnn{(*BHr#U>p0L@{uxT zTED&b&&P0o3%usPL@6w7cEc~;(r1K>S+uE6qS?il&7E6YHzf0JblGUxEas!hR9DSy zeR0+EQ8@v1bKiEm3aYl8Vw+*BN*aCk@0i>`yz;{1U^w6#za#mpAXk_Nw+zw)lOESz zPMh&=doP}pO{4cZv$Wg_VeR{y&1MjIdusoNr1xIPPw&)!!-?6svoBxw5GJA4miXnN zzH~fGBtL6aM~iqOK5jsVDn`V%N^tW#ulFFr>LM9}KAP`cPLf%CO8eCzV$^_tvn-m|>~cQqId@9_SZxA+e{ z^g;N9ov()Ah@ju zE6skC4L+#;x6@u?>Oj3*KHkA-G$;sdCF7X5pmoy|xHGUd56V4Nw!t$&*nt_&7#1oM&+kx4mPexf%{BeG$*X`z@ru z#v6Q8TwtP~@oC=I#jJMVx9M!`HzI1IMRcqtspjZyP4zLn+(~3{v_#-Q<-!pg_D%BNG`*Sej zGB6wy3!P@XEXArgz9jprSsC{`di4p!%llnX8}uIg4eOCY+~Ant9@y(5+Rt*X57_#~~V@nwYO%ARvWQrAEw_5@Tt+ZtuL5}hOm`$Dogx1`W+ax9JWiw?rR%7}tHb7ZT} zrf;921DS2-GSpWPYDPM%lzq*fsZhQBz86S)CezaF*FOrAy;VlF^#|oVgMsMha^Q0r zm8f5qr%`I%sBf}>_ET~}Et0;-j#4R!kxXG!+bU+tZq_)PFvlL~TW5(9&mbqD<|iAN zLCLzdcs#{H*#sz5HHl)1RMoG?Sm#9T?A7}qH|QP&TN-xnQP_ZbWKDgX!F8A=d;pIw z;>%oC0mmU5-BhtUpIZey86+ps=s<=yCFnPEa=C#=9?v8eZs}OofsOUs1cyQX0V)!n zJU01h8zkjJ}7#28JFb8WSX8_ zB9>!D2hedc%HS3< z%W$5AD&%M4m1CHm_!lL8%Z00&zUe}j_ztW(n}0|r=8B{4la%6}dVEe@^vt&kH5mydBp!fnaq(>+E~$0ba%=8pj$upFYK&TZq0 z#7nOA+Q3)C*^~{OC*nTeYw4&5omF;_%^>ayF6=t{Y@eQidyrba=hS9+)=qwgltTKq zi^Tcyn3FSA#aVIj>PBgk+j2=xA>?PQC?$uSPT-ST&Sn6_(4{_TR;s*#w=v=aPk@L3 zE#KvILyQPXz=QTO&WqA)5U0;M#H*=6QImI8hM)bxsSYQodUy~iMK?#oMZ|K1>QRRg zUIPyg@vTJ0hdbDB?MdGm(e^CGY6usKs zX_vMTCD3y&!pWvXHw`fAL!Cq7(g!> z)SZ^Cc;A;dd9)3{_RJ=*x%}E-b;1rBD-jaC8>=BKW@9`NXwgyy@3(SLra|rPPj_cktb(^&dz-ns z$rWwHv3H}N?_vpRXssY{<_d8RaKEx6+_k1GyxE~~|8B=y13}a>>8bF;E`exGW^vGD zqV`|VWVa`5tfbBs}BI2g2 z3Uk0i`S$oNl-1waeHDL5@Nd8Pu5IFyFZ4RjWOG3Nrq*I@R8Z&ut8+;~JZJbF6H8v< z%Cat3(GaIJ8nY)Gfpgju7JO3>`UB%TJ-3u?lu;bPXOycOCf;ODV%4Jzt#UnE!Sc;*~=_Q7$06Aq?KUjJqO_o1PPy4=xrCe`I3|jUV7aaJ=UsL>;z~ zsO`keLjfHbYMxt~EjW$tY(yajF7?xnI-unwrEX4dz2kH$U^=w^I#Z(=CF5*%dd7ZD z?{G6qSQXzDr58HXL}wVkm}csg&@xx&R{|&fWGW2lK<-f{-^{a<;zF&VrE}PF+1MfB zT+*azxTNys)%!pG2=WD8ltev(WB>z4(avcVd@<_ zgfP;hBPE8B86rqrSK=9>VMVl>fyAA)*{aR^0&$ZFtoqB}>I<(vtr6QtNG{bxVdW40 z2ZLnxitFeKcdAnIR{Zz5_V>@GeL^NfyT$cF-_V11+LW$gqmV)39xzs1jD;NrckR6{ zCCL`XquH1WAM$2{hpEzIaLK!tqr68(BSa%7daC5-m!U~Af-a8dLnR6@1R@=h570rwr%fJg-7Y8n*Pl03oLCJQkdi@WNdJOy?o0}0P& zQ#WE_*_lgKA2|wX9*Y8E1QC?M7>P+nY=mmeR4u3RIpKz&cRBpwG!4g9j+wBMINdr^ z7xj;bi2086Pqjf94cw+?I-Sn27hULhsi4qBHJbubs& zpa-MXiy&$N2lttSe%{PkWLV;vx00Jh+-KE^bc7!%ZggnR@zu-h>jcC8R8Npn-h7WGRs-9lL`a$hdT$H1E4Fvsno&~X@hy$az z){I@Cb-=Au|FqOJI0%gmvmmlA0VYQfirDj61eE~wG{9yGFw$y@ke)1mlTURp$l>Xt zwu#v@IeRW96!ORjN2`kw5)gH12-BA3URVMw-oPO{w`ggbBE8R*pgkr;h~3+&8y!)& ztcRUcaD33oL|sv{IK)!|4PXp0sa}m*jyprMnx$7w4gfT3Tbe;Y9|AZRMEpB)90-8 z)jQRy3jSjTq#J4&&l4^Lota+p$Xa>w23b>cdwboOBSSn5{*U1hT?o&uD5w7F9JnCV zT&BIJ`!cqkAabFN$Ae$Nmeg>d7K#xr-|1*IiW`~dTeHGYyTK7Lf0Fszf`RB`6jUYU z6O}KwNK`zC)K4jfs99^j7+wcl1y4A)jWnYL4$g)@Ae@4wwSEzy-{YZMs{nVD>>Q@N zUi8dYUSy#fqi?Z;$Cvs6w>DuklB=Qebha0|tt^`=?FjJ92d&^8esh$KPeGTpH(!X8 zYBiqvzkn`NO{Xndz-)m}qQeHaaUY+|!MPjxmr{a+7)1TRCLV$_<=r+b zn8F-I*ifDoRZHNR*Ggsao^v)NT2=FP(OYY>fD0^okl_BJj@TTYzf?Us2iPiH5+_R| zr4x-f8&EV=^U;Yu4y5r=$K=C4otfLY(uF1|ox8eTxT7(qcSy+yeAWQf_#<8yd32;i z>k&3D%&gIic9HLa?s5{lV>)V@FD5~06fm zL`+$6$>ucxQn93)+rCyKmG&xHs=AUv96ga_eky#W6|CSHtbUBzAt%L!##*xv z^f;k>ifMQz^<;>0Keuj7%bwGI_DGi}%;K7wp_^Pi{BuYU1~nKLCLlo9;a~>sM%%+T zYlDHbReH0~$mwaJF>Ah5NB=8!D2gG8=P9x#y!RQjkgkwOHyPpqf;WQ1k1>)z5|xgV z^&%dEdIN|7;5-DujCu-uVF(Z@>Kg9CvoX3sI)sA5wWRe9=n&GCvdi6Y`z@MdH4YwPh%+6#fT_uy$e8it0t?Xt>y3D5Qn4dYH{PYB&D-z z;+>61wAkg7sL`LqI|n|b$pITc9gKK2ce~^h1Vkulj2fnnksl-d;{=sa^koQmKAZKr zsA0y(BZw=WjN*X+4#8^%Wry0ShAs-Lm(V8f)aoQD)3Sg|5JORfXhz@m=It4t&8OQzqZVEV#rs$I5CeCI@`9s&pURIRu8(=(-l zFj!FEtCg@}8F`+eoXQ}yI9lmFUgH=~rNsPomdxlfHX9>B0cjaH0&0L}t^pC8a)?9K z%UcUERJpU06T1d@GAxCK(2SW0L3fH&O(L!{NQT6XUNzH2Zgbk#yb_>uA`bN`a!H61 zCn|{aBXbs(ET71^3%6V)GyGdX1gZ)cZ^kCP$4aQNfCTa3B{4!4Iu0|%;mkp8LE|T-!8Gtf?G3P0^*j1|CSbHyZPtz6e&qFYn+9_n> zD_zpkaNyYy>{-6>aps)b`s^*=roA9JyN8URhCQUCSMZ;%T-UL~jnUk5I=E zrx)y)VP<_@Nc+M@ek>zZ^VYh(t>bST09d*7rq^j+A$8$1+>K4x3~>MQclJHj;po7b6A zLeu{=nk=(^DSiG=yU|gpeMqW+G}%aYfLQz^V$lYjqvuru&}n#YP>`8~CIs`xE)6Xr zfd^Thr(QvOhJs2BcOFkKEe{t)xkox(r0^GTcDKX6Sjr@yCG4Ji^O!l*cFF3_nRn7&|j7#K7ymVukv{AUl^ABC3`{+IVt4 zrg3y6R>6@s8{W%1S&{Tj`M7A0LMaT6*g*@VMruFcVWji`$SZ0F1>%5a@wB{xVG=7! z#kEuzJAoRhsdnyDzEWHUxGI<-v&*3;G$XlJmk7f3!dL8^Gmtze_;Ab#TC3$A z3b}yv^T%CXv5e@FWD#iZTeelPu<~W&vAo+-i6wE#y1$4VWU48|2XgWT>-^nx@YI}u zhNBx~J;1f74>D3Om1Z)~L0@qH9RaP@2yJy786J)yFP~=L5hcb)_H07!N?rnIOL}rC zW6jZBL&0g3u7Z>6_^bPh5}v?KUr)e`cw~cbxt?@yyeAjlb0rnF;id~a#!d33kk8uN z-o$0CUkEZC`Hgewsz;Tn*GJ`?>m^oZ%yh=?wcarpR`UQojkqDxGcN9)@(z)+D~Hu; z32Ah`i=o}V$)!R3Yk%hPrTF<=wdEtZSD9?uzjH7QbzI_G5zdFFtoj|fqi`g7@&J-c zR(CkmPN(tlu@g++6%qP$o6{~lF(5R8q{nF~>*;R2mKTMri#gS#16T$o%A&y3Nye-_ zU{xmGCPu+@^=%iVm@!^q%|OU&?Zof4ttSwv&AOa+i!0Nu)RAE3fH$pu4JNXS)R@<8 z;hHvjgIaj5m%0$41>oxbAiWjb=0FcH+t2JS;hA{B`h=TR!RY=JNbfF zJ-$L4!FG->k$(*S${VI%+Aw@irFs|=0)MteDfn*JX+#Irhu~OPA?6o>K2lqTq$ct6 zX>n#V*oR{RVj%cj@QG0Bz_K4%Tkah4kta8piE4fb)i@w(JOH~z`2RbIyX;BRG zNiz;cd~9oRfHb9Jn^U-YXbS~-&{CoauuT`?Wg8X!p!bD&FG_)MA zrosc!m8l(dmH^`p&ZdIgVpE+d4m2R}I5h!ys3Gc7Rh3gVSmJ?V$G0_QvjKfD%Zuz> z1l(u(OmH%(F=!oeo0DuZaS>M0aE6_bw(^#CL@&6m(6(1z0Ue0Jnw#s^#@hM#*iAi3 zX}*PBHXiQ2w5OSG53s(0`)H?>y1JqgP@xA0Cg-dbYi2O~EUA9-hXHneFPj19*Y^00 z&8m(!clc3_IR1*^qxgH*Cus+i3qAXGhaP8&b_&l)KE(hvqLqVn!F_{4DuCDq13gfK zqt|*ICBst!L|A~QnhscFsN>_$nh_sXk13Mvq|}xX=}Vw3ah3p&x6*x;=^xG!s>7DU zGj+!t!95cW(GV@-u^~-ln+rA@GR`J>jvfXudKmQKG#MpvuEWj1H;du3oQvCL)+T_p zY8fYC*&c7I9_4_)f?%x!@u)L_ZeEyDqLf`+?@yD{6c4^onv&CCt`>yy@ih@PL;~JI zZ;=wN>vQ?ADYD6~7T&I5-T`H4k?rk;*JG>@QuyvBkd}i|s3$}7#5V6V6WeJj?1jss zOgBxptu4*lx$EVOU9g!ebMOYY8tzY->-1mYpwxh<{nB&<)Y)Km@^bP%<|_XQx!LuJ z5c6=8JlC9jI7lLm4v`};f-DCxeL9M#sZFqeIfI)py2E4BY&aW$V5bJ%f7J7uh!pN! zyTh}H^C;+(4yaog{DL>Y*;sFW_TzDfcbZ5gbu6YrBaV z(LwzXjzxqEV|^)VK$%ae53afy%0w*!c2r02low}P&6q$m*$Yc( zi*8aGx z0dJ?xqL`+~Kz)cB*>1(JFTh~>7Eal{>}7INcy;ZL(qRIMIs8U(KS6n1c1xO{oFdj8 zlqRJ^01cI#E8we{jM70`L`PNKIqGtJ`>al0@vDJuh(fc zTYv7_t4>t+IRH{bi(g6I6p?N-J=lDWFu`m3vSM{S0^PFSMBp_?dzyE0jMSclpKvLb zf7hz{{=Dtr-DvmX>1k3>E=~elmvXCOjeVj=t#3t7AB3(bY4Bno50OdGRC>B!Ul?KM z=8fkR#Oc=`&^gTrj3}N1Oj`5TSaMa}l8jFiQYF}M3iKnt1M>yA05uML|RjReicQiN{V((*4Ry zV)fd%vW>IW7C(QzIc;BXi8$|uGVxMHkV7>%ZWXqVt64Lm=wy_|Ob9$8b_Vn1$3%^L zT3p(1hJ3bZ1F%qXq0!G$&~8dfuJhCT1fpFn7p15f7D3?9v=3}!=gJ2wS*3fIbXMtR zhl|s{HY-baSd|ril3ijw;6?6H#9K)LyTLAgy1_1fx=I((mcHKlvX?B58Q6Pil_hsf zm%R1sjnR@^$pW`Gw8ejZgGd_>Ht4Fg zKOYZe&FVb=7>{O<=p_EY@XA`2XpJV+9;@;f$D{0c`Qh5*2S5IB|A&V^{xE#J^7#H> z_4wrR@qp`M$RT;$F1Ud!XMfzvXN}|7YHR z-q-78>T*A`i50h|tS7c((rgfZ-?1N5tRbQnsb51S9OZQu)8&fs>&&g$9 z0-GyRClui)TSydU6fXWvD``8wHB}WAH>3K1kbfp>?#pJ_lf+LzRPszKNg{gGkb4HLyJmYz$1 zry0NtJT;W(5WXE!ECO1&&>ycs%R?p8FwQ3DfVtcG%8cafgY~Kc^Zou{Bt{11$MgbsFwKGH*v(3`!yb2e8h!)fAQ`TwWmx z9{S8-+|N=JSP;j0?=)QRd%CYmJ59xt)@J!BY$Q{6zs3ePD(LcB1)=TfOPS0{2D4A0 z`Vd<$%z8>s_$1LOKnvQsUdkwvK5J|Dmx1z2N!;bfYs0nUIC*e?^@qm~hd(@6c^D5@ z?ynv{7_P1TkQ|>pUVV@}9zJ}elLDtX(g{-h%0+yc>uaE908`B$GVp*Q63mYXC<&z( z zchV>g%=2@isdaL7H%o!y2>d)B(`;z{4TtVT-S)Fh||2Nkwa# zV=%-~uckvSG#HzZ*KsizHUJx6?>+D9v;>q6$z)~1cntuFo85PuD9t)m`jL`Ah?*y8 zZ(I1Y!9Ac2rk$1n$r4;10*C3zs~J@};hgxY(TvSgtmX0mYs!@8!}Ra<(Z_C1oA zD$V^rVIz)4mxjI=qegNB*+YOw8lM7Z0zxqX;|8vIw90m6txUO%Ur|TKw?A+Atoh4Nlg6h##)qPaY-lBkQYh%0R~Y<@e;YFzr}&TGc!M z*Ow0;J~n&J4&JYE{;TQr8+AhV{h6@ON`+kv|A3UiU8}=Akw5zyo|y!~d9byU;+D@R zs!zx12s|Q`HWp#{s}Fi>EtyBq(iI~^Wk4`I(|8DimaCVd;&MV+nF5fmJ{KPnJl#16 zq;7N28!8E~-KNROY{V2f!y$1O>S&ebcOkkLsxK0?b};nj^%UWDtQ+j? z()sHs2Um_#2~tm$S*Lmsq^EQldh_{sb`Bv~*;IsN>;e-z-&_TeO&fj48`TU`M(5%* zGavvpvnA^@cpS8B7Odzl@S@$7M6|s1DOn7}@dayrJ>FxUMl^SOL|7YTA}oAC;0eUI z=Id4dB_pEk<5ilC6v~(|t#oC5j^Q9fi$$JKviMVUAIfs7$4Kfbx&MzEt<)3*JQo6N z!*$^MVKU)FiP_lX?kYv$@S5!s-zW@B%i(lw8bqXeUM1EbXj(AcVBB7a)1u&r7(Uvp(%wbK-{SOucL#euldVYuobGzcP) zT3#5X4ME_I^({AQpf2F|Iw}+bWeEz`nGo>pZ}Q3vHiB}63oB<6hJD>aH?S6X7ym%R zxcc{Yx`9RCR5yt5LRUy%?xw(y7LOC8Py=6YV`Bp9;J;J`c3*o3*#I6LyEDh=5pK|d zu(wqr+XHuERK}Y^x*dcqM6p{;)PAt|GK4tvn09KQhhM}q&DT7i00h7Y_3+3LsFb`F zHCR#^e0O^FGTebFof1|V2nR4Z4}M-m>%8HOgI2K@N`qC8ygs?9-BPO*-%?cE&1@^W za#|}0ds6|U3YXA9GE9*E4;i|&rN}YbMQtZrgfu*!s6punCYfKe3%LGvIZ)LkJt2J@ zli399Qb96(q*w>T=9K)HfuicS8pDllgr^B_h2dTXyW#2OO>700pmh;y(for22b7hfx)Wyo#_z624R;~s z1j!9Z#TZMEkx(9*7qyS5AC=uW>ga0YJUXgZ1&=hbh2RMktB$DljQtGx1|geyYUyz~ z6Yx2&0?b~Tj&fqmn8qfpb~ZtBKuAn%-V-%V-7vX00x}#3Czz&chZL7UmWy~!T}yej zHk8C9I`an6j&mOS!yn3x058$v(zm%{FB}h$9f$=^g>21$f!NAc@V}n9f(stg zU3RSCnAh>^oe^|WSEc#DjrYsW>$lGjci#bobZhhF%fqM7f7#w?(dF(e{g0TlOwEQ9 zOcfaCm28(pj(eUBlM&b~9dE=CGh$;39yJ2d`K%DOBedTT5vB(4q~lx-*IQwEEdye5 z`XP!71Z#pp7?6ag$+W{?ndAM=zJ9og{n2QwTYE3F)92G^Hf{fWS6J#a5j`NNbG3>H zV}yVJFp1NXcRTBW{F&Si57jI~AxkJ$7zs@_>vfs`b-_=>SatNgkk^!^}Vz?Uq0rty8vgQM0+F~W>7jO>fop`1s{~t2lN&Hz2|Gm5z_VtwwrF=4_s~f$> z75Ld})XPuWhuBa&ie(*2rrcZ|x(eQ`ZrMG;J{SQEMwnAfQMr|kC{wcA;W;lDi94s< z4Nza+ZQ}qa>|5FSL}}6Eg6M_`nfH^L zWakG}M3D`$5$3lqy+%PwQd3SnJvsEwS5=u?Sm0Hl?5Ma^err zEOA7QipMEK5zvFBA$F`6wTTnAAN||UU8;}%qyfY~G1D(5FgA~N?&>OGsdOuwYZ}(Z z@;df&U;+g9d=Z}>bV${r`dh2TiH*odC;Wtdi zbOTI2C4(6zyyxK2qa`05fi1$9)sUlBKpMcabodJ6;t=k#JDbA6vxnIji|J+m;7Dr( zN*zG@onRm7n-|%1FFx(JS=g(;J8HyncT}F^^@-{5k)A39R;2o;>>7s$)F$9Q)Fj? zC}x%cjB{5K{3Mw!APT~}KGa%&-=n-M^|Kw)CZi`*A!dZ|H-CoPe$&~}FcdYCXYRm| zdC(39mfA=#s+P?#0gL$tHb7k5#E1z=SsU&fD(gVL-Dn84yluGlnpsRjEi=kvupUkt z?OwTq+ls3~g-^Gud>0Og(cjY1h~#LM{{E``lG5S}`>BM}2@2$@;{69z*}3qCv{ohHRAZDnjbt~~uh4>b=psJaDb@sNTm^^5&JU~>!ME%hYc;f_94iVQq5 zm}d%S#0FfHju%tE()jRMTQQszPJl$I*m88le(4#oDm_i!jaja3kKauwwU(H=gHY&1 z{Sq-J?TxLF-{At`l#pbp!z4Z+yC^H&abhyDK`y2^2QDCtU@c7022MlILUi$X$%|3? z0W)1_hbhHRLll|11*r9N4qsoG#Pv{S25@to4-Jf?*%a6UG2%JCK+NSyue_*9Jnxal zVLBk(&I_&oJ@rVxzm2-3!vef`A+$t0j^Fy$4Zb=P)~y?TxuRn$c1Ok@VR(vR%Y^{)3Eusxn8r|QOp1Pve=zt6o-$(jdWR#6( ziBdt*Ns&#}P2;0dou{2a^=}fO$0eyIse=&~XXRTz1r2Y37oM~`A*1KB%W-_34k)Xg z5}_Y33Eo7>QjF}O^=1-5V@PkB_nU`BVZ`=>$M+wv!O9;dC!o$BKKu8b%~#u7hcDi4 zzIy)K>$iX2h4G_&j9EN%vM{R89;ZS@39X;i6Rj4SVQTJr-bjsds#gww`DWciI}Q%u z&u-oe@8MVRI97K>Zp^=Y`|qFiKaSvAPP2R&;?o9~%i(a9-ud}e>sVP?d9=0`g}zt6 zJYHK{iT>rm!$&KttB)S7sOMHzAFZxER{ws90hobXR5j(gQ(kJ z`#N2EOa@^{9F1X(nBfexK(Gly8M7SGj8uKDa{wXRC#sH^c~#%AA<5V&fGH=*pw}Xg z5&4#qPm^i1v%8m`lmAl{0Me7YWj&eCbOSe{mEOa3>^qqL)p%hyq1#}0L9@%`4DT80 zWIBfsa8flsiSR4N*#K}8Ej0s?%Z#iyISqj(0tpON7s}9w;6?J?$He4)#oTEA#QZ^~ zar%%X6C@qs_TvyMy;z-+z3AOIEzm|x)7E6X$^-QR^#Lv)N9_R&Td6E!`+yY(B*U*K z(&w(ratc~quPNk3+^CF|-u-B~LA%%IwRa10<;}0`;k9dnjy0=n^#&_@2ASLE*VwQ^ zSY$}g5+UUnZJBf2?~IUA>+g(zffWzF8n~^g#fCd(7vP?>#nYS$QHz!Wo7G}Q?Oc5g z1#)IIK#fkb7*Zq~xs;m~4-h}K)JIA~%sEALUYJ)Mk%E;SUq+h~wUz5ug}qD1pEgHI z63`H>&r{M&Bl?hgZyYT<&{0o%GH0{za+Wx&1FUrJR4cB9U>F zFPH>iN2yX?gyu?brBNM$U2C7&uYO1ndI8dVMOV+6I{2~e@h?R#G6YY*$p;gz(DR~Zm{xRP#q0o?TbUw8T& z%zi7d2JFs0&CsZoO)ui<5J+=)@P{??1dL@w&S@AtneWqPeQ15DU4GMh{Dx=vniD+t z5b!;$X1XZf5QT=w9-wbgM~PJ3F3VR*tB1R`g1n?;*6lzh;cGS><(asS(z?KOg=oC5 zG=)5Sq4LTzN^ldrKMb^c9rN?(+)2)nZ60}FnIw+cijxspeRNq~;hcY|1zfM2CrVeP zZ3@A`l%CUm+$cZtD*iwpi2b7N7T#4CqxE3Pkp6U`f|uNWP{}3{qNkCESnubud>fA1 zrd;3V%DM0;Ero|ET6|bSqrz&lCfm)8-A~^joc(8Xbn_Ud1Le29?4Njx2}mPx2hwH% zi$W`hcI9h|R4@;dtXnX_~ zDVX7b_3B)mThr2I&NPbE!rcjk+wFO8ZGy4=)4>7L=Ti!PasH!C{SCX?? z*9}wSyw^&5dV8p(jLDB#y@rC*8#L^Dx?ix@TNY*X7e1hPAk?^J?9oHhh5qCwb`Q63 zU##0I@UYBP*JPaM4%iKhd2oA@sw9kNcLI;uZ+?qyo}~k=pB=1M+VNxK%Wx+L1E?h_ z=Z)B4<_}U}JufV_g0;93tYXT)lmM--e$hR~LKXDisQnkCd_Pt4@k58yVpYIj{&sUK zwaJbNopB4w6?1GDDYEW#)q)hcuN5K$yZ=qT+?sq5yl{6K8}{bw0Q1iNmQ1sGBU-?% zob%Nuy}MOyQsjyw(p&BaHjnB&xjsj-SD0obCC*tAJ&*Hbd;EF^p^hc{oa7WjSOBM7 zaCrOEvGFwvi_SzCSr$+XecW?T7ZGOgU0Wo484F-!6&YE{WeRiQg`XH+)I_z26Zb zmg4JN4R7*BxcEiz3jjE;bwRiQK0v|0Id5dRrzI0F5?eGE>tA8tHrc^O{9U7pIB*fT z+JE&!ziO@PSNZS8ZwQTB`_He!e|eYJx9xZH+pkKc54g8}7aMS1d;fv9V4XGkDdvs; z%J%46vPfS=6)78Ui)=!JB#f0N7yBHOzu9uXvMu)?>2SR5aLu~SJCa?7C-LpC!X{*Ox4#G% z_Mw=83Z1jgdI_$fpkB7`jQ6*zXSJ|SKBdJM7uLuPaR^m^Enz*E`>=I|E6xq)iAIA*A#z+Cl=N>388-{ql#$4R4CzW$tPkOpOQdOCKMbkgWlUOq-48T+Y)<2b|AA zbvrl3>)#3&zXASEkT!-d!H)N}3?kvR{n`9AkWv&#RXEqJ<#V`QsbXdPKOE%ZzxsRy z!~+m$enSF&Ljr!GZL!eJZt*>|(6#ZKfc%;QazQn1@wL!MQ~&bv*CSVZGdmuogJ|>3 zwwqiQg)c=fpc<^x7%6FMLmF}=imbM5#zki6xlWXXPS=FYZE3#c5_|UB6`ahLh)y=7 zZJkbcD(Re+>OBFT(k9O`yLvEL)33=0>hPaU*j7zuEFQ6{VydpKJ#h)ZaaLaD~a6T&_{7CQfQ%JPBQ~fF}-OH za-^nt2s5l+#FtcVdOFUg2~FEH`RgpnAz`}7fr5Dx6FY<5D6vH5 zz&{(OSVuOSMn~o;t%V+8_NX4!^qZGhD)jitY>d%$@rYllRJ*uW&X;UOtr%_rk|E6+ z+$MuTHXSlz=dLQ}R6UNPmA>AMR@d%AHlJ5Iwl&?u`a2zU*Lh+S-8l8+XW487ODOsu8_`<# z;Uk`bZO9pR0w4=YX$$1kxlYff8mInGEjGxOGlXofs+DKSG~r(BY@0*%$Ovv+y% zq1S1c4Q7VBczKq7NDKA9G=?;}6Ek_stB=-x{9)z(63pHoNAdV{7M~^?+3|l2MCy%Z zlaKc&>iS!#yT8_Z_=t4t{}QP$=jMO>6&b{p&^$>`ub2a}F8|}|+WklOS4;UHAFn+6 zmjCf9=6`f%UF3lL=`G~iSjM!pV|A+Jn40kHN#KDO@$nQ0g*c&N{$?~gO)DQ(hjRFr z?Q^v}ogBC|&%Ml`*)oXp(|Jz&RkyR znITUnGhDQXTn%}|#k_wg;MB5-Y}revU$@&*xbt4Y`&V)CiYhehRLP{?py4HN#masB znnb+PU&6KaG>MDh1$1M*Fg2m@4o8G!oL1LV<~4%@wS*HS;Y!~ic*yG6{XRW$s&_`a z2fMS$sVL75zh7^^+5GtKaB#SD_-=dfW^ea!aIo`#wgJ$~^=BK4eESZ|$J-DdKcNbW z=r4$9MK+U&IvhBFvk=rfC2$NQkh)1d^`V%LotF>~9 z90zIn3gnbedj(cb?03|s54t7&p{pG7^XFDQB_YxLZTrBfFL-n$+Vg*2T^|-)9dqj< z##jT`Iar#k>K_4>$Vc!yy1Ga!Ub@X}w3}Rwfl|x{$H&n)0mjhfII!%X!RjoU3c?Q((l6)XarYX{JP(VR|Sa4gOY)f zqzu=ii-by)#$socVn`Qol{m#BKk*1l!8{bXcB!Z-Tn4F>aHS`^7agKhem0KJQ8b6E z9jrYG?oomjP6@c^()cX8Oyg+I^5JEUO3~xZ0Ji)%8W2wV=6vs6X}C^|qWfaZ(EMalAIq5 z&PfhN;+{tYk6ts?sNbK&pOQgI1o$mColD8|{fmxB-Z+>Fa3*x|3a*e?u|hB{S%`H! z8{(Qhk9POO<91X-XUn)u#77IQhx4tqeKE;dt8L#A%?{H4PJa5lBEQ-mokO2MVx{r$ zLk>*xr_WvKjfBgFt3}gu9I6p3ts$xL?;?ds%Jgj7Tn$OWW{XiK49o+p@o|_| z5i=7XoZl;!ILSQrLC z8ti1*}eSwg$BTL zV14iK{LcZLws&9aS@$F@CzxWhg>sr3xEeK@kewpq1wL|-k8~yaG`q|{aaCxF{JI7{ zH;Kv=tGUw$)UBvppI3s*!|oO3y!Z@AlLCdcYb@b*1uPN^!~t8pCcWD@KTn6&M8i>k z2n-c@3K3>^ivLR`14+V1^AP<3ZBco{bwp8})oDCkCBshL88^#7ibg%;arH{E7Q{nn zf@YBDU;m00{ERZxTJ;gPd*?kkqJa71+~#L6o*@c;uwt`QQkRwaS#K7912KmXZG z;4kL(D$Pd7qAgLAZTNUj&JvxoUn7KiTetwxd~tC(T(XDzJeBhLgESFGA9t*4%aU1o z5GDb6I8GSQC(L!JB!F@S=8#uKD4dDRK!|~hC>6$5fI2vrF&Z!7yyfG-xPUno(YkV8 ziL~YRxDbq9e0QLU`bbWcqKg7IahdQva61DDqd;^0u{n8Sli?)dr7RioPZ?b%9@E1< zZ$$P9>H85e+!PeXyr1`z67{wKI4}gc?;T9^zNgK8Y`9q{^Os4C(gR;fbYB7;xO@X|JWGFM zla%VB$N_^12U&{?0&!ZNg%RFv$IQ~rnJXh01oqB+z*BB^)7o zgaBcb7QhbRkRXFwG|5Nt7+&Y%T<1VZ1rsW4Zpzu<{m@rDZ&^b~iH%Ufr zDQ(r9+R<6-k$H(%pLQ_3(I$s3~{Pu_&v8${k zkax5w#~x|IxTBH}3Cs#!eel0soT88uN1|@>sGogJOr4w$^cBCW+bo3~KVT*Hiu63q z^nJ^CD9LLF4~rD`SM<01Jcb>fjmB`Y>>mP~M*S*u3kS;msE29kRYmU#mIlwaxQu#~ znjP5zev?$gp4LNZgtZiE=qUv14Enh-Zg>)psIE93BlatJup&FPUlc%K{Lv{nJ=w4M z*=IvbiVLCfqF>KZe8<1=e$7kO!KLaNpE*)BPbYs#ulQ5?vtvv+r+**~Se9HZsPeI} zT=7ljii?rX+NZjM)5z92$^?{4!DCyDLGjq*j`nMxUm? zxCR{{E<6+d*DRSO{XRo_PipNMDl-A#U`N)QZ-m@$4|&rVMz?li@azbUKt<;OkM$51 zc1L=9?VVYMtiE>Tc1&Dw70ArrO80utdTd)}l(5KJTRbYD4e4b88&g4aWI>U{gCF&+ zHzJXVyc-&fRe3AzW#pFh=;_zU1;)TZ%3(ZBP<~3Z=oEv*bWXBIbb{Lwozk~|tk8Bk zVjg-z_u(whR_OraTaa+rp;_(0VfYn#F`k%$s4l zqTvwLCe(wuy}l~BgPeD9jPl@k+*|rXN{8WK=fm5*-R;5N!QlJt{ezvo-NEMfn>+6| zU%lJzJbF4<>&iv3l*%CQv1mfhOWfZpsrZyD7Fe4eqtd&MR_$}FjL(uypis>RdA0ul zQwS))Alf0kBo)EoQ_7|QVq(3PCzez$kZYJ`PcGS8Jsh_@A;Abh&P~UNh4^6 zDXMdYK=-u0`7Fhv-qe~nPeZ5rdiXjmry%U2AsPJqbRvfN@+S;?nOIv*MN&(j>*t2Q@A{nzhTAZKxASoC>&oP6GHK} zXH-)^m8wR;X#>OgOVl)Fx}os$AYgsgTOoUYLm8w1Y=ycRqq!(FXJaAmp?|R-8#|i| z*Fgt3J!eLyDiT=q{ziM5pE3+-j$}70@vB9Ns>Rn{YBFATf^!!dct2WGa)^=$S-D^! zpk4$pP@?3LOs6Q+hI%5Tyie{G7jY@L$le)R0s5UeW`Zu76MjzX23c~Iveb6lkjhX! zEHr_7hl470D@=@bU~RZ9Y$?Z958i{(>_{G;SDw#WZ2dV>m1LY!8B9-lXrV;&fT6Xy zCykzoL4Eh{i)p<_c5wPm7#A`Qe&d}ljJ--4u&1>v!{(&AVN0vN}@euAMo60i)UJdDK~058drqY)f8?5gd%+5QMF*e4H13?jA1R@I&$5vpkd0 z7r=8y$X-!tU??p~m$jO7-nKZH}Z&nr6h!po_W=+L43?iwmDV2U^a7=>Oestqyv$ydJDcu^m9c3_TF>Ey z?2YZ*E2KvyGYk@2K1nE)u8f2yVEW&&JCISHup7oSpOR9W5tmw9{9*Kanw;eYH<2qu zS%q+P@Cc39=thFpQfnNU0Z6X^>wpo6Flyl~JetJWj5LEda7IMfp-`G6qo9}zr;EHm zBLKFAi`zN;IifDhtIwDQ$RS`j_?^b|bXlU{59@#A@;!Nh2Z%DNr79r|ix7{=}+ zy|I`lBo+I)v5EyV#2OolC2Dbn|8ZrZ7$&!%P7CxIA$9xu$CfE zsBKYg>*kBB;gICDeKW6RK2%EX1MCEmE(Oam{ehg0~MBP%;Zg8d@RO&ld%Z*>fXowcfI(u%*Qjd zEfkt$?W7r~mvs4(r(p>b>NLbBc1s{D_?ndZ`cJakOsCuboTZnYMs`WI4|Z%Gke5kP z41ir!w(GJEntEXz5ag%aU45bZHMy$R<=(T{EY_!VGB@`m1p#BXMq&0CazN_-9y9?1(sisGH-sAWlsSQzN9RNKA1wG{306c3We{GpV&C584?#_I!{Q0NuDP<*O8mA()n#A zMY>}gP5F)tIVg(al|Wn=d2!j_N}QFZwMK}8@~Xi;2Q}~}Kz8{T5+gC-SC@gZ>S7P@~5};O|)j=Dec>OIKlK3KJt%a;d%D6 zds5xbj|w>>@H}|>SGb?&0@OQuM1W3EzC8E_gNd<$}DLB|1w4N%aN@eyk5hB$A0B%)pp zQGCF!`N#7}kG^NT*jTczdi+LerfQ$;Xl@{7e^w+%dejl=g+rZGsj7T(&87wSIp zTLA6>>EB-4Nu1zf@-9E$E{eS9yj(_x#0Jvi$k^msH&)K36e~UldT+VwL(EAa)i^j) zSp2x7I}=HWIAlvFgL;Qx3#4?)-+@gN1gbCq%W4lg;1duAJbmE zi6_h*$RgF^!j+iLE$P9zLJ5}Gf}`vI#Xo8U;5@Kt7;Q- zB$B5Hi3$JgXT}w>%|ATLd1Cth`MN=|Hc}ikIWL8=q2c$hw8Q#h9n&^mbPDh;5Ep5@ z*zA#x<2~@N6ob->{(5iCUo^0J2|w=83ZCZU5%Wy^xpziBVMj!iRJiQ>csxr~^$3^g zRCTK$iV4`UkNu1>IPoyZgY4H1BeGvAZnUJL2kl182p8s71Yq;mWm$_1B8oJ|?Kzs; z!(T!#`it^F0`SIRgx+QMb3zYi-)I;)RE@ESM$A=tu;&x% z8}X0lb>(BLK&cyqFpG$`3mIHVX4{MWift0)BEpXSTEmoLNGl=~OlbH521;2NV1$N1 zX`c~&kZpwQC-g#T4_uEpGp3Q1cH?W=E&7m@w@S<~osGv2A3c8jRELSv>Uy$%DgLp0 z@DWRhqqbgTK7zfJ{3yJ@Wq_PscT`is`@J(xS0EWjeP>LIw=yG+K+tlJMQ7(q zt}hS=>;z#>BCeldb3(oeQVFfk1lY|vvM~vTG2j79 zMvV{n2)>8DH3f|LW0@2?GC?0{wDfRiwRh|$RI`!Ebx_Rlm2lDStG$aO{Xko_&pWoJ zmDj?Nu?ls>;p;OX(RZqKsogF=FX^Sz;6!Kw+VGW5u+}GV5|{J5^Fi1gU&YrY@RwGt zcQOCVhH>9&ZP6g3mTOw^*|TWP<)G0$a}*cGHHhFFm?cd|A? zgDJ{MB5u9xYqXTKUZc@h&R}X8VKzr2rCvwVJ|}$VWEt0viKpO0hpg`oT;y5_CoIvQ z^wfnqU@Hsa8L*l;9x>!-%SqE5q$1R<3!T06i=obhszw?1b;hhTgTK&KOf5Y-K@Y40$nr+0}cfd+wa6j+P^a9)Po?{YF-KWE_;^^ll zJH^}xcnp*Wv#H1-<^$l5K{DJ$EXl)65@>g2MaPu&Hk5b4-^e?1yTAsLt(sIye`kpQ zlAbg4hde{pk1o6D^{^xaP4-j6r?j8tEzd}#r~G+Ez_?7CTFRu-BDs!E#3?=Q9@wZy z4<0Tp8aE=A!W%gvH}DXJ1zpqPQ!*vigNX(3*2$^r|=4C(Bd zP8imU=z+Y0Nty~%I%Ok^k$;rg*13uvf68MqTnRvOS^~1!pt#5#fitDS24?j;T zrz^9TU*oPaLF-S1J=%83YBSB0^zfGR?AzJ@Ruk=KO?N!gTbKc@=r8aEokZr^_&Bi` z=^Y@b(&w99nsI9?hon|K)bmSHSQ=+sx1BuMC*y~>$Xplcm&g=vk*UY z9uHtma8sC|M=?v2Cm`VSwVd4@PFRkdV8sJ`zwD|!mlabtJ)%a}Sv+B{`d;*3*?(n} zxb)^(rvCIN?JrVW7$Xoy_|bVH?|hDR3UkA^uE|N%`sRTicw?v^vGDrA$9c%C*nIzi z{9J$SB++s|CAmf+i2B_Fl8rd_UPHll*4vF%iB{G{q7`$dU&fa}Zn(8OR1@oIZ*3)d z&~@FWC5C*vFOreM@&g`>lSF1jRiq>zZ*)t?PnVvF-d;+;nzhXTbSNo5+w z`Y13~GZhaA7gZcRB|q`}RJss?0A=h+%&|E;KbO*$Pz35+_oDon+edz8r7mUz8Qz&D zN!g1Ia*!bK&lTRkCzQM~8J##iCn8-p&QX!0`xfoI6}PvvGCaJ{r*SsL@Y1hze$Xq* z34}YI47#Yee;`%*kMKB2IaLK?D1w6AZrF1ids?xg6geLnM?X{^aICyOqHle-b; z;HVJG8qID}`L{!milNsXfsF_v+uhSSP9tK0B#NS{RC+Vis9}sVMT^WCzd1`FNw{da zNl6<-&Z(Gt{5%#l`c{$Z43||+Cu#@QXu+8=Ax4WNg=%6kPvP}dd`+i}Xfw&Pv|=Yq zBZe9E5tzQy?I%lyNm-&}2O4{@6eVsdS!7DY*pWgZtqg6)OeBBBDIC>S1HmgyZ1br&(#iiCF&6<_DRu-s7&rpX@cu*?6`zljKM9smX#fR(J*0d z{YY}_@XfFe@W%3*zRa5fn+Kf8+2WHD*ev9n^sdRlbG$26NAo3E-d#abNzXH)BYlI# z7)%kMdE%37d%fO?`K3CoaldqhA@fd>%dda)Yz>t_C{$KucWIurWzH2%+8$1=7ZfB& zFG}g6sFTmzFqUrO!Lwytm-=D+j_2!S7Q}zQ4&-J&NZt{6P*h1_ekN-d1Mid?x(l|M zh#%PKo)E3V^=Gq$L#yhTrd;GSE0OJ@9#+cqF&3C2%uIVQ$HndL3HS6lRysJSeMUOL z3*-}#xVtIsyG~{lYAEx5b8T!k&eKbAh}-@fWS3$ulBIeq zduNfE>5|wWUYG+hT9XoccrokrnQT1QuTl`}W-M-Cr_rSEMYgW%mkNTwTH9=Zx4TjfHpv ztFc9;Uoc^mEBe)4b-19oIA-X3+cD=&Ui>w=?tk2=6?(@L$c6?Do#50a2RP|AV@4Qx zs;L-ydB&AIhy2QUXGYYgfq=+5Qci@V9dRwtZT9M`DF6fdCSO>${VPOqV#S)M&II-O zxaMxNFfCi@k?TGj{v>@OEt_=kSn`M%!r#OEzUE{pe91OH>DbwU5o0Say>G2!!u>s3@ml(xWd)4=X%TwBtIQFZuW~8L18^ z)u_`QyRq$PA-Es8(4`o*bEXa=Gs(ZK84+WumZ3=d-l2j~bf#bDv*m;diB%R-G_37_(dkCYrU10jrx4QMn==ZCsY{YJF@N_e5N8 zTsPra!3JF;Q2FH4Ei!x}g)Lw%J!Lg!k04C!&3SyT630uH6VAc+M&hSR1lXg{B7M;) z=Z|MJmIGJal5V_qeaeiD=4AEu)le3m_SPd{1V`SsjyDgRk81OM)3}ZnaTboLjRe~d z<2cQJjy}0G-Hl^A=KSN@E6RsolwaUm*T*tpM~PRSwGqoa;tZNvNAo_(W}=p6eMy0M z@x8dBHGwdr*gGymHsYbwU{|c_d(7`mjXfn>vpYGs z19$iJwQv3a>jHRS%R#aJ&C_olJ$m%)v7uDGchs;hX}SH+Sv>B)l+y;RTZJ7)k?%{Y z`l5ZH=98e2@$;rJ)beY8SaA(KGWTP(b1e&p=fIS8iVbB*!ZVR4)^h4S3YGHuB*RM7cZRA=5Y zQzMBX28WU`W2yh9@P{m6_ev>Y2;WL7jeD8^CrdHN_l(XPlw3K>AME$zPFeFnU z4@Ney9Kn|t5+sY&)PR$@70Fj3pJ0~H&Oywc#?sC7?)2z?`m@eF)zOh29ktB>oRVkiUDy3dR0_|6_BBh-sHuQT=EX{eLGY>S{MugILs-N zy_Qq(Mu+yDN1~1}`7TZqkjBdjrFOb9>TamACe@bfA?-$gSYP||`jWEv;Ye9+QwC5$ zEw+ui>u^aGualARG+=Cq03=H4%cb_cx}ECK9oZ)c>{AqfkW!_afF8HV*=(0%p?tAi zDGhroEqv_gg9Q+i!2`l!So@}<9}a#Gf_*aD$#yc4f?Hfqf$_dP3Up7`)#zB4?E$78 zB9^|5(6Ppd^mM-=e6)krZvv^$wSx0?XUlHbxTn4|D&QTg zD;=b^Ln_TLLA1WtwWo~0*c8xM+kpRkYl4LbUw^H0jhNy*wt{b{@lkrL(@fx#qnFEk z!tIQfkI`dOJj6q6*-$%^LcyAq)Y3IH=oy6e76C8SF3^APXu^?*xXNxW6ohiu_9Ueg z^Uvoc%B>aN3(JMu2H+RJP$Fv_t1t2lt$C%0$Tert zmnZm!qw4qfg5CTS*quE&0zKEbNS!0~{Nv%9Kk>>PcRzQV++p#gZxVOP%L8= z>Sej>P5hv};Ob-Om6{sJ0BU=2zg*tbHdmT)RX>G(B_r~9A#mhU(u+DA72S{i_;Q6& z=RbLZ>CfcHv?T8^-L$)GcZ`H-KdIyHpvoMj>pned9CQ`Kf!mcv_2wNmXCrtB#9~i; zNg=0<#W_R^-~fF}661^M@vZ%UpY}U}d(Qj%H_`oWrGNRHoepM{Cg(80;Qy+) z4nlVu0_f6tq)wlf`+A`Xvg6!ABt|{x1Qt^%Kug>fuv2bH#ZR6-RThsO;a4JZtZLUi zk@n)j19s~cwe_TDQ-N8aiU$u&!}c_n?x|^|Q>`hR!Ua1IM<>_;EH%ff%GpyMEINbS z3DC7qi+@6zUg^!swe!$qRtmMSN^K=~3vvZ^j^Qi{oYLYD1`^3q$>eH=)*E)dLdRrs zJ17m)P$nw@R*5R!WdS3HC+(RlZA{v^Mi{upoILA7xG!HPDi;_RO~eQ+l@O1%DZD`Y zO_K4d^rC&P{e%b!XnE#304x7vSsF&P#2H2nekprRQZ$UMs1+KKV>wsvo?wzaA_1{x!FHwTHo{LT2BzR*mV!pLDN^O0%(^d}1wb|ZGA8A#ZHX<&hBnx)J z1H@AzN*UJhp-A%r{(I7uwiV88gI*jOATGhGNV%i}K4HR!i6Z!n3P@lbGcuU1zo_~tldIUx;1?ps4YRb`R z3Nl8@%X;2UU5H+ZAJ4s~rF@M)D1jvpjwi9?N-eE(mn@L60hh^Ujo@m zJhOfK#3jPF+@}yqvxY#jy2CvuNwdm?*D-dLleLf3>IJ;Ff4xi>&3P_rSX=nrDA!55 zrSU|d{rj$sPV#ZHNK-!sP6?Ez0xTah4vZfui3_(F+`Gr4<&`M31I?6OpKFC-*MJFrQhE|w;nkSLStv4MGZFE03y?IGbn1(0@y(X z-w!XEfQcq;Ftc{I7cpwl0@QvoP2#Ev6t~#$)!Bgq;T49BkuHD><)Cz|YiCm4$BM&` zjSP1&Nz41Y(OU1>({?k0G*_>$iRruDLaW_i{nSpN_7tqb2yF{t{Qr& z3x;lx?pU`Fs1B#o9jhv=?y^_Xv$7Xgoi+NNBT}e2JA!U~hkyKJ12Kz6N4{5FcJ4Dr zqkPzeTC84FN53?)njdY8rzjo8{&e`^4T3vS> zbY?}`W$(f{rE-s)_l~_rW7!v2-jUtJC|Y~tDhmDrx9-B=!{d!5yCfsOB$?Myf`?RZ zS~!L)Bq~$id6Oi81X_Xg%z$N;`cZcD3gpdF#AOVYj zd8(2Ih~LhgQ_sgQI$c|7OVT$jU|)`cfAtS2Ojq!?`&}-+nvX|q8p~!i4vGH5gzSP` zGCWAZItQ92=G%!hfl=9e#BHHh!TIuLco~3|a>gwfcXP<1Jko{*RR|-zI$mBK0*9^m zlCQp$+Jl1X0Nr??*i-NEn8JzBwsVNQeGGfPpY>IwPxN5R=4cKTs=<#u0@ux z{`s*3_HDz8`dT@kzpCYvhM|k1w={A(py+3K&<+r0?}Jk#u6DusRG)^nzc1LVPF)S* zSOXt~o8##tt$N(pA}e3}T5n)0ueE0r+X=D<-pI=Om%$j$K_KA62vU+)(pDiLrh)>b zag5Fi9Vo_E$Dw1tUL?aq3H3&b5N5st9QL5wp$_%9>8#cv4|UvYAsYiD{=~sF z7>Y6vU)QJJB4A<8snd+xLRGFJ^FEpRu*yY|LngtE$c5Dqm}x=!N)beRl2%ryXD zW66{Bdu}`lMO(Cr!T@AQrPS#P8!4&sEe#si+=7N$>+X%4Fc&2q;Uc_&OU3nT&IWCJ zWKxnJ`p)sUvmkqN`W*4lw4)~i@i=dxEw|G)01>F?jvA-OcwqTZ1?IoA0-O*xUc>fhzHe zWpPbCIQ}LI(src)gr%3zVrhw3O@rfWgYtGo+rV~*(h+Ut;AMp|u z0@?rZ%l?B?;S==!FwM({;{@xxJ!~*&_a>7Y^s%&q=St@_3ClG#q2 z`R?P_$yu5urTaLvRiK$_$7jw%_76>phssP-K2+)Y9(oKZ(AkT{fS4hGi+}C1khjTr zD&S~9MmOc~>ZH414QounDuE>o0N9I(5aw2(Dxzy)4tLbOtJqty(5U-L|j26b;$pOG3UWs+wkovBQp zjY(8NIx(NnN!j#)nLct}hStHZK%q;1!FJ%=hU?43l9dHt<=c+#9a%4KoMtxcp0&g4 z9VySO6_In?s;yq@U?n=wDR^pm#Y(MdMS;0h1GF?^ej>$cZ;DqcV${X#lh!j+vN^(k zQmJg>cpPa**kdL?``&bHxOw}ZLRT#IlS&SY$H2{VD6`agoy5N?#av=3(N(@%;pcZ! zeEm<~s0)aDYm>9WIZV0I4J9R~0$Z@Vuak!~#`#rr zm1DgYMD!w>mC=0!%cbd&@kGmM$@k3HX;ED-!#DA{NV1;`7yI}WYog4`6{=1{PWZFa z5T@SrzC-^CEib9$T*NA3n@(D zk2iOIdQb&(TkpwRk8uJ<@iDP}pvZOfC6)B0w z*rd;~I#o`?Fwrm~F>BNCgBp(AgIDu#>)8{xQ!geVkb&8!t$2zDeK`l$ALgu>)M>z4 z0rFG0@%#5Qh3PQ_%CC8taQit?77;e~+_&E{9=D$|jxJEz1ztr_j zbkq1>vt$NBEkk0*-5F-H0)T^^$s`%2jvA}>uiqZ>rZJ4Jh$8VUAIkVre71rs$5w2e zrE#&|H0`?(wTDg!MdPVR@k;Xj-8_T01JaLDA$7$UgiNS% zODycn`b)btW_TfYSLb%#hW0vJx^CgshA|#}$2i#(58|~{);punjbme{L04~m*TKN& z-m9Yx&O-!#Sal+^0UflU+5qS_1TQ*U%(n0wbO#P=Vx&JX#+h?U+;+f#Wu(85CmPnc#THH0TL#0}?Z=-h0%pD-=60 zy7(HaV9<4_h|3F6Gj)nN9(UtiOxnAXow1Nv%BmAsrF53@^$C4-iQiXT-;?edXtjWA zW&szG)#*5{(BT6s9%l)DFvg@NlsR|>qnV*QioQiCIeLoZT(U}LBH&KPglG)+kz_pX zA#M*5G15xG{bUpiakxlIjHAI~u2|c25@YQqAZ$7h%U*;>yDSU~@^nX6_3X+);4|*D zNJv8n<3vxiG!xPDQ3t1n;(4;Ou9zID_rhK&;{1Qcm+=A7Uxex6`8q4B$Vz`3)L3DG zjzmPrdHTo~oUQY&k8@@a#54 zKMsSz-0o4h4adWDZLN%5A^YX>sz`X+@t#!O$hTQ)RDax$_GQSmQ0d7l;Y1LB=Z`PD zWJr(sN{+vKMk*ZB3y|rbhhoU2$V|Id@t?)&7OQJK*{e5a@`kBx>7-M{xqBOzTOvmN zIZ7tA4M;PLs3&#B0bvA8-|yG-_Fm<4wk1Il48NBgsafyEsYsbNQp2q4;u{L`HwS> z&(r&B>$ov=O%ulbI;esddXv_hhECfmE=W5|ERkzlt{|%)QW$20k>y-?m^`^lBcBxJ ziXW#S#f_qK$~la@as&d%3Ia9?Y@pJQ&BmWb!qjaSrZ zXH;WC_=GP4kamgso;m5^L=w3)`I<-p$ zD1F}Iht3>2;p{2iedbKxSF%QpNXm!iyoA*4s!UCVsds?WJboIHjL=hSRsQ>t0l84Wb{CEAvN&gu~~w~Xdwc^@K%Mph9dJ}s@})tCpd5O zE#%)toMK6M6+Wtt$wj{v=emg?%!*9tf*_Ao2gKBSJTL!y6E1@wL=6>0p^xO}eRDk4 z7sox!H*=ZT^?OXve_I&z-~6DL%(_ccQSs#C&R^3?6v@+MsAvo53GEwdpF|?eTOSfe zT>d&*4k~U!`R$BZ#N>@JrdDX8n^zwXso;YK87S>3KKQB>o#Bp~#FU(gD0afdHOO|9 zqF5kz-3nEQ!yFc+)d@|*SEVt@ShhCCM@ zM1xikN#9&NdNXISg|$mH)Eh>D?M<~h?c)w3AeA4lS-bZZ*s$kAFS+=}z!wG&iME8%FKt;Wb=wfz$PB}njM|cftawb}G3Z@E?S3Tb07=OWDtu>T4&C^;P$2sZ1jSypFZ^^W9 zx4%K&3L|j{y4OmhTCZDa%T&p`{zBe6lTSD$(T~K;d+o&1`}d=^?MW;Rm>6qwC9^iP z7dK&mY$NRYE<2WcF6tPiU6HJcK^;fe@gi<_Z}8MswkLWKA-aVK?Gf1R40f36_0%nj z>9-aP!iE8vBWmIKz=~>@fHz*0(N>UW_yYv$#|gD(XMk~IKuK##u^UaDl}llK*{ zkf3wq99C*0_d1>Dr_keeID2T5yg+W;YX{ZSD@zm510Py7#EP)Z$4fOA1E zM>mW9nG^rB_UO^$XKwt@)2ENtzQq6hI`Kbxxs5;`9kas9ZHgTUZa4*k7#CLb{JCMhC#v0LFN1;l zu@Uwy+m>zmMzyuB`ZDa4M{ExI@0;1~EGHTyX}hTdQD z6uexP$S+6z=w(L@OPKLrE{idU*K%xsiPqu4nrdYZMl`jCYqtNqVch!>9Vx()*gTB0 ztsEr$a+r8b$X`b$WlwVSJ$#+xHTWX8OwKQtVeZVxxSq%{PtTS&?3$?P`&^vQCR8og zgmT2@0zn1jzq^Y@%Q5WFooqU*)RtpU1w};N6(P6c0FNUU(tnL;*x%p3rug3DfQ`{C~9e_-Xz8UwiWC%lZH7od4FUdt-TJY38CK)R)40g93ut2P*+vXcOc` zA*MsjzU;h;%Y-~uW+hyCH>r{|Mt~k9?Cl_2z(;90MFZLZ?_7h5u3j7ZHQZvx(PbU5 zhKG$UAf5YEy(9WN9}$fo8w#DHqodt}S2IlJPQ_CA&*LbkJPmLV>||%TK8gOCT=!ot zvx&EP(0J4>AJa&5{lw2I>%_fA1pOh7J7>{#K8vnIPAo2OLU&qSv70;^2@?u=qYtFp zr1;>!j4B!P#}Iu=l-dF3qBljqOa%A0?Stj6omqTI(T4Vxa^MNAY%Beqs*=tnFHl%~ zJ!2Qm{#x&ePXObl=)>9n`%idP9f=QMI!ac6E`LC2A)ZX|I7Xe}`s3wj+KV2quSfPi z@k@A^O&jQdw@#}WI}Pv93?nQfOJ*36+Ylj%b{h=? z+Y!-ozlp&aI5&>XoP?&qQ9Ea3H-&|`%>h-La;GdqOAJiL7uXtVUiXSrvX!pr&3FWx z#Q23CJ^jGP_^zAVzme^e2U3=ikq1N`^JUW~YQqSH(!0kyV6NBu8OsmG%(O43Sxyz$2&{&VY z{nniO4UaIIpwIR&d7YH#^zzz6zpX`5b?1mNVy>fpo+s4+?+=52VmNT8t1coRcUTBR zUpY?e5ZSVsELsf>?j}pFY{+v-UZmIbBj6+U(2O64&kb>Zyg@HMO z<4pK*(4rZQ(S|B+hS;k};jCDr(=y9BilWFXQG1mkepQ++b@@DZ@@@XQbJkTc4QRas{SPLADa%bB{tT&jKr>#DV03sBLg<-st?lITH6|9KvX^X^K&Q~szT zs*xKyo?W&Jj~u(dv+t6eYJ`7KN$biTK=Q$P{ki227LmK@d_ay1s`q#fQVaTG&UP!&gqOIFY*33$h{L7~&!4S} zWcZ2JgtxG0XG9SP}`wN3?{=9N^$&KdX$xDHo^+8m==#5 z%eYuQ(8a@Vp8fghH_z7AAF@jJG%k!4TfK_QRlRdp(`+@(%hmPAt81(1F}Nxg4GyB! zDqn>w6Z+q+0)&T8*B?Fk^PkuL^auIJg6a!eAm{KfPK(;5%RWl@46)jKa__ad@v624 zu$~WL^O_2C&q+&>eVt+kL3VK5{&AYqRvY4`BWnd6x*T3A%sQmhgRSL+v~QN5L^}{l zh;m5hZctK}KCLtIG(%r;j4RAX@wgOz!OqjA`d3cP@=l$Tb}xrwo!mE=l*xI9VH}r3 zuBB|hDvu*<&zaPZq~j5$78_~zP5V-B=4FJ=upk^eIIz|I2uM$Vf5TnO%ad)x1MZ;*5Txs&*}?SrNE z-#R0_9DeaDYWDxR;B4j(%OOtLD!K{?XCU~A<$t+=(k42ulK{}tDX7{1_whH+zH$72 z9z9xnw*JNc=U4Fm2`#kO75H6#ZsxUbr~$B@%_e1oOHiO&EgNIN#Pwm$O+`jlaW(w7 zqn`rPD%B60Rb0_+m9wIlcwz&I#DN)Z|O%4IOdmKSXX`UKr z;P3Ub;}p}59KUwsRRu~NxpfR|9(0r7da>SLN`3)7A4CP?nbe4)eo&`W-%7*cE(^{I~w(sVo1jtv&i8 z|NTnxpRv%U)c0>vp-E&vzPq|k?J8VZ^Y&d*!i^b|JLczE`rqs)e~U^>7Vw`tS-Q-! zm{ydS@i-K1r4x>bmZqQm?NY5<<26&P2NJ9=B$+>a7;RD+7qV0mlcO*IbkU}u@D&-A zZQ6Y>^t@oF_mVR-Tg*}v0YQbUhy~|_7QJXMi*n3rp&?Nc;!Ba@E{>*SEI)-g9;=H2 zW9nrn_)#0m66zOvX*52rUo)D3h})H&nP%}sAitHg+tz3s(ry38N5ne=q#H8nH?b~N zjwN;4#=hHA8jg9l&yU|&toxULcOw7QkGDAj;T-wz$)hK({P+0D+Ose6->)tIdF7aZ zJk&;Xsli()U9(T@nIqpE#>IJ3(UBJMx!ljvv91e4`9TrjxID~f!;7S3yNHp;l-i7h zi4Nr#l7IXnQ7xZvZNSpjrz5z}-6p7&sh`jf>J1q^I{wwegLDF7QJf|DtQ=F__wU-m zCn=@ALI#JYJdmI77~i1vO? zWAr25AM_CB;LUzYr&u!_-N6{DZz5zN!Jc08(D-^nfJLDSL=Bkey)zOY&%G_ZHSK6> zgpIRwbK|=btz6c_fCRppXR9JrlqrT^#3d|Ql10LY3B~&KLp%b}zD!Z-7y0&jz4od2 zq@z*Kay05$ac(=KG@p3F+~_&9VkB{D%u9R=72Y z%yaEi(%l|)?0P=(wLBE25yv&(2*b^#s-t%)GDg=qLA&b==D|EQ(GPaAH)&B;`n1@(h*OHx zk&Y)O1CUPylM)gcSA1SlZm#BAJ5L^IPt4c$VQgZ`SJdMgM-xR(sJMwevKMB@-MRym z@wTVZ2T~%y=23|G0G$^3w<|0S5_&t>;DmWhwsZc;~BoY}L2;0n=JmMNCRQkyrB~1u3K$ zUR+ar2#at-<;E<01a(?)*0-!^nCR+ekdV69-SF*DS!=?8Vx&PhRq|IQUdd=b zIZv^Wj0`&G7E~-ych0eh#M?cnMFXE>>PD!s$5k`f*6U6hZ0+~Vl^XcpfCq@9&izoN zm(sN1@eE<8sD-U$neHgOc*+9fUp(>Ja6{36@6-=PlV1xzl*msz(ep-+Rr=0DMr}Ux zQdFA{{lqo*ON>o=@L(x=z?VlEK6p{&SVA%5Fm1{;k(|!XDSjG%k-sOFyNc5>U0qQO zqf4zJy-A8*LGUqFb>N~8n1O)mrIx@G6B%mFrMx9ekU0!YaQr1zlz}_!gNH~eO*#?% zK-wC1Dhi*6p{?H42!I#k8mxfaB?&!hFG(w#2VMO?|V z#L}0O9BCQt7UyHpc`p)W1bg!;mFj(BjiQKs?#R%mo;If@HvKL0&wFd0z4iiNT8p8(QI|8+p`S`emcisUTj6 z24i_OF+yK3cL1;#+coFxmI`GH?8V@Ir~#-IP*40R5me-~)X(Vlt!)Tx zEC>>6w_R@ycdxm9hrhKk>6OM2=T}@ig880My6OFawW#0cm&swy5!z^klj#Dx8Crwz zZ`1X(_JwMtG9joZZq+K{8}pd5R#izPN9oAy(Zt4irBD!seL3Hug~a@ehOb6e)6JC- z^~J&Yt^lSRRp5U4IS9~mH?ZJ&ub^RY-aCT@FmO)|gY@4fESL?qS$EO{uE?4)I28?D zy80a!&>SsAzt3;`TpuvH-=!OyDIVwJ(P9wf2Q;i1nfVNDiB#HWt2FS*;i(8kvr>4b|v;(Z9F~i{TKOxi;cIX+O6%c z(NyF0`}gmxaN|zP?nJGjkcvULHGK{b*LQ9Aj@Hvq4Yu{&UySw83?J^a>+9Ad^=lfu zWA!#wqg_B{A;!CJhje$^_IT?t2b3hqKeZ1hMn0A0Y=S~Al^TWXJ6$QxZ)CZ0;=##7TuMz;RW@n z%7A*;PEk{nPSjIO+_-^wZ;i*MyYYXX(o4{|OR}0SZPKPu(RIb5Xz8fd5Go}uGPdxJ zcIO|lR3JLio+m|@9+=W>sKX#I`d=Gqu^7K3+TkwSAZ~kmV-&#fdN@u-{xWvIiI_r< zDe89^Q`kCQ(3q~qc)^gp`AhKNFYW(j)c;G+bMaMN+@|bbJO7WzYfqm&a_j#+S$p>M zi~q+j>HqQV$DO~X=nw$^Yq=aW4w=0cWwYM4|ZGYhLY9Bb^d7R6^3=YmG>9F*?H=`gK?7>+_F zDYIdQddg=bE%#2#AqcOpfI*!XWZO%s4deY=IHYxAP^^`eRJE$nLo>6hH4}@*lj^9^ z+q)LXH8Xt*8oUZjmkc1~NuFsBig~FyjP`+=eS@K${nkT6P7-;3zn_$VWNBpB#MoNQ zbUl`rCm_+o@AnRTWJ$r4LoOr=n0o>C?i9OvGH($lCQ~w5o%Kq^+-lLk*wRm*+HJ*8 zpW;1P3E?${4X_ZzC9uWKd_d0lxXp!vK+%hQZn$J%d%Cb4!iGJKDPHe9XmCt`f9+*& z^5Lu`ex_}qiNW4T#Q~D#v??^!15uAynw_IMl-QaK^M&0(SKZlGU*s8oK}dDx!0Gq* z%;rCR?$+!EJj7SyS@ByNA>!12fY>@bKG9}YRk*v8l9!W$-hr)KCGC;V|CVM!fu=ykv5NYm^q*`HRlhk5J`ANzL;}#@UTc@9oq%09q6wRoKmPABeiL>@>!|4Mh z&8>KvR-`*eZ8CCJd^(g*xe^Xup<=5ss~NpAbtE9yVf1Yn$1Xl30$=1~icK~StEqlI zr(Y9-@l~xSXnqLC7~PO@+~V5YJvE+(%Aor%68sm=-{HipeC_kwnskCTQkKkn++)n$%VPqBV- zUUn@3OKVD(AZI-V-vPI#h%fowz6bYl%=>4jNFLN(Kv#hS5gV5Wo*aC{Y()MGy!oVp*Wy@ju8`;_V~4HWpHm8_WW;WqZYGo!&d5jqgPiv5A+)Bgl;W> z4=WI@`-Gmz8YUr4s((eWivV7xZhLE-l5Ej>KIWJM<{?uj?HYRK%(g-=+$UTRUoJ0i zm_gy`c^0H5&$_y6;8o<;2=azYRWIEu_>G*SQy|8s+h$;VraLWel_n5@4QIx@jT7Ec z^ArF$VwisW>ncA#A6v65bV+C9_eff-#B zjeC3LKu_~#No7%mdDplm87I=}YQ+95Q8`V9=~+4qM6`J0PuPkg;#iE}mBz+a$cht) z!sqx6B10VKg>d-EUG-28Ix!&k>RL_q&u?sF$EJBzsq;V_yTJoyYgduv!O3k>L#d|V z0#Quv3kngSzjo8{&e`^4T3vVMbYINOE-588Hv8s?5=!&srH$;d&$%+u-iKWFJ>JO(kM-`MSOO}O?9MXBOFqfqZ^ z1JJ5`n0TS6r+KGCfPQv@z#7THv_g1LGGEuQvdku}5wk!x;$~lgoZ-oL^|?~lp7wL4 zfX&}Jq=XD?98;S}(J7{Pmq~T1zIOd$JeTMc%tl;8eqx@z<8Nt=BkXn74w%A}JUTN= zH=wRz0PQXg{ODi*T2gVp3ol>r>swFg#==2(GTPohT^8Z@oJLY3qQqGEjH97!#f(`4 z?6(nrqXhJD;EgiUQAi!Sy~gOa!@{L;pBVC+-FT8jadBSiCYwbOUqcIgN+<)Xunt`l zGI2@!IQ@DBZ<6>9ov<@Q z>Bc}&(G*Xo@Dv-w6vroGBVQ*Krrz=f7Gbg0E8v2wFc9>`w*rh-rT;ny8m&M5Q!PB} zOT?2&prKGFrB+a3O^x3C@8c{!$IQ8i+V=ncu(y9Wc)$1h0E9A3hcIBvR)a=yz zX9?lWI6g1GOR}Vh$NVIGk7bZsaQvJCcj^EAr=}|};akE4W#XB4Dkd}WzK zg8y{>uHe7VOHFT&j6QjoG+QFDgu?BKNZ-I(N+P0MUk1r|2;;Z`rO(HAwR{H&h&rRV zxaPeGLLZzozw5>HY}_I_11al5%@xnmFS9_eQ@|*`exHw$FMG_l$BYU3P42M0B9+I~ zoi5;!_W+F&tRXW@;4*{-6uFq2fx^5o>wJSRPvNVx=*=wSvU!x01?|w7h7eGMOGXJY zJ>y);G=n=;gsx-U0;4;*nqm|<5(%iRH&bxc$0*LOqany(`2?d(p${xRgIh1HxLS6O zB^M`2B|CkIIaVf-TKYo{9pxW6rvcUrsgj`3g!BvmzP*2VVMUCBpq>v?v{(^=afV?@ zibnF`tBVxwVMQ`b;DS=hS;CQEe-jerOZr;)egg?pbR*|cG{lwgVRCb+#r)^&lf{G= zad$nal4-OKH@$O~24q|>W>@ok)*IIgYC1axWWS^ZJ=!u#IYRNVaC_t7Z_@qaKpgWjszt zRE7+_Xl9d)?TWzhU?j?BD_Q}k#wJ_31&;N73Vd=@U8n=1H%YT}GMjwh;qXV)WWF^9 z|4rf_U4ZeAet@QT!p)F8m`&w<=PpqVfklK{yK>gOu3D8%z0Gv&WN0sklX&5&XqXHi z(3+)2)8!+eOLEIXs+9a4jwUA1QcS{pYD3pGRldx(nZjQhyD8Yn(*4gT)%n;2zcjH4 zHS%MlSaSV?z2c3m+l!bXtu6T3x6yjjcp88x5YHFv({T8|**I^C@y#$@3eakZJG7

    62_AE)zTIrxZY+LD zuO03`|C#6AWl{zTQE>Af^L_2vU!G>Na6%n3J#n7TT5;q#9~u-pZ=F4W8u@Z_&$YUu zHyQJ7aKAauOF;_q2`=p^$oqAjf_%*yFbj)RZ7^&2y3U&y!%R)O@3U64J?F#rwCw}b zNZZXlx1jC$W4;aUH^(_BWIqNWdlF~qbT;PeA1Pf@2~!B$O98ztdoU{nibADtT|(94 z`RXqz=AWqH8QRAaN>`Q?)io--RIgG=75Iko_Hy3e%Or}UQ8GfaRWgdmKbq=5QyLW2 zxhnPXKMz(od?crA!t5WSZXy>670psQAxdhD1+t3SFpbCPEkIEzahV7(0SqtVQUt4v zqS1`1ZBcAXg(VeJJvmo#i9IGiCc_!x&x)<_^-2VNpZ8?OH<)o)NDOBsmjrpCKfT73 zzE|lK5fu;_H&h2wie?@fAU@0|7$6f}p(h3QNtND0_*Xd%6y1yyo;;p7L-RP!^H0%v zo{xTC8bwESei!=0TWb>yhjHI>dU=fTUX}f5VqO-!=iYt-wTWbT8d;9Y(+j@ZIeVAK zBaT|bG~^uQfYtEgagoF$thqy7VVoSo*q~H(xGgPG5}NPH6u2dW61zovadFl>^z%Wd z13D*)VyZLx0Ot|#rC#!m) zDxv~tkD1*kVJ6V{z zaV)en9=oIGA#S;GLhHo|EDdMEb{fmh)^~5ld0cfT;)&Kf>Q~~4w4$Id8P+hM9H^pH z0Z{ZQ9Pd=yAj`AW4Ba(>yZ2<-ghV9~qR&>uIROX#>iP=iSSM*h)KuUwREaU3*u*&m zM;*yh0}5v;1WQaU*}@@brDMswVc6x=_aVd4u!^)!jj zfrD0TJIpj@e>_`uyOf{To{Mj~^Gl)s?(=_#{QnGV_>CL@8~y***6a1(*PlIEd-TQs z?^pK!Gnc)cAD-!wrg$h_dVG#;hTnt`R^M>2X%r3g?=t$=^GN?RuWj3W`(Qy>To{JJ zp+Z&!7O|G{iUq8)WM2Yer}7yE&qAANfd*s{NBLqy*B$tgl`~QOMD#-J`T{Qn8S-wp z6`*G^`FQ(;aP528b{1Us5P}wU{@!JK`=>M8iTlY)p zSm`mzr(Ho`Y)OSLbjL=(SlKR$=gvZZ;nlw$|5q}P($r0E&Kkq~e|>%JX^sCw^Yt(M z|5xGvwPjz#_xoLb?)B<+fyz*%hQ@C&eq`?ydbwmI+O!M8#l$i?$jKgEt>GRpbJ5N{ z0i{<^6dGC7=|T;5IIY`XYJ&|2GgtFhr1CDSHo027GX4{?_Fku3}0E^CmA2m1aZU%x+Y* z!8gBxZT+mS;BWbC|iX zELEv@TxKC%|0b`;;R&iStAQZL=Eu4!TyQscIr1-xtDS~Ya5W<3UiMD^kqoQ8145R6 zlm1}wX!K!LUZ{Pb#AN=?R>c&_B&VX{3!xvtl7U%RI(X&Nc4+Z46@R;ld0^6t)Kv>& zDRWTrBhDxzAk~slYG`JMn-x62(AnXR{4B_wc5?!(eA9C~#IRDcL%M?aIpw9L1TJOi zm(p5;o;H;nn{fsW12NXg9mnVTcJZ^hACeayHDC9p`YQ|7M1)MXZY2cOSA0nJC>x)p8oC&o0rfVno$AOHGPzE7o^ z-2`AKo`Z9TN`|4zE=tccD$t6i$*YTWoXAvocznQ#bDE$P5RHOa1duM$(J0CIK=F>- zI!!scEhrv6sD-={;h2~U4gKag&(6_qqB}yx@BG8VP!#{Y({}dpb@F5Nf(YJ7^E2=4 zNy{lFs2#-RbvEp%pXW(6n83an{BxGfl1_Ie`uk7qj=k{AEgl#Y3=z85E=0(%2&ivY zUxa8tR<&*M&kP;%Y_sE*fIhcr_PN^v+UMg9&(;m_Ghs~^I4izZssqR`b)gw{-#*~_ z?ulm~TET;Nu@j3Hzs~aN?Y%CsE7|0d+PFm%ZDrkG9F)^@+atX>R5BWi^A7jFyfbJY8@G6nH$d z|M+snzaaYX=KuUPxz@@~N0@GLp*PNV^R?EJdhfqoaXsU^%Seim+^R^=(`@g|djYz^ z4T5vRsJytP?=&zv)|pVn{pX85_IIfNwbgJpQvo;I|DHTqtLOiK55DMse?|T8t+lU9 zqD(5;tsdAPX-x^s#319{Z*H+MPf=>KRW5}a(30{+BB(4i{NkF_xAH@@0oLQlrvtUTo zoG}x=3AhcCuo4!I;x$dp8{L(eh58de3pwkZ38A+`(D2XTb{wkg@~>0!#fyvUZ}rmf z%)qY|;6!*y;r2^GodnMU`v+ilyUZ%vDSqR{ym_K@q%Q62z?6t-&;#bd%0Y=T2NJ2F zu?97w2XyoCh&q5J;V+Nec>uzN2gl5p(cCeoJy5c0kM5yIs*AMzYnqMr&M28%A3o&A zl#T89IQrMWqI*XQeskl%F5dcXAEyF(q>F zw_`Ju#MsEAgltC(F!ta5ohB^*v!AYrNHB}+E_@4Umj53=S%2in|Bu!lJ$w8`{{L0v z|M0RKw*c)2!pVyy}qoIUuIl73R- z7IDm2f(k^OH4(UlIHlzU6+ZzCmhC=StNyt@V#DG7hjcuad^(>{xtGR(j_#1-5sXLe z6z)~t0(eH{c{JoZSP}17byfPGdi4Rp`B-EkD=y_R7Np$o&l|vDE((v=fp!hI^`0}% zJLu4X*u5L_^G@E~(&F}y(^!;MzvW6Zq`TUA4DD{miZvZnc-g zK>m}0SsVXf{qB7}%g~TdR(uXPR2nDG7pz(PntoWXY1=*&mh9r83Xd)yssr(%ry@YP zFc3Gl``ctZO&E?VxT1+ODO7{ykYMY+3T4G^Epj(x$gVYEz8ze}T06_vJA(^yIYl35 znmS;WG7*2jPfs?Ka-X@e2yu*(X_=16V`Dv8eTrG-Ml|SXYwz91_q&7l+lTu*TZ65) zo4em_zwYe4d%Y6vZvU_nec0dou)Tlyx54h_`|Xuz`@_M`yS?3R^k2ZcJ7@7}*}=x$ z9(xkKPe7;{mObmM2#ykf)!3#Ry@)yzc-QOAT_b*W=WZtHmG*w8qN0TC%B&->2ox_R z5aE<<{mbhk{b?y`ao>6{*v&IM;Jw$U5N_sYLrbOPIGHz4Fguvry&ud@QR7^ao(xD7 zo>RD)H!Nwx)21OMwy$|DL9?$>EeWv54Ddrj2|%deMU-OdUXGVWk?9I#u4q=GA4Pnc z&#Gu7%4i6K2Is*Q2~OFMPTV5K*JdT&p28s(5^-&Ry&KngTDWkgS4D0b|*GjG^N-O&abi{BZ=0N_){`q`8q6Hi#m)X#&fwx zGS_KnJhj1eyxx1N)yOJYKd^JrdcXdlo4iY6bm-c!P_XaMtrJlYkmiKN^I*B37_fcG zg1z9P2URke@{28sm+&_;A|;N^nX9K6HsH<4G+eK9%k%2VGh7ujrqy72UyGux{7d=` zzG_N)y1L$5qwkxtk*Y@R(r%hi=jN|;5optNkvxdZW?MQVZ#!lGROb_n?AzS~9t8<)B1^J$h~eCHeZkjpg zqM2(`IhTat$IudP2{J>0%qn-N zV<2MbVtkrV5CTed!gKcmR>$=7Et3Qz@cQH&Oo3A?7&CFQx_(0Qh2N;ucs-GFK!<2? zhkIu-UaE0+13umDW>RfyhgeqErNF17Y%IW(UuLB#SB$uxb3o%TE{bb0Y?uv^m@SoQ z0a?i?WlOUkJ6uZ-ArQ0GdZvIwz0$Fh#?n#frEuJ48C^sWf!IH#qTmDJ*VJ#)D&l?b_cIYpJiu<1cW>Fu}hv<)v(l*DG~>rr&vQO-t zcS2}N?{)60XJ)#uEwVlB57tm&?=(hzJ*Eu0&bvL~UfsUKy9ANQ|ign7QmqnV^x=K+TwW z0f#II>`HW%@NTE%Xwb`7n8PyR4p0L#hEZdD0+qm|_-4*!CsJf>R(!jr({KeY&*^wn zAW31R84&~!%K!&~%3J2Vf)rQqnTZXcK^WMH8kNROnrV!+vwi(A_m+FKN}8=7_Iyi? zA`6}oFYTyR+)!y7SU;-)cVPDtOKD`q&Y8{PT6V}_Q>mq>HI(s!TXIn*cv_nA=X3@l zxm}337hcOpcN6AI8IuIZ{+jfgz?@)ohzR~IKcWEm3TE+B|L<*N zkQHDjA1!{e!YKkMPIzF@E?5KnpLUqb2)Ai=N z-M#(o!NJE5+xvs9&3EqxueQJ2*~KN#grajn1!>nJcUi_*Qi8n~1TidyQ}=CFs5oFg zn-`xrX)UHERJ^dp@aB@r;&QKp+H#tl7S zPT$~H-+EMMp5|l{VJyjyi<42mO7;`V1UZt|ExO;p0gBn@;k2kIY2;ZXZEq#(GB6FL zN&r7AXCT{ONB8*-l*DCdzwIB8ohZ8BB}Je~>G^oH7zFu2O}UvHr2OG76`f`$8H#v!)`{#x z?77+qLUk%}L%N6r+Uf|qCiA7V$28dD)S?AkO4S|rgBJk00n3qhS|e{2&hkxTQ?I9O= zvkYh58r`(bYBuW7E^`~+gui+W;;s6cL+@c*x_2?hpZ|f*D zZzdKmy?Y0gAMz@?J^eR0H?`aat;*-#!Cv?|=3uUaSId2Ii#b@bZqoEaI<+l=k8#m`5pJ#Zn8j)-@9eK%|3&7LQV6$65CVI+`77V$$)*% zjJKzWfG=fBnh4d#y-TKh*W+&VA#KTQf7WrgbYH!Ty)f57w)I|^Ux)YeP5f-3?QU+C zLKW4JGRn9b{eS<@{~I+r*rurNk4I6St&WljI&7S>N1sX+Bda%YXJy?Pw=?MbyEx2o zM)EQlDIef|cZDn7%}T@Fr|!E`zry*u@ZaA2Z6y_uzFB&zwbrKi-I(WSQv!$xDx2>j zEqwT(;egx~JMz;xPSfH$`eu+9o%v4G^HhGOd$o7YjRv0W=8LN__M7dz9asw6ryFP9 zRQwySI?dRrk&+n*Hh@uuj3^TDVpmX9F_h{M+snq+LX|@0kTI)pS*GV17BUm=zh`NI zB|g%LE)Yq{hp{9{G~>*|Bfum*yQaFz z0i+s6HeIC-^ZCVNx@pZxv?rF0Cy5#MOjMw4Ul4@q5hgq2aS!I995WW@vrJbt{H3O= zdUU62ok7BS2&O5h1BS3-q0FKXU&KM>RIrZYNjtFe3V3d&37&W2#$Y-95~S8DdZm^c z`)t=05n_REX1O}F0Ih?39WCMTT7lqIxeb9AUWhP~pEWAz+Gx1Vh8aj;tJpWALGQ7D z!G#Y5ngECNQpg=^YoMGUJ;Zr3h?&G9yz2VVxT=^OvAKD{9@P1Hj}g#K@nUV_U^Xb% z%jRr8nQT6M{NV9-pl2Q?BOni_FaEZ_^Lp?3>C3k}uXlevc>C8w@c6@(frN+pLk552 z$TjVXzXcA@f_|+v-n7Q5PD<)kcWMm7Hlc$lt>Qd`xp>uJH`zRH@_)3%f5TXqWs-o@ zTmRuw^(DIvO>Aw~ zU?Lj%?fp^;U)lAjY3=+t$ zSUc$umt&oJ719hLqYU$;gfJp5_K+C{#-Q*TM8Dip17SoM3V(0Yz`{5krN1Xbet@16 zaE&qBf}QnJLmB)Cj+~Q3wod>oe#|85P+K;fKdjgc*8!5+vIgVmtoA6KMbo4j=fg7U zoPiJyMy0c8Gr~VWTs-SiA63qlb2NdRDwpGQFqVS^^OTx`NQx@9;@ut%le~WHKpO+7|(ifgR0BI!e&U zXbr)PM|aZ-!=MJ%ucdw)-72T4>9}BKpFx-f-ekE4!E>ONQ(t6d?OR>WH47|5i%!iv z2wY+CON?6Rhafsi`1;dT6EufPq827)I@YDX0D(gKn$ZZJw-xQX)`&wRzzfC2|6(Q0 z;}?YbiX!(+c)%@V`2VoB&tt}*rqxvd%EZR*L1ZYx@4~g%_!l6~!nuL$N+lkzkY9%Ixd7J&VgiJBhj^pei^Lw3F{imAvKNg4 zCGUa_bOZ{}sz@)`0ZuP4Y&XgQgD65)Ot_a#(hmtoQW8)8B)-b$RnM6_zF?Q&OqIb5 z2FYkX!N-q=qY`RG=P^$x9Y_#1Nj@eMbkgFp-$1U_~laPnmyjS@CJwkioR5D)zlLsGUYe76Ier7SP~kAl-6nAv z4BX!UYe1C07VLNGyQdP+#wo6M&bvKPju&CV8>-j9R}-x2S|pKGxfoh%LbuL*4Rpzz zEh5O{gUOt<1VVR2cxwu?k~=H+)=;sM7>(2k@5pqaR)auu%Zju+c%q@TO`XPhCMci0 zTaE-ScA!}pYw>V+1hg85as>j*dwbHR55^R1X-EIZx*jB-rB&#IMuB&CzrH&6cxP4W z`p|>ly4wbVB;%l56hr%|A`;*8CgvU4^i1ahoV{c#DH6kZAS=KRA2akI9*BO1*d8Jz(wGDc~hVQ47YB;n|VaCX}QeI-HRfk5c=$fu%;QwWQ*V5KcHuKkp}h; ztop7z$U$Q5*ZSv#W41Gvr^shT3UU)FZ%M}=!G%soDLWO8tpy&?HDT(QR|K~)jb|YI z6Oy`Xo{`~b1-llr9J!5vF$EROqK{`Y#&b;_Na-U3vs-i`xB-=A8Jplj&rV%`*2ws9 zXT)_5#lws5De(_~iTvJ+1nLt|gEuJMOU3}P1K6>?3EGe9nFoD-1^KMPgLsk8rHXo6 zHSn-?14l(VGaKMpkXpC!E|bS76nyc94Kf|T0k80wipK#}ONne)bG;PcgO_^x*hK^7 zTfEg?lpXQ@qV4%mFCjP$J;SU~+IIM?dm3$DaM#&;U!*Bhb}=hOfEG`R;f#z(QyYEN zg7xz87W`yc_tLUD?*7|e_A4X)6`>+kEM6pRKb$jJ1QGAW_Y2$&K7TGhUq|=hdqp9yP~K&lxN`=B9wL z0Be|ok!6ryWEgWHq*#CfM;8rssJr1~tY3;u&vbaqyV?D19#2Fn_5L$C^%E=M&9nBA z&-ERc%ehcDt|ZymncSttIug~owm}hben0s_Q{D!m&9t~eEicV;Bj`p1+(lZvoSAN> zDS%HH39~$(xfhD}M%FlQXZ(6_g^-q7gz`>knli{cl7M!FAJ=i36mc;aUq$FYG)YB2 ztjgpHL#?S%QxDS=oTmjd2i0tDCJ0m9uT8(C>y{Ah{yY)duVkpFf2dV>o&-*VX1tNg zTVbNnzI79zPLQud{ier11hk@h5Wa_gN@ibfW5@+ip)`vVu!G({>!Rk4qd!V^xdd-7D8)8|3ty1!#3%N~nyL_b)nF^4T_(UO|1vP7f{u`Dz1af*Ce_i^ zHa8Me1hbul!4vPN;I%~70AhWI1nk|?G>d0Ujws{A(%IM=_ znj%ACsHn(CuM)_jBvv6q~3Ws%#5%d2a{~;&P@hf%QY!5+) zYrb!H?x;mM?$p&l_RiZURzaG2(p6F|kjyj_cj=S0-|EoS%)35$MNl6ZI3Pjt<~<_r z;Rp}ZIw;64u4-Dkn!rzjmBU*nOu@V1@feB&jWO`{7GOqd-PLX8VIQ8?RJaNQ7r~f~ zi!>wO1tMj4OTVd)UczxKOm9Q++U|iK788?|l)+4LCI*ASn9WRepG>8OkDS3cD`qm1 zrEhYWvHbwYEiK#qMciXt3$D9sMnXek)%@F{8F4k10tF|Yf|4C&InKSz^Cnu^wc4w{ ztlb3_R%xbJ6)zY1Y)dtn5NhERmC>G6m>keVSu(wX&K*^2`@$M`K!8hJ-;o&{G+$5n+sX!-_~prfs7Z6ebZeCFzdO7Oqn z>Z5w86#<^Wg%^ze`H4DqxOR{5ISncsk8%jj?V_z4^BoKMh8R=|4$uH8ux(tv*T?|;U&y$D{nGjzTG7vy!1IRf;dLj`Pz?TV~+(o|! z^ocYl(8@ZQs-j^s1P49VCT(w#OzqqQmrNfsArj=7x0VB`i(+0&19g9QBLVjv5o zl!b6va+Cua&vyJH`T$5m@b8DxQWB}WZj>o{B=3EKL(Cz*?vkK!VIwKJP)Q7U!{KfY z6Ykk3yCDfd=#1Cl45L0-n>J7#mG{~w+RN##&N;}o)WRVObz{3|)s*eQP(&JF+dY1v z=feaflH!08-#=ZK`B|2DS}0OSI@D4N%%`D+_#~4j56y&PLpN(!+vUUMssXxkx~ua3 z(7=o0)Zp~=GKoJB?lJu9kU}O!_yH_m)cHGUsGkQVye_RXtN3>fN4aE!%`SM0qH%Q? z_0QHh@(`_sMAu`jZNCJoD>|+t#L9kjwFc!D9R6V6bF>Pjq|lNc*iTufddMg{f4$0A zPROSg_kT8e~ln7Fq1=d}Zv*~LxLa?X=CN`qPhfVgEm_P0@o zByQW0o$;YWaDxo=#qfWuY{NQW--2L3_4r>8A3WT0@V~x){M`-y&sV|!soQox7|^Ty z0+4`$a4t11BGDkoUc?AKomGV&sh1ituzDjGNmA%7&?AyiCBXrNyb=@|tcySi@7{be zVckm{2B*-*(_Cw<^UqHPq^pdZ(BF$7y6oYHl(jO9^zm5&L^PbE3<%>&%P}e&*eFaO zS`hwTv}I$fnzd|jwUdU$loIf&_FFc%$}Lygh*j_UO6RY)x5B+&(#g@C5&XJ8P$<@#|`K3Y+Tuvu8C9A^_o`BM$m` zo=={3tTNqI?JV_p%}u>w^1ea-52Htx1A(mN{~kQ}?t7d6+j_ip{|5i@o8bR&+cg-( zIt)nl?YT^)RQd~ubQSr0Fs392g;5Ym+$P6sD>fy3PPr))HiZuAi-Z+OXNVeU;!_%< zx*htop@OBXLcb*O5aj1*;*0EhB&21t>(S(t#vNCV=Blk}V~#FPvTt1W1xuU#EEvQ7%Q zcePoLUP+D1_FltL{kOXmM`<>@Mzhz6?#pDDHkbzVK zOP-hx9Z&+#LKvw{0X6WVMq0BnsYJRoEi`^UGaTkZ4DE57+akb+oRjM|;GBD^yM};) z-eVa=4h$SqRvtJ<6LU(REphJy!vuC5JV=l|Z*ZcZ9RsIHS(mhy(UPUvI`mHt5|cLI zAp~@MWHI zM9nQY;3)wS2WTl>M<|UMyu6Hu=a8ZJZbu|CSO!Yo`W|;wWFiJ6O-;f3Ay=bjgigBV(vBP{)YZ;R^0B)4^8FP*2$78m& zzB9Vuf6;UxvriHg)2K|?H%Ga7@7%azvliLa!FK7?vvmiGW>U<#j+%{jM;_#jMNpa^ z_WRTLLvl)Lg*uf2)_F>1q0<>}KyQzTA>D{0I9gAfS4@0Tc9K&WAF{E9f5#?8G_kRC za=F8Sgou9Q=f5UExJB5WT9k{ltk5;UqR;|?i$_LC?>0XCWjFb^vY2qShMC#r>v>X$5=pJjB)kLsM z1*}o_q4BL=w8L)vEC%v|lmo|3fni#p*qJ}@<4j<3V0ah0tw8|iC8oxXOB2b&JNSb6+k*)AO!^yY-6C7d>#<)ocvxTG91@Gq78KEfdwn0^PG(%p9~2N z^B%rR2r3ns{CT+!i0F(^6$k^0o;5fnaJ-q$;8}rFl3j71D|?81gjnxf8zuB9U1o4; zFy6p)awDgBM)Yv$jYLhxmy^1%fRI39xr5ln`t4X`@T()+v4~9e^ODCdx5dLQ)z3C( z>3VV6Ls7l+16bgIa=k9` zf0L^kC3bBxI=@ETYUV_^rI>Q3RDK(rJdect6u7<~e8@EeA;71rVY46Gzv&UjI$Q4) z3=tBp?=gq*Zf|!8jzEn4D7ze4rw~QyXD8ITG(KB$L^%VzCeecQO3^h;v`hPk`ftx6 zMj2!jWJ~C;XvWw@y%1nu^tGwT)}x$WT?z$fg8m+3SHUIdr0rrzXIh@^0h9TW2e4oe z@X>v@$9R?VJT62K6jz#Q2i;g5IYgcE2a0Ioa;tUpPcD$hZw?jZb4 zKM1@fb`IIuu9BJ-fABJ;o(jmKe!_7IG$F=({FJa)=xQ+G@Q=dfl_PIE&x-y?%pc}Pj zGqSTXKZm$EQ&^XfvB5NpjNm!d2vX7y%!^W%uZkTS8CDsa;gP3ftsumG*VXk7ciU~p zB~Ps72J&UmShq-TfNj5st5}>eXjSjGO)JQua9>lU8(Fv+%d>Tf7Sd^ak-W;|z&t)F z$`LwV!K33rUY=+Fg%9a*?MgBRoG~7HTk}GY%63On#?{-mK|b>;V|ccb-5?Gcc;~*L z&-9nbSx%z;oi4=&FnOm78JF&bv{8|pgqLj6K8MLT^}x!F-Up*zL0 zrWb2LRKRI#>XSRUwjn}^a&NIj?S?jm6e*}v(Ywh&Tere)L@)d*IkV_#S{u-j+1+d3 zHSSeY^hi@CzUAEh=swn>27*?}0d{Kplg5L(k=Kpd8gUdZ+0d0zGI@KGpH==NO)JHS zp$=p~|JqL{dn3ea=+w2>FfK!7<{g?|LXOVVw8{HYvz2~D98SkPa-D`D2-+C3gDBu; zi|WutCGDfU)#4u(U^^qmU?1c7Q6M84w3L2-9hV6q8eD!cpNWGCoC* zwFuzvtDt{1YkLxEq&_m~a~gAKol!`UyhQuirS?W8y(kGAuBgn9lc)AFWXy19>*)P= z?W`6;%oc3^Uz21ev3!MTOor0~B-sS=+Hi&83=yUm`DpD5PA$yi_B~V%CpU}k7^+~8Nc!n!vzcD~m zn2%8CCdf7!e#wQoel)HsMlv=xFVbo}KWBu0x+$*6CZ0W>UTbqUpG-C%K7R1{I|%-T ztn-in`o-V&cV6#3KYjUj=k@Nd2XFs+2!bZ5Xc^~iC}!E;csjMT`5Za?)dTc|l%IOJHfqW;4Tx+9Rr zRi~>WDf7w4#A-t4#DmlKRdjoNx5xIM?K?mCqcp?P>|CnfTXg5GM_|Apma5;{0Uy-- zH%{gSTh?^&HqWbm@Ur<*030UZI*9(EdXg7Zn`kZr5?zvGl3$J!o((tQzDXAFbUrUI zd7O?ojZKWWCu2UZq!S~PMdt&yl*xLO^rDL%jzvN36p2E>);vt1Iqnr{O(Hf%GshIb zu_o+e37$XnLz2uW!wsLa&<%X#d2VzNYc~23xNk8LI1YGkN+aevxguSrkW@IGxOoKv z_36NQz9?&qQg78Jt_nPFrZtAotP>nfs540ogKsT|%f@wm5`F`eqE7g1sA{)7c}^7Y zOwR*B6G|i?0xNxvtj1+t#p2ecLJxQo3aa~&aK>H*ozA=k$m+vU!SH^R>zZ4hx|J zy|BA%BGbG`wy^>#zaj)xL+|nl#FnsS&3dN)vmbA~7FvkRq(cK~$;*@Y{r<39q+h~= z&y!I`_{E~b1;^8pz+M3obb_s#*&vMieiU~JKFu}~S~f_AMsk4)o13gY-X;^8h);nR zR-5u(#vy(VM);!YgEE<#MWK|!Xgcn%Mg&d62tCz%u5WW=Gs?YLpR(p>BhIxvt(5x4 zbGq5}!zD~)}*u0tlW zck+r|`8mcMh|exL~*Zjc&E2s+_T-P5rBC z{_ZX3MgUdy? z!m0Xv2ibRw(IpC#yC*|+nCXJmoZ~f<*`eq=iXcnyMJ@$~Ernc6k`bnH%>>`oNqS5e zg97L9{T#dPZZira@kZIL$2dVo=V$mQb&7r61$gS;=8UBFRAxULR3uDBOdpB1;Yh7X zw(X7XI*{LBh^$Qi?}Yv@VFjqS|2^2+`p&`s`2NxNH~RmtsQ>pOKCA`zkw)BVJ43OJ{Yr zi!68po_4U=?#*-iFZ2EbleP)U|4qTNxn|f^zyI*&KXTvy`#1QnU;h2~@3{ZzZ-n@& z%k=B1L2}$*@Q;Q01y4lV;!KC`FS~eca@by&(Xsp%e@P}Yt9RI$7s)8i5(ga&k$1J- zF+40}Q%i!Fg>x_Z3LeaoBDTQWy#+)57)!v3d5T) z)*A7xm(z%^(6*cSRz!~ZBc7iU448&+h6i%UmHb}~*{UtD)e7)f#GbZ9V+n4c0JsW% zSI}6&J=U7=ewR&H`(yxPQIg^;0CPg>e#n6Z+Mlu>X^O~*4e2UYEIR)4WO$-H^1)>u z>)u``4Sp40bhp<+ph4j59$wBi{_d!J!dSnz3Hsew9z>kV0nQ8t16r!RaOVs7ZIF>c zRYez7cC*M_hv)1NE&(^DEchP)!pP~^U`3c&SIu6}rYe!$_~6Q_U`% ztT}y|Zn)@isTDhua%z1PwiTr+?1#V2(}LZx8QmtmK3{S^F=5(DhV-Xsf&Yv@_G$9m z%r$n~63a%PexbEfSH&2jSXrxIwX(RJ+R5`ZddqA7jyy&6^QC!d;Bz5{G7F5rC2DN# zr}K=XFA$27_S$I>5E_#~DRzy3u};R{!5V%9Y{XjACWxi>rO9Z{x`OYMNUKAr*CiYO zU>xajXRwx5O z?%&RTUW){a)1&0ru)_0W56bwn;$-iJj_%bC5^B)pQKQQB_C!AuJzN@cD84bQQAH;0!EkM zk-%Sa41NogTKH6-w&ax^*rB5+r>v|pu@ z(!5_@+Wyrpm~tVz26y_&f%vW!d*x(3?m-ryBHIu0EJLchLCBz5&R(7zIQ;`zI&!$t zJd1g>L1f{8Q_4+%wJUd6v+S;?f3W=p5DNC<<=Vg{{KYZ5yx6(=NE~DgM_!iiKRaHt z(v0#R^Vv=|80Q7)%CI!I%hK?Xqb_XXZJINRhT4PL zla>vdWlvaxCns70gF1k6CqJwg;Y|c`+?9y(jWh z{bw`&uTDVW+pXn!G?(s~a4=(s9TS$5=G}~>K{e^(YPce69_5|DHBZ2lP!(6f*6o5| z)VF>TSieC+nf#s%=2h~BpjTv(Qj+Ah` z3{BRBuwgYR$ff4COKwI-Aa(>73m4R9jD=cwh~aXEY{y4p1lUU7ty2hybq4xP>V}`1MIK$C>@gh3bRNwEU9?N_}}t5;C}-JXkK;YGlvv2uq=Ej)TC*` zVq)|^a$o~vL#-eVMu5p*tVlLu)Im|4ajG^Ff(-$f^~xaAMKa(h?+G=t5sfD441If` zSUws>sR)gjT#4x8TY=~2&YkF(#(iw>1UudAhm#0Wm*dYTP?}HqNgKL)jkD`XHa|=d zb&Bh1R!y;2PCgd8@}SWPMWx=zXaCpBf4X;fYf-Qv{_{S_dZzwo>$~r7;y=Fu`7hwi z-vk5c7bM%sZhF=&UDo*}9S)O>3pZo@V~vwbpRybLQv8nk-K?cdVFNeM(;iCG|#Nt3yH{yVqJsJq#njV*~X0_9E?`8TRsem=Bf2&h^AM* zii?ZnD6J+*Dbx+23EhNu-n{?znOaBJh%c=*V{e|T{J(c>HZ=db<#`y>9E-hLU% zVn_c4Vi*l9;6Q+*{u3ei7_|t(x43GEV6^qwP8e33ctt%(bdmT*y&U}Z(#?Ozc_b&q z(fcfPx)Ki1ci#JUN>zFO2+YnuudBZ8|+DnJEV~GeJ zBA$p}wi{wX-FeFmxUhRUmJ ze~2D*b#zwJbhsyF$c73GIrSTE_PDXfjp%{YjccW&7AII6#EECr3SHOUR{)P@S8-S# z<%jGiz$NaV4yHpJM>kNrc5j`Jc4#Z|RT_q^XDpq=-B$qBi+`DTwsTSJSiQ`P=8<`OghufO5gXY?+Ot6Rt*B<8^}0)_6~@ey zPZ>qZZGSVG|8$@gtwhPgv}+z3qXn~=DB{0Lrx0#%h09=GSPXMzqbb1Kj|~VPSwfrf z!zR{{W7f;?0xpo+8fbylINf{m%fbFGH1dA;?cv_R{^`#9oxN8(KfT)RJa}|^zuVOE zfMC(h#%aU(|Adi_eADooN==18lXMwXc|JK81u60LpU^4Z0}$x}TB^g7l;&JB?C~@U zs4V;u;#L`z>EJlr&|=b~7BakOw7o#3w*2I@p99F9_A%4v?m0X%u-&Q_fLWIDPEL7; z*73(sS}gGOa30;>P@dRmu?k?py4IC~Q}j&c&+DWIhtY#az5DBo4>0oDn>VZFdV1?N zEvmm#iD!fQe!X2hTf2iU!=HHWR`W44fx7Y%3ABGbboEk(>&!-NK|Cpss2 z*9oTLm1jCT$0Mn_X3#j56a>mzpGMzz<7w{7m9f)~h|PZ@{m_kyPqkGDCmNC73A{1e zF~KV}+g=Jfry12ArW$1+_TP152~ND>_I475Ys-oXB+$#Ocv$oO!*@K!Gt!uN3``YH zHMY1w(V+*zxIHi@3B9{5Jz*W~=Zt}!XG6~@K!M!RhLZCBech9^vdzmpb%*_zXp4W{ z*jVgBHt)S}K*uzajcDuS#J;2sAj2`ge@=GUC}ID5kQo>9jTp&^8GkMu(3}IsW6&gE zhY7rFAIhB?6N*HP@ym)&qb){EOth2c!|PqWnqEL6HRVYj-D(P9;fa~HoDDc*Iw+IA zJWpwbj`9`|)<)`z`Za95Hrh36HDhO>zCkTBe@J$;*g3h@{`W>{QS$Kkwll98qBCO; zW|dz$^*}hq@c)S|_KBhmHT zQf>b(-)VS6eDbi9VkJ;2I(E*O;)y?*sa*WGpLKNK1F2*-3IUgaA)Kgtuz#RGz!Z}$ zdv_jd^jh*YX+oxX$KrPS$xM@kP2zexN}KR9fGTOhu{z;j8!OC-O>Q%Lqu^ zwj4=;?d&#GQ9h!!q;GQ;BQRTq9!QX-jK?~D!|)XHrzY*wCF`E%h}_smyG8<@f{yPzwz$Bl#QiZne+q0 z_siiTe~fOC+E8O+qJBV0fYjgw1V!au7Q}agg$G!E0eLWD)J$d2xy|9%G?>32FO<)E zIv$E5{_FL8Q+ky+hN=2O4k;$-r~lW>ir)lp;x(gig~hYZ(epVGmA6az59bpLSVAffr2}J{&X}uawLFezel}`h{2ha(!G(x~77vcNkp zfVpV^u=(g>`a}{D?WC()uMlm-S<Qof9^lNx&L4H{x>CZSv)mNjlZ$QPmUPQES%XRP? ziTPc6mNRN==SQzqp zU}hu##t8`-q0Mno-j%Ejc&x+)P}Kh4!LS2=rDdK`E{~3Tvd;gpwI2C?exN615C)@X zj_X_4-v^owXbMicCg?g2GwCnb`h#HWDj5X+3m0`}lgDFynZX;OC^?4uZZ@ALxQHik zxs(8|>5t5pOuwS+Ak2K~Q{5@fXAH2SUadE)BRlB!TvTN_N$1(Xqt0ilz5r;7{Ycs~ zf-B@FhHGtbNB3C8K~jRfsqaFLwq8lECeF8%Htw{smKGb)xR`ne^~R0Qt?ie4B0l^&_hHt~-wrNDam@4GDr|M9_t`}ZH*$bVl&{%eSLT_zJOM)6)Z z8Ea4|tQl}b`6D6eHD-o9omGWDJ0vxt*L?~Z7%dN4Lj)pk+Fu!8wu6=yoqvAn>Udoe5zEcbejYx;1c#K8qO0iO~Saq z+?LQNZkxTD=vUgjU!=p;O22a^A)c}ha~6jXIvB+Rk4cI z@wXhjExu39e;pSeplD5Ns_+0)!~Hf$?Im! zfe}2?MyS>*dn9DCdGNRouz0YxDUn!#(G{R^1Sjbj!fFR>>M<*J)^vu>deQ%wCq;^N zl_Ph4nSnFtS!kkXQ710(vk|j!)+M)B3hoi^q#SsFTGs~-`sQ+$4AOWKC8H6jSi$0Q zdJ0HWR=|*!SYlwuzKF$nIf*Yy3HmaQOHvFQiORn~@(I7axwXMrq`NPZ3Mr$OUl{W< z8lmYS_VBpPfupECXu`i%(y#K=le(3E)TW_^y!KCt8A^!+$ri%1eq#~z_F?StH2#pB z5|3QRA7dTNE(+##-hkeoI0FOeDv2=&P+a;9svNtgCIUl?eC)hq7ka^62WyvzhaVUL zmd8N~VwsN3oX`@D=G2C}c;Y)%m|+tvBaFIf0d#A#tI}86hqzfXDP=+ac0jk0HGzN$Bj{Nf?sZOV?vr{w@b-L@(-|5<5 zLnWJ4cEOtxt!f_q^0Tsqs^2!An#;^LoBK0&1M!N^%b}lWqO30@MOB3%zRZYdEY%+n z1VO~M-)W-2tmYJdl|eb&0F9M403RnhPsZ#$CZ}E#c9)Y=EJdmrAZqK7G;)K^{YDkf zL}OKskrH6YdlL`4J+yrd44h>ru&}SsD=&6gPkqSmP$ZP$VFI)ge%+Z&MCO_2aAhJ0 zY@w_&i8^5$8G=4sh*x2Q4~@TM?A@4)kOP;{!|~Fn{-@dq(*fRFe`Klfrtv%_{xjk+ zgPEQCxH&e6vHhFVyE zqSny!7?Gk!%-FB`cT9F8*!6Q>9cggpSGgjBa# zkE@imFw-~}o$Mj$Z!Q?IQQ8d`Q2}oaW8$D(K?X;v_iEmEw+mmvnsrnhh<6cHns+Cz zPiJ1d7t&!Mc%&LnRRbYPvI(JcH8>*Hm&gSK>rVgLPbYgL4DjjH4Oe+*5Lc{UOJdUG z9TWdBUD?oq>-jj(4hzYH>}q7T2TvM~M%2Y0G>|Y)6&oikEWDerQ>S`H zWey2em-yQ4d)sZteFg+_TlkrfpmU)jJ<2EaBCcY3ypTCE`fbz7$O9CyL^F7C%Ima{ zPUDLN@Du&>_@vHb=y(N>4*q}hh|aVB!iV&@b|s_1gfWbITXX4e+U{sd4%@yB8nlI= z4cRJo14C08r$NNL1oqmfNx#32%MYAKfPX4$k^dP`SGryd=3+Rx0)ZY)4_Kj_VN_J| znocC`DX4Ltw6rTZjEYf6@hgeV@EK&3_A)$(Ve#td{dZ{{{t|U~ zMUPTe5VGzd{7q-Ic}r|Otn(_VY4Hc|Xgw8B8$sjLAOL>?_|2<<$O-B<87k3S)A*S3 z`b`peBMCg;g}OBGL2S>jk_dho``@BD;2ZP5JNCb=?;qdffBt6de{C|rhpW})fTvnF zS>XQ**#DPH{nO(9KYFzF(8>S)=;6)%|GM`-WK9j`_c1JNhxFVpY)JMb{P~>L;AM9i7F_##t@+Rr51TN;wZ9vzjQi>NKG+$VR27}iv*85) zsYTJtsj<3)8Xbd?PvO|Bv-ZX=`ZFSUSBM)Qy^AYgM19e>H9(RGEa(_zEHLrD4V_2; zVOsZA8ybcfwVR_UHJjljj){1^aIH3^~M!124#!xy!G(z zptVLbJ00t%I$+B=X&ve0`){1g3%2m-06>ZRT6c2x*D4D(zpK zL}-{s&@QGRUd-QnX^Dz3sakUiQ0@{f5B&AEPfp&ugKd}5D7xrA28M7gkXk<^7{TSB z#T-IEqbk05^5@C09?O;xw>l6T-_>XmV1}*pY%ksIYvST6fN|fslel{jey==AAnYHR zmPx`^yB9qNr8GJXAhzB)UiFA<7^u(^O>y8psLmFC0zPbPgoND3zah$Jk7GENNis#~ z(lRN?UM*VE5FB++mkwjK^1*zHfdT6h-EPEZ=Ad*gjT zo2VbiP#=WfvrJ~mXzt0d!5a95Uo|p9m;bBy9Q}-s;`5(y_qdzR(!ft`-T421arwV)-@PFKFBu*4`~L4K+h?no;M+2wp!y4q#Fr>oy0iPf2l5Nck)+uL z`QFRH1_rRqN~W4*kU5;|l+=W&tiZBkvo| zVBX1rLzw-WA;Pe(Y=OJf+SPC;LhC}<462=~m;^v1G&8zBbmNS%rnF*GOF=7QQ5njKCNfkjw zz*uHA!Lb}X91jkmRmMjYfc(QAVh-|~k67>Eb$%INv2iozV+t`M=&cBkYtpA?h2d*48my*Z^NJ}sC;>f+veQT|>Td4ip$o_ydt9=K$Z)kYSOd8Qrk+LMs5X2M) zjfX}2`-yo_dg>FNZS^PkTx7$z7%HOetL)P!yEV71rcs}SF3gKJ?x{=oLn2^f4}2hK zr+9~b)wjLu;5e?ftKVh_YaT41;F-ego=7e^*jZjo85vC37(s+N&q11mL=Lby9SWSI zlIfc<>k@8A7sVX-4Y$=OhOGnTjYg9^Mn7-3Q%n*{hY9=FI$XBFm_vO7B0Y1*=*TB zc78hDd9!zV_~NfBlP#y;W{KT>`1C@mPL!~uy%wxXyDt|GJJhQgtl~MLoi+>SJF%U% z%T|ACJ2pv?;a^^UM3*PoWR9+8LZF4Q?D3xtM~7F_^L&CSxmwlup99DkQW@z6%!+T5 zGM^WN1k5u|#>mBorX&2xvo5yzSo~66m24;Q-_Y-{Uo$8Pyhp>~ZCL}YeLi2t?0Fo5 zrUSftqQjRFU&jA+`_Iq**KOnu#N!F~7!c{hfLGWtOfB-QTINbCNIw*KLG1K;qDa<^&!9OaTDWU2PMw)?dAyG4-~9fQW1 zCqc>FJYfzSn9x~jvtlR39b7b`V}#MO>Q~GRaeLk9l(fC|lhFkP{WQtzq9SiqZTh4y z`th;xWLQ>Ydk%foRPSRvfjB_WV)yT#^yFg}Jka(qmOR?t>?&4QU~pfy`8D60!8$?6 z{Jk&qcF6t~c|$^jv?!p}_Nw0$(Y!?z{gO(H(R(STE(TajW&GXzssvX${WjLv^V)oy zD}3|b{9pOr)SQ!*y)B*tXI>U`?qNG26O&lyEa%cEpEn^3G%JBltUY_ADJ z;;%{kVPg=_D$vzI>@CP{q@MyAJ2q}GldGn}LX{ci(>OizoN4cM@V`%Al;cGG9)H+F=UjimSAX6E zt>2vcuYKnv~Ii@mIj^0iPIWQLN>QCD4}N zE3^PyCecL>Yy#NoOMx90R|qeIjKp6e%ACu1joA#d;j*uw?lrs0yW{kV-X{UYw4sYWa;wa#_ zvr$}vd6|y`hN^&z*cioeqZaZJn23vfR$zMNGW{co?nGIV3`JUW7|TEAB|0)Rz#B~}}NvRDZ=0`%?7i}V81F%Q}0%m@`!AO+MM8WS<#*u5AL z0R_ky5>E+ksD1w&&@Fu5yWs)waCy-JH|*jGgOz%POQ4M0fAJf+l}*((>1noFL56p9 zrv=+cr9N`dAC>#$MM_Xs#T9ERHp@vHllA-Yv)j}ucwt@?`5fXJCV6rD#A2fCq4mzq z3K%aVowV5W@YjCp?oJF^5Ue(t7rO|t=nPPxzeM-WWg-3x8(OOdtGpNa0xT1G^ zYM2Tur(+MOYbDXH+n2*}%!pIVlT)g~3!WOI8}r0?b{OMTlc8^8wX0$gkf0lkc8T2? zQBOn;g4dTWu-O(};+nBH&N9s=DLY%Af0ESE3g;+B-Z(`}Z#1a(zSK;O{#H0)YaWg7 zhBZ#FJBa7~Fn?ZaH`>rUlDcIT#*gRYG0-|-zE-qP2q^1pmN|nS^%&e4s|}iP{n)KD z=`Spp(l0sH(j!VOJ6SdUR?{8j1Hi)klC$Mqu41xs^sVN~*jEh5`;wCteXe4*YW%IJ zj6FUtsj&(f^v3$Ow!j4HD37ngnvv02uTrJeBspS&{Z7R=nDaRx0E5}ZS6H*YsQ9l? zmy!WN%NO(lp$#wNX*#)*{0Io^@?i<;;r}`=u)IY2x&I?#w7M)Zo z>ICGxlj`tH#q2$wYDKOb%3|K7xZeuel?e;}la+$6;3c9~xD^M4FBr%73|Bj$c6AXm=R z{4hzUX;o^0p+ZRqVWICTMB``~`RT!uA{DR!*z*=qZHg;TmZBudenqAHQ4zC2EgcuC zuuAa$QbM4l(0g9{(-0?t;SjG6-tM06@4VmpdFN>FVE^>_!T!TRMGjKk- zb4UN-{oWy~hgh3+p@9MbRY0o0GysRTIZr>N75o2;3Y^DgW(+@`PpF<=EwDbP?8%W4Pu}qJ) zanQdeXsC$vEE4KiN=)V|=hhJ&7y%f@G>gurtZiTIGb^K$V=jP9POcR}bh_R!D3Q*o zu@j1LVn0Dfab?HZJKWhQFVjE%0NdsnU@usZ76R_0e+CB3nrTAwn9ODX?oXHFgf(A8 zG2uCI>+-C~2W+8xGMQzlxqin#Z6?NLo}cAQM*X~Tr{Mz>`DmGD)}Dd=iV>SD%p(z3 z`7|A@quuJVV9#NfGAQvriX8pG=!#{p0~3LvYztIZ5%tvO!mVxIySEnIi{2))3ENeC zv8}Lf@H}kTvNZb#_jt*w9GZ)xAqUGX=jW(x0L$IkyS>l^&Z3WLtRIseYX(~SiQ{%K zx%X0}w1UB`6Sl`_o}p}vSCbvN(oUddpV2dD6rqj5NC};yzunz3I;-rWk=!VNCV`F# zXk{>FbhZl(v6IN!8GMijQ4CKU#dG$jNNyT+lHNs6WMn-vvKWvJnN8#j9QE#P3W|fC zhDBDMx*O0rJ)LJ|JW5Uh#0*J4+khb=2d%epG&$UT6`c;I!&ZgegKeJcR*m4n7U!mG z1gGjP7xAU@;}f3Iijb|{(L6=)zHZOnL{yTfj1I@XwjF?Xhh-pz*?NtS@9yt2>CJ7n zPG)I#{OKP2`Pn*nZuAim$Y?Owv02PB11i?#$ciCTa&R@hb)I%S9jSCB$4;9;B1_&4 zA(CkxBdL>QEW~_JMesPE5+)M<|GjLKv(YPmuwIc2lJuj*%u;LB!wRRg>Q89(A^+7O z{EUBfSnF)l;78ngIzk*k=o8rywG1%R^5T#YSgh|(L=$w11fNNE!7Ex|yv3b+-Htn} zd+Try*FE=Z9j{CGX$MQ|op-rK*4+apQ5`P&Zh&7J_Y|w2VdswAf^{MskwpX4-4=40 z84=s+MGE7JSjeMo@i?^Pg+7E2j$%RC9rB!edlpw?cnh6dY_4W`2A(gF?+M-?#&N*|ML)7_Ugs^B$=4Med_-*! zo+i;KEy~JCjMs}^#o&O%#{K%;;Zd}IaKya`dy?|mVu>-b%={*Y^GiM%mJuTX73D1? zUpeWc9|KM&#ICS~dAGNLj`tdG-a#)V@U2S9N^Ut$5rX#7!;Wne%>j=Qcs-Te?TPkb znzL&HP-M6bdSnn>hIEit!U=0LLM{R`ee#Y$=}R@46olS^K#Q^VP{87qd|Y3517L}D zmRoIuzcOX%cuPxbv@`?4l3i$}nP3J_Ogx<;pDG*V@DyTp$hVhRev~qaun;`_Bt}?j&$QpM z0Rpg!Xt932AQaOx*q6gbU&%9C3h6~iBcyBcT3ip@4BmX4<;23nrDO9%h|FGe#O@Ca z8tV-6gnVDK1k&%tMao_Xw%C`d*-6Sy>IU6o@P>(|u~^t5+2H<#Y#%@)3FMl>z$61t zj=v~0q~P=WF)dSQkJEXOdohsgJgF|BBEaxL6q4Ax9N&7me#!6*cVx!-0hkFVanzt1 zlNt=0{V*A^LyT^mqUFn!J$7uW7XNl8P(`>aGW(0sPPTe;w)eC+rP^kGZLKi^s9+5h zuvJTp5aiZRqIQicOa~XAnZ;Bq0etZqgm8re?qe>DEKT8u(I{4hLDV?BcMZ&%MERBt z!5V~`u)mp=JrHX8IIY*z_~w#lRS90cbph1$3p|24VNT_1$=YFDNuv~ru8$rs%Wiu`St#0N@Ryak1b z0KVKg>BvyLW6H!vie z>65bGahqDa65{*wV|gg%-dF3mG_tz6N`3Rs>tMFWbRy6vUIbY0XurMDI08~@nc36d z(3SKqPM@!em2hst?mn-^Gq!0tyLgIhd9$d5Y=OY0hr_@{7AXU2?)uZNpbsXfyhAlu zFS1$j(HuvSOxYHEfuT@G;nCo_R_x(;kzZy!HGhKzw+Cg}F zyL8`Y%IRi3&)%_me_tX`Ms3Chp6KB@sTKUW8#q39g3j!igj~LBjE4kb2n<`;HWGDW zP%C)9b)yaH;W9zyS!A9-&YVg82!9QnFZaIh`2(*wxv~PTV6Ek-1xX8pTHQMKG~!dh z`f+E?1e<(!v~-u^UbyPbkm{o2j;HHSlwn>W3Um?(?Pj(Vx>X^rfNH8tBRr>zj#`h1 z-w@=mcjtBD@YOWy15@*N3DUM7b)JF14Y5x|n(18*Bniz(hu;9HP;u=P`Q6{cH zH#9`K^>$RI7ZB-|JF3bJU_9w>$z~2f$x&j>p6KIs3P32DgnbUe$(S->O68-Wo3Lfc zew1X4A`LX!CtBy`vXrngWgjGCSNTjJ{L``KGA%&+_hj@Ee-+B0pn)d)YA>TbJAtK7 zdC)3y*&))aA*XgNfB`maINjVWz*kuaGuBa-CzD*Fjm$86L0L&wc5kUtp3QRD_MrJf z4n3b8<&d2x+}+y*FpHJ6@J4PDSQVr>puc}`qQmKU2XM=QSHfW!3~LUlK6ob}{9zmz zb)H?A?FnIpbDx5(W*cD>BhO$?GypyYWwcX~W($+-l6LXzqYk-XHgYBcrLU4oYJX7~ z1NBSQZj~#24?9VBOUJH48Wyfhn`5YJl`sK}Ehe`-tJW@)yX;*DA333YRSWOh$#D5i| z*b|A#g=0~-)W7JDt5eG;saUOMU9$Ps3Qe^wrodG~vWkDXuc0;`n^`zL{Q$mZBM&|5 z`fp8OqE^FY1DE-^r{07t>{%K(?!*shbBDj6wyfDn^Rlz`Z@v57ZeS j*Eon{zvG zW5ptYcj0r`790Sh+mxdn*TDP+93-cI))##Vf(-`h5_?(q4z56)3G}VQTzEQZ!jwQ} z!UE7Lhcy=~hHMFD<&#G= zIYd^pG$>51^2pH~)T`~frTP(%m|x3L>c&Mq8NfK5SOd&Sf80AIFD zI<8h}NSwO5#dmXqh5aw2QM}byVOnb;6t{5q_BC!QxV3c^ETY{_tqi2?ATMb?R_U^n zOi&(TPisC%QO?rbg7EbaZp=E~7Z}o`c=!>C5*g#UPmt;TgOH)7`%00_U=N4BX3=VP zo(Kdn)b_A;%e0!ulu?l!jy#@cXM7mo@kG#U;9SHd)+_W*xfdOPn0J{1wrO>j%|N&m z2v-N3#vm_%fK0B$2%iyqQyL!9syCx2FhJ)O`9BN0N!=NPhgMU#I|4?Q{2q`W)>?r6 zk)=~5|MHkRTbjx`9$Q&gFConr;Sqb8Mw!0VFuC4R5I_=5huy%RCUN%RpaAYySs816 znvY~EPsb`dC*=cdrstTVhYTxnyQ^Jkab7IeOpOIsoiCZbI!aHh^Z*uL^jx@WN({g7 zN?I%{1P)!9c36$$t!YNCJl_kXwGOR7tY@1lz`evb3v-_420`JTcAg6^osZ_c>9IkI zdXT$|k)rt&odM%2N`8;wDxw#n-Z)1DOu+O19-#>WNdm+-?RrvjTiJ*1MMPl^(xxnL ztOnm`n=v!UWIEB_xTb)Dg+#IX6V|8#w0zL@gXp=s@FZJl>De!gZ+MH;S#zCOHaS>n z)w6sm+TQ}Zp_U;G=odyWjqnK@*Z~dbTS*-%Wqqp)-?$ylsM~D_2R%fLLOR0I>ka%a z*^pW33-JU&uDy7|PnUF16VsNPhUek>Rz0wc?Cv4q9n?B7k>I}8%KGuXNmxfcy z*@d3zX9U7;8GJhHf|UZdfXH4Y9JeEX%>;6#Chzz*+Uc3^_ADxumoMh3Z>xh z+0z3m`pY=0$XLOj0h4((e2Y05K}U4$`5PG6u#|BUVrt{zI^0mW91uL14ATKpAGGv< z^XtDKMjtY;ewhBdamkICB5Akd$^uQwL(4V)aN#(yB$DL#k(HQLPZ-a=CgbLJ&e(#b z?!CI&`v*0*nVZ)PrF?yV%`9mx(rFI%Bz$LbaQ4!}CogE#G(}sdo{XeyGx}j$;UD z5b-*c@eh~|-67>nb8!)#Q9PQ1{8z9kCse#&=hPxIwi0`D)=(YI;tIO}LrDk843EEd zGOx#f`I+6wW|XAoi=X*fNru(tQGk1mGZL_z0zOESQ;!A&$ZMV~lB|`_z!{`=bq+F3 zT9T7s+{`&-v!G1Bcr>GXvwQ{&RFUV^JuZ4goy)vnhYSOM$jPFs948>5FG6l45PLN2@I#9bf} zXy@h8?ps)2?&P)}ozE-onS<&y1N2_(UN71yBgpv`#SkMR`am8UHi&p<6Ud+X15I*_ z+E_s6>1kZ*=Www4=yk2~tg!a1_}o=P;5F*4b?B-eINvU|rZ8RnvTZw>s)SJ&Z-Ijo zShD6j_S^}b3nU}UN7|c92oVmRgv5FvDf)Q~v48>i9x0l!hC?^c)GU3x9z&r3JT+&+ z+BW?IZvnW`V(pCDA3fuht9W$q;-D{Jm&gSbScF{JgNXJL9iLon3=8(Z=rLceiRV;O z%U|PiPrTY}cq$yB)l2>{kfX%kde|TU%yhzMkGvIm4mL6h@xht+lwKN72m(LG*!`ekojVB4&V|?CxFvf$6N&%^MP>&}s zikbq#97=@Ru8FhItaU}AYo;g@AVBqy( z;V+lr{VC& z1@deQ#3-HvD0W<2A&%)TV>hm3n&No4iLjOIpA!*kf)26*lZslnqp3zNa`aFr^7#dM z7?6Vlcmqu!R~(O569Hp3EY=1@v=8B6*qSx%3(uz9+xknFu~DpxIWYs!c=G#TGA}_P zvjG5-Su&XjSekeMiWF^lF@&vLu(K(a0QfzGIAI(_$xY>D#e>|SbzN&(#l-Dx#1~m! zR_UOpZe0*ROjBUDbRxD*(PhvSAeky29nb&dUWaw~B&7$;YX#are&w~`G=BG$7Xw~7 zNBO7hlaygz>>x}5l)9?q8OK~g##$o#@SJ~FarQxTyF&z{%lL|zKaHsKH1@`r)@*8K zj+tXoEUl#E`QdmH5IPE4JPoefQv`R!d?98@kR9SHG<3$Zn7zbTj$1|9qnrT5aDw8n zQ+v`JhlezCPlcwq!+{|(0-!ARCAE8rIW1kyo$d8uDC8uF1dOjwO^dTE{&AvZstfKK z$U#`0BYTy6c-}qK^pNf=;NNyvi@2b=SwKXpLpKjt*sM#t0(BKalZ9 zH_~rJi<6@;9noT+uVpbnex{CI{n(G59WSKlH?SUTC`Q?y4ggT=I9O+o@wj)bU{wso z`vQbU9dR4A$Fjh103YXVG1;H}Tsvk&rGT_ZG1w!9X!m;or?BpJ_-6XXFN&*6=@0R& zxtgrR&pPiSfHO)H_-~!i+TzN_{sToBhjXOs$MuNRG!Hb0J|xME@fdl*Nc}}mrwE~7 zNPB6d>38bbV$QIn&ivm4Q0qIQ40^$QCY#3m*J-k@+>p)-Vm5Dt8g)h)5?vKdD}o z&)oF6pVn4LseFU8Pi^m`XAFyyuBLwMdGcej8{T}pyl_m)b&Herf!YQF;jt6$7w*BC zMK<2#*e3A>hy&vkoRdA=8(uN~(;8tb;w}#*uIGeN8tBW!Swu`oj>;-=57(7bnb%c~ zhEGVjxrl=3S#UqjVQ=tTUd7oDWiJS+uA{bhVtcI#L&CW`H{ zx&vCJcNgVD4$W`%^x|{Sh6Xy&sF2%;bZuCGiSI1t*xsz&@x9~?)Kxt8x=opm{WSgn zmio2=h@_7@lS{yoIiIJ16CFn{-|im%5(#88MvKs8mTX*tf({5iiaQUT$DE2rv2+6h z3lKJsL%TQ;vQ05PWE zDEN1mxXQiNAWJllTU<2XI&a&hKV47#SDT$^Lt(UN52O@|vXy1o3BV1vusbcL_gW}h zj-wV1zqC&x-{e|aZ*2EV9@8m(6G>s}5v6>6DlZ36R_2Tc^Mvf>5MM9?H#0D{U(FI> zOun}H%L1KP82Z_WdrAto>XU340XAee)$Hs0Bl)OoTTpdK5V?M*9ym=N&CDPDz z6-$q0LATOX92ngx*?~uQq2E%F6EN0QM>5|)StHgU+1a@+xLUUzHnbVUrX3f8Nue3M zO?aOoZpMEGt@8tSQLec_Vpl^I9xkw&HDi^?rtPNl*+OIVgSPN;EpfnH;Bg7I)4~ER zt?{>3bx_^S-GLklko=(}kKV_3OKz7CuNper4y6zJ!MmfM5BBzd_DQRTae~?hgx?bF z6y!FHM@KQ5h*>zAK=-EeCx=Kx)s!3wcQWd&U0;Q0t%M4|P*D_5qDB2hg#+BN1LI|~4L~BTtV^Kk%LjpC>Z_4C zzZLq?h%HbeLq-D_Plh`iC7}H^F0zivFYZ(oWQWo`3=mU*tnWplQq3gMDUX>IV<&+U zRdVU%*6r?g_*e$b)c&zcM%$?EG0~im^%1NJ_pLDF7O*nvym*ki!Z^aUP!27JP}{Y7 zirUVf)wmy^pW%ae7>q@3h0T*?Nh7+uLbBN3eZTuwi&hYD6e&k~iJsG^geR0zNjcBz zMsX$~E*RUBUoavI>CS$K#AV!#B+l6S0dx}Y5j;x-z6_%|8I$FsGtU&x5WhZ~&wRiX zHA`Z7EQXJk?efF1>BKekjv1PqfZDW;G?I-@Yr05lTtQ6Idv#oAGK?d+g6!(Nm- zteap>Hv+|}DNSfi*KHR-_xF^jlSKmF5Qo65dSk3h-I3f|0_*lYM_0+meo^FYLRo}$ zU`J0gE%f@ep}MW?0>Emq*S^dpt7OfU2Fw1UQaI_>76)5|awtDK9;uLW-92$75J*BJ zq-Sj5yr0JEBH$S9$$B);CIIW9&)c4jIVa^YuCoP@G)C{7&_1L1f#1OF_l{b)h&gs z92~LPcD=HoX=Sc~QWL}^5X}mkp0`we zEv*iMV#9pQL)HL2uaV=Nm^a)N!I}B+#q^^!beG2 z?Y&sE(O2<#GV!*z;}i=a=D(}3jG2S3-U*~$+HXLmzC~kBNxG0n8AdRT{o2v!EI~)3@Z)RkT@?3x-If>VIvI1rAMw2aY4KC z>cYKSVT;yCYH;fyZW|M}tV|0Sj`Y@%+equYJcN>m&TcQ14Z0x*v_X$i_1)@d)Yx$h z_t3iNld$)!ks;tkQbFuDIJA$_-`W3OMborocXcpkGpR1Ys()tRNJ5CifRMW-hph-6 z5d#^VTb99-0Q>+5&d$ybb{1_Kzd8UYLSum-{TVcl;LfB}0EkuC2k_6N90%tBbOK@MUP1|SAD2@y;z0yzmtMY=zPUGq^YMp2=J zwz*z1j#T!es7(Jz_;u23^Pt{*6Aw)bQM-&-O^zs8^_?kYKrkWn`J&;R}Z z8dy4PZ5TV2BD4&IIkN>-J_mU;EYeYhr-0Djvb>nG$xB2O}MO~zreN;EaXgHub~i{6UpC$=Rx6AHPV6$!f#^CH2BL_R+LDvNI5LP#)R zevA;T1#p2eB3-Sl>Bu;m1LO;8BWJyO=e_GdGS}iF>lra5*hm{FWP#UISSz;#Q?iMq;Ga z82V+#1@0fzz_rkft&lhLiO2h^mNT+V*3KS%=B#bY+2n~9FF@*t4GmJ2X`%JExW|Y0)7$gNH?q!pg)=HRP$K1!p^sDa{`L8PO$T^ z_(gTP&5vGIL z1=h)O#SLfh-Xi=Ox@?;1h-z)#*UdtRq{(q63JFpzykP?}VM8V%!DVaOeqcD>C}H)7 zK+~E)>-Yz5Y}7mhN&p?i8KhDSB6BSqumLI6KLdJwyx^alBKbC?h2 z5=eJ*o_BYF>tS@VJNE|D$L}Ruz5!m6W4^*i~vm6*M zaN#8eBErK9>OJ5SfC1pF0orTX0!L*$0_KbHVPK0T;6OtXYIa+D`Xu`BI%Qwj9|xoN z&r6 zy5VpGR9rJwjGd*v76J6SU38(OEfRjvK3@HH+q;8MRa1J)E-5~-#XxHr4 zvFiXd3-;^PPUI$`2t7Va8weyIG{>Rhg@5x)vgk%w-)2^7hfkb z-`37A)BEnF|zDB@g9j3?iP>tIdlJul8;iguNZQ+ZN zrZMc$kINl~MOhhncY^1~!sQaoXtZVO<$jx|2?Pm{XvkG)Qa%jDKyxHX80{>eQ>Zb}15@PnrqHmmC+s5HTPd7y>u6=d8}XcC7IsvAK5a>j0GtQuk) zN)P5ld$!Z*T6XG%9%;xlq~kUiPllu#^^8G~?#YX3T*(tLR>B%I{87K;06r~CZXLG? zuHAW)&p7l+K64{Iz|K{pN{8D|CpC&eyTgQ@>Qc`{P#VDeLNO2h1{&PsnD9tTW|Xi1 z1nl!9&8>tYS8bE_dx zam`e#7c02+tzIQ1I0TS9reP@cmApmPw5siVSALoA8@D7i1M4MWO)`2VQik4+>TksQ~B_RGJXiCCrHc$TuW6( z=jY?0YT zVACH!EwB%t=542YH0Bbdq-vqSk0MIp9>#)JNyl3G4FiL+% zT|5z8Y?_e(2&KkGWT#e#v`cX1@%ke@V5?Z3oDUL} zL7DbN(V~t5ihlY%We)5T6XIN`6cW$nkRotC19wFpkujw6&q+}VJdaFAyci`|{FNXJ z0-pjmCqy|uS*Pp};V<>{58pwy9^j@kW~BF?-q=3exF>Wdp*{G!qZg1UTYDqt6yP&} z>>!*2do^i|Q?)F3aiku98mT0i-uZ9Uahqo5zQ|Z;fE$QA&UfaJw+J7PAAJ7^xwkT7WGkyi9Z#WkbTZ4m-@1Hee=gbF>{-*Hq0?&B zrPDi5XV=%Z^bc?<8!vdEX+&NGU>5lCO`I0SuB=Dincn9zvf^g#*8Q%XDyUy{>wTwd zmMMx}6Jdw5$N#C_$UV-Z(HCnf&qxiM7oEBnxy$E9ZN|ePZ_AqDx>u-a*@eGyXYci! zuK!;7E6^JU^FF$oCA*B>g9yEjTAHp{G~DU}qpFiaZM8M0uDoHKW|pmJrgKQT*J0sa zBm?1wO0ebO9DF>SnaKl~)sRjW%-+rybJo)sgS04ej;qQcM>3KBIh@Y*Ou}?2h_(SG zE?3a#)1b|58SqPyj`WRtk~#|{cs6FHJh~Q3U#svT^m?ZE(3eXa%e|G7Qi9fjs#Xqu zFY^c4*7`6Bo2)TG9Ya6Ua`OT*7#uUna?3-(6*e&@8^la3T<%U7w+X3$%4nQkj3?;b zF~qh-#!7m&NpHn@Waxt(X`Yi*Oqjba(}+>-6JO9nG|uKE?n#r+f(y0aX@vEsC@4QRi^15ZA=S=K*LPGnedScuL^XBtG|I|e zNJx&>ExY^316^e}r09xGn3~Tb4rzgZmqwB-(F|kindDk~+$(_Q;tefRh2?V{t^@j=%w>9DInff@dS(# z$2?DcW~4=D;!*`+4eQdoy>2|v3n=vk*a@DlLvSJ<`ulB|9w;Z#FX%iGXHWs56hQK$ zEWh*}$N;@NThqJ;tKq>UxZvkkP1X%I&?F5Lu|io+!W|3<6a)6g2d z!9%_{AV#d&3JuGn{E#aF`br(( z8K?4aO@}`OR^h1%0X3Z(YN|Z-gf?}}WIM7^&#)Yk$n0k?V-I0kS;-}=eR{?|to^sI z{NF4de26cS%``7J;S_I5bD#y})0f6{5?uXoOEjIgGXp7U)VfMw2ih-`*(wh14*de| z)1s=L2(LM71Ju)_ffiBghw{@t>qPn#Rl|NipOJsk528O+_Nn%oY2d9zMxB=Z{y;v= z{eGasqug!SKkBZIKc{}RPWdiak2-51sZg;B+&BQ?U_6tHHXHURyTfAQGOA@URkHf^ z_|KE!iQzNdndI3;w{8+{hT|wQwvZ>@6F=Db_pFiiHMRmIhNRPF&0TO9E<@+svKKpe z9NpGb(YMJgPP6LQy!a4fq+jd1=bUdf8a=K?(`F)?vOv;_nP`47xx&oL)HA*^d)#qi zg1E&1beu#KxDNgq=+=ww8MLb#6j(V_SvXLQ@$?BGDQ|TVf`cRL@Vrmjb_-^V{HNe0 zgvLobi!LG4lw5t%0S^eiEOJo2!jssGcG$;@jN|8wi~JHZ1W`61Y>{#TN!=v4NT3-M zdJ!qB@fl9oml#`{Vm>(dFv>~ai@RfG7Q#h%OX0*hFs0Jz2zx;Q8#uTqD*rZ53m$c> zJ-JJsf+eZu3V_XOPFv&IZwG=_gHrqLzaa`+L?{}DnA+013S zWki+&W9cXcI|#KwR|C7cd6d5VqvV{6RXt4VRB5k_eS;{^tTl#F+o#6g=1(Ay^tE+B z3FSQuu5Hsbcb-PvF)>4rq@BtyE%v4x~B&Z;8O9l0c@8_J-o((inf{`Hm0mE9e0k!$C1Ji2{9r~&67SSEmZ;`sB zpX(kq92<>F*aty<(vjzmFwrCXLZ}`S3_8dt@b&hwlJuddj3*q$0NcDvw)3Lj)+HO%ZGYw5)OYoPCIW zoBgrxvM;c&un~CyGJ(X6gk@JZeR`%uAQ2gv85tQF8To~##SNGCY;a5e!1ch71p%E< z#H>~{*gxGk0xc)_C_B|2#t&m!ZkGlnFl%B!20(oIJmXk+6^7G=JR1z|P5qN? zSO@pNTHC0h-y(3H#c;Pv?_#AIm{Ii)i@s&IR4;2>JzN zz_o|=c!BPkbL3P^h$783S-<>DaZ)vVY@{j7i_!F5RI7+p1&HxKXgXwvCCwnZfW=&~ zJNz{EDuqr^EWrWQxld7EOA2p~DD%9bft9+|k_!1PH-*RH)=M9WMd^(QK`;J{Ej0~! zlJcX}B|1hVxL*`mpplRY-JoDS%pCQa{S|}UN$rXOpb>IV(J=a*2<^ux4cVy0P<)Y9 z7RYLmeJ=i0LIyi9VI_SSj#+##bqNrFeFh5ef{BP{?R+_A7zhOKou9|g9dcPn`&lD- zCrsV3SjZ}kRA*RP6d;~B$uMx=M6e@r2IXpwePJBI78+7Aw7v$vQ7pjZ>x@Fe zHCTmQ{N{od(Bj1YC$lf(A{X#}#TvJz&|D5CR}W8i^}XRXyt|u{{0emUj`zO3tPzI7 zqL2V>RL1qvD`A&iTqgD@9v3?rs9M~+;gvy+M(%r;``FBDc{tQJU?5<^TC1{6C6e4tRHx0)KOLD`j*2D69%6y_Y9Pw@tVgbMW3I z`Y&{GbD4FG{N`SYZ{lZQT`jsBA{`EDr^t8Q?Rv?v{Z#FNi?r|4NCLfKdld+EjTatC zRD_TX1x9E*q5Y!V06qkP3bcT1(w(~Wu)^#^#Mhvs>iI~}_U=tSSu>l2U9|?L|0C=H zpw>Cwg&mvvCl42n*Hyt>bcn3woxt*nZ5qCY!CDkr>%%PpQvRU*`u0?_ufab1L@?_x zIG0_wbzEya_L6gr!APlS$9VQQn@>2P<_9+FG-~GqO*ZD*wpKjUuV*kP=94=DVaV$y zoOxYJ;1ebw%p4zvCLc)KfhcH6{ihf(4+ZkabdQiZ9q9<5Z+#B{Q3&E4KpWvKvQXe3 zkm)S?1mK0&GOo#j0Y%2ZdVwu&G?sLVp0AQtePwBko; zqr`%nDW3#FsdT2(-aqFS>>1%fJewsU>`Czc&~c^_o_H*&= zCljD7yHLgEV42P6>ffIx{zLQ=C0z}U-~ZQ}xYrNFe{5HGR{Q@SVE-=-^I{R*- zKGtU+5QF-IHNjljA4qIBP1sB_OXHT69E13YU7m!SUb)qs0lQuh!p-8hM%k^@a2J>N z?QjPO7aG3G;2k{oXDXn#{zr=-5h@0Owi~;_AvZ&eIf=lZuq(T!c#aZE z!uQoP+A>+*e~R^=d1O<8g2dy0Y&WW*^}pVz)mQ8P53v3}c08wN3(&*Q)V_M=OU9K; zd#2`);^nT9v#q%`P2_83^eJ!!Ns4r66BP^utOV$H}>u@C6a}OQL zFUI_xMyOk!uf^1;IQe)sV5^SM;^$dO(6vgUz`=Y6meV%=F~>9sH$s88nA+4DTkZq( zV;L_vDi~4q=Oj36MmiWQF<1R2HhNPP(pOA16eYzNq_Xf7-{I{K^@yq?L$%1pdWc`D zz`4*`>4ZSB5&})5CCYar$SWeE2%;Lk5+nKz}O|K*y1Mkk&I6cb&oqCrh#hDqDC zqQc8N)E!exqJO!%=6fgkI&J39{i}QA>@VG{h)7$J zX5JF(UXVJhwY;y=g`s5XZp02z6xPV?mMk0!NvzQGLCzv87ZMe}n2)BzghU}DvGw=_ z;;Iq2@#;z4+iER&N_02B@a*YJRwYYfB)AjA;OnrZLyKoqWrG2%Q#QGn$`=xE*~~)6 zOXhRRsNm6=NRgv1C<;nJ>H$f9A1+VNbJ@?Cca;6B`9J{7e_5y9x6ZRBw6a->MB_-T zYvI*Hq0JWUc4ut65kRU$8Ty!CnU<&t3ZP*BJi~fOU(*-S32)dzJ~}MC(|PYyAJY<8 z_S~U@N^oADvxJr8X8xEYmm{~~p}vZbU64w2@tZ60fK&mP|DHeA4MP%wKqsn1BHYgu zf5T~=^r0}h>g&zPQ}XIWSmM2ZOGp;!Wi}XmEaFvz=)DnDLagmwN@s0K3FcM0-}69u z)Px$H)-Ufh4htNi?pYv>cD1IKZ<4SPM(}@%gTY6P=okr`3W3i4h8Dsf)}C>347?C? z<&jSp?_yh$iZ76$v%`GqR0T2_W&VMYJSl^Mu_rdAyd;FGcDoEV197VKd||!IxUL zu9GkNPHfj2JqV?UD81GlDoutM5t*vmC@a8&V#lps;?_Y(-LsU=4rT0NjV&6d2K@S7rdIITJ*dyO(BRkIZ;9yFCl@jxi(qa9#_*H=I9e* zS>-D|DfyG8RgyIE_^;ng1QU)>n_f&6%dzPJ?vO;R99xGbnB-I$9^2Tn&%1)Pgj`)NBkBV3Hpz^tfzFwO3Gd`K%=~_R4+!m zWSN6A+gg%gC!=z)EkGpXj-c&QJFNL?{==V_uHwHU&ii6cf)nDu8e75q5B04^b8B_} z`vaW+=6$Ri6$7?7j*qZ5;9+sPlhP12M_y;9^hy+7IHH?-qKN+m&kVDi@=1LG712KW zZ*yiV0?w4{wfd&n6Z(|F0OFrTQeY!cWl1z!)wTlxvj_)yu`sWRNg0y7EAe;kvG^B| z_db|AUdo{F$)BtMwTnt$J=cCD)8%~uv7<;Y%)#cq$XSj^ zq8zi&S3+p~A9HhVwpug^z%hXmn4>UXkIli+-JNMz6s3c)8yxWwodrtF|9}ydBrPig z;m272MY}mE2rPd6uWdDIq4@8{*6RNEkFfrW!<=d9Pqz+>TWiz^R85vpL0-Uq@X&Id zmiUnGJ&*9-6xpL__m<&e;6df4LLhca8|=!i?tG-DInwzgUN*v;oZdbZHmMHZf#MVA z9@oneZd@%WcBfO=WDJ(2he-C+#vD3?Kyf}9BhW@0mkF8~5Wm7CJ0*IbiJwbRH1$BI z&;``d*g>I2^8Y#;6#ALuppq`CY9CE=>fn`J4&h82e~-68Jv20p#>(ByFa-ha7m~{d zk7oJZSG-t-iDaNNX11&IthAJZyot55qx$DngJrc*ZSb}maE#L#eBGaKg)^GzkF)+JCiH1qv>JG1x)W|D5nslf= zBFxO^arckIpg3u`)LtYgfqhnGgee)O={s>G=@9{hheA4b84NfBq~Ay*trAkrM1(Ce&c>LW584I&!lAaBxW(K%Hg$!N6oF`Eww6oH-1o zb`R%B;swZIB#Q5~i6!N8M{$}(ItF=gwPU&*Tmv0ILn-0-dsQ34`HsTcz{gU4|Nc&Y zxHbp#3GU0TuBc=hmT}I`v3N)Y)+bi48%97brQi!f-0wXF!MFq8Lz*#)q!XcEMJqNP z6oto##&CIA9qaU}r##iMC9+!ZV4-7ir9+C4enr4KQ|k;bDmk0Z@IQ;u8LUu-Ngp%C z7sC#fVve}%<_MgQV{mlZPXU-t5xb$6E-Vb99osYE`pcMjBf8eivOl~vi6i#%=n^FEh%fDZ_hD=XznGaAYSBjr#R*f?JZZ5vx|C&lTHKz% zYKc|%5aWeJUb9g3;IO?G1Kx4;pIo%hU-d2qr!Zn~iPR96fM=v0z{d85TsPo7^ZxRP z{z=oJ1psqEjK2z8p&Ve035Y><+5tI|3B=wfPCez#ah61-sx5 zx~k~n)UinHmS(!cN(nio$5TZ3@UK2yUg+Wg?+pu#Qg?Ve&@n4z(ffI0x&yQ`2LD7) zf3dWo{f}_Zod$d}TAZwq^w@hXhWvOB%lo2Ibzwet?fZ!rkQr6b6J>U2P*S7w(a0Fn zy|oQzQOrG|+R9X)F|{i!IC7{ZSw9Fs+c`-%$`ojDe}wBt2XmXs#9Bga84hP$TPAai z*;6s`6=@RF{w$s+0>jE7(pEBk!Oa+skve1xli-W13gV1m% zeLtBPVWw-2%^Nfs0tba&fxzXXEW4VBD9wODos_2>iVBI&y+gwH!4)`QwRCjY9$M~0 zOJ2GwL+t6R=V=e51A5Y*HY8O#H+hF#4ly^bT%mY6`d%of#GO>7Jq^H3#-~Un6|kh{ zFIDK0%CGi|9%J4qT{lxEK5m*4VGuXjW#AV79s@!~`4Z?$fsq6Upoe^IR_C=aM%;W?{!5x9* zgrS6G?mvGakaDJORHRI*A%6MnC8zx9tyKXf6&hIlWj&x?TAD0pE#PQ3(GSu?3PT!kE;l_AlY_KZbU#vSStcgOV2YH))85FDpfHL4=ofFQ2ADr z57BxLws|V82TE)@hL-X5A8ZuvC1FA1BIThyLo;fo8`j*ZsJ$^@EX-z>L2zD?TvfY% z@RA^t{Nd(Pn5}bSr;5bM22ReI{D~&0MCnG1!v`?#9>{4Lwr|Ri3_a#I*iqwfOi*Y7 zv^h<=4Qu|bAUc2VrCrVYL|3M_9b~%nUcO|&)`nJK;Dj&Yw^jG@1qy{eNwqO!U#>~! z21$P&ikjk)rhF+pJ-~}+2k^XgJjgLk>&_oZ5 z^bRwqeEp~FKZW`Wmpi`fUuY+%7m}k|B%Qe<_g`w&V7-ANq|$>PDavaMo?QqFb=>iX z4=6}~wvoT`{gn<-~|CFQ9A2css={7&?shGx1bXchI@?LAaBg zRt!@~JrwjME4w97$m@%EpS_1aqHtKlL}ZRxqCCioL4GE)SQ4=xj@j)vv=1$U>P-$% zKZyS34;z@&frP4&8)?+9PObzJvz*5GIe0)vXwt=!+;hVh6I^Mz%NoZ6kFF~T9U9^3 zj_Fy2P7J!V$@wH5FxZ{VXFQIPV1H;dphnxI@sk9R-XQoP(2X8w$mJ2=Y*C|t_2up~wTzN8$^0fWONGqx6-8RRbYb*P#IMp7x>W7xUh!M6RtyoKdHeoYRREeRRM z;--DQ8KYRkqCSY=>}3?-H36xlM5=+MrsIs8a3 z5kT*Nbzg4^e$FhzsN7wuJE7IW`vt^w7Ja6Xc-9e>T3+%KT;Rke_(us}(@3Ro2x%K; zoJ`6k(@JEgoK!a71ct^QYa-CM{YP%_aOyU9+ zM#)=kH>14u>1~y>nK?3Nu*#eveqD5?sf3SG>%|?ALbTe{>rzlr?u5ir7;|>trZNpT zLAq0#R$_VMI2EPzM)p84u#3;!@1@k*aYx;Oi;s8Y4O~f@BPlE<*wG9{>eQXoG9C=n zQp$9okQ@^Vn}k3lS1VeQ;zPhtkh~Z~DVJJuOi5zXN=Y6FG2l$u4MG9WP?e+9A{!nK zUc0+UM%>7)!Q&75@BQ8tkf3RHc z&A@MQ7fZs0D+}bV3*5FRdEld*_)?a5?Pr!JUOooMMuC68up5K(H!)Q`d^0q`s5a#L z_?aE~krnM1Nh4WItq{sDK%u)c%lu>t;s4a>jciKnU8T&YqrP&_3;CUA22Tg2v6ns) zi_-XA@h}9t#xw#Uz>6G)NNIhFVx?rpyjL7rDwqwkBlq0Mnvk57K8t6>N&n?~jyOGU5^$&m;M- zkQg%I>QOYt7rTTZM)AthdKV`9n>8NO!`P@9uiynHyzBp3Qw41H=VaBnF!6SjJ9~&$ z!-0aR%L9)n_{QS}`Ac(&uz4KQafw~rwTzs5_?vXF7lOP5vb}QBS?CL_|vUWb^~%F>Uzlp=+AxnLdanv>6jQ- z`Nj|5%7nQ8;}5Ay9=7<)ItJZwm$7%I?vVeGYhU&Zwk9p5sr}1(j?tAYNQmA)`p;i9 zfiL+VO+)@SsGa<^uC?G#77r8g3G3V|KRQC}3n_oz!qLF24rZ5&@xF2(JP_qAAd{!% z$CYK%b#gvA+NeOU$}P(!CRc7|fFy2~i9Ebb-_TSFnzxk?A0g~1p}<8mSk+-Fu)yn} zCLq)QDwK#XNP`1Xi!^*d*0hH;Ty4dl!lX$57!#N1ALTt6#GX+FO~n8|t@rO`JonJ$ z03ka=3`19OyK?VzkOs7c1|gn9Y-gClTGrz$F=AfClR4nclTH$lVJu@VTtC^X1aIyUqu{_C+xM_7tL+Fma(E6< zVYErc3j_lIv2)phH_2v7K#1OU2(l0JLNE^mZIWhq1HlMe9Q+MeBuA`-J2-;nCeO zne{33Jghqip+pQW3M_?-c@PAHoHKg(SBTT2xv*w!s7vA~6O3i-Z+fW&h=$=^sBb{3 zgIL9596fZF&P?D43>4g@f?a0|J>gsPCwm zQ9lJK&2Sy*b9&3oD|WS^ro%L43Q}UpC~9MvBGB+Os8VOJ<`=P9BU`E7T9)}UqL-#tlyBajNyDGaLN()UkZ z*g+CO4hU2`jdfcK-)STW{P6@Xy-gM3;>$q&LZB$CqmnupH7*F1dlMqFqyV2h&a?HU zq6)l?W+)@J3doieo+UOTWViQuN(JcT^h7&3dZS4n1w}T@eOj{NFN3s$!sTyj zQ_stoi4tN6RueX1e-2NY%#-AdH?Q?&K|v+L`pqU7TTMfw_fj!1SVz9I0}LnHQ^#Po z!xzMr@MBB*pH3QC*ZQW}+P9NHb%MI?!$iNch7?vE(^7juC~Ir5Iix#UF9Q!dZELB} zb@@StgsQta1p(P??9_L*(2ru6BOIC`m1Vs;= z&&;(oa*7di6;*GIrXrs<%TAk>ctX15SWAqV|)0qSd#InctFp5g76wr zZx+Ss{9BQcVXYL#gkqP_J__`ngb5)fXIrVve=q!h(E?r!{y#zgH|w>XApU=Ir@6Dz z|KCghOGCaO{69f;&kKJ)3SB-G%6iP|BJJ)df-45W66HRay0g@=uTii$Dn#g1DcV#Z zy(~Fd4DNG6pz*@3`?+KaVaH15Ou5x$Qx!hOC`v!i^atE2mZtQ-eguXZ>qyScMpjuG z;t!=^NUFp#ht-K6N~&xq8&bR2@mU6*4s?(<%p~= zYq8gO`X33lnkJ1p1?WP_bxkzs88U=*cgtpXVs}+}WN@ zbgu7)D%mWSFfHwiNGpPB;t7se^Sa{RJ?x7 zS5|^P`eNwOH;|7&4L`7CB4mwaYI{#4^txy%P5!R8u!fF9wx8(m$wTD(I-Ow{;pvnr=`w_F(mRd@X z2z@UwyWD~gjkg30+D{4)aqu39{||SW>b?+tKm$fYExTGQf)MoDtHr+Iku{-mg6LDi2fc7NamJ+=)4B=T+_&`cxiQK2) zb#SjEKVYQ-o&xb_rpd|&D1|V|v6fV__|?B}I$Z+;O5@fO2UHqH@{Rc9N;zF)p=FmA z%p7EMG4WhrV6&)1>UBx44R2?Y&m}zvTTNzvx5LT+SZS z?gx|3f9uU){7=2Qz1si%2>ZX#kgJb_X)G@Z=1I!qopQAaR!kIXATgU|yompJA9En{ zM5#jlW6L3KJ1}LIJEiqV$K+ep#Ok~YlM!|*S+qzZGnQwznB2w^=fd}43KjnGhxT6J zlO>gO*kybE;wlhn}Oc>z7w{D#EU32gWu}Uu_aG(Yl;Lpkft@mv8gNh%1~W< zJf<}|5jBzXdvug`bGZPS-#tjRf##5Cj-*=(w3L9JgbnOlHxqr_`kxe1;bl7Zuod_N z`C>tfRusNG>e*moN3fPrRD-L~0Gxl*D|z4IYTnXU^dk2VKjtv2E=>k6w@LjQyl^3O z6zzd1Svbq-%GtLZ^&fbklsEPi=aF%P6*V;7{O>!Z+gE4q>VEHy*Kw3(f!~T|U zR<#l@a#4WAT*i;2LiNUQW;#=QVmKEzXR{ULTgZ8utc&BDp&Dlf!x0{B)=2U|EB;R@ zQ)3B*1B|N&cjvDhKP(aoaYzc$Cy@cSUHZ^yQ0 z2BszCE7}&HRGv6@b1@ei8bqzNWb9S-%QiXr;sCgq%(OKpyL} zkLD~|hNZ%N)BK28+|phrP~F50-%I9*@LCLxWwE5aP_eQfe=MUSMJyWebr6b7fJA%l zmkr;rdo~!{oBBs>aKdlZ4P-=3V$(=f$%%{yC(I&N-H4#J3Bxu-{1#wHBVc7SVl?52 z2Ik$?H3NhcIzfkGACkuQLq+>P=H`s#{Cl8P5a6@>2^tq4V*2HSR@5C*!n0hDT=VWj ziKc`DQ`jDV;z{F%lx=2T3B02L<a*N*V|gox44(!TRl#pLGBo&M1r z(Ca(Ot2zowJo!oIMl%I`_bGtPgZ?uC>G+wYO@L7*-WwE87qQ;yh^{;2z#B} zxeqYS0f#V!<>?;RWxPB-pJ@6BC+*O~1md`_=&pe3aP@+DRwS6B7O~JZ(;Zey{t8JH z9{$y*>ef>!h6To$JG>p}&ckF_^nTu$?%)n;8~k%_&dp+JL;D}$YLw%m8-&r~WS@JU zF8{Ufy6~Ts_hq%{p=c8)Mk$QOqcY$`B}L7eu4m5pg%SQ(G!06Q{{z#{QcH(8@ng*& z`0|cCb2hLV1Y3!W_^&K`%F~q(<p=E@fj#2smZ!ZN$g_?W!5!PGF8GK^I}JnLo}Iv_0YKmspp{7o*l~4 zye-re-$Ye7JG88<;aw#XR4U-}hew9_W@vgLJ{*@vIO_?Q4hiSR96r1#eH`j`M|v%F zDqjsag|20E$R0@a2GLWllfL+@W0Fj3%pRt{v?(m7Vj(mFT0&Di>YB~c6p&mjWK|#8 z2$p~KJY{F#SpW(9yEENe+i(bG+r?@3wB_xB$(Hi+=vVXM+6GG#UH!g;O8_UAddFi? zL}H65a^;^MrRvA4h#X2vq_H3r0Zl^}l{$yM^|PFj{YoIoTs8{)1JVy--m_3XL0BK2 zWy*UN++xw)715$%MWJMZOIO6}S;I6S_83pwh~0@B-(beFFC%^Im|vd@WhpsfMW@FxqEbMHc3es_!BQpvlqj@eORj_EJz#-4YRciwVc-I!Pyhs- zyS*uX6uGGx(NEr?*P4)<*c&S^r(7_}N|>6sAysyoP{5B!lXkH&SO9)G;l_VROyb#M zO-+!mRE>2+OwVvrz1JoGdMZD@jUiS+d*}f-2vNA~cd%NLlhkWG;)TLb_yyI0Bj43c zB>_I43GEfU70L$t@4caw92`o9POS0Ks7IO{S0iuW;-cw)c>&h>Q8z9RvtP<<2~%F| z+eI1^BCT#xyH%+qzskw?|o(U zk!jo`7+8t6nUgRQInkz~whUKiaI5WqNNtH|y-CmR+(R&y9~cbpg?(fcbGWOM zW*|yep303}{?qE)uuC#Z^@d7{kwfWdSJ3BPENL%af^7?Z*J`nYi&pFO;J%s{xCwM6eHCLUa3uCkb7|0^{i!jz>T}Z`5VIrbdubc~I0vlN=OT&y8b-Gb=6M;gR|9I%X)dd-+92 z(*j4~zSS$>ZLLKmGa)fPVw_CsMl)lZ#9v#EIwWXa4A)-c!8uTRG}dqMDl@<=g}!QQ5M?^b{c>$_@yZ^EyHmRg%;ps-P3>4IzefI_>>) zZhg|nz(Xp=Ww&N@zlgrCI3Ht1UzM61uMDQzp^a9wAF{yH7sK?&=sAs~*&W(53`WpM zvQw&l^lJ@6AuN6>vCq#WmCk0KuWMn#_;6^e&- zRN;6hZ~Tud1<*Ln`+JOOB!NgvjxV@lJqD4IUB!5xu0#Bs&R7h-Ssl@K+3B4=VBz+Wa zdGNtiN|^UhS4oBZb5b)Hb0V=SeIyp8XUE}a5!T6yuNOP{lpyGfA_pb8C&*&i!L?I5 zj>h;Z{BKM~tY@WR=rav(iF|GqPx`3(ZlE^uKoqC291#}S-(Xt6e(%T^=YCsV* zBMiv5Y2YCza&Ph!JCw)aL4a=b!aEO5mixw;lmE4T^ zTx_nOYc|;g^A^=m8vFU=2bd&`3HFN!-NF+kx-$J7`;6Kv*q5NJS9iin7R!UKJuFm4$uCm zd5wWtF(>=LpTXRL32o|*#UhR-_I<^R@f~7?B&r2j_cv1(?2zm`)6g+c)1KelR@Nl& zldr1yVF7JG4l4fl%x}3&`bH`>k=Ywn_c!D6H>SG(yJQZic>J$=vk|`kyS=l@|Mwm5 z|Nbx;pyDq?I3VSrDq_$41mexG8#`4qaAkzcbtVtKXN zBbaZau`|11C+0fd9>F|^+#{O%sOQ>#kzS5tpJ(fH$ejUD%NZ3i2ZUJ)eqtBr4%`C}`=1Ir8kS1cv-_%k$&WQg@JN z1ElC9zid9;+;BltP|KqceGC3LkK>o zDkEL9ea~vOoL^zuo{oq+rlne)h&$zqAS)}sQ8UCo+mM2MWvkZf)4&Mmh9Pzp+Yfysm17D3Si_7yYLyiRUI=xd?z7%MBxvj zM($R3Kty4Z1cs1dL%tUXrCo{Sujp1KSXMYm5GFpO(-vC75A? zz*HrJl@44{XI;kM26{3O#H6FI8qaqEm`VQUxM#D*73~tHFTvf8W}CN^c)U&BsL(HLv>v(zYrX)`< z0r39DbUpm`(;0dWLI@sFO5IQXC%Bz0=S&%h-owLZwq;5|JSv)fg)SZM|DC(m*x3}# zb+9T$px7Heq{e|%&@C`t%2fXg<<7O!R464(|Kl=u&8>AF~h(l z8&XYpNgt5wzjBQ}xf>6L3_m!ZPw)}}`Ob@sJA~B$_FnF-;IXYZ^K10HIF%uZOWN0J z+Rr~TcME^_zIGMZEC+cDCL9WW?%9z9XrK26D}Zha=>sV#gI6dW13nA%JN5cPb`M`u zjnM%phl9E6+LPaA=t`P-qs2cM`n-7C?#OYbqrt5%MOi-}E>tr{+S2$giC(&Pq@k-I z@<}P1AbXU8D4k&8;$H$d$S>=I%OhIQ`(oX9#QdUv(k)V7(K_vG^=75I0aXU~ErCt~ zEnROWHL0cGaKxMW$>Qe=;z4V4b+zrBs!K6c=~`DLRg}WDE_Ho=ltvD4;3&AQYM<3O_!%6kr&}nAR_E0jUM@P;Kbl3*AxX4CXG@__w6#!V`@f$1 zK(+tpBCzDPKW+T?&Q3iv|97@l`JW$q{s)I$9rxXzlk312_I!BzTAvssUTZ(PAkDtB zF9m!Z+lD@-S|dWic&bJ1Q&95}+V_bsk0x!r5aZf2a@k7S#qxjyz7-d9-N4d*I?)dG z>ly3-U7oOSV$%!!1WAD0;UE|U0x$Zt{RuJ6(IUgMaZ>!&GH3d1c>A!S-P-pi$)@KL zYz1iMnAV1~&~i+pqJ5Bi@dHDE#-N8OroRA|c`X~-{jD{;)i8UzLq4P&v(GgW@7y>^v;Vi=xLq`2X7ye>rOO(;#hys>^UB?PYeh1GJ-W29I*|I z37$Rh+l`3tz^^CHSsGbP^fW^#9y&t62)P$!-%mUa$-t6&$jGwB(5i|C+IIvH1BaPa zWI28?%soh=eNWKyq3=O@d4LPu^q|k_NksICJd0T1$pSw+xJ)K@1U=n}VPO+`=Tpk1PJJ>Q$(sXEXnA=myq# zA%^4~m-tP@&`O$g7_t2lgCrw#-+s}rtQ7D16U-Rd{+G{$^4bH$um3ySxLnHX|Mph1 zwp#xmJN`Q+F=KBszY9H7oyjT^?XmEWaKw0wyh*l)ef^K&=8QTIl$XLnF^kfsBzz)X z)>L7RWGP^WhVKo?EOA6~yy^82XWzk@luBpQ@MB1`fIS>G1 zD`Gz)=%^z?_#!V6N*fFE{{7_<0)w#x8ydY-COo68D&tCGVNLBdMmFCA!ksct{tb)qoabOD5Ha65NTzTnvVqSli31hgnt!u15c3e>6#en)J5# zv;We|x<}9a=GNPe5UUYvs~?S_vBXCM^D~8BJd%t#$H%`UAK@9&5p!M~lMjntRU4Ww zYr_-!{DSkt3|37dGssCT>fJh2p7HJgC0!q)o*6?+@(fp>3F8i~eI1{~xH( z%Jlj({+a*$->MO;uR#3w&Q5bR{~zNYULE<%v&)Oa-DiJ!0@Lp&Jf4JA@-6icfH7Zt zzV;K5auZE^DDf2@&@8Bap{=I(kd|88e5M_kH!$DM4t_tF!&-ZYM_&$0Lhx*J?Wdo% zw(5=DycR@U6E=tK8wFrI|_`_ztOCjt>732|tI8{MbA<5r%`2 zJxC(^Z1cH>Z2>-Zt2?`g+x1#)YkLpBsP9&r&6@Oq@4{YyAws{Fn!du1Das-Q4LkYCxfJuzRpot=4vG z+s(r;g+;g{w5zFTdmJ5XcN+CJG^}3jG#fi82Zx6TyWQ_Ss_?ouF(RjhG;uoi^Z^Sv zr0Cf#0vbqDXnXfyyH@M&)!KW#W@mT1im@|IP@X`=`;|UySMOS-I@%?bHm*HOY!Xdw0qgP7@V|^d!=<=4pzUBJ;p50 zQ*(%#q)Fb42v0AyBK|0mN3;3lbTWPb1H5Za$Myr76KA&L!jeOB98PYad=-m|L)%sj z?Z0ZPeX&Up3Q2$tskoXm6VJIN(Wy1Ic57SpYOUSg>b0AVYV`mn8I|9$b?qAidT=^O zM4w~$%h(!PEi+Snpe(Pz&!)?Y`HBZ~eEEh0B@;4itF=Ne7MbOFEN<-z6An~MSK zZxzhmregN6no@^74%b3`>DbSrf^%kHEhIvf$t}Qzx0`X{ zy3v)aKBmNIa`9f%A5wec0I9 z*{wGld%L}Qvs!I5w;SCi=iJ0~<3OK%0}=5_r{i&t@wgMoW6pEXr<=eaGky+L#*et5 zmfcUM{cpHgLG=#5EhLr|rjT)dkUC{tNo7u)3eF=)T6H-J*ybwXLv{uWPIIYXdT@)UZq6GdD@dVX-8F` z?@HPb%Ovu-r(?6iUX&5mvI5`E^w~p&Myjj_7tZYn-<}}k%Lx># z=Rj4@f&xXjij^u>X;W2cP^wZ}5K{3($5|dy6>Rdt!j)aAEiGZbwu{ndlF~b)hh$29 z*HF!1{sBEPQOPJPL0Y1_?##NLyZitK{=I?C#pU#72Fq>d)*Papw^t`7+D|{7p1*3J z9Q}9u;^_1QU6b(ckmW(51!!y9VXDzO+AHx>XGTC6PbU|)J$C#Ppk}33sZsiLj`II} zO{25B%|&!J??#2X#`H4q!8N%(=ySRLJ8r(TMy1aDwdN;tY){eRJ%`aGWqOkvM0q9e zVP^J+`eb4nU>)V{@ii0T6SrNxdc zB@1xDQ%1=qUf`5XvgDUMPuy5`!<(?QeVd_M?mTc~>Fsi?RQvM(d*>jeDrNbvc6!@; z&35yk-Ro^_ciOv6*aOzuq#+lkFNfa%vxSI@m!P)FmWoynhaqS?vM_IY9 zInw82xAJ-D@;94vXST`QPa9sX{qxTG>FGsjvr=LIqxa39JzI|~B<21K6sIHl)0?A% z^Y;0>U~A=OveotCHFV3H?%|vED^jL>di{?Mdnuj>h79YU`oA8X4USIw7wtE1c)Ct+ zfn-*bKY#&GL$9mScU|*(ezWXQx8MBMe%I%XzwAT%cn=`%1zGjqv`=0QI=}xuz&|eA zuX+P$#!0t*-o-w+nauzC*Xrl>P+{7y%v(78>tCf3y_5FAo8I6U`s_`wFZ77~{`BC# zIs;%WT;R9%`N`nm&1vV?L8pCoae3Ywv@b7C&wFor?f%iptEewdPu~nWrzdZF=Y6n| zB1&~mkB{4t?{E}PE|RHqkIs7_5Tp%9{WkP2^c^ib=$)URo`=5Z9iLsi10K8YB1ra5 zE{~HdJ~}x(Iyt)N4WRzZ_<|{#adLVBgL%PJxRUz{Ixi2wQG^y1xFFS6Wa|Lmx9bb8qbVSL+*D0O*q z-fMSW6PfIscB6_7x|e5fjyi4V`s3ck>(lPwxP8%iEtNh!dp9_H-R}1Yi)X?_V=~V; zC*5+~f7S1ubVUNJ)I|@Z@Zbn~S(24koz9@6oTxBQ_xAikJYng{=>Xae1LZ5)% z?G6qvPdXP-tg7-v)895z0*E)^x2!!cgH=L)bz?Tl-jMZVL85d!<8!O zdQ=jpBAL^ZH}3{*;NWc!HLfJI{qC39nvcISMXZJ>|~A9GhqZt(LCU z(zRN;{?toXa8)`1HP?rk$hCd8r7iX1m9##sR)Pgq0zOs|%KoRf(2_372ixgHZDW{v z4=e?T)o-?kS#4Lp%XU?@;2+k$;ex*!=kGGk!THRbX&sd_Z8e{Nka33Z8SIe|p)RO) zavbJ!9hpNVlGx`- z&IwvJb%@2c?DKCKe7bzcr||$tA!7-d=!b{Flf0H#7e^w`Qic{tw5Vtgo#xc>sC- zum0&tWvb5{vxs5g71**G^sj%|)}%NX7}jhs*wEJ5Clx}WR4T2l894t3WBUe{@|%H4 zv-e_T?aABsPeIa{)$!)^)hpQSJiS8L+^4mtYjejO3_pLqo>>NA!7C5;4sOd1&X2mU zdVcw}HF{@dKqCDH=4TSt`t-}~#@)u<-NtY_{rXfYg4mSn2zB)A+2*s#e|!mj{&oFo zZSBsQ3_S2X%osQL~P-$Bi2Y$ zb|qqaDn5Gg0(So0Q2)LJUJzU={u8N-;S&VJlEy<~l&D4x*d(}DPpJcXPO_l|X}*6-LPdIgw!6l`)}Dfu38Q^x8?6?iYE&lXy=K_jAI={L zV_G3*C;unjJXj~Md5^Q#OkUrwv*+tqq?9wiyVGY6p;Cqk99mP?FDX31k}qDAyS;u# zks8nspDt@7YX&QbHNn92vFV!H#LFkAIrex?{=n7y0dtxVmykwRadgS3@(!{}oyx6! zXHId_vgY5q?$l{*Zr)h#?fkj|HEufg$i3HRX8BH^V4EceRM`=GYiteSZ`13{qUY5Te^)#~XV@h5rDG_W(! zX9ySL&gLe=HjkS7nnq^A-jlN{W_q!2H1^$9ZV5AFUiD&*Ma8ybM!qczX^JQ zYU3~P*T(Hv=+5=fvnx)G-DnuxlJIn|Q$xgVB{H|`+J9(Wd-9a$YSKD?{i{Z4INEf6 z4KhD|z+64hTKit3kcZfTBYuYp`T}0}@2!!m@v$QuF-~AH6s9I{S zwdth#`}$vsXQ$mh+rtk$FS6g>zpqn4^M=2x^R4pi8O(Cm#H3(M zsy|ma<9PT};SK%Q%H+7*{*R`P5HLVDVb3}>XYND))-+vI1XJgm%>P=g5yt=Au5GR6 z|6}}n@wd-+V~yD?FUgJsmJ)i;@#Xs59hG<2|F*yOf_pH;Tc0o2x3D-6m^d$q4Fx8Y zGlbcyJC!?YIJ41+BG#1Q9kbp!#H+Mf2gSd>4=dJ-BeL4ki;i#fYjgZ^U6MU#UE3rD z`BSOrrDM*Hb*zgQdr~fwCHKPChA_JJo%O$_Mi)$6_cwpDCg&F#vn24pNE9rZmPS5% zrp?gDR33s+@R4Mm<3%U7VX`7%M$o*3U`kNRXC58Ra?LwdjslR=a$?n_9U2<#6k7cd z6yBW;`*7X(gjnXFEWrxbXJ8g@kPL|4w!yH6a}RBKLz`0nnPZwQLP%lB9`ljd{5;kt zH=8cZ?@bsY7e>RG4L7+80Am8Qz|7s8%+YH|$d${Ao~PY>1g-3Xy2l)l_QZShVv~u= z{z|$2ne~6OUU9~gZ}0!x+E(cQ#&)f`(*KX~@BjP1|DQEaL1=F+XReR6zB@N8*zIt) z1bY28P)FVAP=@WPYxPRC3Ra%{jrX>>Mqkk90-SWSQmr&#thXw)>NeCpvF_$~5oN1T zcB@*i?Nw@POsy2xzw91#_By?T-SY0iR=ZrQb*tswZmm|XR@>FiUJc%E)xWNnWKYnB z@Z;~kxBJtO;F{y>FGjPmJKEYa%k@2@Ufyc%?UnW6cD+2(hsJJwr0>)kyIg`5(Z)<0(Tx&P>wwv9pX1BM?dGd2n5U%8(U$zdLz3pbF zQLfcH+vTmT?Va*oy|GuWwmXN_gB_@+9mz$8o}tSY)>x_h9alEK%m2IN=wN!`@3c7k zkJM|2opxjQu+b^+baoo$tv$gJ3EK%-R;J1HwS(X?eX{_HM^uV{r?iP z?Y+Hfb9=W_?(`b9^43A8Q{LNY9+s$yW3Udp$Xh<)w=ca-eGO8+^hF?L5uG7wtHJSHKI?_(q(D^{p9cd*T_4X zL}U(rsdhT`-R<4Ia$~Qz1rud=w|r1*LJM{d_O|y9cMo>A8hMEKt^RSQrx$Nhoc%{; zxYO9(+o^V{*czw&GKQ+e|4 zbQ|>pV5`2fwO8KSZS=}}?P?b!tFhfX+<{5bXy(y4$4=iA$HxDY2Vr-sSKU51Y-6v( zARGdV2ZskuP&d8Z?Hy3J+dI7+_$1wwQ8NnPh<^vvPIqs+y$2end$0rS@63sgYoE#yA=r{5}b=$iKjqQU*x!bN*LBUp`rQKe$+=O*-=b&*2 zE8#&7EzPKxm`+mZqk45~uY1tj!>VBUs(1Fv?cUY_sMSWh2MgQbVYix>WYHWlP6sx6 z*~CluF!VqYv|6oG-Q5D?111ETA$!eoyScMh?zB6#or7kzQQOL6X*i-W(5V~2Vy6Gj zzLQ6wcUaxoI%wC*u&Tj|&;>=*uJ88B+r8>m^Q_N%ddt&i7!qW|sFsEi^V z-Qzw*SfND;pc%3}otfO6UHj>$92T=@U#i0)U&&0il3ml4>>5_G(e5>_WY6elxLJ0n zqXW+?_R{i@YV;XDwb~HIUT>83?VTYVdmYDqs1Iv9*KF+7{A{J=M?I0Aky&J1n=p$E zGK+>_I-6V9IE!=>tm@Ios4m+V!G+-&tO{mz`+B68hu8Y3yfvyC0z8GPau6%W@c{ zT%LU~N1$1*b#CBX^NYc5XD&Ck>5CZR$ z*%{pg#xOG>VmMW*OOk1BsW~FOxBxK)SJ_E@R&w=uF+xF(79q3SRPY00)u=B*D#%er zLc*+*xRU4U0>pg2mL(M!zo4F2h@g_YMaX@Q;O_IH#KN2{Mk*j>{P}|7AxqkV#C(Oj zyr>DZmXtJc8OTG>BzjBBnZFP%FJ=O*C8f+?1oIFwfm)1=r8;a#vo>ZW6tOmDBon_j zW+xW8HfA9eyEbMe6tOmDA``zhW+fH5Hl`z#a&62`EK*}-CY7YJ79kj;v$By(P+HlE zMQW{#q>|KDc5+MWt#oT+W?D;1TKctd0leMwb0tr>Fz{q!ZIX>Pw(X6LjcwbuPHfxe zZfsi{+qP|;d%pL*f5ZFBshXZX_33A7YHF%``guU6W_uGeD@dffTuaaRUvFLR|DTr~ zT>_UO)dUI+9rQz*RLfyTf!W1jMuY*1>VMma|7#<|=;A0PNp`cHkfGBuHYHCfug4_% zS=PXkAg!d85g}$>WmEiPQO)eX_>!%S)&7eww27IgtK^XGa4kJ$DN!la%XUJFeu^`J z0-7yFo}ILSD$k081&)7lVzAKn54Pmcy9(=%$h!(N;-nqrNlBWuvdl;Ytvo9;quJc* z%Hkiox(F!I_pYWSX$Kc$GIVR}DKRRA9ki@%C68)Sc~<{RLg!hz{dY0PDaN7Sv0 zDOt+J*_a&D(sD|OPGJKrZQW0Mbqb|_SJjmVsY}|HpfOw0Q(J2K{K+__r#Jt zVz#edyh`}r5Ll?eC_X(IB9q<3-z8l+`^I7+_6MEKh}XvvwnT_`hVQaHX)>RKE;>p2 zKB@|GST@1o$rVJBN`$j!Md`zJIT1!Qy%93#%EZDZh3R9a3I81jM;qt#yG)2OW2gyI zL{>1|n-``_ae~gDwR=NH{F4(L|W<=F>xXKKSPew8e(Z*iS);2v(C zxpj3eTDy;XcWr@3D^zV(64==aah6*>6I9Ny12O++f&t3_IpGs|cH^VReN3NvVA# zezsEa`{@w1;{EcIp=!lj`9$sAak3hI4h$L>nIObQVSq^mm`JwrWSc4eeq!J~J zMNKjgn|5$F8e`9Y7=BjLIK-p8PMB>;gBZt#ZW7Ei5SJE((+4L`LJ4m~I2oN7Eovqr z_CO;(lO>sywQ5u&HiU{gkgI+^BKAllJe4Jz6ru$E77sy5IaG#DoR*=`*f0{67$0FK zD&q9NKb0G!YB?(6Bg{p?o{tLBa~(M{8;wipw3m;F!7(t}|8tCsLe*AVn-k?{5ND8E za_S?dJ%AKd8^wsIj3-6cB<7^67nUUOiz;GHOr9FU^R2#)lsElVN(Gy)5!4_;1w>SU2o(@f2_g(kL$YT<9r{y11TBcr01H5RnQZ=s|=Ih-d>5E+FCpMC^cw*>Mn&1|k?igaL@?0};U>0&%g{6#RQ1R01>Pp!U9Chf{0=eA=4l%mv07& zViH7Tf(UjHVFMyoK|~{numKS}AYvBO0d&vY4RV4A2N1CfA_hT3G>9NF2SqUjBC83nOTr3F8ZN>QFuoST9VVNgP)hFQGOHx{DGPMoAGI`P!Y8oe@oMfyG>k>)c=| z+pF}1ZqNDN>yvh#ElXTfqzJN11}%S^rk(>OU)@D1HNk@_{e{$eiGXjtL~Z+k1H{|g zPhw6rFf4$de-uXaU>>Bnk!!-&_7%zJS^HbwLHpZT|5eXs$mU|xion07l|}L}K!^1A zzkeYCiGLyg8k_+-49-+X@AAk@xEAX~&e}3^v>rAlglYZk%u3MNIaz0ar)OzeicL>e zCbu8C*a*-G(Tua{2ODj$O^Y*HW}25_@=dbNNo60cp3)0UidhAwR|Zb^sNTHNRC;49 zb(v_mkF|A0qU(RYJMQ;Pp8#m5y4anZt4TS&I@T&Vv-)1H*W{Y&B7uIp&+h+*B9M$jimW(lxhF%ja+E}^W}Qi&hcedaZ?n@r}J#%B$?RUC_(+7h^{te1Jt zxO5OmMNHZChLeH;D-AG{M!S$hU#oSXrQWr{CGMx0rD>aiSv%@F9^bQM8p%)f`E08t zQD*`Qk;|{8Y>Re7jU-%Kqb=*yOR)8OYdQQ0+{vy*U0Vxz9sKUXgY2=j6_?dG{@r`T zRU6Ce_bbG3-2f?{p3=S^SE9D=-On~pzJ_RADq?bQw=eJ%Hy zl0R58|DHT_zDzQn$CeqQLtl(+woV4ZZjxm0leir5hY+myGCA3#gdTC0qUs(#rZ@~- zR|hRZ>z%DLtM^P#NF@j}OnuGH=0w&w{@cDX>xHMx|#wKkoi^QMBQ_+k#5ECu69H z$QMq7cOidA^r$gE*8*DU$>Tn73wYUBtjXEAp0r+fz3iyx$IFa`U?o=(oJ8<6RbnM# zia$_|r@UjDP_oz=g@D>6h9lX+lPlXwI$cQX{>#p?9O^soYPhw7cV64$m>P0q09D%l zB=#3lA04PfZ>f)6H=QTu)B|KdDV}czZOTqJ9D207-nmAY*MzNU|54X&*lpkFE)6&w=bXWm@wO?_~iJc1}>O}k{-qw%V;&Y)YD z#=4$9p!RntQ!bOGx!U~gqTnY*dEXy5<9ti z8y|n>s(ZeUtZZJ#(@Lg~Xb#XJ3}8v9cQSwaMWLA9`ncK2EVOx=|HbKP`LkMfN2yig z$xx)y+^C}wp1HoN#J609qYLOu5NkHm>+StC`zbZRae-7uehnsbEsI6F#!OUQOVj0tUw~nL zgkKr6_8LL?Gws-3usK`XntQ0`;#H>MnzA+)o1zw~XY+J}-cs<~%LZ8>GgKpmkGS|~ z*%*PU_qcu7s+T=gy+9uEaw|6u$>VjJt1^8JMm!g~QdI-5;y&_O1V#_A0(Lc-s$UD>~Ih%R)HJj*O@nRD36bC_VreD&r zDY`0+V-GH+q*@6@)LJ;#>dWQf_q@5-wNSLx4zSRya+*OE?MKIgk~3^xF>}CoYTM!> zk*H(oDq1!w#<9y&wnPwd*x7&%nu}DbSv6D&WMlNnD&rjjo)-vYU-B zM+_F$yFsHKZ&=j}fa%h;5)BuME;Ws~PEgx}J?(4aemesxCCRUkI?wcr^L)MUwMTV( zRc{g~aR)@YtQg|-g2yGiR@yvgZSW~Wi}N=bPs@?#TsY|A_$%w|K8|O1srLM^c_wG8 zvW|awxzr+FWZeKK(r%jBp-L(~7p|RP?|W5stFf=mY8$-#tWCcUe!*)U9xJp$hb%iu zFq?yUR)chj<7SikzChd|^=CETa2dH)R(S6Fg^Nthw*iivf67?y?t)nh2HVb@htTBS zH3AmTHxc}^ItDMK3SsTKS*+sjUXsqCxsv_ROD+KG7}?7r%S7Fro@%R>d{M%s#N?}I zeP9M7O0(S+UMRt{zVVrZvq2G*Zu}5lcpk7xob&`(v+RcR_Z4*M(&KWXj;MdguP~}Q#f%(-==ke>`OV-OXlo`Bb@wrIYTbREE^ZCh| zM)C^G6Am}ql}Eq?qgWZ-k1&N9E6A)_@Dqn6GtugOYR(695igs3R%aFlqQ7I4+Ne<9s=wwlU_HQxYl>{DEFTkypMbKO149!dk|8g zhih!T5d$$TlgHXEO_R);fJ#)KArpNbpv;gPaF;+g9NJG{>l;9JUO1C5SF9TTMoy^^ z*BToWTJbl`a>^J56`C)<8~!drC4Fal@4SX7R-dv5pC&0u_2aR!6$?wEoI!!dG;(e( z_w&*MZ;PGXW*P3*HRqA!$7<6xW&iXz^{1otbR}?zG{BLNa6T-LN!*@_&*}QI+EoI1 z_XA74mu`N8T8%G1$!j;LUYfxd*P5I~$>ZiQG(vD2s;By)%g^UKp%!V?&wmglRbmRU z9~WG&u+#zx?SV-Ufi0}H@646?oLQ@^+xWnTuH)6Nxvf=arY(UCoS(p{1w5@Hn9Cs1 zTL>iObe>i!KZV-Yg1h8*e(B_@nj2t`)zJzwHfOMWhT`RM9_T{mtd3ec{kBM{&q7DL z?h@UNWERg5b~7={?ew8u;r{3QB6Kk@{%-^>*X|!#8TVU+uCsWdaCHIIxxq?ub7PNB zf)t?2N8yq9V|ya_8BMh#uf`WiJ~RIxMlKEfJv2PMmHX|#TXg(ACXrmHfHaj!C2GFin5^M`Ge>S6VT`3}ix;j5eB7V*!oY{h!qBK4X|$Z=qmGMYWFgRQnqD??>!llPkp`09NV zo6xpXF#oEWe!Fe27&7DP+*;lej3cZ^GD%<3UeaPOIav~Yl7!64uTx3kPg7^Zkky$Z z7yyPjVjP*fM>O=He*rIZ;(($jyfVfs5X&owVE&Bk1$drAm_j+5?@GxRDvn6tg z!*w549^F880wZU7%yo7a>2VX^aK0YETmq8rqNnKodhz^wN;BqRX}@1Lm0W@o?Zg@a z2FyG8UXjj#DJ;0f65C)<`@Im z$T_6ewkf*UG2hiv^1o+Ekj-MrTH^Pc1FfZsN280US>ualr@xaMqDxb2v68r!cZa9% z+N^z8W=|Ia(;V`qlc$}&mN(#ELC>hmH>)p!;hr+*+MY0Dgj7kFV6P~iyf@sn!`V81 zaMTQH`QzNT@Bbh-3%W!B^xj%rm&&-?^*Lp0gn)9rQ#51fd#*Ef2wpL0hw*hDOTx}m zq_>=Vz3XE6=qp%pbcO}d5~zob)of2B5mjn_f2njH#zf@qlma*Yrngmz033s&n?c;r zv6$2DrU~z-BxpyhJg?A+`NURw;VtE@dT2jWl7i@b3y7oU2ggWinyc)jo#Xb|3 z69$WNjRp`_8c%s&UOC2Xe6&M+g3~N4Rx!u*GkRWO7)F0DPz7$@f1;uepw>K>plX&E z1#O6`6uS^Yr~OcZSg-vTRQADJTS?Vjs&m*ft?dGYc{2!n9_4^j5)0F}95=zGI00%E z;Y0#0Y^wGI2%Uk?GV}QD=Mig{kDFo4SB<5(I97yr&OxC}yY}_Zky^x%Bt&-Rtn=Ur zBn&GE=v;uX6MIxRL-`G=$24JCfp_jHD5GF}CMZ4zeGi$dC_T-yQpZ4>Mv`B~U&PJZ z2k*jOlzkiu?Eb_r7ppiA+fc(%yN9Be-$kXoIqEje^W~dREl77z&}mZ7vW~tnyQ!?& z$QjzVX|7k)Arm=c1s&tuc+Akc%iX_{kBot?@9CPl?a&KZ}ZCcagqZ(v4(Vy+`*m70NiXD zaiTq~_{y6_YypdqKgx-TR1VWcvtC!=Y9VL1vH6vY ztNOFv!rv4cg{-%{7E!H~sQK}!bEJhlsRTGJI2rWx1i`QzO_<1p8$|GHdv2qGHI=%n zMheH-xQOU2jieB?L^)8@OxgB3~pTHm&nyk1^s@!Xc&5y)JQ2u%yK3S z9l+YFxt0h~HX!7g<}M-7J_NKMXFjLB8tU$V0!ME(E}!`n z_`CGh+$%kky)Osd9_{u8#~Q*aqKO?o^yr4f|CDbLNZWQ5XSqO)nm9om=D!7QO@p~XFH13NYNNo0E@k~8z$eO=zFYg^ ztU017h7B9uNBvbCKf5~Ke6lm^5bhT9;*ZpNy8ARBJ(dZcl;!Xh16V) zml=k#4*UM$+Gyr}=Bc)|1k3C>JM&g(-;AekjX2|f6}s+zR<*qfbwHFH34Y=X>+&!( z27xc0HQLFRrI0c}9?xaycjU{qzFzn=`jfWwzYqi!G;@h`#DW9d=)*+86~6G7y1v&< za=FfUA>zu@&c&MaWK-Y4Y%2~++~20-)COloSmN}@6YJ$eB|E0q>i02>C~H=mo@)XYmPhz7>Vkx?%>o_}~QfW*?C5%5q7r0m@ettKScaQ+1(e4q# zChiEV&=$85V_>YDr_N9_S{^u_Ar~}&fPshG+U`F?TfWBGoMFNcj~8f6nnw`gU-=#q z$8hQ{K@bmo27dIgRorV*7dQvvPdrNyj6XP}(kp+x{ZXp$Whq<98Og7OQPV-EVUP$= z7hNFP`uB~h^W%@3!LY@b!KjN#)Kif${c&u?Tcu>jd)47i>im9UJ`B-5))U*y?^cwn zxJ2oMPc^4Hh&lU&L#d6wZr$3f7%<<`>1|0>TLRHNh_`$$?QfVB6s@qeLc;d0s1rQ zNsMtg-XPZ=DvS)>hjcLS`B!hwGw&#_H>%UX2%W)!(KmMeu-;^A3fwxeiav5NSnp(J zX*rDKC%UfBJd4@4p*>rz9-Tndb1a!!Ypyl0%NfR9#f^Aqdl7<$m|lfGoV&!8g2v;5 z9*q_(XD)3W$XU){q4o5f+Hu&)T$I7hjS4m~)f zbi8Gzv5u8_H=WCKO($V55(UMma6{b<3XfsT-_hfskr<5L4t5FDF>U!vm~vX$+51$Z z*We7?d4Ks?>iiKN51OXDO51|HLyC^vi{-;(WzLg!nHtIf4=&CiQBac8%~%NF{i_FE zmLV_0*A;?|v4NU6m}KKbkmc@{Elg&lPUc<{g@+Ly7d91=eqbTYBSf!-NfDnZFQ{tZ zM`%RKwNZQ%Ch;>Jc3V^C<9PtX+t<^D*Zaik!fmG?UnvXe&u{_m=h@ zVR=W31bgrsQA$>ZOd1pvlRPUDtn4IDoH)v&0;!j7kZ$y?wI@-&M&x`!wHkf_M?V2L z?UWn9X?6TcTmE?&bHs+$2^qx)s~Or{`-=kp(uj~-an2lS&WGpeSyr~j?I+bJkBql} zNhC#3sl^Ja6;6J4X%G{)mW2aiokjR1VJy}Ku=vSZ5ETx3Xe!8-9`KhCJf435g-vml z-XGFWg8JQZ*M^|nle6vVu2GQV>}GFH(X64^eO{PD7vvn^Vdp^eYu%PT@x+kX`>e8= zrwn#MfS8bDPK>P=(=Z2X^DX#Bu4b1Pwql9`&RAldETB8ifdN(@|K*K~%ai%l zvTFS)aC9&KVvYArGmH*$6^qn-^$z~tY41*ul_)$YvHv3BaCLbKqlT!SasOQHDu7X! zPIS_pZHO$oXfh=yCvVmA|zr@%uc zbosXnhHFR$44(W6&54P_DU&;jTV(+t==nMo|JEBq<{Y1Eg9qHc#>Y><^p?kqC)y5` z3en)3R34hEqhf8@-(CV4!H|oA09bS5GdccCqKwQRx`I-5VX4@_Pm?YKU)&VCpB1*c zGSv#(`DDW%sN2!A0nMB(rV_&rw#&GikGbh%U}=&YlXRhttmbkDW6OcW++3n`ctzvj z>WTx<;;jt?aW@k!He>B!S+uC6GK{28FYa)1g&Mg{qelTEg7|f&$jKYl-yZ*trfqUdw=1hEX@3$2FrjPN51<%995vSXnlkq^9 z@OM8d*5%2+JGLmspSQnq2h7~7I>b-tu0xcG647yt!a9!FF{SA`TKgCl$Uom7o4xwR z!+A`WpUp;R?8C%^Dg@?45^C}Lv4vm2@_ffa6fptnUtgBu8(Q1FD;iL>f9y`Z7@?C_ z1=GVvmA}SJ^%*~EQl4%s)c&ll=xAT>r0Q_%S6O$i!K1NY-Le~IO#8+mUmoobvNL6n1!z;HY~^CGK(#w+8#Ut-kj4>)JdN+I2Lk@ zkcl)y%Ahv6^#)!!Qj^SyDS7xrF5CM!;Dy($jt5_ioO0#Fy-&nEbS#3NxUU&eLsWvY zt%7`HwQF{Nc>C+AW!T{I!iE#(+`j9`z?Pk}x~Gc0RS&Rkg|Y+ywf1mkY1drF92s1) zwdeufua68q-LLIm@otmL2|bs;RuU>1;iRaC-uO2;Wxg3))ddtZK($)Ofd*%^H z=I_*?A_ z{cm+|>X<6tL6>BxLTta5cF#Ea#-^7U#s+c#nxpG%AM{23Y4NVwm9IkUIL!YNxaqD?z5%N5K|@giIJTuXVJ};&I~X z1!vU^YGx6xHSH%X$eS?DXjQF#K!oU^VtR}NloPbWpI3Hf;Fw|Ky4Cas_Ec0(EZ8oU z4&V0cO|fdLi7JV%cofS6A-zgRX*<_w`szlEXOO1jIzf|kv_Hj<4~G9?No4$hS2Bbg z&1}&nlyfsT3CYU3^Ef~O@?$1+(~qbh*Wmy|olNuMc;m(K+aVwyDx=53go_jZMn)!w z=XP6t?yGd0875lfY`+o&8T|Ip_n$(d8yjBQ=1`7eG1&KiJVlZw;q3={cOSkH4ByJ< zKtB|V?D4Kyh};$yX0;R&ew(9|^WW}&2puJlW7Ox~9fcFY?xpMOF&*-{{s_1XeeSH~ zS+|a}G23Fy5gw1oh6+v+6wiY{J*1oZ=1oEy!6vav z)=9G#Q3-Y&8BcU04W);=c!k~5%;|MMRP%W0yYL#9dfy&td&h0(k7cK5_dskNk z_E(ZL)IF8^sG8(v8~3uCpPTcCI{hDw@$IMs)KaQdmx>zU?*Zt=?Tx9miR% zyy)@LI-h6sT}v@r9qb># zZvc#A`q{<(ffkdVek4`mmKu4chAD767+XmJk*%kiKeCw8M*%B~2QKhYe>xBA+2-@H z#xZFx5>`r=bk;_bxef;nQaN!)A3W^W^vC4eIHy0ToabNRUw1H5C8UK;E47edR)TYD z%GIs6>+V>;U^k&XqR|~>R6RW-xQM5C9Now7X!h+2#yWHFOO%@Zvx1a8hBdvYLx;+F zq3j$#a#3r3#Zx=boOV(0Q}Z3|Cu*He2TGh}OF2+o!>Y%Z|KJ%Y(V$C!!K{1s>hHZ% z_RWTlAi}2SZ>-|pdI-OViV^F+Q69s7RhEpxot*5W8b+5dREdl*SEQPd;R_QkADglM zExF39j+tzhBd!a&NPXCl^&V?tPOA&bl|&cR^A*JBLj&JCZ%RlGEVHSedr1k3$HJHg z{)LB+%VTxpwg_$NGFg}Oxa$ke5x8C=Pkpc;DWt)TO=U6s{-Ft8&`3xuI9hw~yEwG| z%IK7I)7;GN)m&s35kc(Y@PtvpZJeV`{+uE3iM zEq)+Z68#m8ee9waU7q1)5w-hO6Y~7tVL4wll-QHJn1|}&W5q~jLwuyW!o6P4Si?WF zF5j%@2iukL7DME1vo7y_Tg$n>%O|t69|Q4Pw33?KrfLb#PUW>y0ME(_rItfTSAp)4 z$v1q?>-O}md#TvGw_(a@uz77Qqz!XhbzR2y(pM)cvV;|z!P5!`{Dew5uk9EN9!XhZ1Ci~gw`rNp3s z2edZ#_EsJTHg|O|%>nUF1-`#ryn#<=8+1|bUOp^Az_B>Jg;DvsQAImsLZln_I141r z{%S7?CA`pHZh=WwB*VB{5$XBra%uS4Q%;S%jLXZj9<(G)6{xMjS zc`%JGaBZWSJwH`A(6JNz{f{Uw2XeT33^s+$h;g5#%Q;Io&h(=L6abmB2vy3a%y?-; z+pZuAY)BU>{TMQsN5jrDj->dm8@ACFw!diO*lexsghG;*qm^bSTXOi!rH9-8$UlW&AWnez z-5?wgilM!k-$&$MLIxFj&KW@gQ#J)jzvE+AKtmu#7j9<^1()!at+x@6Lq0bS-ma&c zAWOf6^z!TIGx6dIo>Jc*Zv^&-#F=Mh@{m9IFI*T0j1^L!iup9D!b`cj5jhWDvsJ9@ zBlezRs5Gu0j`YLb8hI!9~!AMVu5-0(v;ly$iJ$?j`wciPXx-nED;)vx}>}?HZSi?5*npBV-bpO zFPNW3?6E}T;(Qfz6u?jj%(rEG%|Z+M8z~dy-%UFh_+y-ISy+Yh+|vJm!FO#!KBY&p zxF7)een8-Ecs)t>*72E(j-F{h^%C4KXl>cz#ceoUYO~oNL)vju(6)C9K_$S(!zEi6 zN%gfsbt6nXW@j%NX5OO2G5m{;Mbx=qEK9LA_dhbDR9hb%vng`_#83c( z^--4rn^*W{aG*>mfjNG`>XN^bYssNz{|;~+nyB2ud_+J$xI32XG;i#b5igv6Qz51J zYB>1pA=%fGL9%tEP#adHFaWZGD~SZcSoO3@5;TZ?{pwK4W8%a!$H7NKtT*b*j5DA% za&QPNP=(+AuBbGg^{ZWD4z+q^rf)=8F*Ju{J3Npa;pQ7 zEYQrb0>kHGNkn}@`jW62crb_(;t3&+eX8{CDsr?B#*gY~{R*8s=PW29mpzy%BGT)? zE(700BsNWCRw|_$ zT$Uz`Z5Um4=5e*@WKhceiQ zd0Hqo31`~K)rBH9D%PdZD?2N}F(C`h7J=~-?x%~5#K1w_g@5JJFljD_3=>qqzFYJN zUabMAW2`w!Udag&^lrFKSKvB=xHm&=l1X^-2Pbx_EQCmLeu6#||AkJ534qDr<@S2Z z4U&lmCDfiUra8IU36wMRea%g$K9vG|Z3=u=kU~3wREQuSIha@FcJ!#BNLPMJ+JA4& z!?#_Dh49<{W@+yM$tg*!P2Go^3Q?a#+%@&GQ>=r$(l7Cu8<_7+(6JeAoCO*gf|{cm z$tw?Ya5G4V2eXz;GCQdzh&>Ix`far$Uh0}?)9o}_rT8S`*(vbGEI-Z@Pz{rrX``7A zoK@KZMJIOx?Y197@CnmM+_RR)+*fYFsxOZ&ZDNqv!>NfXAqsY!g3ipngQ8Q7?-${m z4!9H54t^jMkO%z2p5KMbGI35(^x0pi4PHv4QbSx|yBh!PVpKkf8i`!v;Nq{4w&x`q z?a$IT2T`M~MIC?TRWB)s&g28|gdcL6BUvX7&&m3fK*Ri2}k89;bXQ`+& zLz6%ugLx<^DI{&gQ@R7+yR2IORU;y*ENTR_uw)F9{Sd60N1m(3zPdO|!&d05QAm!^ zv)3|N=(jpb`WGBLl=rSh2nKP(hr{jDKqjFIDgHo)N(v!Zf$zgg`{#m86JQ*dLx%Y< z3N0sOnW=o%m9LX-W@JNDGXJa3dZZatjAZwfvoN;98DrhE_OXO?a z9L;4>MgL(YYkf8?W&ze?#MRLJy`l`?y5gBQ`GnqIqAJLj-wtq>o`r-+(V#&!5bx9iqq|c)jE2fi==ob5nSCo- z%duK;x_No4;v_JmA)4Xx#vHEqB^d zdyy6j1esNmruN_2-5_{SSkih3Q&N9axS2c-SIo$F=}zco1dfkVFNYtf8a9!w7Ya(7 zg40I+!9O?ZADVihh{XMZn{;w{P5=7Mc_h=@);J}bh|hWdx89ypmRhQu+wr%!P!I)i zd#^qVM{j`?;5oiJ&p?rsd>jhdCyj1m@`l6qOgw5A3T^E5g6u|!VQQh#LH+13@kNvE z^1N^uSb6xMNGf9y0hFjII|N)lx`EP0#a+({A1QjQ{()recjIF=4M)1R-LWnTE;ez+!VCzD$T&C~v-@GjqXNPI<92o~86e8eUPIgi ztCOl4CF57%8DsVspMGW4xkkdahc!D|$ts6BzY(OKb9YgVGI{Cz_67WE@p_5^ZG7}T ztB`NleP+A1?7~-ZeEZ_X|5Gz{m3&(@BvbwoBzXT*7G=w8C;Oz8gW z*C}wW8#dby^)l*xQ67BkEMTD*YFUysr2a*p2UqiL+j1Yk7Y*3B~KA&5qd|$RXfZT~QB-Q$dXVd!NUn z(;Ixy%@4)6EqMA^)&OTv->uM3`QlvSUVBdnl%Y?^X$O0IV*y${52&*iadU0sREA`+ii!TVI; z`)7|cWK#QT4xVz&0!4%_)4m|lMzO!GAHH^~o{~%xgGwZR`5eA+(7j33bWp5eau=R} zSxln%TiChyj@mY-;y5%#9dtqoW@&z#a;ecYJ!M%AWTf%#z|FTWUU^1SPE)Bhz;sKTH2bcMgZHZc9#Ok)j)7YR@h(_oT%U%buFskK#hYlI<`ux z>S`@I$O2L=?3)J$T7pqeT7~|~9?G+E_?kv9LvLCYEmf2%nh2c38LdF7iQrJ<4umF? zIjJ9~4|gXIb0;$OCv1rpMNTIRN+*ac7JE@k>1=D!bZgmUYw37v!RTw*h+1B8dKk5S zaa;LhV~Q6^gg$kzLHoqcBQgiQO1UL88Nwl0q6gY(RGoWSs^V8mcxsZKB&z1l(d2j^Z z=gbI*MCrfMs#}(fW&AWgHN|D5vTizaL;q~5EA+7*xubeqi#>e7j!n_y+_>Nnlq{G2 zTy&x*cQNUz@l!WYnE%V$Ss>}%fVd+pd+B75pTZ;<{)o{3sK<^&&qcxc&h+i|xekAY zw4qnd9t94Oy>6Jp6FA&m0!Kg-lNMxUDW8AJ6WMgvTY; z$1TDyCy&G`v-yGA7ujEy9=|#l>-hj#9mLYZ=yct$Zd z^5h=?Z;2k@d6jKV%Lh>L9?&@Czek#P=QJjkb}S%U31i~H5vx#jOC!iPDe4cz^GeFT?8vyoNK_SW1TJ${buxyk65tO56Ml^ZbB>bb zE&A}W!s^G5S7%vh$rfM}I=){NaTLY3)<5*BC_keq$|G4s>ca;M_vb&_no8+(I#sw4 zwZc6rSfp!Hwc4|XKT^C(OR%Gqcj8lb^QVHwBnSo$J;HQLthBB#EMUXNmrIiIYK-%JAJ;vJ@NJ?jKpw20yyJbXpcM<6`b4X&G|eBX&bK8}bDz#Y3ykcfF57^_KgS39V(k37b5(RGR}_cr7fkECFaOB!4O z@)1%%Nd|;P-a0!U!^cex*!E%BSm^UGm6ES_)!B=nYFKdrPpE~{E+R~_B}v1S`rvWC zm&Fpr%gs`&T52{1(E+#&VwokFjR?RBDt>cI%v1vg0k z`Cb|Uf(fJ-23T^HeXyT%X?@9trgLd?l6%G{>VWONI`1ui#C8@3T?N4ZKHA6DXzr}F zU=Vz%-UGL_mbZYF&uh;B=SAS@qwmPuFrae;p*tBl^x<%^mMG^_3;NrZ?2d!okL`=q zPkscE<9^4S6OL{UiS6*B8+@FcJEOG?KHV8Qhc$~-1p;_?=g>B~(8bS@)1LM)23%C+ z@r?>g*?T%0aj%yvyd$%Gpj8}T{1jkZRW=KBI|n|loCr+grzENP=tox@O~beL>H0D* z>P%Q~Dqp{T#u-4YeOK{$QGWaYjnlsRS?e80>IqbSo{UQs&ma4nO=Bb!fwrxKck~yf z-+d?r@oQT(qWccR0PgepAbTW#J zldFTOXLo;tw|m|#=Rtz83I@tt=BAZ2f)Ykyi~~u$&c@<9sH(e<07VIs0UVsz|++VYtW}B*E4tK<_kQ*hJ`}p05ZH z#^!dejhmalit;n<4QNwod-|1!TlIPEI0h69^iZh}z`+vy)Mb|nKe-DE*YJQ;dPjwp z7l;8P=Nj%Z-hv!M7a-H~L$6DS4Lj?okJGczuXmqP=aAHLS09RyVX&Dij*xc)tty4* zrbq8S47I>6Ia@o%7lUbb?sdODcpn((QK~ls?Dcun(I@c3hKg+VQ?MAHAgr8GUpR^P zdsch<_R1~>QAGN_61Fn z%PIQz3$MDo&4nX|Q0xMV!c|gCVwKl(k@FJ?SGiL(@@%?YF3qQBCCXERj2Z6jPb%fi zZROn~R)=#De^aE(asqW&ESu~$skci`V>A$EXeo?goeN{4MRSS*@RY`Uum`wGBs6*2X>4{FkYl9{Zz9e?MaW_jkN1ebG1(WQ>YFQ{C27>$pW;vpZi$+=qT=#aF~EwGVt0cM=(tajPPqkap^4Xp*L< z@=n$2>iT}`T!#*)eLZSo-XVtYbZ#&8$Hta3Us<{wRDk7TUl z7Z-B8hHmH6htmYv)|q0(f(`a2Wh_b+tI(199*p4 zdm0H4adXq;?vJx#x(;SVAD8bix}W2!(qyQVa+a&Gl&RF0{j8!6=4GeDuFKGAF`J>( zc=Gki^cWz0)`s}{rU<*18f76=nRXNYqfL>*EB8DT0t0T%W34e#{D&7!v6c;+_hg>y zpMOtSxIth;G2H774|(3w!E{N?fuh*b!tbt%Bl%*#`sNT~km;{e^W-iqQkX5#+cY(a zemrje^G711pQRGh;^U7O5UFg$`64tE^Empj&&txy1Rjgq`*t(|d8L4HMX32v9%uY3 zFtXI#A1^-7Du-!K@|_7^HB|0Z@aXDCz$0f+GFM5b6r+x|T`pXxb`@nT2h3o#n$vgUsnJFB3$y0A-Q3Be%* zx8UyXPH=a3cL_n8;O_435Reh>Y@3Y_i?6uab z_$Vguw35ZJo-|r^_RrVGG?y%d-Mh85N)l`ZsV`fll=bX;VAE$?$u-$2MT`GbqTc(qu*sJ87dWVv z$+?WPxbE40TUpnvn*#2M-an-K@Q{I0{O`>!R;b=M!SJg)B1FrVEK)eckQ73;^b?5n zK!T<$GEmoIo+s;+X{Hgvv#r2dx{drps)4I$;a8}fRbDQqzCuxXilOoT|Ma|>#uqN& zWA($&T91_GRG|2VR#6+Ws4RY+60b`qjY2e7{L}hG%;UpgzW-t%l{1yqWT{gjXo0%z z_d0h)7)Ncmd{KmaNm#NqDVBTuF=ssOU7YE=Z)UebJ>FjKG3&XiFid$vcKp)?<)+)3 z3~N!I7)gdAL53oZjW%Y+`HpBdQryXULFpUtPGQTP=svN!mL$>1IQE>?(F%7Gbm*G;xetT0duCNNM4$w-R0vP%SwVncT2RG zDHe7N-7|5c{TUu9a%sp29A$91 zvoZzV&{o#nE3elh4z;n|$pW00Blg`gbxywIq^WcABGgbMo8_yzPE-;zLHB)RIX>hQvP&!^MNbk7$&~KJGG7iW^YA)dE zQxyZSrhzJbM7RJa>L@I*JG_sH{rngX2in+-ANV*mdAct_7x{)gbF% z)qGDb;8WAOHVouazkwPRO|l+L(ym&>o8xJRG7eS248FIU^|1ozbeEy!Nic~Dq_zH0 zcoK3Cad{Bh?mpSQ0njkL7A8OL;vO93EqLSIWB@oc-3nVhmpKUC%)DON{V_{%WzW-X ze}g_I2_mSDKHEaV4?abJDLH)qW~cwkT`|aEaxC>N|GLdN@{I4whd29}z+py$C!wP} zg3Ypb1O73{j$aPw%BjiW0>Au|sYGKu z2;r^H$+6nzwx?|=7q}?~)c%;epUWxQ=@i<$$wv1n#h%h_@9^Qao}4}IU)6eWxr;^3 zWi>e;DgF8B(N`U?Emr~ku>pTg1td*c74W;(E4<~yi5m3G02Wo92A^;CbwteLjX0D! z@gG;SVBJK4XABeE#!LRO&Nx{2pC#eCg}1$))Z`aLu(B6nh0*YyaH+k*bmvK5hg=dY zNn(1;hp1@%wmst$ac_8hP1I!~AAU+24v5hN9g-NUl7H*emGMIl+M@hnV@`}!r_aH} z`8^d;ol)2%?b$cw!mC^3^~!^jCmT$h(Bwlp1y4;y+5{(-sYpo** z6ICw-y|0TJFym){Hw4@~Whe)r$phuc=EsIw@c#35O=&(LvF9G)H;|c?pV|t*z5bAQ z(+p&U`;Q(A8id)w%htpry4R?{B(E2~X0b76--qRk(3A*Jz*d0Qq2>eP)YkvOzbxOK zi;nOS#uRP1tJufzn2?hU3>B#a8pl!N)Gknm7c{#MFvQjW{DAA^LQi>dI*<0=RifWT zb3jp(tSE_^WbyKI1zIMe;qO2lgQF0l2E}P^ao*!SS=#k4!DAc-u_i*BgWJ-E@LY;? ztq06xvNEEAt5z$-VfG>@5|a9qY8FG9>}|arQfqv5eR!ZDdRr|R8)Ea$A5VEDE@j4q zG-9c~!!h&~WKAcDx^*V1yNP^9UknOl?^q+y&z9&wglK2s8b^Tp5%?_ZMdA^w@{qKS z6X~Sdo3&T$qwqh-3E4pnPX2t1oXr&?pIB&#oq2LP!HRqVqZ{%4#!| z*=BEArh9WN!cp_GcYc*ZhArPuQ<%0hulo=msm*WNoFSQ7c73wcf8PRSt#(j^;FkfG zNIUn2hQ?DIiOPd1pkuVK1FAl6y67R%1{MYdN}9CcC;w13>25vL0Fw8TzrD8yw)u4H zU(yL@&>Zbp1KD0KQd0pBKf9_D0KN!dU%kR<+J15E!4filtmF&88tB8Ju>DO!&Z9z` zHi4U&yy|nq|1`IzF8Q1{^r|NX5QIJer|;`%SP2kKDUFS#s$FXrRG{13k@NdG&Wrcv z#}Nqo#wi2PQYr#Ar@c=BPOF!?pGS=cM%AiI&-8s=t#!0Ldk8Oi2uMY!{ybP~ks*wn zOfIui*TO@2M`1*T8QO1;q+gBLn@!5sGA#;BZ52Z^Ub;0jRif)E3McdGy-spSSa!`l zWF%(S=?M%x94qFL_O~>;s4Yk2R45tZV2-|GNeB7jAl(1TA6sW?3 z;lQ7wh{ScU99z}9s35t0egEmS=-wH{@m0_A#v>Sw4zN4)Rn?}c!QuH#K(Dz1D-ooL znGXLp!1b%QPi^?S6()p!72x6k;?B=wmLc3}peo_FCaB;R;J(J=`NESq7M!aa20T0a zdOyy4d$_(%^ZB^B+jjB$KV6*{G=p5jNzjyHH;KB1H$ZmhJs_Jde($#fWYJEZCPm@y z$C2&643F>jT|B&hfWx_$mF2#yw}(13r5dGg?*Y}qxjGkdGqI?sHM^Ln=W|g>Z%H7% zi+cbX^yd1*)!Y5maPb0WP-6_)U)9a~*hS`ZZGxH#n(^#)?C0mals&sykdp)BDX_M2 z)_g0h^#)=^-5ldI@m~$i&s13CSR0EtYoPCl^A0us`h`G~a@{EvGk2Ur_GP7Icc-I5 z%ioy=`ff354|6(psJj6|VcNNia~uA#30FffGyIEx;d`EwU&=Ak7}2_0NMg5VwVdU* zq<}di#9wl+8yqFklX|KlwL=ppTXvZZe7ZkE5Iv|R3%GAxT=oNGWVRUThG}MNw8Ae z_JrPdyf;&bdw{39==2EYp>6sKi;HvVC1vTAv`&^dO}bMb=JaiYY&dW4-eKp#)yLTs z-MR1`*^qqeKP8y-@ISRUN{l7e9WcwaFjwB-vgdkuyDfj(fir*FEFpfoAAcy?k=)^w z#Uar3Mcg`KG`cFb6mxO%1>}SZ6vVz`uNod*(rW>TSezP4?0!xmffo<2XzAy?6;!?aRDh?B z`W6R!h`(Hc_aUDHQg#3|H~;Mh^61|VN4HsPeo{b|wRQu3Lzb{gxiwDEe?oV|F|Q5n zos4$es`*QO)vi9#&^_LAnFUb+dWQ3HK9|6|sQ&l@SmEAyb#f~M-6BnxL#fDJ`%&OT zx(28#!OW}f78L;Y??VNF755jT_ji!fQ;#krZWuG5>B}R}u`u`Xx0`2R{XVu9Xnhhs zPym{*K~2Fo;klFx!FrtJMD3)s%b>Ewt^>;QF6 z*6$4QeSbW&Z^YNMtTSTE1p(*0nkI5zdApIBat4rYtu=A9!*0$Oc zaW;D?%oO#=0Z+GAwsg~Y8e>Og8P^zl8lNb?_Gmflsgw5MCkX>1#QWOtchIY4R|8QR zwJZ9YPK(vrs+Ffp8x8x@Nu@^|LqGT|nciXkoJ3bf|LTE%oh5dkl&xQ=Cb?T^7O|7fDT9Z`U}0U zLUgv5Qg*L|OysMbl*BA^;Lku*%6EU(0q5YqS9fwA+?Uj|ct-Vh8#yezb|@>J3hCG1 zBIOe)F0kE9pgtlen|~!`uw>P9&-=#I(e%$?@~z-ugy7piZ%@6zcQc~T4G7`1+5Fef z?}<&)Th52yzDs=ideme0wvnezzIF7g#+?KqD?b0N?SsZlV-oKL)3qq0+x2T-V%%X4 zAUmIz0S-v+1Xfueftu!PKz|0kDz1A#z6e%FL7a|!0Rh>3TAK9!O}$* z*+15ap zW$f5JL7ecny74iRBuFu>$`$cmbtP+r{6DQ-ocXFz2+gF!v8=6a>||R1BwJ`ZZ<|F! z5ZxG4Qmr9vkGv(rFfu!(@y*97iwstc(Sr=u*l$xDNqVAFr-^#BNe)!4hIC+gM@G(F zd6N=8vkCJY+N#`Xel;FMwS$cQk9ctKe9o~-9`-Dy1u|L_G7?F($$61fIL)_l$6u2( zP*o9ZZ-$i=|1)Fj@$9fKrg zjBMH8YbECHyQ#tVVNLJ8?;>C%39!gTFxGu$mVS1vy8yL#yj}5jS4H8 zEj}7cM+rU&-~FGN)kOSfD!pte#~-ynK?L_&0j`9)xl`&>%~y*5+v*G)oJvFYyc*eRgj`0u_8KZ-Ez{J7L;0E}kZ{)NKOzwk=rhx`17|qYX$}{~#yY}yuP8MO7 zx3VAzr`Yq-8A#^^

    BI^Ii(TKO13*wnf20lTmi>lLB-fJ`6q!q+Xs~K`-d%3tnG@ zK-_jfkwiI+FxJOUbztB@7no@q2aY-Fvwv9>YU6;IVno*9C7!%+NK`$m9b4_-wMgIY zPMs!}Zv;>Ai+@0FUxraTGZl_vyr#HNFT?N^u2okmc_0HH2sm>9SJBKwDA`TG(ZTK- z7+&@!0L;4YL2fNg6b8EMgVeG9j(b5j86ma1lJv0DgCmvn#4q?Y`A7wK4|~cw+bNoY zpby4vir@&%04bbGw{$0$KZqVp@8#uD6Te7?=qFllbfhEkd-Q==a*bXPzk+-*+isQx z)#})rQVE}Ut_71o(`c7c>9q{&g#NFj#0mi+anIOS>O|2<4)hBae2&)cF&|daSgp^< zNKfzelim%WZ3JGked{?G1BQrZe+nCGMQ;LO)(Ap8r`lwTP~eMLtZ(1XwcYcckv`^+ zAt6f|r)-Guu&`*xUbMCTr{gH03;)KpbHc8XByHW>L-L5pydIL^5!Y#LMl{HWAT@)X z0Bge_gqfW7MLtZ;27Jfr9*0O2Rp(3NyAR*{1AaJn$^Nr!6Jpn4fhy~gPXjhC62Z-h z7mVtrYkUF~zbdr=rDC(4!DDZtz0ywx*ZZ7b=q+sKzy4rs;^nHO=P|fbAPV(s)jE9> zlY02LRyayx74>+RubU~)^m)x-|D4&qlP5QLK=KsLF2Qtqq3)BXtG!v~s;A5L{veis zG;LH-*shTo@*O6ygyZ=TRP3$qo_a1qv?a9v38M&eg{+f%1#ck^y?odrZ#Th5grM`I zJ#*bQAM8Dz&5oN0uM}GdQ^(@?WB-tIlh_DaBl<2`AT_niZnVzXVnEVwY_!VqDHi_- zN`mZPteeke2ZHxc>Nc?mInT9G(8`Y}+{=yBECSX`hri}RWf!i8UPGsiMYN>gehL@< z`in!zk0C-BDJ%a6C8PIeMBc8L){l zzPtH~V?RA-0!RNy0}jVK%of1qhjO`-JFYz#SeS|NRX!jehDzm!B1BNm%eH<@Y#gu> zcr_W()&GO3fpOG*`pj+LYqD$WlQj1M$^UH%3jeg{0|K7rO%b=V6#1Isg?z3HkzGmI z_XoaT%uFmfrboRQWv!T2#v^34g)IV?uyG%#?|U*HKvc#=qBVr5)ebI9(14u}`yXlhh)kLe}7!O^l{rSwEcAv8$`Vntwce z*z-mA5<-F^y}@>VzJ+0N^f{Zmx^-hHI4VydyjACHZJjikSD8r^Brjhe{lv@N94dVK z?d;QKqtiG4%TUzD!ulxZMA}pXbt8(^T26}isX`fPA-}I6vn&bJm1B8LCK8|YyQv8T zw@erUC;OD~JxQGsvMV#s`9nC>7H^#d?949+9i@&7rWM_2nS1LPg>$(uVg^WRFONRd z3G|XLG(rJ}ih-(ZVBMr@($R27@2YC&mF%o&Q*5rUiT8>*)5nb!ebeN1)AkY*w;acrJJOhaBHdf0Y7 zX`^I6Z1c{nmP7%yJjxd+XQrXkg|rkx`QF*`nS5)mime=;Ey#J zG<^sr`pl(9x6BQ8IpWMsf34ym{+j1~$><22pI?uXY(NM5a=jr3YVf{#xh%uc%9@cE2jFFqK7sMX%}ZyEBRMw6y!dIkBWvf# z#z2)^GKONBGAd~2iG_yCZ0}n$MiqLj&{4SD0v@x1$uEcm7JlRw7W5Xdmg6?($qTr2 zR%~k1w#D|75*beU6m6$*e7_{4kYDjoQWVl@r=-UF5e_owrrbF2=#lykGZ?0}thjZ# z>>4--7@{z!M(k}eoh=3|1RM9|r6z0?QWx+T2w1cf&8n*%DzlfA*P=5TY7-h*G54!K zj_V$bc@K|0myY3wj*VN6ok)%0Q;kJ_9P>UHm5NSa&myy8mr%3{&7S(i+lbJR3f<8C zR9|{k-+f%4y6xiKXyzt0QYs%CgGWff#?576Z$G-2-b|3Gz(%XLJHIpbqBi8*y1Gq! zT&(JfjDOE`H>>koPijIw;ctT<(ak_NfKh53i$(@Zu(br2V{F%hMB@cC)|-R_tqa`% z2nv{KlyTm^@tbD9v;7{sz4g7m|g- zO{-Vo_S3P!_pTC-7a-zGNEsi$bYKvrhteumIgjk@>=~yb3UOhSXlQJ^7V;i{00xrC zCC1Hc%kAIM7c|UozfEv7B!4zMg7wy)db&65W`MTW3I2Rmy}L83dWpn)0Z8mL@VT&# z88LYl$b>*ZR(54E0B;%y4M*haSO|5KM@t-==AXcDlF>-?eJ4QtUR4$5%_Ayf19B6<+=h$3@WFm~#s3_na)|gL+b+z&(4HXC;N&eSf)O%GYS@@ zcs2atybZ(q+-4LRKHhtlFC0uqk|<<07rptn_qm_t)>|ogMC(VEpDbr$F_dIo%5S$X z?X}oTWS*C+o{E6~E^?vC^h3uavJvkj(HDgnx^UjPXEZ-gk;YLUUG18eg!A9NHWJq} zlV6#LxOa}fdhT8dvv5qh>&$X9#`Sh1b|@kkw&~rHXksyB==WkPrGnlVk5P@#e=|%+ zs5H5dwIG(#|3wpCWr`~7hxhgTh}>sJW`5po9tk&Xt9?h&Y;QUk6qxdvQ}Q!{dHBV- z%G|uGL=9AiRyFGC1L~u;PYq~f z-o^)lv%AxeVIuj2|9n{C12wnMn$=JGbt>F8 zd8E?#YsI(|Av0Rr278ep`#SsXTn=7&OgN_5tyz_31A`SijT=2#eZbD#U`iU+HEN$ZB*RUYdKvd_3gZ>9{uL5 z{qJdW{DhzTve7R!qzzp7L@jP5+@>fR8E&aN!!rY%cUfbdR>WM)#@1fz6(^(ku4>L$)#9GG& zLn`^_kx7Svf&R`c7OODM?{YPS7AR=MxXMX)w#Z0A*wP_(h5+Y##dT&T`oMZs)YEU; zf3eidj6LujwQQOge0mzw2^w}pi$`+szdftn6%=n!7U{4s%CaTK+Z9)k7Nh3?m`6+* zhSi2N+Qq5#FLl^eK4o9Z!Zu|*t60!N^24cVgldRVqEq?X&-`2C*|~QoCvVuM$9c*+ z@l=#x3r#ILJ?TMDQkeZDSIPgTE%;d@_%m0CC_gFoxYD5ezzt0CU`A;tOQr3+I_)4; z+N~LmQ)|tdtsmj- zn{S%nx$d&RKyg*bGu|u>ZeTxI;ch!-?(Jn%{pQ5vDH&YMx?Wq&d35~8-25v8vu#qH z#zfxCIQrq&dW7MXfG6U#>pq32ZO+cpjC0xJD|Q*TRcwULm#?n&v2vzDcM3a|*Y)I# z{+07h$rXzwd=so=7bo3+ebM_3GonSYU+sgLyO|E5DUwRyq(BsIGIB67G0C4y8=OmR zJu75K%wd+`tD7Em7-Y&;7jrlZmB}yP{VE7 zO7}`_otZL4*_&(v{-l)fFwsXlN|5$RKJf!0)gQsB-RsxPhT!HhZ0@zhLF%i{Oy5+W z#o`ZV_~KjM4Z}U(q=vif36&1id-SlaP(;PNf*~X!Fx3sO?%OU>%QXG~9!r+keM8@g z%qu(A>gL0xhm;FXc*CQUH{@dK?wkuUA#e^7Hep!BKN2*Wxn4up4XF!hdVhO zN+Me|_{lUw@tdi~I3KCX>JNH~ES56AmnLs&O98EA2?zrh5jNuHNZF##SoR2H=Of+- zTx{it$N_Fe0L(l<)nIzn$r-tCkok9k!YF_`5h+|illtuXvWkTFjRJCeH5!pi``4qI z8oI-`!Rk$eBZTWk;?(+0w-gxTFxXV(2XDuhK-KD8w(i< zefqLbaT&72L=PE(kSYv6lxL%rU15iZ^+z-?{~PE*!YE;$gxw2F@-`r5$p6(uV++>e z8hFn94soh(eE{6b0MyW*V$Z!<>p=y(s4xd5byql?u5H;r{;05{iKY&Ja@7iZwcqi( zdL?jj(zy-?WkafbjuPh$5>8aLS0nG;3P6H4`Vm<9!4S`7Z{63jbHgeCl=j>sy`&RK z&-~iwVDF^BwvCPVG5D`$=?G)pdvdp)K2TeETzK*)v)=T}f-Wp_T{=Wku`z{ortkMV z4IT{A47^N3f*QI7>n-dE*E-qPHeZ1cuj3Y~O!Ptql0Hp@AY@?b|7lL8nfa69NBK6Q zimJ^N%3zJC%Vjwp?tozYr(#(LRr5bSGeFzKScbL=2a5U{yGyed=Vjxl7%o~ zEtT@;7@KkM3MXa0IJfndz!l+*W^}8j*?E~e?Lhb0!9Pu8xS$_dPqM^MEr|XaT+KUJ1(5P6YC4T`o{cQ7p>(LOS~MH@-sVaC|n zOLwQp%Kk>~H~xX^fc-ey`|xMOibSN=+5TY-;q;|%3lFu2T#c@o% zX~|4Y{9(tVv@5Qpb7EbsA@7{C>rcbWs+ZlXZtfo+U;taz;3XtYm zxGUI|jpY?yU#4M(2?Cs^O@&FtW; z?uq+FhqN#$NJ?=p0XKqw8Rz?{Q6wghahmW2)jtBs8fZ@P#TIs4zc2_%3^X~GzE}X@ zcY*fB$HsPZ*#OI7cP4SIi3h05xNo!5JNv0N{&D;aw88)Urj!NP)n506?>j@8y9^z% zdWB<`1~tas4WkwPsN>%cyF3dN8mY@9^FR4V>!u|k4;Kd#=f(MmOrp#&y2+bjjR

    ~!C3rf}ypT=D!l**Q2<)e`{&k7L;DT4k`kiCImNNox` znX*edGcim-v%*z**~g&9eqkMn!Ni!`DXigQS}%TzS+~!MEGdKwpMdtgCA_L-7z|WN zK}OaMfs}V5XZ61SXkXX_gmPaODU$oVlI_~Kpot3OB-V-OA*`p=J$ z&zSWI3i3+Fao$;eEfG}X+|IYUB7`B)-udw{_+=m?%@5h$mGX@u} zAJ~J=Dim5KhrfLJepvC`oZLT9e+%xSMVor5=ULeUem8Q-Gg;3Ac5lrHyGOfvn9v5*eDT zkOAM3w7#K-oA)yu=lu(n$<<_U06{T)PzF)~f=NCB?4DoZa_-+oirN8siY=HSVNvV~*<^v+aja2H>5>u9NAV4D zp!=vN6C+<90CNli>-M`Kp>jx`{dJr@Tzc%GuhKyP-ZA*shOOr~W1&^S;MCl`}fveF=!YhnTtfl=%U7M5}-;Kl{uIvUYS$jjr+>B&{cUDLSC! zUib~-)D;5=Ydz;R_SG?gtw;Z*eRZ{a%~FJb_Fq&45A$4~!G`Rez`E@Wa4`aDeO^#P zT2@;A_NAR!-|`&96WF75a~4P#LAMSYF?5OTi_Au4V-$>`r4B~1FaIP5 zHqBrDTf1(9qDJYYL6b=%al6Gf>V9i9gVcHU7Pk#?>hJ@eoS%ikdn6(kN=iZlDE{gl zVdlmz&e4KY&q`Lc=~IX)yawew))AGTKSS@& zF+_ius6V{KABs4XeeCb(QV&T}p>mv3SzNEI!;^jFd_L>{!!*s)--$T34yN`0CgO*e+xwIvWzJ=oxWr8-ZmS}szPjD%;8{H6bZSzHp&W7KTy1Rpd)wkP?Yt&qUmpHX-VAyZ^yaZ=J#QojM30QPz_My5#p4N{oDo(4cD3Xi?M zLoRT+0!GfShv@N(pltoW@Hf;ev*P~`{?;D6T!{C|#_LzvAs4S+cuF`lHck7#@b^N2 zunYW1?;Eq%fdb;7zEyeQ$5G_zi?Ju?qk?Ew8mQn&y#qVy5{CbO03K9$|33gc4mPwY z*>p|vE(Av3PWo$>WYT2_QT%I+!)IRoGIpJJ*Jt*69PBq)E9Hy@FX+`DW>xA!F^&<6 za`c~tzf=3Yx6Z0c!?FQwGJg54@V9hG)sKW@teIS*Nbo~&J9g=Pq##-9I|v`ly>k7W zocOeyX^tW)<-VY25z>L0cE0ypt)bB5PsK$}@+}Q59Na3XVFQW8ya#a+<+;G^T%z?C z8AjEpkKoICTj8A6;In#A}7}<9wFF4x^bKSR?W7dSR*5=0jLoY`9uUA^AIE@zSy>Y%? zQ8YCu!v7)U&i_NmGr@nYedyOARX+g!jVUL}7bhnxy$Rsg+%PC(rpG57;Lr#plH;AZ zSvaqLxB9vhG3(;-kTxt~#ref(<_GJ5lxmqZf0Qh}CTt7s(&}XB z3DxAHCAr5o+>QLw0dHpXQpnbg{Ica`BSB3Tr%o2i^3bOwvlcFe-woUCxoh53MR!e9 zr^m?4DCt#YtePua8Z)-#j>GZ>E7E_{BfK?XJs5A?1$W*37Ti$=+$|d2J&N2>lH4VO z+y&j-$%l_J6y_T73d`8VXDowr*We5$pbWsT`U{Wx;9LEPOM$MnHr^sPHLAm59!39hn7illVK7fsEA+h#bOJskHx-!~BC@-H4WvhJW5eM! z?qcf=Z07r@g`u$VQXBaoTvEk*`U0L}WCTu!pEa|93!g5)>(7%g@~1a)VZ>@YAY^f* z9U*^W8t^!O1J=J2054aafLcqp<3AKDHS&=kexdk3+V z+lTiKS~b9kJ|tuFY@P}z-;S$v%@mdi=&(7;GlC0zbU)UgA_lDdVBP$zc7f{FD-g-< zQ=Ma1zR&sT@!pOu<`b3*X{Zke%p|6Aymu8Y;d|;Xr@8|)#Y1O8`BL&#FGwEW$?!#+ zT6+-&(QcA`N&mVQ7s~Kepo(QxaNuSnyDL7LSu>N(fDAlH35yzI*ZB=Q!?tmYXjJAC z2}gkE<5FCCfBDMAvBqLEF16 zkBxo0NLt`Ul2^G6KavN*yF7MTXQ=sr_wHAudCZHE!;uE87}gDtW2p2#0h_xw-^=CR zw~MYpP^4e$pjzC}*BwQOf@EV=?GeHx`u5kh1Pf_*sE z_ARWB^GM1OTIQj5v0h0qKgeJ015;hs@jLF%`6o4CPgz}OnPclB@SQ$=4}``V=ahA< z{pIOuFu2_BH~B6UmfQjdM+26y@KF{I|0tx1B?d{OL`;y+T3sYaxgngtf|zf~jkPeh zie3vooHdBcvIyq=MlqovF(El`EZFU8+#Yoqlz42-Hg%hV;D|r-cpZ!L~N+{b;l%K@-V9HRrzq9eF?H87< zhVHui-F&lx79erqL?;STK?+Iy?qtiPfOVwOoQ|b}fEtz*)j-ElyKwETvi}xI=cJ+T zl~!1ZM7{Ze+KMrEIi#LPzN|q}V0b_;Zg=O{stI+%Io@;8O&_5;(Ou60_1h-2poOTE z!73T{M`X2_9*^Mcc!|6GFkUROY-F=@F3P}&RymTc*>3ZT>^ghnNE9yl>{9uhDzHdR-z=Grx+G)1>#wD<`}BqOqKAX=D(wAHn^SHf%kz-KTY&NJ zE25zY<$c~1<9l+mUq=@JIKF`{SRry)KJ(F{rsE7MjbhAPCQGd*Huv;)wl^BX?iQ4= z-aHE_jve!XSv1%ThFf$!W9@w)S^m`U`OkU{66HyRda2Kx58{RnLxm;&W2;Sw@cD`i zGqo1@k@W`4Eh)k@ZyjshDcmgQW-3+DDfA%q{%Ovn0=sc(#>VwF{9mq1^)4xTzfV?M zPqbT0>m5?`T1xBfQgoUtSDPxen{3w_ZFL%L*BVM2&6gU@mg-G48ceXVa%9i7ma4Th z9JDkTwKUE(v1&9q9W)hmt1}z5G@3P+nl%(mDqt|q|HmXDNjf)6*&7+EXr1s4n#FX2 zSID5;WID4ioimEoj!Jx4-LjgDT+_t9lua#_mqw$W)h-fsfr`=0xLhh)mv98NCsve3 z^tRz|2vv>#!s_qRdS?X-aOQNo#!3t44ZG5mV;_lE3+)1*xORWgFm%0Hh##xPu|!W$7eg@%nWl10MUZ(c#jw2;lDmcz)Yl!|yL zo1zwi8re`3LPzOC3cJGT(aljo5iaxUtlQ6V(W5-%$OI9uYb|*K9$J>yU%+1DfH0{n zj?m#}UIa&j{%cf#U=63oi`J)ZYL5D1?8g}UugR}JoSmL_cx#!J)i-%vt!KO*Bh??G zqHl0>#klvHc61^xQ=v!7gy!PGiK0s!9<`Fn+x&k2Y z_if0Vi;0@#i`7dxInAuLW+w16o-EZ!klNqnhDt)dQ)qRj&77+}-5({ zer!%nMe#mhai52m1eJOT+<7QKj5b#P*gS#WrQPBKWjf-!Ipb4 zu;C5z{qFAk3FX%m9-n0&$bRPen7Z`GqI&>*NV39hSza|RQB79C!h5?ej!8WnWFj~q z$Z$I$u9wMJ-)J`fZ&@eZClBMlJhf38uw2)5Yf$O!SSYN@tOw>Ny5BIq3&WH~D*qzO z4u=g6J%nzc+la(v3L*NV%k6(TMAd>i5Ji|K!fV3Q2Q)Ly)gTg0PA`4nGeXADKSIv$ zX#DNp>+I!OWAY_t;P28Bz41*_6qCG1if&yci)MwwZ$$Nmzo(JSJ{BF0(x@Ih*KQ^N z!Wq4OpBv%GYBRaK627hwGDpDN`rcb8x@HV(mYR7NoNMo_3q15qH9u#+lFHVB6F%-K zlM%|?nsk+~Z`dpNYT%VUe-aXR#k}fY$cgJ~vFsQx8|ednk!n(G+k6bq~<`36dD#|5Al>(9!CC^TaQF=eif` znobIFK*#0*@Mw76b9Vz;vLI)tWK`I$mo^&jzW>ikEF|scweswtQtV>zdOnjq*3M6+qvbu7Xfm4@b(3;>3kKHj(0_FgJQ6U9Na=?uD zx8|i3WY?C%x2}2N=4>{|&9Icx`*fZ=0K7jv^}IJs{GueST3OYdMe0>-MgCOwc@6gw z;o&$z>d@e{>!K^~HP*O<5_|4-ElamlDqWk2)sevq)vA8xnLzvAtA z2$vW?Vm9FBVMxQ?9yeYtst>=d>Gm_r{QThTtCgPiqZcvK7@fuPDNVDTE$_}%3w588 zASgZPJN;J7$`1uXT~gSqv54Ti@f^5unz4S$VfQ|LVECjF>LT~Qq_kZ^wx-nIo^((q zMZe6h;1QQYh!`gP_u+QAM}42yf9#7`bSQ}*Y(u%!5+jjYucoDeXOk8z45I$|6~O3r zRkOdqj7vJb^6d#&huCa?F--5gDv=zWy9NFXptwl;#XW#cf;0? zcJJI{m1^!BigA5=2Vqn!G@c8~K89=)@!S+&*52{~hXy&~c7Ni(<#tBXX`xDtCfPQV zgY+x@87vDtAd zv_Bg8!&36|VB+7PLGG+h>S0b{9VK_3pzM|WWG%hnDu(R{o6S0RErS-!fs8nPYW^yU zg|TrRFD7D_0)}si*`l$SOuxa69ProKV+p^pS|P>vJv$Wnl=r|C=U>3{9Y9`bp8+>4 zZ=i*zt=hXbGmj1f9tSdaA%|DpEt4B2e-eoBnq=0&ol_fd*m4iC^RsJQ0*FNbJ2FUC z^jthPMy_5|Jq2YUL->&Z&F@GSK^ii$-HJOX+@TzM&*y;(`8ux$#*+RME*s;4|QCoDEc zG&W~nZT{hAoU=Md)-$v?{UoV+bqWWl%m|hvUNvQavW{H)*D%9V;NizDsO_!|dfgqx zh%2J&S(POyP^r`9N|X&iz+VS&@;#$-R z_0BvaVm8jb%qg51H@*LIYh-_x)emVc+JBE;I=6q9BgcVkYFIY+4CFpDNuumk8y`o| z)CzvQuQ84WXNQ{qTOON8_RiVb`ze^A@g2MCC5LF4c`9 zXh*z{cpT}dn%$!oGZ?5GC`4Q|YT3aY z+$16MWoWSiVJLHlmNO6`i_0bS;%dle|AE@XF@BIE^$d@a91Fh`$_`aK(8Yp<^AAIy ze?VEH9;|l=2K7-k5<}3{(=1D{fcHbTELxE*!o>L(#S;hj>~nJ!O%Z%@P82^+<@9rN z2AgT)*4d+ZbNSY}m2U?Un8liemI@g)AdGuzXt|-)8IB2M)o+v(lxXsaN@yPiu#&q2 zm93uaqnOaowkAcpN(At2)tiZLwM&R=d&JI5w$+;j!besZK4nAGPbTfgP|x8hWyiOE z6k)6-Y4}+F@5f|kMjx_C&J0~G>BBBR%vGoC`G5Z4zT?>*FId0JE8`8iwJ(pPv`X$S z{a-Q;8qP>gV(j-`v%y7RvT}_71zfr3@cKLt#GE-5$y5*S;erbBU-k6NElY zF~F6c2}8Y$oWsV9Dyb>83#qTJ)% z%$`4;$Ms^77Si4;Hg^A4rt~#@|F^x~ThHi!y`7z%HvY>4-v8;>bVTKydkG7i8wFUq zXly(za1H%*Dh0JfrL3c$mVwGuPkCypL>A_MOC=SSZotMM%AnF|AuU7*pVpZ>EsNAv zLX(xPV_h~|Ahg+Rv7*oBE3~4kl!m~|NUh~Rxne65Zb{WvWaCnm+iA7iY1G>$mD>hY zTccuI7#KQNy%nvn7jrgegOXdNCpm+1_>C#NQ>nX6DZ5ov-5C|#8Pwb+l-wp%+{y~B zG1ohz2Sg&xdu0W`A!5w`4)rW(U*g5kiBTyv+ ze6>=pFgK$vH=-<8SC#81%Co4+_4VXpMR`Uwc~&L42^HC>APeJ6b(NOu{HmsmMX8RO z)|DI5l%>E>tRe%J7=`L*=|A_D5G&%BV+pihc{WYNmqn3TlQv)e5$~id(6>c?5$hPY zRF;9tDB{`EnOwc%OsH+vhd+n^*O0kmkgHe2A!AXK{vW-~P2K-veWShqo#*{;DnX~} z;iWiv84ZG`Zw?R7KEFCT`+Ro%`QZ5d+2Q-M(_P_<_3LkitqzXgeb_(y{C5B3^#}D^|z`i{SPlX!Yw80yUyvCrHY8~dfir(yKb z8#tqp1hnK%2BzMOm5)Hs`nvdA$8zm3Qi|kA;vrQV(2?gvj_oRd;Fu-~|9?~S|KC_^-~T^^|DTyoJC=$5Qo)|^`zklV zB&lFHk^7g{Ibly%1#sStbH6ZQyTO_^a2t0r@wIApJ4S6t zV-W3lY#*8wK%m|iVI(&ImvY2JLp$Q3hwu}osiD9@>>A|`3k3`e%EUte$(Tq49%v$! zc!l4(WNAaDo;NS&)8fXsw9kzzUYV`ch>DjN|E-Ydn|=SYy{*N6Tia~k|33izFIagT z!1M($GP#n~nLx`0O1QxEY~aDpe?N@6A}vC5@_%p3Apf`dKOW@$kKE;5Yl%$UcqgMj zm65dy52P%vv>saDzx|FGV^|9f!#KcgB*1kj>?#h4)t#ZzX2G$o{! zu$R8!LqG}$Hs<~BW=2$)leRwR`cXJmefqDi_gA9-jqPnc|95Y5tJVJ>K>z1AvZuEm z$hwi!C)bQl-Kfw<(%s=WaAX`-@pkaEKLo-o2op^pUNesc(EDf{-IhNVeCYs&+PClg z0sG{*F82YG%|t#C9?w^)Dcu3M$2@wA8b96{baka7zg7Rm37rcEzTDDVK=;PJ z&jLMmO?r_A@C6&%*Nz|PF9`?KRiAq&`p_Ku<(`U;%S@m?1kT6~ZYd%%KyzWyU>?G` z(RR?+t)N-r^>vD|dlACG^t2G2i!N;AT$IJprxP-eG6&GnF>EBClgC~#1a~Sw$zpVO zN*6{qzNs!08|%IlYnOa5*9s@E-Hcv&61sb99`F2;V%;`KS(Zu%2o0x(~vZ@Zuh2k0-1p10^ah7~br5=H3F41s5=5M2r!<8%Fl-T%CsgzU&q z(UZ*H{yOvQQy`XLo?{>OB3z!+O7QMyUWpGN3b6o9Av3JSI0GbE4UAGv8LuzVmm2XO zI~X$%m4G0Cv+xGOMQKiJ7Y|ooz2o4O7-(ILG7n~9Aq&TMqjR&5$qz#j1EI?SXeljymvK0#Ukj0&YuD--oYuGVwbuA)!Z#T| z;%3)c;uGIBcAE({prFtj5D{=!g{UAmX%gakQ*xih(Bd{DD++8cqz#a1g!=g8D2ybq zWRbZM;Xa^uD0A=3{8D)#ZzAwne%25GLyOPW~(^r`T&gL3zu`a#-6e;u2 z55$nRw#gj7*NFjGxQV2OC1&7IUO(#sfYYmB6(ZS;&#nLSzyIH&=DeDmqtc#C%T=56 z!07cd`G%59S4qTs4;)s^2q(nnlPvj4c;j3~U3}r1!+#45_PMEi<2_EH`XC>YMEs)L zfolBFLgD2C-HgaT>52hwaJ*~(3rbR$f-%AV8yhzX*z`DLl#dS&U+>CgnWJx2M!^zw zVX#X}Y}a_*^Gv&aNe-9g=BJ=6sWrsu(Dlv0M6%;aF!n={q!L}iWGC<&Z?n!~*tdcYrA%+MO8F8#-+*!c4k%Lh( zs1`6N;EkzDwJ@1`91_N=XP_9eO6-`5Qmp^93X1Q=n#mg;Sl~W4Cs7WQ;rYoOTOzYX z_C?OQ4WeqF4JPOyf-5iN1tKaV$oN3&b*;0&hBGpR-60{12tDQpf$v9&T1zQQNB%XF zr=A3ZCjf&T0#-tjGN`j59mO#tS??y10k}v3%p-6_7Fq!11Z87|e=C-AX`L%y&k0Gt z0v7Zgl1@O5SYq9)ZU@9yfECzIbly=QSk}3VGI-w}v-4#VHUR>?(sL;JE#}xAbV~cg zj$jSW#SP|phimpWN=eCrr86{Kqk#CNKRPuL#D45T++odu0IClR?Q{RCG3_PqoWVTM7_H(WsN9pqCU9+o6%|nw+Waw4`drE#K z$iPvVxsGwll(huF`0lxptL`GGmCrhhB3)MNyB- zktJmW;MSfS2ah4uG7qBzJnG=tPg_GKbSnUr#|g;L#V0djkuHl#7$X>9$OY~jG(z1{ zz$-wXD_jtQ3sNW_f#~3dE#eY=Md1_huM?k*FhjIH!8}5T8!h5DIMSp848ZBL#gLn_ z5#K}ScQhZ{VR)a0o~)&*4=N?FRzZQsr(84wrWE+&fQ*6J78f=N?&>sX{6xWgK-iw< zW3u|8*Bm$U&;pyUQ$yJ;8z?YzKRXU6G1jXMsc_}ucIE$P@7tT>Hj+H&Z+;5IT*Q)8 zG)cX^N7>q=^%!&4dW0y??1jUz4R)hRthxa%&@GBZmsLIOB%FN3ACH&XK7i*$`Fn&2VkE2nSsl0&*zs5U?NrAEg^?CIwAYsyD;uV87 zOxttNQublvz>}ESpg8CV{1D(>byUJR8>3sQj#4_ITpyi^ zt5O=2^L1~v>e^bhU5gfI-d~SOGPQ}5^M#2hPH_3BRbr;3FvQ@ZQkNHChJZ6KVr*+D z%Fs41a75hOVyQa4mL<%3t=GFe zCHM;quvrz7qDF2DwI#KZ%m3{h($?QpC#>=rHG3RQ6rm_Lgs9{!Y((*QV8kd}UNJCi zvJLF>F+xw;aE^NOe(;l$&W|9UY2rv1=b=!Tmxsh#l5lE;=tU1zmD`9Oswu|t(n`n4 zG@i(v7wHH*CJL1Sc<&Eq;dB&)xIUdcgQqRBBUu##YXz=I0@10RlSnClH;AB`i3O$< zu!8fFE1SFql`l7rdP9y*UM#Gn{ou4p!)k?(DPF~_@LYjIvC6D(YDX!Tk3zICJT*!RW&RtTiy_nT0a zs|l|4Vh^(TG_ATukt+5ol$&f|cLWv2TmyCTV7$)>P}F7Q?3ZS!UE}vSBxOtVk9m={ zD=`ziDKVG7mVOhG=M9My^1gpmJa1AM`6dydW2`Ij(Are?(D2SK$kHXDva1K;w;_I6 z?m2dKAk^j^Ye%d_o+<(^*Z^Is@9i+DcbgW0Nt4lXRAm#|P8Nih2iE64LYD1IG_nOV0hesA%O$> zA_oiz9ngXuP~El4MG&CRDli1}A6#Sr`nUo|K>wlU`&C#1!+;5_g(lz)C%~3dFDLs603qX3U2NmEeE49_p9s+ay zHSo3=b*_K+6{e0P`NFUOwrmYRlFo1d*Cb}wK*S+n0DQ%+u74^!mp6OEHTe9x&i6=$=mXZ? zuUC1$PTjplhC#9mZd}E^uw7-k9y0T=mG%`{ZRjd{oV8tH&rR(6mDKXX11_Se&fen3 z&D7X`=?Z(^oSN$T)}8XIu^)cRylU)uX#>~SuQ&1Qt*!Ur-u~$~9Xy9pJ^eMPfWB4r zJWj8Q`a$aH+p6i;uBBhMlD=yl{UBBJ)iw0%n@#K2&X+6ad)3VkRW(L&S?_KKN{j1*l?uB2ayH`A*PnEj_R{bm7+gM=})$Q6Q z`dVu+2M(WHe?|NH_3WFiX5V}*`vxl6H&DlZ{Z;HYEF{*we!V^9)w6EBO;c{BX8q5m zE3czYebZIygVd-GR-t|qB!2DM^bS(rsV2Qm==;>8yH{ghK`r|FB77Y+=+|3;etq@l ze)9Q6#j}6qc~LH{s&mhP?^dbW{Hv-pXWg=Xoq1W)Y^cV3eQ6)GwtN$ngQ*C~YChNfq(lekZ?&Qt2(Mf(rorvMwdH>no@Vi+ zDEpMeqO&r5)j+fSKYsk+yQ2Kxe|Yy6|KSVZKRi?B*IAUj2SxxB_St(2{b3Yv?l`CQ z)PO;F0x|djFtkB1G=nghstH)cx&RFBXlo)cxP$qjFVrrl{CXECp#~ zp#dJjovRCrpdk#9*7Ldi*GUS(55?GZeu3;7>sS3{oxtJ#2H7O;Czu9OnxhN4;LH)P z?rB@Y>_gq|b;rPPhk0LVRThWwuN^X;WEl~n8B^ie`+c~je@?IcUj6!Vo`%XH*t@rX zm-|09p%VB!?^~tsHJ8@H_6{prbChny&|OwnT_N0UPHjc79p+P0q}YMdYmDGurPnsv zXG>1VzG?1}N>zVvX7s+i@(sty^1=O4P@1fJvs-wsO0kb@yiPwKp%M_EOdeZ^kawqC3rv!NuUYH(eivCa&O-TL~C^_i>=ZN`JGuWSs* zwEx~xgioK86r?UcHci8yaV_i)1U%nGGbuaOLqh$j^UkNEKniHdVlOlnha%fyNBwN# z7La0O@YKxRqdDhnwG_mm?{ha>pAdt=e|96ibmGK|H z`|jbb|9>6-wpTf%O~Dxka05;_K*?c6KtO$JLIUa+M;b<3%@C2lD`CPBU0EBIK5mXN zY#t_ZHQPVi;=#F@}m_c!n`(0Wua-frA;$4^B9vz$2)g8X_9&#WU86W(*h07$TA} zL>yz-CU3wX{;O3ST~+AWHh5wI796UUs98;%axz`D*FiK_3Cip zY>1HLd;eq!xu2ZbNO{MYZ{0B}La#p3nU7mr!$wJlj*+Y#Az3dz(kVKE-ji2|1i$H? z%J{lYagw#8Bx}b=d?O?-VQRu8L=)?j(%dTCv9;wNJ8@rG6F{kd<*|i;v9;AZveGrK z!iqt8M#}UC9k9f=4-#r=L*eKgZmGga7$D#>T)3rsyrntd;)Gjp^&O%kRvc8^5%|oB zy*QDV;a~7s1D`d=UlfrleZ}wHU!U$EQ6#U-jP~ocq13k08D?4AzbQ9$*8s3du%_Av zbv6g+vHkA#iH?qJ!|HzjJlfg?_qb_SZ&dzAb^g~00f;F2W@;@eJEC1J-|Ahy;9c#5iw$|QrA{c!FO2F+4wD0hVH1)wmd?>w`MxUgf zf>W!*Jde*3Lh=}siwS4w7&6_}G|uHiQbgk?fts9#{b2vo9a4FtcMhs=dMrdtoC{R4 zD8Ja4HTdiIo@O@pS{=$@R=ilmtlzp$GnT2&RtbSB6A^bHwLd>B?ZK|h z`d?4}eg6eTXx5tX`?gsEpesB6jJ`KfZj&-pp=kIph?lwE5Udo(KLQ*u*v#`Qp#-7? zB1+JmK>1zENY@ZbxdMDu?M@w8U}7#+G#?I5lq0=(aF`S=##R_)%qTNde-j4(da{Q# z@8C|ti6wlAq};Zs18`_c?O>e+*8ZhSEW){?v3;M=N0)IP*`!f4j^;BiwWSIajpr?-lCtwM?7$mMW1A0mA4gKmEH2fF*%{6J{FZgT8s^P!tPpWJiYeF98 z&2^bIea?T=t8j?(|KUC8^;f9>`0lp;;|BHr^So3oVc8QuksNYdWSdmoHeI=~IBnNH zd!$s&$9So;pr?@%-ScQlSEWn-rS|dXgfLp3?iRr?5aAz1=d3IDeg0 zx`o8f80|Hk1BwG0eOa56==+YkF1r7)3QEOp$r+i2&gWAD=PiZ-9QblNawrjtBgaDa zvD@=Ew6^MB4r@2jug^JP!9XQvrVm-hKtWhI0y8~bogq>=>}a9=Fn;;pB8Z+-jLPq| zejvC^LBuDlq1Z@Zal_dXikK1XS6(MPjAg|5_fRY@M#P1pVk{69R0l4#-F26l3V%1^ z0@sK`13K_J#1Y^d>#b^4f`U=*I1F&B1mvqmQPyXkrrGWZ+`vyhu(kBZq_9m@isVj6XJjM z{lA?J?bQLi&HlglsFeTt;K6PE=gqPIueWP=SJz=(-=UB5(Cj++`40Y`LvPo?-*G5| zsch0|z=N!x!%zWO)yr8R)R|UCp>L;*b)1CtT?Eq*h1`SIP8ID=!jSz>Je&j#In&ih z)=SsU!B^9%$InF=VC9x+_*E~y=pb}mx@8>m;%&#^>tFhM8(+Qp&F%f?ty^>YPjUa> z6#=Rx|K}mt{Dt`cgGaab|JU+w^S5o+l{?h!e(e^Ss!{&SrJzK&UIm+t@W zKfbO1{1X0u75HBZB7*Q>s62q6=_lA3yk;(|wd zy$(=CQxVPHJ^pIJNiIBKlWJrUM*tT*?!gMd^z|SXrh|-AjBc+vr{RW@%eLxKDp|2q zE^tkt1NG|sHeUZ{jsN=|`du#pTF(Fb4@>y3$|QJnd;VX?zh3wc1;_^n5TYyo!&d?H z(D_QClOfW<5^1?N=v>>I%;K}H^-U6QgJcbJq|F$iCB2|>#KG*6VXTtjY?8q&lA-L8 zK1Fi6LXOyKdxQ+BnHS(b4zWBH>hNx6-Q1rj{s-}zgL9>&qU->JH$UOeq+EQ80bAmK zkM5T6pC3N_?)Ln@PXE85{)a;YrJa{jpyKE6qM2O}RALyZ3aWE-=py=Mnq7w3R1Nmx zQ@V^NMmtnbB={$NgFQQ1s^jBywT$xbt7iAKyT{bX^*7pK8chBhiUOMC|K5Xp5AT=i ze;;8@;H~^$cl-}Uh%0eilf0x45Rl{V(BowkyjK<|p+z-Y|7BHjvO4`p=pG0x0fylb zF_fi4n8gmt&$--JdeK|%g?+4aOljEt1NFcBT&bYxfjsVH`k1$YMJ&%NJ3n6tX!#u{DXnP{JRTS>H0`-=j=v$EpFB z=|yq4({#B^7xg2`HcWA79@5^EA7DyLWlEl02z*@lF^uQnn9)bIV_QHg^?Uw51zmy7Q~x|NWo;E8oe3X)0qvMI!cqox~T=6Eg#j-0lpSRqjG< zeO+Bb)F6QElEJP1^58tZEcKOT4#S3E)b=fro~r75nGB{Ir};`rWd|{>We(-5(93u> zGp)a9HflumxO(O?yfUqac}gSeS;Zs*Oj8S1^HS@k1kVub!LMNPv-lH;-HFwMlvXAC zECXUEzXf1ULLMk~CY2KK*$R-WD)@nnakZ_TL}Fqc5RfP8WnFkxclezQm_^FzS7av3 zKxPZuIHPzxRBtbh9_+aLyKAW>&Ek3Ltqn;dv5GULas%q4lI22&=c_1L9WSHBiBc~| z`4Lu)Tb70mdW%0oC=pCe3dhdgrRl=@(fO<3;(FK#GK(L>JZe{oza;U0L9^;oPO)0@ zN?4*B3FE2T(WvURXppV{swE85vFK6lP6ipq_^e5pE>46F0K!5rEmcibqizSvN`R^F zb1LYbn(``X$XFPQ6n+>0iibLxPop{YTsB7$m_M)pS_`+ZgdVa>fE7{X9phid`*aem z=5zUkq|!1D@JZmZE4ACZ9tP{e8GfL zbf2fm*&ZMW=E08Izl#)wLLKdDy1O=O$xtmD>%F4|Rd>W)$`LCAU|#4ovZeI{KY>XQ z0*RiYZkVk{vF-H2UUZ~B5fD^LSBZfvU$i?W6Gd*p@kWjecKF~8EJojTB&tCFHhQ9T zjP+^mfhTMND9#`A7}BO$L?nl(4TYcFE1skMNrDGRl|7&&T#W zP%M2W6CBUTG--D+?^CwYd=b({IW& zQhnRy_2$X9ywf3ODr5DZtKDh4a;}xy&=#W($6Pn(hfE@HU+5>9P3}y#UYm|eV~GLP zeN5vA$>1Pp6Icc+4SgPSRTwv*CVXkd576Fv0;A<-B$nW*Jx?<9V16AJONR|{o_U3C zZLRxeFGZ{6tJy4`#JJxO*DBA#rdHS4#4|Gqr^mL(L~1B&4cJ2BO3I?W3`@Zux-JM# zW-|dST{(@KPU2c+S#pb{xjAlNh{rty-!As%YNB&Ti-5wmw7T|$Se=t7IecwqcadJK zF3?ts&yqBY9J?`(mdlDLmU}okXA%r~3E=JYu|9Z(gJp>Hf&kaSw35xA2Gdn08yqL` zG7jhLX5j>pyz0EuhV?YOSi(fqLi0#DC&^O6QtGW1Ja;fb*yOxcwc_6Usn*+2wFdH4y*3k9JBvZPz=I9zeW!Rb z&LHpvV2>C-(?cq*l~Hr0vO;aTo-9|>IQ5B|AZLim5v^ybd)Lq?c#e9Y>QnlNm8Mbv zI6D;K)x|ViMi!fMyhEME7`~Yh0(GzqKSa3+B}YmZVwfBhA>ARQiSnlt)?o6skPE3O+H^mZL0Ov z2Hgqvw>G*L=Jra$9uQEdU7!C>JvT0B-B$TV$qPvc)E0!Auwg%$LU*`Mv2PhyGKtrX zlD~rO=R@xFKOsbi&V!D;bd1E2&9dMrY&XDhA)GY78MhMnnh<$?EXBXU7xN%W&^pN# z|4+D-o?Ged5bW-r`O`s=DRV3xDN9>leJNu)-lJXF-4BlF{b+kk(hCE4jN~9ch6Rdv zLQ7eKx6)@cDZX!49Ge7n@iMlx7+YW*GuvSFdQ1~i^6`vDI*m}=Y`HExK|D6@wp%#e zEdw@c}gC86qz);ab=l zhUse$127He(c}ZX5wOeI5*{;+#!e7MMAsRCRF*XiXv$d93(5lp7IJjF)C3ICYoUd< zW@F6ETC~KL?p!1RMxD;=9|vRGD;!(O`T{r}cY$U{SIQi87@1&4l7a=lQvPn7Oydc8 zt$Z^9lQ5UnW(R7$Sxrw*wauL0=*`+X*bk_b{de3xC6_p5a%5=@7>D*@X-Jw^+Yu&; z(z6xC>-qizPSqM&_i>wo1vsi6}ejyEG0`C&WqGEnE`hyD}=u z?%+|uu|b|7?*<&eWty=?V)QENNVODX8|?wVW=JGVvEe7xbS?ZNz9Y|~(-nZT^D;~d zMtM0ygqoR%)YDA_gW{7jMzO7Jj_40`$~uOY#N0AkG53lfbq0_H2=WbrfjTV|B2-#CS}aGZ(v8B$aGq@k_7k64u9vXxWNW*=G3QVZX+W|DVRH z_#3^*5CEN{QwUxlX~IdC=DD4KgmYMCQPj(HXitZD?nKJTa#p)y=ocs5wumlKp(IJ2 zM;JYV;0y_!&9#bLWH-$*cTdM)AzKU-U$BGwrngpY6b+5KWIPf{#9cHyVy9;A;8}5+ zezFD`KtmCWX_*{OJsF~N7Nn+@&b#2avocR7QT&lNZ64>4>t*M`F;$7~$ItVw%Auff zKuyS)R-o<}y2BBN{ra2Jn9fsi`1wF2HAvW)qWtzHI2~?AR)^Fhpt2op0dF?0yFLmq zv6uD`V!vAD*DDvTi)wUC59Q*a5%OA_%vRyQ{^rNYuk0(M@=cJ~wcQg&EHKUL*)sp> z`F3ErUJm+bE&i?X;dvihvwUd$ZDotf%*NI#C-X2CHdI<1-3NV97u%O1M#(}p{3~Ty zWy;V}w89JxAqJu*XOLML088ec3k@<#%b}}9AMt(_ULY2# zMY@yKDNt`;d01A>$IdP$s1XdHnu4Mp7w>87ixPfx=C3%{sCV%q-tpx)2!<=?xJi~? zdL&>^&lpdVY_RT<;k zI;Z{!QO<2K$2Mr4UPwzC#6FjJ&! zjCcQ5{$DV~dRV-x)WX0nx6<%o1-qC3mieK>3wf)e7 zfX4Cjszhm63tim~pPkJj+jxo6(UzqV54)&}HgkdscBMgbJ1tG3E?JZiY9R{dvOwEd z;$ktfKu@7&!AD4rCX?u5X*IN?Hz2UJ8ZXPjE8fk;6RI#H@zB=^OkQmyTm|D;2?5-| zi|hfU_LwQksokJ%K*E&!x`NYNe;af8)QXK~moDN7t;dQPV@B1LOm?E?8B<~iUP+C~ zuz^Gk2)$k@t(K)py2|HQoEvnW&aFf=96h)UVWQw9U|KodTb)KTpgXb^R&U4-zyV6h zT0C(pb&d{<`PJ?dGh_W6GH9%6n<>s1LAeM{UQuqLKFyRr8d90x94sylq1!oLNid6} zITj`257w(vNORy>jcC&-b4yW|RC=_4rDDhsoWKOitI|-JZxeeR<1{JS`fahhAAyo; zmDvi78{B4GA_SilkD>7xygYvShPIwo&WjYLaPpuaT?0}%urZ~gQ50r#3<4#%jXB>h zo&#V@LXHvw`^>8}O~Hcth4-wKP|LE$X3Cgnnte3xQN(wA1M;?(|Cv}!*aX`Km7~=n6VY28c*s9 zh{t-Q^LQ#1o|&>2&gqKA96r$co{VSU4L8gLYoIk)#sciImIDZ>6tVsxcBl)gJSVvFc9>@-L<<3QpUmY$&(7R zaG@37QSpQ!r?zm=@VDKDO0_9phgu&WcClan=v9g?fX%}T7YNhC-))NJSYprClQ(f9>gD-pKDXuuO0+1 zKH(*dQK`622w7B>^zPWVo65yHH3$Fr)$?b2yi?=gBOC%s6t1FWDd3Bz9m%;QM8o|T z$i^ZPjcz+G`#RSgzns7Q|gfFu#@QkgqQJ#Ucq~l8z`O;JAGC+A+9OG z&g5_nzmPlv|2sFka1llqNw;znxlWg3o>oM~vU=FY#kr}&JiSEwt0FTVW}FK|2V8_h z$!VTW0P{fm9`JIy-*&V0@!=6@a-5!?4Z1-*^X~HhBhqsHWatiCX4Uk5;jHIc0$f8< zZNqAT4n9CGd@?#Kgxf(IHpv#G#l?~ zO;&i(ZGqCaQlR9?%g1XL=~OYYTVToR1_3FS=UEi(O;dG7)5qq@O`U@2MU>>FXzq4y z2E-eW-Tu;HIh2;~q~U(#HBz5?DeFFQ(veM0NyeH0sYtDE<>o89Jt@6Pe@oIzWzp)5yE~!9TeA3zY{@*qJV-i&L{* zCL}cypG0Tx$)fNaIB0HM7QqOOS9vfq8TqN*#qq3!3R+1w-TNu~`!LfJy^2Q4Y zuO1=nN>>JXl}_n~YFs(o81C-Tx^a!(NFI;@Q!2*B<(1rYBPcAZ=RPT% zl|<3l*{XYNrczIv`ga%$)TU5zB^LSAKo7C=r5(`Uzk;a z}))T$wFbIbI=y|HV1r>JV9~T{oN|STq)E^p%27@$who&f#T%EEBd! zk|>0d${?DaMZpf*A+G9!ht*~kEEG7LrT*j8F`CK~A8T)y^k*oC?U{P<3Sd-~f*sk2 ztQl7afO)0#K%)-5p%UV2WvRXDvGLmQm%N90JX4#!BLRj`EjJ102hlwGNTn3{x%$Sg z0}LRbrC1OI6T%yC#V|^AVlSH?0u5O-k1=SZE0!!PO*nyI*bT@cCI?`|qEMk~&Rr-~ ziL$X#y)joK$db7biP7KY=>!shSl^}w(-&TC(9)B@fUyJ3bc#HrNFx?O#*P4u;?d~f z$PMZgv>qHo9FY<3!2l2Qk0hH|x1Kt-G=8Rye7^H#aBu>F4Hb7!Bg$~u6Tu~jb}*nU z1-C>g0Br#EA=cK!3(2MWCma|>@=#c^#s+?EhR(k$(y8~Z)Rx2@f|)CYs|T-#BRgtU|dRd0d|RU zdHkS)!>P8(;&f*guG5U$yma`8KG)d;4x{wsh6fGP* zG>zqF^1va1ib|b|uf8rJtWgquQV?t|pk1Ub%1Cz3=om#C9MWEv6v{0&K5}ia;^SEu9U)y67CWx%1Qq-lQ6ubfnNhcW#dm9rC zBSgA_gLiAGIS_8Z=a^ERntmtbCZ`u6V5Hz9PaMrC)((X9!A#v-o~u=#ojaW2+$0rM z>Um0qqu7p3)N9_XUhUl|C>!PTx7lUI9W=MY(Wl9L1vVLHR6zL{j7fHLSV7bgxd&>u zE>rrm9*hdxu=8lwz%wrC$1ppA0sCrcCwbl}u<|Q~5SJ&9GDcsM@U$~nA@P?mC*V+1 zuA7$nkRo7Q{*$K*%vHIFvL%-B@s-qw(n#vF_iZ0nigVT|GjJ+DshA7vl1zHO1i$5R zw8;+xLAN;|pd4L91wyo3Weogfm069;*jnTlQ*j&9MfB#!*aQd~keVqFI0-Y-RSEDe zEZixcGFuQ+sWnMImQ?YjHQkYN4U_ViA+ZO`s-T7vao=Ee=G0cSP?{J)`LV1P)JlFy zorZBBz_-kCyeC*67bR2q6XGwaBf%{OheJ3?_fkTyhwKL8Ig#(E3e-ALR#QCHBV^Hk zQgtSHQdofcDFk$lV7dY=gq5{D-kWJIxjQ}=Xr%fIYme1q_3#>*(lALEAG}l zLd<`)$S^h{i5ClWp$)_EjqqoPw{eKR2WFr4_ z7s?TJ!7JA%u_3`|jx$SDn6p4?1J{DBg2hVGA5Tqj%z1ovuFc>Qfw4yTIT3?M)Hzgc zdh|k!8;e5(EE%96ER3tV;<(N+zb8VlpyvGDv4Qm@_)* z;HlxNOFd%^t_@YnsZ(`v8YfnaD>RQ9Y7sd>si(nP;I+dM2QsKLJ_l8 zb5NM6m`|+;7~m8TY+^E7bz{~F`>qE>8ueS_6%58VLa&Xm&c1YxY}q{POxC;%7$UlW z&sqEDK>+7?QtJ~`85K%E5X8ftKZP{pHM?>=i+q>_0FO$1i_#7DZ-ub(mN*jXEPGs# zGQC%P8DTrI_y^NGSueYGJFt4sZ#$aRM&V>?Y*y~Id(xNu>)*Rt;!?Nt9@H)LWUruY zP1p8~%0(%o>{^HmJa2VkYGZwi=hZQWB80~JY?WbrLU2TExR-j+i6P{sW^YDiZy zO>|3YHf-dY9zEDqevUaM*qD?1I63X*Ff%|b!Z4w{WLVU2>`v|>cblU|wY5U_gp=Cw zYvhj=klL-7YMW_(7;F=jw#(2)R&RKW^b;VIP5~e|Ch~6sx(d&i*^1HBF+?=s_>PGI zitknhAyK|-ac{r18P5IcLV@TpG4YZ5TSPrLq(pLc<{&=ufxvfD`h?Jpet|D~Ggb;BQO-0+%=#c3*pT`$@ z{NL?GIYSyF1!a$cG%`yx6de$|lsaB)NnnUcAra2T2Q;;NV~TfEunnkFfJMDGZjQjx zh^GFQ&yd6QHLA}9^%)LM0HGN@`&j@taFOAALOt`$9$q{-fYM_I}|U@yC9 zdlY4So~9ol)q0P^P;`#es&s9hZM!GM_PFk7xvqQ!=EY;6018t_0enH37A5g>nB3>e zG)ks>XsO}VMwCI8E{r)Y%@9!Kt5``h1##5R@dpB^LkTO79lKJG(n+5or< zRfH^ULEwjTwb1Dm39S~{IQbejR&7$pZFQ1Mh~=R}v_tu-AmUe&>PB(1o>#Un73I`? z7O{y`-%tv`N?zhZ6dAy9I$2)LqhGgY`t$AII?x0xt6=Bk?d~@~mJ_v>GEv~uP9(W> z3O#NxJ^q0UDN}?#@5UzZ^tZqwu#O5IfggPd;wI^dPkNKcp)X&C)226?4x84gt;8uL z=6_-$u@YKLOiq>NNNhkZyBcL4(6|Ll4oD;R7}8)Vi@2?#VTUbN(bG+^nDQZPRY! z-oiJ|dYkhZzMVrtuW19Pga^HDuJwCe=ft0eh#U4IzH#bp-k12+*{{QC@pmZ9AAkQXtw?2ekI3WHwXd~S+ zZ+^$!yLTTyd>B-He)rz}hu_`38~o+LqsMpe-Fy7_uKM2H`w#9vc%c4!9RyfGqM4e? zy5ro%e}Vq*4fz)@uw4oEF!?=r2cYnbk`R2R)YWZ7ir;>+<$l$eAR{A|?lXIo!dZIM z;wMI+l5}}HLtcj3KVWViV(CRy{X_Jy`v>ZOl=*%jk4t-1e7>e-v5ZY=0XtqB}%4kW7kwh@yN^cqH0_!a%>p7wUXfn znV_!5;Gqjiv-PhiHMy)}2Y8Ja9ay2@RtZroD?`~B$uf(RJf8433FbvGvZk>jH$qSZ zmudC^P<7PbupCrbJRjMRxDyZ{)SfOf^Wen3k4DOA0p*s`8yr-$zOQ3>!)g`09 zU_X%QY7&|9BWoJ_!Apw>1Cn`5aHx=XZAKhs*EEvji~eNVX%Y! zplwYJHcunZ*LIPuX8UIZmkH&MmqlnrVIzbwRTid3SK%7IEhq6lZx;ZM3Q4R>OlPw( zb*#{H5Zt@)GQ2VXZBmY?pM*BxYC4~4A727ft4ToX3B~23U;@48siaLF-s0**3jwB* zg~!U?ln&~Z{h&{+GQJAB#v&@qELu{Zh?IcTED-r%tPK1gHO8-jEJmr@-2pN z3>JX37cbz%Qj1h`Qfnn>=rBPHGFUM7-rkpXYg9aiE0P_h;jp|;*XV`Qsp)S!TLF%D zVc)rT)okX)=@Uz-yJd9^=R?7T>Z{CG3}|i&thRKQ5!$Nyy)WwX>Mi7yt{ry|cP+ib ztedCas*F^M3x+a~LMfRV25skf8HC+qb0}>WE$Y~|ZQIF;ZQHhOJ1e$r+qP}3*mkn> zyr)j>kNY2VS66l2-PfFB(8?yoa!kb4O*H8v%5!eFd_nIy1=Ww?k2JcKGx1&OLGAJr zwAu6$>y#J=QL0e4=_Oz=yg?{fp^0&Yhl>zRLHPbZ*gwPF%q*@geHqFxaEjMWuks`pexZfNf?X*Zwa3ooR(>gBj)#5}K*tPkyJg6d&RadT z;0a5x|JST9Mzuu)w&P+vl<&-YGyzK@e6mLv?JhHw%H+B14RW1Pv5#m%Pp2qrX%ukC z+Sf&ANDtpJeA^O-8b~eLYVXq85R6oNNU*!c-~QpO51Yr0go!1yrRXE`B+tn#KOZ9 zg2tB_*_ZA6tc>U>d#6+-u*A_NRdXsLWbI&S6fvUXLmhAV%L(Vd8I%jw-7OAs{!a4{bitDx{@@S(V z3brMhrnBfOWhc-up^49SQe5=yQgpBI`twr|`T7*R9VBr&*M%SW}7%wg-FnJnk5d zoF2nfaQFE8qUBMr`5SFc?XBN3rJ`9BxnPWXU|`XZ?U|aDi&mN>KVoT%b{L~_N>i`4 z;d^94Q%X!&`{XHJP9){Ww6SCthWu=GHqzlag(iI{+}LaXl>lyZvq=l5r-(Eo>F@WIZ4Ax6QK>@yur?88j#^^?#B(ZjY68 zAITpJRll`rtz3Vl2$M}-=#KRqt#DI_3lxQhHMnrHowL+5Ow$eEE}j7g2YjCOr-Sk) zEtFnyk81(ok{2%pGtW-ZP0Nuk0S*d>xdW6^2vo~k;^eP!MztdoQ0Y3&CYV9|t2)KQ zRI(G!fl3NIYo4@7LC{4Pzc%m^kv!tdJ%CHjCG04fn$adS!+H#o=_v~i!#bDnx`U|4 zQCKr$5lFtO&h8TUye`Zrh|0=GzBrEuQm33B1J!jEvG;vc0w0E2fI#V-Lw;lgD~M6) zt*3)SmM7&sXLw6Ws1d#cKwG~r>1V2%S6t)FPQZy=s&=3NX0+LeWo6<8=sh(}KJ`S( zeO%aQ+dtmTcwh`gD_!Nv%>det#)?w=2*~=SnN0WKSO{-UJ*OJVn-VKfC(%(#+Tq7y z6vA>?a0XMu(UQmQEr5KZ~xzDp8xpwIc z8WUr1>kJ z*8}luu=L25O$Vra_Fo5=EMEz}@Uo>y4L!`p<@f5}SpM#*sbPSGuz#30T@H~RbwYlu zS1DI$fBqs8SUZ)?e%@X4xxq*~9ZPeE>y4wJ{c(mmZM;@i-pa4E68~DSZnW62hU4+_ z(na?h6#Lk1n@!YZddDi=TO?-u|Ij)y&`$3ct%~lY) zRiNu*(W(s|BMEDk>%c4q)gEz}R91FLB8v@HcxLKr1|>ZVp^#$Ya!+~|bmUP9PMA_q ziJojEvh|(ioTf+SY(~1YPr_Le$Z6JEi-$aak}o8 z<@8Vk9=w<=V4{OEL@i#z6F+gV05?fs8HN|Bj(F*er5bxl?K0XoEkRi}?jUxT-BQ%nsRy2;v56AL0 z6!mSMGC_{3`yE@r7<*l^YxEwO*Q(#`NHB>jQo9ie70&vC# zQS}18bBwB^T{SndzM5&|8I*%PWKXM+H0hEd&mAqh`Z%H2mI9u+=(u*tX@kTY)bb8J z>$Dg%mshnofWGi8s~GA%a$>u=idkfzX5yIN@EG60#P}WJXrT2mDfr`6*i{gRe24nnqMCytd*thVaSYxmFxQZ~8dBo_w3*W8Eee0|06P zubEfhA^;PGWpD+r1|#G_)Ob*Q0E$N*KZ~FjGKF=l5^)i zEF|g0^};Snz9rUprw-qw9?X(i^rMo3Q~ul?>5NB12fQMOGuJCOnqIW~GnCZ>bSFy{ zA)VF$84@jRaNMa)0;S+lphovx_?P201}(%WM7#-fX{_?Cdd&{2Vi9l_VYIUCF_!Fq zYi&Bt$PTm!lVf}EPwVxoZQ{~OWm6tJiai8zl5J7%RVX>F3`gN?i zt0BrDvFro09kV;YrCtvNFTpU+-ROJp9k@hbd-X(dmxfN&lnQ&>qtfIO zP-vyZ!d$cAozJTIv!^W4LtWwf-cWrZ0MGKThjVC5X7lpl=$_~g4~II>njS;Y(eWkK1#N*tvw&PQskrU8WmPh_jH*oG>MdFxaW@2M19a%S%8jY!nQZWiEO*xE zKCH&$6yt7ksad^QZI{Xp++k8H1oaV2$CVqI_cV9XOY`;W2a4f8{e^$}jjJa}{3p0c zI**}8P&u2CR*QIXhC!>xf?FhuaH*ywa68XP0hyB%GYm@F`yl&xX_b$T&OjpD*5AX> z7oRFKjbq|s`A-g(9;N3X>-*EYieANvwmj2oaM?zw96kjls+*&B)aguyu|G)Lo(q{z z8Wz;`R0J>7>QTQEv^1i)nz2{N9u+uDw(Eb%S&H|66W2=H*c zx`kW?k-XbfEUIYgoV`4=1?F}c6-tSGDOSWTT*c@L38_RGo9&oz#BZHG96OFZG_yj7 zkz>$B_BSaPy&5&C-MV2X7jmP5Uq>a;L%3n3g!Z5uddJxutvYZVtZ50XNb)uk_x3W! zMZKi$M~^etma-d}ZInR`hSa@X66i4Bdk;qM3zHu1u4Yw>m(}7vYRM<2IpQ=f^rg4w!b>8Y zAn(eGlv4Gwi+XrH#tRq~X(D1U`wjCtS*V;<9hY-2{#qe$_Z~oy0`fn7Q3F<^sK&Aw zW4=7NfyCci7T&d5en3l_w%=@uUOaIe$!!1gX7VjDeN7(-Kx1weY{`hu1Hr0Oe#}Bl z_Yw;X$E-a$oqJaMQpzv~F2{A-IC4NYYL5)Ez=D!BF61iSpyCn=cgO}k@wtPw1BeC2 zrQCRiPx6e*-NRGG>|`91<2<@=kwU|3@SnW`EM*{mIkSqiPcJOW@3C}ye)YCcRtfpE z8K@(MH4Hw-V@PazMtP&#=qO`cJ8{#bMlmEqB%uIIgaOx9YVa=qynVz2K4*g~MT3b$^4l->K`8aRu50mp~DG(m;@zEzk$A2GX+V zFSOKxS^>hzN=WfLPoqDgj9Zr?=|-PWa4*sA0Gbji#t>t3Hylad(m$qXx1IB0554iZ z#AznSWmn5wb`;}_GqV(O8v=x1zE80CMVKy(OWMoHt+cz;e3}kq2~WmgK<$!Ip~)X2 zw5>1e2c0eOhz>y3Au`kuZ&(aAw!U4umBjS%DD|Gr3YDirFfl5mGn&Cn4RO2z4-%MO zge;1vOOXcEM%J~`4@>OR#{$lFCSopXH&k#L8DWc=hXC^Z7s^q=m9G?BP+l0(W45S} za>r~V{4h;Ke1oKGRL0?krEol$6Hc^`6GT|3vV`=8ZH(>sTs<9*UQnw)>zG;Ozh6_< z@GH$tzQDIuOjr<7N^o}4X?xC6h3Is2NqmruM&={MDP8)w)JPg9OhN7ZLt&Ex#++vX zc>k=y43wOx<7Q6Hyc&P0S>s4BhBnS!d+h7)l^dxrGY7J|Z3g3!1D1-~SOC#&=ZIR9 zU!wDi3k)j9aX+|P2&0PZ6st7Mah9=eMu zhC%R5rw-MzUMaYIm~s5GsF}`76d~TZOT*GyGxc$UCf0hUDuQb{Qy(X;XAV5u-(Pm( zQ)Xj2zE8&@-kAsb;=9;z_ii=Wy??P8w{PiA7usHy-JdE&{H$eS##o zbzYLz7d9o#gUYRgVsayl35}GvRI}BrvIObA5NYY2ZBlBI$wCnh{lLSz zo;bt3>^I0vIp7`WWkD*ZC&sk-p@1yv@hQ7|pg6ShSW z_$C-vLMPfGzcp%(D$&A|lZweWB47;3l|2rvXot{z3}JF|+dZuRfP>b&c2F}P%?iZ& z4bckt@o9mXNvnBKMOnCSf-%UH|B?2E8=jzC!_8oxiGUrT&P-Ww#59I~qij`%4@J7$ zb(qM@ge9~90?v$n_ENqdq*93<^`j82F>mX(W8S=)oA%LRDlswqe5i$%*xbuH>talk zNQ4Z^q>i)?H5-hI#yXlLI_DI}b0@v{n5E4o+i%GR0B|i=d-{cTQ9pBVwWo5mQRx2Yd7*i(AzvIfb!j&B!@$yrFh>InRS$`#abN<6++Cfx#36C$0;_RsZ zAD90<{o-3-n(b>iz^tqk?=*a#ny zGRK_@lK}EyD_R$17I$oAsUq3l;oq;?F*$QQ78D{3Xx44uW}5P_zbU~qPx1k6|JgF3 zpa3~F6@`w{RPhRsmJie^7GrvBhlgJO$~j1ju#BJkO3-bn?FJ8x*QFG3LyF8oY00q? zKlfa*%#7!sT0lk8?u%8TY_4O}MdA&AULz>LF>2SNJ(QZ%%Ht6#qo@Md7SoVD#xq(A zCp0d)vFIH{xav(f!f$~MjqQZhxC|eU-oGwQ27y3k7pg=ymo^S9T(0ucb38fKOplkKFMWLqOR3W@ zsWB-J*Of@I7u!=*2@7m4g=a3|qY6x%YD!|aJ}iTj8-!&L$9%%>H`;diG0vj&xCmG4 zXKt`6E1ysi7`0jMjL#H?iDC^YB)L1rLj_T6j$Eh-yda4Pz@sScS~_i7k- zd_#(Jya|$I!Pg=aVBy->g?eohvy6U#A6*V!NR=muJBm>;XkyKU{`lY8R-@TwtD?h^4krV_Xy!Npf0pUVmU8e{p%-avX|bfj@H=v0Qbc>`|sV zKleE-gFX(lFo?UlaD$UY0M0VUBsq{EMei-yTIRkdJ<8}RNkEDXB3X1^`H?IJHr?RC z!#jZXtai|yqRa}0BOWt9zuLd8xJe)VMQcn<0Cc7TEUDxdmY)>mp(qsnMZ4H*;v^T$ ztHY7KNi!rec*m34I-gvilc)S@QGsI#LzCEtKUZU$!AB5F*IAV>)N%qIQ9=PZ#UB1j zDP#vhF`sh6V%s$$hEmh1!5A=e-k-|tWBD&A6QD*E-u|v!l<{V`q|roUih;RGGIy^WDK;7AnCY|<67&RC;fk{ zOm8s9H$lLRys4df47xX?QZrCbA5>r?+oVavVq#dTL)5T7wf#1_Af z-Kd-PA(hpk$$~*cK3VDUc=0Ul*YlN04%(Li=_&s3%WO++5W}w5cw5`?~`&s3x5#L$!z|$k@x0 zOzmS5jHNy4>r~YQiTgQ?Xdw@)6V0*BfqDft2(~c4qx`0sK2khE*6fU3-s8!tL$Q2H(=!OH3Rsa2#jMss}^L&|1k zrKcTAyVgJ-_F5n_(?~)$4DQDivPIzH1G{j^)aKSMrkczAepbs*<6$>MQ_vj^_uu-^ zwPx_q47hM{kRoyQ*V#AIi{JlcU7yd_`u7{X zn*CiUB$FlO72Gg1zkU>!Zhmz6*gx1e27NpaCQ%Ksx5$&Z}Cc7t&VjO&2i0 z>xxX$6tBydI_j)I@3myTM$of?1WMpq+YCdK%y0wfmcR_zfEeuf68R0F3k|lPr#Kf@ z^mav=7FN&l?D&Sj7!IU*j-uJ^NBQ}E9BkiJqSyY`P{WYc{y(BW`qG_1Sxs_N&Fsac zHvG~}0niG_9{;zZKPxQbt=Czwm;0*mG&k>20 z`(tyEd-~(y?dgvb(%y?38^|%e85?B!TZcWr4KDKavv)TL-unK-_5DUw%eAah-Ufu< zePo&YgP=dBTHM$(q5xHzXZP<)t_2S6TZ|u;Sr<`rg#O0&J3r^*zj=Y`Po-;zYBckt zAF*BDo}W63pSbIt-sYbgrEiTfgOw{s&9YO8f^2Yv-hTnbOn8m!%nGIA-Fr{S(0xKAUBEAw9RHl*ZYZir31rrmOEDpdOsAxRJ-*>e?XTgxRYtU(ur0?**L9iB z0%4P4D2Cps_6m(!R%wZ+@qF+U;kC=V8>fz7Mef*$wQ+CMmo|!h2{Njjq4^DA z@_!#c3gJJ?%0dGl6&IgoEl>b1HUjdHGHa~Idun%=T0_Rgh7mA{4?qTr<_X96SDf70 zi6ZXq|96N0t*&~fsapHzilzUDCXe#bmC;(UTcNg4s;4ArKsZY5t29uktQ?>q|G@c1 zjXV3uvzwEy^&M#CF(cqu@c{5x(p4UEN#ZUMo|?#CKTBUArbdD#(+f`w)4aJ7ap|#u ziJ;8U|KEt61;3u{cPxfHz+{LYs684$-vE`umk*7is5%;6f|m#b9=gEEMa!dn2`t_- z-Z(UYq*gaU!>M&KOiclc`(>BFM8%-(+hrx*!!qSvAFM){Zbco9LD!U3O3jSjQ+(Cz z@4jCeBL`Ckq;nBiG@wI5-H|C{p*xf#4Qi3r@a1x&pVPsNFV&;x!mX*Bk^BgV$PA7w zPkasYX`|IB#9rwx4RT_jkDSdAF73nKKNEY$W)7K?3Dl3GB7BZqgwrx9(ibg~xjTkl zNwRBvQKo_tI2Yep*62!qxS0bQF=x62Gncjf02q#7?YfBHDhXdmo6U0qI*JOk2OM(w zMHyW2^oZxPtH`9s!4n-(iPJtb-t-JgsuTlW_CfYC0dRw}nadfNi7^3Cv8Q;5wOlFc zE(k=i)Z4QqKi(*$sa)xKHwkic6zsK)Oy`eRAULkoL0wP=%|mYFu=3^bDsxvWto z(tSRdPqKgzuorx4)9i1vaEb{i=D3 zERiT>V6_GDV@k}J$uSQf)Je*|+~&(d5yJqIqUJ&tOliUHqsV9li`Da4-5vKwN9#L9 zr$6r~Cx0B>K-Zq}1F!6hhhPaZ=Mz}UIP8+9{qcQ_m2+A(SfZ93xVL>3XSIgWSvX)k z?;y}0Aid6rQYgAXZBfbod2n4G4a(GFNIs<=`bDK~dN6z+skf(Y;bM+JR9j#r;_`b1 zV;u^4YF0ahLX3HKaHz!4^o^BkyJl?~wKpjPBVAUMSw5KM0SN6xsS=FG_X8|KxE&g;Z0;L;Axz;HH!@&i9{xU(lpd59sJjWJ z%Nmn#+fUX$lAJ@tvNmxBK@*-K0J%1eVsUj{>NL+!vT~Y;L${9?ngAR|Ox1cUdE$x- z`W&7g_S@@((*}Z0w$gUXPDwnis?*l=cf7nKj5O*=bpT-em@P3NZC zn=hb=r!!Np{_To6d>~3e_Cf+aKX8aB_Zl)HWGpL;FQ`o{aheNz%1a6!BVRR(ntN^y zzv)FOWu@Ea--T$2m~zIjFxF5khOL&kOcyxqBr*<-#OvZ5xxWcD@8|8o53}LB5ek%@o*gs=zckjVX2_lVoWr`nMhSD zxGCtqieXj`6wxQhcU#FA&?L&KYvKUeQj}^VbHmRmjyI7`^ZZ4*iqYJhvsQS$W@0tyx8-CXHtSM`j z{f_Jk0aQf!vCJ@tvJEWYa`q*%BDTbN-U>dqQy{`P$4t4SW)SCswCsEQ4F*eHpt`r= z0KhqjEpN@lMRoUNlob+*zgY`B@XVx8{tZG`M*fFx2Vds72>th%oB^HB0pmV_kKB2) zpFM!#S|$!PvEnAfBYQl8($EO&jQ|pX+jG*X@Q1sIitmmhAbpRJLHTB<;z}$z{XD#d zWP}vE{teSIE)L&au!5i-n6B4wcaM$!Df~D@Q&<7o2yLy#fcdcr)@te1r4l5Y;VNQ1 z7fVq5yz#9vN_jRj8Uz#afDsYMcaew>QBwqg61kMQhu(h3&%Np1GWPk_lwD+B<{^l$ zSpad)csZgE9YGzO5Ta00f;?$A%+ppx{DlR`ycK9$3fV6VmW~=?Jz*ZVQnu|toVsip zGoVkH;+M2(Lf<<*a2*#HlpZB`4IN~H0F=SjA31{P0H=e9T$x4(;J5=a6VhBTE%U(T zes9b@rT}f7k3u2qWpaXT1!dvF%4OB55SO*!11UR`w?y$L#QOU3%`zcUEQkkB|n@JvGB&u!IV1&1tGudlK8tWIB3*6tXsDt#qgVz zCjul$#ohJ)0s2^-)w1?!h9kmAVv2nDFl#V0wV##Z2A>2CaZ5M!N&2=!@BCXZVElf zLu}#h#bkGrv$LI*C_7nhSZ1+r3)RSyhqg862pKyh={UeKhpTI{0N)?(2l2dM%7|`@r|J0)^cN<&`aHHFOgEK1rQLxcBzejUvVZLwR&?2q z0S_dOU9pv9yIZRWtzCN z7l#o+Ub#Du--jn1sNMbt53V#Rv9S!Dlx3V`eqWa5QxQhs+%#l?6=-uY+I*^!B7$ky z&kuC~J?%-23PZ-Wg4>^bmvoW{Z88SwBRlOXAS%#p8&FZKvQoJ>(+I@}ixvzh9@+Qm^huuhgK_jXY`t%VqIg}L)IC2AzOzwqf z_6!#wafV&D3*UWyxblcbKFdhNk!jm_v+aeS0nM4F&}NU*muA}kBF~EIQ%1wmv<(sT zPo&IeuxqJ?XS&#}J4MS1o+~fY6)w_+Z+1!YLo!~w?j7hYdor4DY2yuBzU4Ru zKuT#cg-M@?XV~D}E7gAq^j3F?ztfeRM#-VV3C96066SH8dy!{fd*;3lc;{WAmqskh z`#(61uFlvt+#X@JWbQVbw#V?su>*X82PYJDaCcJ2u_S3}0!z{)R{q>RO1p32A2j?e zgH-WwCf}SXAkW|iBmj$$fmv$IV$DtJ^)NzrO{+lU}RgMc>`VzFdFp%XRaaZczQ!=DHCyc1 z1?l#j_p~F-c2_kd~$*3K~_O6xz_4hBaL5wbib%-B$&3^8|J!8O}w zIDUQm1B7m5OiuCxnH-7EtOnVZkDtnt|Yt zAEz_WzuiuIc)71O(XhGf32*H;*K73M$1<#fjj%iVQCkpO{4k80O&1!4_UK^{NvWf_ zg|{VP!1TVS@^!MM)j!3Ly452R6NIAdoSkG~XU!TnXGjIC96653Tvvs&XuQY@BtTf# zy*z4{H!#;N!aMan&~Bn!aeq?jju>=3>nACrs88|FwCO1_hVZMy%aoCv2-WKob{WX^ znBJsj%45HJZxrOWz8cCy0gp2BN4u)Ip;Id@YgxNeOj8RsDw!m5pT`k{v~^WZ0(>P_ z4Cg`>7K1?zP*h9I%6bM{O|AE`D3^i9LY$#1Sf+toygUPrhQmUZ)blf7MxzLWAzeSP#9wWR{6^*Ri!E=4zRF0gFyDn+m20^LiXMcThfqG-wm z=CVk1va_9f{EHANb`sX>LZB#P5+Ov+p!8sO^SLOU4sTE-9n4%Sq~p1CEquq5ge4*0?C6hQU6c+a%kfBW@`pFs4DOg=FA~W`U`W5-#)-Z5w;OzV! zki~qD!i?)%z!nc|DjBNXGgxGE=!hyo9w!zcmjl00EuqNipQ0+ExJb@%{DxE0V21?2 zeG<2>3DisBS3rc+%kDu2y=*56)e5CDN@N-PL=;MpOP?IFNe<*JAb@FjlxOvVjkPF& zNi?--0tA?f-w^5spFcfBKKiYcE+Y2aKn(g;h!aH`HiiQB!eX*R^3z^S)%6Xe(A)0l zZ}}l)45JrqZ(nWndKWmW&6qOAR{M?qXt6Wot%{=cBAVy5>l+9$y#iLxI}k_ZKlui> z6ZoCbD6ko6#~BO}3PF~vf7Zl9yNKLDArp|pBtmEsbYmbk4Vz>c(G@;|^7ka&l8BA) z$7x((MsXQY#n4=A*(m1S?RqDB-`Q3a>RB4L)lcUnbWw^5?L)x=xn92y0)d)KMfh7X z`KT@|X1=aN!u+JGUm3e#(SOuuis5wkW|Aac01q7>Vjn~pD|z>hc)E}^aJJ5QO#kg6 z6q^^|J+uH?$=|QC9}n9QaiPjP4!mXg!obX2haRV+!fSM33P^#8S4Q8Yb=17yv^3F! zt%|*f#W8Fk-$q^uK427*|B5k&NfgHXTe!C)3065`8&CdG1Pu`$mYzEaX1j9YLf1UY zgj;A!7N8z^yUD2Do}9leW@M*b5)3(vPYO1$YD}h@CaY2?p9I4bwe{O61WawTAZxBe z>O_%)fxf^ydUYEzJT3CxBkz1s-_7S1Jo}na$w~Z*KH^>kM{3&l47 zY=A6-&(-Qp@f7rr8B(suwi<6jYhmX&|L$oT&-v*BON*POXS%jn2za--)v3&+W*>_a zW^*Lp4LR)C=gDH{mi%mIkC??3K4cmy`X%!d;EKE1fv?(l3N~5#qMG~ zCI7Bkr*)_iRj8T_vjuXHfEI&o%Rk>^@#Ee?Ni=x&dPZhcu zhD$#&giwn{nN6J`ih@P0Y`ccGc8&Aeax@ewn~SP!|EI2#YVIba2kOy4#Vo|O8kI`A zz-5DK$x${g6l$^3EW|?Kb z#cCi!2l>58hJv~vUz3c5+ZLEb3ZqH|Xx4d+4HB4LqRcAZ-ps1*86svHF<3N+RaCu| zR#0KPofeS`3wu`u6FaC`;B)~ze}Dm_09-H*4r2PgbAJaM``p_;#s|Kp@uHuL7e16> zlJ_0QJ6#mq(w4Rw?iQ{dV(!}eG_Z-=j|!-n+ph}9%B^S4ouYR2Gf(Ow2nxNMna|6K zqtt&=*Vmv{6}6Ir>9l6Mn{rKf!< zcCQ7$KlM_~o?0r07o2B!{F=2c-W1qfXTFSBYPZambFTEHFSR$Tr#aeFLv+vu6{DfF zuMQV<;{y`Lf^3Hksm+FjL7f<|BdA5*x5^dhG1G#=ep6v@^OA!9TTYx~|_ zIC($0`kwTmb~Ic^W(g-9sEbghnP&6RObKj1{jZv_ci{c=kNk5r6@h2rrP>%(_{icv zBnjVr9PdE|r@QO-VsmyqYiAKzdhrOXAeO#D3S)WhZ$0L3#nC$Gl)YL+2i=m*j0jx5 z9zrYpBemP10s)iYqC<9rgLX;4dN`k8JvaUVdWO{4KRZr2R);(6t01nUT|W!bi_jOo z@n^p`lnAhYfnNL!P~dzt8*$QRqq!96+em@`TnwgLdt_16wlM- z@_Ha2`*#;%BDMHnojsRkY6mOqjoCqJenM@wI@%vpCD=ITpPmR;-M5#SG6}9YA0@Y^ z$BdV}X1jxW7yzx`BlU{RhTZhKUkI{)@kM?9(A(*FzxS`QCzY}T;Q6p(`S7N#c5pn# z+;0Z{(Gv({1zhJQo^1_KdKSytojd5Dvqt84z~)caLpJL1z+N3pxdEip+xcJ(IC^?t z0Z85jF?{24KbrPZ1tp1lHe%K1{2qr_!0WlloA|g-DDjU7N=?C{_13_-{|6 z9)R9YH(9!24B@8f1yLt^6TNw^Noqb%qd0(QN&bW6-9#Hqpw{|spad%wYscv#wv-pt zpbYWNYH{T-uv>t9WrnO3&b;knu|lV7IQ6uu{n(UqIS@i3-i(oKsO3#`(MMlFZLIH` zIDBkKq)VZs4Uy&|j_(R?fBh*jor{b|p^$ah{XAaIE{}0}f|xffomu{b%5&lKG=d5K zWZuX`aosb=Jo?}56!D1nj5By-g*?O4>ei~8=9gakpxu#%;xFG?N)x68Iy!*cxPyC* z49Qa!E!8|{1_u7rAXb~b`Jj&A+bdI$u`S1+B?`%0pWk>8BDH4KBhL05nc;Dz4aMfr zjWExr4NvsxjT2=KBkWScY{caeU~2!#%XybqS!ZJW2|S7(XE)p6jFD@0OoyrXlE74ZAfuaE z<-}jD&1meUaOw-GJFr@|?jF41rNa%&y<_PTxTAN;)vu5X#baU8|J2=YBsE5Bp4D{nZ4SzlgE zDX!m^t!|$e*L>PPy~U1s@bfBEd~#^hMFl2$kXnn2qY!3)BA{65a&y6cShn=RoGkEv zeq6X>oR?%D%_Bf|Vzfn&YC1zM$o~DWb0gTKw@FzwEZFc_T3MtT>lb~z;_!ETw14sE zCFUlwtto?5Vs#?*z%QdZ6;qe*wajO(Y?73pHZs#T*F?) zcj3$9J0%4F@F@8=Eo+?vL_ldjZepe!`{HJ6tCVMfshc8JD7_F>i1(1BlrnbQNB}mT z6_0e=Fq9Ck#Gv@JklbZW{)Q4=@j3{R?h{nmA)`LoGfU8N<)z5WW@uOO<51CV1pMioCW+vr$5RXR6jH!lGVvu9&pIk<6%Zwzv0lZzaw z!eY6#Co8_G_-8}p#Os#n5@@;u%6a+wo#8M6tNR1_jtv>J)Cc2v_*bJ##3?gXezriQ zf3J2se-(zl5I`FHQhomUHb?w4WOoe<#U$@AtL}xyYMWZD`?{sd}T5{K@ z$^wFlp1pPF_vH=BE998bPL zSKCczdcRdmKL7FL$T2VBEd$%n$V?+lgIvsrUR~7avzju$)>9&GM-5#&Vw>3lQN>i& za)gidxfODc6nt!7^xd_+=mqEcs`eh4JS+9$kxzoZi1l}u^3EWzlbk*Xi>Oash*Wd+ zD*zei^EJyEhw?u0UZs`c;wxd}0HruI&u@15lW}{Hm^vHo~5lKnXR@|81^o>?+;&mu!xkQ75oDpEh$ywl}u{cGET_bxv93)-lU!gK9%M7Xo^Lq{m9aKjRO+AFNg`bI&Cw&T`)SJYddV+b_x` zQRcm#Mj^)~5lXCs7LkN^IgHI=DfiyV73+{62793NY>){qcb|E!#l>gQt=tWo<>mR> z2#eFo{u(G$RPy6FlPsJd|0+{{E5pye6#M-n?Ayg$P2sk8#KThMqlR?8c_m#*c zcC7I16V~T79G#u20=mrI(@IPC)5HW&TI<~x1M6#F8_?5Q03yd7OxJckF3hka$fm^c zgJ+k~4a7Ln8Bw|^H%)r?k0-<1oGSJuvZrY_d*Z-+--a;l{ODUrXon} zjk*^&at;9IM297@(y?2{-t7AFa1k*s5#Lq!vbL8~#qu3$dxrEcCqCNBt?}g7j^FL{ zM?SpPIBhr2KQGQ*``^mwnhSQOacl{s5*H99Cm>i!v+4q1X=3!*ut(MAtdOv%EnUh0@@lV`t!V&RxnA z0Q`L&)jx~To@98BQrzn7XZlx3LCwf;D{}0&Gwf?nJ?1WWh343rQ}t=o+VkvjR6!=?LLTx*y0b-^RI|Qr;FVdnGE2d|c)Pj7uWAkOqjGVY8maSsfZyaIqU=TZ z7LuPl){j2ompr?B0UQ1@wy(a(oz3~2k)@!LA>ge+xOH$# zmxGtnZbI+f^h3t?9Fe8p2`{YUN!et|{oHP2ZRIqU#kOY}H9j;syb` zgtuivswKpe8rfrN6XG6|J@hQ=&tqe3i6@VyW@xt-8KB41O+UYtPk6b{RF3JZDFo|p zCy+6A?w`IXUx3@dA-;>P;fATt79>_BKp2xE41aP0J(+m8gs^TkDA@lFia}bNt zE7-3lBJOL+e|l!$TXJ?M_@GJMYDw7xHOF4cRx#0hdSg58YqC=QJ zmHEYyuKu$r0aN{*ig1ll3ao(b5G1PhgJ@|#!9O~;PZgHk5|B#t1-k+COQ2|z;7oK^ zpX63>kW%>$ncAy!wIzek=Trd1+6MS*xDpM3bVNReF_LTtxE}f(w$n4 z^W6wgyud=fm;;&10P>cH@3$`C43)K;p-KHz4{j5q>hX0HsWu6`{J8WSX^AXQjzOz{ zBCPlZF{SMNcqSH*-w1QJdM570(i5s5h|*^-zaM#%w!LYm)EQX%>8jUi?C09;w3Xby zHS6aEZ;SfG?@y1L*Y|GD&+fq6jRk;wMlA;5VfA7x`Xms><)E?%`C(oe3YHoM4wl^^go zyfhy_adsEIJ?F3ZSDsQRbA~ao_57X^&NToP!PCNDV|4XxMiUPFt ze|S{t|M2kcgIoXq=E;9Q1>mNxxHE%ndpWulTA-5-Xwd-Q*!k~ge^_+}7}S4%Q5lHdW(WOKRvJ{Yw)6*DA82aMEi`6Wyor>@RsVg}!C; zbfVU+X=U~vM_Xo5guyoTO@85(22sEVbi3X-sb`n{G$~|4R+Cwx4NHHjn?s9N+aWRMHfQrFSQ#Ak$mNV0XCdl`L_hij0MiLMS zVy0*Tx+#g&o1;ud1pNxl0W%g9n42)W8cUfeXIZ$wB7J@X=^n|I4PXP5H0g3kug=X4 zl(v8`IyJi%S+Xcf(xk|^%vKY~EDh2NwE>mlWD(m?8CoJ<<-~sWFp9uYm_bu7*omr5 zSeFRqWnJm;JW@=yfXZ=d-^jNEz0@vr=bWW;C5^yOAE8b`Ei;SEZd)DM$3%y*`Z1JV zk$8H}P%fL#L=NMtCGt<7O=ETJ+PMUpPxpgoVFH~jPw_yWngd{5KyDbznXjiuTSL>+ z$0xGM<$0Pb-bfD8*^IecX=HE*KB!k{kP0OgSNmpKgik@oN-#~a!6qy<81oXwPozES zD4?2qoRb-0M{Uaq88v#|!8>Tn8G{hQoMsKjRAI%6c7;zq@0xc(FVGO`p@PXOgSpI$ zM zE&R6R-=gTkGR`@u1J=N736=oRL-vI&J&$d{e)Zh5U8`g{jmWYS(kf|M#JQP*-ilDH zTOH$pV@4gtaROlr@z~TC&^$u3qDE!2D{72q>A%i>+mV07a5a3SY`tZAu{V!CMsu8- zl10~>eQbNP!zif_upz)nZ)Gd$Tdp?-<#0d2kX0Ts{JcvxKcSm#)iN+&j$?36z|7hL zbnrUZ8Rc#wdcB0pC?J$b_zxZ^O-onWW&%=x^FN=ms&z=llrlW4isD0A&5w>eJK zPcYab{16$=r^sF~kJX1+IE_=HlrrnGG|w@3NU)~n7!$I*PM6AD2A52+3m$6}^a47N z5O637c}~pgO8)|*mUvFX#4_+WvS<>;AM5?J4i3PbAo_?mrYJGdCnZ1XP3jD05ub`D zqDTer*ReI4lK$c%L~#)>WdtKLo!~L0f9DpN*qE#Kr*>dQbPgz+2&9>0ugC(sNEsy) z%4J^VMHdNJQ1PaGRCEmQ1g}x7SS zFf>K4FYvl%%(Z8{b+e(?7px!n3dMLYi*y?CJQOW({b(94k<%y2$P1O8_l*@fiV3B^ zY@YLZ-KwD2i8U6yZA#&xsa{yrm8i=o$N&q{JO%TCv%*p*@SPB25G7yIL_;i(8U2OS ziE2P3Ovg1W_e8V5_MGdTlzkJjV3m|1i=bUnX$Ex)B$n|^hww!BtpCD($XD!wJSURb z@#sVys}XTkg%OUPHePc2la}owvpxKdIA_wRkERHU0%r}eBnYc!Ba|cyW_E2NqZnzT%Wx1Ncg5xnJnsx`2Cn z_&J5IY)$LodgZ5~1ys+ntbM`s+E2L&IL|^#ZGd^DWYiFnw$!Fa1e@oo=@ zciS+$zCn1aLGbd&ECau*zFb#;-4&1FN|3u+?%oU7u1~04FF-pn?fyY_{X^_30d{#B z6yUm2d7%}ou1IRlA$6s6)gzd$S14UQkgoh%tvu^3z;l)bZvvbvUF&{bRs8|xsu!%0 zm#Vl_9YAuW3)d$+t}!^SS7=;0Fs^d(n*id9OWzR;SGxSG!r)d0!R6Gqt#!lSy0Mlv zkhcxyZG(B+VBWrR%v(!)WNTC8DoD3#oZH%LjUCO59ZZb`bDqxfn95a zT$9RKuc)<^F>9@@YK@UQD?nl&a6Uye`vGzGW}LFgqgX+4bWv4O}PeHR=SlvV#<0$lvVPU#z?X@=7h#5 zveI?z5kJ-=daOU}*!qxTHO7lo5n~(3m>n`kY0rzpw|*^wV;k&Ohx~6Ymm@UcU}wuQ zR(%|bpDan>>heDx-!J5Uy}QZ(`laN5&SZrYn_ZFjIZqA}*d#dDi~oN6;@#=17q8Es z{NOx!dpdAloqqTIx%2G%C$GPI;rw{;#(DDke><<=oS!~>K`dbEO#)=Jwp7ugGfSRm zbD;Jl(YZ|2#dqtmr8d(4%D|=lpZ{Vi$eQ#2@LnPR>%;q-{ExSK{(p7;UO3jo2lmS4#tEkg&Pim%Lh@P1kqqn|on_k=8(dEamk|Q<2e>Xn# zB~b9X{O=D6^`Gt^KHTJg{=&|G8wS8-hzurRt8OG1L9WzMZAZ;{K?`QkjumWm;{?6% zf!n$NFWhnLPXrq%4pa;-SJAv%{g;^o!$7mRmO41|>lhpZHC^aY9&3k0b^Kn4|k}owDH7c0ECc^G(jQ{J#^jEF_d35() zf&bqE^ z*Dh`Lc&~K1SbDftsxK9~ciP@8^b$=G{-O&ee_PnMF>}N0VDXlBug^+3$u{=$t4QG~ zmJGpJCk{f}co5!4M7euY>3&a6oS(M+H)q@bz~4U&wsw&$tFE~XkMG6)2P-V@1o*2= z((l6m5APip>OUSGA8z>n7h?aiNU=MtN=>hqt{J2sE`4{qIN)EImx0h z9uQli_-rzRLqBky6EFigMz(c(aV10KBe_8}1ah7E38XteoO!XImc6U{{l*wW{d=qW zA!6_1Tgm(0c^7X8b^K_Dzpx8J&L1;dhy}Yt=<0F9%0UZng7|F)-UaRr0xYL$#sbtW zLKGENWI%{Rl=xIGDTHA%LQ6eWNNH6Xac$s^BnHhahawgo<8GH&EuNu{+eu-(1LLJ2 zjqwGLEhq3w;9aEqX|x~vm$S6;y*Lew_vm?i5gRDHSC>NWhx~f_gjcdmSJb%AH~1!W zlyab=GrTyx0ZI{3vM^%fgpOcUMoD@Tcth3eNVc|fJ@Y3s#E~WDwo2t^#M4vsJ|b?Q zy4)Q)r>Rjz1iyDLT^#(C?^1(E5oV}k5*eF!0qRuZ5O7O`Zm1E;CJEsd5;{y!or^6J3;M_!7>nT$a38!J{q5zQf^}Act`hW-%0+HNFu)NQe~$HH0^OP-Js!Juegz zt<+W`3}&Rfu$cpcq%Pl~5BWFEr>8*Y@?rct|7_jScxJBFD#yYI=Qe{RGrJLc<0Y&( z?-M&eRj#>nE;ecejvyHNieQo)eM0Y^Ku6x>!#MhQz>!x9Q%G?@YBL3>(4{DLeKJN2 zQ5T&VFt=pHr$z)`Xr6}bmD$*9ofRWD7UR!v|7|Ap zw^00>$^VBB^YZ`E!-w}a{{M~q-?#j~k^<0K1E^O44i22}Jh<82V5_}0056}vdH&|a z`7wg)l_TsEj{e76lvD*`-@o;vFo?G5GL!Y8FjS}udt2Q^pAL#bH#MP~l382j+ zZS|}g^r;y1t`;;>3i2UOh7eQ;+%Nmb)A7G!hajlzq#OVCHM9s zzlGFS5%>EP_WKj{8wmQV(02#nej`2q$J+m|y>W9NU>*MVuz>$KdU*eMBmaL{_}`2a zJBd9nByuoC`)_nBf6hVD6zQBe(wFyU?x$L=9XKDn8%+9WGjKoRVeZ^9$8=|Rh;K^) zHD}5GgaT}DjZqY)YytlFbu{%>@HEQf^ZBhF&GElQova`KADsXDMgQN?Cja|wo_|Bh zw_$w;79dyy{BJ?IR$4LPvrvGfzfeBxip$N)Emj;=b{GRaid%)wmYko@%mQlgpO!)Q zZT~xbcywH}{~d15|CfOOtU`L)(V^?Zf41Z%UD!$15VtC4Ss614%`--FeK^Oa@_#}S zA9Dqk{(6)DA08I*KZl$6-!H}fFQ9+C=~nQcpocZV|G4#?UzK5eK>5M1{PYGP%L9i= z^Kae_CT#i94}vFDg+PA7f)eVB=l=AG`62fsN{6=Rchk z7Ogo}kzcBkC5!{Os`#+N`EQH?tUmw8M@9Yr?$N!C{(rmrKU478O_BKd|+fsG@E%bU!*s=Cf<3ID4sszq(}oNghLlUuIe4$ zvCZg_b&QWKFg+&nCkCfMiDT-)Z!^wCzwM^tL+R_($@gwJ4ZIj$rv$N}T3AO`nD%2M z^;1dvb{ru{D^_=55v^o0(vz}z^P`H(W@~+$9QWIk(zzFUHdt}H*D@@tBSo;KsR-BG2PxEtI%8`RE!2>z$^(r;%2sJZ_SA3Vt4|A+TC z`2Vlt{%ai2-c&c*b>^wpxe;7J-1< zqR}T?U1ft#l0j#=pqo^%wSrKvqDZi+K%n;f!oOT{y}zy2BEsh0{7>`$TSS4z7=XI` zpBDcgeA?XqxAD*7rRVL<`ROZs=D#cVePiR>m*c{}9sfU1yAuMSCjZOBhYyPN-|ybt zod4VR|M!;vi@azc(<*6}WO#Rq8Py;C`suNFtW06hkJgzEWlJp$b?q<)rc7LP1wP4m zBXrM?+ZfKEO$HO2lG~%gGCr=eq^;6UUW8t8O#L+_#Zh}Jc8#u;DXshPN(AQ|i3oZf zaFLDMgq@VLZN9jw@gMfL+af?}>_2z!9~SIC$48s^uP*`r@!9i#fv)N$0kG?RQTKiy z>qPHUsiVJWfn}c`IvBg*K1V(^yoOrbL)EuXyKAT;I7OOi*-3{>gz_UGzEzmy{1DfhVpTw3Mh>~GORCPCM0Z)pIWfDF1VZ${72hEUuhDki~o99xc~1R z-QC>(UmyOXBLi581GGK=T^WG&_zT5pXNU7-In~&X=G;JJP;)d_D5_}vxZ!)e|Is*M z^$FGGf4*C=|2=qkyz&2h$@0J0>jr9oq>2vOfOxe+DKLkznr>hc?|+htu!i$rPy_1D z|AV`^^MCh{a>8xS|1IAC%`>{y-14oA0%f;whQHXPapT(mw(j2VEKwIJ`gg|~R+ay= zOVrW+cduCg?ZGDg^LKszS7ZPy9siBhulD{IPyk{B<-5~M{l~)xmimuW6Ml34Z|(f| zjsNOy25g7S%JPAnAF_N913iXY0^_thM7JkqSnxkB5B;k3{|+A>TjIZu9&FD4ZTz$2 zf7-%y)&?8u%uTwmlb)E#3Ta=;Pu;rmz;wI++rhxw*MGlvw9)^+>ifTq)6RZ?KXsG{ z>~1Le;k92TLVnK`hzcUQDCjR5_Ua|h;{RbQT@8V&-I`Gh8X~Bv#ebhlXEQdBK+l3BD2rp{QD%(?c{nKKf^cyjHB)9Csv zx=2sM#WKb4rEl)I(|^JSzw;9R7w^uitpQ2lISng7;*QJ8JGf?d4;@&unSTj8o+%Ou z?Bb~#!-_9pMhR^1DeiT7GMR=feUo!;{r3MhOutPGa1H;bT(3F(AO5r9|6dmWFP1c0 z7w=bf&3fhgMv<`9c)n<84g9`0Zl5m%rZ|0N8L&DYPaM8Af8X%f-v|GncBuT(%>R40 zQ2+PAy$2il|5pBqWc}x7&ODkf11xQrM8T32ca>K_W)mPz_wKc$xChxy6MS~zrf?aA z4i+jEP#LJL4E_JLIXh>BHwwgld8(B{(@$oIKC~MRI{U~X} z4r=Xx1^@5Sy}KLy$HxBG*8V5ya-$-y*Zzm=+_?Hj&G|oiaR2D2nE&r+WB&&>T!{LO2S_woiXWA*@go1ka=EF!ROx#kQ?& zk*^G-a+we<+cFyy;2#^}I;CCf-5r*FlUtWC zOskxoe`_ug54ihguHwLux^NAeI$?j-(D8@imARdHlO^#(M6Pr=t zdag82pDZp3`Re3(vQ*dYScq`o*ysS@XR6fFjaN5%{ zn^zefoh`hHuT)klFUbZ?G=?g+4n1ze6KLkct?(znU+CHVoX-lYe*9nn*N6k#9Kptc zXqP6=WdN!*EZqFI86m45)?#sC^hJz4KB`$Q?jk(~0&RDWR z+!TXc+fe`UgQ5=;6e2jQ(KS9u*4>1-Mj>S*9NFBv`_4!)xDk9J1H=`MJ2F{?JOPE7 z812yM!ZyrvsLVX_P2&ZV;pg9zj|+(>p$Bn#Q{-eo9sn~wt8WQn&Av4ay$ zTOJ6me}Vo!II}bbW%uA<3QHcK6={f8d~XQ!e~@|mVCjGGQ}|z)DNJ;txcY&dvfRku zESx`odw(ipj3SPKsK%hDVFzY;{GgIq=7+=qnl?$JhB@rZYCXh;YSGx%f2J_~uErS_n^_*`{8fi?8*bInxLKLu&Agq%sg};t) zn$>5^Lg;EjC&>9Qq8oIsc&kV_Am7W!UTX&tAFuT4&DlETA4BFr&vS8J>Y$6s{rg}4 zPr}q2rbeTDTjg354M`E5-f=JmIA086sj{sI-83(;z$q22@nr=Ek{`^=@t&6zF*4!- z?a~}gJD7>v50bS>8?ba%2?M^r4XL6=1*wfu75cFZXPk--M+8to=oik3^0p=eqQpROub za*EQ^Wjhn4vYNQg64^+0k4HEuSZ72L5llxPK*f{Z;I3Z#i8siPpVH(Kd$z zcn4nT#()jWgd1EVUnt#P+{*D1IJOWk(V&y$yO)wwF~+25s?doyo1Sw*1YA7YiXy zwlRxh{}&h&uwTTCITN&#kPXX!JA+d`;Au)A8?_D82Q6HBF_BJ?lbhbgk@L#^IB?zu zOSY;#@#9_83<6YCCoZflbSfD$^%W5};vRF&@t4RYq2XO#$Otzskk4b&NXoGbr_yM7 zU@oR{5bFrKEt78~2sF|!A6DK3rKVtYDU=(Fo7@jd{sE#60fxbjx!w;{b4{iNM z4X%M+eMsVHuEUR{DXF0^DLZ#VWU+a6J~g)gMPcs!+FZ=G{>sxXH$WQvE+Fa6@qc>C zz1Dv^x^JofhY{c#|NqAS-^u^K0Ra4m004S&|5rNy`*8i2IR3kM{@W!y7p3D3_kSb* z|CU45%9K-`KQjt||IXk5vR@GrphqY`J1l^G=n@gYpA;sbAt<02_|gFoAe!hA4A3VO zpaT#83Ai`IK^^S5;J zmu`?=|7Onpx>axH*l$41!j-YN)y;4x-s{7e-_n)ek0ZaC8^1p%ew_>7*oQvc_g{|l zKF`vstS^n(T;9iI9J9{u`l^%H&{bcE^z6w^zq*rNGR7_rdVSPach2|fn$J7t#Xx&> z%F7mecF3>A9lr)={90V`)sFbqH&?+W-HciE;(9N#jCOAK=5fud%&pGRUMF(YI@z1a z&RaEB^`0e>mEG!nIMr8isY^+@gFF59o#|`2uI{xtx?8%r_qLk5xGSw+7T30_ zGrEF<+m?j5WBhlE*za2Z@A3V+_Y3*Ij}MPG`TswI|F_QNI%r%b%$7AmW)rg{kH{pK zt8N9|GL{w;Ji+cqKAV7~3A5s)-KYo{PHOlMDdp3Hvhsx3#cTKK43+kGAa{95CJMO# zF@-gRi6+W9*#raxY-|qHn6q9Q#wKVdac-g|Xg-+5MoOzAZFo-Nl-@I$jGAgK}ihb3IjX;e4^whzjSsMtD5+?TK`iGr;hICy7-Pli5(A}m#DX0M`Eq=vrj-SKnd&Vz!)B{*b`?D?^lo#J&^w|E);TZ zXms}I8|SI<*&3onHo^oX0Ho=;pW*;KVbmqnOiTpCCC#Zlj-0>2?8Ib3n3B}72Q?AXo=y0R0JpcZM^Y-1FzrT3> z>X9d{;^q8IJfA zl#D-Qw@f*uV(i)2wCW?0`_m<^$Nnksiuu7fArU(DSi>xunyVDpgCx%^5F%$cJZL z*roLPWTh^NWEn{7t^sH%E`;EGJncQ8L6H1`YwqztT*DKe<+s^cX3!q(K}jsYYNu^9 znJnW(PSl`}2EZizL$pn%<_q>j$NPQ=(;)d8PaAPLcTTDe^)3(&5|(7Hnp%}2zjT8L zz9z0S_fi+=#6>U1Qdr^DjD#V3SOERB%=i&{*J$-I9O^x1_HfD0kQsbsS`XRukEW4~ zoDUgrQUG_s)EKzp;p!Xr()D#$e)7&+c!cGUw&{|-)DjDYhOIbgyaQcEWFKW^n%%4W zX$1dSAaTW_3*E@B8UPINTsC;dt-YYeisLB=7H{B1P`q}?GqB66AeaZGHuV!;ox;(o z5+(MX5t8wUG`K0rcJgGrKV=h#%L~D?mD(eynFCU|@r!BfUhV_=?}H4wP~nZ(ws{`* z|Cw+%e?2o!jbwjQFMv*QosmnESQcjG#Ze}4MWJ;bHTR%oE7gs%H5tbq#T&Fx;d<)8 zTbwhrN(Yk{1kLP7MoDV6fM*ZB)K?fe8sYNuCw|JqP}(W=Ca8wYQYnztc$t##0$qF| zdhY4X{BU^L+3OA*W464w2xMMHO{LRi%zm<*-D!Wq2o^;XWPE+=>Ll^-Uv-bO8A|dw zHq&PLGt&Z7;)#Q`j~H>5+vnnULcO7?LSl`!t!(|B=O)ne_3Wm=zvZst(T}}<@&O;; zlyP&kETd#K64nV%-njU_^2N9;eic|2CAV~d{kkEqEbvHR4nlS@m=EzAkzg3jZUK5| zg+&UlA#SHs?BI!<;TWWT^qz*9d@@Xr!!79#XWpXcWN-ks*5sLD#pM8#eMjBXAGGe@ zINP)yv{SOT&N;!%!^RU$%67Hv4t~bROqhEWr3zeX;iB0Y6)vpNAS)kXt8~F846E`q zF==K*uC0NSM8d2EPyO|bCxSvVu!Sg`U^Y`fO}J|{0mFV(vC^MNgjhtvIvvYj5itlq zv&4{Vt2ya3*z;zRf#DO9SLzP0{qE{wLhW`ok`e3(=IdFMm>kk%8md|OMa8IbrA^gO zbfHM5K!MC;3SB%ck!)o@;{0XVdc~j>A)QoFSx3n^e|8r!JybZn*zDMwxL5;^7e2o9 zQp4YbCy!ng#xVKf$YgBgl=_xm{tqP8g`Bz3uK30>?oW1un_;+vwKG3aaX%1t=@z!V zs?B`g7$(2k*JSlQAsU^fH-V@SSY_I5SR}L3jIsuhFV(>d`Xt zA}e3#RGyQvuFADU#3Ewh$^s83$RSS@miwmc;v(p&LQ?%z4+CWYH z4TR;0B|geo52NuudXr_!Fl(FyEiMMA2vK|HPK6pE_co(07)CK|fsaUsvKn)>RaOos zl&dE;MPf7D6xccNIZ!2G02k@;2+NM{el~21Hn}UZi-F3Y&Tvix zM<6CW8E28G*XS-qlXtS5%yi4?l8x~LVTw6!Ow)*OXvfo~$1o-&UP0kxTQRKNhDIRW@&f5Cn^&?S&PV7*1wmN! zu7EhT0#lPTP^QFdW6xE|oF+3rn8FxNoYztK8VCsm&PU4s`qQ?2VEZ4Bo20cW$@26j z6wtx{8e}kiZ22XPTts3C-p3d-v|K1_Jo#QLVx~r`m7p7xWdH1z5KG83c-Q4cGK!0W ztxr<8Lw>P3jj()_994qip`&0>|Q8A}$gY^w9a`3k&4aDRQDais_D`BfNq>lXN49Hf26Jh`v zk-z>q={eeGtO?dgPYUBTb0`KW!8l=YC-6$(U8HDOk4e<4dM{1`<2`yF zU&ICq?^RKxx;VTzMLV7V=b_Z7Edr9m=8pi36&3rF*=%&vxpE;3VIe0ynh)+WNFV6gp%>(^x1BI&B4M*mhaAg7~u?&b-`Aj z8-99R-hq-~P*YSl10IM2CU4RW&3%}f`c$e`0)wefkNy(U!UoYVF^ zPA#y(*i2JF$AsPqTPTRb3n56~FUYr0d07YV$}z1h#FuXl62JMtWVjac8~F}>$iHbm zJ$2&`@?rct|7`y|HPMzIW3RIIpe7f8BY3OnMK;{uZO5E}W7Os*YMhs-my}iQ+!|GO z2)j-GQ`t%w4>n(teCxNjHsxkxZgFMPaigWp8j-8kRz2BjakK4LWW=gAC~_^pX;Nxt zgcI`ZNfF9f*g|on^3O^sq*a=(P1WfB@_%*9{Z)_uzW4CKVZQ$7!+V?jUmN`QXT*O4 zUvd5rdzbj2P^`CFfWnJ`lU6`@X^>2RQ1C_>uD1GzfLDfZt`q#dZs_-|0KdiRtp)Vm zKjgas;M)vd?GNl-4fU3F+WUfei}uzA@m@2)yBB!(-qt#y-RlK*w}y3dZisS7H(e4S z^XBgfy5!WRSNNec`s+`zzlfPuFJf%lQ$u^a$y$IJ8%{w{%j-wyD*8SJ|#NOXgI zmx>PS0(-9$>b(x2cXya~?;!8BL%f>-yopNt2X}AK-CqZ~djs$Oox{6VLCkiz{+ps@ z%kTcqpzcaY_v(P|dN_9*Fn4Px_wNnM{X3@1Jc7Fhzr6~8yDNgb6NI}id0#8|b|H^T z-XADb$QH2eoKjg(AREHEp%tl*mgoEF2i@HZw!1oC(3())D`UFbBDyU(f?A`w*No*R zifx1AE*_|LV7SW=+!nf71A4n}?Dj1px65(crcHJ|nC*2Tw%3B!UI$uxy;$wrLTc+Y z(d$BKuM?wPo-wa<8y`roeVXQN} zX=QwMpXlnou+_aHt9!*&_l~O0bAAJ-+l*heLQ*%YlGGhV-I)({z)!b9PdCR-ue={E z@zN~NkXb9S-_ff9r<)Br2bgY@@wQdA+g0-(w?$3cb^Qv&bbc8!Xz6zAQGuCW@4{3; zrh5TQ*Q}}qENxj<11Q~kT}$E8w*1PgVWyiQrrY7A%kaOg@;_gDbSQv? z@v8`Tz5rsRqNBri-bK~hrvkb8I7g;1n6803G%BHrlPND*IrBf)L=;HU|GmT$ zl$`$ycO3f@KKpX+f}=?FQ!t1IjG+4b-#xnfFn|8<9zVRhIscyn|3CFEz3?0f0lh2F z&Ig&80G1=g&Nm1Ks7~fulgY~TVm><`f+lwN_|MgOP@Co_?H?*cGt~woYohp?`R0+P zdd{EnD`|mA0Bf5fJnGSkYg?03t_(n6Ys3Hs*l|0b9$O|>aJ2U|vq}%PF%paGS~;C@ z0;J>7U#P-uTQY4ii_D66`mFqFPnBdRo0tiqUzc(VR6c7BrH&rp;i+`nGdYd9m@FeY(k-<;<{88+-AKG0VB` zEJQZYH1^mW@jDN}yJ>Rz>IKL*pno+O{HLv~RQIzGa!#`R`;kGWj|&2ke5pntst^h` zA(vlS{AUZHuT>amjr{izV?DF--{FG?4>t1OZTv&o$C(6JAq^2#LLB@RI0s3BxqWXG z^aXk(CXw?NjH^|HC3`^rOZ$!F_cJ|&1B3qfYIwk%*tt2ikB_r z%TDWADOom=D_cmFZNb9)f3HNAi;+enw|^1cE}$ z0G>H?Cqp$BSS?V3X=YyH$!J2X&^XMEsI=EE!f*zw*g`H|Nh;2HJ$e$5Ys~j;h2)}5 zx!#oBT3EJw1WbraYvFkX(Rnq&nMYorPBLb0ttM0F1!VgTnGvdYw%@%z%Slq(1GeqU z^MNrrCybYbuf30xU^%7Io#ZP^PMn{%{WoXZ{}`Yl6Ry%K5q`b)ztcBo?QMVc^54CO z_Y3mhG1Y|J$bY|A`(IxsoiEN1__)pz*ly+(GTY;gP4KpCg0{lCJ&j={yCc1r!zC47 zN|~VJG%(I1T!OFs(4YIicu&0<&}tOpi>jPOWUiI(^G?1M~1^`p@!=vi7o1mA6*PKFS&{eZkhpm~i1wCKE47 zqWGpU!QG}szC=tfvBuZge=V)GP61dg|KGp=;C>~ z6{#kD=q7zCC%N^qugS)O{`FhXd)9Hjc7yznl$U5b#eg1Xq+{VIg`@}tlW876T<|gK zk1|S#03dEfhi!2D-3Lbn{P*Do|9z|e z9}0{xl`d-zcHa7|#|Lw#7l=cqQI}Sai)F}+TR$TMOmgk+%3zPbp8fam1*d4X25ay; z6}Auyoo*0`{hxnbVx!ynKf3qu;c@=_-#fm)iU0b{^&ieb)u7xG%_=>}+wgP}g@r;7 zwh{}u<4~&W2$PbRtYJYl~H_H{Jjk2r;^gwOt;tv={Zjp z3(t*FgO;OhTHyNv1SJusPU5LdFP8dgz*(Ez$xP)k@G%)dUHvFaaZ4QNIkxyR(vqXv zlb^O}0NelQQmD+IP_i?e0FJ%4;o{cUX1J@`obG#4$l5121rs#HrNvL1%&eVfsA-uv z8<}ilGLpq2kZG8^?!!B3q@U5!p>nR&fu!7VwsLjW2RY{zCa99Z;`CUerjOI6`RmFB zn3)TdxnhB=(v%MFmFrX1C9@PBatxJ~fyOpQHHq!fe(3!BU;nRQ82|p)|Hp%sgTyA` zzjtYL2`2Hw99jeXL%l2cP}$4(^viS5?~Q@UH?xED6Haao>u?=9Q7)x|k=P2GHEZ|r z914x8cHAJyzn&QHE4Eo^VMZz!9i)mj%*{~=HRR@G%hzD*(FLg1m>2RDu;3W~P&!iL zOryzCmE<_F)kT}7Daecm2bZ7%F2_TVQV!;#p?yPSv3$2Lj?BS0h{o_h0#6)3hlwfb z*?xZn-8o0YJqyT6OxLi*u?N zRH8QCi(3_mI4t6SfSV8SjhxKJIu!n|j21XYSD+L52- zE8n1LW7KXL6K&R9*Fw74Wh4$uxhAQIPQvNi)=yStt0$6}^V@iZ^ zU%y$D2PSF~wB`JtZW3}tzcSutYCxGLr*cnA$G5k7T$^8x!L>}%-QDOfGHC;qBvJa zKC0%X)4$7duJfw-BL2sZDZz4*aQQIRBpegjYC<`7B{`g>%c&o=qdBAppfa0hU?u9Z zDTQeA(ly0w)RyQ3`Oc7`H{h9*7Yy5vlMgQN?gS(sf&)>QKuZ;sxBS8hqQ@H{8 zqjdf8C({+Uf-kTxQ*}o_U|BlkTq%W){=d3GH}d_p7-BP@Uwfxt>oQ#x4!?%eX(Mho z%VOTx$=71Rn>zREi$9b(_KXbbYjWz<EDU>dvW3A z^YTmgU5mrpgWFD>>)UeKt;_IVyKE~t>gtxMos(|OF1j`jy4q!1(J@y`UyYq|Ya^V# z9dflxx|$=dmK2*h;ck6FEe^QawOY~f*0nsV)#=uPGq-lORSmR-qb+}_NGDtOq1QUs z8tFA19BW+^o3;+MMq}^lOf#jRt`4KNi_qPHw(3%pJI_AHI&^oKHCl%b&ay7#+}26f zY!6#I$hr)@onx#ko7N7e7GrGf467Sml_RX}xSBb@T6w_ghF|CX+S}^c^R+g=S4Xc` zy$rmfx2usAt<=*c-nDwU+WELzd${(tdh%{{@@?&Hwe)Ls@@f_5xT;61mY$k>vYJy) z6E9Yll2-9x$veHH_o}hh+1zWjiM{*M^8eTS|EBGeKh?+oKD>9YkpJoU;l}@WEB}aJ z0be*nv-eW4-(X3%G~N?f%U)d+l%(O^YsFbO8%cuCF5DDu@zBBI$x^0@J`S;MjMvAn zUCKA)0116`E_^H!54}=t-gFj6*YNwDXKsl70gsr9@!WB|I3{(T$B84rmw^qYexlea zw1m_PKg1|RFz=F?&B=m25uV1xzYN`U3DX>4gOu+a*yt-SjUyh-jPi2GOBT4Q6EDNm z$3zu=W`S@}2P$V)MF@38^t3H%Iv?)5YV`D4;(xKKd_#6n7yo~+nE(0s-owrL|2pFT zztFgUNuQgT|NphN^WPx$zxMneKe&JVppgIL-ti{?$Jd4b0W~f@mDUYCtYX|IH-Mly-JJ7{(Kg|MZW7R=L{^~E2F~ayG~HlMfpHXlAO({02T&RLNy;Cl z(RGNLFMqmluc8?AH1WwJ@qx1rfH;yy@eTcrzyR?_lD_jMm{L=IM1zNTC8l2zkXmhfQu2kpdG^|NQZnmq*BzBH-XRCRlyyVUC9o3 z)K-h?hXJ(RbWJl)=aw`?+ob?vWEb@`^OjswAFI+|F5x=fzxJo;%<;l2KaN7YV3Pqa zIXr_!ev*j`$HiFX++G|#aNw?XUb!FhGeF!#;HA(xoCk6)ps^8TfF$wQ$?3x)Bf`Lc z>rUa%sdwe0K?X*tz&<#PCkSrZPMq(Tb2oGpKXJZW`cn_5msG}uL$h9hBKy$Ah6r6U z^-$zLm|=_i>P+olsumA;#r}ZV-*|ZYJiL9J#lD!uZ{MGOcj7#!A2QSX)R+``TR$@; z;?0h-UOS4kzSWpApIP5FC{#4x6NPT5vkP^KJvdzxkv0Tu3W8Pk{wiSqxy%_!hEHX! za9JuaP4g(@rDzTynFDwG_(78^1Scbn`1sb*0H8o$zn=&`7+rhuEo6!rgNP$gqIkvw ztllQiSNqlaF#=JCjq2mbO`JJ$*eOp;HuL^eOQ1sBB~B%Je;#KBWAW;3?R>lGAK+MQH$g$RyJh5eX#UD_$mA^j)VttKm+;Bob-@p zU7Fk^vkCnlH8qS3r^+Y=t5iKnfx`` z43%1UNtP^Dx70C4BRwaMX`o&;M4NV8koGBHdR&a=tI!Z2+LbEWW@-5>YtR(^)yb`>@Flb9{5<#WASR zW;C7{CrTZoTvGlKNgE?eSmS7=)$2EJt6ZUr#7phboB1U&a!Zu0)bl(95amf&J@V?~ zs^E8QaoA1_7Ht?|2C^-}2f*M+q@BArrpW6C{w0}oppAI}i@q+(_!@FFWx7Z%&Ti)8C@4>e6gp=w9ezj? zt|;R~;r4TT%O5A`AxX;{PDswe;r=&2npoR`#lsEN>yZ7T*l=d@yeKY0;N^S#osnef zJS#yEN|Dc$JWxV;&n7E#awnIMo0PS!-3J@U=Fw^{PVZ>r#jokP~)+hyQ%eZWG+Sn+SNWQ z@<>QQry($TtwPGWdL!hc7k&)KlxvG-(l&!_FZp>W#nl3smrR(T#lT1KL z;3r2^*i_Lc$WtR#7Oy}sPj93j6DY*Q<&EIj=|N(R;@a7%Gv*O|LyQ9^1}o}Laqc7$ z?Mu(z4wDv&O?rU4LsCff>7oLe=2#M*CgzgC3XE_nj7&5{w_)ar{gLh5vMeN97HEK! z-(g_(&dGqVXe`AlqZVJvz`ZH!y3p%ODtO*4;dP!aCI8S9epb2cZIfSXFZI1EP;VL4pl0S?`x&4}80+7PWVPvO!@0hBXW6TbL1Cb0*6;GpmMLtZn zmfW+$6-%;I;0XG;5wP~T8nXlx6mi3)kOE^>8y`!P%6?uJ`hrtf96;Ornpr6P*A;Nk zF8)u?v`rjfHU9tpz5DkH`9BYjHu(Rq3je?5*dhSnTO$SlKIOEN!B(pjq3z=U8UzAx z&QKi;z@Jq(0Dsg00sL8q1c=WvD1bl8umJu35(e4vK4HPx4!*205SI4Mp=Jh z80$)s>w^66mEy`#|7Dcd9r6Ep@ctVrtfj)v2><@_G1s~-RqnBsA+)7SQ3IY`Q%2|> z!2F_v7C`>qSF^Q>tWyHjf%jL3?ynKJU&dW}2kqY$WIvyGvH;ibDMMcTLqy1 zMRAss1GEkCq*m32np*DX(%ywGdH-&ZR!-EsG|6w)%obKhw5%6WH3 z+Sl%UyEM$VI_nj%^&P?WYe*8j)d;&*xei}I2H{(p)@ROuyPipX{`9zO+N@r4=XL|g zH=b3`(D=4%)n6&2ZgBX$vUTYT2;UbBzRkkg;P2%A`fW`hc*pr9+V8dcRI3eZ0f67z zS_k;OSLnMKVo$L5deD1A@@fWl?~)F@4(Q$+n{EwnZ;WsMQWACB5Y+wRr|TRlozd*I zOnwCvF7yI2X;W0d3wl)_e^}^OiVsx%39c{Iep= z_qIL@y1bg4+oH;A%71l5l&=R*Zbg%e@%N1+?+-`5GK#z}40%Dyu;9lV*Ksqcu0PCp zksLeY#ico9O;~Z!{O7@mcSebKc7J!nhqpn8H^+u|tnypxs@}R~OnCl`9XNCt(C{^& z!fj*%<3C&Ehqsu}tR*B=h(TQovaFP~6;Yn5q$PFC-x$ck#tG?P%opj+8@_nHao%~r zI;W446xi4$DC#IUJT6N>PYe1z)t8AMoj-=xcSyoHv93HkvFm*E*tvq4l(lHi;2ZnI zesi|{a0c8jtnPmnig;l4v${)ldcG_G1^Fi_Mk2 zR)zjeW_~aQQ39S(XYHqL`K>K)R3h##bxI$-^(P-5spC0tw(~H-$B&!E9G**$qm&Wl zo(o-%J%)9eBpR#}>5JdW4Q?C3D=oh{7_?E;)65J;im&vzA#_8{#Z4qcUJS(Y?h`o$ zoHdKRzVHUZW~X{Zw3rC&!HbP}K~WuA%$3!8XW_;PBSNvQOD|T7oETk^I?g`1KO4!4 z(JgiD!@`72tM(MF1dn|kzd!Qvr7ocVsJK|3?wghnD zO>d;ZK0|xEMou&j`o{i`4XHaf5i^=}5iQQ6cTq&5Kk-$HiNW0rK|&@9OUQhrO#!GB z$pP!^A9~aRp~MaboI5v3<|ycoieyHp>+$(Eb7_-mdLEre3pE~|5;|W^1A)bGp@(9( zV3@-$8*MS--r;eNJ>hx~KOkx@<^GTPh zjT>e#Y?8@XCzl1TXY=0B4`hyv{AUebS3e$PKC8v4x{iA3$yn&RzCQJkZ%>~x1DcUE zIQDRPPQ2+dW=n>I^x;vrH>pnT(a<@MUBrq2mB1UR4_lXL4-%a%rBbm8Bf3&H6iwLB zDeQZavKXMy4=$=a6+mD%XAEHi8<%D@3pig z8Qe69=ALc{E{wqc;5nl+m}*K^o?QayOlDEU)+ihfwtu5L_2E6<58aT#BAJz5-GjEh zOLua!i*OV!V8`+|hy}|S2$05N-yLJ6gtd+Z2Q9=aS~A$@-rh0{yd>G%GkE&dJ>vr4 z=aBb>k=kJ5d|B0j9Y;u*`sI;`X?+>Em&s7uPf$ZMUOnZlLv!Lw|Kkg0i@3SECCMvy z7(qo`1BoUSxB3Ki4ni37LMd{flM+U{r62-LB32g7#3UEEnT?YqEm4<#I$Ms(0F^`+ z=`}pNKX<8S>4B-Q?(>=?2a9D89NfEi_@~{Ti&v;g?PrYm1)pAqY2mF!(=)D8NrdJ# z4MJU6!zk}rX^mfdx=i0MP@Z@WLR3{7<0T{5mh|*Ky3X{VR3vV7g4lh7pOkbpj?nif z9@<|LQR>gxUag&(6Wo?BzF%yYO)b-zYSB25)$^!aw(P)Lw63{p)iSB4_5<%C%^qL< zTbwf;ml1<{;|FN0tlwOhzGxk}-ⅈx)1Si5be%n@*xly-+@F2{ zQ)j4{pM0jxe=d9e?EAmM8mvMBD=k2M3UJUiQXF1|e*uQ|?TO7VR9I^zYSyaW6OB`e!ZeNoE6}YMmvjL;l)pS>s6){e zT^vzAZs$IhIAweL0oexRiTyD6i~J?#G$jrz^S6ku0{Y ztrI!Vwp%)o8(u^?$8jOn+wML#yt2RLT*(#}Z;g|;;O1@VQtqY47OrI-v@Ta*^_cc{ zu4V~+@8V|e#mQXfVqV$7ysCS-t#i2-*RtKQEH*3aR9@YsT* zr^O~4JAT5dN6}`ND?QYh)x&94a(XZRn9(SH~mu+6rle@Yrx2YqytsD1$ZT@|^ z@xRyJxLNdXjsN%X@c!X(A^!LHc;o-Qm48!j5|9!Kl74EO%M)jZGB2GtZ{~jbh`pve zcT9cv&hF3%2mavQL>I!S7<(6R$lu|!!*||A)!V0GLMnfrEj@Pr*k5Dh7?mx+|Gti< zo(hGP!mytEpKAdmlK8)V_(;k5zo5(}eD;k@Zp^3Ir>N|NwQQjJ{NF#i`yhY*@7{m- zU~~RINBy^{cj<-aNC+sMJUbsaXwy`CJ#bES>bwD)vXjJ{nuX&OQz%OeK%J4V*`JVP zLb3{hB-|9KfB>pG;G{N0zD$}1Quyt0i6e73n%8fV_34*@YaP%3|;%Qb4I3m==x<4xoPd&Ke+Lw*ZJ*C z8u^CfHB+?LzfzJexZ6%qW0>l^``Ooe8Q;5EolG;^Uv1EKSl8!%3`ZiP4ic;t zAgt0(UM*j7O#L)fl2xP>HC1-92$dy|Rw}AQtHb}dQuaRzA$x3EDmnbCbQ87WdR0Nn0)xn zO9GIh_J#Dmk7aB3sWR+iA37Mj;XZxaR86hJxf^2>M`>TAt(@qGn&`S*1e|mu zSsYwwy!=sWx%|o0dK;>}y(qmatGv}ZFRUgnle2I9xV5?}Y_?4apZ6WshqqQ&ku7br zN0-Y+Y_(RPNi3+=qUjMuEsw2(ATrqJeEozOG#9ju;@wW|Zl!d$QMoNsWL4Qwy7E@9 zO8M$4c^PSJbzKTidD_-T9&q~AZ_^)+Y@xD~Q^d zqOq!exlU$-2Ss7c)27e&hnF~;0q?o){{GWo8MByOlx_8I1K?9sv`PGgpomX`BkHG+ z%Z0$6nXhG@$8SKDjNKoo!aA7=)n8aBi2e{s2C z#&0EG+n=0#uzde)23>lV9}3;-Nfi6e|5ovi9!37g>`?N*7RK{jE?{vuxu444(1XeS zmK_WID0V)ZiAAMcA?t7u$p6p8=<(Ky=%vgD40baaI=@%z3|T#5shD$T%9T^{R~ z47IEdPU3ffthDRFe63+m9ZF#~pa5&3zubF8?mx&f|HrotswpwEtOZb0kN>cYrey-i z>imyKher>K`5!SS8^QzT4=2vqOTG^V>|>BQ)Y#Ys zJP^L?$vV~@NYc)8{U!LEd&k3+n#2lTw!RHxC7G>OHIDUZ8mqUBeT8_GS~OErlUPH8 zSQ~TLRv*@|)ofusSi*{?Fh;+TL@!Wsnb-I`!z3oKbH?AK-t(W*Fvg#>Y0PZ7XdE+t zE&ZYFX~RTj{*p72RSv+MSec>BHWcf7d1G1mpo+iS4Q6EnEBsjXR?cizJ}O~2vsljf z9jToQbY&3ezp@rIK?Bl;mi_pCk^91UMO)HxpUIY%`%YTZisrNeVQZrre^zESLB`6k z#-FumO?;NdHU22gYy61}Z1PWKV$)v;Bbz)WY-W?FR5G;j2gB61lC3Q#G}#5C4WMcR zsLD^ilbLP<#QMBItjzH?Kv*zYG{9W=2*B!M6Krh}Y>JC&Xy9ALJScEzXn@-4WGHN9 zCiGjw&Dyly(m=>l&!mUeiMY4=FdnkuU(gHN-Z}j+tsEXrm9~`lQ-6lV)p2vDZ_cVP zb0X`~1~<3)*Bt-HxrYzVRc10EkjohEGCEiv)26AP8s&d_cvOi0yMKJNvH#v8{%;Q% z1La0CEvzP1?@8#-DY<@$4K`CWri^T!M$;RG2U>{VPE^}N66OT7Cx{Zb2<$&zM)1?i zD87b!xcJ1UKbN{|I0=*(&7+`>v^^aN|`-bQL?(t#1{@2mN z2ge)#&u75@8X_A?1mq-WaNbLxXdB?!?+18hZZ{cr&n7c(PR5%uU=%&emIi@TgK6-# zr$HWSZ7V1C&1c24nZr9%dYdM546TK0$Dc4d4Q?V{!$>qZ^QTiU%r&$c)Qp;t!aeFv zXT+N|C>?%35k5UW#oI=j6aMDJcE}6xU9%Grmm>p_q`sE#b(;HKbRB|Pr%gl^-GCZ% zE`64{rlo5#GpFpA?WYC(oeBLo-=ET(+fU)Y*;krxQM*vvHV7@1jrzB;&VMUi*xxMV zQ@iuIA(YXBy=|*_v?;*)b?AR92z?FZzoQ4ohsXK+zeo4)-P`DYw<-T={cd0{0c)Ue z=+A+3ju`^r1@XJ#?QH@Qa!l8*5)uY^#x*Ehy#w1Fnt}RQH5cfga=Y(2BXd|I=WzI^ zhfeHasjMkwV;^DM|7o~brc^4FruW=U-H{{Mjcrn4L}!N+{jv%=qT}{W#UYsWd{ZdYm!F%{OF84Xiwc(l--O%u2+g(ZU1A0ZDT%q zRDRjzpNa}zdj~}ym}(5)R+BMux*NMF?kw`*Jd2iCY}Tj%No5(0Ybz@u$!?xS^ErxA z>INiq#fh_HRHr3lE9`&q+ezT@NQ*zE(dA{}JwwYl%7STxIjWOcbgk;X7RzKNLLsI?r=ju!Tng%y`jVW*XxgxR zb#8m%Vi^Q%Z#KSzgL*deJRyOj{6(KEgJt$&j?`SY3wP?NKYqru{PQ@1wVv~rfzLv{ z4sxQJXg{mTCV_8x&-gah8kPPskg~rYCHcqHSM*Ssx+^E6zbWVquwd^zpqGhP+@nRb z$gkD4zwi`0RBuR>7b_MD9ky-j&CsB%Yb{kOtM>HsGOIrQJgw26^djo7Vv7d#y$3rR zpDo8%aCxY4Asb_jP+JLPdx$9zXCRTx#oFli1p8waNo-7D8=3vgjAt}E`&4JJEHHDj{bE>~_&W72V>c~)WM-QC@)>PKttC70_SL1R;rSL$m_?%%BTv5+~b=+2jNBTuR^t4Ie^#NAO?eRgiy>%(;jH+R8_K zYAhi<)R3Rv5Hi$g?|6ka4U&E;YTWvqY7BklRF{Z~K#GcavuGJiv92+Y%`a4mDV<5V z_W~aI#TyMX%gb-Pq@Swl=o)3=bLBKTjXA;F4iU9;Kf$WDV&EXBU1Eh!SIP`XaaV9{ zQ?=w#l94AyLq|B=snYF;5JjWrlm)^ma52@~n~<*Hszop+hQ*!bnuyg;d&34ZL{5yedJh-#^c_`bk;4g{Z|noQSTj#(Yh%t-;8B^apW;J z0asR*F~#*AI;W|Gb79HXF!QUU@@vE=$_ce zs*~lq-&h-16j&9a0M)P!Q0-xqnZtO(>`34l%7DibM#G`}2>S92I`BhgCV7g$LgUMX zk}XboRbp>H#*e6&RMIiF9XcbX13srayHw{mUWSf1{&3&RTL&vh(ouuSP0eNz){;B? z%n{y%D8x3LspUXli^&tk)49H60Y}dMI*LEwT3^rLU0D{Js&kG7EXUzYl!@38jBQ9{ zF;C+xgJ4BJQzdqs)bWD>bxFfOR!6r~v=HTz6Pmmq>cePIspc_26(d8h809Oo6S1YJ zFycl|NZcesi)h}=M|69M;JSAH!c~$7R3BGW*Z!lcOp!januMYwsIi!ZvFS3-^2+Qf7fS`pEO%8jK`*1gHAMy@ zTges>WI+;cP%ivF_vijZmVd_{GQ>vB=J-yALCCkLe&ICo1R1gqEH#fV@*Opbgs=B0 zi1#Uyj9iaopy&WEa=itD*i_MPq^rVK0(SuNCJYLwTp-plIS(>Bzy}CMOR4&}BSGi- zY~(2zi!)w^5$GL5wAv*pR<67dWglh#qQWm*#`8tlsSu>h4p`&`e9*5kG!HZq&m>*m zgrqa!a_~(z^1X?c#LvtvqN`JoF|Cl~)b&(SKIY5J{x%%tdoJe=-NCScq6NuKg`%u= zB5w-qBLxZ?P!-|v$J{M6Hs}C$`#i#LNMvxaE%F$-Z2|8R)97(myJhDpiRclU;+Vy@Ck#!4pdPXb~+&4r7*$En9$!3L_^e+_XUu zAtGPpZ~uD6-=svZBmuymLOzF6qHd7)AvBjRp%o8=dL{8FJ}o7QE7uRG8F8yJN6oWz zF&J}Rz%Q@?Qv`xV@zelX?U%qNXZDQy%$VC*J#5E4o@LE5jJzjS;dp$1$#U91Pgv+N z0OmhCJHpr^5$ANF(S1QicjPe_ew?7Bl1xBobx}=%6@l-C2+j;-*I1Z^P?BI3MHzZ2 z?og>@^oST^rHe$2Z0rSa|4+Oal|$$KspIkGlQ=sakrh=I8 zBIr?yDOzPRL=!-dA#g2ksyDah(}? z7NOaT3tGmhf8`V9qJ`IN48cyKG5BjAo0?kM&Gb)K2)-0XmPd<{r>9p#F4iQ1g~AJT zDzA;v`_sImc(k1ebEKdJKlxttEklY}w_*+pbM-Og3QpJGPyvZ1OC{drci}II=Q+Rr z1y|_!}vHRep;GfqKVCc!I`aNcsO!)(8v?%-e zQzh>=RfRJz6$VEs?W0l#v?u7zdFQd$lQ|GBYQecq;d?fP8dL-{wZIiaZglKN$^>W6 z|K{Aik4x*0BeDeA`+rf{YSf{{dLF_B4;g8dV? z?pQZwvFOsH8xVP#mW`#+Rp?|KeVnuRMxlz6-r0e@u_2EhWe_Tl6+mOxmVQu$wY{UF z;X6BcKu+Mf4`Fm2R=vC8^rkYtx-dgC^16>nt~gM%y-9yN?ITZwh znH@}0fHM!BBSmH@ratoLo@E=j@5*h^*P9rt1qJn~*X@x$3Bo533l(ZregZ|TL*+~= zpb>qH-@Qm-)CM*9iGWfSM=nerjNGamGzRuJ9$m-|UUdC5Sm$zT(u%sHsh=fAzDr)0 zDWqf(M-9nf!RR7j+R=(966TV$l#yn-L!jWD-IhFCl7Sw~GafoE>nbZ}u2%`GkW2t? z@|%hUrKQVcf)Fc~S+ABB#3;-(#l#eRamTsHZ9%;lDV*F$+7E=w30BI7R?t-$R-S%( z`#C+vr@86GK~nKPW@pTvR#4r6w$+8-b}=ew2sS!bv-bFd&VWOs_MnJJu#h&_qFD>R zj}S5k=L+p>kE_8%-$&O9bffR8nDDANS2tuko|LW_wUkpn(6F5m0^1t5oPFmGy4>$L z|Nhti;oI7~&JdTdk~{d6cB>7|_oGFF*AZ%yfBQfzD`9k%pZ?nu8+f<+zWO;{n30HlE=*!U>xDDa``_=W#_Y zh8NLr5%>wn^y=7K+Jh4+Ev3a3KhfNQqRT*+^O28W7+7Miy7G!!C+zGnuh?}C#5vwU z9&MqNYk*ILCFj7IyC41ea;}s+`d$tHe+%dh z-ui3Uv+T7@g#0)eDJZEoD z+BxvT@ZrpR!j%Sd*xR!>;bJnbgKP^Ta->ok;ydhDMmU$Bo<{;e2)aJ}j>#McGPZ%g zr#CzSSEo7f!sJj^VijQmcx;6moNqgvYrZdi1=9%qkn*6x#$p9*SmP-zD6A0b|M(}l zBglj0kQF~aIcW|s`2Bq%lw=t% zf~62e5vJzR70l2Eq6WJ0Bs$JY7x(}zyWrY?gz;!c5%k4K+(d&;^8VC4XM65V{pEZc zE60etG^CDUJ-;%{aecYuzLY6U)jbys|3v0YpbnIMVj^F!@DZfpR~K}vo&e3Js5-GJaLOS0xkUsrwv85NILDjzcfr}qi7BeqN>Dwyo2e^A_} zc}O`ZRqXO_YFBhXVl~7c?|#-u1dAqQM&Mn!(xRj|3YI~@H0bz*K+Xyj6$0c<@C!Yj zpZIG~W2QH-ef|V@8?9~FlAj8rHwQ(@f*6RvCj>v*M+omqaUa0Z?RbXFvL4^7&~ctv z?7uYN7jo7W!F?q#Jm$FKx&wj3WdsH7MJx}3v;i{)srgRTPIX*VqM0OpMSeS{c&C)A)u#r-ax>4XwX5P$YdJ9}1&zKlCm| ztRGRhZyksnAt>>RC-5u=a1avx8sblGwDybq@#T!;bQ82)Z!*NJuLUbNQh8sdIl&E zt|B!vm*z$?Fiw8#@yttE_UT0q;mG4bb#_TY(8PKp&Qg8vH}1&!P9ATq5GO7vmtjNWLtZ-$pc9NYwz zBsf(H-tVgHx6k6)aGuqC6>HOWmDf z9$^9ZHKJO5sVE$sEf^DO%!zSpsSwy;$uJt0!c?=qSnFj$XKrAP#?FjVn;6{S+PzUg za*Is;1C#apkQqD^;3;x6^IFx(Q1HaABwY*MZ7fNJtjOEQuCQnUoO!wy>1ciYJpN4l z97ZpgtMIHyekTG-vQ`o5O{}^fLQ}AhH!Q1zu&^Uo->$R(Rgvf+vU5I8u_wqbQPB(X zzSg^+@bXzG^OOftMv;;b+vp-P!odvB66)00oB3>f#yEo%r1hu31{jrw3780>zAOUs zRp5eBGX}+l#Uv0a=4f`4V0IlHNJft%@hYltSE*^`A9(+S^J`OEm*KM7GoO&;cxIv$(|@*cFeflv5d#S z_f@c6s&jt`*MKAxSnx zF;N5AS1vaBg>=mI=xK0t*yGml$sxfl3)pfUjbhB{#lYOi5LMg;G+# zaQtTC@Dqs{f?>tL!@ZAwN>N2}sG=cZVfDg5%0;M<4Ke?q7qEfXS@;DcAJ-~~Y1&`mt zzY+sPf?=7-l=!x1@$ma`0$OgYDO!FmZD~8w34`MsWWXMBx+VsaG__rO_lzN^>76=7`=IGRgLbmn46zwpw7~!1 z>Gm`VijPBbJ5$VLRa|jondz@QGSsFtfGiudf(!&W2>z)k)C~(HbRbjq8QH1O8E#2` z*&dTO_N2yWC|v6S+hJFNMTU4G2?0|;rq}^%$YG{zqAx<(l5z5AHV9{90Z=_^pzbjM z_X_j%xE(dmGs*g41eW1d|i1C)8BI;D~^XThneCN0<3p=U8vSGb$~bX!CzkKUhB z`csn)QVPN+DlXH6WU+9GVhZq3*dL)dj5xti1`Cd$%~li2r*&;NH)6MV+=QOWyW>qF z=@Ck@@Ln`C;p3D&%u}ipk59#gvw4SOM=V;n2ro1x6cOGIXNps=kj)vB zKb&5ujg$EaMe0C-XT$f*VP2v{%930q@)*-hnTwfChs#T;LeuY9HvJi&Fj3X#4lgT5 zTgx+obb4)Ok|Efin2siT>oCr(n_D>|R53b_sz^-s0p_v*HyGAS^+sQdr0hmbde_eu zJ0F;=hfb<$OIAN|o|3pgO^E7&%Q$nwL(h6rB_Q?&ZD^7QX8_AGTXKV0yw%UHzJ$g# zW%eO&s$y2@Mggma)kAak9S{M1wB5=meYWE~b>oqCp=%A_gt8_AH0SxG0FO|N9XVv2 z#~WqDGnO{9Tx^LR3#DQmW)ruH#BgjGCJ6!W`PieRLdLapk(-zr=Q}A86Mhj#xkJh8 z0*X}Lbw-rgVxp40?z7%zE|D^mBZv(1FvX5W6gn4vAuVBvQDUm|qEXltyMQV-otNK~ zPqZOfW#OhJ-rj`RTSDv=1($@^Cz;=yht_8-qnc1^b35HCEmoPta?@`WP;TQQoJ6@H z2l*^Vl9O@BO{?-{bh~nk)uScbDI$|)iGh*09|sC@P?8Ss#1XdOk`$-ncaeuFqXeJWDmI9d12?)Q zbw{bDYtwIyxu9qz7eMF<^2gxD%u1wcDp=pqKLZ)>$Vi3e2%$_>2b8odWgaS%nK32t zwl76I7uKNx<_PSG)V5@o{h~n%+sGrI9PgF?>*R^vKzXy-Q#%0VbW!;K|%@tlTxsat(6nrjAjdl^L>tS6D9q$LZ4RW1RgnxN1sGKK=quy4a` z3Bi_`8kzhN(9H?W1o$`y2ALv%4iW@RFfN2l1raO=*)j}JzIu^WeC0NT?fUE4KCyR- zY^=x88%b9Yxx#At5`zlSk%~m4hLSAD|HQOp=T2f5l4XgXh+3@_Y|lihzwnPut=C|&`Ym%hKn_rbn^_e? zKtO}){OWb4hOb(VdILl?V?r08YSX9=Y6Isf*~yMU`Vn4$|uX=Vb7 zD#MU*p)BQL1eYz^Jl@Np%9&vmG0~YkB(Ih!SGK%(Lk#GV4!7r^db3Pe*$EZa6zl06 z$mQ?lthUeCfWc(!TfsZMkcV7mVt7f9>5Lgb**)cBFtZrcu6FVD&{70Q#U0j^398Y; zLlRpDy7mROk$BqD$j&(B}a41INU z{`VJG!ek4o>5v)L`QNeWzj-%Ch9KjXNw40VWwr`E-0+$Z)9~FVQq*k94~(_r97CKy zxB7aq889N^kE$k;%nC#|sz4Ty>y9%VUl>`ihJ}(poa5*;d`9nK@6EISa!mquGh}K! zsXTgRy*4W}l*jT@EfLnY2=OJ{mc|+ktZ4>N@Os90=2JPNmO)a*-nA7@;{8M!iv|D~3wnqI zOv1`y{yXQ+2Qp=oGbXZ z#msdcIY(b!p6QAeQ=@>j$`_@qTC=wpbk1D<2Vlhm@%KB@yKp}InRX*vEUTM{V&}$` zvRuP`qcF2wB7Rx~a&wNs3E_lD(t#Vv00Xf(k2x@=2t&s)+QP0~0cKAncP8aR<3f2b zxD)gQkpn3KUYzC-(36!6`Y>kAMKG!%#VjjMkWxyE`7n2#Yg3W360MNbN?pi!A{~My zhL>++VJ8vAbB^xS-&HoDBg-mU;`K~+L3UFFcAlybL?f60y;LyTlTyxQ<#Kj*#%DIq zaK4e;nL-pgfov#~$xSXl`A=pENUZv6EhYSqo%Wf;wJd{Y3FpI0DMS#ognt{j;pNi3 z^uCG4|D5dZS`x}DgB=<^iBwSK&$4orfJD1c_{0ok@Te$#j6}@<)(K@Q7JyY-32mBG?I>F!U{;D$OI4K(NK?dlWO;w~70?FGOgbitR{c0tzvOYY}CipM?R@%ZR&nj9U5m zq`stit7ki&Hg?MtKa}7~FNNF$vcd{yI*CoBd>d(ja-}v(X7IGDrSO?BfqBNHat5&6 zz6_nG8o9ERndK`q)<#>iI%d(zKS<27@nyFd)*-zT{ZF#HfsA;2MgH8DSK?W#*%=stCU(iMAPjfkP?Z;y`%&gTmWLGi0lNmodS~_s=9zUD$#4gNO0fq zJ}X#cqNzq*Rl>i4RAiJbmaUXA~DT{a8tuItz>wzF=wtLN<^ zv-3Lk%S^((*|7dLjN?{2?pRG?)~wcsY&+FaEW;vNzEyaO5N=^T5gQSs;ZV2&b%a(P zU7XV&Rq&Ktabd7hX`LrBoI>RPQu!ZM!`iniujy>)Oj~^W-kvU%;RbtqcAPtkm^qv~ z-nEpoP@vmwMBX6<#Aq3+?0QtdJ9Lb13|L^Hez*uZ+ZUCDnA?^M`68O9NJAEZG*Z~sExEK86D`V1Q2=W|l)qQYJ(6XA z@@=f$?zY2{r@N2CVL~J*VNDTSfV8Z3D`MZ_oPWO8+b21Bt1Aj1D9N&WMvmQS5h&Cp zD=RB2EAy8W?jI;hC@8qkdnPXI^Apu&3YB#_v*;kMo@IHT6B_91=oiRrOc?_jN_`Bi z28*GaYPAk5Zbauv#4wp_=f@#D$GYY~AD1?X?=81THYItUF!^DMe(LjiMe7?5YiBLc z4;X|4`x?3|YG?Rt5R7-Z9+50?qI?)rh5*c2Zg+X@b?ttYWxTQ4OfcXKHCL2QY&mRu zL(>YNWQbsy)ULCl$njosU*RX=tP<4}>RBQLMOj08sq9&Wqxac&$6^%dp`FJSi9}o$ zr{X1jDg9MnK))0o{d2v-xIZxW@m3zfHs86))v=Of+rGTf7~SZU!gqSe z1g1v!1hI@JnE=!1@(8P%pDHL;FZ-?8z463?m3ZA#c-z{Xs6y#1%UNzb(kTSf$Ej)H zZ~=rh4nB`5U+dLp$&!R_GLpIaWzvLj+*0%4`tz|iIuj%Ne*UNu%*WMK4LWAy&Vt|5 zelb2#I|>`9=}!dOnhn`qre1bb^rkrzkS^|q=N4Y^^|HFn)q;rX6u|(c66b$otSj^~ z*Z7TtL}$d?S=@{Adx*wf+T*<&KJ?iCS_zz;jVWa``l6gdvL+q7t((4@opt+*4TW#M z1i2Vp$8$=!CFcs|1V^RpP~w2ztw%n z@S}O|kLP7=A$32<-bxj{X{K-q$0-WtB;3m}x!qkK-E4;Q*WC3mt#D(*<#;vT#gDAJ zbermYkid%^%<$|cI!A|5jOHfvg9b|ItIR;U1<(6>)AkHT0i#2#;W@<^<#Gx2txpLe zbH4kTn>03?Rn}zu3pjzgvJ)6Q0Z~D%dX1YbR&9rmRW_qsOqDmY@~!dI zT=MFr6tQ}njtKK3KId>G?Vn4r{p__$KD5E^8G}SkCr@JQEo6T9;pPZ;{ z-tE8)8~^)J=;T74n5K5LkHWc-i<8AM9#Yz!qHX20g?9miX>GFC*_oTFQ&KUJfiaWX zwbG1<{WTvvJL%?pvyU}DD!xp^;@d71CQp=~qlpe=mrBuuju+L(Lzi{RtE?Sn)HqLU zXMby;M1Wc|n-PX3gKmZ(Boj337v%lIxqgSv%xTUiWMdP8C5+m{Hh)E7FeYXX#+lN#I!ZM=1j3pcKMzQLXpFyrD!`+v=I==9$&(& zDpjAPN_Vwuwntg1dgG^uS8ALr%4C$JNKv*uv$Ehgz|Pex`eGmIo3mdBD(>2*4D~B; zQsHZWezT653r1WOUQQLX%wp3opg9&z*!OBI80sVyLyW@O(Uq-bus8$Cm`8(wiL9?r(&{DQ8!+rB_7kXZC_$% z09vv-h`pt&d)mRrGPDIGlH8Dzl`jw*_7DA)Cdh8I2+NKyd?b+&{WwXFPZoC?VCfu8 zk3LpI=j;J*_R+Ht7G=&i50O85^&bU zeJn|`a5QW{;(0rSCEAD&0+p1-ns2|M^;SO3s(q!Dax(Q3OwIz|vf8;q{0jQisLN{V zGEA;XZfsSkd~$f;_;1i8)xicND-eh^A| zhEjiR_nO1uHDi&Od$Z4Lh~f4GQn*9l8=)7M+c$U+RH+7xYI_#$wgfTR=hQ|Qk@NwN72Zi05ymeXN+ z(oCFwb%pAmRv&6aSw%`ph()A~ZCZ!u8vEx0yhT@Ln{S7>G@7D+WpVLNJBXzbUd z_!Y^JZ&Mw`?@P${LN(=Xh4OF?{O$np3=X(UM#f4LGR`_1Hvn3woQX>u8!3;$7t@h( zS4ud`!U|1oDJc^kx4+My4-4w0HtD1zeU;>SAjB zDrc9v!GT_oPsLB;tBlWdz#XD_e)Ulb*4tMZAh&&aT)5z{2s%*%0-NN(?05@moASGr z4*l={{NF+#7H?wA*u4VZ>7F^`r`IUlUa8j&Y4J2IdZ?ks;@3fzJtb_26xTkhOf+1E zcFU6zvbI;CZbxn={12u_G$Kdx7yR)0M1NBWPX~(aDs2Gg^6et^?A_Z%D!ce@N6`h< z$9-~Zshj483m#!6hsgo$uQM*rrRlxmRXoHLJy+X|)i~$Xd93-uKW&9}*5p`(Z`G>@ zf7Ec$(%W~Yx;e-<4+%+0y^8i0O4p!DiZ(?z5l%o-<(%*TJS^RKKZ3iD*SLTOg_~mYna`7*>|eE2Fjqbi|Swk z>m8YaYk8t!ZR;4V*C?6HtlOtLb86F3KSw8=s^QX08D}%!>8o`Kp?SGm7JjZLi~J(e zJxXd=B*ISpnl4I7$rR9FJE*MUfZXNwPvhg{=lHZv3=YyEVl>b092dMw-?)b6UBlZQ z8jW$;uk*pjj9l12)6z0m=ukz%gFZpSjOtO{=goMo@S3P4FvX;Er=D_x)!7)s(;|5; z!4=k`sr#bQGN|(YiFRk0EdHq7-2Z5{s{WWI5B_K|QpbT1H#M-D;AAvme%fXWyI$*5O$rg{~R+>a@;K>@rzlI-Mr7w2M)Pc>PvKaF%LsQE9J;Q4a zAr88eQAXg`I_)<7L`W?2>&@8w{WLpci-gKuO!fv)A-kme4D<5-T7G1c*CElKkd?XAFsbI7Nj6q~b#M#^Nk(qB z+x1-67GGO5vTg@PEGbwq?B@YXtu}~@4L+&(1@*LiL+ZQ_yc_gpu>ts;8Aejtw z@xJu|JovXxg^BqTu_`$3a$3cQJC#{MZAZnzv|OFCvDCeXA-#(Ns<>$FK5=X>7bxd0 zpHgviv8OYP!$*}2Ro<4U1San?P0pD{^%Vz`c-eTbnB?;~l%lnA3W5n;A6lLMjPh*d zFHwd#_lHxx^KAt?`>@P>wk0Y{?_gKzxnlVl8Cn1+2Npf0*B4E$wJwVH2mPpJXPk+c68t+ zKy3%03fs;-0o%XbX9Du}G>^|%xnXJ2_AeL?lT!?qE5A4x9GssXWy8{oUwa6bHz_?2 zw%@kIT~7(ag1y)Jyf*$>q@7y-Mc%6#>Xn5+{rcZnzgOP>*Vk^>|Bd`hPwn+jHp>Iz zX*{$bV3l49n9OCe%+asF{m$vs$c<}e`L)}r z^7~THo*L4YJ63vG_m$~Z>8;x{jYGsd)=WJ43yf@<=iT0St9nK*Nh>kyX*Tyb+6?O>TNpBM9h@$NIe;k`iREs%)WR zTX&o6dvB@TQG9B-gx0lTX$5Y*6M1Tl#(i{&0a==c+gKq_=kL5IC~kG1_~HDu$y%uB zbLgG4A9|(eT6)KvGkf9#y{TY{$|i14YF}ReW2~QRQu-R|e{1))*2??K+Y43dqoa7jH`dV;ewp(o{P2%#@Thd$ zglB;Rr*6GdcaT%Jd|kVb-DtJ*!+n8}=j2aayyo6VBv-;V)VO)wNaSA&!Jy)goP;e@ zy1PWc7{5sJ>Npx^Jp}5)>qT)xL^zo=n-)nq7JtmfQ}H0j zwKZIH@AO<;Dy_*$SX#!)?@^we6$%=dvY?kVOT-xwS6HeI^_N0v!{cdjA`0qh*4L*Q zx-;X{U_V)pCDV7(4?a8$e>9j5hkVQCJcSi|aFQfi%|;!Ieuw(6Z|La-^L0XAII1*HCLdA8B{GtyfRdZ~6Xi1Cc?&EC+p|tnHS=H&SQRI!_f!8U^}gTW zEM*hdJj_FF9U#63h-}KiCVXpLm z#dqnkLZS;V50X^sj*MkuPGXyH_?f=qz>FA&(^K@%wX!LAQS)9xbBdx-!j|2RrK*>L zx=yj-JpqK6QJ;-_XdGh^15ea8s8{DG#*jATFRgl6eRJR8IuN3c&`h&FC zamMaz4|qkD9BQbVbaH;cQETKe@<+5P|6P;+uFHR$wIPL_b~HZ+vcgHqGnF09f^`7S z`CY>*vR*xaBEJiQBT<@5Kc~ml4xPuO$(Xuqx=wjRvhaHwgZoUv5TDU*!Gf3=~)(>%H($yDz! z{3kE1H1{RU3Q7GL@qoHF#5R~`Ruxo$3kdwjY1AXZ!=yI^?E_R~;jm1!Bm`a!n+b}V z^ET4@ujKkFB@2O1q*AQULk17#Ao z(}$ZL!22KZHER_Oy6}rm!K+ZP-I=GXfdx67_L&NKvdky~K&u)a72s5!abA0^B|=Zl zr=v)gGbTosZ6r3?BUN%FAC8qq$gBIM%4#udd^1u^@=6s4I5p<8EdLM@cxGl2G7{K; z&K$x%xP_OI$6lQZjoATV#^$ICjCetL18Bs+WHexvFbP;N1E&gfccvW4!z z^9bky6UUJ}#883`&kowUHi6#ghGj;V)^i(Uo^-k@91+t@SE4sdTA3h0l8+SS)aOJh z(;{Y=ZD|}f;Ouym{_ZLsidySi6c06pW<@_B_}-kE^9iHdzskm1{Zy58)gIrL9NG$v zxW_7q3FAstrfF{ZDA5_mt*EWL77tTqPrTAkCoX52JgnID2deXuI%Xh4Vtn11a@!nk ziTKYNOUhp?cE(E79x2<8W^NQ6#YqeYBu?AIx>J9eyE4ySy+RlushT~hfIdb2sE=Yn z)p6qREbDx*=Wznm(-bqJLK&{#VJND!Q-#7T#YDB5<0`K}?mu#^y?{upRSCp9jua$dq`2;2@wg~Vo1CVeqCdmhkt z^E2srHYMp~Pl%wm5VI0Zl*Fy)hj2fSomp(qGgR$ULXgQZvNsi`>dS%F98EA?K!H3a zGo5M{KPl}XN7ih^oF1AKfHCIS!+TtkfvuikA`of5@%&gY zq zvb{hWq#C%Eg~5_0g_VR?GE^%ygcA#Z25j}zO2Q{;fqF@f2e9bPzQWfiqLz)a-nO9O zm$+3mM3bXirAcvzSJINurvS4KfdoqW=IjS;q@AP_Y2yg;;9-0O&!r_+P0wP)c|&W6 zn2bDX(ui@_po1Rg?Sbk~G3gyz;FY<`?$_PBKWCF<`z{K3q|Q->%I=Xig?Cw9E4(|t zqU$8*0c{&NG9D!$M=)76bhH;!PeAJ*vPY&ch_jDAGmKMcja;!9=XOcx6pQ7QRcv_P znLpLKG`Ji{Kcydp*x6)Jr;pJuh;p=t%F;n0j!x*t4=NoDh z{I!e~^#iq9ZPPY@pNz6VL01oKl)An9X5YWO?JgE##gmy&ix<)^s$qqkWE!TI(bm_i zWSHTh;z)U*f0TQD;p(80HrSeJhm&eIGi@(c)RQ9@RDdZ482phh<-p63y7xI>X;c@OWaH}B7qwPICu_3K@aZKy7Ve5(Cw%e9(#1~HZJnOGpEvLXFeOiprR^ zvUYDpsV~cdgR;DK?=NRW<7L>rl1UM!Ctbe{r#(*8G)vXs^uLH)uE9Lb^^BW;?Ft%^ z)xE}QcC)muVw9AV5GNn9?4a3bj~-o1R!+gLkkDeP5*dG6#V}WYd8I)ic3pW~Pu0_F z8QPUPh}IBRa+&vWYd%9YfvRa``qpd-myT}@YO$e$%HnGzWXEi}1pB-pWD@&QS}sj zR=srmh@uN?HfpzzMl+&}@^m#%l2FbxcCX79{0+Ka;}vinBap<`h6U0>+>+eBi#>t8 zpK4itG#w5bar%hwFh`U(awdhVna7-tBg5*f*71h$bJdB^N#Q0E6|LsXFy{1!d^n|p zL)XaC4lrzMsi6o~lW#ilgu6TgQ>UImS8UxoE*$oRfmC;zpH=o4$NXMR=Ju8!dr;Y3CZ~$^n5a znC-dw$ZI$RrZq=688qO8ZS>ljNrug?eLdQ3>)Xgdg8wyNk(J@o#HK^p`=|jOYFw7S zL@u*M~*J=3)HjsjVJ>&Catnud03X3GkX>*#y78z zhXos}tdia!$rOK-U|}uo=CvEz0xKl=s#WBilur$QGZXfva_ld%Gwl&#cAA`AFmY`0 zh)rTj6=Fvk?<9b>U`t<{&t4QQM|be{_)he{|MUOyKAW65tRzO|2uqopq>lMI8#nkE zy+Pb>SBQ}0Y-(fZ1iVnIR|lF3fdrD9;@l=;Ix_BU{X~|5n~QZE(EuJ37hEP>f@Ll<6J%PsSVV&qBR-Opm8yI8X}ekwPfri?*(&YJ&w*%z{v| zJXz#2EVJSy%SxKdOKwHC z5_(u)i>!w{IgRrV$|MXYS+r+u^Zwf{d?$8Nmou&7`6|})sDT5c?ILUfgLMILJ4(PHt?V?={ zubUBSJUr(h^JO{FL&2&vuBT3`DHhZ?n5 z7A!?8vQk?p1D5p7r7<6B>)|m7mgy0h4Dm(-?JUcuG2!d2*wI|eqE4@j;XJ&uxw-m- zCrquu=zXsg4N)a!pAu{ih#fIG6tY=i{1%);Xvab?2Ia$(0SB`omig8`;JBw?eF(BQ zg%{F@*|+-A0n#W3x!gs!Vs&664XyijwH>X!>+-<_{=G59Q=-z`!MVb?CCTNx-YH-aRTD_QAW<0^l&TT^$l_-pp z`Y?&^-c_~)6#gl!=|t|7p#OL8dN)xyS)f7R1f@08scW!2U!bCgQ93!y#B&YK)$l&a z<6&F*#Pf6dA`u^F>OB+0asCn7LC0}f%3Uw-SvW?&C9{oE|I$%!IPK#l@+t0072K&?1GuhrXDb6KIEBJqzPIVP7NO zMFjqSIz3&4ZOq(evd;E)wQ~>FlPneLU|Vj>qmy_zP%QUW$v+%VWW>R zbFfr-`%Zo?I*V~pJ(W3DKY3=A=H|KMM2xD?T7$zTMI|nlLw2gj(D5If- zIBtqF5c*x)ejQZYCd1Eg(CDATB&I!_b2M#XE$Ku|#~eH(M_+R4R*B`#$l;O#20(I` zukRfKuWweILiRjMDs}Lg8f9g4SkPOH<*969pftqnBtW#!74s`=Dm&6tZJ>o80$aO_ zUdn-yM@qdc(6Qz~JOmE2XPgah)(swA-(>j+6E4QUZG{y*LzJMC@FYKHpGR^9#&gs4 zj9D0UpbCt#(efxcmRK*&MH=ja>Kh!N^}6T&w}HJ2^BJc~(x)-&!dJIVXN4(lVO}}q zB;Ifuo)8&dQ!i%o+^kRo#fE1XS9YAoM_8E&g!mFX*VGb5efv9iiSkPhn{pbhqGIBx zTqJH;y@}aXoE*+aZ~!_H`GC4)dU~quch|yohsNojO*c{;e(=;8rAy^@Aw#@T1?fg0 zAmzKF>ga)KA2rNBtVR^mm`FRtK_4YN;-G-tA9!wd+>=x%NIk;coz3#q9cqi#1V_S; zX`YR==ERDTHhO{j0wZ}@Pl%}Ku%tmXEVMS#9md7Ps-j{FsFu;Wnq8_Spt;p)ZNyK? zIHkuEb$KW6wy`U)1iDd+Lk-Zy54{69vPPCi7mnv$7*1DlAcYvA9tvV!GPHA#jCw_0 zb=)bt4>+VSNB98?UssV53yL3Ctgm#v5QbAa+Q87$-`Tik?N=+Aeb(2?2wOkQ#KJz& zIqqP$dU#ZUm81Q{+3tu&m5#PoQX#nrpTd+_ZF9THr(Tlu)mhsTYAGH;2tzQ!Lk=8h zGN19z)QOqG+JJD&#%)(W6mG^#2n_YJ#=}lk6H%XlTo~L)QHS;wWI*84%4!EgXPy3; zBYiwPi_f)}vZw3*$n5)O#KM|cZJ#q1IeM>BVHFyNXt%}0*L>Xwdshz*zoTm8<+CXZxOt5bD`v+L^QY-3~EUzosOU?n8|b2 zyg^}TiD+C>cA6@dUPUp_3olQEO$+TxuXb%S+XWNNw`kOOh}CJ*MTi3=LMpzn5ZNwjw~wk zAFIt+b(I4@KC*Ua_p;z#9@X?RV!B0i$wdZ~0Jmz^I+YY8K$9U0d+9X?%26bMZSM=v zfE8DZ2#Xl^e2%L|wSqTtcq=htmlqBR!0&a$)7 zwt*IG3&Uw&J5ylz9yYsV-}FIkDgd#PP;AAbrA??6%udGiS4fbtM1W|I+scJ9k8>zB zbU@7e5_aza=bqWefy^0;rNoD9>n2j!y6r4%&jmG|M&~IRF4f|~{aD;^o3EB>L3+LT z#0;}z3_H!>AUNuQe8h-6u6R1`(-8njBig)4g+)T#7m4l3&`i`4V-Sh(qqs29X39sSZ?ZdO0~5ldhGrvOFx!YbcH2lQtrA*M ztfZB(SA?38$^}qkVF~iTeEgaeKkbgtJYWnRIm)0UtZS}lXB_5ybfn-A^Kk}``vC+5 z#;mxOyK%a}Er<^_3DiRM0{7uF1Uchx@UOyhrobq^>BK2$qnXKdHM@IYV^~1kbod@2 z(AY4Qn`->acD8K8WdHXx#pP4dwYH->ger%3b#fWIq^q>>5K28Njaq`C#Zka@jkMNj zxtVSW%&qG;MkQv9--mufM+W{*v>3~qhVeFN+d;Jnty(R0y@7peJ1!7vsJ4F}ox7Go zjq@G*jJBysw9%QnD5%8+q{B*(Si|%~VrnyfKg{~YO$^Tg#k*p^oS|Q_UwA4q?ow{l z1PTg}6!@p+%6Q~4&I;qHS@1x3c^tqqoX_X%C^0$Nzo0W2HY4J1gjLsK7tfDI7VxJL~Jxqmr5do+M?9MQw&q(u@x$gm3wONZuE#n z8r2P1XS=7e7}+TowdrJ0f?2z7_6f?;7Ig&lwx`4UY?mZf0#VHXISF$kYJHcHU>x^l zbVZzFI6h40d?O=~ii=B5j65?*FH<)yh3lw_rXeg&Fiwd+)Nt=~=rEgQIE;FrH%sHa zjet(sIR5Wx;%FKY`wVM{aWu#djIsZ47+2G#@ZbcjO}x4AJG-05s7Oc-Xvq3?~bd^ndI+TLHBH(!Wy?*sZE-{EQ`e=kRYZ?T8K5d2vyagbN39e7& z@_CMyDBauV$fq`cm%7(A7;24)dp-YTDlB0f?^k)OQK%A|J=7HOum&I_G zGVBDe_qj29F>~4a)l3Q|u4L80MTLkQO6c?Fgax2GaBE`@!UT9IY{LvKCOP?1nVZ6E z0}+^h3e3eNVFUU)eU(NbLu54(2prezpsHyYOUsfdv8@WYy({}$TVLN-Ae3!KVsov) zjg+_x+hf>0}{ACUkU7W&|p6sr6hb`Y3CM1v(tCR|Or}Gavw26tqjZ z{)`XW`g#o}=RXdXNl;T1UA-5$g<*;Q9(+)qVgMUnGa`HFP83thAW!$+pf!IWGe5Ju zzO+_%YvN==SKnhq;qQ^Z1=-Ti&?|dH-YiowH^g!;cp865+jWcwefU)l)T;&r88 zp~C?l7uUO>VA1U3(~+y~*u{K)MglGODvO-Z;cHg2wTbz`?Wp zah{^8YUvXj1AK8zgO?*#*`?3eq%n|#~sfa;S09L-{xP3eJeI~*E zk4J$-COY zt39P4HeUV~!5CJC0ZZ#PkFo_JBRQxOx+owR+9Ku3oo>DuJ$7Kh9Ce(`B@Ab?KYBvn zV5`j#RvC+(?e$Jl8jX4uYOPtG6x%hOvLo;jh}@avmd_ews=EdjZ8rI`w7BoM8gRo8=Q8meb!iBOpf=< zp%Cef5?5D5K6InwSGgD-B(=xd=Bz!%*8$w+K8Kr`z2M2fh+c>LI;PF?RmUqD=;L;! zjg){gcS7v z&Bc#~@#uIOA1Axn(SP-pmON-}ZSg`|A+ZJ?tz9v!aVN~30r@;~{MFiCjzI zu7~qSUc%Aw5GB1zC^UOF8I|+yKH3guQR6!68H{l{@mWIf#{fv@wy)adH9IW6>l zr7EWGpglriR$7nWzPo%E*@5ozeWZQ#`a3ytI@yzaoV1-rsJ)YZu3wFVnB`IK*hfISw9bx3?v}HfBH2}D z(|Dvzj&LUE7Nso{4H0Iv;J*2{>dz zT#oSeX$X%>He|7KHVua(TPP@GmJBFo6E$G;pbDCZD*K1-Y z8gb1A04|#fu$Y;ge|#Bx*%L7iCDvd( z(~vg`*VdnA7B#mwXBsN( z`up<>)jgX&T9)BeM{Ep{e9k0DD7?3Ga#DWPmGfdANv&_%bZ+zHQ_`DGJY;s2ZmZB* zfSn~n9yNu7pSw+T1@PK@n#|YcfuP7*P7R{8@g86)6)}f^NgoNQ;>4J~EN8$;GsC(x zZJJ{jeT6-Rr!#9(ZF4p1dJRx-rbk!r39q4s{df+RL-lY7wJplH&pKT)Uo5lnWC@sd36f1)#uO z5NnVQ0sGU=VX+48o#-sW_)$EBt+J0-C33n!L~DwJYv~l(44ro3gV^vb+>(P4Ycr!D zrHJ>`j|5(%UojN;DmYL)RGousAF-X*4LRTwz+sfO_5YV&eqg zPR!*dhQk6cF*7reCf$<`ir9$>Gw8U^&W&RaM2Q5t;mvxErZKsGGC?xF->7relK2%x zRWZ(Jl-Zv0eOH;g737_+)Z8Mf?bD8Fn9o7^?p=ex9}n-|4Ld5MYUepB;~q$WZQ`Kp zv+vxo^WEu4!-G-E+)-pbvE0ABY`qfcvn1ULw8j&7{Xgzh&K5 z>gpeF_TRw6ef5y;&w?p(Eh^TXrEETUKP^=aE2G?A17AITrBL?8du_YNg3^}cu|WKR zdX13rCMmZ)F;4=Q=ox=3IW;_#%>j_Kz-gyHR9-t>w=RcHFI`Hf0vt^UlTh43SSWwd zgH@Iv!V%K`4DB*>th5rIyWhi;@f2;l$*`~M0^z4>Ds;r3{$#PR@yqzun|RXnh0@G1 zy|EJht*StzGLM@0(Ih)Q9!gq6zAV5`9gGOc6QsnVK%1j6pJV{z_)24PBjI7jtdx^R ziN~zwjwjjaDFQtcj^c3Wxz74nyX4i{i^nOjYWll~S0`shAV!ddv^?r+v6ESGhn!nT z`|P9no^d3t!W0D^C`dpMxC>k69qi{5)lHM_a?$C^Vh}R&EX(@_EnQcxpMVs`tP`M1 z)FY*9{0zDyRSUpkMRb}&V24KgH4f-G*3l3YS=u2+x7^{_;9_}D46IMU_nzTIf#11p{dilCq>I=jGM4`{K%JW8;`$gb(VED0J9v z*7k;?)iHsVXJON>wmZlHSrXvj2W2q&Fg<#fTtL~>z&fxz43S>g&#zqLww_(S4179lGtTR^Ijp=BC$5B-ZBpAGxel5^39&9vUPVT^aax z2`AaqXKU-rXtW5hgf5Lpj`^v=esmI2s!bNp1z3UCEd}|j&1x!0$+9Qq#(S7z*<9$2 z+G~3tqLYXH%Clj~EH_6%qrR970u&U|Fv9qJtc|L~n7-dN?$^z@ls-rIS&TQw?RG+h}SRmA()%u#906wA}2r|~4dzTw%2fr59#e#2DSPq1*~ z#MyH)vQW5RVrHfG-6S`=L}FC(hD3TfCU>jrp^?o~{;Ik zHmzAz$IA#zooX@!qeCEuwNkkE3?=YYW{{);2WQz}J=2PJW{)-QpO|Z0UZDQ;DQ8>G z5Y(BbJIC*vb2Z-jut}-19&?M$8Hd$T+r&?c5~yo6j*nQe9f(ueh;krQzNpG4!&5WK zOOx_4%H|pofI)oDAs^b`lERtUBawU~16?yG`kD@&#MVi6tQSLJ8B;~c#4zpyOC&n?` z8Xyh4(j;YsoMYe25K3W!#_WPTEI6U&(3vI8*#v`XLR*9tXIrJOXpnZ}h*r2~&@tVw z<>D+IOgaf1Z2l-k1RRtc%`d{*Da;eD^|h_e>XN$|o$_r>xu$szP9~c*Wm&~4{N)gz zW@rbMgBH8vbEu)trEO2%L1JIESeI+;vHVeIVKFUmpg!Fl(Z$@zmg*!bC7@?)dF2;2 z_snKnIzI&%w+Vzi-+y|otLsp;ySB}!(bsj+&o(c2W#nT|s7Fi|jlt7Pb6_(fPb^HU z4vAB7y3_au_4E^|m1o%`a4%Not#yaD$URd<8<=$Vm$7#m*D_;Zghz847^TXSRB4o^ zMl+(I5{#c5Ub;cDp^}k~qUhM#%G`B8lGxx2XoN)y_O=F;g1QN+F76$;AEED1pYNa1BUSk`k)XhvPrk94jwN4@ zI#$4k85wg2DOzdnM$Zs&gO`p9sB#>6d1+9}mzWGY)Q#6@a>hhR+m{gKKtk5du(x!h zOk3<&hPHq-l6yTehy{Xg{-M9p1lf%iVI?svs-*d#A1CSY$>L5ERF8w{(Z?`|#~$!t zA3e}uq2+9M2Radr1R0#J<9j)5oW2u)1$;|8#f;aO6!oJ9p0DoxU6sRxbwy+7h)0j&B+ez3A8&=s*&0lw zjKO+E;yRye?Z(n8Z-C}>$4Mja8^SnN<>G+e&ShHEE5ywPBoZT^j&x2m*uqY?h0;WN z@^Hw`K<8CRJyI1;4qiT6r@~CK8FA1)NgdaEZIDy2LmF^ zm-&{gqG=D*;>s!C5JJnTd{|Vwgp;f-^;5Ie;rm$iUm~~w9XZrJDs>TNP^Aae6^Rpw z*p)uhfAQa7w#&LHSnFwSip_8j=YO+7H1OX=eU8MQWTH|-H{|E-|CMNqSa2VhxlVEV z2>NGi;swoQM0a2h!#ldswaIDcpa&paR_~9WnH=x}X}XAynT? zz2=x;%|;}%-t6=0V2myS6YUU!KUHZ%8#Ui4DFqov0n%N%}Qg6nNsBhDqF|5Ku#J*5uhZ$n2(4x#}wJ~`G{geP*)J? z8zRvak0Rf={`*g>z`CnDMq57nt=W)iv8}dzO)jGjS{{P%E1-PJi1?rDkXORHgc>?l z;T8QiNjSx}J5_bFnk-viO>KpxC-XnM!}F-F>fb!LpZ34Tn)zJ%`J7t1(ZLllnyKh< zua^{xp?}2lNM%D#Q8eIHlnlA22iDOqAZZwrmYm`lO}75Pho@rDnwfhScj#81Zt}OI zmW8-{4SHOW4EYw((&MgQz!$0+v-`<|d0=`2)G~O#Hr*J?iRe~;W}O+QUP1Ob4|4=a z{zbH0+=~*1NnfEEbP}PEZ(zpfu52)%|DL9wI4{i{&VYQHu~h!^Ve&-|H}-Ji3CsvE zQ9_A4spZR@GU^5d`+hzZXM-CblWE)x%RIZ<$OL@q#s|gi&JR)*915YcX_Qct8k2oH zK><^)ozjv2{h$9^2#VX69w&CKz<0V=dd&0+#nLPFs=@f5rbQ3+%UGNd$X=!d1`YpO zXOD@5%gSzb;=t&7FY9*9hO+u#dPGC91a-lWu0v8c6Un6eZ&&ToIBjnisYmbLE>hXW zcRNfB)cy9!{i1q48?9diuNx)@wExv!1WTiP#jALTDKW0NRjL8br-JC|!9TV|@vNT- zP32bohW$qklPtY`$5l0^R&#oRf_PcgDTJl2N{UKD)_E)1r1H?ScJBs`*5d1|yA~G$ zcNzDb6xdQu(;0}!<>pVO5Sr9++(SmOS3W-?){?)~F##V9hRLToAoT_fmmPV0-Y%n4 zF0%R$3mB)KYj~oOFRK}<*C1Cz{ryXwNwV=^pQD>dc$D;thS$XN_KLkhmxb@^tTUgf3|?Jh*&eat!Z|53*dq4&WB6e{Sg`|2XT9IWbZc?wpnrW`xR^0VwASngCuH!mpI|!5U;PI*k=-S^C&Xp$G|VaNGRelu z2@DRxD$f}?eQwwDTbq?n{y6;|zAcYdr$2k0V9>||7GZ6WARk6R_lMYhiUT?P%H6ow zaj7P`2wx|I;suFnAeVehh9mkO{_?>U__t0ghk41cJ~*awda{LwJJq@MjMrUZny*fM zR_f!!vd%@(Ib5)24>`6M6O?inmsi+aoY|S#E7{E7uIN2x)G^J?n}hHb2P}A*E3c3v z?>Lm(u#)bd(J( z0vpUBdchjWVrS(f%RUqCl zZrA^fuYcI@O>#Zd;pk@npmzcXKm4gPx8V;Bz4kIsl^qeEA9#rY@mU$F4WHW9b~Y#b zPX!9=X&#@kqETeiyjS{o|K-79a*Cl;p;rfkgY(m)Y#4a;YY)-HCV@8j)#nBD=aPeJ{SVV4?tU*%$CDKf<+X=+k_^X5PJ;iHq*Lqkn+c0q`~SUl zZ~d>YZ{AzIUH><@{`*O9h+b=h>4+~OtZ9C5_^?I(0Nc^w68yh=L|9q*OT?@4C(i2m z=Jk)eK9h7#F)&@U}j5!8S8)j{)WH)H#RqJ_y3z+|ALil zt^e?Jw?G7>r}$Cp4(4{5EOTJ=vc)SNGbsbeouv+M^W-b|fUR;5t3^l+zugLyue$z? z5$jc+9pkl7ofMd{{pjY{V!1W zaRxO%&vJxxx%LJwnn<;ZRdl1Wtz!KvQ(%>`r>W}S=={Ixpa0j_Ha2hVe>d`v`JV0h za{7B&ps;O6sJ0^RM&2z@^u1HNt|xRJ@mTNyREP+s z`j*41`e93-UeOgodmn5EZl<^*0sF37y5&XiC)Y$O*1vV|pTtLS`iDtEF>nY!aKf%} z{jc3$TP?5utBE>>tQS*ca7t=GH>=W})bTM5(y6>gZZJ%3esHsHe*680_6{Bl7(*Ym_?{c!Vm- zf$t9vABtjUq-WO0E8gu{H~RRg7NJ-}HnNDoSgq`}P=VL#608!k^`dT*P@{QpsP}~? zgniihTpDoO9Y8y}*e(-G>%mT080+PRN}8h#?{ke3>B!-$Y(e(@&rG3z$*ZAy`M2rL2XpNQpQG15AN-gdmur$k)|Ix7dG-!+&}%?$ zr(ER@GII;L>IUL%9|nZ_a+^pwtk?KaUC#~@7<1bnM|&Z-iwMEs801#LOHQAg=nOJY zW`@@C+7jWs>z91vD*oBLFQtR1<@c(iu&KY>C2}w6knlj`y6~<6MREi}W%WzZqes#5 z8Y=@AQ8Fx&W(}E-Fx;jcy?w{yWCA(#)Ty^E5~*kGDR~KgQJv(4+ZkrZEj{+Gn!M=k zU+qBpx&aXxblHn{zOwMw&;N1o^5+g8P}9LVce%szllrxE!MQEq-;xScch^UUKa#f> zT>7n3<{deE5+(CwGR;R})g?&ZHU~c4I`aK%*#CW1kbP5?GRU5+jcouk^uM*WwbK3n z+O7Z3KlAyY8R$wHUtJZ=yKrf(uj=zs)6Xz*1?xL)t6`#_igR)GJ6T;N;{ZQzU>=xF z-9KiNz&83AVQKrH z$+>|6nrZalMm(i;Hp^5QKJ=utJ@U34Lbd%Nw@v4@!Ng-7Z8Et8^R%PSpby5QbB~LL zDJZ_@y`k3qifs$#$nNnB(~Lba4rq(2NfYL(6**+PhF)DmT^IA1pDZQd)*Itbum6SY ze?g`15;nkk{qNq^n(zO$cK_bi{agL-U&sFUub>akZwI`x>Nub7sLg&eXTvMm)I=Ck zA+K&EyrTMe9m?a?RmW>l9D{0Om5EdIMfhx9m9f20XX`YIV~MzJk5ujQz}xmPw5`-v zAaAXu^H@s_!LMj6EoqK3?WBVH@b<5tYeqo!P}51B^D|`ewANXb!P6A6?461hnmfN? zQs_R`Qo@M6DnjTI0XjH~?i*(d-OrhvVdQ5WbLdiG{`fPH#qQ=TE}2afyO2c_zn@+o z7v{P5fL=4M^Nzhs{q?i{!S@@1%Zbn~Q-9ufPx~SH*Io#m<^#f^y-;yjuUJ6sh46Vi z%vWtQ4PWnOrQZJ^b%nWH{V(JHyHSe&U0>bWy7m9K$@8BW`OA}y_;sKjKF<2*_F1E< z+@j^dTaG9Q#}U*vT4RIy2E6D074H8irRECfLs*$k(&5|!V8;5tzqVCc|M%B!{eN$E z{acw15m)XR!xm#Cy#ES;ag8WQvtq?7(1B;#22piwfu|y?p~vU@{rwR>C|U?`2>W@t z*|+c7QQEgW`71juCW5(|Z!t)14I!?n?T_&=?Z*>>pw#+HxOJ!wnqmumSP<$3c{#yX zvHmZ$&M&$Cw>EwM|FzAv+x`D${&_sg=b*IEu#bBRdiZ&kKVh|A*@iSaS&7qCEgnK- zajDAmvPnKAz3C+3!xo~jy;2&4{*;uMmt(>z9x7bs-Dpvp$tZ~d<$9*SFzs%Qj2g#z zkw`U%nOCo|A-6DhT@b4c`sRJr;Ck(fEBadQe*&BS0HZLS2AjL)!(&fC#xoPUTYExddl4|C|lxilQX z&e6t-)Ps&TtER<%sBdyb66|;M#~;ym-x1q*T_Ou%V;-aX9DL?+D}q%o8n(e=nuFv6 z%z$vHKbmC6&tEc7&QcHuqilk?agwMT z{kt=S#iOJbPYX;0gxS9^NhT7cgV1gS`YuCfQ1*zGCzOob9LyB(N@uaHD_!!}X6<}= zb5%`uO)jrTm!Mc+Of;A)kZqI{6UqunE2d_-b)rSTCIVm&cMFq?OtSGQMkV-FHlV-! zF;f63SsW(*lD)^>#tHlh{lBiN=U-0$Ut8Y_;eT%2>i;)t|HA{dIGMCH@@17nm(rN# zF&BV>_ZDQsBAg3I3@yg&U<5zjb<$C9IPE7zOO@_`UR7>q4ub+|7glY&mtf<8<4Bch zK`Bdzs43~v>F8tjAt^}gGD#>YDkYgK7?~fDR1HBp$)|L(Hy*c0MvC@{wg=JxuV%EE z+gPzcm`!1O1!;+XR|s|gEX6d@Wa-dHrWp1${pq%Q?y-8mV@hM!{em(aU4{ng7vj3L8BzKDKa_=65I>GKX4<3G|uk@Ml;=#&8 z5O99;p!1tP>z-&oL8mVd=E*lgKB_IUPkg(O(82Yu&A3d@ZQYH(84b{I%VZr zM1@}3WFXs{B4FxXc`iIuiTiiDWc+|ZA07#nzm&>Z@&-Q72Vpg zQvH-N{Wv+tNWuLGEk>uXAfmgYY;rd`0%Z(l9lDqFJ6P%UBVbjVO1mVis59{}^CN$vn6QB%c1P5*y#-mtLFW+5R7$%d*6+No= z&!0SSDf$pd+Vbg0UgYYJPNxHOnJiprCrnW)9|ReJzT5r$mcMtZeRmOkmf9|&6-kYS z<|x=_dgy>S9=odQ+Ry|S+HY_UkRmrd*)}#f`kQzLD8N+62079^7aWcCU)_ZW0B$T|) zHixRS8rj-#PU8HWnC-{x_;L2hJykrg<-^0zGDc7noaZ~XeNw)ZqLK}`G_4&;CXBd~ zc#?D9F4}hv%1zEKa&!8^T>Jl7a&#FTV3z;q)>di%zrS|7|9@Nif3yAl3R`TYu>gKKg=gpu0 ztCl`bySL;A^l;G$^5lZD>P)stE^4`H)oY~-3u+hFoEXK!VRnYsmkFh|mtYdmPzU&n z8o&btT4Y(@>|iB({1v5SPbcRGn8XG%>EZ}ZZ1XK&$Bp{vq`qG~&wB7*%DH=%`0!+JXG zqbi<^(B{zBx9V@X1Puz5zhv^<{C;Q0EYXveaygh004bJsOqmQ-9;T;BHg!uxURy2h zhmI>w4$J0X-1@<9EBCg))yKivZC!+l?;tPkW|K|Oo9Kt+4yxl+03*8peu|J}!Tp8Q`&pTJxB|82;B zI`z@q&rtg`k03sx7dl=6piskTfl2~RxvJik8?CLbgwUWr^H3t})gtB~%h3{!v)S-t zvOh{EX*|Sa$)m|W>}Q|a=H-uZj;WX5jQZ*6Y0?iDQr*izFMb{O(`iw9$sZQBv!?Fb z8@%riU5DxtO4)YDX~*Bot|2m)-AnqaO?K}JjWUXcwFAUE%4CqhzT5YQ7s;lrPXyZJ zV{AQuvD|rJRI^>m zGBg*NuIm%S5QUR`RFYbUmGJj{;bVRV*` z`q>%3#NMOW(5cX*`R!;?&9OLK@|?)V$5TRK|M)*4ZohPmAO%8iF9>ZSFikJ}{g*FQK>{@3I5L0EiXl!_}4m(OMUK3n!-rZLYWXqfNg z9$wC;N9iz~oa;jfc67h^33ZQ0LH}fSxiEGLzYE%45$u~|w*XD9KRIbfCzMp0>;(Zn zWRGq1YOviIzEkcDcJ!!@C|Z1jF*xNu;0~9KKmn)lLQ;>Ru72_{1zL0#U8^}wntyT0 z(KYTtw}NZjGwxgKOrjLY=b%Zq%XE0B&Qvp}XxBtkv7=@4*;S!O`Z(!%YRd7eVr=H* z)!t_;{q~E|1bG}uCH!x-tPZ5x&MEr9eIX%Un>dK8iBtesn)m%Zef-C z#Kptgb^8_9|1rDNFQ0Jm(u%m)qfxRq$#Ugt-&_pL!v9(?-T$rKzkloh_08P>g<>p1 z9?#|j9noQxQej2Dmzc+1o|~BWcF|V<{{BL2yh`m0bu{E2<~LdL53BS=t5^%F1D|KO zwm6*;1x^#IqiRD1aSB?S9^XYEl%@QcR*NpX{!_egxVEUT#{OTc_e?4QO0Ob2uJhUSKw21^DPZNAI8USgJfNR z3iu2|@7vKJiE;JQFYB@&owJdwcRS07+CuKN zbsvl7C8t@MNG;f8YRD zq4-!*u%g7RtoXC7|7%wRXWaj+m;C?NHr8(OUv6sslK^;<<%zFwvBSBjZ*Bd6U8YT< zp5hrhrnj}HzkcAH{_@09xF|f88l0hLs1IKMd=Nb`7d~EBEkVWo#m?-H(634Vv?7B( zO>AY*QiQmSrTu)?A6lS$oQS9$dUY^8`Y2airFTCjgULUtUOq`MilXxMYc$SRzC|6E z5pniUVKIk$rj%!{rDpi0k=pcdNUf@j!nLW_M*I9+o+%RxThk>wcCxxE>UcD3WV5S4 zaKM?>5Q1R1s`AZGu(DFI+MYenvPnIWxWr6iTXAtSDDH;m$wGXQWgn{5gkW9N1cK?K`rqSAlbZbT1T`fd0vcyx6rmo~OQ&g!Isq6N#dRCXI&zghRm}8t>aS!4_vIiY_ zp2w$&_iTSuB$Eo~+~ooaEyKX#<(}U}tsW8Rb)b~-lihPP&uCXakljH2)aIg=YYZZV zar9x7os9ynNl%6`-W4t}e@Ms{-o8h1zt5l$5(;#59+`PU-1Q#8ycbDwT0}^U9HWbv zGZ46fhWYyE4#KEyW94&0sacZw2>P(HF-U$m+PHVLdN285{b+r#9w&o-yf!%c+x`20 z`&)1D!&b(B>uaEPzf1C_~BlXPI zY&ROv0t68dsE>)gVOp`ZY~ZnkCp8uxw%9+T4f8e7F)JEZO*^^=kkd@DG7_0WD{Dt5 zaY3=L%(nDBR%FNgYL8;}Gj;&m`ZdpIWX-;$I0Zteb`bVXVeb~-nD%7DJnMD857nE{Pk2+H%==h z9>*AAm!HN%5VjrD99^|9cU8BDOEga40E4p!7do$VU2D6a zRb^H#8Oab{qnkMJI21I8phH7)!Y~ijBz2grcNu2BqaNEuWv0B6^3*P9&z4u(eq}zn zFzG|yTJ1l6XsW~ea1xRn7W*mfCVkvRD>W>)4T~er@(j_QfO=Mu z65ymtFyQ1-c@!;%rqM05O&ComI*|3CM+IkDfuO+OvL_MXs=kGZD)dzzwzA=<&N z3EvzglQYsaa@b$+Mtqtlh0}(!IbNoViy)8#rOX_?YrFC6$iQV~yBP!5DCx}@n6jl2 zZ#lvWLuty=Pi2r_a_Ws7Ip=Y#{c6YgsJi-@A73Q0(4>x;W|= zy%@8O36(lTqUfRed1A>2jZ))GhorO%;TCf$Gc05!k*g?X;EiV%9m6JiATFCGJDS(4 z@4t_}vj*tEC3mS$-FDQ~tqOLCuDVN^ZB)BO)Xx|d1GK8>>?9j1%*8gMm{>{x6~JR* zipPeI8Hjh_Sl#hc!08D~i&t?UlL1;!4*y{W;4wvMgEPsniKcYu7Bo#Z00hx*|lw^n)A`tZ-W3Q3TI9i4oj$lBbB?iMW+A zxacTW;sp&lo12{lt;kM`Hg-UA>M`%4)^`G>l}YE`Y8wl%(Sw^*j89F1hIkHYVA_KZ zhrmp_`j?{!)*H+xA7h?MseLNN@TH{=qGpW;WZRyE=A{U2ZB`#alfD;?(@#*lZqG*S zhkhnQ)V@3McsxwV-UJk$o)#;U6HMi~vbD9oxwS=#FQKe5LO&?HM~FLA>RHEZprcgrPVhkYh9zTuF^sK;!gRrl_1ejeH zjV_BtbiN#aN)13!8?Ax5AMj!o3x7-zXvv#kco zNcozg;mfkB(zm`hxBfp3?*Hb#^lN8QTm2;XhklznJ&qq%n*o{_c6%=*n3TFa)^$O_6;hZUV_hRiWY%#wVn z+$9@qjsKGS;e7Ic{@DKp@gEy&TP6RGwcGqJH!uI^%>UQ`G32uwb#Yz0N6D+8d|h1j zDG2Gw-eytxt&tu*l;kC$p&aUAjLE2968{funN0#(Wx4~2r*HxU7uAydNMMyn5`m%# z_HcpnFId=J3VXn6V`)43i02>P3MMCM(Se2@bwVF5=@SXOg~k!rv2m|dLweg$ZP6w4 zV8=XT{DN$xY4RF7ge@(ZELxn^qvcxW0$Zh3V_I1Ax}Z$qHM72;Ea7{e*bnWRe%sFG zWO{Ki*M92mc~lFNKDg1AwAs=V`b3vh0w}M94hdjf{eT8odXBV_@4`-FW zXy&!f8JQXl%kp7gc-Yc^U%`L90{>h4a;_*BG~D(t83{JS{6n!~zE+I;@mMd&ZoP+Zx5|%oZ#>=`^`8&3vz7W9*pc<)z0Ou=jhN8qw}nM?!0ioF z!OCrvu*j;z&&=c2?;a@ZcOLj{n*@pt`inue)#b%sJ%|h(UR;%c!%Y$}l~VZFHR}_C zPnHlISxuJ~DhxADuHd3G7$%?E0ts7i!eA+w^pBHb_w%tjhCIXH*c0wyhu(|d$%;XF zwJ#t24;Q4?!06sC(woJ1s0vi?_Z%@IzuSwS+{+sF{k>)Y%M zf7+wQ11Bdg&ow&J0`YtTc#La}u{| ztuC0TdY=18D;CZJF+#5;(fee$2P#EP2n5O1_%!TMP1XwTS|fckRI*OflocKP&QOQE z&YJh4x_E=GRDW#G0$LYd<;ll1o7%d&nq}*)8^(C>;@Pvq_fPkK`svxz_j^D7viIA8 zl@XJ4E6NG2QtTD}+uEF*R3e!4(8g0zom4=YdQ5m5nmlGtx5Mn^7}-1?$^%1VFPn&h zDwS#+2vUd43WSvGB=(f;*6>aA$E@kx@h-@1P}hdnu#7}{%?#2#)RTJ!^JR>u#`y(iny^@#S=Q-+Tv{G$Mh(Vb11iB(8yL$`mHd|GnK;M z`2P%_)%mZ&%8_oKSKSts?plzuKkCoaVSuuT>`f+ldNiFR#kNV$&G}!9zo=Y_+Zk2( zE=in1?Ht_rKksj}u^ViSQ_KIHou;tx&}(uMV+aqe7>f2`uFI1_(TTbL?NgF6zbHgN{Zd4TY1_~bV%pkc$~y}TmOEV7P0r} zmvlJX3q13RI=}@IvMD3^{`=}Ws8Y&X0hV0ascs_3nH4Ps%eL%P z)b+e>M-0}CQ>AfVW^1iPRMw24F0p$>0n_WE`si|*o~pf!K^vxY<9m{wMZaaou~Be1 zD6VY_Cz5h1HrmSVT1ZFWO`Ga#F&10l=YKhspF87 z<@=k$K0uhK61rw57d9qtD5y_#0rEVVsKR?^v zRnOBQ!r)>&66s%p%|p%wz&*XVz}WjA0+7J22i0XRrTw$|$r$uv@>d=? zhrS3TE{5-H+Z6;ZJ=HfK(_@;c@`JP}rf_uJ{NaZ!GdDDhw)!s)JOFsc8%>m3z_7f^{*l`$Rs8BWo&FVb!j~ zs$AVcCKsO_Y*e~x@(h8pS`NT~ll0u}3P>G6N9iYM&=SBS zYs8DTg0U2e;B_Q3=V)nu)+EvOOO}6#^9*Q0s4NPn1*Z?`fZHWs0Eo$&%#0@~iAMPQ zch~}C#yfVk7;8#G_|@y}ka#@K&Jf3f$JCd4HMVyua*t-rvB0E;pjf z59v#c$?Rcdw6QEJ#XBoL09jk!@m7rn=DDOt(F6Ym z4F9wHng8oxm{bLQ1)X?g86(KQ{qHVAJDq}vIM+u{6dNEcfF=YBYF{>xu61+{@A}Cx zhQ(QGmsE&ZlQDha1nf1-}jlTe2^ri)t ze4nrVu(rCjw)MBa-SZk+aek{~_U>o(7hZ)~BY(T7UM#-5@IKYYZ2VgP2vnfomFqA^ zc&x}Q&&gXX4 ze&}#fFc*{BzwNhnX-1Y)#QMhOJ-<$j^Z@k&4)}0jKx8Kz3HOUMH=tLKU^?+^jeR~% zuv}W4;*$IM<)MP~Btb zW3zl?KMGN@%KWS-TlGFrmt(5)X0~j`b1MiR?{_~p^e$+56EMqK;2^=(8W4O)HZoo1 zlkKYVs2{?6grr?wYkQxEl0WAc(>ia*WafAVd*Z3@>?rwB23A9!Xz+TmP7pjrs-=ji+1 z=wmGCeo9{QjlQLl?#Hm48+{7pJQ;jG>Ye1-D4P}sbe}-`JY%94RXdgKc-29a^c+#0 zCLOynT3>EGHDni~JV={lA|lxI=H=*Ro+^{S0Its0vl0_@F4mq?j+a9?XSoT7z#oTT0bPr+TS$;SsU|{ZyfuHb*`UC=VfcQ}xC%Ydl_wGd+prl^++QV|AM=w%cdsd zs*@&X3pGb9ZD}i;-m$qwj9gko_~8*yW6?W*k>=q-AXJ#BR(!rNP(XO6C+_4{fby9F zX@p;SbQYsZ2CwnnFBzSnbb-UE6aR=6C7t7rD%PEmk**uHP{wrapyxq0M8pGv1|Mfj zt{0t)h_V|B32s>)at1T%lAd?fxi4G*2Fp`Rb;IfE62h=r9ka3gAGlSSnKQUfFn5Au z9ENJD1ZcX2Dg5g3KbH&pYl{DL4`Y4Y_@Axy+x&lDGybP30+SsK)Bp}D6jo%aa@{+l za3D>ML$bspQr4zLec{i5O`50K;;1}CejFazwMbtqz>SqZjv?$mMj__eA zn~`-rZ+SrN&}I^sx`R&WN!%yr@1I{DJ`>!f=pf1AL}#TuSE$>nJ+God`+f5ZCrqO; zER_OaQs__raqz1`fD+xpR<2ytDyUdFYSnB{Uf&gb&z zKw+FCnOU6x3uY-*o-P$gytR$D?R}cKw1BC{6G?K!c~p=G(vfpPZv%tVcb)~CuM{u7 znEs{{MAYbMHoO*5z8jsN|s`+u|0-bP?7E)UICDXPP5o2dx1 zV{LV3yl}ypA+%K=S`ac4hNzsIF5T4ZDi-aGFJAv#P)L{fOHTU3noSvT`VY@C+6szb zdc=kKg9Na=U9TTJP&sn4~RV!^&@XiCEc`wpUhj(6M*{Xum{{hduI1MZiVoKfBw zYj@6zimgW1U%ThbC;z_;{!2;zudlA&-@29m-yHsn%MB$6n9K0r?Y}>V{s%wBx;MW7 zn6duvZETj-|Hi#r{J)!A|Fs%i8AdOx28OuhG8$Ip*A*I%DX-L?Fv8t~CLQc$!(lw8 zJD=Up6c%-1+p}lwxTfMihuFKs`kz|~n6>}kyI;ot-?+v9|AzN}!wn6TfIr)kxZVF} ztp9Y>&(4bGGGPAozrMP8d;fF0{%`+%6YKwZmQ5~g{GDz8h5swv|8A|{p8x-(`ag5a zyn27|E3)QdMB3fx4LY|Vbj;qX{RIrp^3DPLeIxxp+`+PrPq1KISOMvg;Z$7X9&PKZ zBSiGd`iTwC@1XzdzH)8m`s%Z;ExOpY+p6w&=z#Lj`0G7z=vi}E;R3f3`BwfnmH(Ia z0-B-!t#6d%|NYI?TlxQu>VG1Jl-|d$Ji#_YD^#*aLx=mH-=c?qi|c>hbKj-c|N832 z`Yrzd?fSp{_chkPL_scdEMP7|K;rZ7#f7_WuVrG=dV08Tx9JOnY`?yS79aJcaps%;+OpRZ!Yaa>_eL7cDR^I;bE zt+E_#M=uQsyA$>5v9sF=7_ODN4eLEk;zIgB@yX2nChh}Yz*y7bB=qJq{*e4MrIRWF z$4^HuM{o8kdgkP})8swIV0#S9Lu!iQY;V**jE|o~6&ObprNwhPLR--Ie5QWmg2jK1 zG!oU}G(XCI%KB4lZaOQW*UnV4*kk<212 zuUcdW@I!^X6^0ODwgH1F?G$;Or~PB%+~e#q>KuPk@3CEsxFb96}qPO}0?lPL68$9fm>L^%R4as$ZksbK+<6+vm)wJG#}NpQqJ0 z@2(|$sL-y~5!p^CCpZ*be2TSOZl?|W+fvI8G$pcX1<7faXk;eik3S-pV)(=oeN}-d zWpdW>3mTvu2JwEXOy3XDaxIW|80p^PN5Rk%XU4Q>#+2SMwg zem-Z|D^Mel`y=eN$uH7bTyi6?FBYo%`et7*q;LW95N=0r{hibmjqg|{=!*!n@k}R) z3S#EXCt;kFvS$6DRvUjv8_677Ua=){ zWk$xh%ao*ywS^dFGNaJ*PxR{0UN)qy;@4=!LV)zT+c``7gtPAVwG$sva+l;+7ww>~ zcRwbBaOWb}XrjA;t}R6?H7z%8_$dgKda6`(;J2k(;+!N%?>g%I)|@(T#$no&J5@)N z- zak(3AR+(Sz-qbFn8nUz(g839w8^1McW5&=+jrjCDjX$;Yu-c~er9jAsddcA&wjj<% z2grY|%gWvgxoautdjFs%Y@sMkQvyyB zJuSS;?pIFzuz2WmpMjG5ZGfR%wHB)=o9wBGZMo zS}u3pdo7*)D>_qqw0F(ou4>h0=3S~t&F7!r?81%qep|TK9=fEQZh`it3UK}+*0U&mozp- z>E6=KGyG!z@cqG$FJHYs*#Cc?K_~7xCd$1hhx`B7JKTTy^ZTd!2ZwtlY?P4Zha=~oZF+Hv17IZwxbYfNv|Ti&d;Enk@bzr8pAisMKYMt|p=_dk^QJk#ACXm;e= z0`wt3GWSZL2gn{DSr3Zt0?O*{>RVL}Vle;v%ZSL#%B_}WGq#H}V?pJf84(!~8B6;- zNSh6^k!-ZYZT3B5Ar|E_5cjTR_N^HihwduydeN|vQu;K?OOM&q0v`g2QfGJ5eonRz zl@Cya%vbgz=jikZCUiZonWhPxKCj;x54!67J1(0D8NApCS-du946#A4<+ph@iAMFm ziveO4`(F&GeUAX3#rD6etE*Q0uhr$%+W-4We%$fDq&XlO-#}8KE$OF}V-{#z^kv3G zhuNF)j~E(RG2qk8eDJ%;xGO-&D-}f#d-o#`#Ww?-aW=9D+o!bU@uHRH@RGX^k_pv; zKW8KMceSu$I7P#+YzB8m$$6#CKF1l()loy3TKh>7#Ob>q#m0d^Aa}_)0uhm(yTw_j zx#l_q{+`Q33@ZqzWf|7dk4yD{gIJ83(N>rkvlFB4MJas>gx=e2ay*1k(GZfd@$N^i z25*2hfPQCaAwZE06sOwcCPwAcAZeqqMd>=pM9|A=2JE~qGP_{LSri~nv{{|BnY8^i z@K8&84H@uw`WG@hyu#IBF3_A&+Krj)0#gBD3(dn!Ry+|Ni%s3;u47@-=Y~?;_~J0i zbG0$5P&)%M>{eO@Xj29u>_|w4VBdNqfHCQ2-L?dC=YlmYZb0uX%8V807CBYG#yK|u zICv-`j;ZpD#kC09%s})sXG0S45qyLVdakXWF*N^%382%^RHX8e zLB$TUz`{aBl5MlhgP|wQ&Y}zJAq-XJ9tV~z7Je3AU%9_FI9W9|>$Nm$d;QwGxit(l zW3-=AJ8t4b%qt@xziqZ_1?HERoYL57?`N*Wc|~uiW}%i%V8=VQZ30n}NhvdEdJ**) zC@|DwAS+UY4jr^ix0hu9$f-Fj>h5B`D08>+{>`hh>D*w$R7!1HL|mID^33D|yM=Wv zHG2Y=iCpZ8OsXQ%!YJcO(Pz!MEHmM|zLQMSa(&K1IBQcjzj~SJayC(rDi~qD_$NyejoUi5o=js1T>D7uId>z_YZ|Mw%RqJ_2CM0B9o|95$Q(T@N8a=!Ne{iF4N`HDV*g*SfuW}K$ca5oJGAA;d{ zvn!-HM+HzjK_x?@r-GGK0b1pU}$V2WGe~dzYOWOneVlIZwuK z6?ZV1wzFsks(N=62P4LQIsi=dL%s<5qabE{Xnt)y>;jp8kmBLtur zStrJ(%Qc9zOq9X==$j*WI48Re&2t&`&U3DMoFi;YMr6X-RVGqnrr%^LXDb6In zcq;&C%`ZQSqW-BL%kM4?D5sXaA9JR|74LborSvZoP|_)pt)+lWH*@#KYxL1pWlE%P zQMC8&6*;z0Tav5%5!d88QN~)3;ZXHexlUeNmb1bmuFDCEUZabjCs~;av)YQZf=675 zhBRIyr9X?8aN#FT=473}B2B_+@TaO~8DhR}_k-bidg1;4yL4770sOKiu0t$qqi&a| zOT>;{%seg-D~B|ODVRaBdK6CuZ6#^@Ah9z0;@Cn)uyXcxiq2CQle;+mwHrPvJZF-B zymz}{#0{Vsh0un}gFwV~GJHFo%I3cQ1|v0rPZ?ItV1;a2&kK(T+p!*y&l=2XJ!jyV++* z%YBP2aM7>Z`m*hX=ZuQ7auQ53>=re-)42ROn0AaY_FI_pm34I%egjK)7{M&v)t+4# zF*^C~Ca2*Y+8K23MameMX~7Y~hwY$vs9>TrzJ4RS^j zSHX=6=^8NN&d`e<-$ zV-G1G>apBxc&q)b*k?CrB=rs>#G>y=#`t8cA^Ct%T_5i@_W=_dI7rDYPo2#-Zwox9 zyufE*#n5F|<;BpP$HzInDQ?AvO_CBHuu3`DMke1!t4jgDAh9GAw2I-wB;0{sZxIGPn>mxSJ;;L$W@78hx7` zGDw*g9b6B)7jZO<#tB7Yj=+DbPnaO(X`=|InQ(n5#4eq(6A zje1gfynbVTF=}RNV_7x(WYkqO# zzJI+3Zy}6$=Ts|yiUKn@4sY=0%9&AaldL|&wmP;?0kDivJ@?Iu%!Sz%!(fM%O;6>4 z9k0*B=}iJwm4S*6;eaY`IkrYSzz`|?Pw*k?u>eCryuV44;&a+NV;yXdV{RllZ=@BS zGK|Z#@iwMfrH6n3hE_b<_7}dTd;e5lxARU%O&5AZ96Grv-W0Qvb zQ_N@s2e8jB2%DkOF9xcW9ubg>Km7^8tIJ>YfauD$kLZ~l0jDU>{19j}?|Kv@MKCtx zPNz|Rp&0xrZEPY2o&IC)kSbT(99;$?;s{h?+mo3twb`m6I0DS;Lbnv5sh;(NZ`&cn zgTUz4jX1jEp|bsccust3N50ol$#3H%4bQH5I#0b6JvtmG|5)QukB1j=IQ-i27K|wn z&mzViMIz*QLxlC@eeie#jfMI7e<D$$_4yL;<~tRcPDd0qBS_#K z3T}2{QwoC(>DGd6pj-1{*uEC5XfD^_+a%5yR2BC6oFZ{D$ zlbx^4FOs8-LRJ!1b(V>>{+!yVZEvqqI%#ieKbm9Kt)$+QS=~4L$ZY2#m6pD<{$!N%$q!t@`lf0NW za3KNXb>oQiqRx-2njp+!m|mj>Pb9H}kgIQ5hyMv?vH&P52@n+-Rom?MqpP3?-u&$J zrB91o%S@)bQ{&hOgVbn9JM$w3OC;+!Sug7M;e08WNitI!rM2r1f_~TMmQQ0K7OcWC z&XW++3V6eVe_>M6;7l-$Z^^p|lM}4zq-O)7Gi6F`+R>lFBxEBo7<-|C0Qb*%rRcz6 zwj4k)tzrkiz%tdZorY*xV`$l0!nW014qgbqSl5R(Hdf{<}&409l) z(1saUNaj~dT+xJF_k$6jXLrD89TE(l#xI6Ev=pz$oT3QQ^Fm#Rg<4>V$P+P&1RrWK z=5id3lZ(bH0|se7e?)-OJ8^-e+*oBU!U`35mMma>-I^-3pt@|V%P<-zHWV}qJ1&p4q(?f_x8jAm73yN{ynbsnGHMpI zDqM;%^E%nS9{K~wdey&Xq@NK}u%@M^jl{bIYC$2&!l5^SLry3{6`L+h4SYpnW0(zE4$IOUX(^+P>oD7H;wv6~EZ+tz01-q^+w( zk8&scc=k_!gW%v8VJc}_jauy_8U*M#N;I3nP4)+#G~rsKrMW@9>5pTcOMKly4^OjV zXYf9?wpqCW21E@ zjIDq;e&PP%3`CtC29!XEQT!N$zbsM}n_Zt(gf)Xz6vHHZ(V|VFj(X!jIA{Y84zcEa z@C^@A@Ue!^6eSugEvX%UK;ywXc@TKC4ui~boVR03mC@TvS4N~7h!x77lH9mozC`Rt zg>O@GSIX!6Y$C~wsLba~Ux95AnKfwVl*uPewSST5$zEiZ{sLRQvZjW4UhKhT1bvYU z8`#)SNuWtf>n5D5JOy0q^Gd(4vR;a_`)0!|!HID-GC+69}G{iqEhIiFO0GZZ`}|Tt_4^9w|)edqZ=` z=@`=xJ*bt`xMccvXR*kt9I1?WzV#G_fOqHf)al*{9gZ^cK3hP16*3ZSx5K;NY|03g4?!s~7zIUe;UOmCVO&THLi_k9$7O}u`{kkhGg zgt(Sr0=$(P3>z>?)n!Tzn-Lc~OdG0`M~MTi;lIS!%mv_VkzzB9U7%fbu7C36Ih?}G z_5#N2sJ?3SL7m%Yp)*oEkgTlZp1TF)co!udJX(>?NptfMzXX~&pQa6*%Dhgb#%%pY z`%{|#DJrz}8}%<)Lxy7&zh$O_lU?1yb3#*7uHWcFMIVZe!nAM4kaaN5FlFnb)3kBM z#lU@07OBew>o=y3;k6>*bnFJ1kA|%#EbGkG=GL?ym6Xx$tLco3YnUN+1!tl0HaZ1z zK~_F|Rfn>wuy#)X$vw;5)6xJ%vA`6jhHt}u|JI0I>pEZO3Ff3OVTHKGL5?bHp^i% z+_Lf)J7#NLBYIr{uRw11BGOQ+*_)+cS5SJv__TXE$KZXo{ z_=?3Y_#_a9%E0%fR}qgtonX9a-tj^kAFiZEj_VHaOk=i^lu;Jo7>Eh~B|7)LzJCpZ zMgZzRkR@0d=Ih9Kdg_`9Dt^B^?t>7L@f(zQfy=^Rdt5QIRc?&*u4fZ3A$HW!VaR1~MGfZddxG9E6QxRgxQ&Dw=Web$sBEW|-SWDt8z#?fq|o1{;s7IPev42)T~F~*IT zdC+^SFw8bm-10}$LnalI_ThkZ!|TecCn;QnF}V@O{dFg^aQNIX3L9U{htFH(^;6U5 zxF133;Zr8xpgbcUfIB--5MR9r|C?|%74qGuQOwSX{YB2w?3W}=9ea;6VtvMtQYgEW8x%gXjiP?FfO+OA zpqovej!x;GL8F|LmG$FLxKTI{dS*&QQQ**Yqdf3(HKD4!)26{2-|F|Ht~{FTMIFru zEo+O<4OC4rxVz!_WWC~`3iS8iAx~)%><-hc#!n1Z;JMpdm~VM&-r}l(X*ai8+D^_D z;N**@@`}$yXTc~7@feJ0VK%N;J2AXE5S$K8L%qt(8p2Ns@-#vk@R=;Jtx6BnW_UQ2 z+-W5FP+eP87ZevMDtATUNhPyH=<+W{+xhR0<(Z+{|EV@L$nwsn5&Pe0_I%8^K41~q zx1;DN8oh%LZ3a)3ECP$|{}-0#mrVQr#nn3g_cM+EjhE(4Wv>7eXexxWt^#{@gy<>c zP`~$f{{3<1VE4n$-qGfJNO>~jeb{~XXm@L8%ELCu zS%r{^%lrO&H5$c?)boA+y~4O%U4#&o2{XK&qEi<XL?fnCC^FWha6RfNPY&>M10kUE+1Qm~_be63`V_231&Pq%cEj4@Np zS@?~v_7n9)P8tbxmxMj!Bt`u=fKzfN)W+7DLU#_^By$W^R~H!X#yB@FCaU&X+Br2% z3w)yNoC>d%L5E@y-(?m35vttQZ%A%OEBV{nQ2mdh|5dHQOX&Z?%CbfO+5c+#|AhA6 zh0a^0w7#ajPfvTH$G1#mPEcBbne%x2Z)5+QqT;i8j5vp%aA`4R`@$WM8THO5+c#3G z2h-6U>b;RYYp)S07Wcu8)g!RrHMRY-(SPs~n;rP^*C6KPA3)&)eyHvYR7U?7Ut0dZ zFKhqb=R*I@bFaX`mo%}23A4W8KH>m9!zI`tWLZrmF|IvAOAp#&+2L0W;8L_!8NKT) zJv0VzJS;)f^s_A3OEjv~hSihN+2CHR)Cg;eW~67X)Q+m&I^)yG=q@#>{~OT%%0VDX z@;@#+^gk<$HT{29{C|m>=TT`me~-~AQO7$5y{K?;x-F#ff@76#_DHj8{Xwb!w}`25 zN6+sn_`k}8ebfJYb^c{7|36RtFT->J6uFUF6c<>0YvMtgkD~n%842(YSvERA>*|I8F;Ec&zWkM%_ascd5q1HotJG`5~O?%DgHz5@DDy8K8&(6=#QJTGq{)3$)jwJ z(tq*(+_qFRjhwMH?_*X2RF@=2H@h?>TZ9;<<0eeF#>*(|X^Z8if7v)HImyhtm1GUF zJ2e3q&LJxk4J;$%w33tB;hTa(M`g-1Vpjx?S z!>dRBSzXze%(IVDKvlX!Rfjio6^&iIOF)S(y8_iVD#I`Re%KQxLZDmchFo4RxD2}i z_imIk=WI9L$ZWae;nyJ~brqpz+LX!_y%02)Hkw^@ny_;V-biB{?o}W5f<6B-JVz5f zHaihu;Vn=-@dx;Pir=jpSQMy~NH380a(OTYx7Gi62(JjUmyG@4TXAN^WGK=q<=w7| zuZIc;w>j~Gtvy`7smPIKP@QuQivO8)BzG7k=UA0d#6jXYgV8kpLJUru;*`N=rwu_`?k&)UpFywIQw2%8RlGlHd;;l>L?8qru2- znEF7|o?OnLeiiz2$p4c?e4R-Dx3IKe$^Wa%3$^_Jg!;b{v6+XUl>C5*5m}@4UmO2L zj5GAkc_jQ_U0t;Fe=p}3>-djPi~lm|bOlAfaN9D1<4KJ^crAcg4@Qb0PxI5j`x=Ze z@(y@4k<*NjkH9d-h<5wq&d$;C+uft%qy6Kp{k@}|y(94H`aHYv<&~@I*8Yc&n@7j* zHxJ(JK)r?bd||~8JKMXTK0wX+_DW&R&G+wlwHYtB*X`X8tVdS&=gnVWp0f*%hYg?c zc5%x6TQ=j^zurv_S&g9A^x?0LHyjU6gZT3o63{Sj9Y#oDJubXr|EzgfktbATU;gzk z)%6SuKZo9|xA5hKx8d=>zr4tV-1H)juDl_m^*eDKDe3gI2smJ*)WdmFA0PmNu$tg( z;Sl|#8w}AE3SCZKG)$cUN_4jz7Qh26_!eRUJ`e5x5!9l7&ub9XX%Mg^XnX~^TG2!c zESG@vfH9eHM}fJ+pUH(k3edOZXYlDqw+b%~F@iyu&Ug?&6hho6pOf*3PK0eN4a-OE zuxWr}Xk!M_HTJ^s0DklP{YKk5MaEi3{Y!EV&>a?SCr6Om{Ir^@V=Fs;>B50P(hdn& zny$WE>iOq}FoVbhuGS)<`-Zoafe7?;e7wE$)A4buipoO;9Nd)z&nI*9@_Lcyce{Z8 zs6`l$;4GK;-nUt`YlsCf>W>A<@Z-P>&xcVQ^xF9f5%+FW0&;t)l%aTjhAs7#l5NGg z+9FJK9fH~+7vx>_6z>(=;Z4lG)a}hl8!LBBaPI6EmzTg65 zqx8mjuDVOKV}Bgj&9>5jXL%g3mnQrg`LSAh*CeN4S zT5uV}*9w}^nO2!|8J6u1!!-2!%GLp-g@hqClG)WKMlS5zagh9b$w`XVTHqMAU%Q!q zyw}A9+`xh;ArG;E_y1`_(auj5VKi2q@#ga}*onWn*jovA9lLx_oYn5nD%h&QW~Mw3 z=}1&xTj#d17MDCGF$V+&BW!~48<@BHbqtQY#@Cj7AXROP_OP@K1 z5uj-R^79?E=P-uT91q27Sf+qAj0sX%{spt-0G`Yg140`ZrM}@l?ng3`A4b0GM*Z<% z$bI|U{9T-)pN0t!bvghcbj-xqzGzAz_q-F_nYJD`k)jHs2T=2bkyo{JcmMFD^~wkf z0`IC2R|DS$Y3D#5!u2;8QN0>^tQxy| z9-I~|$OPeLJ|p`TC<8XML0t1OC*^NWB|BL3gU{JR+c8Pcz;I_$se_unVy|MK}g5e%m+ z*oT0ZQVVLkCbyt=8bBM+SYrPyn1k(*weznbb!Wz3vgSI+`6jsVFGC3OWU`tF_qs(p z1+tPEyYSB~U|x!b6$;_aVL}LN`JW@0|rfPx#0q zr;XkY5JnB!_$kgCdI}SWkA`!@5r1Uj`Xywrr}5BQvmbT8@(6`VAXs#a8USez_$-un z%^6li$sq7_0vw_n`^m)&W);W!JSz#rMufL93=>6r0%JnR0l*otD{XF$p=|QoDCV#T zYIGhd{ewROb{^nl&4zXxNj`tU?4N)?X$aBWJ~xs80fI>+#dTg!`Q6Jb7 zfZc?ZCTG`p$bkK?;Q0GY)I(D@I7S3`jC2_ey?%5ac9Ag=(MNv*0NS<>M`8fwT~Lm% z<0M!b+H}nGlUy2x?biE8&9PW$L(xOR`eXq-j&}VafLP!sA(;0Dad>tu#Zd`KIuK*X z$wa_B$>FgHoY1HvMPe#%tQDq68LX0tdADKdPbD*hm{ zo1y!wfDNCuZTiv6GoQD2S1nkwI3wiE8?*xUgj5WXHS|kKcSxr#jRO^y zX0oq3Oex01qKBb%&v0)Vc>z^(ozd-=HSZaW%6!!kS`#hsJO)x1BMA$9J(zk97tn@I z=~}gWnBjx9ibJI5hpAUZ7?-tvCJRkutgnTnm*LiD6btDG-zMnvue43K+_B`d;?+=; zwv2~ywvYsr>uxk@nf@X~tT9_o8QISE$BCGBsdeFMtLKk8II3x8!I==H`$Xm&GDuY} zt#eO9Ni2|e_9weTBEBjO_olzQaV`6Hn(jmG>?c~=XoxuQ? zc~;yUxW|t7-Ho+B?cbu%0AU8ij1`#CzO`;e9ygbR4bRCo3E|a}u*sN7lCc+|k<*Aw zc*qS=)YIA`x zenDvlxdTNkX%hu8vyz2F;BwL^{sU&TE5_E+mhIpxGbf?ov&S|I)a3MB3QwBj^9zqB z%$@O)h`0-=!V}S;l^6s9i^eIXJwxUjLy7Tzhg{#^=8y>WIpr&?k%c?R5l!7K-gX?t) z?bT+#LY{;Z8f5mn8yxlaL`_4$^MO%`mS79}P?J=ZO=83KAk5c-ogvfbOofvpE^Eng z#3iX1hJ4z^mSE1lB_&=hHoCA7U^2PDQ7-V2+#3L}v(vyw@$f&CP=MZW1bfXYQ$V_& zmTwB;(Z}sHB8{Udg)Iv!2!^H?7Hy zf!Q)aMxH)Kx;Q;V2x)}vr_^v))c@Yc{ipo>XWsVzSzN94zkkE`pPw0=)o(w~>ivf^ zA@_IzE{p&5()9mZ1gcQu|I^|B-v0i_Kf%*4O?HcGQE=9;gl{6|EHl1QUK9PJ6fWd< zld9bC5oV~<8BaX;WKI7%!E7tz|2ji7K?j;CUwDIyVpe_bP4W}`W~(7K;_<0hY=ln-Tu4a&c%8a>)E z!CAwmYBi`FfcjJ+)Y?jHA6iE)~D(BX{|)ad%5yqNt#9} z|LH0^P5g7tFB1UoX@**OHV~$s)ayb4-;KtQl5~Xmf>bRRJg^V2u*Z{h_pinIUjUEK zSUDI|SA+3GJiR8xk_ikjAsrSZ>X|{|jSUo`;qL{-VizE!6x$SzkvNAFu2UonUEllf z!&&4&AXd!?9aIT5aZek{HVu1O66_0~?I_wJz<6Trujmsd3Rm`sXY0e!fT`&VGGJ^P zf19a-L6a+vMN0c0=4axaLqa!a?AdS1lS7`KfhzyiG!2#c|9fZvOXUBTtCs%n69EFYf;ogv%yH{peNlyV^~I0Y<_^_$sab4g|)-pdTRjP7y7uwd50`D5pt< zsp~Z#{Fs#nhvQSQ=9eJNDfgURMG*DM7*x6~cR26j!>m-PU{toAyPaie^-pN*SPjJN zQ!lzj8@dFBV*=G zb%9!*dLxlMM>0}ZfTs&%hP7{y4qf6RkI*0cVc)C=T~d%=IDHm`q5SPh0^7` z?fd7H703L#1A}d%flfiu7UkRxdRCopVfqQuU*^_09Qq@XXF!6#^#So=J4D;B9W+Ve z9i?Fkfy;;K&jFa{@!rR$&hL!hWycxxj$DOZF~g&xriVFQ%hq`n803&qWYFVCi9p1| zdvhoQ6FSIUPw>kVkdQkMSapFj5_U<+Mk%krEAL>B0#+qp+7!g-umQEeWGiY*NIR1= zBJf@3uxCuSqf`_$qiNJ>?bw`UKyUPueZeR4`1_6a$;?;*YpTk_VBThHUmO_Izyu9%}=| z_{{v3#t-PKWP_cB!=8PIzPaA*HD&5US^8RPC55gRXW1rxABSy+JuUA5D5v>ouQR1~ zW5DOz$h6eFR-1=Rf8B1X@hg=>ur|-dE#6}v%a~91mfp}S$1c0a5^Njvc6)S#S)24q zX@{-sgTP7PF{ngajKOyX|9ahq828$2+1@F0z{=_d>i3o)Rg@+`E7Cz(Z>B)Adgp1q z?JEU(UuqMjr|i-a{fH1`vLI=?h&+PU3_p-(bK|VaaB`ape0wSessZ-R<7UZ zdVdxcsl^sjsD%ccXKz1q-^=H@kBl`<(A7C0_X$u;xxAsW#;u{5A~w zJt3`|%%hAj`f4hNQ7o?^8O6M+7Dt&=5g1FQD0_@O+&b8Q|Ni*x`^|U9+dFSJKfOOX zJbv@b@zF0IceKbO`6^grJ7fToWZ=UtxLIo^UIPzWz($wXP!{d3>ewA;McN%%gs$uq zNjZP|5WeEcXYU%6CND~EDOPfP9{d@neyir4bx3gh0kM+zDPJhEPew4 zw?+usWWe(V5>yN6GxEmY=+R%W0GmD|@p%2(Yan7YzPvIioqbYr0d2DkIuZl7Cy-k^ zLQrz_tW?YCenJ7b;$X5q8x9(w`_yKo9t?cxbk65n$Ujg{mbnUMK)-k{{2qTnG$ zHA=-d51b{B8{-U9R*@%GL(RB?%&7A-QZ#cVdPgelD6)vHKxC*&MTs3^_PL!17aA_e- zx$x7}4kU}EqkPu3z!gbk#!iXM2{r4{kP7p(ugF3S$DImx7RIf`RkcE9r0b{jDVk?e zm*K=-!01LE2(rW@#W*IWQZ>np5I~bQ{)HES$YDht;`cV694h05PFbQN9|pgxDWtT~ z#1n!TPuk5}BMQa7uKjPAK{U=V7=M8xocCiY!D$d|tx^*yl4C4?E4GYf*O(6s#+yhW zDO?pR3WoCrT!d9X!Z5utfkMoo8ek|#Wj0jLCnRtPZAjoX%v??PJ6LsFMNjg131;zF z$6NehcUOdObqcMlER~T&?(9+RHTT{yPF1!OM@uQOJ`KbF9S5K~-|Zz$4F_@S)LQQd zW2l-^%D>ACHQ9m8aAvIozZXV4)reHEXO%SOm&gv6#j$(i;!uc_c)jJb?&yRBZ!zqK zLyFcCs@Ig)nYDD+ zJ}DHdQ_<|p1<3|I5<5~t0mREFJ$xq5w%H zsMG*b1<4FI%fS%=lJkp%IXE{qZ;oeZfS;rc)2_wVDQLDA?bFlu{_rR|XGadLm3j|o zazzKiU)pCfpbYtZXG9Psnh1lNG_a3rN_n=o2tjsS8ZgTdczz{ol&MM2*B}Be;&_K! z8>7Ak00DakXv82>6Z_I^A)Z_cWT<}ILZ@vel_=M4t3o=c6IB4(9*_EA7vn?G z3XpQ*-XeR^`0gg_{kGBe^hkdvurJ>3b|Uk^j&Zv@pGmU`xkx~cEO1^0ihSbq?8All zy`H*|S7p2bJRX^sy_xG#KRcT>fPu}_rq1J+w_f!-eZpghm8ut-2qaa^lk+r@F5uzF_a}iwUz{#g$XzrcqJp@g?`0`Rn!^@J(L#YO zou9N}EQfcR1lCkgqGk4@uHP5aQs7R39=eInfm=AwpykyQ>?0a%sTeGz$QOE+J*nu? zR-+L;7nB#mxdG`PB)v0eohO4|Cmilg_+~vRZ6qRiR~KRTLS$DKoH{qVz(iAyju5^2 z5YfNw5nCbR3??Ig2nHHZBT-Sr9d-9EFq{K`Dq~R&#ctmIu0U+iLx7bsfGj7F#CEq0 zHrNQ%n}$P*L#}Qog$*5}q2s(LoCq0c2Fl?TV}_%mrD>60oYifPC?l?hu9U4Si!$nl zC0e`|450dLf}p|A$G$jFvkEaI*$B6Yiiv%dU?2f|N$c=d)b=)0Zva6s7LkGWgiC^Z>c76=aI0-ffcW1^=7G(G|?CB?&W5_cnoY7X>Nq|N{| zV&ru>s(Rihr(*~)A*6=3M{vU-jB_ODt(!)k&r#|`u{lQZD}SXvZgZ7u>>*bwM%7jd zVNYl+82VLWnsqnnOxnpN&!>tzBIDYMJ<(C4^(qI#+|uSff$N1~#I37HA+@5QO|9cb;Q}mMFi{gRbw?5+{x$0m2{x<54UEjA!vx5cgcAK?M zr*1pdyezdSHa9|*0$u2Om15&9uA8yf*3rDen~oZ;wtNGM8uKcy)_j|aTJtKd)?zb@ znu|KQD&>k#qF|{)-#e2zv=%5_4L{}^Bcsf|7_VY`QrYdF{>E^l4eqHs!!!;9`P#}$ z0{&Gz^0v?Ve)<6v*q^@!*E1Am{tKJ-Xa11ojfY={(bez^JaKPWDKvm(>ith}tv!E3 zICX~M+*+slekqU43F9xB%+?hB8yqs2&o>-E+UOM5;?P)Dje}iTH342)J&B~B4Uy9P z8~16(`lvy(Rmu|q7~urUHuf{OBN4m##U8c=_c2@`W|2`>wO3F#rP0w&Najyq|NMkA zUY~k^b*GW|)|@bw-ybI4G&}Qvgnei|-R5xsW%f8Pg|Lu`*(C(g zIZZ%%;P5=ncvs2p#s`j*1{I`@YU!14r>4qVmfy8_UM;QfTbJRHp@ra>dMQu45#h*&4uZ;G z2ww!P?1gTAgjHKPx(1U?)cC_sS&m4?)nU{r+J%#r(}v1H_;jLY5j=(wKSQ2(ijWxx zm?lsng@(+ia3BORyAWAuc-}h(CDa>ay2R`>LrCgDK|k%}kvh6v+GyqxVe~pwsIEXE zjxyS(bKJz?>Wx1Ip5F7S&nW{~YoEisOHUQ8Ga=z{2=ni;Ig(FU|O0 z3(HH(b^NbCCjJ+y8qFYJ_|X{<45B^z{r)KrN+&b*6-NpC5DcOU0mJ@~7+$;khm*(e zQXBEuU7kl3Hb*1IiuptGE3$(oh_5d1{}~yp%Vvkzkol3LIO-O!%iL&nSvW_y^H58j z?I%P1Iinhp12a&OQR$FTnjo&&BUN_w>#SI0ME-P-G9@TF0 zsC5|t;gM@)yF;+U*w6*S@F$Rk%)P?|wMMZ6FMNX-H;PQ$*|8FvW+mUxp%+UX%O>Z~VTfP2wJvvmz1fMfmyopDh3 z6UEXJ;fJ0eo)(7Dl0mh&{x3hC7I#`=VBNiV0My`b<e0o3*zD#^|KT)l^KU1BUorEPC;({gw@aC^gOyRR0Y5+YxB?eOF= zJ!s^?Kir|$2d*9XGdgU|U5cm?yVCKdr?874FEq3K^HZh@hg~>L_4?orL8_}6&s`j* zaOUj4|NJNWuVPpcVq`G21(>{v>TUEqn9&B*Zl|XiQMvpceL&+P^}H|o054vIA{O3f z?dmqJZT-s5y8UA2oTlhXW@I`=z~m566kW|0uQoI!r;!v;<~~AhHZ#Kw0gl=B*iq<& zQC5Z>nmds?D7R7D$v5m$sg7FJic}}9diYU@ndWS-9kOon4R%SYu5G@-nXA$1KFBR9 zj8dnbQ}F@GbKyf-J1)%u3rwi6yeOcZGVSWSE>$7Trm{DR2^dZfl05OY*_52ZP3HWR zrK*?1wzOciD9}(GOdc@OrlDR_Vxq9n@LpTOP*$nsb(HrVzHApVmqebkS;fK}-8ZT% zQ67-a;HxRVm-4}`r?A>;ZRNAwM&ZHLHp*w2mD-jKV3F2G83a#=4C^=YS7}))#gFxy z?02nH$Sb^p_|xt|&i=&<>!YYm^2+)R{hNjSGsTVd8+MIkf2t2oO}n;?y5*8j3cGUD zZ)N+&0TBWh8sMLs`kO&x7W~f{FEA%ipo0jC7G^L&8D;NjilsztuT7 zV<%(8ZMi->8W_7td_zZKtkAI7GD=(41{n2~l9p?4jOuz}%eiwMmA+-4@qJx=2IfKj z%uv@`f6rCZ=M`%Tb-R+SbhqnRO8b#`lIi3=|ET`x4dA02U^pA{+X4Pn zxa$N5u-vf~wJ>QQSu1WVrP!Ig$RsW~FX~oYa^CO1=T(uUy{HZ++Co5^(hX1C+MScn zxHDh8-63-3N~gfi(~3LZ^mjM1;C6aO7*K>{BA>-3J+Z_?Ws=G=1goz)ri)3fIiIvN8=&W@k*Dq7i_rrGPhw^hnJpnu2_JaftWDX-|f-wu1Vi!Q=v(? zf)W%cqo%4Vg>|$s^@q}Y*7+{``AeDAtg{*0t~@i@2R4#5O=NY$*bMT2FW#g4dR83? z>qYjns?p|DiP@@Yv`WoPb349KZ8U3KcV0)?wFEM-^|_Pxa@bV+ZW*?ynkiPMr{yF; zU7TTMSJ}?4*wU`d*3PuHdkA|w!$7gpsW}XdAHeeN!ENs*wZ6-P0IoD@W!e%p(Gg^z zhfI)gP~JtrW;;6zUg7cqHyn(JR^V(iV)|L5Enkol@}veR4g`=HVFxmLiUq_0^4f!Fe!5dmnZztOKzs z^3~>|0tSm3kZiE`>HYg7EM$U40*|f9m|vMIWe}%t zqkjK-`+DdP!tPPD+XIo}DuCz*!jKYJcWsS+G8-o5n|r<~!*S75y9?m;XLdVn9e1`1>fe1^M7PZuDmV$cl=M!h zBp=cfwhg`HNJQI!S6&Vg*&C(-UE=*xtL1z#l`on^5dioc4`A+t$rN@^D5ln2-h=ah zs)I%;7g)^yEzGZ4{$ES;D@!&1_tgAfB`%M@AKi!7%j=?y8{>}?#XMQQ>(EUF9;_~pKGBVDH7YfsQ3~nq5rFk^CtaY zURYSH>HqVf{~8(Q=UzaAEy$Rh2-_Er$Q0|$EtG%r5ENJK=fQpZ_-GChTir%o%FZHA zD~WD{Tgfkw<13r3O=Z?d2qbo>HGv`2{A*+1%swC>j}&L~qRX zXL5(wBZt&cxccm-%vV?Q4qjEfP0dA=->X1wp3%ixO#h=K<-5y%o8ZM~GWx$@(*K3k zmn$!8`u|+$zw~UMly)u< zK275w@WSCm5Qh-F5>!~A(@H!ay|q%Ac~Hiw%yLwUVes_X#HArSSS(@qPa?Msb^+XE z)!8CDhH;-@BcISU!AC$3KB+Lj`p|rJKlD{ zc(Vb-iMYDJj0Isr^qKwpPf&R?Y7~PPYH|^c`#tXA%&>aENKH?mg2;*;1VaQgB~~4` zF8IjFq-uiz0o;pi5B^Z3`m4zQ37!j406L>b!2hKMOaII8|79Kj>lw-alW=_mC=>I2 z&SvT?_Q54{5Q?B#>R?VbPyno{f55ON@Nl&qoQ}_Thi8#(L@7 zRt4Nn`hzw;|I^19U#iK|g=T#go{wYkJU^mxqZ<)ITsbC{U6ynBIfjYFjHV+XwsT$5 zbs*5*!ZAF(<&z&K+LTnIXJdun?kzSvbMp3reiCSn3acBB@M0sZ4bINidZOtx3JoQ>Hkd|7v*V)jt|f74_(JRe42rp#_#HYz3qajkB+cSi~zzYgetly}ADSpJXtaW9`%-8-9-j0v;Vf_zs32b<@uWbdvf`&iv2ggT?(bI8q5Fe_@A=7dG0hy(`Yd3 zcfrLvim&I6*w;7saZ`M&LJUgq|E2B!v9wU@|DTKe=Q#9g!X3g;d23ZRbJR9vJ0}c2 zprMbWf1&r!o1M$Pr+nRtl6q7vwvkYPWA!%vHIw(^%9Y@mbKQQYU!K{D`l-d8; z@!ywM7V7)|&!_)!A~1)L3V1AGNk`}Wn)jna?oKA84ZIV@60l?o+~$Clr}sRlM*Z`5 z;Q#RX4FxROl1+Tw}kD6 z61+bevdul@0bpgIe@rEQ5d800O>Zjf6yJ#1@L+Uub}se)Uer^9?Mrn&1ka#9B)qOw zKnN||zSu5p2_5}U@xB5R72*EHIF$x$X0e3y70pP+wY7n_t~ZC`!Q93_Y{&Z_{g}vz zYI5{N3B0PF6$V{ZKis-z2|9Jh9%X%M!16{94=94y>cI56ZA1~gb-tt|;}LoE#SA1$ zpH=uyyKH483@YRBot+gvwIZgVfBMFc4+8j{Cc7VYc86eZwtmCW>~%Y*M*h1%@mc1+ z4Txq{&2F>fcNklF-c{I3FFIahVSfG}-0b#(;{GY$YEPEQ_`k_=zE$V{Ru+~QE&Gqf z#oGSkFT?-kB~&Zo{p>cA@O>403m=i^a|rWUn<`4RD!Px!dgP7N<%AHNZ*g@${*h6} zAU4)@5%fnvoXiPZ-%n;bt>?hjK5a&;zZb8WgMg32XXXv|;5=q-s0iJ;Hsdd9cG6}i z!~H{(l{6d@)^8-}-)hX}k72JD3^#`%#a)zH2pF;nZqr>w?}nQQ^@Etqf|TZZk>2bu zWE(-edpOl; zIBg7X;w)1V6fhvr01+Em-}4xG_Q5p@Mte4{4fZJuU+U6hZz#@x1$u5T7+!{PG{obW zj)U`Y-;bv^w3wPODRWNE2Cti?2FOGa_fU75QHTM!D@};1-eo?{TB5jR3Ry+rRcZmN z{&`sW@2=iI<@R5Tw*CL&d>#Mq+1Y8FOD*Yj;Uy% zs+y*vJ{%d{l_qkEyzz`y>{yDdrz@&VwA58ril)oyC)IXfxVsCX1;WsfTm_?VPjbPy z#onCD+bZkbRus{9wE^bu;A6mWYC^2UIjE&*;=Wdjs>$*{dbUrNcU9Sx!m~?pGi5lu z%uLyyE8?5(apv?nGu2;7rr?@TadqSM!?U1!-R(<1u*6g#(TXcBzJemj*ai1KxOO6E zFBHe5j%O{ttvUBetraaF;*<&(Xv7B$ma1U_$=vdx4rB4^pG5A`U0#%@7AxAk%Eo2f ztjtoMeYwr?8#@AbarVDVGsR|pedSkHP}u;YmdYKx=M(8f$u8A&!SWQW4rKDD;#bt7 zW0@q0U}9wvEWC$W9q&O?2$E{+jVqBL6~Se95ROLdAhV(&BxdPzDxzt3&!R3+S)pG? znCzMTZGx9zusiHQ_~0{W zS*;QP>sWnFm@F~3VL`^kVt6iFY(5UOicujK@bqdeXV%2h1LZjsjVc;pE8?ITg>w|U zNu6VyVw&eVUfMUM1a4&hQHh&RsSxb}D^U)6z}z!%X7+SUF1G_&!*zZI>s%i(|yWflbD}$tr9>1cUuJWFO-}pi2p02<)O0Er9G&}(z2n_+W(F+y|z`#DS6J?wYGVc^ebpL zZ+vx;dGjbc3qiddJr>RGHt#J)c*x%MH6}m4Wc^N4|2&%h_ii?T#rognm*DGR`G3^$ zpZ|LLU!kgn=F|dimLvA9x^B48m=G^5?5<4nn`>WKtYNOIc&?~BF1J(6(HryJ-8#ox zQfy2T>p0cPj@$`ls^rO3$dKyzVb#ZXQ2;+*Tf+M(fhVxs`)etH?_ovwCsY2SaTeuY z!(Ve*b4$;gYXJE-(D6F#7Q+O^;ZH)mQbONLuxzJL1tT3}8nfSjcb1u|o7=mnkm+RY zCIQSGGvh5K*cm&ho!)s+tv|UO>e$hGMmH}*^oni)4^ zOHT1R0WAUhKW~?PkOqo8*z_UJ2aHL$DIEG2ZT5a+Tp5g$*;eZ7n6$k(k_sC4BBQ!; zZF?nUJKnqxt!T%HT|)0i7KofAvvshmVpsW8s+2#x@O#mf{9Hkm?HlmKrz17Ac`9M5 z)>5Zk)`n`e9IGp9sa0-0q~=}OFlSq(yg=(&tne(*b=t6{f6do!R#t5CCzzPx-N60g(WncTyne4G7tEU@wl@8v2&4sl@}SI^!JIXr z_>&gIWJ=79%)VSQ-o4l6FA}sg`#p(Iu;W_hjI5%7vsF_m%Npc9Glj*^OYX6(J+;Qn zL#Esw+>b~$<+PEikPr$o_{UPFTZQ8Q9Oj2DPX1QH0#tz-O47OH?C^SU8uc4yDoG7r7q zN>lmP_2I6pFb7fdPRCH44($%RQ49{JL~{TG{1l@ch5LtX@0VyyB#4t9??k}I$&7cR z90dHS@5hP9IyvFXI59}pU*K(bviHBc`GA$i|6a1+|K{uXZ_m>It0erl3jg)v`k(Cm zzaO0Y-Rrp{CL$9BVt4&{jPmC&4Zhtu0cZ*SuUPRP7hW#DT&nT^k9+_B9v;gJg8)%{ zhM-p#UmKZ*T!?xT^{yE$xgkpdEa}Lu+dujJae#7SpIwQ=b63%M#0uU<@s%IPgJa+|`VKh!2{{COG{J$6H7nf@P z@29>0*%9xYoiP@AhEh(Q>6opcw3I{@s8~DNfVZ5KG4w}F&^)qq%)~*&SxT!jXp#x1 zsLPRa;Q6eCbaB@1j$>evSz{ap*>CNkTd7pXt-c@rv-9R;bPi8S{6z`Ue?%E$A}Uat z|HHEXTd3pzJuUv1UHEP&;J2i-$||f?6j(nO&YEhvKoW1YB!Sd*vvA-{Pb@l6HJ&Bv z&gm@(g*cg2r-*6a3;$Bw7tzZ@UEjVB+>?5gU?NY*x&5;p??{kgX<8J z-_r~Bf%gVFVAd%1_NYP#4@Eri7ue8TVN$prbdrrFt=d4T5x<_!(WlZ(-DGhB{I|KJ zid3Ks|L3jv4~r|16R5`jXNdo~yt0D|2rQh@bZW*rnsmF#$7&6g20HEI01;Z{W*usTMk!8VrX%LMeYbsvB<01d6 z4PEZ09N`j%q9D6q=Usn@&zSHe!0EF%pD%deufG3gYKxb3m2Ej1}=P>!GPFZpJk! zAtSO=r#3A`lr%=xgiyF7)Qyo6=?k(7&Js%(jLKH0miPL#H@kpSvW9kb%C0Q7 zsY;S+V^f=rGe7JTtl~|j09M1yT|G7B?17_2qs6Bs?8~(j+*T`zPGT5(-kb40f1!@< zcz6wgy$wwXpzfyrXs(Qtj|3iW42{+C2wvf<0eb>l)PU9B-DrK@dY`|$$vV6J;(d3+ zd$~P%R*AJ7{6Dx?+?Cb86aT+t#s6PjuKEAx!T(DKygVLS@xCimqieoqca#fU(mUYhy87nYXm{BKY5(;cw$ zJ{~~0^6}s}4*qu>vTL>Z)6T)+?*5*))c&vbd~0fo0oHH85;0boY;M>$zt=nNho`Y0 zUuWMDE)Rd)JlNUh_SbXW(MVh^(q!jo9G^xXqTU#8_0h;(mFgY@gNWe`z$AzSS#`X# z27WliX1@)?S- z3DRQ*Qs?X+uzd{T6q?Bm@;)Byf804Z+TA(C_FMn{_wnJ;=HB+^!M3+JZ+$yH*!h1y z?XvCk_V;Ar&cBa#_TYdHz5Tau@$<+1!`&nH<#=~*d*|a0`_JCd@z(zK4zy#_;!^@P zh4F&NNWPH#@FnRd`0-%(r_G}s{C#%&?f(1szZ~!Fv;Owp?!H@JfDVD69dq*0M1m@! zv&b1!^_E`s?35jqT}Mo?9}d5Au+sJ|^MK{UbUJuPk;LgJ{VfT}XbQ&bbnqWFLLOT~ z{rqFme~Arjw;t*;D9Qi1xN7NtR$eaE??2Cj{x2-GfTT6IK5YKa&N2J`X8&;K82)&> z`yPl3k-X;n-8aYYcaL@sHs5>7Eg_1IHV@wI93Ag%e%SF4Zy6=(1?R!=_`4gW>8;Wm zqkjkcyW7Xxy9Z2sqVcyr9UK5*JKXi8>E3D6`;2=VZ zZ#827eB9q-V;m~&vkq_PaQ1Ut0F6~^33DapLf-A{v67>m?c@L6+s&KZ={W57=FZ`& z!zUQBm7U|e1!!5+v2*sh<4>{}yza&A5sl={F?$w#>jv~U{u!q!(An7}?X7qJ^)I~A zW`Fkkef)P6^!#Cp{@1fV52M-h;du5xK^#HEgwXGW-PwWPWq-2EU<@9-iKy5E|MYk^ z9L_S%nDuZL2JtLvx)L@tT}b_)`R@Jx8xN1{)8WznhvUP8twVmtd^%tNgID9B_dQ(k z`r+zZcRN?13TIWS*BnC@Evnx#=gY2Yc0hR(&O1rxtPSRjZ6P&RkPx&zl1<}N&vxuK z`>0IoR^cOZSR>~+A3wc$zq`eLX)E1Igp%L#!L*=?vHq!CMj~%=MJ$Djq0ra~;1SO> z=I5AG&f^ced;168yMrAz&4Xjc?J&Cd!#udh8Jw6hq{J(Grh+4DO@V2A)6tKDlQ~N@ z*}C`p?^u<0>%UIp3Wz=ab(%_bv{n$?K(Nw|I&N8^$A-aifc>(lT0(W&20 zXtP))(R!beZAd=X+eqVaur_CZHyRv75k6ZaY(2r|IE_98>~xLRIhA-%V6G?#klqncwaGuz2RbTv+95^mRf7Y?p6oE zWk^$=hbiZkA|%dfpjZH%3Qxf&g^sEcTJ3U%QYy|dWOLSOH9T-aY)o%_*Zln95DCzi zPUl^~^G` zb<8q)_Ew~dl|a1xKORRG0XJ*BjP4)d?#wHxYx3A?is}j|@}?2$+Y3J|tu6xe7d0xH z14Hylrjb>*X7cjo>Qe4ZvUZce(ob9b!v5%|7cJ%77Xhs1LV~T6F_!u-iuaDC>&VZ?G=bWKwD|_MB_*2`9F? z`~Xv1U4DegF)&zKyzk_&lSi1`xBKtgw+G&}gP@__%sYb8O*AyUwO@UTyOlrABJZ^l5Sum&$T)0dqrMOXUDV&wi@f}4Mg$Abn(hR zB>~oZFO+edwY9(V2Jge+S9acUK(Quyb@S^3ErG+zY zpIe->34FN9yD%XWU|jy#ScB>vp=)R0mWIP~e)Thy$m-@c))`2-srDS%ZpI*%A=|0X z@Vll!)E1cT#12L_kc{la{Qz^yC;L9?`Vi!I+fRK_Rqo6~E?28&oIs^<+%i_BjeZbL z|I^h7wv>VX<4YChSc)`Z@0F7-15A8?*QF#!4pBJYZD}8o^$L`@zeVbH0X6jej()Z!K@`G(3+^e_NZg>&vcQ%+4MKaBPfbLdjyoui#Cu)FbGJ5jg;+qC3E z9+8^wc(Sqh!{R(UfxKcB6nK2dD9*q&**K;dZohy3>L1OVN-cy9XLH9+eF7iH?d%a} zL&~&FLOo)o^XAjL z`$2_tH{q@;Z}FE6&d9)(d5WBOPCfStKkyDomhKLFw0GYrCL&#JYjVR_r!z;|Ga8TG*q6TD&0?XmUkPBdSHa$k~N>Ci@=Y&7jOJY*2oaw z@6*^5Pj-R^8M(QbR#9*S20O#_(+Ff;StsoE0q@kp3?zAK2AY%4c4k+iH!( z=@AU3E;FZIMv*3Mpzck@wnUXCshmfbChx;RnaVqH(WcT?In=4*lqYK9qymM=J2BZP z2e|K=f}86B2Zqumz0S4I?|j&IKj8_Eb)rFX4yK$Md|>-qa%8NAG7qDHN^h%=x)%*q z^21Ff+FjgTWiY*-#$n%euwuoE(bZ&nGBRx|)Rfuk4&L7rs$=9dpF|nd3 zG|#3oOvZ7Lzfs08<>NNdoO_te-H;l z#<_FjNeB`^VRuSj^u4pF4~XI&T?F1BOfc*ZnD9a{ft&f;_S#%Fs`8LvO^+t0p393T z(3HWn0M0&>l-#B~4qrMQlFMLhIsH~+#(TWEv=r2T%B@gf^cif1oQ;Aq1nuPt_;PCU zG)8L0H0DX?Fe&BP8@~(09f%Z#8a)Bw!I3@T441ShrU+!86htK&vTv+0aNuKbXY@WL z0dL2r>>UIN7RHdSu`WlDSzNZS;6+z|6TGHfuPL2Osx(036r^ghXaCTw$H!gh2f9B&c#6@fM$AQiea4SXQTGf z`77W*oG-lO%=LXHAfH`t)l*O~JX@IluP+!I+z8S`81;A-F%rG&}3lY_Z*o;}It6an_?C zyOqWY|09MPjXJz$(8ENE?_&QlOs_vgpv96{bqGiWY|hj(#eZ?OK~-dt0lA|2+EN z%cUhG|FiJR-LHU>{4a}37XP<6|FV|bRLJrwS9;zP>10S#^t5R|ctrT8DKP(|nNLz;C zfzb}WvBe%I{pgDAWzhyLv;E62j28Vz!ol+xLVDtU7e<@qxaH45hOkVm2c z^?a2ht67C-^ZuJ7|FL7QDgc(se=FAgzpz@r|NdR%KjmIuD7e+<+spSC+8$6&%Wjq@ zl>iqC#lQB#O0fjEP$>R6o7WOxE&n;?zp7V2iTt;;FmKWS#noE>U(0{__r#w@{^Pt8 zBn9pHaqn26_!9_%^VP+iYJ%W=kr+7ZmVy3slHgspu%e_m{~&T=iM&}^Qk=iHoLDGp z){DM{5Ss>kpGre9@zA=@Wrow@T*NjYw@qX{|n{6 zGrt!c1E3zCB5XXA{I@c{?C}4q?SGz?{P$vZmPds1_M-8*pL)?K7ob5p9Q3JQCi7k-E9#-E5YNmO^!R_rq3}1(5!V(nkyLU5x;)7*`5*qF&heLg zzTly!xU>RO#FS500VZSuax#pLCG;G#DsZ5lpFkK>FJk|RGvM+VhavmN*r>Qt43Ft0 z;jkNctm7+xn0kyfU3k$M21AADsO%>57_dU834<~(4F^HnI|v5B;FJx)YIHCB;d#b8 ziD^S*XzUC^pC;bb1%vhhLyNO(h=&@c2z!72t7uaCKZ)H(HTpllGH=O$Oe%a?)Bk5i z|F;EZr2bci3_x1E9%Oks2aHIfam?@#Qj;QT5UqS5!t65s$S1cBy`50&F*1a7$R+?9 z;sd#0w-B4p2*U8P<-smFX~5r%|C>bbe-Hd$T$-=(zsCO})la_Kr^f%s!~ZmzRQfBI z|7`#Nh2`ZM|DU=12X6pRF8_7=Q4-+u!?TkA7M$|m{9Wb0N*dxaZ^xCYa^bvFF8tvi z_jZ;nmt9FPER+lvYsv7L(El(>Nd4Cj&eG#k*6LH`M>< z;y);5i`c{%KUQ8 z|JV3miT|Ii6##!U{+BQSljHyL%R2r?jsN$R|7U9jz$4;+bp=2v|8M30SXrp^|JL~b zOyqwQ4F0NAfo&R(BKQ zw14gW1&leL68~4`AISf2etFTd|0bWn8vkqj&$SzTBy;Ym8viTd|I$O^|9p-AHU3Y4 z|4X$y#~+RVl=u4~;{VJquUhi|;__-8|EqwT==8lKK4Jh{J*@iQu}{DEBwbpen3B~QLsJ@f;U6NzVxr+5aozT zG=i8%2R=jZWPSRF-pCcVXoNwH)Jif`W7H3OvSv5xgVyl>oZ%0TbqF)6<^RI_KVh2) z0U#!h0aSwjt1s>Qe|4pP|Nm?2|5Xn}m+t+SoOij2xd2c1FqDL&fTb;PHmv4ksJJ9f z=4?2Tli?qV|6m0CAo(BWmzH1J`Tt(l{Quwm{m;eC*Ot4_<^D$_fCrKPmzGy;`M>7> zYy7W>|26tQCH_N9*9n8b+=2gJS*rQ}zYqVP&;8rDUGx8cEdS5(e**UZF8BXJ?f+Nb z{}u25GSA)m{(l7gpMd?pga6OsQf>cR9C`(;5!sW)3K$}DMS8!V|&{{is- z((jMi?;yzp$>hP`@528tyj-s1zdtYhXW)PVo)iA04p#SX7D@iO?g!qG3B2pmUAEEDVA>n`F{X4p!nz;WB@%~zf2qCgU+*DLxfgAAp|>}F%L2sx{|EI&z8V0$QUD&QbAkW> literal 0 HcmV?d00001 diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index e6d4d2d218d6c..8f55f236996d4 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -14,5 +14,7 @@ if (!majorVersion) { } module.exports = { + // Hash the pinned Screens fork's native source, rather than only its version. + nativeModuleSourceType: "files", extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], }; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index c0db129ee8c86..3115a5a34a042 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -287,6 +287,7 @@ @implementation T3MarkdownText { UILongPressGestureRecognizer *_longPressGestureRecognizer; UITapGestureRecognizer *_pressGestureRecognizer; NSArray *_contextAccessibilityElements; + BOOL _textLayoutNeedsUpdate; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -384,16 +385,18 @@ - (void)prepareForRecycle _textView.frame = CGRectZero; _textView.attributedText = nil; _contextAccessibilityElements = nil; + _textLayoutNeedsUpdate = YES; } - (void)layoutSubviews { [super layoutSubviews]; - // _textView's frame is assigned inside drawRect, which only fires when - // state changes. Trigger a redraw whenever the host frame moves out from - // under it (rotation, parent relayout) so the text view resizes and - // onTextLayout re-fires with the new line wrapping. + // Resize during UIKit's layout pass. Doing this in drawRect can leave the + // text view's drawing surface clipped to its previous width after a pane + // expands, even though TextKit has already rewrapped the lines. if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + _textView.frame = _view.frame; + _textLayoutNeedsUpdate = YES; [self setNeedsDisplay]; } } @@ -455,7 +458,7 @@ - (void)drawRect:(CGRect)rect // entirely when nothing actually changed so a JS-side state update made in // response to onSelectionChange doesn't deselect what the user is selecting. const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + const BOOL frameChanged = _textLayoutNeedsUpdate; if (!textChanged && !frameChanged) { return; } @@ -482,9 +485,9 @@ - (void)drawRect:(CGRect)rect _suppressSelectionChange = NO; } if (frameChanged) { - _textView.frame = _view.frame; _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); } + _textLayoutNeedsUpdate = NO; // Text attachments have no native link element. Expose their existing runs // at the measured glyph bounds, without inserting views into text layout. diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift new file mode 100644 index 0000000000000..43070a614582c --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore +import UIKit + +/// Reports the body geometry of a native navigation column. +final class T3LayoutMetricsView: ExpoView { + let onMetricsChange = EventDispatcher() + private var lastMetrics: NSDictionary? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isUserInteractionEnabled = false + registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + lastMetrics = nil + publishMetrics() + } + + override func layoutSubviews() { + super.layoutSubviews() + publishMetrics() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + publishMetrics() + } + + private func publishMetrics() { + guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + let metrics: [String: Any] = [ + "width": bounds.width, "height": bounds.height, + "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "safeArea": [ + "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, + "left": safeAreaInsets.left, "right": safeAreaInsets.right, + ], + ] + let next = metrics as NSDictionary + guard lastMetrics?.isEqual(next) != true else { return } + lastMetrics = next + onMetricsChange(metrics) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5813c8a0e8b6..3674e232ee5b6 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Constants { + if #available(iOS 26.0, *) { + return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + } + return ["supportsWorkspaceColumns": false] + } + View(T3LayoutMetricsView.self) { + ViewName("LayoutMetrics") + Events("onMetricsChange") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 53b5476d3a2da..ca295e389fe20 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -120,7 +120,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "~4.28.0", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", @@ -142,7 +142,8 @@ "install": { "exclude": [ "react-native-keyboard-controller", - "typescript" + "typescript", + "react-native-screens" ] }, "autolinking": { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 14fbc8b82f51e..b215ba3380700 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,8 @@ +import { + createV5StackNavigator as createNativeStackNavigator, + createV5SheetStackNavigator, +} from "./native/createV5StackNavigator"; +import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, getPathFromState, @@ -6,7 +11,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createNativeStackNavigator, createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; @@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsContentStack = createNativeStackNavigator({ +const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({ // The outer stack never owns visible chrome. Settings routes render inside a // nested stack whose native header remains mounted, while Clerk owns auth chrome. // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsSheetStack = createV5SheetStackNavigator({ initialRouteName: "SettingsContent", screenOptions: { headerShown: false, @@ -670,7 +674,7 @@ function NotFoundScreen() { ); } -const RootStackConfig = createNativeStackNavigator({ +const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f64370c3cead2..5120e7c1868d0 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,3 +1,4 @@ +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; */ export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; + return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; } /** diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index 6024ceec08d93..4a3877571966e 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { ]} options={{ headerShown: true, + headerBackVisible: + !layout.usesSplitView || + !props.backInSplitView || + !(props.backInSplitView.onPress || props.onBack), title: props.title, unstable_headerSubtitle: props.subtitle || undefined, ...(props.matchSearchSurface diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index df18347f336cd..ed42f5345abe7 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,14 +1,14 @@ +import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, flattenFileTree, @@ -17,8 +17,6 @@ import { } from "./fileTree"; const fileTreeCache = new WeakMap, ReadonlyArray>(); -const FILE_TREE_INITIAL_RENDER_COUNT = 20; -const FILE_TREE_RENDER_BATCH_SIZE = 12; const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly selected: boolean; readonly expanded: boolean; readonly loaded: boolean; - readonly onPressDirectory: (path: string) => void; + readonly loading: boolean; + readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { }} onPress={() => { if (node.kind === "directory") { - props.onPressDirectory(node.path); + props.onPressDirectory(node.path, !props.expanded); return; } props.onPressFile(node.path); @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { > {node.name} - {node.kind === "directory" && props.loaded ? ( + {node.kind === "directory" && props.expanded && props.loading ? ( + + ) : node.kind === "directory" && props.loaded ? ( {node.children.length} @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { readonly entries: ReadonlyArray; readonly error: string | null; readonly isPending: boolean; + readonly isRefreshing: boolean; readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; readonly loadedDirectories: ReadonlySet; + readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; @@ -127,12 +130,13 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, loadedDirectories, + loadingDirectories, selectedPath: controlledSelectedPath, } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { [], ); - const toggleDirectory = useCallback((path: string) => { - setExpandedPaths((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); + const toggleDirectory = useCallback( + (path: string, expand: boolean) => { + if (expand) onLoadDirectory(path); + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, + [onLoadDirectory], + ); const handleSelectFile = useCallback( (path: string) => { if (pendingSelectionTimeoutRef.current !== null) { @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} loaded={loadedDirectories.has(item.node.path)} + loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { handleSelectFile, onPreviewFile, loadedDirectories, + loadingDirectories, selectedPath, toggleDirectory, ], ); - // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and - // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` - // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge - // blur to a scroll view in the screen's primary position; a scroll view buried in - // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. + const extraData = useMemo( + () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), + [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + ); + + // UIKit owns the header inset on every supported iOS version. Keep the + // list as direct screen content so automatic inset adjustment can find it. return ( - item.node.path} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} scrollIndicatorInsets={ - NATIVE_LIQUID_GLASS_SUPPORTED - ? { top: headerInset, left: 0, right: 0, bottom: 0 } - : undefined + Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" - initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} - maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} - updateCellsBatchingPeriod={16} - windowSize={5} + estimatedItemSize={42} + recycleItems + maintainVisibleContentPosition + extraData={extraData} contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} - refreshControl={} + refreshControl={ + + } renderItem={renderItem} ListHeaderComponent={ <> diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index e0421e2a587c0..6c8cac046bb20 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { key={JSON.stringify([environmentId, cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={entriesQuery.error} isPending={entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={null} @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { // Hand the file navigator to the workspace so it renders beside the // navigator, outside this screen's native header. const renderWorkspaceInspector = useCallback( - () => renderInspector(inspectorHeaderInset), - [inspectorHeaderInset, renderInspector], + () => ( + renderInspector(inspectorHeaderInset)} + /> + ), + [cwd, inspectorHeaderInset, renderInspector, threadId], ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index a8bde49488fc2..9b945d1a5f5bc 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { }; } +const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + function compareNodes( left: Pick, right: Pick, @@ -84,7 +86,7 @@ function compareNodes( if (left.kind !== right.kind) { return left.kind === "directory" ? -1 : 1; } - return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); + return fileNameCollator.compare(left.name, right.name); } export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index d35ed1a98bdb8..998c23b1e3001 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: { sharesBackground: false, tintColor: foregroundColor, type: "button" as const, - width: 44, }, ] as ComponentProps["headerRightBarButtonItems"], [foregroundColor, toggleAuxiliaryPane], @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { key={JSON.stringify([props.environmentId, props.cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={ canReadFiles @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { : (fileAccess.error ?? "This connection cannot read host files.") } isPending={fileAccess.isPending || entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={props.selectedPath} diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 604fb3bd5231e..1553736dcdbd5 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,7 +1,7 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { }) : null, ); - const [revision, render] = useReducer((value: number) => value + 1, 0); + const [, render] = useReducer((value: number) => value + 1, 0); + const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); const refreshVersion = useRef(0); const directories = useMemo( () => ({ @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { }), [cwd, environmentId], ); + const [refreshingDirectories, setRefreshingDirectories] = useState( + null, + ); useEffect( () => () => { refreshVersion.current++; @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, ), ); + entriesChanged(); } else { const error = Cause.squash(result.cause); directories.errors.set( @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { } }; visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); - return { revision, entries: [...merged.values()], reachableDirectories }; - }, [directories, revision, rootData, searchData, searching]); + return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; + }, [directories, entriesRevision, rootData, searchData, searching]); const refresh = useCallback(() => { refreshRoot(); @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { directories.pending.clear(); directories.errors.clear(); const version = ++refreshVersion.current; + setRefreshingDirectories(directories); const remaining = paths.values(); const worker = async () => { while (version === refreshVersion.current) { @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { await loadDirectory(next.value, true); } }; - for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + const queries = + cwd !== null && environmentId !== null + ? [ + projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), + ...(searching + ? [ + projectEnvironment.searchEntries({ + environmentId, + input: { cwd, query: debouncedQuery, limit: 200 }, + }), + ] + : []), + ] + : []; + const work = [ + ...queries.map((atom) => + executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }), + ), + ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), + ]; render(); + return Promise.all(work).finally(() => { + if (version === refreshVersion.current) setRefreshingDirectories(null); + }); }, [ + cwd, + debouncedQuery, + environmentId, directories, loadDirectory, refreshRoot, @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { return { entries: snapshot.entries, + isRefreshing: refreshingDirectories === directories, error: root.error ?? (searching ? search.error : null) ?? @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { (searching && (query !== debouncedQuery || search.isPending)), searchTruncated: searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), + loadingDirectories: new Set(directories.pending.keys()), loadDirectory, refresh, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e1cc7448eb591..ac58bcc8c7871 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; +import { use, useCallback, useRef } from "react"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -9,11 +11,14 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { // Static header config (glass, title, fonts) lives in Stack.tsx // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, - unstable_headerRightItems: () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], + unstable_headerRightItems: () => + iPadSidebar + ? createSidebarHeaderItems({ + filterIcon: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + filterMenu, + onOpenSettings: props.onOpenSettings, + }) + : [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ...(iPadSidebar ? { - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. headerSearchBarOptions: { ref: searchBarRef, autoCapitalize: "none" as const, hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + placement: "stacked" as const, + allowToolbarIntegration: false, placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); + onCancelButtonPress: () => props.onSearchQueryChange(""), + onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), + }, + unstable_headerToolbarItems: () => [], + } + : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + onCancelButtonPress: () => props.onSearchQueryChange(""), }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); + unstable_headerToolbarItems: () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu, + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onStartNewTask, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, + }), + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, }, - }, - }), + }), }} /> - {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da2975..48f074a3438f6 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -13,15 +13,18 @@ import { import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { ActivityIndicator, Platform, + ScrollView, View, + type ScrollViewProps, type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; +import { ScrollViewMarker } from "react-native-screens"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header is rendered in-flow above this screen and @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { return ; } +function HomeScrollView(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const primaryColumn = use(NativePrimaryColumnContext); + if (Platform.OS !== "ios") return ; + + // v5 needs the actual content scroll view registered with its screen controller. + // The layout observer and header siblings prevent UIKit from finding it implicitly. + return ( + = 27 ? "automatic" : "soft", + }} + > + + + ); +} + +function renderHomeScrollView(props: ScrollViewProps) { + // FlatList clones this element with its cells and ref. Keep the marker inside + // the component so those cells remain children of the actual ScrollView. + return ; +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; + const containerClassName = cn( + "flex-1", + Platform.OS === "android" ? "bg-header" : contentBackground, + ); + const columnMetrics = useNativeColumnLayoutMetrics(); + const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; + const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); - const iosBottomToolbarClearance = - Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED - ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT - : 0; + // UIKit's column safe area already includes its bottom toolbar. + const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, - selectedThreadKey: null, + selectedThreadKey, }); }, [ workingShelfEnabled, workingShelfExpanded, pendingOrder, + selectedThreadKey, queuedThreadKeys, nowMinute, snoozeWakeTick, @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; return ( + @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { } return ( - + {/* Shared with the iPad sidebar: cells are reused across data @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior="never" + contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ + paddingHorizontal: primaryColumn ? 8 : 0, paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + ? iosBottomClearance : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} /> diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index caaed841ce01d..801d5e85da80e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + NativeWorkspaceModeContext, + NativeWorkspaceInspectorContext, +} from "../../native/v5-workspace-context"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const nativeWorkspace = use(NativeWorkspaceModeContext); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { + if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( ], ); + if (nativeWorkspace) { + return ( + + + 0 }} + > + {props.children} + + + + ); + } return ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx new file mode 100644 index 0000000000000..c8104f9f4b1ab --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -0,0 +1,342 @@ +import { + createNavigatorFactory, + NavigationContext, + NavigationRouteContext, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { View } from "react-native"; +import { Split, type SplitHostCommands } from "react-native-screens"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; +import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; +import { + modalEnvelopeOptions, + V5CardStackView, + V5StackView, +} from "../../native/createV5StackNavigator.ios"; +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import { + nativeWorkspacePopAction, + projectWorkspaceStack, + partitionStackPresentations, +} from "../../native/workspace-stack-projection"; +import { + NativePrimaryColumnContext, + NativeWorkspaceInspectorContext, + NativeWorkspaceModeContext, +} from "../../native/v5-workspace-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function ColumnScreen(props: { + readonly descriptor: Descriptor; + readonly primary?: boolean; + readonly selectedThreadKey?: string | null; + readonly onDismiss: (routeKey: string) => void; + readonly navigation: ViewProps["navigation"]; +}) { + const { descriptor } = props; + const primaryColumn = useMemo( + () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null), + [props.primary, props.selectedThreadKey], + ); + const key = descriptor.route.key; + return ( + { + if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); + }} + onWillAppear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) + } + onDidAppear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) + } + onWillDisappear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) + } + onDidDisappear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) + } + > + + + + + + {descriptor.render()} + + + + + + ); +} + +function WorkspaceColumns( + props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, +) { + const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const inspector = use(NativeWorkspaceInspectorContext); + const hostRef = useRef(null); + const activeDetailKey = props.detail.at(-1)?.key; + const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; + const selectedThreadKey = + threadParams && + "environmentId" in threadParams && + "threadId" in threadParams && + typeof threadParams.environmentId === "string" && + typeof threadParams.threadId === "string" + ? scopedThreadKey( + EnvironmentId.make(threadParams.environmentId), + ThreadId.make(threadParams.threadId), + ) + : null; + const handleNativeDismiss = useCallback( + (key: string) => { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + }, + [props.navigation], + ); + + useEffect(() => { + // In compact size classes UIKit exposes one column. Selecting a thread + // changes the visible column without rebuilding either navigation stack. + if (layout.usesSplitView) return; + hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); + }, [activeDetailKey, layout.usesSplitView]); + + const primary = props.descriptors[props.primary.key]; + if (!primary) return null; + return ( + { + if (inspector.visible) toggleAuxiliaryPane(); + }} + columnMetrics={{ + minimumPrimaryColumnWidth: 280, + maximumPrimaryColumnWidth: 380, + preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, + minimumSecondaryColumnWidth: 320, + }} + > + + + + + + + + + [ + { + type: "button", + label: "", + icon: { type: "sfSymbol", name: "sidebar.left" }, + onPress: togglePrimarySidebar, + }, + ], + }} + canGoBack={false} + /> + + + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } + /> + + + {props.detail.map((route) => { + const descriptor = props.descriptors[route.key]; + return descriptor ? ( + + ) : null; + })} + + + + {/* Inspector content supplies its own navigation header. */} + + + ); +} + +function WorkspaceStackView(props: ViewProps) { + const projection = projectWorkspaceStack(props.state, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + // Linking normally restores Home via initialRouteName. A detail-only history + // must stay a single stack: placeholder descriptors cannot own an interactive + // sidebar because React Navigation rejects their actions and setOptions. + const primaryRoute = projection.primary; + if (!primaryRoute) return ; + const primaryDescriptor = props.descriptors[primaryRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...primaryDescriptor, + route: baseRoute, + options: { headerShown: false }, + render: () => , + }; + const overlays = partitionStackPresentations(projection.overlays, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; + return ( + { + const route = group[0]!; + const descriptor = props.descriptors[route.key]!; + return [ + route.key, + { + ...descriptor, + options: modalEnvelopeOptions(descriptor.options), + render: () => ( + + ), + }, + ]; + }), + ), + [baseRoute.key]: baseDescriptor, + }} + /> + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + return ( + + + + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx new file mode 100644 index 0000000000000..fb22815dd2c1c --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx @@ -0,0 +1 @@ +export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack"; diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 34d5570e61094..44ef2a992e8b9 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; /** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. + * Group search, filtering, and composition on platforms with Liquid Glass. + * The v5 header adapter turns this intent into UISearchController and native + * toolbar items. Earlier iOS versions use separate search and toolbar options. */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< >; /** - * Builds the patched react-native-screens Mail-style bottom search toolbar. - * - * Keeping this behind an app-level helper makes the iOS-only RNS patch an - * explicit layout primitive instead of a per-screen object literal. Android can - * keep using platform-specific header/search primitives without depending on - * this helper. + * Describe the thread-list search and actions for the native header adapter. */ export function createNativeMailSearchToolbarItem( input: NativeMailSearchToolbarInput, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f0dae4ebb0e1a..1f474a9d56b85 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { return ( <> + {Platform.OS === "ios" ? ( + + ) : null} {Platform.OS === "ios" && layout.usesSplitView ? ( - - navigation.goBack()} - /> - + <> + + navigation.goBack()} + /> + + ) : null} {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 767a1fa1dc7f1..70579053bd939 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -9,6 +9,7 @@ import { import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "../../components/AppSymbol"; import { ScreenHeader } from "../../components/ScreenHeader"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { const insets = useSafeAreaInsets(); + const nativeColumnMetrics = useNativeColumnLayoutMetrics(); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) Platform.OS === "android" ? themeVariables["--color-card-alt"] : terminalTheme.background, + // The v5 column extends behind UIKit chrome. Its measured top + // reservation is zero when UIKit has already inset the body or moved the bar. + paddingTop: + Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, paddingBottom: Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, }} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index cf123a736559f..5df9282d13f90 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -29,7 +29,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { useState, type ReactNode } from "react"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { ScrollView, useWindowDimensions, View } from "react-native"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; import { NativeHeaderToolbar } from "../../native/StackHeader"; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9f1e0a9411e94..5ff9397a179d9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -664,11 +664,11 @@ function ThreadRouteContent( () => inspectorMode === null ? null : ( ), [ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ac03553eb7dc7..1ff108198c134 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,4 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import type { EnvironmentId, ModelSelection, @@ -15,10 +16,7 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationProp, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; import { diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 1198b06cb6a44..6f434ad656b71 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -1,8 +1,6 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; import type { ReactNode } from "react"; import { Platform } from "react-native"; diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 5a1601b0e5ae9..b36218cd29aa8 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { View } from "react-native"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -43,11 +43,11 @@ function InspectorContentPane(props: { } export function ThreadInspectorContentStack(props: { - readonly Files: ComponentType; - readonly Git: ComponentType; + readonly renderFiles: () => ReactNode; + readonly renderGit?: () => ReactNode; readonly mode: ThreadInspectorMode; readonly resetKeys: readonly [string | null, string | null]; - readonly Route?: ComponentType; + readonly renderRoute?: () => ReactNode; }) { const [mountedModes, setMountedModes] = useState>( () => new Set([props.mode]), @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { return () => clearTimeout(timeout); }, [props.mode]); - const Files = props.Files; - const Git = props.Git; - const Route = props.Route; - return ( - - - - + - {Route ? ( + {props.renderGit ? ( + + + + ) : null} + {props.renderRoute ? ( - + ) : null} ); } + +// Render callbacks carry changing route data; they are not component types. +function InspectorRenderer(props: { readonly render: () => ReactNode }) { + return <>{props.render()}; +} diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts new file mode 100644 index 0000000000000..3188962395365 --- /dev/null +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -0,0 +1,12 @@ +/** Geometry of the body inside a native navigation column. */ +export interface NativeLayoutMetrics { + readonly width: number; + readonly height: number; + readonly horizontalSizeClass: "compact" | "regular"; + readonly safeArea: { + readonly top: number; + readonly bottom: number; + readonly left: number; + readonly right: number; + }; +} diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx new file mode 100644 index 0000000000000..77b03098b36c7 --- /dev/null +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -0,0 +1,31 @@ +import { HeaderHeightContext } from "@react-navigation/elements"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; +import { NativeLayoutObserver } from "./NativeLayoutObserver"; + +export function NativeColumnContent(props: { + readonly children: ReactNode; + readonly primary?: boolean; +}) { + const [metrics, setMetrics] = useState(null); + return ( + + + + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} + + + + + {props.children} + + + + + + + ); +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.ios.tsx b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx new file mode 100644 index 0000000000000..8d284d9edeec5 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx @@ -0,0 +1,33 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +interface ObserverProps extends ViewProps { + readonly onMetricsChange?: (event: NativeSyntheticEvent) => void; +} + +// An older development client can still run the compact app while it rebuilds. +function resolveObserver() { + try { + const module = requireOptionalNativeModule<{ + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; + }>("T3NativeControls"); + if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null; + return requireNativeView("T3NativeControls", "LayoutMetrics"); + } catch { + return null; + } +} +const Observer = resolveObserver(); + +export function NativeLayoutObserver(props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return Observer ? ( + props.onChange?.(event.nativeEvent) : undefined} + style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} + /> + ) : null; +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.tsx b/apps/mobile/src/native/NativeLayoutObserver.tsx new file mode 100644 index 0000000000000..e2b24a55a2137 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.tsx @@ -0,0 +1,7 @@ +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +export function NativeLayoutObserver(_props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return null; +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx new file mode 100644 index 0000000000000..c85529b1dbf26 --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -0,0 +1,7 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( + "T3NativeControls", +); +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = + Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx new file mode 100644 index 0000000000000..4bb7aa16ac9ba --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -0,0 +1,3 @@ +import { Platform } from "react-native"; +// Android retains its existing navigator. The iOS file checks native availability. +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx new file mode 100644 index 0000000000000..2c843dab7cdc6 --- /dev/null +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -0,0 +1,253 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { isValidElement, useRef } from "react"; +import { Platform, StyleSheet } from "react-native"; +import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { + SearchBar, + Stack, + type HeaderBarButtonMailSearchToolbarItem, + type SearchBarCommands, + type StackHeaderConfigProps, + type StackHeaderMenuElementIOS, + type StackHeaderMenuIOS, + type HeaderBarButtonItemWithMenu, + type HeaderBarButtonSearchBarPlacementItem, +} from "react-native-screens"; + +type HeaderItem = NonNullable["leadingItems"]>[number]; +type ToolbarItem = + | NativeStackHeaderItem + | HeaderBarButtonMailSearchToolbarItem + | HeaderBarButtonSearchBarPlacementItem; +type Options = NativeStackNavigationOptions & { + readonly unstable_navigationItemStyle?: "editor" | "navigator"; + readonly headerSubtitle?: string; + readonly unstable_headerSubtitle?: string; + readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string }; + readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[]; + readonly unstable_headerToolbarItems?: () => ToolbarItem[]; +}; + +type MenuElementInput = + | NativeStackHeaderItemMenu["menu"]["items"][number] + | HeaderBarButtonItemWithMenu["menu"]["items"][number]; +type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] }; + +function convertIcon( + icon: + | Extract["icon"] + | HeaderBarButtonItemWithMenu["icon"], +) { + if (!icon) return undefined; + if (icon.type === "image") + return icon.tinted === false + ? { type: "imageSource" as const, imageSource: icon.source } + : { type: "templateSource" as const, templateSource: icon.source }; + return icon; +} + +function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS { + const children = menu.items.flatMap((item, index) => { + if ("hidden" in item && item.hidden) return []; + const childId = `${id}:${index}`; + if (item.type === "submenu") { + return [ + { + ...convertMenu({ items: item.items }, childId), + title: "label" in item ? item.label : item.title, + displayInline: + "inline" in item + ? item.inline + : "displayInline" in item + ? item.displayInline + : undefined, + }, + ]; + } + // Use actions even for checked entries: the app owns their durable state. + return [ + { + id: childId, + type: "menuItem" as const, + itemType: "action" as const, + title: "label" in item ? item.label : item.title, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + keepsMenuPresented: item.keepsMenuPresented, + disabled: item.disabled, + destructive: item.destructive, + subtitle: + ("subtitle" in item ? item.subtitle : undefined) ?? + ("description" in item ? item.description : undefined), + state: item.state, + }, + ]; + }); + return { id, type: "menu", title: menu.title, children }; +} + +function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] { + return items.map((item, index): HeaderItem => { + const id = + item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`); + switch (item.type) { + case "spacing": + return { type: "spacer", id, sizing: "fixed", width: item.spacing }; + case "custom": + return { + type: "item", + id, + render: () => item.element, + hidesSharedBackground: item.hidesSharedBackground, + }; + case "menu": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), + hidesSharedBackground: item.hidesSharedBackground, + }; + case "button": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + hidesSharedBackground: item.hidesSharedBackground, + }; + } + }); +} + +/** Translate the existing screen options to v5's native header API. */ +export function V5StackHeader(props: { + readonly options: Options; + readonly canGoBack: boolean; + readonly primary?: boolean; +}) { + const { options } = props; + const theme = useMobileNavigationTheme(); + const searchRef = useRef(null); + const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; + const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); + const trailing = convertItems( + [ + ...(options.unstable_headerCenterItems?.() ?? []), + ...(options.unstable_headerRightItems?.(itemProps) ?? []), + ], + "trailing", + ); + const toolbar = options.unstable_headerToolbarItems?.() ?? []; + const mailSearch = toolbar.find( + (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", + ); + const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad; + const bottom = toolbar.flatMap((item, index) => { + if (item.type === "mailSearchToolbar") return []; + if (item.type === "searchBarPlacement") + return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }]; + return convertItems([item], `toolbar:${index}`); + }); + if (mailSearch) { + if (mailSearch.filterMenu) + bottom.push({ + type: "item", + id: mailSearch.filterButtonId ?? "filter", + icon: { + type: "sfSymbol", + name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", + }, + menu: convertMenu(mailSearch.filterMenu, "filter-menu"), + }); + if (bottomSearch) { + if (bottom.length) + bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); + bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + if (mailSearch.onComposePress) + bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); + } + if (mailSearch.onComposePress) + bottom.push({ + type: "item", + id: mailSearch.composeButtonId ?? "compose", + icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, + onPress: mailSearch.onComposePress, + }); + } + const left = options.headerLeft?.(itemProps); + const right = options.headerRight?.(itemProps); + if (!options.unstable_headerLeftItems && isValidElement(left)) + leading.push({ type: "item", id: "custom-left", render: () => left }); + if (!options.unstable_headerRightItems && isValidElement(right)) + trailing.push({ type: "item", id: "custom-right", render: () => right }); + const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title; + const titleElement = + typeof options.headerTitle === "function" + ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor }) + : undefined; + const searchOptions = options.headerSearchBarOptions; + const titleStyle = StyleSheet.flatten(options.headerTitleStyle); + const headerStyle = StyleSheet.flatten(options.headerStyle); + const appearance = { + backgroundColor: + headerStyle?.backgroundColor ?? + (options.headerTransparent ? "transparent" : theme.colors.card), + shadowColor: options.headerShadowVisible === false ? "transparent" : undefined, + titleFontSize: titleStyle?.fontSize, + titleFontFamily: titleStyle?.fontFamily, + titleFontWeight: titleStyle?.fontWeight, + titleFontColor: titleStyle?.color, + subtitleFontSize: options.headerSubtitleStyle?.fontSize, + subtitleFontColor: options.headerSubtitleStyle?.color, + }; + return ( + - + + ) : null} From 538329603352173afe3ae9f4586a4f772c0f3a4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 13:39:14 -0700 Subject: [PATCH 062/680] fix(desktop): sign-in and captchas work again in desktop browser tabs (#16939) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/CdpRelay.test.ts | 22 ++++++++++++++++++++++ apps/desktop/src/preview/CdpRelay.ts | 9 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 4ec88a7cc516b..a87d8af85ea05 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -75,6 +75,28 @@ describe("CDP relay", () => { }); }); + it("resumes paused child frames, so cross-site iframes run", async () => { + const send = vi.fn(async () => ({})); + const relay = createCdpRelayConnection(makeTarget(send), () => {}); + // The page's own session is already running; only its children wait. + relay.receive( + JSON.stringify({ + id: 1, + method: "Runtime.runIfWaitingForDebugger", + sessionId: "t3-preview-page", + }), + ); + relay.receive( + JSON.stringify({ + id: 2, + method: "Runtime.runIfWaitingForDebugger", + sessionId: "iframe-session", + }), + ); + await settle(); + expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + }); + it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 19000aa50c638..62c2410c19b8d 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -158,9 +158,14 @@ export function createCdpRelayConnection( }; const pageCommand = (command: CdpCommand): Promise => { - // The page is already running; nothing waits for a debugger. - if (command.method === "Runtime.runIfWaitingForDebugger") return Promise.resolve({}); const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; + // The page itself is already running, but its child frames are not: + // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses + // every new cross-site iframe and worker until this reaches it. Swallowing + // it left sign-in checks and captchas, which run in such frames, frozen. + if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { + return Promise.resolve({}); + } return target.send(command.method, command.params ?? {}, child); }; From 85489611b5477050d7d777a4195c1c50ac0ce6be Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 7 Oct 2026 16:43:24 -0400 Subject: [PATCH 063/680] fix(server): missing project folders no longer log favicon warnings (#16757) Signed-off-by: Yordis Prieto --- .../project/ProjectEnrichmentService.test.ts | 55 +++++++++++++++++++ .../src/project/ProjectEnrichmentService.ts | 7 +++ .../src/project/ProjectFaviconResolver.ts | 6 ++ 3 files changed, 68 insertions(+) diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 71d4cbec2fc9f..726cade85f365 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -3,10 +3,13 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; @@ -95,6 +98,58 @@ it.effect("preserves either enrichment field when the other resolver fails", () }), ); +it.effect("does not warn about favicons for workspace roots that no longer exist", () => + Effect.gen(function* () { + const warnings: Array = []; + const logger = Logger.make(({ message }) => { + warnings.push(message); + }); + const metadataLayer = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + resolvePath: (workspaceRoot) => + Effect.fail( + workspaceRoot === "/missing" + ? new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot, + cause: new WorkspacePaths.WorkspaceRootNotExistsError({ + workspaceRoot, + normalizedWorkspaceRoot: workspaceRoot, + }), + }) + : new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot, + cause: "favicon resolver failed", + }), + ), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + yield* service.request("/missing"); + yield* service.request("/broken"); + + for (let attempt = 0; attempt < 100 && warnings.length === 0; attempt += 1) { + yield* Effect.yieldNow; + } + + assert.lengthOf(warnings, 1); + assert.nestedPropertyVal(warnings[0], "[1].workspaceRoot", "/broken"); + }).pipe( + Effect.provide( + layer(metadataLayer, { concurrency: 1 }).pipe( + Layer.provide(Logger.layer([logger], { mergeWithExisting: false })), + ), + ), + ); + }), +); + it.effect("publishes repository completion while favicon enrichment is still pending", () => Effect.gen(function* () { const releaseFavicon = yield* Deferred.make(); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 199c31b827d08..1477733d66bd3 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -192,6 +192,13 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( workspaceRoot: string, ) { const faviconPath = yield* Cache.get(faviconCache, workspaceRoot); + const isMissingWorkspaceRoot = Option.exists( + Exit.findErrorOption(faviconPath), + ProjectFaviconResolver.isMissingWorkspaceRoot, + ); + if (isMissingWorkspaceRoot) { + return; + } yield* logFailure(workspaceRoot, "faviconPath", faviconPath); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index d04af8344a5cd..58a2de4f7a1a9 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -111,6 +111,12 @@ export class ProjectFaviconResolutionError extends Schema.TaggedError + error.operation === "normalize-workspace" && isWorkspaceRootNotExistsError(error.cause); + /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, From eba0521563946a0834af881465d3e1ff7a4ca2f1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:54:17 -0700 Subject: [PATCH 064/680] fix(server): unload Codex threads left idle on the shared app-server (#16917) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../ProviderSessionManager.test.ts | 207 +++++++++++++++ .../ProviderSessionManager.ts | 244 +++++++++++++++++- 2 files changed, 440 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index bfb076ee3bf3b..2f2b0402f6205 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -319,6 +319,7 @@ function makeProviderAdapter( readonly initialProviderItemIdentityVersion?: 2; }) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; + readonly hasPendingBackgroundWorkForThread?: Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; @@ -407,6 +408,11 @@ function makeProviderAdapter( ...(options.hasPendingBackgroundWork === undefined ? {} : { hasPendingBackgroundWork: options.hasPendingBackgroundWork }), + ...(options.hasPendingBackgroundWorkForThread === undefined + ? {} + : { + hasPendingBackgroundWorkForThread: () => options.hasPendingBackgroundWorkForThread!, + }), ensureThread: () => unimplemented("ensureThread unused in test"), resumeThread: (threadInput) => Ref.update(state, (current) => ({ @@ -463,6 +469,7 @@ function layerTest(input: { readonly paused: Deferred.Deferred; }; readonly hasPendingBackgroundWork?: Effect.Effect; + readonly hasPendingBackgroundWorkForThread?: Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; @@ -488,6 +495,9 @@ function layerTest(input: { ...(input.hasPendingBackgroundWork === undefined ? {} : { hasPendingBackgroundWork: input.hasPendingBackgroundWork }), + ...(input.hasPendingBackgroundWorkForThread === undefined + ? {} + : { hasPendingBackgroundWorkForThread: input.hasPendingBackgroundWorkForThread }), ...(input.hangSessionScopeClose === undefined ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), @@ -4210,6 +4220,203 @@ it.effect( }), ); +function runIdleThreadUnloadScenario( + name: string, + scenario: (input: { + readonly state: Ref.Ref; + readonly manager: ProviderSessionManager.ProviderSessionManagerV2Shape; + readonly providerSessionId: ProviderSessionId; + readonly threadA: ThreadId; + /** Starts run `ordinal` on a thread and returns once the provider accepted it. */ + readonly startTurn: (threadId: ThreadId, ordinal: number) => Effect.Effect; + /** Ends run `ordinal` on a thread and waits for the session to process it. */ + readonly endTurn: (threadId: ThreadId, ordinal: number) => Effect.Effect; + readonly resume: (threadId: ThreadId) => Effect.Effect; + }) => Effect.Effect, + options: { readonly hasPendingBackgroundWorkForThread?: Effect.Effect } = {}, +) { + return Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make(`thread-provider-session-manager-${name}-a`); + const threadB = ThreadId.make(`thread-provider-session-manager-${name}-b`); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ threadId: threadB, providerSessionId, modelSelection, runtimePolicy }); + const providerThreadOf = (threadId: ThreadId) => + makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const resume = (threadId: ThreadId) => + runtime + .resumeThread({ + providerThread: providerThreadOf(threadId), + threadId, + modelSelection, + runtimePolicy, + }) + .pipe(Effect.asVoid, Effect.orDie); + const startTurn = (threadId: ThreadId, ordinal: number) => + Effect.gen(function* () { + yield* resume(threadId); + const runId = idAllocator.derive.run({ threadId, ordinal }); + yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: ordinal, + providerTurnOrdinal: ordinal, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: providerThreadOf(threadId), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }).pipe(Effect.orDie); + const subscribe = runtime.subscribeEvents; + assert.isDefined(subscribe); + const endTurn = (threadId: ThreadId, ordinal: number) => + Effect.scoped( + Effect.gen(function* () { + // The pump hands an event to subscribers only after the session + // has processed it, so receiving it is the receipt. + const subscription = yield* Effect.acquireRelease(subscribe!, (sub) => sub.close); + const received = yield* subscription.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + Effect.forkScoped, + ); + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(queue); + yield* Queue.offer(queue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThreadOf(threadId).id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: `native-turn-${threadId}-${ordinal}`, + }), + runOrdinal: ordinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(received); + }), + ).pipe(Effect.orDie); + + // B's running turn keeps the shared runtime itself busy throughout. + yield* startTurn(threadB, 1); + yield* scenario({ + state, + manager, + providerSessionId, + threadA, + startTurn, + endTurn, + resume, + }); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + ...(options.hasPendingBackgroundWorkForThread === undefined + ? {} + : { hasPendingBackgroundWorkForThread: options.hasPendingBackgroundWorkForThread }), + }), + ), + ); + }); +} + +it.effect( + "ProviderSessionManagerV2 unloads a shared-runtime thread left idle, and reloads it on its next turn", + () => + runIdleThreadUnloadScenario( + "idle-unload", + ({ state, manager, providerSessionId, threadA, startTurn, endTurn, resume }) => + Effect.gen(function* () { + yield* startTurn(threadA, 1); + assert.equal((yield* Ref.get(state)).resumeCount, 2); + yield* endTurn(threadA, 1); + + // A follow-up before the timeout keeps the thread loaded. + yield* TestClock.adjust("500 millis"); + yield* startTurn(threadA, 2); + yield* TestClock.adjust("1 second"); + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); + + // Idle for the full timeout after its last turn, A is unloaded while + // the runtime stays up for B. + yield* endTurn(threadA, 2); + yield* TestClock.adjust("1 second"); + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, [`native-${threadA}`]); + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + // The next turn's resume reaches the provider and reloads A. + const resumes = (yield* Ref.get(state)).resumeCount; + yield* resume(threadA); + assert.equal((yield* Ref.get(state)).resumeCount, resumes + 1); + }), + ), +); + +it.effect( + "ProviderSessionManagerV2 keeps an idle shared-runtime thread loaded while its background work runs", + () => + Effect.gen(function* () { + const pendingWork = yield* Ref.make(true); + yield* runIdleThreadUnloadScenario( + "idle-unload-pinned", + ({ state, threadA, startTurn, endTurn }) => + Effect.gen(function* () { + yield* startTurn(threadA, 1); + yield* endTurn(threadA, 1); + yield* TestClock.adjust("3 seconds"); + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); + + yield* Ref.set(pendingWork, false); + yield* TestClock.adjust("1 second"); + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, [ + `native-${threadA}`, + ]); + }), + { hasPendingBackgroundWorkForThread: Ref.get(pendingWork) }, + ); + }), +); + it.effect( "ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f84441129cc3a..d181497ec2754 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -61,9 +61,10 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; +const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; /** The identity a turn's start and its `turn.terminal` share. */ const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => - `${providerThreadId}#${runOrdinal}`; + `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; export const ProviderSessionReleaseReason = Schema.Literals([ @@ -228,6 +229,18 @@ interface LiveSessionEntry { readonly idleFiber: Fiber.Fiber | null; /** Set when idle release is deferred for pending background work; bounds total deferral. */ readonly pinnedSinceMs: number | null; + /** + * Shared runtimes only: the provider thread each attached app thread last + * started a turn on, and the timer that unloads it once it has been idle + * for `idleTimeoutMs`. + */ + readonly idleThreadUnloads: ReadonlyMap; +} + +interface IdleThreadUnload { + readonly providerThread: OrchestrationV2ProviderThread; + readonly generation: number; + readonly fiber: Fiber.Fiber | null; } type ProviderSessionEventSignal = @@ -1400,7 +1413,11 @@ export const layerWithOptions = ( // Clearing a turn that is not marked busy (one whose failed start already // cleared it, or a subagent turn the manager never started) only // records activity. - const markIdle = (providerSessionId: ProviderSessionId, turnKey: string) => + const markIdle = ( + providerSessionId: ProviderSessionId, + providerThreadId: ProviderThreadId, + runOrdinal: number, + ) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1412,7 +1429,7 @@ export const layerWithOptions = ( return current; } const busyTurns = new Set(entry.busyTurns); - busyTurns.delete(turnKey); + busyTurns.delete(busyTurnKey(providerThreadId, runOrdinal)); const updated = new Map(current); updated.set(key, { ...entry, @@ -1422,9 +1439,181 @@ export const layerWithOptions = ( return updated; }); yield* scheduleIdleReleaseInternal(providerSessionId); + yield* scheduleThreadUnload(providerSessionId, providerThreadId); }), ); + const hasBusyTurn = (entry: LiveSessionEntry, providerThreadId: ProviderThreadId) => { + const prefix = busyTurnPrefix(providerThreadId); + for (const turnKey of entry.busyTurns) { + if (turnKey.startsWith(prefix)) return true; + } + return false; + }; + + const updateIdleThreadUnload = ( + providerSessionId: ProviderSessionId, + threadId: ThreadId, + update: (current: IdleThreadUnload | undefined) => IdleThreadUnload | undefined, + ) => + Ref.modify(sessions, (current) => { + const key = sessionKey(providerSessionId); + const entry = current.get(key); + if (entry === undefined) return [undefined, current] as const; + const previous = entry.idleThreadUnloads.get(threadId); + const next = update(previous); + const idleThreadUnloads = new Map(entry.idleThreadUnloads); + if (next === undefined) idleThreadUnloads.delete(threadId); + else idleThreadUnloads.set(threadId, next); + const updated = new Map(current); + updated.set(key, { ...entry, idleThreadUnloads }); + return [previous, updated] as const; + }); + + /** + * Starts tracking the provider thread an app thread runs its turns on, and + * stops any unload pending for it. Called before the thread is resumed or + * given a turn, so an unload cannot land between a resume that found the + * thread loaded and the turn that relies on it. + */ + const holdThreadLoaded = (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + }) => + Effect.gen(function* () { + const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); + if ( + entry === undefined || + !entry.supportsMultipleProviderThreads || + entry.exposedRuntime.unloadThread === undefined || + input.providerThread.nativeThreadRef === null + ) { + return; + } + const previous = yield* updateIdleThreadUnload( + input.providerSessionId, + input.threadId, + (current) => ({ + providerThread: input.providerThread, + generation: (current?.generation ?? 0) + 1, + fiber: null, + }), + ); + yield* cancelIdleFiber(previous?.fiber ?? null); + }); + + /** + * A shared runtime never goes idle while any of its threads is in use, so + * its threads get the idle timeout one by one: a thread with no turn for + * `idleTimeoutMs` is unloaded from the runtime, along with the native MCP + * servers it started. Its next turn's resume loads it again. + */ + const scheduleThreadUnload = ( + providerSessionId: ProviderSessionId, + providerThreadId: ProviderThreadId, + ) => + Effect.gen(function* () { + const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); + if (entry === undefined || hasBusyTurn(entry, providerThreadId)) return; + const tracked = Array.from(entry.idleThreadUnloads).find( + ([, pending]) => pending.providerThread.id === providerThreadId, + ); + if (tracked === undefined) return; + const [threadId, pending] = tracked; + const generation = pending.generation + 1; + const fiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( + Effect.andThen(unloadIdleThread({ providerSessionId, threadId, generation })), + Effect.forkIn(layerScope), + ); + // A turn that started meanwhile already moved the generation on. + const previous = yield* updateIdleThreadUnload(providerSessionId, threadId, (current) => + current?.generation === pending.generation + ? { ...current, generation, fiber } + : current, + ); + yield* cancelIdleFiber( + previous?.generation === pending.generation ? previous.fiber : fiber, + ); + }); + + const unloadIdleThread = (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + readonly generation: number; + }): Effect.Effect => + Effect.gen(function* () { + const outcome = yield* threadAttachment.withLock( + threadAttachmentKey(input), + Effect.gen(function* () { + const key = sessionKey(input.providerSessionId); + const entry = (yield* Ref.get(sessions)).get(key); + const pending = entry?.idleThreadUnloads.get(input.threadId); + const unloadThread = entry?.exposedRuntime.unloadThread; + if ( + entry === undefined || + pending === undefined || + pending.generation !== input.generation || + unloadThread === undefined || + !entry.attachedThreadIds.has(input.threadId) || + hasBusyTurn(entry, pending.providerThread.id) + ) { + return "skipped" as const; + } + // Unloading stops the native thread's background terminals, so a + // thread still running background work stays loaded. + const hasPendingWork = + entry.runtime.hasPendingBackgroundWorkForThread === undefined + ? false + : yield* entry.runtime + .hasPendingBackgroundWorkForThread(pending.providerThread) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (hasPendingWork) return "deferred" as const; + const unloading = yield* Ref.modify(sessions, (current) => { + const latest = current.get(key); + if ( + latest?.runtime !== entry.runtime || + latest.idleThreadUnloads.get(input.threadId)?.generation !== input.generation + ) { + return [false, current] as const; + } + const loadedProviderThreadKeyByThread = new Map( + latest.loadedProviderThreadKeyByThread, + ); + loadedProviderThreadKeyByThread.delete(input.threadId); + const idleThreadUnloads = new Map(latest.idleThreadUnloads); + idleThreadUnloads.delete(input.threadId); + const updated = new Map(current); + updated.set(key, { + ...latest, + loadedProviderThreadKeyByThread, + idleThreadUnloads, + }); + return [true, updated] as const; + }); + if (!unloading) return "skipped" as const; + yield* unloadThread({ providerThread: pending.providerThread }).pipe( + Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.idle-unload-failed", { + providerSessionId: input.providerSessionId, + threadId: input.threadId, + providerThreadId: pending.providerThread.id, + cause, + }), + ), + ); + return "unloaded" as const; + }), + ); + // Re-check on this fiber after another idle window, outside the + // lock so the thread's next attach is not held up meanwhile. + if (outcome === "deferred") { + yield* Effect.sleep(Duration.millis(idleTimeoutMs)); + return yield* unloadIdleThread(input); + } + }); + const observeActivity = ( providerSessionId: ProviderSessionId, activity: Effect.Effect, @@ -1544,6 +1733,13 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( + Effect.andThen( + holdThreadLoaded({ + providerSessionId, + threadId, + providerThread: input.providerThread, + }), + ), Effect.andThen( isProviderThreadLoaded({ providerSessionId, threadId, providerThreadKey }), ), @@ -1607,6 +1803,13 @@ export const layerWithOptions = ( // the adapter emits the terminal anyway, clearing the same turn // again changes nothing, so another thread's turn on a shared // session stays busy either way. + Effect.andThen( + holdThreadLoaded({ + providerSessionId, + threadId: input.threadId, + providerThread: input.providerThread, + }), + ), Effect.andThen( Effect.acquireUseRelease( observeActivity( @@ -1622,10 +1825,7 @@ export const layerWithOptions = ( Exit.isFailure(exit) ? observeActivity( providerSessionId, - markIdle( - providerSessionId, - busyTurnKey(input.providerThread.id, input.runOrdinal), - ), + markIdle(providerSessionId, input.providerThread.id, input.runOrdinal), ) : Effect.void, ), @@ -1692,7 +1892,8 @@ export const layerWithOptions = ( event.type === "turn.terminal" ? markIdle( entry.runtime.providerSessionId, - busyTurnKey(event.providerThreadId, event.runOrdinal), + event.providerThreadId, + event.runOrdinal, ) : touchActivity(entry.runtime.providerSessionId), ).pipe( @@ -1965,6 +2166,7 @@ export const layerWithOptions = ( lastActivityAtMs: now, idleFiber: null, pinnedSinceMs: null, + idleThreadUnloads: new Map(), }; yield* Ref.update(sessions, (current) => { const updated = new Map(current); @@ -2111,10 +2313,16 @@ export const layerWithOptions = ( ); } } - const detached = yield* Ref.modify(sessions, (current) => { + const detachResult = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { - return [Option.none(), current] as const; + return [ + Option.none<{ + readonly entry: LiveSessionEntry; + readonly idleUnloadFiber: Fiber.Fiber | null; + }>(), + current, + ] as const; } const attachedThreadIds = new Set(entry.attachedThreadIds); attachedThreadIds.delete(input.threadId); @@ -2135,16 +2343,30 @@ export const layerWithOptions = ( return pruned; })() : entry.mcpCredentialIdByThread; + // The detach unloads the thread itself below. + const idleThreadUnloads = new Map(entry.idleThreadUnloads); + idleThreadUnloads.delete(input.threadId); const updatedEntry = { ...entry, attachedThreadIds, loadedProviderThreadKeyByThread, mcpCredentialIdByThread, + idleThreadUnloads, }; const updated = new Map(current); updated.set(key, updatedEntry); - return [Option.some(updatedEntry), updated] as const; + return [ + Option.some({ + entry: updatedEntry, + idleUnloadFiber: entry.idleThreadUnloads.get(input.threadId)?.fiber ?? null, + }), + updated, + ] as const; }); + if (Option.isSome(detachResult)) { + yield* cancelIdleFiber(detachResult.value.idleUnloadFiber); + } + const detached = Option.map(detachResult, (result) => result.entry); // Plain detaches deliberately do not revoke: a detached thread's // provider process may still be alive (shared multi-thread codex // session across a workspace handoff) and holds its MCP client's From 26285ab80513260d1ff29d2c3fbe18f21990cb62 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Wed, 7 Oct 2026 18:03:31 -0300 Subject: [PATCH 065/680] fix(web): fast typing no longer scrambles text when type-to-focus kicks in (#14595) --- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6b7eb7df7066e..bac965c0b97db 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1210,7 +1210,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const focusAt = useCallback( (nextCursor: number) => { if (!editor) return; - editor.view.dom.focus({ preventScroll: true }); + // view.focus() writes the state selection to the DOM as it focuses. A + // bare DOM focus leaves the native caret at the start until + // ProseMirror resyncs it 20ms later, and dictation tools type fast + // enough to land keys there, ahead of the text already inserted. + editor.view.focus(); // A newer prompt is waiting to be applied (a chip was just inserted // through the store). Reporting the editor's stale text now would // overwrite that prompt; the pending rewrite places the caret instead. From 7f3628e4153d5be4a241c4d54f73a6d7087014ce Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:47:37 +0300 Subject: [PATCH 066/680] fix(web): simplify workspace card rows (#16823) --- .../components/BranchToolbar.logic.test.ts | 13 -- .../web/src/components/BranchToolbar.logic.ts | 7 - .../components/BranchToolbar.panel.test.tsx | 138 +++++++++++++++++ apps/web/src/components/BranchToolbar.tsx | 146 +++++++++++++++--- .../BranchToolbarBranchSelector.tsx | 13 +- .../BranchToolbarEnvModeSelector.tsx | 137 +++------------- .../BranchToolbarEnvironmentSelector.tsx | 47 ++---- apps/web/src/components/ChatView.tsx | 16 -- .../components/chat/PanelLayoutControls.tsx | 8 - .../chat/ThreadDetailsControl.test.tsx | 38 +++++ .../components/chat/ThreadDetailsControl.tsx | 49 ++---- .../chat/ThreadDetailsPanel.test.tsx | 2 - .../components/chat/ThreadDetailsPanel.tsx | 66 +------- .../components/chat/ThreadDetailsPrRow.tsx | 8 +- .../chat/ThreadRelationshipsControl.tsx | 13 +- .../chat/threadDetailsPanelStyles.ts | 17 +- apps/web/src/routes/_chat.pull-requests.tsx | 1 - 17 files changed, 376 insertions(+), 343 deletions(-) create mode 100644 apps/web/src/components/BranchToolbar.panel.test.tsx create mode 100644 apps/web/src/components/chat/ThreadDetailsControl.test.tsx diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 4de529a3d558a..a169539deccc9 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -13,7 +13,6 @@ import { resolveBranchToolbarPrBranch, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, - resolveWorkspaceDisplayName, resolveLocalCheckoutBranchMismatch, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, @@ -568,18 +567,6 @@ describe("resolveLockedWorkspaceLabel", () => { }); }); -describe("resolveWorkspaceDisplayName", () => { - it("returns the final folder for POSIX and Windows paths", () => { - expect(resolveWorkspaceDisplayName("/repo/.t3/worktrees/feature-a")).toBe("feature-a"); - expect(resolveWorkspaceDisplayName("C:\\code\\project\\feature-b\\")).toBe("feature-b"); - }); - - it("handles missing and root paths", () => { - expect(resolveWorkspaceDisplayName(null)).toBeNull(); - expect(resolveWorkspaceDisplayName("/")).toBe("/"); - }); -}); - describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d018802a590a6..7db804d25a25e 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -118,13 +118,6 @@ export function resolveLockedWorkspaceLabel( return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; } -export function resolveWorkspaceDisplayName(path: string | null): string | null { - if (!path) return null; - const normalizedPath = path.replace(/[\\/]+$/, ""); - if (normalizedPath.length === 0) return path; - return normalizedPath.split(/[\\/]/).at(-1) ?? normalizedPath; -} - export interface PreviousWorktreeSeed { branch: string | null; worktreePath: string; diff --git a/apps/web/src/components/BranchToolbar.panel.test.tsx b/apps/web/src/components/BranchToolbar.panel.test.tsx new file mode 100644 index 0000000000000..5a43c2131de0a --- /dev/null +++ b/apps/web/src/components/BranchToolbar.panel.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + worktreePath: "/tmp/worktree" as string | null, + showContextMenu: vi.fn().mockResolvedValue("copy-path"), + writeTextToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: state.showContextMenu } }), +})); +vi.mock("../hooks/useCopyToClipboard", () => ({ + writeTextToClipboard: state.writeTextToClipboard, +})); +vi.mock("./BranchToolbarBranchSelector", () => ({ BranchToolbarBranchSelector: () => null })); +vi.mock("../composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: unknown) => unknown) => + select({ getDraftThreadByRef: () => null, setDraftThreadContext: vi.fn() }), +})); +vi.mock("../state/entities", () => ({ + useThreadShell: () => ({ + environmentId: "local", + projectId: "project", + worktreePath: state.worktreePath, + }), + useProject: () => ({ workspaceRoot: "/tmp/project" }), + useThreadShellsForProjectRefs: () => [], +})); + +import { BranchToolbar } from "./BranchToolbar"; + +it("keeps machine choices usable when the combined row's workspace is locked", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onEnvironmentChange = vi.fn(); + try { + await act(async () => { + root.render( + ({ + environmentId: EnvironmentId.make(id), + projectId: ProjectId.make("project"), + label: id, + isPrimary: id === "local", + machine: "server", + }))} + />, + ); + }); + const trigger = container.querySelector("button")!; + expect(trigger.textContent).toBe("local"); + expect(container.querySelectorAll("button")).toHaveLength(1); + await act(async () => { + trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, ctrlKey: true })); + }); + expect(document.querySelector('[role="menu"]')).toBeNull(); + await act(async () => trigger.click()); + const items = [...document.querySelectorAll('[role="menuitemradio"]')]; + expect( + items.find((item) => item.textContent === "New worktree")?.getAttribute("aria-disabled"), + ).toBe("true"); + await act(async () => items.find((item) => item.textContent === "remote")!.click()); + expect(onEnvironmentChange).toHaveBeenCalledWith("remote"); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } +}); + +it.each([ + ["local", null, "/tmp/project"], + ["worktree", null, null], + ["worktree", "/tmp/worktree", "/tmp/worktree"], +] as const)( + "copies only an existing workspace path with mode %s and worktree %s", + async (envMode, worktreePath, copiedPath) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.worktreePath = worktreePath; + state.showContextMenu.mockClear(); + state.writeTextToClipboard.mockClear(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render( + , + ); + }); + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await act(async () => { + container.querySelector('[aria-label="Run context"]')!.dispatchEvent(event); + }); + expect(event.defaultPrevented).toBe(copiedPath !== null); + if (copiedPath === null) { + expect(state.showContextMenu).not.toHaveBeenCalled(); + expect(state.writeTextToClipboard).not.toHaveBeenCalled(); + } else { + expect(state.writeTextToClipboard).toHaveBeenCalledWith(copiedPath, "workspace path"); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + state.worktreePath = "/tmp/worktree"; + vi.unstubAllGlobals(); + } + }, +); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 7e7d4a71d7d65..4e44c114d5b8c 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,3 +1,6 @@ +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { readLocalApi } from "../localApi"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -10,6 +13,7 @@ import { } from "lucide-react"; import { type Ref, + type MouseEvent as ReactMouseEvent, memo, useImperativeHandle, useCallback, @@ -41,6 +45,11 @@ import { import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; +import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, +} from "./chat/threadDetailsPanelStyles"; import { ComposerControl } from "./chat/ComposerControl"; import { Menu, @@ -93,7 +102,44 @@ interface BranchToolbarProps { contextStripVisible?: boolean; } -interface MobileRunContextSelectorProps { +function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + void api.contextMenu + .show([{ id: "copy-path", label: "Copy full path", icon: "copy" }], { + x: event.clientX, + y: event.clientY, + }) + .then((action) => { + if (action !== "copy-path") return; + void writeTextToClipboard(workspacePath, "workspace path").then( + (didCopy) => { + if (didCopy) { + toastManager.add({ + type: "success", + title: "Path copied", + description: workspacePath, + }); + } + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }); +} + +interface RunContextSelectorProps { + displayMode?: "toolbar" | "panel"; + workspaceRoot?: string; forceNewWorktree: boolean; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; @@ -112,7 +158,9 @@ interface MobileRunContextSelectorProps { onUsePreviousWorktree: () => void; } -const MobileRunContextSelector = memo(function MobileRunContextSelector({ +const RunContextSelector = memo(function RunContextSelector({ + displayMode = "toolbar", + workspaceRoot, forceNewWorktree, autoEnvironmentLabel, onAutoEnvironment, @@ -129,7 +177,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ previousWorktreeLabel, previousWorktreeBranch, onUsePreviousWorktree, -}: MobileRunContextSelectorProps) { +}: RunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -148,13 +196,35 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); - const isLocked = envLocked || envModeLocked; + const isPanel = displayMode === "panel"; + const isLocked = envLocked || (envModeLocked && (!isPanel || !showEnvironmentPicker)); + const workspacePath = + forceNewWorktree || (effectiveEnvMode === "worktree" && !activeWorktreePath) + ? null + : (activeWorktreePath ?? workspaceRoot); + const handleContextMenu = (event: ReactMouseEvent) => { + if (isPanel && workspacePath) { + showWorkspaceContextMenu(event, workspacePath); + } + }; const workspaceIcon = ( }> - + - {workspaceLabel} + + {isPanel + ? forceNewWorktree + ? "Each model starts in its own worktree." + : (workspacePath ?? workspaceLabel) + : workspaceLabel} + ); const icon = showEnvironmentIndicator ? ( @@ -164,11 +234,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ }> {autoEnvironmentLabel ? ( - @@ -182,7 +255,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ const triggerContent = ( <> {icon} - + {autoEnvironmentLabel ?? (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -192,7 +265,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( {triggerContent} @@ -203,8 +282,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ return ( } - className="min-w-0 max-w-[48%] flex-initial justify-start" + render={isPanel ? : } + className={isPanel ? undefined : "min-w-0 max-w-[48%] flex-initial justify-start"} + onContextMenu={handleContextMenu} + onMouseDownCapture={ + isPanel + ? (event) => { + if (event.button !== 0 || event.ctrlKey) event.stopPropagation(); + } + : undefined + } + aria-label={isPanel ? "Run context" : undefined} data-composer-context-control data-composer-shortcut={[ showEnvironmentPicker && !envLocked ? "composer.host" : "", @@ -212,13 +300,19 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ].join(" ")} > {triggerContent} - + {!isPanel ? : null} {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> @@ -618,13 +712,25 @@ export const BranchToolbar = memo(function BranchToolbar({ return (
    {panelSection !== "branch" ? ( - ) : null} {panelSection !== "workspace" ? ( @@ -662,7 +768,7 @@ export const BranchToolbar = memo(function BranchToolbar({ > {showGitControls ? (
    - - {displayMode === "panel" ? ( - - - - ) : ( + {displayMode !== "panel" ? ( - )} + ) : null} {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 3afa31e2acee1..ba6c471ff6ee5 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,20 +1,12 @@ -import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl"; +import { ComposerSelectControl } from "./chat/ComposerControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo, type MouseEvent as ReactMouseEvent } from "react"; -import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { readLocalApi } from "../localApi"; -import { cn } from "../lib/utils"; -import { - THREAD_DETAILS_PANEL_ICON_CLASS, - THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, -} from "./chat/threadDetailsPanelStyles"; import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, - resolveWorkspaceDisplayName, type EnvMode, } from "./BranchToolbar.logic"; import { useComposerMenuProps } from "./chat/composerEventScope"; @@ -28,7 +20,6 @@ import { SelectValue, } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { stackedThreadToast, toastManager } from "./ui/toast"; const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; @@ -37,9 +28,7 @@ interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; - workspaceRoot?: string | null; onEnvModeChange: (mode: EnvMode) => void; - displayMode?: "toolbar" | "panel"; previousWorktreeLabel?: string | null; previousWorktreeBranch?: string | null; onUsePreviousWorktree?: () => void; @@ -50,72 +39,27 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe envLocked, effectiveEnvMode, activeWorktreePath, - workspaceRoot = null, onEnvModeChange, - displayMode = "toolbar", previousWorktreeLabel, previousWorktreeBranch = null, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { - const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null; - const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath); - // The panel names the workspace kind only when it is not the project folder. - const workspaceKind = activeWorktreePath ? "Worktree" : null; - const lockedWorkspaceKind = forceNewWorktree ? "Worktree" : workspaceKind; - const selectWorkspaceKind = - effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind; const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ { value: "local", - label: workspaceDisplayName ?? resolveCurrentWorkspaceLabel(activeWorktreePath), + label: resolveCurrentWorkspaceLabel(activeWorktreePath), }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, ...(showPreviousWorktree && previousWorktreeLabel ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] : []), ], - [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree, workspaceDisplayName], + [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); - const handleWorkspaceContextMenu = (event: ReactMouseEvent) => { - if (!workspacePath || forceNewWorktree) return; - const api = readLocalApi(); - if (!api) return; - event.preventDefault(); - event.stopPropagation(); - void api.contextMenu - .show([{ id: "copy-path", label: "Copy full path", icon: "copy" }], { - x: event.clientX, - y: event.clientY, - }) - .then((action) => { - if (action !== "copy-path") return; - void writeTextToClipboard(workspacePath, "workspace path").then( - (didCopy) => { - if (didCopy) { - toastManager.add({ - type: "success", - title: "Path copied", - description: workspacePath, - }); - } - }, - (error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }); - }; - const stopContextMenuMouseDown = (event: ReactMouseEvent) => { if (event.button !== 0 || event.ctrlKey) { event.stopPropagation(); @@ -125,47 +69,31 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked || forceNewWorktree) { const lockedRow = ( {activeWorktreePath ? ( - + ) : effectiveEnvMode === "worktree" ? ( - + ) : ( - + )} - + {forceNewWorktree ? resolveEnvModeLabel("worktree") - : (workspaceDisplayName ?? - resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} + : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} - {displayMode === "panel" && lockedWorkspaceKind ? ( - - {lockedWorkspaceKind} - - ) : null} ); return ( - + {forceNewWorktree ? "Each model starts in its own worktree." - : (workspacePath ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} + : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} ); @@ -187,56 +115,37 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe } > {effectiveEnvMode === "worktree" ? ( - + ) : activeWorktreePath ? ( - + ) : ( - + )} - + - {displayMode === "panel" && selectWorkspaceKind ? ( - - {selectWorkspaceKind} - - ) : null} - - {workspacePath ?? - (effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath))} + + {effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath)} Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 89b3dbe07b9f1..e323c70921214 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,4 +1,4 @@ -import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl"; +import { ComposerSelectControl } from "./chat/ComposerControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { Tooltip, TooltipTrigger, TooltipPopup } from "./ui/tooltip"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -6,11 +6,6 @@ import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; -import { cn } from "../lib/utils"; -import { - THREAD_DETAILS_PANEL_ICON_CLASS, - THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, -} from "./chat/threadDetailsPanelStyles"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { @@ -29,7 +24,6 @@ interface BranchToolbarEnvironmentSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; - displayMode?: "toolbar" | "panel"; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +33,6 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir environmentId, availableEnvironments, onEnvironmentChange, - displayMode = "toolbar", }: BranchToolbarEnvironmentSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo(() => { @@ -67,19 +60,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { const lockedRow = ( - - {activeEnvironment?.label ?? "Run on"} - + {activeEnvironment?.label ?? "Run on"} ); return ( @@ -102,8 +90,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir {autoEnvironmentLabel ? ( - {autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"} - + Run on {onAutoEnvironment && ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ca71e42f7a402..6a021ef7e5e2e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3069,11 +3069,6 @@ export default function ChatView(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; - const handleDismissVersionMismatch = useCallback(() => { - if (!versionMismatchDismissKey) return; - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]); const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; @@ -11078,15 +11073,6 @@ export default function ChatView(props: ChatViewProps) { : {}), onComposerFocusRequest: scheduleComposerFocus, ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}), - versionMismatch: - showVersionMismatchBanner && versionMismatch - ? { - clientVersion: versionMismatch.clientVersion, - serverVersion: versionMismatch.serverVersion, - serverLabel: versionMismatchServerLabel, - } - : null, - onDismissVersionMismatch: handleDismissVersionMismatch, onRunProjectScript: runProjectScript, onAddProjectScript: saveProjectScript, onUpdateProjectScript: updateProjectScript, @@ -11100,8 +11086,6 @@ export default function ChatView(props: ChatViewProps) { threadPanelPresentation, threadPanelPopoverHandle, threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), - threadPanelHasAttention: - activeEnvironmentUnavailableState !== null || showVersionMismatchBanner, rightPanelAvailable: activeProject !== null, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index ff6f00e28272e..5809782a7100a 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -19,7 +19,6 @@ export interface PanelLayoutControlsProps { threadPanelPresentation: ThreadPanelPresentation; threadPanelPopoverHandle?: ReturnType; threadPanelShortcutLabel: string | null; - threadPanelHasAttention: boolean; rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; @@ -40,7 +39,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ threadPanelPresentation, threadPanelPopoverHandle, threadPanelShortcutLabel, - threadPanelHasAttention, rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, @@ -58,12 +56,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ size="sm" > - {threadPanelHasAttention ? ( -
    - {openRow(label, "link-primary")} +
    + {openRow(label, "primary")}
    diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index cd1265e46a60e..ef96d41058806 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { - THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; @@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? ( -
    +
    openThread(threadId)} @@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" disabled={node?.missing === true} onClick={() => openThread(threadId)} - part={canStop ? "group-row" : "row"} + part="row" /> } > diff --git a/apps/web/src/components/chat/threadDetailsPanelStyles.ts b/apps/web/src/components/chat/threadDetailsPanelStyles.ts index 33c454e4232b0..bd74bd95cd5e4 100644 --- a/apps/web/src/components/chat/threadDetailsPanelStyles.ts +++ b/apps/web/src/components/chat/threadDetailsPanelStyles.ts @@ -6,29 +6,20 @@ const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none"; const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS = - "hover:!bg-black/[0.055] data-pressed:!bg-black/[0.055] dark:hover:!bg-white/[0.075] dark:data-pressed:!bg-white/[0.075]"; + "hover:!bg-black/[0.035] data-pressed:!bg-black/[0.035] dark:hover:!bg-white/[0.08] dark:data-pressed:!bg-white/[0.08]"; const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`; +const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.035] dark:data-popup-open:!bg-white/[0.08]`; +const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.035] dark:has-[[data-popup-open]]:bg-white/[0.08]`; export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left"; -// The row supplies the first tint; the hovered or open segment adds a second tint. -const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.055] dark:data-popup-open:!bg-white/[0.075]`; - const THREAD_DETAILS_PANEL_CONTROL_CLASS = `h-8 min-w-0 rounded-lg border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80`; -const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.055] dark:has-[[data-popup-open]]:bg-white/[0.075]`; export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SELECT_ROW_CLASS = `${THREAD_DETAILS_PANEL_ROW_CLASS} pe-0 [&_[data-slot=select-icon]]:-me-px [&_[data-slot=select-icon]]:relative [&_[data-slot=select-icon]]:flex [&_[data-slot=select-icon]]:h-full [&_[data-slot=select-icon]]:w-8 [&_[data-slot=select-icon]]:shrink-0 [&_[data-slot=select-icon]]:items-center [&_[data-slot=select-icon]]:justify-center [&_[data-slot=select-icon]]:before:absolute [&_[data-slot=select-icon]]:before:-left-px [&_[data-slot=select-icon]]:before:top-1/2 [&_[data-slot=select-icon]]:before:h-4 [&_[data-slot=select-icon]]:before:w-px [&_[data-slot=select-icon]]:before:-translate-y-1/2 [&_[data-slot=select-icon]]:before:bg-border/65 [&_[data-slot=select-icon]>svg]:me-0 [&_[data-slot=select-icon]>svg]:size-4 [&_[data-slot=select-icon]>svg]:text-muted-foreground [&_[data-slot=select-icon]>svg]:opacity-100`; -// No transition on the group: the halves are Buttons whose background snaps (their base only -// transitions shadow), so an eased group tint would land frames later and the hover would -// visibly commit in two steps. -export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details-link flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; - -export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; - export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`; export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; @@ -40,7 +31,7 @@ export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rou /** A small text control on a row's second line, such as a pull request's checks or next action. */ export const THREAD_DETAILS_PANEL_META_ACTION_CLASS = `h-6 gap-1 rounded-md border-transparent px-1.5 text-xs font-medium text-muted-foreground ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; -export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; +export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 133fe91762386..c6b0591ee2578 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1843,7 +1843,6 @@ function PullRequestsRouteView() { threadPanelOpen={false} threadPanelPresentation="inline" threadPanelShortcutLabel={null} - threadPanelHasAttention={false} onToggleThreadPanel={() => undefined} rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} From 7ac5ec6b15593f5b682fe73b483bc8e24de76665 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:51:31 -0400 Subject: [PATCH 067/680] fix(web): Copy MCP URL shows up for environments reached over plain http (#16909) Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/ConnectionsSettings.tsx | 9 +-- docs/user/outside-agents.md | 9 ++- .../src/connection/presentation.test.ts | 76 ++++++++++++++----- .../src/connection/presentation.ts | 39 ++++++---- 4 files changed, 92 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5877f1bbda6c8..625efb562f977 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ ) { setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); } + const prepared = usePreparedConnection(environmentId); + const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; const mcpUrl = environmentMcpUrl({ entry: environment.entry, relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + connectedTarget, }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); - const prepared = usePreparedConnection(environmentId); const routeCount = connectionRoutes(environment.entry).length; const subtitleText = [ - environmentTransportLabel( - environment, - isConnected && prepared._tag === "Some" ? prepared.value.target : null, - ), + environmentTransportLabel(environment, connectedTarget), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 70129139206fd..86294d68dc3c8 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -16,11 +16,12 @@ example: https:///mcp ``` -The address must use HTTPS, or `localhost` when the agent runs on the host -itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. +The copied URL uses the route this device is connected over, so an agent on the +same device can reach it. -- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or - `localhost` on the host. See [remote access](./remote-access.md). +- **An agent on your own computers** can use any address that computer reaches + the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on + the host. See [remote access](./remote-access.md). - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach the environment from the internet. Use the T3 Connect address. A Tailscale address only works from your own tailnet. diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e9f4fe0d57eb6..61d6278a53051 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; import { BearerConnectionTarget, ConnectionBlockedError, ConnectionTransientError, + RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; import { @@ -72,25 +77,62 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); - it("offers an MCP address only where an MCP client can sign in", () => { - expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); - const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ + it("copies the MCP address of the route this device is connected over", () => { + const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { + const target = new BearerConnectionTarget({ ...TARGET, connectionId }); + return { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }; + }; + const lan = route("lan", "http://192.168.4.53:3773/"); + const tailnet = route("tailnet", "http://100.115.1.44:3773/"); + const serve = route("serve", "https://machine.tailnet.ts.net/"); + const entry: ConnectionCatalogEntry = { ...ENTRY, - profile: Option.some( - new BearerConnectionProfile({ - connectionId: TARGET.connectionId, - environmentId: TARGET.environmentId, - label: TARGET.label, - httpBaseUrl, - wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), - }), - ), + target: lan.target, + profile: lan.profile, + alternateRoutes: [tailnet, serve], + }; + + expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( + "http://100.115.1.44:3773/mcp", + ); + // Not connected: the preferred route, plain http or not. + expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); + }); + + it("passes over routes without an address of their own", () => { + const relay = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, }); - expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( - "http://127.0.0.1:3773/mcp", + const entry: ConnectionCatalogEntry = { + ...ENTRY, + target: relay, + profile: Option.none(), + alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], + }; + + // Relay discovery has not reported the tunnel address yet. + expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( + "https://environment.example.test/mcp", ); - // A plain-http LAN or tailnet address is refused by MCP clients' token checks. - expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); + expect( + environmentMcpUrl({ + entry, + connectedTarget: relay, + relayHttpBaseUrl: "https://tunnel.example.test", + }), + ).toBe("https://tunnel.example.test/mcp"); }); it("distinguishes initial connection, reconnect, and retry errors", () => { diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index a409847f0b5e4..9015a755133e5 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { SupervisorConnectionState } from "./model.ts"; +import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; +import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; export type EnvironmentConnectionPhase = | "available" @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( /** * The address an agent outside T3 (Claude Code, Codex) uses to reach this - * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP - * clients refuse to sign in through a plain-http token endpoint elsewhere. - * SSH connections ride a local forward that disappears with the client, so - * they have no stable address to hand out. + * environment's MCP server: the route this device is connected over, since an + * agent beside this client can reach it too, else the first route in + * preference order that has an address. SSH connections ride a local forward + * that disappears with the client, so they have no stable address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; readonly relayHttpBaseUrl?: string | undefined; + readonly connectedTarget?: ConnectionTarget | null | undefined; }): string | null { - const httpBaseUrl = - input.entry.target._tag === "RelayConnectionTarget" - ? (input.relayHttpBaseUrl ?? null) - : input.entry.target._tag === "SshConnectionTarget" - ? null - : connectionCatalogDisplayUrl(input.entry); - if (httpBaseUrl === null) return null; + const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; + const routes = connectionRoutes(input.entry); + const connectedRoute = routes.find( + (route) => connectionRouteId(route.target) === connectedRouteId, + ); + for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { + const httpBaseUrl = + route.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : routeHttpBaseUrl(route); + const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); + if (mcpUrl !== null) return mcpUrl; + } + return null; +} + +function mcpUrlFromBase(httpBaseUrl: string): string | null { let url: URL; try { url = new URL(httpBaseUrl); } catch { return null; } - const loopback = - url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; - if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; url.pathname = "/mcp"; url.search = ""; url.hash = ""; From abbf5d5bcd045cdb6baf16128ef54d4df3a7f3fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 15:05:31 -0700 Subject: [PATCH 068/680] fix(web): C#, Java, PHP and 11 other languages get file icons (#16974) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/pierre-icons.test.ts | 24 +++++++++- apps/web/src/pierre-icons.ts | 79 +++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 6090d7ed9e72b..c90b130b33eae 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -38,7 +38,10 @@ describe("Pierre file icons", () => { it("ships every custom icon referenced by the extended resolver", () => { const customIconNames = new Set( - Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")), + [ + ...Object.values(T3_PIERRE_ICONS.byFileName), + ...Object.values(T3_PIERRE_ICONS.byFileExtension), + ].filter((name) => name.startsWith("t3-")), ); for (const iconName of customIconNames) { assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); @@ -64,4 +67,23 @@ describe("Pierre file icons", () => { it("gives a dockerfile fence the Docker icon", () => { assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId("dockerfile"))); }); + + it.each([ + "csharp", + "dart", + "diff", + "elixir", + "haskell", + "java", + "kotlin", + "lua", + "php", + "powershell", + "r", + "scala", + "toml", + "xml", + ])("gives a %s fence a language icon", (language) => { + assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId(language))); + }); }); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 46f0099fedee3..ead46e89d6ee0 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -28,6 +28,51 @@ const T3_FILE_ICON_SPRITE = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; export const T3_PIERRE_ICONS = { @@ -41,9 +86,33 @@ export const T3_PIERRE_ICONS = { "pnpm-lock.yaml": "t3-file-icon-pnpm", "pnpm-workspace.yaml": "t3-file-icon-pnpm", }, - byFileExtension: Object.fromEntries( - VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), - ), + byFileExtension: { + ...Object.fromEntries( + VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), + ), + // Languages the complete set has no icon for. + cs: "t3-file-icon-csharp", + csx: "t3-file-icon-csharp", + dart: "t3-file-icon-dart", + diff: "t3-file-icon-diff", + patch: "t3-file-icon-diff", + ex: "t3-file-icon-elixir", + exs: "t3-file-icon-elixir", + hs: "t3-file-icon-haskell", + lhs: "t3-file-icon-haskell", + java: "t3-file-icon-java", + kt: "t3-file-icon-kotlin", + kts: "t3-file-icon-kotlin", + lua: "t3-file-icon-lua", + php: "t3-file-icon-php", + ps1: "t3-file-icon-powershell", + psm1: "t3-file-icon-powershell", + psd1: "t3-file-icon-powershell", + r: "t3-file-icon-r", + scala: "t3-file-icon-scala", + toml: "t3-file-icon-toml", + xml: "t3-file-icon-xml", + }, } satisfies FileTreeIcons; const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); @@ -51,11 +120,15 @@ const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); const LANGUAGE_EXTENSION_ALIASES: Record = { bash: "sh", csharp: "cs", + elixir: "ex", + haskell: "hs", javascript: "js", jsx: "jsx", + kotlin: "kt", markdown: "md", mdx: "mdx", plaintext: "txt", + powershell: "ps1", python: "py", ruby: "rb", rust: "rs", From 95ccbf406626a8a80fa545777a6388bdf5153086 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 15:06:03 -0700 Subject: [PATCH 069/680] feat(clients): live row shows the agent's latest thought (#16284) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 3 ++ .../src/features/threads/thread-work-log.tsx | 28 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 40 ++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 15 +++++- .../chat/MessagesTimeline.logic.test.ts | 54 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +++ .../src/components/chat/MessagesTimeline.tsx | 42 +++++++++++++-- apps/web/src/components/chat/WorkLog.tsx | 8 ++- .../src/work-log/presentation.test.ts | 38 +++++++++++++ .../src/work-log/presentation.ts | 20 +++++++ 10 files changed, 241 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c18874b1cb03f..b5f258a0889f9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1628,6 +1628,7 @@ function renderFeedEntry( summaryToolIcon={entry.summaryToolIcon} hasFailure={entry.hasFailure} shimmer={entry.shimmer} + thought={entry.thought} onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": + // A live thought wraps up to four lines, so that row measures itself. + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 4a9067529510d..32e6d272b6780 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: { readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; + readonly thought?: string | undefined; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; + const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; + const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel; const icon = props.summaryToolIcon ?? (props.toolSurface @@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: { return ( + {props.thought ? ( + // The latest thought sits above the status line as an ordinary work-log + // row, wrapped up to four lines. + + + + + + {props.thought} + + + ) : null} row.type === "work-toggle")).toMatchObject({ - summary: "First paragraph. Second paragraph.", + summary: "Thinking", + thought: "First paragraph.", live: true, }); } else { @@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])( }, ); +it("keeps the latest thought under the live tool status", () => { + const at = "2026-06-20T00:00:02.000Z"; + const thought: OrchestrationV2TurnItem = { + ...base("found-thought", at, 1), + type: "reasoning", + status: "completed", + streaming: false, + text: "Found the cause: no commits yet. Checking the UI next.", + }; + const tool = { ...command(at), status: "running" as const, completedAt: null }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ + thought: "Found the cause: no commits yet.", + live: true, + }); + expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ + summary: "Thinking", + }); +}); + it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool", at, ); expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "first-thought", + summary: "Thinking", + thought: "first-thought", live: true, shimmer: true, }); @@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool", ]) { const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); expect(live).toHaveLength(1); - expect(live[0]).toMatchObject({ - summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp", - }); + expect(live[0]).toMatchObject( + items.at(-1)!.type === "reasoning" + ? { summary: "Thinking", thought: "next-thought" } + : { summary: "Running vp" }, + ); } expect(first.status).toBe("running"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d88b1dba2ea2e..2dc513e40f023 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/work-log/command-label"; import { contextCompactionLabel, + liveThoughtLine, toolItemForDisplay, workEntryDisplayIndicatesToolFailure, liveActivityToolStatus, @@ -213,6 +214,8 @@ type ThreadFeedEntryContent = readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; + /** First sentence of the latest thought, shown above the live status line. */ + readonly thought?: string; } | { readonly type: "run-fold"; @@ -1496,8 +1499,17 @@ function appendToolGroupRows( const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const latestThought = + live && !expanded + ? activities.findLast( + (activity) => + activity.workEntry.itemType === "reasoning" && + (activity.workEntry.detail?.trim() ?? "") !== "", + )?.workEntry.detail + : undefined; + const thought = latestThought ? liveThoughtLine(latestThought) : ""; const summary = live - ? expanded && latestActivity.workEntry.itemType === "reasoning" + ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" ? "Thinking" : "Thought" @@ -1552,6 +1564,7 @@ function appendToolGroupRows( ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), + ...(thought ? { thought } : {}), hasFailure: (() => { const lastToolLike = activities.findLast((activity) => activity.toolLike); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1ce7145d63a3..98339041f68b4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); }); + it("carries the latest thought on the live row while a later tool runs", () => { + type WorkEntry = Extract< + Parameters[0]["timelineEntries"][number], + { kind: "work" } + >; + const work = ( + id: string, + at: string, + fields: Omit, + ): WorkEntry => ({ + id: `${id}-entry`, + kind: "work", + createdAt: at, + entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + work("old-thought", "2026-01-01T00:00:01Z", { + itemType: "reasoning", + detail: "First idea.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("new-thought", "2026-01-01T00:00:02Z", { + itemType: "reasoning", + detail: "Found the cause.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("running-command", "2026-01-01T00:00:03Z", { + command: "rg cause", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }), + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "running-command" }, + thought: { id: "new-thought" }, + }); + }); + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b619a347255b5..74f3991396ef9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -540,6 +540,8 @@ type MessagesTimelineRowContent = groupId: string; expanded: boolean; active: boolean; + /** Latest reasoning in the live group, shown above the status line. */ + thought?: WorkLogEntry; } | { kind: "working"; @@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: { latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); + const latestThoughtEntry = visibleActiveToolEntries.findLast( + (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "", + ); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed @@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, active: latestToolKeepsActivityLive, + ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), }; })() : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43f9746a6590b..6145098f60437 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + liveThoughtLine, resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, @@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {thoughtLine ? ( + // The latest thought's first sentence sits above the status line as an + // ordinary work-log row, so a finding never hides behind the next tool call. + + + + } + label={{thoughtLine}} + wrapLabel + /> + ) : null} {label} diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 4358f30f4de36..8ac63e0dae467 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -46,7 +46,13 @@ type RowContent = { function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { return ( -
    +
    {icon ? ( {icon} ) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8a73dd70fb73f..6e41a53478a1c 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, extractCommandOutputText, + liveThoughtLine, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -960,3 +961,40 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("liveThoughtLine", () => { + it("keeps the first sentence, including a closing quote", () => { + expect( + liveThoughtLine( + 'Found the cause: the repo has no commits, so `git worktree add` fails with "invalid reference: main." Now checking the UI.', + ), + ).toBe( + 'Found the cause: the repo has no commits, so git worktree add fails with "invalid reference: main."', + ); + }); + + it("does not cut at dots inside file names or long dashes", () => { + expect( + liveThoughtLine("I read ThreadLaunchService.ts \u2014 it skips the fetch. Next step."), + ).toBe("I read ThreadLaunchService.ts \u2014 it skips the fetch."); + }); + + it("uses a bold-only opening line as the whole line", () => { + expect( + liveThoughtLine("**Narrowing dispatch files**\n\nI should check the adapter. Then more."), + ).toBe("Narrowing dispatch files"); + expect(liveThoughtLine("**Narrowing dispatch files**\r\n\r\nI should check the adapter.")).toBe( + "Narrowing dispatch files", + ); + }); + + it("returns unpunctuated text whole and flattens markdown", () => { + expect(liveThoughtLine("- Checking [the docs](https://x.dev) for **limits**")).toBe( + "Checking the docs for limits", + ); + expect(liveThoughtLine("This is *really* ~~not~~ _fine_ in snake_case_names.")).toBe( + "This is really not fine in snake_case_names.", + ); + expect(liveThoughtLine(" ")).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f5e9daab74901..8ef6f58be5954 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -754,3 +754,23 @@ export function toolGroupSummaryKind( ); return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; } + +/** + * Plain-text line for the latest thought in the live activity row. A + * bold-only opening line (the Codex summary heading) wins; otherwise this is + * the first sentence of the reasoning text. Web and mobile both render it. + */ +export function liveThoughtLine(markdown: string): string { + const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1]; + const text = (heading ?? markdown) + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "") + .replace(/`+|\*\*|~~/g, "") + .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); + if (heading !== undefined) return text; + // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows. + const end = /[.?!]["'”’)]?(?=\s)/.exec(text); + return end ? text.slice(0, end.index + end[0].length) : text; +} From 300f7f9d45ff19c01bc987dbf1c3bc3e9a3a59ee Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Wed, 7 Oct 2026 23:29:43 +0100 Subject: [PATCH 070/680] feat(web): block-level Markdown in the rich text composer (#14677) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/web/package.json | 5 + apps/web/src/components/ChatMarkdown.tsx | 81 +- .../components/ComposerPromptEditorTiptap.tsx | 422 ++++++- .../chat/ComposerCodeBlockLanguagePicker.tsx | 121 ++ .../chat/ComposerCodeBlockNodeView.tsx | 67 ++ .../composerCodeBlockHighlight.test.ts | 152 +++ .../components/composerCodeBlockHighlight.ts | 231 ++++ apps/web/src/composer-block-shortcuts.test.ts | 126 +++ apps/web/src/composer-code-block.test.ts | 391 +++++++ apps/web/src/composer-code-block.ts | 254 +++++ apps/web/src/composer-code-languages.test.ts | 59 + apps/web/src/composer-code-languages.ts | 97 ++ .../src/composer-list-continuation.test.ts | 19 +- apps/web/src/composer-list-continuation.ts | 21 +- apps/web/src/composer-list-split.test.ts | 265 +++++ apps/web/src/composer-rich-text-doc.test.ts | 529 +++++++++ apps/web/src/composer-rich-text-doc.ts | 1002 +++++++++++++++-- apps/web/src/index.css | 84 +- docs/internals/composer-editors.md | 15 + docs/user/composer.md | 17 + pnpm-lock.yaml | 15 + 21 files changed, 3798 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx create mode 100644 apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx create mode 100644 apps/web/src/components/composerCodeBlockHighlight.test.ts create mode 100644 apps/web/src/components/composerCodeBlockHighlight.ts create mode 100644 apps/web/src/composer-block-shortcuts.test.ts create mode 100644 apps/web/src/composer-code-block.test.ts create mode 100644 apps/web/src/composer-code-block.ts create mode 100644 apps/web/src/composer-code-languages.test.ts create mode 100644 apps/web/src/composer-code-languages.ts create mode 100644 apps/web/src/composer-list-split.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index d17795f15c5d3..915466100bcff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,12 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code": "^3.31.3", + "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-heading": "^3.31.3", + "@tiptap/extension-horizontal-rule": "^3.31.3", + "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25c..e595efdc5a07e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -989,6 +989,63 @@ function MarkdownDetails({ ); } +/** + * The code block frame, shared with the composer so an editable fence and a + * rendered one cannot drift apart: the wrapper, its header row with the + * language title, an optional actions slot, and the body as children. The + * composer passes Tiptap's node view wrapper as the outer element. + */ +export function MarkdownCodeBlockFrame({ + as: Wrapper = "div", + language, + fenceTitle, + theme, + wrapped = true, + title, + actions, + headerProps, + children, +}: { + as?: React.ElementType; + language: string; + fenceTitle: string | null; + theme: "light" | "dark"; + wrapped?: boolean; + /** Replaces the language title, as the composer does with its language picker. */ + title?: React.ReactNode; + actions?: React.ReactNode; + headerProps?: React.HTMLAttributes; + children: React.ReactNode; +}) { + return ( + +
    + {title ?? ( + + + + )} + {actions} +
    + {children} +
    + ); +} + /** * Filename titles render icon + text; language-only titles render just the * icon (redundant next to its own name) and fall back to the language text @@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({ } return ( -
    -
    - - - + {leadingActions} {canWrap ? ( @@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({ ) : null} {copyButton} -
    + } + > {children} -
    + ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bac965c0b97db..6e830991ab7eb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,11 +1,17 @@ -import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; -import { TaskList } from "@tiptap/extension-task-list"; +import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { + type EditorState, + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; +import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -45,19 +51,34 @@ import { } from "~/composer-editor-mentions"; import { buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, buildTiptapContent, collapsedToFlat, caretTakesMarksBefore, + convertBulletItemToTask, ComposerCodeExtension, ComposerTaskItemExtension, + ComposerTaskListExtension, flatToCollapsed, flatToMarkdown, flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, + splitOrLiftListItem, stepCaretAcrossStyledEdge, type SkillMeta, } from "~/composer-rich-text-doc"; +import { + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentCodeBlock, + indentedNewlineInCodeBlock, + selectionInOneCodeBlock, +} from "~/composer-code-block"; import { COMPOSER_UNDO_GROUP_DELAY, type ComposerChangeKind, @@ -65,6 +86,7 @@ import { markAsClipboardEdit, } from "~/composer-undo-grouping"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { FileTagChipContent } from "./chat/FileTagChip"; @@ -82,6 +104,8 @@ import { import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; +import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { let range: StyledRange | null = null; let openLength = 0; for (const run of map.runs) { + // A fence is drawn as a block, not revealed a character at a time, so its + // delimiters never become marker widgets. + if (run.nodeName === "codeBlock") continue; if (run.openLen > 0) { range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; range.markers.push({ @@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { return ranges; } +/** + * A typed marker becomes a list item that remembers the marker it was typed + * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. + * + * `- ` alone is not enough for a dash: it waits for the first character after + * the space, which it carries into the new item. That is what lets the GFM + * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, + * instead of being cut off by an instant bullet. A `- ` left on its own is + * still a bullet the next time the draft is rebuilt. + */ +function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { + return new InputRule({ + find, + handler: ({ state, range, match, chain }) => { + const marker = match.groups?.marker ?? "-"; + const space = match.groups?.space ?? " "; + const carried = match.groups?.carried ?? ""; + // Top-level paragraphs only: inside an item or a quote the new list + // would nest under a line the stored draft writes flat. + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + const command = chain() + .deleteRange(range) + .wrapInList( + listType, + listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, + ) + .updateAttributes("listItem", { marker, space }); + (carried ? command.insertContent(carried) : command).run(); + return undefined; + }, + }); +} + +/** + * `[ ] ` at the start of an existing bullet item turns it into a task, for + * items that were already a list when the checkbox was wanted. New tasks are + * typed whole, `- [ ] `, and reach the task rule directly. + */ +const bulletToTaskInputRule = new InputRule({ + find: /^\[([ xX])\] $/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + const item = $from.node(-1); + if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; + // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` + // item comes back out as `- [ ]`. + if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; + const checked = (match[1] ?? " ").toLowerCase() === "x"; + chain() + .command(({ tr }) => { + convertBulletItemToTask(tr, range.from, range.to, checked); + return true; + }) + .run(); + return undefined; + }, +}); + +/** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */ +function taskInputRule(type: NodeType): InputRule { + const rule = wrappingInputRule({ + find: /^- \[([ xX])\] $/, + type, + getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + }); + return new InputRule({ + find: rule.find, + handler: (props) => + props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null, + }); +} + +function hasAncestor($pos: ResolvedPos, name: string): boolean { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if ($pos.node(depth).type.name === name) return true; + } + return false; +} + +/** + * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: + * a quote holds prose lines, and a list item is not one. + */ +const blockquoteInputRule = new InputRule({ + find: /^>(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) + .run(); + return undefined; + }, +}); + +/** + * `---` becomes a rule as the third dash lands; `***` and `___` need a space + * after them so typing bold or an underscore is not interrupted, matching + * Tiptap's own rule. Only at a top-level paragraph: the list and quote + * serializers have no line to write a rule into. The typed characters are + * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a + * rule at the end so the caret has somewhere to go. + */ +const horizontalRuleInputRule = new InputRule({ + find: /^(---|\*\*\*|___)\s?$/, + handler: ({ state, range, match, chain }) => { + const source = match[0] ?? "---"; + if (!source.startsWith("---") && !/\s$/.test(source)) return null; + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setHorizontalRule() + .command(({ tr }) => { + // The rule is the block before the caret's paragraph. + const $pos = tr.selection.$from; + const index = $pos.index(0) - 1; + if (index < 0) return true; + const rulePos = $pos.posAtIndex(index, 0); + const rule = tr.doc.nodeAt(rulePos); + if (rule?.type.name === "horizontalRule") { + tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); + } + return true; + }) + .run(); + return undefined; + }, +}); + +/** + * `# ` through `###### ` at a top-level paragraph make a heading. The space + * is required, which is exactly what keeps `#1234` a pull request reference + * with its picker rather than a heading. Not inside lists or quotes, whose + * serializers have no line for one. + */ +const headingInputRule = new InputRule({ + find: /^(#{1,6})(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) + .run(); + return undefined; + }, +}); + +/** Whether the caret sits inside a fenced code block. */ +function isInCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ @@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { expandedCursor: nextExpandedCursor, contextIds: map.contextIds, }; - const cursorAdjacentToMention = + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + inCodeBlock || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - map.contextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); }, []); const editorAttributes = useMemo( @@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { gapcursor: false, trailingNode: false, code: false, + // The list extensions handle Backspace and Delete themselves. + listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, // Plain mode has no marks: typed markers stay literal characters. ...(richText ? {} : { bold: false, italic: false, strike: false }), @@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ...(richText ? [ ComposerCodeExtension, - TaskList, - ComposerTaskItemExtension.extend({ + ComposerCodeBlockExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { + // The header holds the language picker. Tiptap only shields + // events aimed at the button element itself, so a click on + // its icon or label would reach ProseMirror and move the + // selection; keep every header event away from the editor. + stopEvent: ({ event }) => + event.target instanceof Element && + event.target.closest(".chat-markdown-codeblock-header") !== null, + }); + }, + // Tiptap's own ``` + space rule would open a fence inside a + // list item or quote, where the serializer has no line for + // it. Enter on a fence line covers the gesture at top level. addInputRules() { - return [ - wrappingInputRule({ - find: /^- \[([ xX])\] $/, - type: this.type, - getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + return []; + }, + }), + composerCodeBlockHighlight({ + resolveTheme: () => + resolveDiffThemeName( + document.documentElement.classList.contains("dark") ? "dark" : "light", + ), + }), + ...ComposerBlockExtensions.map((extension) => + extension.name === "blockquote" + ? extension.extend({ + addInputRules() { + return [blockquoteInputRule]; + }, + }) + : extension.name === "horizontalRule" + ? extension.extend({ + addInputRules() { + return [horizontalRuleInputRule]; + }, + }) + : extension.name === "heading" + ? extension.extend({ + addInputRules() { + return [headingInputRule]; + }, + }) + : extension, + ), + ...ComposerListExtensions.map((extension) => + extension.name === "listItem" + ? extension + : extension.extend({ + addInputRules() { + return this.name === "bulletList" + ? [ + listMarkerInputRule(/^(?[*+])(?\s)$/, "bulletList"), + listMarkerInputRule( + /^(?-)(?[ \t]+)(?[^\s[])$/, + "bulletList", + ), + ] + : [ + listMarkerInputRule( + /^(?\d+[.)])(?\s)$/, + "orderedList", + ), + ]; + }, }), - ]; + ), + ComposerTaskListExtension, + ComposerTaskItemExtension.extend({ + addInputRules() { + return [taskInputRule(this.type), bulletToTaskInputRule]; }, }), ] @@ -982,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { event.preventDefault(); return true; } + // Inside a fence Tab belongs to the code, not to the composer's + // focus order or its autocomplete. + if ( + event.key === "Tab" && + !event.metaKey && + !event.ctrlKey && + selectionInOneCodeBlock(view.state) + ) { + event.preventDefault(); + event.stopPropagation(); + return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => + view.dispatch(tr), + ); + } + // A fence is multi-line by definition, so Enter and Shift+Enter + // belong to the code rather than to sending or splitting: inside a + // block they make a line (a generic split would cut the fence in + // two), and on a line that is only an opening fence they open the + // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which + // falls through. + if ( + event.key === "Enter" && + richText && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + if (selectionInOneCodeBlock(view.state)) { + event.preventDefault(); + event.stopPropagation(); + const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); + return ( + exitCodeBlockOnClosingFence(view.state, dispatch) || + exitCodeBlockOnTrailingBlankLines(view) || + indentedNewlineInCodeBlock(view.state, dispatch) || + newlineInCode(view.state, dispatch) + ); + } + if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } const handler = onCommandKeyDownRef.current; if (event.key === "Enter") { const instance = editorHolder.current; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); - const handled = handler?.("Enter", event, isTaskItem) ?? false; + const isListItem = richText && (instance?.isActive("listItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; if (handled) { event.preventDefault(); event.stopPropagation(); return true; } event.preventDefault(); + if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { + return true; + } if ( - isTaskItem && + richText && instance && - (instance.commands.splitListItem("taskItem", { checked: false }) || - (view.state.selection.$from.parent.content.size === 0 && - instance.commands.liftListItem("taskItem"))) + hasAncestor(view.state.selection.$from, "blockquote") && + view.state.selection.$from.parent.content.size === 0 && + instance.commands.lift("blockquote") ) { return true; } @@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pastedText = clipboardData.getData("text/plain"); if (!pastedText) return false; event.preventDefault(); + // A fence takes the clipboard verbatim. Running the markdown path + // here would split the block on newlines, nest a pasted fence and + // build chips the code block's schema cannot hold anyway. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } const importFragment = importFragmentRef.current; let text = importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } const editorInstance = editorHolder.current; if (editorInstance) { - insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { - // Tagged on the same transaction insertContent builds, so the - // paste is one undo step of its own. - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - .insertContent(content) - .run(); - }); + // Inside a list item or quote, pasted block markup has nowhere to + // go: it stays literal lines the next rebuild reads back. Inline + // marks still apply. + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested }, + (content) => { + // Tagged on the same transaction insertContent builds, so the + // paste is one undo step of its own. + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + .insertContent(content) + .command(({ tr }) => { + // A paste ending in a rule leaves the rule selected, and + // the next keystroke would replace it. The caret goes to + // the line after it, which is added when there is none. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); scrollTiptapCaretIntoView(editorInstance); } return true; @@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; if (from === to) return; - const { doc, schema } = editor.state; - const slice = doc.slice(from, to); - const first = slice.content.firstChild; - const content = first?.isInline - ? schema.nodes.paragraph!.create(null, slice.content) - : first?.type.name === "taskItem" - ? schema.nodes.taskList!.create(null, slice.content) - : slice.content; - const text = serializeEditorDoc(doc.type.create(null, content)).value; + const text = serializeSelection(editor.state.doc, from, to); const contextIds = Array.from(new Set(collectInlineContextIds(text))); const fragment = contextIds.length > 0 ? build?.(contextIds) : null; event.preventDefault(); @@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean }, + options: { styling: boolean; blocks?: boolean }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx new file mode 100644 index 0000000000000..bf200bab53d8d --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx @@ -0,0 +1,121 @@ +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + CODE_BLOCK_LANGUAGES, + type CodeBlockLanguage, + codeLanguageEntry, + codeLanguageLabel, +} from "~/composer-code-languages"; +import { syntheticFileNameForLanguageId } from "~/pierre-icons"; + +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +/** + * The language's file icon: plain text gets the text-file icon, and a + * language the icon set does not cover gets the default file icon, so every + * entry has one and the labels line up. + */ +function LanguageIcon(props: { language: string; theme: "light" | "dark" }) { + const fileName = props.language ? syntheticFileNameForLanguageId(props.language) : "file.txt"; + return ( + + ); +} + +/** + * The language control in a composer fence's header: shows what the fence + * declares and switches it from a searchable list. A language typed on the + * fence line that the list does not carry is offered as its own entry, so + * the current choice is always visible and checked. + */ +export function ComposerCodeBlockLanguagePicker(props: { + language: string; + theme: "light" | "dark"; + disabled: boolean; + onChange: (language: string) => void; +}) { + const [query, setQuery] = useState(""); + const current = codeLanguageEntry(props.language)?.id ?? props.language; + const items = useMemo( + () => + CODE_BLOCK_LANGUAGES.some((entry) => entry.id === current) + ? [...CODE_BLOCK_LANGUAGES] + : [{ id: current, label: current }, ...CODE_BLOCK_LANGUAGES], + [current], + ); + const filteredItems = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return items; + return items.filter( + (entry) => entry.label.toLowerCase().includes(needle) || entry.id.includes(needle), + ); + }, [items, query]); + const selected = items.find((entry) => entry.id === current) ?? null; + + return ( + // The trigger's pill pads its content by 8px (padding plus border) and the + // button pulls a leading icon back 2px; offsetting the difference lines + // the icon up with the code below while keeping the hover pill. + + entry.label} + isItemEqualToValue={(a, b) => a.id === b.id} + value={selected} + onOpenChange={(open) => { + if (!open) setQuery(""); + }} + onValueChange={(entry) => { + if (entry && entry.id !== current) props.onChange(entry.id); + }} + > + + } + > + + {codeLanguageLabel(props.language)} + + + + setQuery(event.target.value)} + /> + No matching languages. + + {(entry: CodeBlockLanguage) => ( + + + {entry.label} + {entry.id === current ? : null} + + )} + + + + + ); +} diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx new file mode 100644 index 0000000000000..fcaf93af94139 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -0,0 +1,67 @@ +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; + +import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; + +import { useTheme } from "../../hooks/useTheme"; +import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; +import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; + +/** + * Draws a composer fence in the chat view's own code block frame, so a draft + * looks like the message it is about to become, with the language title + * swapped for a picker that rewrites the fence's language in place. + * + * The chrome stops at the language. Chat's wrap and copy buttons act on text + * the reader cannot change; here the text is the draft, and both are already + * a selection away. + */ +export function ComposerCodeBlockNodeView({ + node, + editor, + getPos, + updateAttributes, +}: NodeViewProps) { + const { resolvedTheme } = useTheme(); + const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; + const language = languageOfInfoString(info); + + const changeLanguage = (next: string) => { + updateAttributes({ language: withInfoStringLanguage(info, next) }); + // Back into the code, at its end, the way a picker hands focus back. + const position = getPos(); + if (typeof position === "number") { + editor + .chain() + .focus(position + 1 + node.content.size) + .run(); + } + }; + + return ( + + } + > +
    +
    +          {/* A caret needs a line to sit on even before any code is typed. */}
    +          
    +            as="code"
    +            className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]"
    +          />
    +        
    +
    +
    + ); +} diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts new file mode 100644 index 0000000000000..bd765dbd7b442 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -0,0 +1,152 @@ +import { Editor } from "@tiptap/core"; +import type { EditorView } from "@tiptap/pm/view"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; +import type { DiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +import { + composerCodeBlockHighlight, + MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, + shouldHighlightCodeBlock, + tokenizeBlock, +} from "./composerCodeBlockHighlight"; + +vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); + +const THEME = "github-dark" as DiffThemeName; + +/** + * Stands in for the Shiki highlighter so the offset arithmetic can be checked + * without loading a real grammar. Splits each line into whitespace-delimited + * tokens, which is enough shape for the flattening logic under test. + */ +function fakeHighlighter(colorFor: (content: string) => string | undefined) { + return { + codeToTokens(code: string) { + return { + tokens: code.split(/\r?\n/).map((line) => + line + .split(/(\s+)/) + .filter((part) => part.length > 0) + .map((content) => ({ content, color: colorFor(content) })), + ), + }; + }, + } as unknown as Parameters[0]; +} + +describe("composer code block highlighting", () => { + it("maps token offsets to positions within the block text", () => { + const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); + + const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); + + expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); + }); + + it("accounts for the newline between lines", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); + + // "one" is 3 characters, then the newline, so "two" starts at 4. + expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); + }); + + it("accounts for both characters of a CRLF line break", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); + + expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); + }); + + it("skips whitespace-only tokens so indentation is not decorated", () => { + const highlighter = fakeHighlighter(() => "#0000ff"); + + const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); + + expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); + }); + + it("falls back to plain text when the grammar throws", () => { + const throwing = { + codeToTokens() { + throw new Error("unsupported language"); + }, + } as unknown as Parameters[0]; + + expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); + }); +}); + +describe("the highlighting size cap", () => { + // Each keystroke re-tokenizes the whole fence on the main thread; past a + // few hundred lines that stalls typing, so large fences stay plain. + it("highlights a fence up to the cap", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); + }); + + it("leaves a fence past the cap plain", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); + }); +}); + +describe("a highlighter that fails to load", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * Shiki can fail to initialize at all. The blocks it could not reach stay + * plain, the ones it could are still painted, and nothing is left rejected. + */ + it("still repaints the blocks whose highlighter loaded", async () => { + vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => + language === "ts" + ? Promise.resolve(fakeHighlighter(() => "#ff0000")) + : Promise.reject(new Error("Shiki failed to initialize")), + ); + const editor = new Editor({ + extensions: [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + composerCodeBlockHighlight({ resolveTheme: () => THEME }), + ], + content: { + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, + { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, + ], + }, + }); + // The plugin watches for theme changes; the unit suite has no DOM. + vi.stubGlobal("document", { documentElement: {} }); + vi.stubGlobal( + "MutationObserver", + class { + observe() {} + disconnect() {} + }, + ); + // ProseMirror names each plugin after its key, though it does not type it. + const plugin = editor.extensionManager.plugins.find((candidate) => + (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), + ); + const repainted = new Promise((resolve) => { + const view = { + get state() { + return editor.state; + }, + dispatch: () => resolve(), + } as unknown as EditorView; + plugin?.spec.view?.(view); + }); + + await expect(repainted).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts new file mode 100644 index 0000000000000..68f024cb907a5 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -0,0 +1,231 @@ +import { Extension } from "@tiptap/core"; +import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import type { DiffThemeName } from "~/lib/diffRendering"; +import { languageOfInfoString } from "~/composer-code-languages"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +interface HighlightedBlock { + /** Guards against repainting a block whose text and language are unchanged. */ + readonly signature: string; + readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; +} + +const composerCodeBlockHighlightKey = new PluginKey("composerCodeBlockHighlight"); + +/** + * Past this many characters a fence is left plain. Any edit changes a block's + * signature, so each keystroke re-tokenizes the whole block synchronously on + * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms + * at a thousand, which stalls typing. 20k characters is roughly 400–500 lines + * of ordinary code; a fence that shrinks back under it is highlighted again. + */ +export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; + +export function shouldHighlightCodeBlock(code: string): boolean { + return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; +} +function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { + // A separator that cannot occur in a language name keeps the parts distinct. + return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); +} + +/** + * The composer's code blocks, which are always top-level: list items and + * quotes cannot hold one. Scanning only the top level keeps a keystroke in a + * long draft from walking every node in it. + */ +function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { + const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; + state.doc.forEach((node, pos) => { + if (node.type.name === "codeBlock") blocks.push({ node, pos }); + }); + return blocks; +} + +/** + * Highlights composer code blocks with the same Shiki instance the chat view + * uses, painted as inline decorations so the text nodes stay editable. + * + * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the + * block that changed, and only after its highlighter has resolved. Blocks whose + * text and language are unchanged reuse their previous decorations. + */ +export function composerCodeBlockHighlight(options: { + resolveTheme: () => DiffThemeName; +}): Extension { + return Extension.create({ + name: "composerCodeBlockHighlight", + + addProseMirrorPlugins() { + // Every keystroke in a code block mints a new signature, so each scan + // drops the signatures the document no longer holds. The cache is then + // bounded by the document itself, and no block it holds is ever evicted. + const cache = new Map(); + // The latest scan's signatures, so a tokenize that resolves after its + // block was edited away does not put it back. + let live = new Set(); + + return [ + new Plugin({ + key: composerCodeBlockHighlightKey, + + state: { + init: () => DecorationSet.empty, + apply(transaction, value, _oldState, newState) { + if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { + return value.map(transaction.mapping, transaction.doc); + } + return buildDecorations(newState, cache, options.resolveTheme()); + }, + }, + + props: { + decorations(state) { + return composerCodeBlockHighlightKey.getState(state); + }, + }, + + view(view) { + let disposed = false; + let paintedTheme = options.resolveTheme(); + let scannedDoc: ProseMirrorNode | null = null; + + /** + * Tokenizes any block missing from the cache, then repaints once. + * Returning early when nothing is pending is what stops the + * dispatch below from re-triggering this on its own update. + */ + const refresh = () => { + const theme = options.resolveTheme(); + const themeChanged = theme !== paintedTheme; + paintedTheme = theme; + // A selection change cannot alter what needs tokenizing, and + // building a signature means concatenating every block's text. + if (!themeChanged && view.state.doc === scannedDoc) return; + scannedDoc = view.state.doc; + const blocks = collectCodeBlocks(view.state); + live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); + for (const signature of cache.keys()) { + if (!live.has(signature)) cache.delete(signature); + } + const pending = blocks.filter( + ({ node }) => + shouldHighlightCodeBlock(node.textContent) && + !cache.has(blockSignature(node, theme)), + ); + if (pending.length === 0) { + // A theme switch keeps every signature but changes which one + // applies, so the painted decorations still have to be rebuilt. + if (themeChanged) { + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + } + return; + } + + // Settled rather than all: a block whose highlighter fails to + // load stays plain, and the others are still painted. + void Promise.allSettled( + pending.map(async ({ node }) => { + // The stored info string keeps everything after the language + // (`js title=x`); Shiki wants only the name. + const language = + languageOfInfoString(String(node.attrs.language ?? "")) || "text"; + const signature = blockSignature(node, theme); + const highlighter = await getSyntaxHighlighterPromise(language); + if (disposed || cache.has(signature) || !live.has(signature)) return; + cache.set(signature, { + signature, + decorations: tokenizeBlock(highlighter, node.textContent, language, theme), + }); + }), + ).then(() => { + if (disposed) return; + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + }); + }; + + refresh(); + // The theme lives on , outside the editor, so nothing would + // otherwise tell the view a repaint is due. + const themeObserver = new MutationObserver(refresh); + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + }); + + return { + update: refresh, + destroy() { + disposed = true; + themeObserver.disconnect(); + }, + }; + }, + }), + ]; + }, + }); +} + +type Highlighter = Awaited>; + +/** Flattens Shiki's line/token structure into offsets within the block's text. */ +export function tokenizeBlock( + highlighter: Highlighter, + code: string, + language: string, + theme: DiffThemeName, +): ReadonlyArray<{ from: number; to: number; color: string }> { + let tokens; + try { + tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; + } catch { + // An unsupported language should read as plain text, not break the editor. + return []; + } + + const decorations: Array<{ from: number; to: number; color: string }> = []; + let offset = 0; + for (const [lineIndex, line] of tokens.entries()) { + // The separator between lines, which Shiki drops; CRLF is two characters. + if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; + for (const token of line) { + const length = token.content.length; + if (token.color && token.content.trim()) { + decorations.push({ from: offset, to: offset + length, color: token.color }); + } + offset += length; + } + } + return decorations; +} + +function buildDecorations( + state: EditorState, + cache: Map, + theme: DiffThemeName, +): DecorationSet { + const decorations: Decoration[] = []; + for (const { node, pos } of collectCodeBlocks(state)) { + if (!shouldHighlightCodeBlock(node.textContent)) continue; + const highlighted = cache.get(blockSignature(node, theme)); + if (!highlighted) continue; + // Token offsets index the block's text, which lines up with document + // positions only because `codeBlock` is `text*` with no marks — one + // unmarked text node, so `content.size === textContent.length`. Allowing + // marks or hard breaks in the code block would desync these silently. + if (node.content.size !== node.textContent.length) continue; + // +1 steps past the code block's opening token into its text. + const start = pos + 1; + for (const decoration of highlighted.decorations) { + decorations.push( + Decoration.inline(start + decoration.from, start + decoration.to, { + style: `color:${decoration.color}`, + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); +} diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts new file mode 100644 index 0000000000000..8ecd47d0e299e --- /dev/null +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -0,0 +1,126 @@ +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + ComposerTaskListExtension, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +// The same modifier ProseMirror's keymap reads "Mod" as. +const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform); + +/** A key chord as the browser reports it: key, keyCode and modifiers. */ +function chord(spec: string) { + const parts = spec.split("-"); + const key = parts.pop()!; + const has = (name: string) => parts.includes(name); + const base = key.length === 1 ? key.toUpperCase() : key; + return { + key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key, + keyCode: base.length === 1 ? base.charCodeAt(0) : 0, + shiftKey: has("Shift"), + altKey: has("Alt"), + metaKey: has("Mod") && mac, + ctrlKey: has("Mod") && !mac, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyboardEvent; +} + +/** + * Presses `spec` with the caret inside `value`, the way the composer's + * editor would receive it, and returns the stored Markdown. + */ +function press(value: string, spec: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + trailingNode: false, + }), + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ...ComposerListExtensions, + ComposerTaskListExtension, + ComposerTaskItemExtension, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + // What the view's `someProp("handleKeyDown")` does, without mounting one: + // offer the key to each extension plugin in order until one handles it. + // An unmounted editor's state does not carry them yet, so they come from + // the extension manager. + const event = chord(spec); + const before = editor.state.doc; + const handled = editor.extensionManager.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, editor.view, event), + ); + return { + handled, + changed: !editor.state.doc.eq(before), + value: serializeEditorDoc(editor.state.doc).value, + }; +} + +// Tiptap's own block shortcuts would nest a list, task list, code block or +// heading inside a quote, or restructure a list item. The serializers only +// write what the composer's grammar has, so the nested block, and the text +// in it, would vanish from the stored draft. None of these are the +// composer's shortcuts; they must do nothing. +const BLOCK_CHORDS = [ + "Mod-Shift-8", + "Mod-Shift-7", + "Mod-Shift-9", + "Mod-Shift-b", + "Mod-Alt-c", + "Mod-Alt-1", + "Mod-Alt-3", +]; + +describe("Tiptap's block shortcuts", () => { + it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => { + expect(press("> keep this text", spec).value).toBe("> keep this text"); + }); + + it.each(BLOCK_CHORDS)("leave a list item alone on %s", (spec) => { + expect(press("- item", spec).value).toBe("- item"); + }); + + it.each(BLOCK_CHORDS)("leave a paragraph alone on %s", (spec) => { + expect(press("plain text", spec).value).toBe("plain text"); + }); + + // Guards the harness itself: a chord the composer keeps must still reach + // the keymap, or every case above would pass without pressing anything. + it("still delivers the composer's own keys", () => { + expect(press("**bold**", "Mod-b").handled).toBe(true); + }); + + // Sinking or lifting an item moves it without touching its `indent`, so + // the stored draft keeps the old nesting and the next rebuild undoes the + // move. Nesting belongs to the composer's own Tab, which edits the source. + it.each([ + ["- p\n- a", "Tab"], + ["- p\n - a", "Shift-Tab"], + ["- [ ] p\n- [ ] a", "Tab"], + ["- [ ] p\n - [ ] a", "Shift-Tab"], + ])("leave the nesting of %j alone on %s", (value, spec) => { + const result = press(value, spec); + expect(result.changed).toBe(false); + expect(result.value).toBe(value); + }); +}); diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts new file mode 100644 index 0000000000000..3a0ec2fe127ce --- /dev/null +++ b/apps/web/src/composer-code-block.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Editor, Node } from "@tiptap/core"; +import { newlineInCode } from "@tiptap/pm/commands"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + CODE_BLOCK_INDENT, + indentCodeBlock, + indentLines, + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentedNewlineInCodeBlock, + leadingWhitespace, + outdentLine, +} from "./composer-code-block"; +import StarterKit from "@tiptap/starter-kit"; + +import { + ComposerCodeBlockExtension, + ComposerListExtensions, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +const extensions = [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + ...ComposerListExtensions, + Node.create({ + name: "composer-mention", + group: "inline", + inline: true, + atom: true, + addAttributes: () => ({ path: { default: "" }, source: { default: "" } }), + }), +]; + +describe("leadingWhitespace", () => { + it("reads the indent a new line should inherit", () => { + expect(leadingWhitespace(" nested()")).toBe(" "); + expect(leadingWhitespace("\tnested()")).toBe("\t"); + expect(leadingWhitespace("flush()")).toBe(""); + }); + + it("stops at the first non-space so code is never treated as indent", () => { + expect(leadingWhitespace(" a b")).toBe(" "); + }); +}); + +describe("outdentLine", () => { + it("removes a full indent when there is one", () => { + expect(outdentLine(" deep")).toBe(" deep"); + }); + + /** Hand-indented lines should still outdent rather than refuse to move. */ + it("falls back to a single space or tab", () => { + expect(outdentLine(" odd")).toBe("odd"); + expect(outdentLine("\ttabbed")).toBe("tabbed"); + }); + + it("bottoms out instead of eating code", () => { + expect(outdentLine("flush()")).toBe("flush()"); + }); +}); + +describe("indentLines", () => { + it("indents every line it is given", () => { + expect(indentLines(["a", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + /** Indenting a blank line would leave whitespace the user cannot see. */ + it("leaves blank lines alone", () => { + expect(indentLines(["a", "", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + "", + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + it("outdents every line it is given", () => { + expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); + }); +}); + +/** Builds an editor holding a single fenced block, with the caret placed by offset. */ +function codeEditor(code: string, at?: { from: number; to?: number }) { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], + }, + }); + const start = 1; + const from = start + (at?.from ?? code.length); + const to = start + (at?.to ?? at?.from ?? code.length); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), + ); + return editor; +} + +const textOf = (editor: Editor) => editor.state.doc.textContent; + +describe("indentedNewlineInCodeBlock", () => { + it("carries the current indentation onto the new line", () => { + const editor = codeEditor("function f() {\n body()"); + const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(handled).toBe(true); + expect(textOf(editor)).toBe("function f() {\n body()\n "); + }); + + /** + * With no indent to carry there is nothing to add, so the default newline + * should handle it and stay a single undo step. + */ + it("declines an unindented line", () => { + const editor = codeEditor("flush()"); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(textOf(editor)).toBe("flush()"); + }); + + it("indents from the line the caret is on, not the last line", () => { + const editor = codeEditor(" deep()\nflush()", { from: 10 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" deep()\n \nflush()"); + }); + + it("replaces the selected text rather than keeping it", () => { + const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" keep()\n "); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], + }, + }); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +/** Enter inside a fence, in the order the composer's key handler tries it. */ +function pressEnter(editor: Editor) { + const dispatch = (tr: Parameters[0]) => editor.view.dispatch(tr); + return ( + exitCodeBlockOnTrailingBlankLines(editor.view) || + indentedNewlineInCodeBlock(editor.state, dispatch) || + newlineInCode(editor.state, dispatch) + ); +} + +describe("exitCodeBlockOnTrailingBlankLines", () => { + it("leaves the block once two blank lines end it", () => { + const editor = codeEditor("flush()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe("flush()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** Enter carries indentation, so the blank lines after nested code are not empty. */ + it("counts lines holding only carried indentation as blank", () => { + const editor = codeEditor(" nested()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe(" nested()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** An unclosed fence runs to the end of the draft, so leaving it must close it. */ + it("closes an unclosed fence on the way out", () => { + const editor = codeEditor("flush()"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", "")); + pressEnter(editor); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```"); + }); + + it("stays in the block when the caret is not at its end", () => { + const editor = codeEditor("a\n\n\nb", { from: 3 }); + expect(exitCodeBlockOnTrailingBlankLines(editor.view)).toBe(false); + }); +}); + +describe("exitCodeBlockOnClosingFence", () => { + const exit = (editor: Editor) => + exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr)); + + it("ends the block at a typed closing fence and starts a line after it", () => { + const editor = codeEditor("const a = 1\n```"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts")); + expect(exit(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("explain this")); + expect(serializeEditorDoc(editor.state.doc).value).toBe( + "```ts\nconst a = 1\n```\nexplain this", + ); + }); + + it("moves the lines after the fence out of the block", () => { + const editor = codeEditor("a\n```\nb", { from: "a\n```".length }); + expect(exit(editor)).toBe(true); + expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parent.textContent).toBe(""); + }); + + it.each([ + ["a line that cannot close the fence", "a\n``", undefined], + ["a caret before the end of the line", "a\n```", { from: "a\n``".length }], + ])("ignores %s", (_, code, at) => { + expect(exit(codeEditor(code, at))).toBe(false); + }); +}); + +describe("indentCodeBlock", () => { + it("inserts an indent at a collapsed caret", () => { + const editor = codeEditor("ab", { from: 1 }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); + }); + + it("indents every line a selection touches", () => { + const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two\n three"); + }); + + /** A selection of whole lines ends at the start of the next one, which it does not touch. */ + it("leaves the line a selection ends at the start of", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 4 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\ntwo"); + }); + + it("outdents every line a selection touches", () => { + const editor = codeEditor(" one\n two", { from: 2, to: 9 }); + indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + /** Repeated Tab presses should keep working on the same block. */ + it("keeps the selection across the shifted lines", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two"); + }); + + it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], + }, + }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +describe("selections across two identical fences", () => { + /** Two structurally equal code blocks; the selection spans from one into the other. */ + function twoBlockEditor() { + const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; + const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); + // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), + ); + return editor; + } + + it("leaves Tab and Enter alone rather than editing through the boundary", () => { + const editor = twoBlockEditor(); + const before = editor.getJSON(); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); +}); + +describe("convertCodeFenceOnEnter", () => { + /** Builds an editor holding one paragraph with the caret at its end. */ + function paragraphEditor(text: string) { + const editor = new Editor({ + extensions, + content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), + ); + return editor; + } + + it.each([ + ["```", "```", ""], + ["```ts", "```", "ts"], + ["~~~py", "~~~", "py"], + ["````", "````", ""], + ["```ts title=example", "```", "ts title=example"], + ["``` js", "```", " js"], + ])("turns %s into a code block", (text, fence, language) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + const block = editor.state.doc.firstChild!; + expect(block.type.name).toBe("codeBlock"); + expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); + expect(block.textContent).toBe(""); + // The caret belongs inside the new block, ready for the first line. + expect(editor.state.selection.from).toBe(1); + }); + + it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); + + it("refuses to open a fence inside a list item", () => { + const text = "```ts"; + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }, + ], + }, + ], + }, + }); + // Inside listItem > paragraph: position 3 is the paragraph start. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), + ); + const before = editor.getJSON(); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); + + it("keeps a chip's source in the info string", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "```ts " }, + { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } }, + ], + }, + ], + }, + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md"); + }); + + it("ignores a fence with the caret before its end", () => { + const editor = paragraphEditor("```ts"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts new file mode 100644 index 0000000000000..f4f28765f76c8 --- /dev/null +++ b/apps/web/src/composer-code-block.ts @@ -0,0 +1,254 @@ +import { exitCode } from "@tiptap/pm/commands"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc"; + +/** + * Spaces rather than a tab: the fence round-trips through Markdown on its way + * to the agent, and a literal tab there renders at whatever width the reader + * happens to use. Two matches how this repo indents its own source. + */ +export const CODE_BLOCK_INDENT = " "; + +/** The leading run of spaces and tabs on a line, which Enter carries forward. */ +export function leadingWhitespace(line: string): string { + return /^[ \t]*/.exec(line)?.[0] ?? ""; +} + +/** + * Removes one indent's worth of leading whitespace, tolerating lines that were + * indented by hand with an odd number of spaces or with tabs. A line with no + * leading whitespace is returned unchanged, so outdenting bottoms out quietly + * rather than eating the first character of code. + */ +export function outdentLine(line: string): string { + if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); + if (line.startsWith("\t")) return line.slice(1); + if (line.startsWith(" ")) return line.slice(1); + return line; +} + +/** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ +export function indentLines(lines: ReadonlyArray, direction: "in" | "out"): string[] { + return lines.map((line) => { + // Indenting a blank line would leave trailing whitespace behind on a line + // the user cannot see the caret move on, so leave it be. + if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; + return outdentLine(line); + }); +} + +/** Whether every endpoint of the selection sits in the same code block. */ +export function selectionInOneCodeBlock(state: EditorState): boolean { + const { $from, $to } = state.selection; + return $from.parent.type.spec.code === true && $from.sameParent($to); +} + +/** + * The code block containing the selection, or null when the selection is + * outside a fence or spans out of one. Both ends must sit in the same block: a + * selection that reaches past the fence belongs to the document, not to the + * code. + */ +function codeBlockRange( + state: EditorState, +): { readonly from: number; readonly to: number; readonly text: string } | null { + const { $from, $to } = state.selection; + const parent = $from.parent; + if (parent.type.spec.code !== true) return null; + // Same occurrence, not merely equal structure: two identical fences would + // otherwise let a selection across them edit through the block boundary. + if (!$from.sameParent($to)) return null; + const from = $from.start(); + return { from, to: from + parent.content.size, text: parent.textContent }; +} + +/** + * Enter inside a fence, carrying the current line's indentation onto the new + * line. ProseMirror's default splits the text but starts the new line at column + * zero, which means re-typing the indent on every line of a nested block. + */ +export function indentedNewlineInCodeBlock( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const beforeCursor = block.text.slice(0, from - block.from); + const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); + const indent = leadingWhitespace(currentLine); + + // Nothing to carry, so let the default newline handle it and keep this path + // out of the undo history. + if (indent.length === 0) return false; + + if (dispatch) { + const inserted = `\n${indent}`; + const transaction = state.tr.insertText(inserted, from, to); + const caret = from + inserted.length; + transaction.setSelection(TextSelection.create(transaction.doc, caret)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Two blank lines at the end of a fence leave it, the way every code editor + * does. Without this a fence at the end of the prompt is a trap: Enter only + * ever adds another line and there is no way back to prose. A line holding + * only the indentation Enter carried forward counts as blank, and is removed + * with the rest. + */ +export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { + const { $from, empty } = view.state.selection; + if (!empty || $from.parent.type.spec.code !== true) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent); + if (!trailing) return false; + const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos); + // An unclosed fence runs to the end of the draft; the prose after it would + // be read back as code, so leaving it writes the closing fence. + if (!$from.parent.attrs.close) { + transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`); + } + view.dispatch(transaction); + return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); +} + +/** + * Enter at the end of a line that would close the fence, such as ```` ``` ```` + * typed under the code, ends the block there. That line becomes the closing + * fence, and any lines after it continue as paragraphs after a new empty one, + * which is how the stored draft reads them. + */ +export function exitCodeBlockOnClosingFence( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block || !state.selection.empty) return false; + const caret = state.selection.from - block.from; + const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1; + const newlineAfter = block.text.indexOf("\n", caret); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + const line = block.text.slice(lineStart, lineEnd); + const node = state.selection.$from.parent; + if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false; + + if (dispatch) { + const { schema } = state; + const paragraph = (text: string) => + schema.nodes.paragraph!.create(null, text ? schema.text(text) : null); + const code = block.text.slice(0, Math.max(0, lineStart - 1)); + const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n"); + const closed = node.type.create( + { ...node.attrs, close: `\n${line}` }, + code ? schema.text(code) : null, + ); + const start = state.selection.$from.before(); + const transaction = state.tr.replaceWith(start, start + node.nodeSize, [ + closed, + paragraph(""), + ...rest.map(paragraph), + ]); + transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so + * Tab works mid-line the way typing does; any selection spanning text indents + * the whole lines it touches, which is what makes re-indenting a block possible. + */ +export function indentCodeBlock( + state: EditorState, + direction: "in" | "out", + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const startOffset = from - block.from; + const endOffset = to - block.from; + + if (from === to && direction === "in") { + if (dispatch) { + const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); + dispatch(transaction.scrollIntoView()); + } + return true; + } + + // Grow the range to whole lines so indenting is stable no matter where in the + // first and last lines the selection happens to start and end. + const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + // A selection ending at the start of a line does not touch that line. + const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + + const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); + const nextLines = indentLines(originalLines, direction); + if (nextLines.join("\n") === originalLines.join("\n")) return true; + + if (dispatch) { + const transaction = state.tr.insertText( + nextLines.join("\n"), + block.from + lineStart, + block.from + lineEnd, + ); + // Keep the same lines selected after the shift, so repeated Tab presses + // keep indenting the block rather than collapsing the selection. + // `split` always yields at least one entry, so these stand in only for the + // compiler's benefit. + const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); + const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; + const nextFrom = Math.max(block.from + lineStart, from + firstDelta); + const nextTo = Math.max(nextFrom, to + totalDelta); + transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Enter on a line that is nothing but an opening fence turns it into a code + * block. Tiptap's own input rules only fire on a space, so without this the + * gesture every markdown editor has — ```` ```ts ```` then Enter — leaves the + * fence as literal text. + */ +export function convertCodeFenceOnEnter( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const { $from, empty } = state.selection; + // Top-level paragraphs only: the list and quote serializers have no line + // to write a fence into, so one created inside them would vanish. + if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + // The parser's own grammar, read from the line's stored source, so a line + // Enter turns into a fence is exactly one the stored draft reads back as a + // fence, info string and all, including any chip's source in it. + const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end())); + const codeBlock = state.schema.nodes.codeBlock; + if (!opening || !codeBlock) return false; + + if (dispatch) { + const blockStart = $from.before(); + const { fence, language } = opening; + const transaction = state.tr.replaceWith( + blockStart, + blockStart + $from.parent.nodeSize, + codeBlock.create({ language, fence, close: `\n${fence}` }), + ); + transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} diff --git a/apps/web/src/composer-code-languages.test.ts b/apps/web/src/composer-code-languages.test.ts new file mode 100644 index 0000000000000..cc29b29dfd390 --- /dev/null +++ b/apps/web/src/composer-code-languages.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + CODE_BLOCK_LANGUAGES, + codeLanguageLabel, + languageOfInfoString, + withInfoStringLanguage, +} from "./composer-code-languages"; + +describe("languageOfInfoString", () => { + it.each([ + ["", ""], + ["ts", "ts"], + [" ts ", "ts"], + ["js title=example", "js"], + ["typescript ", "typescript"], + ])("reads %j as %j", (info, language) => { + expect(languageOfInfoString(info)).toBe(language); + }); +}); + +describe("withInfoStringLanguage", () => { + it.each([ + ["", "ts", "ts"], + ["js", "python", "python"], + // Anything after the language belongs to the fence and stays put. + ["js title=example", "ts", "ts title=example"], + ["js {1,3}", "ts", "ts {1,3}"], + // Plain text clears the language... + ["js", "", ""], + // ...but a bare `title=x` would read as the language, so it keeps `text`. + ["js title=example", "", "text title=example"], + // Whitespace between the fence and the language is the user's formatting. + [" js title=x", "python", " python title=x"], + [" js title=x", "", " text title=x"], + [" js", "go", " go"], + [" ", "go", " go"], + ])("sets %j to %j as %j", (info, language, expected) => { + expect(withInfoStringLanguage(info, language)).toBe(expected); + }); +}); + +describe("codeLanguageLabel", () => { + it.each([ + ["", "Plain text"], + ["text", "Plain text"], + ["cpp", "C++"], + ["ts", "TypeScript"], + ["arduino", "arduino"], + ])("labels %j as %j", (language, label) => { + expect(codeLanguageLabel(language)).toBe(label); + }); + + it("lists every language once, plain text first", () => { + const ids = CODE_BLOCK_LANGUAGES.map((language) => language.id); + expect(ids[0]).toBe(""); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/web/src/composer-code-languages.ts b/apps/web/src/composer-code-languages.ts new file mode 100644 index 0000000000000..1c6a68aab750d --- /dev/null +++ b/apps/web/src/composer-code-languages.ts @@ -0,0 +1,97 @@ +/** + * The languages a composer fence can be switched to from its header, and how + * a choice is written back into the fence's info string. + * + * Ids are the names the highlighter and the file icons both know. The list + * is curated rather than every grammar the highlighter bundles: a picker is + * for the common case, and any other language can still be typed on the + * fence line, where it shows as its own entry. + */ +export const CODE_BLOCK_LANGUAGES = [ + { id: "", label: "Plain text" }, + { id: "bash", label: "Bash" }, + { id: "c", label: "C" }, + { id: "cpp", label: "C++" }, + { id: "csharp", label: "C#" }, + { id: "css", label: "CSS" }, + { id: "dart", label: "Dart" }, + { id: "diff", label: "Diff" }, + { id: "dockerfile", label: "Dockerfile" }, + { id: "elixir", label: "Elixir" }, + { id: "go", label: "Go" }, + { id: "graphql", label: "GraphQL" }, + { id: "haskell", label: "Haskell" }, + { id: "html", label: "HTML" }, + { id: "java", label: "Java" }, + { id: "javascript", label: "JavaScript" }, + { id: "json", label: "JSON" }, + { id: "jsx", label: "JSX" }, + { id: "kotlin", label: "Kotlin" }, + { id: "lua", label: "Lua" }, + { id: "markdown", label: "Markdown" }, + { id: "php", label: "PHP" }, + { id: "powershell", label: "PowerShell" }, + { id: "python", label: "Python" }, + { id: "r", label: "R" }, + { id: "ruby", label: "Ruby" }, + { id: "rust", label: "Rust" }, + { id: "scala", label: "Scala" }, + { id: "scss", label: "SCSS" }, + { id: "sql", label: "SQL" }, + { id: "svelte", label: "Svelte" }, + { id: "swift", label: "Swift" }, + { id: "toml", label: "TOML" }, + { id: "tsx", label: "TSX" }, + { id: "typescript", label: "TypeScript" }, + { id: "vue", label: "Vue" }, + { id: "xml", label: "XML" }, + { id: "yaml", label: "YAML" }, + { id: "zig", label: "Zig" }, +] as const; + +export type CodeBlockLanguage = { readonly id: string; readonly label: string }; + +/** Short names people type on the fence line, labelled like the entry they mean. */ +const LANGUAGE_ALIASES: Record = { + cs: "csharp", + "c++": "cpp", + js: "javascript", + md: "markdown", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + shell: "bash", + ts: "typescript", + yml: "yaml", +}; + +/** The language is the info string's first word; the rest belongs to the fence. */ +export function languageOfInfoString(info: string): string { + return info.trim().split(/\s+/, 1)[0] ?? ""; +} + +/** + * Replaces the language and keeps everything around it: the whitespace the + * fence had before it and whatever follows it. Plain text clears it, unless + * something follows: a bare `title=x` would read as the language, so that + * keeps `text` in front. + */ +export function withInfoStringLanguage(info: string, language: string): string { + const leading = /^\s*/.exec(info)![0]; + // The language is the first word, so what follows it is empty or starts + // with whitespace. + const rest = info.slice(leading.length + languageOfInfoString(info).length); + if (language) return `${leading}${language}${rest}`; + return rest.trim() ? `${leading}text${rest}` : ""; +} + +/** The canonical entry a language names, if the picker lists it. */ +export function codeLanguageEntry(language: string): CodeBlockLanguage | null { + const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); + return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; +} + +export function codeLanguageLabel(language: string): string { + return codeLanguageEntry(language)?.label ?? language; +} diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index b92514dd2714c..a878ed95a1253 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; +import { + listContinuationForEnter, + listIndentForTab, + nextOrderedMarkerText, +} from "./composer-list-continuation"; function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); @@ -66,3 +70,16 @@ describe("composer list continuation", () => { expect(listIndentForTab("- foo", 1, 3)).toBeNull(); }); }); + +describe("nextOrderedMarkerText", () => { + it.each([ + ["1.", "2."], + ["1)", "2)"], + ["09.", "10."], + ["001)", "002)"], + ["99.", "100."], + ["99999999999999999999.", "99999999999999999999."], + ])("counts %s up to %s", (marker, expected) => { + expect(nextOrderedMarkerText(marker)).toBe(expected); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 952dfa549475d..be617da2a2680 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -55,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number return null; } +/** + * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, + * zero padding kept, and a number too large to count left as typed. Shared + * with the rich editor's native split so both modes count the same way. + */ +export function nextOrderedMarkerText(marker: string): string { + const numberText = marker.slice(0, -1); + const delimiter = marker.slice(-1); + const number = Number.parseInt(numberText, 10); + const next = Number.isSafeInteger(number) + ? String(number + 1).padStart(numberText.length, "0") + : numberText; + return `${next}${delimiter}`; +} + function nextMarkerText(marker: ListMarker): string { if (marker.kind === "ordered") { - const number = Number.parseInt(marker.numberText, 10); - const next = Number.isSafeInteger(number) - ? String(number + 1).padStart(marker.numberText.length, "0") - : marker.numberText; - return `${marker.indent}${next}${marker.delimiter} `; + return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `; } if (marker.kind === "task") { return `${marker.indent}- [ ] `; diff --git a/apps/web/src/composer-list-split.test.ts b/apps/web/src/composer-list-split.test.ts new file mode 100644 index 0000000000000..0e5c4d6a3958d --- /dev/null +++ b/apps/web/src/composer-list-split.test.ts @@ -0,0 +1,265 @@ +// @vitest-environment jsdom + +import { Editor } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + backspaceAcrossList, + buildDocJson, + buildTiptapContent, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + convertBulletItemToTask, + deleteAcrossList, + serializeEditorDoc, + splitOrLiftListItem, +} from "./composer-rich-text-doc"; + +// ProseMirror's DOM observer can flush on a timer after a test ends, which +// throws once jsdom is torn down, so every editor is destroyed after its test. +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy(); +}); + +/** + * Splits the last item of `value` the way Shift+Enter does, types `b`, and + * returns the stored Markdown. Tiptap carries every attribute left at the + * default `keepOnSplit: true` onto the new item and merges the overrides on + * top, so the new item must keep the source marker, indent and spacing. + */ +function makeEditor(value: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + codeBlock: false, + blockquote: false, + heading: false, + horizontalRule: false, + trailingNode: false, + listKeymap: false, + }), + ...ComposerListExtensions, + TaskList, + ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editors.push(editor); + return editor; +} + +function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { + const editor = makeEditor(value); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.commands.splitListItem(type, overrides); + editor.view.dispatch(editor.state.tr.insertText("b")); + return serializeEditorDoc(editor.state.doc).value; +} + +describe("splitting a list item", () => { + it.each([ + ["* a", { space: " " }, "* a\n* b"], + ["+ a", { space: " " }, "+ a\n+ b"], + ["- p\n - a", { space: " " }, "- p\n - a\n - b"], + ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], + ])("keeps the source marker and indent of %s", (value, overrides, expected) => { + expect(splitLastItem(value, "listItem", overrides)).toBe(expected); + }); + + it("keeps the indent of a nested task", () => { + expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( + "- [ ] p\n - [ ] a\n - [ ] b", + ); + }); +}); + +/** Puts the caret `offset` characters into the first text node holding `text`. */ +function placeCaret(editor: Editor, text: string, offset = 0) { + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (caret < 0 && node.isText && node.text!.includes(text)) { + caret = pos + node.text!.indexOf(text) + offset; + } + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret))); +} + +/** The stored draft, after checking a rebuild from it gives back the same document. */ +function storedDraft(editor: Editor) { + const stored = serializeEditorDoc(editor.state.doc).value; + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + return stored; +} + +describe("Shift+Enter twice on an item", () => { + // The second Shift+Enter lifts the new empty item one level. The stored + // draft must write it at its new depth, so a rebuild from that draft gives + // back the document the user is looking at. + it.each([ + ["- a\n - x", "- a\n - x\n- b"], + ["* a\n * x", "* a\n * x\n* b"], + ["1. a\n 1. x", "1. a\n 1. x\n2. b"], + ["- a\n - x\n- c", "- a\n - x\n- b\n- c"], + ["- a\n - x\n - y", "- a\n - x\n- b\n - y"], + ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], + ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === "x") caret = pos + 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret)), + ); + expect(splitOrLiftListItem(editor)).toBe(true); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + const stored = serializeEditorDoc(editor.state.doc).value; + expect(stored).toBe(expected); + // Same nesting after a rebuild; spacing defaults may differ but write the same. + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + }); +}); + +describe("Shift+Enter in the middle of an ordered list", () => { + it.each([ + ["1. a\n2. x\n3. c\n4. d", "1. a\n2. x\n3. b\n4. c\n5. d"], + ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], + ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Shift+Enter on an empty task under a bullet", () => { + // There is no task list one level up to move it into, and the item cannot + // hold a second line, so the draft stays what the editor shows. + it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + splitOrLiftListItem(editor); + splitOrLiftListItem(editor); + storedDraft(editor); + }); +}); + +describe("Backspace at the start of a line", () => { + it.each([ + ["- a\n - x", "x", "- a\n- x"], + ["- a\n - x\n - y", "x", "- a\n- x\n - y"], + ["- [ ] a\n - [ ] x", "x", "- [ ] a\n- [ ] x"], + ["- a\n- b\n- c", "b", "- a\nb\n- c"], + // A task under a bullet has no task list to move into, so it joins up. + ["- a\n - [ ] x", "x", "- ax"], + ["- a\n - x\nb", "b", "- a\n - xb"], + ["1. a\nb", "b", "1. ab"], + ])("in %j at %s writes %j", (value, at, expected) => { + const editor = makeEditor(value); + placeCaret(editor, at); + expect(backspaceAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each([ + ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], + ["p\n```ts\nl1\n```", "p\nl1"], + ])("removes the fence of %j rather than joining its code up", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "l1"); + editor.commands.keyboardShortcut("Backspace"); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Delete at the end of a line", () => { + it.each([ + ["- a\n- b", "- ab"], + ["- a\n - x\n- c", "- ax\n- c"], + ["- a\n\nb", "- a\nb"], + ])("in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + expect(deleteAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + editor.commands.keyboardShortcut("Delete"); + expect(storedDraft(editor)).toBe(value); + }); +}); + +describe("blocks that a list item or quote cannot hold", () => { + it("puts lines pasted at the end of an item after the list", () => { + const editor = makeEditor("- a"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); + editor.commands.insertContent(lines); + expect(storedDraft(editor)).toBe("- a\none\ntwo"); + }); + + it("will not make a task list inside a quote", () => { + const editor = makeEditor("> q"); + placeCaret(editor, "q"); + expect(editor.commands.wrapInList("taskList")).toBe(false); + expect(storedDraft(editor)).toBe("> q"); + }); +}); + +describe("[ ] typed at the start of a bullet item", () => { + // The value holds `[ ]` already; the rule fires on the space after it. + it.each([ + ["* [ ]x", "- [ ] x"], + ["* a\n* [ ]x\n* c", "* a\n- [ ] x\n* c"], + ["- a\n * [ ]x", "- a\n - [ ] x"], + ["- a\n - [ ]x\n - y", "- a\n - [ ] x\n - y"], + ["- [ ]x\n - y", "- [ ] x\n - y"], + ["- [ ]x", "- [ ] x"], + ])("makes the item in %j a task where it stands", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "[ ]x"); + const from = editor.state.selection.from; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from + 3)), + ); + const { tr } = editor.state; + convertBulletItemToTask(tr, from, from + 3, false); + editor.view.dispatch(tr); + expect(editor.state.selection.$from.parent.textContent).toBe("x"); + expect(editor.state.selection.$from.parentOffset).toBe(0); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("a rule at the end of the draft", () => { + it("keeps an empty line after it to type on", () => { + const editor = makeEditor("a\n---"); + // Removing the line after the rule puts one straight back. + editor.view.dispatch( + editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), + ); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + expect(storedDraft(editor)).toBe("a\n---"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe("a\n---\nb"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 44f8e6d1f6d1a..5b26b74cacfd7 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -5,9 +5,15 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; +import { collapseExpandedComposerCursor } from "./composer-logic"; + import { buildDocJson, + buildTiptapContent, caretTakesMarksBefore, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -16,6 +22,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; @@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions( }), TaskList, ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerListExtensions, + ...ComposerBlockExtensions, ]), ); @@ -344,6 +354,437 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); + it.each([ + "- one\n- two", + "* star\n* star two", + "+ plus", + "* star\n+ plus", + "1. first\n2. second", + "1) paren\n2) paren", + "3. starts at three\n4. four", + "01. zero padded\n02. two", + "- parent\n - child\n - sibling\n- uncle", + "1. ordered\n - bullet child\n2. next", + "- outer\n 1. inner ordered\n 2. more\n- outer again", + "- wide space item", + "-", + "1.", + "- \n- second", + "- **bold** item with @README.md", + "- [ ] task\n- bullet after", + "- bullet\n- [x] task after", + "para\n- item\npara", + "\t- tab indented\n\t- again", + " - leading indent only", + "- item\n\n- after a blank", + "- item one\n```ts\ncode\n```\n- item two", + "-no space stays literal", + "1.no space stays literal", + "10. ten\n11. eleven", + ])("round-trips the list %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "- one\n- two", + "1. first\n2. second", + "- parent\n - child", + "* star\n+ plus", + "-no space stays literal", + ])("keeps the list %s literal in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( + "maps every document offset of the list %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a list marker to the start of the item text", () => { + const value = "- item"; + const map = roundTrip(value); + // The marker owns no document characters, like a checkbox. + for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "- it".length)).toBe(2); + expect(flatToCollapsed(map, 0)).toBe("- ".length); + }); + + it.each([ + "> quoted", + "> line one\n> line two", + ">no space", + "> two spaces", + "> a\n>b", + ">", + "> **bold** and @README.md inside", + "> - looks like a list but stays quote text", + "> > nested stays literal inside the quote", + "before\n> quoted\nafter", + "> quote\n\n> another", + "- item\n> quote after list", + ])("round-trips the quote %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "---", + "***", + "___", + "- - -", + "* * *", + "-----", + "--- ", + "text\n---\nmore", + "- item\n---\n- item two", + "```\n---\n```", + "--", + "-- -", + "---text", + ])("round-trips the rule %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses the blocks it renders as the right node types", () => { + const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "paragraph", + "horizontalRule", + // The empty line a final rule keeps for the caret. + "paragraph", + ]); + }); + + it.each([ + ["---", "---"], + ["a\n---", "a\n---"], + // That line and a trailing newline are the same document; the newline goes. + ["---\n", "---"], + ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { + const map = roundTrip(value); + expect(map.value).toBe(expected); + const json = buildDocJson(value, () => ({ label: "", description: null })); + const doc = ProseMirrorNode.fromJSON(schema, json); + expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses a quote as a blockquote of one paragraph per line", () => { + const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); + expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); + expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); + }); + + it.each([ + "> a\n> b", + "> **q** @README.md", + "text\n---\nmore", + "---\ntext", + "# Heading text", + "## **b** @README.md", + ])( + "maps every document offset of the block %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a quote or heading marker to the start of the text", () => { + for (const [value, prefix] of [ + ["> quoted", "> "], + ["# Heading", "# "], + ] as const) { + const map = roundTrip(value); + for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(flatToCollapsed(map, 0)).toBe(prefix.length); + } + }); + + it.each([ + "```\ncode\n```", + "```ts\nconst a = 1;\n```", + "```ts\nconst a = 1;\n```\n", + "before\n```ts\nconst a = 1;\n```\nafter", + "```\n```", + "```ts\nline one\nline two\nline three\n```", + "```ts\n indented\n deeper\n```", + "```js title=example\ncode\n```", + "~~~py\ncode\n~~~", + "````\n```\n````", + "```ts\ncode without a closing fence", + "```", + "```ts\n**not bold** and @README.md stay literal\n```", + "```ts\ncode\n````", + "- [ ] task\n```ts\ncode\n```\n- [ ] after", + " ```ts\nindented fence stays a paragraph\n ```", + ])("round-trips the fenced block %s", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( + "maps every document offset of %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("reads blocks written with CRLF line endings", () => { + const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter"; + const doc = buildDocJson(value, () => ({ label: "", description: null })); + expect(doc.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "codeBlock", + "paragraph", + ]); + expect(roundTrip(value).value).toBe(value); + }); + + it("keeps the end of the code inside the fence rather than after it", () => { + const value = "```ts\nfunc();\n```"; + const map = roundTrip(value); + const endOfCode = "func();".length; + // Not the end of the string: the closing fence is a line of its own. + expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); + }); + + it("keeps the caret inside an empty fence", () => { + const value = "before\n```\n```"; + const map = roundTrip(value); + const inside = "before\n".length; + expect(flatToCollapsed(map, inside)).toBe("before\n```".length); + expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); + }); + + it("still places the end of an inline mark after its markers", () => { + // The fence rule must not leak into inline marks, whose trailing edge is + // deliberately the position after the closing delimiter. + const map = roundTrip("a **bold** c"); + expect(flatToMarkdown(map, 6)).toBe(10); + }); + + it("clamps offsets inside a fence to the edge of the code", () => { + const value = "```ts\nab\n```"; + const map = roundTrip(value); + expect(map.value).toBe(value); + // The opening fence owns no document characters, so every offset in it + // lands on the first character of the code. + for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); + // Everything from the closing newline onwards clamps to the code's end. + for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(2); + } + }); + + it.each([ + ["```\n\n```", "```\n```"], + ["```\n", "```"], + ])("canonicalizes the empty fence %s", (value, expected) => { + expect(roundTrip(value).value).toBe(expected); + }); + + it("keeps a chip in a fence info string as source and keeps later chips aligned", () => { + const value = "```@README.md\ncode\n```\n$my-skill after"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const after = json.content[1] as { + content: { type: string; attrs?: { skillName?: string } }[]; + }; + expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]); + expect(after.content[0]?.attrs?.skillName).toBe("my-skill"); + }); + + it("keeps chips in a fence's info string and body each in their own place", () => { + const value = "``` @a.md\nsee @b.md\n```"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const code = json.content[0] as { attrs: { language: string } }; + expect(code.attrs.language).toBe(" @a.md"); + }); + + it.each([ + ["listItem", { marker: "-", space: "" }, "bulletList", "- text"], + ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"], + ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: list, + content: [ + { + type: item, + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }], + }, + ], + }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe(expected); + expect(roundTrip("-").value).toBe("-"); + }); + + it("writes code holding a closing fence inside a longer fence", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { fence: "```", close: "\n```" }, + content: [{ type: "text", text: "initial\n```\nsecret" }], + }, + ], + }); + const stored = serializeEditorDoc(doc).value; + expect(stored).toBe("````\ninitial\n```\nsecret\n````"); + expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1); + expect(roundTrip(stored).value).toBe(stored); + }); + + it("closes an unclosed fence that has a block after it", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] }, + { type: "paragraph", content: [{ type: "text", text: "after" }] }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter"); + // At the end of the draft it stays unclosed, as it was written. + expect(roundTrip("```\ncode").value).toBe("```\ncode"); + }); + + it("counts a chip's source in a fence as one cursor position, as the draft store does", () => { + const value = "```\nsee @b.md now\n```\nafter"; + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(flatToCollapsed(map, flat)).toBe( + collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)), + ); + } + const afterChip = "see @b.md".length; + expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip); + const end = map.docLength; + expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end); + }); + + it("keeps fences literal in plain mode", () => { + const value = "```ts\nconst a = 1;\n```"; + expect(roundTripPlain(value).value).toBe(value); + }); + it("maps every document offset through collapsed coordinates and back", () => { const value = "hi **bold** @README.md bye"; const map = roundTrip(value); @@ -353,6 +794,37 @@ describe("composer rich text document model", () => { } }); + // Flipping the rich text setting remounts the editor, and the caret is + // restored from the stored collapsed cursor. That only works because the + // coordinate means the same thing on both sides of the flip. + it.each([ + "plain prose with no styling at all", + "a chip @README.md counts one character in both modes", + "$my-skill leads the line", + "trailing newline\n", + ])("resolves a collapsed cursor identically in both modes for %s", (value) => { + const rich = roundTrip(value); + const plain = roundTripPlain(value); + expect(rich.value).toBe(value); + expect(plain.value).toBe(value); + for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { + expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( + flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), + ); + } + }); + + it("clamps a cursor that was sitting inside a marker onto the styled text", () => { + const value = "a **bold** c"; + const plain = roundTripPlain(value); + const rich = roundTrip(value); + // Between the two asterisks: a real caret position in plain mode, and no + // position at all in rich mode, where it lands on the first styled + // character instead. The flip moves the caret by a marker's width at most. + expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); + expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); + }); + it("maps markdown offsets at styled edges onto document text", () => { const value = "a **bold** c"; const map = roundTrip(value); @@ -365,6 +837,63 @@ describe("composer rich text document model", () => { }); }); +describe("serializeSelection", () => { + /** The stored Markdown of the selection from the start of `first` to the end of `last`. */ + function copy(value: string, first: string, last: string) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + let from = -1; + let to = -1; + doc.descendants((node, pos) => { + if (!node.isText) return true; + const text = node.text ?? ""; + if (from < 0 && text.includes(first)) from = pos + text.indexOf(first); + if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length; + return false; + }); + return serializeSelection(doc, from, to); + } + + it("keeps the markers of the list items it spans", () => { + expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two"); + expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two"); + expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two"); + }); + + it("keeps the nesting of an item and its children", () => { + expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a"); + }); + + it("keeps the prefix of the quote lines it spans", () => { + expect(copy("> one\n> two", "one", "two")).toBe("> one\n> two"); + }); + + it("copies text within one block without its marker", () => { + expect(copy("- one two", "one", "one")).toBe("one"); + }); + + it("copies across blocks as they are written", () => { + expect(copy("intro\n- one", "intro", "one")).toBe("intro\n- one"); + }); +}); + +describe("pasting into a list item or quote", () => { + it("keeps inline marks but builds no blocks", () => { + const content = buildTiptapContent( + "**bold** text\n- item\n> quote", + () => ({ label: "", description: null }), + { styling: true, blocks: false }, + ); + expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]); + const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] }; + expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] }); + expect(JSON.stringify(content[1])).toContain("- item"); + expect(JSON.stringify(content[2])).toContain("> quote"); + }); +}); + describe("caret stops at styled edges", () => { function stateAt(value: string, pmPos: number) { const doc = ProseMirrorNode.fromJSON( diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 49a489c82ad48..efdfb145dc1d7 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,9 +1,24 @@ -import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; +import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; +import { Blockquote } from "@tiptap/extension-blockquote"; +import { CodeBlock } from "@tiptap/extension-code-block"; +import { Heading } from "@tiptap/extension-heading"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import { type Command, type Editor, mergeAttributes } from "@tiptap/core"; +import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; +import { TaskList } from "@tiptap/extension-task-list"; +import { + type EditorState, + Plugin, + Selection, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { nextOrderedMarkerText } from "~/composer-list-continuation"; import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; @@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({ exitable: false, }); +/** + * Tiptap's block extensions each bind a chord that turns the current block + * into their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). + * The composer does not offer them, and they are not harmless: run inside a + * quote they nest a block the quote serializer cannot write, and the text in + * it drops out of the stored draft. `drop` names the chords to remove; the + * keys the composer relies on, such as Backspace and the arrows at a block's + * edge, stay. + */ +function withoutBlockChords>( + shortcuts: Shortcuts | undefined, + drop: readonly string[], +): Shortcuts { + return Object.fromEntries( + Object.entries(shortcuts ?? {}).filter(([key]) => !drop.includes(key)), + ) as Shortcuts; +} + +/** + * What a list item may hold: its own line, then nested lists. That is all the + * Markdown can write, so ProseMirror refuses any join, wrap or paste that + * would put a second line or another block into an item. + */ +const LIST_ITEM_CONTENT = "paragraph list*"; + +/** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */ +export const ComposerTaskListExtension = TaskList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-9"]); + }, +}); + +/** + * Tiptap's Tab and Shift-Tab sink and lift an item without touching its + * `indent`, so the stored draft keeps the old nesting and the next rebuild + * undoes the move. The composer nests through its own Tab, which edits the + * source the way plain mode does. + */ +const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"]; + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — * the same fixed-point deal as `__bold__` becoming `**bold**`. */ export const ComposerTaskItemExtension = TaskItem.extend({ + content: LIST_ITEM_CONTENT, addAttributes() { return { ...this.parent?.(), @@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({ contentSpace: { default: null }, }; }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS); + }, }).configure({ nested: true }); +/** + * Fenced code blocks keep their exact source delimiters so a fence round-trips + * byte-identically: `fence` is the opening run of backticks or tildes, + * `language` its info string, and `close` the closing newline and fence, or + * the empty string when the fence was never closed. + * + * Neither delimiter owns a document character, so the caret can never land + * inside a fence — the same deal as a checkbox marker. + * + * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes + * to the blank-free form, the same fixed-point deal as `__bold__` becoming + * `**bold**`. + */ +export const ComposerCodeBlockExtension = CodeBlock.extend({ + addAttributes() { + return { + ...this.parent?.(), + language: { default: "" }, + fence: { default: "```" }, + close: { default: "\n```" }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), ["Mod-Alt-c"]), + Backspace: () => unwrapCodeBlockAtStart(this.editor), + }; + }, +}); + +/** + * Backspace at the start of a fence removes the fence and keeps its lines as + * paragraphs, the way it removes a list marker. Joining the code onto the line + * above instead would drop the fence from the draft and leave the rest of the + * code as stray lines. + */ +function unwrapCodeBlockAtStart(editor: Editor): boolean { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parent.type.name !== "codeBlock" || $from.parentOffset !== 0) return false; + const { schema } = editor.state; + const paragraphs = $from.parent.textContent + .split("\n") + .map((line) => schema.nodes.paragraph!.create(null, line ? schema.text(line) : null)); + const start = $from.before(); + const tr = editor.state.tr.replaceWith(start, $from.after(), paragraphs); + editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, start + 1))); + return true; +} + +/** + * The markdown a code block node serializes to, delimiters included. `followed` + * says another block comes after it in the draft. + */ +function codeBlockSource( + node: ProseMirrorNode, + followed: boolean, +): { + open: string; + content: string; + close: string; +} { + const attrs = node.attrs as Record; + let fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; + const language = typeof attrs.language === "string" ? attrs.language : ""; + let close = typeof attrs.close === "string" ? attrs.close : ""; + const content = node.textContent; + // Code holding a line that would close the fence, such as a pasted fence, + // is written inside a longer one, so the agent reads all of it as code. A + // parsed block never holds such a line, so its source stays byte-identical. + const longest = Math.max( + 0, + ...content + .split("\n") + .filter((line) => isClosingFence(line, fence)) + .map((line) => /^[`~]+/.exec(line)![0].length), + ); + if (longest > 0) { + fence = fence[0]!.repeat(longest + 1); + if (close) close = `\n${fence}`; + } + // An unclosed fence runs to the end of the draft, so with a block after it + // the fence is written closed, or the next rebuild would read that block as code. + if (!close && followed) close = `\n${fence}`; + return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; +} + +/** + * Bullet and ordered items keep their exact source marker so a list + * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` + * or `3)`, `space` what followed it, and `indent` the leading whitespace. + * Numbering is not renumbered: what the user typed is what the agent gets. + */ +const ComposerListItemExtension = ListItem.extend({ + content: LIST_ITEM_CONTENT, + addAttributes() { + return { + ...this.parent?.(), + indent: { default: "" }, + marker: { default: "-" }, + space: { default: " " }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS), + // Task items share these: the list extensions are always loaded together. + Backspace: () => backspaceAcrossList(this.editor), + Delete: () => deleteAcrossList(this.editor), + }; + }, + // The source marker rides on the item so the composer draws `3)` and a + // nested `7.` as written, rather than the browser own numbering. + renderHTML({ node, HTMLAttributes }) { + return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; + }, +}); + +export const ComposerListExtensions = [ + BulletList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); + }, + }), + OrderedList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-7"]); + }, + }), + ComposerListItemExtension, +]; + +/** + * A quote keeps the exact `>` prefix its lines were written with, applied to + * every line, so it round-trips byte-identically and a new line typed inside + * it gets the same prefix. One source line is one paragraph; a line whose + * prefix differs starts a sibling quote. Nested markers and list markers + * inside a quote stay literal text: the composer quotes prose, not documents. + */ +const ComposerBlockquoteExtension = Blockquote.extend({ + content: "paragraph+", + addAttributes() { + return { ...this.parent?.(), prefix: { default: "> " } }; + }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-b"]); + }, +}); + +/** + * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so + * it round-trips byte-identically. It owns no document characters: offsets + * inside its source clamp to the block after it. + * + * A rule cannot hold the caret, so a document never ends in one: an empty + * line follows it, which the draft does not write (see `isTrailingLineAfterRule`). + * A draft ending in `---\n` reads back the same way and so loses that newline, + * the same fixed-point deal as `__bold__` becoming `**bold**`. + */ +const ComposerHorizontalRuleExtension = HorizontalRule.extend({ + addAttributes() { + return { ...this.parent?.(), source: { default: "---" } }; + }, + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + appendTransaction: (transactions, _, state) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + if (state.doc.lastChild?.type.name !== "horizontalRule") return null; + return state.tr.insert(state.doc.content.size, state.schema.nodes.paragraph!.create()); + }, + }), + ]; + }, +}); + +/** The empty line after a rule that ends the document, which writes nothing. */ +function isTrailingLineAfterRule(doc: ProseMirrorNode, index: number): boolean { + const block = doc.child(index); + return ( + index === doc.childCount - 1 && + index > 0 && + block.type.name === "paragraph" && + block.content.size === 0 && + doc.child(index - 1).type.name === "horizontalRule" + ); +} + +/** + * A heading keeps the exact whitespace between its `#`s and its text. The + * `#`s must be followed by whitespace to count, which is also what keeps a + * `#1234` pull request reference a reference: the marker owns no document + * characters, closing `#`s stay literal text, and nothing is ever stripped. + */ +const ComposerHeadingExtension = Heading.extend({ + addAttributes() { + return { ...this.parent?.(), space: { default: " " } }; + }, + // Its only shortcuts are the Mod-Alt-1…6 block chords. + addKeyboardShortcuts() { + return {}; + }, +}); + +/** Block-level nodes beyond lists and fences, in the order the parser tries them. */ +export const ComposerBlockExtensions = [ + ComposerBlockquoteExtension, + ComposerHorizontalRuleExtension, + ComposerHeadingExtension, +]; + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: }; } +/** + * An opening fence: three or more backticks or tildes at the start of a line, + * followed by an info string. A backtick fence cannot carry a backtick in its + * info string, which is what keeps `` `code` `` on its own line literal. + * Indented fences stay paragraphs — the composer is a prompt box, not a + * CommonMark renderer, and honoring indentation would cost another attribute + * for no case anyone writes. + */ +export function parseOpeningFence(line: string): { fence: string; language: string } | null { + // `[^\n]` rather than `.`, here and in the block rules: a CRLF draft's lines + // end in `\r`, which `.` refuses, and the `\r` stays in the stored text. + const match = /^(`{3,}|~{3,})([^\n]*)$/.exec(line); + if (!match) return null; + const fence = match[1]!; + const language = match[2]!; + if (fence.startsWith("`") && language.includes("`")) return null; + return { fence, language }; +} + +/** A closing fence matches the opening run's character and is at least as long. */ +export function isClosingFence(line: string, fence: string): boolean { + const match = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(line); + if (!match) return false; + const run = match[1]!; + return run[0] === fence[0] && run.length >= fence.length; +} + +type ListLinePrefix = + | ({ kind: "task" } & TaskLinePrefix) + | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; + +/** + * The same grammar the literal continuation uses, so plain and rich mode agree + * on what a list line is: a task first (it also looks like a bullet), then an + * ordered marker, then a bullet. A marker followed by nothing is an empty item. + */ +function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { + const task = parseTaskPrefix(line); + if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; + const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); + if (ordered) { + return { + prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, + markerLength: ordered[0].length, + }; + } + const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); + if (bullet) { + return { + prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, + markerLength: bullet[0].length, + }; + } + return null; +} + +/** Items of one kind and marker family belong to one list; a change starts a sibling list. */ +function listKey(prefix: ListLinePrefix): string { + if (prefix.kind === "task") return "task"; + if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; + return `bullet:${prefix.marker}`; +} + type InlineJson = Record; interface DocLine { - task: TaskLinePrefix | null; + list: ListLinePrefix | null; inline: InlineJson[]; } @@ -143,28 +476,52 @@ function atomJsonForSegment( }; } -interface PendingTaskItem extends TaskLinePrefix { +interface PendingItem { + prefix: ListLinePrefix; content: InlineJson[]; - children: PendingTaskItem[]; + /** Nested lists, in order; a parent can hold lists of different kinds. */ + children: PendingList[]; } -function taskListJson(items: PendingTaskItem[]): InlineJson { - return { - type: "taskList", - content: items.map((item) => ({ - type: "taskItem", - attrs: { - checked: item.checked, - indent: item.indent, - markerSpace: item.markerSpace, - contentSpace: item.contentSpace, - }, - content: [ - { type: "paragraph", content: item.content }, - ...(item.children.length > 0 ? [taskListJson(item.children)] : []), - ], - })), - }; +interface PendingList { + key: string; + items: PendingItem[]; +} + +function listJson(list: PendingList): InlineJson { + const first = list.items[0]!.prefix; + const items = list.items.map((item) => { + const content = [ + { type: "paragraph", content: item.content }, + ...item.children.map((child) => listJson(child)), + ]; + if (item.prefix.kind === "task") { + return { + type: "taskItem", + attrs: { + checked: item.prefix.checked, + indent: item.prefix.indent, + markerSpace: item.prefix.markerSpace, + contentSpace: item.prefix.contentSpace, + }, + content, + }; + } + return { + type: "listItem", + attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, + content, + }; + }); + if (first.kind === "task") return { type: "taskList", content: items }; + if (first.kind === "ordered") { + return { + type: "orderedList", + attrs: { start: Number.parseInt(first.marker, 10) || 1 }, + content: items, + }; + } + return { type: "bulletList", content: items }; } function textJsonForSpan(text: string, marks: RichTextMark[]): Record { @@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean }, + options?: { styling?: boolean; blocks?: boolean }, ): Record[] { const styling = options?.styling ?? true; + // Inline marks without block structure: text pasted into a list item or + // quote, which has no line to write a nested block into. + const blockSyntax = styling && (options?.blocks ?? true); // Hide token source from the markdown parser, then restore the atoms with // the marks of their surrounding text. Choose a sentinel absent from input. let sentinel = "\uFFFC"; @@ -190,17 +550,19 @@ export function buildTiptapContent( sentinel = String.fromCodePoint(codePoint); } const atoms: InlineJson[] = []; + // Code fences hold no chips, so their lines put the original source back in + // place of the sentinel rather than building an atom for it. + const atomSources: string[] = []; const text = splitPromptIntoComposerSegments(value) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); + atomSources.push(segment.source); return sentinel; }) .join(""); let atomIndex = 0; - const lines: DocLine[] = text.split("\n").map((line) => { - const parsed = styling ? parseTaskPrefix(line) : null; - const content = parsed ? line.slice(parsed.markerLength) : line; + const buildInline = (content: string): InlineJson[] => { const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; const inline: InlineJson[] = []; for (const span of spans) { @@ -212,61 +574,185 @@ export function buildTiptapContent( if (piece) inline.push(textJsonForSpan(piece, span.marks)); }); } - return { task: parsed?.prefix ?? null, inline }; - }); + return inline; + }; + const buildDocLine = (line: string): DocLine => { + const parsed = blockSyntax ? parseListPrefix(line) : null; + const content = parsed ? line.slice(parsed.markerLength) : line; + return { list: parsed?.prefix ?? null, inline: buildInline(content) }; + }; - // Pass 2: consecutive task lines group into (possibly nested) task lists - // by indent prefix; everything else stays a paragraph. - const blocks: Record[] = []; - let stack: { indent: string; items: PendingTaskItem[] }[] = []; - const flushTasks = () => { - if (stack.length > 0) { - blocks.push(taskListJson(stack[0]!.items)); - stack = []; + // Pass 1: fenced blocks claim their lines whole; everything else becomes an + // inline-parsed line. Fence bodies restore chip sources as literal text. + const sourceLines = text.split("\n"); + const entries: ( + | { code: Record } + | { rule: string } + | { heading: { level: number; space: string; inline: InlineJson[] } } + | { quote: { prefix: string; inline: InlineJson[] } } + | { line: DocLine } + )[] = []; + const restoreSources = (line: string) => + line.split(sentinel).reduce((joined, piece, index) => { + if (index === 0) return piece; + atomIndex += 1; + return joined + atomSources[atomIndex - 1]! + piece; + }, ""); + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index]!; + const opening = blockSyntax ? parseOpeningFence(line) : null; + if (!opening) { + // A thematic break outranks a list: `- - -` and `* * *` are rules, and + // `***` on its own line is a rule rather than an empty bold span. + if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) { + entries.push({ rule: line }); + continue; + } + const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null; + if (heading) { + entries.push({ + heading: { + level: heading[1]!.length, + space: heading[2]!, + inline: buildInline(heading[3]!), + }, + }); + continue; + } + const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null; + if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); + else entries.push({ line: buildDocLine(line) }); + continue; + } + // The info string went through the sentinel pass like every line, so a + // token in it is put back as source here, in order, before the body. + const language = restoreSources(opening.language); + const body: string[] = []; + let cursor = index + 1; + let close = ""; + while (cursor < sourceLines.length) { + const candidate = sourceLines[cursor]!; + if (isClosingFence(candidate, opening.fence)) { + close = `\n${candidate}`; + break; + } + body.push(restoreSources(candidate)); + cursor += 1; } + // An unclosed fence runs to the end of the prompt, which is what the user + // is looking at while they are still typing the block. + const closed = cursor < sourceLines.length; + const content = body.join("\n"); + entries.push({ + code: { + type: "codeBlock", + attrs: { language, fence: opening.fence, close }, + ...(content ? { content: [{ type: "text", text: content }] } : {}), + }, + }); + index = closed ? cursor : sourceLines.length; + } + + // Pass 2: consecutive list lines group into (possibly nested) lists by + // indent prefix; everything else stays a paragraph. Items of a different + // kind or marker at the same indent start a sibling list, so `* a` under + // `- b` keeps its star and a task list can follow a bullet list. + const blocks: Record[] = []; + let rootLists: PendingList[] = []; + let stack: { indent: string; list: PendingList; container: PendingList[] }[] = []; + const flushLists = () => { + for (const list of rootLists) blocks.push(listJson(list)); + rootLists = []; + stack = []; + }; + const openList = (container: PendingList[], key: string): PendingList => { + const list = { key, items: [] }; + container.push(list); + return list; + }; + let openQuote: { prefix: string; content: InlineJson[][] } | null = null; + const flushQuote = () => { + if (!openQuote) return; + blocks.push({ + type: "blockquote", + attrs: { prefix: openQuote.prefix }, + content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })), + }); + openQuote = null; }; - for (const line of lines) { - if (!line.task) { - flushTasks(); + for (const entry of entries) { + if ("quote" in entry) { + flushLists(); + if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); + openQuote ??= { prefix: entry.quote.prefix, content: [] }; + openQuote.content.push(entry.quote.inline); + continue; + } + flushQuote(); + if ("code" in entry) { + flushLists(); + blocks.push(entry.code); + continue; + } + if ("rule" in entry) { + flushLists(); + blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); + continue; + } + if ("heading" in entry) { + flushLists(); + blocks.push({ + type: "heading", + attrs: { level: entry.heading.level, space: entry.heading.space }, + content: entry.heading.inline, + }); + continue; + } + const line = entry.line; + if (!line.list) { + flushLists(); blocks.push({ type: "paragraph", content: line.inline }); continue; } - const item: PendingTaskItem = { - ...line.task, - content: line.inline, - children: [], - }; + const item: PendingItem = { prefix: line.list, content: line.inline, children: [] }; + const key = listKey(line.list); + const indent = line.list.indent; for (;;) { const top = stack[stack.length - 1]; if (!top) { // A leading indented item with no parent flattens but keeps indent. - stack.push({ indent: item.indent, items: [] }); + stack.push({ indent, list: openList(rootLists, key), container: rootLists }); continue; } - if (top.indent === item.indent) { - top.items.push(item); + if (top.indent === indent) { + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - if (top.indent !== "" && !item.indent.startsWith(top.indent)) { + if (top.indent !== "" && !indent.startsWith(top.indent)) { if (stack.length > 1) { stack.pop(); continue; } - top.indent = item.indent; - top.items.push(item); + top.indent = indent; + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - const parent = top.items[top.items.length - 1]; + const parent = top.list.items[top.list.items.length - 1]; if (!parent) { - top.items.push(item); + top.list.items.push(item); break; } - parent.children.push(item); - stack.push({ indent: item.indent, items: parent.children }); + const list = openList(parent.children, key); + stack.push({ indent, list, container: parent.children }); + list.items.push(item); break; } } - flushTasks(); + flushQuote(); + flushLists(); return blocks; } @@ -275,7 +761,10 @@ export function buildDocJson( skillLabelFor: (name: string) => SkillMeta, options?: { styling?: boolean }, ) { - return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; + const content = buildTiptapContent(value, skillLabelFor, options); + // The empty line the rule extension keeps after a final rule. + if (content.at(-1)?.type === "horizontalRule") content.push({ type: "paragraph" }); + return { type: "doc", content }; } export interface RichRun { @@ -495,7 +984,210 @@ function appendInlineRuns( } } -function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { +const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); +const LIST_ITEM_NODE_NAMES = new Set(["taskItem", "listItem"]); + +/** + * Gives the list item at the caret the indent and marker of the sibling it + * follows, or of the one after it when it is first. Tiptap moves items between + * lists without touching these attributes, so without this the stored draft + * keeps an outdented item at its old depth, and the next rebuild nests it again. + */ +function alignListItemWithSiblings(tr: Transaction, itemType: string): void { + const $pos = tr.selection.$from; + // A lift out of the list leaves the caret in a paragraph, possibly inside + // an item of another kind, which did not move. + if ($pos.depth < 3 || $pos.node(-1).type.name !== itemType) return; + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const sibling = + index > 0 ? list.child(index - 1) : index + 1 < list.childCount ? list.child(index + 1) : null; + if (!sibling) return; + const attrs: Record = { ...item.attrs, indent: sibling.attrs.indent }; + if (item.type.name === "taskItem") { + attrs.markerSpace = sibling.attrs.markerSpace; + } else { + const marker = typeof sibling.attrs.marker === "string" ? sibling.attrs.marker : "-"; + attrs.marker = index > 0 && /^\d+[.)]$/.test(marker) ? nextOrderedMarkerText(marker) : marker; + } + tr.setNodeMarkup($pos.before(-1), undefined, attrs); + if (typeof attrs.marker === "string" && /^\d+[.)]$/.test(attrs.marker)) { + renumberFollowingItems(tr, $pos.before(-2), index, attrs.marker); + } +} + +/** + * Counts the items after `index` on from `marker`, so an item inserted in the + * middle of an ordered list pushes the rest down instead of repeating a number. + * Only marker attributes change, so positions stay valid while walking. + */ +function renumberFollowingItems( + tr: Transaction, + listPos: number, + index: number, + marker: string, +): void { + const list = tr.doc.nodeAt(listPos); + if (!list) return; + let pos = listPos + 1; + let previous = marker; + list.forEach((child, _, childIndex) => { + if (childIndex > index) { + previous = nextOrderedMarkerText(previous); + if (child.attrs.marker !== previous) { + tr.setNodeMarkup(pos, undefined, { ...child.attrs, marker: previous }); + } + } + pos += child.nodeSize; + }); +} + +/** + * Shift+Enter in a list or task item: split it, or on an empty item leave one + * level of nesting. Either way the item at the caret ends up written with the + * indent and marker of its new siblings. + */ +export function splitOrLiftListItem(editor: Editor): boolean { + const $from = editor.state.selection.$from; + const itemType = listItemTypeAt($from); + if (!itemType) return false; + const empty = $from.parent.content.size === 0; + // One transaction, so the split or lift and the realignment undo together. + const move: Command = ({ commands, tr }) => { + const moved = + commands.splitListItem( + itemType, + itemType === "taskItem" ? { checked: false } : { space: " " }, + ) || + (empty && commands.liftListItem(itemType)); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + return editor.can().command(move) && editor.chain().command(move).run(); +} + +/** + * Deletes `from`–`to`, the typed `[ ]` at the start of a bullet item's text, + * and makes that item a task where it stands. A task list holds only tasks, so + * the bullet list splits around it into sibling lists, which is also how the + * parser reads a change of list kind. Nested items and the item's own nested + * lists stay where they are. + */ +export function convertBulletItemToTask( + tr: Transaction, + from: number, + to: number, + checked: boolean, +): void { + tr.delete(from, to); + const $pos = tr.doc.resolve(from); + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const { schema } = tr.doc.type; + const before: ProseMirrorNode[] = []; + const after: ProseMirrorNode[] = []; + list.forEach((child, _, childIndex) => { + if (childIndex < index) before.push(child); + else if (childIndex > index) after.push(child); + }); + // The spacing after the bullet stays between the dash and the box, as typed. + const markerSpace = + typeof item.attrs.space === "string" && item.attrs.space ? item.attrs.space : " "; + const task = schema.nodes.taskItem!.create( + { checked, indent: item.attrs.indent, markerSpace }, + item.content, + ); + const lists = [ + ...(before.length > 0 ? [list.copy(Fragment.from(before))] : []), + schema.nodes.taskList!.create(null, task), + ...(after.length > 0 ? [list.copy(Fragment.from(after))] : []), + ]; + const listStart = $pos.before(-2); + tr.replaceWith(listStart, listStart + list.nodeSize, lists); + // Into the task list, its item and its paragraph. + const caret = listStart + (before.length > 0 ? lists[0]!.nodeSize : 0) + 3; + tr.setSelection(TextSelection.create(tr.doc, caret)); +} + +function listItemTypeAt($pos: ResolvedPos): "listItem" | "taskItem" | null { + const name = $pos.depth > 1 ? $pos.node(-1).type.name : null; + return name === "listItem" || name === "taskItem" ? name : null; +} + +/** + * Backspace at the start of an item's text leaves one level of nesting, or + * the list itself at the top, written at its new depth. At the start of a + * line right after a list it joins that line onto the list's last line. + * Tiptap's list keymap, which StarterKit loads, does both by moving nodes + * without touching their indent or marker, so the composer turns it off. + */ +export function backspaceAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + const itemType = listItemTypeAt($from); + if (itemType && $from.index(-1) === 0) { + const lift: Command = ({ commands, tr }) => { + const moved = commands.liftListItem(itemType); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + // Not `can()` first: a dry run of the lift skips the schema check, so it + // says yes to a task under a bullet, which has no list to lift into. Its + // text joins the line above instead. + return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch); + } + const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null; + if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") { + return joinTextblockBackward(state, editor.view.dispatch); + } + return false; +} + +/** + * Delete at the end of a line next to list structure pulls the next line's + * text up onto it. Tiptap's default would instead nest the next item, or a + * whole list, under this one at its old indent. Before a fence, anywhere, it + * does nothing: pulling the code into the line would drop the fence from the + * draft and leave the rest of the code as stray lines. + */ +export function deleteAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || !$from.parent.isTextblock || $from.depth === 0) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const next = Selection.findFrom(state.doc.resolve($from.after()), 1, true); + if (!next) return false; + if (next.$from.parent.type.name === "codeBlock") return $from.parent.type.name !== "codeBlock"; + if (!listItemTypeAt($from) && !listItemTypeAt(next.$from)) return false; + return joinTextblockForward(state, editor.view.dispatch); +} + +/** The literal prefix an item serializes to. Empty items keep their exact spacing. */ +function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { + const attrs = item.attrs as Record; + const indent = typeof attrs.indent === "string" ? attrs.indent : ""; + if (item.type.name === "taskItem") { + const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; + const contentSpace = + typeof attrs.contentSpace === "string" + ? attrs.contentSpace || (empty ? "" : " ") + : empty + ? "" + : " "; + return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; + } + const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; + // A bare `-` keeps its missing space only while the item is empty: once it + // has text, `-text` would not be a list line any more. + const space = + typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " "; + return `${indent}${marker}${space}`; +} + +function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { let itemPos = listStart + 1; let firstItem = true; list.content.forEach((item) => { @@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul const itemContentStart = itemPos + 1; const first = item.firstChild; const empty = first?.type.name === "paragraph" && first.content.childCount === 0; - const attrs = item.attrs as Record; - const indent = typeof attrs.indent === "string" ? attrs.indent : ""; - const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; - const contentSpace = - typeof attrs.contentSpace === "string" - ? attrs.contentSpace || (empty ? "" : " ") - : empty - ? "" - : " "; - const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; - // The checkbox owns no document characters; every prefix offset clamps + const prefix = listItemPrefix(item, empty); + // The marker owns no document characters; every prefix offset clamps // to the start of the item text, exactly like style markers. acc.runs.push({ kind: "prefix", @@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul item.content.forEach((child) => { if (!firstBlock) pushBreakRun(acc); firstBlock = false; - if (child.type.name === "taskList") { - walkTaskList(child, childPos, acc); + if (LIST_NODE_NAMES.has(child.type.name)) { + walkList(child, childPos, acc); } else if (child.type.name === "paragraph") { appendInlineRuns(child, childPos + 1, acc); } @@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul }); } +/** + * A fence becomes one run whose open and close lengths are the delimiters, so + * every cursor rule that already clamps out of a style marker clamps out of a + * fence too. `nodeName` keeps the marker decorations off it: a code block is + * drawn as a block, not revealed a character at a time. + * + * The code is literal text in the editor, but the draft store still counts a + * chip's source in it as one cursor position, as it does everywhere. So the + * run splits at each chip source, which spans its characters in the document + * and one position in the collapsed space. + */ +function appendCodeBlockRun( + block: ProseMirrorNode, + pmPos: number, + followed: boolean, + acc: RichAccumulator, +): void { + const { open, content, close } = codeBlockSource(block, followed); + const pieces = content + ? splitPromptIntoComposerSegments(content).map((segment) => + segment.type === "text" + ? { length: segment.text.length, collapsedLen: segment.text.length } + : { length: segment.source.length, collapsedLen: 1 }, + ) + : [{ length: 0, collapsedLen: 0 }]; + let offset = 0; + pieces.forEach((piece, index) => { + const openLen = index === 0 ? open.length : 0; + const closeLen = index === pieces.length - 1 ? close.length : 0; + const collapsedLen = openLen + piece.collapsedLen + closeLen; + const mdLen = openLen + piece.length + closeLen; + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: piece.length, + collapsedLen, + mdLen, + openLen, + closeLen, + pmPos: pmPos + offset, + mdStart: acc.md, + collapsedStart: acc.collapsed, + nodeName: "codeBlock", + }); + offset += piece.length; + acc.flat += piece.length; + acc.collapsed += collapsedLen; + acc.md += mdLen; + }); + acc.value += open + content + close; +} + +/** Each paragraph of a quote is one source line behind the quote's prefix. */ +function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void { + const attrs = quote.attrs as Record; + const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> "; + let childPos = quoteStart + 1; + let firstLine = true; + quote.content.forEach((child) => { + if (!firstLine) pushBreakRun(acc); + firstLine = false; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: childPos + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); + childPos += child.nodeSize; + }); +} + +/** + * The stored Markdown of a selection, as copy and cut put it on the clipboard. + * A slice holds only the content of the blocks the ends share, so items or + * quote lines arrive without the list or quote that writes their markers. The + * shared block is put back around them, and an item's list around it. + */ +export function serializeSelection(doc: ProseMirrorNode, from: number, to: number): string { + const slice = doc.slice(from, to); + const $from = doc.resolve(from); + let depth = $from.sharedDepth(to); + let content: Fragment | ProseMirrorNode = slice.content; + if (slice.content.firstChild?.isInline) { + content = doc.type.schema.nodes.paragraph!.create(null, slice.content); + } else { + while (depth > 0) { + const shared = $from.node(depth); + content = shared.copy(Fragment.from(content)); + if (!LIST_ITEM_NODE_NAMES.has(shared.type.name)) break; + depth -= 1; + } + } + return serializeEditorDoc(doc.type.create(null, content)).value; +} + export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; @@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { - if (blockIndex > 0) pushBreakRun(acc); - if (block.type.name === "taskList") { - walkTaskList(block, pmBlockStart, acc); + if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); + if (LIST_NODE_NAMES.has(block.type.name)) { + walkList(block, pmBlockStart, acc); + } else if (block.type.name === "codeBlock") { + appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc); + } else if (block.type.name === "blockquote") { + walkBlockquote(block, pmBlockStart, acc); + } else if (block.type.name === "heading") { + const attrs = block.attrs as Record; + const level = typeof attrs.level === "number" ? attrs.level : 1; + const space = typeof attrs.space === "string" ? attrs.space : " "; + const prefix = `${"#".repeat(level)}${space}`; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + appendInlineRuns(block, pmBlockStart + 1, acc); + } else if (block.type.name === "horizontalRule") { + const attrs = block.attrs as Record; + const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: source.length, + mdLen: source.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + block.nodeSize, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += source; + acc.collapsed += source.length; + acc.md += source.length; } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } @@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { : last.mdStart + last.mdLen; } +/** + * The end of a run belongs to the next run, which is how the end of `**bold**` + * lands after its markers. A fence is the exception: its close is a line of + * its own, so the end of the code stays inside the block rather than jumping + * past the closing fence. Inline marks keep their trailing position. + */ +function runOwnsOffset(run: RichRun, flatOffset: number): boolean { + const end = run.flatStart + run.docLen; + return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); +} + export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { - return run.collapsedStart + run.openLen + (bounded - run.flatStart); + // A chip's source in a fence spans its characters but one position. + const within = Math.min( + bounded - run.flatStart, + run.collapsedLen - run.openLen - run.closeLen, + ); + return run.collapsedStart + run.openLen + within; } return run.collapsedStart + (bounded - run.flatStart); } @@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { return run.mdStart + run.openLen + (bounded - run.flatStart); } @@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe if (run.kind === "prefix") return run.flatStart; if (run.kind === "text" || run.kind === "token") { const within = collapsedOffset - run.collapsedStart; + const contentLen = run.collapsedLen - run.openLen - run.closeLen; // Marker characters clamp to the styled edge: they are shown, never edited. if (within <= run.openLen) return run.flatStart; - if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; + if (within >= run.openLen + contentLen) return run.flatStart + run.docLen; return run.flatStart + (within - run.openLen); } return run.flatStart + (collapsedOffset - run.collapsedStart); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 46b57325a3ff3..d43fc36769a13 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1422,7 +1422,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { /* Chat code blocks join the other code surfaces on the code tokens. The default look keeps its secondary/input tints; themed palettes tint those surfaces with the accent, which fights syntax highlighting. */ -html[data-theme-id] .chat-markdown .chat-markdown-codeblock { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { background-color: var(--code-background); border-color: var(--contrast-border); color: var(--code-foreground); @@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre { color: var(--code-foreground); } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { background-color: transparent; } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--code-foreground) 72%, transparent); } @@ -1685,7 +1685,7 @@ code { Inline code stays relative to its sentence so it never towers over prose; diffs and file previews take the size through --diffs-font-size. */ .chat-markdown .chat-markdown-shiki .shiki, -.chat-markdown pre code { +:is(.chat-markdown, .composer-tiptap) pre code { font-size: var(--font-size-code, inherit); } @@ -1755,45 +1755,47 @@ code { .chat-markdown ol, .chat-markdown blockquote, .chat-markdown pre, -.chat-markdown .chat-markdown-table-container { +.chat-markdown .chat-markdown-table-container, +.composer-tiptap ul, +.composer-tiptap ol { margin: 0.65rem 0; } -.chat-markdown h1, -.chat-markdown h2, -.chat-markdown h3, -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h1, +:is(.chat-markdown, .composer-tiptap) h2, +:is(.chat-markdown, .composer-tiptap) h3, +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { margin: 1.25rem 0 0.5rem; font-weight: 600; line-height: 1.3; color: var(--contrast-foreground); } -.chat-markdown h1 { +:is(.chat-markdown, .composer-tiptap) h1 { font-size: 1.25rem; } -.chat-markdown h2 { +:is(.chat-markdown, .composer-tiptap) h2 { font-size: 1.125rem; } -.chat-markdown h3 { +:is(.chat-markdown, .composer-tiptap) h3 { font-size: 1rem; } -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { font-size: 0.875rem; } -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h6 { color: var(--contrast-muted-foreground); } -.chat-markdown ul { +:is(.chat-markdown, .composer-tiptap) ul { /* Reset for nested uls under a widened ol. --list-gutter is an inherited custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ @@ -1807,21 +1809,27 @@ code { widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ -.chat-markdown ol { +:is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } -.chat-markdown ol > li::marker { +:is(.chat-markdown, .composer-tiptap) ol > li::marker { font-variant-numeric: tabular-nums; } -.chat-markdown ul ul { +/* The composer shows the marker the source has, `3)` or a nested `7.`, not the + browser counter. Browsers without ::marker content fall back to `start`. */ +.composer-tiptap ol > li[data-marker]::marker { + content: attr(data-marker) " "; +} + +:is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } -.chat-markdown ul ul ul { +:is(.chat-markdown, .composer-tiptap) ul ul ul { list-style-type: square; } @@ -1870,12 +1878,30 @@ code { background-size: 4px 2px; } -.chat-markdown blockquote { +:is(.chat-markdown, .composer-tiptap) blockquote { border-left: 2px solid var(--contrast-border); padding-left: 0.8rem; color: var(--contrast-muted-foreground); } +/* A quote in the composer is text the user is still writing; muting it reads + as disabled in an input, so the border alone carries the quote. */ +.composer-tiptap blockquote { + margin: 0.65rem 0; + color: var(--foreground); +} + +/* Preflight already draws the 1px rule, as it does in chat; only the block + spacing needs to match. */ +.composer-tiptap hr { + margin: 0.65rem 0; +} + +/* A heading at the top of a draft should not push the first line down. */ +.composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); @@ -1939,23 +1965,23 @@ code { background: color-mix(in srgb, var(--contrast-border) 78%, transparent); } -.chat-markdown .chat-markdown-codeblock-header { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); } -.chat-markdown .chat-markdown-codeblock pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; } -.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { white-space: pre-wrap; overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-shiki .shiki { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { background: transparent !important; } @@ -2181,7 +2207,7 @@ code { outline: none; } -.composer-tiptap code { +.composer-tiptap :not(pre) > code { font-family: var(--font-mono); font-size: 0.92em; background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 6ed0a8c2f7bf9..650564f751b60 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -25,3 +25,18 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels the source and marker decorations are not content. Structured context records accompany that text when available. Paste completes trailing chip delimiters and adds a leading boundary when inserting a chip directly after text. + +Block nodes (fences, lists, quotes, rules, headings) keep their exact Markdown +delimiters as node attributes, so the round trip is byte-identical and offsets +inside a delimiter clamp to the block's text like task and style markers. The +serializers write only what the grammar has: a quote holds paragraphs, a list +holds paragraphs and lists. So blocks may only form at a top-level paragraph. +Input rules check this, and Tiptap's own block chords (Mod-Shift-8, Mod-Alt-c, +and so on) are removed, because nesting a block the serializer cannot write +drops its text from the stored draft. The list grammar is the plain-mode +continuation's in [composer-list-continuation](../../apps/web/src/composer-list-continuation.ts); +keep the two in step. + +Enter sends, so the fence handling in `handleKeyDown` must run before the send +handler: checked afterwards, a fence never opens and a newline inside one sends +the draft. diff --git a/docs/user/composer.md b/docs/user/composer.md index f4c2829019a7d..ba8d26fd81c29 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## Formatting + +The composer writes Markdown and shows it styled as you type. Markers such as +`**` stay in the text and show beside the styled words when your cursor is on +them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists, +task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code +block. What you typed is what the agent receives, markers and numbering +included, and `#1234` without a space still looks up a pull request. + +Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter +on an empty line leaves it; **Tab** nests a list item. In a code block, Enter +starts a new line at the current indentation, **Tab** and **Shift+Tab** indent +the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines +at the end, leave the block. **Backspace** at the start of a code block turns it +back into plain lines. Choose the language in a code block's corner to change +it. Very large code blocks are shown without syntax highlighting. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe1e71770eb81..4455564018677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,9 +687,24 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-heading': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From 46dd0949053ae387e785c5e9801725a9ccc28de1 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:50 +0100 Subject: [PATCH 071/680] fix(web): center project monograms in settled rows (#16841) --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb1..779f662e0b8d8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1821,7 +1821,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { hover so the tail stays scannable when you're hunting. */} Date: Wed, 7 Oct 2026 23:58:53 +0100 Subject: [PATCH 072/680] fix(web): cancelling a new citation no longer leaves a stray space (#16828) --- .../components/ComposerPromptEditorTiptap.tsx | 30 +++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 15 ++++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6e830991ab7eb..ae2c6e460f263 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -183,12 +183,16 @@ export type ComposerCitationCommentRequest = { value: string; citationStart: number; sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: CitationInsertedSpaces; }; +// Spaces added around a freshly inserted citation, removed with it if its comment is cancelled. +type CitationInsertedSpaces = { before: boolean; after: boolean }; + type OpenCitationComment = { key: string; sourceAnchor?: AssistantCitationSourceAnchor; - removeOnCancel?: boolean; + removeOnCancel?: CitationInsertedSpaces; }; const ComposerCitationCommentContext = createContext<{ @@ -392,18 +396,20 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { [editor, nodePos], ); + // Undo a fresh insertion: drop the chip and the spaces inserted with it. + const removeOnCancel = commentTarget?.removeOnCancel; const onRemove = useCallback(() => { - if (!editor.isEditable) return; + if (!editor.isEditable || !removeOnCancel) return; const pos = nodePos(); if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); + const { doc } = editor.state; + const current = doc.nodeAt(pos); if (!current) return; - editor - .chain() - .focus() - .deleteRange({ from: pos, to: pos + current.nodeSize }) - .run(); - }, [editor, nodePos]); + const end = pos + current.nodeSize; + const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos; + const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end; + editor.chain().focus().deleteRange({ from, to }).run(); + }, [editor, nodePos, removeOnCancel]); // Put the caret right after the chip so Enter sends and typing continues the prompt. const onRestoreFocus = useCallback(() => { @@ -450,7 +456,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { if (open && !editor.isEditable) return; commentContext.onOpenChange(citeKey, open); }, - ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + ...(removeOnCancel ? { onCancel: onRemove } : {}), onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; @@ -1531,7 +1537,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: pendingCitation.sourceAnchor, - removeOnCancel: true, + removeOnCancel: pendingCitation.insertedSpaces, }); } } @@ -1618,7 +1624,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: request.sourceAnchor, - removeOnCancel: true, + removeOnCancel: request.insertedSpaces, }); } }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4923e1fc47e7f..c7eb2628e1c9a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -200,7 +200,11 @@ import { shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; +import { + type ComposerCitationCommentRequest, + type ComposerPromptEditorHandle, + ComposerPromptEditor, +} from "../ComposerPromptEditor"; import { ComposerContextActionsContext, composerContextRecordsFromDraft, @@ -3832,7 +3836,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expectedText?: string; expandedCursorAfterReplace?: number; focusEditorAfterReplace?: boolean; - citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; + citationComment?: { + start: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + }; }, ): boolean => { if ( @@ -3862,6 +3870,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) value: next.text, citationStart: options.citationComment.start, sourceAnchor: options.citationComment.sourceAnchor, + insertedSpaces: options.citationComment.insertedSpaces, }); } promptRef.current = next.text; @@ -6122,6 +6131,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationComment: { start: cursor + (needsLeadingSpace ? 1 : 0), sourceAnchor: options.citationCommentAnchor, + // An existing space after the cursor is consumed and re-emitted, so it stays. + insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, }, focusEditorAfterReplace: false, } From ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:32:58 +0530 Subject: [PATCH 073/680] fix(settings): provider updates show live progress instead of a bare spinner (#16958) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../providerMaintenanceRunner.test.ts | 66 +++++++++++- .../src/provider/providerMaintenanceRunner.ts | 101 +++++++++++++++--- .../settings/ProviderInstanceCard.tsx | 59 ++++++++-- 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c82ec00143f2e..b6580c78856da 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { ]); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.deepStrictEqual( - (yield* Ref.get(updateStatesRef)).map((state) => state.status), - ["queued", "running", "succeeded"], + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["running", "Running native-agent update"], + ["running", "Verifying the installed version"], + ["succeeded", "Provider updated."], + ], ); }).pipe( Effect.provide( @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("reports the installer's latest output line while the update runs", () => { + const exited = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const runningMessages = Ref.get(updateStatesRef).pipe( + Effect.map((states) => + states.filter((state) => state.status === "running").map((state) => state.message), + ), + ); + + const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepStrictEqual(yield* runningMessages, [ + "Checking for the latest version", + "Running native-agent update", + "80%", + ]); + + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); + const result = yield* Fiber.join(fiber); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", + exitCode: Deferred.await(exited), + })), + ), + ), + ); + }); + + it("splits installer output into clean status lines", () => { + assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { + lines: ["Downloading"], + partialLine: " 5", + }); + assert.strictEqual( + ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"), + "==> Pouring", + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null); + assert.strictEqual( + ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length, + 4_096, + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); + }); + it.effect("aborts without running when the installation changed since the advisory", () => { const calls: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 1e22ba899bec0..cc8b3728785f2 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as NodeUtil from "node:util"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; +// Every progress publish resends the provider list to each client, so the +// installer's output is sampled rather than forwarded line by line. +const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); +const UPDATE_PROGRESS_MAX_LENGTH = 200; +// An installer may write for minutes without a line ending; only its tail matters. +const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; export interface ProviderMaintenanceCommandResult { readonly stdout: string; @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; + readonly onProgress?: (line: string) => Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // Holds the newest output line not yet reported; the sampler takes it. + const pendingProgress = yield* Ref.make(null); + const onProgress = input.onProgress; + if (onProgress) { + yield* Ref.getAndSet(pendingProgress, null).pipe( + Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))), + Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)), + Effect.forkScoped, + ); + } + const trackProgress = (stream: Stream.Stream) => { + if (!onProgress) { + return stream; + } + const decoder = new TextDecoder(); + let partialLine = ""; + return stream.pipe( + Stream.tap((chunk) => { + const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true })); + partialLine = split.partialLine; + // A progress bar that redraws with a leading `\r` keeps its newest + // frame unterminated, so that frame is the latest status. + const line = + toProgressLine(split.partialLine) ?? + split.lines.map(toProgressLine).findLast((value) => value !== null); + return line ? Ref.set(pendingProgress, line) : Effect.void; + }), + ); + }; + const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: trackProgress(child.stdout), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: trackProgress(child.stderr), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }, ); +/** + * Split a chunk of installer output into complete lines. `\r` ends a line too, + * so a redrawn progress bar reports its latest frame. + */ +export function splitOutputLines( + partialLine: string, + text: string, +): { readonly lines: ReadonlyArray; readonly partialLine: string } { + const parts = (partialLine + text).split(/\r\n|\r|\n/); + return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; +} + +/** Turn one raw output line into a short status message, or null if it has no text. */ +export function toProgressLine(line: string): string | null { + const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); + if (text.length === 0) { + return null; + } + return text.length <= UPDATE_PROGRESS_MAX_LENGTH + ? text + : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`; +} + +/** `claude update` reads better in a status line than the full executable path. */ +function describeCommand(command: ProviderMaintenanceCommandAction): string { + const executable = command.executable.split(/[\\/]/).pop() || command.executable; + return [executable, ...command.args].join(" "); +} + function trimNullable(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; - const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => + const runMaintenanceCommand = ( + update: ProviderMaintenanceCommandAction, + onProgress: (line: string) => Effect.Effect, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, args: update.args, + onProgress, ...(update.env ? { env: update.env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { function* () { const startedAt = yield* nowIso; yield* Ref.set(startedAtRef, startedAt); - yield* setUpdateState( - makeUpdateState({ - status: "running", - startedAt, - finishedAt: null, - message: "Updating provider.", - }), - ); + const setRunningMessage = (message: string) => + setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message, + }), + ).pipe(Effect.asVoid); + yield* setRunningMessage("Checking for the latest version"); // The cached capabilities chose the lock; re-derive ownership // now so the command that runs matches the executable as it is @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } - const result = yield* runMaintenanceCommand(command); + yield* setRunningMessage(`Running ${describeCommand(command)}`); + const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* setRunningMessage("Verifying the installed version"); // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 381dcd864e425..eab2fba04cb28 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ enabled, ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const updateState = liveProvider?.updateState; + // The server reports each update step. `isUpdating` also covers the moment + // between the click and the server's first report. + const updateProgress = isUpdating + ? ((updateState?.status === "queued" || updateState?.status === "running" + ? updateState.message + : null) ?? "Starting update") + : null; + const updateProblem = + !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") + ? updateState.message + : null; const hasCompatibilityWarning = compatibility !== undefined && compatibility.status !== "supported" && @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ size={mode === "list" ? "icon-micro" : "icon-xs"} variant="ghost-muted" className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} - aria-label={`${versionAdvisory.title} — view details`} + aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`} > - + {updateProgress ? ( + + ) : ( + + )} } /> } /> - {versionAdvisory.title} + + {updateProgress ? "Updating" : versionAdvisory.title} +
    @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ : "Update now"} ) : null} + {updateProgress || updateProblem ? ( +

    + {updateProgress ?? updateProblem} +

    + ) : null} {onRunVersionAction && updateCommand ? (
    @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ {versionAdvisoryNode} - {statusDotNode ? ( + {/* The dot describes provider health, not the update in progress. */} + {statusDotNode && !updateProgress ? ( {statusDotNode} ) : null} - {summary.headline} - {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + {updateProgress ? ( + `Updating · ${updateProgress}` + ) : ( + <> + {summary.headline} + {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + + )} From 14fe0158ede3d964f183e281f9fec225d30a6b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:11:05 -0700 Subject: [PATCH 074/680] feat(web): find in diffs with Cmd+F (#14623) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/DiffPanel.tsx | 13 + apps/web/src/components/RightPanelTabs.tsx | 3 +- .../components/diffs/AnnotatableCodeView.tsx | 4 + .../components/diffs/codeViewSearch.test.ts | 106 +++ .../pullRequest/PullRequestCodeTab.tsx | 6 + apps/web/src/index.css | 8 + apps/web/src/lib/editableFocus.test.ts | 25 + apps/web/src/lib/editableFocus.ts | 6 +- docs/user/keybindings.md | 9 + patches/@pierre%2Fdiffs@1.5.2.patch | 869 +++++++++++++++++- pnpm-lock.yaml | 10 +- 11 files changed, 1042 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/diffs/codeViewSearch.test.ts create mode 100644 apps/web/src/lib/editableFocus.test.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index cc5354f383540..633094738e9eb 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -695,6 +695,18 @@ export default function DiffPanel({ return { scopeKey: collapseScopeKey, fileKeys: next }; }); }, []); + // Find can ask again before the unfolded file reaches the viewer, so this must never fold. + const unfoldDiffFile = useCallback((fileKey: string) => { + const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current; + setCollapsedDiffFiles((current) => { + const fileKeys = + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys; + if (!fileKeys.has(fileKey)) return current; + const next = new Set(fileKeys); + next.delete(fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, []); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); @@ -1197,6 +1209,7 @@ export default function DiffPanel({ }, } : {})} + onRevealSearchMatch={unfoldDiffFile} renderHeaderPrefix={(fileDiff, fileKey) => { const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; return ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4f6f3607f470c..2c7e3e3da68cc 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -415,7 +415,8 @@ function RightPanelEmptyState(props: { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - const target = event.target; + // The composed path starts at the real target, which may sit inside a shadow root. + const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index ab4f545ed07b4..b36fd8b3cb37d 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps { fileKey: string, collapsed: boolean, ) => ReactNode; + /** Unfold a collapsed file that holds the find match being navigated to. */ + onRevealSearchMatch: (fileKey: string) => void; } interface DiffSelectionContext { @@ -115,6 +117,7 @@ export function AnnotatableCodeView({ renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, + onRevealSearchMatch, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -262,6 +265,7 @@ export function AnnotatableCodeView({ items={items} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} + onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)} options={{ ...options, enableGutterUtility: !hasOpenComment, diff --git a/apps/web/src/components/diffs/codeViewSearch.test.ts b/apps/web/src/components/diffs/codeViewSearch.test.ts new file mode 100644 index 0000000000000..80af9f5ed2f90 --- /dev/null +++ b/apps/web/src/components/diffs/codeViewSearch.test.ts @@ -0,0 +1,106 @@ +import type { CodeViewItem } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { describe, expect, it } from "vite-plus/test"; + +// Find-in-diff lives in our @pierre/diffs patch and is not a package export. +const searchUrl = new URL("./components/CodeViewSearch.js", import.meta.resolve("@pierre/diffs")); +const { collectMatches } = (await import(/* @vite-ignore */ searchUrl.href)) as { + collectMatches( + items: ReadonlyArray>, + params: { text: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean }, + ): Array<{ + id: string; + side: string; + isContext: boolean; + lineNumber: number; + start: number; + end: number; + }>; +}; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,4 +1,5 @@ + const keep = 1; +-const oldName = 2; ++const newName = 2; ++const added = oldName; + const tail = 4; +@@ -20,3 +21,3 @@ function x() { + ctx20 name +-del21 Name ++add22 names + ctx22 +diff --git a/src/b.ts b/src/b.ts +index 3333333..4444444 100644 +--- a/src/b.ts ++++ b/src/b.ts +@@ -1,1 +1,1 @@ +-nil ++: nil +`; + +const items: CodeViewItem[] = parsePatchFiles(PATCH)[0]!.files.map( + (fileDiff, index) => ({ id: `file-${index}`, type: "diff", fileDiff, collapsed: index === 1 }), +); + +function find( + text: string, + flags: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean } = {}, +) { + return collectMatches(items, { + text, + caseSensitive: flags.caseSensitive ?? false, + wholeWord: flags.wholeWord ?? false, + regex: flags.regex ?? false, + }).map(({ id, side, lineNumber, start, end }) => `${id}:${side}:${lineNumber}:${start}-${end}`); +} + +describe("collectMatches", () => { + it("finds removed, added, and context lines in render order, folded files included", () => { + expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]); + expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]); + }); + + it("honours case, whole-word, and regex toggles", () => { + expect(find("Name", { caseSensitive: true })).toEqual([ + "file-0:deletions:2:9-13", + "file-0:additions:2:9-13", + "file-0:additions:3:17-21", + "file-0:deletions:21:6-10", + ]); + expect(find("name", { wholeWord: true })).toEqual([ + "file-0:additions:21:6-10", + "file-0:deletions:21:6-10", + ]); + expect(find("n[a-z]+e", { regex: true })).toHaveLength(6); + }); + + it("marks unchanged lines, which split view draws in both columns", () => { + const flags = collectMatches(items, { + text: "ctx", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ lineNumber, isContext }) => [lineNumber, isContext]); + expect(flags).toEqual([ + [21, true], + [23, true], + ]); + expect( + collectMatches(items, { + text: "oldName", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ isContext }) => isContext), + ).toEqual([false, false]); + }); + + it("returns nothing for an empty query or an invalid pattern", () => { + expect(find("")).toEqual([]); + expect(find("(", { regex: true })).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index e1c6f1de04533..9af92ee96a814 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1516,6 +1516,12 @@ function PullRequestCodeTab({ renderHeaderPrefix={renderHeaderPrefix} renderHeaderMetadata={renderHeaderMetadata} renderAnnotation={renderAnnotation} + // Find can ask again before the unfolded file arrives, so this unfolds and never folds. + onRevealSearchMatch={(item) => + setToggledFiles((current) => + toggleFileDiffFoldForViewed(item.id, false, effectiveFoldOverride, current), + ) + } unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} />
    diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d43fc36769a13..453a9fd599fe9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2087,6 +2087,14 @@ code { color: var(--code-foreground); } +/* Pierre's find panel and match highlights read these. */ +.diff-render-surface { + --diffs-search-panel-bg: var(--popover); + --diffs-search-panel-fg: var(--popover-foreground); + --diffs-search-panel-accent: var(--primary); + --diffs-search-panel-font: var(--font-sans); +} + /* Pierre's file renderer owns its own surface tokens. Keep the root of a file preview on the same themeable code surface as the diff panel. */ .diff-render-surface diffs-container, diff --git a/apps/web/src/lib/editableFocus.test.ts b/apps/web/src/lib/editableFocus.test.ts new file mode 100644 index 0000000000000..1028b98c60710 --- /dev/null +++ b/apps/web/src/lib/editableFocus.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { isEditableFocused } from "./editableFocus"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("isEditableFocused", () => { + it("sees a text field focused inside an open shadow root", () => { + // A key event from a shadow input is retargeted to its host, so the host is + // what page-level shortcut handlers receive as the event target. + const host = document.createElement("div"); + const input = document.createElement("input"); + host.attachShadow({ mode: "open" }).append(input); + document.body.append(host); + + expect(isEditableFocused(host)).toBe(false); + input.focus(); + expect(isEditableFocused(host)).toBe(true); + expect(isEditableFocused()).toBe(true); + }); +}); diff --git a/apps/web/src/lib/editableFocus.ts b/apps/web/src/lib/editableFocus.ts index ad704a39f6166..79af4774b49e3 100644 --- a/apps/web/src/lib/editableFocus.ts +++ b/apps/web/src/lib/editableFocus.ts @@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [ /** * Whether a text-editing element owns the keyboard. Shortcuts that share * their chord with native editing (mod+z) must yield when this is true. + * Focus inside an open shadow root counts: a key event's target is retargeted + * to the shadow host, so the host's focused descendant is what is typed into. */ export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { - return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; + let element = target instanceof Element ? target : null; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element !== null && element.closest(EDITABLE_SELECTOR) !== null; } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 488b57b997cbb..2d7c786cc3f82 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID” or “Copy Number”. They copy the selected PR and leave terminal input alone. +## Find in a diff + +Click into a diff in the Diff panel or a pull request's Code tab, then press +`mod+f` to search every file in it, including folded files and unchanged lines +hidden between changes. Enter and `Shift+Enter` move between matches, and a +match in a folded file opens it. Escape closes the search. This shortcut is not +configurable. A very large uncommitted diff loads its files as you scroll, and +find only searches the files loaded so far. + ## iPad With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch index f1984ea41835a..fbb672dc90abd 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -1,7 +1,704 @@ +diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts +index cc32d28..3a46bf4 100644 +--- a/dist/components/CodeView.d.ts ++++ b/dist/components/CodeView.d.ts +@@ -158,6 +158,12 @@ interface CodeViewOptions extends CodeViewPassThroughOptions + * final pre-detach state. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** ++ * Called when find navigates to a match inside a collapsed item. Folding is ++ * host state, so the host unfolds the item (a controlled update with ++ * `collapsed: false` and a new `version`); the viewer then scrolls to it. ++ */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + /** Render a non-virtualized element at the very start of the scroll content, + * before the first item. It is always rendered and scrolls with the content. + * Return the same element across calls and mutate it in place to update; +@@ -268,6 +274,13 @@ declare class CodeView { + getSelectedLines(): CodeViewLineSelection | null; + clearSelectedLines(options?: SelectionWriteOptions): void; + getItem(itemId: string): CodeViewItem | undefined; ++ /** ++ * Open the find panel, or refocus it when open. It searches every diff ++ * item's lines (including folded files and virtualized rows), and is also ++ * opened by the primary-modifier+F shortcut while focus is in the viewer. ++ */ ++ openSearch(): void; ++ closeSearch(): void; + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js +index 140f94f..96fc774 100644 +--- a/dist/components/CodeView.js ++++ b/dist/components/CodeView.js +@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js"; + import { roundToDevicePixel } from "../utils/roundToDevicePixel.js"; + import { VirtualizedFile } from "./VirtualizedFile.js"; + import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js"; ++import { CodeViewSearch } from "./CodeViewSearch.js"; + //#region src/components/CodeView.ts + const CODE_VIEW_DIFF_OPTION_KEYS = [ + "theme", +@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) { + } + const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120; + const SUB_PIXEL_TOLERANCE = 1; ++/** Room the find panel covers at the top of the viewport; matches under it get scrolled clear. */ ++const SEARCH_PANEL_RESERVED_HEIGHT = 48; + const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override"; + function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) { + return { +@@ -211,6 +214,8 @@ var CodeView = class CodeView { + isReadySubscription; + pendingHighlighterTheme; + isContainerManaged; ++ search = new CodeViewSearch(this); ++ pendingSearchReveal; + constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) { + this.options = options; + this.computeMetricsCache(options.itemMetrics); +@@ -392,6 +397,7 @@ var CodeView = class CodeView { + this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true }); ++ this.root.addEventListener("keydown", this.search.handleKeyDown); + this.resizeObserver.observe(this.root); + this.render(true); + window.__INSTANCE = this; +@@ -464,6 +470,7 @@ var CodeView = class CodeView { + this.heightDirty = true; + this.resetRenderState(); + if (!this.isContainerManaged) this.flushSlotCoordinator(); ++ this.search.refresh(); + if (failed) throw failure; + } + cleanUp() { +@@ -485,6 +492,8 @@ var CodeView = class CodeView { + this.root?.removeEventListener("touchstart", this.clearPendingScroll); + this.root?.removeEventListener("pointerdown", this.clearPendingScroll); + this.root?.removeEventListener("keydown", this.clearPendingScroll); ++ this.root?.removeEventListener("keydown", this.search.handleKeyDown); ++ this.search.cleanUp(); + this.root?.style.removeProperty("overflow-anchor"); + this.container?.remove(); + this.stickyOffset.remove(); +@@ -626,6 +635,137 @@ var CodeView = class CodeView { + getItem(itemId) { + return this.idToItem.get(itemId)?.item; + } ++ /** Open the find panel, or refocus it when it is already open. */ ++ openSearch() { ++ this.search.open(); ++ } ++ closeSearch() { ++ this.search.close(); ++ } ++ getSearchItems() { ++ return this.items.map((item) => item.item); ++ } ++ /** Index of the first match at or below the top of the viewport, or 0 when every match is above it. */ ++ getFirstVisibleSearchMatchIndex(entries) { ++ if (this.items.length === 0) return 0; ++ const visibleTop = this.getScrollTop() - this.getItemTopOffset() + this.getStickyHeaderOffset(); ++ const itemIndex = this.findFirstVisibleIndex(Math.max(this.getScrollTop() - this.getItemTopOffset(), 0)); ++ const item = this.items[itemIndex]; ++ for (let index = 0; index < entries.length; index++) { ++ const entry = entries[index]; ++ if (entry.itemIndex < itemIndex) continue; ++ if (entry.itemIndex > itemIndex) return index; ++ const position = this.getLineScrollPosition(item, entry); ++ if (position == null || item.top + position.top >= visibleTop) return index; ++ } ++ return 0; ++ } ++ /** ++ * Bring a match on screen. A folded file is unfolded by the host through ++ * `onRevealSearchMatch` (folding is host state); the reveal resumes once the ++ * item arrives unfolded. Collapsed context around the line is expanded here. ++ */ ++ revealSearchMatch(match, afterUnfold = false) { ++ const item = this.idToItem.get(match.id); ++ if (item == null || item.type !== "diff") return; ++ if (item.item.collapsed === true) { ++ if (this.options.onRevealSearchMatch == null) { ++ this.scrollTo({ ++ type: "item", ++ id: match.id, ++ align: "start" ++ }); ++ return; ++ } ++ this.pendingSearchReveal = match; ++ this.options.onRevealSearchMatch(item.item); ++ return; ++ } ++ const target = { ++ type: "line", ++ id: match.id, ++ lineNumber: match.lineNumber, ++ side: match.side, ++ align: "center", ++ // Center in the space below the sticky header and the find panel. ++ offset: -(this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT) / 2 ++ }; ++ // Before its first render a file reports every line renderable, so collapsed ++ // context can't be expanded yet: scroll there and finish once it has rendered. ++ const rendered = item.instance.getRenderedDiff() != null; ++ this.pendingSearchReveal = rendered ? void 0 : match; ++ if (rendered && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber); ++ else if (rendered && !afterUnfold && this.isScrollTargetInView(target)) return; ++ this.scrollTo(target); ++ } ++ cancelSearchReveal() { ++ this.pendingSearchReveal = void 0; ++ } ++ /** Finish a reveal that was waiting for its file to render for the first time. */ ++ resumePendingSearchReveal(updatedItems) { ++ const reveal = this.pendingSearchReveal; ++ if (reveal == null) return; ++ for (const item of updatedItems) if (item.item.id === reveal.id && item.item.collapsed !== true && item.instance.getRenderedDiff() != null) { ++ queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); ++ return; ++ } ++ } ++ /** Scroll a wide code column so the current match is visible when lines don't wrap. */ ++ revealSearchMatchHorizontally(match, element) { ++ const item = this.idToItem.get(match.id); ++ const code = element.closest("[data-code]"); ++ if (item == null || code == null || code.scrollWidth <= code.clientWidth) return; ++ const codeRect = code.getBoundingClientRect(); ++ const matchRect = getTextRangeRect(element, match.start, match.end); ++ if (matchRect == null) return; ++ // The line-number gutter is sticky at the left edge and covers the code under it. ++ const gutterRight = code.querySelector(":scope > [data-gutter]")?.getBoundingClientRect().right; ++ const visibleLeft = gutterRight != null && gutterRight > codeRect.left ? gutterRight : codeRect.left; ++ const padding = 24; ++ let next = code.scrollLeft; ++ if (matchRect.left < visibleLeft + padding) next -= visibleLeft + padding - matchRect.left; ++ else if (matchRect.right > codeRect.right - padding) next += matchRect.right - (codeRect.right - padding); ++ else return; ++ item.instance.setCodeScrollLeft(Math.max(0, next)); ++ } ++ /** Whether a line already sits fully below the sticky header and find panel. */ ++ isScrollTargetInView(target) { ++ const rect = this.getScrollTargetRect(target); ++ if (rect == null) return false; ++ const top = this.getItemTopOffset() + rect.top; ++ const scrollTop = this.getScrollTop(); ++ return top >= scrollTop + this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT && top + rect.height <= scrollTop + this.getHeight(); ++ } ++ /** Pair each match with its mounted line element; rows outside the window are skipped. */ ++ getMountedSearchMatches(entriesByItem) { ++ const mounted = []; ++ const { firstIndex, lastIndex } = this.renderState; ++ if (firstIndex === -1) return mounted; ++ for (let index = firstIndex; index <= lastIndex; index++) { ++ const itemEntries = entriesByItem.get(index); ++ const shadowRoot = itemEntries != null ? this.items[index]?.element?.shadowRoot : void 0; ++ if (shadowRoot == null) continue; ++ const lines = /* @__PURE__ */ new Map(); ++ for (const element of shadowRoot.querySelectorAll("[data-line][data-line-index]")) { ++ const unifiedIndex = element.getAttribute("data-line-index").split(",")[0]; ++ const column = element.closest("[data-deletions]") != null ? "deletions" : "additions"; ++ lines.set(`${column}:${unifiedIndex}`, element); ++ } ++ for (const entry of itemEntries) { ++ // Split view draws unchanged context in both columns, so paint each copy. ++ const additions = lines.get(`additions:${entry.unifiedLineIndex}`); ++ const deletions = lines.get(`deletions:${entry.unifiedLineIndex}`); ++ const elements = entry.side === "deletions" ? [deletions ?? additions] : entry.isContext ? [additions, deletions] : [additions]; ++ for (const element of elements) if (element != null) mounted.push({ ++ entry, ++ element ++ }); ++ } ++ } ++ return mounted; ++ } + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +@@ -645,6 +785,7 @@ var CodeView = class CodeView { + this.render(); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + return true; + } + updateItemId(oldId, newId) { +@@ -678,6 +819,7 @@ var CodeView = class CodeView { + this.renamePendingScrollTarget(oldId, newId); + this.renamePendingLayoutAnchor(oldId, newId); + this.render(); ++ this.search.refresh(); + return true; + } + addItem(input) { +@@ -687,6 +829,7 @@ var CodeView = class CodeView { + this.appendItemsInternal(inputs); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + } + removeItem(itemId) { + const item = this.idToItem.get(itemId); +@@ -709,6 +852,7 @@ var CodeView = class CodeView { + for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp(); + } + this.syncSelection(); ++ this.search.refresh(); + } + /** + * Append new records to the viewer while preserving existing layout state. +@@ -1330,6 +1474,10 @@ var CodeView = class CodeView { + item.item = nextItem; + item.version = nextItem.version; + item.renderedOptionsRevision = -1; ++ const reveal = this.pendingSearchReveal; ++ if (reveal != null && reveal.id === nextItem.id && nextItem.collapsed !== true) queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); + return true; + } + getMaxScrollTopForHeight(scrollHeight) { +@@ -1710,6 +1858,10 @@ var CodeView = class CodeView { + this.updateStickyPositioning(); + this.validateStickyContainerHeight(); + this.fixContainerFocus(); ++ if (updatedItems.size > 0) { ++ this.resumePendingSearchReveal(updatedItems); ++ this.search.repaint(); ++ } + if (fitPerfectly || this.scrollAnimation != null) this.render(); + }; + flushManagers(updatedItems) { +@@ -1804,7 +1956,8 @@ var CodeView = class CodeView { + this.notifyScroll(); + this.render(); + }; +- clearPendingScroll = () => { ++ clearPendingScroll = (event) => { ++ if (event != null && this.search.isOpen && this.search.ownsEvent(event)) return; + this.pendingScrollTarget = void 0; + this.pendingLayoutAnchor = void 0; + this.scrollAnimation = void 0; +@@ -2066,7 +2219,12 @@ var CodeView = class CodeView { + const item = this.items[index]; + if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index"); + item.top = runningTop; +- if (item.type === "diff") item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ if (item.type === "diff") { ++ const wasPartial = item.item.fileDiff.isPartial; ++ item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ // Hydration fills the diff's lines in place, so the search has new text to cover. ++ if (wasPartial && !item.item.fileDiff.isPartial) this.search.refresh(true); ++ } + else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []); + runningTop += item.height; + if (index < this.items.length - 1) runningTop += layout.gap; +@@ -2086,6 +2244,27 @@ var CodeView = class CodeView { + return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight; + } + }; ++function getTextRangeRect(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range.getBoundingClientRect(); ++ } ++ consumed += length; ++ } ++} + function prepareItemInstance(item) { + item.instance.cleanUp(true); + if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []); +diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js +new file mode 100644 +index 0000000..29370ad +--- /dev/null ++++ b/dist/components/CodeViewSearch.js +@@ -0,0 +1,360 @@ ++import { isPrimaryModifier } from "../editor/platform.js"; ++import { resolveFindAgainShortcut } from "../editor/command.js"; ++import { SearchPanelWidget } from "../editor/searchPanel.js"; ++import { SVGSpriteSheet } from "../editor/sprite.js"; ++import editor_default from "../editor/editor2.js"; ++import { iterateOverDiff } from "../utils/iterateOverDiff.js"; ++//#region src/components/CodeViewSearch.ts ++const MAX_SEARCH_MATCHES = 1e5; ++const WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; ++const SEARCH_HOST_ATTRIBUTE = "data-diffs-search"; ++const SEARCH_PANEL_HOST_CSS = `:host { ++ display: block; ++ position: sticky; ++ z-index: 100; ++ height: 0; ++} ++[data-icon-sprite] { ++ position: absolute; ++} ++[data-search-panel] { ++ position: relative; ++ top: 8px; ++} ++[data-editor-widget] { ++ --diffs-bg: var(--diffs-search-panel-bg, Canvas); ++ --diffs-fg: var(--diffs-search-panel-fg, CanvasText); ++ --diffs-modified-base: var(--diffs-search-panel-accent, light-dark(#009fff, #69b1ff)); ++ --diffs-header-font-fallback: var(--diffs-search-panel-font, system-ui, sans-serif); ++}`; ++let sharedHighlights; ++/** ++* Find-in-diff for a CodeView. It searches every diff item's line data rather ++* than the rendered rows, so matches in virtualized rows, expanded context, and ++* folded files are all found, and navigating to one reveals it. ++* ++* Matches are painted with the CSS Custom Highlight API. Ranges are rebuilt for ++* whichever rows a render leaves mounted, so highlighting never touches the ++* rendered DOM or the renderer's caches. ++*/ ++var CodeViewSearch = class { ++ #view; ++ #host; ++ #widget; ++ #entries = []; ++ #entriesByItem = /* @__PURE__ */ new Map(); ++ #current; ++ #revealHorizontally; ++ #ranges = []; ++ #searchedDiffs = []; ++ constructor(view) { ++ this.#view = view; ++ } ++ get isOpen() { ++ return this.#host != null; ++ } ++ /** Open the panel, or refocus it when it is already open. */ ++ open() { ++ if (this.#widget != null) { ++ this.#widget.setMode("find"); ++ return; ++ } ++ const root = this.#view.getContainerElement(); ++ if (root == null) return; ++ const host = document.createElement("div"); ++ host.setAttribute(SEARCH_HOST_ATTRIBUTE, ""); ++ host.style.top = `${this.#view.getStickyHeaderOffset()}px`; ++ const shadowRoot = host.attachShadow({ mode: "open" }); ++ const style = document.createElement("style"); ++ style.textContent = `${editor_default}\n${SEARCH_PANEL_HOST_CSS}`; ++ const sprite = document.createElement("div"); ++ sprite.innerHTML = SVGSpriteSheet; ++ const anchor = document.createElement("div"); ++ shadowRoot.append(style, ...sprite.children, anchor); ++ root.prepend(host); ++ this.#host = host; ++ const defaultQuery = getSelectionQuery(); ++ // The widget runs its first search a frame after opening, so callbacks from a ++ // panel that has since closed must not touch the current search. ++ const isLive = () => this.#widget === widget; ++ // Without a selection that first frame reports no matches; ignore it if a ++ // query typed before then has already found some. ++ let initializing = defaultQuery === ""; ++ const widget = new SearchPanelWidget({ ++ textDocument: { search: (params) => isLive() ? this.#search(params) : [] }, ++ containerElement: anchor, ++ defaultQuery, ++ mode: "find", ++ allowReplace: false, ++ initialMatch: defaultQuery !== "" ? [0, 0] : void 0, ++ scrollToMatch: ([offset]) => { ++ if (isLive()) this.#goTo(offset); ++ }, ++ applyReplace: () => {}, ++ onUpdate: (matches, options) => { ++ if (!isLive()) return; ++ if (matches.length === 0) { ++ if (!initializing || this.#entries.length === 0) this.#setEntries([]); ++ return; ++ } ++ if (options?.syncSelection === false) { ++ this.#paint(); ++ return this.#current != null ? matches[this.#current] : void 0; ++ } ++ const next = matches[this.#current ?? this.#view.getFirstVisibleSearchMatchIndex(this.#entries)]; ++ if (next != null) this.#goTo(next[0]); ++ return next; ++ }, ++ onClose: () => this.#teardown(true) ++ }); ++ this.#widget = widget; ++ // Runs after the widget's own first-frame callback, which was scheduled first. ++ if (initializing) requestAnimationFrame(() => { ++ initializing = false; ++ }); ++ widget.focus(); ++ } ++ close() { ++ this.#widget?.close(); ++ } ++ /** ++ * Rerun the query after the items change, keeping the current match when it ++ * survives. Updates that keep every diff (folding, annotations) only repaint; ++ * `force` covers diffs whose lines were filled in place. ++ */ ++ refresh(force = false) { ++ if (this.#widget == null) return; ++ if (!force && haveSameDiffs(this.#searchedDiffs, this.#view.getSearchItems())) { ++ this.#paint(); ++ return; ++ } ++ this.#widget.updateMatches({ syncSelection: false }); ++ } ++ /** Repaint highlights for the rows the last render pass left mounted. */ ++ repaint() { ++ if (this.#entries.length > 0) this.#paint(); ++ } ++ /** Whether an event came from the panel, which handles its own keys and pointer input. */ ++ ownsEvent(event) { ++ return event.composedPath().some((node) => node instanceof Element && node.hasAttribute(SEARCH_HOST_ATTRIBUTE)); ++ } ++ handleKeyDown = (event) => { ++ if (event.isComposing || event.keyCode === 229) return; ++ // The panel's inputs handle their own keys; its buttons get the shortcuts below. ++ if (this.ownsEvent(event) && isEditableTarget(event)) { ++ if (event.key === "Escape") event.stopPropagation(); ++ return; ++ } ++ if (event.defaultPrevented) return; ++ if (isPrimaryModifier(event) && !event.altKey && !event.shiftKey && (event.key.toLowerCase() === "f" || event.code === "KeyF")) { ++ event.preventDefault(); ++ this.open(); ++ return; ++ } ++ if (this.#widget == null) return; ++ const findAgain = resolveFindAgainShortcut(event); ++ if (findAgain != null) { ++ event.preventDefault(); ++ this.#widget.navigate(findAgain === "previous"); ++ } else if (event.key === "Escape" && !isEditableTarget(event)) { ++ event.preventDefault(); ++ event.stopPropagation(); ++ this.close(); ++ } ++ }; ++ cleanUp() { ++ this.#teardown(false); ++ } ++ #teardown(restoreFocus) { ++ if (this.#host == null) return; ++ this.#widget?.cleanup(); ++ this.#widget = void 0; ++ this.#host.remove(); ++ this.#host = void 0; ++ this.#searchedDiffs = []; ++ this.#setEntries([]); ++ if (restoreFocus) this.#view.getContainerElement()?.focus({ preventScroll: true }); ++ } ++ #search(params) { ++ const previous = this.#current != null ? this.#entries[this.#current] : void 0; ++ const items = this.#view.getSearchItems(); ++ this.#searchedDiffs = items.map((item) => [item.id, item.fileDiff]); ++ this.#setEntries(collectMatches(items, params), previous); ++ return this.#entries.map((entry) => entry.range); ++ } ++ #setEntries(entries, previous) { ++ this.#entries = entries; ++ this.#entriesByItem = groupByItem(entries); ++ this.#current = previous != null ? findSameMatch(entries, previous) : void 0; ++ if (entries.length === 0) { ++ this.#view.cancelSearchReveal(); ++ this.#paint(); ++ } ++ } ++ #goTo(offset) { ++ const index = this.#entries.findIndex((entry) => entry.range[0] === offset); ++ if (index === -1) return; ++ this.#current = index; ++ this.#revealHorizontally = this.#entries[index]; ++ this.#view.revealSearchMatch(this.#entries[index]); ++ this.#paint(); ++ } ++ #paint() { ++ const highlights = getSharedHighlights(); ++ if (highlights == null) return; ++ for (const range of this.#ranges) { ++ highlights.match.delete(range); ++ highlights.focus.delete(range); ++ } ++ this.#ranges = []; ++ const current = this.#current != null ? this.#entries[this.#current] : void 0; ++ let currentElement; ++ for (const { entry, element } of this.#view.getMountedSearchMatches(this.#entriesByItem)) { ++ const range = createTextRange(element, entry.start, entry.end); ++ if (range == null) continue; ++ (entry === current ? highlights.focus : highlights.match).add(range); ++ this.#ranges.push(range); ++ if (entry === current) currentElement ??= element; ++ } ++ if (current != null && currentElement != null && this.#revealHorizontally === current) { ++ this.#revealHorizontally = void 0; ++ this.#view.revealSearchMatchHorizontally(current, currentElement); ++ } ++ } ++}; ++/** ++* Collect matches across diff items in render order. Each match gets a ++* synthetic, increasing offset so the panel's offset-based navigation works ++* across files; nothing else reads those offsets. ++*/ ++function collectMatches(items, params) { ++ if (params.text === "" || params.text.includes("\n")) return []; ++ let pattern; ++ try { ++ pattern = new RegExp(params.regex ? params.text : escapeRegExp(params.text), params.caseSensitive ? "g" : "gi"); ++ } catch { ++ return []; ++ } ++ const entries = []; ++ let offset = 0; ++ for (let itemIndex = 0; itemIndex < items.length && entries.length < MAX_SEARCH_MATCHES; itemIndex++) { ++ const item = items[itemIndex]; ++ if (item.type !== "diff") continue; ++ const { fileDiff } = item; ++ iterateOverDiff({ ++ diff: fileDiff, ++ diffStyle: "unified", ++ expandedHunks: true, ++ callback: ({ type, deletionLine, additionLine }) => { ++ const isDeletion = type === "change" && deletionLine != null; ++ const line = isDeletion ? deletionLine : additionLine; ++ const text = trimLineBreak((isDeletion ? fileDiff.deletionLines : fileDiff.additionLines)[line.lineIndex] ?? ""); ++ pattern.lastIndex = 0; ++ let match; ++ while ((match = pattern.exec(text)) !== null) { ++ const length = match[0].length; ++ if (length === 0) { ++ pattern.lastIndex++; ++ continue; ++ } ++ if (params.wholeWord && !isWholeWord(text, match.index, length)) continue; ++ entries.push({ ++ id: item.id, ++ itemIndex, ++ side: isDeletion ? "deletions" : "additions", ++ isContext: type !== "change", ++ lineNumber: line.lineNumber, ++ unifiedLineIndex: line.unifiedLineIndex, ++ start: match.index, ++ end: match.index + length, ++ range: [offset + match.index, offset + match.index + length] ++ }); ++ if (entries.length >= MAX_SEARCH_MATCHES) return true; ++ } ++ offset += text.length + 1; ++ } ++ }); ++ } ++ return entries; ++} ++function haveSameDiffs(searched, items) { ++ return searched.length === items.length && items.every((item, index) => item.id === searched[index][0] && item.fileDiff === searched[index][1]); ++} ++function groupByItem(entries) { ++ const byItem = /* @__PURE__ */ new Map(); ++ for (const entry of entries) { ++ let list = byItem.get(entry.itemIndex); ++ if (list == null) byItem.set(entry.itemIndex, list = []); ++ list.push(entry); ++ } ++ return byItem; ++} ++function findSameMatch(entries, previous) { ++ const index = entries.findIndex((entry) => entry.id === previous.id && entry.side === previous.side && entry.lineNumber === previous.lineNumber && entry.start === previous.start); ++ return index === -1 ? void 0 : index; ++} ++function createTextRange(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range; ++ } ++ consumed += length; ++ } ++} ++function trimLineBreak(text) { ++ if (text.endsWith("\r\n")) return text.slice(0, -2); ++ if (text.endsWith("\n") || text.endsWith("\r")) return text.slice(0, -1); ++ return text; ++} ++function isWholeWord(text, start, length) { ++ return isSeparator(text, start - 1) && isSeparator(text, start + length); ++} ++function isSeparator(text, index) { ++ if (index < 0 || index >= text.length) return true; ++ const code = text.charCodeAt(index); ++ return code <= 32 || code === 127 || WORD_SEPARATORS.includes(text[index]); ++} ++function escapeRegExp(text) { ++ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); ++} ++function isEditableTarget(event) { ++ const target = event.composedPath()[0]; ++ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select")); ++} ++function getSelectionQuery() { ++ const text = document.getSelection()?.toString() ?? ""; ++ return text.includes("\n") ? "" : text; ++} ++/** ++* Two registry entries shared by every CodeView on the page; each search adds ++* and removes only its own ranges. The `::highlight()` colours live in the core ++* stylesheet, because a highlight rule only styles text in its own tree. ++*/ ++function getSharedHighlights() { ++ if (sharedHighlights != null) return sharedHighlights; ++ if (typeof CSS === "undefined" || !("highlights" in CSS) || typeof Highlight === "undefined") return; ++ sharedHighlights = { ++ match: new Highlight(), ++ focus: new Highlight() ++ }; ++ CSS.highlights.set("diffs-search-match", sharedHighlights.match); ++ CSS.highlights.set("diffs-search-focus", sharedHighlights.focus); ++ return sharedHighlights; ++} ++//#endregion ++export { CodeViewSearch, collectMatches }; ++ ++//# sourceMappingURL=CodeViewSearch.js.map diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +index 5e68e35..79dcd88 100644 --- a/dist/components/VirtualizedFile.d.ts +++ b/dist/components/VirtualizedFile.d.ts -@@ -49,7 +49,7 @@ +@@ -49,7 +49,7 @@ declare class VirtualizedFile extend setVisibility(visible: boolean): void; rerender(): void; syncGhostTextRows(): void; @@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF protected finalizeRender(): void; private updatePendingRender; diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +index d1f57cd..5908ad7 100644 --- a/dist/components/VirtualizedFile.js +++ b/dist/components/VirtualizedFile.js -@@ -23,6 +23,7 @@ +@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { heights: /* @__PURE__ */ new Map(), checkpoints: [], fileAnnotationHeight: 0, @@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil ghostTextRows: NO_GHOST_TEXT_ROWS }; pendingRender; -@@ -35,6 +36,8 @@ +@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { super(options, workerManager, isContainerManaged); this.virtualizer = virtualizer; this.metrics = metrics; @@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil } setMetrics(metrics, force = false) { const nextMetrics = computeVirtualFileMetrics(metrics); -@@ -86,10 +89,12 @@ +@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } @@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; -@@ -135,6 +140,13 @@ +@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; @@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { -@@ -358,12 +370,12 @@ +@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { codeView.instanceChanged(this, true); } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); } @@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil const file = this.getRenderedFile(); if (!this.isSimpleMode()) this.computeApproximateSize(true); else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/components/web-components.js b/dist/components/web-components.js +index 6e7b486..5b7290f 100644 +--- a/dist/components/web-components.js ++++ b/dist/components/web-components.js +@@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js"; + import style_default from "../style.js"; + import { getMeasuredScrollbarGutter } from "../utils/scrollbarGutter.js"; + //#region src/components/web-components.ts ++// `::highlight()` rules only style text in their own tree, so find's match colours ride ++// along with every container's shadow root. ++const SEARCH_HIGHLIGHT_CSS = `::highlight(diffs-search-match) { ++ background-color: var(--diffs-search-match-bg, light-dark(#ff963288, #ff963266)); ++} ++::highlight(diffs-search-focus) { ++ background-color: var(--diffs-search-focus-bg, color-mix(in lab, var(--diffs-modified-base) 45%, transparent)); ++}`; + if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") == null) { + let sheet; ++ let searchSheet; + class FileDiffContainer extends HTMLElement { + constructor() { + super(); +@@ -12,8 +21,10 @@ if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") + if (sheet == null) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(style_default); ++ searchSheet = new CSSStyleSheet(); ++ searchSheet.replaceSync(SEARCH_HIGHLIGHT_CSS); + } +- shadowRoot.adoptedStyleSheets = [sheet]; ++ shadowRoot.adoptedStyleSheets = [sheet, searchSheet]; + } + connectedCallback() { + const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" }); diff --git a/dist/editor/editor.js b/dist/editor/editor.js +index 8f04a6f..c203023 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -1899,7 +1899,11 @@ +@@ -1899,7 +1899,11 @@ var Editor = class { changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, documentLineCount: textDocument.lineCount }); @@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; +diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts +index 9542849..1c1a51f 100644 +--- a/dist/editor/searchPanel.d.ts ++++ b/dist/editor/searchPanel.d.ts +@@ -15,6 +15,8 @@ interface SearchPanelOptions { + containerElement: HTMLElement; + defaultQuery: string; + mode?: SearchPanelMode; ++ /** False keeps the panel in find mode, for hosts with nothing to replace into. */ ++ allowReplace?: boolean; + initialMatch?: MatchRange; + scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; + applyReplace: (edits: ResolvedTextEdit[]) => void; +diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js +index df1ba63..676751b 100644 +--- a/dist/editor/searchPanel.js ++++ b/dist/editor/searchPanel.js +@@ -12,7 +12,7 @@ var SearchPanelWidget = class { + #navigate; + #close; + constructor(options) { +- const { textDocument, containerElement, defaultQuery, mode = "find", initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; ++ const { textDocument, containerElement, defaultQuery, mode = "find", allowReplace = true, initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; + const searchParams = { + text: defaultQuery, + replaceText: "", +@@ -278,7 +278,7 @@ var SearchPanelWidget = class { + ] + }); + const applyMode = (next) => { +- gridElement.dataset.mode = next; ++ gridElement.dataset.mode = allowReplace ? next : "find"; + this.#inputElement.focus(); + this.#inputElement.select(); + }; diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +index 3efafc4..9e5b751 100644 --- a/dist/managers/ResizeManager.d.ts +++ b/dist/managers/ResizeManager.d.ts -@@ -5,6 +5,8 @@ +@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions { columnVariables?: ResizeManagerColumnVariableMode; } declare class ResizeManager { @@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts private static managersByElement; private static getResizeObserver; diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +index 9724cb1..14145d8 100644 --- a/dist/managers/ResizeManager.js +++ b/dist/managers/ResizeManager.js -@@ -19,6 +19,7 @@ +@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager { for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); } observedNodes = /* @__PURE__ */ new Map(); @@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js setup(pre, { disableAnnotations, columnVariables = "apply" }) { const annotationUpdates = /* @__PURE__ */ new Set(); const applyColumnVariables = columnVariables === "apply"; -@@ -212,6 +213,7 @@ +@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager { this.applyAnnotationUpdates(annotationUpdates); annotationUpdates.clear(); this.applyColumnUpdates(codeUpdates); @@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js codeUpdates.clear(); } applyAnnotationUpdates(annotationUpdates) { +diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts +index e78e713..642b235 100644 +--- a/dist/react/CodeView.d.ts ++++ b/dist/react/CodeView.d.ts +@@ -56,6 +56,8 @@ interface CodeViewBaseProps { + * is frozen; re-key the accepted value in place before accepting. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** Unfold a collapsed item that holds the find match being navigated to. */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + renderCustomHeader?(item: CodeViewItem): ReactNode; + renderHeaderPrefix?(item: CodeViewItem): ReactNode; + renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; +diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js +index 84c7915..d1930bf 100644 +--- a/dist/react/CodeView.js ++++ b/dist/react/CodeView.js +@@ -24,7 +24,7 @@ function createDefaultCache(controlled) { + }; + } + function CodeViewInner(props, ref) { +- const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; ++ const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onRevealSearchMatch, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; + const controlled = controlledItems !== void 0; + const contextCreateEditor = useCreateEditor(); + const poolManager = useContext(WorkerPoolContext); +@@ -52,6 +52,9 @@ function CodeViewInner(props, ref) { + onItemEditChange?.(event, item); + }); + const emitItemEditComplete = useStableCallback((event, item, nextItem) => onItemEditComplete?.(event, item, nextItem) ?? "reject"); ++ const emitRevealSearchMatch = useStableCallback((item) => { ++ onRevealSearchMatch?.(item); ++ }); + const managedOptions = useMemo(() => createManagedCodeViewOptions({ + options, + hasCustomHeader, +@@ -63,13 +66,15 @@ function CodeViewInner(props, ref) { + getEditStateKey, + createEditor: contextCreateEditor != null ? createEditor : void 0, + onItemEditChange: onItemEditChange != null ? emitItemEditChange : void 0, +- onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0 ++ onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0, ++ onRevealSearchMatch: onRevealSearchMatch != null ? emitRevealSearchMatch : void 0 + }), [ + contextCreateEditor, + controlledSelection, + createEditor, + emitItemEditChange, + emitItemEditComplete, ++ emitRevealSearchMatch, + emitSelectedLinesChange, + hasCodeViewFooter, + hasCodeViewHeader, +@@ -78,6 +83,7 @@ function CodeViewInner(props, ref) { + getEditStateKey, + onItemEditChange, + onItemEditComplete, ++ onRevealSearchMatch, + onSelectedLinesChange, + options + ]); +@@ -302,7 +308,7 @@ function createSlotContentStore() { + } + }; + } +-function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete }) { ++function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, onRevealSearchMatch }) { + const managedOptions = { + ...options, + controlledSelection, +@@ -312,6 +318,7 @@ function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRende + if (getEditStateKey != null) managedOptions.getEditStateKey = getEditStateKey; + if (onItemEditChange != null) managedOptions.onItemEditChange = onItemEditChange; + if (onItemEditComplete != null) managedOptions.onItemEditComplete = onItemEditComplete; ++ if (onRevealSearchMatch != null) managedOptions.onRevealSearchMatch = onRevealSearchMatch; + if (hasCustomHeader) managedOptions.renderCustomHeader = noopRender; + if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender; + if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender; diff --git a/package.json b/package.json +index d9dc7b5..3c37bae 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4455564018677..8a54dc0d1fa25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 + '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -276,7 +276,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -665,7 +665,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -14913,7 +14913,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -14928,7 +14928,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From 08b1222d0bae670433c275ec70165c7b5048726f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:24 -0700 Subject: [PATCH 075/680] refactor(server): GitHub services are named for the API they call, not gh (#16967) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/scripts/measure-pr-preview.ts | 4 +- apps/server/src/git/GitManager.test.ts | 79 +++-- apps/server/src/git/GitManager.ts | 2 +- .../ThreadPullRequestService.ts | 2 +- ...i.test.ts => GitHubPullRequestApi.test.ts} | 272 +++++++++--------- ...lRequestCli.ts => GitHubPullRequestApi.ts} | 159 +++++----- .../GitHubPullRequestProvider.test.ts | 54 ++-- .../pullRequest/GitHubPullRequestProvider.ts | 12 +- .../PullRequestProviderRegistry.ts | 6 +- .../src/pullRequest/PullRequestService.ts | 2 +- .../pullRequest/gitHubConditionalChecks.ts | 4 +- apps/server/src/server.ts | 6 +- ...li.test.ts => GitHubRepositoryApi.test.ts} | 64 +++-- .../{GitHubCli.ts => GitHubRepositoryApi.ts} | 141 +++++---- .../GitHubSourceControlProvider.test.ts | 26 +- .../GitHubSourceControlProvider.ts | 14 +- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 6 +- apps/server/src/ws.ts | 4 +- 20 files changed, 410 insertions(+), 455 deletions(-) rename apps/server/src/pullRequest/{GitHubPullRequestCli.test.ts => GitHubPullRequestApi.test.ts} (94%) rename apps/server/src/pullRequest/{GitHubPullRequestCli.ts => GitHubPullRequestApi.ts} (96%) rename apps/server/src/sourceControl/{GitHubCli.test.ts => GitHubRepositoryApi.test.ts} (91%) rename apps/server/src/sourceControl/{GitHubCli.ts => GitHubRepositoryApi.ts} (90%) diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 414bd99d07459..a8d2f9d986d1c 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index c4a688b1aa7a9..f1fc491b1a2bb 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; -import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; @@ -73,7 +73,7 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe( +const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 61dd000633ef3..f30af2c23c5b5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -42,7 +42,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -92,7 +92,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCli.GitHubCliError; + failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary( + raw: unknown, +): GitHubRepositoryApi.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -381,8 +383,8 @@ function createTextGeneration( }; } -function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCli["Service"]; +function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - GitHubCli.isGitHubCliError(error) + GitHubRepositoryApi.isGitHubRepositoryApiError(error) ? error - : new GitHubCli.GitHubCliCommandError({ - command: "gh", + : new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: error, }), @@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository lookup: ${repository}`), }), @@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), @@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter( + (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + ), ), ), createPullRequest: (input) => @@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + Effect.map( + (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + ), ), getRepositoryCloneUrls: (input) => execute({ @@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository create: ${input.repository}`), }), @@ -691,7 +693,9 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + input?.ghScenario, + ); const textGeneration = createTextGeneration(input?.textGeneration); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", @@ -740,7 +744,7 @@ function makeManager(input?: { ), Effect.provide( Layer.merge( - Layer.succeed(GitHubCli.GitHubCli, gitHubCli), + Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), Layer.mock(GitHubApi.GitHubApi)({}), ), ), @@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliRateLimitError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ cwd: repoDir, cause: new Error(upstreamCause), }), @@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { errorTag: "SourceControlProviderError", provider: "github", providerOperation: "listChangeRequests", - providerCommand: "gh", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); const loggedText = [ @@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ cwd: repoDir, cause: new Error("gh is not authenticated"), }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cbc0490a95c1d..e9b244dfbe86d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1286,7 +1286,7 @@ export const make = Effect.gen(function* () { ? { provider: error.provider, providerOperation: error.operation, - providerCommand: error.command ?? "unknown", + ...(error.command === undefined ? {} : { providerCommand: error.command }), errorDetail: error.detail, } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 072bf930c038e..c1e0cff2658f6 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one + // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts similarity index 94% rename from apps/server/src/pullRequest/GitHubPullRequestCli.test.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 28aca8b6a9b37..c42bf6d147bfc 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,13 +12,13 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -96,7 +96,7 @@ const mockApi = Layer.effect( ); const layer = it.layer( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), @@ -489,7 +489,7 @@ it.effect( }), ), ); - const cli = yield* GitHubPullRequestCli.make.pipe( + const cli = yield* GitHubPullRequestApi.make.pipe( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), @@ -537,7 +537,7 @@ it.effect( }), ); -layer("GitHubPullRequestCli.layer", (it) => { +layer("GitHubPullRequestApi.layer", (it) => { it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; @@ -569,7 +569,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -623,7 +623,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const preview = yield* cli.getPullRequestPreview({ cwd: "/w", repository: "acme/web", @@ -653,7 +653,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockImplementation(() => Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const results = yield* Effect.all( Array.from({ length: 4 }, () => cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), @@ -683,7 +683,7 @@ layer("GitHubPullRequestCli.layer", (it) => { return output('{"id":123,"login":"viewer"}'); }), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cancel.test" }; const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Deferred.await(firstStarted); @@ -727,7 +727,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -795,7 +795,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -855,7 +855,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = yield* cli .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) @@ -895,7 +895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -943,7 +943,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", repository: "acme/web", @@ -963,7 +963,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads an empty stacks listing as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -986,7 +986,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -1009,7 +1009,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1032,7 +1032,7 @@ layer("GitHubPullRequestCli.layer", (it) => { status: 503, }); mockedExecute.mockReturnValueOnce(Effect.fail(failure)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ cwd: "/w", @@ -1048,7 +1048,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports a stacks answer it cannot read against the stack read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1068,7 +1068,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1091,7 +1091,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports truncation from the extra row, counted before decoding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1117,7 +1117,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1143,7 +1143,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1168,7 +1168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1189,7 +1189,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries every repository and every qualifier into one search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1218,7 +1218,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("narrows a search to the author, and to merged on the merged tab", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1240,7 +1240,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("keeps a searched-for qualifier inside the phrase", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1263,7 +1263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to search for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.searchPullRequests({ @@ -1295,7 +1295,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ]), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.searchPullRequests({ cwd: "/w", @@ -1341,7 +1341,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = () => cli.searchPullRequests({ cwd: "/w", @@ -1390,7 +1390,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1443,7 +1443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1469,7 +1469,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -1499,7 +1499,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stats = yield* cli.listPullRequestStats({ cwd: "/w", @@ -1521,7 +1521,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to look up counts for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.listPullRequestStats({ @@ -1545,7 +1545,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1575,7 +1575,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1603,7 +1603,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1641,7 +1641,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1667,7 +1667,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1701,7 +1701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1732,7 +1732,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1759,7 +1759,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1784,7 +1784,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries the further narrowings into a batched search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1813,7 +1813,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1843,7 +1843,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1873,7 +1873,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1895,7 +1895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries on from the instant the last slice ended on", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1923,7 +1923,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // answer means the text matched nothing, and listing everything instead would fill the // page with rows the reader did not search for. mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1948,7 +1948,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1984,7 +1984,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2020,7 +2020,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2064,7 +2064,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2094,7 +2094,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2139,7 +2139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("updates a stale branch with a merge commit unless asked to rebase", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2168,7 +2168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2199,7 +2199,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2243,7 +2243,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", mergeMessage(body, queued)], ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2269,7 +2269,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2294,7 +2294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("does not fetch a message for rebase merges", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2317,7 +2317,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const result = yield* Effect.result( cli.runPullRequestAction({ cwd: "/w", @@ -2336,7 +2336,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("arms auto-merge with the same strategy a merge would have used", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2366,7 +2366,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2387,7 +2387,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes auto-merge back off without naming a strategy", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2405,7 +2405,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("opens a pull request that reverts a merged pull request", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2441,7 +2441,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2458,7 +2458,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("finds and approves every workflow waiting on a maintainer", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2490,7 +2490,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli .listWorkflowRunsRequiringApproval({ @@ -2532,7 +2532,7 @@ layer("GitHubPullRequestCli.layer", (it) => { heads([7]), workflowRuns([10]), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2568,7 +2568,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.as(output(encodeJson(workflowRuns([10])))), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli.listWorkflowRunsRequiringApproval({ cwd: "/w", @@ -2588,7 +2588,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2636,7 +2636,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2670,7 +2670,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2701,7 +2701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("returns a pull request to draft by converting it", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2719,7 +2719,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a comment body as a variable, never inside the document", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.commentOnPullRequest({ cwd: "/w", @@ -2743,7 +2743,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2777,7 +2777,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listReviewThreadComments({ cwd: "/w", @@ -2794,7 +2794,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2823,7 +2823,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // GitHub answers 406 rather than a diff past 300 changed files. mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2846,7 +2846,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2866,7 +2866,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; const first = yield* cli.getPullRequestDiff(target); @@ -2884,7 +2884,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a cursor it never handed out rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2906,7 +2906,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2933,7 +2933,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", @@ -2957,7 +2957,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a commit that is not a sha rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2980,7 +2980,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3003,7 +3003,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports unusable diff revisions as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3030,7 +3030,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3058,7 +3058,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3084,7 +3084,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3103,7 +3103,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("ends the diff on a page with no files rather than asking for it again", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3122,7 +3122,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3139,7 +3139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("skips the avatar lookup when a listing named nobody", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3172,7 +3172,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3190,7 +3190,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails when the authenticated account has no login", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); @@ -3203,7 +3203,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); @@ -3215,7 +3215,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reuses verified credentials offline and refuses an unverified replacement", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cache.test" }; mockedCredential.mockImplementation((host) => Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), @@ -3263,7 +3263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a whole review as one request", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.submitReview({ cwd: "/w", @@ -3294,7 +3294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a reply body as a variable, never inside the document", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.replyToReviewThread({ cwd: "/w", @@ -3314,7 +3314,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("resolves and unresolves through the mutation each one needs", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewThreadResolution({ cwd: "/w", @@ -3343,7 +3343,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3379,7 +3379,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.setReaction({ @@ -3402,7 +3402,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("looks up the pull request's own node id when no subject was given", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ @@ -3423,7 +3423,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a reaction back through the remove mutation", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3443,7 +3443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites only the words a request named", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (fields: { readonly title?: string; readonly body?: string }) => cli.updatePullRequest({ cwd: "/w", @@ -3470,7 +3470,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites a remark through the mutation its kind needs", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (kind: "issue-comment" | "review-comment") => cli.updateComment({ cwd: "/w", @@ -3512,7 +3512,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.updateComment({ @@ -3537,7 +3537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails the read when gh returns something unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ @@ -3586,7 +3586,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3664,7 +3664,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3720,7 +3720,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3768,7 +3768,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ cwd: "/w", @@ -3802,7 +3802,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3822,7 +3822,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3847,7 +3847,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const slice = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3872,7 +3872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3902,7 +3902,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3929,7 +3929,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3954,7 +3954,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const page = yield* cli.getReviewThreadComments({ cwd: "/w", @@ -3983,7 +3983,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getReviewThreadComments({ @@ -4021,7 +4021,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const access = yield* cli.getViewerAccess({ cwd: "/w", @@ -4069,7 +4069,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -4134,7 +4134,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.getViewerAccess({ cwd: "/w", @@ -4165,7 +4165,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks GitHub to review, naming the collection a request is added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4191,7 +4191,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a request back by deleting from the same collection it was added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4233,7 +4233,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const list = yield* cli.listReviewerCandidates({ cwd: "/w", @@ -4256,7 +4256,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("puts labels on by posting to the issue's own collection, all at once", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4280,7 +4280,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes labels off one at a time, naming each in the path encoded", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4329,7 +4329,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce(page(0, true)) .mockReturnValueOnce(page(1, true)) .mockReturnValueOnce(page(2, false)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4377,7 +4377,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4401,7 +4401,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4428,7 +4428,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks the host nothing when nothing was pressed", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4451,7 +4451,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; yield* cli.setPullRequestFilesViewed({ @@ -4485,7 +4485,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const write = () => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4522,7 +4522,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const tick = (number: number) => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4534,7 +4534,7 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* tick(HOT); // A cache's worth of cold pull requests, with the open one pressed in between each of them. - for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { + for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { yield* tick(HOT + 1 + filled); yield* tick(HOT); } diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts similarity index 96% rename from apps/server/src/pullRequest/GitHubPullRequestCli.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.ts index ce944b931aa86..116a8a742d1a7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,7 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; @@ -138,7 +138,6 @@ import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullReq export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubPullRequestReadError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, cause: Schema.Defect(), @@ -149,11 +148,10 @@ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubViewerLoginUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -162,11 +160,10 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( "GitHubPullRequestUpdatedAtUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, repository: Schema.String, number: Schema.Int, @@ -181,7 +178,6 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCursorError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -194,7 +190,6 @@ export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCommitError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -207,7 +202,6 @@ export class GitHubDiffCommitError extends Schema.TaggedError()( "GitHubDiffRevisionsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, commit: Schema.optional(Schema.String), @@ -224,7 +218,6 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( "GitHubDiffFileContentsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, path: Schema.String, reason: Schema.Literals(["oversized", "binary"]), @@ -246,7 +239,6 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( "GitHubRepositorySelectorError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -260,7 +252,6 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError()( "GitHubSubjectScopeError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -274,7 +265,6 @@ export class GitHubSubjectScopeError extends Schema.TaggedError()( "GitHubWorkflowApprovalRefusedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), @@ -297,7 +287,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -311,7 +300,6 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadChangedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -321,7 +309,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError {} class PullRequestWatchFingerprintRead extends Request.Class< @@ -435,7 +423,7 @@ class PullRequestWatchFingerprintRead extends Request.Class< readonly number: number; }, GitHubPullRequestWatchFingerprint | null, - GitHubPullRequestCliError + GitHubPullRequestApiError > {} export interface GitHubPullRequestSearchBatch { @@ -460,8 +448,8 @@ export interface GitHubPullRequestFilesViewed { readonly truncated: boolean; } -export class GitHubPullRequestCli extends Context.Service< - GitHubPullRequestCli, +export class GitHubPullRequestApi extends Context.Service< + GitHubPullRequestApi, { readonly withVerifiedCredential: ( input: { readonly cwd: string; readonly host: string }, @@ -470,18 +458,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly viewer: string; readonly credentialFingerprint: string; }) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; readonly getRoutingIdentity: (input: { readonly cwd: string; readonly host: string; }) => Effect.Effect< { readonly accountId: string; readonly viewer: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly getViewerLogin: (input: { readonly cwd: string; readonly host: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; @@ -497,7 +485,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor?: ProviderListCursor | undefined; /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** * The same listing for a whole host in one search. `limit` is the size of the slice across @@ -516,7 +504,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** The line counts the search leaves out, for rows already on the page. */ readonly listPullRequestStats: (input: { @@ -526,14 +514,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly number: number; }>; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getPullRequestSummary: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * What a watch compares between passes, batched like summaries. Null when GitHub gave no * answer for this pull request, so the watch reads it in full instead. @@ -543,7 +531,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly revalidateChecks: Effect.Success; @@ -552,7 +540,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestPreview: (input: { readonly cwd: string; @@ -561,7 +549,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect< Omit, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly listWorkflowRunsRequiringApproval: (input: { @@ -573,7 +561,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly headBranch: string; readonly headRepositoryOwner: string; readonly isCrossRepository: true; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; /** * The host-native stack this pull request is in, or null when it is in none — which is also @@ -585,14 +573,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiff: (input: { readonly cwd: string; @@ -603,7 +591,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor?: string | undefined; /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiffFileContents: (input: { readonly cwd: string; @@ -616,7 +604,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly newPath: string; }) => Effect.Effect< { readonly oldContents: string; readonly newContents: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; /** @@ -628,7 +616,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, @@ -640,14 +628,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; - }) => Effect.Effect; + }) => Effect.Effect; readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ readonly listActorAvatars: (input: { @@ -655,7 +643,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly ids: ReadonlyArray; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getReviewThreadComments: (input: { readonly cwd: string; @@ -664,7 +652,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly threadId: string; readonly cursor: string; - }) => Effect.Effect; + }) => Effect.Effect; /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { @@ -674,7 +662,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; /** Manual action checks may use the quota held back from automatic reads. */ readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** Who this pull request may be sent to, and who it has already been sent to. */ readonly listReviewerCandidates: (input: { @@ -682,7 +670,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewerRequest: (input: { readonly cwd: string; @@ -695,7 +683,7 @@ export class GitHubPullRequestCli extends Context.Service< }>; /** False deletes the same collection a request posts to, which takes the request back. */ readonly requested: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** The repository's labels, and which of them this pull request already wears. */ readonly listLabelCandidates: (input: { @@ -703,7 +691,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setLabels: (input: { readonly cwd: string; @@ -713,7 +701,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly labels: ReadonlyArray; /** False takes each label off; true adds each to whatever is already there. */ readonly applied: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; @@ -726,7 +714,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly removeAgentCreditsOnMerge?: boolean; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; - }) => Effect.Effect; + }) => Effect.Effect; readonly commentOnPullRequest: (input: { readonly cwd: string; @@ -734,7 +722,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly submitReview: (input: { readonly cwd: string; @@ -744,7 +732,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray; - }) => Effect.Effect; + }) => Effect.Effect; readonly replyToReviewThread: (input: { readonly cwd: string; @@ -752,7 +740,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewThreadResolution: (input: { readonly cwd: string; @@ -760,7 +748,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly resolved: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls @@ -776,7 +764,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly subjectId?: string | undefined; readonly content: PullRequestReactionContent; readonly reacted: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ readonly updatePullRequest: (input: { @@ -786,7 +774,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed @@ -801,9 +789,9 @@ export class GitHubPullRequestCli extends Context.Service< readonly commentId: string; readonly kind: "issue-comment" | "review-comment"; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/pullRequest/GitHubPullRequestCli") {} +>()("t3/pullRequest/GitHubPullRequestApi") {} /** * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. @@ -1134,10 +1122,9 @@ export const make = Effect.gen(function* () { }), ), ); - const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( + const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( function* (input: { readonly cwd: string; readonly host: string }) { - const unavailable = () => - new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); + const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); // A missing or signed-out credential keeps its own error, so the page can say which. const { token, fingerprint: key } = yield* api.credential(host); @@ -1183,7 +1170,7 @@ export const make = Effect.gen(function* () { ); }, ); - const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( + const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( input, use, ) => @@ -1199,7 +1186,7 @@ export const make = Effect.gen(function* () { ), ), ); - const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => + const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => captureVerifiedCredential(input).pipe( Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), ); @@ -1219,7 +1206,7 @@ export const make = Effect.gen(function* () { readonly host: string; readonly number: number; readonly operation: string; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const key = `${input.host} ${owner}/${name} ${input.number}`; const held = nodeIds.get(key); @@ -1287,7 +1274,7 @@ export const make = Effect.gen(function* () { }) => api.graphql(input).pipe(Effect.asVoid); const readError = (cwd: string, operation: string, cause: unknown) => - new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); + new GitHubPullRequestReadError({ cwd, operation, cause }); /** Decodes an answer, reporting a failure against the read that made it. */ const decodeWith = ( @@ -1311,7 +1298,7 @@ export const make = Effect.gen(function* () { readonly variables?: Readonly>; readonly query: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .graphql({ host: input.host, @@ -1329,7 +1316,7 @@ export const make = Effect.gen(function* () { readonly operation: string; readonly path: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .rest({ host: input.host, operation: input.operation, path: input.path }) .pipe( @@ -1355,7 +1342,7 @@ export const make = Effect.gen(function* () { readonly number: number; readonly page: number; readonly commit?: string | undefined; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; return api @@ -1407,11 +1394,11 @@ export const make = Effect.gen(function* () { ); }; - const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = + const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = (input) => Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); } const { owner, name } = parseRepositorySelector(input.repository); const refsResponse = yield* api.rest({ @@ -1444,7 +1431,6 @@ export const make = Effect.gen(function* () { !isCommitSha(headRef) ) { return yield* new GitHubDiffRevisionsUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, ...(input.commit === undefined ? {} : { commit: input.commit }), @@ -1469,7 +1455,6 @@ export const make = Effect.gen(function* () { response.truncated || response.body.includes("\0") || response.invalidUtf8 ? Effect.fail( new GitHubDiffFileContentsUnavailableError({ - command: "gh", cwd: input.cwd, path: filePath, reason: response.truncated ? "oversized" : "binary", @@ -1497,7 +1482,7 @@ export const make = Effect.gen(function* () { * mixing two revisions' checks. */ const readAllCheckContexts = ( - input: Parameters[0], + input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { @@ -1534,7 +1519,7 @@ export const make = Effect.gen(function* () { return { headSha, contexts, truncated: after !== null }; }); - const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); return AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => @@ -1659,7 +1644,7 @@ export const make = Effect.gen(function* () { return runs; }); - const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = + const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = (input) => Effect.all( [ @@ -1681,7 +1666,6 @@ export const make = Effect.gen(function* () { if (heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-list-truncated", @@ -1693,7 +1677,6 @@ export const make = Effect.gen(function* () { if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-not-unique", @@ -1711,7 +1694,6 @@ export const make = Effect.gen(function* () { runs.length > workflowApprovalLimit ? Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "run-list-truncated", @@ -1830,7 +1812,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => Effect.request(new PullRequestSummaryRead(input), summaryResolver); // Every watched pull request on a host in one read per pass. A pull request the batch has no @@ -1884,10 +1866,10 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestWatchFingerprint: GitHubPullRequestCli["Service"]["getPullRequestWatchFingerprint"] = + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); - return GitHubPullRequestCli.of({ + return GitHubPullRequestApi.of({ withVerifiedCredential, revalidateChecks, getRoutingIdentity, @@ -1901,7 +1883,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequests", }), @@ -1913,7 +1894,7 @@ export const make = Effect.gen(function* () { page: ( after: string | null, rows: number, - ) => Effect.Effect, + ) => Effect.Effect, ) => Effect.gen(function* () { const items: GitHubPullRequestListItem[] = []; @@ -1930,7 +1911,7 @@ export const make = Effect.gen(function* () { const read = ( continues: boolean, requestedRows = input.limit + 1, - ): Effect.Effect => + ): Effect.Effect => collect(requestedRows, (after, rows) => continues ? graphqlRead({ @@ -2047,7 +2028,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "searchPullRequests", }), @@ -2085,7 +2065,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequestStats", }), @@ -2214,14 +2193,14 @@ export const make = Effect.gen(function* () { ...(input.commit === undefined ? {} : { commit: input.commit }), }); if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); + return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); } // A cursor only ever comes from the files walk, so a reader carrying one is already past // the point where `gh pr diff` had anything to say. if (input.cursor !== undefined) { const page = diffCursorPage(input.cursor); return page === null - ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) + ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) : filesPage(page); } // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. @@ -2283,7 +2262,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed({ comments, nextCursor }) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "getReviewThreadComments", }), @@ -2297,7 +2275,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const threadPage = ( cursor: string | null, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2522,7 +2500,6 @@ export const make = Effect.gen(function* () { if (detail.headSha == null || detail.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2547,7 +2524,6 @@ export const make = Effect.gen(function* () { if (current.headSha == null || current.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2562,7 +2538,6 @@ export const make = Effect.gen(function* () { ) { return Effect.fail( new GitHubWorkflowApprovalHeadChangedError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2737,7 +2712,7 @@ export const make = Effect.gen(function* () { after: string | null, collected: ReadonlyArray, pagesLeft: number, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2799,7 +2774,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(givenSubjectId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "setReaction", }), @@ -2850,7 +2824,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(input.commentId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "updateComment", }), @@ -2871,4 +2844,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubPullRequestCli, make); +export const layer = Layer.effect(GitHubPullRequestApi, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 88079ebb94c7f..f00cbfaf5d324 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa let operations = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, withVerifiedCredential: (_input, use) => verificationFails ? Effect.fail( - new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ - command: "gh", + new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ cwd: "/w", }), ) @@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () => ); const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.sync(() => { @@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => let summaryReads = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestSummary: () => Effect.sync(() => { @@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through" }; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), }), @@ -189,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () => Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestStack", cause: new Error("unreadable"), @@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => { } }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -541,7 +539,7 @@ it.effect( Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ getPullRequestDetail: () => Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), listWorkflowRunsRequiringApproval: () => @@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", expect(detail.checks).toEqual([]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), listWorkflowRunsRequiringApproval: () => @@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => Effect.fail( - new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ - command: "gh", + new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ cwd: "/w", number: 7, reason: "head-not-unique", @@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () => expect(error.reason).toBe("rate-limited"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => @@ -713,7 +710,7 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.die("Unexpected detail read"), getViewerAccess: () => Effect.sync(() => (accessReads++, access)), @@ -723,13 +720,13 @@ describe("getViewerPermissions", () => { }); const layerWithDetail = ( - detail: Effect.Effect, + detail: Effect.Effect, onDetail: (allowReserve: boolean) => void = () => {}, ) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubCli.AllowGitHubReserve.pipe( + GitHubRepositoryApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), @@ -789,12 +786,11 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestDetail", cause: new Error("head changed"), @@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => { }; const layerWith = (commits: GitHubReviewThreadComments["commits"]) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ @@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { viewer: { canUpdate: true, didAuthor: false }, }; const layerFor = (body: string) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), @@ -1040,7 +1036,7 @@ describe("editing", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), updateComment: (input) => Effect.sync(() => void rewrites.push(input)), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 74c5852a48a49..5e6f242072994 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,8 +7,8 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( - error: GitHubPullRequestCli.GitHubPullRequestCliError, + error: GitHubPullRequestApi.GitHubPullRequestApiError, ): PullRequestProviderFailure { switch (error._tag) { case "GitHubCliMissingError": @@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean => body.replace(//g, "").trim().length === 0; export const make = Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ provider: "github", operation, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubCli.AllowGitHubReserve, true), + Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 60de91d0e99af..efbe37f991eee 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; @@ -61,11 +61,11 @@ export const make = Effect.map( export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provide( GitHubApi.layer.pipe( Layer.provide(GitHubCredentials.layer), - // The same layers GitHubCli merges, so both share one budget and one pause per host. + // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 3da0b9ba2e03b..9e2b81d97d5f5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 8172bf544074c..6975da251936b 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -9,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; -import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; const HeadSchema = Schema.Struct({ @@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { Pick & { workflowApprovalsRequired?: number; }, - GitHubPullRequestCliError + GitHubPullRequestApiError >, ) => Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a437..fdd7d6c7165f4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubCli.layer), + Layer.provideMerge(GitHubRepositoryApi.layer), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts similarity index 91% rename from apps/server/src/sourceControl/GitHubCli.test.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.test.ts index 3954617008a58..688216211f1fd 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts @@ -11,7 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -51,7 +51,7 @@ const node = (number: number, headRefName: string, owner = "acme") => ({ }); /** - * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what + * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what * `git remote -v` prints; `git` records every driver call. */ function harness(input: { @@ -92,7 +92,10 @@ function harness(input: { args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), ), }); - const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + const layer = Layer.effect( + GitHubRepositoryApi.GitHubRepositoryApi, + GitHubRepositoryApi.make, + ).pipe( Layer.provide( Layer.mergeAll( driver, @@ -111,7 +114,7 @@ describe("selectGitHubBaseRepository", () => { .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) .join("\n"); const select = (input: { remotes: string; resolved?: string }) => - GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); it("picks the repository gh reads without a prompt", () => { assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { @@ -175,7 +178,7 @@ describe("selectGitHubBaseRepository", () => { }); }); -describe("GitHubCli repository resolution", () => { +describe("GitHubRepositoryApi repository resolution", () => { it.effect("reads the repository gh would pick from the remotes", () => { const paths: string[] = []; const { layer } = harness({ @@ -197,7 +200,7 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); }).pipe(Effect.provide(layer)); @@ -221,16 +224,19 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.getDefaultBranch({ cwd: "/repo" }); // A provider's host hint for the same alias (`github` here) resolves the same way. yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); assert.strictEqual( - GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), + GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), "github.example.com", ); - assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + assert.strictEqual( + GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), + null, + ); }).pipe(Effect.provide(layer)); }); @@ -240,15 +246,15 @@ describe("GitHubCli repository resolution", () => { api: {}, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubCliCommandError"); + assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); assert.include(String((error.cause as Error).message), "No GitHub repository"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.listPullRequestsByHead", () => { +describe("GitHubRepositoryApi.listPullRequestsByHead", () => { const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { @@ -271,7 +277,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookup = (headSelector: string) => gh .listPullRequestsByHead({ @@ -324,7 +330,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookups = yield* Effect.all( Array.from({ length: 26 }, (_, index) => gh.listPullRequestsByHead({ @@ -368,7 +374,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const open = yield* gh .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) .pipe(Effect.forkChild); @@ -397,24 +403,24 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const read = (headSelector: string) => gh .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) .pipe(Effect.flip, Effect.forkChild); const missing = yield* read("missing"); yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); + assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); const limited = yield* read("limited"); yield* TestClock.adjust("500 millis"); const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); assert.propertyVal(error, "retryAt", 123); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.getPullRequest", () => { +describe("GitHubRepositoryApi.getPullRequest", () => { it.effect("reads a pull request by number, and by URL on its own repository", () => { const variables: Array = []; const { layer } = harness({ @@ -428,7 +434,7 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); yield* gh.getPullRequest({ cwd: "/repo", @@ -449,14 +455,14 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli writes", () => { +describe("GitHubRepositoryApi writes", () => { it.effect("creates a cross-repository pull request with an owner:branch head", () => { const requests: Array = []; const { layer } = harness({ @@ -476,7 +482,7 @@ describe("GitHubCli writes", () => { const fs = yield* FileSystem.FileSystem; const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.createPullRequest({ cwd: "/repo", baseBranch: "main", @@ -515,7 +521,7 @@ describe("GitHubCli writes", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const urls = yield* gh.createRepository({ cwd: "/repo", repository: "acme/new", @@ -531,7 +537,7 @@ describe("GitHubCli writes", () => { }); }); -describe("GitHubCli.checkoutPullRequest", () => { +describe("GitHubRepositoryApi.checkoutPullRequest", () => { const repository = (fullName: string, defaultBranch = "main") => restResponse({ full_name: fullName, @@ -551,7 +557,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); assert.deepStrictEqual(git, [ [ @@ -588,7 +594,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); // Nothing touched the local branches: `main` must not be reset to the fork's commit. assert.deepStrictEqual(git, []); @@ -609,7 +615,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); assert.deepStrictEqual(git, [ [ @@ -632,7 +638,7 @@ describe("GitHubCli.checkoutPullRequest", () => { it("names the local branch the way gh pr checkout does", () => { const name = (headRefName: string, isCrossRepository: boolean) => - GitHubCli.pullRequestCheckoutBranchName({ + GitHubRepositoryApi.pullRequestCheckoutBranchName({ headRefName, headOwner: "someone", isCrossRepository, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts similarity index 90% rename from apps/server/src/sourceControl/GitHubCli.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.ts index d12e5f334febd..595d869d72275 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.ts @@ -33,24 +33,23 @@ import { export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; -const gitHubCliFailureFields = { - command: Schema.Literal("gh"), +const gitHubFailureFields = { cwd: Schema.String, cause: Schema.Defect(), } as const; -export class GitHubCliUnavailableError extends Schema.TaggedError()( - "GitHubCliUnavailableError", - gitHubCliFailureFields, +export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( + "GitHubRepositoryUnavailableError", + gitHubFailureFields, ) { override get message(): string { return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; } } -export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliAuthenticationError", - gitHubCliFailureFields, +export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( + "GitHubRepositoryAuthenticationError", + gitHubFailureFields, ) { override get message(): string { // A missing or turned-off credential already says what to do about it. @@ -60,9 +59,9 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliRateLimitError", - { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, +export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( + "GitHubRepositoryRateLimitError", + { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, ) { override get message(): string { return "GitHub API rate limit exceeded. Requests resume when the limit resets."; @@ -71,16 +70,16 @@ export class GitHubCliRateLimitError extends Schema.TaggedError()( "GitHubPullRequestNotFoundError", - gitHubCliFailureFields, + gitHubFailureFields, ) { override get message(): string { return "Pull request not found. Check the PR number or URL and try again."; } } -export class GitHubCliCommandError extends Schema.TaggedError()( - "GitHubCliCommandError", - { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, +export class GitHubRepositoryCommandError extends Schema.TaggedError()( + "GitHubRepositoryCommandError", + { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, ) { override get message(): string { // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. @@ -92,15 +91,14 @@ export class GitHubCliCommandError extends Schema.TaggedError()( "GitHubPullRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request list."; @@ -109,7 +107,7 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( "GitHubChangeRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid change request list."; @@ -118,7 +116,7 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( "GitHubPullRequestDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request."; @@ -127,53 +125,56 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError()( "GitHubRepositoryDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid repository."; } } -export const GitHubCliError = Schema.Union([ - GitHubCliUnavailableError, - GitHubCliAuthenticationError, - GitHubCliRateLimitError, +export const GitHubRepositoryApiError = Schema.Union([ + GitHubRepositoryUnavailableError, + GitHubRepositoryAuthenticationError, + GitHubRepositoryRateLimitError, GitHubPullRequestNotFoundError, - GitHubCliCommandError, + GitHubRepositoryCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, ]); -export type GitHubCliError = typeof GitHubCliError.Type; +export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; -export const isGitHubCliError = Schema.is(GitHubCliError); +export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); /** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { - const context = { command: "gh" as const, cwd, cause: error }; +function fromGitHubApiError( + cwd: string, + error: GitHubApi.GitHubApiError, +): GitHubRepositoryApiError { + const context = { cwd, cause: error }; switch (error._tag) { case "GitHubCliMissingError": - return new GitHubCliUnavailableError(context); + return new GitHubRepositoryUnavailableError(context); case "GitHubNotSignedInError": case "GitHubHostDisabledError": case "GitHubApiAuthenticationError": - return new GitHubCliAuthenticationError(context); + return new GitHubRepositoryAuthenticationError(context); case "GitHubCliFailedError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); case "GitHubApiRateLimitError": - return new GitHubCliRateLimitError({ + return new GitHubRepositoryRateLimitError({ ...context, ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }); case "SourceControlRateLimitPausedError": - return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); + return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); case "GitHubApiNotFoundError": return new GitHubPullRequestNotFoundError(context); case "GitHubApiResponseError": - return new GitHubCliCommandError({ ...context, httpStatus: error.status }); + return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); case "GitHubApiRequestError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); } } @@ -208,15 +209,15 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } -export class GitHubCli extends Context.Service< - GitHubCli, +export class GitHubRepositoryApi extends Context.Service< + GitHubRepositoryApi, { readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly limit?: number; readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; /** * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the @@ -230,24 +231,24 @@ export class GitHubCli extends Context.Service< readonly limit: number; /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; + }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; @@ -255,20 +256,20 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly force?: boolean; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/sourceControl/GitHubCli") {} +>()("t3/sourceControl/GitHubRepositoryApi") {} /** * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a @@ -385,7 +386,7 @@ class PullRequestsByHeadRead extends Request.Class< readonly allowReserve: boolean; }, ReadonlyArray, - GitHubCliError + GitHubRepositoryApiError > {} /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ @@ -549,7 +550,7 @@ export const make = Effect.gen(function* () { const gitRead = (cwd: string, args: ReadonlyArray) => process.run({ - operation: "GitHubCli.resolveRepository", + operation: "GitHubRepositoryApi.resolveRepository", command: "git", args, cwd, @@ -558,13 +559,13 @@ export const make = Effect.gen(function* () { }); const commandFailure = (cwd: string, detail: string) => - new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); + new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); /** * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else * the remote the caller resolved the provider from, else the best-ranked GitHub remote. */ - const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { + const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { readonly cwd: string; readonly host?: string | undefined; }) { @@ -623,7 +624,7 @@ export const make = Effect.gen(function* () { cwd: string, input: GitHubApi.GitHubGraphQlInput, decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubCliError, + onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, ) => api.graphql(input).pipe( Effect.mapError((error) => fromGitHubApiError(cwd, error)), @@ -646,7 +647,7 @@ export const make = Effect.gen(function* () { Pick >; readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubCliError; + readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; }) => { const query = buildPullRequestsByHeadQuery(input.lookups); return graphqlJson( @@ -668,7 +669,6 @@ export const make = Effect.gen(function* () { decoded.data.repository === null ? Effect.fail( new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The repository could not be read."), }), @@ -697,8 +697,7 @@ export const make = Effect.gen(function* () { locator: { host, owner, name }, lookups: entries.map((entry) => entry.request), allowReserve, - onDecodeFailure: (cause) => - new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), + onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), }).pipe( Effect.map((aliases) => { for (const [index, entry] of entries.entries()) { @@ -724,7 +723,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), ); - const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { + const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { readonly cwd: string; readonly headSelector: string; readonly state: PullRequestListState; @@ -759,7 +758,7 @@ export const make = Effect.gen(function* () { const toSummaries = (rows: ReadonlyArray) => rows.map(pullRequestSummary); - const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { + const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string | undefined; @@ -787,7 +786,6 @@ export const make = Effect.gen(function* () { }))[0]; if (found === undefined) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("No pull request has this head branch."), }); @@ -799,7 +797,7 @@ export const make = Effect.gen(function* () { ? parsed.locator : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); + new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); const decoded = yield* graphqlJson( input.cwd, { @@ -815,7 +813,6 @@ export const make = Effect.gen(function* () { const node = decoded.data.repository?.pullRequest; if (node == null) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The pull request does not exist."), }); @@ -825,7 +822,7 @@ export const make = Effect.gen(function* () { return record; }); - const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( + const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( cwd: string, locator: GitHubRepositoryLocator, ) { @@ -838,7 +835,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd, cause: decoded.failure, }); @@ -846,23 +842,23 @@ export const make = Effect.gen(function* () { return decoded.success; }); - const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( + const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( cwd: string, host: string, ) { const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { - return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); + return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); } return decoded.success.login; }); const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubCliCommandError({ command: "gh", cwd, cause }); + new GitHubRepositoryCommandError({ cwd, cause }); const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); + git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); /** * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a @@ -870,8 +866,8 @@ export const make = Effect.gen(function* () { * track the head. A head branch that is gone is read from the base's `refs/pull//head`. * An existing branch fast-forwards, or with `force` is reset to the pull request. */ - const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubCli.checkoutPullRequest", + const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( + "GitHubRepositoryApi.checkoutPullRequest", )(function* (input) { const reference = parsePullRequestReference(input.reference); const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); @@ -994,7 +990,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(gitFailure(input.cwd))); }); - return GitHubCli.of({ + return GitHubRepositoryApi.of({ listPullRequestsByHead: (input) => AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), @@ -1043,7 +1039,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd: input.cwd, cause: decoded.failure, }); @@ -1083,7 +1078,7 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make).pipe( +export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( Layer.provideMerge(GitHubApi.layer), Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubGraphQlBudget.layer), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index f3f7f31ed4e3b..89398b6347860 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -27,12 +27,15 @@ const processResult = ( }); function makeProvider( - github: Partial, + github: Partial, api: Partial = {}, ) { return GitHubSourceControlProvider.make.pipe( Effect.provide( - Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), + Layer.merge( + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), + Layer.mock(GitHubApi.GitHubApi)(api), + ), ), ); } @@ -87,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +it.effect("adds safe request context while retaining GitHub API causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", + const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ cwd: "/repo", cause: new Error("raw upstream detail that should remain in the cause"), }); @@ -109,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: error.provider, operation: error.operation, - command: error.command, cwd: error.cwd, reference: error.reference, detail: error.detail, @@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: "github", operation: "getChangeRequest", - command: "gh", cwd: "/repo", reference: "https://github.com/pingdotgg/t3code/pull/42", detail: "Pull request not found. Check the PR number or URL and try again.", @@ -130,8 +130,9 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => it.effect("lists change request history through the batched head lookup", () => Effect.gen(function* () { - let lookup: Parameters[0] | null = - null; + let lookup: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ listPullRequestsByHead: (input) => { lookup = input; @@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () => it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cf06c6efdfcd0..a3b7a22f8d559 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ServerSettings from "../serverSettings.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () { }); export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; + const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = @@ -286,7 +286,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -325,7 +324,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -403,7 +401,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, @@ -428,7 +425,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -445,7 +441,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getRepositoryCloneUrls", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -462,7 +457,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createRepository", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -486,7 +480,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getDefaultBranch", - command: error.command, cwd: input.cwd, detail: error.message, cause: error, @@ -500,7 +493,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "checkoutChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 54de8cc14fde7..d9bbb51d7a616 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08751017b3513..c9a7164c8b310 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; + readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,7 +98,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index af3fc819a4c71..5f64ecb12f87e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), From a21ac5fb153e3bf418ccd2a4abef2787c241813c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 076/680] refactor(server): GitHub GraphQL batches use variables and share one pager (#16960) Co-authored-by: Claude Opus 5.5 (1M context) --- .../pullRequest/GitHubPullRequestApi.test.ts | 107 +++++-- .../src/pullRequest/GitHubPullRequestApi.ts | 268 +++++++++--------- .../pullRequest/gitHubPullRequestJson.test.ts | 55 ++-- .../src/pullRequest/gitHubPullRequestJson.ts | 165 ++++++----- .../src/pullRequest/githubStackActions.ts | 20 +- .../src/sourceControl/githubGraphQl.test.ts | 105 +++++++ .../server/src/sourceControl/githubGraphQl.ts | 100 +++++++ 7 files changed, 565 insertions(+), 255 deletions(-) create mode 100644 apps/server/src/sourceControl/githubGraphQl.test.ts create mode 100644 apps/server/src/sourceControl/githubGraphQl.ts diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index c42bf6d147bfc..64259e337e922 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -767,11 +767,15 @@ layer("GitHubPullRequestApi.layer", (it) => { ); assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - const document = queryAt(0); - expect(document).toContain( - 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + expect(queryAt(0)).toContain( + "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", ); - expect(document).toContain("pullRequest(number: 8)"); + expect(varsAt(0)).toMatchObject({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_number: 8, + }); }), ); @@ -815,10 +819,10 @@ layer("GitHubPullRequestApi.layer", (it) => { // GitHub had no answer for #8, so its watch reads it in full. expect(eight).toBeNull(); expect(mockedExecute).toHaveBeenCalledOnce(); - const call = callAt(0); - expect(call.kind === "graphql" ? call.query : "").toContain( - 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', + expect(queryAt(0)).toContain( + "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", ); + expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); }), ); @@ -1108,6 +1112,37 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => + Effect.gen(function* () { + // Two pages of 100 that hand back the same cursor, for a page of 250. + const repeating = output( + encodeJson({ + data: { + search: { + pageInfo: { hasNextPage: true, endCursor: "same" }, + nodes: rows(100, 1), + }, + }, + }), + ); + mockedExecute.mockReturnValue(Effect.succeed(repeating)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 250, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.isTrue(batch.truncated); + }), + ); + it.effect("excludes merged pull requests from the Closed tab", () => Effect.gen(function* () { mockedExecute.mockImplementation((call) => @@ -1408,11 +1443,12 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(batch.continues).toBe(false); expect(mockedStackMemberships).toHaveBeenCalledTimes(1); const membership = mockedStackMemberships.mock.calls[0]?.[0]; - const query = membership?.kind === "graphql" ? membership.query : ""; - expect(query).toContain("pullRequest(number: 4)"); - expect(query).toContain("pullRequest(number: 5)"); - expect(query).not.toContain("pullRequest(number: 1)"); - expect(query).not.toContain("pullRequest(number: 6)"); + const numbers = Object.entries( + membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, + ) + .filter(([name]) => name.endsWith("_number")) + .map(([, number]) => number); + expect(numbers).toEqual([4, 5]); }), ); @@ -1420,7 +1456,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); mockedStackMemberships.mockImplementation((input) => - input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") + input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) ? Effect.fail( new GitHubApi.GitHubApiResponseError({ host: "github.com", @@ -1513,9 +1549,8 @@ layer("GitHubPullRequestApi.layer", (it) => { { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, ]); - const document = queryAt(0); - expect(document).toContain('s0: repository(owner: "acme", name: "web")'); - expect(document).toContain("pullRequest(number: 25)"); + expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); + expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); }), ); @@ -2614,6 +2649,38 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("refuses workflow approval when the head list stops before its end", () => + Effect.gen(function* () { + // GitHub hands the same cursor back, so the second page is never read past. #7 looks + // unique on what was read, and an unread page could still hold another head like it. + const repeating = heads([7]); + repeating.data.repository.pullRequests.pageInfo = { + hasNextPage: true, + endCursor: "same", + } as never; + workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "head-list-truncated", + }); + }), + ); + it.effect("refuses workflow approval when GitHub omits the head repository", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -4386,9 +4453,9 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The same cursor twice is a page GitHub already gave, so reading stops there. + assert.strictEqual(mockedExecute.mock.calls.length, 2); assert.isTrue(viewed.truncated); - assert.strictEqual(viewed.files.length, 5); }), ); @@ -4420,8 +4487,8 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); expect(varsAt(1)).toEqual({ pullRequestId: "PR_1", - path0: "src/a.ts", - path1: "src/b.ts", + f0_path: "src/a.ts", + f1_path: "src/b.ts", }); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 116a8a742d1a7..59281964df4a6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -44,6 +44,7 @@ import { import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -64,7 +65,6 @@ import { PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, PULL_REQUEST_HEADS_GRAPHQL_QUERY, decodeWorkflowRunsJson, - type GitHubCheckContext, pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, @@ -1481,42 +1481,45 @@ export const make = Effect.gen(function* () { * its first hundred. Each page names the head it read, so a push mid-walk fails rather than * mixing two revisions' checks. */ + type CheckContextsPage = Result.Result.Success< + ReturnType + >; const readAllCheckContexts = ( input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const contexts: GitHubCheckContext[] = []; - let headSha: string | null = null; - let after: string | null = null; - for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { - const read: { - readonly headSha: string; - readonly contexts: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }); - if (headSha !== null && read.headSha !== headSha) { - return yield* readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ); - } - headSha = read.headSha; - contexts.push(...read.contexts); - after = read.nextCursor; - if (after === null) break; - } - return { headSha, contexts, truncated: after !== null }; + const { pages, truncated } = yield* readGraphQlPages( + (after, previous: ReadonlyArray) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }).pipe( + // Each page names the head it read, so a push mid-walk fails rather than mixing + // two revisions' checks. + Effect.filterOrFail( + (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + ), + { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, + ); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; }); const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { @@ -1568,7 +1571,10 @@ export const make = Effect.gen(function* () { const workflowApprovalReadError = (cwd: string, cause: unknown) => readError(cwd, "listWorkflowRunsRequiringApproval", cause); - /** Every open pull request whose head branch carries this name, up to one past the limit. */ + /** + * Every open pull request whose head branch carries this name, up to one past the limit. + * `truncated` says some were never read, which is never enough to call a head unique. + */ const listHeadsByBranch = (input: { readonly cwd: string; readonly repository: string; @@ -1577,25 +1583,25 @@ export const make = Effect.gen(function* () { }) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const heads: GitHubPullRequestHead[] = []; - let after: string | null = null; - do { - const page: { - readonly heads: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - variables: { owner, name, head: input.headBranch, after }, - query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decode: decodePullRequestHeadsJson, - }); - heads.push(...page.heads); - after = page.nextCursor; - } while (after !== null && heads.length <= workflowApprovalLimit); - return heads; + const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }), + { + nextCursor: (page) => page.nextCursor, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ); + return { heads: pages.flatMap((page) => page.heads), truncated }; }); /** @@ -1650,9 +1656,10 @@ export const make = Effect.gen(function* () { [ listHeadsByBranch(input).pipe( Effect.flatMap( - ( + ({ heads, - ): Effect.Effect< + truncated, + }): Effect.Effect< GitHubPullRequestHead, GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError > => { @@ -1663,7 +1670,7 @@ export const make = Effect.gen(function* () { pullRequest.headRepositoryOwner?.toLowerCase() === input.headRepositoryOwner.toLowerCase(), ); - if (heads.length > workflowApprovalLimit) { + if (truncated || heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ cwd: input.cwd, @@ -1755,18 +1762,18 @@ export const make = Effect.gen(function* () { ); // Stack membership rides along where GitHub serves stacks, so the background sync can skip // the REST stack read for pull requests that are in none. - const query = buildPullRequestSummariesGraphQlQuery( + const document = buildPullRequestSummariesGraphQlQuery( batchable.map((entry) => entry.request), first.request.host === "github.com", ); const batched = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestSummary", - query, + ...document, decode: decodePullRequestSummariesJson, }); return batched.pipe( @@ -1833,17 +1840,17 @@ export const make = Effect.gen(function* () { const batchable = entries.filter( (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, ); - const query = buildPullRequestWatchFingerprintsGraphQlQuery( + const document = buildPullRequestWatchFingerprintsGraphQlQuery( batchable.map((entry) => entry.request), ); const read = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestWatchFingerprint", - query, + ...document, decode: decodePullRequestWatchFingerprintsJson, }); return read.pipe( @@ -1897,16 +1904,22 @@ export const make = Effect.gen(function* () { ) => Effect.Effect, ) => Effect.gen(function* () { - const items: GitHubPullRequestListItem[] = []; - let rawCount = 0; - let after: string | null = null; - do { - const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); - items.push(...read.items); - rawCount += read.rawCount; - after = read.endCursor; - } while (after !== null && rawCount < rows); - return { items, rawCount }; + const countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after, pages: ReadonlyArray) => + page(after, rows - countRows(pages)), + { + nextCursor: (read) => read.endCursor, + until: (pages) => countRows(pages) >= rows, + }, + ); + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; }); const read = ( continues: boolean, @@ -1932,7 +1945,7 @@ export const make = Effect.gen(function* () { decode: decodePullRequestListJson, }), ).pipe( - Effect.flatMap(({ items: rawItems, rawCount }) => { + Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { const items = continues ? rawItems : rawItems.filter((item) => matchesUnsortedListing(item, input)); @@ -1949,9 +1962,11 @@ export const make = Effect.gen(function* () { items: items.slice(0, input.limit), // One row over the page size is the probe for a next page, and it is // counted before decoding: a skipped malformed row must not end paging. - truncated: continues - ? rawCount > input.limit - : items.length > input.limit || rawCount >= requestedRows, + truncated: + cutShort || + (continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows), continues, }); }), @@ -1989,16 +2004,16 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStackMembershipsGraphQlQuery( + const document = buildPullRequestStackMembershipsGraphQlQuery( input.repository, chunk.map((item) => item.number), ); - if (query === null) return Effect.succeed(chunk); + if (document === null) return Effect.succeed(chunk); return graphqlRead({ cwd: input.cwd, host: input.host, operation: "listPullRequestStackMemberships", - query, + ...document, decode: decodePullRequestStackMembershipsJson, }).pipe( Effect.map((memberships) => @@ -2061,8 +2076,8 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStatsGraphQlQuery(chunk); - if (query === null) { + const document = buildPullRequestStatsGraphQlQuery(chunk); + if (document === null) { return Effect.fail( new GitHubRepositorySelectorError({ cwd: input.cwd, @@ -2074,7 +2089,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listPullRequestStats", - query, + ...document, decode: decodePullRequestStatsJson, }).pipe( Effect.map((stats) => @@ -2328,23 +2343,26 @@ export const make = Effect.gen(function* () { // Almost never entered: the embedded page already holds every dismissal a pull request // ordinarily accrues. Followed so a review whose event fell past that page still finds // its reason. - let dismissalPage = 0; - while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { - const read: { - readonly dismissalsByReviewId: ReadonlyMap; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor: dismissalCursor }, - query: REVIEW_DISMISSALS_GRAPHQL_QUERY, - decode: decodeReviewDismissalsJson, - }); - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - dismissalCursor = read.nextCursor; - dismissalPage += 1; + if (dismissalCursor !== null) { + const { pages } = yield* readGraphQlPages( + (cursor) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }), + { + from: dismissalCursor, + maxPages: REVIEW_THREAD_PAGES, + nextCursor: (read) => read.nextCursor, + }, + ); + for (const read of pages) + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); } const reviewThreads = entries.map((entry) => ({ @@ -2708,44 +2726,34 @@ export const make = Effect.gen(function* () { getPullRequestFilesViewed: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - const read = ( - after: string | null, - collected: ReadonlyArray, - pagesLeft: number, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestFilesViewed", - variables: { owner, name, number: input.number, after }, - query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - decode: decodePullRequestFilesViewedJson, - }).pipe( - Effect.flatMap((page) => { - const files = [...collected, ...page.files]; - if (page.nextCursor === null) { - return Effect.succeed({ files, truncated: false }); - } - return pagesLeft <= 1 - ? Effect.succeed({ files, truncated: true }) - : read(page.nextCursor, files, pagesLeft - 1); + return readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: { owner, name, number: input.number, after }, + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, }), - ); - return read(null, [], FILES_VIEWED_MAX_PAGES); + { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, + ).pipe( + Effect.map(({ pages, truncated }) => ({ + files: pages.flatMap((page) => page.files), + truncated, + })), + ); }, setPullRequestFilesViewed: (input) => { - const mutation = buildSetFilesViewedGraphQlMutation(input.files); - if (mutation === null) return Effect.void; + if (input.files.length === 0) return Effect.void; return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "setPullRequestFilesViewed", - query: mutation.query, - variables: { pullRequestId, ...mutation.variables }, - }), - ), + Effect.flatMap((pullRequestId) => { + const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); + return mutation === null + ? Effect.void + : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); + }), ); }, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 3105634dc0875..06e25c6e4709a 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1754,36 +1754,40 @@ describe("decodePullRequestFilesViewedJson", () => { describe("buildSetFilesViewedGraphQlMutation", () => { it("asks for nothing when nothing was pressed", () => { - expect(buildSetFilesViewedGraphQlMutation([])).toBeNull(); + expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); }); it("clears and restores in one document, each file under its own alias", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: "src/a.ts", viewed: true }, { path: "src/b.ts", viewed: false }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).toContain( - "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)", + "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", ); expect(mutation.query).toContain( - "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })", + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", ); expect(mutation.query).toContain( - "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })", + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", ); - expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" }); + expect(mutation.variables).toEqual({ + pullRequestId: "PR_1", + f0_path: "src/a.ts", + f1_path: "src/b.ts", + }); }); it("keeps a path out of the document, so one cannot be read as part of it", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).not.toContain("evil"); - expect(mutation.variables.path0).toBe( + expect(mutation.variables.f0_path).toBe( '") { __typename } evil: markFileAsViewed(input: { path: "x', ); }); @@ -1922,9 +1926,14 @@ describe("pull request stack membership batches", () => { expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( - "pullRequest(number: 8)", - ); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_owner: "acme", + s1_name: "web", + s1_number: 8, + }); }); }); @@ -1982,10 +1991,10 @@ describe("batched pull request summaries", () => { it("reads stack membership only where the document asked for it", () => { expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, ).toContain("stack { number size baseRefName } stackEntry { position }"); expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, ).not.toContain("stack {"); const pullRequest = (number: number, stack: Record) => ({ pullRequest: { @@ -2019,16 +2028,22 @@ describe("batched pull request summaries", () => { describe("pull request watch fingerprints", () => { it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { - const query = buildPullRequestWatchFingerprintsGraphQlQuery([ + const document = buildPullRequestWatchFingerprintsGraphQlQuery([ { repository: "pingdotgg/t3code", number: 7 }, { repository: "pingdotgg/lakebed", number: 8 }, ]); - expect(query).toContain( - 'w0: repository(owner: "pingdotgg", name: "t3code") { pullRequest(number: 7)', - ); - expect(query).toContain( - 'w1: repository(owner: "pingdotgg", name: "lakebed") { pullRequest(number: 8)', - ); + expect(document?.query).toContain( + "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", + ); + expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); + expect(document?.variables).toEqual({ + w0_owner: "pingdotgg", + w0_name: "t3code", + w0_number: 7, + w1_owner: "pingdotgg", + w1_name: "lakebed", + w1_number: 8, + }); expect( buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), ).toBeNull(); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index ce89cf6fe44a2..028231c513f24 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -35,6 +35,7 @@ import type { import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** @@ -1917,53 +1918,81 @@ function toStackMembership( : undefined; } -/** What a repository selector may hold before it is written into a GraphQL document unquoted. */ +/** What GitHub allows in an owner or repository name. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; +/** + * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is + * one document, and a single unanswerable alias fails all of it, so these are refused up front. + */ +function repositoryParts( + repository: string, +): { readonly owner: string; readonly name: string } | null { + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; +} + +const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; + +/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ +function aliasedPullRequestsDocument( + name: string, + alias: string, + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, + selection: string, +): GraphQlDocument | null { + const addressed = changeRequests.map((changeRequest) => ({ + parts: repositoryParts(changeRequest.repository), + number: changeRequest.number, + })); + if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { + return null; + } + return aliasedGraphQlDocument({ + operation: "query", + name, + alias, + items: addressed, + variables: ({ parts, number }) => ({ + owner: ["String!", parts!.owner], + name: ["String!", parts!.name], + number: ["Int!", number], + }), + field: ({ owner, name, number }) => + `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, + }); +} + /** * The line counts for rows a listing already handed over, as one aliased lookup each. * * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a - * repository and a number — and never sees a node id. Owner, name and number are written into - * the document, so each is checked against what GitHub can actually name first: null for anything - * else, which the caller reports rather than sends. - * - * Null too for an empty request, since a GraphQL document with no selection is not a document. + * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, + * which the caller reports rather than sends, and for an empty request. */ export function buildPullRequestStatsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`, - ); - } - return `query {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStats", + "s", + changeRequests, + "additions deletions", + ); } /** Stack membership for the visible rows of a per-repository listing. */ export function buildPullRequestStackMembershipsGraphQlQuery( repository: string, numbers: ReadonlyArray, -): string | null { - if (numbers.length === 0) return null; - const [owner, name, ...rest] = repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - const selections: string[] = []; - for (const [index, number] of numbers.entries()) { - if (!Number.isSafeInteger(number) || number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, - ); - } - return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStackMemberships", + "s", + numbers.map((number) => ({ repository, number })), + STACK_MEMBERSHIP_SELECTION, + ); } const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); @@ -2024,26 +2053,17 @@ const PULL_REQUEST_SUMMARY_SELECTION = "latestReviews(first: 20) { nodes { state author { login } } } " + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; -/** - * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written - * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". - */ +/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestSummariesGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, includeStacks = false, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`, - ); - } - return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestSummaries", + "s", + changeRequests, + `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, + ); } /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ @@ -2170,19 +2190,13 @@ const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = /** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestWatchFingerprintsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` w${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_WATCH_FINGERPRINT_SELECTION} } }`, - ); - } - return `query PullRequestWatchFingerprints {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestWatchFingerprints", + "w", + changeRequests, + PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, + ); } /** @@ -3357,25 +3371,20 @@ export function decodePullRequestFilesViewedJson( * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each * take a single path, so the batching is done with aliases; top-level mutation fields run in * write order, so the last word about a path is the one that sticks. - * - * Paths travel as variables rather than interpolated into the document, since a path is data - * and a document is not. */ export function buildSetFilesViewedGraphQlMutation( + pullRequestId: string, files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, -): { readonly query: string; readonly variables: Readonly> } | null { - if (files.length === 0) return null; - const parameters = files.map((_, index) => `$path${index}: String!`).join(", "); - const fields = files - .map( - (file, index) => - ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`, - ) - .join("\n"); - return { - query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`, - variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])), - }; +): GraphQlDocument | null { + return aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: files, + shared: { pullRequestId: ["ID!", pullRequestId] }, + variables: (file) => ({ path: ["String!", file.path] }), + field: ({ path }, file) => + `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, + }); } /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 59d29627b81ba..c2e8fadb751dc 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -9,6 +9,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; import { cascadeRebaseStack } from "./githubStackRebase.ts"; @@ -192,13 +193,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* host: input.host, operation: "runGitHubStackAction", allowReserve: true, - variables: { owner, name }, - query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open - .map( - (layer) => - `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, - ) - .join(" ")}}}`, + // `open` is non-empty here, so there is always a document. + ...aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (layer) => layer.number, + items: open, + shared: { owner: ["String!", owner], name: ["String!", name] }, + variables: (layer) => ({ number: ["Int!", layer.number] }), + field: ({ number }) => + `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, + within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, + })!, }); const access = yield* decodeBranchAccess(permissions).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), diff --git a/apps/server/src/sourceControl/githubGraphQl.test.ts b/apps/server/src/sourceControl/githubGraphQl.test.ts new file mode 100644 index 0000000000000..9ab8f93544933 --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; + +describe("aliasedGraphQlDocument", () => { + it("keeps every value out of the document, each under its alias's own variable", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + name: "Lookups", + alias: "s", + items: ['acme") { x } #', "web"], + shared: { owner: ["String!", "acme"] }, + variables: (repo) => ({ name: ["String!", repo] }), + field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, + }); + assert.isNotNull(document); + expect(document!.query).toBe( + "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + + " s0: repository(owner: $owner, name: $s0_name) { id }\n" + + " s1: repository(owner: $owner, name: $s1_name) { id }\n}", + ); + expect(document!.variables).toEqual({ + owner: "acme", + s0_name: 'acme") { x } #', + s1_name: "web", + }); + }); + + it("aliases by key and nests inside a shared parent when asked", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (number) => number, + items: [2, 3], + variables: (number) => ({ number: ["Int!", number] }), + field: ({ number }) => `pullRequest(number: ${number}) { id }`, + within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, + }); + expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); + expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); + expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); + }); + + it("asks for nothing when there is nothing to ask", () => { + expect( + aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: [], + variables: () => ({}), + field: () => "x", + }), + ).toBeNull(); + }); +}); + +describe("readGraphQlPages", () => { + const pages = (cursors: ReadonlyArray) => { + const asked: Array = []; + const read = (after: string | null) => + Effect.sync(() => { + asked.push(after); + return { next: cursors[asked.length - 1] ?? null }; + }); + return { asked, read }; + }; + + it.effect("follows the cursor until GitHub has no more", () => + Effect.gen(function* () { + const { asked, read } = pages(["a", "b", null]); + const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); + expect(asked).toEqual([null, "a", "b"]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => + Effect.gen(function* () { + const limited = pages(["a", "b", "c"]); + const atLimit = yield* readGraphQlPages(limited.read, { + nextCursor: (page) => page.next, + maxPages: 2, + }); + expect(limited.asked).toEqual([null, "a"]); + expect(atLimit.truncated).toBe(true); + + const enough = pages(["a", "b", "c"]); + const satisfied = yield* readGraphQlPages(enough.read, { + nextCursor: (page) => page.next, + until: (read) => read.length === 1, + }); + expect(enough.asked).toEqual([null]); + expect(satisfied.truncated).toBe(true); + + const stuck = pages(["a", "a", "a"]); + const repeated = yield* readGraphQlPages(stuck.read, { + nextCursor: (page) => page.next, + from: "start", + }); + expect(stuck.asked).toEqual(["start", "a"]); + expect(repeated.truncated).toBe(true); + }), + ); +}); diff --git a/apps/server/src/sourceControl/githubGraphQl.ts b/apps/server/src/sourceControl/githubGraphQl.ts new file mode 100644 index 0000000000000..0b1e3ca65844c --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; + +/** GraphQL variables by name, each with the type it is declared as and the value it carries. */ +export type GraphQlVariables = Readonly>; + +export interface GraphQlDocument { + readonly query: string; + readonly variables: Readonly>; +} + +/** + * One document asking the same field for many items, each under its own alias: the prefix and + * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub + * has no bulk form of most lookups, so aliases are how a batch travels in one request. + * + * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those + * placeholders rather than the values: nothing an item holds is written into the document. + * `shared` variables are declared once and referenced by their own name; `within` nests the aliased + * fields inside one parent selection, such as a single repository. + * + * Null for no items, since a document with no selection is not a document. + */ +export function aliasedGraphQlDocument(input: { + readonly operation: "query" | "mutation"; + /** The operation's name, which tells one batch from another in logs and recorded traffic. */ + readonly name?: string; + readonly alias: string; + readonly key?: (item: Item, index: number) => number; + readonly items: ReadonlyArray; + readonly variables: (item: Item) => Variables; + readonly field: ( + placeholders: { readonly [Name in keyof Variables]: string }, + item: Item, + ) => string; + readonly shared?: GraphQlVariables; + readonly within?: (fields: string) => string; +}): GraphQlDocument | null { + if (input.items.length === 0) return null; + const declarations: string[] = []; + const variables: Record = {}; + for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { + declarations.push(`$${name}: ${type}`); + variables[name] = value; + } + const fields = input.items.map((item, index) => { + const alias = `${input.alias}${input.key?.(item, index) ?? index}`; + const placeholders: Record = {}; + for (const [name, [type, value]] of Object.entries(input.variables(item))) { + const variable = `${alias}_${name}`; + declarations.push(`$${variable}: ${type}`); + variables[variable] = value; + placeholders[name] = `$${variable}`; + } + return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; + }); + const selection = fields.join("\n"); + const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; + return { + query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, + variables, + }; +} + +/** + * The pages of a GraphQL connection, read one after another from `from` (the first page by + * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is + * handed the pages so far, for reads that size or check the next page by what has already arrived. + * + * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time + * would page forever, so it stops the read as truncated too. + */ +export const readGraphQlPages = ( + read: (after: string | null, pages: ReadonlyArray) => Effect.Effect, + options: { + readonly nextCursor: (page: NoInfer) => string | null; + readonly from?: string | null; + readonly maxPages?: number; + readonly until?: (pages: ReadonlyArray>) => boolean; + }, +): Effect.Effect<{ readonly pages: ReadonlyArray; readonly truncated: boolean }, E, R> => + Effect.gen(function* () { + const pages: Page[] = []; + const seen = new Set(); + let after = options.from ?? null; + while (true) { + const page: Page = yield* read(after, pages); + pages.push(page); + const next = options.nextCursor(page); + if (next === null) return { pages, truncated: false }; + if ( + seen.has(next) || + pages.length >= (options.maxPages ?? Infinity) || + options.until?.(pages) === true + ) { + return { pages, truncated: true }; + } + seen.add(next); + after = next; + } + }); From 42d92ec59775c187af69071c81b2b91180810bdd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 077/680] refactor(server): GitHub source control reads GitHubApi directly (#16982) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/src/git/GitManager.test.ts | 280 ++--- .../ThreadPullRequestService.ts | 2 +- .../pullRequest/GitHubPullRequestApi.test.ts | 2 +- .../src/pullRequest/GitHubPullRequestApi.ts | 3 +- .../GitHubPullRequestProvider.test.ts | 3 +- .../pullRequest/GitHubPullRequestProvider.ts | 4 +- .../PullRequestProviderRegistry.ts | 11 +- .../src/pullRequest/PullRequestService.ts | 2 +- apps/server/src/server.ts | 6 +- apps/server/src/sourceControl/GitHubApi.ts | 10 + .../src/sourceControl/GitHubCredentials.ts | 4 - .../sourceControl/GitHubRepositoryApi.test.ts | 651 ---------- .../src/sourceControl/GitHubRepositoryApi.ts | 1086 ----------------- .../GitHubSourceControlProvider.test.ts | 768 +++++++++--- .../GitHubSourceControlProvider.ts | 911 +++++++++++--- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 5 +- .../gitHubRepositoryResolution.test.ts | 87 ++ .../gitHubRepositoryResolution.ts | 190 +++ apps/server/src/ws.ts | 4 +- 21 files changed, 1746 insertions(+), 2291 deletions(-) delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.test.ts delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.ts diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index a8d2f9d986d1c..b91196f28f84c 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f30af2c23c5b5..1ce25b4106be2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -24,6 +24,7 @@ import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { + ChangeRequest, GitActionProgressEvent, GitManagerServiceError, GitPreparePullRequestThreadInput, @@ -37,12 +38,11 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SourceControlProviderError as SourceControlProviderFailure, TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { ForgejoPullRequestSchema, @@ -92,7 +91,10 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; + /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */ + failWith?: string; + /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */ + failCause?: unknown; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary( - raw: unknown, -): GitHubRepositoryApi.GitHubPullRequestSummary | null { +type FakePullRequestSummary = Omit & { + readonly state?: ChangeRequest["state"]; + readonly updatedAt?: string; +}; + +function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -383,8 +388,12 @@ function createTextGeneration( }; } -function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; +/** + * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes, + * which is what `ghCalls` records and the tests assert on; the provider methods translate to them. + */ +function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { + service: SourceControlProvider["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - // The fake still speaks in gh's command shapes; the service methods below translate to them. + const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => + new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + + /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + readonly operation: string; + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { - return Effect.fail(scenario.failWith); + return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation)); } if (args[0] === "pr" && args[1] === "list") { @@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { } return fakeGhOutput(""); }, - catch: (error) => - GitHubRepositoryApi.isGitHubRepositoryApiError(error) - ? error - : new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: error, - }), + catch: (error) => fail(input.cwd, "GitHub request failed.", error), }); } @@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository lookup: ${repository}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`)); } return Effect.succeed( fakeGhOutput( @@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected gh command: ${args.join(" ")}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`)); }; + /** What GitHubSourceControlProvider makes of a pull request it read. */ + const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ + ...summary, + provider: "github", + state: summary.state ?? "open", + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), + }); + return { service: { - // The fake answers the CLI shape, so batched lookups read it the way the fallback does. - listPullRequestsByHead: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - input.state, - "--limit", - String(input.limit), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) return []; - const decoded = decodeGitHubPullRequestListJson(raw); - return Result.isSuccess(decoded) ? decoded.success : []; - }), - ), - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter( - (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + kind: "github", + listChangeRequests: (input) => + input.state === "open" + ? execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is FakePullRequestSummary => entry !== null) + .map(toChangeRequest), ), - ), - ), - createPullRequest: (input) => + ) + : // The fake answers the CLI shape, so batched lookups read it the way the fallback does. + execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return []; + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + : []; + }), + ), + createChangeRequest: (input) => execute({ + operation: "createChangeRequest", cwd: input.cwd, args: [ "pr", "create", "--base", - input.baseBranch, + input.baseRefName, "--head", input.headSelector, "--title", @@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ + operation: "getDefaultBranch", cwd: input.cwd, args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( @@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), - getPullRequest: (input) => + getChangeRequest: (input) => execute({ + operation: "getChangeRequest", cwd: input.cwd, args: [ "pr", @@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + Effect.map((result) => + toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary), ), ), getRepositoryCloneUrls: (input) => execute({ + operation: "getRepositoryCloneUrls", cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => - Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository create: ${input.repository}`), - }), - ), - checkoutPullRequest: (input) => + Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)), + checkoutChangeRequest: (input) => execute({ + operation: "checkoutChangeRequest", cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), @@ -693,7 +707,7 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh( input?.ghScenario, ); const textGeneration = createTextGeneration(input?.textGeneration); @@ -729,10 +743,7 @@ function makeManager(input?: { ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - (input?.sourceControlProvider === undefined - ? GitHubSourceControlProvider.make - : Effect.succeed(input.sourceControlProvider) - ).pipe( + Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), @@ -742,12 +753,6 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide( - Layer.merge( - Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), - Layer.mock(GitHubApi.GitHubApi)({}), - ), - ), ), ); @@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); yield* manager.remoteStatus({ cwd: repoDir }); @@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ - cwd: repoDir, - cause: new Error(upstreamCause), - }), + failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", + failCause: new Error(upstreamCause), }, }); const logs: Array<{ message: string; annotations: Record }> = []; @@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ - cwd: repoDir, - cause: new Error("gh is not authenticated"), - }), + failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", }, }); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index c1e0cff2658f6..cd88d93977768 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one + // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 64259e337e922..20fc1dd885266 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 59281964df4a6..5e35729154604 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,6 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () { const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return AllowGitHubReserve.pipe( + return GitHubApi.AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => graphqlRead({ allowReserve, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index f00cbfaf5d324..df4f7da4cd2cf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; @@ -726,7 +725,7 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubRepositoryApi.AllowGitHubReserve.pipe( + GitHubApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e6f242072994..f2672dbe991fd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,7 +7,7 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), + Effect.provideService(GitHubApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index efbe37f991eee..316cd1e7ae92f 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; @@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( GitHubPullRequestApi.layer.pipe( Layer.provide( - GitHubApi.layer.pipe( - Layer.provide(GitHubCredentials.layer), - // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. - Layer.provide(GitHubGraphQlBudget.layer), - Layer.provide(SourceControlRateLimit.layer), - ), + // Built here too, so a registry built alone still has one budget and pause per host. + GitHubApi.layerWithDependencies, ), ), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9e2b81d97d5f5..a44bd322adcba 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fdd7d6c7165f4..192b17204aea8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubRepositoryApi.layer), + Layer.provideMerge(GitHubApi.layerWithDependencies), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 01aa1c9ccfcfd..0da53e0230b17 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -598,3 +598,13 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(GitHubApi, make); + +/** + * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one budget and one pause per host. + */ +export const layerWithDependencies = layer.pipe( + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index fb043d809c684..d47d57ea7ffef 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError = | GitHubHostDisabledError | GitHubCliFailedError; -export const isGitHubCredentialUnavailableError = Schema.is( - Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]), -); - /** * Where GitHub tokens come from. Callers ask per host and never see how the token was found, * so another source (an in-app OAuth login) slots in here without touching any of them. diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted file mode 100644 index 688216211f1fd..0000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { assert, it, describe } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const remotesOutput = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - -const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ - status, - headers: {}, - body: body === undefined ? "" : encodeJson(body), - truncated: false, - invalidUtf8: false, -}); - -const node = (number: number, headRefName: string, owner = "acme") => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - isCrossRepository: owner !== "acme", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: `${owner}/web` }, - headRepositoryOwner: { login: owner }, -}); - -/** - * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. - */ -function harness(input: { - readonly remotes: string; - readonly api: Partial; - readonly localBranches?: ReadonlyArray; -}) { - const git: Array = []; - const record = - (name: string, value: A) => - (args: unknown) => - Effect.sync(() => { - git.push([name, args]); - return value; - }); - const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (args) => - Effect.sync(() => { - git.push(["execute", args.args]); - return processOutput(""); - }), - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), - ensureRemote: (args) => - Effect.sync(() => { - git.push(["ensureRemote", args]); - return args.preferredName; - }), - fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), - setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), - switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, - listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), - ), - }); - const layer = Layer.effect( - GitHubRepositoryApi.GitHubRepositoryApi, - GitHubRepositoryApi.make, - ).pipe( - Layer.provide( - Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ), - ), - ); - return { layer, git }; -} - -describe("selectGitHubBaseRepository", () => { - const remotes = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - const select = (input: { remotes: string; resolved?: string }) => - GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); - - it("picks the repository gh reads without a prompt", () => { - assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { - owner: "me", - name: "web", - }); - const fork = remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/Acme/Web.git"], - ); - assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); - assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { - owner: "me", - name: "web", - }); - assert.deepStrictEqual( - select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), - { owner: "acme", name: "other" }, - ); - // gh ranks remote names in any case. - assert.deepStrictEqual( - select({ - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Upstream", "git@github.com:acme/web.git"], - ), - }), - { owner: "acme", name: "web" }, - ); - }); - - it("leaves gh to choose when it might weigh the remotes differently", () => { - for (const input of [ - { remotes: "" }, - { - remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["mirror", "git@gitlab.com:me/web.git"], - ), - }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Origin", "git@github.com:acme/web.git"], - ), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", - }, - ]) { - assert.strictEqual(select(input), null); - } - }); -}); - -describe("GitHubRepositoryApi repository resolution", () => { - it.effect("reads the repository gh would pick from the remotes", () => { - const paths: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - paths.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "trunk", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); - assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an SSH alias remote through github.com", () => { - const hosts: string[] = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github:acme/web.git"]), - api: { - rest: (input) => - Effect.sync(() => { - hosts.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. - yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), - null, - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails clearly when no remote is on GitHub", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), - api: {}, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); - assert.include(String((error.cause as Error).message), "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.listPullRequestsByHead", () => { - const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); - - it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { - const documents: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - documents.push(input); - return encodeJson({ - data: { - repository: { - h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, - h1: { nodes: [] }, - }, - }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookup = (headSelector: string) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. - const firstLookup = yield* lookup("feature/a"); - yield* TestClock.adjust("200 millis"); - const secondLookup = yield* lookup("feature/b"); - yield* TestClock.adjust("300 millis"); - const first = yield* Fiber.join(firstLookup); - const second = yield* Fiber.join(secondLookup); - assert.deepStrictEqual( - first?.map((pr) => pr.number), - [7], - ); - assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); - assert.deepStrictEqual(second, []); - assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "headRefOid"); - assert.deepStrictEqual(documents[0]!.variables, { - owner: "acme", - name: "web", - h0: "feature/a", - s0: ["OPEN", "CLOSED", "MERGED"], - h1: "feature/b", - s1: ["OPEN", "CLOSED", "MERGED"], - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("caps a background document at twenty-five heads", () => { - const headCounts: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); - headCounts.push(heads.length); - return encodeJson({ - data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(lookups); - assert.deepStrictEqual( - headCounts.toSorted((a, b) => a - b), - [1, 25], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("matches an owner:branch selector on the head owner", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.succeed( - encodeJson({ - data: { - repository: { - h0: { - nodes: - input.variables?.h0 === "main" - ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] - : [], - }, - }, - }, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const open = yield* gh - .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("50 millis"); - assert.deepStrictEqual( - (yield* Fiber.join(open)).map((pr) => pr.number), - [8], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps API failures onto the errors callers handle", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.fail( - input.variables?.h0 === "missing" - ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) - : new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "x", - retryAt: 123, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const read = (headSelector: string) => - gh - .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); - assert.propertyVal(error, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.getPullRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const variables: Array = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: (input) => - Effect.sync(() => { - variables.push(input.variables); - return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); - yield* gh.getPullRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", - }); - assert.deepStrictEqual(variables, [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a missing pull request as not found", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi writes", () => { - it.effect("creates a cross-repository pull request with an owner:branch head", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - requests.push(input); - return restResponse({ number: 1 }, 201); - }), - }, - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - assert.deepStrictEqual(requests[0]!.body, { - base: "main", - head: "me:feature", - title: "Title", - body: "Body", - maintainer_can_modify: true, - }); - }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); - }); - - it.effect("creates a repository under an organization the viewer is not", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: "", - api: { - rest: (input) => - Effect.sync(() => { - requests.push(`${input.method ?? "GET"} ${input.path}`); - return input.path === "user" - ? restResponse({ login: "me" }) - : restResponse({ - full_name: "acme/new", - html_url: "https://github.com/acme/new", - ssh_url: "git@github.com:acme/new.git", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const urls = yield* gh.createRepository({ - cwd: "/repo", - repository: "acme/new", - visibility: "private", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "acme/new", - url: "https://github.com/acme/new", - sshUrl: "git@github.com:acme/new.git", - }); - assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.checkoutPullRequest", () => { - const repository = (fullName: string, defaultBranch = "main") => - restResponse({ - full_name: fullName, - html_url: `https://github.com/${fullName}`, - ssh_url: `git@github.com:${fullName}.git`, - default_branch: defaultBranch, - }); - - it.effect("checks a same-repository pull request out from its head branch", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); - assert.deepStrictEqual(git, [ - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], - ["switchRef", { cwd: "/repo", refName: "feature/x" }], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: () => - Effect.fail( - new GitHubApi.GitHubApiRequestError({ - host: "github.com", - operation: "x", - cause: "offline", - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. - assert.deepStrictEqual(git, []); - }).pipe(Effect.provide(layer)); - }); - - it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["someone/main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: (input) => - Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); - assert.deepStrictEqual(git, [ - [ - "ensureRemote", - { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, - ], - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, - ], - ["switchRef", { cwd: "/repo", refName: "someone/main" }], - ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubRepositoryApi.pullRequestCheckoutBranchName({ - headRefName, - headOwner: "someone", - isCrossRepository, - defaultBranch: "main", - }); - assert.strictEqual(name("main", true), "someone/main"); - assert.strictEqual(name("feature", true), "feature"); - assert.strictEqual(name("main", false), "main"); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts deleted file mode 100644 index 595d869d72275..0000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; - -export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; - -const gitHubFailureFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( - "GitHubRepositoryUnavailableError", - gitHubFailureFields, -) { - override get message(): string { - return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; - } -} - -export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( - "GitHubRepositoryAuthenticationError", - gitHubFailureFields, -) { - override get message(): string { - // A missing or turned-off credential already says what to do about it. - return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) - ? this.cause.message - : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; - } -} - -export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( - "GitHubRepositoryRateLimitError", - { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, -) { - override get message(): string { - return "GitHub API rate limit exceeded. Requests resume when the limit resets."; - } -} - -export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( - "GitHubPullRequestNotFoundError", - gitHubFailureFields, -) { - override get message(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } -} - -export class GitHubRepositoryCommandError extends Schema.TaggedError()( - "GitHubRepositoryCommandError", - { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, -) { - override get message(): string { - // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. - const reason = - this.cause instanceof Error && this.cause.message.trim() !== "" - ? this.cause.message.trim() - : null; - return reason === null ? "GitHub request failed." : reason; - } -} - -const gitHubDecodeFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( - "GitHubPullRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request list."; - } -} - -export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( - "GitHubChangeRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid change request list."; - } -} - -export class GitHubPullRequestDecodeError extends Schema.TaggedError()( - "GitHubPullRequestDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request."; - } -} - -export class GitHubRepositoryDecodeError extends Schema.TaggedError()( - "GitHubRepositoryDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid repository."; - } -} - -export const GitHubRepositoryApiError = Schema.Union([ - GitHubRepositoryUnavailableError, - GitHubRepositoryAuthenticationError, - GitHubRepositoryRateLimitError, - GitHubPullRequestNotFoundError, - GitHubRepositoryCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; - -export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); - -/** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError( - cwd: string, - error: GitHubApi.GitHubApiError, -): GitHubRepositoryApiError { - const context = { cwd, cause: error }; - switch (error._tag) { - case "GitHubCliMissingError": - return new GitHubRepositoryUnavailableError(context); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return new GitHubRepositoryAuthenticationError(context); - case "GitHubCliFailedError": - return new GitHubRepositoryCommandError(context); - case "GitHubApiRateLimitError": - return new GitHubRepositoryRateLimitError({ - ...context, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }); - case "SourceControlRateLimitPausedError": - return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); - case "GitHubApiNotFoundError": - return new GitHubPullRequestNotFoundError(context); - case "GitHubApiResponseError": - return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); - case "GitHubApiRequestError": - return new GitHubRepositoryCommandError(context); - } -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly headSha?: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { - const { updatedAt, ...summary } = input; - return { - ...summary, - ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), - }; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitHubRepositoryApi extends Context.Service< - GitHubRepositoryApi, - { - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive - * together share one GraphQL document. - */ - readonly listPullRequestsByHead: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit: number; - /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubRepositoryApi") {} - -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. - */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); - } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; - } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; -} - -const RawRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - html_url: TrimmedNonEmptyString, - ssh_url: TrimmedNonEmptyString, - default_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeRawRepository = decodeJsonResult(RawRepositorySchema); - -function repositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; -} - -const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); - -type PullRequestListState = "open" | "closed" | "merged" | "all"; - -/** The pull request fields every read selects, in GraphQL. */ -const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; -const GRAPHQL_STATES: Record> = { - open: ["OPEN"], - closed: ["CLOSED"], - merged: ["MERGED"], - all: ["OPEN", "CLOSED", "MERGED"], -}; -/** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. - */ -const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. - */ -const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; -const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ -const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ -const OWNER_HEAD_SCAN_LIMIT = 100; - -class PullRequestsByHeadRead extends Request.Class< - { - readonly cwd: string; - readonly host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubRepositoryApiError -> {} - -/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ -function buildPullRequestsByHeadQuery( - lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: Record } { - const variables: Record = {}; - const declarations: string[] = ["$owner: String!", "$name: String!"]; - const selections: string[] = []; - for (const [index, lookup] of lookups.entries()) { - variables[`h${index}`] = lookup.headRefName; - variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; - declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); - // `gh pr list` orders the same way, so a head with more matches than the limit keeps the - // same rows. - selections.push( - ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, - ); - } - return { - document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, - variables, - }; -} - -const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } - } -}`; - -const decodePullRequestsByHead = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), - ), - ), - }), - }), -); - -const decodePullRequestByNumber = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), - }), - }), -); - -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ -export interface GitHubRepositoryLocator { - readonly host: string; - readonly owner: string; - readonly name: string; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -function parseGitHubRepositorySelector( - selector: string, - defaultHost: string, -): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); - return owner && name && rest.length === 0 - ? { host: url.host.toLowerCase(), owner, name } - : null; - } catch { - return null; - } - } - const parts = trimmed.split("/").filter(Boolean); - if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; - return null; -} - -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ -function parsePullRequestReference( - reference: string, -): - | { readonly kind: "number"; readonly number: number } - | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } - | { readonly kind: "branch"; readonly headSelector: string } { - const trimmed = reference.trim(); - const numbered = /^#?([1-9]\d*)$/.exec(trimmed); - if (numbered) return { kind: "number", number: Number(numbered[1]) }; - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); - if (match) { - return { - kind: "url", - locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, - number: Number(match[3]), - }; - } - } catch { - // Not a URL after all; read it as a branch. - } - } - return { kind: "branch", headSelector: trimmed }; -} - -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). - */ -export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; - } - return provider.kind === "github" ? host : null; -} - -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ -function apiHostForHint(host: string): string { - const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; -} - -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ -export function pullRequestCheckoutBranchName(input: { - readonly headRefName: string; - readonly headOwner: string | null; - readonly isCrossRepository: boolean; - readonly defaultBranch: string | null; -}): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. - return input.isCrossRepository && - input.headOwner !== null && - input.defaultBranch !== null && - input.headRefName === input.defaultBranch - ? `${input.headOwner}/${input.headRefName}` - : input.headRefName; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const api = yield* GitHubApi.GitHubApi; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubRepositoryApi.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - const commandFailure = (cwd: string, detail: string) => - new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); - - /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. - */ - const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); - if (envRepository) { - const locator = parseGitHubRepositorySelector(envRepository, defaultHost); - if (locator !== null) return locator; - } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); - const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; - const fetchRemotes = remoteOutput - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - name: match[1]!, - url: match[2]!, - host: gitHubApiHostForRemote(match[2]!), - })); - const host = - (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: remoteOutput, - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - host, - }); - if (selected !== null) return { host, ...selected }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). - const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); - const candidates = fetchRemotes - .filter((remote) => remote.host === host) - .toSorted((left, right) => { - const l = rank(left.name); - const r = rank(right.name); - return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); - }); - for (const remote of candidates) { - const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); - if (owner && name && rest.length === 0) return { host, owner, name }; - } - return yield* commandFailure( - input.cwd, - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }); - - const graphqlJson = ( - cwd: string, - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, - ) => - api.graphql(input).pipe( - Effect.mapError((error) => fromGitHubApiError(cwd, error)), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(onDecodeFailure(decoded.failure)); - }), - ); - - const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - - /** Pull requests whose head branch is `headRefName` on one repository. */ - const readPullRequestsByHead = (input: { - readonly cwd: string; - readonly locator: GitHubRepositoryLocator; - readonly lookups: ReadonlyArray< - Pick - >; - readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; - }) => { - const query = buildPullRequestsByHeadQuery(input.lookups); - return graphqlJson( - input.cwd, - { - host: input.locator.host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, - allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - input.onDecodeFailure, - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail( - new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The repository could not be read."), - }), - ) - : Effect.succeed(decoded.data.repository), - ), - ); - }; - - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ - request.host, - request.owner, - request.name, - String(request.allowReserve), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? "", - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { - const [first] = entries; - const { cwd, host, owner, name, allowReserve } = first.request; - return readPullRequestsByHead({ - cwd, - locator: { host, owner, name }, - lookups: entries.map((entry) => entry.request), - allowReserve, - onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), - }).pipe( - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); - const interactiveHeadResolver = headResolver.pipe( - RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), - ); - const backgroundHeadResolver = headResolver.pipe( - RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), - RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), - ); - - const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. - const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); - const rows = yield* Effect.request( - new PullRequestsByHeadRead({ - cwd: input.cwd, - ...locator, - headRefName, - state: input.state, - limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, - allowReserve: input.allowReserve, - }), - input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, - ); - if (!ownerMatch) return rows; - const headOwner = ownerMatch[1]!.toLowerCase(); - return rows - .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) - .slice(0, limit); - }); - - const toSummaries = (rows: ReadonlyArray) => - rows.map(pullRequestSummary); - - const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string | undefined; - }) { - const parsed = parsePullRequestReference(input.reference); - if (parsed.kind === "branch") { - // `gh pr view ` prefers an open pull request, then the newest of any state. - const [open] = yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "open", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }); - const found = - open ?? - (yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "all", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }))[0]; - if (found === undefined) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("No pull request has this head branch."), - }); - } - return found; - } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); - const decoded = yield* graphqlJson( - input.cwd, - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeFailure, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The pull request does not exist."), - }); - } - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); - return record; - }); - - const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( - cwd: string, - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest(cwd, { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd, - cause: decoded.failure, - }); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( - cwd: string, - host: string, - ) { - const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); - } - return decoded.success.login; - }); - - const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubRepositoryCommandError({ cwd, cause }); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); - - /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ - const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubRepositoryApi.checkoutPullRequest", - )(function* (input) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); - const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); - const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; - const isCrossRepository = - pullRequest.isCrossRepository ?? - (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); - const headOwner = - pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; - - const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), - Effect.mapError(gitFailure(input.cwd)), - ); - const remoteFor = (nameWithOwner: string) => - remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .find( - (match) => - match !== null && - normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.[1] ?? null; - const baseRemote = Effect.suspend(() => { - const known = remoteFor(baseNameWithOwner); - return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); - }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. - const defaultBranch = isCrossRepository - ? yield* readRepository(input.cwd, base).pipe( - Effect.map((repository) => repository.default_branch ?? null), - ) - : null; - const localBranch = pullRequestCheckoutBranchName({ - headRefName: pullRequest.headRefName, - headOwner, - isCrossRepository, - defaultBranch, - }); - - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ - const headRemote = Effect.gen(function* () { - if (!isCrossRepository) return yield* baseRemote; - if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository(input.cwd, { - host: base.host, - owner: owner!, - name: name!, - }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: headOwner ?? "fork", - url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, - }); - }); - - const exists = (yield* git - .listLocalBranchNames(input.cwd) - .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); - - // The commit to check out, fetched from the head branch where it still exists. - const target = yield* Effect.gen(function* () { - const remoteName = yield* headRemote; - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch: pullRequest.headRefName, - }); - return { - ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, - upstream: { remoteName, remoteBranch: pullRequest.headRefName }, - }; - }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* runGit(input.cwd, "fetchPullRef", [ - "fetch", - "--quiet", - "--no-tags", - yield* baseRemote, - `refs/pull/${pullRequest.number}/head`, - ]); - const { commitSha } = yield* git.resolveCommit({ - cwd: input.cwd, - revision: "FETCH_HEAD", - }); - return { ref: commitSha, upstream: null }; - }), - ), - Effect.mapError(gitFailure(input.cwd)), - ); - - yield* Effect.gen(function* () { - if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - if (exists) { - yield* runGit( - input.cwd, - "sync", - input.force === true - ? ["reset", "--hard", "--quiet", target.ref] - : ["merge", "--ff-only", "--quiet", target.ref], - ); - } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. - if (target.upstream !== null) { - yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); - } - }).pipe(Effect.mapError(gitFailure(input.cwd))); - }); - - return GitHubRepositoryApi.of({ - listPullRequestsByHead: (input) => - AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), - ), - listOpenPullRequests: (input) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: "open", - limit: input.limit ?? 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }).pipe(Effect.map(toSummaries)), - getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( - Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); - if (locator === null) { - return yield* commandFailure(input.cwd, "Repositories are named owner/name."); - } - return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); - }), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); - const owner = locator?.owner ?? viewer; - const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); - const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); - const response = yield* rest(input.cwd, { - host, - operation: "createRepository", - method: "POST", - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd: input.cwd, - cause: decoded.failure, - }); - } - return repositoryCloneUrls(decoded.success); - }), - createPullRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe(Effect.mapError(gitFailure(input.cwd))); - yield* rest(input.cwd, { - host: locator.host, - operation: "createPullRequest", - method: "POST", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. - body: { - base: input.baseBranch, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const repository = yield* readRepository(input.cwd, locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }), - checkoutPullRequest, - }); -}); - -export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( - Layer.provideMerge(GitHubApi.layer), - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 89398b6347860..b70bfff78f6b7 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,17 +1,23 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const processResult = ( stdout: string, options?: { @@ -26,191 +32,611 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider( - github: Partial, - api: Partial = {}, -) { - return GitHubSourceControlProvider.make.pipe( - Effect.provide( - Layer.merge( - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), - Layer.mock(GitHubApi.GitHubApi)(api), - ), - ), - ); -} +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); -const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ - status: 200, +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, headers: {}, - body, + body: body === undefined ? "" : encodeJson(body), truncated: false, invalidUtf8: false, }); -it.effect("maps GitHub PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), +const node = (number: number, headRefName: string, owner = "acme") => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + isCrossRepository: owner !== "acme", + updatedAt: "2026-01-02T00:00:00Z", + headRepository: { name: "web", nameWithOwner: `${owner}/web` }, + headRepositoryOwner: { login: owner }, +}); + +/** + * The provider over a mocked GitHubApi, git driver and process. `remotes` is what + * `git remote -v` prints; `git` records every driver call. + */ +function harness(input: { + readonly remotes: string; + readonly api: Partial; + readonly localBranches?: ReadonlyArray; + /** Fails the local branch listing, the first git read a checkout makes. */ + readonly gitFailure?: unknown; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; + }); + const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (args) => + Effect.sync(() => { + git.push(["execute", args.args]); + return processOutput(""); + }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), + ensureRemote: (args) => + Effect.sync(() => { + git.push(["ensureRemote", args]); + return args.preferredName; + }), + fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), + setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), + switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, + listLocalBranchNames: () => + input.gitFailure === undefined + ? Effect.succeed([...(input.localBranches ?? [])]) + : Effect.fail(input.gitFailure as never), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (args) => + Effect.succeed( + args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + ), + }); + const layer = Layer.mergeAll( + driver, + process, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ); + return { layer, git }; +} + +/** The provider context for a checkout whose remote is on `host`. */ +/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ +const makeProvider = (api: Partial) => + GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); + +const githubContext = (host: string) => ({ + provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl: `git@${host}:acme/web.git`, +}); + +describe("GitHubSourceControlProvider repository resolution", () => { + it.effect("reads the repository gh would pick from the remotes", () => { + const paths: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "trunk", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", + it.effect("reads an SSH alias remote through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.getDefaultBranch({ cwd: "/repo" }); + // A provider's host hint for the same alias (`github` here) resolves the same way. + yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(changeRequest, { - provider: "github", - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - closedAt: null, - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", + it.effect("names a missing repository rather than a pull request", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), + api: { + rest: (input) => + Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.include(error.detail, "Repository not found"); + }).pipe(Effect.provide(layer)); + }); -it.effect("adds safe request context while retaining GitHub API causes", () => - Effect.gen(function* () { - const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), + it.effect("fails clearly when no remote is on GitHub", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), + api: {}, }); - const provider = yield* makeProvider({ - getPullRequest: () => Effect.fail(cause), + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "SourceControlProviderError"); + assert.include(error.detail, "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider change request lookups by head", () => { + const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); + + it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { + const documents: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + documents.push(input); + return encodeJson({ + data: { + repository: { + h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, + h1: { nodes: [] }, + }, + }, + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookup = (headSelector: string) => + gh + .listChangeRequests({ + cwd: "/repo", + headSelector, + state: "all", + limit: 100, + context: githubContext("github.com"), + }) + .pipe(Effect.forkChild); + // Each branch's own git reads come first, so a sweep's lookups arrive spread out. + const firstLookup = yield* lookup("feature/a"); + yield* TestClock.adjust("200 millis"); + const secondLookup = yield* lookup("feature/b"); + yield* TestClock.adjust("300 millis"); + const first = yield* Fiber.join(firstLookup); + const second = yield* Fiber.join(secondLookup); + assert.deepStrictEqual( + first?.map((pr) => pr.number), + [7], + ); + assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); + assert.deepStrictEqual(second, []); + assert.strictEqual(documents.length, 1); + assert.include(documents[0]!.query, "headRefOid"); + assert.deepStrictEqual(documents[0]!.variables, { + owner: "acme", + name: "web", + h0: "feature/a", + s0: ["OPEN", "CLOSED", "MERGED"], + h1: "feature/b", + s1: ["OPEN", "CLOSED", "MERGED"], + }); + }).pipe(Effect.provide(layer)); + }); - const error = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", - }) - .pipe(Effect.flip); + it.effect("caps a background document at twenty-five heads", () => { + const headCounts: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); + headCounts.push(heads.length); + return encodeJson({ + data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookups = yield* Effect.all( + Array.from({ length: 26 }, (_, index) => + gh.listChangeRequests({ + cwd: "/repo", + headSelector: `feature/${index}`, + state: "all", + limit: 100, + context: githubContext("github.com"), + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(lookups); + assert.deepStrictEqual( + headCounts.toSorted((a, b) => a - b), + [1, 25], + ); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, + it.effect("matches an owner:branch selector on the head owner", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.succeed( + encodeJson({ + data: { + repository: { + h0: { + nodes: + input.variables?.h0 === "main" + ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] + : [], + }, + }, + }, + }), + ), }, - { - provider: "github", - operation: "getChangeRequest", + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const open = yield* gh + .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); + assert.deepStrictEqual( + (yield* Fiber.join(open)).map((pr) => pr.number), + [8], + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps API failures onto the errors callers handle", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.fail( + input.variables?.h0 === "missing" + ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) + : new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "x", + retryAt: 123, + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const read = (headSelector: string) => + gh + .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); + yield* TestClock.adjust("500 millis"); + assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); + const limited = yield* read("limited"); + yield* TestClock.adjust("500 millis"); + const error = yield* Fiber.join(limited); + assert.include(error.detail, "rate limit exceeded"); + assert.propertyVal(error.cause, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.getChangeRequest", () => { + it.effect("reads a pull request by number, and by URL on its own repository", () => { + const variables: Array = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: (input) => + Effect.sync(() => { + variables.push(input.variables); + return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual( + (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, + 42, + ); + yield* gh.getChangeRequest({ cwd: "/repo", - reference: "https://github.com/pingdotgg/t3code/pull/42", - detail: "Pull request not found. Check the PR number or URL and try again.", + reference: "https://github.com/other/thing/pull/42", + }); + assert.deepStrictEqual(variables, [ + { owner: "acme", name: "web", number: 42 }, + { owner: "other", name: "thing", number: 42 }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a missing pull request as not found", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.include(error.detail, "Pull request not found"); + }).pipe(Effect.provide(layer)); + }); +}); -it.effect("lists change request history through the batched head lookup", () => - Effect.gen(function* () { - let lookup: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - listPullRequestsByHead: (input) => { - lookup = input; - return Effect.succeed([ - { - number: 7, - title: "Merged work", - url: "https://enterprise.test/acme/web/pull/7", - baseRefName: "main", - headRefName: "feature/merged", - headSha: "a".repeat(40), - state: "merged", - mergedAt: "2026-01-01T00:00:00Z", - updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - }, - ]); +describe("GitHubSourceControlProvider writes", () => { + it.effect("creates a cross-repository pull request with an owner:branch head", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(input); + return restResponse({ number: 1 }, 201); + }), }, }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Body"); + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, + }); + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + assert.deepStrictEqual(requests[0]!.body, { + base: "main", + head: "me:feature", + title: "Title", + body: "Body", + maintainer_can_modify: true, + }); + }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); + }); - const changeRequests = yield* provider.listChangeRequests({ - cwd: "/repo", - context: { - provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, - remoteName: "origin", - remoteUrl: "https://enterprise.test/acme/web.git", + it.effect("creates a repository under an organization the viewer is not", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: "https://github.com/acme/new", + ssh_url: "git@github.com:acme/new.git", + }); + }), }, - headSelector: "feature/merged", - state: "all", - limit: 10, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const urls = yield* gh.createRepository({ + cwd: "/repo", + repository: "acme/new", + visibility: "private", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "acme/new", + url: "https://github.com/acme/new", + sshUrl: "git@github.com:acme/new.git", + }); + assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { + const repository = (fullName: string, defaultBranch = "main") => + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: defaultBranch, }); - assert.deepStrictEqual(lookup, { - cwd: "/repo", - headSelector: "feature/merged", - state: "all", - limit: 10, - rateLimitHost: "enterprise.test", + it.effect("checks a same-repository pull request out from its head branch", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - assert.strictEqual(changeRequests[0]?.provider, "github"); - assert.strictEqual(changeRequests[0]?.state, "merged"); - assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); - assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); - assert.deepStrictEqual( - changeRequests[0]?.updatedAt, - Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - ); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); + assert.deepStrictEqual(git, [ + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], + ["switchRef", { cwd: "/repo", refName: "feature/x" }], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); -it.effect("creates GitHub PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; + it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: () => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: "github.com", + operation: "x", + cause: "offline", + }), + ), }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); + // Nothing touched the local branches: `main` must not be reset to the fork's commit. + assert.deepStrictEqual(git, []); + }).pipe(Effect.provide(layer)); + }); - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["someone/main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: (input) => + Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); + assert.deepStrictEqual(git, [ + [ + "ensureRemote", + { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, + ], + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, + ], + ["switchRef", { cwd: "/repo", refName: "someone/main" }], + ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("keeps git's own output out of what a failed checkout reports", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh + .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) + .pipe(Effect.flip); + assert.strictEqual(error.detail, "The pull request could not be checked out with git."); + assert.notInclude(error.message, "secret-path"); + }).pipe(Effect.provide(layer)); + }); + + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubSourceControlProvider.pullRequestCheckoutBranchName({ + headRefName, + headOwner: "someone", + isCrossRepository, + defaultBranch: "main", + }); + assert.strictEqual(name("main", true), "someone/main"); + assert.strictEqual(name("feature", true), "feature"); + assert.strictEqual(name("main", false), "main"); + }); +}); it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( @@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => Effect.gen(function* () { - const provider = yield* makeProvider( - {}, - { - rest: (input) => { - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.path, "repos/owner/repo/issues/42"); - return Effect.succeed( - restResponse( - encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), - ), - ); - }, + const provider = yield* makeProvider({ + rest: (input) => { + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.path, "repos/owner/repo/issues/42"); + return Effect.succeed( + restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), + ); }, - ); + }); const lookup = provider.resolveLink?.({ cwd: "/unrelated", url: new URL(`https://github.com/owner/repo/${kind}/42`), @@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)( operation: "resolveLink", status: 500, }); - const provider = yield* makeProvider( - {}, - { - rest: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed(restResponse("private response text")), - }, - ); + const provider = yield* makeProvider({ + rest: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), + }); const lookup = provider.resolveLink?.({ cwd: "/repo", url: new URL("https://github.com/owner/repo/issues/42"), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index a3b7a22f8d559..86edda994ccef 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,21 +1,42 @@ -import * as Schema from "effect/Schema"; -import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { DEFAULT_SERVER_SETTINGS, SourceControlProviderError, + TrimmedNonEmptyString, type ChangeRequest, type GitHubSettings, type SourceControlProviderDiscoveryItem, + type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; - +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; +import { + parseFetchRemotes, + parseGitHubRepositorySelector, + parsePullRequestReference, + resolveGitHubRepository, + type GitHubRepositoryLocator, +} from "./gitHubRepositoryResolution.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { - return { - provider: "github", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: - summary.updatedAt === undefined - ? Option.none() - : Option.some(DateTime.makeUnsafe(summary.updatedAt)), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; +function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { + return { provider: "github", ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlManagedCliDiscoverySpec; }); +/** + * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the + * operation and request context; `cause` keeps the raw failure for logs, never for transport. + */ +class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ + readonly detail: string; + readonly cause: unknown; +}> {} + +const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); + +const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; +const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; + +/** `notFound` names what was missing, which only the operation knows. */ +function fromGitHubApiError( + error: GitHubApi.GitHubApiError, + notFound = PULL_REQUEST_NOT_FOUND, +): GitHubFailure { + switch (error._tag) { + case "GitHubCliMissingError": + return failure( + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", + error, + ); + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + // A missing or turned-off credential already says what to do about it. + return failure(error.message, error); + case "GitHubApiAuthenticationError": + return failure( + "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", + error, + ); + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return failure( + "GitHub API rate limit exceeded. Requests resume when the limit resets.", + error, + ); + case "GitHubApiNotFoundError": + return failure(notFound, error); + case "GitHubCliFailedError": + case "GitHubApiResponseError": + case "GitHubApiRequestError": + // GitHub's own reason ("A pull request already exists…") when it gave one. + return failure(error.message.trim() || "GitHub request failed.", error); + } +} + +const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); + +const RawRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeRawRepository = decodeJsonResult(RawRepositorySchema); + +function repositoryCloneUrls( + raw: Schema.Schema.Type, +): SourceControlRepositoryCloneUrls { + return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; +} + +const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); + +type PullRequestListState = "open" | "closed" | "merged" | "all"; + +/** The pull request fields every read selects, in GraphQL. */ +const PULL_REQUEST_NODE_SELECTION = + "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; +const GRAPHQL_STATES: Record> = { + open: ["OPEN"], + closed: ["CLOSED"], + merged: ["MERGED"], + all: ["OPEN", "CLOSED", "MERGED"], +}; +/** + * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one + * single lookup, but half that keeps each answer near half a second. + */ +const HEAD_LOOKUPS_PER_DOCUMENT = 50; +/** + * How long a head lookup waits for company. Branch discovery reaches GitHub only after each + * branch's own git reads, so lookups started together arrive tens of milliseconds apart. + * A background sweep's lookups spread over up to ~300ms, and every document costs a point no + * matter how few heads it holds, so reads without reserve wait longer. + */ +const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; +const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; +/** + * Background documents fill up under the longer window, and a failed document fails every head + * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own + * processing limit; twenty-five took ~7s. + */ +const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; +/** A full document is 5,000 rows of well under 2 KB each. */ +const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; +/** + * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch + * named like a busy default (`main`) is the case this bounds; the owner's own row is near the + * top because the newest come first. + */ +const OWNER_HEAD_SCAN_LIMIT = 100; + +class PullRequestsByHeadRead extends Request.Class< + { + readonly host: string; + readonly owner: string; + readonly name: string; + readonly headRefName: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly allowReserve: boolean; + }, + ReadonlyArray, + GitHubFailure +> {} + +/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ +function buildPullRequestsByHeadQuery( + lookups: ReadonlyArray>, +): { readonly document: string; readonly variables: Record } { + const variables: Record = {}; + const declarations: string[] = ["$owner: String!", "$name: String!"]; + const selections: string[] = []; + for (const [index, lookup] of lookups.entries()) { + variables[`h${index}`] = lookup.headRefName; + variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; + declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); + // `gh pr list` orders the same way, so a head with more matches than the limit keeps the + // same rows. + selections.push( + ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, + ); + } + return { + document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, + variables, + }; +} + +const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } + } +}`; + +const decodePullRequestsByHead = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + ), + ), + }), + }), +); + +const decodePullRequestByNumber = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), + }), + }), +); + +/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ +export function pullRequestCheckoutBranchName(input: { + readonly headRefName: string; + readonly headOwner: string | null; + readonly isCrossRepository: boolean; + readonly defaultBranch: string | null; +}): string { + // gh prefixes the owner only where the fork's branch would take over the default branch's + // name, which is the one collision every fork pull request from `main` would hit. + return input.isCrossRepository && + input.headOwner !== null && + input.defaultBranch !== null && + input.headRefName === input.defaultBranch + ? `${input.headOwner}/${input.headRefName}` + : input.headRefName; +} + +/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ +const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => + context === undefined ? undefined : new URL(context.provider.baseUrl).host; + export const make = Effect.gen(function* () { - const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const environment = yield* HostProcessEnvironment; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubSourceControlProvider.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + /** + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + */ + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( + function* (input: { readonly cwd: string; readonly host?: string | undefined }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const { host, locator } = resolveGitHubRepository({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + hostHint: input.host, + defaultHost, + }); + if (locator !== null) return locator; + return yield* failure( + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }, + ); + + const graphqlJson = ( + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + decodeDetail: string, + ) => + api.graphql(input).pipe( + Effect.mapError(fromGitHubApiError), + Effect.flatMap((raw) => { + const decoded = decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(failure(decodeDetail, decoded.failure)); + }), + ); + + const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); + + const headResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + [ + request.host, + request.owner, + request.name, + String(request.allowReserve), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? "", + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ].join("\0"), + resolver: (entries) => { + const [first] = entries; + const { host, owner, name, allowReserve } = first.request; + const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); + return graphqlJson( + { + host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner, name, ...query.variables }, + allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + "GitHub returned an invalid change request list.", + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail(notFound("The repository could not be read.")) + : Effect.succeed(decoded.data.repository), + ), + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); + const interactiveHeadResolver = headResolver.pipe( + RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), + ); + const backgroundHeadResolver = headResolver.pipe( + RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), + RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), + ); + + /** + * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the + * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together + * share one GraphQL document. + */ + const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly host?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); + // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the + // owner is matched on the rows it returns. + const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); + const rows = yield* Effect.request( + new PullRequestsByHeadRead({ + ...locator, + headRefName, + state: input.state, + limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + allowReserve: input.allowReserve, + }), + input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, + ); + if (!ownerMatch) return rows; + const headOwner = ownerMatch[1]!.toLowerCase(); + return rows + .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) + .slice(0, limit); + }); - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { - return github - .listOpenPullRequests({ + const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly host?: string | undefined; + }) { + const parsed = parsePullRequestReference(input.reference); + if (parsed.kind === "branch") { + // `gh pr view ` prefers an open pull request, then the newest of any state. + const lookup = (state: PullRequestListState) => + listByHead({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); + headSelector: parsed.headSelector, + state, + limit: 1, + host: input.host, + allowReserve: true, + }); + const [open] = yield* lookup("open"); + const found = open ?? (yield* lookup("all"))[0]; + if (found === undefined) return yield* notFound("No pull request has this head branch."); + return found; } + const locator = + parsed.kind === "url" + ? parsed.locator + : yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const decodeDetail = "GitHub returned an invalid pull request."; + const decoded = yield* graphqlJson( + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, + allowReserve: true, + }, + decodePullRequestByNumber, + decodeDetail, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) return yield* notFound("The pull request does not exist."); + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) + return yield* failure(decodeDetail, new Error("Malformed pull request.")); + return record; + }, + ); + + const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest( + { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }, + REPOSITORY_NOT_FOUND, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( + host: string, + ) { + const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub request failed.", decoded.failure); + } + return decoded.success.login; + }); + + // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client + // sees is fixed and the raw error stays in `cause`. + const gitFailure = (cause: unknown) => + failure("The pull request could not be checked out with git.", cause); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ + operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, + cwd, + args, + }); + + /** + * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a + * fork gets a remote of its own), checked out under the name gh would give it, and set to + * track the head. A head branch that is gone is read from the base's `refs/pull//head`. + * An existing branch fast-forwards, or with `force` is reset to the pull request. + */ + const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure), + ); + const remoteFor = (nameWithOwner: string) => + parseFetchRemotes(remotes).find( + (remote) => + normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.name ?? null; + const baseRemote = Effect.suspend(() => { + const known = remoteFor(baseNameWithOwner); + return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); + }); + // A fork's branch named like the base's default branch is checked out under the owner's + // prefix. Without the default branch that collision cannot be ruled out, and the checkout + // would reset the local default branch to the fork's commit, so it fails instead. + const defaultBranch = isCrossRepository + ? yield* readRepository(base).pipe( + Effect.map((repository) => repository.default_branch ?? null), + ) + : null; + const localBranch = pullRequestCheckoutBranchName({ + headRefName: pullRequest.headRefName, + headOwner, + isCrossRepository, + defaultBranch, + }); - return github - .listPullRequestsByHead({ + /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ + const headRemote = Effect.gen(function* () { + if (!isCrossRepository) return yield* baseRemote; + if (headNameWithOwner === null) return yield* failure("The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); + const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? 20, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map((items) => - items.map(({ updatedAt, ...summary }) => ({ - ...toChangeRequest({ - ...summary, - ...(Option.isSome(updatedAt) - ? { updatedAt: DateTime.formatIso(updatedAt.value) } - : {}), - }), - updatedAt, - })), - ), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); - }; + preferredName: headOwner ?? "fork", + url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, + }); + }); + + const exists = (yield* git + .listLocalBranchNames(input.cwd) + .pipe(Effect.mapError(gitFailure))).includes(localBranch); + + // The commit to check out, fetched from the head branch where it still exists. + const target = yield* Effect.gen(function* () { + const remoteName = yield* headRemote; + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch: pullRequest.headRefName, + }); + return { + ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, + upstream: { remoteName, remoteBranch: pullRequest.headRefName }, + }; + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* runGit(input.cwd, "fetchPullRef", [ + "fetch", + "--quiet", + "--no-tags", + yield* baseRemote, + `refs/pull/${pullRequest.number}/head`, + ]); + const { commitSha } = yield* git.resolveCommit({ + cwd: input.cwd, + revision: "FETCH_HEAD", + }); + return { ref: commitSha, upstream: null }; + }), + ), + Effect.mapError(gitFailure), + ); + + yield* Effect.gen(function* () { + if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + if (exists) { + yield* runGit( + input.cwd, + "sync", + input.force === true + ? ["reset", "--hard", "--quiet", target.ref] + : ["merge", "--ff-only", "--quiet", target.ref], + ); + } + // Tracking is set once the branch is the pull request's, so a sync that fails leaves an + // existing branch's upstream as it was. + if (target.upstream !== null) { + yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); + } + }).pipe(Effect.mapError(gitFailure)); + }, + ); + + /** Reports a failure against the operation and request that made it, with transport-safe context. */ + const providerError = + ( + operation: string, + cwd: string, + context?: { readonly reference?: string; readonly repository?: string }, + ) => + (error: GitHubFailure) => + new SourceControlProviderError({ + provider: "github", + operation, + cwd, + ...(context?.reference === undefined + ? {} + : { + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference, + ), + }), + ...(context?.repository === undefined + ? {} + : { + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + context.repository, + ), + }), + detail: error.detail, + cause: error.cause, + }); const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( input: { readonly cwd: string; readonly url: URL }, @@ -385,121 +905,122 @@ export const make = Effect.gen(function* () { if (!match) return undefined; return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); }, - listChangeRequests, + listChangeRequests: (input) => + // An open lookup is a user waiting on a status; the rest may be a background sweep. + (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( + Effect.flatMap((allowReserve) => + listByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: input.state, + limit: input.limit ?? (input.state === "open" ? 1 : 20), + host: contextHost(input.context), + allowReserve, + }), + ), + Effect.map((records) => records.map(toChangeRequest)), + Effect.mapError( + providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), + ), + ), getChangeRequest: (input) => - github - .getPullRequest({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), - ), + readPullRequest({ ...input, host: contextHost(input.context) }).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + providerError("getChangeRequest", input.cwd, { reference: input.reference }), ), + ), createChangeRequest: (input) => - github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe( + Effect.mapError((cause) => + failure("The pull request description could not be read.", cause), + ), + ); + yield* rest({ + host: locator.host, + operation: "createPullRequest", + method: "POST", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, + // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. + // gh allows maintainer edits unless told otherwise; the API's default is not documented. + body: { + base: input.baseRefName, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }).pipe( + Effect.mapError( + providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), ), + ), getRepositoryCloneUrls: (input) => - github.getRepositoryCloneUrls(input).pipe( + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) return yield* failure("Repositories are named owner/name."); + return repositoryCloneUrls(yield* readRepository(locator)); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), ), ), createRepository: (input) => - github.createRepository(input).pipe( + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(locator.host); + const owner = locator?.owner ?? viewer; + const name = locator?.name ?? input.repository.trim(); + const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); + const response = yield* rest( + { + host, + operation: "createRepository", + method: "POST", + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }, + // GitHub answers 404 for an organization the account cannot create repositories in. + `No organization named ${owner ?? "that"} that this account can create repositories in.`, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return repositoryCloneUrls(decoded.success); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("createRepository", input.cwd, { repository: input.repository }), ), ), getDefaultBranch: (input) => - github - .getDefaultBranch({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - cwd: input.cwd, - detail: error.message, - cause: error, - }), - ), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ + cwd: input.cwd, + host: contextHost(input.context), + }); + const repository = yield* readRepository(locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( + checkoutPullRequest(input).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), + providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), ), ), }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d9bbb51d7a616..6101d148a6327 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index c9a7164c8b310..f715eff1dea01 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,6 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,8 +97,8 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts new file mode 100644 index 0000000000000..3b33f0c38e406 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts @@ -0,0 +1,87 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + gitHubApiHostForRemote, + selectGitHubBaseRepository, +} from "./gitHubRepositoryResolution.ts"; + +describe("selectGitHubBaseRepository", () => { + const remotes = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + const select = (input: { remotes: string; resolved?: string }) => + selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + + it("picks the repository gh reads without a prompt", () => { + assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { + owner: "me", + name: "web", + }); + const fork = remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/Acme/Web.git"], + ); + assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); + assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { + owner: "me", + name: "web", + }); + assert.deepStrictEqual( + select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), + { owner: "acme", name: "other" }, + ); + // gh ranks remote names in any case. + assert.deepStrictEqual( + select({ + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Upstream", "git@github.com:acme/web.git"], + ), + }), + { owner: "acme", name: "web" }, + ); + }); + + it("leaves gh to choose when it might weigh the remotes differently", () => { + for (const input of [ + { remotes: "" }, + { + remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["mirror", "git@gitlab.com:me/web.git"], + ), + }, + { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Origin", "git@github.com:acme/web.git"], + ), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", + }, + ]) { + assert.strictEqual(select(input), null); + } + }); +}); + +describe("gitHubApiHostForRemote", () => { + it("reads an SSH alias through github.com and a real host as itself", () => { + assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); + assert.strictEqual( + gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", + ); + assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + }); +}); diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts new file mode 100644 index 0000000000000..8d4cbf038bfdb --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts @@ -0,0 +1,190 @@ +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +/** A repository on a GitHub host: the API it is read through, and its owner and name. */ +export interface GitHubRepositoryLocator { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +/** The fetch remotes in `git remote -v` output, by name. */ +export function parseFetchRemotes( + remotes: string, +): ReadonlyArray<{ readonly name: string; readonly url: string }> { + return remotes + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ name: match[1]!, url: match[2]! })); +} + +/** + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a + * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin + * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of + * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * + * Null whenever gh might weigh the remotes differently: a remote on another host or under an + * SSH alias, more than one mark, or several remotes with none of those names. Callers then + * fall back to the provider's remote, then to the best-ranked remote on the host. + */ +export function selectGitHubBaseRepository(input: { + readonly remotes: string; + readonly resolved: string; + readonly host: string; +}): { readonly owner: string; readonly name: string } | null { + const host = input.host.toLowerCase(); + const repositories = new Map(); + for (const remote of parseFetchRemotes(input.remotes)) { + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(remote.name, { owner, name }); + } + const marks = input.resolved + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); + if (marks.length > 1) return null; + const [mark] = marks; + if (mark) { + if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; + const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); + return owner && name && rest.length === 0 ? { owner, name } : null; + } + // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the + // top place has no defined winner. + const score = (remoteName: string) => + ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; + const ranked = [...repositories.entries()].toSorted( + ([left], [right]) => score(right) - score(left), + ); + const [top, next] = ranked; + return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) + ? top[1] + : null; +} + +/** + * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, + * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller + * names when no repository was found there. GH_REPO is the caller's to check first. + */ +export function resolveGitHubRepository(input: { + readonly remotes: string; + readonly resolved: string; + /** The checkout's GitHub API host, when the caller already knows it. */ + readonly hostHint: string | undefined; + readonly defaultHost: string; +}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { + const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ + ...remote, + host: gitHubApiHostForRemote(remote.url), + })); + const host = + (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + input.defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: input.remotes, + resolved: input.resolved, + host, + }); + if (selected !== null) return { host, locator: { host, ...selected } }; + // gh's own order without its prompt: upstream, github, origin, then the first remote, among + // the ones on this host (an SSH alias counts as its API host). + const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); + const candidates = fetchRemotes + .filter((remote) => remote.host === host) + .toSorted((left, right) => { + const l = rank(left.name); + const r = rank(right.name); + return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); + }); + for (const remote of candidates) { + const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); + if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; + } + return { host, locator: null }; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +export function parseGitHubRepositorySelector( + selector: string, + defaultHost: string, +): GitHubRepositoryLocator | null { + const trimmed = selector.trim().replace(/\.git$/i, ""); + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + return owner && name && rest.length === 0 + ? { host: url.host.toLowerCase(), owner, name } + : null; + } catch { + return null; + } + } + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; + if (parts.length === 3) + return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + return null; +} + +/** + * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request + * URL, or a branch name. + */ +export function parsePullRequestReference( + reference: string, +): + | { readonly kind: "number"; readonly number: number } + | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } + | { readonly kind: "branch"; readonly headSelector: string } { + const trimmed = reference.trim(); + const numbered = /^#?([1-9]\d*)$/.exec(trimmed); + if (numbered) return { kind: "number", number: Number(numbered[1]) }; + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); + if (match) { + return { + kind: "url", + locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, + number: Number(match[3]), + }; + } + } catch { + // Not a URL after all; read it as a branch. + } + } + return { kind: "branch", headSelector: trimmed }; +} + +/** + * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH + * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through + * `github.com`, which is what such an alias almost always stands for (issue #6198). + */ +export function gitHubApiHostForRemote(remoteUrl: string): string | null { + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider === null) return null; + const host = new URL(provider.baseUrl).host.toLowerCase(); + // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. + if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { + return host.includes("github") ? "github.com" : null; + } + return provider.kind === "github" ? host : null; +} + +/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +function apiHostForHint(host: string): string { + const normalized = host.toLowerCase(); + return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f64ecb12f87e..55d5ec85b5a54 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), From d720210996a514368ba99f4860063110033d93fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:26 -0700 Subject: [PATCH 078/680] refactor(server): GitHub rate limits read the response headers (#16986) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/measure-pr-preview.ts | 16 +- .../pullRequest/GitHubPullRequestApi.test.ts | 122 ++++----- .../src/sourceControl/GitHubApi.test.ts | 76 ++++-- apps/server/src/sourceControl/GitHubApi.ts | 50 ++-- .../GitHubSourceControlProvider.test.ts | 2 + .../GitHubSourceControlProvider.ts | 10 +- .../sourceControl/githubGraphQlBudget.test.ts | 241 ------------------ .../src/sourceControl/githubGraphQlBudget.ts | 150 ----------- .../src/sourceControl/githubQuota.test.ts | 86 +++++++ apps/server/src/sourceControl/githubQuota.ts | 122 +++++++++ 10 files changed, 350 insertions(+), 525 deletions(-) delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.test.ts delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.ts create mode 100644 apps/server/src/sourceControl/githubQuota.test.ts create mode 100644 apps/server/src/sourceControl/githubQuota.ts diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index f1fc491b1a2bb..e8f0727ac93cf 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -1,7 +1,7 @@ // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. -// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. +// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; @@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; import * as ServerSettings from "../src/serverSettings.ts"; -import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption( }), ), ); +/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */ +function withRateLimit(query: string): string { + const end = query.lastIndexOf("}"); + return query.trimStart().startsWith("mutation") || end === -1 + ? query + : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; +} + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -40,7 +48,7 @@ const measuredApi = Layer.effect( return GitHubApi.GitHubApi.of({ ...api, graphql: (input) => - api.graphql(input).pipe( + api.graphql({ ...input, query: withRateLimit(input.query) }).pipe( Effect.tap((body) => Effect.sync(() => reads.push({ @@ -66,7 +74,7 @@ const measuredApi = Layer.effect( }), ).pipe( Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 20fc1dd885266..294d053d6f056 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; @@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => ); /** - * A transport that answers from the mocks above but spends the real GraphQL budget, the way - * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + * A transport that answers from the mocks above but spends the real GraphQL quota from each + * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. */ const mockApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; return GitHubApi.GitHubApi.of({ graphql: (input) => - budget - .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) - .pipe( - Effect.flatMap((query) => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input, query }), - ), - Effect.map((answer) => answer.body), - Effect.tap((body) => budget.observe(input.host, body)), + GitHubApi.AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + quota.admit(input.host, "graphql", { + allowReserve: input.allowReserve ?? interactive, + }), ), + Effect.andThen(() => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input }), + ), + Effect.tap((answer) => quota.observe(input.host, answer.headers)), + Effect.map((answer) => answer.body), + ), rest: (input) => mockedExecute({ kind: "rest", ...input }), credential: (host) => mockedCredential(host), }); @@ -98,7 +101,7 @@ const mockApi = Layer.effect( const layer = it.layer( GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provide(NodeCrypto.layer), Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), @@ -109,8 +112,19 @@ function output( body: string, truncated = false, invalidUtf8 = false, + headers: Readonly> = {}, ): GitHubApi.GitHubRestResponse { - return { status: 200, headers: {}, body, truncated, invalidUtf8 }; + return { status: 200, headers, body, truncated, invalidUtf8 }; +} + +/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ +function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { + return { + "x-ratelimit-resource": "graphql", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), + }; } /** @@ -493,7 +507,7 @@ it.effect( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.merge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), @@ -538,15 +552,13 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { - it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + it.effect("holds background previews once an answer reports the reserve, until it resets", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const resetAt = "2099-08-13T14:00:00Z"; - yield* budget.observe( - "preview-budget.example", - encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), - ); + yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); + // GitHub's count, not ours: the answer below reports the reserve has been reached. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => { author: null, }, }, - rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, }, }), + false, + false, + graphqlQuota(499, resetAt), ), ), ); @@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => { host: "preview-budget.example", number: 7, }; - const results = yield* Effect.all( - Array.from({ length: 20 }, (_, index) => - cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); - for (const result of results) { - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); - } - } + yield* cli.getPullRequestPreview(input); + const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); + expect(held._tag).toBe("SourceControlRateLimitPausedError"); expect(mockedExecute).toHaveBeenCalledTimes(1); yield* TestClock.setTime(Date.parse(resetAt)); yield* cli.getPullRequestPreview(input); @@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - it.effect("accounts for the avatar lookup in the GraphQL budget", () => + it.effect("looks up avatars by node id in one GraphQL read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( Effect.succeed( @@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => { encodeJson({ data: { nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 4_999, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), ), @@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); expect(avatars.get("octocat")).toBe("https://avatars/octocat"); }), ); @@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { const response = coreResponse(); mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - ...response, - data: { - ...response.data, - rateLimit: { - cost: 1, - limit: 5000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), + Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { @@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ); @@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => { } as const; yield* cli.getViewerAccess(input); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); const error = yield* Effect.flip(cli.getViewerAccess(input)); @@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index a5c0f82be4431..60e2c6f1b45d5 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp ); const layer = GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); return { layer, requests, invalidations: () => invalidations }; @@ -97,15 +97,8 @@ describe("environmentToken", () => { }); describe("GitHubApi", () => { - it.effect("sends GraphQL with the token and records the reported budget", () => { - const { layer, requests } = harness(() => - json({ - data: { - viewer: { login: "julius" }, - rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, - }, - }), - ); + it.effect("sends GraphQL with the token, and the document as written", () => { + const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); return Effect.gen(function* () { yield* TestClock.setTime(NOW); const api = yield* GitHubApi.GitHubApi; @@ -117,11 +110,16 @@ describe("GitHubApi", () => { expect(body).toContain('"login":"julius"'); expect(requests[0]!.url).toBe("https://api.github.com/graphql"); expect(requests[0]!.headers.authorization).toBe("Bearer first"); + // No `rateLimit` selection is added: the quota is read off the response headers. + const sent = requests[0]!.body; + expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( + '"query":"query { viewer { login } }"', + ); }).pipe(Effect.provide(layer)); }); it.effect( - "traces the operation, path, query and cost, never the query string or variables", + "traces the operation, path, query and quota, never the query string or variables", () => { const spans: Array = []; const tracer = Tracer.make({ @@ -134,17 +132,7 @@ describe("GitHubApi", () => { const { layer } = harness((request) => request.url.endsWith("/graphql") ? json( - { - data: { - viewer: { login: "julius" }, - rateLimit: { - cost: 3, - limit: 5000, - remaining: 4990, - resetAt: "2026-10-05T13:00:00Z", - }, - }, - }, + { data: { viewer: { login: "julius" } } }, { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, ) : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), @@ -168,8 +156,10 @@ describe("GitHubApi", () => { expect(graphql).toMatchObject({ "github.operation": "getPullRequestDetail", "github.graphql.query": "query($body: String!) { viewer { login } }", - "github.graphql.cost": 3, - "github.graphql.remaining": 4990, + }); + expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ + "github.ratelimit.remaining": 4990, + "github.ratelimit.resource": "graphql", }); expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); @@ -258,6 +248,42 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { + const reset = Math.floor(NOW / 1000) + 600; + const quota = (remaining: number) => ({ + "x-ratelimit-resource": "core", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset), + }); + let remaining = 499; + const { layer, requests } = harness((request) => + request.url.endsWith("/graphql") + ? json({ data: { viewer: { id: "1" } } }) + : json({ ok: true }, { headers: quota(remaining--) }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. + yield* api.rest(read); + const refused = yield* Effect.flip(api.rest(read)); + expect(refused).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + // A user's own request may spend the reserve, and GraphQL has a quota of its own. + yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); + expect(requests).toHaveLength(3); + // The reset gives the background its quota back. + yield* TestClock.setTime(reset * 1000); + yield* api.rest(read); + expect(requests).toHaveLength(4); + }).pipe(Effect.provide(layer)); + }); + it.effect("carries GitHub's own reason for a refused REST request", () => { const { layer } = harness(() => json( diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 0da53e0230b17..5f29e8b4053d1 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT = Duration.seconds(30); @@ -218,16 +218,6 @@ function rateLimitAttributes( return attributes; } -const decodeGraphQlCost = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), - }), - }), - ), -); - /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, @@ -354,7 +344,7 @@ function classify(input: { export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const credentials = yield* GitHubCredentials.GitHubCredentials; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const gate = yield* Semaphore.make(CONCURRENCY); @@ -403,10 +393,17 @@ export const make = Effect.gen(function* () { }); const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // this reads today is `core`; a `search/` read would need its own resource here. A refusal + // still pauses the whole host, the key PullRequestService records its own backoff under. + const resource = input.graphql === true ? "graphql" : "core"; const key = { provider: "github" as const, host }; const run = Effect.gen(function* () { - const lease = yield* limits - .check(key, input.allowReserve ? { allowPaused: true } : undefined) + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) .pipe( Effect.tapError((paused) => Effect.annotateCurrentSpan({ @@ -452,6 +449,7 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; + yield* quota.observe(host, headers); yield* Effect.annotateCurrentSpan({ "http.response.status_code": status, ...rateLimitAttributes(headers), @@ -556,17 +554,15 @@ export const make = Effect.gen(function* () { : input.query, }); return yield* Effect.gen(function* () { - const query = yield* budget.query( - host, - input.query, - allowReserve ? { allowReserve: true } : undefined, - ); const response = yield* send({ host, operation: input.operation, request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), ), maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, allowReserve, @@ -581,14 +577,6 @@ export const make = Effect.gen(function* () { status: response.status, }); } - yield* budget.observe(host, response.body); - const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); - if (cost !== undefined) { - yield* Effect.annotateCurrentSpan({ - "github.graphql.cost": cost.data.rateLimit.cost, - "github.graphql.remaining": cost.data.rateLimit.remaining, - }); - } return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); }, @@ -600,11 +588,11 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(GitHubApi, make); /** - * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An - * application builds this once so every GitHub reader shares one budget and one pause per host. + * The transport with the credential, quota reserve and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one view of each quota per host. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index b70bfff78f6b7..caa91a5ac937b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => { }); assert.strictEqual(requests[0]!.method, "POST"); assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + // A user's own write, so the background's reserve is not held against it. + assert.strictEqual(requests[0]!.allowReserve, true); assert.deepStrictEqual(requests[0]!.body, { base: "main", head: "me:feature", diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 86edda994ccef..223a2ec58a264 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -679,7 +679,12 @@ export const make = Effect.gen(function* () { const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( host: string, ) { - const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const response = yield* rest({ + host, + operation: "getViewer", + path: "user", + allowReserve: true, + }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { return yield* failure("GitHub request failed.", decoded.failure); @@ -944,6 +949,8 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "createPullRequest", method: "POST", + // A user's own write may spend the reserve the background leaves for it. + allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. // gh allows maintainer edits unless told otherwise; the API's default is not documented. @@ -990,6 +997,7 @@ export const make = Effect.gen(function* () { host, operation: "createRepository", method: "POST", + allowReserve: true, path: isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, body: { name, private: input.visibility === "private" }, diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted file mode 100644 index 26f9747f32026..0000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import { CredentialScope } from "./SourceControlRateLimit.ts"; - -const RESET_AT = "2026-08-13T14:00:00.000Z"; -const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; -const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); -const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); - -function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string { - return JSON.stringify({ - data: { - viewer: { login: "bilal" }, - rateLimit: { cost, limit, remaining, resetAt }, - }, - }); -} - -describe("GitHub GraphQL budget", () => { - it.effect("adds rate metadata to a read query", () => - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - - const query = yield* budget.query( - "github.com", - 'query { repository(owner: "acme", name: "web") { name } }', - ); - - expect(query).toContain("rateLimit { cost limit remaining resetAt }"); - expect(query).toContain('repository(owner: "acme", name: "web") { name }'); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("protects the last ten percent with the shared provider cooldown error", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - provider: "github", - host: "github.com", - retryAt: Date.parse(RESET_AT), - }); - expect(error.message).toBe( - "github requests to github.com are paused until the rate limit resets.", - ); - - yield* TestClock.setTime(AFTER_RESET); - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps hosts isolated", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error._tag).toBe("SourceControlRateLimitPausedError"); - expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("isolates query reservations and observations by credential", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = budget.query("github.com", "query { viewer { login } }"); - yield* budget - .observe("github.com", rateLimit(0)) - .pipe(Effect.provideService(CredentialScope, "first")); - yield* budget - .observe("github.com", rateLimit(5000)) - .pipe(Effect.provideService(CredentialScope, "second")); - yield* query.pipe(Effect.provideService(CredentialScope, "second")); - const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps the lower remaining value from out-of-order responses", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("learns a cheaper observed cost without restoring reserved quota", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = "query { viewer { login } }"; - yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8)); - yield* budget.query("github.com", query); - // The admission reserved eight points, but the completed read only cost one. - yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1)); - - for (let count = 0; count < 4; count += 1) { - expect(yield* budget.query("github.com", query)).toContain("rateLimit"); - } - const error = yield* Effect.flip(budget.query("github.com", query)); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores a response from an older reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("accepts a response from a later reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1_000)); - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows reads above the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(515)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows an interactive read to use the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - expect( - yield* budget.query("github.com", "query { viewer { login } }", { - allowReserve: true, - }), - ).toContain("rateLimit"); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("reserves an admitted query cost before its response", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(514)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("stops interactive reads when the reserve is exhausted", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1)); - yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true }); - const error = yield* budget - .query("github.com", "query { viewer { login } }", { allowReserve: true }) - .pipe(Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores malformed or partial rate metadata", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", "{"); - yield* budget.observe( - "github.com", - '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}', - ); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("does not add a read field to a mutation", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const mutation = "mutation { addComment(input: {}) { clientMutationId } }"; - yield* budget.observe("github.com", rateLimit(0)); - - expect(yield* budget.query("github.com", mutation)).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); -}); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts deleted file mode 100644 index 1021f6cde9fa8..0000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Predicate from "effect/Predicate"; - -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; - -const GRAPHQL_RESERVE_RATIO = 0.1; -const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }"; - -interface GraphQlBudgetSnapshot { - readonly cost: number; - readonly limit: number; - readonly remaining: number; - readonly resetAtMs: number; -} - -export class GitHubGraphQlBudget extends Context.Service< - GitHubGraphQlBudget, - { - readonly query: ( - host: string, - document: string, - options?: { readonly allowReserve: boolean }, - ) => Effect.Effect; - readonly observe: (host: string, raw: string) => Effect.Effect; - } ->()("t3/sourceControl/githubGraphQlBudget") {} - -function hostKey(host: string): string { - return host.trim().toLowerCase(); -} - -function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { - try { - const parsed: unknown = JSON.parse(raw); - if ( - !Predicate.isObject(parsed) || - !Predicate.isObject(parsed.data) || - !Predicate.isObject(parsed.data.rateLimit) - ) { - return null; - } - const { cost, limit, remaining, resetAt } = parsed.data.rateLimit; - if ( - typeof cost !== "number" || - !Number.isFinite(cost) || - cost < 0 || - typeof limit !== "number" || - !Number.isFinite(limit) || - limit <= 0 || - typeof remaining !== "number" || - !Number.isFinite(remaining) || - remaining < 0 || - typeof resetAt !== "string" - ) { - return null; - } - const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; - } catch { - return null; - } -} - -function isReadOperation(document: string): boolean { - const operation = document.trimStart(); - return operation.startsWith("query") || operation.startsWith("{"); -} - -function withRateLimit(document: string): string { - if (!isReadOperation(document)) return document; - const end = document.lastIndexOf("}"); - if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document; - return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const snapshots = yield* Ref.make>(new Map()); - - const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( - function* (host, document, options) { - if (!isReadOperation(document)) return document; - const now = yield* Clock.currentTimeMillis; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - const retryAt = yield* Ref.modify(snapshots, (current) => { - const snapshot = current.get(key); - if (snapshot === undefined) return [null, current] as const; - if (snapshot.resetAtMs <= now) { - const next = new Map(current); - next.delete(key); - return [null, next] as const; - } - const remaining = snapshot.remaining - Math.max(1, snapshot.cost); - if ( - remaining < 0 || - (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) - ) { - return [snapshot.resetAtMs, current] as const; - } - const next = new Map(current); - next.set(key, { ...snapshot, remaining }); - return [null, next] as const; - }); - if (retryAt !== null) { - return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", - host: hostKey(host), - retryAt, - }); - } - return withRateLimit(document); - }, - ); - - const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn( - "GitHubGraphQlBudget.observe", - )(function* (host, raw) { - const snapshot = snapshotFrom(raw); - if (snapshot === null) return; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - yield* Ref.update(snapshots, (current) => { - const previous = current.get(key); - // Concurrent reads can finish out of order. Quota only falls within one reset window, and - // an answer from an older window must not replace the current one. - if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) { - return current; - } - const next = new Map(current); - // Keep the conservative balance, but learn the observed cost even when our reservation - // was larger. Otherwise one expensive read makes every later cheap read spend its cost. - next.set( - key, - previous !== undefined && - snapshot.resetAtMs === previous.resetAtMs && - snapshot.remaining >= previous.remaining - ? { ...previous, cost: snapshot.cost } - : snapshot, - ); - return next; - }); - }); - - return GitHubGraphQlBudget.of({ query, observe }); -}); - -export const layer = Layer.effect(GitHubGraphQlBudget, make); diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts new file mode 100644 index 0000000000000..e21970cecca72 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as GitHubQuota from "./githubQuota.ts"; +import { CredentialScope } from "./SourceControlRateLimit.ts"; + +const RESET = Date.parse("2099-08-13T14:00:00Z"); +const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ + "x-ratelimit-resource": resource, + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset / 1000), +}); + +describe("GitHubQuota", () => { + it.effect("refuses a background request below the reserve, per quota and account", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(499)); + const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); + assert.strictEqual(refused.retryAt, RESET); + // Another quota, another host and another account each have their own balance. + yield* quota.admit("github.com", "core"); + yield* quota.admit("ghe.example", "graphql"); + yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("takes the latest balance in a window, and ignores an older window", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // GitHub can report more left later in the same window, from another region. + yield* quota.observe("github.com", headers(900)); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // An answer from an older window says nothing about this one. + yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // A later window replaces it. + yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); + yield* quota.admit("github.com", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("lets interactive requests spend the reserve but not an empty quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(1)); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + yield* quota.observe("github.com", headers(0)); + const refused = yield* Effect.flip( + quota.admit("github.com", "graphql", { allowReserve: true }), + ); + assert.strictEqual(refused.retryAt, RESET); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(501, "core")); + // Conditional reads answer 304 with the balance unchanged, however many are sent. + for (let index = 0; index < 5; index++) { + yield* quota.admit("github.com", "core"); + yield* quota.observe("github.com", headers(501, "core")); + } + yield* quota.admit("github.com", "core"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(0)); + yield* TestClock.setTime(RESET); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); + yield* quota.admit("other.example", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); +}); diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts new file mode 100644 index 0000000000000..c553535019f45 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.ts @@ -0,0 +1,122 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +/** The share of each quota a background read leaves for a user's next click. */ +const RESERVE_RATIO = 0.1; + +/** + * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). + * Each is spent and reset on its own, so each is tracked on its own. + */ +type GitHubQuotaResource = string; + +interface QuotaSnapshot { + readonly limit: number; + readonly remaining: number; + readonly resetAtMs: number; +} + +/** + * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer + * carries. Null when the answer named no quota, which an Enterprise host with limits off does. + */ +function quotaFromHeaders( + headers: Readonly>, +): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { + const resource = headers["x-ratelimit-resource"]?.trim(); + const limit = Number(headers["x-ratelimit-limit"]); + const remaining = Number(headers["x-ratelimit-remaining"]); + const reset = Number(headers["x-ratelimit-reset"]); + if ( + !resource || + !Number.isFinite(limit) || + limit <= 0 || + !Number.isFinite(remaining) || + remaining < 0 || + !Number.isFinite(reset) + ) { + return null; + } + return { resource, limit, remaining, resetAtMs: reset * 1_000 }; +} + +/** + * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that + * would dip below it is refused until the quota resets, so a user's next click still has budget. + * + * The balance is GitHub's own count from the latest answer's headers, so it already includes + * what anything else spent with the same token, such as an agent's `gh` commands. Requests in + * flight together all see that one balance; the reserve absorbs what they spend before their + * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub + * spends nothing, and a local count would drift below GitHub's for the rest of the window. + * + * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + */ +export class GitHubQuota extends Context.Service< + GitHubQuota, + { + readonly admit: ( + host: string, + resource: GitHubQuotaResource, + options?: { readonly allowReserve: boolean }, + ) => Effect.Effect; + readonly observe: ( + host: string, + headers: Readonly>, + ) => Effect.Effect; + } +>()("t3/sourceControl/githubQuota") {} + +const make = Effect.gen(function* () { + const snapshots = yield* Ref.make>(new Map()); + const keyOf = (host: string, resource: string, scope: string) => + `${host.trim().toLowerCase()}\0${resource}\0${scope}`; + + const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( + function* (host, resource, options) { + const now = yield* Clock.currentTimeMillis; + const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); + const snapshot = (yield* Ref.get(snapshots)).get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return; + const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; + if (snapshot.remaining >= floor) return; + return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: host.trim().toLowerCase(), + retryAt: snapshot.resetAtMs, + }); + }, + ); + + const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( + function* (host, headers) { + const quota = quotaFromHeaders(headers); + if (quota === null) return; + const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); + yield* Ref.update(snapshots, (current) => { + const previous = current.get(key); + // The latest answer wins. GitHub serves requests from several regions, so a later answer + // can report more left in the same window, and keeping the lower one would hold + // background work at the reserve until the reset. An answer from an older window says + // nothing of this one. + if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; + const next = new Map(current); + next.set(key, { + limit: quota.limit, + remaining: quota.remaining, + resetAtMs: quota.resetAtMs, + }); + return next; + }); + }, + ); + + return GitHubQuota.of({ admit, observe }); +}); + +export const layer = Layer.effect(GitHubQuota, make); From 83a82a46b0631234ac85748b912566d6f4b1dee3 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Wed, 7 Oct 2026 18:36:19 -0700 Subject: [PATCH 079/680] chore(review): remove the custom Approvability check (#17018) --- .coderabbit.config.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 5a9505c702765..2394e757b72d8 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,21 +1,7 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it: - -- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change. -- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides. -- Adds a subsystem or user workflow, or is a large refactor across apps or packages. -- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept. -- Changes authentication, pairing, credentials, secrets, or remote connection trust. -- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook. -- Adds, upgrades, or patches a dependency. -- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts. - -Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review. -`; - // Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit -// requests changes until its comments are resolved and its checks pass, then approves. +// requests changes until its comments are resolved, then approves. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -31,13 +17,6 @@ export default defineConfig((ctx) => ({ pre_merge_checks: { docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), - custom_checks: [ - { - name: "Approvability", - mode: isGated(ctx) ? "error" : "off", - instructions: approvability, - }, - ], }, path_filters: [ // Vendored read-only reference checkouts of upstream Effect and Alchemy From f9e16b3f66476ad1e213152708c9aa8b035085a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 19:21:59 -0700 Subject: [PATCH 080/680] perf(mobile): show the cached thread list sooner and stop the freeze after it (#16713) Co-authored-by: Claude Opus 5.5 (1M context) --- .../environment-cache-store.test.ts | 75 ++++ .../src/connection/environment-cache-store.ts | 15 +- .../src/connection/shell-cache-decoding.ts | 19 + .../src/platform/persistence.ts | 3 +- .../src/state/environmentHttpAuth.test.ts | 20 +- .../src/state/shell-sync.test.ts | 346 +++++++++++++++++- packages/client-runtime/src/state/shell.ts | 131 ++++++- .../src/state/shellPullRequests.ts | 97 +++++ .../src/state/shellReducer.test.ts | 44 ++- .../client-runtime/src/state/shellReducer.ts | 36 ++ .../src/state/shellSnapshotHttp.ts | 51 ++- 11 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/connection/shell-cache-decoding.ts create mode 100644 packages/client-runtime/src/state/shellPullRequests.ts diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 8e4ce3e621147..e1d627b504661 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -10,12 +10,14 @@ import { ThreadId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadDetailSnapshot, + type ThreadPullRequestLink, type VcsListRefsResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; @@ -254,6 +256,79 @@ describe("mobile SQLite environment cache store", () => { }), ); + it.live("returns cached threads before their pull request links, which load after", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...SHELL_SNAPSHOT.threads[0]!, pullRequests: [link] }; + const unlinked = { ...SHELL_SNAPSHOT.threads[0]!, id: ThreadId.make("thread-2") }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [linked, unlinked], + }); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); + const links = yield* shell.loadPullRequests!; + expect([...links]).toEqual([[THREAD_ID, [link]]]); + }), + ); + + it.live("keeps cached threads when their pull request links cannot be read", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 5, + url: "https://github.com/pingdotgg/t3code/pull/5", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const readable = { + ...SHELL_SNAPSHOT.threads[0]!, + id: ThreadId.make("thread-2"), + pullRequests: [link], + }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [SHELL_SNAPSHOT.threads[0]!, readable], + }); + const id = cacheId(ENVIRONMENT_ID, "shell", "snapshot"); + const payload = JSON.parse(memory.values.get(id)!); + payload.snapshot.threads[0].pullRequests = [{ number: -1 }]; + memory.values.set(id, JSON.stringify(payload)); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); + // Only the thread with the unreadable link loses its links. + expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + expect(messages).toHaveLength(1); + expect(memory.removed).toEqual([]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("round-trips schema-validated VCS refs", () => Effect.gen(function* () { const memory = makeDatabase(); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 70c2a91f67afe..99019d1d69212 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,5 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, StoredOrchestrationThreadSnapshot, Persistence, } from "@t3tools/client-runtime/platform"; @@ -16,6 +15,7 @@ import { attachProjectFaviconDatabase, projectFaviconDatabaseCache, } from "../lib/projectFaviconDatabaseCache"; +import { decodeStoredShellSnapshot } from "./shell-cache-decoding"; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; @@ -32,9 +32,6 @@ const StoredVcsRefs = Schema.Struct({ refs: VcsListRefsResult, }); -const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), -); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredOrchestrationThreadSnapshot), ); @@ -113,7 +110,15 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), + }).pipe( + // Read cached project icons in parallel with the shell so the first rows can show + // them, instead of making the thread list wait for the icon read after the decode. + Effect.zipWith( + Effect.promise(() => projectFaviconDatabaseCache.hydrate()), + (snapshot) => snapshot, + { concurrent: true }, + ), + ), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ diff --git a/apps/mobile/src/connection/shell-cache-decoding.ts b/apps/mobile/src/connection/shell-cache-decoding.ts new file mode 100644 index 0000000000000..ef70036e52417 --- /dev/null +++ b/apps/mobile/src/connection/shell-cache-decoding.ts @@ -0,0 +1,19 @@ +import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform"; +import { deferPullRequests, detachPullRequests } from "@t3tools/client-runtime/state/shell"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeStoredShell = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot); + +/** + * Decode the shell cache payload with each thread's pull request links left out, so the + * thread list can paint first; `loadPullRequests` decodes the links afterwards. + */ +export const decodeStoredShellSnapshot = Effect.fnUntraced(function* (raw: string) { + const parsed = yield* decodeJson(raw); + const rawLinks = Predicate.isObject(parsed) ? detachPullRequests(parsed.snapshot) : []; + const stored = yield* decodeStoredShell(parsed); + return { ...stored, snapshot: deferPullRequests(stored.snapshot, rawLinks) }; +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index c146adee1e883..29463ce6da585 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { DeferredShellSnapshot } from "../state/shellPullRequests.ts"; import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; export class ConnectionPersistenceError extends Schema.TaggedError()( @@ -82,7 +83,7 @@ export class EnvironmentCacheStore extends Context.Service< { readonly loadShell: ( environmentId: EnvironmentId, - ) => Effect.Effect, ConnectionPersistenceError>; + ) => Effect.Effect, ConnectionPersistenceError>; readonly saveShell: ( environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshot, diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index c7c7fc53197a2..61504668427ec 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -204,7 +204,11 @@ const LOADERS: ReadonlyArray<{ path: "/api/orchestration/shell", response: SHELL, expected: SHELL, - load: fetchEnvironmentShellSnapshot, + // Pull request links decode separately; the rows match the response on their own. + load: (input: HttpInput) => + fetchEnvironmentShellSnapshot(input).pipe( + Effect.map(({ loadPullRequests: _links, ...snapshot }) => snapshot), + ), }, { name: "thread snapshot", @@ -254,6 +258,20 @@ describe("authenticated environment HTTP requests", () => { }), ); + it.effect("keeps the status of a shell snapshot error that is not a declared error", () => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json({ error: "bad_gateway" }, { status: 502 })); + const error = yield* fetchEnvironmentShellSnapshot(harness.input).pipe( + Effect.provide(harness.httpLayer), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "RemoteEnvironmentAuthUndeclaredStatusError", + status: 502, + }); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 69d7283164987..b20d3e9cda199 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -3,6 +3,8 @@ import { ORCHESTRATION_V2_WS_METHODS, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -29,7 +31,7 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; +import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -953,4 +955,346 @@ describe("environment shell synchronization", () => { expect(Option.getOrThrow(state.snapshot).projects[0]?.title).toBe("Server reset"); }), ); + + it.effect("fills deferred cached pull request links without overwriting newer rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const otherThreadId = ThreadId.make("thread-other"); + const cachedSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [v2ThreadShell, { ...v2ThreadShell, id: otherThreadId }], + }; + const linksReady = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => + Effect.succeedSome({ + ...cachedSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as( + new Map([ + [v2ThreadShell.id, [link(1)]], + [otherThreadId, [link(2)]], + ]), + ), + ), + }), + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // No HTTP snapshot, so the cached rows stay until the socket sends deltas. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => Effect.succeedNone, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const threadsOf = (state: { + readonly snapshot: Option.Option; + }) => Option.getOrThrow(state.snapshot).threads; + expect(threadsOf(yield* SubscriptionRef.get(shellState))).toEqual(cachedSnapshot.threads); + + // The server updates one row before the cached links finish decoding. + const serverRow = { ...v2ThreadShell, title: "From server", pullRequests: [link(9)] }; + yield* Queue.offer(events, { + kind: "thread.updated", + sequence: 6, + location: "active", + thread: serverRow, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => threadsOf(state)[0]?.title === "From server"), + Stream.runHead, + ); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map(threadsOf), + Stream.filter((threads) => threads[1]?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled[0]).toBe(serverRow); + expect(filled[1]?.pullRequests).toEqual([link(2)]); + }), + ); + + it.effect("shows HTTP rows before their pull request links and saves them once filled", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const httpSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 3, + }; + const linksReady = yield* Deferred.make(); + const saved = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Queue.offer(saved, snapshot), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...httpSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as(new Map([[v2ThreadShell.id, [link]]])), + ), + }), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const rows = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + Effect.map((state) => Option.getOrThrow(Option.getOrThrow(state).snapshot)), + ); + expect(rows.threads[0]?.pullRequests).toBeUndefined(); + expect(rows).not.toHaveProperty("loadPullRequests"); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]), + Stream.filter((thread) => thread?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled?.pullRequests).toEqual([link]); + + // The cache must end up with the links: the last save carries them. + yield* TestClock.adjust("30 seconds"); + const saves = yield* Queue.takeAll(saved); + expect(saves.at(-1)?.threads[0]?.pullRequests).toEqual([link]); + }), + ); + + it.effect("does not save rows whose deferred links are still pending", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + url: "https://github.com/pingdotgg/t3code/pull/8", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const saved = yield* Ref.make>([]); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Ref.update(saved, (all) => [...all, snapshot]), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...v2ShellSnapshot, + snapshotSequence: 3, + // Links never finish decoding before the shell closes. + loadPullRequests: Effect.never.pipe(Effect.as(new Map([[v2ThreadShell.id, [link]]]))), + }), + }); + const scope = yield* Scope.make(); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + Scope.provide(scope), + ); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + ); + + // Neither the throttled worker nor the closing flush may save the rows without links. + yield* TestClock.adjust("30 seconds"); + yield* Scope.close(scope, Exit.void); + expect(yield* Ref.get(saved)).toEqual([]); + }), + ); + + it.effect("drops an older snapshot's links once a newer snapshot reuses its rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const firstLinks = yield* Deferred.make(); + const secondLinks = yield* Deferred.make(); + // Set once each snapshot's links start decoding, i.e. once its rows are applied. + const firstFillStarted = yield* Deferred.make(); + const loads = yield* Ref.make(0); + const subscriptions = yield* Ref.make(0); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => + Stream.unwrap(Ref.update(subscriptions, (n) => n + 1).pipe(Effect.as(Stream.never))), + } as unknown as WsRpcProtocolClient; + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Both snapshots carry the same row; only its pull request links differ. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Ref.updateAndGet(loads, (n) => n + 1).pipe( + Effect.map((n) => + Option.some({ + ...v2ShellSnapshot, + snapshotSequence: n, + loadPullRequests: (n === 1 + ? Deferred.succeed(firstFillStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLinks)), + ) + : Deferred.await(secondLinks) + ).pipe(Effect.as(new Map([[v2ThreadShell.id, [link(n)]]]))), + }), + ), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + yield* Deferred.await(firstFillStarted); + + // A new session loads a second snapshot that reuses the bare row. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 2, + ), + Stream.runHead, + ); + + // The superseded first fill finishes first, then the second. + yield* Deferred.succeed(firstLinks, undefined); + yield* Deferred.succeed(secondLinks, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]?.pullRequests), + Stream.filter((links) => links !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled).toEqual([link(2)]); + }), + ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 2c479489e318a..dbc4405a9faa0 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -3,10 +3,14 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + type OrchestrationV2ThreadShell, type ServerConfig, + type ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -25,7 +29,13 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import type { DeferredShellSnapshot } from "./shellPullRequests.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, + sameThreadPullRequests, +} from "./shellReducer.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -49,6 +59,12 @@ function shellStatusForSnapshot( return Option.isSome(snapshot) ? "cached" : "empty"; } +function withoutDeferredPullRequests(value: DeferredShellSnapshot): OrchestrationV2ShellSnapshot { + if (value.loadPullRequests === undefined) return value; + const { loadPullRequests: _deferred, ...snapshot } = value; + return snapshot; +} + const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { @@ -57,17 +73,18 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const snapshotLoader = yield* ShellSnapshotLoader.ShellSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + const cached = yield* cache.loadShell(environmentId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached environment shell.").pipe( Effect.annotateLogs({ environmentId, ...safeErrorLogAttributes(error), }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); + const cachedSnapshot = Option.map(cached, withoutDeferredPullRequests); const state = yield* SubscriptionRef.make({ snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), @@ -83,12 +100,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const persistenceLock = yield* Semaphore.make(1); let lastPersisted: OrchestrationV2ShellSnapshot | undefined; + // Rows still waiting for their deferred pull request links, which must not be saved: + // the cache would keep them without links until the next server refresh. + let pendingLinkRows: ReadonlySet = new Set(); const persistLatest = Effect.fn("EnvironmentShellState.persistLatest")(function* ( flush: boolean, ) { while (true) { const latest = yield* Ref.get(latestLiveSnapshot); if (Option.isNone(latest) || latest.value === lastPersisted) return; + if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; const snapshot = latest.value; const saved = yield* cache.saveShell(environmentId, snapshot).pipe( Effect.as(true), @@ -155,11 +176,17 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ); + // Server items and deferred link fills both read the snapshot and write it back; one at a + // time, so neither can overwrite what the other wrote in between. + const snapshotWriteLock = yield* Semaphore.make(1); + // Apply each received batch with one state write. The RPC client's bounded // buffer can split a server chunk, so a bulk action can still need several // writes, but each write includes every event in that batch. const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( items: ReadonlyArray, + // Ids of rows whose links will fill later; they are held out of saves before one queues. + pendingLinkIds?: ReadonlySet, ) { const initial = yield* SubscriptionRef.get(state); let waiting = yield* Ref.get(awaitingCompletion); @@ -201,6 +228,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } yield* Ref.set(awaitingCompletion, waiting); if (next === initial) return; + if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) { + pendingLinkRows = new Set( + next.snapshot.value.threads.filter((thread) => pendingLinkIds.has(thread.id)), + ); + } if (Option.isSome(next.snapshot)) { yield* Ref.set(latestLiveSnapshot, next.snapshot); } @@ -214,7 +246,81 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) { yield* Queue.offer(persistence, next.snapshot.value); } + }, snapshotWriteLock.withPermit); + + // Rows can arrive before their pull request links (see DeferredShellSnapshot), so the list + // paints first. The links then fill into the rows that still hold the object they arrived + // with; a row the server has replaced since carries its own links. + // Captured so fills started from the subscription callback still end with this state. + const stateScope = yield* Effect.scope; + // Each full snapshot supersedes the previous one's fill; links from an older snapshot must + // not land on rows a newer snapshot reused. A cancelled fill's rows stay pending until the + // next snapshot replaces the pending set. + let activeFill: Fiber.Fiber | undefined; + const cancelActiveFill = Effect.suspend(() => { + const fill = activeFill; + activeFill = undefined; + return fill === undefined ? Effect.void : Fiber.interrupt(fill); }); + const fillDeferredPullRequests = Effect.fnUntraced(function* ( + source: DeferredShellSnapshot, + applied: OrchestrationV2ShellSnapshot, + ) { + yield* cancelActiveFill; + if (source.loadPullRequests === undefined) return; + const arrivedRows = new Set(applied.threads); + const arrivedIds = new Set(source.threads.map((thread) => thread.id)); + pendingLinkRows = new Set(applied.threads.filter((thread) => arrivedIds.has(thread.id))); + activeFill = yield* source.loadPullRequests.pipe( + Effect.flatMap((linksByThreadId) => + writeFilledLinks(linksByThreadId, arrivedRows, arrivedIds), + ), + Effect.forkIn(stateScope), + ); + }); + const writeFilledLinks = Effect.fnUntraced( + function* ( + linksByThreadId: ReadonlyMap>, + arrivedRows: ReadonlySet, + arrivedIds: ReadonlySet, + ) { + const filled = yield* SubscriptionRef.modify( + state, + (current): [Option.Option, EnvironmentShellState] => { + if (Option.isNone(current.snapshot)) return [Option.none(), current]; + const snapshot = current.snapshot.value; + let changed = false; + const threads = snapshot.threads.map((thread) => { + if (!arrivedRows.has(thread) || !arrivedIds.has(thread.id)) return thread; + const links = linksByThreadId.get(thread.id); + // A reused row may already hold these links from an earlier fill. + if (sameThreadPullRequests(thread.pullRequests, links)) return thread; + changed = true; + if (links === undefined) { + const { pullRequests: _previous, ...row } = thread; + return row; + } + return { ...thread, pullRequests: links }; + }); + if (!changed) return [Option.none(), current]; + const next = { ...snapshot, threads }; + return [Option.some(next), { ...current, snapshot: Option.some(next) }]; + }, + ); + // Saves skipped while links were pending are queued again. Cached rows are never live: + // until a server snapshot arrives there is nothing new to save. + pendingLinkRows = new Set(); + const live = yield* Ref.get(latestLiveSnapshot); + if (Option.isNone(live)) return; + const latest = Option.isSome(filled) ? filled : live; + yield* Ref.set(latestLiveSnapshot, latest); + yield* Queue.offer(persistence, latest.value); + }, + (effect) => snapshotWriteLock.withPermit(effect), + ); + if (Option.isSome(cached) && Option.isSome(cachedSnapshot)) { + yield* fillDeferredPullRequests(cached.value, cachedSnapshot.value); + } const foregroundResubscriptions = Option.match(wakeups, { onNone: () => Stream.never, @@ -258,9 +364,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); const httpSnapshot = yield* snapshotLoader.load(prepared); if (Option.isSome(httpSnapshot)) { - yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); + yield* cancelActiveFill; + const previous = yield* SubscriptionRef.get(state); + const snapshot = reuseUnchangedThreadShells( + Option.getOrNull(previous.snapshot), + withoutDeferredPullRequests(httpSnapshot.value), + ); + const pendingLinkIds = new Set( + httpSnapshot.value.loadPullRequests === undefined + ? [] + : httpSnapshot.value.threads.map((thread) => thread.id), + ); + yield* applyItems([{ kind: "snapshot", snapshot }], pendingLinkIds); canResume = true; current = yield* SubscriptionRef.get(state); + if (Option.isSome(current.snapshot)) { + yield* fillDeferredPullRequests(httpSnapshot.value, current.snapshot.value); + } } } @@ -288,7 +408,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, - ).pipe(Stream.runForEachArray(applyItems)), + ).pipe(Stream.runForEachArray((items) => applyItems(items))), ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { @@ -449,5 +569,6 @@ export function createEnvironmentShellAtoms( export * from "./models.ts"; export * from "./shellCommands.ts"; export * from "./shellReducer.ts"; +export * from "./shellPullRequests.ts"; export * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts new file mode 100644 index 0000000000000..aedc90d25d901 --- /dev/null +++ b/packages/client-runtime/src/state/shellPullRequests.ts @@ -0,0 +1,97 @@ +import { + OrchestrationV2ShellSnapshot, + type ThreadId, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +/** + * A shell snapshot whose threads may arrive before their pull request links are decoded. Those + * links are most of a large shell's decode cost and only feed PR badges, so the thread list + * can paint first. `loadPullRequests` then decodes the links by thread id; until it runs, + * those threads carry no `pullRequests`. + */ +export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot { + readonly loadPullRequests?: Effect.Effect< + ReadonlyMap> + >; +} + +// A zero sleep is a microtask yield, which on React Native runs before the next frame. +// A 1ms sleep takes a host timer, so the rows can render before the deferred decode. +const yieldToHost = Effect.sleep("1 millis"); + +const decodeThreadLinks = Schema.decodeUnknownEffect( + Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))), +); +// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches. +const LINKS_PER_BATCH = 400; + +/** + * Removes each parsed thread row's pull request links from `snapshot` in place and returns + * them in row order, for `deferPullRequests` once the rows are decoded. + */ +export function detachPullRequests(snapshot: unknown): ReadonlyArray { + if (!Predicate.isObject(snapshot)) return []; + const rows: unknown = snapshot.threads; + if (!Array.isArray(rows)) return []; + return rows.map((row: unknown) => { + if (!Predicate.isObject(row)) return undefined; + const links = row.pullRequests; + delete row.pullRequests; + return links; + }); +} + +/** + * Attaches a `loadPullRequests` that decodes the detached links after yielding to the host. + * A thread whose links cannot be read gets none; its row stays usable. + */ +export function deferPullRequests( + snapshot: S, + rawLinks: ReadonlyArray, +): S & Required> { + const threadIds = snapshot.threads.map((thread) => thread.id); + // Schema decoding runs when called, so each thread's decode is built after a yield. + const loadPullRequests = Effect.gen(function* () { + const linksByThreadId = new Map>(); + let batchSize = LINKS_PER_BATCH; + for (const [index, threadId] of threadIds.entries()) { + const raw = rawLinks[index]; + if (raw === undefined) continue; + const size = Array.isArray(raw) ? raw.length : 1; + if (batchSize + size > LINKS_PER_BATCH) { + yield* yieldToHost; + batchSize = 0; + } + batchSize += size; + // An unreadable thread keeps no links; the others still fill in. + const links = yield* decodeThreadLinks(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Discarding unreadable shell pull request links.", { + threadId, + cause: String(cause), + }).pipe(Effect.as(undefined)), + ), + ); + if (links !== undefined) linksByThreadId.set(threadId, links); + } + return linksByThreadId as ReadonlyMap>; + }); + return { ...snapshot, loadPullRequests }; +} + +const decodeShellSnapshotJson = Schema.decodeUnknownEffect( + Schema.toCodecJson(OrchestrationV2ShellSnapshot), +); + +/** Decodes a JSON shell snapshot with its threads' pull request links deferred. */ +export const decodeShellSnapshotDeferringPullRequests = Effect.fnUntraced(function* ( + json: unknown, +) { + const rawLinks = detachPullRequests(json); + const snapshot = yield* decodeShellSnapshotJson(json); + return deferPullRequests(snapshot, rawLinks); +}); diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 7a3bd4c752541..b7a69b2a51522 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -1,8 +1,12 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, +} from "./shellReducer.ts"; const repositoryIdentity = { canonicalKey: "github.com/example/repo", @@ -463,3 +467,39 @@ describe("applyShellStreamEvent", () => { expect(next).toBe(v2ShellSnapshot); }); }); + +describe("reuseUnchangedThreadShells", () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 3, + url: "https://github.com/pingdotgg/t3code/pull/3", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...v2ThreadShell, pullRequests: [link] }; + const other = { ...v2ThreadShell, id: ThreadId.make("thread-other"), title: "Other" }; + + it("keeps the previous object for an unchanged row, even without its deferred links", () => { + const previous = { ...v2ShellSnapshot, threads: [linked, other] }; + const { pullRequests: _links, ...withoutLinks } = linked; + const next = reuseUnchangedThreadShells(previous, { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [withoutLinks, { ...other, title: "Renamed" }], + }); + expect(next.snapshotSequence).toBe(5); + expect(next.threads[0]).toBe(linked); + expect(next.threads[1]).not.toBe(other); + expect(next.threads[1]?.title).toBe("Renamed"); + }); + + it("returns the incoming snapshot when no row is unchanged", () => { + const incoming = { ...v2ShellSnapshot, threads: [{ ...other, title: "Renamed" }] }; + expect(reuseUnchangedThreadShells({ ...v2ShellSnapshot, threads: [other] }, incoming)).toBe( + incoming, + ); + }); +}); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 331b271d6eb70..f99f5e7681a42 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -1,5 +1,6 @@ import { OrchestrationV2ThreadShell, + ThreadPullRequestLink, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, @@ -7,6 +8,41 @@ import { import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); +const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); + +/** Whether two rows' pull request links are equal, including both absent. */ +export function sameThreadPullRequests( + left: OrchestrationV2ThreadShell["pullRequests"], + right: OrchestrationV2ThreadShell["pullRequests"], +): boolean { + return left === right || sameLinks(left, right); +} + +/** + * Keep the previous object for each row a full snapshot left unchanged, so the list only + * re-renders rows that changed. Links are compared apart from the rest of the row because a + * deferred snapshot arrives without them; such a row keeps its links until they fill in. + */ +export function reuseUnchangedThreadShells( + previous: OrchestrationV2ShellSnapshot | null, + next: OrchestrationV2ShellSnapshot, +): OrchestrationV2ShellSnapshot { + if (previous === null || previous.threads.length === 0) return next; + const previousById = new Map(previous.threads.map((thread) => [thread.id, thread] as const)); + let reused = 0; + const threads = next.threads.map((thread) => { + const prior = previousById.get(thread.id); + if (prior === undefined) return thread; + const candidate = + thread.pullRequests === undefined && prior.pullRequests !== undefined + ? { ...thread, pullRequests: prior.pullRequests } + : thread; + if (!sameThreadShell(prior, candidate)) return thread; + reused += 1; + return prior; + }); + return reused === 0 ? next : { ...next, threads }; +} function upsertById( items: ReadonlyArray, diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index c4154e009c783..14e57be1fa675 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -1,10 +1,11 @@ -import type { OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentHttpCommonError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient } from "effect/http"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/http"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; @@ -14,6 +15,10 @@ import { executeAuthenticatedEnvironmentHttpRequest, withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; +import { + decodeShellSnapshotDeferringPullRequests, + type DeferredShellSnapshot, +} from "./shellPullRequests.ts"; // Long enough for a slow but alive server to finish. On timeout the socket asks // the same server for the same full snapshot, so a short deadline only throws @@ -45,11 +50,45 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, + // The body is decoded here so each thread's pull request links can decode after its + // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps + // the error the credential retry checks for. request: ({ client, headers }) => - client.shellSnapshot({ headers: withOrchestrationProtocolHeader(headers) }), + client + .shellSnapshot({ + headers: withOrchestrationProtocolHeader(headers), + responseMode: "response-only", + }) + .pipe(Effect.flatMap(decodeShellSnapshotResponse)), }); }); +const decodeEnvironmentHttpCommonError = Schema.decodeUnknownEffect( + Schema.toCodecJson(EnvironmentHttpCommonError), +); + +const decodeShellSnapshotResponse = ( + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect< + DeferredShellSnapshot, + HttpClientError.HttpClientError | Schema.SchemaError | EnvironmentHttpCommonError +> => { + if (response.status === 200) { + return response.json.pipe(Effect.flatMap(decodeShellSnapshotDeferringPullRequests)); + } + // A body that is not a declared error keeps its status, as the generated decoder reported. + const statusError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request: response.request, response }), + }); + return response.json.pipe( + Effect.flatMap(decodeEnvironmentHttpCommonError), + Effect.matchEffect({ + onFailure: () => Effect.fail(statusError), + onSuccess: Effect.fail, + }), + ); +}; + /** * Loads the environment shell snapshot over HTTP, returning `Option.none()` when * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). @@ -61,7 +100,7 @@ export class ShellSnapshotLoader extends Context.Service< { readonly load: ( prepared: PreparedConnection, - ) => Effect.Effect>; + ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} @@ -78,14 +117,14 @@ export const layer: Layer.Layer fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe( - Effect.map(Option.some), + Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchCause((cause) => Effect.logWarning( "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.", ).pipe( Effect.annotateLogs({ cause: Cause.pretty(cause) }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ), From 9fba2093e50f208c52d9041c70a793296e311477 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:36:42 -0400 Subject: [PATCH 081/680] fix(server): replaying a command no longer freezes the server (#17041) Co-authored-by: Claude Opus 5.5 (1M context) --- .../OrchestrationEventStore.sequence.test.ts | 58 +++++++++++++++++++ .../persistence/OrchestrationEventStore.ts | 10 +++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 82d4838fb27a8..9c7f87b3c9b88 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -272,3 +272,61 @@ it.effect("uses indexed high-water lookups for populated history without OR scan ), ), ); + +it.effect("replays a command's events through the command index, not the sequence range", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 + ) + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, actor_kind, payload_json, metadata_json, application_event_version + ) + SELECT 'history:' || n, 'thread', 'thread', n, 'provider-session.detached', ${occurredAt}, + CASE WHEN n IN (12, 24990) THEN 'retried-command' ELSE 'command:' || n END, + 'server', + '{"providerSessionId":"session","detachedAt":"' || ${occurredAt} || '"}', + '{}', 2 + FROM history + `; + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + const replayed = yield* store + .readAgentEvents({ commandId: CommandId.make("retried-command") }) + .pipe(Stream.runCollect, Effect.withTracer(tracer)); + assert.deepEqual( + replayed.map((event) => event.sequence), + [12, 24990], + ); + assert.equal(statements.length, 1); + const plan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statements[0]}`, + [0, Number.MAX_SAFE_INTEGER, "retried-command", 500], + ); + assert.match( + plan.map((row) => row.detail).join("\n"), + /SEARCH orchestration_events USING INDEX idx_orch_events_command_id \(command_id=\?/, + ); + }).pipe( + Effect.provide( + OrchestrationEventStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), + ), + ), + ), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 588defc0d6cab..ef8b49c2c53f2 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -407,9 +407,13 @@ const makeEventStore = Effect.gen(function* () { correlation_id FROM orchestration_events ${ - input.onlyAgentEvents === true - ? sql`` - : sql`INDEXED BY idx_orchestration_events_application_high_water` + // The sequence range otherwise wins and a command lookup scans every + // V2 event, which blocks the server for seconds on large histories. + input.commandId !== undefined + ? sql`INDEXED BY idx_orch_events_command_id` + : input.onlyAgentEvents === true + ? sql`` + : sql`INDEXED BY idx_orchestration_events_application_high_water` } WHERE sequence > ${input.afterSequence} AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} From 04ad17425a80484bf323a7d5076bd477a9499119 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:16:52 -0400 Subject: [PATCH 082/680] fix(server): opening a long thread no longer blocks the server for a second (#17029) Co-authored-by: Claude Opus 5.5 (1M context) --- .../orchestration-v2/ProjectionStore.test.ts | 44 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 24 ++++++---- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../060_ThreadSnapshotWindowIndexes.ts | 23 ++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 + 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f47b4d6829e86..194d7507f2e6e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; @@ -588,12 +589,26 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; } + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); const initial = yield* Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(JSON, "parse")), (parse) => projectionStore .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 }) .pipe( + Effect.withTracer(tracer), Effect.tap(() => Effect.sync(() => { // Tool outputs must not be allocated again just to collect cohort IDs. @@ -606,6 +621,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), (parse) => Effect.sync(() => parse.mockRestore()), ); + // Large threads blocked the server for over a second here. The boundary + // must come from the user-message index rather than a scan of every tool + // row, and payloads must not go through a sort after they are fetched. + const windowStatement = statements.find((statement) => statement.includes("turn_anchors")); + assert.isDefined(windowStatement); + const windowPlan = yield* sql.unsafe<{ readonly parent: number; readonly detail: string }>( + `EXPLAIN QUERY PLAN ${windowStatement}`, + ); + assert.match( + windowPlan.map((row) => row.detail).join("\n"), + /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/, + ); + const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail); + assert.include( + topLevel, + "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)", + ); + assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY"); + const nodeStatement = statements.find((statement) => + statement.includes("WITH RECURSIVE retained"), + ); + assert.isDefined(nodeStatement); + const nodePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${nodeStatement}`, + ); + assert.include( + nodePlan.map((row) => row.detail), + "SEARCH orchestration_v2_projection_nodes USING INDEX orchestration_v2_projection_nodes_live_idx (thread_id=?)", + ); // Only the selected turn cohort and two lookahead anchors are decoded. assert.lengthOf(initial.projection.turnItems, 12 * 102); const bounded = buildBoundedThreadProjection({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 07d847e2b91d7..4da19774075b7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2762,7 +2762,10 @@ export const layer: Layer.Layer = END AS ordinal FROM user_anchors ), selected AS ( - SELECT payload_json, ordinal, turn_item_id, run_id, type + -- Choose and sort rows by ID, then fetch payloads in that + -- order. A turn window can hold megabytes of tool output, and + -- carrying it through the union and sort cost about a second. + SELECT ordinal, turn_item_id, run_id, type FROM eligible WHERE ordinal >= (SELECT ordinal FROM boundary) ORDER BY ordinal DESC, turn_item_id DESC @@ -2771,10 +2774,10 @@ export const layer: Layer.Layer = WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 ELSE ${window.rowLimit} END - ), retained AS ( - SELECT payload_json, ordinal, turn_item_id FROM selected + ), retained AS MATERIALIZED ( + SELECT ordinal, turn_item_id FROM selected UNION - SELECT request.payload_json, request.ordinal, request.turn_item_id + SELECT request.ordinal, request.turn_item_id FROM orchestration_v2_projection_turn_items AS request WHERE request.run_id IN ( SELECT run_id FROM selected @@ -2782,9 +2785,9 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION - SELECT latest.payload_json, latest.ordinal, latest.turn_item_id + SELECT latest.ordinal, latest.turn_item_id FROM ( - SELECT payload_json, ordinal, turn_item_id + SELECT ordinal, turn_item_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND ${window.anchorItemId ?? null} IS NULL @@ -2792,10 +2795,15 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT 1 ) AS latest + ORDER BY ordinal ASC, turn_item_id ASC ) - SELECT payload_json + -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite + -- skips sorting again once the payloads are attached. + SELECT item.payload_json FROM retained - ORDER BY ordinal ASC, turn_item_id ASC + CROSS JOIN orchestration_v2_projection_turn_items AS item + ON item.turn_item_id = retained.turn_item_id + ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Reuse the decoded items for cohort IDs and the resulting projection. // Parsing these rows separately duplicates every retained tool output. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1e1f207a7dac8..35c1a3fdce4bb 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts" import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; +import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -146,6 +147,7 @@ export const migrationEntries = [ [57, "ScheduledTaskWebhooks", Migration0057], [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], + [60, "ThreadSnapshotWindowIndexes", Migration0060], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5f2e05f2809f3..5e45ed05d3024 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 59 }, (_, index) => index + 1), + Array.from({ length: 60 }, (_, index) => index + 1), ); }), ); @@ -31,6 +31,7 @@ layer("055_OrchestrationV2", (it) => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -56,6 +57,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 57, name: "ScheduledTaskWebhooks" }, { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, + { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts new file mode 100644 index 0000000000000..6977a165a3036 --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // A bounded thread snapshot finds its turn boundary from the newest user + // messages. Without this index it reads every turn item in the thread to + // find a few dozen of them. ProjectionStore's turn_anchors CTE relies on it. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_user_message_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE type = 'user_message' + `; + // The same snapshot keeps every unfinished node. A long thread has thousands + // of finished ones, and reading them all to find a few live ones was more + // than half of the node query's time. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_nodes_live_idx + ON orchestration_v2_projection_nodes(thread_id) + WHERE status IN ('pending', 'starting', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 826dc4831d8db..675d5f5db0215 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -40,6 +40,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -122,6 +123,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 0f09627a111f7e9adc22c256c725565672676f1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:59:26 -0700 Subject: [PATCH 083/680] fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017) --- .../src/features/threads/ThreadFeed.tsx | 30 +++-- .../mobile/src/lib/nativeMarkdownText.test.ts | 4 +- apps/mobile/src/state/entities.ts | 36 +++++ apps/server/scripts/migrate-dev-db.test.ts | 59 ++++++++ apps/server/scripts/migrate-dev-db.ts | 126 ++++++++++++++---- apps/server/src/mcp/OrchestratorMcpService.ts | 35 +---- ...OrchestratorMcpToolkit.integration.test.ts | 3 - .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/mcp/toolkits/project/handlers.test.ts | 16 +-- .../src/mcp/toolkits/project/handlers.ts | 8 +- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../provider/T3OrchestrationInstructions.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 15 ++- .../components/chat/MarkdownThreadLink.tsx | 28 ++-- .../orchestrator-mcp-server.md | 9 +- packages/contracts/src/orchestratorMcp.ts | 4 - packages/shared/src/threadLinks.test.ts | 85 +++++++++--- packages/shared/src/threadLinks.ts | 97 ++++++++------ 18 files changed, 384 insertions(+), 180 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b5f258a0889f9..b2e10812cb5e4 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -198,6 +198,7 @@ import { } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; +import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; + readonly environmentId: EnvironmentId; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { - const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), - [props.markdown], - ); + const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); + const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -1905,6 +1905,7 @@ function renderFeedEntry( (null); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); - const text = replaceComposerContextReferences(props.text, (ref) => { + const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); + const text = replaceComposerContextReferences(liveText, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; }); @@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { - const threadLink = parseThreadLinkHref(href); - if (threadLink) { + // A thread link names a thread in this feed's environment. + const linkedThreadId = parseThreadLinkHref(href); + if (linkedThreadId) { navigation.navigate("Thread", { - environmentId: String(threadLink.environmentId), - threadId: String(threadLink.threadId), + environmentId: String(props.environmentId), + threadId: String(linkedThreadId), }); return; } @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (text: string) => ( ), - [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills], + [ + markdownStyles.assistant, + markdownLinkHandlers, + renderMarkdownImage, + props.skills, + props.environmentId, + ], ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 62f0de0c1b955..e43685407f85b 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => { children: [ { type: "link", - href: "t3-thread://v1/env/thread-1", + href: "t3-thread://v1/thread-1", children: [{ type: "text", content: "Fix the build" }], }, ], }), - ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]); + ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]); }); it("preserves the destination of a link with a code-formatted label", () => { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 94ab662c72919..0d3b33d582d5a 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -12,8 +12,11 @@ import type { ScopedProjectRef, ScopedThreadRef, ServerConfig, + ThreadId, } from "@t3tools/contracts"; +import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); +const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( + Atom.withLabel("mobile-thread-titles:empty"), +); + +/** Thread titles in one environment. Emits when a title changes, not on every shell update. */ +const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_THREAD_TITLES; + return Atom.make((get) => { + const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId)); + const unchanged = + index.size === previous.size && + Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title); + if (!unchanged) { + previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title])); + } + return previous; + }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`)); +}); /** Resolves when the project event reaches the live client store. */ export function waitForProject( @@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ +export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { + const titles = useAtomValue( + hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM, + ); + return useMemo( + () => + titles.size === 0 + ? markdown + : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)), + [markdown, titles], + ); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 731a67d94cb62..07d3dc61c7913 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("never copies a settled thread's rows, and new events append after the source's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" }); + const source = yield* createFixtureSource(sourceDir); + const [sourceSequence] = yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + }), + ); + + // Keep every family, so only the copy can leave the settled one out. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 100 }, + { sharedHome: sourceDir }, + ); + + const copied = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [settledRows] = yield* sql<{ count: number }>` + SELECT + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + AS count`; + const [sequence] = yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + return { settledRows: settledRows?.count, sequence: sequence?.seq }; + }), + ); + assert.equal(copied.settledRows, 0); + assert.equal(copied.sequence, sourceSequence?.seq); + }), + ); + + it.effect("upgrades a source from before the V2 thread tables", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" }); + const stateDir = path.join(sourceDir, "userdata"); + const source = path.join(stateDir, "statev2.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 })); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + assert.include(result.executedMigrations, "55_OrchestrationV2"); + }), + ); + it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 3e9121fa2a9fd..4cfdf62333460 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,9 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only - * VACUUM INTO) and prunes it to the most recently updated projects and, - * per project, the most recent threads that have fully stopped, with + * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`, + * attached read-only): the schema, and every row except those of + * deleted, archived, and settled threads, which are nearly all of a + * long-lived database. It then prunes that to the most recently updated + * projects and, per project, the most recent threads that have fully + * stopped, with * their forks and subagents. Working, settled, and archived threads, and * threads with pending recovery, are skipped, and scheduled tasks and * queued effects are dropped, so the dev server never adopts live work. @@ -28,6 +31,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray = [ "delegated-completions", ]; +/** Tables the prune empties. The copy skips their rows. */ +const CLEARED_TABLES: ReadonlyArray = [ + // Pending work the dev server would otherwise pick up and run. + "scheduled_tasks", + "orchestration_v2_effect_outbox", + "orchestration_v2_thread_launch_workflows", + "orchestration_command_receipts", + "provider_session_runtime", + "auth_sessions", + "auth_pairing_links", +]; + +/** Shared between threads; the prune keeps the ones a kept thread is bound to. */ +const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; + +/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */ +const rootIsVisible = (sql: SqlClient.SqlClient) => sql` + root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + +/** + * Copies the source into the empty snapshot, without the rows of thread families whose root is + * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's + * events, so copying them is what used to make this take a full-size copy of the real db. + * A source from before the V2 thread tables has no visibility to filter on, so it is copied + * whole. One transaction reads the source, so the copy is consistent while the real server writes. + */ +const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`; + const schema = yield* sql<{ type: string; name: string; sql: string }>` + SELECT type, name, sql FROM src.sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; + const threadColumns = yield* sql<{ name: string; notnull: number }>` + SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c + WHERE m.type = 'table' AND c.name = 'thread_id'`; + const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); + const tables = schema.filter((entry) => entry.type === "table"); + const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); + + yield* sql.withTransaction( + Effect.gen(function* () { + for (const table of tables) yield* sql.unsafe(table.sql).unprepared; + if (filtered) { + yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; + yield* sql`INSERT OR IGNORE INTO copied_threads + SELECT family.thread_id FROM src.orchestration_v2_projection_threads family + JOIN src.orchestration_v2_projection_threads root ON root.thread_id = + COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) + WHERE ${rootIsVisible(sql)}`; + } + for (const { name } of tables) { + if (CLEARED_TABLES.includes(name)) continue; + const nullable = threadIdNullable.get(name); + const filter = !filtered + ? "" + : name === "orchestration_events" + ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id) + // index instead of scanning every event. + `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads)) + OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'` + : nullable === undefined || name === PROVIDER_SESSIONS_TABLE + ? "" + : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`; + yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`) + .unprepared; + } + // The source's AUTOINCREMENT high-water marks, so new events append after them. + yield* sql`DELETE FROM main.sqlite_sequence`; + yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`; + for (const entry of schema) { + if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared; + } + }), + ); + yield* sql`DETACH DATABASE src`; +}); + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; @@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id - WHERE root.deleted_at IS NULL - AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + WHERE ${rootIsVisible(sql)} GROUP BY f.root_id, root.project_id HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; @@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const threadTables = yield* sql<{ name: string }>` SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c WHERE m.type = 'table' AND c.name = 'thread_id' - AND m.name <> 'orchestration_v2_projection_provider_sessions'`; + AND m.name <> ${PROVIDER_SESSIONS_TABLE}`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)} WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) AND provider_session_id NOT IN ( SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings @@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; - // Pending work the dev server would otherwise pick up and run. - yield* sql`DELETE FROM scheduled_tasks`; - yield* sql`DELETE FROM orchestration_v2_effect_outbox`; - yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; - yield* sql`DELETE FROM orchestration_command_receipts`; - yield* sql`DELETE FROM provider_session_runtime`; - yield* sql`DELETE FROM auth_sessions`; - yield* sql`DELETE FROM auth_pairing_links`; + for (const table of CLEARED_TABLES) { + yield* sql`DELETE FROM ${sql(table)}`; + } }), ); @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( ); yield* removeDatabaseFiles(snapshotPath); - // The snapshot is a full-size copy of the source; make sure it is removed - // even when a phase fails partway through. + // Make sure the snapshot is removed even when a phase fails partway through. const { executedMigrations, pruned } = yield* Effect.gen(function* () { - yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`VACUUM INTO ${snapshotPath}`; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); + yield* copySourceSlice(sourcePath).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), wrapPhase("snapshot", sourcePath), ); // Migrate before pruning: a source older than this checkout would // otherwise crash the prune queries on columns that don't exist yet. - // Running against the full snapshot also exercises new migrations on the - // same data volume the real database would face. yield* Console.log("Running migrations on the snapshot..."); const executed = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dccda1b495190..ebb1aa9e4c1a3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,5 +1,4 @@ import { - type EnvironmentId, CommandId, type RunId, isProviderAvailable, @@ -59,7 +58,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -633,23 +631,12 @@ function threadSnooze( }; } -/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ -interface ThreadViewContext { - readonly environmentId: EnvironmentId; - readonly nowMs: number; -} - function listItemFromShell( shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadListItem { return { threadId: shell.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: shell.id, - title: shell.title, - }), title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -661,7 +648,7 @@ function listItemFromShell( interactionMode: shell.interactionMode, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -674,17 +661,12 @@ function threadDetail( projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadDetail { const latest = ThreadManagementService.latestRun(projection); const active = ThreadManagementService.latestActiveRun(projection); return { threadId: projection.thread.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: projection.thread.id, - title: projection.thread.title, - }), projectId: projection.thread.projectId, title: projection.thread.title, createdBy: projection.thread.createdBy, @@ -717,7 +699,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), // From the shell, like the list, so read and list agree on snooze state. - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => - listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), - ), + threads: page.map((shell) => listItemFromShell(shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; @@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () { } } return { - thread: threadDetail(target, timeline.totalItems, shell, { - environmentId: scope.environmentId, - nowMs: yield* Clock.currentTimeMillis, - }), + thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 673830dd66d3c..e59f8fff9a056 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => { }); expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - expect(metadataRead.thread.link).toBe( - `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`, - ); yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make("command:mcp-empty:snooze"), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 256b08638ac0e..e7ef07101a39e 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. Each thread has a link: paste it when you mention the thread so the user can open it.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 255829ff094c7..d657938ff2067 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId, modelSelection, title: input.title }, + thread: { id: input.threadId, projectId, modelSelection }, runs: [], }, resumed: false, @@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread", const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ - projectId, - modelSelection, - link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), - }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); expect(launchedSender).toBe(sourceThreadId); }), ); @@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () => return Effect.succeed({ threadId: input.threadId, projection: { - thread: { - id: input.threadId, - projectId: input.projectId, - modelSelection, - title: input.title, - }, + thread: { id: input.threadId, projectId: input.projectId, modelSelection }, runs: [], }, resumed: false, @@ -338,7 +329,6 @@ const clientLaunchHarness = (input: { id: launch.threadId, projectId: launch.projectId, modelSelection: launch.modelSelection, - title: launch.title, }, runs: [], }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 4f3b66e8dc704..9a7d7024111ef 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,4 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ - environmentId: scope.environmentId, - threadId: thread.id, - title: thread.title, - }), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bb81b8c83e0e4..ee489d4e104c0 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index fe700c0e20845..2280d4d25619f 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. ### Choose the workspace before starting a new thread diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e595efdc5a07e..d926f5aaf412d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; - // A thread link opens the thread here, never a browser. - const threadLink = href ? parseThreadLinkHref(href) : null; - if (threadLink) { - return ( - - {children} - + // A thread link names a thread in this message's environment and opens it in the app. + const linkedThreadId = href ? parseThreadLinkHref(href) : null; + if (linkedThreadId) { + const label = hastPlainTextDeep(node) || linkedThreadId; + return environmentId ? ( + + ) : ( + {label} ); } const contextReference = href ? parseComposerContextHref(href) : null; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index cc3f5bdb0efbf..4b1b44d7c62b7 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,31 +1,41 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; import { Link } from "@tanstack/react-router"; import { MessageSquareTextIcon } from "lucide-react"; -import type { ReactNode } from "react"; import { useProject, useThreadShell } from "../../state/entities"; import { ProjectFavicon } from "../ProjectFavicon"; /** - * A `t3-thread://` link in chat. It leads with the thread's project icon, like - * a web link leads with its favicon, so it reads as a thread link before you - * hover it. Opens the thread in the app. + * A `t3-thread://` link in chat. It shows the thread's current title, so a rename + * reaches every message that links to it, and leads with the thread's project icon + * the way a web link leads with its favicon. `label` is what the message wrote, + * shown only when this client cannot see the thread. Opens the thread in the app. */ export function MarkdownThreadLink(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; - readonly children: ReactNode; + readonly label: string; }) { - const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decodedId = percentDecodedThreadLinkId(props.threadId); + const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decoded = useThreadShell( + written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null, + ); + const thread = written ?? decoded; + const threadId = thread?.id ?? props.threadId; const project = useProject( thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), ); + const title = thread?.title.trim() || props.label; return ( )} - {props.children} + {title} ); } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 08c2f19bdabf0..5fc670a99c6d0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -List, read, and launch results include `link`, a Markdown link of the form -`[title](t3-thread://v1//)` that clients open as the -thread. List and read results also report `snoozed` and `snoozedUntil`, and +Agents mention another thread as `[title](t3-thread://v1/)`. The +link carries only the id, which resolves in the environment of the message that +holds it. Clients show the thread's current title rather than the label, so a +rename never leaves a stale link. + +List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d13cf419f19f2..0ab3e0b9b86ce 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index b6f853e9827f2..087aa8894f6aa 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -1,28 +1,75 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; +import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; describe("thread links", () => { - it("round-trips ids that contain URL characters", () => { - const link = formatThreadLink({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - title: "Fix [the] build\nnow", - }); - const href = /\]\((.+)\)$/.exec(link)![1]!; - expect(link.startsWith("[Fix the build now](")).toBe(true); - // A raw parenthesis would end the Markdown link early. - expect(href).not.toMatch(/[()]/); - expect(parseThreadLinkHref(href)).toEqual({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - }); + it("takes the thread id verbatim, percent escapes included", () => { + expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234"); + expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe( + "thread:delegated-task:mcp%3A1", + ); }); - it("rejects other links and malformed ones", () => { + it("rejects other links and an empty id", () => { expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); + }); + + it("resolves a percent-encoded id when the id as written names no thread", () => { + const titles = new Map([ + ["thread:project:1", "Decoded"], + ["provider%3A1", "Literal escape"], + ]); + expect( + relabelThreadLinks( + "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)", + (threadId) => titles.get(threadId), + ), + ).toBe( + "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", + ); + // A thread with an empty title still exists, so its link is not redirected. + const untitled = new Map([ + ["a%3A1", ""], + ["a:1", "Other"], + ]); + expect( + relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)), + ).toBe("[Kept](t3-thread://v1/a%3A1)"); + }); + + it("leaves links inside code spans and fences as written", () => { + const markdown = [ + "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [old](t3-thread://v1/t1)", + ].join("\n"); + expect(relabelThreadLinks(markdown, () => "New")).toBe( + [ + "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [New](t3-thread://v1/t1)", + ].join("\n"), + ); + }); + + it("formats a label that would otherwise break the Markdown link", () => { + expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); + expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); + }); + + it("relabels links with the current title and leaves unknown threads alone", () => { + const titles = new Map([["renamed", "Fix [the] build\nnow"]]); + expect( + relabelThreadLinks( + "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).", + (threadId) => titles.get(threadId), + ), + ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted)."); }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 179350c1ee6bf..15384aeea4d42 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -1,58 +1,73 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; /** - * In-app links to threads. Agents put them in Markdown as - * `[title](t3-thread://v1//)`, and clients open the - * thread instead of a browser. Thread tools return a ready `link` for each - * thread, so agents never build one by hand. + * Agents mention another thread in Markdown as `[title](t3-thread://v1/)`. The id is + * the reference and resolves in the environment of the message that holds it. Titles change, so + * clients show the thread's current title; the label only stands in for a thread they cannot see. */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; -const LINK_LABEL_MAX_CHARS = 120; +// Code comes first in the alternation so a link written inside a code span or fence is skipped. +const THREAD_LINK_OUTSIDE_CODE = + /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?t3-thread:\/\/v1\/[^\s)]+)\)/g; -const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); const decodeThreadId = Schema.decodeUnknownOption(ThreadId); -// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. -function encodeIdSegment(id: string): string { - return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); -} - -function formatThreadLinkHref(environmentId: string, threadId: string): string { - return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; -} - -/** A Markdown link to the thread, labeled with its title. */ -export function formatThreadLink(thread: { - readonly environmentId: string; - readonly threadId: string; - readonly title: string; -}): string { - const label = - thread.title - .replace(/[[\]\\\r\n]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; - return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; +/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */ +export function parseThreadLinkHref(href: string): ThreadId | null { + if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; + return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } -export function parseThreadLinkHref( - href: string, -): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { - if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; - const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); - if (parts.length !== 2) return null; +/** + * Agents often percent-encode the id anyway. When the id as written names no thread, clients try + * this decoded form. Null when decoding changes nothing or fails. + */ +export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { try { - const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); - const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); - return Option.isSome(environmentId) && Option.isSome(threadId) - ? { environmentId: environmentId.value, threadId: threadId.value } - : null; + const decoded = decodeURIComponent(threadId); + return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); } catch { - // Malformed percent encoding. return null; } } + +/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ +export function formatThreadLink(threadId: string, label: string): string { + const cleaned = label + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`; +} + +export function hasThreadLinks(markdown: string): boolean { + return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); +} + +/** + * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from. + * A link it returns nothing for keeps its label. + */ +export function relabelThreadLinks( + markdown: string, + title: (threadId: ThreadId) => string | undefined, +): string { + if (!hasThreadLinks(markdown)) return markdown; + return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => { + const href = (args.at(-1) as { href?: string }).href; + if (href === undefined) return source; + const written = parseThreadLinkHref(href); + if (written === null) return source; + // The decoded id only stands in when the id as written names no thread. + const decoded = percentDecodedThreadLinkId(written); + const threadId = + title(written) === undefined && decoded !== null && title(decoded) !== undefined + ? decoded + : written; + const label = title(threadId)?.trim(); + return label ? formatThreadLink(threadId, label) : source; + }); +} From 6f83358ae6d85b5dee7d3280e645e0e9a0aeff62 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:01 -0700 Subject: [PATCH 084/680] fix(web): messages with quotes or links no longer collapse when short (#16624) --- .../components/chat/AssistantCitationChip.tsx | 8 ++-- .../chat/MessagesTimeline.logic.test.ts | 37 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 31 ++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +------ packages/shared/src/assistantCitations.ts | 5 +++ 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index e03872553a88f..9f5d3a2e1e55d 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -1,5 +1,8 @@ import type { AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { + assistantCitationLabel, + serializeAssistantCitation, +} from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; import { PencilIcon, QuoteIcon } from "lucide-react"; import { @@ -94,8 +97,7 @@ export function AssistantCitationChip({ }, } : undefined; - const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); - const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; + const label = assistantCitationLabel(citation); const sourceLinkProps = { to: "/$environmentId/$threadId" as const, params: { environmentId: citation.environmentId, threadId: citation.threadId }, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 98339041f68b4..93fc89598be8a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -21,12 +21,14 @@ import { import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; -import { MessageId, RunId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, deriveMessagesTimelineRowsWithState, + shouldCollapseUserMessage, liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, @@ -5162,3 +5164,36 @@ describe("live subagents after their parent turn settles", () => { expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); }); }); + +describe("shouldCollapseUserMessage", () => { + it("measures a quote chip by its label, not its encoded link", () => { + const quote = "A long assistant paragraph that the user quoted. ".repeat(40); + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("source"), + text: quote, + comment: "Why does this matter?", + start: 0, + end: quote.length, + prefix: "", + suffix: "", + }); + + expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); + expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + }); + + it("measures file links and context chips by their label", () => { + const links = Array.from( + { length: 8 }, + (_, index) => + `[file${index}.ts](/workspace/projects/example/packages/some/deeply/nested/directory/file${index}.ts)`, + ); + const text = `Compare ${links.join(", ")} with [terminal 1](t3-context://v1/terminal/${"a".repeat(36)}).`; + + expect(text.length).toBeGreaterThan(600); + expect(shouldCollapseUserMessage(text)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 74f3991396ef9..4a81905eaa2b9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,6 +2,10 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; +import { + assistantCitationLabel, + collectAssistantCitations, +} from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; import { @@ -2226,3 +2230,30 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } } + +const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; +const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; +const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; + +function visibleUserMessageText(text: string): string { + const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); + let visible = ""; + let cursor = 0; + for (const match of collectAssistantCitations(text)) { + visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + cursor = match.end; + } + return visible + linkLabels(text.slice(cursor)); +} + +export function shouldCollapseUserMessage(text: string): boolean { + const visible = visibleUserMessageText(text); + if (visible.trim().length === 0) { + return false; + } + + return ( + visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6145098f60437..8fd9789d9d331 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -207,6 +207,7 @@ import { resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, + shouldCollapseUserMessage, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, threadReadLabelPrefix, @@ -4522,22 +4523,9 @@ function UserMessageContextReferenceChip(props: { }); } -const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; -const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; -function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { - return false; - } - - return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES - ); -} - const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index e27e35f352bb7..86ca0701f20fb 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -98,6 +98,11 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu } } +export function assistantCitationLabel(citation: AssistantCitation): string { + const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); + return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; +} + export function serializeAssistantCitation(citation: AssistantCitation): string { return `[Assistant quote](${formatAssistantCitationHref(citation)})`; } From 843c593ab2dfe432abfba7b928292f3f8e9c7d44 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:29 -0700 Subject: [PATCH 085/680] refactor(web): PR loading skeleton shares the detail panel's layout (#15583) --- .../pullRequest/PullRequestDetailLayout.tsx | 130 ++++++ .../pullRequest/PullRequestDetailPanel.tsx | 413 ++++++++---------- .../pullRequest/PullRequestGhosts.tsx | 195 ++++----- 3 files changed, 412 insertions(+), 326 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx new file mode 100644 index 0000000000000..92bd0ab324d51 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -0,0 +1,130 @@ +import { ArrowLeftIcon, FileDiffIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { PullRequestMetaLine } from "./pullRequestPresentation"; + +export function PullRequestDetailHeaderBody({ + title, + author, + updated, + checkout, + base, + head, + files, + diffStat, +}: { + title: ReactNode; + author: ReactNode; + updated: ReactNode; + checkout?: ReactNode; + base: ReactNode; + head: ReactNode; + files: ReactNode; + diffStat: ReactNode; +}) { + return ( +
    + {title} +
    + + {author} + {updated} + + {checkout} +
    +
    + + {base} + + {head} + + + + + {files} + + {diffStat} + +
    +
    + ); +} + +export function PullRequestDetailTitleRow({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ); +} + +export function PullRequestDetailTabBar({ + tabs, + value, + onValueChange, + onTabIntent, + inert, + children, +}: { + tabs: ReadonlyArray<{ readonly value: Tab; readonly label: string }>; + value: Tab; + onValueChange?: (tab: Tab) => void; + onTabIntent?: (tab: Tab) => void; + inert?: boolean; + children?: ReactNode; +}) { + return ( + + ); +} + +export function PullRequestChecksStatusLine({ + icon, + label, + className, + ...props +}: Omit, "children"> & { icon: ReactNode; label: ReactNode }) { + return ( + + {icon} + {label} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f448960739858..7f6a4592a0b4f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -102,7 +102,6 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { PullRequestEditButton } from "./PullRequestEditButton"; import { Input } from "../ui/input"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Menu, MenuItem, @@ -117,6 +116,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -2373,77 +2378,79 @@ export function PullRequestDetailPanel({ inert={condensed} > {detail ? ( -
    - {titleDraft === null ? ( -
    - - - {detail.title} - - } - /> - {detail.title} - - {canEditPullRequestChangeRequest(detail) ? ( - setTitleScope({ pullRequestKey, text: detail.title })} - /> - ) : null} -
    - ) : ( - // A title is one line of text, not markdown, so it takes an input rather than - // the editor the description and the remarks share. -
    - - setTitleScope({ pullRequestKey, text: event.target.value }) - } - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveTitle(titleDraft); - } else if (event.key === "Escape") { - event.preventDefault(); - setTitleScope(null); - } - }} - /> -
    - - + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveTitle(titleDraft); + } else if (event.key === "Escape") { + event.preventDefault(); + setTitleScope(null); + } + }} + /> +
    + + +
    -
    - )} -
    - - - updated {formatRelativeTimeLabel(detail.updatedAt)} - - {checkoutCommand ? ( + ) + } + author={ + + } + updated={updated {formatRelativeTimeLabel(detail.updatedAt)}} + checkout={ + checkoutCommand ? ( - ) : null} -
    - -
    - - {/* An out-of-date base wears the warning on the branch name itself, so the + ) : null + } + base={ + /* An out-of-date base wears the warning on the branch name itself, so the name is amber and pointing at either the name or the mark opens the way - out. Up to date, the name keeps its plain tooltip. */} - {freshness ? ( - void perform("update-branch", undefined, method)} - className="max-w-[40%]" - > - {isStackedPullRequest ? ( - - ) : null} - - - - - ) : ( - - - {isStackedPullRequest ? ( - - ) : null} - - - - - } + out. Up to date, the name keeps its plain tooltip. */ + freshness ? ( + void perform("update-branch", undefined, method)} + className="max-w-[40%]" + > + {isStackedPullRequest ? ( + - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - - - )} - - - - - - - {detail.changedFiles.toLocaleString()}{" "} - {detail.changedFiles === 1 ? "file" : "files"} - - - -
    -
    + ) : null} + + + + + ) : ( + + + {isStackedPullRequest ? ( + + ) : null} + + + + + } + /> + + {isStackedPullRequest + ? `Stacked on ${detail.baseBranch}` + : detail.baseBranch} + + + ) + } + head={ + + } + files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`} + diffStat={ + + } + /> ) : null}
    {detail ? ( - + ) : null}
    diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 85c9802c21706..7e155882a34ad 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -13,7 +13,6 @@ import { ChevronRightIcon, EllipsisIcon, ExternalLinkIcon, - FileDiffIcon, PanelRightIcon, TagIcon, UserPlusIcon, @@ -26,20 +25,24 @@ import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestLabelChip, - PullRequestMetaLine, pullRequestChecksStatePresentation, resolvePullRequestState, } from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { - return
    ; + return ; } /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ @@ -202,32 +205,35 @@ export function PullRequestDetailGhost({
    -
    - {seed ? ( -
    -

    {seed.title}

    -
    - ) : ( -
    - -
    - )} -
    - {seed ? ( - - - updated {formatRelativeTimeLabel(seed.updatedAt)} - + + {seed ? ( +

    {seed.title}

    + ) : ( + + )} + + } + author={ + seed ? ( + ) : ( - - - - - - - - )} - {checkout ? ( + + + + + ) + } + updated={ + seed ? ( + updated {formatRelativeTimeLabel(seed.updatedAt)} + ) : ( + + ) + } + checkout={ + checkout ? ( - ) : null} -
    - -
    - - {seed ? ( - - - - - - ) : ( - - - - )} - - {seed ? ( - - ) : ( - - )} - - - - - {changedFiles === null ? ( - - ) : ( - `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` - )} - + ) : null + } + base={ + {seed ? ( - + + + ) : ( - + )} -
    -
    + } + head={ + seed ? ( + + ) : ( + + ) + } + files={ + changedFiles === null ? ( + + ) : ( + `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` + ) + } + diffStat={ + seed ? ( + + ) : ( + + ) + } + />
    - +
    From 2b7fa212a89edda7aad8f73fc3b4afd5b56c442c Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:20:36 -0700 Subject: [PATCH 086/680] fix(web): PR file headers keep the file name in narrow panels (#15562) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 9af92ee96a814..caadd569b9c5f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -858,7 +858,7 @@ function PullRequestCodeTab({ ) : ( - "Viewed" + Viewed )} @@ -1503,7 +1503,7 @@ function PullRequestCodeTab({ // diff crosses the overflow boundary. The viewer is itself focusable for keyboard // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" + className="@container h-full overflow-auto [scrollbar-gutter:stable]" viewerRef={setViewer} items={items} selectedLines={selectedLines} From b24f0fbba09d8623c896081b4ff70aa7190c8fda Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:22:25 -0700 Subject: [PATCH 087/680] fix(web): PR timeline no longer shifts when its scrollbar appears (#15588) --- apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 4b3997bf5f626..c6174b54f1357 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -577,7 +577,7 @@ export function PullRequestTimelineTab({ }; return ( -
    +
    From 0bfd9dd19d0c9418a936190525ec8f4c4fdae09b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 21:49:54 -0700 Subject: [PATCH 088/680] fix(server): held queued wakes no longer keep delegated tasks running (#17028) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + ...OrchestratorMcpToolkit.integration.test.ts | 14 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../DelegatedCompletionDelivery.test.ts | 192 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 32 +-- .../ProjectionRecovery.test.ts | 19 +- .../src/orchestration-v2/ProjectionStore.ts | 8 +- .../SubagentProjection.test.ts | 8 + .../orchestration-v2/SubagentProjection.ts | 6 +- 9 files changed, 265 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ebb1aa9e4c1a3..78d4ee48e6664 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -370,9 +370,11 @@ export function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { + // Held queued runs wait for the user to resume the child; task_cancel holds them. return childProjection.runs.some( (run) => !ThreadManagementService.isTerminalRunStatus(run.status) && + !(run.status === "queued" && run.queueHeld === true) && (delegatedRun === undefined || run.ordinal > delegatedRun.ordinal), ); } diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e59f8fff9a056..0dbb2f5716837 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1873,6 +1873,20 @@ describe("orchestrator MCP toolkit", () => { legacyDelegatedRun, ), ).toBe(true); + // A queue Stop held waits for the user; it is not pending child work. + expect( + hasPendingChildRuns( + { + ...legacyChildProjection, + runs: legacyChildProjection.runs.map((run) => + run.id === activeChildFollowup.runId + ? { ...run, status: "queued", queueHeld: true } + : run, + ), + }, + legacyDelegatedRun, + ), + ).toBe(false); const completedTaskCancelCall = yield* invoke("task_cancel", { taskId: delegated.taskId, reason: "Stop the child's later work too.", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index e7ef07101a39e..ac0ebb63b7ddf 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -76,7 +76,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. Turns held in a stopped queue wait for the user and do not count. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 6f1819a672b79..0344e3aed4cfe 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -14,6 +14,7 @@ import { ProviderThreadId, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -1204,6 +1205,197 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); + it.effect("settles a cancelled child whose held wakes wait behind it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:held-wake-parent"); + const projectId = ProjectId.make("project:held-wake-parent"); + const runId = RunId.make("run:held-wake-parent"); + const rootNodeId = NodeId.make("node:held-wake-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:held-wake-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "held-wake-child", + completionWake: "always", + continuationPending: false, + now, + }); + // Pull request watch wakes queued while the child ran; the restart held them. + const held = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:held-wake-child:2"), + ordinal: 2, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:held-wake-child:held"), + events: [ + { + ...held, + payload: { ...held.payload, startedAt: null, completedAt: null, queueHeld: true }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = recovered.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.isNotNull(task?.result ?? null); + assert.isTrue( + recovered.contextTransfers.some( + (transfer) => + transfer.type === "subagent_result" && transfer.sourceThreadId === child.childThreadId, + ), + ); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(child.childThreadId)); + // The held wake stays held for the user to resume or discard. + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["queued", true], + ], + ); + }), + ); + + it.effect("settles a child whose provider failure holds its queued wakes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:failed-hold-parent"); + const projectId = ProjectId.make("project:failed-hold-parent"); + const runId = RunId.make("run:failed-hold-parent"); + const rootNodeId = NodeId.make("node:failed-hold-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:failed-hold-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "failed-hold-child", + completionWake: "always", + continuationPending: false, + now, + }); + // The child is resumed, and a wake queues behind its running turn. + const activeRun = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:2"), + ordinal: 2, + status: "running", + now, + }); + const queued = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:3"), + ordinal: 3, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:resumed"), + events: [ + activeRun, + { ...queued, payload: { ...queued.payload, startedAt: null, completedAt: null } }, + ], + }); + const afterSequence = yield* eventSink.latestSequence(); + const errorItemId = TurnItemId.make("turn-item:failed-hold-child:error"); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:failed"), + events: [ + { + id: EventId.make("event:failed-hold-child:error"), + type: "turn-item.updated", + threadId: child.childThreadId, + runId: activeRun.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: errorItemId, + type: "error", + threadId: child.childThreadId, + runId: activeRun.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + { + ...activeRun, + id: EventId.make("event:failed-hold-child:failed"), + payload: { ...activeRun.payload, status: "failed", completedAt: now }, + }, + ], + }); + + // The failure holds the queue, which leaves the failure as the task's result. + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && stored.event.payload.id === child.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const recovered = yield* orchestrator.getThreadProjection(threadId); + assert.equal(recovered.subagents.find((row) => row.id === child.taskId)?.status, "failed"); + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["failed", false], + ["queued", true], + ], + ); + }), + ); + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f3..e0d288282b1ab 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10613,25 +10613,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while holding the parent lock, so nesting the parent lock inside the // child lock here would invert that order, and the keyed executor's // semaphores are neither reentrant nor deadlock-aware. - const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); - if (parentThreadId !== undefined) { - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - } if (stored.event.type === "run.updated") { yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, stored.event.payload.id), ); } - yield* threadDispatch.withLock( - threadId, - startNextQueuedRun( + yield* threadDispatch + .withLock( threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } - : undefined, - ), - ); + startNextQueuedRun( + threadId, + stored.event.type === "run.updated" && stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : undefined, + ), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to start the next queued V2 run", { threadId, cause }), + ), + ); + // After the queue decision: a provider failure holds the child's queued + // wakes, and only then is the failed run the task's result. + const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); + if (parentThreadId !== undefined) { + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + } }).pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to react to terminal V2 run", { diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 6bde9f596e2ef..c7ea0530d3512 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -231,7 +231,7 @@ it.effect("recovers terminal subagent results until their cross-thread transfer const now = yield* DateTime.now; const parent = yield* createThread("subagent-parent"); const children: Array = []; - for (const name of ["terminal", "archived", "deleted", "running"]) { + for (const name of ["terminal", "archived", "deleted", "running", "held", "queued"]) { const child = yield* createThread(`subagent-${name}`, { lineage: { parentThreadId: parent, relationshipToParent: "subagent", rootThreadId: parent }, forkedFrom: { type: "node", nodeId: NodeId.make(`node:${name}`) }, @@ -239,12 +239,22 @@ it.effect("recovers terminal subagent results until their cross-thread transfer deletedAt: name === "deleted" ? now : null, }); yield* createRun(child, name === "running" ? "running" : "completed"); + // A wake queued behind the result: held by Stop or a restart, or still deliverable. + if (name === "held" || name === "queued") { + yield* createRun(child, "queued", { + ordinal: 2, + startedAt: null, + ...(name === "held" ? { queueHeld: true } : {}), + }); + } children.push(child); } + const terminalChildren = new Set([children[0]!, children[1]!, children[4]!]); assert.deepEqual( new Set(yield* projections.getRecoveryThreadIds("subagent-results")), - new Set(children.slice(0, 2)), + terminalChildren, ); + const completed = children[0]!; const transferId = ContextTransferId.make("transfer:recovery:subagent-result"); yield* projections.apply({ @@ -273,7 +283,10 @@ it.effect("recovers terminal subagent results until their cross-thread transfer }); const archivedChild = children[1]; assert.isDefined(archivedChild); - assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), [archivedChild]); + assert.deepEqual( + new Set(yield* projections.getRecoveryThreadIds("subagent-results")), + new Set([archivedChild, children[4]!]), + ); assert.deepEqual(yield* projections.getUnreadableThreadIds(), []); yield* sql` UPDATE orchestration_v2_projection_context_transfers SET payload_json = '{}' diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4da19774075b7..11f98f2330b5d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -514,7 +514,7 @@ function needsRecovery( parentThreadId !== null && projection.thread.forkedFrom?.type === "node" && ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( - projection.runs.at(-1)?.status ?? "idle", + latestUnheldRun(projection.runs)?.status ?? "idle", ) && !projection.contextTransfers.some( (transfer) => @@ -3505,9 +3505,15 @@ export const layer: Layer.Layer = json_extract(child.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(child.payload_json, '$.lineage.parentThreadId') IS NOT NULL AND json_extract(child.payload_json, '$.forkedFrom.type') = 'node' + -- A held queue waits for the user, so the newest unheld run + -- decides, matching latestUnheldRun. AND ( SELECT status FROM orchestration_v2_projection_runs WHERE thread_id = child.thread_id + AND NOT ( + status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ) ORDER BY ordinal DESC LIMIT 1 ) IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back') AND NOT EXISTS ( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 23d180102cd3e..e2691e08e0db6 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -237,6 +237,14 @@ it("waits for nested work and retains the report across monitor acknowledgements startedAt: null, }; assert.equal(delegatedTaskProgress({ ...projection, runs: [run, pending] }).state, "working"); + // Stop or a restart holds queued wakes for the user; they are not work the task owes. + const cancelled = { ...run, status: "cancelled" as const }; + const held = delegatedTaskProgress({ + ...projection, + runs: [cancelled, { ...pending, queueHeld: true }], + }); + assert.equal(held.state, "result_available"); + assert.equal(held.resultRun?.id, cancelled.id); const report = { ...pending, status: "completed" as const, startedAt: parentCreatedAt }; const monitor = { ...report, id: RunId.make("monitor"), ordinal: 3 }; const artifacts = makeSubagentConversationArtifacts({ diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index f28dc1aa4be5f..7ee8d7c1aa8d9 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -229,7 +229,11 @@ export function delegatedTaskProgress(projection: { const workRuns = projection.runs.filter( (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", ); - const active = workRuns.some((run) => !terminal(run.status)); + // A held queue waits for the user to resume it (after Stop, a restart, or a + // provider failure), so its runs are not work the task still owes. + const active = workRuns.some( + (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), + ); const children = projection.subagents.some( (task) => From c28cd905572e2a28934a6229d88bcf2975963032 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 22:20:16 -0700 Subject: [PATCH 089/680] fix(server): a message sent during a rollback no longer undoes it (#17079) Co-authored-by: Claude Opus 5.5 (1M context) --- .../CheckpointRestoreSafety.test.ts | 2 + .../CheckpointRollbackService.test.ts | 21 ++- .../CheckpointRollbackService.ts | 131 ++++++++++-------- .../src/orchestration-v2/runtimeLayer.ts | 1 + .../testkit/ProviderReplayHarness.ts | 1 + 5 files changed, 95 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 5f79aefdd0b7b..34bf5cce6a537 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; @@ -102,6 +103,7 @@ it.effect.each([ Layer.provide( Layer.mergeAll( NodeServices.layer, + ThreadCommandExecutor.layer, IdAllocator.layer, Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)), diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 433a3be43ac3c..bb37290363ac7 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -20,6 +20,8 @@ import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -64,6 +66,7 @@ it.effect("rejects a non-ready checkpoint before opening a session or restoring const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -141,6 +144,7 @@ it.effect("rejects a rollback when another provider thread became active", () => const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -221,6 +225,7 @@ it.effect("rejects a rollback when provider selection changed before execution", const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -292,6 +297,7 @@ it.effect("reports a missing provider turn as a structured rollback failure", () const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -352,6 +358,15 @@ it.effect.each([ const checkpointId = CheckpointId.make("rewind-start"); const scopeId = CheckpointScopeId.make("rewind-scope"); const calls: string[] = []; + let lockedThreads = Effect.succeed>([]); + const layerThreadCommands = Layer.effect( + ThreadCommandExecutor.ThreadCommandExecutor, + Effect.tap(KeyedLock.make(), (lock) => + Effect.sync(() => { + lockedThreads = lock.activeKeys; + }), + ), + ); const providerThread = { id: providerThreadId, providerSessionId, @@ -390,6 +405,7 @@ it.effect.each([ const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + layerThreadCommands, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore: () => Effect.sync(() => { @@ -398,7 +414,10 @@ it.effect.each([ }), Layer.mock(EventSink.EventSinkV2)({ write: ({ events }) => - Effect.sync(() => { + Effect.map(lockedThreads, (locked) => { + // A thread command that planned against the old provider thread + // must not commit after this and undo the rollback. + assert.include(locked, threadId); assert.ok( events.some( (event) => event.type === "run.updated" && event.payload.status === "rolled_back", diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 1710e7a8cd844..b1532a74c6dc7 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -26,6 +26,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import type { ProviderAdapterV2RollbackTarget } from "./ProviderAdapter.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; export const ROLLBACK_FAILED_MESSAGE = "The provider could not roll back this conversation. Try again; if it keeps failing, check the provider and server logs."; @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | FileSystem.FileSystem | Path.Path | ProjectStore.ProjectStoreV2 + | ThreadCommandExecutor.ThreadCommandExecutor > = Layer.effect( CheckpointRollbackServiceV2, Effect.gen(function* () { @@ -103,6 +105,7 @@ export const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const projects = yield* ProjectStore.ProjectStoreV2; const path = yield* Path.Path; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const execute = Effect.fn("orchestrationV2.checkpointRollback.execute")(function* (input: { readonly threadId: ThreadId; @@ -274,72 +277,80 @@ export const layer: Layer.Layer< yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); } - const now = yield* DateTime.now; - const makeEvent = (event: Omit) => - Effect.map( - ids.allocate.event({ threadId: event.threadId }), - (id) => - ({ - ...event, - id, - }) as Event, - ); - const events: Array = []; - events.push( - yield* makeEvent({ - type: "provider-thread.updated", - threadId: input.threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { - ...snapshot.providerThread, - lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, - updatedAt: now, - }, - }), - ); - for (const staleCheckpoint of staleCheckpoints) { - events.push( - yield* makeEvent({ - type: "checkpoint.captured", - threadId: input.threadId, - ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), - nodeId: staleCheckpoint.nodeId, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { ...staleCheckpoint, status: "stale" }, - }), - ); - } - for (const run of runsToRollback) { - const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); - events.push( - yield* makeEvent({ - type: "run.updated", - threadId: input.threadId, - runId: run.id, - ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, status: "rolled_back", completedAt: now }, - }), - ); - if (rootNode !== undefined) { + // Thread commands write full provider thread rows under this lock. Without + // it, a message sent during the rollback can plan against the old native + // session and commit after this, undoing the provider's fork. + yield* threadCommands.withLock( + input.threadId, + Effect.gen(function* () { + const now = yield* DateTime.now; + const makeEvent = (event: Omit) => + Effect.map( + ids.allocate.event({ threadId: event.threadId }), + (id) => + ({ + ...event, + id, + }) as Event, + ); + const events: Array = []; events.push( yield* makeEvent({ - type: "node.updated", + type: "provider-thread.updated", threadId: input.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, occurredAt: now, - payload: { ...rootNode, status: "rolled_back", completedAt: now }, + payload: { + ...snapshot.providerThread, + lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, + updatedAt: now, + }, }), ); - } - } - yield* eventSink.write({ events }); + for (const staleCheckpoint of staleCheckpoints) { + events.push( + yield* makeEvent({ + type: "checkpoint.captured", + threadId: input.threadId, + ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), + nodeId: staleCheckpoint.nodeId, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...staleCheckpoint, status: "stale" }, + }), + ); + } + for (const run of runsToRollback) { + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + events.push( + yield* makeEvent({ + type: "run.updated", + threadId: input.threadId, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "rolled_back", completedAt: now }, + }), + ); + if (rootNode !== undefined) { + events.push( + yield* makeEvent({ + type: "node.updated", + threadId: input.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "rolled_back", completedAt: now }, + }), + ); + } + } + yield* eventSink.write({ events }); + }), + ); }); return CheckpointRollbackServiceV2.of({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 98e05de9a5a26..9cf3351f5d252 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -182,6 +182,7 @@ const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.p IdAllocator.layer, ProjectionStore.layer, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntimePolicyProvided, ), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 83762ef06ceab..230ad7aa0feb8 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -395,6 +395,7 @@ export function layerWithRegistry( IdAllocator.layer, layerStores, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntime, ), ), From 6053a7878d24e5fd596d2b417ed94964a4b9c1c6 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:24:58 -0700 Subject: [PATCH 090/680] fix(web): PR file stats ignore the hide-whitespace toggle (#16162) --- .../pullRequest/PullRequestCodeTab.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index caadd569b9c5f..d531259e5781a 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -37,6 +37,7 @@ import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -596,6 +597,23 @@ function PullRequestCodeTab({ ), [loadedSlices], ); + const sourceFileStats = useMemo( + () => + new Map( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.map( + (file, index) => + [ + buildFileDiffRenderKey(file), + getDiffLineStat([parsed.sourceFiles[index] ?? file]), + ] as const, + ) + : [], + ), + ), + [parsedSlices], + ); const fileKeys = useMemo(() => items.map((item) => item.id), [items]); const collapsedFileKeys = useMemo( () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), @@ -811,12 +829,8 @@ function PullRequestCodeTab({ const renderHeaderMetadata = useCallback( (item: CodeViewItem) => { if (item.type !== "diff") return null; - let additions = 0; - let deletions = 0; - for (const hunk of item.fileDiff.hunks) { - additions += hunk.additionLines; - deletions += hunk.deletionLines; - } + let { additions, deletions } = + sourceFileStats.get(item.id) ?? getDiffLineStat([item.fileDiff]); const path = resolveFileDiffPath(item.fileDiff); if (additions === 0 && deletions === 0) { const withheld = omittedFileStats.get(path); @@ -864,7 +878,7 @@ function PullRequestCodeTab({ ); }, - [omittedFileStats], + [omittedFileStats, sourceFileStats], ); const diffViewOptions = useMemo( From 28f5516a9acfcfc1e203487e78869a406fd23b76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:47 -0700 Subject: [PATCH 091/680] perf(mobile): omit duplicated turn items from bounded thread snapshots (#15385) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/ThreadStream.test.ts | 103 +++++ .../src/orchestration-v2/ThreadStream.ts | 40 +- .../boundedSnapshotTransport.test.ts | 370 ++++++++++++++++++ apps/server/src/orchestration-v2/http.ts | 10 +- apps/server/src/ws.ts | 6 +- .../state/boundedThreadSnapshotHttp.test.ts | 77 ++++ .../src/state/boundedThreadSnapshotHttp.ts | 16 +- .../src/state/threads-sync.test.ts | 111 ++++++ packages/client-runtime/src/state/threads.ts | 7 +- packages/contracts/src/environmentHttp.ts | 9 + .../contracts/src/orchestrationV2.test.ts | 83 ++++ packages/contracts/src/orchestrationV2.ts | 13 + packages/shared/package.json | 4 + .../orchestrationV2BoundedSnapshot.test.ts | 147 +++++++ .../src/orchestrationV2BoundedSnapshot.ts | 58 +++ 15 files changed, 1038 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.ts diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 5ef09f776da0f..d64de56cbf8aa 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,5 +1,6 @@ import { EventId, + OrchestrationV2ThreadStreamItem, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -8,6 +9,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadStreamSnapshot, @@ -262,3 +265,103 @@ describe("decideThreadResume", () => { ).toEqual({ mode: "replay", afterSequence: 9, throughSequence: 10 }); }); }); + +describe("compact bounded snapshots", () => { + const FORK_PARENT = ThreadId.make("thread-stream-parent"); + const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), + ); + const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + + /** Fork child: inherited parent rows and marker, then local rows, plus an older interrupt request. */ + function forkProjection(): OrchestrationV2ThreadProjection { + const base = timelineProjection(6); + const local = base.visibleTurnItems.slice(3).map((row, index) => ({ ...row, position: index })); + const inherited = base.visibleTurnItems.slice(0, 3).map((row, index) => { + const item = { ...row.item, id: TurnItemId.make(`parent-${index}`), threadId: FORK_PARENT }; + return { + position: index, + visibility: "inherited" as const, + sourceThreadId: FORK_PARENT, + sourceItemId: item.id, + item, + }; + }); + const request = { + ...base.turnItems[0]!, + id: TurnItemId.make("interrupt-request"), + type: "run_interrupt_request", + ordinal: 0, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + return { + ...base, + turnItems: [request, ...local.map((row) => row.item)], + visibleTurnItems: [...inherited, ...local].map((row, position) => ({ ...row, position })), + }; + } + + it.each([ + ["linear", () => timelineProjection(80)], + ["fork with retained interrupt request", forkProjection], + ])("restores the exact opted-out snapshot for %s threads", (_, makeProjection) => { + const base = makeProjection(); + // The shared fixture omits a model; the wire codec requires one. + const projection = { + ...base, + thread: { ...base.thread, modelSelection: { instanceId: "codex", model: "gpt-5" } }, + } as OrchestrationV2ThreadProjection; + const full = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, projection }); + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 44, + projection, + compactTurnItems: true, + }); + + // Opted-out clients get exactly the previous representation. + expect("turnItemsOmitLocalVisible" in full).toBe(false); + expect(compact.turnItemsOmitLocalVisible).toBe(true); + expect(compact.projection.turnItems.length).toBeLessThan(full.projection.turnItems.length); + const { turnItemsOmitLocalVisible: _marker, ...compactFields } = compact; + expect({ ...compactFields, projection: full.projection }).toEqual(full); + + // Decode the wire form like a client, then restore before use. + const decodedFull = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(full)))); + const decodedCompact = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(compact)))); + if (decodedFull.kind !== "snapshot" || decodedCompact.kind !== "snapshot") { + throw new Error("Expected snapshots"); + } + expect(boundedSnapshotProjection(decodedCompact)).toEqual(decodedFull.projection); + }); + + it("keeps the retained interrupt request outside the visible window", () => { + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: forkProjection(), + compactTurnItems: true, + }); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + expect(boundedSnapshotProjection(compact).turnItems.map((item) => String(item.id))).toEqual([ + "item-3", + "item-4", + "item-5", + "interrupt-request", + ]); + }); + + it("omits the marker when the window has no local rows", () => { + const fork = forkProjection(); + const inheritedOnly = { + ...fork, + turnItems: fork.turnItems.slice(0, 1), + visibleTurnItems: fork.visibleTurnItems.slice(0, 3), + }; + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: inheritedOnly, + compactTurnItems: true, + }); + expect("turnItemsOmitLocalVisible" in compact).toBe(false); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index ca8e2170c06d9..fad28abf677ab 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,7 +3,12 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { buildBoundedThreadProjection } from "./threadHistoryPaging.ts"; +import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; + +import { + buildBoundedThreadProjection, + type BoundedProjectionResult, +} from "./threadHistoryPaging.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; /** Maximum number of reducer applications allowed during a thread resume. */ @@ -39,10 +44,34 @@ type ThreadSnapshotStreamItem = Extract< { readonly kind: "snapshot" } >; +/** + * Bounded snapshot fields shared by the HTTP route and socket fallbacks. Only + * clients that opted in get compact `turnItems` and the marker; everyone else + * gets the unchanged representation. + */ +export function boundedSnapshotResponseFields(input: { + readonly bounded: BoundedProjectionResult; + readonly compactTurnItems: boolean; +}) { + const { bounded } = input; + const compactProjection = input.compactTurnItems + ? omitLocalVisibleTurnItems(bounded.projection) + : null; + return { + projection: compactProjection ?? bounded.projection, + historyCursor: bounded.historyCursor, + hasMoreHistory: bounded.hasMoreHistory, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + }; +} + /** Build the same bounded, wire-projected snapshot for every socket fallback path. */ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; + readonly compactTurnItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -51,11 +80,10 @@ export function buildBoundedThreadStreamSnapshot(input: { return { kind: "snapshot", snapshotSequence: input.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: input.compactTurnItems === true, + }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts new file mode 100644 index 0000000000000..a86b19be95f7e --- /dev/null +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -0,0 +1,370 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthSessionId, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentHttpApi, + EventId, + MessageId, + NodeId, + ORCHESTRATION_PROTOCOL_HEADER, + ORCHESTRATION_PROTOCOL_VERSION, + OrchestrationV2ThreadBoundedSnapshot, + OrchestrationV2ThreadStreamItem, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { Etag, HttpRouter } from "effect/http"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; +import { subscribeOrchestrationV2Thread } from "../ws.ts"; +import * as OrchestrationHttp from "./http.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +const decodeBounded = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadBoundedSnapshot)), +); +const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), +); +const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + +const provider = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId: provider, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:compact-transport"); +const PARENT = ThreadId.make("thread:compact-transport:parent"); +const FORK = ThreadId.make("thread:compact-transport:fork"); +const LONG = ThreadId.make("thread:compact-transport:long"); +const parentRun = RunId.make("run:compact-transport:parent"); +const longRun = RunId.make("run:compact-transport:long"); + +const store = Layer.mergeAll( + ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + SqlitePersistence.layerMemory, +); +const management = Layer.unwrap( + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + return Layer.mock(ThreadManagementService.ThreadManagementService)({ + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), + getThreadSnapshotWindow: (id, options) => + projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + streamStoredEventsFrom: () => Stream.empty, + }); + }), +); + +class OrchestrationApi extends HttpApi.make("environment").add( + EnvironmentHttpApi.groups.orchestration, +) {} +const auth = Layer.succeed(EnvironmentAuthenticatedAuth, (effect) => + effect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("compact-transport"), + subject: "compact-transport", + method: "browser-session-cookie", + scopes: new Set([AuthOrchestrationReadScope]), + }), + ), +); + +function threadCreated(id: ThreadId, at: DateTime.Utc, forkOf?: RunId): OrchestrationV2DomainEvent { + return { + id: EventId.make(`event:${id}:created`), + type: "thread.created", + threadId: id, + occurredAt: at, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: String(id), + providerInstanceId: provider, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: + forkOf === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: id } + : { parentThreadId: PARENT, relationshipToParent: "fork", rootThreadId: PARENT }, + forkedFrom: forkOf === undefined ? null : { type: "run", threadId: PARENT, runId: forkOf }, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }; +} + +function runCreated( + threadId: ThreadId, + runId: RunId, + at: DateTime.Utc, +): OrchestrationV2DomainEvent { + const rootNodeId = NodeId.make(`node:${runId}`); + return { + id: EventId.make(`event:${runId}:created`), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + driver: ProviderDriverKind.make("codex"), + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: provider, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId, + activeAttemptId: null, + status: "completed", + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +function itemUpdated( + threadId: ThreadId, + runId: RunId | null, + key: string, + ordinal: number, + at: DateTime.Utc, + type: + | "command_execution" + | "run_interrupt_request" + | "run_interrupt_result" = "command_execution", +): OrchestrationV2DomainEvent { + const base = { + id: TurnItemId.make(`turn-item:${threadId}:${key}`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: key, + startedAt: at, + completedAt: at, + updatedAt: at, + }; + const payload: OrchestrationV2TurnItem = + type === "command_execution" + ? { ...base, type, input: `echo ${key}`, output: `output ${key}`, exitCode: 0 } + : { ...base, type, message: key }; + return { + id: EventId.make(`event:${threadId}:${key}`), + type: "turn-item.updated", + threadId, + ...(runId === null ? {} : { runId }), + occurredAt: at, + payload, + }; +} + +const seed = Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const at = yield* DateTime.now; + const events: OrchestrationV2DomainEvent[] = [ + threadCreated(PARENT, at), + runCreated(PARENT, parentRun, at), + ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), + threadCreated(FORK, at, parentRun), + ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), + threadCreated(LONG, at), + runCreated(LONG, longRun, at), + // The request sits far outside the recent window; its result is inside it. + itemUpdated(LONG, longRun, "request", 1, at, "run_interrupt_request"), + ...Array.from({ length: 90 }, (_, index) => + itemUpdated(LONG, longRun, `tool-${index}`, index + 2, at), + ), + itemUpdated(LONG, longRun, "result", 92, at, "run_interrupt_result"), + ]; + for (const event of events) yield* projections.apply(event); +}); + +// Bounded subscriptions without afterSequence never read the event store, and +// thread routes never touch projects. +const TestLayer = Layer.mergeAll( + management, + Layer.effectDiscard(seed), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({}), +).pipe(Layer.provideMerge(store)); + +const withHttp = (use: (get: (path: string) => Promise) => Promise) => + Effect.gen(function* () { + const context = yield* Effect.context>(); + const appLayer = HttpApiBuilder.layer(OrchestrationApi).pipe( + Layer.provide(OrchestrationHttp.layer), + Layer.provide(auth), + Layer.provide(Layer.succeedContext(context)), + Layer.provide(NodeHttpPlatform.layer), + Layer.provide(Etag.layerWeak), + Layer.provide(NodeServices.layer), + ); + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(appLayer, { disableLogger: true })), + ({ handler }) => + Effect.promise(() => + use((path) => + handler( + new Request(`http://environment.test${path}`, { + headers: { + [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION), + }, + }), + ), + ), + ), + ({ dispose }) => Effect.promise(() => dispose()), + ); + }); + +const firstSnapshot = (input: Parameters[0]) => + subscribeOrchestrationV2Thread(input).pipe( + Effect.flatMap((stream) => Stream.runHead(stream)), + Effect.map((head) => { + if (head._tag !== "Some" || head.value.kind !== "snapshot") throw new Error("No snapshot"); + // Through the wire codec, as a client receives it. + const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(head.value)))); + if (decoded.kind !== "snapshot") throw new Error("No snapshot"); + return { raw: head.value, decoded }; + }), + ); + +function itemIds(projection: OrchestrationV2ThreadProjection): string[] { + return projection.turnItems.map((item) => String(item.id)); +} + +it.layer(TestLayer)("compact bounded snapshot transport", (it) => { + describe.each([ + ["fork", FORK], + ["long run with a retained interrupt request", LONG], + ])("%s", (_, threadId) => { + it.effect("HTTP: legacy, ignored and opted-in requests restore to the same snapshot", () => + withHttp(async (get) => { + const base = `/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`; + const legacyBody = await (await get(base)).text(); + const ignoredBody = await (await get(`${base}?compactTurnItems=yes&unknown=1`)).text(); + const compactResponse = await get(`${base}?compactTurnItems=1`); + assert.strictEqual(compactResponse.status, 200); + const compactBody = await compactResponse.text(); + + // Requests without the exact opt-in get the unchanged representation. + assert.notInclude(legacyBody, "turnItemsOmitLocalVisible"); + assert.strictEqual(ignoredBody, legacyBody); + assert.isBelow(compactBody.length, legacyBody.length); + + const legacy = decodeBounded(legacyBody); + const compact = decodeBounded(compactBody); + assert.isTrue(compact.turnItemsOmitLocalVisible); + const { turnItemsOmitLocalVisible: _marker, projection: _p, ...compactRest } = compact; + const { projection: _legacyProjection, ...legacyRest } = legacy; + assert.deepStrictEqual(compactRest, legacyRest); + assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); + return { legacy, compact }; + }).pipe( + Effect.tap(({ legacy }) => + Effect.sync(() => { + if (threadId === FORK) { + const visibility = legacy.projection.visibleTurnItems.map((row) => row.visibility); + assert.deepStrictEqual(visibility, [ + "inherited", + "inherited", + "inherited", + "synthetic", + "local", + "local", + "local", + "local", + ]); + } else { + const ids = itemIds(legacy.projection); + assert.isTrue(legacy.hasMoreHistory); + // Window rows first, then the retained request outside the window. + assert.strictEqual(ids.at(-1), `turn-item:${LONG}:request`); + assert.isFalse( + legacy.projection.visibleTurnItems.some((row) => row.sourceItemId === ids.at(-1)), + ); + } + }), + ), + ), + ); + + it.effect("WS: bounded snapshots stay unchanged unless the client opts in", () => + Effect.gen(function* () { + const legacy = yield* firstSnapshot({ threadId, acceptBoundedSnapshot: true }); + const compact = yield* firstSnapshot({ + threadId, + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + // Full snapshots ignore the compact opt-in entirely. + const full = yield* firstSnapshot({ threadId, acceptCompactTurnItems: true }); + + assert.isUndefined(legacy.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.historyCursor); + assert.isTrue(compact.decoded.turnItemsOmitLocalVisible); + assert.isBelow( + compact.decoded.projection.turnItems.length, + legacy.decoded.projection.turnItems.length, + ); + assert.deepStrictEqual( + boundedSnapshotProjection(compact.decoded), + legacy.decoded.projection, + ); + assert.deepStrictEqual( + { + ...compact.decoded, + projection: legacy.decoded.projection, + turnItemsOmitLocalVisible: undefined, + }, + { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, + ); + }), + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cd433d3621ad9..e6b769d98c407 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -32,6 +32,7 @@ import { import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import { buildActiveShellSnapshot } from "./ShellStream.ts"; +import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; function isThreadNotFound(error: unknown): boolean { @@ -211,11 +212,10 @@ export const layer = HttpApiBuilder.group( }); return { snapshotSequence: snapshot.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: args.query.compactTurnItems === "1", + }), }; }), ) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55d5ec85b5a54..53bf27800583b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -748,6 +748,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptCompactTurnItems?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -841,7 +842,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ); const { snapshotSequence } = snapshot; const snapshotItem = useBoundedSnapshot - ? buildBoundedThreadStreamSnapshot(snapshot) + ? buildBoundedThreadStreamSnapshot({ + ...snapshot, + compactTurnItems: input.acceptCompactTurnItems === true, + }) : { kind: "snapshot" as const, snapshotSequence, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 48d26b31f70ca..906f06fb95aa2 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -76,7 +76,84 @@ const FULL_SNAPSHOT_BODY = { }, }; +const AT = "2026-06-20T00:00:00.000Z"; +const command = (id: string, ordinal: number) => ({ + id, + type: "command_execution", + threadId: String(THREAD_ID), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + input: id, + exitCode: 0, + startedAt: AT, + completedAt: AT, + updatedAt: AT, +}); +const LOCAL_ITEMS = [command("local-1", 1), command("local-2", 2)]; +const DEPENDENCY = command("older-dependency", 0); +const VISIBLE = LOCAL_ITEMS.map((item, position) => ({ + position, + visibility: "local", + sourceThreadId: String(THREAD_ID), + sourceItemId: item.id, + item, +})); +const boundedBody = (compact: boolean) => ({ + snapshotSequence: 12, + projection: { + ...FULL_SNAPSHOT_BODY.projection, + turnItems: compact ? [DEPENDENCY] : [...LOCAL_ITEMS, DEPENDENCY], + visibleTurnItems: VISIBLE, + }, + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + ...(compact ? { turnItemsOmitLocalVisible: true } : {}), +}); + describe("boundedThreadSnapshotLoader", () => { + it.effect.each([ + ["an older server that ignores the opt-in", false], + ["a server that sends compact turnItems", true], + ] as const)("requests compact turnItems and loads %s", ([, compact]) => { + const urls: string[] = []; + const fetchFn = ((input: RequestInfo | URL) => { + urls.push(String(input)); + return Promise.resolve(Response.json(boundedBody(compact))); + }) satisfies typeof fetch; + + return Effect.gen(function* () { + const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; + const result = yield* loader.load(PREPARED, THREAD_ID); + expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(result._tag).toBe("present"); + if (result._tag !== "present") return; + expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "older-dependency", + ]); + expect("turnItemsOmitLocalVisible" in result.snapshot).toBe(false); + expect(result.history).toEqual({ + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + }); + }).pipe( + Effect.provide( + Layer.provide(BoundedThreadSnapshotHttp.layer, RpcHttp.layerRemoteHttpClient(fetchFn)), + ), + ); + }); + it.effect("falls back to full HTTP snapshot when bounded returns a plain route 404", () => { const fetchFn = ((input: RequestInfo | URL) => { const url = String(input); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index ffad41874c940..a049a6b148634 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -1,4 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +19,11 @@ import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; // Same cold-open budget as the full snapshot path; bounded payloads should fit. const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; -/** Load a bounded recent-window thread snapshot over HTTP. */ +/** + * Load a bounded recent-window thread snapshot over HTTP. Opts into compact + * turnItems and restores them, so callers always see the full bounded shape. + * Older servers ignore the query and send the full shape. + */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot", )(function* (input: { @@ -40,9 +45,16 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( request: ({ client, headers }) => client.threadBoundedSnapshot({ params: { threadId: input.threadId }, + query: { compactTurnItems: "1" }, headers: withOrchestrationProtocolHeader(headers), }), - }); + }).pipe( + // Drop the marker with the restore so nothing can restore twice. + Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ + ...snapshot, + projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), + })), + ); }); /** diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index a832d46b67325..d6cbbf6a7f423 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -122,6 +122,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastSubscribeAfterSequence = yield* Ref.make(undefined); const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); + const lastAcceptCompactTurnItems = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -139,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly afterSequence?: number; readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; + readonly acceptCompactTurnItems?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -147,6 +149,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker === true), ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), + Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), Effect.as(streamFrom(inputs)), ), ), @@ -261,6 +264,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastSubscribeAfterSequence, lastRequestCompletionMarker, lastAcceptBoundedSnapshot, + lastAcceptCompactTurnItems, supervisorState, supervisorSession, savedThreads, @@ -842,6 +846,113 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("restores compact socket snapshot turnItems before reducing and caching", () => + Effect.gen(function* () { + const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const command = (id: string, ordinal: number, threadId = THREAD_ID) => + ({ + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "command_execution" as const, + input: id, + output: id, + exitCode: 0, + }) satisfies OrchestrationV2TurnItem; + const parentThread = ThreadId.make("thread:compact-parent"); + const inherited = command("parent-item", 1, parentThread); + const local = [command("local-1", 1), command("local-2", 2)]; + const retainedRequest = { + ...command("retained-request", 0), + type: "run_interrupt_request" as const, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + const visibleTurnItems = [ + { + position: 0, + visibility: "inherited" as const, + sourceThreadId: parentThread, + sourceItemId: inherited.id, + item: inherited, + }, + ...local.map((item, index) => ({ + position: index + 1, + visibility: "local" as const, + sourceThreadId: THREAD_ID, + sourceItemId: item.id, + item, + })), + ]; + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshotSequence: 5, + // Only the dependency that is not a visible local row is on the wire. + projection: { ...BASE_PROJECTION, turnItems: [retainedRequest], visibleTurnItems }, + historyCursor: "compact-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + turnItemsOmitLocalVisible: true, + }); + + const seeded = yield* awaitThreadState( + harness.observed, + (value) => value.history.historyCursor === "compact-cursor", + ); + expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]); + expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); + expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); + + // Live updates reduce against the restored list, not the compact one. + const updated = { ...local[1]!, output: "updated", ordinal: 2 }; + yield* Queue.offer(harness.inputs, { + kind: "event", + sequence: 6, + event: { + id: EventId.make("event-compact-update"), + type: "turn-item.updated", + threadId: THREAD_ID, + occurredAt: at, + payload: updated, + }, + }); + const live = yield* awaitThreadState(harness.observed, (value) => + Option.exists(value.data, (data) => + data.turnItems.some( + (item) => item.type === "command_execution" && item.output === "updated", + ), + ), + ); + expect(Option.getOrThrow(live.data).turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + const saved = (yield* Ref.get(harness.savedThreads)).at(-1); + expect(saved?.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + expect(saved && "turnItemsOmitLocalVisible" in saved).toBe(false); + }), + ); + it.effect("seeds the thread from the HTTP snapshot and resumes live events", () => Effect.gen(function* () { const httpProjection: OrchestrationV2ThreadProjection = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 62df7cd30dfb7..5c64c41b1d018 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -6,6 +6,7 @@ import { type OrchestrationV2ThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -589,7 +590,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // Bounded socket fallbacks carry their cursor. Legacy-compatible full // snapshots omit these fields and still replace progressive state. yield* setThread( - item.projection, + boundedSnapshotProjection(item), hasProgressiveHistory ? { history: { @@ -919,7 +920,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const } : {}), + ...(acceptBoundedSnapshot + ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + : {}), }; }), { diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a722ba064bd3b..b74affb862168 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -584,6 +584,14 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +/** + * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`. Other values are + * ignored, and older servers ignore the whole query. + */ +const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + compactTurnItems: Schema.optionalKey(Schema.String), +}); + const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, }); @@ -615,6 +623,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") HttpApiEndpoint.get("threadBoundedSnapshot", "/api/orchestration/threads/:threadId/bounded", { headers: OrchestrationProtocolHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadBoundedSnapshotQuery, success: OrchestrationV2ThreadBoundedSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index e8fd216de9803..cd185f908d5ce 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -45,6 +45,48 @@ import { } from "./orchestrationV2.ts"; const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); +function emptyThreadProjection() { + return { + thread: { + createdBy: "user", + creationSource: "web", + id: "thread-1", + projectId: "project-1", + title: "Thread", + providerInstanceId: "codex", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; +} + const LegacyShellStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("synchronized") }), Schema.Struct({ @@ -326,6 +368,47 @@ describe("orchestration V2 contracts", () => { expect("acceptBoundedSnapshot" in legacyDecoded).toBe(false); }); + it("negotiates compact bounded turnItems without disturbing older peers", () => { + // Older servers strip the unknown opt-in, so they keep sending full turnItems. + const legacyDecoded = decodeLegacySubscribeThreadInput({ + threadId: "thread-1", + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + expect("acceptCompactTurnItems" in legacyDecoded).toBe(false); + expect( + decodeOrchestrationV2SubscribeThreadInput({ + threadId: "thread-1", + acceptCompactTurnItems: true, + }).acceptCompactTurnItems, + ).toBe(true); + + const decodeStreamItem = Schema.decodeUnknownSync(OrchestrationV2ThreadStreamItem); + const projection = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + }); + // Older servers never send the marker; newer clients treat absence as full turnItems. + expect(projection.kind === "snapshot" && projection.turnItemsOmitLocalVisible).toBe(undefined); + const marked = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: true, + }); + expect(marked.kind === "snapshot" && marked.turnItemsOmitLocalVisible).toBe(true); + // The marker only means "omitted"; any other value is a protocol error. + expect(() => + decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: false, + }), + ).toThrow(); + }); + it("decodes persisted capability snapshots that predate runtimePolicy", () => { // Events written before the field existed must replay; absent decodes to // the weaker client-boundary guarantee so history never overclaims. diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4b..abc226e356df2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3244,6 +3244,11 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Allows snapshot fallbacks to contain a bounded, pageable history window. */ acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + /** + * Allows bounded snapshot fallbacks to omit `projection.turnItems` entries + * that repeat local visible rows. See `turnItemsOmitLocalVisible`. + */ + acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; @@ -3283,6 +3288,12 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), /** True when complete turns or required live control state exceed the usual byte budget. */ payloadBudgetExceeded: Schema.optional(Schema.Boolean), + /** + * Set only for clients that opted in: `projection.turnItems` omits the items + * of local visible rows, which lead the full list. Clients must restore them + * with `boundedSnapshotProjection` before using the projection. + */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; @@ -3363,6 +3374,8 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ hasMoreHistory: Schema.optionalKey(Schema.Boolean), latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), + /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }), Schema.Struct({ kind: Schema.Literal("event"), diff --git a/packages/shared/package.json b/packages/shared/package.json index 50cc08129d8e9..4025994af0765 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -359,6 +359,10 @@ "types": "./src/nodeSqliteClient.ts", "import": "./src/nodeSqliteClient.ts" }, + "./orchestrationV2BoundedSnapshot": { + "types": "./src/orchestrationV2BoundedSnapshot.ts", + "import": "./src/orchestrationV2BoundedSnapshot.ts" + }, "./orchestrationV2Timeline": { "types": "./src/orchestrationV2Timeline.ts", "import": "./src/orchestrationV2Timeline.ts" diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts new file mode 100644 index 0000000000000..cf231c0617ef8 --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts @@ -0,0 +1,147 @@ +import { + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + boundedSnapshotProjection, + omitLocalVisibleTurnItems, + restoreLocalVisibleTurnItems, +} from "./orchestrationV2BoundedSnapshot.ts"; + +const PARENT = ThreadId.make("thread:compact-parent"); +const CHILD = ThreadId.make("thread:compact-child"); +const RUN = RunId.make("run:compact"); + +function item( + threadId: ThreadId, + id: string, + ordinal: number, + type: "command_execution" | "run_interrupt_request" | "fork" = "command_execution", +): OrchestrationV2TurnItem { + return { + id: TurnItemId.make(id), + type, + threadId, + runId: type === "command_execution" ? null : RUN, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: id, + input: id, + exitCode: 0, + startedAt: null, + completedAt: null, + updatedAt: "2026-10-01T00:00:00.000Z", + } as unknown as OrchestrationV2TurnItem; +} + +function row( + visibility: OrchestrationV2ProjectedTurnItem["visibility"], + sourceThreadId: ThreadId, + value: OrchestrationV2TurnItem, +): Omit { + return { visibility, sourceThreadId, sourceItemId: value.id, item: value }; +} + +function projection(input: { + readonly visible: ReadonlyArray>; + readonly turnItems: ReadonlyArray; +}): OrchestrationV2ThreadProjection { + return { + thread: { id: CHILD }, + turnItems: [...input.turnItems], + visibleTurnItems: input.visible.map((value, position) => ({ ...value, position })), + } as unknown as OrchestrationV2ThreadProjection; +} + +/** Fork child: inherited parent rows, synthetic marker, local rows, retained interrupt request. */ +function forkWindow() { + const inherited = [item(PARENT, "parent-1", 1), item(PARENT, "parent-2", 2)]; + const marker = item(PARENT, "fork-marker", 3, "fork"); + const local = [item(CHILD, "child-1", 1), item(CHILD, "child-2", 2), item(CHILD, "child-3", 3)]; + const retainedRequest = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + return { + local, + retainedRequest, + projection: projection({ + visible: [ + ...inherited.map((value) => row("inherited", PARENT, value)), + row("synthetic", PARENT, marker), + ...local.map((value) => row("local", CHILD, value)), + ], + turnItems: [...local, retainedRequest], + }), + }; +} + +describe("compact bounded snapshot turnItems", () => { + it("omits only local visible items and restores the exact list and order", () => { + const { projection: full, local, retainedRequest } = forkWindow(); + const compact = omitLocalVisibleTurnItems(full); + + expect(compact).not.toBeNull(); + // Inherited and synthetic rows never had turnItems; the retained request stays. + expect(compact!.turnItems).toEqual([retainedRequest]); + expect(compact!.visibleTurnItems).toBe(full.visibleTurnItems); + + const restored = restoreLocalVisibleTurnItems(compact!); + expect(restored.turnItems).toEqual([...local, retainedRequest]); + expect(restored).toEqual(full); + }); + + it("round-trips through JSON as a separately decoded client would see it", () => { + const { projection: full } = forkWindow(); + const wire = JSON.parse(JSON.stringify(omitLocalVisibleTurnItems(full))); + expect( + boundedSnapshotProjection({ projection: wire, turnItemsOmitLocalVisible: true }), + ).toEqual(JSON.parse(JSON.stringify(full))); + }); + + it("treats rows sourced from the thread itself as local", () => { + const own = item(CHILD, "own", 1); + const full = projection({ visible: [row("inherited", CHILD, own)], turnItems: [own] }); + const compact = omitLocalVisibleTurnItems(full); + expect(compact?.turnItems).toEqual([]); + expect(restoreLocalVisibleTurnItems(compact!)).toEqual(full); + }); + + it("refuses shapes it cannot restore exactly", () => { + const [first, second] = [item(CHILD, "a", 1), item(CHILD, "b", 2)]; + const visible = [row("local", CHILD, first), row("local", CHILD, second)]; + // Different order than the visible rows. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [second, first] }))).toBe( + null, + ); + // Equal content but a different object: restoring would not be provably exact. + expect( + omitLocalVisibleTurnItems(projection({ visible, turnItems: [{ ...first }, second] })), + ).toBe(null); + // A local row without its turn item. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [first] }))).toBe(null); + }); + + it("sends nothing compact when no local rows are visible", () => { + const inherited = item(PARENT, "parent", 1); + const request = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + expect( + omitLocalVisibleTurnItems( + projection({ visible: [row("inherited", PARENT, inherited)], turnItems: [request] }), + ), + ).toBe(null); + }); + + it("leaves unmarked snapshots untouched", () => { + const { projection: full } = forkWindow(); + expect(boundedSnapshotProjection({ projection: full })).toBe(full); + }); +}); diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts new file mode 100644 index 0000000000000..117306819cd26 --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -0,0 +1,58 @@ +import type { + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; + +/** Local timeline rows also carry their item in `projection.turnItems`. */ +function isLocalTimelineRow( + projection: Pick, + row: OrchestrationV2ProjectedTurnItem, +): boolean { + return row.visibility === "local" || row.sourceThreadId === projection.thread.id; +} + +/** + * Server half of the opt-in compact bounded snapshot. Bounded `turnItems` start + * with the items of the window's local visible rows, so those entries can be + * dropped and rebuilt by `restoreLocalVisibleTurnItems`. Returns null when the + * projection does not have that exact shape, or when nothing would be omitted; + * callers then send the unchanged projection without a marker. + */ +export function omitLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection | null { + let omitted = 0; + for (const row of projection.visibleTurnItems) { + if (!isLocalTimelineRow(projection, row)) continue; + // Same object, not merely the same id: restoring from the row must + // reproduce the original entry exactly. + if (projection.turnItems[omitted] !== row.item) return null; + omitted += 1; + } + if (omitted === 0) return null; + return { ...projection, turnItems: projection.turnItems.slice(omitted) }; +} + +/** + * Client half: call on a decoded snapshot that carries the + * `turnItemsOmitLocalVisible` marker, before reducers or caches see it. + */ +export function restoreLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const local = projection.visibleTurnItems + .filter((row) => isLocalTimelineRow(projection, row)) + .map((row) => row.item); + if (local.length === 0) return projection; + return { ...projection, turnItems: [...local, ...projection.turnItems] }; +} + +/** The usable projection of a decoded bounded snapshot, compact or not. */ +export function boundedSnapshotProjection(snapshot: { + readonly projection: OrchestrationV2ThreadProjection; + readonly turnItemsOmitLocalVisible?: true | undefined; +}): OrchestrationV2ThreadProjection { + return snapshot.turnItemsOmitLocalVisible === true + ? restoreLocalVisibleTurnItems(snapshot.projection) + : snapshot.projection; +} From eb17e0db2bd075e71b4015dc57588e7f838cbcdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 092/680] perf(mobile): pause elapsed-time timers on hidden thread screens (#15397) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 1 + .../features/threads/ProviderSubagentBar.tsx | 9 +- .../threads/floating-working-control.tsx | 7 +- .../threads/use-visible-second-clock.test.tsx | 199 ++++++++++++++++++ .../threads/use-visible-second-clock.ts | 34 +++ pnpm-lock.yaml | 3 + 6 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.test.tsx create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca295e389fe20..b8d8dea0d058b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -135,6 +135,7 @@ "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index 813b641489019..fc93df0cd7a94 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -3,12 +3,12 @@ import { type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { RequestActionButton } from "./RequestActionButton"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; /** * Replaces the composer on a provider-native subagent thread. The provider @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { readonly onOpenParent: (() => void) | null; }) { const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!live) return; - const id = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(id); - }, [live]); + const nowMs = useVisibleSecondClock(live); const statusLabel = formatProviderSubagentStatus(props.status, nowMs); const modelDescription = props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 2e032eb8ba5e5..f21a83f09a177 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -510,11 +511,7 @@ function WorkingDuration(props: { } export function WorkingTimer(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, []); + const nowMs = useVisibleSecondClock(true); return ( { + const listeners = new Set<() => void>(); + const appStateListeners = new Set<(state: string) => void>(); + const state = { focused: true, appState: "active" }; + return { + state, + appStateListeners, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setFocused(focused: boolean) { + state.focused = focused; + for (const listener of listeners) listener(); + }, + setAppState(next: string) { + state.appState = next; + for (const listener of appStateListeners) listener(next); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return harness.state.appState; + }, + addEventListener: (_event: "change", listener: (state: string) => void) => { + harness.appStateListeners.add(listener); + return { remove: () => harness.appStateListeners.delete(listener) }; + }, + }, +})); + +import { useVisibleSecondClock } from "./use-visible-second-clock"; + +const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); +let root: Root | null = null; +// Clock value of every committed render, the work a tick costs on device. +let renders: number[] = []; + +function Probe(props: { readonly enabled: boolean }) { + const nowMs = useVisibleSecondClock(props.enabled); + useEffect(() => { + renders.push(nowMs); + }); + return null; +} + +function mount(enabled: boolean) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => root!.render()); +} + +// One act per second, so each tick commits its own render as it would on device. +function advance(ms: number) { + for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { + act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); + } +} + +function latest() { + return renders.at(-1); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + harness.state.focused = true; + harness.state.appState = "active"; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.useRealTimers(); +}); + +describe("useVisibleSecondClock", () => { + it("ticks once a second while enabled, focused and active", () => { + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(60); + expect(latest()).toBe(START_MS + 60_000); + }); + + it("does not wake while the app is in the background", () => { + harness.state.appState = "background"; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake while the screen is unfocused", () => { + harness.state.focused = false; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake when disabled, even when visible", () => { + mount(false); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + }); + + it("notices an AppState change between first render and subscribing", () => { + // Layout effects run before the hook's passive subscription, so this change has no listener. + function BackgroundBeforeSubscribe() { + useLayoutEffect(() => harness.setAppState("background"), []); + return null; + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + <> + + + , + ), + ); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { + mount(true); + advance(5_000); + act(() => harness.setFocused(false)); + const paused = renders.length; + advance(120_000); + expect(renders.length).toBe(paused); + + act(() => harness.setFocused(true)); + expect(latest()).toBe(START_MS + 125_000); + advance(1_000); + expect(latest()).toBe(START_MS + 126_000); + }); + + it("pauses while backgrounded and catches up on the first active render", () => { + mount(true); + act(() => harness.setAppState("background")); + const paused = renders.length; + advance(600_000); + expect(renders.length).toBe(paused); + expect(vi.getTimerCount()).toBe(0); + + act(() => harness.setAppState("inactive")); + advance(10_000); + expect(renders.length).toBe(paused); + + act(() => harness.setAppState("active")); + expect(latest()).toBe(START_MS + 610_000); + const resumed = renders.length; + advance(60_000); + expect(renders.length - resumed).toBe(60); + }); + + it("stops when the work it measures settles and refreshes when new work starts", () => { + mount(true); + advance(3_000); + act(() => root!.render()); + const settled = renders.length; + advance(60_000); + expect(renders.length).toBe(settled); + + act(() => root!.render()); + expect(latest()).toBe(START_MS + 63_000); + }); + + it("clears its interval and AppState subscription on unmount", () => { + mount(true); + expect(vi.getTimerCount()).toBe(1); + expect(harness.appStateListeners.size).toBe(1); + act(() => root!.unmount()); + root = null; + expect(vi.getTimerCount()).toBe(0); + expect(harness.appStateListeners.size).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts new file mode 100644 index 0000000000000..ff98092b231bd --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -0,0 +1,34 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { AppState } from "react-native"; + +/** + * Wall clock for elapsed-time labels, advanced once a second only while + * `enabled`, the screen is focused, and the app is active. Retained routes stay + * mounted when hidden, so an ungated interval would keep re-rendering them. + * The clock re-reads the time before paint when ticking resumes, so a label + * never shows the moment it paused. + */ +export function useVisibleSecondClock(enabled: boolean): number { + const focused = useIsFocused(); + const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setAppActive(state === "active"), + ); + // A change between the first render and this subscription has no event to replay. + // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. + setAppActive(AppState.currentState === "active"); + return () => subscription.remove(); + }, []); + const ticking = enabled && focused && appActive; + useLayoutEffect(() => { + if (!ticking) return; + // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. + setNowMs(Date.now()); + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [ticking]); + return nowMs; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a54dc0d1fa25..bf0256cee1cda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: babel-preset-expo: specifier: ~58.0.8 version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) + jsdom: + specifier: ^30.0.1 + version: 30.1.0(@noble/hashes@1.8.0) tailwindcss: specifier: 4.3.3 version: 4.3.3 From 65404eb75de9233c9d1068ddfd09bacfc3b0465b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 093/680] fix(mobile): pause hidden home thread list updates (#15705) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeRouteScreen.tsx | 12 +- .../home/home-route-visibility.test.tsx | 143 ++++++++++++++++++ .../features/home/home-route-visibility.ts | 57 +++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/home/home-route-visibility.test.tsx create mode 100644 apps/mobile/src/features/home/home-route-visibility.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 0f213ca59b079..cd8af281c9f51 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,7 +9,8 @@ import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; +import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; import { useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; @@ -37,7 +39,13 @@ export function HomeRouteScreen() { const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; const { layout, panes } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); - const threads = useNavigationThreadShells(); + // Streaming turns rewrite thread shells many times a second. While a Thread + // covers Home, rebuilding this list is invisible work. + const visible = useHomeRouteVisible(); + const threads = useAtomValueWhileVisible( + environmentThreadShells.navigationThreadShellsAtom, + visible, + ); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx new file mode 100644 index 0000000000000..f1c670119ce15 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type Listener = (event: { readonly data?: { readonly closing: boolean } }) => void; + +const harness = vi.hoisted(() => { + const listeners = new Map>(); + const state = { focused: true }; + return { + state, + listeners, + navigation: { + isFocused: () => state.focused, + addListener(type: string, listener: Listener) { + let set = listeners.get(type); + if (set === undefined) { + set = new Set(); + listeners.set(type, set); + } + set.add(listener); + return () => set.delete(listener); + }, + }, + emit(type: string, data?: { readonly closing: boolean }) { + for (const listener of listeners.get(type) ?? []) listener({ data }); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => harness.navigation, +})); + +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; + +const shellsAtom = Atom.make(0).pipe(Atom.keepAlive); +let registry: AtomRegistry.AtomRegistry; +let root: Root | null = null; +// Value of every committed render, the work a shell update costs on device. +let renders: Array<{ readonly visible: boolean; readonly value: number }> = []; + +function Probe() { + const visible = useHomeRouteVisible(); + const value = useAtomValueWhileVisible(shellsAtom, visible); + useEffect(() => { + renders.push({ visible, value }); + }); + return null; +} + +function mount() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function stream(count: number) { + for (let index = 0; index < count; index += 1) { + act(() => registry.update(shellsAtom, (value) => value + 1)); + } +} + +function pushThread() { + act(() => harness.emit("transitionStart", { closing: true })); + act(() => harness.emit("transitionEnd", { closing: true })); +} + +beforeEach(() => { + registry = AtomRegistry.make(); + harness.listeners.clear(); + harness.state.focused = true; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + registry.dispose(); +}); + +describe("Home route visibility", () => { + it("re-renders for every shell update while Home is visible", () => { + mount(); + stream(20); + expect(renders.at(-1)).toEqual({ visible: true, value: 20 }); + expect(renders).toHaveLength(21); + }); + + it("stays live until the covering Thread finishes its push animation", () => { + mount(); + act(() => harness.emit("transitionStart", { closing: true })); + stream(3); + expect(renders.at(-1)).toEqual({ visible: true, value: 3 }); + }); + + it("does no work for shell updates while a Thread covers Home", () => { + mount(); + stream(2); + pushThread(); + const before = renders.length; + stream(600); + expect(renders.length - before).toBe(0); + expect(renders.at(-1)).toEqual({ visible: false, value: 2 }); + }); + + it("shows the freshest list as soon as a back swipe starts revealing Home", () => { + mount(); + pushThread(); + stream(50); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 50 }); + stream(1); + expect(renders.at(-1)).toEqual({ visible: true, value: 51 }); + }); + + it("refreshes on focus when no appear event arrives", () => { + mount(); + pushThread(); + stream(5); + act(() => harness.emit("focus")); + expect(renders.at(-1)).toEqual({ visible: true, value: 5 }); + }); + + it("starts hidden when a deep link mounts Home under its Thread", () => { + harness.state.focused = false; + act(() => registry.set(shellsAtom, 7)); + mount(); + stream(10); + expect(renders).toEqual([{ visible: false, value: 7 }]); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 17 }); + }); +}); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts new file mode 100644 index 0000000000000..1d5f5c7aab997 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { Atom } from "effect/reactivity"; + +/** + * Whether the Home route can be seen. Home stays mounted under a pushed + * Thread, and native-stack never freezes the route directly below the focused + * one on Fabric, so Home has to stop its own work. + * + * Driven by the native appear/disappear events rather than focus. A form sheet + * over Home blurs it but leaves it on screen, and the presenting screen gets no + * disappear event for it. Home becomes visible as soon as a back swipe or pop + * starts revealing it, so the list is fresh during the transition. + */ +export function useHomeRouteVisible(): boolean { + const navigation = + useNavigation>(); + // A cold deep link mounts Home under its Thread without ever showing it. + const [visible, setVisible] = useState(() => navigation.isFocused()); + useEffect(() => { + const show = () => setVisible(true); + const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { + if (!data.closing) show(); + }); + const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { + if (data.closing) setVisible(false); + }); + // Popping a sheet or a deep-linked route above Home has no appear event. + const removeFocus = navigation.addListener("focus", show); + return () => { + removeTransitionStart(); + removeTransitionEnd(); + removeFocus(); + }; + }, [navigation]); + return visible; +} + +/** + * Like `useAtomValue`, but unsubscribed while `visible` is false: the last + * visible value is held and atom changes do not re-render. Resubscribing reads + * the current value in the same render that turns visibility back on. + */ +export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { + const registry = useContext(RegistryContext); + const held = useRef<{ readonly value: A } | null>(null); + const subscribe = useCallback( + (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), + [registry, atom, visible], + ); + return useSyncExternalStore(subscribe, () => { + if (visible || held.current === null) held.current = { value: registry.get(atom) }; + return held.current.value; + }); +} From e803242d936af3e052757d59ab32bc7c4fcc2390 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:33:55 -0700 Subject: [PATCH 094/680] fix(mobile): restore file viewer insets and glass header (#17073) --- .../ios/T3ReviewDiffModule.swift | 8 +++++ .../t3-review-diff/ios/T3ReviewDiffView.swift | 36 ++++++++++++++----- apps/mobile/src/Stack.tsx | 4 +-- .../src/components/ComposerContextSheet.tsx | 1 + .../features/diffs/nativeReviewDiffSurface.ts | 2 ++ .../src/features/files/FileTreeBrowser.tsx | 24 ++++--------- .../src/features/files/SourceFileSurface.tsx | 29 +++++++++++---- .../features/files/ThreadFilesRouteScreen.tsx | 9 +++-- .../files/thread-file-navigator-pane.tsx | 1 - 9 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 96a1785f9fc64..5564dce34af30 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -49,6 +49,14 @@ public class T3ReviewDiffModule: Module { view.setContentWidth(CGFloat(contentWidth)) } + Prop("contentInsetTop") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetTop(CGFloat(inset)) + } + + Prop("contentInsetBottom") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetBottom(CGFloat(inset)) + } + Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in view.setInitialRowIndex(initialRowIndex) } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 928e3b5669d18..dfddaf7016c36 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + // Source files fill the screen so UIKit can sample content below the glass bar. + // Keep the same visible row when header or safe-area reservations change. + func setContentInsetTop(_ inset: CGFloat) { + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + let inset = max(0, inset) + let delta = inset - scrollView.contentInset.top + guard delta != 0 else { return } + scrollView.contentInset.top = inset + scrollView.verticalScrollIndicatorInsets.top = inset + scrollView.contentOffset.y -= delta + updateViewportFrame() + } + + func setContentInsetBottom(_ inset: CGFloat) { + scrollView.contentInset.bottom = max(0, inset) + scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset) + } + func setContentResetKey(_ contentResetKey: String) { guard contentResetKey != self.contentResetKey else { return @@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { hasAppliedInitialRowIndex = false pendingScrollFileId = nil isProgrammaticScrollActive = false - scrollView.setContentOffset(.zero, animated: false) + scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) updateContentMetrics() updateViewportFrame() lastVisibleFileId = nil @@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let clampedOffset = min(max(targetOffset, 0), maxOffset) + let topInset = scrollView.contentInset.top + let maxOffset = max( + scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, + -topInset + ) + let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset) let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 isProgrammaticScrollActive = shouldAnimate contentView.isVerticalScrollActive = shouldAnimate @@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { return } - let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom + let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3) hasAppliedInitialRowIndex = true - scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) - updateViewportFrame() + setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b215ba3380700..569bfc11ada9a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally -// (file viewer, terminal, review) — there is nothing for glass to sample there. +// (terminal, review) — there is nothing for glass to sample there. const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, - options: SOLID_HEADER_OPTIONS, + options: GLASS_HEADER_OPTIONS, }), ThreadMcpApp: createNativeStackScreen({ screen: McpAppFullscreenScreen, diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 208c15268ec1d..4fbc02ab1e492 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined; style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }} > diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index fda7341066873..16208a7d5a2dd 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly contentInsetTop?: number; + readonly contentInsetBottom?: number; readonly initialRowIndex?: number; readonly refreshing?: boolean; readonly nativeViewRef?: Ref; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index ed42f5345abe7..4d313b47b49fc 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { buildFileTree, flattenFileTree, @@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly loaded: boolean; readonly loading: boolean; readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; @@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { {node.kind === "directory" && props.expanded && props.loading ? ( - ) : node.kind === "directory" && props.loaded ? ( - - {node.children.length} - ) : null} ); @@ -115,7 +110,6 @@ export function FileTreeBrowser(props: { readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; - readonly loadedDirectories: ReadonlySet; readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; @@ -128,14 +122,12 @@ export function FileTreeBrowser(props: { readonly selectedPathAtPress: string | null; } | null>(null); const insets = useSafeAreaInsets(); - // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the - // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const columnMetrics = useNativeColumnLayoutMetrics(); + const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, - loadedDirectories, loadingDirectories, selectedPath: controlledSelectedPath, } = props; @@ -226,7 +218,6 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - loaded={loadedDirectories.has(item.node.path)} loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} @@ -237,7 +228,6 @@ export function FileTreeBrowser(props: { expandedPaths, handleSelectFile, onPreviewFile, - loadedDirectories, loadingDirectories, selectedPath, toggleDirectory, @@ -245,8 +235,8 @@ export function FileTreeBrowser(props: { ); const extraData = useMemo( - () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), - [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + () => ({ expandedPaths, loadingDirectories, selectedPath }), + [expandedPaths, loadingDirectories, selectedPath], ); // UIKit owns the header inset on every supported iOS version. Keep the @@ -259,9 +249,7 @@ export function FileTreeBrowser(props: { data={visibleNodes} keyExtractor={(item) => item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined - } + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" estimatedItemSize={42} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 42b7dbf282d4c..928da8b0f5435 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -11,9 +11,10 @@ import { useWindowDimensions, View, } from "react-native"; - +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, @@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; + /** The enclosing sheet or card already reserves its own chrome and safe area. */ + readonly embedded?: boolean; readonly initialLine?: number | null; + /** Override when a fixed notice already reserves the native header. */ + readonly headerInsetTop?: number; /** Keep the entire document in one native text-selection scope. */ readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ @@ -184,6 +189,9 @@ function NativeSourceFileSurface( }, ) { const { NativeView, onRefresh } = props; + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const safeArea = columnMetrics?.safeArea ?? insets; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); @@ -214,6 +222,8 @@ function NativeSourceFileSurface( appearanceScheme={themeAppearance} contentResetKey={props.path} contentWidth={contentWidth} + contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)} + contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8} initialRowIndex={targetIndex ?? -1} rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} rowsJson={rowsJson} @@ -233,6 +243,7 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); @@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const list = ( String(index)} @@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { - ) : ( - - ); + const surface = + NativeView && !codeWordBreak && !props.selectable ? ( + + ) : ( + + ); + return surface; } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 6c8cac046bb20..ca9adeaea1650 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { ScreenHeader } from "../../components/ScreenHeader"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; @@ -229,6 +230,10 @@ function FileContent(props: { }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const reservedHeaderInset = + Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); @@ -300,7 +305,7 @@ function FileContent(props: { } return ( - + {props.truncated ? ( Preview limited to the first 1 MB of a truncated file. @@ -320,6 +325,7 @@ function FileContent(props: { contents={props.fileContents} path={props.relativePath} initialLine={props.initialLine} + headerInsetTop={props.truncated ? 0 : undefined} onRefresh={props.onRefresh} /> )} @@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { Date: Wed, 7 Oct 2026 22:45:07 -0700 Subject: [PATCH 095/680] fix(web): PR code toolbar no longer overlaps in narrow panels (#15561) --- .../pullRequest/PullRequestCodeTab.tsx | 178 +++++++++++------- 1 file changed, 115 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d531259e5781a..7c631825dc456 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { MessageSquareOffIcon, PilcrowIcon, Rows3Icon, + Settings2Icon, TextWrapIcon, TriangleAlertIcon, } from "lucide-react"; @@ -69,10 +70,12 @@ import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; @@ -1075,8 +1078,13 @@ function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; + const changeIgnoreWhitespace = (next: boolean) => { + setIgnoreWhitespace(next); + setDraft(null); + setSelectedLines(null); + }; const toolbar = ( -
    +
    {/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} @@ -1133,7 +1141,7 @@ function PullRequestCodeTab({ ) : null} {/* One count, and the caveats as icons that carry their own words. Spelled out they competed for a strip this narrow and every one of them truncated to nothing. */} - + {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} @@ -1224,30 +1232,113 @@ function PullRequestCodeTab({
    - - + + } + className="@lg:hidden" + aria-label="Diff options" + /> + } + > + + + Diff options + + + changeIgnoreWhitespace(checked)} + > + Hide whitespace changes + + setWordWrap(checked)} + > + Wrap lines + + + { + if (value === "stacked" || value === "split") { + updateClientSettings({ diffLayout: value }); } - variant="ghost" - size="sm" - pressed={ignoreWhitespace} - onPressedChange={(pressed) => { - setIgnoreWhitespace(Boolean(pressed)); - setDraft(null); - setSelectedLines(null); - }} - /> - } + }} + > + + Stacked + + + Split + + + + +
    + + changeIgnoreWhitespace(Boolean(pressed))} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + + { + const next = value[0]; + if (next === "stacked" || next === "split") { + updateClientSettings({ diffLayout: next }); + } + }} > - - - - {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - + + + + + + + + + { + setWordWrap(Boolean(pressed)); + }} + /> + } + > + + + + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + +
    {fileKeys.length > 0 ? ( ) : null} - { - const next = value[0]; - if (next === "stacked" || next === "split") { - updateClientSettings({ diffLayout: next }); - } - }} - > - - - - - - - - - { - setWordWrap(Boolean(pressed)); - }} - /> - } - > - - - - {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - {fileKeys.length > 0 ? ( Date: Wed, 7 Oct 2026 22:45:24 -0700 Subject: [PATCH 096/680] fix(web): PR commit menu no longer stretches across the window (#15560) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 7c631825dc456..1339b46196b89 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1098,7 +1098,7 @@ function PullRequestCodeTab({ {scopeLabel} - + onSelectedCommitChange(value === "all" ? null : value)} From 647d8affa78c848c2a55c3e00ed14af9a9eca58e Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:26 -0700 Subject: [PATCH 097/680] fix(web): command palette scrollbar no longer clipped at the top (#17035) --- apps/web/src/components/CommandPaletteResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b909a0c97e444..8a00d8f435c25 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -147,7 +147,7 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize onScroll={updateScrollFade} contentContainerClassName="px-2" className={cn( - "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2 [&::-webkit-scrollbar-track]:my-2", getVirtualizedScrollFadeClassName(scrollFade), )} /> From 85a41391e9625d760f5bb2294834c38f42bbf5b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:39 -0700 Subject: [PATCH 098/680] fix(web): Usage breadcrumb stays centered on small viewports (#15552) --- apps/web/src/components/usage/UsagePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0a525054915f8..a7a1be01188d1 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -328,8 +328,8 @@ export function UsagePage() { ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; const topbarContent = ( -
    - +
    +

    Usage

    @@ -398,7 +398,7 @@ export function UsagePage() {
    -
    +
    { element?.focus(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8a5db463884e0..d668fa7871b24 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1763,7 +1763,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Unpin thread" onClick={handleUnpinClick} - className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" /> } > @@ -1876,7 +1876,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} - className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" > Woke @@ -2040,7 +2040,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} className={cn( - "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", + "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", topStatus.className, )} > diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index 12d620a0c7e55..b3ad9cd105267 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -311,9 +311,9 @@ function AccessOption({ value={access} className={cn( "flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", selected - ? "border-primary bg-background ring-2 ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background ring-2 ring-inset ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a1cb0dac252a1..779e4bc134225 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -178,7 +178,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { type="button" data-scroll-anchor-ignore aria-expanded={isExpanded} - className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" + className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" style={{ paddingLeft: `${leftPadding}px` }} onClick={() => toggleDirectory(node.path)} > @@ -213,7 +213,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22cea..3e95f86de902c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1817,7 +1817,7 @@ function TimelineMinimap({ /> diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 9bf65102bf70f..dd8cd0dab03fa 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -203,7 +203,7 @@ export function MediaVideoPlayer({ type="button" aria-label={label ? `Play ${label}` : "Play video"} onClick={onOpen} - className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 39af63a90616c..afba72c805bd7 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -44,7 +44,7 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro type="button" aria-label={`Remove ${label} from history`} onClick={onRemove} - className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" + className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring group-hover:opacity-100" > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 19a246cf4be4d..6db7e296337bc 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -369,7 +369,7 @@ export function ProjectScriptEditorDialog({ type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ isSelected - ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} onClick={() => { diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx index bcfb3b180113f..48f71d1396758 100644 --- a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -32,7 +32,7 @@ export function PullRequestCopyableCode({ diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index cb6f09af1001b..cf4e91fcf5340 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -46,7 +46,7 @@ export function FoldedSettingsSection({ className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" >

    - + {title} }>
    - + @@ -818,7 +818,7 @@ function KeybindingKeyControl({ onClick={() => setDraft({ isRecording: true })} aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`} className={cn( - "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/24", + "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24", pillClassName, )} > diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx index 34178f9b54c7c..9bc2478923b18 100644 --- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx +++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx @@ -11,7 +11,7 @@ export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) { + } + /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" · ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9d..a47a4bf002415 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd9..aff52adeb1550 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 0000000000000..306302db6761c --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e1..c92226a9ee4f3 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f20..c11a49897b880 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b7388..b2c6933fabb7c 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b15..1d49fd71f70c4 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7c..c84169e6e33f6 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4ccc..2a61480a907be 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c7..36afee2f9efa0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d96580..b5d39323a23d1 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fdc..0038ede86c2d4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69df..af9a7112c3ea3 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74fc..dbc2704171f96 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c13..909eeb0a35ce6 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, From e858ddb65668d54be5de3996f30c0bd5c3149657 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 10 Oct 2026 03:09:23 +0100 Subject: [PATCH 256/680] test(usage): usage service tests keep their state directory until cache writes land (#17636) --- apps/server/src/usage/UsageService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 393437fcc37b1..e28e926d3f890 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -132,7 +132,9 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -1203,8 +1205,7 @@ describe("UsageService", () => { return text; }); - const restarted = yield* UsageService.make; - const summary = yield* restarted.readSummary(WINDOW); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1217,9 +1218,6 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); - // The migrated cache is written in the background; let it land before - // the layer removes the state directory under it. - yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ From f3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 9 Oct 2026 19:09:46 -0700 Subject: [PATCH 257/680] fix(checkpoint): pulls and rebases no longer flood a turn's changed files (#17161) Co-authored-by: Dylan Zahn Co-authored-by: Claude Opus 5.5 --- .../checkpointing/CheckpointDiffQuery.test.ts | 30 ++++ .../src/checkpointing/CheckpointDiffQuery.ts | 68 +++++++-- .../src/checkpointing/CheckpointStore.test.ts | 132 +++++++++++++++++- .../src/checkpointing/CheckpointStore.ts | 29 ++++ apps/server/src/checkpointing/Diffs.test.ts | 10 +- apps/server/src/checkpointing/Diffs.ts | 20 +++ .../CheckpointCaptureService.test.ts | 1 + .../CheckpointScopeOwnership.test.ts | 1 + .../src/orchestration-v2/CheckpointService.ts | 54 ++++--- apps/server/src/vcs/GitVcsDriver.ts | 64 ++++++++- apps/server/src/vcs/VcsDriver.ts | 11 ++ docs/user/source-control.md | 7 + 12 files changed, 390 insertions(+), 37 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index b7cf965f57ca3..bdb3e02785d8a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext { function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; + readonly authoredPaths?: ReadonlySet; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( @@ -57,6 +58,7 @@ function layerFor(input: { }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), + listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null), }), ), ), @@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { }).pipe(Effect.provide(layer)); }); +it.effect("leaves Git imports out of large turn diffs", () => { + const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed( + input.format === "numstat" + ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") + : (input.filePaths ?? ["all"]).join("\n"), + ), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + authoredPaths: new Set(ownPaths), + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + const patchPaths = diffCheckpoints.mock.calls + .map(([call]) => call) + .filter((call) => call.format !== "numstat") + .map((call) => call.filePaths ?? ["all"]); + assert.isAbove(patchPaths.length, 1); + assert.sameMembers(patchPaths.flat(), ownPaths); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the typed missing-thread error contract", () => { const layer = layerFor({ projection: Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 37414f35bfb65..37ba07ca12c30 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -31,6 +31,31 @@ import { type CheckpointServiceError, } from "./Errors.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts"; + +// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. +const MAX_PATHSPEC_CHARS = 24_000; + +/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */ +function batchFilePaths(files: ReadonlyArray) { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path]; + // Each pathspec also carries a `:(top,literal)` prefix and a separator. + const size = paths.reduce((total, path) => total + path.length + 16, 0); + if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(...paths); + length += size; + } + if (batch.length > 0) batches.push(batch); + return batches; +} /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { }); } - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + const comparison = { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary. + // Filtering is optional: when it cannot run, the complete diff still loads. + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const diff = + retainedFiles.length === files.length + ? yield* checkpointStore + .diffCheckpoints(comparison) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")) + : (yield* Effect.forEach( + batchFilePaths(retainedFiles), + (filePaths) => + checkpointStore + .diffCheckpoints({ ...comparison, filePaths }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")), + { concurrency: 4 }, + )).join(""); const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 2846b86e1b88a..b345535fade70 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -57,15 +57,22 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, + options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, ): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const result = yield* process.run({ + const vcsProcess = yield* VcsProcess.VcsProcess; + const result = yield* vcsProcess.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, + ...(options.committedAt === undefined + ? {} + : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), }); return result.stdout.trim(); }); @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, - { path: "copied.txt", additions: 0, deletions: 0 }, + { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, - { path: renamedPath, additions: 1, deletions: 1 }, + { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); @@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ]); }), ); + it.effect("limits a diff to repository paths from a subdirectory workspace", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = NodePath.join(tmp, "app"); + yield* fileSystem.makeDirectory(cwd); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + const patch = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + filePaths: ["app/kept.txt"], + }); + expect(patch).toContain("+kept"); + expect(patch).not.toContain("hidden"); + }), + ); + }); + + describe("listAuthoredPaths", () => { + it.effect("leaves out paths that only a merged upstream changed", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string, contents: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), contents); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt", "upstream\n"); + yield* commitBeforeTurn("shared.txt", "upstream\n"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("shared.txt", "ours\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); + + // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. + yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); + yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); + yield* git(tmp, ["commit", "-am", "merge upstream"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + const numstat = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + format: "numstat", + }); + expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ + "README.md", + "shared.txt", + "upstream.txt", + ]); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["README.md", "shared.txt"]), + ); + }), + ); + + it.effect("leaves out paths that only a rebase onto upstream brought in", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("feature.txt"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* git(tmp, ["rebase", "upstream"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a658..78844ec70dfef 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -40,6 +40,14 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface ListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface DeleteCheckpointRefsInput { @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * List paths changed by work done after the "from" checkpoint: uncommitted + * edits at either checkpoint, commits made after "from", and commits that + * left HEAD. Commits a pull, merge, or rebase brought in are older than + * "from", so their paths are not listed. + * + * Returns null when HEAD did not move or a checkpoint does not record HEAD. + * Then every changed path belongs to the turn. + */ + readonly listAuthoredPaths: ( + input: ListAuthoredPathsInput, + ) => Effect.Effect | null, CheckpointStoreError>; + /** * Delete the provided checkpoint refs. * @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.diffCheckpoints(input); }); + const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( + "listAuthoredPaths", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); + return yield* checkpoints.listAuthoredPaths(input); + }); + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + listAuthoredPaths, deleteCheckpointRefs, }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 6b7f1a8756904..441643fbda63e 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { ]); }); - it("uses destination paths for renames and copies", () => { + it("preserves both paths for renames and copies", () => { const numstat = [ "0\t0\t", "src/old.ts", @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "other.ts", additions: 1, deletions: 0 }, - { path: "src/copied.ts", additions: 2, deletions: 1 }, - { path: "src/new.ts", additions: 0, deletions: 0 }, + { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 }, + { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 }, ]); }); @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { const path = " café\tline\r\nname.txt "; const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; - expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); + expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ + { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 }, + ]); expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ { path, additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index b7951009118e9..24aa3b6879e91 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,5 +1,6 @@ export interface TurnDiffFileSummary { readonly path: string; + readonly previousPath?: string; readonly additions: number; readonly deletions: number; } @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray left.path.localeCompare(right.path)); } + +/** + * True when Git brought this file in and no work in the range touched it. + * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported. + * A rename stays visible when either side was authored, because the summary shows it as one file. + */ +export function isGitImport( + file: TurnDiffFileSummary, + authoredPaths: ReadonlySet | null, +): boolean { + return ( + authoredPaths !== null && + !authoredPaths.has(file.path) && + (file.previousPath === undefined || !authoredPaths.has(file.previousPath)) + ); +} diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index bb7619f6a8abd..9b24161b54e36 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { IdAllocator.layer, NodeCrypto.layer, Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, hasCheckpointRef: () => diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index b29eb33b69a2f..33a7490a0245a 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o getCheckpointContext: () => Effect.succeed(context), }), Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), diffCheckpoints: (input) => { assert.equal(input.cwd, "/prepared-repo"); assert.equal(input.fromCheckpointRef, checkpointRefs[0]); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c4..ee3316311eaca 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -440,33 +440,49 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); + const refs = { + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + // Keep only the files the turn's own work touched. const files = previousExists - ? yield* checkpointStore - .diffCheckpoints({ - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, fallbackFromToHead: false, ignoreWhitespace: false, format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), + }), + checkpointStore.listAuthoredPaths(refs).pipe( Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { scopeId: input.scope.id, checkpointRef, cause: String(cause), - }).pipe(Effect.as([])), + }).pipe(Effect.as(null)), ), - ) + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ) : []; return makeCheckpoint({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dcb..599269791edf1 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const message = `t3 checkpoint ref=${input.checkpointRef}`; + // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, - args: [...durableWrite, "commit-tree", treeOid, "-m", message], + args: [ + ...durableWrite, + "commit-tree", + treeOid, + ...(headExists ? ["-p", "HEAD"] : []), + "-m", + message, + ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, + // Paths are repository-relative, while cwd can be a subdirectory. + ...(input.filePaths + ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] + : []), ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return result.stdout; }), + listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { + const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const heads = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "log", + "--no-walk=unsorted", + "--format=%P %ct", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + }); + // Each line is " ". The parent is the HEAD at capture. + const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); + const [startHead = "", capturedAt = ""] = fromLine.split(" "); + const [endHead = ""] = toLine.split(" "); + if (startHead === "" || endHead === "" || startHead === endHead) { + return null; + } + + const listPaths = (args: ReadonlyArray) => + execute({ + operation, + cwd: input.cwd, + args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\0"))); + // Remerge diff lists a merge's paths only where the result differs from Git's + // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + const pathLists = yield* Effect.all( + [ + listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), + listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), + listPaths([ + "log", + "--format=", + "--diff-merges=remerge", + `--since=@${capturedAt}`, + `${startHead}..${endHead}`, + ]), + ], + { concurrency: "unbounded" }, + ); + return new Set(pathLists.flat().filter((path) => path.length > 0)); + }), + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54beb..c124aa90b9637 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface VcsListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface VcsDeleteCheckpointRefsInput { @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { input: VcsRestoreCheckpointInput, ) => Effect.Effect; readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly listAuthoredPaths: ( + input: VcsListAuthoredPathsInput, + ) => Effect.Effect | null, VcsError>; readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b98..3730eb6a18d43 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -3,6 +3,13 @@ T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. +## Review turn changes + +A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or +rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, +committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed +against your base branch. Restore still returns the complete saved workspace. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote From 07b80800124982bce94879a97767a5365987e8f1 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 10 Oct 2026 07:45:23 +0530 Subject: [PATCH 258/680] fix(web): place notification icons after titles (#12209) --- apps/web/src/components/ui/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 27ff2a2733a96..9448bfff99ea9 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -340,6 +340,7 @@ function ToastBodyContent({ )} >
    + {leadingIcon ? (
    ) : null} -
    Date: Fri, 9 Oct 2026 22:17:46 -0400 Subject: [PATCH 259/680] fix(web): attachments on an open question are visible again (#15537) --- apps/web/src/components/chat/ChatComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9ef3b1d81cf42..0d55c1c83e067 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7094,7 +7094,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && - pendingUserInputs.length === 0 && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || expandedComposerImages.length > 0) && ( From f34adad34f21510e867b47fe018c7975779a0ffc Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:18:58 +0500 Subject: [PATCH 260/680] fix(web): scale Files tree with interface font size (#8011) --- apps/web/src/pierre-tree-theme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 906b34ba1c510..3f191d255789a 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -8,7 +8,7 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; + --trees-font-size-override: 0.75rem; } button[data-type='item'] { border-radius: 5px; } svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } From 318201caa520525517ef2bee8f4bbeedb2fec1d7 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:29 -0300 Subject: [PATCH 261/680] fix(server): probe only owned preview listeners (#16687) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/preview/PortScanner.test.ts | 163 +++++++++----------- apps/server/src/preview/PortScanner.ts | 79 ++++------ 2 files changed, 110 insertions(+), 132 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d46995..8aae21d8cf71b 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); @@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e9663335..7a59079ea9f47 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( From ed4ea1083d477b67304f50d5691ba5fc2b9496cd Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:57 -0300 Subject: [PATCH 262/680] fix(chat): surface pending subagent questions on parents (#16634) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++- .../threads/floating-working-control.tsx | 24 ++++++++-- .../threads/floating-working-status.ts | 6 +++ apps/mobile/src/state/entities.ts | 4 ++ apps/web/src/components/ChatView.tsx | 47 +++++++++++++++++-- apps/web/src/state/entities.ts | 6 +++ .../client-runtime/src/state/threadShell.ts | 46 ++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da2..a3d7a6f4e0c6a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.id, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f21a83f09a177..1fb87ce1c7251 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 0d3b33d582d5a..b8e42b0cb99ec 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a80fc56c57e67..06a6bacaf48cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -427,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b50..ebf62f01be6fd 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index bc6d444f471af..3cb54087ac14d 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: EnvironmentThreadShell[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push(scopedThread(ref.environmentId, child)); + } + } + } + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } From c1f21e53b3b138cc591f9df90bd5c8aef6a00ce4 Mon Sep 17 00:00:00 2001 From: Bishal Banerjee <56464152+ZenderGoD@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:09:30 +0530 Subject: [PATCH 263/680] fix(web): section header chevrons point up when collapsed (#14273) Co-authored-by: zendergod Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ui/collapsible-section-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 5cc52ffe2950b..6a685fe03acd3 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -36,7 +36,7 @@ export function CollapsibleSectionHeader({ {accessory} ); From e08c31e1a1fd5e89e2d4130b9ad18b44d15f92cd Mon Sep 17 00:00:00 2001 From: jonesfionn101-dotcom Date: Sat, 10 Oct 2026 03:39:40 +0100 Subject: [PATCH 264/680] fix(web): timeline divider pill shows a pointer, visible hover and focus ring (#15188) Co-authored-by: Fionn Jones <264670603+jonesfionn101-dotcom@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/TimelineSystemDivider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index 1c4be795007cd..e680cab50c4cc 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -48,7 +48,7 @@ export function TimelineSystemDivider(props: { type="button" aria-label={props.actionLabel} onClick={props.onAction} - className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-muted" + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" /> } > From 6e4ca6ce9d04009af290412b77a0acb9be9f8a18 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:48:05 -0300 Subject: [PATCH 265/680] fix(git): allow creating prs from dirty worktrees (#15625) --- apps/server/src/git/GitManager.test.ts | 32 +++++++++++++++++-- apps/server/src/git/GitManager.ts | 8 ----- .../GitActionsControl.logic.test.ts | 12 +++++-- .../src/components/GitActionsControl.logic.ts | 1 - apps/web/src/components/GitActionsControl.tsx | 5 +-- .../client-runtime/src/state/gitActions.ts | 8 ++--- 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a1330..c9a08607bc087 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect( + (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(), + ).toBe(headBefore); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), @@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; + const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout; + let generatedContent: TextGeneration.PrContentGenerationInput | undefined; const { manager } = yield* makeManager({ + textGeneration: { + generatePrContent: (input) => { + generatedContent = input; + return Effect.succeed({ title: "PR only branch", body: "Committed feature work" }); + }, + }, ghScenario: { prListSequence: [ JSON.stringify([]), @@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); + expect(generatedContent?.diffPatch).toContain("+pr only"); + expect(generatedContent?.diffPatch).not.toContain("staged readme"); + expect(generatedContent?.diffPatch).not.toContain("unstaged feature work"); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore); expect( events.filter( (event): event is Extract => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a94..bd4ee0da7e9d2 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () { detail: "Feature-branch checkout is only supported for commit actions.", }); } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Commit local changes before creating a PR.", - }); - } - const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6d33d412ec8c3..b29a44be3702f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { + it("enables create PR when synced with upstream but ahead of default, even with local changes", () => { const syncedFeature = status({ aheadCount: 0, behindCount: 0, @@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => { const items = buildMenuItems(syncedFeature, false); assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + + const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true }; + assert.equal( + buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, + false, + ); }); it("resolveQuickAction returns disabled no-action state", () => { @@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => { ]); }); - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { const items = buildMenuItems( status({ refName: "feature/test", @@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => { { id: "pr", label: "Create PR", - disabled: true, + disabled: false, icon: "pr", kind: "open_dialog", dialogAction: "create_pr", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8d3b7bc305780..5422628825a68 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -184,7 +184,6 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && hasDefaultBranchDelta && !isBehind && diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5615c64ee450a..cfe45ff24cc4e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -338,13 +338,10 @@ function getMenuActionDisabledReason({ if (!hasBranch) { return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e61..f708e9ca9b98c 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -65,9 +65,8 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: { if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return "No local commits to include in a PR."; } if (isBehind) { From b822420e5074d750e7258ea48568666720cf0415 Mon Sep 17 00:00:00 2001 From: Ege <68082325+ege-arhan@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:57:13 +0300 Subject: [PATCH 266/680] docs(install): polish binary install destination phrasing (#15732) --- docs/user/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/install.md b/docs/user/install.md index 948e5d3676076..67df94217ac36 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -20,7 +20,7 @@ On Windows, in PowerShell: irm https://t3.codes/install.ps1 | iex ``` -This puts `t3` in `~/.local/bin`. If your shell reports `command not found` +This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found` afterwards, that directory is not on your `PATH` yet; the installer prints the line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or `T3CODE_VERSION` to pin an exact version. From 0981e6b9d421ec46010d9b9edebd0beb241ef472 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:57:34 +0200 Subject: [PATCH 267/680] perf(server): keep passive terminal output flowing (#17178) --- apps/server/src/terminal/OutputProtocol.test.ts | 2 ++ apps/server/src/terminal/OutputProtocol.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 548a09c086861..a12f9d6ecbf85 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -13,8 +13,10 @@ import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalObserve, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.terminalObserve, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => Effect.gen(function* () { diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 441961a7952f4..402ed32c022ae 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -22,6 +22,7 @@ export function withTerminalOutputWindow( if ( message._tag === "Request" && (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.terminalObserve || message.tag === WS_METHODS.subscribeTerminalEvents) ) { const key = `${clientId}:${message.id}`; From b6aa268b12e8eb6d5aab52e5e09c12e255fca905 Mon Sep 17 00:00:00 2001 From: Anneshu Nag <132702983+NK-Works@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:28:01 +0530 Subject: [PATCH 268/680] fix(web): stop button icon no longer shifts on hover (#16012) --- apps/web/src/components/chat/ComposerPrimaryActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ac8a70e665530..85d8504ce256e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -138,7 +138,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } /> - {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + {pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel} ) : primaryAction === "auto-merge-armed" ? ( @@ -1970,21 +1962,44 @@ export function PullRequestDetailPanel({ size="xs" variant="default" disabled={actionPending} - onClick={() => setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} aria-label={ - pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel + pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel } > - - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + + {selectedMergeMethodLabel} + + + Confirm merge? + + + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} + } /> - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} ) : (primaryAction === "merged" || primaryAction === "closed") && @@ -2101,11 +2116,18 @@ export function PullRequestDetailPanel({ ) : null} {showsMergeNow ? ( setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} > - Merge now + {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} ) : null} {/* The same merge, left with the host to carry out once its requirements @@ -2121,13 +2143,22 @@ export function PullRequestDetailPanel({ ) : showsAutoMerge ? ( - setConfirmation({ open: true, action: "enable-auto-merge" }) + closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} + aria-label={ + inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge" } + disabled={actionPending} + {...inlineConfirm.props( + "enable-auto-merge", + () => void perform("enable-auto-merge", selectedMergeMethod), + )} > - Enable auto-merge + {inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge"} ) : null} {/* A preference for the merge action rather than a second action, so it @@ -2200,12 +2231,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "close" })} + {...inlineConfirm.props("close", () => void perform("close"))} > - Close pull request + {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} ) : detail.state === "closed" && can("reopen") ? ( @@ -2220,11 +2255,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "revert" })} + {...inlineConfirm.props("revert", () => void perform("revert"))} > - Revert changes + {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} ) : null} @@ -2553,20 +2593,33 @@ export function PullRequestDetailPanel({ size="xs" variant="warning-outline" disabled={actionPending} - onClick={() => - setConfirmation({ open: true, action: "approve-workflows" }) - } + {...inlineConfirm.props( + "approve-workflows", + () => void perform("approve-workflows"), + )} aria-label={ pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run" + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run" } > - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} + + + Approve workflows to run + + + Confirm approve? + + + {pendingAction === "approve-workflows" + ? "Approving..." + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} + @@ -2575,7 +2628,9 @@ export function PullRequestDetailPanel({ {pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run"} + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} ) : ( @@ -2802,74 +2857,6 @@ export function PullRequestDetailPanel({ />
    ) : null} - - setConfirmation((current) => ({ ...current, open }))} - onOpenChangeComplete={(open) => { - if (!open) setConfirmation({ open: false, action: "merge" }); - }} - > - - - - {confirmAction === "merge" - ? "Merge pull request?" - : confirmAction === "enable-auto-merge" - ? "Enable auto-merge?" - : confirmAction === "revert" - ? "Revert these changes?" - : confirmAction === "approve-workflows" - ? "Approve workflows to run?" - : "Close pull request?"} - - - {confirmAction === "merge" - ? `This merges #${reference.number} using ${selectedMergeMethod}.` - : confirmAction === "enable-auto-merge" - ? // The host merges this as soon as it considers the pull request ready, which - // may be immediately — there is no telling from here whether anything is - // still outstanding. - `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` - : confirmAction === "revert" - ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` - : confirmAction === "approve-workflows" - ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` - : `This closes #${reference.number} without merging it.`} - - - - }> - Cancel - - - - -

    ); } diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.ts b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts new file mode 100644 index 0000000000000..5b4f7fc57212a --- /dev/null +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts @@ -0,0 +1,55 @@ +import { useEffect, useState, type MouseEvent } from "react"; + +/** Arms one control at a time, including controls rendered in a menu portal. */ +export function useInlineActionConfirm(scopeKey: string) { + const [armed, setArmed] = useState<{ + scopeKey: string; + action: string; + element: HTMLElement; + } | null>(null); + const active = armed?.scopeKey === scopeKey ? armed : null; + + useEffect(() => { + if (!armed) return; + const disarm = () => setArmed(null); + if (armed.scopeKey !== scopeKey) { + disarm(); + return; + } + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !armed.element.contains(event.target)) disarm(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + disarm(); + } + }; + const timeout = window.setTimeout(disarm, 3000); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", disarm); + return () => { + window.clearTimeout(timeout); + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", disarm); + }; + }, [armed, scopeKey]); + + return { + isArmed: (action: string) => active?.action === action, + props: (action: string, onConfirm: () => void) => ({ + onClick: (event: MouseEvent) => { + if (active?.action === action && active.element === event.currentTarget) { + setArmed(null); + onConfirm(); + } else { + setArmed({ scopeKey, action, element: event.currentTarget }); + } + }, + onBlur: () => setArmed(null), + }), + }; +} From 4107930420cffe14776ad80783f7060236b5868b Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:39 -0700 Subject: [PATCH 299/680] feat(web): reorder right panel tabs by dragging (#17730) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 330 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 5 files changed, 260 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06a6bacaf48cf..2aeca85e6aa29 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} @@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1206929b4cb..5c8323a7ae34d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,6 +14,17 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, @@ -27,6 +38,7 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -115,6 +127,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -199,6 +213,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
    + ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
    - {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
    handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
    - ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.open !== false ? (
    { ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 812de88040462..8afdbedfcc877 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -176,6 +176,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: ( ref: ScopedThreadRef, tabIds: readonly string[], @@ -893,22 +894,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + updateThread(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee2578..02e0e9e26d464 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2241,6 +2241,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} From c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:39:17 -0300 Subject: [PATCH 300/680] fix(azure-devops): list pull requests with token sign-in and check out into worktrees (#17725) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 100 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 45 +++++++- .../src/server/AzureDevOpsCli.test.ts | 25 +++++ .../server/AzureDevOpsPullRequestCli.test.ts | 43 +++++++- .../src/server/AzureDevOpsPullRequestCli.ts | 37 ++++++- .../AzureDevOpsSourceControlProvider.test.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 3 +- .../src/server/azureDevOpsPullRequests.ts | 4 + 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c9a08607bc087..79053fbb65eb0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("prepares a worktree from the head branch when the host has no pull ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n"); + yield* runGit(repoDir, ["add", "head.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]); + yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]); + // The remote keeps its Azure spelling, which is what the pull request is compared with. + const remoteUrl = "https://dev.azure.com/org/project/_git/repo"; + yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]); + yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]); + + // A pull request of another repository in the organization is not this remote's branch. + const { manager: otherRepositoryManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Another repository's PR", + url: "https://dev.azure.com/org/project/_git/other/pullrequest/79", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(otherRepositoryManager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }), + ); + // Nor is the branch of a closed one, which may have moved past the head it closed with. + const { manager: mergedManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Merged PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "merged", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(mergedManager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }), + ); + const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout; + expect(localBranches).not.toContain("feature/no-pull-ref"); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Azure DevOps PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/no-pull-ref"); + const worktreeHead = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "HEAD", + ])).stdout.trim(); + expect(worktreeHead).toBe(headSha); + const upstream = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstream).toBe("origin/feature/no-pull-ref"); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bd4ee0da7e9d2..c5d372f8fb2b3 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -54,6 +54,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { + canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); + yield* gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the + // same repository is a branch on the primary remote, so it is fetched by name instead, + // but only when that remote is the pull request's own repository: Azure finds a pull + // request by number anywhere in the organization. Only while it is open, too: the + // branch of a closed one may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue( + cwd, + `remote.${remoteName}.url`, + ); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + canonicalRepositoryKey(pullRequestKey) !== + canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index e4fca9c5ffae0..0a6b4fd6fc57f 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", timeoutMs: 30_000, }); + assert.strictEqual(result.isCrossRepository, false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks a pull request from a fork as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 43, + title: "From a fork", + sourceRefName: "refs/heads/main", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" }); + + assert.strictEqual(result.isCrossRepository, true); }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index b3afaca037159..8ad71188e1876 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("fails when nobody is signed in", () => + it.effect("names a token sign-in by the viewer's own pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("Please run 'az login' to setup account."), + }); + mockedExecute + .mockReturnValueOnce(Effect.fail(notLoggedIn)) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))) + .mockReturnValueOnce(Effect.succeed(output("[]"))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); + expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); + // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), ); + it.effect("fails when no sign-in can read pull requests", () => + Effect.gen(function* () { + const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("signed out"), + }); + mockedExecute.mockReturnValue(Effect.fail(signedOut)); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError"); + }), + ); + it.effect("completes a pull request to merge it, squashing only when asked", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 70fe78b34bf9e..b7d719725fe3f 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError error._tag !== "AzureDevOpsCliUnavailableError", + () => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--creator", + "me", + "--status", + "all", + "--top", + "1", + ], + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodePullRequestListJson(result.stdout.trim() || "[]"); + const login = Result.isSuccess(decoded) + ? decoded.success.items[0]?.author?.login + : undefined; + return login === undefined + ? Effect.fail( + new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }), + ) + : Effect.succeed(login); + }), + ), + ), ), listPullRequests: (input) => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224b..f0362851596de 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "closed", + isCrossRepository: false, closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index dcb07e0632f3c..f935fb808709f 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; @@ -77,7 +78,7 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 6179a61aa65d0..7ffc398490cf7 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), + /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( headRefName: normalizeRefName(raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), + isCrossRepository: raw.forkSource != null, closedAt: state === "closed" ? terminalAt : null, mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( From 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:47:53 -0300 Subject: [PATCH 301/680] fix(usage): keep one email in two workspaces as two accounts (#17711) --- apps/server/src/provider/ClaudeProvider.ts | 6 + apps/server/src/provider/CodexProvider.ts | 12 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../server/src/provider/claudeResetCredits.ts | 7 + apps/server/src/usage/cliproxyApi.ts | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 6 + .../src/server/managedProvider.test.ts | 16 +- .../src/server/managedProvider.ts | 27 ++- packages/shared/src/usageLimits.test.ts | 190 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 165 ++++++++++----- 11 files changed, 382 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fefc..8bc961834f270 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + /** + * The organization UUID from the login's `.claude.json`. The driver reads it + * in the same cached probe, so it describes the same login as the rest. + */ + readonly workspaceId?: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** Where the CLI found an API key, when it authenticates with one. */ @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01c..5109bb840f561 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; + /** The ChatGPT workspace the usage belongs to, when the backend names it. */ + readonly accountId?: string | null | undefined; } | { readonly failure: string }; @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); + // One email can hold several workspaces, each with its own quota; the usage + // read names the one this login draws on. + const workspaceId = + rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); + : accountProbeStatus(snapshot); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df283..0169eb87ba761 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.all([ + probeClaudeCapabilities(effectiveConfig, processEnv, cwd), + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath), + ]).pipe( + Effect.map(([capabilities, workspaceId]) => + capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities, + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e381fa7cabe28..3c4a54742082c 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ +/** The organization the login is signed in to; one email can belong to several. */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( configDir: string | undefined, ) { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index c50ebb57f40eb..b80ebe59261e7 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519b..0108bbb669eb2 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), + /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d19..a726f05a311c8 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The workspace or organization whose quota the login draws on, such as a + * ChatGPT workspace or Claude organization id. One email can belong to + * several, each with its own quota. + */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c6..1d73e112b815f 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( Effect.map((count) => count === 1 - ? { ...refreshedSnapshot, usageLimits: probedLimits } + ? { + ...refreshedSnapshot, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + usageLimits: probedLimits, + } : { ...refreshedSnapshotSecond, + ...(count === 3 + ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } + : {}), usageLimits: { checkedAt: "2026-04-10T00:00:03.000Z", windows: [], @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + // ...and the workspace they were read for. + assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + + // A failed read for another workspace does not inherit those windows. + const switched = yield* provider.refresh; + assert.strictEqual(switched.auth.workspaceId, "ws-b"); + assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bada..a11734a1661b4 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; + // A failed read keeps the last good limits only for the same account, + // and they keep the workspace they were read for. + const previous = state.snapshot.auth; + const differs = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + // Limits read without a workspace cannot be attributed to a new one. + const switchedAccount = + differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || + (probedSnapshot.auth.workspaceId !== undefined && + probedSnapshot.auth.workspaceId !== previous.workspaceId); + const usageLimits = switchedAccount + ? probedSnapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); + const workspaceId = + usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; const snapshot = withUsageLimits( - probedSnapshot, - resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }), + workspaceId && !probedSnapshot.auth.workspaceId + ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } + : probedSnapshot, + usageLimits, ); return [ { snapshot, generation }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b2bef55cb1082..6681033024175 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -496,6 +496,128 @@ describe("pools", () => { expect(account?.redeem?.environmentId).toBe("env-2"); }); + it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => { + const login = (instanceId: string, workspaceId: string, usedPercent: number) => + provider({ + instanceId: ProviderInstanceId.make(instanceId), + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] }, + }); + const hubAccount = (id: string, workspaceId?: string) => ({ + id, + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], + usageLimitSources: [ + // A hub that names the workspace joins it; one that does not + // cannot say which of the two it read, so it keeps its own row. + { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [login("personal", "ws-plus", 26)] }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual([ + "env-a:business", + "env-a:personal", + "hub:x.json", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); + // One hub file name in two workspaces stays two accounts, even without an + // email to tell them apart. + const withoutEmail = (id: string, workspaceId: string) => { + const { email: _email, ...account } = hubAccount(id, workspaceId); + return account; + }; + const sameFile = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }], + }, + }, + ], + ]); + expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([ + "hub:a.json:ws-1", + "hub:a.json:ws-2", + ]); + expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("joins a report without a workspace to the only workspace signed in with its email", () => { + const native = provider({ + driver: claude, + auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = { + id: "claude.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }; + const withOther = (other: ServerProvider) => + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, other], + usageLimitSources: [{ ...source, accounts: [hubAccount] }], + }, + }, + ], + ]); + const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); + expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ + "env-a:codex", + ]); + // A second org whose limits could not be read still makes the hub ambiguous. + const unreadable = provider({ + ...native, + instanceId: ProviderInstanceId.make("team"), + auth: { ...native.auth, workspaceId: "org-b" }, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ + "env-a:codex", + "hub:claude.json", + ]); + }); + it("names an environment once however many of its instances share the account", () => { const shared = provider({ auth: { status: "authenticated", email: "same@example.com" }, @@ -536,6 +658,45 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("pools Codex windows by kind, whichever slot reported them", () => { + const codexAccount = (id: string, slots: readonly string[]) => ({ + id, + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt, + windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })), + }, + }); + const pooled = (accounts: ReturnType[]) => + collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } }, + ], + ]), + ), + now, + )[0]?.windows ?? []; + // A weekly-only plan reports weekly as `primary`. The pool keeps one id + // whichever account sorts first. + for (const order of [ + [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], + [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], + ]) { + const windows = pooled(order); + expect(windows.map((window) => window.id)).toEqual(["weekly"]); + expect(windows[0]?.members).toHaveLength(2); + } + // A second window of the same kind on one account stays apart, with its own id. + expect( + pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( + (window) => window.id, + ), + ).toEqual(["weekly", "weekly:secondary"]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { }); }); + it("gives a hub credit to no native row when its email is signed in to two workspaces", () => { + const workspaces = ["ws-a", "ws-b"].map((workspaceId) => + provider({ + instanceId: ProviderInstanceId.make(workspaceId), + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + }), + ); + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "same@example.com", + usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now); + expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([ + { instanceId: "ws-a" }, + { instanceId: "ws-b" }, + { sourceId: "hub", accountId: "duplicate", creditId: "c" }, + ]); + }); + it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { const fresher = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d5..85c5e6e39f886 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -125,11 +125,51 @@ function accountKey( : null; } +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly workspaceId?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const nativeReport = (provider: ServerProvider): AccountReport => ({ + driver: provider.driver, + email: provider.auth.email, + workspaceId: provider.auth.workspaceId, + usageLimits: provider.usageLimits, +}); + +/** + * Keys each report by the account it draws on. One email can belong to + * several workspaces, each with its own quota, so a report that names its + * workspace is keyed by it too. One that does not (a hub, an older server) + * joins the only workspace known for its email, and gets no key when there + * are several, since it cannot say which one it read. Pass every enabled + * login, read or not, so an unreadable workspace still counts as one. + */ +function accountKeyResolver(reports: Iterable) { + const workspaces = new Map>(); + for (const report of reports) { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (key && report.workspaceId) { + workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); + } + } + return (report: AccountReport): string | null => { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (!key) return null; + if (report.workspaceId) return `${key}:${report.workspaceId}`; + const known = workspaces.get(key); + if (!known) return key; + return known.size === 1 ? `${key}:${[...known][0]}` : null; + }; +} + /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Matching emails or credentials across environments name one + * account, unless they name different workspaces. Its quota is one bucket, so + * counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + const resolveKey = accountKeyResolver( + [...presentations.values()].flatMap((presentation) => [ + ...(presentation.serverConfig?.providers ?? []) + .filter((provider) => provider.enabled) + .map(nativeReport), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]), + ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; - merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, - { - key: `${environmentId}:${provider.instanceId}`, - driver: provider.driver, - displayName: provider.displayName?.trim() || null, - email: provider.auth.email, - plan: provider.auth.label, - accentColor: provider.accentColor, - environments: [{ environmentId, label }], - sourceLabel: null, - redeem: { environmentId, input: { instanceId: provider.instanceId } }, - limits: provider.usageLimits, - }, - ); + merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, input: { instanceId: provider.instanceId } }, + limits: provider.usageLimits, + }); } } // Every hub account, including those a native instance also knows: the hub @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( - accountKey(account.driver, account.email, account.usageLimits) ?? - `${source.id}:${account.id}`, - { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }, - ); + const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; + merge(resolveKey(account) ?? key, { + key, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }); } } } @@ -401,7 +442,7 @@ export function collectLimitPools( .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; const orderReset = (account: LimitAccount) => { const window = account.limits.windows.find( - (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow), ); return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; }; @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +/** + * Codex ids are slots, not allowances: a weekly-only plan reports its weekly + * window as `primary`, other plans as `secondary`. Its windows pool by kind; + * other drivers' ids name distinct allowances. + */ +function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { + return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; +} + function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { const byKey = new Map(); for (const account of accounts) { for (const window of account.limits.windows) { - const key = `${window.kind}:${window.id}`; + const pooled = poolKey(account.driver, window); + // An account with two windows of one kind keeps the second apart. + const key = byKey.get(pooled)?.some((member) => member.account === account) + ? `${window.kind}:${window.id}` + : pooled; const list = byKey.get(key); if (list) list.push({ account, window }); else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; + // Pooled Codex slots take the pool's own key, so the id stays the same + // whichever account sorts first. + const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the // members that have a clock; a window with no reset would otherwise @@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }) .sort((left, right) => left.at - right.at); return { - id: first.id, + id, kind: first.kind, label: first.label, members, @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + const resolveKey = accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + resolveKey(account) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = resolveKey(account); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, From c3243f05088c3c20e9439f285fc6d7b0bfe1747d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:05:38 -0700 Subject: [PATCH 302/680] feat(pull-requests): hosts can report edit and resolve permissions per item (#17667) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestCodeTab.tsx | 10 ++- .../pullRequestEditing.logic.test.ts | 66 +++++++++++++++++++ .../pullRequest/pullRequestEditing.logic.ts | 30 +++++++-- packages/contracts/src/pullRequest.ts | 23 +++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1339b46196b89..d5f054dadab01 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -33,7 +33,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; -import { canEditPullRequestComment } from "./pullRequestEditing.logic"; +import { canEditPullRequestComment, canResolvePullRequestThread } from "./pullRequestEditing.logic"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -934,7 +934,7 @@ function PullRequestCodeTab({ thread={thread} workspaceRoot={detail.workspaceRoot} canReply={review.reply} - canResolve={review.resolve} + canResolve={review.resolve && canResolvePullRequestThread(detail, thread)} canReact={detail.capabilities.reactions === true} environmentId={environmentId} reference={reference} @@ -966,7 +966,11 @@ function PullRequestCodeTab({ } // A conversation on a line is made of review comments, whatever the host filed them as. canEditComment={(comment) => - canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" }) + canEditPullRequestComment(detail, { + author: comment.author, + kind: "review-comment", + canEdit: comment.canEdit, + }) } onEditComment={(commentId, body) => runThreadCommand("The comment could not be saved", () => diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts index 1b43ff209b23d..06ecec5d32959 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canEditPullRequestChangeRequest, canEditPullRequestComment, + canResolvePullRequestThread, } from "./pullRequestEditing.logic"; type Subject = Pick; @@ -89,6 +90,33 @@ describe("canEditPullRequestChangeRequest", () => { ).toBe(true); }); + it("follows the host's own answer about the reader over authorship and merge access", () => { + expect( + canEditPullRequestChangeRequest( + subject({ + author: actor("someone-else"), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(true); + expect( + canEditPullRequestChangeRequest( + subject({ + viewerPermissions: permissions({ actions: ["merge"], editChangeRequest: false }), + }), + ), + ).toBe(false); + // The host can't rewrite change requests at all, so the reader's answer can't widen that. + expect( + canEditPullRequestChangeRequest( + subject({ + capabilities: capabilities({ changeRequest: false, comment: true }), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(false); + }); + it("refuses a reader who neither wrote it nor may merge it", () => { expect( canEditPullRequestChangeRequest( @@ -146,6 +174,23 @@ describe("canEditPullRequestComment", () => { ); }); + it("follows the host's own answer about a remark over authorship", () => { + expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false); + expect( + canEditPullRequestComment( + subject(), + comment({ author: actor("someone-else"), canEdit: true }), + ), + ).toBe(true); + // The host can't rewrite remarks at all, so its per-remark answer can't widen that. + expect( + canEditPullRequestComment( + subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), + comment({ canEdit: true }), + ), + ).toBe(false); + }); + it("refuses a remark the host attributes to nobody", () => { expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); }); @@ -170,3 +215,24 @@ describe("canEditPullRequestComment", () => { expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); }); }); + +describe("canResolvePullRequestThread", () => { + it("uses the reader's repository-wide permission where the host says nothing per thread", () => { + expect(canResolvePullRequestThread(subject(), {})).toBe(true); + expect( + canResolvePullRequestThread( + subject({ viewerPermissions: permissions({ resolve: false }) }), + {}, + ), + ).toBe(false); + }); + + it("only narrows the reader's permission with the host's answer for one thread", () => { + expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false); + expect( + canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), { + canResolve: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts index a7e49f3f635f0..520ff7a7ef965 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts @@ -1,4 +1,8 @@ -import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts"; +import type { + PullRequestComment, + PullRequestDetail, + PullRequestReviewThread, +} from "@t3tools/contracts"; /** Only the parts of a detail either answer reads, so a caller can pass a whole detail view. */ type EditingSubject = Pick< @@ -14,12 +18,15 @@ function sameLogin(one: string | null | undefined, other: string | null | undefi /** * Whether the title and description can be rewritten from here. Beyond the host being able to at - * all, either the reader wrote the change request or they may merge it — merging is the one action - * every host here grants with write access and withholds without it, so it stands in for the - * permission none of them publishes by name. + * all, a host that reports `editChangeRequest` answers directly. Otherwise either the reader wrote + * the change request or they may merge it: merging is the one action every host here grants with + * write access and withholds without it, so it stands in for the permission they don't publish. */ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean { if (detail.capabilities.edit?.changeRequest !== true) return false; + if (detail.viewerPermissions.editChangeRequest !== undefined) { + return detail.viewerPermissions.editChangeRequest; + } return ( sameLogin(detail.viewer, detail.author?.login) || detail.viewerPermissions.actions.includes("merge") @@ -33,9 +40,22 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean */ export function canEditPullRequestComment( detail: EditingSubject, - comment: Pick, + comment: Pick, ): boolean { if (detail.capabilities.edit?.comment !== true) return false; if (comment.kind !== "issue-comment" && comment.kind !== "review-comment") return false; + if (comment.canEdit !== undefined) return comment.canEdit; return sameLogin(detail.viewer, comment.author?.login); } + +/** + * Whether the reader may resolve or reopen one review thread. The host's answer for this thread + * can only narrow the reader's repository-wide `resolve` permission: a thread can't grant what the + * repository withholds. + */ +export function canResolvePullRequestThread( + detail: Pick, + thread: Pick, +): boolean { + return detail.viewerPermissions.resolve && thread.canResolve !== false; +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2cd0e45cc8e03..1efbc1808d143 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -209,6 +209,12 @@ export const PullRequestComment = Schema.Struct({ reviewState: Schema.NullOr(Schema.String), /** Absent from a host with no reactions at all, which is a different thing from none on this. */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestComment = typeof PullRequestComment.Type; @@ -232,6 +238,12 @@ export const PullRequestThreadComment = Schema.Struct({ editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; @@ -257,6 +269,12 @@ export const PullRequestReviewThread = Schema.Struct({ commentCount: Schema.optional(NonNegativeInt), /** Opaque cursor for the next comment page. Absent once this thread is whole. */ nextCommentsCursor: Schema.optional(TrimmedNonEmptyString), + /** + * The host's own answer to whether this reader may resolve this thread. It can only narrow the + * reader's repository-wide `resolve` permission, never widen it. Absent where the host + * doesn't say per thread. + */ + canResolve: Schema.optional(Schema.Boolean), }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; @@ -481,6 +499,11 @@ export const PullRequestViewerPermissions = Schema.Struct({ * changed on this host at all. */ labels: Schema.optional(Schema.Boolean), + /** + * This viewer may rewrite the change request's title and description. Absent where the host + * doesn't say, which leaves the page to guess from authorship and merge access. + */ + editChangeRequest: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; From 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:22 -0400 Subject: [PATCH 303/680] perf(server): run Git for Windows' real git.exe, not its launcher (#17707) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++- packages/shared/src/shell.test.ts | 89 +++++++++++++++++++++++++ packages/shared/src/shell.ts | 48 ++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 51c1f7b29468a..53e7f6418058c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...input.env, ...trace2Monitor.env, }; + const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; + const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); + // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit + // messages at the first newline; leave that case to Node's lookup. + const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( - ChildProcess.make("git", commandInput.args, { + ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, - env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, + env: spawnEnv, }), ) .pipe( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 428d0d5951f58..5f493c8044d33 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -15,6 +15,7 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + preferGitForWindowsBinary, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +describe("preferGitForWindowsBinary", () => { + const files = + (...paths: Array) => + (filePath: string) => + paths.includes(filePath); + + it("runs the git.exe Git for Windows' launcher would start", () => { + // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.EXE", + {}, + files( + "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe", + "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", + ), + ), + ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); + // Before 2.56, and the portable build's bin launcher. + expect( + preferGitForWindowsBinary( + "D:\\PortableGit\\bin\\git.exe", + {}, + files("D:\\PortableGit\\mingw64\\bin\\git.exe"), + ), + ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe"); + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + {}, + files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"); + }); + + it("keeps the launcher when MSYSTEM is set", () => { + // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; + // without them a `#!/bin/sh` hook cannot start. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + { MSYSTEM: "MINGW64" }, + files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); + }); + + it("keeps the launcher when no git.exe sits beside it", () => { + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe( + "C:\\Program Files\\Git\\cmd\\git.exe", + ); + }); + + it("leaves other gits and other launchers alone", () => { + const everything = () => true; + expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe( + "C:\\Users\\me\\scoop\\shims\\git.exe", + ); + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe( + "C:\\Program Files\\Git\\cmd\\gitk.exe", + ); + }); +}); + effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { it.effect("runs Windows executables directly without a shell", () => Effect.gen(function* () { @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); + it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () => + Effect.gen(function* () { + let scans = 0; + const scan: SpawnExecutableResolver = () => { + scans++; + return "C:\\Git\\cmd\\git.exe"; + }; + const resolve = (env: NodeJS.ProcessEnv) => + resolveSpawnCommand("git", [], { + env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env }, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, scan), + ); + + yield* resolve({}); + yield* resolve({}); + expect(scans).toBe(1); + // A swap made without MSYSTEM must not reach a child that has it set. + yield* resolve({ MSYSTEM: "MINGW64" }); + expect(scans).toBe(2); + }).pipe(Effect.provideService(CommandResolutionCache, new Map())), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index b24c998b1785d..4d29841bfa8b1 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds +// live in clangarm64 and 32-bit ones in mingw32. +const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; + +function isFileSync(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Swaps Git for Windows' launcher (`\cmd\git.exe`, the only git its + * installer puts on PATH, or the portable build's `\bin\git.exe`) for the + * git.exe it starts. The launcher costs a second process on every git command, + * and each launch leaks a kernel token reference that slows process creation + * machine-wide until reboot. The real binary sets HOME itself, but adds its own + * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is + * unset, so the launcher stays when MSYSTEM is set. Anything else is returned + * as is. + */ +export function preferGitForWindowsBinary( + executable: string, + env: NodeJS.ProcessEnv, + isFile: (filePath: string) => boolean = isFileSync, +): string { + if (env.MSYSTEM) return executable; + const path = NodePath.win32; + if (path.basename(executable).toLowerCase() !== "git.exe") return executable; + const launcherDirectory = path.dirname(executable); + const launcherFolder = path.basename(launcherDirectory).toLowerCase(); + if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; + const installRoot = path.dirname(launcherDirectory); + for (const build of GIT_FOR_WINDOWS_BUILDS) { + const candidate = path.join(installRoot, build, "bin", "git.exe"); + if (isFile(candidate)) return candidate; + } + return executable; +} + // Untraced because it runs before most spawns and returns at once off Windows. export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - resolvedExecutable = resolveExecutable(command, platform, env) ?? null; + // Cached with the scan: its file checks would otherwise run before every + // git launch. A Git upgrade that moves the real binary can fail git for up + // to the cache lifetime. + const found = resolveExecutable(command, platform, env); + resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } From a7223f3969428170cc13bd7859aa59098b2f94b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:09:20 -0700 Subject: [PATCH 304/680] fix(clients): restart continuations show as a T3 Code notice, not another agent's message (#17723) Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 8 +++- apps/mobile/src/lib/threadActivity.test.ts | 20 +++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++- .../src/orchestration-v2/Orchestrator.ts | 8 +++- .../RestartContinuation.test.ts | 26 ++++++++-- .../orchestration-v2/RestartContinuation.ts | 22 ++++++--- .../src/orchestration-v2/runtimeLayer.test.ts | 17 +++++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- .../src/state/threadExecution.ts | 1 + .../client-runtime/src/userMessage.test.ts | 31 ++++++++++-- packages/client-runtime/src/userMessage.ts | 48 +++++++++++++++---- packages/contracts/src/orchestrationV2.ts | 2 + 12 files changed, 171 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a210ffcb1dc69..ee0c4711b03bc 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1719,11 +1719,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation ? ( + {presentation.attribution === "automation" ? ( Sent by automation - ) : message.createdBy === "agent" ? ( + ) : presentation.attribution === "t3code" ? ( + + Sent by T3 Code + + ) : presentation.attribution === "agent" ? ( { expect(withoutPath?.canExpand).toBe(false); }); +it("expands a T3 Code notice to the prompt the agent got", () => { + const item = { + ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), + type: "notification", + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + } as OrchestrationV2TurnItem; + const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getFullDetail()).toBe("Continue where you left off."); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { expect(messageEntry).toBeDefined(); expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ text: "Run checks", - isAutomation: true, + attribution: "automation", }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11f..edaaf2b0fab83 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "monitor": return "eye"; case "background_task": + case "system": return "zap"; default: source satisfies never; @@ -814,7 +815,11 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 475c6bea0a940..3f17a6faa55d6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } } + // A restart continuation resumes the interrupted turn, so it starts + // immediately rather than queueing like other wakes. if ( command.notification !== undefined && (command.createdBy !== "agent" || (command.creationSource !== "server" && command.creationSource !== "provider") || - dispatchMode.type !== "queue_after_active") + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa637284..34b3d8352c68c 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -10,6 +10,7 @@ import { RunAttemptId, RunId, ThreadId, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => Effect.gen(function* () { - const texts: Array = []; + const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( Effect.provide( Layer.merge( @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => getThreadRecords: () => Effect.succeed(projection), recoverDelegatedTask: () => Effect.void, dispatch: (command) => { - if (command.type === "message.dispatch") texts.push(command.text); + if (command.type === "message.dispatch") dispatches.push(command); return Effect.succeed({} as never); }, }), @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => ), ), ); - return texts; + return dispatches; }); +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + continuationDispatches(projection).pipe( + Effect.map((dispatches) => dispatches.map((command) => command.text)), + ); + const cutMidTurn = (extra: Record = {}) => { const base = makeProjection(); return { @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => }), ); +it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => + Effect.gen(function* () { + const [resumed] = yield* continuationDispatches(cutMidTurn()); + assert.deepEqual(resumed?.notification, { + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }); + }), +); + it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc45..b7e905d0f4a9a 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const text = + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`; yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb70c880b0615..3d4bf4c2a64a5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("restart-automatic-message"), text: "Continue where you left off.", + notification: { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" as const }, @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const admitted = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf(admitted.runs, 2); assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); + // The timeline shows a work log row; the prompt stays in its detail. + const continuationItems = admitted.turnItems.filter( + (item) => item.runId === admitted.runs[1]?.id, + ); + assert.isFalse(continuationItems.some((item) => item.type === "user_message")); + assert.deepInclude( + continuationItems.flatMap((item) => + item.type === "notification" ? [[item.summary, item.detail]] : [], + ), + ["T3 Code restarted and resumed this turn", "Continue where you left off."], + ); // A differently identified stale delivery still must not create another run. yield* orchestrator.dispatch({ ...command, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a3138c29044b8..0518c96afc587 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.isAutomation ? ( + {userMessage.attribution === "automation" ? (

    - ) : row.message.createdBy === "agent" ? ( + ) : userMessage.attribution === "t3code" ? ( +

    + Sent by T3 Code +

    + ) : userMessage.attribution === "agent" ? (

    {senderThreadId ? ( { it("removes attribution from legacy scheduled prompts", () => { expect( resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), - ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); + ).toEqual({ + text: "Check for crashes.\n", + attribution: "automation", + scheduledTaskId: undefined, + }); }); it("uses task metadata without changing the prompt", () => { @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { scheduledTaskId: ScheduledTaskId.make("task-1"), text: legacyText, }), - ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); + ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); }); it("preserves user-written and assistant-quoted schedule headers", () => { @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ text: legacyText, - isAutomation: false, + attribution: null, scheduledTaskId: undefined, }); } @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ text, - isAutomation: false, + attribution: "agent", scheduledTaskId: undefined, }); } }); + it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { + const text = "Note: the T3 server restarted."; + expect( + resolveUserMessagePresentation({ + role: "user", + createdBy: "agent", + creationSource: "server", + text, + }), + ).toMatchObject({ attribution: "t3code" }); + for (const creationSource of ["mcp", "provider"] as const) { + expect( + resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), + ).toMatchObject({ attribution: "agent" }); + } + }); + it("recovers the triggering automation from older scheduler message ids", () => { for (const trigger of ["scheduled", "manual"]) { expect( @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { }), ).toEqual({ text: "Check for crashes.\n", - isAutomation: true, + attribution: "automation", scheduledTaskId: "task:daily-audit", }); } diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 5acad0a448027..b292bafe0ca4f 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,31 +1,59 @@ -import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; +import { + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, + ScheduledTaskId, +} from "@t3tools/contracts"; const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** Older scheduled messages stored their attribution in the prompt itself. */ +/** + * Text and sender label for a user-role message. Older scheduled messages + * stored their attribution in the prompt itself. + */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; readonly text: string; readonly createdBy?: OrchestrationV2Actor; + readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; -}) { +}): { + readonly text: string; + /** Who sent a user-role message the user did not type. */ + readonly attribution: "automation" | "agent" | "t3code" | null; + readonly scheduledTaskId: ScheduledTaskId | undefined; +} { if (message.role !== "user") { - return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; + return { text: message.text, attribution: null, scheduledTaskId: undefined }; } if (message.scheduledTaskId !== undefined) { - return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; + return { + text: message.text, + attribution: "automation", + scheduledTaskId: message.scheduledTaskId, + }; } const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); const legacyTaskId = legacyPrefix ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] : undefined; - const isAutomation = - legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); + if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { + return { + text: message.text.slice(legacyPrefix[0].length), + attribution: "automation", + scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + }; + } return { - text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, - isAutomation, - scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + text: message.text, + // Restart continuations were sent as the agent before they became notices. + attribution: + message.createdBy !== "agent" + ? null + : message.creationSource === "server" + ? "t3code" + : "agent", + scheduledTaskId: undefined, }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 423f8234178b9..7aa8c74fd80a5 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), + /** T3 Code itself, such as a restart continuing an interrupted turn. */ + Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), () => ({ kind: "background_task" }), From 57b3780770a829ff81e62d8dc658683bf51d5155 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:13:32 -0700 Subject: [PATCH 305/680] fix(mobile): browser picture in picture opens from the header button (#17731) Co-authored-by: Claude Opus 5.5 --- .../browser/preview-stream.browser.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 07a9953cc5a2f..91fa5d7a70851 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = interface Viewer { readonly stop: () => void; readonly command: (input: PreviewStreamInput) => void; - readonly togglePictureInPicture: () => Promise; + readonly togglePictureInPicture: () => void; } let activeViewer: Viewer | null = null; @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { } export function pictureInPicture() { - void activeViewer?.togglePictureInPicture(); + activeViewer?.togglePictureInPicture(); } export function start(configuration: PreviewStreamConfiguration) { @@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) { }; const painter = createPreviewFramePainter(canvas, () => { if (!streaming && !stopped) reportStatus("streaming"); + sendPictureInPictureFrame(); }); const send = (message: PreviewStreamInput) => client?.send(message) ?? false; const clearInput = () => { @@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) { const pictureInPictureSupported = interactive && typeof canvas.captureStream === "function" && + typeof CanvasCaptureMediaStreamTrack === "function" && ((document.pictureInPictureEnabled === true && typeof videoPrototype.requestPictureInPicture === "function") || typeof videoPrototype.webkitSetPresentationMode === "function"); + // Since the iOS 17 SDK, WebKit revokes the activation it grants an injected script when + // the script returns, so the native button's request must start before any await. The + // video is ready ahead of the tap: paused, and fed a frame only when it has none or + // floats, so the inline view captures nothing. let video: PresentationVideo | null = null; + let track: CanvasCaptureMediaStreamTrack | null = null; const pictureInPictureActive = () => video !== null && (document.pictureInPictureElement === video || @@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) { active: pictureInPictureActive(), ...(detail ? { detail } : {}), }); - const ensureVideo = () => { - if (video) return video; + // Called after each paint. A frame drawn before the player attached is lost, so retry. + const sendPictureInPictureFrame = () => { + if (video && (video.readyState === HTMLMediaElement.HAVE_NOTHING || pictureInPictureActive())) { + track?.requestFrame(); + } + }; + if (pictureInPictureSupported) { + const stream = canvas.captureStream(0); + const [captured] = stream.getVideoTracks(); const element: PresentationVideo = document.createElement("video"); element.muted = true; element.playsInline = true; - element.autoplay = true; // Under the canvas at full size: WebKit pauses muted video it considers off screen. - element.srcObject = canvas.captureStream(); - // A static page sends no new frames; repaint once so the stream has one. - if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); + element.srcObject = stream; for (const name of [ "enterpictureinpicture", "leavepictureinpicture", @@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) { }); } container.prepend(element); + // Covers the letterbox, where a stale frame of another shape would show through. + canvas.style.background = configuration.background; video = element; - return element; - }; - const togglePictureInPicture = async () => { - if (!pictureInPictureSupported) return; - try { - if (pictureInPictureActive()) { - if (document.pictureInPictureElement) await document.exitPictureInPicture(); - else video?.webkitSetPresentationMode?.("inline"); - return; - } - const element = ensureVideo(); - await element.play(); - if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); - else element.webkitSetPresentationMode?.("picture-in-picture"); - } catch (error) { + track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null; + } + const togglePictureInPicture = () => { + const element = video; + if (!element) return; + if (pictureInPictureActive()) { + if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {}); + else element.webkitSetPresentationMode?.("inline"); + return; + } + if (element.readyState === HTMLMediaElement.HAVE_NOTHING) { + reportPictureInPicture("The page has not shown yet. Try again in a moment."); + return; + } + // The page may have changed since the video's last frame. WebKit captures only on a draw. + track?.requestFrame(); + canvas.getContext("2d")?.drawImage(canvas, 0, 0); + const fail = (error: unknown) => { + element.pause(); reportPictureInPicture( error instanceof Error ? error.message : "Picture in picture is unavailable.", ); + }; + if (document.pictureInPictureEnabled) { + const entering = element.requestPictureInPicture(); + void Promise.all([entering, element.play()]).catch(fail); + } else { + element.webkitSetPresentationMode?.("picture-in-picture"); + void element.play().catch(fail); } }; @@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) { painter.stop(); client?.stop(); client = null; - if (video) { - for (const track of video.srcObject instanceof MediaStream - ? video.srcObject.getTracks() - : []) { - track.stop(); - } - video.srcObject = null; - } + track?.stop(); + if (video) video.srcObject = null; }, command: (message) => { send(message); From 65d33177b777e014c56f01851bef446785b3aa28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:18:22 -0700 Subject: [PATCH 306/680] feat(source-control): GitCafe lives in @t3tools/source-control-gitcafe (#17681) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 1 + .../SourceControlDiscovery.test.ts | 21 + .../SourceControlProviderRegistry.test.ts | 2 + .../src/sourceControl/builtInDrivers.ts | 5 + apps/web/src/components/CommandPalette.tsx | 20 +- apps/web/src/components/GitActionsControl.tsx | 12 +- apps/web/src/components/Icons.tsx | 8 + .../ThreadStatusIndicators.test.tsx | 22 + .../src/components/ThreadStatusIndicators.tsx | 23 +- .../pullRequest/pullRequestDetail.logic.ts | 2 + .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 2 + apps/web/src/sourceControlPresentation.ts | 7 + apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 20 +- package.json | 2 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/changeRequestUrl.ts | 7 + packages/shared/src/sourceControl.ts | 31 +- packages/source-control-gitcafe/package.json | 52 ++ .../src/server/GitCafeApi.ts | 176 ++++ .../src/server/GitCafeCredentials.test.ts | 155 ++++ .../src/server/GitCafeCredentials.ts | 166 ++++ .../server/GitCafePullRequestProvider.test.ts | 751 +++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 867 ++++++++++++++++++ .../GitCafeSourceControlProvider.test.ts | 339 +++++++ .../server/GitCafeSourceControlProvider.ts | 503 ++++++++++ .../src/server/driver.ts | 20 + .../src/server/gitCafeHosts.test.ts | 46 + .../src/server/gitCafeHosts.ts | 49 + .../src/server/gitCafePullRequestJson.test.ts | 547 +++++++++++ .../src/server/gitCafePullRequestJson.ts | 578 ++++++++++++ packages/source-control-gitcafe/tsconfig.json | 7 + pnpm-lock.yaml | 34 + scripts/release-smoke.ts | 1 + 35 files changed, 4463 insertions(+), 17 deletions(-) create mode 100644 packages/source-control-gitcafe/package.json create mode 100644 packages/source-control-gitcafe/src/server/GitCafeApi.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/driver.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts create mode 100644 packages/source-control-gitcafe/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e5..1147b5e06233f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535fe..8fa9201b94ca8 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unknown", account: Option.none(), }, + { + kind: "gitcafe", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } + if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { + return Effect.succeed( + processOutput( + encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), + ), + ); + } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("forgejo-user"), detail: Option.none(), }, + { + kind: "gitcafe", + auth: "authenticated", + account: Option.some("cafe-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(layerTest)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bfed745ca2955..1fab84a6acd0d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -126,6 +127,7 @@ function makeRegistry(input: { Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 2e830dec803e3..1b891ce9259aa 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; @@ -32,6 +35,7 @@ const drivers = [ AzureDevOpsDriver.driver, BitbucketDriver.driver, ForgejoDriver.driver, + GitCafeDriver.driver, ]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ @@ -51,5 +55,6 @@ export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, + GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df0..48c4b20a777f6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -187,7 +187,14 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitCafeIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "forgejo", "bitbucket", "azure-devops", + "gitcafe", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "azure-devops": return ; + case "gitcafe": + return ; case "url": return ; } @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, + gitcafe: unavailable, }; if (!discovery) { @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitcafe", "url", "environment", ], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cfe45ff24cc4e..2b684cd0cd4e8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,7 @@ import { GitHubIcon, GitLabIcon, ForgejoIcon, + GitCafeIcon, } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type GitActionToastId = ReturnType; @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, + { + value: "gitcafe", + label: "GitCafe", + description: "git.cafe", + host: "git.cafe", + pathPlaceholder: "owner/repo", + Icon: GitCafeIcon, + }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { forgejo: null, bitbucket: null, "azure-devops": null, + gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63f..909b9c7456f3d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( ); +// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not +// read heavier than the round and cut-out marks beside it. +export const GitCafeIcon: Icon = (props) => ( + +); + // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup // step points at the same icon the user is about to look for. export const MacScreenRecordingIcon: Icon = (props) => { diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448ed..3ae07b403063a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, }); }); + it.each([ + ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], + ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], + ["https://github.com/owner/repo/pull/42", "github"], + ])("classifies %s as %s", (url, kind) => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + url, + snapshot: { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result?.sourceControlProvider.kind).toBe(kind); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a2921..8368dbb0eac9f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,6 +6,7 @@ import { import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveEnvironmentMachineKind, type EnvironmentId, @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - const kind = link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. + const kind = + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" + ? "gitcafe" + : link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : link.url.includes("/pulls/") + ? "forgejo" + : "github"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 00d3aafd2d2fb..083f2c8db22ab 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( : null; case "azure-devops": return `az repos pr checkout --id ${number}`; + case "gitcafe": + return `cafe pr checkout ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559a..6b673ad983252 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { gitlab: "Open on GitLab", forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", + gitcafe: "Open on GitCafe", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d0..68465b06d0fd5 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, + GitCafeIcon, GitHubIcon, GitIcon, GitLabIcon, @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 7a8d2cab8fe51..61a1befd6099a 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -16,6 +16,7 @@ import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, + GitCafeIcon, GitHubIcon, GitLabIcon, } from "./components/Icons"; @@ -62,6 +63,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + case "gitcafe": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitCafeIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 23b44f7992bbe..f760678136dc0 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3730eb6a18d43..b0452974f1c11 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish -repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone +and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -96,6 +96,22 @@ az extension add --name azure-devops az login ``` +### GitCafe + +Install the GitCafe CLI and sign in: + +```bash +bun install -g @gitcafe/cli +cafe auth login --host https://git.cafe/api +``` + +Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only +sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. + +Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. +Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index ba161940d2f7a..e21d895fd656d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 06934e93c740b..c4d48e3ccd03b 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitcafe", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166d..265c31c157fbf 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -96,6 +101,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "gitcafe": + return `https://${host}/${repository}/pulls/${number}`; case "forgejo": { try { const remote = new URL(remoteUrl ?? ""); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index ddfa3bdf9b465..be09c017767e7 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitcafe" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; +const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitcafe", + providerName: "GitCafe", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitCafe pull request", + checkoutCommandExample: "cafe pr checkout 123", + urlExample: "https://git.cafe/owner/repo/pulls/42", +}; + const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "gitcafe": + return GITCAFE_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { ); } +/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ +function isGitCafeHost(host: string): boolean { + return host === "git.cafe" || host === "staging.git.cafe"; +} + function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if (isGitCafeHost(hostname)) { + return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + } + if ( hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo") || diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json new file mode 100644 index 0000000000000..eeda68c5e92de --- /dev/null +++ b/packages/source-control-gitcafe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@t3tools/source-control-gitcafe", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitCafeHosts": { + "types": "./src/server/gitCafeHosts.ts", + "import": "./src/server/gitCafeHosts.ts" + }, + "./server/GitCafeApi": { + "types": "./src/server/GitCafeApi.ts", + "import": "./src/server/GitCafeApi.ts" + }, + "./server/GitCafeCredentials": { + "types": "./src/server/GitCafeCredentials.ts", + "import": "./src/server/GitCafeCredentials.ts" + }, + "./server/gitCafePullRequestJson": { + "types": "./src/server/gitCafePullRequestJson.ts", + "import": "./src/server/gitCafePullRequestJson.ts" + }, + "./server/GitCafePullRequestProvider": { + "types": "./src/server/GitCafePullRequestProvider.ts", + "import": "./src/server/GitCafePullRequestProvider.ts" + }, + "./server/GitCafeSourceControlProvider": { + "types": "./src/server/GitCafeSourceControlProvider.ts", + "import": "./src/server/GitCafeSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts new file mode 100644 index 0000000000000..949ece637faad --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -0,0 +1,176 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +const NO_REDIRECT: RequestInit = { redirect: "manual" }; +const TIMEOUT = Duration.seconds(30); + +/** + * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token + * disables the provider, a limit pauses the host, and the rest belong to the one request. + */ +export const GitCafeApiFailure = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); + +export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { + host: Schema.String, + operation: Schema.String, + /** GitCafe's HTTP status; null when no answer arrived. */ + status: Schema.NullOr(Schema.Int), + reason: GitCafeApiFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`; + } +} + +export interface GitCafeApiRequest { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ + readonly path: string; + readonly body?: unknown; +} + +/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ +export class GitCafeApi extends Context.Service< + GitCafeApi, + { + /** The body of a successful answer. */ + readonly request: (input: GitCafeApiRequest) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} + +/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ +const decodeProblem = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + title: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + }), + ), +); + +const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type => + status === 401 + ? "unauthenticated" + : status === 429 + ? "rate-limited" + : status === 404 + ? "not-found" + : "failed"; + +const MAX_PROBLEM_TEXT = 300; +const boundedProblemText = (text: string | undefined) => { + const line = text?.replace(/\s+/gu, " ").trim(); + if (!line) return undefined; + return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line; +}; + +const credentialFailure = ( + error: GitCafeCredentials.GitCafeCredentialUnavailableError, +): typeof GitCafeApiFailure.Type => + error._tag === "GitCafeCliMissingError" + ? "missing-tool" + : error._tag === "GitCafeNotSignedInError" + ? "unauthenticated" + : "failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + + const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) { + const fail = ( + fields: Pick & { cause?: unknown }, + ) => + new GitCafeApiError({ + host: input.host, + operation: input.operation, + status: fields.status, + reason: fields.reason, + detail: fields.detail, + ...(fields.cause === undefined ? {} : { cause: fields.cause }), + }); + // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + const host = GitCafeHosts.gitCafeHost(input.host); + if (host === null) + return yield* fail({ + status: null, + reason: "failed", + detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`, + }); + yield* Effect.annotateCurrentSpan({ + "gitcafe.host": host, + "gitcafe.operation": input.operation, + "http.request.method": input.method ?? "GET", + }); + const credential = yield* credentials + .get(host) + .pipe( + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const base = HttpClientRequest.make(input.method ?? "GET")( + `https://${host}/api${input.path}`, + ).pipe( + HttpClientRequest.bearerToken(Redacted.value(credential.token)), + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + ); + const { status, text } = yield* Effect.gen(function* () { + const response = yield* httpClient + .execute( + input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ) + .pipe( + // The client's own span would record the query string, which can carry branch names. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; + // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + ); + return { status: response.status, text: yield* response.text }; + }).pipe( + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }), + ), + ); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); + if (status >= 200 && status < 300) return text; + // The source may hold a newer token than the one that was refused. + if (status === 401) yield* credentials.invalidate(host); + const problem = Option.getOrUndefined(decodeProblem(text)); + return yield* fail({ + status, + reason: reasonForStatus(status), + // GitCafe's own explanation is what the user acts on; it is kept short and single-line. + detail: + boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, + }); + }); + + return GitCafeApi.of({ request }); +}); + +export const layer = Layer.effect(GitCafeApi, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts new file mode 100644 index 0000000000000..f0f8be74711f6 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -0,0 +1,155 @@ +/** + * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: + * + * - `class GitCafeCredentials extends Context.Service` keyed + * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with + * `get(host) => Effect` and + * `invalidate(host) => Effect`. + * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. + * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, + * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). + * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST` + * names (git.cafe when unset). + * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and + * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +function harness( + run: SourceControlHost.SourceControlHost["Service"]["process"]["run"], + env: NodeJS.ProcessEnv = {}, +) { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, env)), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return { layer, calls }; +} + +describe("GitCafeCredentials", () => { + it("hands CAFE_TOKEN only to the host cafe itself targets", () => { + const token = { CAFE_TOKEN: "env-token" }; + assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token"); + assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token)); + for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) { + const staging = { ...token, CAFE_HOST }; + assert.strictEqual( + GitCafeCredentials.environmentToken("staging.git.cafe", staging), + "env-token", + ); + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging)); + } + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " })); + }); + + it.effect("prefers CAFE_TOKEN without running cafe", () => { + const { layer, calls } = harness(() => Effect.die("cafe must not run"), { + CAFE_TOKEN: "env-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const credential = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(credential.token), "env-token"); + assert.strictEqual(credential.source, "env"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { + const { layer, calls } = harness(() => + Effect.succeed( + processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"), + ), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + // A token for staging is never taken from an environment that targets production. + const credential = yield* credentials.get("Staging.Git.Cafe"); + assert.strictEqual(Redacted.value(credential.token), "gct_1"); + assert.strictEqual(credential.source, "cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.command, "cafe"); + assert.deepStrictEqual(calls[0]?.args, [ + "auth", + "http", + "credential", + "--host", + "https://staging.git.cafe/api", + "get", + ]); + assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); + // A refused token is asked for again rather than reused. + yield* credentials.invalidate("staging.git.cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 2); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + [ + "a missing CLI", + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + "GitCafeCliMissingError", + ], + [ + "a helper with no login", + Effect.fail( + new VcsProcessExitError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + exitCode: 1, + detail: "not signed in", + }), + ), + "GitCafeNotSignedInError", + ], + [ + "an answer without a password", + Effect.succeed(processOutput("username=alice\n")), + "GitCafeNotSignedInError", + ], + ] as const)("reports %s", ([, result, tag]) => { + const { layer } = harness(() => result); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const error = yield* credentials.get("git.cafe").pipe(Effect.flip); + assert.strictEqual(error._tag, tag); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts new file mode 100644 index 0000000000000..c47e9ce1153f3 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -0,0 +1,166 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export interface GitCafeCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: "env" | "cafe"; +} + +/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ +export class GitCafeCliMissingError extends Schema.TaggedError()( + "GitCafeCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`; + } +} + +/** `cafe` is installed but holds no login for the host. */ +export class GitCafeNotSignedInError extends Schema.TaggedError()( + "GitCafeNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`; + } +} + +/** `cafe` timed out or failed for a reason other than having no login. */ +export class GitCafeCliFailedError extends Schema.TaggedError()( + "GitCafeCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`; + } +} + +export type GitCafeCredentialUnavailableError = + | GitCafeCliMissingError + | GitCafeNotSignedInError + | GitCafeCliFailedError; + +/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ +export class GitCafeCredentials extends Context.Service< + GitCafeCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} + +const normalizeHost = (host: string) => host.trim().toLowerCase(); + +/** + * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by + * default): a remote URL picks the host, and must not steer a production token to staging. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const configured = env.CAFE_HOST?.trim(); + const configuredHost = configured + ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host + : "git.cafe"; + if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; + return env.CAFE_TOKEN?.trim() || null; +} + +/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ +function credentialPassword(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; + + /** + * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand + * the stored login to another program, and answers over stdin/stdout so the token never + * reaches an argument list or a log. + */ + const fromCafe = (host: string) => + process + .run({ + operation: "GitCafeCredentials.get", + command: "cafe", + args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"], + cwd: workingDirectory, + stdin: `protocol=https\nhost=${host}\n\n`, + env: { CAFE_NO_UPDATE_CHECK: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitCafeCliMissingError({ host }) + : error._tag === "VcsProcessExitError" + ? new GitCafeNotSignedInError({ host }) + : new GitCafeCliFailedError({ host, cause: error }), + ), + Effect.map((output) => credentialPassword(output.stdout)), + Effect.filterOrFail( + (token): token is string => token !== null, + () => new GitCafeNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromCafe(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "cafe", + } satisfies GitCafeCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 4, + // A transient failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitCafeCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitCafeCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitCafeCredentials, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts new file mode 100644 index 0000000000000..0603dfec80ec0 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -0,0 +1,751 @@ +/** + * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer + * supports. Pass 2 creates: + * + * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) + * with `request({ host, operation, method?, path, body? }) => Effect` + * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer + * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. + * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. + * - `./GitCafePullRequestProvider.ts`: `make: Effect` + * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, + * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by + * `expectedStackHeads`. + * + * Every write carries a fresh `requestId`, which GitCafe requires. + * + * Out of scope here (layer 3): review-revision fencing, async outcome polling. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +interface SentRequest { + readonly method: string; + /** The path below `/api`, without its query. */ + readonly path: string; + readonly query: URLSearchParams; + readonly host: string; + readonly body: unknown; + /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ + readonly redirect: string | undefined; +} + +/** + * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 + * problem document. Every request is recorded in order. + */ +function fakeGitCafe( + routes: Record unknown)>, + options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, +) { + const sent: Array = []; + const client = HttpClient.make( + (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => { + const url = new URL(request.url); + assert.isTrue(url.pathname.startsWith("/api/"), url.pathname); + assert.strictEqual(request.headers.authorization, "Bearer env-token"); + const entry: SentRequest = { + method: request.method, + path: url.pathname.slice("/api".length), + query: url.searchParams, + host: url.host, + body: + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined, + redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect, + }; + sent.push(entry); + const route = routes[`${entry.method} ${entry.path}`]; + const response = + route === undefined + ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 }) + : new Response(encodeJson(typeof route === "function" ? route(entry) : route), { + status: options.status ?? 200, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }, + ); + const layer = GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provide(TestSourceControlHost.layer()), + // An environment token, so no test runs `cafe`. + Layer.provide( + Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + const writes = () => + sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`); + return { layer, sent, writes }; +} + +const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; +const timestamp = "2026-09-12T12:00:00Z"; +const headOid = "0123456789abcdef0123456789abcdef01234567"; +const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid, + observedBaseOid: baseOid, + mergeRoute: "provider", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 4, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const root = { + id: "root", + threadId: "root", + version: 2, + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: headOid, + resolvedAt: null, + capabilities: { + edit: true, + hide: false, + unhide: false, + delete: false, + resolve: true, + unresolve: false, + }, +}; +/** + * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged + * layer's head comes from its own pull. + */ +const stack = ( + members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, +): Record => ({ + "GET /repos/owner/repo/pulls/7/stack": { + stack: { + id: "stack-one", + number: 3, + revision: 5, + landingBase: "main", + members: members.map((member, index) => ({ + pullRequestNumber: member.number, + title: `Layer ${member.number}`, + state: member.state ?? "open", + draft: false, + sourceBranch: `layer-${member.number}`, + position: index + 1, + })), + }, + }, + ...Object.fromEntries( + members.map((member) => [ + `GET /repos/owner/repo/pulls/${member.number}`, + { ...pull, number: member.number, headOid: member.headOid }, + ]), + ), +}); + +/** GitCafe refuses a write without an idempotency key. */ +const assertRequestId = (body: unknown) => + assert.match( + (body as { readonly requestId?: unknown } | undefined)?.requestId as string, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + +describe("GitCafePullRequestProvider", () => { + it.effect("lists a repository's pull requests from its pulls collection", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "all", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual( + page.items.map((item) => item.number), + [7], + ); + assert.deepStrictEqual( + server.sent.map((request) => `${request.method} ${request.path}`), + ["GET /repos/owner/repo/pulls"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps a staging repository's requests and links on staging", () => { + // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7": pull }, + { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const summary = yield* provider.getChangeRequestSummary!({ + ...target, + host: "staging.git.cafe", + }); + assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe")); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .getChangeRequest({ ...target, host: "github.com" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [401, "unauthenticated"], + [429, "rate-limited"], + ] as const) { + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7/stack": { stack: null } }, + { + status, + }, + ); + const error = yield* GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.flip, + Effect.provide(server.layer), + ); + assert.strictEqual(error.reason, reason); + } + }), + ); + + it.effect("never lets fetch follow a redirect with the token", () => { + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } }); + return GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + server.sent.map((r) => r.redirect), + ["manual"], + ), + ), + ), + Effect.provide(server.layer), + ); + }); + + it.effect("reads the stack a pull request belongs to, with each open layer's head", () => { + const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([ + { number: 6, headOid: baseOid, state: "merged" }, + { number: 7, headOid }, + ]); + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getChangeRequestStack!(target); + assert.strictEqual(result?.number, 3); + // A merged layer is not fenced on, so its pull is never read. + assert.deepStrictEqual( + result?.layers.map((layer) => [layer.number, layer.headSha]), + [ + [6, undefined], + [7, headOid], + ], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads activity with the host's edit and resolve permissions", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/commits": { + items: [], + truncated: false, + next: null, + headOid, + }, + "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const activity = yield* provider.getChangeRequestActivity(target); + assert.strictEqual(activity.comments[0]?.canEdit, true); + assert.strictEqual(activity.reviewThreads[0]?.canResolve, true); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the viewer's permission to edit the pull request", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { + ...pull, + capabilities: { ...pull.capabilities, edit: false }, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const permissions = yield* provider.getViewerPermissions(target); + assert.strictEqual(permissions.editChangeRequest, false); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the diff from the pull request's changed files", () => { + const file = { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + }; + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": { + ...snapshot, + items: [ + { + ...file, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const diff = yield* provider.getDiff(target); + assert.include(diff.patch, "-old\n+new\n"); + assert.isNull(diff.nextCursor); + // The batched hunk read is a POST, but it writes nothing. + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("comments and replies to a thread on the pull request's comments", () => { + const written = { id: "c1", threadId: "root", version: 1 }; + const server = fakeGitCafe({ + "POST /repos/owner/repo/pulls/7/comments/": written, + "POST /repos/owner/repo/pulls/7/comments/root/replies": written, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.comment({ ...target, body: "Hello" }); + yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/", + "POST /repos/owner/repo/pulls/7/comments/root/replies", + ]); + assert.deepStrictEqual( + server.sent.map((request) => request.body), + [{ body: "Hello" }, { body: "Reply" }], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("resolves a thread through its root at the root's version", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "POST /repos/owner/repo/pulls/7/comments/root/resolve": { + id: "root", + threadId: "root", + version: 3, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/root/resolve", + ]); + assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("submits a review against the pull request's current head", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.submitReview({ + ...target, + verdict: "request-changes", + body: "Please fix", + comments: [], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + verdict: "request_changes", + body: "Please fix", + commitOid: headOid, + }); + assertRequestId(server.sent.at(-1)?.body); + + // GitCafe rejects a null body; an empty review leaves it out. + yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] }); + assert.notProperty(server.sent.at(-1)?.body as object, "body"); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["completed", true], + ["failed", false], + ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .runAction({ ...target, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, succeeds ? "Success" : "Failure"); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + strategy: "squash", + headOid, + expectedVersion: 4, + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "lands a stack through a layer only while its heads are the ones the reader saw", + () => { + const layers = [ + { number: 6, headOid: baseOid }, + { number: 7, headOid }, + ]; + const server = fakeGitCafe({ + ...stack(layers), + "POST /repos/owner/repo/pulls/stacks/3/land-through": { + id: "land-1", + state: "completed", + landedCount: 2, + stepCount: 2, + stopReason: null, + error: null, + steps: [], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid })); + const stale = yield* provider + .runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }], + }) + .pipe(Effect.result); + assert.strictEqual(stale._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + + yield* provider.runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: heads, + mergeMethod: "merge", + }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/stacks/3/land-through", + ]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + expectedRevision: 5, + throughPullRequestNumber: 7, + strategy: "merge", + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("restacks from a layer on update-branch", () => { + const server = fakeGitCafe({ + ...stack([{ number: 7, headOid }]), + "POST /repos/owner/repo/pulls/stacks/3/restack": { + id: "restack-1", + state: "completed", + stepCount: 1, + completedStepCount: 1, + pauseReason: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ + ...target, + action: "update-branch", + updateMethod: "rebase", + stackNumber: 3, + expectedStackHeads: [{ number: 7, headSha: headOid }], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]); + const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown }; + assertRequestId({ requestId }); + assert.deepStrictEqual(body, { expectedRevision: 5 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["authored", "authors"], + ["reviewing", "reviewers"], + ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { + actors: [{ actorId: "act_one", handle: "Alice" }], + }, + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]'); + assert.strictEqual( + page.items[0]?.reviewRequestLogins.includes("alice"), + involvement === "reviewing", + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("lists nothing for a viewer GitCafe has no actor for", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { actors: [] }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "authored", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual(page.items, []); + assert.deepStrictEqual( + server.sent.map((request) => request.path), + ["/repos/owner/repo/filter-options"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["authored", "reviewing"] as const)( + "lists %s unnarrowed when GitCafe can't say who the viewer is", + (involvement) => { + // No `filter-options` route: the fake answers 404, which is an ordinary failure. + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); + assert.isDefined(listing); + assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); + // An unnarrowed row says nothing about who was asked to review it. + assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("reads line counts, open merge strategies and requested reviewers", () => { + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const files = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` })); + let changePage = 0; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { + conflicts: "unknown", + fastForward: true, + strategies: ["merge", "squash", "rebase"], + blockers: [{ blockedStrategies: ["squash"] }, {}], + }, + checks: { pending: 0, failing: 0, total: 0 }, + }, + // Checks continue on a cursor too; a failure on a later page must not be dropped. + [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ + items: [ + { + name: request.query.has("after") ? "late" : "early", + status: "completed", + conclusion: request.query.has("after") ? "failure" : "success", + summary: null, + detailsUrl: null, + }, + ], + next: request.query.has("after") ? null : "checks-1", + }), + // `/changes` counts no lines, and a pull past one page continues on a cursor. + "GET /repos/owner/repo/pulls/7/changes": () => + changePage++ === 0 + ? { ...snapshot, items: files(0, 2), next: "cursor-1" } + : { ...snapshot, items: files(2, 1), next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + ...snapshot, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: 2, + deletions: 1, + })), + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const detail = yield* provider.getChangeRequest(target); + assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 }); + assert.strictEqual( + server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"), + "cursor-1", + ); + assert.deepStrictEqual(detail.mergeCapabilities, { + merge: true, + squash: false, + rebase: true, + }); + assert.deepStrictEqual( + detail.checks.map((check) => check.name), + ["early", "late"], + ); + // A requested reviewer shows before they have reviewed. + assert.deepStrictEqual( + detail.reviewers.map((reviewer) => reviewer.login), + ["alice"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("fences a native merge on the target branch's current commit", () => { + const branchOid = "fedcba9876543210fedcba9876543210fedcba98"; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null }, + "GET /repos/owner/repo/commit": { oid: branchOid }, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ ...target, action: "merge" }); + const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit"); + assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main"); + assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses to drop a reaction whose id it cannot address", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/reactions/": { + items: [ + { + subject: { kind: "pull_request_comment", id: "root" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: "../escape", + reactors: [actor], + }, + ], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps GitCafe's explanation short and on one line", () => { + const server = fakeGitCafe( + { + "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` }, + }, + { status: 409 }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getChangeRequest(target).pipe(Effect.flip); + assert.notInclude(error.detail, "\n"); + assert.isAtMost(error.detail.length, 310); + assert.isTrue(error.detail.startsWith("Line x")); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts new file mode 100644 index 0000000000000..7ea6a1e6eb87f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -0,0 +1,867 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCapabilities, + type PullRequestStackHead, +} from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; +import * as Json from "./gitCafePullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + // Only through a stack: GitCafe's one branch update restacks every layer above it. + updateMethods: ["rebase"], + search: true, + stacks: true, + stackActions: true, + reactions: true, + labels: false, + // Line comments go through GitCafe's review drafts, which arrive with revision fencing. + review: { + inlineComment: false, + reply: true, + resolve: true, + verdicts: ["approve", "request-changes", "comment"], + }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: true }, +}; + +const PAGE_SIZE = 100; +/** Conversation pages read before the list is reported as truncated. */ +const MAX_PAGES = 10; +/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ +const DIFF_FILES_BATCH = 64; +/** + * Line totals cost one hunk read per batch, so a pull request larger than this reports none + * rather than reading every file. + */ +const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; +/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ +const MAX_CHANGE_PAGES = 20; + +const chunk = (items: ReadonlyArray, size: number): Array> => + Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); + +/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ +const detailablePaths = (files: ReadonlyArray) => + files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); + +/** + * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without + * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows + * GitCafe's own reason. + */ +const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => + status.merge.permitted !== false && + status.merge.strategies.includes(strategy) && + !(status.merge.blockers ?? []).some((blocker) => + (blocker.blockedStrategies ?? []).includes(strategy), + ); + +const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)); +const isId = Schema.is(Id); +const Principal = Schema.Struct({ handle: TrimmedNonEmptyString }); +const Written = Schema.Struct({ id: Id }); +const Versioned = Schema.Struct({ version: NonNegativeInt }); +const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt }); +const Status = Schema.Struct({ + merge: Schema.Struct({ + conflicts: Schema.Literals(["unknown", "conflicting"]), + fastForward: Schema.NullOr(Schema.Boolean), + strategies: Schema.Array(Schema.String), + permitted: Schema.optional(Schema.Boolean), + blockers: Schema.optional( + Schema.Array( + Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }), + ), + ), + }), + checks: Schema.Struct({ + pending: NonNegativeInt, + failing: NonNegativeInt, + total: NonNegativeInt, + }), +}); +const FilterOptions = Schema.Struct({ + actors: Schema.Array( + Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }), + ), +}); +const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const Snapshot = { + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + comparisonBaseOid: TrimmedNonEmptyString, +}; +const Changes = Schema.Struct({ + ...Snapshot, + items: Json.GitCafeDiffFiles, + next: Schema.NullOr(TrimmedNonEmptyString), +}); +const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles }); +const DiffCursor = Schema.Struct({ + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + baseOid: TrimmedNonEmptyString, + after: TrimmedNonEmptyString, +}); +const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor)); +const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor)); +const MergeOutcome = Schema.Struct({ + id: Schema.String, + state: Schema.Literals(["accepted", "completed", "failed"]), + reason: Schema.optional(Schema.String), +}); +const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); +/** Where a stack operation stopped short; anything else is underway or done. */ +const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); + +type PullRef = ProviderRepositoryRef & { readonly number: number }; +type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; + +/** The heads a reader saw are still every unmerged layer the action moves. */ +function stackHeadsCurrent( + stack: Json.GitCafeStack, + input: { readonly number: number; readonly merge: boolean }, + expected: ReadonlyArray, +): boolean { + const members = stack.members.toSorted((a, b) => a.position - b.position); + const index = members.findIndex((member) => member.pullRequestNumber === input.number); + if (index === -1) return false; + const moved = (input.merge ? members.slice(0, index + 1) : members).filter( + (member) => member.state !== "merged", + ); + return ( + moved.length === expected.length && + moved.every( + (member) => + member.headOid !== undefined && + expected.some( + (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid, + ), + ) + ); +} + +/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ +const withDetails = ( + files: ReadonlyArray, + details: ReadonlyArray, +) => { + const byPath = new Map(details.map((file) => [file.path, file])); + return files.map((file) => byPath.get(file.path) ?? file); +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ + const repositoryPath = ( + operation: string, + input: ProviderRepositoryRef, + ): Effect.Effect => + GitCafeHosts.isGitCafeHost(input.host) && + GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository + ? Effect.succeed(`/repos/${input.repository}`) + : Effect.fail( + failure( + operation, + `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`, + ), + ); + + const send = ( + operation: string, + input: Pick, + request: Omit, + ) => + api.request({ ...request, host: input.host, operation }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: error.reason, + detail: error.detail, + cause: error, + }), + ), + ); + + const read = >( + operation: string, + input: Pick, + request: Omit, + schema: S, + ) => + send(operation, input, request).pipe( + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + failure(operation, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + + const pullPath = (operation: string, input: PullRef) => + repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`)); + + const readPull = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), + ); + + /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ + const readPages = , unknown, never, never>>( + operation: string, + input: Pick, + path: string, + schema: S, + ) => + Effect.gen(function* () { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read( + operation, + input, + { path: `${path}?${query}` }, + schema, + ); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); + + const readComments = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => + readPages(operation, input, `${path}/comments`, Json.GitCafeComments), + ), + ); + + /** + * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no + * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + */ + const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( + operation: string, + input: PullRef, + ) { + const path = yield* pullPath(operation, input); + const { stack } = yield* read( + operation, + input, + { path: `${path}/stack` }, + Json.GitCafeStackEnvelope, + ); + if (stack === null) return null; + const members = yield* Effect.forEach( + stack.members, + (member) => + member.state === "merged" + ? Effect.succeed(member) + : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe( + Effect.map((pull) => + pull.headOid == null ? member : { ...member, headOid: pull.headOid }, + ), + ), + { concurrency: 4 }, + ); + return { ...stack, members }; + }); + + /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( + input: PullRef, + revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, + paths: ReadonlyArray, + ) { + if (paths.length === 0) return []; + const path = yield* pullPath("getDiff", input); + const files = yield* read( + "getDiff", + input, + { + method: "POST", + path: `${path}/diff-files`, + body: { paths, expectedVersion: revision.version }, + }, + DiffFiles, + ); + if ( + files.version !== revision.version || + files.headOid !== revision.headOid || + files.comparisonBaseOid !== revision.baseOid + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + return files.items; + }); + + const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* ( + input: Parameters[0] & { readonly stackNumber: number }, + ) { + const land = input.action === "merge"; + const operation = land ? "landStackThrough" : "restackStack"; + if (!land && input.action !== "update-branch") + return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`); + const stack = yield* readStack(operation, input); + if ( + stack === null || + stack.number !== input.stackNumber || + input.expectedStackHeads === undefined || + !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads) + ) + return yield* failure( + operation, + "The stack changed since it was read. Refresh it and try again.", + ); + const base = yield* repositoryPath(operation, input); + const outcome = yield* read( + operation, + input, + { + method: "POST", + path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`, + body: land + ? { + expectedRevision: stack.revision, + requestId: yield* randomUuidV4, + throughPullRequestNumber: input.number, + strategy: input.mergeMethod ?? "merge", + } + : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 }, + }, + StackOutcome, + ); + if (FAILED_STACK_STATES.has(outcome.state)) + return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); + }); + + /** + * Added and deleted line totals and the file count. `/changes` lists files without counting + * lines, so the counts come from batched hunk reads, and a pull request past the cap reports + * none rather than paying for every file. + */ + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + version: number, + ) { + const path = yield* pullPath("getChangeRequest", input); + const files: Array = []; + let revision: { version: number; headOid: string; baseOid: string } | undefined; + let after: string | null = null; + for (let page = 0; page < MAX_CHANGE_PAGES; page++) { + const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + if (after !== null) query.set("after", after); + const batch: typeof Changes.Type = yield* read( + "getChangeRequest", + input, + { path: `${path}/changes?${query}` }, + Changes, + ); + revision ??= { + version: batch.version, + headOid: batch.headOid, + baseOid: batch.comparisonBaseOid, + }; + files.push(...batch.items); + const previous: string | null = after; + after = batch.next; + if (after === null || after === previous || batch.items.length === 0) break; + } + // A hunk read that loses a race with a push leaves the counts at zero rather than dropping + // the file count with them. + const counted = + files.some((file) => file.additions !== undefined) || + files.length > LINE_STATS_MAX_FILES || + revision === undefined + ? files + : (yield* Effect.forEach( + chunk(detailablePaths(files), DIFF_FILES_BATCH), + (paths) => readDiffFiles(input, revision, paths), + { concurrency: 4 }, + ).pipe(Effect.orElseSucceed(() => [[]]))).flat(); + return { + changedFiles: files.length, + additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), + deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), + }; + }); + + const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( + input: Parameters[0], + ) { + const pull = yield* readPull("merge", input); + if (pull.state === "merged") return; + if (pull.mergeRoute === "unsupported") + return yield* failure("merge", "This GitCafe pull request has no supported merge route."); + const base = yield* repositoryPath("merge", input); + // GitCafe observes the base only for merges it routes to the provider; a native merge is + // fenced on the target branch's current commit. + const baseOid = + pull.mergeRoute === "provider" + ? pull.observedBaseOid + : (yield* read( + "merge", + input, + { + path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`, + }, + BranchCommit, + )).oid; + if (pull.headOid === null || baseOid == null) + return yield* failure( + "merge", + "GitCafe has not reported the pull request's head and base yet. Refresh and try again.", + ); + const path = yield* pullPath("merge", input); + const outcome = yield* read( + "merge", + input, + { + method: "POST", + path: `${path}/merge`, + body: { + requestId: yield* randomUuidV4, + expectedVersion: pull.version, + headOid: pull.headOid, + baseOid, + strategy: input.mergeMethod ?? "merge", + }, + }, + MergeOutcome, + ); + // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. + if (outcome.state === "failed") + return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); + }); + + const provider: PullRequestProviderApi = { + kind: "gitcafe", + capabilities: CAPABILITIES, + getViewer: (input) => + read( + "getViewer", + { host: input.host ?? "git.cafe" }, + { path: "/auth/principal" }, + Principal, + ).pipe(Effect.map((principal) => principal.handle)), + listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")( + function* (input) { + const base = yield* repositoryPath("listChangeRequests", input); + const involved = input.involvement === "authored" || input.involvement === "reviewing"; + let actorId: string | undefined; + if (involved) { + // A host narrows as far as it can: when the actor list can't be read, the listing comes + // back unnarrowed and the page's own involvement filter does the rest. A refused token + // or a rate limit still fails, since the service acts on those for the whole host. + const options = yield* read( + "listChangeRequests", + input, + { path: `${base}/filter-options` }, + FilterOptions, + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PullRequestProviderError: (error) => + error.reason === "unauthenticated" || error.reason === "rate-limited" + ? Effect.fail(error) + : Effect.succeed(Option.none()), + }), + ); + if (Option.isSome(options)) { + actorId = options.value.actors.find( + (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), + )?.actorId; + // A viewer GitCafe knows no actor for has authored and been asked to review nothing. + if (actorId === undefined) return { items: [], truncated: false, continues: false }; + } + } + const limit = Math.max(1, input.limit); + const items: Array = []; + let next: string | null = null; + do { + const query = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - items.length)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (input.query?.trim()) query.set("q", input.query.trim()); + if (actorId !== undefined) + query.set( + input.involvement === "reviewing" ? "reviewers" : "authors", + encodeActorIds([actorId]), + ); + if (next !== null) query.set("after", next); + const page: typeof Json.GitCafePulls.Type = yield* read( + "listChangeRequests", + input, + { path: `${base}/pulls?${query}` }, + Json.GitCafePulls, + ); + items.push(...page.items); + next = page.items.length === 0 ? null : page.next; + } while (next !== null && items.length < limit); + return { + items: items.slice(0, limit).map((pull) => { + const item = Json.toChangeRequest(pull, input); + // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An + // unnarrowed listing (no actor to filter by) says nothing about who was asked. + return input.involvement === "reviewing" && actorId !== undefined + ? { + ...item, + reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])], + } + : item; + }), + truncated: next !== null, + continues: false, + }; + }, + ), + getChangeRequestSummary: (input) => + readPull("getChangeRequestSummary", input).pipe( + Effect.map((pull) => ({ + ...Json.toChangeRequest(pull, input), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + })), + ), + getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) { + const pull = yield* readPull("getChangeRequest", input); + const path = yield* pullPath("getChangeRequest", input); + const base = yield* repositoryPath("getChangeRequest", input); + const [status, checks, changes] = yield* Effect.all( + [ + read("getChangeRequest", input, { path: `${path}/status` }, Status), + pull.headOid === null + ? Effect.succeed(null) + : readPages( + "getChangeRequest", + input, + `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`, + Json.GitCafeChecks, + ), + pull.headOid === null + ? Effect.succeed(null) + : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: 3 }, + ); + const permissions = Json.toViewerPermissions(pull); + return { + ...Json.toChangeRequest(pull, input), + headSha: pull.headOid, + body: pull.description ?? "", + ...(pull.sourceRepo === null + ? {} + : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }), + additions: changes?.additions ?? 0, + deletions: changes?.deletions ?? 0, + changedFiles: changes?.changedFiles ?? 0, + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + reviewers: Json.toReviewers(pull, input.host), + checks: checks === null ? [] : Json.toChecks(checks), + ...(status.checks.total === 0 + ? {} + : { + checksState: + status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const), + }), + mergeability: + status.merge.conflicts === "conflicting" + ? ("conflicting" as const) + : ("unknown" as const), + baseComparison: + status.merge.fastForward === null + ? ("unknown" as const) + : status.merge.fastForward + ? ("up-to-date" as const) + : ("behind" as const), + mergeCapabilities: { + merge: mergeStrategyOpen(status, "merge"), + squash: mergeStrategyOpen(status, "squash"), + rebase: mergeStrategyOpen(status, "rebase"), + }, + viewerPermissions: + status.merge.permitted === false + ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") } + : permissions, + }; + }), + getChangeRequestStack: (input) => + readStack("getChangeRequestStack", input).pipe( + Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))), + ), + getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")( + function* (input) { + const pull = yield* readPull("getChangeRequestActivity", input); + const path = yield* pullPath("getChangeRequestActivity", input); + const operation = "getChangeRequestActivity"; + const [comments, reviews, commits, reactions] = yield* Effect.all( + [ + readComments(operation, input), + readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews), + pull.headOid === null + ? Effect.succeed({ items: [], next: null }) + : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), + // Reactions decorate the conversation; a failed read leaves them out, not it. + readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: 4 }, + ); + return Json.toActivity({ + comments, + reviews, + commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" }, + host: input.host, + ...(pull.headOid === null ? {} : { headOid: pull.headOid }), + ...(reactions === undefined ? {} : { reactions }), + }); + }, + ), + getViewerPermissions: (input) => + readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), + getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { + if (input.commit !== undefined) + return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), + ); + const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; + const path = yield* pullPath("getDiff", input); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(DIFF_FILES_BATCH), + }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (cursor !== null && + (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + return { + ...Json.toDiff({ items: withDetails(page.items, details) }), + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }), + runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { + if (input.stackNumber !== undefined) + return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); + switch (input.action) { + case "merge": + return yield* merge(input); + case "ready": + case "draft": + case "close": + case "reopen": { + const pull = yield* readPull(input.action, input); + const path = yield* pullPath(input.action, input); + yield* send(input.action, input, { + method: "POST", + path: `${path}/${input.action}`, + body: { expectedVersion: pull.version }, + }); + return; + } + default: + return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`); + } + }), + updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")( + function* (input) { + const pull = yield* readPull("updateChangeRequest", input); + const path = yield* pullPath("updateChangeRequest", input); + yield* read( + "updateChangeRequest", + input, + { + method: "PATCH", + path, + body: { + expectedVersion: pull.version, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }, + }, + Versioned, + ); + }, + ), + comment: (input) => + pullPath("comment", input).pipe( + Effect.flatMap((path) => + read( + "comment", + input, + { method: "POST", path: `${path}/comments/`, body: { body: input.body } }, + Written, + ), + ), + Effect.asVoid, + ), + updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) { + if (!isId(input.commentId)) + return yield* failure("updateComment", "GitCafe comment ids are opaque slugs."); + const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`; + const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail); + yield* read( + "updateComment", + input, + { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } }, + Written, + ); + }), + submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) { + if (input.comments.length > 0) + return yield* failure("submitReview", "GitCafe line comments are not supported yet."); + const pull = yield* readPull("submitReview", input); + if (pull.headOid === null) + return yield* failure("submitReview", "GitCafe has not reported the pull request's head."); + const path = yield* pullPath("submitReview", input); + yield* read( + "submitReview", + input, + { + method: "POST", + path: `${path}/reviews`, + body: { + verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict, + ...(input.body.length === 0 ? {} : { body: input.body }), + commitOid: pull.headOid, + expectedVersion: pull.version, + requestId: yield* randomUuidV4, + }, + }, + Written, + ); + }), + listReviewerCandidates: () => + Effect.fail( + failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."), + ), + setReviewerRequest: () => + Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")), + replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) { + if (!isId(input.threadId)) + return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs."); + const path = yield* pullPath("replyToThread", input); + yield* read( + "replyToThread", + input, + { + method: "POST", + path: `${path}/comments/${input.threadId}/replies`, + body: { body: input.body }, + }, + Written, + ); + }), + setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) { + const path = `${yield* pullPath("setReaction", input)}/reactions/`; + const subject = + input.subjectId === undefined + ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id } + : { kind: "pull_request_comment" as const, id: input.subjectId }; + const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions); + const mine = reactions.items.find( + (item) => + item.subject.kind === subject.kind && + item.subject.id === subject.id && + item.emoji.kind === "unicode" && + Json.reactionContent(item.emoji.value) === input.content, + )?.viewerReactionId; + if (input.reacted === (mine != null)) return; + if (input.reacted) { + yield* send("setReaction", input, { + method: "POST", + path, + body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } }, + }); + return; + } + if (mine == null) return; + if (!isId(mine)) + return yield* failure("setReaction", "GitCafe returned an unreadable reaction id."); + yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` }); + }), + setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")( + function* (input) { + const comments = yield* readComments("setThreadResolution", input); + const root = comments.items.find( + (comment) => comment.id === input.threadId && comment.threadId === input.threadId, + ); + if (root === undefined || root.version === undefined || !isId(root.id)) + return yield* failure("setThreadResolution", "GitCafe did not return the thread root."); + if (input.resolved === (root.resolvedAt !== null)) return; + const path = yield* pullPath("setThreadResolution", input); + yield* read( + "setThreadResolution", + input, + { + method: "POST", + path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`, + body: { expectedVersion: root.version }, + }, + Written, + ); + }, + ), + }; + return provider; +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts new file mode 100644 index 0000000000000..aaaf5d0e90ffd --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -0,0 +1,339 @@ +/** + * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: + * + * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`, + * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a + * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its + * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. + * - `make: Effect` with + * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. + * + * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. + */ +import { assert, describe, it } from "@effect/vitest"; +import { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ + stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), + stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), +}); +const failure = (code: string, status: number | null) => ({ + schemaVersion: 1, + error: { code, status, message: `Failure ${code}` }, +}); + +describe("GitCafeSourceControlProvider", () => { + it("probes the cafe CLI against production", () => { + const { discovery } = GitCafeSourceControlProvider; + assert.strictEqual(discovery.kind, "gitcafe"); + assert.strictEqual(discovery.executable, "cafe"); + assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); + assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); + }); + + it("reads a signed-in account and keeps transient failures apart from sign-out", () => { + const { parseAuth } = GitCafeSourceControlProvider.discovery; + const signedIn = parseAuth( + authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), + ); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice")); + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) + .status, + "unauthenticated", + ); + for (const [code, status] of [ + ["FORBIDDEN", 403], + ["NETWORK_ERROR", null], + ] as const) { + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, + "unknown", + ); + } + }); + + it.effect.each([ + ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + const context = { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + assert.strictEqual(provider.kind, "gitcafe"); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), + { + nameWithOwner: "team/project", + url: `https://${host}/team/project`, + sshUrl: `ssh@${host}:team/project.git`, + }, + ); + assert.deepStrictEqual(urls, [ + `https://${host}/api/repos/team/project`, + `https://${host}/api/repos/team/project`, + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(TestSourceControlHost.layer()), + Layer.provide( + Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect( + "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", + () => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + // The checkout has no remote at all, as when a repository is about to be created. + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), + { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }, + ); + const foreign = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://example.com/team/project/pulls/42", + }) + .pipe(Effect.flip); + assert.strictEqual(foreign._tag, "SourceControlProviderError"); + assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }, + ); + + it.effect.each([ + ["no context", undefined, "https://git.cafe/fork/project.git"], + ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], + ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], + ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], + [ + "an HTTPS remote with a user", + "https://alice@git.cafe/team/project.git", + "https://git.cafe/fork/project.git", + ], + ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { + const remotes: Array = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid: null, + updatedAt: null, + sourceRepo: { owner: "fork", name: "project" }, + closedAt: null, + mergedAt: null, + }), + ), + ), + ), + ); + const context = + remoteUrl === undefined + ? undefined + : { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: "https://git.cafe/team/project/pulls/7", + ...(context === undefined ? {} : { context }), + }); + assert.deepStrictEqual(remotes, [expected]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + ensureRemote: (input) => { + remotes.push(input.url); + return Effect.succeed("gitcafe"); + }, + listLocalBranchNames: () => Effect.succeed([]), + fetchRemoteBranch: () => Effect.void, + setBranchUpstream: () => Effect.void, + switchRef: () => Effect.succeed({ refName: null }), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect("waits for a created repository's admission before handing out its URLs", () => { + const urls: Array = []; + const states = ["running", "complete"]; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + repoId: "repo_1", + state: states.shift(), + owner: "team", + name: "project", + }), + ), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + const created = yield* provider + .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(created), { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }); + assert.deepStrictEqual(urls, [ + "https://git.cafe/api/orgs/team/admissions/repo_1", + "https://git.cafe/api/orgs/team/admissions/repo_1", + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput( + encodeJson({ + schemaVersion: 1, + data: { + resource: { + repoId: "repo_1", + state: "running", + owner: "team", + name: "project", + }, + }, + }), + ), + ), + }, + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts new file mode 100644 index 0000000000000..5d55bd72bac3b --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -0,0 +1,503 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import { + PositiveInt, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, +} from "@t3tools/contracts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +/** Discovery reports the production account; repository operations pick their own host. */ +const AUTH_HOST = "git.cafe"; +/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ +const ADMISSION_TIMEOUT = "30 seconds"; +const ADMISSION_POLL_INTERVAL = "500 millis"; +const cliArgs = (host: string) => [ + "--host", + `https://${host}/api`, + "--no-input", + "--no-update-check", +]; + +const decodeAuth = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }), + }), + ), +); +/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ +const decodeFailure = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + status: Schema.NullOr(Schema.Finite), + }), + }), + ), +); +const cliFailure = (output: string) => + [output.trim(), ...output.split(/\r?\n/u).toReversed()] + .map((line) => decodeFailure(line.trim())) + .find(Result.isSuccess)?.success.error; + +export const discovery = { + type: "cli", + kind: "gitcafe", + label: "GitCafe", + executable: "cafe", + versionArgs: ["--version"], + authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"], + parseAuth: (input) => { + const signedIn = decodeAuth(input.stdout.trim()); + if (input.exitCode === 0 && Result.isSuccess(signedIn)) + return providerAuth({ + status: "authenticated", + host: AUTH_HOST, + account: signedIn.success.data.username, + }); + const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); + if (failure) + return providerAuth({ + // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + status: + failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 + ? "unauthenticated" + : "unknown", + host: AUTH_HOST, + detail: failure.message, + }); + return providerAuth({ + status: "unknown", + host: AUTH_HOST, + detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, + }); + }, + installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`, +} satisfies SourceControlCliDiscoverySpec; + +const Repository = Schema.Struct({ + name: TrimmedNonEmptyString, + defaultBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +const Pull = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + sourceBranch: TrimmedNonEmptyString, + targetBranch: TrimmedNonEmptyString, + headOid: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), +}); +const PullDetail = Schema.Struct({ + ...Pull.fields, + sourceRepo: Schema.NullOr( + Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + ), + closedAt: Schema.NullOr(Schema.String), + mergedAt: Schema.NullOr(Schema.String), +}); +const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) }); +const RepositoryAdmission = Schema.Struct({ + repoId: TrimmedNonEmptyString, + state: Schema.String, + owner: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +const CreatedRepository = Schema.Struct({ + data: Schema.Struct({ resource: RepositoryAdmission }), +}); +const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +interface Target { + readonly host: GitCafeHosts.GitCafeHost; + readonly repository: string; +} + +const cloneUrls = (target: Target) => ({ + nameWithOwner: target.repository, + url: `https://${target.host}/${target.repository}`, + sshUrl: `ssh@${target.host}:${target.repository}.git`, +}); + +/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ +const isSshRemote = (remoteUrl: string | undefined) => + remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); + +const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; + +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + const host = yield* SourceControlHost.SourceControlHost; + + const error = (operation: string, cwd: string, detail: string, cause?: unknown) => + new SourceControlProviderError({ + provider: "gitcafe", + operation, + cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const read = >( + operation: string, + cwd: string, + request: Omit, + schema: S, + ) => + api.request({ ...request, operation }).pipe( + Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)), + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + error(operation, cwd, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => + host.process + .run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json" }, + timeoutMs: 30_000, + }) + .pipe( + Effect.mapError((cause) => + error( + operation, + cwd, + (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || + "GitCafe CLI command failed.", + cause, + ), + ), + ); + + /** The repository a call names, falling back to the checkout's own GitCafe remote. */ + const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( + operation: string, + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + ) { + const named = + input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); + if (named) return named; + // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no + // remote (a repository being created) still resolves, on git.cafe. + const readRemote = host.git + .resolvePrimaryRemoteName(input.cwd) + .pipe( + Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)), + ); + const remoteUrl = + input.context?.remoteUrl ?? + (input.repository === undefined + ? yield* readRemote.pipe( + Effect.mapError((cause) => + error(operation, input.cwd, "Could not read the GitCafe remote.", cause), + ), + ) + : yield* readRemote.pipe(Effect.orElseSucceed(() => null))); + const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null; + if (input.repository === undefined) { + if (remote) return remote; + return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); + } + // A bare owner/name lives on the checkout's own GitCafe host. + const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); + if (repository !== null) + return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; + return yield* error( + operation, + input.cwd, + "A GitCafe repository is named owner/name or by its git.cafe URL.", + ); + }); + + const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) { + const match = pullUrlPattern.exec(input.reference.trim()); + const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const target: Target = + urlHost && match?.[2] + ? { host: urlHost, repository: match[2] } + : yield* resolveTarget("getChangeRequest", input); + // A pull request URL on another host is not a GitCafe pull request number. + const number = match + ? urlHost + ? match[3] + : undefined + : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; + if (number === undefined) + return yield* error( + "getChangeRequest", + input.cwd, + "A GitCafe pull request is a number or a git.cafe pull request URL.", + ); + const pull = yield* read( + "getChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls/${number}` }, + PullDetail, + ); + const owner = target.repository.split("/")[0] ?? null; + return { + provider: "gitcafe", + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + baseRefName: pull.targetBranch, + headRefName: pull.sourceBranch, + ...(pull.headOid ? { headSha: pull.headOid } : {}), + state: pull.state, + ...(pull.draft ? { isDraft: true } : {}), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + updatedAt: pull.updatedAt, + isCrossRepository: pull.sourceRepo !== null, + headRepositoryNameWithOwner: pull.sourceRepo + ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` + : target.repository, + headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner, + } satisfies ChangeRequest; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitcafe", + repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, + getChangeRequest: getPull, + listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( + function* (input) { + const target = yield* resolveTarget("listChangeRequests", input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = input.limit ?? 20; + const items: Array = []; + let after: string | null = null; + do { + const query = new URLSearchParams({ + sourceBranches: encodeBranches([branch]), + limit: String(Math.min(limit, 100)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (after !== null) query.set("after", after); + const page: typeof Pulls.Type = yield* read( + "listChangeRequests", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls?${query}` }, + Pulls, + ); + for (const pull of page.items) { + if (pull.sourceBranch !== branch || items.length >= limit) continue; + // List rows omit fork identity, so only the branch's own rows are read in full. + const detail = yield* getPull({ + cwd: input.cwd, + reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + }); + if ( + (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || detail.headRepositoryOwnerLogin === source.owner) + ) + items.push(detail); + } + after = page.items.length === 0 || page.next === after ? null : page.next; + } while (after !== null && items.length < limit); + return items; + }, + ), + createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")( + function* (input) { + const target = yield* resolveTarget("createChangeRequest", { + ...input, + ...(input.target?.repository ? { repository: input.target.repository } : {}), + }); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const sourceRepository = + source?.repository ?? + (source?.owner + ? `${source.owner}/${target.repository.split("/")[1]}` + : target.repository); + const branch = SourceControlProvider.sourceBranch(input); + yield* cafe("createChangeRequest", input.cwd, target, [ + "pr", + "create", + "--json", + "--repo", + target.repository, + "--head", + sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, + "--base", + input.target?.refName ?? input.baseRefName, + "--title", + input.title, + "--body-file", + input.bodyFile, + ]); + }, + ), + getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const target = yield* resolveTarget("getRepositoryCloneUrls", input); + yield* read( + "getRepositoryCloneUrls", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return cloneUrls(target); + }, + ), + createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { + const target = yield* resolveTarget("createRepository", input); + const [owner = "", name = ""] = target.repository.split("/"); + const output = yield* cafe("createRepository", input.cwd, target, [ + "repo", + "create", + name, + "--org", + owner, + "--visibility", + input.visibility, + "--json", + ]); + const created = yield* decodeCreatedRepository(output.stdout).pipe( + Effect.mapError((cause) => + error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), + ), + ); + // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is + // still running, so wait for it to settle the way `cafe repo create --clone` does. + const settled = (state: string) => state === "complete" || state === "blocked"; + const pending = created.data.resource; + const resource = settled(pending.state) + ? pending + : yield* read( + "createRepository", + input.cwd, + { + host: target.host, + path: `/orgs/${pending.owner}/admissions/${pending.repoId}`, + }, + RepositoryAdmission, + ).pipe( + Effect.repeat({ + until: (admission) => settled(admission.state), + schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL), + }), + Effect.timeoutOption(ADMISSION_TIMEOUT), + Effect.map(Option.getOrElse(() => pending)), + ); + // Anything short of complete is not yet usable. + if (resource.state !== "complete") + return yield* error( + "createRepository", + input.cwd, + `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`, + ); + return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` }); + }), + getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) { + const target = yield* resolveTarget("getDefaultBranch", input); + const repository = yield* read( + "getDefaultBranch", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return repository.defaultBranch; + }), + // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const pull = yield* getPull(input); + const pullHost = new URL(pull.url).host; + const sourceRepository = pull.headRepositoryNameWithOwner; + if (!sourceRepository) + return yield* error( + "checkoutChangeRequest", + input.cwd, + "GitCafe did not name the pull request's source repository.", + ); + const contextRemote = input.context + ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl) + : null; + const git = host.git; + yield* Effect.gen(function* () { + const remoteName = + input.context && + contextRemote?.repository === sourceRepository && + contextRemote.host === pullHost + ? input.context.remoteName + : yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "gitcafe", + // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. + url: isSshRemote(input.context?.remoteUrl) + ? `ssh@${pullHost}:${sourceRepository}.git` + : `https://${pullHost}/${sourceRepository}.git`, + }); + const localBranch = pull.isCrossRepository + ? `pr-${pull.number}/${pull.headRefName}` + : pull.headRefName; + const branches = yield* git.listLocalBranchNames(input.cwd); + const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName }; + if (input.force !== true && !branches.includes(localBranch)) + yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); + else yield* git.fetchRemoteTrackingBranch(remoteBranch); + // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. + if (input.force === true) + yield* git.execute({ + operation: "GitCafeSourceControlProvider.checkoutChangeRequest", + cwd: input.cwd, + args: [ + "checkout", + "-B", + localBranch, + `refs/remotes/${remoteName}/${pull.headRefName}`, + "--", + ], + }); + yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + error( + "checkoutChangeRequest", + input.cwd, + "Could not check out the GitCafe pull request.", + cause, + ), + ), + ); + }, + ), + }); +}); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts new file mode 100644 index 0000000000000..467614c2ec1c5 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull + * requests through GitCafe's REST API on git.cafe and staging.git.cafe. + * + * @module source-control-gitcafe/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitcafe", + make: Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + pullRequests: GitCafePullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts new file mode 100644 index 0000000000000..a828a99f6ff14 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -0,0 +1,46 @@ +/** + * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): + * + * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` — the only hosts GitCafe lives on. + * - `isGitCafeHost(host: string): boolean` — case-insensitive membership in `GITCAFE_HOSTS`. + * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` + * — the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. + */ +import { assert, describe, it } from "@effect/vitest"; + +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +describe("gitCafeHosts", () => { + it("knows exactly the production and staging hosts", () => { + assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]); + assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe")); + assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe")); + for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) { + assert.isFalse(GitCafeHosts.isGitCafeHost(host), host); + } + }); + + it.each([ + ["https://git.cafe/team/project.git", "git.cafe"], + ["https://git.cafe/team/project", "git.cafe"], + ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ["git@staging.git.cafe:team/project.git", "staging.git.cafe"], + ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ])("reads the host and repository of %s", (url, host) => { + assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { + host, + repository: "team/project", + }); + }); + + it.each([ + "https://github.com/team/project.git", + "https://evil.git.cafe/team/project.git", + "https://git.cafe/team/nested/project.git", + "https://git.cafe/team", + "team/project", + ])("is not a GitCafe repository: %s", (url) => { + assert.isNull(GitCafeHosts.parseGitCafeRemote(url)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts new file mode 100644 index 0000000000000..a04b759b2d5f4 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -0,0 +1,49 @@ +/** + * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no + * self-hosted installs, so the list is fixed rather than discovered. + * + * @module source-control-gitcafe/server/gitCafeHosts + */ + +export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; +export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; + +/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ +export function gitCafeHost(host: string): GitCafeHost | null { + const normalized = host.trim().toLowerCase(); + return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; +} + +export function isGitCafeHost(host: string): boolean { + return gitCafeHost(host) !== null; +} + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const REMOTE_PATTERNS = [ + /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^[^@/:]+@([^/:]+):(.+)$/u, +]; + +/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ +export function gitCafeRepositoryPath(path: string): string | null { + const repository = path + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + return REPOSITORY.test(repository) ? repository : null; +} + +/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ +export function parseGitCafeRemote( + url: string, +): { readonly host: GitCafeHost; readonly repository: string } | null { + for (const pattern of REMOTE_PATTERNS) { + const match = pattern.exec(url.trim()); + if (!match?.[1] || !match[2]) continue; + const host = gitCafeHost(match[1]); + const repository = gitCafeRepositoryPath(match[2]); + return host && repository ? { host, repository } : null; + } + return null; +} diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts new file mode 100644 index 0000000000000..41d578cdf1e0e --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -0,0 +1,547 @@ +/** + * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral + * `Provider*` shapes. Pass 2 creates these exports: + * + * Schemas (REST bodies as GitCafe sends them): + * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, + * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), + * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` + * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), + * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, + * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), + * `GitCafeChecks` (`{ items, next }`). + * + * Conversions (all take the repository host, so staging stays on staging): + * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` + * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`. + * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): + * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve` + * on review threads, from each comment's `capabilities`. + * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` + * - `toDiff(diff): ProviderDiffSlice` + * - `toChecks(checks): ReadonlyArray` + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts"; + +const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull); +const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail); +const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments); +const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews); +const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits); +const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions); +const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope); +const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff); +const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks); + +const timestamp = "2026-09-12T12:00:00Z"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: true, + sourceBranch: "feature", + targetBranch: "main", + headOid: "abcdef", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 1, +}; +const detail = { + ...pull, + draft: false, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const lineComment = { + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: "abcdef", + resolvedAt: null, +}; +const capabilities = (overrides: Record = {}) => ({ + edit: false, + hide: false, + unhide: false, + delete: false, + resolve: false, + unresolve: false, + ...overrides, +}); +const noReviews = decodeReviews({ items: [], next: null }); +const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null }); +const activity = ( + comments: ReadonlyArray>, + extra: { readonly reactions?: unknown; readonly host?: string } = {}, +) => + GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: comments, next: null }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: extra.host ?? "git.cafe", + ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }), + }); + +describe("toChangeRequest", () => { + it("reads draft state, the repository host's URLs and absolute avatars", () => { + const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(row.state, "open"); + assert.isTrue(row.isDraft); + assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.deepStrictEqual(row.author, { + login: "alice", + name: null, + avatarUrl: "https://staging.git.cafe/avatars/one", + }); + }); + + it("names linked GitHub reviewers by both logins and drops unavailable ones", () => { + const row = GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null }, + reviewers: [ + { actor }, + { + actor: { + kind: "github", + actorId: "act_github", + login: "upstream", + avatarUrl: null, + linkedProfile: { handle: "linked-local" }, + }, + }, + { actor: { kind: "unavailable", actorId: "act_deleted" } }, + ], + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.strictEqual(row.author?.login, "remote-user"); + assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); + }); + + it("requires capabilities on a detail response", () => { + assert.throws(() => decodePullDetail(pull)); + }); +}); + +describe("toViewerPermissions", () => { + const permissions = (overrides: Record) => + GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides })); + + it("lets an editor rewrite the title and description, and nobody else", () => { + assert.isTrue(permissions({}).editChangeRequest); + assert.isFalse( + permissions({ + capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true }, + }).editChangeRequest, + ); + }); + + it("offers lifecycle actions by state and never a standalone branch update", () => { + assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]); + assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]); + assert.deepStrictEqual(permissions({ state: "merged" }).actions, []); + assert.isTrue(permissions({}).stackRebase); + }); + + it("keeps a locked conversation's composer for moderators only", () => { + const locked = (moderate: boolean) => + permissions({ + lockedAt: timestamp, + capabilities: { comment: true, review: false, merge: false, edit: false, moderate }, + }); + assert.isFalse(locked(false).comment); + assert.isFalse(locked(false).resolve); + assert.isTrue(locked(true).comment); + assert.deepStrictEqual(locked(true).verdicts, []); + }); + + it("offers no composer to a viewer GitCafe won't let comment", () => { + const silent = permissions({ + capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true }, + }); + assert.isFalse(silent.comment); + assert.isFalse(silent.resolve); + }); +}); + +describe("toActivity", () => { + it("groups line comments by their persisted thread id and marks a paged list truncated", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ + items: [ + { ...lineComment, id: "reply", threadId: "thread-one" }, + { ...lineComment, id: "other", threadId: "thread-two" }, + ], + next: "next", + }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: "git.cafe", + }); + assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [ + "thread-one", + "thread-two", + ]); + assert.isTrue(result.commentsTruncated); + }); + + it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => { + const result = activity([ + { ...lineComment, id: "root", threadId: "root", resolvedAt }, + { ...lineComment, id: "reply", threadId: "root", resolvedAt: null }, + ]); + assert.strictEqual(result.reviewThreads.length, 1); + assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null); + assert.strictEqual(result.reviewThreads[0]?.comments.length, 2); + }); + + it("carries per-remark edit permission onto both copies of a remark", () => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + capabilities: capabilities({ edit: false, resolve: true }), + }, + { + ...lineComment, + id: "reply", + threadId: "root", + capabilities: capabilities({ edit: true }), + }, + { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null }, + ]); + const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; + assert.strictEqual(canEdit("root"), false); + assert.strictEqual(canEdit("reply"), true); + // A host that says nothing about a remark leaves the page to guess. + assert.isUndefined(canEdit("silent")); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), + [ + ["root", false], + ["reply", true], + ], + ); + }); + + it.each([ + [null, { resolve: true, unresolve: false }, true], + [null, { resolve: false, unresolve: true }, false], + [timestamp, { resolve: true, unresolve: false }, false], + [timestamp, { resolve: false, unresolve: true }, true], + ] as const)( + "reads canResolve for a thread resolved at %s with %o as %s", + (resolvedAt, allowed, canResolve) => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + resolvedAt, + capabilities: capabilities(allowed), + }, + ]); + assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve); + }, + ); + + it("leaves canResolve absent when the root says nothing", () => { + const result = activity([{ ...lineComment, id: "root", threadId: "root" }]); + assert.isUndefined(result.reviewThreads[0]?.canResolve); + }); + + it("maps review verdicts, dismissals and commits", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: [], next: null }), + reviews: decodeReviews({ + items: [ + { + id: "r1", + author: actor, + body: "LGTM", + verdict: "approve", + dismissedAt: null, + createdAt: timestamp, + }, + { + id: "r2", + author: actor, + body: null, + verdict: "request_changes", + dismissedAt: timestamp, + createdAt: "2026-09-12T13:00:00Z", + }, + ], + next: null, + }), + commits: decodeCommits({ + items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }], + headOid: "abcdef", + truncated: false, + next: null, + }), + host: "git.cafe", + }); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["r1", "review", "approved"], + ["r2", "review", "dismissed"], + ], + ); + assert.deepStrictEqual( + result.commits.map((commit) => commit.oid), + ["abcdef"], + ); + }); + + it("maps supported reactions to the pull request and to matching comments in both views", () => { + const result = activity( + [ + { ...lineComment, id: "comment-a", threadId: "comment-a" }, + { ...lineComment, id: "comment-b", threadId: "comment-a" }, + ], + { + reactions: { + next: null, + items: [ + { + subject: { kind: "pull_request_comment", id: "comment-b" }, + emoji: { kind: "unicode", value: "👍" }, + count: 3, + viewerReactionId: "reaction-viewer", + reactors: [actor, { kind: "unavailable", actorId: "deleted" }], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + // Neither a custom emoji nor one outside the shared set is shown under another name. + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "custom", id: "party-parrot" }, + count: 2, + viewerReactionId: null, + reactors: [actor], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🔥" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + ], + }, + }, + ); + const thumbsUp = { + content: "thumbs-up", + count: 3, + actors: ["alice"], + viewerHasReacted: true, + } as const; + assert.deepStrictEqual(result.reactions, [ + { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false }, + ]); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + }); +}); + +describe("toStack", () => { + it("orders members by position and reports heads only where GitCafe gives them", () => { + const { stack } = decodeStackEnvelope({ + stack: { + id: "stack-one", + number: 3, + revision: 1, + landingBase: "main", + members: [ + { + pullRequestNumber: 7, + title: "Top", + state: "open", + draft: true, + sourceBranch: "feature", + position: 2, + }, + { + pullRequestNumber: 6, + title: "Base", + state: "merged", + draft: false, + sourceBranch: "base", + headOid: "0123456789abcdef0123456789abcdef01234567", + position: 1, + }, + ], + }, + }); + assert.isNotNull(stack); + const result = GitCafePullRequestJson.toStack(stack!, { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3"); + assert.strictEqual(result.base, "main"); + assert.deepStrictEqual(result.layers, [ + { + number: 6, + title: "Base", + headBranch: "base", + state: "merged", + isDraft: false, + headSha: "0123456789abcdef0123456789abcdef01234567", + }, + { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true }, + ]); + }); + + it("reads an unstacked pull request as no stack", () => { + assert.isNull(decodeStackEnvelope({ stack: null }).stack); + }); +}); + +describe("toDiff", () => { + it("converts numeric hunk coordinates into a unified patch", () => { + const diff = decodeDiff({ + items: [ + { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + truncated: false, + }); + assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n"); + }); + + it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => { + const diff = decodeDiff({ + items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }], + truncated: false, + }); + const result = GitCafePullRequestJson.toDiff(diff); + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.omittedFileStats, [ + { path: "large.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("quotes renamed and binary paths", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "new image.png", + oldPath: "old image.png", + status: "renamed", + binary: true, + hunks: [], + }, + ], + truncated: false, + }), + ); + // Git quotes a name for the bytes a header can't carry, not for a space. + assert.include(patch, "rename from old image.png"); + assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); + }); + + it("C-escapes a name whose newline or tab would break the patch headers", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }], + truncated: false, + }), + ); + assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"'); + assert.notInclude(patch, "two\nlines"); + }); +}); + +describe("toChecks", () => { + it("maps GitCafe's status and conclusion onto the shared check states", () => { + const check = (status: string, conclusion: string | null) => ({ + name: `${status}-${conclusion}`, + status, + conclusion, + summary: null, + detailsUrl: null, + }); + const checks = GitCafePullRequestJson.toChecks( + decodeChecks({ + items: [ + check("queued", null), + check("in_progress", null), + check("completed", "success"), + check("completed", "action_required"), + check("completed", "timed_out"), + ], + next: null, + }), + ); + assert.deepStrictEqual( + checks.map((item) => item.status), + ["pending", "pending", "success", "action-required", "failure"], + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts new file mode 100644 index 0000000000000..453083a7a6b43 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -0,0 +1,578 @@ +/** + * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every + * conversion takes the repository's host, so a staging repository's links stay on staging. + * + * @module source-control-gitcafe/server/gitCafePullRequestJson + */ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestComment, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewThread, + type PullRequestViewerPermissions, +} from "@t3tools/contracts"; +import type { + ProviderChangeRequest, + ProviderChangeRequestActivity, + ProviderChangeRequestStack, + ProviderDiffSlice, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const Actor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("local"), + actorId: Schema.String, + handle: TrimmedNonEmptyString, + displayName: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("github"), + actorId: Schema.String, + login: TrimmedNonEmptyString, + avatarUrl: Schema.NullOr(Schema.String), + linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), + }), + Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), +]); +type Actor = typeof Actor.Type; + +const PullState = Schema.Literals(["open", "closed", "merged"]); + +export const GitCafePull = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + targetBranch: Schema.String, + headOid: Schema.NullOr(Schema.String), + author: Actor, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + version: NonNegativeInt, + reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), + labels: Schema.optional( + Schema.Array( + Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), + ), + ), +}); +export type GitCafePull = typeof GitCafePull.Type; + +export const GitCafePulls = Schema.Struct({ + items: Schema.Array(GitCafePull), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafePullDetail = Schema.Struct({ + ...GitCafePull.fields, + description: Schema.NullOr(Schema.String), + closedAt: Schema.NullOr(IsoDateTime), + mergedAt: Schema.NullOr(IsoDateTime), + sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), + /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ + observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), + mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), + lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + capabilities: Schema.Struct({ + comment: Schema.Boolean, + review: Schema.Boolean, + merge: Schema.Boolean, + edit: Schema.Boolean, + moderate: Schema.Boolean, + }), +}); +export type GitCafePullDetail = typeof GitCafePullDetail.Type; + +const CommentCapabilities = Schema.Struct({ + edit: Schema.Boolean, + hide: Schema.Boolean, + unhide: Schema.Boolean, + delete: Schema.Boolean, + resolve: Schema.Boolean, + unresolve: Schema.Boolean, +}); + +const GitCafeComment = Schema.Struct({ + id: TrimmedNonEmptyString, + threadId: TrimmedNonEmptyString, + /** What a resolution or rewrite fences on; absent from older answers. */ + version: Schema.optional(NonNegativeInt), + author: Actor, + body: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + path: Schema.NullOr(Schema.String), + // The API allows any integer here; only a positive line anchors a review thread. + line: Schema.NullOr(Schema.Int), + side: Schema.NullOr(Schema.Literals(["left", "right"])), + commitOid: Schema.NullOr(Schema.String), + resolvedAt: Schema.NullOr(IsoDateTime), + capabilities: Schema.optional(CommentCapabilities), +}); +type GitCafeComment = typeof GitCafeComment.Type; + +export const GitCafeComments = Schema.Struct({ + items: Schema.Array(GitCafeComment), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeReviews = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + author: Actor, + body: Schema.NullOr(Schema.String), + verdict: Schema.Literals(["approve", "request_changes", "comment"]), + dismissedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeCommits = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), + ), + truncated: Schema.Boolean, + next: Schema.NullOr(Schema.String), + headOid: Schema.String, +}); + +export const GitCafeReactions = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + subject: Schema.Struct({ + kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), + id: TrimmedNonEmptyString, + }), + emoji: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), + ]), + count: PositiveInt, + viewerReactionId: Schema.NullOr(Schema.String), + reactors: Schema.Array(Actor), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +const GitCafeStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + /** What a land or restack fences on. */ + revision: PositiveInt, + landingBase: Schema.String, + members: Schema.Array( + Schema.Struct({ + pullRequestNumber: PositiveInt, + title: Schema.String, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + headOid: Schema.optional(Schema.String), + position: PositiveInt, + }), + ), +}); +export type GitCafeStack = typeof GitCafeStack.Type; + +export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); + +const GitCafeDiffFile = Schema.Struct({ + path: TrimmedNonEmptyString, + oldPath: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional( + Schema.Literals([ + "added", + "deleted", + "modified", + "typeChanged", + "conflicted", + "renamed", + "copied", + ]), + ), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + binary: Schema.optional(Schema.Boolean), + tooLarge: Schema.optional(Schema.Boolean), + hunksOmitted: Schema.optional(Schema.Boolean), + isSubmodule: Schema.optional(Schema.Boolean), + oldOid: Schema.optional(Schema.String), + newOid: Schema.optional(Schema.String), + // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + hunks: Schema.optional( + Schema.Array( + Schema.Struct({ + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, + lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), + }), + ), + ), +}); +export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; +export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); + +export const GitCafeDiff = Schema.Struct({ + items: GitCafeDiffFiles, + truncated: Schema.optional(Schema.Boolean), +}); + +export const GitCafeChecks = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + status: Schema.Literals(["queued", "in_progress", "completed"]), + conclusion: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + detailsUrl: Schema.NullOr(Schema.String), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +interface RepositoryOnHost { + readonly repository: string; + readonly host: string; +} + +function toActor(actor: Actor, host: string): PullRequestActor | null { + if (actor.kind === "unavailable") return null; + return { + login: actor.kind === "local" ? actor.handle : actor.login, + name: actor.kind === "local" ? actor.displayName : null, + // GitCafe serves its own avatars by path, so a relative one is read against the host. + avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, + }; +} + +/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ +export function toReviewers( + pull: Pick, + host: string, +): ReadonlyArray { + return (pull.reviewers ?? []).flatMap(({ actor }) => { + const reviewer = toActor(actor, host); + return reviewer === null ? [] : [reviewer]; + }); +} + +/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ +function reviewerLogins(actor: Actor, host: string): ReadonlyArray { + const reviewer = toActor(actor, host); + if (reviewer === null) return []; + return actor.kind === "github" && actor.linkedProfile + ? [reviewer.login, actor.linkedProfile.handle] + : [reviewer.login]; +} + +export function toChangeRequest( + pull: GitCafePull, + target: RepositoryOnHost, +): ProviderChangeRequest { + return { + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + author: toActor(pull.author, target.host), + headBranch: pull.sourceBranch, + baseBranch: pull.targetBranch, + state: pull.state, + isDraft: pull.draft, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt, + reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => + reviewerLogins(actor, target.host), + ), + labels: pull.labels ?? [], + }; +} + +/** What GitCafe's own capabilities let this viewer do with the pull request. */ +export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { + const { comment, edit, review, merge, moderate } = detail.capabilities; + // A locked conversation keeps its composer for moderators only. + const open = comment && (detail.lockedAt == null || moderate); + return { + actions: [ + // A merged pull request is settled; a closed one can only be reopened. + ...(edit && detail.state === "open" + ? ([detail.draft ? "ready" : "draft", "close"] as const) + : edit && detail.state === "closed" + ? (["reopen"] as const) + : []), + ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), + ], + stackRebase: merge, + comment: open, + // Each thread's root narrows this further with its own `canResolve`. + resolve: open, + verdicts: review ? ["approve", "request-changes", "comment"] : [], + requestReviewers: false, + editChangeRequest: edit, + // GitCafe's only branch update moves a whole stack. + updateMethods: [], + }; +} + +const REACTIONS: Readonly> = { + "👍": "thumbs-up", + "👎": "thumbs-down", + "😄": "laugh", + "🎉": "hooray", + "😕": "confused", + "❤": "heart", + "🚀": "rocket", + "👀": "eyes", +}; + +/** The emoji GitCafe takes for one of the shared reactions. */ +export const REACTION_EMOJI: Readonly> = { + "thumbs-up": "👍", + "thumbs-down": "👎", + laugh: "😄", + hooray: "🎉", + confused: "😕", + heart: "❤️", + rocket: "🚀", + eyes: "👀", +}; + +/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ +export function reactionContent(emoji: string): PullRequestReactionContent | undefined { + return REACTIONS[emoji.replaceAll("\uFE0F", "")]; +} + +function toReactions(data: typeof GitCafeReactions.Type, host: string) { + const reactions: Array = []; + const byComment = new Map>(); + for (const item of data.items) { + const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; + if (content === undefined) continue; + const reaction: PullRequestReaction = { + content, + count: item.count, + actors: item.reactors.flatMap((actor) => { + const reactor = toActor(actor, host); + return reactor === null ? [] : [reactor.login]; + }), + viewerHasReacted: item.viewerReactionId !== null, + }; + if (item.subject.kind === "pull_request") reactions.push(reaction); + else if (item.subject.kind === "pull_request_comment") + byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); + } + return { reactions, byComment }; +} + +const REVIEW_STATES = { + approve: "approved", + request_changes: "changes_requested", + comment: "commented", +} as const; + +/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ +export function toActivity(input: { + readonly comments: typeof GitCafeComments.Type; + readonly reviews: typeof GitCafeReviews.Type; + readonly commits: typeof GitCafeCommits.Type; + readonly reactions?: typeof GitCafeReactions.Type; + readonly headOid?: string; + readonly host: string; +}): ProviderChangeRequestActivity { + const { host } = input; + const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); + const remark = (comment: GitCafeComment) => ({ + id: comment.id, + author: toActor(comment.author, host), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: null, + ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), + ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), + }); + // Stable, so remarks written in the same instant keep GitCafe's own order. + const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const groups = new Map>(); + for (const comment of ordered) { + if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) + continue; + groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); + } + const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { + const first = group[0]!; + const latest = group.at(-1)!; + const root = group.find((comment) => comment.id === comment.threadId); + return { + id: first.threadId, + path: first.path!, + line: first.line, + side: first.side!, + isResolved: root?.resolvedAt != null, + isOutdated: + input.headOid !== undefined && + latest.commitOid !== null && + latest.commitOid !== input.headOid, + comments: group.map(remark), + ...(root?.capabilities === undefined + ? {} + : { + canResolve: + root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, + }), + }; + }); + const comments: Array = [ + ...ordered.map((comment) => ({ + ...remark(comment), + kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), + path: comment.path, + reviewState: null, + })), + ...input.reviews.items.map((review) => ({ + id: review.id, + kind: "review" as const, + author: toActor(review.author, host), + body: review.body ?? "", + createdAt: review.createdAt, + url: null, + path: null, + reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], + })), + ]; + return { + comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), + commentCount: input.comments.items.length + input.reviews.items.length, + commentsTruncated: input.comments.next !== null || input.reviews.next !== null, + reviewThreads, + commits: input.commits.items.map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.summary, + committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), + })), + ...(reactions === undefined ? {} : { reactions: reactions.reactions }), + }; +} + +export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { + return { + id: stack.id, + number: stack.number, + url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, + base: stack.landingBase, + layers: stack.members + .toSorted((a, b) => a.position - b.position) + .map((member) => ({ + number: member.pullRequestNumber, + title: member.title, + headBranch: member.sourceBranch, + state: member.state, + isDraft: member.draft, + ...(member.headOid === undefined ? {} : { headSha: member.headOid }), + })), + }; +} + +/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ +export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { + const chunks: Array = []; + const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; + for (const file of diff.items) { + const oldPath = file.oldPath ?? file.path; + const a = quoteGitPatchPath(`a/${oldPath}`); + const b = quoteGitPatchPath(`b/${file.path}`); + const before = file.status === "added" ? "/dev/null" : a; + const after = file.status === "deleted" ? "/dev/null" : b; + const mode = file.isSubmodule ? "160000" : "100644"; + const lines = [`diff --git ${a} ${b}`]; + if (file.status === "added") lines.push(`new file mode ${mode}`); + if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); + if (file.status === "renamed" || file.status === "copied") { + const action = file.status === "renamed" ? "rename" : "copy"; + lines.push( + `${action} from ${quoteGitPatchPath(oldPath)}`, + `${action} to ${quoteGitPatchPath(file.path)}`, + ); + } + if (file.binary) { + lines.push(`Binary files ${before} and ${after} differ`); + } else if (file.tooLarge || file.hunksOmitted) { + omittedFileStats.push({ + path: file.path, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + }); + } else if (file.isSubmodule) { + const hasOld = file.status !== "added" && file.oldOid !== undefined; + const hasNew = file.status !== "deleted" && file.newOid !== undefined; + lines.push(`--- ${before}`, `+++ ${after}`); + lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); + if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); + if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); + } else { + const hunks = file.hunks ?? []; + if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); + for (const hunk of hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + // GitCafe marks a missing final newline with libgit2's own origins. + if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); + else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + } + } + } + chunks.push(lines.join("\n")); + } + return { + patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, + truncated: diff.truncated === true || omittedFileStats.length > 0, + nextCursor: null, + ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), + }; +} + +function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { + if (check.status !== "completed") return "pending"; + switch (check.conclusion) { + case "success": + return "success"; + case "neutral": + return "neutral"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "action_required": + return "action-required"; + default: + return "failure"; + } +} + +export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + return checks.items.map((check) => ({ + name: check.name, + status: checkStatus(check), + description: check.summary, + url: check.detailsUrl, + })); +} diff --git a/packages/source-control-gitcafe/tsconfig.json b/packages/source-control-gitcafe/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/source-control-gitcafe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31e..249f45a453cc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../../packages/source-control-gitcafe '@t3tools/source-control-github': specifier: workspace:* version: link:../../packages/source-control-github @@ -1643,6 +1646,37 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitcafe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-github: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7bc6da4817163..272026efd71e5 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", From 500cb4266f2bb290aba2c2038295500ff7b25188 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:21:14 -0700 Subject: [PATCH 307/680] fix(web): add provider wizard no longer shifts sideways while it grows (#17292) --- apps/web/src/components/ui/wizard.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 695d4b047ff97..be2b10ab761ce 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -4,6 +4,7 @@ import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; +import { ScrollArea } from "./scroll-area"; /** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ export function WizardPopup({ @@ -14,14 +15,10 @@ export function WizardPopup({ readonly size?: "default" | "wide"; }) { return ( - -

    + + +
    {children}
    +
    ); } From 42f71dfe0768ee768283d50140e7d360a635ee28 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:23:15 -0700 Subject: [PATCH 308/680] fix(web): PR search keeps the caret where you type (#17675) --- .../components/pullRequest/PullRequestListFilters.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cbb0e01b9da38..1fb90f0b30523 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -104,6 +104,7 @@ export function PullRequestSearchInput({ busy?: boolean; onChange: (value: string) => void; }) { + const [draft, setDraft] = useState(null); return ( @@ -111,8 +112,13 @@ export function PullRequestSearchInput({ onChange(event.currentTarget.value)} + value={draft ?? value} + onFocus={() => setDraft(value)} + onBlur={() => setDraft(null)} + onChange={(event) => { + setDraft(event.currentTarget.value); + onChange(event.currentTarget.value); + }} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" /> From 6d5ea190a4a54b01dfe6500e032526839e3258d1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:24:11 -0700 Subject: [PATCH 309/680] fix(server): thread PR badges catch up when another environment reads the PR (#17729) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../pullRequest/PullRequestService.test.ts | 42 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 41 ++++++++++++++++++ apps/server/src/ws.ts | 1 + .../src/state/pullRequestRouting.ts | 22 ++++++++++ .../src/state/pullRequests.test.ts | 11 +++++ packages/contracts/src/pullRequest.ts | 11 +++++ packages/contracts/src/rpc.ts | 9 ++++ 9 files changed, 139 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd1479..11756026f6d1b 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -135,6 +135,7 @@ export const RPC_REQUIRED_SCOPES = { // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope, // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792ab..26a4b007b8b66 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -118,6 +118,7 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsSetReaction]: "pull-requests", [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsReportState]: "pull-requests", [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c74faa92f06dc..117fd3f69a329 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4972,6 +4972,48 @@ it.effect("announces state a detail read sees first or newly", () => }), ); +it.effect("announces a reported state without trusting it", () => + Effect.gen(function* () { + let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), + }), + ], + }); + const announced: Array = []; + yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => + Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), + ).pipe(Effect.forkChild({ startImmediately: true })); + const note = (state: "open" | "closed" | "merged") => + service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); + const readDetail = Effect.gen(function* () { + yield* service.invalidate({ reference }); + yield* service.detail(reference); + yield* Effect.yieldNow; + }); + + yield* readDetail; + yield* note("closed"); + yield* note("closed"); + assert.strictEqual(announced.length, 2); + yield* TestClock.adjust("5 minutes"); + yield* note("closed"); + assert.strictEqual(announced.length, 3); + + yield* note("open"); + assert.strictEqual(announced.length, 4); + + yield* note("merged"); + detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; + yield* readDetail; + assert.strictEqual(announced.length, 6); + }), +); + it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b44d713c31588..51377f1b33b19 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -42,6 +42,7 @@ import { type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, + type PullRequestReportStateInput, type PullRequestListEntry, type PullRequestListFilters, type PullRequestListInput, @@ -160,6 +161,8 @@ const detailTimeToLive = (state: PullRequestState | undefined) => * page and a watched pull request's change reaches its watch. */ const CHECKS_CACHE_TTL = Duration.seconds(15); +/** How long a repeated reported state is skipped, so a wrong one cannot hide a real change for long. */ +const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000; const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); @@ -244,6 +247,13 @@ export class PullRequestService extends Context.Service< never, Scope.Scope >; + /** + * The state a read routed to another environment saw. A new one reaches + * `subscribeStateChanges`, so the host is asked again; the report itself is never trusted. + */ + readonly reportState: ( + input: PullRequestReportStateInput, + ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; @@ -3115,6 +3125,36 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); + // The last state each routed read reported, kept apart from `detailStates`: an unverified report + // must never outrank a reading this environment made itself. + const reportedStates = new Map< + string, + { readonly state: PullRequestState; readonly atMs: number } + >(); + const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( + Effect.flatMap(([ref, nowMs]) => + Effect.suspend(() => { + const scope = refScope(ref); + const reported = reportedStates.get(scope); + if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + return Effect.void; + reportedStates.delete(scope); + if (reportedStates.size >= REF_EPOCH_CAPACITY) { + const oldest = reportedStates.keys().next().value; + if (oldest !== undefined) reportedStates.delete(oldest); + } + reportedStates.set(scope, { state, atMs: nowMs }); + return ref.host === undefined + ? Effect.void + : PubSub.publish(stateChanges, { + host: ref.host, + repository: ref.repository, + number: ref.number, + }); + }), + ), + ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); // Record the summary from a host or cache read, not the stale value @@ -3432,6 +3472,7 @@ export const make = Effect.gen(function* () { subscribeStateChanges: PubSub.subscribe(stateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), + reportState, subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( Stream.filter((revision) => revision > 0), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b5..c1a01b963a508 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2518,6 +2518,7 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => withPullRequestViewer(input, pullRequests.setReaction(input)), + [WS_METHODS.pullRequestsReportState]: (input) => pullRequests.reportState(input), [WS_METHODS.pullRequestsInvalidate]: (input) => pullRequests.invalidate(input, { notifyReaders: true }).pipe( // A reader asking for fresh host state also wants the thread badges it feeds to diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index f8781837c3619..2233dd8a6722a 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -2,6 +2,7 @@ import { WS_METHODS, PullRequestRef, PullRequestInvalidateInput, + PullRequestState, type EnvironmentId, type PullRequestRoutingResult, type PullRequestRoutingIdentityResult, @@ -57,6 +58,7 @@ const writes = new Set([ WS_METHODS.pullRequestsSetLabels, ]); const isRef = Schema.is(PullRequestRef); +const hasState = Schema.is(Schema.Struct({ state: PullRequestState })); const isInvalidation = Schema.is(PullRequestInvalidateInput); const readTimeout = (environmentId: EnvironmentId) => Effect.timeoutOrElse({ @@ -208,6 +210,21 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi ); }); +/** + * The origin owns the threads linked to this pull request, but a routed read never reaches it. + * Tell it which state the read saw so it checks again. Best effort, and never holds up the read. + */ +const reportState = ( + { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, + state: PullRequestState, +) => + request(WS_METHODS.pullRequestsReportState, { reference, state }).pipe( + Effect.timeoutOption("5 seconds"), + Effect.ignore, + Effect.forkDetach, + Effect.asVoid, + ); + /** Credentials stay on their environments. Only a verified host and account cross the wire. */ export function createPullRequestRouter() { const routedRequest = Effect.fn("PullRequestRouting.request")(function* < @@ -356,6 +373,11 @@ export function createPullRequestRouter() { ? guardedSource : registry.run(id, request(tag, routedInput)) ).pipe( + Effect.tap((result) => + id !== origin.target.environmentId && hasState(result) + ? reportState(ref, result.state) + : Effect.void, + ), Effect.tap(() => Effect.sync(() => { const entry = used.get(refKey) ?? { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 8762e9edb3999..75b76d8c3f4ef 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -995,6 +995,7 @@ it.effect.each( it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( Effect.gen(function* () { + const noted = yield* Deferred.make(); const clientFor = (local: boolean) => ({ [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => @@ -1013,8 +1014,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "local project", workspaceRoot: "/Users/local/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", }) : Effect.never, + [WS_METHODS.pullRequestsReportState]: (input: unknown) => + Deferred.succeed(noted, input).pipe(Effect.asVoid), }) as unknown as WsRpcProtocolClient; const { environmentRegistry, supervisor } = yield* makeTestRuntime( clientFor(false), @@ -1035,6 +1040,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "source project", workspaceRoot: "/srv/source/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", + }); + expect(yield* Deferred.await(noted)).toEqual({ + reference: { projectId: "project-1", repository: "acme/web", number: 7 }, + state: "closed", }); }), ), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1efbc1808d143..82e5dc1cd15f4 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -849,6 +849,17 @@ export const PullRequestInvalidateInput = Schema.Struct({ }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; +/** + * The state a read routed to another environment saw, reported to the environment the read was + * for so its thread links can catch up. A hint only: that environment confirms with the host + * before writing anything. + */ +export const PullRequestReportStateInput = Schema.Struct({ + reference: PullRequestRef, + state: PullRequestState, +}); +export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; + export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa0..289b7041be16e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -171,6 +171,7 @@ import { PullRequestDiffFileContentsResult, PullRequestFilesViewedResult, PullRequestInvalidateInput, + PullRequestReportStateInput, PullRequestListInput, PullRequestListResult, PullRequestListStatsInput, @@ -543,6 +544,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsReportState: "pullRequests.reportState", pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", @@ -1087,6 +1089,12 @@ const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, error: PullRequestRpcError, }); +const WsPullRequestsReportStateRpc = Rpc.make(WS_METHODS.pullRequestsReportState, { + payload: PullRequestReportStateInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { payload: Schema.Struct({}), success: NonNegativeInt, @@ -1916,6 +1924,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsReportStateRpc, WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, From bd2346eda2e2c380d1844869c7fd16c279d2190f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:36:04 -0700 Subject: [PATCH 310/680] fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 89 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 13 +++ packages/contracts/src/editor.ts | 13 +++ 3 files changed, 115 insertions(+) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6df4ea16db887..d16391a3ae700 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +const launchWindowsShimEditor = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + let spawned: ChildProcess.StandardCommand | undefined; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + return { exit, spawned }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect.each([ + ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"], + ["LF", "C:\\workspace\\file.ts\ncalc.exe"], + ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'], +] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor(cwd); + assert.equal(spawned, undefined); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherUnsupportedTargetError, + ); + } + }), +); + +it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor( + "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7", + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.ok(spawned); + assert.equal(spawned.options.shell, true); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"', + ]); + }), +); + +it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const zed = path.join(binDir, "zed"); + yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); + yield* fileSystem.chmod(zed, 0o755); + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir, HOME: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 74e95b9ddcc47..8ab130b01e38e 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -14,6 +14,7 @@ import { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, @@ -53,6 +54,7 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { @@ -74,6 +76,14 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +/** + * Windows command shims (`code.cmd`) forward arguments through `%*`, which + * cmd.exe parses a second time after the first escaping layer is gone: a + * line break ends the command there, and a double quote closes the quoting + * that keeps `&` or `|` literal. Neither can occur in a Windows path. + */ +// oxlint-disable-next-line no-control-regex +const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu } const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); + if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) { + return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor }); + } yield* launchAndUnref( { command: spawnCommand.command, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75dca95786bfa..7fa324a425ad2 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( + "ExternalLauncherUnsupportedTargetError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `${this.editor} cannot open a path containing line breaks or double quotes`; + } +} + const ExternalLauncherSpawnFields = { command: Schema.String, args: Schema.Array(Schema.String), @@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, ExternalLauncherCommandNotFoundError, + ExternalLauncherUnsupportedTargetError, ExternalLauncherBrowserSpawnError, ExternalLauncherEditorSpawnError, ]); From c77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 03:34:14 -0500 Subject: [PATCH 311/680] fix(web): align compact button touch targets (#17748) --- .../BranchToolbarBranchSelector.tsx | 6 ++- .../src/components/ComposerContextLabel.tsx | 4 +- apps/web/src/components/GitActionsControl.tsx | 41 +++++++++++-------- .../src/components/ProjectScriptsControl.tsx | 9 ++-- .../chat/ComposerPrimaryActions.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 3 +- .../components/chat/ThreadDetailsPrRow.tsx | 5 ++- .../chat/threadDetailsPanelStyles.ts | 5 +++ .../web/src/components/ui/middle-truncate.tsx | 10 ++++- 9 files changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1d4185b9092f8..7af46f2d47a2c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -779,7 +779,11 @@ export function BranchToolbarBranchSelector({ )} /> - + {displayMode !== "panel" ? ( diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 1abecfdf8ca81..8efcba43a75c5 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "../lib/utils"; +import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; /** Keeps text measurable while the composer's outer label box collapses. */ export function ComposerContextLabel({ @@ -16,7 +17,7 @@ export function ComposerContextLabel({ className={cn( "min-w-0", displayMode === "panel" - ? "flex-1 truncate text-left" + ? "flex-1 overflow-x-clip overflow-y-visible text-left" : "max-w-[240px] group-data-[compact]/composer-context:max-w-0", )} > @@ -24,6 +25,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", + displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2b684cd0cd4e8..2063767f315c1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -127,6 +127,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; @@ -441,23 +442,25 @@ function GitActionProgressButtonContent({ aria-live="polite" className={cn( "grid min-w-0 flex-1 items-center", - // Pin the title row to the button's minimum content height (min-height - // minus vertical padding and border) so revealing the output row - // extends the button downward without re-centering — the title must - // not shift. No row gap: the collapsed output row must contribute zero - // height so the single-line running button matches the static button - // exactly. The panel column gap matches the static row's icon-to-label - // distance (gap-2.5 plus the label's ml-0.5). In the panel the elapsed - // counter renders outside the button (in the menu-chevron slot), so - // there is no trailing column. + // Pin the title row to the button's minimum content height so output + // expands below it. Panel controls leave 24px after padding and border. + // No row gap: collapsed output must not add height. The panel's elapsed + // counter uses the menu-chevron slot, so it has no trailing column. isPanel - ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] gap-x-3" + ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] gap-x-3" : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]", )} role="status" >