From 9a777099ee6ee3fd1c5b4f65b5fc0240db701f27 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 13:36:05 +0000 Subject: [PATCH 01/40] Changed CLI clipboard fields to secondary --- apps/webapp/app/components/SetupCommands.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index fc376480a6..9763a7fff7 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -1,7 +1,6 @@ import { createContext, useContext, useState } from "react"; import { useAppOrigin } from "~/hooks/useAppOrigin"; import { useProject } from "~/hooks/useProject"; -import { InlineCode } from "./code/InlineCode"; import { ClientTabs, ClientTabsContent, @@ -9,7 +8,6 @@ import { ClientTabsTrigger, } from "./primitives/ClientTabs"; import { ClipboardField } from "./primitives/ClipboardField"; -import { Paragraph } from "./primitives/Paragraph"; type PackageManagerContextType = { activePackageManager: string; @@ -84,7 +82,7 @@ export function InitCommandV3() { Date: Mon, 17 Mar 2025 14:29:01 +0000 Subject: [PATCH 02/40] Ignore .husky --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1dac9aa9c1..72851005b7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ apps/**/public/build .yarn *.tsbuildinfo /packages/cli-v3/src/package.json +.husky From d5561b5400a120c59c26200a22ba54feb6d2d140 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 14:29:25 +0000 Subject: [PATCH 03/40] Disable vscode warning about vitest --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 346c458900..f8a7bd0697 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "search.exclude": { "**/node_modules/**": true, "packages/cli-v3/e2e": true - } + }, + "vitest.disableWorkspaceWarning": true } From 3fd4470e891434f07f9405c0cde9698b650ae8d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 14:29:51 +0000 Subject: [PATCH 04/40] Fix for SSE error when trying to send when the controller has been aborted --- .../app/components/navigation/SideMenu.tsx | 33 ++++++++----------- apps/webapp/app/utils/sse.ts | 27 +++++++++++++-- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index df6431f202..e64332c469 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -44,25 +44,23 @@ import { v3ApiKeysPath, v3BatchesPath, v3BillingPath, - v3ConcurrencyPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsPath, + v3QueuesPath, v3RunsPath, v3SchedulesPath, v3TestPath, v3UsagePath, } from "~/utils/pathBuilder"; -import { useDevPresence } from "../DevPresence"; -import { ImpersonationBanner } from "../ImpersonationBanner"; -import { PackageManagerProvider, TriggerDevStepV3 } from "../SetupCommands"; -import { UserProfilePhoto } from "../UserProfilePhoto"; import connectedImage from "../../assets/images/cli-connected.png"; import disconnectedImage from "../../assets/images/cli-disconnected.png"; import { FreePlanUsage } from "../billing/FreePlanUsage"; +import { useDevPresence } from "../DevPresence"; +import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, @@ -80,18 +78,14 @@ import { PopoverTrigger, } from "../primitives/Popover"; import { TextLink } from "../primitives/TextLink"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; +import { PackageManagerProvider, TriggerDevStepV3 } from "../SetupCommands"; +import { UserProfilePhoto } from "../UserProfilePhoto"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { - SimpleTooltip, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../primitives/Tooltip"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -194,6 +188,13 @@ export function SideMenu({ to={v3SchedulesPath(organization, project, environment)} data-action="schedules" /> + - - { + return (event) => { + try { + if (!internalController.signal.aborted) { + originalSend(event); + } + // If controller is aborted, silently ignore the send attempt + } catch (error) { + if (error instanceof Error) { + if (error.message?.includes("Controller is already closed")) { + // Silently handle controller closed errors + return; + } + log(`Error sending event: ${error.message}`); + } + throw error; // Re-throw other errors + } + }; + }; + const context: SSEContext = { id, request, @@ -115,12 +135,13 @@ export function createSSELoader(options: SSEOptions) { return eventStream(combinedSignal, function setup(send) { connections.add(id); + const safeSend = createSafeSend(send); async function run() { try { log("Initializing"); if (handlers.initStream) { - const shouldContinue = await handlers.initStream({ send }); + const shouldContinue = await handlers.initStream({ send: safeSend }); if (shouldContinue === false) { log("initStream returned false, so we'll stop the stream"); internalController.abort("Init requested stop"); @@ -138,7 +159,7 @@ export function createSSELoader(options: SSEOptions) { if (handlers.iterator) { try { - const shouldContinue = await handlers.iterator({ date, send }); + const shouldContinue = await handlers.iterator({ date, send: safeSend }); if (shouldContinue === false) { log("iterator return false, so we'll stop the stream"); internalController.abort("Iterator requested stop"); @@ -173,7 +194,7 @@ export function createSSELoader(options: SSEOptions) { log("Cleanup called"); if (handlers.cleanup) { try { - handlers.cleanup({ send }); + handlers.cleanup({ send: safeSend }); } catch (error) { log( `Error in cleanup handler: ${ From fabfb5051beeee57663d1c423a5b8373ecd8a246 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 15:07:47 +0000 Subject: [PATCH 05/40] WIP on queues page --- .../v3/ConcurrencyPresenter.server.ts | 133 ++++++------------ .../route.tsx | 53 ++++--- ...Slug.projects.$projectParam.concurrency.ts | 4 +- ...$projectParam.env.$envParam.concurrency.ts | 9 ++ apps/webapp/app/utils/pathBuilder.ts | 16 +-- 5 files changed, 88 insertions(+), 127 deletions(-) rename apps/webapp/app/routes/{_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency => _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues}/route.tsx (77%) create mode 100644 apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts diff --git a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts index 1762c2e404..f6ea83feeb 100644 --- a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts @@ -1,67 +1,38 @@ +import { + type RuntimeEnvironment, + type Organization, + type RuntimeEnvironmentType, +} from "@trigger.dev/database"; import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { Prisma, sqlDatabaseSchema } from "~/db.server"; import { type Project } from "~/models/project.server"; -import { - displayableEnvironment, - type DisplayableInputEnvironment, -} from "~/models/runtimeEnvironment.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; -import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; +import { engine } from "~/v3/runEngine.server"; import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; import { BasePresenter } from "./basePresenter.server"; -import { execute } from "effect/Stream"; -import { engine } from "~/v3/runEngine.server"; -export type Environment = Awaited< - ReturnType ->[number]; +export type Environment = Awaited>; -export class ConcurrencyPresenter extends BasePresenter { - public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { - const project = await this._replica.project.findFirst({ - select: { - id: true, - organizationId: true, - environments: { - select: { - id: true, - apiKey: true, - pkApiKey: true, - type: true, - slug: true, - updatedAt: true, - orgMember: { - select: { - user: { select: { id: true, name: true, displayName: true } }, - }, - }, - maximumConcurrencyLimit: true, - }, - }, - }, - where: { - slug: projectSlug, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - }); - - if (!project) { - throw new Error(`Project not found: ${projectSlug}`); +export class QueuePresenter extends BasePresenter { + public async call({ + userId, + projectId, + organizationId, + environmentSlug, + }: { + userId: User["id"]; + projectId: Project["id"]; + organizationId: Organization["id"]; + environmentSlug: RuntimeEnvironment["slug"]; + }) { + const environment = await findEnvironmentBySlug(projectId, environmentSlug, userId); + if (!environment) { + throw new Error(`Environment not found: ${environmentSlug}`); } return { - environments: this.environmentConcurrency( - project.organizationId, - project.id, - userId, - filterOrphanedEnvironments(project.environments) - ), + environment: this.environmentConcurrency(organizationId, projectId, userId, environment), }; } @@ -69,62 +40,44 @@ export class ConcurrencyPresenter extends BasePresenter { organizationId: string, projectId: string, userId: string, - environments: (DisplayableInputEnvironment & { maximumConcurrencyLimit: number })[] + environment: { id: string; type: RuntimeEnvironmentType; maximumConcurrencyLimit: number } ) { - const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts( - projectId, - environments.map((env) => env.id) - ); - - const engineV2Concurrency = await Promise.all( - environments.map(async (env) => - engine.currentConcurrencyOfEnvQueue({ - ...env, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }) - ) - ); + const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts(projectId, [ + environment.id, + ]); - //Build `executingCounts` with both v1 and v2 concurrencies - const executingCounts: Record = engineV1Concurrency; - - for (let index = 0; index < environments.length; index++) { - const env = environments[index]; - const existingValue: number | undefined = executingCounts[env.id]; - executingCounts[env.id] = engineV2Concurrency[index] + (existingValue ?? 0); - } + const engineV2Concurrency = await engine.currentConcurrencyOfEnvQueue({ + ...environment, + project: { + id: projectId, + }, + organization: { + id: organizationId, + }, + }); - //todo add Run Engine 2 concurrency count + const executing = (engineV1Concurrency[environment.id] ?? 0) + engineV2Concurrency; const queued = await this._replica.$queryRaw< { - runtimeEnvironmentId: string; count: BigInt; }[] >` SELECT - "runtimeEnvironmentId", COUNT(*) FROM ${sqlDatabaseSchema}."TaskRun" as tr WHERE tr."projectId" = ${projectId} + AND tr."runtimeEnvironmentId" = ${environment.id} AND tr."status" = ANY(ARRAY[${Prisma.join(QUEUED_STATUSES)}]::\"TaskRunStatus\"[]) GROUP BY tr."runtimeEnvironmentId";`; - const sortedEnvironments = sortEnvironments(environments).map((environment) => ({ - ...displayableEnvironment(environment, userId), + return { + concurrency: executing, + queued: Number(queued.at(0)?.count ?? 0), concurrencyLimit: environment.maximumConcurrencyLimit, - concurrency: executingCounts[environment.id] ?? 0, - queued: Number(queued.find((q) => q.runtimeEnvironmentId === environment.id)?.count ?? 0), - })); - - return sortedEnvironments; + }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx similarity index 77% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 5635b46c17..776c352f46 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -9,7 +9,6 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense } from "react"; import { typeddefer, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -25,31 +24,39 @@ import { TableRow, } from "~/components/primitives/Table"; import { useOrganization } from "~/hooks/useOrganizations"; -import { - ConcurrencyPresenter, - type Environment, -} from "~/presenters/v3/ConcurrencyPresenter.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { QueuePresenter, type Environment } from "~/presenters/v3/ConcurrencyPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => { return [ { - title: `Concurrency limits | Trigger.dev`, + title: `Queues | Trigger.dev`, }, ]; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } try { - const presenter = new ConcurrencyPresenter(); + const presenter = new QueuePresenter(); const result = await presenter.call({ userId, - projectSlug: projectParam, + projectId: project.id, + organizationId: project.organizationId, + environmentSlug: envParam, }); return typeddefer(result); @@ -63,7 +70,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { environments } = useTypedLoaderData(); + const { environment } = useTypedLoaderData(); const organization = useOrganization(); const plan = useCurrentPlan(); @@ -88,7 +95,6 @@ export default function Page() { - Environment Queued Running Concurrency limit @@ -106,8 +112,8 @@ export default function Page() { } > - Error loading environments

}> - {(environments) => } + Error loading environments

}> + {(environment) => }
@@ -149,19 +155,12 @@ export default function Page() { ); } -function EnvironmentsTable({ environments }: { environments: Environment[] }) { +function EnvironmentTable({ environment }: { environment: Environment }) { return ( - <> - {environments.map((environment) => ( - - - - - {environment.queued} - {environment.concurrency} - {environment.concurrencyLimit} - - ))} - + + {environment.queued} + {environment.concurrency} + {environment.concurrencyLimit} + ); } diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts index 5a977cd5cc..caf714fd30 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts @@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { requireUser } from "~/services/session.server"; -import { ProjectParamSchema, v3ApiKeysPath, v3ConcurrencyPath } from "~/utils/pathBuilder"; +import { ProjectParamSchema, v3QueuesPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3ConcurrencyPath({ slug: organizationSlug }, project, environment)); + return redirect(v3QueuesPath({ slug: organizationSlug }, project, environment)); }; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts new file mode 100644 index 0000000000..e5af3479c6 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts @@ -0,0 +1,9 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { EnvironmentParamSchema, v3QueuesPath } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + return redirect( + v3QueuesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }) + ); +}; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 332ed0aa0f..b46c3cac13 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -171,14 +171,6 @@ export function v3EnvironmentVariablesPath( return `${v3EnvironmentPath(organization, project, environment)}/environment-variables`; } -export function v3ConcurrencyPath( - organization: OrgForPath, - project: ProjectForPath, - environment: EnvironmentForPath -) { - return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; -} - export function v3NewEnvironmentVariablesPath( organization: OrgForPath, project: ProjectForPath, @@ -311,6 +303,14 @@ export function v3NewSchedulePath( return `${v3EnvironmentPath(organization, project, environment)}/schedules/new`; } +export function v3QueuesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/queues`; +} + export function v3BatchesPath( organization: OrgForPath, project: ProjectForPath, From a871137e0961e0b35e7248d9b48128a95fc50ff6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 15:52:31 +0000 Subject: [PATCH 06/40] WIP on queues page --- ...server.ts => QueueListPresenter.server.ts} | 31 ++++++- .../route.tsx | 82 ++++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) rename apps/webapp/app/presenters/v3/{ConcurrencyPresenter.server.ts => QueueListPresenter.server.ts} (74%) diff --git a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts similarity index 74% rename from apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts rename to apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index f6ea83feeb..26f0f80ac6 100644 --- a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -2,6 +2,7 @@ import { type RuntimeEnvironment, type Organization, type RuntimeEnvironmentType, + type TaskQueue, } from "@trigger.dev/database"; import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { Prisma, sqlDatabaseSchema } from "~/db.server"; @@ -12,9 +13,9 @@ import { engine } from "~/v3/runEngine.server"; import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; import { BasePresenter } from "./basePresenter.server"; -export type Environment = Awaited>; +export type Environment = Awaited>; -export class QueuePresenter extends BasePresenter { +export class QueueListPresenter extends BasePresenter { public async call({ userId, projectId, @@ -31,8 +32,34 @@ export class QueuePresenter extends BasePresenter { throw new Error(`Environment not found: ${environmentSlug}`); } + // Get all queues for this environment + const queues = this._replica.taskQueue + .findMany({ + where: { + runtimeEnvironmentId: environment.id, + }, + select: { + name: true, + concurrencyLimit: true, + type: true, + }, + orderBy: { + name: "asc", + }, + }) + .then((queues) => { + return queues.map((queue) => ({ + name: queue.name.replace(/^task\//, ""), + concurrencyLimit: queue.concurrencyLimit ?? null, + type: queue.type, + queued: 0, // Placeholder + running: 0, // Placeholder + })); + }); + return { environment: this.environmentConcurrency(organizationId, projectId, userId, environment), + queues, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 776c352f46..664bd236bc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -2,6 +2,7 @@ import { ArrowUpCircleIcon, BookOpenIcon, ChatBubbleLeftEllipsisIcon, + RectangleStackIcon, } from "@heroicons/react/20/solid"; import { LockOpenIcon } from "@heroicons/react/24/solid"; import { Await, type MetaFunction } from "@remix-run/react"; @@ -25,10 +26,13 @@ import { } from "~/components/primitives/Table"; import { useOrganization } from "~/hooks/useOrganizations"; import { findProjectBySlug } from "~/models/project.server"; -import { QueuePresenter, type Environment } from "~/presenters/v3/ConcurrencyPresenter.server"; +import { QueueListPresenter, type Environment } from "~/presenters/v3/QueueListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { Badge } from "~/components/primitives/Badge"; +import { Header2 } from "~/components/primitives/Headers"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; export const meta: MetaFunction = () => { return [ @@ -51,7 +55,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } try { - const presenter = new QueuePresenter(); + const presenter = new QueueListPresenter(); const result = await presenter.call({ userId, projectId: project.id, @@ -70,7 +74,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { environment } = useTypedLoaderData(); + const { environment, queues } = useTypedLoaderData(); const organization = useOrganization(); const plan = useCurrentPlan(); @@ -86,7 +90,7 @@ export default function Page() { LeadingIcon={BookOpenIcon} to={docsPath("/queue-concurrency")} > - Concurrency docs + Queues docs @@ -118,6 +122,76 @@ export default function Page() {
+ + Queues + + + + Name + Queued + Running + Concurrency limit + + Pause/resume + + + + + + +
+ +
+
+ + } + > + Error loading queues

} + > + {([queues, environment]) => + queues.length > 0 ? ( + queues.map((queue) => ( + + + + {queue.type === "VIRTUAL" ? ( + + ) : ( + + )} + {queue.name} + + + {queue.queued} + {queue.running} + + {queue.concurrencyLimit ?? ( + + Max ({environment.concurrencyLimit}) + + )} + + + )) + ) : ( + + +
+ No queues found +
+
+
+ ) + } +
+
+
+
+ {plan ? ( plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
From 6539c4a267dbc1b7f87681af500e6f7ee80e09d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 16:32:02 +0000 Subject: [PATCH 07/40] Added pagination --- .../v3/QueueListPresenter.server.ts | 70 ++++++++++++------- .../route.tsx | 43 +++++++++++- 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 26f0f80ac6..60cd161682 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -16,53 +16,73 @@ import { BasePresenter } from "./basePresenter.server"; export type Environment = Awaited>; export class QueueListPresenter extends BasePresenter { + private readonly ITEMS_PER_PAGE = 10; + public async call({ userId, projectId, organizationId, environmentSlug, + page, }: { userId: User["id"]; projectId: Project["id"]; organizationId: Organization["id"]; environmentSlug: RuntimeEnvironment["slug"]; + page: number; }) { const environment = await findEnvironmentBySlug(projectId, environmentSlug, userId); if (!environment) { throw new Error(`Environment not found: ${environmentSlug}`); } - // Get all queues for this environment - const queues = this._replica.taskQueue - .findMany({ - where: { - runtimeEnvironmentId: environment.id, - }, - select: { - name: true, - concurrencyLimit: true, - type: true, - }, - orderBy: { - name: "asc", - }, - }) - .then((queues) => { - return queues.map((queue) => ({ - name: queue.name.replace(/^task\//, ""), - concurrencyLimit: queue.concurrencyLimit ?? null, - type: queue.type, - queued: 0, // Placeholder - running: 0, // Placeholder - })); - }); + // Get total count for pagination + const totalQueues = await this._replica.taskQueue.count({ + where: { + runtimeEnvironmentId: environment.id, + }, + }); + // Return the environment data immediately and defer the queues return { environment: this.environmentConcurrency(organizationId, projectId, userId, environment), - queues, + queues: this.getQueuesWithPagination(environment.id, page), + pagination: { + currentPage: page, + totalPages: Math.ceil(totalQueues / this.ITEMS_PER_PAGE), + totalItems: totalQueues, + itemsPerPage: this.ITEMS_PER_PAGE, + }, }; } + private async getQueuesWithPagination(environmentId: string, page: number) { + const queues = await this._replica.taskQueue.findMany({ + where: { + runtimeEnvironmentId: environmentId, + }, + select: { + name: true, + concurrencyLimit: true, + type: true, + }, + orderBy: { + name: "asc", + }, + skip: (page - 1) * this.ITEMS_PER_PAGE, + take: this.ITEMS_PER_PAGE, + }); + + // Transform queues to include running and queued counts + return queues.map((queue) => ({ + name: queue.name, + concurrencyLimit: queue.concurrencyLimit ?? null, + type: queue.type, + queued: 0, // Placeholder + running: 0, // Placeholder + })); + } + async environmentConcurrency( organizationId: string, projectId: string, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 664bd236bc..fbe5ea993c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -33,6 +33,13 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { Badge } from "~/components/primitives/Badge"; import { Header2 } from "~/components/primitives/Headers"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { z } from "zod"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { cn } from "~/utils/cn"; + +const SearchParamsSchema = z.object({ + page: z.coerce.number().min(1).default(1), +}); export const meta: MetaFunction = () => { return [ @@ -46,6 +53,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const url = new URL(request.url); + const { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { @@ -61,6 +71,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectId: project.id, organizationId: project.organizationId, environmentSlug: envParam, + page, }); return typeddefer(result); @@ -74,7 +85,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { environment, queues } = useTypedLoaderData(); + const { environment, queues, pagination } = useTypedLoaderData(); const organization = useOrganization(); const plan = useCurrentPlan(); @@ -124,6 +135,17 @@ export default function Page() { Queues +
+
+
+ +
+
+ @@ -192,6 +214,25 @@ export default function Page() {
+
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + )} + > +
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+
+ {plan ? ( plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
From c6fd1816babb16d05281b0f72462682fcd9ce750 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 17:33:13 +0000 Subject: [PATCH 08/40] Current badge changed --- .../route.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 32945e7375..a22a6ad9b8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -48,6 +48,7 @@ import { DeploymentListPresenter, } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { titleCase } from "~/utils"; import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus"; @@ -174,7 +175,7 @@ export default function Page() {
{deployment.shortCode} {deployment.label && ( - {deployment.label} + {titleCase(deployment.label)} )}
From d5ee760b9de4f976041b00c121e27b1460462a5d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 18:49:19 +0000 Subject: [PATCH 09/40] Get values for the queue and running --- .../app/components/metrics/BigNumber.tsx | 45 +++++++++ .../components/primitives/AnimatedNumber.tsx | 13 +++ .../v3/QueueListPresenter.server.ts | 92 ++++++++++++------- .../route.tsx | 90 +++++++++--------- .../run-engine/src/engine/index.ts | 18 +++- .../run-engine/src/run-queue/index.ts | 70 ++++++++++++++ 6 files changed, 245 insertions(+), 83 deletions(-) create mode 100644 apps/webapp/app/components/metrics/BigNumber.tsx create mode 100644 apps/webapp/app/components/primitives/AnimatedNumber.tsx diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx new file mode 100644 index 0000000000..2ccbd0cd65 --- /dev/null +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -0,0 +1,45 @@ +import { type ReactNode } from "react"; +import NumberFlow from "@number-flow/react"; +import { AnimatedNumber } from "../primitives/AnimatedNumber"; +import { Spinner } from "../primitives/Spinner"; + +interface BigNumberProps { + title: ReactNode; + animate?: boolean; + loading?: boolean; + value?: number; + defaultValue?: number; + accessory?: ReactNode; +} + +export function BigNumber({ + title, + value, + defaultValue, + accessory, + animate = false, + loading = false, +}: BigNumberProps) { + const v = value ?? defaultValue; + return ( +
+
+
{title}
+ {accessory &&
{accessory}
} +
+
+ {loading ? ( + + ) : v !== undefined ? ( + animate ? ( + + ) : ( + v + ) + ) : ( + "–" + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/primitives/AnimatedNumber.tsx b/apps/webapp/app/components/primitives/AnimatedNumber.tsx new file mode 100644 index 0000000000..743a899242 --- /dev/null +++ b/apps/webapp/app/components/primitives/AnimatedNumber.tsx @@ -0,0 +1,13 @@ +import { motion, useSpring, useTransform } from "framer-motion"; +import { useEffect } from "react"; + +export function AnimatedNumber({ value }: { value: number }) { + let spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); + let display = useTransform(spring, (current) => Math.round(current).toLocaleString()); + + useEffect(() => { + spring.set(value); + }, [spring, value]); + + return {display}; +} diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 60cd161682..745ac2092b 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -12,6 +12,7 @@ import { type User } from "~/models/user.server"; import { engine } from "~/v3/runEngine.server"; import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; import { BasePresenter } from "./basePresenter.server"; +import { marqs } from "~/v3/marqs/index.server"; export type Environment = Awaited>; @@ -46,7 +47,7 @@ export class QueueListPresenter extends BasePresenter { // Return the environment data immediately and defer the queues return { environment: this.environmentConcurrency(organizationId, projectId, userId, environment), - queues: this.getQueuesWithPagination(environment.id, page), + queues: this.getQueuesWithPagination(environment, projectId, organizationId, page), pagination: { currentPage: page, totalPages: Math.ceil(totalQueues / this.ITEMS_PER_PAGE), @@ -56,10 +57,15 @@ export class QueueListPresenter extends BasePresenter { }; } - private async getQueuesWithPagination(environmentId: string, page: number) { + private async getQueuesWithPagination( + environment: { id: string; type: RuntimeEnvironmentType; maximumConcurrencyLimit: number }, + projectId: string, + organizationId: string, + page: number + ) { const queues = await this._replica.taskQueue.findMany({ where: { - runtimeEnvironmentId: environmentId, + runtimeEnvironmentId: environment.id, }, select: { name: true, @@ -73,13 +79,40 @@ export class QueueListPresenter extends BasePresenter { take: this.ITEMS_PER_PAGE, }); + const results = await Promise.all([ + engine.lengthOfQueues( + { + ...environment, + project: { + id: projectId, + }, + organization: { + id: organizationId, + }, + }, + queues.map((q) => q.name) + ), + engine.currentConcurrencyOfQueues( + { + ...environment, + project: { + id: projectId, + }, + organization: { + id: organizationId, + }, + }, + queues.map((q) => q.name) + ), + ]); + // Transform queues to include running and queued counts return queues.map((queue) => ({ - name: queue.name, - concurrencyLimit: queue.concurrencyLimit ?? null, + name: queue.name.replace(/^task\//, ""), type: queue.type, - queued: 0, // Placeholder - running: 0, // Placeholder + running: results[1][queue.name] ?? 0, + queued: results[0][queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, })); } @@ -89,11 +122,12 @@ export class QueueListPresenter extends BasePresenter { userId: string, environment: { id: string; type: RuntimeEnvironmentType; maximumConcurrencyLimit: number } ) { - const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts(projectId, [ - environment.id, - ]); - - const engineV2Concurrency = await engine.currentConcurrencyOfEnvQueue({ + //executing + const engineV1Executing = await marqs.currentConcurrencyOfEnvironment({ + ...environment, + organizationId, + }); + const engineV2Executing = await engine.concurrencyOfEnvQueue({ ...environment, project: { id: projectId, @@ -102,28 +136,24 @@ export class QueueListPresenter extends BasePresenter { id: organizationId, }, }); + const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); - const executing = (engineV1Concurrency[environment.id] ?? 0) + engineV2Concurrency; - - const queued = await this._replica.$queryRaw< - { - count: BigInt; - }[] - >` -SELECT - COUNT(*) -FROM - ${sqlDatabaseSchema}."TaskRun" as tr -WHERE - tr."projectId" = ${projectId} - AND tr."runtimeEnvironmentId" = ${environment.id} - AND tr."status" = ANY(ARRAY[${Prisma.join(QUEUED_STATUSES)}]::\"TaskRunStatus\"[]) -GROUP BY - tr."runtimeEnvironmentId";`; + //queued + const engineV1Queued = await marqs.lengthOfEnvQueue({ ...environment, organizationId }); + const engineV2Queued = await engine.lengthOfEnvQueue({ + ...environment, + project: { + id: projectId, + }, + organization: { + id: organizationId, + }, + }); + const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); return { - concurrency: executing, - queued: Number(queued.at(0)?.count ?? 0), + running, + queued, concurrencyLimit: environment.maximumConcurrencyLimit, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index fbe5ea993c..615e7c1f66 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -2,6 +2,7 @@ import { ArrowUpCircleIcon, BookOpenIcon, ChatBubbleLeftEllipsisIcon, + PauseIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; import { LockOpenIcon } from "@heroicons/react/24/solid"; @@ -36,6 +37,7 @@ import { TaskIcon } from "~/assets/icons/TaskIcon"; import { z } from "zod"; import { PaginationControls } from "~/components/primitives/Pagination"; import { cn } from "~/utils/cn"; +import { BigNumber } from "~/components/metrics/BigNumber"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -93,7 +95,7 @@ export default function Page() { return ( - +
- - - - Queued - Running - Concurrency limit - - - - - -
- -
-
- - } - > - Error loading environments

}> - {(environment) => } -
-
-
-
- - Queues -
-
-
- -
+
+ }> + + {(environment) => ( + + Pause environment + + } + /> + )} + + + }> + + {(environment) => } + + + }> + + {(environment) => ( + + )} + +
- +
Name @@ -269,13 +271,3 @@ export default function Page() { ); } - -function EnvironmentTable({ environment }: { environment: Environment }) { - return ( - - {environment.queued} - {environment.concurrency} - {environment.concurrencyLimit} - - ); -} diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 834652fad6..46556b9588 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1652,12 +1652,24 @@ export class RunEngine { return this.runQueue.lengthOfEnvQueue(environment); } - async currentConcurrencyOfEnvQueue( - environment: MinimalAuthenticatedEnvironment - ): Promise { + async concurrencyOfEnvQueue(environment: MinimalAuthenticatedEnvironment): Promise { return this.runQueue.currentConcurrencyOfEnvironment(environment); } + async lengthOfQueues( + environment: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + return this.runQueue.lengthOfQueues(environment, queues); + } + + async currentConcurrencyOfQueues( + environment: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + return this.runQueue.currentConcurrencyOfQueues(environment, queues); + } + /** * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 374d224aff..dcedf121ec 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -184,6 +184,76 @@ export class RunQueue { return this.redis.scard(this.keys.currentConcurrencyKey(env, queue, concurrencyKey)); } + public async currentConcurrencyOfQueues( + env: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + const pipeline = this.redis.pipeline(); + + // Queue up all SCARD commands in the pipeline + queues.forEach((queue) => { + pipeline.scard(this.keys.currentConcurrencyKey(env, queue)); + }); + + // Execute pipeline and get results + const results = await pipeline.exec(); + + // If results is null, return all queues with 0 concurrency + if (!results) { + return queues.reduce( + (acc, queue) => { + acc[queue] = 0; + return acc; + }, + {} as Record + ); + } + + // Map results back to queue names, handling potential errors + return queues.reduce( + (acc, queue, index) => { + const [err, value] = results[index]; + // If there was an error or value is null/undefined, use 0 + acc[queue] = err || value == null ? 0 : (value as number); + return acc; + }, + {} as Record + ); + } + + public async lengthOfQueues( + env: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + const pipeline = this.redis.pipeline(); + + // Queue up all ZCARD commands in the pipeline + queues.forEach((queue) => { + pipeline.zcard(this.keys.queueKey(env, queue)); + }); + + const results = await pipeline.exec(); + + if (!results) { + return queues.reduce( + (acc, queue) => { + acc[queue] = 0; + return acc; + }, + {} as Record + ); + } + + return queues.reduce( + (acc, queue, index) => { + const [err, value] = results![index]; + acc[queue] = err || value == null ? 0 : (value as number); + return acc; + }, + {} as Record + ); + } + public async currentConcurrencyOfEnvironment(env: MinimalAuthenticatedEnvironment) { return this.redis.scard(this.keys.envCurrentConcurrencyKey(env)); } From bbe30ea625e532545ea288fd452b5a9f353039cf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 17 Mar 2025 18:54:38 +0000 Subject: [PATCH 10/40] Move increase limit button to the new spot --- .../route.tsx | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 615e7c1f66..1427ac6a32 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -142,6 +142,36 @@ export default function Page() { title="Concurrency limit" value={environment.concurrencyLimit} animate + accessory={ + plan ? ( + plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( + + Increase limit + + } + defaultValue="help" + /> + ) : ( + + Increase limit + + ) + ) : null + } /> )} @@ -234,38 +264,6 @@ export default function Page() { /> - - {plan ? ( - plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( -
- - Need more concurrency? - - - Request more - - } - defaultValue="help" - /> -
- ) : ( -
- - - Upgrade for more concurrency - - - Upgrade - -
- ) - ) : null} From ec4511b6294dd66a8536f37233059801b87097be Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 09:11:39 +0000 Subject: [PATCH 11/40] Add paused status to RuntimeEnvironment and TaskQueue --- .../migration.sql | 7 +++++++ internal-packages/database/prisma/schema.prisma | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql b/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql new file mode 100644 index 0000000000..4daf97b608 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "TaskQueue" +ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 0398103546..84d8c55702 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -382,7 +382,8 @@ model RuntimeEnvironment { ///A memorable code for the environment shortcode String - maximumConcurrencyLimit Int @default(5) + maximumConcurrencyLimit Int @default(5) + paused Boolean @default(false) autoEnableInternalSources Boolean @default(true) @@ -2523,6 +2524,8 @@ model TaskQueue { concurrencyLimit Int? rateLimit Json? + paused Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 43413c1b41d6257eb4cff65f6abfdfcf19e63cd0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 09:12:05 +0000 Subject: [PATCH 12/40] Reduce the env data that determineEngineVersion needs --- apps/webapp/app/v3/engineVersion.server.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/v3/engineVersion.server.ts b/apps/webapp/app/v3/engineVersion.server.ts index 1b514fc398..c1a32052ff 100644 --- a/apps/webapp/app/v3/engineVersion.server.ts +++ b/apps/webapp/app/v3/engineVersion.server.ts @@ -1,17 +1,25 @@ -import { RunEngineVersion, RuntimeEnvironmentType } from "@trigger.dev/database"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { RunEngineVersion, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { findCurrentWorkerDeploymentWithoutTasks, findCurrentWorkerFromEnvironment, } from "./models/workerDeployment.server"; import { $replica } from "~/db.server"; +type Environment = { + id: string; + type: RuntimeEnvironmentType; + project: { + id: string; + engine: RunEngineVersion; + }; +}; + export async function determineEngineVersion({ environment, workerVersion, engineVersion: version, }: { - environment: AuthenticatedEnvironment; + environment: Environment; workerVersion?: string; engineVersion?: RunEngineVersion; }): Promise { @@ -36,7 +44,7 @@ export async function determineEngineVersion({ }, where: { projectId_runtimeEnvironmentId_version: { - projectId: environment.projectId, + projectId: environment.project.id, runtimeEnvironmentId: environment.id, version: workerVersion, }, From a51f8c56a4a69386f32aef9ca95cb9b48903cac3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 09:28:14 +0000 Subject: [PATCH 13/40] WIP pausing --- .../route.tsx | 6 +-- .../v3/services/pauseEnvironment.server.ts | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/app/v3/services/pauseEnvironment.server.ts diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 1427ac6a32..c05bd2e3bd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -5,7 +5,6 @@ import { PauseIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; -import { LockOpenIcon } from "@heroicons/react/24/solid"; import { Await, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense } from "react"; @@ -15,7 +14,6 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, @@ -27,12 +25,10 @@ import { } from "~/components/primitives/Table"; import { useOrganization } from "~/hooks/useOrganizations"; import { findProjectBySlug } from "~/models/project.server"; -import { QueueListPresenter, type Environment } from "~/presenters/v3/QueueListPresenter.server"; +import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { Badge } from "~/components/primitives/Badge"; -import { Header2 } from "~/components/primitives/Headers"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { z } from "zod"; import { PaginationControls } from "~/components/primitives/Pagination"; diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts new file mode 100644 index 0000000000..547fd43dac --- /dev/null +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -0,0 +1,45 @@ +import { AuthenticatedEnvironment } from "@internal/testcontainers"; +import { BatchTriggerTaskV3RequestBody, BatchTriggerTaskV3Response } from "@trigger.dev/core/v3"; +import { PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { WithRunEngine } from "./baseService.server"; +import { BatchProcessingStrategy, BatchTriggerTaskServiceOptions } from "./batchTriggerV3.server"; +import { determineEngineVersion } from "../engineVersion.server"; + +export type PauseStatus = "paused" | "resumed"; + +export type PauseEnvironmentResult = + | { + success: true; + state: PauseStatus; + } + | { + success: false; + error: string; + }; + +export class PauseEnvironmentService extends WithRunEngine { + constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) { + super({ prisma }); + } + + public async call( + environment: AuthenticatedEnvironment, + action: "pause" | "resume" + ): Promise { + const version = await determineEngineVersion({ + environment, + }); + + if (version === "V1") { + return { + success: false, + error: "You need to be on Run Engine v2+ to pause an environment", + }; + } + + if (action === "pause") { + await this._prisma.runtimeEnvironment.update({}); + } + } +} From 0173406999c13b47c9b3c24d79d09a4e84c62a5e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 13:14:29 +0000 Subject: [PATCH 14/40] Pausing the environment is working --- .../app/components/admin/debugTooltip.tsx | 18 +++ .../app/components/layout/AppLayout.tsx | 16 +- .../app/components/metrics/BigNumber.tsx | 23 ++- .../navigation/EnvironmentPausedBanner.tsx | 57 ++++++++ .../app/components/primitives/PageHeader.tsx | 10 +- .../app/models/runtimeEnvironment.server.ts | 11 +- .../OrganizationsPresenter.server.ts | 1 + .../SelectBestEnvironmentPresenter.server.ts | 4 +- .../v3/QueueListPresenter.server.ts | 99 +++---------- .../route.tsx | 137 +++++++++++++++--- .../_app.orgs.$organizationSlug/route.tsx | 7 + apps/webapp/app/v3/runQueue.server.ts | 16 +- .../v3/services/pauseEnvironment.server.ts | 53 +++++-- 13 files changed, 307 insertions(+), 145 deletions(-) create mode 100644 apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index 2dfcce9634..b4ccb74f88 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -6,6 +6,7 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; +import { useOptionalEnvironment } from "~/hooks/useEnvironment"; import { useIsImpersonating, useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; import { useHasAdminAccess, useUser } from "~/hooks/useUser"; @@ -35,6 +36,7 @@ export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) function Content({ children }: { children: React.ReactNode }) { const organization = useOptionalOrganization(); const project = useOptionalProject(); + const environment = useOptionalEnvironment(); const user = useUser(); return ( @@ -62,6 +64,22 @@ function Content({ children }: { children: React.ReactNode }) { )} + {environment && ( + <> + + Environment ID + {environment.id} + + + Environment type + {environment.type} + + + Environment paused + {environment.paused ? "Yes" : "No"} + + + )}
{children}
diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index a38607aab7..e45836e496 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -1,6 +1,4 @@ -import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { useShowUpgradePrompt } from "../billing/UpgradePrompt"; /** This container is used to surround the entire app, it correctly places the nav bar */ export function AppContainer({ children }: { children: React.ReactNode }) { @@ -13,19 +11,7 @@ export function MainBody({ children }: { children: React.ReactNode }) { /** This container should be placed around the content on a page */ export function PageContainer({ children }: { children: React.ReactNode }) { - const organization = useOptionalOrganization(); - const showUpgradePrompt = useShowUpgradePrompt(organization); - - return ( -
- {children} -
- ); + return
{children}
; } export function PageBody({ diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx index 2ccbd0cd65..d89b2cb473 100644 --- a/apps/webapp/app/components/metrics/BigNumber.tsx +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -2,20 +2,27 @@ import { type ReactNode } from "react"; import NumberFlow from "@number-flow/react"; import { AnimatedNumber } from "../primitives/AnimatedNumber"; import { Spinner } from "../primitives/Spinner"; +import { cn } from "~/utils/cn"; interface BigNumberProps { title: ReactNode; animate?: boolean; loading?: boolean; value?: number; + valueClassName?: string; defaultValue?: number; accessory?: ReactNode; + suffix?: string; + suffixClassName?: string; } export function BigNumber({ title, value, defaultValue, + valueClassName, + suffix, + suffixClassName, accessory, animate = false, loading = false, @@ -27,15 +34,19 @@ export function BigNumber({
{title}
{accessory &&
{accessory}
} -
+
{loading ? ( ) : v !== undefined ? ( - animate ? ( - - ) : ( - v - ) +
+ {animate ? : v} + {suffix &&
{suffix}
} +
) : ( "–" )} diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx new file mode 100644 index 0000000000..2c659c9e1a --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx @@ -0,0 +1,57 @@ +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment"; +import { Icon } from "../primitives/Icon"; +import { Paragraph } from "../primitives/Paragraph"; +import { environmentFullTitle } from "../environments/EnvironmentLabel"; +import { useLocation } from "@remix-run/react"; +import { v3QueuesPath } from "~/utils/pathBuilder"; +import { LinkButton } from "../primitives/Buttons"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { AnimatePresence, motion } from "framer-motion"; + +export function EnvironmentPausedBanner() { + const organization = useOrganization(); + const project = useProject(); + const environment = useOptionalEnvironment(); + const location = useLocation(); + + const hideButton = location.pathname.endsWith("/queues"); + + return ( + + {environment && environment.paused ? ( + +
+ + + {environmentFullTitle(environment)} environment paused. No new runs will be dequeued + and executed. + +
+ {hideButton ? null : ( +
+ + Manage + +
+ )} +
+ ) : null} +
+ ); +} + +export function useShowEnvironmentPausedBanner() { + const environment = useOptionalEnvironment(); + const shouldShow = environment?.paused ?? false; + return { shouldShow }; +} diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index e9749f84eb..767aa3fab4 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -1,10 +1,11 @@ import { Link, useNavigation } from "@remix-run/react"; -import { ReactNode } from "react"; +import { type ReactNode } from "react"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt"; import { BreadcrumbIcon } from "./BreadcrumbIcon"; import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; +import { EnvironmentPausedBanner } from "../navigation/EnvironmentPausedBanner"; type WithChildren = { children: React.ReactNode; @@ -14,6 +15,7 @@ type WithChildren = { export function NavBar({ children }: WithChildren) { const organization = useOptionalOrganization(); const showUpgradePrompt = useShowUpgradePrompt(organization); + const navigation = useNavigation(); const isLoading = navigation.state === "loading" || navigation.state === "submitting"; @@ -23,7 +25,11 @@ export function NavBar({ children }: WithChildren) {
{children}
- {showUpgradePrompt.shouldShow && organization && } + {showUpgradePrompt.shouldShow && organization ? ( + + ) : ( + + )}
); } diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index c70d7fdb43..12e7359056 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -69,7 +69,11 @@ export async function findEnvironmentById(id: string): Promise { return prisma.runtimeEnvironment.findFirst({ where: { projectId: projectId, @@ -88,6 +92,11 @@ export async function findEnvironmentBySlug(projectId: string, envSlug: string, }, ], }, + include: { + project: true, + organization: true, + orgMember: true, + }, }); } diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 1ac6685944..415a371e84 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -75,6 +75,7 @@ export class OrganizationsPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index 5ca3458b99..e663250a45 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -3,7 +3,7 @@ import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { type UserFromSession } from "~/services/session.server"; -export type MinimumEnvironment = Pick & { +export type MinimumEnvironment = Pick & { orgMember: null | { userId: string | undefined; }; @@ -45,6 +45,7 @@ export class SelectBestEnvironmentPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, @@ -68,6 +69,7 @@ export class SelectBestEnvironmentPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 745ac2092b..1c949f5425 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -1,18 +1,7 @@ -import { - type RuntimeEnvironment, - type Organization, - type RuntimeEnvironmentType, - type TaskQueue, -} from "@trigger.dev/database"; -import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; -import { Prisma, sqlDatabaseSchema } from "~/db.server"; -import { type Project } from "~/models/project.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { type User } from "~/models/user.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { marqs } from "~/v3/marqs/index.server"; import { engine } from "~/v3/runEngine.server"; -import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; import { BasePresenter } from "./basePresenter.server"; -import { marqs } from "~/v3/marqs/index.server"; export type Environment = Awaited>; @@ -20,23 +9,12 @@ export class QueueListPresenter extends BasePresenter { private readonly ITEMS_PER_PAGE = 10; public async call({ - userId, - projectId, - organizationId, - environmentSlug, + environment, page, }: { - userId: User["id"]; - projectId: Project["id"]; - organizationId: Organization["id"]; - environmentSlug: RuntimeEnvironment["slug"]; + environment: AuthenticatedEnvironment; page: number; }) { - const environment = await findEnvironmentBySlug(projectId, environmentSlug, userId); - if (!environment) { - throw new Error(`Environment not found: ${environmentSlug}`); - } - // Get total count for pagination const totalQueues = await this._replica.taskQueue.count({ where: { @@ -46,8 +24,12 @@ export class QueueListPresenter extends BasePresenter { // Return the environment data immediately and defer the queues return { - environment: this.environmentConcurrency(organizationId, projectId, userId, environment), - queues: this.getQueuesWithPagination(environment, projectId, organizationId, page), + environment: this.environmentConcurrency(environment), + queues: this.getQueuesWithPagination( + environment, + + page + ), pagination: { currentPage: page, totalPages: Math.ceil(totalQueues / this.ITEMS_PER_PAGE), @@ -57,12 +39,7 @@ export class QueueListPresenter extends BasePresenter { }; } - private async getQueuesWithPagination( - environment: { id: string; type: RuntimeEnvironmentType; maximumConcurrencyLimit: number }, - projectId: string, - organizationId: string, - page: number - ) { + private async getQueuesWithPagination(environment: AuthenticatedEnvironment, page: number) { const queues = await this._replica.taskQueue.findMany({ where: { runtimeEnvironmentId: environment.id, @@ -81,27 +58,11 @@ export class QueueListPresenter extends BasePresenter { const results = await Promise.all([ engine.lengthOfQueues( - { - ...environment, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }, + environment, queues.map((q) => q.name) ), engine.currentConcurrencyOfQueues( - { - ...environment, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }, + environment, queues.map((q) => q.name) ), ]); @@ -116,39 +77,15 @@ export class QueueListPresenter extends BasePresenter { })); } - async environmentConcurrency( - organizationId: string, - projectId: string, - userId: string, - environment: { id: string; type: RuntimeEnvironmentType; maximumConcurrencyLimit: number } - ) { + async environmentConcurrency(environment: AuthenticatedEnvironment) { //executing - const engineV1Executing = await marqs.currentConcurrencyOfEnvironment({ - ...environment, - organizationId, - }); - const engineV2Executing = await engine.concurrencyOfEnvQueue({ - ...environment, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }); + const engineV1Executing = await marqs.currentConcurrencyOfEnvironment(environment); + const engineV2Executing = await engine.concurrencyOfEnvQueue(environment); const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); //queued - const engineV1Queued = await marqs.lengthOfEnvQueue({ ...environment, organizationId }); - const engineV2Queued = await engine.lengthOfEnvQueue({ - ...environment, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }); + const engineV1Queued = await marqs.lengthOfEnvQueue(environment); + const engineV2Queued = await engine.lengthOfEnvQueue(environment); const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); return { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index c05bd2e3bd..9f108c98f8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -3,17 +3,22 @@ import { BookOpenIcon, ChatBubbleLeftEllipsisIcon, PauseIcon, + PlayIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; -import { Await, type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Await, Form, type MetaFunction } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense } from "react"; import { typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BigNumber } from "~/components/metrics/BigNumber"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, @@ -23,17 +28,17 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { TaskIcon } from "~/assets/icons/TaskIcon"; -import { z } from "zod"; -import { PaginationControls } from "~/components/primitives/Pagination"; -import { cn } from "~/utils/cn"; -import { BigNumber } from "~/components/metrics/BigNumber"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -62,13 +67,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + try { const presenter = new QueueListPresenter(); const result = await presenter.call({ - userId, - projectId: project.id, - organizationId: project.organizationId, - environmentSlug: envParam, + environment, page, }); @@ -82,10 +92,68 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } }; +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + if (request.method.toLowerCase() !== "post") { + return redirectWithErrorMessage( + `/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${params.envParam}/queues`, + request, + "Wrong method" + ); + } + + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + const formData = await request.formData(); + const action = formData.get("action"); + + switch (action) { + case "environment-pause": + const pauseService = new PauseEnvironmentService(); + await pauseService.call(environment, "paused"); + return redirectWithSuccessMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + "Environment paused" + ); + case "environment-resume": + const resumeService = new PauseEnvironmentService(); + await resumeService.call(environment, "resumed"); + return redirectWithSuccessMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + "Environment resumed" + ); + default: + redirectWithErrorMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + "Something went wrong" + ); + } +}; + export default function Page() { const { environment, queues, pagination } = useTypedLoaderData(); const organization = useOrganization(); + const env = useEnvironment(); const plan = useCurrentPlan(); return ( @@ -112,16 +180,10 @@ export default function Page() { 0 ? "paused" : undefined} animate - accessory={ - - } + accessory={} + valueClassName={env.paused ? "text-amber-500" : undefined} /> )} @@ -265,3 +327,38 @@ export default function Page() { ); } + +function EnvironmentPauseResumeButton({ env }: { env: { paused: boolean } }) { + return ( +
+ + + + ); +} + +export function isEnvironmentPauseResumeFormSubmission( + formMethod: string | undefined, + formData: FormData | undefined +) { + if (!formMethod || !formData) { + return false; + } + + return ( + formMethod.toLowerCase() === "post" && + (formData.get("action") === "environment-pause" || + formData.get("action") === "environment-resume") + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 7700b7e20d..5a32667a26 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -11,6 +11,8 @@ import { getCachedUsage, getCurrentPlan } from "~/services/platform.v3.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; +import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ organizationSlug: z.string(), @@ -45,6 +47,11 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { } } + // Invalidate if the environment has been paused or resumed + if (isEnvironmentPauseResumeFormSubmission(params.formMethod, params.formData)) { + return true; + } + // This prevents revalidation when there are search params changes // IMPORTANT: If the loader function depends on search params, this should be updated return params.currentUrl.pathname !== params.nextUrl.pathname; diff --git a/apps/webapp/app/v3/runQueue.server.ts b/apps/webapp/app/v3/runQueue.server.ts index 7198456d39..e7aa13c5c5 100644 --- a/apps/webapp/app/v3/runQueue.server.ts +++ b/apps/webapp/app/v3/runQueue.server.ts @@ -1,14 +1,22 @@ -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { marqs } from "./marqs/index.server"; import { engine } from "./runEngine.server"; //This allows us to update MARQS and the RunQueue /** Updates MARQS and the RunQueue limits */ -export async function updateEnvConcurrencyLimits(environment: AuthenticatedEnvironment) { +export async function updateEnvConcurrencyLimits( + environment: AuthenticatedEnvironment, + maximumConcurrencyLimit?: number +) { + let updatedEnvironment = environment; + if (maximumConcurrencyLimit !== undefined) { + updatedEnvironment.maximumConcurrencyLimit = maximumConcurrencyLimit; + } + await Promise.allSettled([ - marqs?.updateEnvConcurrencyLimits(environment), - engine.runQueue.updateEnvConcurrencyLimits(environment), + marqs?.updateEnvConcurrencyLimits(updatedEnvironment), + engine.runQueue.updateEnvConcurrencyLimits(updatedEnvironment), ]); } diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts index 547fd43dac..a3e029e565 100644 --- a/apps/webapp/app/v3/services/pauseEnvironment.server.ts +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -1,10 +1,9 @@ -import { AuthenticatedEnvironment } from "@internal/testcontainers"; -import { BatchTriggerTaskV3RequestBody, BatchTriggerTaskV3Response } from "@trigger.dev/core/v3"; -import { PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type AuthenticatedEnvironment } from "@internal/testcontainers"; +import { type PrismaClientOrTransaction } from "@trigger.dev/database"; import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { updateEnvConcurrencyLimits } from "../runQueue.server"; import { WithRunEngine } from "./baseService.server"; -import { BatchProcessingStrategy, BatchTriggerTaskServiceOptions } from "./batchTriggerV3.server"; -import { determineEngineVersion } from "../engineVersion.server"; export type PauseStatus = "paused" | "resumed"; @@ -25,21 +24,45 @@ export class PauseEnvironmentService extends WithRunEngine { public async call( environment: AuthenticatedEnvironment, - action: "pause" | "resume" + action: PauseStatus ): Promise { - const version = await determineEngineVersion({ - environment, - }); + try { + await this._prisma.runtimeEnvironment.update({ + where: { + id: environment.id, + }, + data: { + paused: action === "paused", + }, + }); + + if (action === "paused") { + logger.debug("PauseEnvironmentService: pausing environment", { + environmentId: environment.id, + }); + await updateEnvConcurrencyLimits(environment, 0); + } else { + logger.debug("PauseEnvironmentService: resuming environment", { + environmentId: environment.id, + }); + await updateEnvConcurrencyLimits(environment); + } - if (version === "V1") { return { - success: false, - error: "You need to be on Run Engine v2+ to pause an environment", + success: true, + state: action, }; - } + } catch (error) { + logger.error("PauseEnvironmentService: error pausing environment", { + action, + environmentId: environment.id, + error, + }); - if (action === "pause") { - await this._prisma.runtimeEnvironment.update({}); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; } } } From df53715ee50c1db4a427ae2e3e616b3324c579f8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 13:31:20 +0000 Subject: [PATCH 15/40] Added app version to the org setting menu --- .../components/navigation/AccountSideMenu.tsx | 3 +- .../navigation/EnvironmentPausedBanner.tsx | 20 ++--- .../OrganizationSettingsSideMenu.tsx | 84 +++++++++++-------- .../route.tsx | 12 ++- packages/core/src/index.ts | 1 + 5 files changed, 70 insertions(+), 50 deletions(-) diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 4971a41fbc..0c04044d91 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -22,9 +22,8 @@ export function AccountSideMenu({ user }: { user: User }) { to={rootPath()} fullWidth textAlignLeft - className="text-text-bright" > - Back to app + Back to app
diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx index 2c659c9e1a..bc0501a210 100644 --- a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx @@ -1,18 +1,18 @@ import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment"; -import { Icon } from "../primitives/Icon"; -import { Paragraph } from "../primitives/Paragraph"; -import { environmentFullTitle } from "../environments/EnvironmentLabel"; import { useLocation } from "@remix-run/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useOptionalEnvironment } from "~/hooks/useEnvironment"; +import { useOptionalOrganization } from "~/hooks/useOrganizations"; +import { useOptionalProject } from "~/hooks/useProject"; import { v3QueuesPath } from "~/utils/pathBuilder"; +import { environmentFullTitle } from "../environments/EnvironmentLabel"; import { LinkButton } from "../primitives/Buttons"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { AnimatePresence, motion } from "framer-motion"; +import { Icon } from "../primitives/Icon"; +import { Paragraph } from "../primitives/Paragraph"; export function EnvironmentPausedBanner() { - const organization = useOrganization(); - const project = useProject(); + const organization = useOptionalOrganization(); + const project = useOptionalProject(); const environment = useOptionalEnvironment(); const location = useLocation(); @@ -20,7 +20,7 @@ export function EnvironmentPausedBanner() { return ( - {environment && environment.paused ? ( + {organization && project && environment && environment.paused ? ( - Back to app + Back to app
-
- - - {isManagedCloud && ( +
+
+ - )} - - + {isManagedCloud && ( + + )} + + +
+
+ + + v{version} + +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index 32f77ef904..735959e451 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -1,15 +1,25 @@ import { Outlet } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { VERSION } from "@trigger.dev/core"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AppContainer, MainBody } from "~/components/layout/AppLayout"; import { OrganizationSettingsSideMenu } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + return typedjson({ + version: VERSION, + }); +}; + export default function Page() { + const { version } = useTypedLoaderData(); const organization = useOrganization(); return (
- + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e92a92047b..845b6b4310 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./utils.js"; export * from "./schemas/json.js"; +export * from "./version.js"; From d6beb0c7455464b5b213b4b9592283f91ac66447 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 16:38:59 +0000 Subject: [PATCH 16/40] Separate the presenters out, useful for the SDK --- .../v3/EnvironmentQueuePresenter.server.ts | 30 +++++++++++++++++++ .../v3/QueueListPresenter.server.ts | 29 ++---------------- .../route.tsx | 12 ++++++-- 3 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts new file mode 100644 index 0000000000..39c2eea931 --- /dev/null +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -0,0 +1,30 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { marqs } from "~/v3/marqs/index.server"; +import { engine } from "~/v3/runEngine.server"; +import { BasePresenter } from "./basePresenter.server"; + +export type Environment = { + running: number; + queued: number; + concurrencyLimit: number; +}; + +export class EnvironmentQueuePresenter extends BasePresenter { + async call(environment: AuthenticatedEnvironment): Promise { + //executing + const engineV1Executing = await marqs.currentConcurrencyOfEnvironment(environment); + const engineV2Executing = await engine.concurrencyOfEnvQueue(environment); + const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); + + //queued + const engineV1Queued = await marqs.lengthOfEnvQueue(environment); + const engineV2Queued = await engine.lengthOfEnvQueue(environment); + const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); + + return { + running, + queued, + concurrencyLimit: environment.maximumConcurrencyLimit, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 1c949f5425..373f6cb5bf 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -2,8 +2,7 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { marqs } from "~/v3/marqs/index.server"; import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; - -export type Environment = Awaited>; +import { EnvironmentQueuePresenter, type Environment } from "./EnvironmentQueuePresenter.server"; export class QueueListPresenter extends BasePresenter { private readonly ITEMS_PER_PAGE = 10; @@ -22,14 +21,8 @@ export class QueueListPresenter extends BasePresenter { }, }); - // Return the environment data immediately and defer the queues return { - environment: this.environmentConcurrency(environment), - queues: this.getQueuesWithPagination( - environment, - - page - ), + queues: this.getQueuesWithPagination(environment, page), pagination: { currentPage: page, totalPages: Math.ceil(totalQueues / this.ITEMS_PER_PAGE), @@ -76,22 +69,4 @@ export class QueueListPresenter extends BasePresenter { concurrencyLimit: queue.concurrencyLimit ?? null, })); } - - async environmentConcurrency(environment: AuthenticatedEnvironment) { - //executing - const engineV1Executing = await marqs.currentConcurrencyOfEnvironment(environment); - const engineV2Executing = await engine.concurrencyOfEnvQueue(environment); - const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); - - //queued - const engineV1Queued = await marqs.lengthOfEnvQueue(environment); - const engineV2Queued = await engine.lengthOfEnvQueue(environment); - const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); - - return { - running, - queued, - concurrencyLimit: environment.maximumConcurrencyLimit, - }; - } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 9f108c98f8..4af75407b6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -39,6 +39,7 @@ import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBui import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -76,13 +77,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } try { - const presenter = new QueueListPresenter(); - const result = await presenter.call({ + const queueListPresenter = new QueueListPresenter(); + const result = await queueListPresenter.call({ environment, page, }); - return typeddefer(result); + const environmentQueuePresenter = new EnvironmentQueuePresenter(); + + return typeddefer({ + ...result, + environment: environmentQueuePresenter.call(environment), + }); } catch (error) { console.error(error); throw new Response(undefined, { From 3a4991dd45cd64c010d9dea010d539a639ddf535 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 18:55:56 +0000 Subject: [PATCH 17/40] Loading improvements --- .../route.tsx | 114 +++++++++++++++--- 1 file changed, 96 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 4af75407b6..7d50cc535b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -6,9 +6,9 @@ import { PlayIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; -import { Await, Form, type MetaFunction } from "@remix-run/react"; +import { Await, Form, useNavigation, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; import { typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { TaskIcon } from "~/assets/icons/TaskIcon"; @@ -40,6 +40,19 @@ import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; +import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -334,23 +347,88 @@ export default function Page() { ); } -function EnvironmentPauseResumeButton({ env }: { env: { paused: boolean } }) { +function EnvironmentPauseResumeButton({ + env, +}: { + env: { type: RuntimeEnvironmentType; paused: boolean }; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (navigation.state === "loading" || navigation.state === "idle") { + setIsOpen(false); + } + }, [navigation.state]); + + const isLoading = Boolean( + navigation.formData?.get("action") === (env.paused ? "environment-resume" : "environment-pause") + ); + return ( -
- - - + +
+ + + +
+ + + +
+
+ + {env.paused + ? `Resume processing runs in ${environmentFullTitle(env)}.` + : `Pause processing runs in ${environmentFullTitle(env)}.`} + +
+
+
+ + {env.paused ? "Resume environment?" : "Pause environment?"} +
+ + {env.paused + ? `This will allow runs to be dequeued in ${environmentFullTitle(env)} again.` + : `This will pause any runs from being dequeued in ${environmentFullTitle(env)}.`} + +
setIsOpen(false)}> + + : env.paused ? PlayIcon : PauseIcon} + > + {env.paused ? "Resume environment" : "Pause environment"} + + } + cancelButton={ + + + + } + /> + +
+
+
); } From 629393334d0d7d9abd985fe460cdc8c886291604 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 18:58:29 +0000 Subject: [PATCH 18/40] Show 25 queues per page --- apps/webapp/app/presenters/v3/QueueListPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 373f6cb5bf..0c970d6ee7 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -5,7 +5,7 @@ import { BasePresenter } from "./basePresenter.server"; import { EnvironmentQueuePresenter, type Environment } from "./EnvironmentQueuePresenter.server"; export class QueueListPresenter extends BasePresenter { - private readonly ITEMS_PER_PAGE = 10; + private readonly ITEMS_PER_PAGE = 25; public async call({ environment, From 50593eb24f638d9189fc9c829a2a01b7950e0775 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 19:07:33 +0000 Subject: [PATCH 19/40] Transform the queue type and added tooltips --- .../presenters/v3/QueueListPresenter.server.ts | 17 ++++++++++++++--- .../route.tsx | 12 +++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 0c970d6ee7..941144c30a 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -1,8 +1,8 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { marqs } from "~/v3/marqs/index.server"; import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; -import { EnvironmentQueuePresenter, type Environment } from "./EnvironmentQueuePresenter.server"; +import { type TaskQueueType } from "@trigger.dev/database"; +import { assertExhaustive } from "@trigger.dev/core"; export class QueueListPresenter extends BasePresenter { private readonly ITEMS_PER_PAGE = 25; @@ -63,10 +63,21 @@ export class QueueListPresenter extends BasePresenter { // Transform queues to include running and queued counts return queues.map((queue) => ({ name: queue.name.replace(/^task\//, ""), - type: queue.type, + type: queueTypeFromType(queue.type), running: results[1][queue.name] ?? 0, queued: results[0][queue.name] ?? 0, concurrencyLimit: queue.concurrencyLimit ?? null, })); } } + +export function queueTypeFromType(type: TaskQueueType) { + switch (type) { + case "NAMED": + return "custom" as const; + case "VIRTUAL": + return "task" as const; + default: + assertExhaustive(type); + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 7d50cc535b..d325ff7de0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -289,10 +289,16 @@ export default function Page() { - {queue.type === "VIRTUAL" ? ( - + {queue.type === "task" ? ( + } + content={`This queue was automatically created from your "${queue.name}" task`} + /> ) : ( - + } + content={`This is a custom queue you added in your code.`} + /> )} {queue.name} From 714e4ecb39cfc8e687b3daf88166644f0ecfa8e4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 19:50:15 +0000 Subject: [PATCH 20/40] Added queues.list() SDK function --- .../webapp/app/components/runs/v3/RunIcon.tsx | 3 + .../v3/QueueListPresenter.server.ts | 30 ++- .../route.tsx | 187 ++++++++++-------- apps/webapp/app/routes/api.v1.queues.ts | 43 ++++ packages/core/src/v3/apiClient/index.ts | 28 +++ packages/core/src/v3/schemas/index.ts | 1 + packages/core/src/v3/schemas/queues.ts | 19 ++ packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/queues.ts | 34 ++++ references/hello-world/src/trigger/queues.ts | 12 ++ 10 files changed, 266 insertions(+), 92 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.queues.ts create mode 100644 packages/core/src/v3/schemas/queues.ts create mode 100644 packages/trigger-sdk/src/v3/queues.ts create mode 100644 references/hello-world/src/trigger/queues.ts diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 0e20333c97..a557e5cd35 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -2,6 +2,7 @@ import { ClockIcon, HandRaisedIcon, InformationCircleIcon, + RectangleStackIcon, Squares2X2Icon, TagIcon, } from "@heroicons/react/20/solid"; @@ -59,6 +60,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "tag": return ; + case "queue": + return ; //log levels case "debug": case "log": diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 941144c30a..36028e51ac 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -3,9 +3,16 @@ import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; import { type TaskQueueType } from "@trigger.dev/database"; import { assertExhaustive } from "@trigger.dev/core"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +const DEFAULT_ITEMS_PER_PAGE = 25; export class QueueListPresenter extends BasePresenter { - private readonly ITEMS_PER_PAGE = 25; + private readonly perPage: number; + + constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) { + super(); + this.perPage = perPage; + } public async call({ environment, @@ -13,7 +20,18 @@ export class QueueListPresenter extends BasePresenter { }: { environment: AuthenticatedEnvironment; page: number; + perPage?: number; }) { + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + }; + } + // Get total count for pagination const totalQueues = await this._replica.taskQueue.count({ where: { @@ -22,12 +40,12 @@ export class QueueListPresenter extends BasePresenter { }); return { + success: true as const, queues: this.getQueuesWithPagination(environment, page), pagination: { currentPage: page, - totalPages: Math.ceil(totalQueues / this.ITEMS_PER_PAGE), - totalItems: totalQueues, - itemsPerPage: this.ITEMS_PER_PAGE, + totalPages: Math.ceil(totalQueues / this.perPage), + count: totalQueues, }, }; } @@ -45,8 +63,8 @@ export class QueueListPresenter extends BasePresenter { orderBy: { name: "asc", }, - skip: (page - 1) * this.ITEMS_PER_PAGE, - take: this.ITEMS_PER_PAGE, + skip: (page - 1) * this.perPage, + take: this.perPage, }); const results = await Promise.all([ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index d325ff7de0..4c77b0e34d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -91,7 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { try { const queueListPresenter = new QueueListPresenter(); - const result = await queueListPresenter.call({ + const queues = await queueListPresenter.call({ environment, page, }); @@ -99,7 +99,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentQueuePresenter = new EnvironmentQueuePresenter(); return typeddefer({ - ...result, + queues, environment: environmentQueuePresenter.call(environment), }); } catch (error) { @@ -169,7 +169,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { environment, queues, pagination } = useTypedLoaderData(); + const { environment, queues } = useTypedLoaderData(); const organization = useOrganization(); const env = useEnvironment(); @@ -255,98 +255,113 @@ export default function Page() {
-
- - - Name - Queued - Running - Concurrency limit - - Pause/resume - - - - - +
+ - -
- -
-
+ Name + Queued + Running + Concurrency limit + + Pause/resume +
- } - > - Error loading queues

} - > - {([queues, environment]) => - queues.length > 0 ? ( - queues.map((queue) => ( - - - - {queue.type === "task" ? ( - } - content={`This queue was automatically created from your "${queue.name}" task`} - /> - ) : ( - } - content={`This is a custom queue you added in your code.`} - /> - )} - {queue.name} - - - {queue.queued} - {queue.running} - - {queue.concurrencyLimit ?? ( - - Max ({environment.concurrencyLimit}) - - )} - - - )) - ) : ( +
+ + -
- No queues found +
+
- ) - } - - - -
+ } + > + Error loading queues

} + > + {([q, environment]) => + q.length > 0 ? ( + q.map((queue) => ( + + + + {queue.type === "task" ? ( + } + content={`This queue was automatically created from your "${queue.name}" task`} + /> + ) : ( + + } + content={`This is a custom queue you added in your code.`} + /> + )} + {queue.name} + + + {queue.queued} + {queue.running} + + {queue.concurrencyLimit ?? ( + + Max ({environment.concurrencyLimit}) + + )} + + + )) + ) : ( + + +
+ No queues found +
+
+
+ ) + } +
+ + + -
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > -
1 && "justify-end border-t border-grid-dimmed px-2 py-3" - )} - > - +
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + )} + > +
1 && + "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+
+ + ) : ( +
+

+ {queues.code === "engine-version" + ? "Please upgrade your engine to v3 to use queues." + : "Something went wrong"} +

-
+ )}
diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts new file mode 100644 index 0000000000..72c1bc0964 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -0,0 +1,43 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +const SearchParamsSchema = z.object({ + page: z.coerce.number().int().positive().optional(), + perPage: z.coerce.number().int().positive().optional(), +}); + +export const loader = createLoaderApiRoute( + { + searchParams: SearchParamsSchema, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ searchParams, authentication }) => { + const service = new QueueListPresenter(searchParams.perPage); + + try { + const result = await service.call({ + environment: authentication.environment, + page: searchParams.page ?? 1, + }); + + if (!result.success) { + return json({ error: result.code }, { status: 400 }); + } + + const queues = await result.queues; + return json({ data: queues, pagination: result.pagination }, { status: 200 }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } + + return json( + { error: error instanceof Error ? error.message : "Internal Server Error" }, + { status: 500 } + ); + } + } +); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index a10171d0ed..ae5b2fc2dc 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -18,8 +18,10 @@ import { EnvironmentVariableResponseBody, EnvironmentVariableValue, EnvironmentVariables, + ListQueueOptions, ListRunResponseItem, ListScheduleOptions, + QueueItem, ReplayRunResponse, RescheduleRunRequestBody, RetrieveBatchV2Response, @@ -716,6 +718,32 @@ export class ApiClient { ); } + listQueues(options?: ListQueueOptions, requestOptions?: ZodFetchOptions) { + const searchParams = new URLSearchParams(); + + if (options?.page) { + searchParams.append("page", options.page.toString()); + } + + if (options?.perPage) { + searchParams.append("perPage", options.perPage.toString()); + } + + return zodfetchOffsetLimitPage( + QueueItem, + `${this.baseUrl}/api/v1/queues`, + { + page: options?.page, + limit: options?.perPage, + }, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + subscribeToRun( runId: string, options?: { diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index 0f0c753122..c2b17a72b6 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -14,3 +14,4 @@ export * from "./runEngine.js"; export * from "./webhooks.js"; export * from "./checkpoints.js"; export * from "./warmStart.js"; +export * from "./queues.js"; diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts new file mode 100644 index 0000000000..b119b48f46 --- /dev/null +++ b/packages/core/src/v3/schemas/queues.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const QueueType = z.enum(["task", "custom"]); +export type QueueType = z.infer; + +export const QueueItem = z.object({ + name: z.string(), + type: QueueType, + running: z.number(), + queued: z.number(), + concurrencyLimit: z.number().nullable(), +}); + +export const ListQueueOptions = z.object({ + page: z.number().optional(), + perPage: z.number().optional(), +}); + +export type ListQueueOptions = z.infer; diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index f83254b8c9..5f00a4a3e1 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -48,6 +48,7 @@ export { } from "./runs.js"; export * as schedules from "./schedules/index.js"; export * as envvars from "./envvars.js"; +export * as queues from "./queues.js"; export type { ImportEnvironmentVariablesParams } from "./envvars.js"; export { configure, auth } from "./auth.js"; diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts new file mode 100644 index 0000000000..27df4669d8 --- /dev/null +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -0,0 +1,34 @@ +import { + apiClientManager, + ApiRequestOptions, + ListQueueOptions, + mergeRequestOptions, + OffsetLimitPagePromise, + QueueItem, +} from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +/** + * Lists schedules + * @param options - The list options + * @param options.page - The page number + * @param options.perPage - The number of schedules per page + * @returns The list of schedules + */ +export function list( + options?: ListQueueOptions, + requestOptions?: ApiRequestOptions +): OffsetLimitPagePromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.list()", + icon: "queue", + }, + requestOptions + ); + + return apiClient.listQueues(options, $requestOptions); +} diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts new file mode 100644 index 0000000000..16cb91b66d --- /dev/null +++ b/references/hello-world/src/trigger/queues.ts @@ -0,0 +1,12 @@ +import { logger, queues, task } from "@trigger.dev/sdk/v3"; + +export const queuesTester = task({ + id: "queues-tester", + run: async (payload: any, { ctx }) => { + const q = await queues.list(); + + for await (const queue of q) { + logger.log("Queue", { queue }); + } + }, +}); From b4a20909f7199be8158eee9f5872340d4b3eea80 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 19:53:49 +0000 Subject: [PATCH 21/40] =?UTF-8?q?Don=E2=80=99t=20return=20more=20than=2010?= =?UTF-8?q?0=20queues=20per=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/presenters/v3/QueueListPresenter.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 36028e51ac..a6b4af5525 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -6,12 +6,13 @@ import { assertExhaustive } from "@trigger.dev/core"; import { determineEngineVersion } from "~/v3/engineVersion.server"; const DEFAULT_ITEMS_PER_PAGE = 25; +const MAX_ITEMS_PER_PAGE = 100; export class QueueListPresenter extends BasePresenter { private readonly perPage: number; constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) { super(); - this.perPage = perPage; + this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE); } public async call({ From 2b8c77d8bf58d28d0fff76fbd20021b5b2d8d6ec Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 18 Mar 2025 19:53:56 +0000 Subject: [PATCH 22/40] Added some JSDocs --- packages/core/src/v3/schemas/queues.ts | 18 ++++++++++++++++++ packages/trigger-sdk/src/v3/queues.ts | 6 +++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts index b119b48f46..a9103bd8ca 100644 --- a/packages/core/src/v3/schemas/queues.ts +++ b/packages/core/src/v3/schemas/queues.ts @@ -1,18 +1,36 @@ import { z } from "zod"; +/** + * The type of queue, either "task" or "custom" + * "task" are created automatically for each task. + * "custom" are created by you explicitly in your code. + * */ export const QueueType = z.enum(["task", "custom"]); export type QueueType = z.infer; export const QueueItem = z.object({ + /** The queue name */ name: z.string(), + /** + * The queue type, either "task" or "custom" + * "task" are created automatically for each task. + * "custom" are created by you explicitly in your code. + * */ type: QueueType, + /** The number of runs currently running */ running: z.number(), + /** The number of runs currently queued */ queued: z.number(), + /** The concurrency limit of the queue */ concurrencyLimit: z.number().nullable(), }); +export type QueueItem = z.infer; + export const ListQueueOptions = z.object({ + /** The page number */ page: z.number().optional(), + /** The number of queues per page */ perPage: z.number().optional(), }); diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts index 27df4669d8..67eee5daf4 100644 --- a/packages/trigger-sdk/src/v3/queues.ts +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -9,11 +9,11 @@ import { import { tracer } from "./tracer.js"; /** - * Lists schedules + * Lists queues * @param options - The list options * @param options.page - The page number - * @param options.perPage - The number of schedules per page - * @returns The list of schedules + * @param options.perPage - The number of queues per page + * @returns The list of queues */ export function list( options?: ListQueueOptions, From 4ee85cbe8ce77d62ac8182d2d712313d90b04266 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 11:08:56 +0000 Subject: [PATCH 23/40] Git ignore the react hooks src/package.json --- .gitignore | 1 + packages/react-hooks/src/package.json | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 packages/react-hooks/src/package.json diff --git a/.gitignore b/.gitignore index 72851005b7..2e3a5ed3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ apps/**/public/build *.tsbuildinfo /packages/cli-v3/src/package.json .husky +/packages/react-hooks/src/package.json diff --git a/packages/react-hooks/src/package.json b/packages/react-hooks/src/package.json deleted file mode 100644 index 5bbefffbab..0000000000 --- a/packages/react-hooks/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} From e2da1810b85820e297b860e28bb2100d740f3e84 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 11:09:16 +0000 Subject: [PATCH 24/40] WIP on retrieving a queue using the SDK/API --- .../v3/QueueListPresenter.server.ts | 34 +++--- .../v3/QueueRetrievePresenter.server.ts | 108 ++++++++++++++++++ .../app/routes/api.v1.queues.$queueParam.ts | 45 ++++++++ apps/webapp/app/routes/api.v1.queues.ts | 3 +- .../routeBuilders/apiBuilder.server.ts | 9 +- packages/core/src/v3/apiClient/index.ts | 16 +++ packages/core/src/v3/schemas/queues.ts | 41 ++++++- packages/trigger-sdk/src/v3/queues.ts | 53 +++++++++ references/hello-world/src/trigger/queues.ts | 9 ++ 9 files changed, 293 insertions(+), 25 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts create mode 100644 apps/webapp/app/routes/api.v1.queues.$queueParam.ts diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index a6b4af5525..e735dbe9b5 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -1,9 +1,8 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; -import { type TaskQueueType } from "@trigger.dev/database"; -import { assertExhaustive } from "@trigger.dev/core"; -import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { toQueueItem } from "./QueueRetrievePresenter.server"; const DEFAULT_ITEMS_PER_PAGE = 25; const MAX_ITEMS_PER_PAGE = 100; @@ -57,6 +56,7 @@ export class QueueListPresenter extends BasePresenter { runtimeEnvironmentId: environment.id, }, select: { + friendlyId: true, name: true, concurrencyLimit: true, type: true, @@ -80,23 +80,15 @@ export class QueueListPresenter extends BasePresenter { ]); // Transform queues to include running and queued counts - return queues.map((queue) => ({ - name: queue.name.replace(/^task\//, ""), - type: queueTypeFromType(queue.type), - running: results[1][queue.name] ?? 0, - queued: results[0][queue.name] ?? 0, - concurrencyLimit: queue.concurrencyLimit ?? null, - })); - } -} - -export function queueTypeFromType(type: TaskQueueType) { - switch (type) { - case "NAMED": - return "custom" as const; - case "VIRTUAL": - return "task" as const; - default: - assertExhaustive(type); + return queues.map((queue) => + toQueueItem({ + friendlyId: queue.friendlyId, + name: queue.name, + type: queue.type, + running: results[1][queue.name] ?? 0, + queued: results[0][queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, + }) + ); } } diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts new file mode 100644 index 0000000000..67de0b217b --- /dev/null +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -0,0 +1,108 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { engine } from "~/v3/runEngine.server"; +import { BasePresenter } from "./basePresenter.server"; +import { type TaskQueueType } from "@trigger.dev/database"; +import { assertExhaustive } from "@trigger.dev/core"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3"; + +export class QueueRetrievePresenter extends BasePresenter { + public async call({ + environment, + queueInput, + }: { + environment: AuthenticatedEnvironment; + queueInput: RetrieveQueueParam; + }) { + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + }; + } + + const queue = await this.getQueue(environment, queueInput); + if (!queue) { + return { + success: false as const, + code: "queue-not-found", + }; + } + + const results = await Promise.all([ + engine.lengthOfQueues(environment, [queue.name]), + engine.currentConcurrencyOfQueues(environment, [queue.name]), + ]); + + // Transform queues to include running and queued counts + return { + success: true as const, + queue: toQueueItem({ + friendlyId: queue.friendlyId, + name: queue.name, + type: queue.type, + running: results[1]?.[queue.name] ?? 0, + queued: results[0]?.[queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, + }), + }; + } + + private async getQueue(environment: AuthenticatedEnvironment, queue: RetrieveQueueParam) { + if (typeof queue === "string") { + return this._replica.taskQueue.findFirst({ + where: { + friendlyId: queue, + runtimeEnvironmentId: environment.id, + }, + }); + } + + const queueName = + queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name; + return this._replica.taskQueue.findFirst({ + where: { + name: queueName, + runtimeEnvironmentId: environment.id, + }, + }); + } +} + +function queueTypeFromType(type: TaskQueueType) { + switch (type) { + case "NAMED": + return "custom" as const; + case "VIRTUAL": + return "task" as const; + default: + assertExhaustive(type); + } +} + +/** + * Converts raw queue data into a standardized QueueItem format + * @param data Raw queue data containing required queue properties + * @returns A validated QueueItem object + */ +export function toQueueItem(data: { + friendlyId: string; + name: string; + type: TaskQueueType; + running: number; + queued: number; + concurrencyLimit: number | null; +}): QueueItem { + return { + id: data.friendlyId, + //remove the task/ prefix if it exists + name: data.name.replace(/^task\//, ""), + type: queueTypeFromType(data.type), + running: data.running, + queued: data.queued, + concurrencyLimit: data.concurrencyLimit, + }; +} diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts new file mode 100644 index 0000000000..f17aeac8ad --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts @@ -0,0 +1,45 @@ +import { json } from "@remix-run/server-runtime"; +import { type QueueItem, type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const SearchParamsSchema = z.object({ + type: RetrieveQueueType.default("id"), +}); + +export const loader = createLoaderApiRoute( + { + params: z.object({ + queueParam: z.string(), + }), + searchParams: SearchParamsSchema, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ params, searchParams, authentication }) => { + const input: RetrieveQueueParam = + searchParams.type === "id" + ? params.queueParam + : { + type: searchParams.type, + name: decodeURIComponent(params.queueParam), + }; + + const presenter = new QueueRetrievePresenter(); + const result = await presenter.call({ + environment: authentication.environment, + queueInput: input, + }); + + if (!result.success) { + if (result.code === "queue-not-found") { + return json({ error: result.code }, { status: 404 }); + } + + return json({ error: result.code }, { status: 400 }); + } + + const q: QueueItem = result.queue; + return json(q); + } +); diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index 72c1bc0964..d5360782b4 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -1,4 +1,5 @@ import { json } from "@remix-run/server-runtime"; +import { type QueueItem } from "@trigger.dev/core/v3"; import { z } from "zod"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; @@ -27,7 +28,7 @@ export const loader = createLoaderApiRoute( return json({ error: result.code }, { status: 400 }); } - const queues = await result.queues; + const queues: QueueItem[] = await result.queues; return json({ data: queues, pagination: result.pagination }, { status: 200 }); } catch (error) { if (error instanceof ServiceValidationError) { diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index d28945218f..fae78713db 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -39,7 +39,12 @@ type ApiKeyRouteBuilderOptions< params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined, - authentication: ApiAuthenticationResultSuccess + authentication: ApiAuthenticationResultSuccess, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined ) => Promise; shouldRetryNotFound?: boolean; authorization?: { @@ -179,7 +184,7 @@ export function createLoaderApiRoute< } // Find the resource - const resource = await findResource(parsedParams, authenticationResult); + const resource = await findResource(parsedParams, authenticationResult, parsedSearchParams); if (!resource) { return await wrapResponse( diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index ae5b2fc2dc..dbfa072cb6 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -25,6 +25,7 @@ import { ReplayRunResponse, RescheduleRunRequestBody, RetrieveBatchV2Response, + RetrieveQueueParam, RetrieveRunResponse, ScheduleObject, TaskRunExecutionResult, @@ -744,6 +745,21 @@ export class ApiClient { ); } + retrieveQueue(queue: RetrieveQueueParam, requestOptions?: ZodFetchOptions) { + const type = typeof queue === "string" ? "id" : queue.type; + const value = typeof queue === "string" ? queue : queue.name; + + return zodfetch( + QueueItem, + `${this.baseUrl}/api/v1/queues/${encodeURIComponent(value)}?type=${type}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + subscribeToRun( runId: string, options?: { diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts index a9103bd8ca..181b07752f 100644 --- a/packages/core/src/v3/schemas/queues.ts +++ b/packages/core/src/v3/schemas/queues.ts @@ -1,14 +1,21 @@ import { z } from "zod"; +const queueTypes = ["task", "custom"] as const; + /** * The type of queue, either "task" or "custom" * "task" are created automatically for each task. * "custom" are created by you explicitly in your code. * */ -export const QueueType = z.enum(["task", "custom"]); +export const QueueType = z.enum(queueTypes); export type QueueType = z.infer; +export const RetrieveQueueType = z.enum([...queueTypes, "id"]); +export type RetrieveQueueType = z.infer; + export const QueueItem = z.object({ + /** The queue id, e.g. queue_12345 */ + id: z.string(), /** The queue name */ name: z.string(), /** @@ -35,3 +42,35 @@ export const ListQueueOptions = z.object({ }); export type ListQueueOptions = z.infer; + +/** + * When retrieving a queue you can either use the queue id, + * or the type and name. + * + * @example + * + * ```ts + * // Use a queue id (they start with queue_ + * const q1 = await queues.retrieve("queue_12345"); + * + * // Or use the type and name + * // The default queue for your "my-task-id" + * const q2 = await queues.retrieve({ type: "task", name: "my-task-id"}); + * + * // The custom queue you defined in your code + * const q3 = await queues.retrieve({ type: "custom", name: "my-custom-queue" }); + * ``` + */ +export const RetrieveQueueParam = z.union([ + z.string(), + z.object({ + /** "task" or "custom" */ + type: QueueType, + /** The name of your queue. + * For "task" type it will be the task id, for "custom" it will be the name you specified. + * */ + name: z.string(), + }), +]); + +export type RetrieveQueueParam = z.infer; diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts index 67eee5daf4..f62e04ef19 100644 --- a/packages/trigger-sdk/src/v3/queues.ts +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -1,10 +1,13 @@ import { + accessoryAttributes, apiClientManager, + ApiPromise, ApiRequestOptions, ListQueueOptions, mergeRequestOptions, OffsetLimitPagePromise, QueueItem, + RetrieveQueueParam, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; @@ -32,3 +35,53 @@ export function list( return apiClient.listQueues(options, $requestOptions); } + +/** + * When retrieving a queue you can either use the queue id, + * or the type and name. + * + * @example + * + * ```ts + * // Use a queue id (they start with queue_ + * const q1 = await queues.retrieve("queue_12345"); + * + * // Or use the type and name + * // The default queue for your "my-task-id" + * const q2 = await queues.retrieve({ type: "task", name: "my-task-id"}); + * + * // The custom queue you defined in your code + * const q3 = await queues.retrieve({ type: "custom", name: "my-custom-queue" }); + * ``` + * @param queue - The ID of the queue to retrieve, or the type and name + * @returns The retrieved queue + */ +export function retrieve( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.retrieve()", + icon: "queue", + attributes: { + queue: typeof queue === "string" ? queue : queue.name, + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.retrieveQueue(queue, $requestOptions); +} diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts index 16cb91b66d..193fbada87 100644 --- a/references/hello-world/src/trigger/queues.ts +++ b/references/hello-world/src/trigger/queues.ts @@ -8,5 +8,14 @@ export const queuesTester = task({ for await (const queue of q) { logger.log("Queue", { queue }); } + + const retrievedFromId = await queues.retrieve(ctx.queue.id); + logger.log("Retrieved from ID", { retrievedFromId }); + + const retrievedFromName = await queues.retrieve({ + type: "task", + name: ctx.queue.name, + }); + logger.log("Retrieved from name", { retrievedFromName }); }, }); From c2038b51a8e0f9398fc0dc6522110185fe5cd7ec Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 11:18:19 +0000 Subject: [PATCH 25/40] Retrieving a queue is working well --- apps/webapp/app/routes/api.v1.queues.$queueParam.ts | 4 ++-- packages/core/src/v3/apiClient/index.ts | 5 ++++- packages/trigger-sdk/src/v3/queues.ts | 3 ++- references/hello-world/src/trigger/queues.ts | 8 +++++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts index f17aeac8ad..a9bcd2342e 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts @@ -11,7 +11,7 @@ const SearchParamsSchema = z.object({ export const loader = createLoaderApiRoute( { params: z.object({ - queueParam: z.string(), + queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), searchParams: SearchParamsSchema, findResource: async () => 1, // This is a dummy function, we don't need to find a resource @@ -22,7 +22,7 @@ export const loader = createLoaderApiRoute( ? params.queueParam : { type: searchParams.type, - name: decodeURIComponent(params.queueParam), + name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"), }; const presenter = new QueueRetrievePresenter(); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index dbfa072cb6..d62abb7ab4 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -749,9 +749,12 @@ export class ApiClient { const type = typeof queue === "string" ? "id" : queue.type; const value = typeof queue === "string" ? queue : queue.name; + // Explicitly encode slashes before encoding the rest of the string + const encodedValue = encodeURIComponent(value.replace(/\//g, "%2F")); + return zodfetch( QueueItem, - `${this.baseUrl}/api/v1/queues/${encodeURIComponent(value)}?type=${type}`, + `${this.baseUrl}/api/v1/queues/${encodedValue}?type=${type}`, { method: "GET", headers: this.#getHeaders(false), diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts index f62e04ef19..54f009226f 100644 --- a/packages/trigger-sdk/src/v3/queues.ts +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -3,6 +3,7 @@ import { apiClientManager, ApiPromise, ApiRequestOptions, + flattenAttributes, ListQueueOptions, mergeRequestOptions, OffsetLimitPagePromise, @@ -68,7 +69,7 @@ export function retrieve( name: "queues.retrieve()", icon: "queue", attributes: { - queue: typeof queue === "string" ? queue : queue.name, + ...flattenAttributes({ queue }), ...accessoryAttributes({ items: [ { diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts index 193fbada87..d2c540fb64 100644 --- a/references/hello-world/src/trigger/queues.ts +++ b/references/hello-world/src/trigger/queues.ts @@ -12,10 +12,16 @@ export const queuesTester = task({ const retrievedFromId = await queues.retrieve(ctx.queue.id); logger.log("Retrieved from ID", { retrievedFromId }); - const retrievedFromName = await queues.retrieve({ + const retrievedFromCtxName = await queues.retrieve({ type: "task", name: ctx.queue.name, }); + logger.log("Retrieved from name", { retrievedFromCtxName }); + + const retrievedFromName = await queues.retrieve({ + type: "task", + name: "queues-tester", + }); logger.log("Retrieved from name", { retrievedFromName }); }, }); From c74d57f9fd2156dd01eedafc5f3f2a5633127144 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 11:41:34 +0000 Subject: [PATCH 26/40] Fix for type issue --- .../SelectBestEnvironmentPresenter.server.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index e663250a45..67abdc808e 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -1,4 +1,8 @@ -import { type RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database"; +import { + type RuntimeEnvironment, + type PrismaClient, + RuntimeEnvironmentType, +} from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { type UserFromSession } from "~/services/session.server"; @@ -135,11 +139,9 @@ export class SelectBestEnvironmentPresenter { return projects.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).at(0); } - async selectBestEnvironment( - projectId: string, - user: UserFromSession, - environments: MinimumEnvironment[] - ): Promise { + async selectBestEnvironment< + T extends { id: string; type: RuntimeEnvironmentType; orgMember: { userId: string } | null } + >(projectId: string, user: UserFromSession, environments: T[]): Promise { //try get current environment from prefs const currentEnvironmentId: string | undefined = user.dashboardPreferences.projects[projectId]?.currentEnvironment.id; From d8b114465f2e8d86663e0d7b3f5e5cb1516873a2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 11:41:46 +0000 Subject: [PATCH 27/40] use TypedAwait --- .../route.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 4c77b0e34d..606ffd9d24 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -9,7 +9,7 @@ import { import { Await, Form, useNavigation, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useEffect, useState } from "react"; -import { typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -194,7 +194,7 @@ export default function Page() {
}> - + {(environment) => ( )} - + }> - + {(environment) => } - + }> - + {(environment) => ( )} - +
@@ -281,9 +281,9 @@ export default function Page() { } > - Error loading queues

} + errorElement={Error loading queues} > {([q, environment]) => q.length > 0 ? ( @@ -321,14 +321,14 @@ export default function Page() { ) : ( -
+ No queues found -
+
) } -
+ @@ -419,7 +419,9 @@ function EnvironmentPauseResumeButton({ {env.paused ? `This will allow runs to be dequeued in ${environmentFullTitle(env)} again.` - : `This will pause any runs from being dequeued in ${environmentFullTitle(env)}.`} + : `This will pause all runs from being dequeued in ${environmentFullTitle( + env + )}. Any executing runs will continue to run.`}
setIsOpen(false)}> Date: Wed, 19 Mar 2025 11:49:20 +0000 Subject: [PATCH 28/40] Queues page promise fix --- .../route.tsx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 606ffd9d24..cd3b97ac84 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -99,7 +99,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentQueuePresenter = new EnvironmentQueuePresenter(); return typeddefer({ - queues, + ...queues, environment: environmentQueuePresenter.call(environment), }); } catch (error) { @@ -169,7 +169,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { environment, queues } = useTypedLoaderData(); + const { environment, queues, success, pagination, code } = useTypedLoaderData(); const organization = useOrganization(); const env = useEnvironment(); @@ -255,7 +255,7 @@ export default function Page() {
- {queues.success ? ( + {success ? ( <> @@ -282,11 +282,11 @@ export default function Page() { } > Error loading queues} > - {([q, environment]) => - q.length > 0 ? ( + {([q, environment]) => { + return q.length > 0 ? ( q.map((queue) => ( @@ -321,13 +321,13 @@ export default function Page() { ) : ( - +
No queues found - +
- ) - } + ); + }}
@@ -336,19 +336,18 @@ export default function Page() {
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + pagination.totalPages > 1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" )} >
1 && - "justify-end border-t border-grid-dimmed px-2 py-3" + pagination.totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3" )} >
@@ -356,7 +355,7 @@ export default function Page() { ) : (

- {queues.code === "engine-version" + {code === "engine-version" ? "Please upgrade your engine to v3 to use queues." : "Something went wrong"}

From 57a57b4b87a4bdf3a7eda391e55cf6f2edca2a84 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 13:07:19 +0000 Subject: [PATCH 29/40] InfoBox storybook file --- .../app/routes/storybook.info-panel/route.tsx | 118 ++++++++++++++++++ apps/webapp/app/routes/storybook/route.tsx | 4 + 2 files changed, 122 insertions(+) create mode 100644 apps/webapp/app/routes/storybook.info-panel/route.tsx diff --git a/apps/webapp/app/routes/storybook.info-panel/route.tsx b/apps/webapp/app/routes/storybook.info-panel/route.tsx new file mode 100644 index 0000000000..0a5b4e3e77 --- /dev/null +++ b/apps/webapp/app/routes/storybook.info-panel/route.tsx @@ -0,0 +1,118 @@ +import { + BeakerIcon, + BellAlertIcon, + BookOpenIcon, + ClockIcon, + InformationCircleIcon, + PlusIcon, + RocketLaunchIcon, + ServerStackIcon, + Squares2X2Icon, +} from "@heroicons/react/20/solid"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; + +export default function Story() { + return ( +
+
+ {/* Basic Info Panel */} + + This is a basic info panel with title and default variant + + + {/* Info Panel with Button */} + + This panel includes a button in the top-right corner + + + {/* Upgrade Variant with Button */} + + This panel uses the upgrade variant with a call-to-action button + + + {/* Minimal Variant */} + + A minimal variant without a title + + + {/* Task Panel with Action */} + + A panel showing task information with a view action + + + {/* Getting Started Panel */} + + Begin your journey with our quick start guide + + + {/* Deployment Panel with Button */} + + Ready to deploy your changes to production + + + {/* Create New Panel */} + + Start a new project with our guided setup + + + {/* Batches Panel */} + + Information about batch processing + + + {/* Documentation Panel with Link */} + + Access our comprehensive documentation + +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index bd451f6147..995bfdf50e 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -52,6 +52,10 @@ const stories: Story[] = [ name: "Free plan usage", slug: "free-plan-usage", }, + { + name: "Info panel", + slug: "info-panel", + }, { name: "Inline code", slug: "inline-code", From 8ddaa626b633c2797131c0057e0129c728f035b9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 13:13:52 +0000 Subject: [PATCH 30/40] WIP on upgrade panel --- .../route.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index cd3b97ac84..6f43e4cfc7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -53,6 +53,7 @@ import { } from "~/components/primitives/Tooltip"; import { type RuntimeEnvironmentType } from "@trigger.dev/database"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; +import { Callout } from "~/components/primitives/Callout"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -100,6 +101,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typeddefer({ ...queues, + success: false, + code: "engine-version", environment: environmentQueuePresenter.call(environment), }); } catch (error) { @@ -354,11 +357,22 @@ export default function Page() { ) : (
-

- {code === "engine-version" - ? "Please upgrade your engine to v3 to use queues." - : "Something went wrong"} -

+ {code === "engine-version" ? ( +
+
+

New queues table

+ + Upgrade guide + +
+
+ ) : ( + Something went wrong + )}
)}
From 34a178f169f3f02b4317cf4bca34d63007f494f4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 13:22:31 +0000 Subject: [PATCH 31/40] Added upgrade panel --- .../app/assets/images/queues-dashboard.png | Bin 0 -> 189419 bytes .../route.tsx | 40 ++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 apps/webapp/app/assets/images/queues-dashboard.png diff --git a/apps/webapp/app/assets/images/queues-dashboard.png b/apps/webapp/app/assets/images/queues-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..321c79e6290abd1dd4ae133225c1e77b6f34ee48 GIT binary patch literal 189419 zcmdSBXH=8v76uv>1slj9pj3rXlq#Zh2qHzLOYaKOq)7`c5L6T;5_FUzkbv}>Nbf-r z$VdxCiU|-Qgis8daV*)fyiJMfcn@^wZCQVz;`Yj~AswL|j!C4Z%( z*`J?e$#NW=mwjxb4?TW2@nEe`qSUt=CPS^7>`}eNTlG;Yi3<$rG0WOVcP@1u;>xnr zav=5IjMcy$4(E2bU}^yQ&L*Pu?v&2?qwl|VO)rwJ?}Vb~g7N!bhIpu^rQ`TuRP&ah z35&&A+GuodUisT~>jKEVRM#N+^cK-6{K5fXrvJP?StzOf`vwFGK6=3O*GC{wmN$pi zq2F&EZ-NAVznc{}*!TOL*2t-t{pJ7n)$km2?bp9SpuX#DS-&3=gFX*B`TN7*lmE{= zGUAWl&k#?$?roK(DJK=g7rnaJVdgxouAM0V& z%?E?=+2Vr(Tg;SiTOES~78ML%tDVlms<{vFnk zYi*{-fPRGiS?2wWDgR4;K{MUA$wQmR`e3UFykY%vkj?f?;_1&njc3zgx454yzE@NF zeJ+EzpDIBEKYrXh*x~KSXAMqgGDm*z>y_q0#tB+K zx+SlMJz61qCc%+i`I;@o~={uarr~u zy>cR?N+xb@$eNXUX7Qc5@og$H)Jb)%v@-rjP}#{u&9q^Rv-8;RA>UiXh{WxzQTewR z49ta?H#*zlv}gPUy6}|KU|8S9~V9m}a7}VL_;HGk8*@2Z|61JUoje7lNAId(y!CE_t@l*-9E8L^hTfEv>okMubqI{?{+9* zC;f%~yWSXdf8}yQ5jDi$xRNR(89mX+sBQN(p$sGDaAHsJqRizuxV<_$WNUmYm+04M zd8*;5STGa01T9eR$lRNj;K@k`j^MnMW@6Xv9IM%7QWW!iiWwE;F3%nXz6(Mf2< z2i(<%Mo-HbbxC3{cGdG)bJ-7rp%ZN+)nDgp=yu$sNm)j7gJru_Jub&GAe65RuM_e` zX_=eZ-$K_j^TPG_yvFC8!>3tilADD}I3}VQ3(gp;C`41QYoZ2%qPRCI{7Lhw>5Js%&tdxgu(4?ucfNff4ig4V_Pzv)mb| zPO;o=iX2SE5jGye^GEmMk&$=WD1OIl8#kG+NNOfvH6*0t`%tEH)JD=egXn)*1k=3y zSC!|=UsC~Wx%_gre?_nSi1NzcX_L?lFfCNkRPe*jhi09)n#kZlf>XCe$h@jKo zh6D^^WY!yn>mbI{KRXg7MGvwf)Ap~> z+ZlYO^Vrr_OETed@eQ1B?!$a;K6Sv%1|F20T+yRYMr*E9k=Enb!nUSp#_rLR7dslz z4fDnGdUEn7I)#~m#aHkr~+ z=1|d}4PQvqv|`JYxQhlgSN8B`eYsp>m2f7;ujWp0fp5{#&+Yo88Kd+82ACCYMhhEa z{%~k~@{)VD>pu2P+k&7S2fbaf!&mwPcBQ*(y?V7wg6lZAr7Fb0E|gy8buX)B*MaRr zOJrB=AmO;2ah!~B15_tAi@cM-TDM6%+N-wIbRUc(_1~JL`Y;?rc{86JkgbI78-y>%J0us&#FB4t&2 z2?clC`@s$_Y((o3l?dwMUl$U|HcrFjM6l)*v|O0q85*EUex;D6YHVvr|G*VhsOsFr zRo?q%27Yf(g_>b5lw;#Bgu{tMs*gj6W#+}Zp(BU0yqovHn6%~PAQSvpXPLxFPR@MC zJT2G8QsE0%95{P*M=^>q{F6-%!W;c31>+w%iaRq`Q%Vj@_(REpMUG-ZDPMx55Mb;s z)Z)*CANT5*y9;o7wfBhCmmcTcft2Sm66`B3XT!nz$5-2f%bo9=Q@E;~vaYgMl5LVp zuHpj4zbW1Qi0N-i(0;t))W#GxOWRDlTNqHa^+L94>C=}46AR|oQdYqDSFuyV=Vyn! zHD6ny!Uk@v8GEdRTG%sGnEC0|=+#;)rt~XHJGJe(yoIWt?jB9Qu)%3r1j6pk9jLNn6dd;BSH!d7&%o9F%4rqVas-8sSqHtw-q z-nBUs(3P-Ua^!cn6yEc+iqyripcKm)mCI2ZCC+7n>X|1Tg<~daz3mCjjM}2D7ht2f z(rvA@>;7hGj`g~3v&Nl6nRKE9BmB$rle(6J+fNf;+clqajvWpB7zPB#p<``sDx-XZ zx~9I4MwZ{CTidw@j;BivR+;Z`dEK8&lvuJ48)=+Kk_CKa+~M>tfx|1EH?SOr`pb2t zO^65=W?``%=bs+(&SO*65$D(6%=CQt#Br;G{jTOrczE@~SEp2M51l>{?g;c&-J8f^ z)+G(rn1YcBSXjteRbI^dLX%J?Y~{Tflc>7Tt)M0lgJ_{iX|BD0io(MdDEO%`Aqns_ zkD_W(zlU26&x0w2QYQ}8gOrVAp3L&{QUZQxdyiB5Ul2a-v9n5PkBUu8FPo=-GdJP= z{Qa%5YPbz3LR##F;N$7KL@i&7V1)0>%$$P(Yymk_0Ty})Do!ey_wkR>kwQ*n%GrhZ zJEJRhY_P7!KT;*E8_`IK8q9=xLYunEp3qR$5|+Lw=5e+wk?QKWG5rHV3y}2JZ<3IQ zN!Lz5xgWLAYKZYWLAnd6nttW}Dp29BuaneO#f~*=r*OBB_xs_0(Bg_(NO|GjSZH@< z-Uy~%z6Z>F+D_gNq4QP0FjO8y(wY--&RN4F8RcB-=lF1%Nnes%6yJlmKXpTfPJ|EP zLTgk&j+*5AkxwfH1H)t`bM+2ShvfNQV&4@D<0ta-7K?kPe2L|rZp^=Nrv4296eKN` zEclqBW^iN0CX_QSC5f7_e9y;Z0xfj$)wy7_mat2U3#;?|`nC#bHL`vEj~g>qt=(nD zB-%rKZhT9kp5;rymv8ktldG3)`--<46X*BbxLaj=r+0s_!|kE(3pLJ*Ta%aGar`y% zJeeFMQ*)taE(6S3a}n&q)j*SCDhLdCxGWl44|i+7I<*40{hg0qQ$c$#I7LE#g&9?G z>)R9MIXgnrgC=0dF*$okm;mNdR@#?#GhLOq2=PaPlk-B7h)1ho6v?k2n$V}tlQ@*( zI9SOg7pSi5Peu2BWQAu>1)>T7pR&K2z&GDVSz&0iztyZCqBV>+YI2%rlx2wF+Y&`w zglp8!c5*E%_pPzyq9}Cv!+qkJh3+J@>w9s_J=85n#{;U;4k}FNMJvj&J~g7_f!%)n z5?(dDePw>3mV2H_!80Oiw> zt`*vp!VBW0YMS?$ZB?Y=?sO?rs^o-y2B3BaIO2#>ZF(6Prvkx-d(G=6fE{D zFtO0|?z%zRKCw-b%v7V`dwb}I`uEXm2+IYfd^FEoLbZ)*VLS1>Vq0jpgh5Pdr<%tW zbY+%RTh@0;Kw`s$GONooR?BTFSuEaPCo(QhwSTA(-*buG7dM{~)O&eR+@UGQ_{|W< z54{&ktu^IX;giS870Eyp0&eH1J%$j%26cw6w2Li6JI;DoNQrrng15vuzGckeE2x0is!DfUnb+H2OQ_R zgPe;+b7|piw>z%AiHMl7^3(8o^ymsrVAh1jH}ph&FI`G00f)P*B z+mWAKr$>k_dSbB|`#6)mS!D9_C>jb4e4$c$xku14(|d%Uo%%m?wAHh+zcY@BfdALX%?Auwx{A^gjVAOnlyqP{-QtIqUF?@DVvH11aE1@HZd;U2g?JF;` z)++A0&wPjzk<|Wrb0~@5Dmwb(C?arU#(mmN&$PZ@yyvEWHHm!EM_0xt`G3<2`~d(?nbM z*AE*hkuuQVMG`g(V5$!9u z#0F7$$NDEh>YCZc9Ba==4D{~yc4wTvlOLzROvnh2rKzR%vwm8tn8$(ZIeXvV_>yYM z7ODD$C-qWH<~<7t@jdsunE09qv7hSFAjcg;DVFo!5H4^B@Gll7rbt{; zB#u`qSxLi$bJXwqs*3QbM(1O#QxauzLG@G&Lh>v3Y3yt^Jf7A~ z6u9x@7rrri_9VTiMtGr!UmB0|%H5bA5z7_sJin;-d0wyUr6k^&wL1cLo0jUwL3EeZ z4h|$p+L#j0q>v z<8kHaH^-=doJjAro17*^Fo{2UmQyaz8_^L1xQg_ekHedsM&j7LSYoy^YNzz6x{X zpP=z9^EK!N(CSE`64OJS}*V_ zohp59N8>z)SjVZO&INW84G$Bd=u7(aACD_I6fl}gds8cK^o7$GZCN?o1oUJ}E!Jj{RfvaSK!54x<~o8kFa3M=ge5fEXS6AxTek6MMes^HzqORE(qD(h zxb^%_E8=lAR+`4#I)0}g^sZ?#)=gilTfaqwTtB8o-O@;AeLBQtQF}Q*k$jpcR6AOG z3bzD+f9Yr?7wq2NgFj;YZu}1zKg1|97c<>_X&@(T1%xC!r`ndkf)t{oSqxo}s!r-| ze!8DPO{u%&ZKZ*thb3QnHP4*CX2S_qq8VQeYk`p1N81b#e7BZU5#?Bp@I zV~`?(8Xk}K7AV{qo2AhVyycpdg=>Os$HNOMmHTt7*nD!HK>1)M9yG48=lTY`3*x2@ zf&;#r)2|^mun|WYBhpL!GlE+Oz6_c_Ld;jj2F%4PV^_egL&cWt!qiik{cMUC10xHP zZvEQv;7pEky%?o~WoBnp*xkf`2w^C%{Vlu@%ieYL#YoO$-)27gKG=O&a($$+GyFxL zuQY69eki2ms{SdZ;d-|&_#D9-Q=}={m8kV7Hro43cYRt&S)52jq0I=8KH0N1dms8+ z-sFLMt8eYNRjD|tk+DgHQ#DyTc+o1r!@JmvomRi=HmB^A&^lwQ?rfrfo6~Yz#FgN2 zk!RL`us7fEVJWnE!LdD3l*r4A{xZ_S+;FkoD7M30b$MSdgA=v}mumF0W!b+j0dIwV z+zq-ku$HdK`=PQ9dAGi7-y4i5TA~u|E5!`Y)Y|lhdk>$UoOk%FAY#(~(J%JQnj)&= z;m}8|2ez>nD&1R0xqreL38s`=L+<3Ax}hVOwSZS6Q`iZJZk@XJPU1q*- zx4C-t1MC&jBg1h|=m0g*akXfl{xHH|cX3no?QkxcR=m99COmFYY6f|9%3qFUJ^MDIL)I)qvQnuFp$h znyvWr#g`KQOt$1q*Eg-NNehlm!bN0g$G+{tX-?p5X6mVEv=1ry{#tv*D*dl7`13VV zPH@`GC>eG%S-D+afRI`AMvXVd@AEH~_in*>@LJDY8sqaP7_6Olh&obcpJK=Rc#7k! zo@bemK&kcP5Xno{0SWU%+~3eAcO6Z`HRF~Q-xaqaW(tx3oGZS31~;|4fDzq{P-m5O z#@RbTpraI1H%&zQ?g(1rl8>$Q+JHd`7;I;rP$N`hbva@;a{B{4o-XvnsVVIC?%&V& z`OnXq%I)nfKi$fE|A1ivQ_yAX0nLo<_|>6g;hRE$KX4)@x%wa^LAMgb%vdC00sWN!M^Yw*atuXPjO z0jeAQ<`jLCCZ#=L8;d7HsKTMACBk(=W~Gb&Er48U(2xVa-xO|^Iq9?GMgVfom9dOx z{)R!U=^n4)CfyL~%ei@@md1)w$abeS_U=hReJ5G%ntGRjUQCa9m+p z#?wmfG{iaDWSpGAQpCvFxyS4xhE!DKqsdkqIxu+6F#HEGkrxV4;(zh@_0Y6YClD-6`s`nwKa{Prca{ZX3`PSO@)HF_UPHt`pvpH;QfOa@mo0?jD@o?x;z5+E3Af?#V)D zZ;DE`j?0@&!&d|1O*bvhfySTw6ThUi;y6B1J~^snDA_$5j1bFBd0MY>?aX8BKg=ty z?z4r_nd6)6NvzE@?|~kraf_35-DR}UH7xpKoBCfieip3=iqmp`BHtFbWTLAuEQ?za zfBrLvMRH;&O35iPzRKD;Dulc3m8-3qe=}K7ly9f`Rd`UU#_;$ZOaBJ_*sR*--8*(b zu$!WC+<^W33udjpNTZb)hvIid@u{77H-O$QQRe>OV748PhvwJeC#SaQ+a;agL1p># ztnuOJnO}NBBu|YT$#&HUD2ZY=t@Rq@*jn!jo!psz6>)&-EtzRcIkpi{@ihgz-9C?3 zdpPs*N=WltF&ANRul#rjA6+;zkK!tfleYFloZ5tyLS>VD3!z7gx4;rl`if4+_?`ao zs{(efj%G4uH#V;_z0H-cTOx~LHhFi+4c6$lBpq?ucAL-Tzyo+8v-J4`@mPDg7ixFq zKXDNCKuQNIxi9q|nC6fmJ^E4`FWI)Cb4mGNwOK+Pq}gmoWu}K0Rk^KFEDxJJ?tCdf z@k;Q-a}H^x;)B9F&HMao`vS$cdu<|&obW~R64jMwp%a&{Zi1c{s&${Hp=(QRU~K*V zEsV`?^8uZ7N4Zpy$1@yPMtZF$HVI@w+$Dckw!AJ~p|ybGTJ+Z|3kmunjdIof0V)1I zUc1}kQFCY$pi-hSWiq%hGB)~XmxN)he+19E@$cm-=X|g=fqA1T5L(gYh%YayPa@}z(%K}P^&}Q4ht|-CY+9i$f z_wKBLP0Her<#4wirz|^`aU2?c&av=v$NOnwc~m*_d~0ODMQz(y2@Lw^?od4W?1wHw z)&n1WO3NPQ{`pp`nS>}w>o+G|qvU2q;$$4HETFP+s4t(iVEfeRTwd8*I@kUFzdqdg zwqFD_s@iTW)SzE#Bl+$5wt>60?Hkqy4B$Mc=8LvePEGmK*ui^~AE{zZ^gOw!A{+N6 zk*+fSYiNc#!iyY~x3?wO6@P4KhwV9e%eqh&HvByB!VVDND_KET;8(&d91tOjcE*K_ zF=1cZ^O~V#wuIHM*H+xtSG#0OjLrR7^HIq|$M25NGY6y8pwyR>9hVv&_e} zaX6Dg{x;u4R>78`y{}bLyKie~XpnnLia_JJ4vc!(!D6rnxP`@hda%zmWUxo6KMs=*-bBL-g0rFGi$mW4T|^9EF^SRn$fB!zDsh4z0$fj zfhL5LH5!-=3j)1@kH6UX$7*^%?K5=de#D%RAsGXx=}#Fu_0x*c8$;FuTmCJ1b(voS zU({{p#%>c!`9WyjOgAMZg>CZVm-LPP+RWYva)yTguR|KUhip6)+LYUPEC@^(HP+cd`DnL zhpKbqX07?f=7Z}xg5HN`^WsF`@X6X`I58Q+>z$&k*9~TD8)6_0yXx7ZLZN%FXr#O5 zj`csc86VnAF%KcOY=?r9<~X+70|k@j{HaadhH!ql+a)8=tS~6xqm14!uRXAe>Frft zC>4XM-Q}paQpj}MkmMVQzp}iNBFSh$tj1O(PN#u|pvhG)Hdb`UMQzdgSq#sFxR7WqgSOJGN+Qto|)tl1m8GN+`pq9hrGv;kCb=w{fwREM!-G9Pox??8s_mGIzd&{$=Nrsf zphQgHtgfDr?!Hy&j+b4nBlqlEM(lsRmXzivRCt8>{6&B{B7VH$U~!F6Qi3kF|6WDx z$&GpjNjt`y<*oZ{8sLAjlzd3#IotM#AGC^2Qn-al@8=&JQ|Oijv{}koULsjRYzD6N zNZMQ7k{HChy;rX=?AC#uahr}3O0=;O%2~Zi)_Gn2)7!^h<+Uct?H9)$EXAAkA&Ovm zpg}^u7`qd9dW)eJn(xPBspmn134N|!z_dgYjy&n2@2&xiXmPqqt#nCzf(1>($Im6rYWmaK^J|(k8$Da-BJYj)YtiJBuD|#MQ1Fvu zeMUAB+uUB0+3j-_5&)q0R=AGdQ+*%L`>$r>Ua{!`0YZTzZ=r268sZ4C2d zxQF`1!i2KRxNkguY03%KCqCR_Dkj$ek-e{2jEgTRll{3QSU*y{!iDDs2&fOn!yX1e zOkQwaLwW!^RA))xjg&-snLSo1&ILA9&M3C9lTU0f)PIZ2BkwpcvpFY`uud^Rse=2=Q z%rOt-I6n*ku|B{0vi`Z(S`PIJnPWv!KXxKcnf6FhB(d!;%K1ku0zb~i)3vV}vwmCuRuJE?mu{Hj*~8)Y|A4!Rbb zXONO-=?|9KFU4#ze=h!ZTOe}(3TRye%m^TCn zEG!6ozoQrEUX#1e;aLt%Fe}~Tx4aJ2r;+af+I?4>nVgJKK0m%y**J*p#4klJ)z5v* zi{npm^Nl(` zPOpp{P?K7)->5N%Q(a-K`U_u)Zv2)3~>-RAA{(ADpUpJanj?aUaaJ))?3XSO}9mI&*SyeHJ59r1JbGk^9DCC8;uEv|%+mWhAM#8M5=e35)%o%TbmV zcwkmqyA@ENHF0d|jY4MY2L~ESjSU=3JuM{^J_#=F&Mt&baZT>7Efol~RPfHBZsa_amR+57J zc6);Ge3rT1NXV}3+(hO$QgLth6Bfys2pYVc6b0Lgv?^GdSfx$Xb0m}2X$ymI^hM2M z8_Coq>l$#$xc;x>1A#m{etx@e7OtXQo#5A%2>!4LJ#^^M95!=YL;6zV8>rU zm;J%ji;%Vj%5u;|&8f!=?Yht@C`7i2YYU_LsbMk5>*k(AbTe{N!8;^B|&!p`-{30PghVnp`Ajay5$78o>d z*MyY{7A}c{_5XDf?)odk17?QoTLA^Lg*pqI4;#DmZmdxIQ8SfX4~w2!shamK$mf+Z zKEtQ#RNo>QjJ`Wo8nN&)CWP}+%eo7hhPd)*X#nf8OHH>~yoFC(kBCP1uk?JFmafrY z3E?isedt!Hd9_b<-kEllH1| zFrHW9^nqExjXFeOg*z=j1pVEBa@Yx980H_e_yZudH1yr5j~y8@*6qItIUvM*Ra7$) zPh{@CnPSRFpx$xXH%JSIf0J+j#|Wx##>VBO1;2P5q9H|!2sC@9+O$5Ed~K+y3j7ZF zWwJW^;U-``xA$G|FKPkUp!_rhdsYr%8Bh)*!n{+2Oc5cd)y=_ps-otK%?h+}Z|XC^ zXIld0w+e)LTr=N?g-}#9WJS4kl;+^yI8Ad7pSsQN%i4FN6AV-k{6`opy@mlOk8Q@b zHaBZFntxCKp8Nc5VaR5r6@TrUF~I5u_QMom5^%PB2PQbSpC_28qX=_=Up0??K??8q zp2Sd9DvKa`0i2ip0O0E5LBTJg!oy&FVZ~nVbTSp<3loBKfO!UuzUQ>2JTQd&Yb+Lft+rMPK+l*uTG_SO19sd6M%`%|jdebO z4yf>ZRO1w*f5ysktCWiB0U8cW*H!q*R*{>zQAa&3MfO__S}w&_2jLKB%{~Dg1psb) zK3jGTBEBbt)PI8iRA-@iGh0&Mf;_7>{rZ~C+$}hs@1Lc3T5(6C*RSih6M)tY@<-Uu zi^oR0F?+tvHBs~`R%04~q(G^gQzv%AqoP{zT11_K??BcIrz?N{k7zUPdgw$`whL{M z!RY?9-A=l@XT5|11x{dH;7cq4dA~?})&+{5QIA<6Fn2YOz?Q z)8MTwmaH#n^8vxC{=m4&|G>)Gh#;!$9ev@A^5AuOQ3DMP_`EUCjae#+;TJLaEHf8K zlrChc0n@!AE*!F6?zI!oGXNCo-$q2z2LKM5qWF5>F+?!yJqAlKO!@va^9hI@Kt9Nb zumDf-2T0+|o47@z0ucpMbtY9N2yS1-mAPjZ&{772?9wIZcN-a7EG9`1TjPpDQ~@_I z7_YH)S}7k5m&4Z8jYn;1?3aRXYefOfce0fbO#917j$>un0P6eh6nqfv7zDsHD?%Xd zbfS`f#MONh0M0x=?o5g8&hT4!Kzd#hkXn?dm)ScTn)owQvQL7L%e@M{vWw&`TEDZ# z)t}V>QY_nZLRqL!*m*tGyndr4u44?;v1r1a7WSgNbT*8N@)rtdDCA(|^PunOi>|Ez z+@M?mz@m$qH)cRrzs@ZbMN6(iZnkx|vtKVJYkKSuo4;AFR&Exj@$zk0% z?J>>u4_tY8pnpL3l7M@$La(woGJ5yy9H2HbCj)mO#GSS7l7C!~uu$wD+g`!xxqFqOiS{rxP~` z-hh5|ZY;>UfZurxpt%p}+&X8xdU`(}$uADI>ToqyBY(P$GJB@f;Wss}*xcOQK5G2p z+`PV|t2#lcJ9s}hwp;prX(0L+A%YHe2j5J}|rOuZtD$?$U~rUaaEqcINjJRaS$UEwiT+%m7m3-c^rJxdyyX3 zIB1^E3>ggq*gbfcu72aiVqM)^=g?1ho@S5xk--|t(Q9Ls?XLSRNLlnU=)%`8t@wX1 zf}|q%liqBj%FR}Td`J58#gEkkn+~CLQ~77UJZG9(BBRL1asynn(3~efy{-@92XeaT z^4%b|_ppsb7B-MYoDbp-MqQW6L#oB0;P--Kkt8O9r7F$F`1?!BKYqd;0qC)$jbhKF zXB39?{0YRKZjJ()=pL6GZK?E9XqFonIM~_D_MB>8{EjiWk>hl5MVeM@1zGVd?{Tp+ zn#mh8=fay^IY8$9EuP&r=+`qh2#JEx@m ztspZxV)rvwv}x@Vzfy_fpTm*OLm|&nWryn{Hs-5*zMBr^aD?Dia~=kkxQqjW(zt>B zb`^8tsg!ZMnxD&4rRza>4J9O?VrY+!O9!Z@01EP~qrIi~M8hdmLbwQyPN4_Vd0l!>v)*#1W)~6KjgUB^4DhUo;g3_w8&E zO&Ct9)nIY9udg_~2%p*>h{_hTYhGIGpLzLK0}T0E5Wlo&c3AbvK_vRvbreKfVGV27 z@WiCDV_>F#0$^!gas!|x5(Wt+EH6AgKAeHnoABngGDm(KzD<=KGm z{d_N$)?FD+;9hc}-C6y=U1jBy{}@C80vs2^T*!%w9Vc9*fhLCDLWF!G8QL<@h>Ft=2h(k6li5ZC{BmM-#Rrhmnb{LXFDLDf0Q=nC6=xdUCD_temG8}>jN}&t4=lHoMtfMp} z|7i1`#NLS;8&*xiWeePRacscMtHDZo3t%biqzjJr54~%s)tbQp0CW#osGYC1 zb-4oQBkl}U`@T|^uTD}l>!vfgqN{fIhC4}~8ElQv_VnVegqmSF$u8g1e-)1CNiFFs z_{sq7yWuMgP1qJh+Nv#ww5r&Kuq0Q0GjOtAW!CO5aVHlP*>X}`j+!tNEO7^ z2M6FHdvy_C!$;Qwp5;|a;nb!XrQ@tsy6FUKG!AH$sB7S=#^6RBs?0M5?l;Lz$5VHr z5?w7(vV%^C-79hucVHHxV8*wElYo{9EE|`_+W6{Sf)|Z4q#o?6&(!R=f!*oxx5AyN z@h3S6^Vneb>efY>Utr3f6K^`Wvws-9@_2{iUQIQ4C7y)K?{vK)kLGqYrK}`}W#DeP zoTYpWj@FoY8QwKTt!p3E@iCy)-7F!JJn5~sh)R6n4w#7m4ztsjYc4FAho89Q$89H@ z$leZJVGb{vix4l1rZ+9FV%@m0?!o(vcO3o!a0))Ay=T-LkkJL#Sw_&CT!six7xAqmj1-- zFAp}tsr+;Yj6z|*B2G~kXR&zeU`$c**nLyYNVU)!9aAOM+u2XWR3*eN31J+gcPP5N zR6P%(l9cKlv2}ZY$NrVmlVls~VE1=wnt|IYD0o&7Ct0t3DizSNzakp4n@CHr2fGSp z+XXDX`brnur`qgC;D=NqXO;y-)1qZ!W zQDs{z|9i=`Nrad(k|`xVq6SEvt!bOM-L3;0UhcH zKX&=q`d2_hn??Iu7)r^4s9bY)8UccXsUZ(t5Kko?a7Q>YzO;}Nzqub3uLfr`PGQ>J zLdC;Ou@6E}N`#~RM~Zw$rWFmswj$~ApGk!99N#~-st?TT5l<7}WF{V%0f?=|8@?%F zQ8axy;Zy-e}>vFwv{|iP$BxKj~khD`p?tRKwgzwu^qy_%W zu)6_IrlY`()U3^(fxI|tB2Pe|2d8Zc)wvpc3i-6qZ3S8-5m+KLim!kOHRM36+BgAr|B~a}$L4HY{@w0(^_kNWtf7cF^Rx5J)iHHdu{eEK@>k)8x zgyOIEA#AMhSXkAPG11vpoe)PZyNJXTpVpgT1#uBrsY~KXqy%M1{U=k~ro<)IC;f#Q z^R6yg6K8s)Iny?hH?@dQM)khU235$1vwD(Rs>mk1<*z!~nSCsHjL#~$K8pAj(9A9i zx}NA?a^@WBP}Y9sNYUIY?N`=D-xYE_EHAt4&!V-I*~eVyh+0iG6|sN0(LQ?fl}Fn` zfMxd?a2Hu#<)e_RRJHYip8G#m>nzpzyWkY3;Qscc5p+@STb#Fc_$%p|m&=bt*e@s6 zw?U?9a9aF)cRBjQl+sPt2OBk*@xXiadrlNro?2y8H90!`)kUDGLz8nu=fW(99*n9+ zx*RPVhg*wm|Jz*xlsc0vt6?f%{@p;YJ-FUHch=LZ@A5T1%HxgMS1)?g_lcsZXd<)s zPL*$Bkh@OiQJ)hk5We#TP;TL)dabQdm(QmhmC_0TOWrlq#A1_Pp~| z$+)1KoPX-`PH}viq2{=rSCtIZzZ3fwC#4#&n)l`52rp7F+>tx0C$R4NH!CrOL5}Vx z#c2b-go~lF6h@EmB?{x@PTjZouGd3W!OY=WY)D^C2ey6lnX(kt3I&-RaPJAdL*dMI zm{S)0!f`onv|#M>yDce%nP$yCZ5bw8d^JVwk2Upjv~mX}ez067T(~QR3lsmk7F^Q| zp@X;T@s?`c0t}I4Qlq-F5Zsg*)j7nzy1Itn=~%bWG%KG-^6ro#faGl)Evk3t65)#4 z7H(bHjyz8XHfm|9QyZ&IlylmgrnQo2W~C?VM?{tN6rV(^yZpA3H$WhBJzQ#2q}1GX zx(F0#G%1obEk#ww^>|1_WVWtttdn18?1z#8p)o2u^+(^#aC}cU!<$JhjO6=to`r<2G8uyN*DUDe+SJe;CL&jFP0wwPBBe^? z0LApTeV-uKaxI;BVepL7^+;F$Mn(ckS6NZ@vHLyHiV-o->l0%ygTin*0P765lyntRJ%;%Fl`xil<- zpA6YsO<*`gYQ>UmrCPxCUrGk)Nfbn@-kSP)-&1xOh3HJ}J>y{_Gs3bdC{-OnnJdUN zYoboe5pN#^orCVI_=`ZYJ$)l3 zYRdbQf&l`s{N>V&$|*ro3r>OakFLvcaB--;>~o&;qt?#hd7k3!Rc1xCP8X;j{aJhA zk*k4$ma}KrFULJJQ6p=8+~Vifma$Yp%%Nb5-^D)J8`_^;^Qw+gNV5s`Z7fDb&~i(K zunClRPrKxqJU8adCCPgI$?;?d%wIvC)lt??{!xRI+gRG1*4vg&M+yc9nQJIsMC|TYv+3`ar%nsp-WEPt1{fdfzuY zMbk5D>fTvKve$PB%LVX7`W83^-}NaB22qSFDY(qOWQA{}yTx}-2|ubvo?ijTWr}fA zpuUCA&CTGv$`U92&Zs2givXE&k$5X9P5h(4B$d#cyo&EX%OkAQKgW}pGnnfb=wXFj zw+t3alI!9XzEq7=0FeIvpI9iHaF9}cntkX(O=K=8xKt^$s*OoOlvV6SjEyWvKg_%H z2wf%I^^$BeQM0GH-{oDzyD=7DSS;c!Qy?{n`|S?N~8Y8U8;l##>sE{;}?Sivv<|j4{nyn$Q8*y-p9IdI8(sJF(dvfmRB068xSx zTTapt;?%>w;@J~Nv(8q|iHy0o5S!_Ya*pl#HARJW{&vc*6m6tgo#N4K;@V^eLgB!+ z1B=TbS*|RbNQw8khkWFHnEPFBx05l2l{!MiEjP=n#cpx)WAmXZmTAFh+fRAj&+ttO z$h|my%{?-^>bkd;vlETg?yRO_4(%D6f)ADpN=W+dr99LXv9jfxgxaS!981|+jCe$S z#3}*AAnT=n%eEeJL8D2ftFyHGNOxC=T7LEGk0sjaq)vCI<)@xVpWSf_{MYi+oc9MK z;!h7OXcrYeve1EbD^pJypE?I%2@4vUc7viHel+YVQ}>c93pp|`3Q+9`4VVg_RvM$_ zMrp))loAYj6GP7O8e+fxeKfZrGF6u-KoL9gA--dhgq1f_$P5YrU)D%A!$#4@jNL%e z(EyF-l(lq`>eFvkHg4p3QA1Su3|V1aC;7tFVl`HMJQ;RB%Oo11?|rF0Xc)Zt@nOr$ zlSE7SwV19tD$jbQkoma+2)RDF!)5ZVby zd@G+kV2z1Lo@%PSzpxp(VCmI&uOoSV@Eg;&-{wv-Zh$-U7#H;JuMccW`F#WWW{q>= z?AmH2!oJSCIj5SRaQpj0CCVcjn-mt1=)@jX#NpQ3S`9hWIr(1rfja1^jYoleR@dCbB20eiVQ_j3jEXs?KGnc0KnUl zSduTDcv`t!>)?vaYAy9;LDt}f^rBK-WSQLMOGdrFjQUs~b09bCPWPXs2}F*&bUnz~)4hY7T(@0hByY~NAsZxCgHv*N`GfH5gj(#!=e3;9)4a>Q8}?(e z&D)iY_m8#pd#OUTyXO{ZaGhm_jn1C(xhH8_;k!S1l;!bX>8QgPG( zJCbrRJ5CrXf!6Si7aoj972<0qP!k(V4Uzni%6{`-2b*2oI_vLc7qk`j^TLMz*cxX9 z7ynH6(OpOCB|6v!Ao1KAMP{{k(tHjkm(xilHtC=oB6B%3Lz~Uu?)mL0zX;$nla}WL zearbdFh6)0rZqQH;@vrme{*af=j-p+8)&=98Rs&&%9goXu0qQ|Z`80%HNC`N*b?qo z`$)&UgE};s=XBF^-rW0g6+M zPUMnBtZnl`{*mn+B$bb00qbo|vt#X(p(rdq@z#}(PUE9;e5U@~sxsf|;#Skg#~T1A z@5ZRqH{aj#M;)sI0@&cnSzS7r={@B@?Plt%Ksn+Jamb@9sLAgTbel9o4zU$O{poFG zt;b!8hy0(h>aiM&sE~&;Zu?do#$(kOExpHF)W3`_l}REPY(+Lr-$bTzo{uOg?lh1^ zNjrGY`fpA2CkP6Y3#9peLa`wodj>83HgYBC36toM4i;`T_OxAXTPiZ0A~J2rjye{4#8PV@$hJ zUxoIB$Qc?F>Gd zXwQY^s->^D-Sys`8)UqMSWp>gY@YGsaA;Qx*QIPsEn4EZw#>k(Dt#%f!^gsV)Sx3s zc7cC<@b=}om4SW+ca944n^$F;oH4O8(qkLDbv=&L+-(9LQN)`<*T&1e!&E6k@)JGT zZ9HDrea{I)ai`e7^8DR6bK#CGKxlxT{D5VuhEY+()g4DZ&V(+XZsy?nRzK@Qo=!iu z+tkmkpg)*pdY}ke|{OgM3voFx1|yt$Ml(Z*$jrQ=8wWev#u~XEZ{+H>fq2H%djhe^X@MInuRF_Z z{6WoJG}nBFw=Ua97O>4U9Tr5(nz4XbNwf zXiu+T{#|0 za~XFIsk<*FW4gt<;z0CTncd#4xNpAZcVO6os=1k{)2oo`FYH zCQ0~GE{M3k8z)z?FR#9X28<)o24yU1xQMP==FjZ6-5;*`QhvnVIcj)gtl*-UfGJ-= z`Eze=zQZyp*2})*=(>Y&Xy(T3_xl!)GS~z)=H4PW6&vax)=~EfTZF_AVH;BF*+xko z8FjDlrS*D@Wy>G)>NYi4BD8}@$gb-cu-+8-JRUYB9~Cv7{i7Azg;MK#&%}{C%e0 z-H%7uwtm*2*GV@l&yyRfEHUy&TCB!EcAmvo)+IWl4Wmj$J31YZV+y45?N>biYjRa|Dk{Wi#216>!Ri5SXt^BPdm2jJS+F&#w=HGavL5Bsy&ZkS%KM(M0KYD7 zv!&>2>}3Q?t!fAg$R|Cb3G&1PI*pv&@VKC(ajSGTm zd_6`W^vH?h73yI|jc0gpJ{w$m^A5K?xgxEcBEUHwiAa%pE!!VjAup>S^C9Pf7t}#R z?qm`bTo;fZByG$&?F;nm>PE6h9oao^&ijOAR!>wk&(g~c9QWr<5TsOfYh~LK7sfrQ zr}deq_Zm?_<#U9PRWpop;ON>Oh)4?*Kr+R+v#Qt$U9rXu20@cW=JNBB0R z?yx(FH<_(XeBowLQyF~tLpJh@3KzVhOetadDLMaB`H)n5#qw#5;=HVHv|{^&%7tcC z*~JECcdpC9$0~O49FD!{Wt+8yPe%rMJ+>^_K8JZ68C(@s`U%X?h!=#we!($rXc^14 z2|xjNNW|A8Ip$+Lit5*dU7324q9sgRQDNOudgiPew6^r0PmIjuq2!E>H9KCe0p(_=X#!sfO4#BcV6H$(h3dfbOAI_)Z%~8X5p>ks=0yu@2bD*INdEJ z&6>3a4Y1<+?(i)}AU-a^BC1cuN8c3YGT{%^9r_=o8x8tjnOmKzt}|)@%(d_*E&e&x ze5d+%`@hE`zR!G^cm&%jMIMd4Cdk%FQ=eih3pA8@QB-%bI(709l4QEb?fEDLprfW* z0)3qecoNqDm%!tA>E#a&0rV-0C`_}M;0C92!11hYcpe}d2D^P8a@+6SsqDRy z^ROV9!K=}W&*&9b>{ead-|y$KmT3L=C1>6kV&aOj%<4n_-|VG9z}ZN(H&VdNBVCb- z*zSXaMI)Jxz@dz883Itk<<)3BFr}1YmQi*G6oj~D+__?Lu!bqXu;CtDDPp1Y#P zT~*dY^uf>CYiO_WG%LF66-ew@4vSXV45Db9>z$8kjil;R_fp3i2^0Ot2nO$#b+$6C zghWMC+1q9Grf_w&(}>^dYjxz)b;$by1+L{K!eW5Tk;IZ-b-GgfA0;yR0^JGbie z$rj$QWxQv7WQypQXV-<-JZv35?i(S=pH5o2UQq>jxbuOJk%zxBn{&5L7NX*hXCx)= z1?h)nbkE^TLG9SSD=}qoC27#FE>})R#xcB7!y*|W8UwA+>>s@zKniQcxnaxcyiTNdmLy~>r*%29f%w-=?aF? z94zh5RDVq~dg+ey4H;Lqvij!efW+=d=&|MJ;amORE7p~Iw>u20lD&*S2>sEqq;F_TtTk)Xe}$A!o+N(qX<@;HM&=W_*wbw z#l_L%g447jAFlsA{40Dsz7rsju3jZ%1a1T_H#)Qd@qo+eY-*0)y^eCz zq6wQt%ToC&fv}oWJ2@xYHFe>cE_AIUnLdZR6~nEZffpfVsZPvUJ(sT}>dRi=CD0vJ zL~2B-gsZ>t%kB2eb^ck~Lq=T9d%#|_9ISBb33R_+t!y>!RAM^4varwV*^=Fi+C4=9 z(bP%@`o&qNU_{xiLfa^k;WX9oeadtHfPcG0zXaot<1Bi*4eHfO(f~Zv_MV=A(s(4{ z*ZfWD?_bQUX70{QX~~Noe|VT}%leSDWNYtnO-ah^M$rgwYN@T{*lt3}Bel`R0F_`! zQj?4>Mbp^#w|x4?ikVNAF+mSEz4YN|!b+SIi9pC?IinYGZU8HepX~{In@K$;ALmnc zvfl8zyZZ7@UZVqd#(@wkf3^bYORKnh--nJZiu|gM_zSV4E(ELmhP%cxx|~$v1n0q* zo_`e&3adEMT{HyJ1wA^CAHANANf<0$YG>;)*|tru;M|V&C9|Q5J~PEPH+5{In}2Xpmd9-CYT|+$^#}7toap)Gg-KD>8gW#sbq0zR zKaAxrTuK{8)Xi5P;2ln^o|Iw4#3mYH!v75pfSg zz~x+=n2z|iEf;F(vh>{k^a=pL#og9^Di5|SOrCh2ujCY@_YvoLC6u(!{oTzClBMQ) z-D)|mgknirAhAX!FolE|FPQ8Sa@{-lT`fEGd+fM|n7r;(oZ|gRCxzIWowZ-Si;6`y z5tQFyqB49r4CM@KQ9Bz>1EfRqa(Q01$V=}y!)_~2-mn67c1JIcK z3W?MBhlypuqJWr#ti^!lb$%$c;<(b2z58AJ<6_3(j9*3)ryxncK*>aCRawLr)v+;*hjLIwzX$_mVXWEVcJg)C;=Le%i+wN-U7eZY(*05XTjee zoEqqhRB})HdDWe=h&pr9V_>^F*5Zl2aYm4_mCT>n4{=!3YvV;+SA~mX2(Yqv^H(YTvU=u8FJkT23V{mYP4Ex$Y;&yf(R~9Cj&uuBH-*Dhl_8~03ZHNAJ;-rfu#YQx`V=6a40-S&O% z9dgWX`U;@pOVm|pWETHRv0zKR%bXRc=&8Bufy(4Ed=p#nss*+7h-Arji=Q2s*R!eg zu9jm7?A-sKNUe6aA&!{{J7(c+|&$hvfnxbjuJ6E^Splr4K1z#(#vb+2e zub4_zPXN_l${#o@p9d)|A(PMkPIk-S0~FSwFQT{V-l~q);9BrM#fh91!iep!O%4|V zTAKRsmWE|3Gmfd-2iPs7vx6lKF zBqTlC?t48Rd{EV+~iGn0??MKwtmERgiP8fu|6Mv+L=Yhio_^n8@l%9&TG zlZ!9CQHBrucE=>|G@F}tIZjKQY%xXt-M&9v6OtK-@qNyiR({m7B)vJ1{r=#jN~G_k zC%(gr(Of6Ix5$l{#*~=H36lKVB-xG~#%x?bEPEqh*9`$jHoUU~Ty!On@E;Ka`~*N2 znBvQ$sTnMte0YT=3iYq%oMR_iK5%GF{iLeZ&arK8nk|hY#4PS&C_?>b3to05D-?S9 z<>(o!K(k#OO_k~{#v*=NIZEUOxqMiN zmT&d&b3ds2kH22>DlEj$$KtHlmr~dFyD;240dJgnp0G22;GyNH=V)H{?^-P;?N5z! z34d?jG@Y?&T0dDfa{8OhNgDTB`?5eNHNWeM_LP!&HBoVtsiTjB7SNo>?QS#^B5c|` zZ*DaAkqFc&+Y)L|WGn^TbrddlY@4Zt;IEIQ$9i+1j!*D<9RWYk-JZ6x2kw*RK>0Q; zkWK#s5YDyLE8l8fMGLCzH75aU1y!zIW!+u>@p20lwWn{GDLMsY1_JmqZ+oiodD6#p=EqP|4bLiDkY9 zFx9`IZ-VIqj+5RxZDh8#HU;T^XwZG{OAHbo3ku$tRJ%k(3Xsg)(G&GFyIfl!rj%y% zvmDT)+vo=n|99JAa_+JV=Xx0lLgD2-(;O5Fg?Z)|SYwT(2WFQ1RNqNI7}sS+;&kXs zkU`Q*<^sW(2hAAZRrMCC;kdrkM0>T4G4&skuLdDyDJ^XQN=p^+y25^N%YZypz$SX7 zmQ!}<8j$U7jIS)Ad59k!r?r(Rxa%h75~8x3E-MpPpYF_i6T*3Ypc(5D`x$r<&mRmh zeF$Qut52VStAvIN<@7{L3W&4o-*C%A6I34g_E(;d>_XzFhhCsPzaVkIC7nWb_^bmq zb+v3tO1*_W-pak@g@>WOeygvBbAhzGbWH^!&RiQy~3I2ZE zd>*izAH2z;CTYHdJVC%B9=!pmKSWJJm8ID3eogh)wjM7nCbU9cMiwE9JBh?mUw(pU+^uF zYA7QF?$j>wf=9P+|EmlE(Qq6TzpG3m_h2~N{By&zta^L2&#!5bp#a%ugrV#pmh~gV z6SyNz;UOP@)IRYhkjZsw`P2}W*oK)w&4xdW~~|NjsQWTfXs|LGNB=p-~at# zOHJq1)t3=6+L}df#^>a0ei^?43%TXne^T|LeSBN=C&{w>m0XEFT|l1ybavsbqp6Ji zv1zy0X5oaO8lr4?O-~!2$ik=ct6zXKA0meXE9>%m?)({1;~*Up7b{oz&y#>llxU<_ zvb}tX$k?>rX6H4vS=%*K*9Mo`#r%DK@AuR;h>hGXU0dYf?I)S&pt>K1_r)z7WqPfB z&tK%M+eo}{$AR!{_jPY|;uyU)y+XCE zKODIG^F!OVKGdw1V+=Df-uQ&;w45jvgC)0G68cIM$sZqR`y=b`Of@|KM%FX~he^G7 zw16+EK)F4Mkw%%hfQxFhYqwaZYs;S7Oz~5T!^!5x{_wb@IldWOnAsZ>1zSy>wkX95 z>+bq~8N^neRc$dMXZUAnQhgvX$n2sf8Rd`p!mvXoW2*@@H_#|_L7@wqIUUreP8frO z8^r(1hkkVdV9X zysc-e>2UHO++y=Un~GXpKoHC27qk4y5kcil(=-RZOXKwDZURtO-c^-qnpo_vv0LaK zj(N}<(@tu32w6gcsDrwXd$gP+6r$%XAJ`aaYTrgxX)3F~)0)4GZ4u&2zuRQ0aWr3n=Zbx-_$RX0p+UL@=>!!&Kk5o%Mr9}zEK8Zx%o}dnb3ceRq#qh zBkXt#2xDm!F#-6E{tW$&tUtIT9NQYbaQTzq!Z(_n1m&%jPpz%{MFjqVuucytSh z0J72-^{4L1#}oR1o{nA!b(ek)9kbCL&e9qenaMDahS`^+TU+OYNd#z797tghUpBsj zEp+af*JcH|3-qK%?IOgMhdc%dCPt=SSp%(O;MDV@BF-tHv^g&t1d`Xi2LJix$Zc?j z)?H%x{&zTPwv zclpnceHel&tJ;F-b{n*w|Mv$g`u|_Oo&TrY5ajznGAiTB-69fa&{4McEOgwW zZ7)Jww>F*Xg2b2*^pLO5Q+GRgVrLT}NtOS|O|>185})$jF>fer;9kP((0boc!To%c zudZeaTk5?rxc@wq6Y$S^fh0~}h8Q0m?M}fg(zif+c{MdNARypBb=rKSBYhf}f3V!! zt+e-OF4Vr`H$F_=%we|4+v21}wtjUg2)+SyeTQ{I4u;F+6xNS}&hGmz0(H?xiWMB} zkkDl}ZBTI4bHc1mR?F`CzRa`SNpi=c;z!T``ngwbX5Q1NJWIdF1jD z5o0yebVS(+kDjly`6OCm-?3@Z7JO7AMIkXIxk;)9bEp`9^p_=RcTq#KSX+XEI*thI z(@*&TdZ+irsR1Sd84WPb2*=jSmCkj=3Z|QK?)W(5FjhQHxocSRc+veLP~B01GzDF6 z2>5p9N?vWe8S+8tt}+t0nCiyg`r0=xLPjA~5~>c~8uv+2fyrC$!G(ty_;%m-Q;@GU zHzD#0jHX9mveDq!fESN+?-Uf85+S2XRst1hLw2!P&Tw!;LjM6MKA=(3Db)~e5f94o zbWw%`Y>`LX!T2baHdB9Ibup=@%;wCT<6ZT8PX1&V_+$yjm7`cAt8|2;-epo!m`(T1FZw;i_-bl+5RA?#Fvs11KyssiK+k0LFd)y9$Gxq50{VAOK6dhz zh#*o^TZR&)aYgf1_s*5fW7y|e31O{#78s(3_GE4$F$%w+cb_AZjBr_g6&nm2ibVk| zj}_&pul`h2oHsmwX!GZm$0Q*525d|^FhP{+qX?>LGptaAJST_V?N?(!DGiw)2QAc& ztV|GwZ$|MT>NWHBS`QsUj&V+Nug*AOJ7b(cKIK%U_1P9SAHkQeNUB!Upx5uT&R3@i z;OESwu~tj<yFBr zY-MRI;$Y$Q+yJLDP7Scf$&B!N-r_~oB=0;XF+ROFRk_YnJh5^BHYv%G89_@@l_j`W zH0C#CcJq>mRFEuApqK~X|3N}!Ex%uh$20Q@XZSDXy~(nj9n6S`<0~lE(~*S^XAT%TvjuX#9|W`e1qT3U9HN-Xhu2>Hr#( z$bE(EYH%ec-SGe7TO1x(O;c?t@-lw5X?;(bR2A1%F#R1hnL=#Qc>;VWTO{6Z_F`~9 zZ&eKA>K+EDvzF5k?U`7&Vz9%|b#_o_3til~0m;JJST{{mAwY$AIoski-eVsasl;ck zRLO$Ac(X(Ez{fb27glwyCNjYtku@uy^oIP*H^L#hwtuuU|=djr@5jM0VZ#Wc|S#hv2yU7vcVjV-`{AkDVf)H{plm$Pl*zJvU^`77o}xQ0 z%cXl8+T4Z9Pjwff`oVVqQofwoELd)%9eU&Cmz0Q@JEY!t^h+>kX2z48t9ygi+GLC}YA0gsj zY55?ttR52kK-JD{BPms#V##gH`Q0@cgoO9+DxhZu%YeXaMZW})r&AbqWI0o+jn_5; z*<$D10E3d3FSXyE$)DPcSZ~aE%Ou@1JzS0zO9*{jpmQq*lj?|3S*S6Dt&kmo9%WWI z`zmpFwH#T#jv4LgWP1)Jo(-rxZfjXgNvO}&TTgD*vZaeq8`vvWN9LqY-%Us8=qDTx zCANNoK$<#~K~v<8DSRnJ8`5jM&)wgjUh!EwNwT880Rt3GQp#;a2;LQe7P{;EhubI} z?vuT)VJrB-ux(SGA*>WuW$VWK?n0h$$bx{D4mYRC$#%#wiV~va8~J~^K5{dZ-Z+^- z6P4fXp6u?U3P|`i#oFm|3uJMR{O7klYN%96;q~%fQN8C>1CN@@Q{FOLS!b zsh3OlS2Hql$bIAyzuKvc2>ftnMU7qX*xd1PUp)RRJxDejGW_i|jI>R3Clm2OL}Zc0 z{9eu0krb?|<;uMLJ3IVDB~Ln_lneH;uYVAXU!QLF_i_%eHW0D(@13z^+r4$Y%~49iSMhj#Y;H1*#u%=jQ{-j*EIU>ktf z-pr|J8zuCTLx-Hmzsf%`Z_SECI53+=22)P!6P-mf=<~j454FmShVPmJ*ESn$oVYXqN0pof?K5IwolruK7J>yGHv3kq*C_08QSzhiNKLt_mH^l=^Uh zT0XC33FgS%c{9*PR(bmBgYmL=)^4(gfSOdzG@EgDGuS-jXjiarcqJk$Mst^7`Oj>O zljHV1BUy5I47(0uCC&4L%fsEQM}wP$R~m1@1w^NE?~CEX%(%^?y{605KTtp?dE|me z(TcrJS}%DN;q4H3fAQM5amS|PLXz^==T%dBWns4vR+ZN&AOjYrXU}yJhki8;*`|$5 zc1NVIOBIPBv;;7-de}$0~Nm$ELuk%9m z@vlNEki#-90BLv^r-R+q@hhh*m<0Jbx)x$my%(iUwc3>#_NJ6#hntpy#S0Rl#<5za<&`$v0V zuA^(f)LuWvd1291G(=#uk`H+#qWdfoYeti#>=;y4kzd{V7D50b#USuehf6{i0-*wP zgWB(Q)sV#|Sf}BdfP1NX=;rdb5=I4JNFl~?nI)#S+oNPOlW+C;xG9r2$bRy3GbkYg z9ps6!2{JYFPwj!NSk#*Sl05Y@j@7>J*-LwB_uPv!es%Su{6nHw6_UI7il5he!mrQB zCzQ-87fRDN2%FWF8e3U9)YM=86r8aZ`gj7Ir{UpqAV-HKZase2K^!=9zStJBA3S+X z(u&9T#07a;zJodv^1h#>wgkTXxLS^3c9ilZsrn!S{_I*!s5E%K&N zg_ruSTk`YX=r_N(ybRfXE2sT@A6}*V@$4AKC#`oi)O_tuS}!1G>Q}`or>^~OcV?1c zdap?vH;{ft@p3I)=}nFMTVAWapQl?bidV|bEJiIxkM`5_^u0%eM%N_{FefL0Atw#@ zg%LHa9Zu43$U{{a8jL| zbX_Fb_dApDjeN5}?w_t6WhE){gdbu-ID}34>Ap!GPb{?Nlq8-5IZhrd_!MO6DfdhZ za?TDHxZ(_|H1#wEKG@K`_IBkTJ^j3)?5BU(Jvgg|;8>1!_Pp7zgZ$CTsCt`8feJ`~ zrWh-q(P^q}^3c_P>85XvjBjk6t#Q)h&t-C>5{g_cJ$>_3pNCs_8i;`3s)J~ENgZf? z3*BotK|`lOc6aAFn5!jNd-y1vb}!-VNIx%xQ%@_=BkN(YlVJW6HQA&yfT!q_x~_RI z=9XBhR)IlCe+-A5@ydbw>{q29Jw9IEYvvqA$=kzLRrDaPyk`0l655ngoz0vbx(;Rt z9BjZ>qJD4ZgQ=jFt;6|uPlFFz5*GcC71-I6mUmJuh>1x~eC5pSMQd_Ta-PCwmZpB4@w$-MU>G|?e$%X^<%23g@%gPCAQ&Gl6J?c-G@-H zJ<1vvAxeqwWGC6v4+Pr9-Fc|#c_9#ETA#Iv4+cLY)wojDi{xRk6%z5$WlYB=Rl<(=tqx)SGq1C z#VPN{>DFuZH=K85`(WF)X_>7;EvyY0G?kJE>GN_4Ma6WGF9YP1+`<+VZ0K}w%m$PM zI(ksKGrl9^pMnkOHY`bTtorU@X;(U(^Tm#zGhF+uTfN?7Te90B9=!R+6GB+W2hxUy zUHn;MXS9f|#S1M4$=hzuFD8@iw{Cm&UBVTx+xOk}3huTPI|aET4rIkDOuanTQOq*aP&h;T|CtW*Vk21BpVin_oN#B;aNG<8==(pd14XV^U0d?-#;$B+e*}F*? zbvu*}Wy_T+d;2q{OE^k!_Qj;6CSD9C4D|N-+np-MVzEW+?f&n40u-1~u&r~b-SRUe z4a$L7*snDa@P7~K*yv9~E|+ZUMS;}I;rNH_kX@pcG~XQVZzLLzBCwLEAeZw&B}}r} z6}D4ImQPPFgBfROP?eb3fr?hYPW;{KQ)kBC0!!2Yb0E-Z3Z>+9?1?2t}`xVv34 z7BtNx`^z~l`FE>Hg{SBsckBh}l`ww~xHGGNd+Dx;6>Fb{oY$e`>DJ__2eB>47-8jA z@$er@OOB>@<_~A2#PyxiY4qT_+=+Bl&AvO@Oj1t+r}1t60>zTij%E5H&?#sXrvT4OwpP}sW0Cd~ z0e5Y>!1q&j{QIi!y>|w(YmDJ9h#O(~vi%+V%lXdkb}D;~z246>uaq5#0XNFLw_k}=+c`?tMHIVf+Lf1(}^NEwtE=h zYva=|gNC#PI7mz8jP`=Z~9aNu{#W4kjl-JUKUj+}pY8ghr~JU=+DcjWAf;mPwX zAPX^@RBydDwMP{E2!W+#vWJJMmsK;AD5_=M_0fo8GuTj^&qsq@y zKpfxF$X8e(>xxph9@GqCkkIMUsTDhZj>ICCncMR-;xbg1dJK#_gv5fy(RR#PJiX?N zhvbtP6%T=R^jnc?N5Oe{qxlnV5Y0Pf*(nEj8I!A>jM zmt&J@nIB$L>lOSGkh;=gefTSmk1qt_?4w&Lm=N>gu};L_2ZDn}C0k&7YiUc(e6_i< zj2We-@&|=t2u^Phf&GRfP{ZDVDxh~~LjAOdVx=(3lAz2w;?Fl1&(wV|QOJ(NkS0#zX0XIhD7?a1Q-nN#M zRqUfNbK|*8Ois|(J*xS+Qz>_G1)zCynUTT0=A2(z6wg_R^=5J+WM|V>=X1CQRhDoY zP*vL+0U6;;P-C-dl0U9L-Q=}r$igYRkeH}Xarq1sp>?|Cb!#$vW1SlUg@%MUt5Q;5 z_LL2&!}0NR2`{c@8HR(P-CDvX&BiLK;#v2l=c}Ee?#ay^DJGXAR=dv)yk&*i-?i!> zK_H9MU>#(&#C=@u`E67A%IVM;P<&1MpU=OOIo641ku%SHPn2w3ut~P$%hPAbI z`qN4p`uXj(1*;8s&fGhKBV{I56dlI;I6?UD6PIKHybL@qxf)s7V38ah2@!{UG49iV z9DrF9>h4F$MFyNe{OH5ZpY~*`nVX5_k53lq^a`>fh)wrtE&mL^Z=X#U-L>JNBRak( z5Qz?`O3PzMRaYMO-(%CNyB=H$=H=HVAvJL;C}77eVPRoa5<%6czYJ#9fiAa4aGmRH zN(wXXp9v45zh2)2X1Dwpb+tmZ^|J=Qo zOZ>@hKFEV`%hYGED$!EdECE=>LQ(1n`$R+-Oy8EZT+ETlz>+KzLv?fh;#%DH|^a;iDyal9HBY_f!X;Ht8IdH*xs;5Ly`XL}h+7P!;G|J71T`}@m zGk*VixJ*hBw4LyEP^ZcWh4EH2y0jSWw@}*+Uaq&GP;SO+`Ll_v!E}v(wY=|nd5HXU zs1)R@jfgPC(p#VvUbfr`JkM_Xq;of)c1Ewvf~^wItHGW`VMV@Pb6Np4EZI|vi;8{- z5Ll%vHA@VWaO-7TNBXQgFG<;-Q>3S*%}Z}1xJ<4#2za}~UsgGAdH_Q920M3D9tqGZs>S10_&>d7mv|m;F+a~Ivk(Va6}o@%B%$N0 zcWzEMNV7$VHj4=-LBQ~gmTgnYOvU28ky46^Z9#z=`6ZU`a0Q$>Q|NT`{V}!S-nZ^vT3U*Zx3)?C)~%!@vA(!FUO+8CQiSh)i9|tQZ@}O~2@r|QH+q0a zEgJ3wE*NAw2pr@R)+XaN%#?;7)*l?Uuf2%5^k_++$@}{i|64r$MsfkB443Hrv)^&b z`t(ZWf^J@vMoooFtq%4GUMe>g*Mq9lbr7hlv|Alpo7B8n<^i(-={$KOFaIp4iC%UM z(9my<_-#m;XKQ_ff3f|O)HNy!1cyS}2B3o9$udV(Y|KdeQ`F^GrTfWSBuVFAqyxf@ z1>v-!R~bmsFMfw2A5bJo>Q4>|P7}Wwt!fD5k4>5!w?O7&gY`GboP>ujdbjL*$gkAmkIeG#2kRc2mF z>H4>?f-3judQEJRd(M!S{wprO*;QWQtg$q@Tlh9yMh(CermU*I_PsGC&)$sIpVVow zduS-VNSNju|3!6)zl(Xb)7BBI`ua4aH!A6RlO|urZIJU@yD7`cr)rh{QgW_MIwdo; zecn#B$|o&T^)>7H$Cn)6sabBy^t+@DFX3Hz|1!44HU9(w-zUa#{y{{vTJ&hRW2(JM zCx2|!R?hRju&gUplT>i5@92`msBW>pK}bNOvGnzo4H}Y$=Y_m_neI*P+MOCWpAM%6 zw^Vv&5E{sBwc(c6>P@?gF$KVU-C4ijZjS1I zu2Ik&GNFC9GfHzq%{I6vL2wx%yHPm(1tpxq=Cs!}lC*#IG3^rrBjt#wqQVW1%+Z|t zv|r&7?C#w#&$9qYtVG%|I(V;OzFfiY6=x$qSYHdBt}DejXYa@_;0jz{WGB4!3!b`N zygqAMmE*PZLG?30FgSj8jO)~j7OnPV6}7aom;19st^|}Dx5{~^-hKPW*@F7h*>@Go z%a3RCd+{~Bm~)V?4}c=9hHja(Yq=<$E$uty_zwX##i@m39C}iQQQq4x^9Kh9N8Tmz zim@GgaCmI$yM$EsnWi77o*(w+lJ>ko@zwmc*tXJPs^bBOFdSo-xFcIj?+F%0*D{o_ zxdSl-JLb2!DV4>j1r@8cz6^Ck#NraAuDc}uRaNGt>=^cHYtL4dtAo1_H>+q=RVE z#pwtJm?lbEN-7B|+upj}=|$U0lRzPo*sY)7vXc*7e3r+c@TKZ7^1ZclUnN8zjjv(& z(?MnG9lUj&O1xl#daCf25+jZi%nuQqO7@q?VwAi%mh`y(wjM>3Aq(8* zWo9OtO)O^I>nW(E80X3GLT1aZ*Kp_=bY9gJPl=+#!Ig3i?=5~g$q1}EpQ`0hqIP9G zhKT)+Q(Dj^_}RR_O>d~9srh{R?Bs9t1sshc@$!SQ{Qwd@$1U%0en_vOeu&%71;Sqa zV4EI@wM+-OMSgsx0LTx@B=jA zIs^7EcPkbV)+uX~TzHCeTu1EoaPa!__fU7Mh){QO&!* z1QF>~S{$WHl`cq=CWPLt2uQHdTM(o->4d5x1Ze?7Z=r`4T1X&-vY*U5zBB)Q?Q5U& z;e0sHmzm2e;rGi^*0a{V?t3jqb3XPkMqT?;OY~GBMlsX$B*2~G?QK6A1v4_mM{f{Z zzLO)YcNagZMv&OSeoILzWde|w7MNWTkLQ+M`{Rx*i|o7+DSzhbUsXEybY$4AX54Rt z8qt*#%NYO;K#l3N>qb~1Cf|E>uKVn5=hHxLe8`hD&T09R%5O2X1;Ks=qY_H_0CYz6 zzWoX@yWa);Pz3Lg7@h@J(-#J%(KPZJsT`sLtT%_CD;$ZJ#7FDwRGJ#E6bPbgfW~0O zFrdWvLS1|<>*7i#0MjzVbd&B5Lm;QMI#2XsgSH}WSsgm<^D;wvxjPK7ksD!4nczYp zT^_H`-%5-=h`W6NtV;Kii#;rx1%#J2qWuI+L`N6vtzKp71Sfm%w!eFofCo;EBl4!W z+>S(Tx3;eR8^=25LJ%L-MD^$-*6Pag7#O;j_rGTZr6Ph`S|X6*n96DO34=`r;Cbuk zB;Ly!NREm9&Cpjbu%@d_+n2G!K*`c9vtTPo2v)@G)&vYfSv*}v46jnWA_>ZyRPnfr zDJW#W3|b!C)^seahfO~^ZmB_LX3ZO58*%g@Zq4}W{-Uz7vTfkHUT2ZSU)8(d*Ev|6 z|G*<;62!{S6qM46G^ZZ=&zqhH-H`J8lB{h~A9W{7h>J)WWj2mo7f;_dZVNZMH7psU zOdh^vn0_{)mR3kV(Ls@zz^;_CL=D}&?%n59J4bE~T?S~o?!A%xZZAMNP$t;u9vJ(s zX?Am5xbUMuQOfj3(b#u#l9qJP(c!`AYC#{VYXo0p&R?#soVvuMh69h&A2jlo7kDEi z-T68fS{-B$+PZGqs?CbOIU6UC!8{bQDLp#P@}ZMvgyr0E2y1=3F#seJ|4gbM>NFsL zWPT6v>qI?gfv{2M#>SITZwmgjBNS6k~kbd|A6B|3t2FCcXw~;OjZHL zBe}+VWiY1&O$81pufj1Z)u4iX4jK)F|4mb90PgDcM9z2W7smaDoRq#5EBnAaRKoO< z+^zf5wVEt*Rop!1Vqp3U90Tkekk7-{fhz}daRnN-94h#?@V!f@n=u4hyVLaUUFBQ# z`BM8H9RqftBsAtw@Va)LhCCG@552PLCv4Re)In$prpWpvFWwxeXM21F20*II?kNlS zpZmD<^zmXbYMxt6)dAYIgw@YdSI1bQXZ)V4`PK2Sx4CG78$@c9>_F70!D z45Km}%4ay{8^@}zzg~%@x;9ko)KpUC>gh|6V5;%Y;A^e@O6R-o<~RcQkZ?w2QFm2{ z?C1>Buxn*)*Me5c&uT`7-FShw1ncKi@1gD=NSpRZ>&QJ^cT=o zH;*|=jU0z~!}^%jUlya>Y$g&)EoBp>t@}L{rsf4ugPRg7AId=gadrw z+q4~RXI7*I|0~T7Z6<|_s`QRp4?e_P8U)~8Y zY}5Tt_qDfzC_4v%$Q%0rp2;sN$2ovL<@wC9Y1w%rgcFd@=Ku!{0=ru9DsCy({0`>c z3CI_7lX@T5{CV3cFKSQBvTK})7VWs)F`@Q2hKhtV6+i?;8s@li%$`Y+{bM|346wEG z=g&sNbIND9;$(FCLa^qSO~yo;>tGJ~Wgp*Nb-+3;?4OCFx3B30+fk%yzv=yLcW`Jl z!6r=<|3<}ZEK}|>x}m--*_?VA&#=)(j>y zEz#(~j>Fl79*ukegWrgwuCYTvU?8WZj}0|mgha2Do*9k5ART>dr}fqo*&8=>m-p2M z%3g+qkk+wQkICQ!{fxtpessBWG4`MY05Qeo;;}Elv5R+Ot2Q+cSASjohOeSlrqm^F z_8cz1MvWe6czNHPbale92EA}~!XAwd+UIU1Bg-D0d`#^YkOK;vK^8QV7(aPX^Q{@N zAX}VZ8shhkAyorX_vYOL1MU^p?iZT0QZ9F>3_b#AXMpt?b9FkHvd$;Y_#zVp)4f~JmV>yKU6@lE9i)>B+^8uW6fJtgnX^&k=>Aydph4Nno3BTS%(EP?zyyQ(xZtEX8 z*&@uEiQTdN6IZ_-%L-@AF3$`Yr^8)TW@@gQLe)qa3214eG;{Qh^Wwz;YV zEaX+nt|G7iUqKJi{z|urx}UylLlrq+J5XiK44jgmMx@P`E-v?LzrD$=!LO5g2VA(e zgC!PbkICCJQ58+3MA2|Y?z{YVEk!|-zIfA+)~)%d<$Wq4sqmsDi9@<%^YX=W1D%(x z&pn(qim<)F!O`*UShr{Y;dKvn_i{s>6DH@r z__{&V`apqgq~sBtTdusI$8N0C13nX|W+vlyn2Hx`^}d~&^7U%z$I(8w7y>g(_UwMF zM5TwV_6G9nR6cmTHa15ex_yU|>-J+yk+ zlAV{clXiL5V{7gQz*xnQF00gh%!n*g5WIQXsQcS_n@GoL2P)>9vfQT5B;VbVTh`qW z%z^*8D>IsR$vr`Xvuaq;HEtLOaKig?UDe!H6R(s!Ng8bcXM^?Vg@j~s_!I))#L3N} z))&D0ZPkOZ(n@7PLPsH^!66_A@Ynst-{EzCxeK_%o_jPwE0NCc+)^hT6xFWTs@qL9 zjpQd4s&51-e#0?^NUGlggsiB~Th#8rfO)Mi^xgY5>uYV8Q0dfIPo`+G3kvsi6PbA1 zp~TH}b&vp~;A^BL>H+b^#uLy25>Ei2q(BK&F`RvY$i?l)GGF@O#`A~`s7g$vH5hri z1gCro<@e>;)UALiATP7RCfwGm3Cz=>$(C)@KUm51**?2_}s@{En34F{Xl zCpX~O;IYk0-kj9wddaU+DCe`fSocl9*47rr8q%ZQ?pu3$w1sIcEygD>6`|eS6VVK# zezff`*DeD{Mu1r_&Mj7GlyDyWqa(^G({=+?SnbZow0dY5wVVM(b(=*zqciKGE17(gErFq&~^04zNnlS}5eMgSylheFSr=&x4($3|@z<#e7 zsVxEdx*lNAZqU781c9$YWmRs?IN2#8@4XMU{my8mKg<*u=LSQbn2c{5O~Haddn;^1 z)(QY{{3igycbkpfI$p?>p`h#UW#cCS&mV^K0v{cgjX`U*pCEd?=>Y^)-@op%7rNl} zE5pM?VOO4At#@lysBhvlsS#l)u|f=zc5;29l)tpM-;umtIR%ap5@hu%pp}2}HGsSJ z2EQ#+-&OA5jjt})iF20SW1CE`+;$kEnd5?is%N5JVAw1;WR&cCOHneO(Xhms2F#Y} zY{Po}Y5^NFBxlB9r8^cDn*UpWe@6DRgGPd)PjG4b|l1vVUA0u7`(YAsf3{_rB<=ZOrbvbE6x`Zok{ZSBS0P_wiy1oQj+LFpYW8}AWLCD~E zo5q+x=#RVsGPv|akx$iuLTDYH07cKa*Up>}>pqh@mj|SvneUiN{=7qb)GkdhH)1$-Sq3~g%7x|jH zY}Y~lx9hiGPX?~;>8HRif_&EPGKB2ygQ)!1=)FSa!v7lodz0$uD^83>fCyn)6+n#L zNe{BQoTeCDu&{M6L8LQ224^nJx$^6);J;DEW+!@?f(~okdG3nVf686J9qnM{-zpv< z>2ex|j*@xrDs7K?>wiNdXHhZPN=Qmp94;4FieKaNZYpAQu`OlNNNS%eWD)9iC&9vF zU=E#N5Z^dWbT7IP!dPfjtbJ5c4iJ=I!`kDq*~!HOi}I_*=2Zc0^8ta)N(zUILlr|q z=Dr7SI-&HkJbDZPlC&V*9o*O`p(N)qqn4bQcn2E8qXi63{@6B&nC*Sy#K_KYb7&}t zd;cC`FEd?_E!y2nkgOb6e>op7S=-Ywz=l?YDBO5U1tQErzf@|h3O4>SE7R&EIBq_x zVA}sXg#U0o4eW2;dwI*O4w!+SWtok-X@$MkXvmoB$coXc2-|wVaPG0BN>%{*;MYhT z$vmqZ1Bd5WfU0+d3K05H>1E}gJq--pyd~z-JAD4NUTtZJ(YgL5O#WCjk8G?Del)HT zeyTS(I9R-d`QegDwS|4ffk|nScgloAJ^G*85|AjJF+pXe;Qf5+@u>v+-YFq)zYluV zN{RfPc`0rv=RzrpG;oVJyz2*ABBEpQxD^BUBM@@my6=C!#;OyL_c76`s``V}n5Wnb?L~QW zcbH#0^-hHXNM`*Sx<+HidznHG_i>EbM?|pZ^<7ek)H_z>E!7W7s1@guK<|K&D+=C2 z60oqr;&F74VL(8bdgLI-p@#bop>IBx4$nW=aX9D_Ts5(5Kmlm)a@CnMc*Nu>MBdymFHpl1jpkFo(~}L zFE_kBD4@Z~=L33iXuF@6*LJF_+DlyUOF{Ik!--+u#+8!Q#LQ8k2j(2X9R{xbNViSe z)!uB6X>8t-J<|3l*%Cza3sRzj;nd8~(lYU1@A{^iE;e=T}d{Mm$_zigBK(P-6&+Y12D5g03F#VQo2pd6STS^ziy*8tn_L~nwXhhc#F z`c7_CyhzH{;cQH>Lh$@FtWaLGSlGAQdnh--;-~ohfrB=pBR+KNM@0W!ruJeMH^v9j zbv05G)SMu3mVSdZ+HjDwu*~hnb4pK%9C>nUKL5>39p+@N@BWtP(1CE_c?*7*pqaTJ zPCFNHpG>mr@{IgIaSH!oN=qDpXa$0+@WKLll*~UiJ8ZC%UTym5eDL zd6AdE42o*~rd6YFa2>u3GM(<)AlISwn=*96pD?l$1?)}twZa;j8RIkZRw3j# zaS`6H%Ru{*%a2G+fq1nGu|eO;9B=dwlM+-t>o)wh5e{x)8Teawu}b+HH+l^qve>p{ zR)Y^B_2|z4*Y1BKE#eEAP?K6gplHrmn{lnpk6Gw-6B#k-Wml z$e5$Cqj#MYXPiFS;H2M8oME@&)qCI%avI%6S(G;y306R-=0q4F{GQoCK!athHs&EV z>G0&OCAAa`{`xH-m=umugf!Zaw~7Khh_*mL`XGCTp0e-|2WTH!CB|n|&z4w)_W0ON zXuS-3;(e{wZ`!Ix{0*Px3WX%dY#Z|lEw|`Z%umh(!l1*d(3Di`mGEW-Q5DVwA`By(inPl z_#?xMCmwzg*3<<8f{~D{pIg0c(Ib3k&*BVYZRMKFZK2gzBS$X;=pLL_j^6C>joz6F zQd3`+<}EW1I+_YQd18YlgoLS?d*Qiy5baZs-C+8GZ~@=?L8z0p$kpib4LU9NTLod7^t zynfpGpN#>$r=8#g3!=^A57h0>>ArW?$!;Dwx`fI7iBsB9mfdP?P6MS|584@)!mR>T zyYcE7d(cXZ2X#0~X6m z2z+vMxwS&z1p&RI146jo09+b(Whf9O-j|ynlEfja!N*1~HV*=EJ85!)s>DK!ApV*I zS{&$`TIoA}b*!RU7ZWkc3&vjEdJbatxeyv0H4A1zYHg=8Wm(!Z+1( zUBiFAc8*5YAqibo{Yts(dnM~V$A>-6pp8k~Lq;X8AUAV!CSaz4y*}UE?9uGixRpp` z-XRy*fR6PIeE7^Lk-Kb`etk)gX&rLqsJ}zQD??p<0ohb`Qx-qN!LQQ$*WVb4T#2Gr zQUb@sNu;&Pfah6{!wC3oiP3rcsWDkyC&5NoP}^@|)vr;fv!wY5PS3L+1ysZ!`t?dA z`==PjN@T$sqkHB-ur<0axbKZbYaKu`TfnXOlLTn8(w1C@6`){e$fsC@fn(TpYg&?^KsWdeos-^C? zy6B%N;Cp`$<)Qz-6@&P{#jF3vv-kHev9SF!*`d(LA~jybEV)rJWYt7Ac+cQpE`FY! zUD+b?41G=d18==H9=A?tlOO#jE#za=o^O8HAKvdDg8rkF0gk zqsbw7n{hvbe_jVFnTjcQJ#?~!6W|z+mU}yoO39CJO}WuLv!%o__?iPK56XVwg3|-w zAmva^(63Q3K!R)+7_vFUAy)kNcFU4W3tJ^Yko3-Le^)=B4#0w5Q{j30(fk%3A!|&Tq>yNHgeFKr=2%#?viUFB~&U6@1p4d<0sF_gU zh6&RI14aS7U=WBNevPO;a(=W(^L~NX$h>imQG~07Ppe@*%ii8zIqn2yN9Q*s+)N{I z=j;YPw$wd+dZ7ks(;6u(JyB#onAWZ604u`(Tm>bZkid%kJu>7ftB!9AmDVd(jY;Q>~P6f-?1$s6pyY#mT%t<>C;$jpiJGl`Y59jEo*2a;ryYw<_>#ckwF?0TN;{cFS ztPR|WRrYCG3tzA0Ya|Ssp?|xLu0&77L22GPV<73#G5ljpu@vTiOl8$|NFx9lDm@35@2|*{7Wv|if~`9O zhs*;HV)5U7j15B+L-tLQqd^8$)5sF8ya}@KkBvCspS>Cd`&v7?nJS|%deda{ok=ym zN(EY~Ly95mQ?D2veDO!YxgW4B-1zKfQ9VIV3(a0wj_kzXVT2s5k-ai>-gomN8t#Wv zR>eW+VYgLrK}{S2Rc9tNW?>~WiDbp{pA}P`gZ_|Z3aO?k>DS(XWMBa9@@T#wO8-HO5{bA?Ml0Ny-$aFOzoUj<0-fsg z0o5cd4E@M^ik<RhXfJ@egQx?vRnjr?mMQ(6`}PC?dK51-L|=4M zSf2oIA#&CzFEeN|_+;xzcFh z!gE<|CSE>cam!D#i2Exps8Kq!%+jEzXQ)sSIBLI!S55WF3{DAs-8z>9t=3~tKL>t*f%J-Ay843V4J)^c>`<_8q|NYYwO{e4d&x}V21}{oU6}& zwp#nT5bpffP6MtAi0x!mI)G>LIPV#Z=b1>wgi+MSYTt7@%$<2AY6>?+(zQdS#;n?{ zds66C2I16qqDohVo+u=3T^xR$NBluqkUz=%3>$CSf}d^krhnrN&%$B>a`0tHI$g%B zFVk%zy&m+OEDuuv98K+%?p3>0H|hV+xy}d#r-%Cxu^Fma+CbXtzf0Y5x7aSza}Rs~ zEK`LG<EjU-5=WNh8}_}F(p1X$mnUsfy= z8sWWrTl1Zm$&U4Nza9 zE>dG-qxVqsjWrA3iMW$_vqTq(aTtmfwCwLN6Ou_bz=1n;@_q-!Kl zcVR_DU&xwg-F5i$CCUVgk`fiF3`HwAjRD;Su(l(C01y^YrPgl#dsI@9>uC&MXuLK; zG0tA7nrZCuOZtvBJ|J_84aoiNB#=&MHi^9Y2-VOi$K~B>sMdb6+s5gxsQ|9!G4zS{ zioSM5DPXVUe3-AQ$M0?u?65$TASq?Eyb?eNRaJwRi z{r=pXAOr&2qyXT{Dz~tMrV5;(5=c}38mzCt)ktNh@zfq+oT_a&%ZiOI03Kl=T-VjW z5q{BC>>B4fG*Mcm`XSil0=K7ZUIzdMh|`lNPdb2+TVVkB#sSyB>nOw66bX#tte=!2 zPZcT2SPed}XhLopz)HYJ6}Qn!r!Ab(3-mubY4Ob83Fw6{R1nn7?|>8YGrRL{FsxmkN2<0==JqK4HfziGvb7%_w*Gb#K?i?x>(a_CX-cwYHmP(6 zbN~L|4P#oy+?xD5LI94V;t8B zha&Sv*}%j9&D2|b_8e$Ef+nB92671-z%#13BzShO_9&PgG9+f*A?~!VngM0Qi$EZ6medQD>6_msx{x zBQ_!n%0Cos+YEZ_RX|e{cAD3Nmjat`f#o;0f#8?bq(_0!CBEc-L~_Se4Vizq&K&u4 zPNNBZL^f;o?(vPlFRaXe#IwBY_2P1WflxAfz?(Sv)6T(76ZGUcl=<({D(9wDoa$9i zum~}%Tc|V6?G!=_6@Kg0+ax2}nvXP>}lw^hOV*&oWS4@q1!bo#2tYO{K)>Abs1GY{r3oSy9uODnc4Re zv|eQ(WP)Y6l@1M#Td(~qZtvBK|K!igg3wX&rzQe_jjp->_gSs~Ym}f$MHPOHMUHw4=F2y#bo=k2JXAZ=k&x?Lx}pr)lTTSfVoMX99vZSU4Y?%7mpebqRYT;=SXEsI5$ZJ2G4wt@YBdvMj9c8CMCP-;DF zG}TV^lZ}p!&K3>XjB@(eHwpL-IZCg{Pg)qHJ~cHJRt$;7^Mu0G34LvEj#&37Rv#J_ zy%wKt50r*TbjMMJmzGDbzQP0*(R0*D=MeqoQ~1&0ljGLYChsBEJg&nVIjhG7e76^+ zex3jwc6zsHfzM##(zFxUEY-LkypH(!N<^?aZXJ~2P@8qDbIMA&e$1NDV25rHw!PTr zwcZ%olreg1loOO&<5-G`sJ^#P30ZI4x$2lrKY`MEap|zBhd~(~%n7(@HRGQ5NV10{ zoFIgI8ke^}{)S!?w{q(C31KcAKV6&SdiwWIl z9?6P3QYf+v_AnwN)2WbFb12H)A;!-_$Az!@+6+Z@<$f{Giy7w?-$5-iA_Nc33brPLImit2QN7V;+I2OJIRf^f> z#J7Z_x9<%W9C97uU$6)n)OY5Wr8m_(88F`w_kqp0JCbNChr!#{p;IP6pm)CU0AKUy#A6^a27RsSdw>+V z_T7e5C_zc*kn6cyg)1}q6AOOLUZNgrKeh=`b`GB9gjPJ!-DGl2bwq#fwnQe90!6xV zv@q>;{@LxO_*&CsSBIrQny|rZB$Pa8wKvjjHt-N#ntO)jh%pon)H*XkrET5xkjVTJf{0lf}46-Xf&c5F>)|C7%5RcBhqph+W;> zPYH}jAelu}$Jl@e;-v&ge2eV9*n@~w#-&AezoGg`g)6mvdA00gwH~%cS91K0ailTP zot-qRxv#leT3RYvZxVH&K`;01vi~``!J}-ez1AP~RfS41#LF(8U^5OXROgv8qYP6o z3sncA6?}E-dB9mTt8^xudOgh+GJSwy6jmE6Z$6S3k?pgLmUQ%Z71v-2{rY}mM z+XdRG)Ev|x{O~l#5%AHLg4Y8>Ng-u7=jaqSQX~o$2jy~XKJwf(?>Sk)dhGIRY(>G8CqK=X-E=CSeG&72>K zDdS!T0<9$I_H3%W|7GXU^J7pvD>?{HP~FQ|}N6%mmVzH#%$L#W~w-AEmf^2*@|!w%-=Z6_^?-x4Xi0 zix_A6|61)))xMH^#RW%9>&C2| zPcRH_u6u54^*hrDBeNXt-V=}o=nLFvFK>yLC(-F_b!pcKI9MH{a88m-1e?KmGOeD@)Q=*!p%c|864sPU=^gz%k+k{ zk=Kqj3nv7{1{{SzOW-@#)atA2WVI_Nj;7A8rE-OJ($&}tA+B7-x_j5lk|=V`gpWTq zyhFqvHMtS1RETwA8F8XOsN1}|Q*ZKA)teK zQAhEp<{`aUfwOmXh7i9anu*0yS~BU5p>s!rPzqn^0_WCp8`(E+Am+nPZl!bT7I3OM zNpcf1MG}fBQLuy^b>c!J;ghdyeue1TYr?v#4$ zePS9{Ru+1PZ|Bg7wDhe=aNS=M95>`O_ExeP ze_>=)d^gNtQjYmkNxDjdAG2}slRv%XeuG>q)eP0FTIBuFlHnkD>4+@Tspfdg-8l8Z zA^&5yLL<3LA#fthP{B_&!)BAmiLKu*z|M@|OtNVB#M3@n9pnE;GqVTnHfN!i?^*v0rG5^NwGjWlQ z$mb`(U31oOX!-j!%Z-7(2Foi=%TZrV^_|&HOy*32@>PbTdh?>ZT>?hNibGK!N=+rC zfvYU)%1^RzZf+I0W&1^h_q-5~YU74M zUd@_|yNZxDlcIMh6#d(_+L*9AK}+goK+*#>P`h4`);X85v$ruVYF}acGT=af*rpOS zCJcYdI%Z1TQ_DR1WKrSzA^;V%=83B|o*AE?tuISU^q1lph{P&25gKRulYcMg>qOJR>)fGJYddDHU#lwehEnJHuj>XMb z>&oLWLE5=>Di13nzs_NIqmSfgw_3k`p810H!mSlraZ`)M${2~Nr5>53>x@|{SOoGf zzL*uL-pP?m*Ov2z^fDylWAcqwIg>OU*8V;PebJCrjH)@wbG zgso+kdsaTQm1D~35mILpa;YI)Z8m*;fbZJgIWK<0_t`9nApzof7w|k&JVxl1!s*q} zwG*>C+4{NmjK(K=N2q4?j>lIP-|tN;7VAACjI%O1!|B?ZJM5Qcm(O{)2+1;RJ=qS* zm&yPMcZ>jIrJ8chyCtzKE%Tx+Ok_dm0De_tv<8=!U|RC-fR)aXqwz;?_iIqoVv>-) z(|?9p{mB)#fR4g-iJPk9HU?*k9Xml>Ac5g!wvF3#_uS{?jth%ssMb!!XdFkjUih}q z*|>A)FNyKkzuo|X4NY1A!(umU;+mnhZEc5_0CqU)l;FSWvMclKXM(vb?A&1=v?(33 z7y_bXuOey;RXCTb!*pvR?XwvB{URE+l{N#t)}EJUZvLoLx{Rpj=ZLgkfU6)I&Zuxh zM)|!?+Jp1>DR$im(t4WjWKtvB2{)t>L*9A1!^|`aM(JSimT@L`nhyDp>f_vJiOHdT zZZv3N>_`uhg;vV_6@@*RcUO$!@G7^-6WoKYh;^QX!~)wbV6>K|ecr1>=v*^SVZGtB4oHc>wdj zvMBztuBYCZv z{}|E}*i+@*OIy`mow=x-mBw7W-74DTHEnR_|^s9X1o#%`tG$JCN7HS zN%bT|p$VT|%D%aat;$YFMoI~*Kg~62AJZQYf5`&6b%-1gt(4v>*yK!%YhGHlfA-am z<;0x-+uHa==Ersy>ua%&`h#QcsiphlhDUDeNZi_wA@=}`&ZlTjtH&`~L&i|o7{$G+ z?cpqpv5RG6fH!IXJ2MoMjzua&1I%w(gmWO1SHVmSG@tzIxJPL}4Z53O`?*-^iC)7- zk@syytEaE%v*v6vJ`SGTYK!K?pp*_HV6vbT;6*m6-TBMo=3EBIoyow83%V)>&*66? zT94K2HslRkmKbV@(Kap11np+u@bH+*Sp0j8-$VF6&y;6~4%1$ZuOmY-nUY_}HhI8Wr|C zDB}lakiDf)O8deQsjayMX=9}+E+sq>xQ@s*lJ;8com&AWwKZkY)~C$eDCV(9k+f6NCg?Q>Nu5< zrzY!{^Zl53*fv^qRn3DIFm?+wnL#22U%sDo31Q-78xP%}^R-7Yqu3~+An=y(@8iOj z1<1l65PA=NYehd+*BScq?_bQ$q^s56RB0kt=J?ZJ3jr_#*3i~0RDmo@J2dmoSO@+^ zv-oz}_=In$O*x+Xxi)#qN^y}HzZSHSD;s3NwJW1vwihozxz2cgaPN?;e4l6T$JhWF zO}=PCRK8zkqo0b9ZZnazk{X?(;$c9Qo*qOSv3&7f<>`VkmQD9S9-c@a^}-zZx>w&I zlF*Z&(%To`>3%B8N2iiOtAY{Ioxc_nk>OgVbTvJT`}cJ=ca50C51FP?!t)0LWzjR; zt7XkIwKx~Gw{<41b#+R1bt#KcQn9GbHjjk^>Ar-U6Va>1+x5*9aGww0O7`R`x5im^ zIPsk}ye3)A6%E$6xg0=g{Yd<%bo^4mL*HjM?DB`&GfXJ?vDv=>$Rh`h9E*jj(!k)o z5i{(E>V}nS;d2(92ca_WT~CL-eE8?_Fh<{;tf{rtXVvM8cV0*3*807PLNCt6EJdjB zyn?&V`Rd>X5<>}6N3h2=!>INBp73(pD5y8P-1nEx!b1g7o3wXsXS;RycA2wmi!*+!tY^9Up2*&# zNW)wQp=OzooWoet18M(vx$>19!MD|0o3ec(Z@BI=ZZ)MMWqk)I#XErrw`RQ;8394{ zNz0Q=8lrFy)U3j~zb|TXjy2SmX}ib4PJqyznIWgcPl2oECB3wcuUM?r7q<1}?!uRv3xBDK{&`mn{W%?e+OUGL$<_lDrLU>jn4Dws@pCQK zbf6$=>uYU5oadI0_Zj&C*@fMr0NI~2O@5K zBI9UfBOGHJ6Vf>CZ8Ks5-MwHvEwfG$sb6lT`I1@nUy6+BwHd~RhGm%flj_-Tk0m2- z$If`A0S8_~k@Baa_oFk<)F?L#3g<9Q&|SMa(vvcLs_|W6J`y+ToK0w12p@iICIE)S z>hAZ9&O&+*3dT;R4YCcpiJd&C^L7xF37I<4{6IW|*>Gno@@1O&^>dKA+l_~bHxEt| zB;rS0Lhteuv%7|n&OXgzBB^Lbb5UJwLP_n+{1O%W5h`gAEH?Gtj74Y85QdG9E(Md< z&;ADyvVI73QYLe(Hq`6^YdyX44v}d0{E1jCP=9_iI5Uu%ueM9Kv@zW>vO>QM?%JPT z_&&)ug0BGYN{1$!0xFga)r@b!i?moXxvPim1(NRFOSqnL<~CLC^q(BaG;sb*=Kdx> z)fv+`yCLV}of0BGYc($6fe*sz_ z52C@~Y>dKtMTITw&rRUN8i5Xzu>f~Z_!QXnlK>%duo^-yA20%p@hFCZw|E4Le z;sD=hz??C|?0^24PshFZM*;tbWBl`_zynZ7gsMXFPKbWot0y4)4TxXh=e*?RKd$<9 z#qGj3r31DXQ;X=l82`m8t^4u5-(HVQPF^V~l^4~j@Yvg4+Sai>N;G`t_7doY%5m@B zy>n_oUwYO~6=zf1C^&;11}!_a4N5w2u(RI@kKv-$*>HB_8)N{)=VD~@VTYuY>u9WD zGF;i?weIFqz$H8E$TAgXj>}i&GAo8`O-)&o*g1K10(VyPkN zKRw&f@6(g?DyV=J!4)N?pV<60nJpnheMO3}GJnB2+xe4yq4_*Ji=G@t&-y=Dx=e^Osmi{4lZ zY8TQA2OULA%e`pgfVAmeqeIKjiplkn1E6jkn81OwG>Pje&g5$8>OSC))Ok2w3;Z6) zFI62>031i)gUAS)fQ#G^`BMP)|K^I)cuFY6Xx?E7W8u}D~qM2t2pOwAnkUm zw*e?g5(eGN=$X)eoV`?Y*Tp?^PIA_~5xOb?fb7pNp5-G8Nq zOcvrquoJt~5vhxml3*p3EP}dvQHnq9?zi);&8#uI4jn0Jh+ku}$-4 zFm*af#ys8BBFBL*q($`%1E#!7J1yNu8)U9qHOb?_a{n{8%0g)f){lS{@uQb8c-<@~ zi1p!|@ir-kN*cifo!Du$aH`F+>!8ix<1+6A$7WTA^e(*6`A5vhqREb(yep-9K-TiM zV^v%Q!zt-<2dGK(6=PsXYK^dSzC3yGQ+?e$f0^q4On!gGwG+J${jg1|GJ?wP`KNCG zB~u`_IZ#^XGji*Rj)}Gded}8RHLI8$vdVXS=pmS|1#XX)Wc&~S(-Aw9t#r4`*_>Uc%P^L53|3%z?Mm4pqf1q$I zfWihWG)0OPB@_keC0GEJCPD~3D$=|3mLQ5KRYgFh3epk?y*E)20qLCpp@-0WOS_YO z&e>=0^Z)ja_l`S1P{vBKR@R#3d7k+z0Bgz>gqyA}!behB{yXo3;LYq*AZn&3`?`ry z??h2=Xb6xq#VBz@rIv?Epe&~vt@ouV{KQd_F771-tE&rg2kYY78PnaURBH4%$#^S~ z^|5-xl%Wi8jP-y4c;i&*u~UQ@pW2&d6EqT&{$~Z?wzWGZu|!$1D&h6+X0hFr(JPfvvn~XFjJRzla`xq zwjXDUB<{#zH0iWus`=hFokp;3QO)vB6~1j2@J9nx-Fuh1?1%K7dh1r_=3otYNM>%W zQiIC-24V}Vi_U0;#eH)Xgpn1%AURYFICs(!MlF}kCeW!1rUynIkqRYcF#bdZdct3mMoM={?m>5_UQ92Evj+BNALeZ}xslyX zf+A6uS#FeREDx?c<)4WNZ8<(W@~;K>v(?3Ey8@K?QMo=P*HZbXA1yAwe9dbH%TwlS zi9*0blkP5vZ{dIM?V@h!e@@EMJIgz3x-S{=@7AF;#2;vWW?-8Q7NozD?Kzg+!pr{-2g| zV4ngn{3oU3Tf1gxUGPr)tGb9CURxP}7WjZ51Progri)ieC)T-J+LcAqH@HbXGIj6% z^ZvjY5i-t_)WB_i3^j9RRR##~9;PNPe<=lN#KTe3!lk&bIz#f-bp&m?4&~)L_bZX~ z`c~cBzuvwkR{rp=FRg-jqG#>fdVA(YpoTEC2p~}FW5}EuOKrf84YR20uT%PKDQVRu zfApQ113F=+1AVRuj1z|&sAc~}ECDyX>yY)wP0cM`{uM6#+kZtZUl;yr+M%po%^7!m;H6c)>_h72ibr*{Zd+iJcG-h*%GHdr^cbu`obmF`ss%|0Bu~>u46wk4nV>L(jOE&ekj!eYxp?GV@dV|h8f7#I zQ@mi=uenS47894bdyIRz-@G45x-7fCJUlQ!*F@6D=RpV3^Q8hg520kmPqiyBTEXh6# zk%z^XT6ZcZTxm18$XtY6!9=$ka*dN^f6hB9aSGMeFwntGTXF`SoGz>-^s{Qe+ViQX z33aV6Z=p{94qshzqp*ctfp)Q!eZ<=X9lN#5h0w0h7$D|F@TL@OY;-qf_}4z2p4+I2 zOKf6Hltn30>*&jA*)g~}+TO-9cK?(mqL5CK=6(pfHzfr7s3chj5@4z`K~Z0xa>fAO z=d$FWd+?#F5iL}ponJMIO2T)+V6@!@j~B_}^z5jN-O_QP(RaZ&!Y01$F`-w~ zyE17TlcY*SZmy|Ap0ahvrGwaOzw`pe`C;lI->J!ba@L70&_srQvodKGH(H}iwVTAT zPe3~e(~GtDJbk^WZUA+LzG^{7mO6O|sVDFkb-NY?dO;uM^9+skoKu5hn z9Ll1x4HEpPdF*qev73Z@2}Op>b!_NzP+mgjW&0Cn$AoF9*w`KXK_F}IP&OrVqM^5<`iu%tIdUG70#>B2SL4-TTP3IB!d6q9Tr%>t(FWo9eLGF)fsMAN zf|MhijRQJ4Cnw)v+ad`K<(j#!l|1S#jY+Aw+q379?dH~wHE?dk(6c-pEVO;U#NBEU zw&JG$v@MMEV{a7*%IBbbrCF9;<4c{Gf$ieA)qvjq}Uq>{V<$1Pye3Hzk<0R}1@wUjk%SBn_wZtFhXtlJV{ z8pDs?n9qj)C2|t~Z-J8#b}y$p8D)9mxx74g)S->?Kz;1sL)4FlS}!Ei+{Rewlk24M zkah~bll#WNJ@sF5pL>E7Z=X(NbiGFV5v^VnMr-nba?j{!pUL%+@j0xPrgwHpI?r5O zo|PZ#epo+mdnS30B+@b5L!-qBG|6*Ku&rcOR#hHDwcf0!>#ld*`=}?XFr#*xN%M>!g{CIlcnlw95jFSw zIczmH=g{97=mAp!2FM8g-DsmDuWcS%JmN+n8|h7sDgG`fjINR zOA4`)F@=Pu+6}W4zJr8^y}q3XI2!frF?l<@q02~nv=0%!>AOpC^xtL@pkNpL8`_V5 zh$P&tSx)lUNsPNhw`#BMhxxQU9+|t*x+6kghSI4oDBA*c$!|hZitaJT)a8nNF(VC! zCa+KC(f0e7TZW@YMk_qvvs(`s`zQ3N0$0VB|JZJt-*m z6}DOcOYo;R>8TfmRv))&W%QjJ*5a@4olsuiEEJr>_E{w|Hn8jsAo(xgO6q36zG?$Z zg|NvhOX9KZiP`7;FI_mJXQ1U&RXFn2kAB_Eb=t3u#1Xog=`gvYC)M8R5Lt0NAOLLd zp~sh%VblH%^RpeV1O5EBQ$OIKKg?b>Ec4F)klTxLhUtB*GRSOpCtjnQ?DZu3QxYt8 z^Uyov4ppxF*fJ9eZ}@uGtnXR*s8ow98Aq`+emXvvNEEVgqGV*Wy z#1}|RBopo?)G5B;Yuv8b=yLs0`}nKmmedFuc|0hREuT_lN!+N8^E8>?Uw%64d;m9T zcf1bv!0lz|j+c}Z2f3a;9Ri{ehG0k_@E+Y4bmT$DJ6UzrVv#;=3!f8(+3nOHrP;+L z@g;|O8s$P}5+@C=S;4Wn+>TX-%fDHjiHup3HU1)Vdg#3Zw>|-0 zax!TnIiZ<({0EP2xGOyBZo?`tu9gP<;C?$OvkcXcHG4@%ZtQCd9qd@oT0Ag2`vr)Z z^xBpV=`zUQ{^jHE;Lz-6-!DD*k_Tp$H`L1ScZBTbZ_+|V zA74=C=XIO2-h814#vbp;#UZ>Rx7xG2i3X4?FaAlV&4HzX0a(w%oo1{8(d!4XWoo{G zV04XQYG0G}!^|F!q))kXFk%%nC%fj@p{F({J7)N4~U-9>YzuSd3*`Ge)$22{C+Ul5%IYC;!m~I{5JOk zv;GdypH~eytq$aJO=|59+yTrJfk}yLdRB`cjnT5aCqt)|cZCFC#P!F$)4ur2p^gnG zi)yHqaqiY&`*rgdd4`E{r9O*;ZZ7BuP(K%7YK;?5>gL&QsZ&>K5N^-Q4nGMO=benb zCf#?Z*9l_NM^xUf5P|SxSm zDaBUFERF5c&?E7l= zJZ%`P|FkMUPFNW@kHi9DVYZ0bFR!$p`SJDA-Ljh-XWU+6yKd?VQWHIysXPcGO0eKcVY1pb_nE zeF`R2zsgO_p`W|foALqL{X2;;*U~l5g!Qc2GhV$g1JV<_FOylL1P7FV@8>y+ccw!B zsu5*7CYcFhn6)PTD_Vy?aVsNF^0}q;&>JTpk^S!uk!q;HA&8Ngw7NVxySegR{Sx#n zB5Y9ne1ge)#DiLrwRzGKec_kf+4+z<_X{TCKdsNnn)2<`kTPX-xPIGy8F?Rty}kO$ ziUVpU01c;4O}cf)#*<{%&dKP$vVKbCF;P?)l^WTx|AkdVv|l})n)orw8ji?f3^aJ% z;UfC>TEs(l-523#N~2%bW`CXhJ}E*$=mV%_ZpNUQ#I-i3Zp_)0jr!W}Whd`tKOl#u zxF5!x7fB53FNpH~u_r*CRo5%C@q_FE){eQ)7u=t;iH))PoVh)!%G6G)u5Qd`-|%X#4J;VDS(oYPJPDzDTy;l1mOY z?FRd~|3Psss}*W3Pd3tyk>VzYDfCX_wm*nLdzH8|BE~2m#7`_NgblM13IgjJM~T6nbOLuUFC4K=40J7vooucH_Ok z825O^dU_5GvS|5;e+}(FF0jiYQPXg#?=`y-=Eti8M97q3h`Ymif%e@A0#(a*rM~Wy z$YJ*p52{hQTu?U>7p}t@*8W8D4Q&^gxWbC$pmXb>_a`g9@g1^%S?TSo^*6704kKg##xGP+=rpCey=vZ_-4sySgTBQ0uHDzD4cyVP8 z0A&2c7{du$rDc}m#3Sl}^sS(@(QylG8uHPH+o`6$4N&v9O6pgAO7}NUlJ|?K8~a=7 zgS8XX0rkTl&(Hd|h?Z&MPA*fs#HkOsydy2hH3V`^&KiJz<3lH-8bJ_{zOe4pu?2Cz z4iT@SH1{y?SO26V8caj4&I2#zLfEe?pssX86)_Jp5v>H3ZHW}#dH#Vq*kYcd-D}5} zN8o@A`2|?uIgl#`bQY=xRUFT#JFNd~PUATpxwte^<<;zYo}(Iw1ubj>J=+XcZgr`J zfl*K#izc4yi*Wq0`qM2Tj8gRj1vvC~fvV47dq9j^F<{C!wOb(fg@6NM7JJFKf>vJV z>T7_)cy*fclLi>Rzy_!Xu?vgZ!4*Ww{+w9O9Nle2i$~+L&&)S{+r9j9Y~4*!R#w*N znmoaC!ArBj#gJp|n4M9l(ebnRk7t~9Q;N;VEg&pie%!u&JW-?|aklzZ`?|tti`~qJ z{rE-Ga+p5&oU|XX<`i9$_n$EIBb$Fzn(!N;u8ud|cP^@%S5plc&^#3As%>0iE;-?K z8eVL1x=dER^BQNO#;Uj0^cgkckbCz#DdA*oHb~YVkznIfNk+o^eOZ`6o;ldX{#U=t zT?_qwElJ=0%mo1`2nbsG6Ij_A%iBPfB(aMs_IvDqk(sSQIrX6qfHxdbM31BWm7#Nz z`$R9;(Dt~)nTrJH!(Vv$CP4v3^7EvS(CL;`$}F(hlEloUih7}^cd*aLqJv+YE*}&X zKdmqFG@APkMh0us2{g{|n?K*JJyzourQ!}lat;0vK<{-YC;7wjD3oz_v~0kVTXMWV zoo>DCl!C=9`_s`!pB+AOwB*=*6^{Jr8-iU1w>SDcQw%c^qJ6gXf#WrabCpBrswaKD zIH_KvvNlTG9vx}gojrUp(Y)b`IbU#4clMY2>4@X`oIb}^&)yKcaVzECH@$;CJIn3M zQ(nVzgT$!|3DHwu$t8V8a&p!%%9gVF;4W2}OxF9gQ%hbdA}0B@{tMcFK zjlG-ajA|%#@NMvY_brFHN%)0dZ-N}(!HzI4(L$5w>gPoBj^Vx)Ft=WM85#iDS$cRX z`29%|l~V_{c(ZcazTX&OL#Tb~@!?>V8?LfhDap82k^f%b>aI)v!5Z4;ZF#O-Xbd2$ z;c|!KN*s?-Nv&eTR^M@ z;K_UzlN0E0_1Bn6Ufe-b+Js$8sl$@^a6xKSwM-3H=eUJge>d?C-%Vcgy$yn~R-6>Q zl6Tk|tNjJ-cWDdA%^v4)U)iaz)Ld;e(Oq>4Vas*Mi93pkT!cO;%hT+rT~qPeg=};@ z#Iv$?^0{0zn>}&}A{e*(QN6O5O%jw01C?LRgPpK9*z1>4=UPu!b;v&OK69JFlj1$j z>+JA43VET+@I7xkiMxhfps;TtUH9=>k}K|V#80Ar*3TOL@Jc4jVe=aPI2I zmES35S1wFhcd|wmd6pe}iw#UXM%xO0R{jw%5zLwyN8Q>dUi7~#7j8R`x4L@&jqDj^ zT0J}JSc~dQ%uPS$Q_F)zjk$TD9oDC>ZXK9AdyCP_TZfD=xsYJgTdVqrco|q4cpjM5 z3@-M&*>|v`^5LA*jt@Q_UHrX-X>rbergCv1+rPckk($;TPiL*7gkne5HR0DZ`|WvU zD&MTdxXlb$L@9BhN8l(Y78sN#7xOEIlXEGIQnI^lCOgFB0zWPr8oM5o0mTb5LZ2+$ zs=}x&+!`RQjavsigP+~)MIb%k;k~g{&lfAC8NfbZfS+PVu3fLMR^tbeV(h)%2j=Gd zMi-68t!v7z%c}bl5YG+1?q)a#v`~}0bA~cuIzzOdW2Sz_w7N2Qg0@FwF(GTEc$Q*o z7TiHCczI|7^9LVpZXua`uR}gIw7HoL55GSglI&6IK1@494jp(+JtCxT48!*_37Vf}kglhip`@jX;eU&gj`8Nms&{NPf5OYx89!}2sq>TgU?J9@6? z(bB*>xZp}JBU^6a~|Ng1BU=@ngGRA92tHoWJt}|QYPjyvjrD5DswlF73 z1gz~HW_=@t&X_JW?*)H8=9OX9%>WV9JTL}4MAT|CAFfn-%*!8sz3D4^eF1tEC}TA& z26cZ-VeZDv<}AWif1JdL3iz))!%B(KPChZg&K-56@&pYe#=tz@kUS19ES=fheF z4o0*v2Ty;Y{PNyHk7t1A;gn}Z(MuZ!4ftzI>{M~D4@>vZ6rJ)X52h;TICPm__hEgl zoI|#IudUT6pr+*S`EQO(zRjiXM!uT3Kwr{fm5059|5|bwNs?7ZZzigWjZs=G={;u; zkBHxm?|J*h;?r&e^xUn+e!`C9HjU*5lYZp^n~}~u%z-_zi!A;~@+R*vdG5zrOk7o! zxtFc6OaQ+LVc?8hO?=$z$c!>CC$|IEi|dPJrDa$_!95BUjl^UeORtInwftFo`6?%7 z4Jm+mf~Yu8Rsvb9DG9i7tsIZZC+{ZWBD*-+_+cYcoEO97sl?8mmj}B~PhMV9!>L(Z z?ImyjY`oKquKMZw?q@0{$wz|6qN+7w(v0g&n1JNke1^m@Yn?Cjv4nOi)|mSogc+TyvCMnJsAUNTH)8^9tiw zmqW4&Bz8LwVN-gmGkyV@SCFmOl2bUrx1DseWNsnLn{L*B^xd6$BQ4f+OWI7NfYI)d z)T(f)u@|jGrkv>E;SqauC|Q_f<%?$C*;83T)XEb|YYQ3};2w>Y@`_faBLV!XEu=Gh z9#G*?55Dutv!8OM%lg|}rCry{xGNU>`Ey(y*_{DVxg%fM&BMuTom0!r;F$l+a^H{hl&kjWyD3 zAS#h_&11TxR|68i0Sz3ocMfL{I-ywiK(GM1!{M{AqU5z5lJoG|Bu|qKmu|#0+W<%P zXSQbZv#0M`@SL{)MBn6n;B?+pQ0=|FnhKNN@_OOzw=1SE@oa@<$s7s6~ z-AAv+#s0)?ew*}1iD)c_eyW%H&6E{w2IVS-ziR6-ghIxK(|#vo6up9lCFaipA0^it zQ$s_>+orp}$jfyEhA)z;XJ_%wbtA#KnOgBP{6n;b!J?5{b+EnJwAIPg4P*V_*?iM1 znurmLN{h3(_h_vvwXE_?kQiI6I9u){8nLqr3v%4z9M`ARfBNLzJAIpZR`{2~XHk*F ztisf9?*_SYuqs90N|m#QPYE<@8>Gv*CNoQu7w!0hU#A`HXLhNY9899%8lEh6qmS7k z3a`5K%%8*yOOh8aDb(SEwz69H*yWn*FE}!nc|7?uU zrJ%5&PO2`_5z&Y;x;bX#^)0Gq<#GRk6TO!NMmOg%X_=IUUL)tCGU-$I;;Skb#z5zp|L54EU3IFUwjvPU?AR2cVt@hahFDvhdpE72Ld-lmQTPL#?o(@#@ zpG{HPZE38Oa#Vkx#gdgY@x5$3aQ?^r6rYA}9k1Gm3&^ohjsvqU@01hYILTf}z^vux zHRnu!pTGRWZ$4FZ33e$sG=S4aG>t%&P&#YsNY1RMbI3=F2quaGi{mmV@wY1(e$iIR zy>`QA4t*@|QuaI6vELjz#t)yXwLfiV`h(9g;2F)E9@j{3^0>-TI-+IDOSzUCT2!g0 zaEf|7Z!?1t{S|xubAAWmwf_%zZNwZ$mj80%PLDxTY4*Je-2*)Lcs!@1w9|B+$q8$YhzF)8pog=r6<_{z zN-73=Q}V6w3Z>5kzaQ97leQrNRj%I+SJR(J7Dn1& zuLjHt0=%k9`^vZ#!-uqQ?Lb0N=X9V!Y*BG>k67)_1Y1m8G&~$N`#EIT4=78yL|wJy z?tQtR?+?uE?9BT*0);wHpeS;?RF}b~j!s=>YP@eBrCj;r=?#NJ9p=uAagq%PZf%3y zL8f3ml3nol=-AScBaym~tl|D!jp@RjsTOyLhLT#2_c&*lSnb+HS-6KN_IV|{Nb)BI zYnp3!S?zRN?{agC+5OWhGaNdVO?#Ln?Z)hs8C+Qx8I8SJ#(5>9{ejR6jF`0@dhYVw z%%a=R5xnNc@$TsC+3JfXGSw08_+h@0K@`S9yJwKtH?rvIRb#lh$a*F2u7`4EVV-;K zt;CXxBg>Ccmy&=N(%w0$U-p#;9^J8wZzw&fqYHBV<@KKj3ei_VK2;m~=aXx>Za(fj zTS@!rT-D!IV~efDwK0UlrQ}_Gf+NE;-f?s5&w5$mHFHpx9z+ym{|d(?UWd-)CHYo& z3-_RuPVruQZl6=$dAwg69*eKd zc+4*?@VFQVnjEl8WJx5farYOSAR96%d(7|DIlp#F;AGikx(`5n-{cMFyeZ-zzt^@$ zrIDAT=s4lukC;WEg1Ihhdz-j%8!YjESN)>7XWtDxF4rTR4qaRpx_I9cJPjHa@t<9>oamHox?^g5|V&E=*~w zm=oUiCvWM9kNT$=-56*H<2Lb%xTiZ52hW5)01?QUpLnmuoBW@xK%f0Tf>ZTuB@k>i~2odaX<-o5kgFp9~5 z)!cBnRn?J)LQLB@>YSZ@BI)pgUlec(Iz4-D)NkrXV+d1x=v{63T)0Vya#pdh% z_RNAp=6MaF-S4YPXUp|%hmiBOXcVIl+$-|aS^bt>>b$89U0xgnQ_@@CTvs@k`3mgU~W1(OPNK&BlN~69JMfB9-Gw_!S;O*3A-!3hg0At5uMGgqgR)Yk+ zkdtp;*!)ZRc0zd2PbdNx?%$3sxRBV~G87f`ZSO=}>xW0Y$N;IQa>4GC2WC4)7*VjH z$Wa8IW3O-Zt`9q7ipQC8&kG9bH8&h9=P=k|`ek=*3K8p;*$4vUvZ|$8e1Mn!*}d${ zq&YXvoC67RR9Dz~SkbV+5TRyMdt1G7S#X8#_}+I@bXMK!Pp*9|U)u}eS)2U3kItGs zpEK?bYh77k!hd?g4BsD_Fa9+$IrcW;uP%S-PDeU!4;eB{P3T~6 zKs9s53i5UmY+C33Oqxkn10O)fW6C+*rmtT;f3V3;qgT=oUz16t%&#>r4&@Pe*nP?Q zWzNo+c`3e-ppNyts?R3723uu3LRUx;!c?qpv#&+GlD06pI4%+eCOLrcUceE9w7$( z9*TOJF`kp%M{N*Cb>dExT!!SPYU6;x7yuQL4oAEdib2@j!(d@^McBz zGP%vGj`+X>8B#dEvLep1F$Fst=L<@t zK!y9CU-{j0Sj~{dNHmh592eOcu>)5rG{-DK6rGqBNih;&%PiYD3qKV`B z4O`LvX!r*YWnrkW>5j4EYk74${A5dPgUV1uc2sd~#x1-pLsE#p)7xTifEVBUqOAZ#Ib+2O?1?$kNsXp$l@ zkjIVhr-&OK^sV2~Z}aTKU7F}*LNpFhf_CoOrb@jz*bvMR+;_|Ysu=CC8-^W83s8tP z5w3jcyI{?3g1CG^X`Q9W)X*?Eaqh%Vi4rZoI$ZZX_3*-=Z-%|Zp_E+GZ-1O~0UR?S8A zF>r@R)JvbnXSB@{&HL?7_=!pa>9w&#BKBCPg)5ra_cg+Rb8oMz8mz>5{3+a$P_YtWizIRp<9dPYMmy)ISA~$cQ|wG-W%P)2jnAK|r=L0Te4bendJMPCDc!$3PMudI_|E69TKY-IvQ33%r1i$a+`~6tcB``+ zX3Vl%Q&(}%PHT-%W%}DW%;#ifc?;#jCIAL95a&{#A`AxmFCWu>@UNS${MiYIwU`g^ zCgY+ZT!5?A)YH=}O3{L=pRXV4ZgTga%X59+lY~s+*kPI@cU?AYYWM=Y;x;XrPeAXC z@F`U!1$9-3*&nch+bG|!A;2A$Fbsss1~Z4wNw15j{NfO9TKYbmP!wF^Wi{W zjJXz?j2?|SfJjF&Jld2U;PTw8f0Vnu&93<7`eCeKuCw|?W$N6w*YHpFLtRv6I*pbd zH(cs$RDQJ<#t_WOv&ET3^$d9-e0sjT8F)w!T@qN?JTQsV|J=s0sMk1gYbLu*H{;e% zUZh_uA6<*E7wdOtM-jI6>%7=Q+`3!;h*woR%^UA^VYpP2spwfzgAhIr{|ZZM^ZowT zQO7XDHs$5q-%!I(>-%J+t4t$>FOivBDl~gDu$ZmE&oZU=si(l*yA9@vH34x&X9QBR zKQt`F=#oAYw=kYSNwbVU_=9=S312%R?KC`KyW&N%h^n|=Eld;sgO{z&&WyoQFW3z} z?X8wC4!2^GXK1ug-hfW)BEon4t)~LH$t$oUZo>f83Id^O7qq`OyJ?1 z5{UTdL}X%}!;2y+%9**X0OKyywR_*gks+D*`{QKLw6dufMzH2-FXqcxw=vW0rY#|n zI`85Dt3AiQXyw7x+biB(U_SVF8#DEkhTk(tC#`WKcp(s3uZ^mQ8|4PKeqO#je=DE) zs~S9FOTWU1OWb_nV6|~OlVeFzU;7Ien{q53zG;)2cgzcUWbI{p-oal?_P$=mAFeXA z*3|Y%3&woDcyWo?h*ox}`Rua8!7G8nS(<1}>e?$(N)u)C8~$_tKYzQo`y}%@X=!Qo zbqzbHq*l|YqA(b2-lsUnAW)e*@ve>0_>_abgaJ<(v^PFLm9)D~tRmVWklBwDvhRCK zKbb5#)W*OXLh$^X%3JgB+RAvLd7eO6 zTVF3qQf~pF$3NSzTH#gS+iLh?Ccv1S{n2NolZNbdgGdBdiS`o1e;++5) z--G}6YID=m2P6I03OG49jK@=MwzLaH`ij}x$vbB9^5%>`d0tYwrOA`zeCB_ytP+@r z*DD1k#ajQ*0mz&4|A1TndHC0<|8oN&Q-A!8Q}FK(dHR2ECq(Y~-x&pezA!KWbdUcz zuS(m#N%23Mp!Gkv34b1ZBl-X5%R5g1b8>2G>gRv_A*yn5`32fA;`#siIMOO) zV@n2EmZmvfxt|dR zM!Jvwc{f4+pcBQEGOd5>R%Xlfgl6qc;_H)_26Dr=j1av&J^#07_!{LMPVwsoU>rws zsUx4cfSld?2xfjD&mQ)NeE%6mo0@T4)v_li7XHi^tO=+7=X5S&3dDmQAoA~3Ix*r$ z0Jdc{u9IqhQ{y4IKwwGu>9_+G2$uf6&4yN2D?%?{ElY&{ zzXQ0(rh@YLBPw3EjEp{xfPh2#(ZmmL%bDg-W|NAi=`nF}UbZ!#KAkoJJGdU-ohnI} z;w7ykO$@Ale|V%3EAqN7ZyKKaKVwWVlm)guZ=I==`1F8{AoNpwE^1I5!_@ zsG$*NdGf%lm~pYq+mX|XC$50fRKRw{*czYL@Y^k4%;5mAg5xQ>>yE1v4XdFYL!;q^ zg`O>>@pNY6{T$}MEzr&6#*NQdissHKr5l6-z*5EsK07qxwLH~yGFGbwpohIDh{HND z725g&)fpSxFODV`9fFf{#C7ishI}Y<##^-{`!>T-{NMp%`#|a+)UsFMzq_{M%Pb~@ z%S<*l!0X!>w=Y-Di}_8xRUdwe9m3oNn~%WB+PO??k^8?_vj3OhJuoeT$@DBGoIh>( z6F>MOYhNtkpley1xt7G3ZC%{<-^TIrjS>n;oF*>oqSEILGr)kMjv{uBtpgpE#b==A zP>0F^8yWAyLPJd-`rKGC1nNrFpVq@qNj`32sDl9o(AEBuwyrT(#T_$z5_++u6r}ju zC{>fMMh9%Ov$I`4+d^cyS${$E!(PS?en`C{eHm>N*QYe7@7&wyi62eFFSu)@{Y918 zziwg>jk=y&k=+!S)B_ghUC_r$H*FxsI$13416ZxW}7*Dj}H z%ht1F?x4qDr$lsug|CH>JQ!j*Ul{aS!CknhNuewn08p zypQiWP~Up@sK{Yz>}cuq*u9$_Dr}u2frps5%CmK^Uu6IN={fAgMq_ry>rS`*Yx{S5 z@Oewp*H|Zj=qqyf1RHD_<(FAgWN+PNi6l_&DQ@H(+sG;1^zD9e^yIKkaP;PV9{JVJ zD}};REuiP@RC30|EM<0W9fj4JW6F~U%S98&xd&}H0zEY%_B+;rgrLu`a z9NkmtMPc(`Cy;7qZOWVkvJWrbYGJ;X=e?f&%Fx0hjk>#XLje@6Sp}{Y>byu% z?ft}Qx}^CyWjA}eWzlPT+7lF|r7eDjd-vJ|f3}>&<_!VTz|otBK>Ta`Xk3sRWstHx zwK{p&?_}|H8I$j?PkP>i7l!=dO0?@psrRJpT>JaDltny|f{YmL$mz=d!Iv&`tAkmY zoE*k2Ub=L@eD`gLfM>gqW&#h3O8dvjgPqJb=SviTLP4$geyZ~NTXpbr#tLBPr`KH} zcRV4jUR4!$0=Rldc{%tXlil%D zS68>|&C?z~gqcT7#2L9RJ2rmzm?TyBm(7mlp_%=BJ9(YhUKRsIoQ5Q{Fivp}sNQOE z(Cp;keGluAAU)BmfeYXr{i2+Tx9Hsrd?QL7{*Pw&EviX7kh(u(qr;K95lUL+gVvnD{mz#MdXW@D4K5lVJg86Em z&o~2_sv%wx+BUimz%cV7ID(#<$8tyLfu)Tj@&o~byCk>+m6waYkF+0+(=7amtUfnMh^OsR zM|6UH-zH^bT-tW~`t{l0msA(rFJs7_L)yx^o{rLV5@93Pc`#KqY^VllU|>bJXgaZN z`pgPRGHPyV0Ur|28Dwx@ejy{?sqj?&p3}&4CQ$8hY)X)Glh0v-Sf_=Bw&Zw^xwJ?x z-hyYednC&_m$Q^5q3d}LozO}&uzDWCVH7|;?zf#-<}lA2@7QmGU%2lW!Jzch~T+!yO$rU`Qbz)#Cftpo2hMmVT9q$hps^ShggEZs*A4_|tsiucE43>$Z%m zJUY*9%Npug+zJhyh>7Pkq~ROF@VR9HcxjVb{|Xi@tVA(C6F#W*^?Yoe8Q#e(IFozW zJ(>l!KSForqZBDFiiWHZ>*(z$2OxY|qY;zD`P2yn(Pj!|4M=APoBX}X^L;(TPl(B| zpMaJa9+02;4b)u-2tz}ghKBFM>oa47HH*fm3N#)JP2XrMAw7W?SpunIvtHmU{DOZ{ z>TXXNOr&w|IfMVZ;z}SJ&*lP3ExV!8_WRf^6Y09go0}eeCj35A#}Vm1G|H~ndqoNT z7^553+bENAxrO}!`!Ihr7g%yXQi8{sgC#nbng8`h(!pM@5sx_RiN)_-kig;SW zw~eB+JiqVu8R-rf=$4&rA{!&4_MiQoQdXFYyW%X*UDGz_TkN{iW_1MjvUt0z!kCD; zg}a^$z9U|YmKJYXmQ5TxcklG6HT~FSNB4~fsF6t2^!cLX2>9c?(K?wmY80D(6_}G= zZ9jqd_BCWK0%_mZY1+<7qr5%T+3gczT|4NEmQadevGVmED7mc4+ zW8`*V0BgYo{)D-3`3h|*=}NmU5HEqfeDmf^yx(V-dP#Y?CC##N=Q7F2O*Uxd#l zU_#}5#a)rF>$%|D@c2}HgKCQsCOqMHTFU%~hjrrzt~hGoBj)l~@%>~0#zgt(OUR)m z0@`CEOGy`y^Jw!mn*Ks*LyN+vnTVy=Z7h0x687t{RVSX`N*cmWhRN$lNDs_pvHWx& z$PD^XQoBHN$p6GGh?@;%ej6N8(+~M{bGo~wcDA-op_JMrFR|n?(*BSf2yjZNXAFMh?Bc?U@25RDo_+yWdMWn{%u!OMe!uz)kj$v)=+a;BIo$3SO!oI5*ee?mTqeKiDf^rP(s~tV zmMA|}P+>*&?Ielor_thO#xkuA3n`wFP^#8C#>Sf zETSG9OI02E8WgmnI^22R(%P(J(*v_J%*U*82%;<{nD%XvRPa<#S)Owsfz_ zX2D45_N=tR$h%#Iu5Wx?sH^JT@a*%q>0H9$??h&Y;gvR2$`_3hH>$#1S+@2mr5?sb zjh?%y+NTMwsRZiU>$Afvf$vMPY@eRhmtGh2pfOmO&dS3>BEGc`)kd1lgJQQ!SyJ2c zq4c?KyD$_&>QQdmq-^*4JIu)3iwAxZj%q7Db48ouE_`LE{%bl%IfgEN(x^}{cI+y!%)V04enB-x?I zxj^QT)fKZiQveMr3&%Zbn_>i0S>V!xvwXU*e0E;x-i=$gc%(d*79>4>O%gUAyoc2P zpspHtVz=P-CLXBnnk@b_|7Ha}|&k38DeoD(gF@Ie+ zbMWfrx&g3Awy3csJv3p%{q{aGgi!8NX{~*c-5~WU?G5f*EqZtjlbBuLcu0LtRyWvE zr#%2}%X&5Yab8e^@8t_=`8i4IBL%qaUhNVSKH=BTPVpfzU&FVuzdC!&Dxu@5<$bQd zDzP9ZPw>eJ96n?&FEIDVp2Nzl6U5kY{gACZ&gDaYnP1PYg-G;<(VXdR5A9tAdT&PM zeOanz_|Zl2Yk|0R9M$YPDm8%8G@kR~J#|}@_r}`N-k`>F$Ib=Dctv4%F=i9T`rm3* zlvKT?-Dl^i;DPZw^d?W0n5caw^}~2N#Pk=D$U46itjJ<(X1e(7Z6<RWhWkKy8GuDnCkoA3U?JJ8n44782v-XSgEq%~c;ju$ z+vSFz$5O`vL?@p&S%1^{9Aj94NW479qZ>SpV>A}y;bS=ptqwT%IiU8tF_LP2L?+w{u``7XA$XS91loFQPqeF-k9@rT_{qW&J*(|XV%4W8jG2uQ z=b^S@i+e=v#3!|qBCFN*;uu2H+fMN4>5LjK@#x#(D%u|J!u}5Hz3%M3&O)H+ zzda(suDtVbs&u0^Xb8wdh1DrL4qdOBjx})rW@)8vL~TU9af=;#s1ewQ4EsHujF73c zSXl%atk%{>H*^7Luf1JF95an6pl%fp12%;ot{ZV2NUd#PuHDKoL|)6Vd8&4(BiVNT zh}`>gf*3)kjGeJi%;W_&K~fE_OW%wc4!s>MGKE>(W8?Lp^zb{>4X;{ses=O0%gSXv zzG$a?qN`7tndKegCu>=!mvk`nk&wODrJcFZ$}pG_@qJsw!KRM`nS_@tA{Pgfhf=B8 zlE1yakR;(kW)h7-Y{e%N53j;WLm_!-n3vlbKGE>$iUv#eP`mB-&)Cy zrIOC?#tXq!Q3HekYB|ddub7*y%y-EioQ39<`J*+p{@sGyh5RQMZq;EY)&sDi!}e>@ z^O05kNCGeRdaj3ad_Wf_YQqb!(81+vAT=ApeMSnziJy7vHk_+*a+}NO59Zt>N8&>3 zq5qVOKCeu z+&s@d)M@8DX3#qe+yu(iC*-#YXmnNogWO4i2A9azve0QMyZ3%(nv}A%Ap5?} z*oUzb3MKn83`4dsW9(xa#_yc#zQ6bP>bgIF{C>yrJ$}dc{7V|lobPjaEzj5Eh2V%I zd0PkEdjg03f-xw<&Tn{rzzqmIxRv_xM-(MPl)?jk-=L0I!W!dUeupok_yk=E$O~kunB^3->N2N?-hdTDlz!dz{4J={ z1@W!A&kT_@s(~{@Qai11oyzTcm@)zw!1i;gdYjaA5%UpRC|Bs0=B`ytEaOP};|Atu zKtLg4-5{)Bw|6Kltjjhf!cuQ;3j%qSo>06?(bi?S3qOX3b6xlVO`UQF&rnfnOCj8C zhyBLd#~l+!R=Kr$c5uzw3kkh7&B?V^v%(bbJiR-?qZRrh*;7gqX91xq!4=4dU+pq46d*_RTI7vxKa?=mnit$){(f1zPNN8co{_gBz zj<9lQ^U&I_jyItxlT>w`$V8Z6(eY5&GKGzkghw-6UDIs^?P5xcvte$*L4W+Eomj zVC0!Wd(!yj;wu8vOw=|gVf4$m+chqCb?2) z`M5K_%Z*zkRC>Ns@n2;;SI<1tQ#2@gqtrU#9wTZviBhvm(Km6v+j6kmHUo7%^w76R zD6P^iVXV)2dl}>S#t*jwii8RXSU1u423ba=QO|MY#`FhGEG#$~=a7KppV)}dOpqSv)-d}rH?&RV>A-4ZEQ?B zhy&*rvA8o?-e(fA-ZVkZb;4k)90)S{k~@=cT8#^Tq7Fj>L~X2DqH-tAJP&4klTq(I zvM5}e9=btoOlGD6mw;T`#ahw(E@O0Qe);x%XLhf!*qK3x?Xhf+ozzIB+mr_=UN)Z5 zI@J#Cls8ZhnC1^R%jsMV(FV5iizCsWOs^7quUCkNpjq9|IT1#Dw{Be%-SfopYoe=N zzNHSb7Ujj41$8PcAZg zApsUGW?t{8d89gB+#w(6D^nKY%j|heco`rxEEL9qtH$ADU&BhZ+3FKp`k_PYJf=UI z37UiLpbB-Lutth+$B8w;l4{axNCzO@BRj_y9fwm>F>JDekBarH_!-iX0fy~6SBrzX znVD1&61DkCZQepr`NCav(95!K&K_-(G&PWXD){@bu8z*s!R`qyEEgr7%>^CKd`o3z z)&j)(VnpcKx2TKE8HxThZaxk~fUaxJ>1GUPf}y2+OD-E7tdV z&JWXiC(uee%dYOyD3#4i3~$D1LJg){h6jE<{{TFOMQh{xhD#yps)x^sEbOB9A6Y?t zo)_<}Jq#w;;XNpVdx*v9do^-i@CH)J&r(L}vt@&N8!cZX{GB&pF>sMW5co=~G!Tml$FeVO^=uzf^_Sb)%*0v!?(IiCxEv3t-kyQ z`>z4>iKYGF1_i zQ0=>#^^PO3JPCv)UHH$mM(*dC{)F)hbD11xsZP9q(spHF|D>!MyLXb&qi0h=8-k2B z<+b?p0!ZB?is~2BiRRf^rq%*;*3gP(azwgo;o7xJTfXd>tey%NjWuTrF5P4@Qt4}N z0AA+1S=E#eP3;p)L2P!KM-`yz50`K{26+GM*RUK)JXRvz?IXG~u@YKNS!<-NyDlX& zZ*IFqvq4E#VlPwfMt|ZkGP^;|tgH77V7a+Zz(pF{8EgOArvkekxMB;a8`SPq5CS2T zx(cS0-qlaEu8_ZyvFXp9pA%JzQe-=ZeQsQgtZ2}D8u+ZP5a;JYaIP5L@npnLuO$#@ zWkr-F_AeE)nx`aW`bK$_6`>oW3NJJrq!lhZiLPaPK#5h2e)_p8wO{Nqkf-7P|LGK4RIRLBHb z%&_zepc_wM=SA+5s?K=BZMX7LE52=*nTl4*WJU=eCX1{eJ7dCL zgwL`0*35oK+X)w9AZW@fFun4`24Q~3I9frIUzB+p*#Ot%$RVt+Hd>`hT}T!wDDo&bUQ#S>IFYibNyf=@#@oZV1nRBabIk}`qZPKfd(+~PHD^n6 zqEU# zPq-KyPoa0Vk(U=xT3E(d_Vi<(^+Kch6t|;9-=5|oG-B|Nq+7@pjWws50q5yprA3M7 zcE-JHpS(1hX7}l5yvT)n^yqzxy;?C8A-iHSFQH|9YsF-HL(LXFv@nu)wmRb60L|kM zY9CdhIO$v0B@-LDq&%GWca$^>;XFT6#PqPg>9zhamN)jR+vDkZj^v7^x1W|z$Z;Zpxy^6?6FSX?11LwrqjBL0+_*yz1FMu_6a_gGR$C+Zw= z-08{)l>5a)DadS*B)1&cjqg{ypu8ouXjS3`s`6b5LgbzJ z#|y79&+|UN8|&jvPgd#Z&Qdr7-Ox84scQntob}8>)Q7?K9}0HQH*+Zr;1-W^sk~k$ zEVcg?{v-V8A0lnOpg7=F*rM*XLcsp)J!1XZ1BSZ zjoLT=;4gx**~0s;jk~m8DNLvQ>PSEUb3O)0p}=)&$(yU~WH_NOkUe0Rm(9J|XhXPG z_*vDp+i((`y77gokDLty4ds0W!v=2+eA6|+<2I!C`yJq~`=4Zl;+JxkRm)8qe6KU_ zUee(@YKy>EHeNr6BFw8L@Vaa4mJl)h=i?TA8EyLpp@_v*TG?Cw1`+DRP<&sXk&%(8 zxVZTIW^oO>(Aa8YKv@i>aaIi>(bs~VKK*OXZDT;Vm{ zC)a!>V$5{53=dh;et|$%KyoT6BJZN*;yuuUJlcyt9p@HYwO(%63bw|{5G&4sAB7wo zOJ#hA`a9-njBXqa4^9j8-;4>{wnlbr3SD+}9fU zKt24KuDCnf(mas!-lo^n;+X^%Y`C@tNX#-KWvBg22a@+l%q&F)CFuk|_A{IUr5jzy zWdo8Z05a0XK<3$76jC5HXVA|GPm>wds4EPe8=YpR-;3+Vq2Vxn+`L9qxonca`~vAt zgvOgOf`{kk?nA6Xu-al#(JgmW4>Jo>m7?CIL)})GKn8|ID|mhrE2J$ssA$iHHsv%c0k<>mji1`=*smyu zC_^CKxOv`RNd(8L<W4&BT%9|_ z%%wlc=m83}26q;@c~xLbQ0{vm&4KyNi@MP#d*YU1zx7>s>X} zmArM@Meo&6QAX=%mFv_2tB`CCmlz5OS25Bs*rT9(+i1dutn$RBAj7geDLEBVb`S^Z zWGV!cy_%8^fhC1DtuN3XNTUk>rSzIRrI8{tbFljmBdc2%>;&((Jfn?(10|>)LCwUg zpdM8)iSMxjZS9G4d2P|gf>ro&sLqk-U@!xkI+j+1fEhIZ%OY*Ja2?Zf983pDY1$Z= zrI&^_1QWKZ9_WFTadUaNFcgbfJOoL^yo~u+nZkT;{UxbouY2eH_#!oKVl-7F^P0K1 z%OP8sS`OaoD67~GNUv^fTQ~)(AC`%4>XOml3rgX=s*s#9Fnj=n5lr0g?|V!W7r;UD zbKzwDJ?*W~1Pu82Vtwu%QR#pFJ3~D=kmzFzbb=?7fO>Ip{C&)?XYC(%N#ez|?F!~s z9Q~elkn9R5cNX`Jvrt{kmqiYn=;-vF+e){hvx+ZQW^wC(F)s z@a&q4#?G_7pfw6GA7WxBoAV@3jClXtH- zg+B96yjy(=vPhI+=3N>pY2FE{?~jq7>-oCTsK^FH?8hIa^{G2U4$W1{eYw}8ZO8lY zcw3M?m9k!btbfECB)As?BXohFaduD-WdY9W|1|2)jSP#P*K?a!pp};V0V0FV`EeD@+G6J5!?#IP|ojyx|sir&Ij~n5bG$Q z5WsH#RISOa{LykW?rHF9sF9}3uvq>i=g+^~>A|cg**Q31 z?WYp{>ovEKJ7tvi_Va&)pXn=tLpPJ`Za(iV4R-`5(ci~s!+YoYV`4Z-AR!%;rzkZq z8v}hDK<$*2k}|-~09XFE(JA~tNmCD482v|@I{V+!)Budu!-p9D?P+tmM6T&}NKA^R zJ$oFk-wm%JEoIRBjnNK4j*F5XxA^UEfyfk)#svF7tlrQs^4~_l>Hmv#qyK-%TDO6f z!^y#M?DwblxoT>tBqK)s-yKi>Hbqeq0LBezL)nji`+e?iR{UkZ{_Uk;gLnQPUAh0) z3xePJ{yrxFwtQAxyo;Hc8Sy#bK#YK#)1ocdRWCmX76ADl=3-gSj0c~)dzXH`fzxRAV9)S9dJf>XVdFY6>$kT6BvWFLa90yo427kh z4aHcvbPq{5VF!*f`|tl$t)D#xbVG%!Yx8|MN1*35#u<=;>x6F43qTb5kI!-TN=Pts zL3vilvV=^xrxF`1s1xhkUi?ibXJoVp*$fU$`{CY;mt)_-x{8W{&{9S;V||m~^NL25 zTD1cZbDO@=xgw=2MSi6<5^i%!>g{>AayyD@|dEp2Y;Nu_qI$ql-0xF87Qw@nKf@Q;pS02 zBQJ%08`M7xip5?PV1=AUV}pALS*|yqe|MAh&26DWa1JQKB_>fRLsrj5pUKZ);5%vj z!?YIz^*fP&S4KfneRB8ST6hy{-mL(`rJb&r{W{ZQF8=nYqR9Hf*QV~G3#Px8(~M+w zNO;s`0S)Un8`o^U-;mX+>tZ!n@^K{zkjbX9!E2shuBYAHI(n&QO5_~mSZdeykGCT} zH+C0}v26HM(*lsCQxATh@9AW@_8|G_rK*$()h;vVm8)D48!sb;+y~N6g*&h)F6H^+ zIzO`9WMT`!P_HwT4Rr48$Q%m5m^!{lse58s@%!O=R@$=mV3{tUxbVaxFQ0kJssppu zZmUR5{Tz}K8XHjt9@k`hCGOr^w=OnaGQir6i6m@aOpvqmmb+i@vsej-ayiZ60?Hnt zHMVU<_6D=Uk2I5I2n9vUU(_En`j3h)R?D)=njo zC^HQ<6$K|6eL(4rxSyX}BK~DX{qFeGIg-V!E5I7r;}{`rWxHt^1r0@|_BTS|`F<>V?aM`O&;(O1y=S-chSN2WoqI4Bh#1r>~~*0v>$L4F|od>hS+N- zIoei`zhG%H2I8kpHmH47NBZn!E|pKL4(r#Hoq-%y6@Pg3Nboh3cI8?=dt^xtLpoYu}|2ug~GxDV!7_^UzB=^zQp#( zIK&Y*22wa^{CLh>y#aFp(9Lh{mwz+ED_DnDd$#@~tR`8T;TO6nj)anESPgZ@=1lHo zwo@Uc!dKDi-iw=g8$x$#cVi!pFcb~wF}&6(haH&T-$TQVWZ0h$N)0f=qBs%C(aG*d z_b+c%5v$-;ICL;FPoAp=H`{`~>2>Q+hb>Yexe6372E|T5`eMi;$qOKGJKKz{DW~2~UkxN0X z3e)*WVW<4@0S|^EC}Ka?%h;b^^uv%|Lvn=c`jB%>;gtmV>(CTXswSYJcVE_*m9Uaz zYrW|+xe|W3vnGF^kaGZ0ZbwZh8X+M@)MtG!+1gt#xW!GTT?F@IVVpl99J|%9SEpWA zmf!3H1fdHx6=OD-qS}_0w{>mus$tuke`}ut9FMYm-GG?yVVfpidIPWPCbCK%W14!H zmxvRGzIbbL1X6t6jH9gu(m5GCW3QM_9_*jM$qW1(*ztaWzLs*C?6^51D^pvz(Xh6m z;h8ugJgAf1(ge~fRNMA%gF0F=1l2Zy7Y8RMu-mQ%T3X#%QyYOcuo(^eW~}r+_vtka z5-XCk{-D=7m3qEQ5OTrRM$Yz14zapSW9;+Eb`fq{Ma=}{Ol(1=#V3ALL5XAo5VJC~ zd>AtTk^cWZQmLh!0jx}}Z{9pKnZt#qaRg9Ub@dsF;FG8mA}?gq3wAIu(2vL61~+)w zz8+*IsrnSr69 z!X4M@vC$nBswVD7d3#YM{v{_HhD~_9oQT>hSsUG$6N6=&fgSp8sX%<+^H6}>Y3k#o zfnC~|Sb+pl{0w&TfizEdU)V#1DL)6VJULF^LJ(95$QR{0WDyw z>O_dX-%j+tGT{EHvw7Ff@l|Mw{EQbEN7KWbCLtN0F8n6muwHh^<+)b!hXFO^am?)7?X5t2Bb2z0GvHzyU6dYsX(gV*cT zjfzSqlRh50PmKC>b7gJiad<>V`Xu3_r>kd9I6e{|+qtb8ij!nl1~LSXzU4~qOutMi=}d*z*2WwMeN5V3E&q%w)C9>~S12q`<&bmr zub%Sb0|>e|jvIClSCV2bklS2XWgpnTglbtYn2qXmqk3Y#gVFc3eIxt^m&>5>5qxuBFSIozjd8klaKRlmZ|y0 z#;$Vl!q@f*Et|UEh9mp7Nq6fM(%mviOl!C|ucw?6q#T?`!nNwavUTZ`E^N3(+pY8T ztTL=@7EAJrQ%_KkpixWrtG;;mMx9f(-&h!4EP48sc~f!en=L3=vCz(a?ln(ctO{zM zC@xki*)io3L$MbuBkZd-I8s~x7}44?Y*$!A!##5U%%@;rC-6)aI(42m`jaydun+DN zF={K_2KT>*X88m|t*TRD$4%MxcR-c11?CwX0UCsqlgG*>EIAe|$~rd+{L9*d$!*wz zBAq=R#n_`f&@TBVl>YcrK~{U`E)KsluI5Xo_va#a@?tv=)8@pTJ!RnjQxzk1MbO#- z=hQbPUm^~L=#)7i34(=(&aNz4X+AM?&%Hq7%ULc9!hT~6+WiQ7JpxO3=JzB9;Z-HK z8tF%PP=LHtm=W;@L4D7qDI_cRlrrgVxi(dG!B^`uFv(UT5Dfz5S5TM>{2?~Jmxx}f z9=qQ<6C$!wukOew_xXkFcL6CTH3m4_D6O-Rv=l zYYBG=Y~lGD`k}eMA(M2?@&SO&Wh>9vF?^Er%R}TYYi%WdEDOoFYyPIFZX?tK=i_Ih zZJt{WEO+ix{X4wleN}I(%@Voa4tF#xGD;7k_5?vsPa_8JM3ThA99wC~*@{eI)r1F3 zY-h><-m9*ebn22?F`q+a?mg# zD>ZdhKH-L?ca7!A;8F!^-z_bK#VfjfWr!8}SnyZ}iEIrp9R6XD!1e-Vq_0;yd+^JL zY&Fc41hdyi*{BqcC1R3zgTjZq<257la9Vp7IYONbgUwwAzEAh#stEubQ5;YpMOALA39c_6h&he>fkY|@h;u)3p+*Z)(GTi<^gUG0omzMlzQEl8=#%hG*P zb%DJW7H#bgd6gabDC+Kh!BhE7>*N4zu*5Tc(g7L9$Lc*4S`j;&NV761O*!G}uviN{L#3ZZ45~LilQzZAC<9 zw9M-=|Bz;6Yw$+F)BrVlsloMLbZXz8S`&3{q1MJn8+UMjqrn`;>A3Z5!3%D%mp^dk zZBk9{47hQsRR<8e$Bg*Q%tjo#Ron_Nqyn7H~ZRq;)sv#%3yQBx~~$`VkoB3m{}VU^;2zFNlSKCk<^vPwz|5a#x;bs^TjxU8zo z2i%P+=c3s3>`sfJir?=PJjE<@Mjq87Nea~aJ)ubO4;JNmwetECexl^;7F_!5{O69V z>ie(6~%^Jx1 z){&CqYiX~d+@|t|7}}RlWbrq(F{^X|iY`eQjx^*z$+G;pKSPlo_OC6zUJo~o%2QhH z#?CLo2tzu{#vEysF}T_O6=OIpp^eht7;7*u^MOcudfPck0xEfbSH5CtZ_)LEoK(-( zR47)*%AjiX8(iTT#7U~J;UP2r<^Exu0A)tJW%0fqQvmH%WMV{ZN)?VY5>z>{&n4m{ z!h8fB+^C}xd@3DpdMUr0wld(q&Bp zwmPJ-Ut~|-IL=}GtcJ9(Tvm^j*e6N1?8~pRpNuKD!wsbcr@U<1yGGm#v>LjV*>wle zYjqq-d(n00kj+Gwh3W`lf!MuWVU@h=R`+5prp^Buty*bBa0>)HOS2TdsXwJT(%!#6 z2@Hoh^i$+L&1{qGYfsgbkUBGImwS=>T;T4|T*ky}CT?wiJYcQA)%Fx8msbd*YfWmB zdp>>YaesO-Jec#9&Gp|c#grwHcOE?a8qYfuo(}8Vt zNVpVo$8EC)^#H_;`;z^;bI%XUf%vm^k>Adwqg%5oRY?IQ-Xs z8Jk2~%d?y5z0waf6Of?+fQ(hcKEcuC;xAp_tY5&~tCC8#wJ#$Qkz&QCvk@W|b#p$R zRo%s6!sVL&v&pE_f_hoB4wkNwpwuLzMcMH^@j@5(NpIlHa2a;U=(zdq-qIe?EN8&@ zp55O6gR0Nh+?u`dmRp;*sIo3+wXRK329jC_G+=icXn4j18N|I_Xt890uI{V5`~tMa zUrt+91gx}!lc%)E$J4atG0&s9VH%}U{4%rfPdW<6>{2@r#}wx;CA-U{G_O7~#I+nI-6VNk(eZ&&ljVK~C3o{l!rx z;O4P)7)>zY->Vbmi!531{*VDiXw<_Sq&VG>mWY1oa`a*!O$d4gIE0FtT0Q4FuA)k^ zERYh+=R~4JYCI5;`brwpR(FoAMSpapb>3~yIyb0}PTY$ywPS*Sp4ofDT10|M%ELTsyE?=p2`L+JrK@VK)e$6p9v9~mxVsV6E()V|$`uy*$z@nHr z*ifz5$Ds9RIXU_miGW33v>VR`H%|ymABQh`GF8dh^KI`qvM` z!lk=7$%)|>@J;UzEv3GnpZ^G%`m^=@?oe`}If_8#Kh%wX$p&rMZh7W&gO8jN7uWml{8Z6)Z3K4MaNt=39J*#7N@kjRfgc3UkB}R zlSX3Aa0=`edfnR_BaV?p892Yy4=I(OG58I-jvnSWxP*_v7qtQ@6@glHmIQ2;g#GQF z{hYoTU)W?PHjDU!`8<{REAz?@bBs4UkW^@I+^$ma!TuI3k(0dUBpCf};0!|)$KV;f zM_Tq(_tN47r5^7K#D(1~8HMr*C;*$v*kuI4X^Gjc_K*NgNsm>g%0=HZ=2O+Ww}#ku z``xeW1kfsR0vi{&<|T%WWUDp+ET3of!snpdE3-rsZEh9*>+$K|HSMw@ z_z6|~91eO`uVnc89iaTN_(DsVrA)~wrB-(L;P+gx2i2H}X#CIdThM|}{jF1@i7gypz#W;!JN(f^5o{#oxsCJzT5dsXXcjss3XtM0T?+QfM?q&X`<72-v8 zPU($sj%i5Ih^+?AQ}MtlR=w$4>$kVFMXFz2n~H=wfNsEH4TVuFfGlPOo^_(Je&Cb^ zM`qv5p&{^J*a5VDD_q~n@BoP}h!$JU-`l>5Y3lG+Pa``WHb!U5{!`R`Z^z#gL z%<}r~czTi2B4W7opmybHilwxUZQM*`&E>rQz>Jz7<((hj8w416mfLxYkF&zi8uZUSGRC{68ge7do(~m^07#5z8MQ;GmPXuVLEV=^L+#d&m-u&o#0UtpY(;Ki5sf= zVN!Q{Xzm@^sVv?UOmi$Rj|qC7O)zR|@=$Ol&bZ)0C%+o{IFv1M+VWYvaVt#1UY#E6G14^Uh@dqyw`FZYGg?3lprQ-19A-Epw-Z6$>KH zhor(JscMW>GP1OwrrRqX4|m1ZZa@KH4R|}ZB}{c}oKPUN!PoyNnh$yx#YlLnlWAri z(s+G>S&6=+<943K>;f}DesfQmR(5EM!{_0-YmY=|1h{ARTo3B%U&HX^k92UQ8u0G48)^V z`|EST?Yw+AGR4DkuSx4w0#6U9v*=?v=BP{ zutbfNDL1;)Bx~>c!4UZh{O2zwl&1=?xZy%y>oLX zvSvr(?r%!m>X(v_>5S{lAU#$!2Q-s_~-CC&QW zcW@?PJLp6`2N+3yGyP(N#-A1g?>O#*ot=hW%|Gsr-TF)39K|9bZdQA9%mEgwr|1n# z$d#Gqz*&pN#bSG`{D;GeIZgnj?*lw*9$0oB9Wsv-e^@ysYf6;UI;+orNut5)uDwkD z7o^#?JBi3qMtQixdT7g)weCgn(b@%p>Hvq^D(+*?3M>)SD zI8sDJ#vWAFg9F;IzKlOyeIBne^^Dhtk6zgf9FNS=mk0Y5c149n>9p^W@aOI=TnsMc z!cvEx5`euW_Ls6Am~%V$BMX5U9UMR!MRmhB^Gs0@NyJaS0XtdAg1fjNq1Ef&%vDy8_m?OWue-?B0L?t>=XQK$!|;>sNT&>?c|-u z3C!2I*xA1Xe*;TeOMk-JU8{DbCB}nvSyZ0V%d@zbD|J~hL(0(`q3p}=E@Z#g$C!Dc`*vMu|iP99S!FP}zr z3PajuMR2a~ynhWe)JPE`HLpNj(wO_NweR@I+HmR~ua3)sxyOk=8paF{Z^svzkqRAe z_~2O3Df7#IR_0o~nteBuYk?)j^Z#<`* zkQfIoH@-bHq4*9gEPqOEIkC6H+x4(~|25*=GAQ9WKQ4}tjrEf<0REvmN6g=m+F7}| zxqXjaCO6pa^$ZLn2hir2SHgcF>84Wc^R4zB)CXK|e>O@|37tIZGnZ%ZB*3tA4_0U!hR>h(%Rx!d3o>t_#ir z-y#|?YuO$gY}BLl41D+0?&q#AuTvt&4Px#G>(FGfWO2#hgpt2}E(thCLqjD~?>kvJ z>kDt`K70(43F}w<^k58L?u7T9KTOS>AH1gKExWK&_A*`W8aMtK-f;Op>}i`=B@g$r>Suc zR-K76oi@6;zP3)CFr<<2dv+jJ)_Wpj2nTjm+^^;i=Bp|pDL|N%asyJJ=wf4&X9~K0 z@C%CzqPh3zUhXDc$9?7rvo!jc^q7}ZSV}z z?e)w-utfqAAIaGS9^;j7mlwF|Rz~3@Q~tdGpPEy}7P8Zj}r_k=Ix z>8{(n*`&L=*V@^<*X z-7w4IQ6F&3oV-Vmz-gehs1yr(<5xFVw%p`wb5QC~5FGb7XB|$u{E8erj`HQt<}q;h zeT`}KmSN;a8+%%{24GT3$}7p$S`3j*q#8r(kwN_^Iolta?OTSn^MZ#U>+J9h zP0@)IDmcaj_Dx}*n%NEX6pbdP>C#DKjp)RTh0&@$fJAT^!&qt7C}IZbQf(i<-7UH~ zgxyG^=oZMFP6T2>P;&R$js)7jT+yd*rsCP z`U>Z3S&t6LIFMKk^tokj7BxJ=Qgu5_bP`>F!b7A6v%JV|enJgfrR+=v?&y6o2s9<^O15y!$33}t@#0D< z#^Iz+o8b*}n@RhE`tkectUJEoc}zdpj2o%k4LsS(P=2iA6YBWt=Hk{nNg0yivr1Ilb8YC*5KHS<98L|T0Nh7*%?H7dh<4z`Pd18$KpwtYu@wJ^ z@?*?n$p%V%Qw?1Hmf*1ozU2WkLVtFg{RjS>e_@)NDP~rrdt|6bp@OMDDFs`}H_uW zd7C$IF-}_zsg-s9UKL3)?-(8*y+OSBFf6o+BI&2^lbihz=QFXF<63u<0$@wrPf|vZ{F#)-SaO#4cvoYt_G~0jKfU^ z=E@s1TY<3NzlYmXz-=;Ru!b3KaKyOAq15$ZWQ?+-{jH2{pzdhn-vhbc!U4dR%cnzP zt*|+#@2|)4RQ#L5o2E}|1ejWYhWp$2ZRP^*Jl~@l`}z3|u30|^+6(b53+k!5lS(v; z)>ALQrv2ME8%mdv*4>Hz6uD?-soc_4Zf^&a4&kBW*i%ec;CKFQob~({QQ&2>V-1K~ z{(a21{ZkhQ`5$Pm|8KR2nFT2adVpl?zh$I0{}$J3o=_hT{{=MJQuTp8&tpOMl)h9^3*`VYY_ zA_NCfq6 z=60{Hz0OE(kB`?FnQvjVyE**0pg{iA%UQmEOlE+XoxQyiTWZeCKjD{<>BE0%{Qv$j z0O<6;B#{5-X|AgUvFu!I7$Dp$1A$^d{!!Y*=e01(0+bkkF5ZzsQTe;F`>*%cY)32y%M?-6gcUaQP2;Pnz@sJ%ZTz5hbp|5JhjD ziU0EY6k_Ik^y0g@trGL!6q4YVrEUV~pZs&88&C`yh2ls+b^);Z_FJ#sZKM>&jV#vD z!`eWtF}H*ObfcqXs|F@1X>n$^rjJq zyxrXYfl`H>)d30=`V-4&H5rK_2mYC}^K0~M+%q%`-2|!h@~nrk z9=8q`AZU>n9|9s}{JzJ^q{LXxpyA4TpIEVPdsB$VCSaTfB{832N#(f5ECH&>Drno`q1cGhdK3#D)}1PZ{hKVXmaehx7O zP^z20trgV`?$xeE$o8_{wljV`F}qwn;Z$Ze4$zZSp0OO1gt&%*_>a((^1`Ne{A?n+ zFpLS!#Z&}iq_WmrHZYs8>hT7=Ob@vs8eoo&`q7_!HPRu67ev~A8{Uclj80eH+V@~R zjhD2K8%|7g27PDdpfgI79Evc@m~ktsi8%nwul~HcI@2z@pxo|@pzX3gnQ`*r0t8I9 zQ9u!)_?BBE*79DE$9P4H+}#pUV*fn2ER2&aAKbnjXxaW*)S*TKFdIFeJb`J^sG#l1 zaos^c?OT22Q%L-ddY>4^21R?jmM*(`f!to`X5QygERW@UZx+9>q#Xe!qF+6WBA(7h z63=PgeSrd1z)nB2bqYLX1>?(aMFHd0b7De>nTML{Y^aobQ#5HA^CM28bx&98 z77^K?S*|jo##s$`n?8Kf4sR<2MQe*JMoa)&)Z^;6=fBMq_{a4tN8rl`Dj-MeJ#xSGiXY@B5lsiqGgSi!guC zJqQ}Skjrsq7(}DpH#{hq`E6L}fX4v>cQIE&ZP|<*S77gD(cbx57-PS-sJ2b|Q^byB zpZTWr_0)(`zb64%;hq&9x7>C;ijQIj%ZXm$5bH>^cgDu09QFQ-2@TBA}td zzvWit_BX-i>tqByP0DV${;G6ZsoNa01E#V@)ozPETphjYa@R7;GOVeiLeyjqrHC%D zlz3K&8lHh0e@ z{FAyo(DiLk_u{nfv$DDd(CoQwbB}~iW|OBwGONPAf7`f4KdM>S-Sm!AJ7pK08mNl@ z+$~WE&;tk@Q&v90$QDRL}R_{S<} zx%xrxc|HAD3VaOnTKqxp`2t|2U=5@4iM`}8c=`7|6Q@4)2b+1gzs_fX>ohrg>(sA5 zTGNP~_-E`4X?Z*UxY7cWKMp|3^2PVV`3s6L5-QN!LRDc+{HEdllSN2jik;fVHK zqA}N%uzWR;C-H0@nMdpXeuC3!Q;vp%Lqm_z$<*j8mD@G%U`c&xzyS(I^jn?1pLE2( z#;b(oPxPZTdPkKB3x*^?#s(^}gM)(uyI#`TJC@zl!X~#I02&4x0wVI{W9ZoVNV$`2 zP%6@vpCPJTbFY61wB;yQTWPd|Ve~$G({S~iY;Gd<48>>YKmn=S|KF&9&- z8Vw}w(E*^1UC6=MZRQ{8pVvUwK{2M3n)9av03CR41t}bbj6!vNHhg0JAYW#m-f-c! zUmF$i8y);}75ZOv@P(eIt$?Mz5%%UGz3-G15ZN6&N;?##(-9&ZpB}i_BMro zSY*}dzm=Z=N5=c&+b}a6;D-C8E-3+v6RaNBz`6XT3J}%@(Uw4Z9_D|KfI{w#xHVB= zxyphfF&4!`dqGRVsKAOp#zO%=RqSYzIxy*fADL;XsogN@#of18kzDIZ-JZ(h@9h7p zGA*EtomIGOFov_KYff?eh`jC*I=TEG>+I~6J?gA69geI`Cqp(c9qgu8Hwj4JY#6GS z+wn+{v<*aJAFIU$i2qqAJTOOa|0CM{M{J;7(D5hjDud!L>7xw@X znP;N@&6YyGP2~gy-@-k6>J>WI@^9m8E8XZt4#ldB50aN%hdw>#gYzJ3etQkX#khgx zUS!6}Tad)&ziLA1UhQu$Q52pjzhXBrTYRliq5k@g$06^gj}O}yemRP}K&e4?^)3Z=T>yRu4!N`Z z)q%Fevg@0lyuMxV3O3x$ zArVDqhg8gqBk{qKhH%F_Hu=5FY54ej28$+g-*)~(*8ow1YXZ^rfrrAbOb7!*$#`%I zO@hr)aM;;xbM1l0?$;yHyc8d2v{7~a=f!irn#S~-`JKP>{GQ+C^Zk6n^Om+4 z+xGM5G%RMmf*QQL;|u3gWJ~HRj+^(PC;upv(f0as@JN#TOj59q}-SI|9z4p3i8!cfo znwd1~>G3)#R(*_RI^(CeGC-;fwofe{BcPeZq`)4w8cRFk?a@9k@yj~aCwx2q;IK-w zK(%;x^vzdP)+?wZ4lHfybjjjBb=hfeTdwCzJ0Fiae4$QCTAE@5$Pea1tFMB{HiYl4 z+cJ;hb_3TfXrcHwf@?OuqT+&$)ZiIy4+^8F*X=th(1#N1(^4~IBQ+=KRm8&O!pg^W zW^$Vb;QcRd*_G{kp2g~|TD)H)-6iPO(U{fQa3g!o2Pn{9@8`#1iH@2C$533pbM&lJ z*2m{sM9;h&j~z+J8T#-c(I-di<(2j-9jk8pV1vCGA@^lZ+Q*Vv&Q<^J)jqL$1of7@ zK@RH^=Y2}KtGCmr#nFMRWBU<%K8wnswkxl+ZY@Qk)E8K9gL`V;Kw_$^a@VqgUktb) z(9#?nR#p!;)7&-AlT4R*XP)Ml^!NHSOi!x`D?3zV6o%A&oDm!CjQ;%9>`1Z0SuE`^ zBIN;jyA-5mI5wo>+<__itkK*^SJCb4>~tV)atVd(cDNX*9Y}>E7S~javet&a+qDfFL>Ri?r?oM6$9z1U9Avx&h zUFMo`;+{)N3mFDCP!w|S(2xTc*H@U&@H)(Y>S&_;$Wdm`JE)}VOnDEX6e&4|-IvcP ziw=a;OpMcfc?XD?;68YHnrlI_7I|CwC?BjycB~CW&Gk=T5IiVY7E+`|)>kc)D$(*pk6o7J>prYaors@0gyA>cTEj$A zR=$KMh}Gv56f}qgLO;8>xIC-s>vG?VrhYnVkK;w784_#jf)Ox4!)|CT&dRG%8{KS} z*9`U8KFpXUd9q*^M|2x@bA(jW>USKm5m+uf`HIwk=OTXjgng9YrUJq*U0i(g>|j>i zu?wBVZrf$nr^r zr*2o>$3=-=_{v=?JyiFsf<0h(yXr9E=#FKF@LEQj%9NVfW?_3FaqacnbZCP2kQ2e# zCoV>nRrx(pv9x_5r=bwN^7fKT8Ag9*8ehWioAv1S~W*7 z$WQZQRIPtpk=;DHVzxjn3)6d?#KCdM{0PF~==ZvdGbvua$EJA0qqJ@Q`c7h2)x0{o z_bmV73~jFg4rIOE9B=lOvL}qPnXJK2*kVJ-!T?*T1&66%`e;j{Vq4V~BcELIuxp5l z5wCRWa|!(Mv}ew{0)*oxckiaFg2*&|UJeK_s76-KuYN>>2sg7Hb%UL&zHeb-GG!^M z8qm#Et_bj?$7*lH@8FPsl6PpkQj=GmCr*3AZG7#tLid>v@ueB|emLP<(U8VZ38J2DaP`k(2T`?tb7KSB%8#R) zvlsNmRayt_H{D^lx~ib9l&_M5L` zuVRkpFpda(xKofqO~DA%yUJB*xxE?cQs;$WwThgC_NOt7RE>hT@et`?=?i?KQ1s78mPMoj^fv(P(J?z(8Ew}Wt z{LXk^p7FB!&>L zw-hD(^hZW8>%9B%SbrCk?fy>}VrHdf`uaYiJ-$W81}YtFd9^ASvX}QlJ=|1JA~=aL zpx1UqY|%oXOzp9V{r0->y5n)4W99yi!;4ysuC=|Ym7kRRI4IC=piE|r{ifZko zwgqTn;8ls)AO=laC;}Ob#PVuM|AAUKT|6Xq9n~&!HoJQlIEzkNjDmu8$Zg3<3`g$s zS{<6cmwRa|ZBpgg@U|X9X3hoEA`r8K!|Kbfh?ii03RvX*ELH)cpD;5sVGDrH1KL5> z`l<}05>wCjkj_twJ7xz5bAkReange;L9hT8XU~;_Qx~;^m6|jQ4DO96=t%&`J>5s` zAZsDc_NX_qveJ-D36Z%gXO9XK(Ym_%gsht?P`J#bk4|}*yiCiT_=JSif*&~W|W`Rk0>UA=VP zS}1e|1r3Ldq&Xe5bX7OIQZjd*7yMj%0Eq*skD}5CHs*r%;ZJlle^{-pMXGP6I|=1L z=#sV-zVnuSmV)U_VfmFE)8}l|`sk{31nQNTZnSoS6ZL(8=bKzJ0+GXus5Q9rR|I$U zFY(*28|MQ^S*LK?F_TWQ}J1!9HN#pz7R1NMGGu%^b)GSOCMuQTxPbVI&xGg4#~_T24;^z6z)ty zG!v42tL~IJ5NDe6dvnf%&F9u6w$06K_h@nLTg6U$m<({K!h(CbOqKkz7)F%qHWw*h zuZ|v&=~_$kSP`5%DH)}!ph6f1HtgQ3R<;wPk@@18N>InKmeemVO_m9*ahu;KM0IF4 z_kLKN&1=o%SnmGR6)=0t-QC^G?CLKmz*E{jK$JR1;QLH(GBUY)z@}2Q6}7&JO1DX?U+KdW(P+ZPL7@oQGg5P#c>o++ zC;38!=9Ek@h3+dONpxMTi`~gwJ#DU=eu_m#tj>{FW{H>1%|AR^l;s^c6IsVi)NpJ| z_3?NOR^Rd&%D;BmjgJm5C$4*q_%%Mh0XzSS9lmGn=XwjzGao>f0l{5v2~2d3#v&Uh zA7>w)4HU@zE!hGp7vuoj)u8Jya2r5cbub=<(zL7R(r&M)CjTNBJ5d#oA10<$)^$Y4 z8H!7^70`gau?5ScS49={B8*dBWqSq!8WS+R=!*Pb@=y4x-OKL_xUr6ODEvaEAoFr=faefwbT=a z6DBV5q}5{Fn4>v1sgswPTYoUdm$L3_*@o$jFgAz;BFnBvT~#2nO%>$;?Epw4BbaxO zmg0)1@ARQdO_cL$uWre{eMlD(S=n$hJ79So9x%)`XR;;mjXSt5NDclVjUudF54umA#EPFG|=Y6T!KOyw7Fgbv4n&YG!Y$(C8_L?S9or zuDF}cvQJ8q53fh*6#)nF@KOo}%53{C9S+bSp&5kjLe^jpb=Bd@?R^AG5nAEa2a9s4S4J`g}uMVx)Xf11w@Uzr5k>o9^O2$utt}kwYX0f?dOyxh%olv_7%ElV_#uhH~=t z?f}On>?Q0_AX$vG6FS-JyZR_^G{f=M0WT(gQb93G<|yDF2*^BVs^1fb3Tc2|f3Zns z-O%;B-T)Wt^piJc%Qwkj8Bg9&s%KY0-hS{rc_hK>ph|$c*Rj)n zLi)ZmJ+QLxue%jSk@57@N_IbDkS212JP*yBg;8a`3v`_UXmL@93dCu>N0uO9RA+Ciigt-F8-}3YU2_0tU=-CaI4UeQi<~qYddeTA-ck+m7 z=L$|$b=j;#?b1UUNqPIq(8BKZT;QyHyD|P=QB)v+jziv%Gm<(pO-#J%ggs5A=@ITk zT~Ja|8shTSBpT}UY;tv+yIB+itZYcazm6;MheJQ+BQQU-s+B9B)wFHpy|?xq1&9@~ z=lMfGhwxbS&DIU(T={(TR<6`HO~_g4x|6XDa7-VExq@GYxy87ggzcz)OZWvW*vxPEWxX3*+pN2F-K z)mrctaX8}j_6qY}^IsJLuA!GeWad(%4Uc7FDj@Nb2Tkc1?xPQ>RJXauc{i&DQ^JZ= zJ9682d7lOL)ZOx>8ct=1r`@aUdBgd+>{TQ7_G;U$73Ik`_qiFGK2E)IKpaGNe$88} z+g+;L&g1&8xx+DAcIj~m@Y0ZCS^TXD5cF%y#ZONjNj!7e!q`~ALiVX_CVNFbH~QC# z%v^fdM==m#k*QCMm-0+fTi#|D@Qohi_w=$1d18mry`y%skwIQO|?L&9SBB@e-J$l%b*7UTIlbpLr*B z_017}o0@)z+RO>l;emQBAkp63eGSPJb6R$2Zf$M#I~K*(b>)lMdaR3jFzhu&hfYGc zRvAqUuT|AoRV#&xCq})l|7+Tu$hR2)N5O~u^x_UnMBA%xSFmF2>)ol9`Q^}Vmw|77 z|H=<1y}n3Ge_pg{ap4vb!soxGm%;|bG`!FUlywoYf%BDpE@k5f;SWp%-=GBTpnhlo z7y$Gug?aCbwVEN910f<*e_C<}qQG#t(>r2CGs z7&YlLm3|999B_`LN`QeY(+#d|+sW3cm9M$GLkT7>t&t^_!^uwl%fB&NxK0S_5jpAg z%E;4G%r_Fe35Hn2oFAs#&e27C?`&{t*`L6i$A5yf+o!5l*F`sb=5H^x@yz}l!M|Cp z*BNGFijf^zU+l*nkFQ0r20x%FEIy=In^14%=LP8}s^7yAts zteNeT&!wiPo-IU2?**&wZ{y2*a8p(Wm4l_ddGD6Rgdvv5fWZh(ir%I@|Mr)^eGt5) z+y!*K^4XS`*La;SZ)EPlM`XBaZ!Bj2mdodez;}!;wr-O6fI%G}n;_Bvkv~G=P;~NS z0bPdV-iElG@kK2m*?#9#Z8Ue_!uO1ov2u0%%a=G%N%+qQ`O>;JM4JUT44=iZ8#{kQ zH8mCGG>CQim$0|@#ca}Nkk6EnP^_F~#o*d%Zh$HVlv)c})4o$HodTS(=$)MV$LxT`LQl zkL;cOFPzj*Zh$>@Z1cqC;TKLHh|P(0^*78M41X0F)}H8ywm~8z|51hn35H}ej!*L_5KdWNrJd^~|(Sbq!@xeQ-%wkty6 z#xn&?a?oGN;2Q3DUmn zRb1Sqbj#?*0(z3`BKIVpJQem zkZl;F{8mKSr@xQ1a_RSl&N11Tm5O#8uuqx@%ahPclK(6Wlw6{oZORV5JX(0Wxrk3n z@~!RYEpbz#gW0IW?V`~3{(gr`!V3NBXU!%ou}7AXgki_4n(lD#>5&eJuDYOi5Pr_&!B%LSd{zZf=4m{5D~(Ac3}s- z+L!YjyFWg7(7{oJ|5&B6(r zeDvcAoE#m^skLlE<+);&<@T#{u5__}%aO%3^zV}ul1>Lno8I0*NB{M8pTVgta^nGs z*~@Whx+NVZCV7iM1!x1w_xJ^O+O1Aw)0~eg zx97IjP~d~+gF-8nUMtRl1=s)&;@OQ}!feAUu{=|cphLzd?u(-HuvpKT?2V#tRkP>e zZwGQcne+}w>tMVk&`RE)Gncb!nEoBdukQLZ2n`MFt$KcvX*li}o-w*AqR(1_mY_(2 zK`>G`3|i2IQu%!kXiRu}m6Y?yx1T>4axB3&lhIopftvfX#D*!Fv%w?~zZdtd2UzWF zubyt2%0v!(EVZP^#Rwi>VZw1^R(@r6wcLcDug?T!jdJLdC{?46raNrpjB!d(wN1cw zYs20(UhA)jNY6N9x;(r?&O-0Tz41vG)a;Ngd z^8d6u$&owVWIbZmb`#gx*HO2e}>Uc-!q!Pr6*Ba1~jw@@A=?&R>Kd(7qEw*iOM zaoe*TGV?to8=>28?WVj6tW)=JBOaEiY3hvLzWcL`NF2&(zF7%=o zq^5rxpFhcF?3h$snbiyA*l1DoA!`_Vj}9bQOjynjl@%-AVT#LD{jReC9j=CRj=n{|6)j z_zfH;@z#lsTP@&#$|aTTK{};Te8@;2B;fj@58^0NBFyjk{G9cM-V;?2m^WBY5Q{7f zrYWF2*T{LG==70*s;SIGt-jr`y1aGD{69~`h7xdwkOxiKXI~Ec^3s&L=)*SPalG{Z zma0y5sbs*+wr7hweiloeWG^Yg3NVRYZIGRCIJ~|h3uz&oeT-rTInLbY(W6+^K}SI| zt0W8B)?z#h9GBi(u3SZ;zE0^-H(-Z+c(pTunD{PdJJn57RD)~ZTn>ib&TLc?IkOLKC*-4x@;S#-bL zw`}uLb)u$!dURSM#Z52^hvJHP{tQqqFdaT12DftwlnE(4kV*Q6g9fvjjcnukhd{>rqOJKW(4)X{gTMX?;HEs5rw&mHyHX04WM{LWtp zyx)EQc4w@>Q8^GkZkT&&QAJixeL@u)G;Fq=S6P;IL0&cK%<)-ivA({ZaM+7Eey$`G z$CU%*`KL9MVXIkD4Z9c3^PAg0bw$xuzuz*S_oMwGdz%^?qaxDOrzQ*V_^aF;hc5rq z6{)>}-*X-sH0SV8k>m5HhqL|ezuT=tSE7$zaen*PgWsB zNxDm>m$l64q0&oNXTv9Zf1qBZ3CP;Er+PCe+QWoF; zcDnC;3U2a+fr*L9iRNo$E9t|^U#mQ~Sn)Ry4KnV`wd~Qf%0LHflrw&XQiSK{?(Cm`_@>+O=;gAkkV>kH++Pg zc(B>eOD{vI7lyiBTe&PewP+s0NNV_U3%g^XN$%YoU{$%@v*}YQa``8ES(W8eg}RDk zvK%T^g9oj_v9#pj;X%ifNMehw^Ln6MX+c=^PQTX!7M8FN%)>9*FiEFZ0u9MD{H$Ie9UPg zquWbXk3`gtCfXlXl%;Mt20aZ#eaLOu+NAA_*8{bV;_>DLiJ1K5h&z5N^^JKm=moP?+&d{N#d zVfO(A@y@jbasJ7$ACq$W}0{9?Q=03QLc>C01v?vASDvu#<8|xehJ2GvLjfk+$s7i-ogq%L&EWWRYo{Pb#*E$*USmjbj`leapKzI zCYk2T14@9E?^%+u>7*s;&ntxjqE8xITD-~KGUY+S$#3q7*0_+z3DkW9(RxOowA=># zX}ap=c^<3u&B6u3&aR%NDW$S;Ccub%nEVjCkxQ3QiiTg)t4dDsjHq0ziDJl;QrmO6w^QP{AFGG$(pLL~Hz-e{@4ktTzqXnL;sj8p`6ijFL{SwwC{T6|b~1Ui z;R1ysjKZ-6X0o+8_dNdowYT*oK*+7nNeI6Y5#<|Vma zs8Z?T`fy1?^E#+e^F_!-KN26NXx&ceq1-#_*r01B8M6NlNHxn+FPk7zI zR}VKfHWr4qcX!*e^N#q6l9KL&XN|3Z)Fw{YwW7mke)!JmNlk5b{r=S7tp|7Ke{AUa zj~**W71RH;2`g*dpZui*+3-lhL;SW%-%at`9-(~&93|q{jojhY@m6kdP2)QSU#ONZPI=E_@4 zOBsC`g~vA4vMM2JcS(W)t)3_5?xxq@Mcqq-?^rHiB>>PxQ?oQtMB;|m42*7=YM3gi zCO@3Yl02Fl8(KMla9tL7-+HCelcW>@X3`TgA$R>@Wm3`w^Bx*KA4yk;?xreLXP(Qh z)0I6#X(EjPJMsNtYmLPzs?V8=93|`Fdtk;!vlAB~kY{C{;9(pR_|#hehurmGAIdwI z`97UiE+3VyIVtU#pyX$NxQ7C4XDu9ss?0U+F!44$G5Phm(1DO++U#TZk&9i&Ni(B> zgydJr_K!v~R=5G=B)AZH$4Q0~v}OYJCv3YX^8V!$)aerbDprv=Xr|e71&$|2(0a{+ zTgr->!Xu#zCh4}p9u3rc7mY9J#>(&O`JF_De zR&aQZG)L~|{F)2a5*bDelT-_9d#cM8G#Oq7_f~aE%-4yITEQYQ>ZW;at-vJUsuo!* z^U%ca6SOO^Sai18rR)Jjw<3or!!He85~D70ug$ zRVSG<3Lb=Uu5Q6_m_cB=&S#0s!@Ae*jy{v0xvEzL&H}zmTR&JWH!z}GjxBP)W?RXB zR1zJnS5Sh-K50##dheZF-eI8{n+xCr5s^U9M<@iQGwBDAJ;nLKtQo+fcX5>fIP(T^ z+}lelY&WmGUdkNOVxt9-P?y%yH`IWRk-d$8;!kq7p=a-4v3hp zP^Qhy+kF8(g0=T6=5;|1V%7HcC3Pr=KzOJ=D55i`e!h%uMNNAf3nGGhh&651*j}?i zVG8YTV7QgwW3_?_mJ1!)qPxSKDvIl(ZB@&tdsJI-**}Uy3;cizBMlE+L+#N>@P#UP z5rTs~pvy3u1(seG278cbpIot2KSaM!q%KnKO8r?`qUXHcv=cNpyUu8gok~ZWDb+#B zc%;o&|W3vAtW&|k~J@hDyX zJUo5j#f05OL^^Ly7C2Wu4}qZGxuxaKoUC!~3n)Ox9KZEIt?ngKTj$B^j(%O*Q=^8= zUbbH?KO}kas2$*#0g~n)Viqd=b-+_GDqm_2LZ z+0V4{P@4R5*UV|Q$VvmR7?f>FIJPGP==G zlUy&QC{e%B6d5RRacM8I_l~LL?Y}0CrO)J$6F=JAztxj^DLr#E(JxqT z@DjCRm@BSpe@OIZ-VfK+%$t2gDCqw=^lo$aWl2(Y4JA&?rqH(MAXvWVZ!B58FFW5t zo#bS!tsjnpU!=CDDnDW`>_61;9sc<#txM}m50uowEJu*og>;(L=e0UvSFaKK@Cx=}wBufU5MHAq1dH|GCr@CyfL}9v6gZ9_ma(PTJDz)3@FuOa7nH zRjtjb53cGozTuTgZfa7>|HJhOFBgwUf9hK0u;W7)j}t>q2hHd8_A>~k(RrqQyU%dF zj)m^Z!3L6n#mq^|E;ZvI;&7Kr?&#`9GJ4|>iLM`SU_`cNM^d#BGncOyVd*GRS2cAw z=fon4`_VN`xs;aZYF2Fc)0Mu38?-H>+<`74wJKey!%j@%AmKEhk@WSs+SYgN ziSl=Qa_q~K&ocyO;>;4wp=Pe=NPZcX!+`i~iS0)n;1hKnERiG*!O`fo zk;q&;UV&(SVpMsqv^FJRTCxuoI;t)B!4Qz3;c#Y1KsePg4w03%Vs)i%b^jA-B?LvS zX#UGia|0joikNa+r=-Lx->QlML5TWuh}ar)%eF{IB4)En&BHyl9q)j zQ@azp#8|tz{q`@g+IN@O!pT`nfeQ!BF?f}a*bOgz)~)(20gc`aI5PI=)$Qa+_nrj2 zbF@jO)N9NK6hL*atl98q+0FFk0`{`J zm=z7S|NmK1>%eJDm^sWRG%m$A6#L!Wz}VJsQTb{a+ew9P3gR1@Kp~boe+w4*|C3aU84FKn$9y$a&?SMHC7DLG&^D*h?ekA3? z%iPmBb=ju}dnaYC{Vawr4H1E?;L9PMF)l93{AIRpY2>y1Lo0VkBlKuZ8~7Ls!qJwf zsJN`k^+?fWs9)xK&UkfuLH_5UxiVVaA6Z?Rf|<|`6LK&ROSV7^2hOX)k)}0T{c0Sl z-qR(=nD@-hk5YOK*x_sl-$SIb?P-Ni#isK+gyjJgvn=uq-)xO60_zbMW=1we0-!@? ziHQ2cF2kPj&5F%dhH}KUOULdU`6Q=wBVKrY^C{*J!Gj-Mt)^bak#d8S#v_jgtDp1v z{<+Ep`j*G^+dLq&<1;xTDy6banWy~VY#$f8L;G?W;>5&86!58zpI)}z&OJ(2s z?PYtr^0w=qHjO`56tJ}{^LPNtCkLeuC1ooWaW(=_AmINHeBUa7824Xa z9GCqDNF9qDNKgyY9aG?#)9K^qPC39;{^9?O4clc(P}RT00qV$;;?~_SYu?{?>ZR>YN8Y>@ zw#xHJ{YHF_RAHF9wfUPX`4{|-`ka)VT~qW*e$utoDH75;19?+F>$=UfOR%&)a5A+} zL9^U;WZo#d$dernoiotM)b`MCb8oxd4;I1l6m^E^OT~k*!poNrsLp^@Z>sCHg{_Qa zsDThZB)t`p{n7KGYH~tHvW&Lx0kG;p=arbQc^qxnK*vtic7C{0_Q?>acw0(eWOw$J zhjE>-+G0+7Oxd86bIHGA=KEg$W=>cvpWBVG%C(O6XXXl0GIH2P&CoD|w^f3yYdvPXM$L!@Hsp}X7WF~0_N|rqB9gFo-RyUUF1nj(I6DyYuqaeDSE zHNFs0#@AbUZ2p@di(OqGhiJ-K5W9h3r#%2WrI_V4wFhJu6%U?CLP<4%WJt`P?|%N& z>~pH}ujG9-J?*M$WU)$|!BPSPw@NN#cDkY9Ryx&P^DycUaqZOLrI-cMHzPeh5fs``3(l_7p%m5`X%CHQi$sq@0JZ0NcM_b<4zxb#_! z{7u_Ke=b$kHlwR?PR+G*$ia2^l0v z{az(76xSlxt(|YMN`@YS!}DhT1RSOAk@pz(yI0KWn<86OgO3XCeAlp-*?z2W%SrW zy+&$AnJD2N?-shYD){c&l`0Pa=sM)LH7B+~FdNm2I$*j}=*-j~LpitdpQJdmI}q3> z!NnH%nsW5RZ;Rcr*!v(^vPN`7(>4bT%17A{#G%9aCkVw87sXN+S+jX}=&!#Qy9p=Wyh|4{`!P-@FzMFkw%p! zGgzhQ2$_XA6!frZI<=^X_dTJ*(bd&g;yGU8twBdsH~!T59iuBf#{!-1;+O-q>hAC? ztW;6Wg~7Z*XKEFWyz#yrNtc$3fMMfREmA8leSj3NWZ_xK95=h?*_csIEzkXUy3cLS zdPF^(yqD<=Iju;zNSx2EkUl&6G7i1-sajV=gKdB4PNumrdA=>!J|Nj4*i@Ix zA*CvDc#(uaxj^Ij(A!G!tlh@IHY~df>8m0}!?ceuC({=`U<=;(W-9N_a*vq2FRzCO ziG%9*F$N|B>n_K}n4nLssxkiTu~o`4^YfeW)GmxJmU`P0h*j<<6V%ag$)_Nj5JryT>5cysQ&wuA(bJ z$BbMi{mnU>3_5^6ijgH>3{U}sn0bQ<*|3yYRi#F>bayZ0)ns?YAjyW?0s(*~hCe)A znQ1)9*}mnOW4>OnM<}E9nEDi;^0}7ed5a`K+)O5{_G@hX2U}L#0Q6oldnBFdQd+a{z*q8+7?H=nURQa9g{cNEr~`H^DhCoeop zt-#&$397c#Kao{S1x;|16awIdns`a>gn@>jkn|saL$eUPi0dTT=T%!>bA9x<1jG}6 zgfDEZbA7yn1mBv0+>!{7NsvR#g?;`PCM-PK6~Lv0&hEYUa}D5e3kq%OiO*UgGT5fY zo%}()t5#eSYizQPR9;D}dUaFhU~1Smh!zid9nU77;JsFF*dMU)6)l$FV98$Wk8p8}znyw;x~dv!Si#(t?F>OD}Jf*Bh)Unxt5rt;4QQ=#`jlJwBc zi?EJ2IyORFVS6uD=*zRyO-|KU4%aDigczD+a{k{!rW%wyPAv|nip}s{f`|ja6wlRW z)w>+!>U8aNnvvGG>5O~G`0vF)SbWm?U$xxNk~)G1KD`595}_mn>7+ zm$F3We#`BD{>i3KlFuGiEonIxO!HwDWK?rEZ!7iMVX0M=BheMVOQ(gT%aFlL+LDj- z5sxqX+qag;O}rq4E?d`Yk{Q+4*u99r?)_~lf zmj7=;r_Tld{hp-o1C7$q4dgG0mA_v?<7Tu7v%9G1N$SZYv@pAFbbC|?JcqxJkiUWe zmOw#D`uV<##KQoP!$g(@Zx%i{LQ?zt$H40k+9SQ7eY<~h)>+7pOPdrv+ok78iUMV* z=8}Iu>i_(Ydu{+U<^+IY6`MiI^yM!47{HOKEPigdq$J(^ivfxh75d){P?DhVQ!292 ztP6_4PG$CEralVp-LC+wN}t{p?c=mvO3_O>`bK^;g!N-tOzy3Ghu%w-W{Fq)gPlXp z{{#6|drMchJdk6j%;=8EV#u@EeA>5S02;eW4PJ_(tT@tdrE5+)YvRMCbf_+C*Oj`R+8#1L44L zqrrh=qeil8cJYd*1;c(l?PH^<={vDtnz5y26|XMh@&n8rS=F|1*VW!in?u6_KJd7C zxKpsc{6C6791jx;IlL@~-zpHMfdW)Djp{mV%OkwB$qu;SWY@+qNpV*Vo$U zZeu1Or^UVVR<%7!T||T~N$A+Hrb_;HYrrYIarhGl3Z_{I3gMHM>Py|DHy+^vTy0uh zX9s6oypTqMN;w{Gpvz;W$0R`)Z*eFWRzcVjAs4dv)5(2GUG_m=Ws}agKu;aS4J>Sk z&bFwQ?_ZHXs}HUWZ}`%C1`~+TtX^htU<=_S8CinF$(6H`p$22|>LTky)l&W2TeJBG zjzyAEF0|pu>l;`YVpY#;J$$^&d>e)EGT*B(%TK?hpDJcT)@JHoi!0YjHOy>Vb1c~? zCQk@@ilq{2D`5r!X0{}>I%aS3r1Bxrsnu`SNj=m%m$v_5V>mPnu5q@1d_XI+=Zdba z`L-Ys0~K@#x0gsjHHN=?B*3{6I$di)HjIJ2%U1mH*%lrrC|1owYmOG8>_BI zf%PN56w|#BYRlzHGWGZWc0N2(F6n%actQ$oaPk6b#Zs>X0mR-pj($ihou zK60OdJJ~6^n;VT&1%1WdOEPkuLiDA#*AavJAE)*3Q-y*nUgLV)J3P2z4ae09a07!w z#ka}Jn^j7Kj>m1kx?L%%=BLrv#lJ4}t-FGC@k zi0>E?A@9I8uKQwS2<$HG@)~>T7OXB_S-M|S3pw|426L+N(}bc68FF4)AV*;^5_1VF zRhZ=n`Xq9sIxv6bpw{WwT#RacWUjKhYdY3vbG|JvYWMZ$VO4`JU%w*u?9q6cDt|5@ zC&<`b(;1~krvcE3`A4Tp7>Ne8pd22HR$)#zFLc8hSL1M~U@$+f!4z+Bn{+HycjJ>( zznb3`xxu%mvvYQMss2_yv|fvvm7gcly)NP%L5qb>MQ62dQnx6C*Lh3J`EYXePA1=H z`$5y~`}XLq=jWPD&G>(_^C0FliGL6xC3fpQj?=z~z{VWXw_Ih@o* zX_c8zM+#?ySWqGKLEIkv0yIH2Jo*8-H(~Z;i*N>W@q{Db!17zGS^U(?TACn!ULM{c zQ0(`jUBb~iC#I}KRT1M?4y{2CnF*j_sVIbi-AWu5_2lvkb<1jb{6?lmNj80LckJ2Z z?yT=Lm+cQ?0cV4@8M9XxAZhi6F(zTwKmS^x40ntw`HwE{(a0eMEvNHprmFaNs;X@k zd^LS13?l2l4G;y2?Qq}<*MYt=j63@ibKElsXLUDw)eWvJni56VU6sdic3{z4F<_l; zKbJN9I_++OLQ8*GEa27ml@UekKXw#maODL*ODgB6XbNmw;M&~g*;HucR_e>`A;vl9 z#KDy9Z}x%*!{-cB&&~Qdmhm5F#;j>9rAp3TjtH+d{BzTMWPV2TKxEP*hpcP0xGA6dI0aOH~l zo+{fuOJAGGKsMa!Z?npN>E_sQor9R0+rs5sD zVQf`qTXRb2`wB83gBANFj=-u4EN}7Z*rEp55SY%VU$sr_Y=`(gm$H_?a;H4e>Qc7c zesqVlCVj5idzM-BPr5k1JBN2`NOvohEmv4V56j-T$0A(FNmTi=aYNU65)cci)+O97 z>MW8gt2%Q#sZa0;dg^P`xt=#Vnge$(kqW&GCs!ZE+Yz>To3n_l6sWvQy zn8<0SW8;!FouSUdcP6zIN7|GSba}z~TmgUHelu~RC zH&l{#oxgLhk|+Ve&y+JpTtvw5a{TY~a=_8N3M?#S`k59HV11v#*6(~MncZ~Yq7`t= znuP0gAbkS!rENvbI{Y#)(p_0noaBB=bde${%OI-b{R zOCxyLYVypgdnvAm1zWbrzVC_n#A6d=F*KnN-sfT>;imoi)=#sp-Q&Pzx{e zOMlYGz~i8!+dC&Wvhbz{57wlZ_?KbDD+yb4m$(eWbes36QmO7&DCV<1B2DhAP*FazUmWHP{Fm_u#30&sKK6X_Or z9sKOzPy`SQ#KrNOzI!kD9w!o>uQTG5D@4*4J-#<07q#z7PRsIhk|QG7+uuI!$4V7k z4?Pn_NoHnmDEINu)vJnd`L?<8K0)fst37>ZpKT=v2Ix(qy}sdIuHl2pJuoKyla#N) zd)DKJ52tc6bFDnjD+6jlU>1(#MzHMq7N+tMo12!8Mqhcy0$`fyywK*%cT{63Bb;fy zIkQZqh?;P5BoV7`Iq6y&L5cR z+1d$fl-JDMz<8Cu$V($8h%^5_LS7-e2Sz25OLnAj4JuP2XC@x3si254z1fxM6F zDS2=&0~56}E^V-C8`Sv0L73qwzdXKFx0ITZPrpECd*Xna+}QQ-hf%&4UZcb<61%bN zcg9_gtCSF~v>o|r1r6cl&;ho^D1{7zqc3g8p`-tV$^j7Lj-9iM(h~z~ub+01faz}h z`ws@MLyY!?)P<1ER->^Sn>TdJdvq;Do%?^^N+5eBwM)b1Gu@8`{a3vEpNnjq1T8?n z534u7;_~0#wCex=;=P7{oW~q#LPbSIXC);1^pxoro1<0aJw$*A?x6ZsKL8yg)BZb} zY`XUH7!ttuEp}~y-UOe`{&UI?4?Vp$_2;_Ci^IyjzuJY|%&@%L{|nXkEj(2>sz_gb z{R>yxxf&65myDAn9_Y?K*Nj|7O!8&-n)8zXoU9Omv9U2?Q(G!}LvqAkdy0@oeT){~ zh_d}3putnCFU*zwfBZ3G@0CG)d<^qP^22{VZ;qJP9T~-R(9p#F_=@{x>4O9RMB9Hh z(4yO)IuBZGKs;*|lcbRM0VTm9P;e}yS>+u%cgpy>VgaVUiymvQSc`{e3?D|GIGwKH&lIA^941qP)3Txyv!d%#~+v zMXE>n=H>-tlTKGIcnI|MuHReJV*J@x`uqgQRXd+P(CVJw+^S51@0hIEypX%tDV=ko zGlHE%J#>c6=d$l`?RDM2a@<8lQ>U2|w-Sbi%oWv|o~k^@Q-``8z|Bt`RG(|tnc!I= zxq^RPy%RiPv!Oh3H;`^d`?%2w4BmGm9d-i778T@{h^xfm;mAcRUADqt_Fi1W$L<$%p(m6(x1**i<6r7SJAy+{g4v^A7{H>daI zG_R!olbnSA!`*vFHMxELzMx_O5x0OS2v|X>iU`t+6qP1I2rVE2(hR*wM-UN_s(=Ea zNlhq93mpNa_YMIf(g~p^KnQ`mviEz>yWRVD?ihF6GwvDpuD=i>B+s*+wdR`hJ3rr! zy*h&@I&Ili!yxT3F6-(HZ}|RT<>@WD*DO5r3|BxZiO`e;>M%DWuX1>NKNjdS_`P;P z3kckAiV!_y=P=^BszLn#Gh(lK?e1KK(1u@8SmBxQPxN7LjlE$D$?+);pywalTuoM} zsSAuilyX@83P?ZStNnXR!r)6K=l{xYa@+^%oUhc5VEr1eQtu~g)Kw#uTeM>Bfkpb~ zni`p155Hl2wkwXmPCD5h;^t)$*nlRY3u3+)=`H%gjoz~-=r48(wgmY;B)aR@?_VWf zpy17s&HRJ1Dao6o`HfzN!u#H8nJ|aQ2mY9&m|O>4W58pZK_=9FlNqidmkmDGpSLev zvJv>qZ0nWm)T-{CW+&2vco&wG(=Ji7DTcC~N2EBCk*5HcGeLcPe{AR@UReJ{4M+UK zcg$Znkbv60^2TGHG2v1y3#8Gul}3lkrHVSUjj3GCZ;?P@aCj<09YMq;5|Jjn}bh#PKiwrjfvwm*y@6 ztYjnd*0#1q*VN)%jZ6#;Z5hrAe4!RGfa{|*XtB;ZL%8^-z_UoV)3c#rgg?{JWWO$- z5+pu>8!yh%ueUsqnWH4N6unGu4dou+yCQxg4dZNkCWBDT@N~#iM&x`NM(f8h6X78| z@-j0=CDQ2S)X#hrwP3F@tV6T)t0m*XUjEm|75R`6!LOpN=_g{8pn>xqFhMSV4q;yv z;vIp2#Jor^XukZ!SU1G2BeC(>b>r&hka?ZOIKa) z*pv7ZCzQ=Af5PSuGv#?=!CpN#I6RDb%Zh+xvj&1D$Xm4krHr&dh}lw~I;tX=(>O=TU8r&9RhA!VlKjla%m!{|jf-@J)d$NTJ06VbHA{`I>zsXmP%n+5O&?79`&{f=(g?Q4PS^Zp^DTZ!q+uBC3M($K^7KhGTbS8_jIIei zV~7EjZmn|chK00)Ni@)#46amfcG=g@ynSmEO^Q-f`+h8UdctS3v&?a-!33|SU_*yw zjoTs^l6&sqp@k;!Mmnm{rBB{$atC9zbX5~zm8$sBthvJ!L|s0_6I!K&9(bGrI1Miu6v&~cxnFC?{`#_?b2$>piwZ}olJQGo8R;jI zc4TcalDVw@#^4mSwDgqMSG4|fw=FO8F$b?-yl)8xffT|X>o!<{@J>ecw!Q0U&iCCB ztr_;KB>qii-@5v;dcWOO1@dz4#E6Up`6~}}5b5Dzj%0=t( zTWMjNO4G_h4L|`vnQG#MPp#UFua6=cCs)tL2rba7;#N!%9;#tA~*X6y4K4c6SVvsNl&ETdc?a5-$bG?2R@ zaqedAdJ_k1$6#s!Sp1F@z^XuxTtvJO%=SI6mU}ln&vyKu?ykR)+C)HE)uJ`VnnSP( zS5RgA)402&U?Fl(U1yS{&qj(RwtAFzIKO5IJavwR5hWo zmHi&1)AV%teLJdPz#=eEmR^qR)C|= z0a;6jny|5CvsA|hvJ@;2g|c%OaIt-IwQ+E8MXxO9!p?!~{!Y02onCT8JQh-6z#AwX z`~vdQ5MRgriJ)!C`6>^PX*waw6vG(?ZYHxSwK|JhqWrSRE{fQgkRwVY=u2jzYDE!O- zo2WcVIr?i?&U>4xd#{APB(k2@S=8i7cbW&$(Zj{>Ku;v^U_G8D1-mgI!0z#bSuSfPID>iv=uejkMe^l1dnscXe-JWxq znC#ylWRn1f>8=%9g7i`$qm^zb2Kj9M44Ko^_Tn!#KCRzdlprY3_c$-C1}%o^%XY$A zx8{;3d~1omNNPJj1OkyM$Oes~C_MvU%>krpkM_Q+Xwfp8YDjDo26z=?@WR^q;(ur$ z7hXkaKFW>x+R%m=L=`&vg8fky{UD{X61_ay@*ofJY-gJobT+b8?+w#)$W^Vhe!N)1 zr!`y~^Ws;|9s7<{3NkRVFlM>27tqIE9!Z;hm2&Grd5uhc#$yv`xj`&SO~7s6+gd~9 z)J9$ClWQQ{zgc80{pDB6es9gtPMUzo>waY$Sq^P=;^N&V zT);7PvC+~*o&R4Vvy6)dQAh_!e!3A>*RQ=_24%aL*9r4_F!+15x)~Fw@g6HE&T&6C z%FxKVF=b*39fnuL>;P24N?0`|LTVgqHw+q>XuR6Bj0tn}&;4Vvv-ztjf?p~HyYU!{ zW*s}P+8cDS^5-B~@GnQ5M`N4HO3aoY?v`pMe{Fjp_PLv;>!p(Rfl5mq^&hIul zf-4feb9OoXrGNPP=TG6tp#lX9$7pEMokCUu(!!c*jeNs6{O>GdhjC%G)c~V7J|U{K zd6)hW(T#d}TNgB$U22C6a#W@xz_F-Y0-^Ny)0MvRAJ|(Oo4VeH9)v1E@ZeM@&zXaT zXjFnnn0Nb~HHVhRhry3mh3h?YQunI6(xs_NeehWL{y)@&wt*r=hQ-qkET}}-zE|s3 zW4GM`)++LAsA3^BLza56Rk9(X*R}_=DSsueRtXbi0Q;h1o{Q~OEwKzUUWq&ooH}AeGfdK z%u-&@0cBH4I|C_!T&X4nC-l@VxGbgJB+Si=kKAE!a7?o-UDWDPd9PWyWP=i7kd*Z+9-rQ3k1ev&~2lY;>MaXX7snw4|@rP(@;e2LSK;}*f>;u(C zMK0{i6G085lN3jwz)XuTrTD+0^~|IC4gggFJw)q1sE)t zyd;bz(4@#Bl)*@gN>wePP!G60SZZeqVe^IIS-ne=)^~ON;{-S^$_^M)D44v|i=~ez zJv~X$M2Aw4iH`64CN#m~t|v0-PXdcioDDq;Fhd-yDU)TiG#SIxFa&1eZelfS7W~AU zSy4LxT}&aJK%KP1^qn3pU}D29Ick(FU}3@jjUQCw4t~aQ8vwoS=XS7#oIfEO;*gDr znLDbM|KAN)o zE7Fe7N8hmdmU#V%Vx?x(KBB7(v<&KBmQ0pBRSze(<`)%hGNbud$LEse@Zj)&Q^dL> zHR_R>nNuHek5gdR8K)Dj;dA@}I@n(<{L)o$nNxq3PZNjB#tMh_E@wK0%%zF)JQOXQ znRSCGoSdW+7%bbjx67SW0GB+ea`!g(ba&M?NhsTd>L^_SqRWo1`^v71uB6t-#F=y< z#61F=z0)yauuI4D34k^VK-a?|Z=(VURF&9i!`M$jz_F4bb{L7zeO%noHWs<%J>fPy z3b3||hq|bJMZpYG#R_9}g@*akdAlE$qjMUFk8VGqwbo+RqLw#m=)~I)EndSw+Kup`VUhDS1jyHF;Q$oR{zS zD|Er7=v!%E-&olYk*Tz|=-02A!;UoayEl$pkfTk15F!C}->>uOUYVQ_(P8GKFSqTR zP6!_MU{Jtigx4uA0K(>v>g;cC7nC0Br<52U92GypqsVenPrlruL88FfSsGusgrsu^ zodmZak#1_j#Kx2o`o30y1BaTzNBMQAIblc4ThPSyZM`1rsw=+LJeA;JvKle{l0xJCPY%dt6blvI%(&txrO4%JD`ab>dXeOy+z)(Upe7hom0cf?T&aqs> zLqqP~{(wb%;&4x`JXn8{G58uK*3Up6XCEoMYc2ZoLIzFoYGcK`W`xjoJLq9NU8w?cV=fr_;g9O^UQHIYvAjrZvKE zZG20H#Om#3iW?jlcvS{}xA&8};B6bq&15yHu$8#e&I9WlvT>C=Tpd$XBtnH$D3bpq z;tT=W&{;dGt<2QIg0`XWuXv<)&s-ke+k4&eAn}3yFQs~O z4U>g9DCk3V=7lR=!d4cbzR7639kBQ8rL$%xKB%93a>qg=9RyAf$RJ7*8Zb7Il`#xA zATzB9zc{oIe4l%8?iaxQyDcqA@>>5QFYxQ)zW0Ofy)gByj*?aE-WBgFeaC#W#*;3v zEkm#bhdn!|w3XW9nx|Sh{Fh+IWp+pd;=ai{=0y_DF+#oHp(E?hR~Oz$h#{R>q?2>~ zn8t*uht78w9F`}iRt`-G$Moq7-{-}+n#I;_-oGb&SgIb_!K=$mY*5~}e9S~Ffs=JA z)6*tSZ!htc5w1_Fy%XgF&!|%4%`5p%kYu;1nuYX&pW~r(^*%KkKRx*Cm{rKa}rl>4K)%Ri`_Jb=8;JA-?So`srCz)iw}7 zcn>1XbH?8 zP)a4l@G0?2<8}5oBDcq$N4e5JEc!f6-4lwzV%XO zH*OBR|5<)-Q+fZXs~mQ)$BovaUg zBo*31bt42$NUreVLQ@B@$cNcTJ?j{;u9};hYjLMH-$L6@NU}K(ZLt&-6c$DnWbfrD z_E7snFV83EnDz@}U)4#u z$RajbWEqy3xRrLgm`N^-NvyDLS!BCbv9C6;1S!YnL0427!DNXGnJhO8va5Rf1E_ZU zSy|67-UeMxF~KPK^*)Pxoi4cX*4Vuki&q8T-QS-+(ZHMVuII`io=?Z0-QSe=`%?2KQE~B zxk%h1N7@z60>1%l_91zo3ua64*$8Fi1*F@;3Fme}A^kkZqQZu}pX-O2q<==tY)FJzQ86&&Va^+TRjxx|HWrAb#g?v3c zI{Q&6mzRw3J4x@ojQ4TRK*70;!ApCvLYU>a5vhkBhr(Pj^`ioHPKH3-^#aOE@;WFB zEJFJsmhE{3fON-MI_y4RlDl^kmGYtij@xgE>~6VrH`eW0;*n?Q-r*ZSiMd2og+SFHCYa*s5$PNR>B<*4hUUAmLNlG zu~XkE=gh0@SGK_m9wC~OuTH=qek>RJlnsutPim2aExp!^DUL!z}8)4-W)L+It^fYU9#SR;hgdFCI|B=Oe0f` z@LQ{qVb$BvXt$aBGc|ImL+5RQVXwZngzT%NcS!l|Y(x&bE8nGO#9ruIy$CTx{rq3@ zZ>ed|l1tjOyH6VTO&~Azc07&nYH_FNdD*&CQe1&u*=^RV1zJTDpH(AVjk35C6#MC) zVF7|}OKThJAoWYJ=dU9f_#lq9=|x~_$j@_@>>B1*$bp>7x0V4c>D+KFHz{ww>=@{% zT=+|{4TSfPvMzq*ySe>CzD(zlS2@Q3O$n38Dp1>OQAM&hO11U904K=+@EkMmrPKG& zpFP!ulUj#72NTZg$>#rJ|XLPl4t+wiC`1g20Mzg_Z}==WoLid9H+p z5HWo4q;RNqMzJTi$4eMKY2i@Yi+<&Onf@d%gBVs91|N)89o!>r5&E^P^3J$A^N->5tH|{hEP2fcV1986wDUmBi>#DFbnE(5CzCZ!T2PGRgb* z^jXwlt6PD(RINh8p|Nj~FoJ}XS@))Qz3s`&Oru@66}^2lL9uHo5mcS?PrHIxvNKr* zL#cWCgC2S#;k0N#8ge>FeEHq_ZTQU#pn}||fNtFbJtDV$`(xj}wHDE)uWgi##V`BI z?r(l*?50J5T){&t1Zz4U;^oZSw$39(NoZ?S=!V z4b#=+W&jTZU7fyO<@H-KBoB8&4MEp@B{Z;``@l01PaedMX2Th&PtSjkbIC=5hgl4P zx#ED}^22n%^(EvW_)A%~+b8VlCwswbea0<8g@~tWWhJ*K_Yb}d~ zt2&l&OU8FAp6GPqn+FvC&3=;DD-vTF~ zheao4B>Pwcg!qJ3wkF3+561M|=)wV~@wnkjE4q{evr&=uR|$+9%S}JRCS;Y;tcab! zIdv~C(ZQV-qzQf-KXpZxme=2FvhQk4P4^Xv0FC%`7A-yZv29dp*%t0Fh9>Im|0Rr- zTk08T2oS$G2^ceQS9kZOO>0o?fAZ=dHh32yfIlfFX;KM|!unMuoPE}h1JCXM{8LjQSX6!+H?+%%K#3tHDTSdkq3w0J zWHgv^<$q5zfpp#7-`*N1EE)b++d%t2en9iTPrmd2hD7K;{SB&YNW}VoMtD>DKe@>N zeW4nUQl&8GV;A{7|NEQNudXmqfAIS_$?>1WD*s>n-gzeZ`I#9*(6aFWD|}tR8T4l1 z+<*VDUPiMB%Y1l+u6PkSC77z`EiPIve4hV5!P=t|9`s2ZBdf zI+ZR$^-qd&dq7OggRM@`?okM(>?yDX0*@QkG-8le@Tu?pTKVpfw81TZa@Y36Mz(*= z*eR|Nt3^Es#PY|FA1lOmqcVM87_5)TpQVLfWMv&18L$9+-*Yr4Ri`(kgzu9U8ks<< z=x@VI#EW1JJr2FJ0Lo%x)R^gtneIu?&xAL7rwB%O0VR&p-p=jif_l3?-2y{fcEvnc z&1W0BJw2^}a4h$AiZX5=p3#}m?7yPeGd2{glQr655Rd7%IVBI^#bizor2{U&S&$jI zM`JiOpNK@k=OZ}(IsLx9`PtC@S!Fak#RoL}pPP!{Mr%i7fyXEg-84XdY8dArsO>{(oLW-;D+vsN+|pwTC<5bP%><$*GB z{-L?r1Hd88R<^6p3~6S#Ar;Rq9fFRZLnd&on~-7U_(`a(e=+MBYwB{u>(cJeYaTl*|Q zP@rdQybM^XUmN|PI@2cERmdX6wtJUymgV^Wb}PY`@(%B~5O6B``CKTor z1^q@_1f59L1Fm@N_FA!ro>?@542UBk+xN2_e5N^845sFrnpC_!+s{_H_g<#*tm)!F zji_+IM8Ou`4feQj(5&3-R$pMEeS7;ZennD8Y{I;dG^DdSs zwgICA(Yz=})9WRB-TZp4sPg49DS%X7jr89$XHz=$ILBZW|AsKVCAD%qpFJO7Ogr*? zuOgRyFmQAQjk`2n6(bz}9G4ctB|>xVWD18|zjIf@Qv-=R z@XRcy;{X}_`>QC^h`s!uUyuU)ITyI)H7%=T_45N>I2xKxQ$;l?i&D|U`Ot6qdb(utGzm}Jkv3XuH(qnr}Xc{}TN6bz<8!~QK`*XKtJ zEu);bmC6>`2BSc3FEIUG75roegw~FF3eiNUWZ55Dw9`n^=7b$xY7oo0t$K6FgB3wj zHv`D0#zP&3TU7;bsUFa2m`5pI9Muuoe7&uCUdy+j8itWyW%3wk7DdlZS*>;~s{OuB z-IQj0L{r(btA=R-s7}|7!4wF|v%Osyl&+6`sOxP9CA06ApafRR2U>kyMjAsB`GIp) zGlO2n3JM|Rp|b4+Vn|igWA7y_H7F{5)^iL8vXKzUjT5E3GtwIN6foA1Cpo?c; z2srh4sTOe;Ppw)!N&}@0nR^-`@q(nrdu7bFYW$`~%`CJV|G@ z`O@G2&bQp_fXfV~3BKWmjvx&5sJOyL4l z=pG6Bj^gs}D68HpViXHT`vw?I%`f(68~rG;5hEA1gDSzLHOuz6^B|-6!joh*1{4O- z-Zh}SdaJT}FMSap84(_NRFo+Yzfzxb5vm8t$xHT0!1xJhGrm~?{bPRWmywzkYX>yw z61EF}mk3_Ads$o@knobsK^wJ){3Ps`FiN(acN?fnVCWC0m*XywN&$1_vP{P zj9xf{$kMv~QnEU~UY=ujuAeN>*78c8!h~uWdJpp82tMs?=dI3FiVEqkb>i}6HdaYg za>M<+d|XCSlWaabDHVK(X8;HDgz6+GHk|I%xP`|&2gn=HUi!^3b-e@b?;iqmzJEe3 zT}fz%o#a^cj~_PNfgqUMWj?Q=;i-p0#i57mlW+3Sq+oVCht&g59#AoGUa&_1HSKXk zq$e<~1=gw~*=41L)g#lK^6yKngpSiV@_`J_5e_M#?wbAXAV&8c$LIFe*lh-JpRU-kpyXrY*m$-61h#xfQ#xmu90R`o})YO)ZjZL_* ziJ$tld5ft9u%p9Ml!k0Ge!0$|rB!~ra|B>#v2Ao4#L23&5}v?swqY%p(l8|@TU zxeOIm$^|kS;kop9Dt!QAJ|uP<;!COmOQEb?y6+J=Ehp`0P~SQy8BvYyWj~nSGAX^W zzv~<4QGdCyC$`@$*oTAF!z<%$x115_eOFl9-CM_i3>qro9q}h3CVyQ480S`b#x?+L z8m%=D3a&J)0~9CqB0tJzsOr1)V)FjfJdnplBnL@;@lWyYihLK#f=qVqb%pw_gH$hw zsRMKu8u)^vGV}3K9H!QJx6FQB73bbWEEz55-a#p@{u=E!ju3K&M)33+pK?(Eilv1m zyMnM3{&jF9Nxc1)U180T4B62|OZVCs^u8X%iu>7CxB2DvUpEMQ;YdDo zEuX-_PP3dlnOU$3UxqQ>F|Y+YAP%*=>or^m_*qpq(b$mei(Az5H)5&9#gUVEuf4h{ ziwDfbdkhM|?D8W0(hjo;l`&=VCULDl^e!R_Rqd*2vDY(ZPP_;%Mg1cHaq$)sxbAE= zbMr4}VWz_srypN@?g})KutZQyj~IS zrQ8@?8Uk_^l@!@7S#iH-9YtGAlO@cY@D(>b&E(l+H?t0Mb~yjnmvM=Sc2qeye~qu( zd4xr7p*Q~`Ui8j-+QSwS@MLkoX5G2EwloY1F~tnufeP8rUj|X44MFq)mI>L;#{Qjp z)-nU)^g0eH#~6Jg9?(v0m@Dl$b^(v~=@5htaRWFa(@WLw=fKvA7iuI-6S-^1L3|2O zSZPnqP^B3q<>vYYkLN+Yllx<3J>~;!W>`eQxE4WXXTO#TQeD+!*7hW&c%XF{tNKh+ zvh$XG#BEsjqfH0|B)1D)Ruxy+jq#imVVf!e%|%l??JrDc%mgA7Q}bJbe%@pL zkR5k*D1pZOvX+OxpF~|_SF0>9FB7lCvB|>HE=gYUI(l0oZUVbnT!%@yU250J-#g6k zNmdoiSSe#}W9Bzpf27>~d5v9(pHC8-7@ zfNj$M5?FaSvQ{KJtBO3^l=2(*|k#*O@#t3oVp+`COYyqJEbhVu+E;6hQIDwvlb!s ztA0hvW5O+-L6+?P8k1(;zb;+#G?-St?7W0inRhU!zHcIBzFu$z7?D_RzTVP4Gu3SS z`iAuQkQYK`d-<)sf-Ym^52a&Ubt_6W29*IWH7>-G6*`hxNsfm<*zL4Lha4 zOZR?ou1J4D@@wbPz~bt3HOu8;QpE0xC@hlS&VFUrf(~nfm*b<6vETc#;)JAk#q1Ys zrgz0gsFG1eRZZzTE?F+0&IJrf^rf14HaM7F?EV7~`%djgro|Pd>owW%1r>L0H>w9@L zcW6=_Sdxg5gSu~C^k+d~mgn6>I;#C5a9?_^6gIHFXEhwW7#=6_#lMgv$Hrb_21$f` z4@8g6MM9-V1%3Dv3LT5MVoU9y^S}g-I3STu$@iQZ6~gUM7Nb+$>I|p1vfXWKKAGma znT^iPWwjv1+20hIAtL7DhW2H<)M%Y$E}IZ0YSya+*UJDmHf5hH*-ylhb=8&Rl4*y?udj72 zaP7GExmh1?+UYi*i)K-W?nvy_RGKp&u3S0-6j3Xi;=!dy4kpo=FWQ0%?zo0$(2Bsq zZY_X0QKV6R@PKl&hEX2(+8x^ssmrxX3E8c3mC2C33X@ZoCFZ5O_zQMYt*9=thWFbT ztC^d(?wUVT{1IY^(G;F;8b0K8#t!Mk1AAY-o}1o9vso|%hYe*5AEyae&g^kynN??f zcS@(_#n$rUL`k=|zVlMp#k1-K$OLv{=m&if+53q56$74|b41X@`H=rn{+E*M?y(RP za5QX76I2=HxOX<-EFFSa`NTEXcdU0xXt_(5UWQvzl)!0wB$@Wf&kr9WLg_xqEj(5* z;QT|lz^}eCxZ9H%tB*{ext zRxS7Q8NJ`4B}Mn`rzQ&SUG)h;AN3iicsxF2YZpWbmU_U>KDl_3ANweA4+!}rxv#OC z7UBs4jC?n~=htVfeXc6Wi-3>sICxZyIVFj;SK-HHM`OSLKVdI4567 z;`NAVz(%@JWB@u)YA3(6EV*J>A0*!x_ufkz8X0*T>kZSg6WAepY#@K_$kVGq>@WDZ zBC9&FmQNFvg(_4NPbB+E5!-EG#O{U4hm;!*)n9*rK;P?E)7P=|c@poJw*?Xn=67Vz zA-Xi6?9He=X*vDu0rwwSq_+M=0^N-q4b#(r&Fj$i;l_&0g`2g1g-ufn;Ak=$!6`Lv z6&yjWJ_4AI>+1hddi#)^jnMW8OnesbIw_%((?51 zx=fd<3<;OIyz&;yEp<#N)mXC0Fu}_|NE=zE=Hdt!8Iv1(UPlZAO00-3H{F(TXz&(m zZojstL9F+1s>wjbplI+i(J0}9fEp`k4{$beT5dI1Jeh#-)3v2~k0m{Dy{K}xd09;HH%HpWEGisWc=WC-855gGIiVVW;l-yad%Lmmv zC6fd?k|q69YNPQdwo}}NrggHi696E)(aM_-aALBsOVi}lI&8A{iUhjgP;)Ea{p|K* zUvM~a%+g@Civm3rAX|8_TPU=aMT$pOD!(jeshCOe&utc*U#%qRobIHi zf7wVX>?(y-l~rlJwumfxd}q$_j)niW`(?njbJx9DS5srxta7ihsx`s<*p}}TccPn~ z*#2cT<$7D-^3=Z(Nm0*2YgzTUyERww@kR>WD6L(?Eoz&|AU?<^<+WcbSDsW z(Nts=Z4elzTCcy$I7GV?5|+N7!5_FEv(`@qzw~9^Dr*n2J0bH@M>L{rE_g*8I?I-U z75@CS>ATzR0GqBnb+#VP{qm*fte|1iO?U?CSKjHb*Y96A#qGbDh;_vU|HYdfEpE;) zeA`1|JV&M>wnV2aiUHoN5tTQ)zW66^))ELbglJ?QUBcH>(M|!f&V#Z3rA^QLGKV0k zpDv(`mrVhxSxWOb>@`^<%WjlX0th8+DrHgoMY4Ov@mng zY#{i2@7V7t3n{_;TCcL@tO%EOqJIL}V zCI6&cq^bD!qX{4^nmGlVk2P4e?^(i!KL^)hG*2Xase1`mG0VXK!}h(Tc<1{~A03uU z-xtl75?VNn{N#jyaS`6zeER+{9K1f)T0PIbYka3XvOpZnOCIOtE)D)7lM@cv0NYVA zyg6-~+jN@R>2!le+IuUn#gqPMp%J>kC`!M-G0n{MCFxg~@XDDauH(p?`ER?%_I0#m zX2&-6fe6>O{|sxx7VW|fovf{qxLf8;-_fz1)F!MRCgeEkB@1Kf2dFx|q#RGVe7*_0 zdM?A!$7haNiasj#$4+vgC$Nr1+a3(75yyP~w9z2J(D2}r#Gv@O?ck%ywsn%jK1LjV z2L)1M!Jeh~g!8w9HhZd4BkH6k913}-emYJR>QzA{`Gh{rj1_2J!mA;4^S`>|?sz_Q zt42O5;A@%J^rxZeV)3KlMp4|V zAcgV+2)jhwbbH9*?$*Ewvro^-@_#aMC%h5clrRV-=btcUu1TEH~wVzHbSc3!P|uj_wSCM_@9Qqp1E!C4${mq@KO z$ZP4YHAjYKB6lwgjSVeL_In<(8F;TW!7R^@>RV!X9LsG_XzeL0G#(^*8RU-N@gjW82zo@PP(@~s&|4ez;GyisR<>Ba zeLt?f8r0m%!piEA=>7zI?3qHdK2o!wx8|fgYFZ8dsHN7=oIu*HJ0@8TqHkZXr8o9@ z{Y32ti|6d@-|lb2hCj;gyzlAlA2H8kJG`eQYqmew`8qXswt z$mKa_4Pd~Yh%i~v=zRKNDg6E%Q5>~91LW_8;{_g8Vk%e zLOFEo>C^^)!GhnUF6~9641Ka)%I->*?;^5=Lh34ZC1Ioa!r4@pb@~a{p`KUD1i)p4 zRVM2OUwYs+@$-N(tYM^k8|;o&^mP78Q}epw-^tleV#8CST+b8fNU|u$SY?oWxn9ey zBA$@V;dhqUzA&a_9-O=DtO&m5vO5t{;?9Z4tNC~#!;%r_KN|#h7rxcj6ih2#(w(4P zX)Iw^`m_mkk>bC~>v}iGt2ir{uSBtH`YCHdX@M-(8K;QuEW7IAj7nJ5sQ2>o55?iV z?7ZVCi>F*3JpVkT9WVlYvy$J{9L(iRPTCx-SU+7*Rw(}h51r?MLDQbo1(vQ5#Rn?O zKD=t^qnAhNsLpFESaoVi4|S!kf6187<*n_%kJn{Y#=0Z2F>I3)*RFETHIBv~YhNrM zOm4d|xMIAdxu3ITZB+Ykvjb-bJ8n4N&fTrCX|GXPXF=e^opU|qCY#W3jGw%W} za#7emj18u$G7-S$cfqcRR|!npnalo-o;$f#+5^Hocc9(xs!zZuJWC<#m@8Mo|9IT{ zBCO7Sq{hx@Z!xFUBvjeM=ipt_hcjg7l014^63ymV{E#cAZk3@=gg^({cPM|daG8UE zS2uXv-yHz2ZnIkU%{+tRO7&r9&C(^V7iol%WL5Gy7riE-ohgq zwNV(@p-tAg=-ya3oHe0RI{Ra=yQ#6LCbR2YaQWc+FDYwjx3+g2{!SLxEV+;CHAhJL z68dN$6Kbe?7!$Zl5bcfN)X!tt<}eP!+HJTgf*{pHlDqT#BScTn%}9&ZTBe|byI9Vi zv;Vb!p^Mo<4quNEc4XW$-hU0m@A>iB*ib)aYM_uWK2*J??s#hcNL>iB#U(@a>!3q= zBT$#$R^F-X3ib-L%ZclG&$HLB>dINhPf!9eO@Q%y%4PR*V)u=BovD{^-wIdmUnSOD zKb?WQMwhMj@L`p7e%=byuG|Jt95ZH86faeVB6;2F%MHr4R8qp;c13Q_>gwrL-Ac=; zP0GQ=e6LN_#r3ltq50OM12X>4=-xm+84Sj4FON>|UM;AtY=U?WVUb6Gi}h1#Brgcu z>hr6fb+RYQc)Q2DsYVFAD;ypl2R7Il?fZI#CV0iJvE+t9&@H$=+w|@{$kP6oaJ4oT zD2djJP3Vn1%D#T5OQkponC@rH(u1DofTYKwi_fGSmR+|=_R?)R-#JzLi8lQmJ+j^N7tQQX z2a`XKEub_*wlg0UjAchXe@gf3FgZ{&cEnpmQiaZgwRXL^pgD@~1%_l@sJA^iHdNEE zLbg9Xmgn}F&jh#XZ?tZ4)A~*;0`u!dW=bSXz5;mql=znV8uuv7bblTz*v!$qj30(p zXlDG}-q#PdErrc%Hcv;G>05>94tVWy5fUHq#>ypA>PZuoM8bB|_0jF=Il6up_F)Ge zpNKvRCzz?@(}g(EwM<%3Mt9GBp4+f^@MtAHcpD~3mL8Lmd@PiWyF}!z2FvB3p-L+QUvN-o^3C1F*jN5RprK)2?r-q z?St7Y{>#X`Ze_dffbzo97EEa%Zxzt*$uul21M*gr-6a0ku)FRJHxAPzm8!6^Rc%M^ z6ViZH%{b=Cy~Ubleu4Ob{XuDH^xgFpkS(W4LbzUu=O*-p>%V#d{#nV+hI*H?7Gp0m ztT7FT>-vI2>%A-E@t<)qIZq`Q3#gyvB8ShG3){&0bTgH+Di$SF?U=+G!RMTEuoFT7 zwHTv#(&s?Wl)5jQ_VZWjtL2WgTW~;3PeFAxPhQGS=j)*uXaUD_#OdNO;CRk0>1r(> z)ZN%E(Y&#-A(E_0wY;Ma`3R>VAfESkyMgCqY?RrLyuG?jlSdWkdieWTq(%n6kEkpEMzF#gPreb;uVbV_41OE;+rce-zT@~S z`%MR)Z!&+J-lft;=No zB=-uR9RFS(w&}^g1Y!Xt2x-p-AXin5OEG;yH_xhX!sRqjJPJO>vc76nsehk+wkD(I zxnxtY#QrwC^1=J438)vN9#MZA7ghg-o}!7m{jaM?({lcwLLIQYi&6*<{tS|Ze;bZlcb#XxSf2T}Xl~{t)hShk zuisN-j}n|K}PZ&%UwAU_3hy+5-OYQ%Uk; z*wt7-r(y4B{o4-QkiLumiw>{W89xO*n zG;%2KERN#P(N^c3HwDp{k)rjL%}qHYn`7Incqc?r(TOc_^!S`Cy0mocEw}7H-!RQj zO92{AF$&TSPZB-U^PVr8o#yAV(@KsA$7Zam;6Y%kn6&=~%duV=T%PCj0laza)IXL` zaYnR4>cbuqiS9;x?_TI^{5kLoQr9+L8Ts4Kha+UKPgzmxOd2~FHvD+H$nSf|OqRk4 z`S#3MoyT1%`s=}5_xeB(*t}qOKu)DgX?#7;Hu{l+3$&R3ZOGfV6`#Z{SVuxU_S4~* zT}(zhOwtqSEe8_T;7-36f{NJ(!aN5{-Rl?5pkeTX#<*vgBGMhHa({(y%%S02+2#kkH2b(a@Ou8tp}`QsyxbR99e>+3lS2icG2Khr7NALb)6>OZZLF| zo3{4X#6p^l(&0|`mlnZ!a`*+u&5}ax`u*WyN;PKRzIJx&MO8I}N_=H}R^8fon|+!E zXL~1ld`R$n{%oBS`M`d4d2&%!HTVJGYZ$KqD#Z3UYlQ-?rtS?rd4|OBO3%=!pkk?#(t;3{T$}|KH0vYrQ zccz%5o8#Z|=^w<`?R&LZCLfHpwXFVrt-;xurxAX7+2%!e4KKriW+cSNuWr321Cx_H zR8hbuQCDULlRlD{y6zxWHAmsMl^P)dgg~`1rpE9Zj%ybVU)ievbm_d7pwY|;acmbbaRhek*d|arZ)+Gl> z-l_guWcRvayAb8xueb}E0tZi*r8Bgeb5hX}x4rgdw?T+sWJ+S=F|f3ZEd`5YXJ?3j z(JUK_e&^tDg}Xgv;u(%VI7Bu6ozuZJBGr>)4=j zcKP|-AmuAxDY^zB4_3hJTh4k+l)9_YlakPhbd=r_2)%|DIs^zw-Yd?#_c1=t|NXR& zz4!HPj$=&7eP3;@^;_qO;CY~zsf7l)kxL24%{iT+Ld%dm`RZsVf%@Jq9%PNDIoHOs^!OoMZg zFHLhv36N@&Oq(8mM%Y*%b9aJh>IAhpx{Z9S2!9?la|-g^;14r;jWc!RjJ=I_v!>xu zqxN@;!-d%wh4JA44Lrt&gkPv=ve}_l<|Ds;E$M?CjrD;$u6otny zApB%s8e6REh&2cMuVziIOpOBgi0?7W_EICK==-CUl@9g7FzBp>WsAa*VMSo{jDI>5voi| zeP_sPyZl63$Gp5(L13=obl7p;<-{m_x#d_uN@bqXxVfKbz<(TZOFIH%zgw$whX^2^0jQ%p>(l%X*Qazsm*A*W)1n(F@P`v;o| zlgRP?1e)wo&x!*L!0LrY6O)2hoA%FZ%PP38WY_hi>FSr^T&e4gqYxQV z*jOKSR>+02JwI%FjwyL%KViV};W8#X*5c8l#}@R$SciXa4dq!fTo5N1i_VnUuDFGJ*blrkQYMVk7FR|M{aRkvD2;&70ogeMjWW zQ!kep{22mD$LB%i8U6F85D%Y21mliB_hU<{&2`1M&#q5t5GN&B$OyduYOga0xq|Nl$j4SB(>^YBpsXu22UNQ(wErM^r;I>e z8(f|PJBcSZ7F7jiEaK^o@g_NzD>41saV93?)WX8&DMce|H4QnRTAY-L!N%Vig1x?WmD^|9;d{M$F0MGtar>PGa(e7t^m z3blMV;nqyF#sX$EA3F3AlP2w%=~G71#oE4Po!tykR9(ZqtQAeL@5U^en~Xf$bp=I` z2!HZj2WKyrHks`JZ(gG!_e$8}nW4SPc4(BNlY*6^_j={EQsN4&b6W2T;3;Ej6_@or z@;<+t1qYiOKKocOBkeFl`6&7KtEB2Ag^i0~?VVKvJNO%0!J3{1zrVaH?t1RH3gH+z|AN!6oeBJPjw`Si5!hW_ZU~94;%0= zz5+Y&YrMRi2vOm<6Wj)b)4XF7IVMoZqP=woW(%em(b>C>?}+Ct@aEfo(wgGbUw9f~ zfk=-l&0K>HVI=l=zgo7=;+J}UW_t?lR8iY&oR2acsV`4!sxTZ_-B0Zz>WE%SX!W-# zEu4UI#X`Ca6S#am%9VwyecpXu-p64!o!=_gRp<>nYu}woot$(^5M$}xX|@Z(>)BKS zGz%XNhSPzuhONH&RPrhEPJT=+)|P0DEL(+jMNKBRFG%agZLciuOYJHtXKJ;-i&<-~ z&DnTB)S+jD^C;HB4%$+QiO}wX6D8-%QLFCO((q6}GV#G!%B93Kef^1w)YRg%B_%~+ z1kj$mmBjzR{?9#jg05Nb+_`(&$z6|Q^AB(z1Mhe6K3p}E5;^J$zZ5B)15JubEE?oB zYt34*7I1nFvL160E>A8%v44Y9DLU-7WR+$196=Q>d5)e_+OZ~M__vs6Z)GBH>|0EM ztn+I4MY?EF}+l%g@7^WVDDFt;td!s(lX zoNK6t)P*0VWn1~~N%av^C~%!DO$_X095%+<6dm+5txF&pC6`kK!b9`vaKXZb;+=(l zU2={@{!?@&(yIWQf(uIKWLnYuBBzmH$fBs4U zi4^mJw%kq@J~5M$mMdEEbEBLZ%VI&F;X4dtRSXb|C>7JhD{xpDbnSM?uDmNqvjwV7 zFIeSr9LMOO=J=14BGs2PT;AK>$qWenGmwV{;yZ!e>B}o*o-uWMd1fR!QRsm^l>bB` z*p3%@H)`#`mib|K-(3+?qkHc;wmJxjj3E6<$fAa^U~&YC54P`_l6KTcc!$1OyyFmV ztO4g(#Hr{%4B{|PE|2Xe*02F;b1-aKdGlvZJ_(}@d48Vf*dx0nmPht<6Kjue?EK5P zo6aj8Vf<&91msaI@kWiEL2xAc%6Wxl+i$A$<3cZl-y9feAla6?F!|OC+vly3SB&h3 znxV>Vso@-K_p>iP*YAqy866#5OI7-&EWvWvd1s5@v^d|r$RZleR>wC7E))W;()-u< zyf*O#ea5?&Bh9{46+{G(6AV#GpS+!52(uURllwu}l;lb3y(3=f(L61zHIzJ4?iR2< z<%PqySmy|7f;8WI+CAV#*dm!`d4Yc`VlH@V_4JS1nh>^rt~5cX?1Ia~5rCe1@94ip zbkV7{iZzv?!0M!sGZAz9!%L#WD&Dfkt`Pr$hu=^G0_BLk< zTlv$e zAiI0ry;HeuFM2pnt@{aTJy)nTmZ+kKLgZmk)OFk3pB`V7hgeiWtnaqJvwy=W8`+M* z?wW#Zzwt-5$*Wm8bgH4?YZ+}_nE%y*oS9AUF00mEQdqeBTj44*nUwLslbN!X`xghu zD?@P}NESy`=441=xx@awsI~xjwO>PaUAq(VVPFH|gl6qZ(c0Qa^ zfTpYCd8*Z?mqCSOP;E*PKGD43TU^srRk2h`JQ@Vn7UC*MbOH8MP=jY4B`y5-b(T{h z_041Zvr;3z6aNS`ZZEkm=KSS>Jk+G1B=0tDUC|DN8qY9<8vQF6LXE0}RX)Y6O6>X% z{KT!UGbQ96EEfGq!#2~BzAo}ff0LT`2P%FG$N0Pqv^PH|KZB2m4GU}3KB@kw^USt7 zm5}XNjd=pEGTEBI;JOn0^0u*v{d& zgKn*Eiqd>tdeM~!oG2>l>I>t!b>CTbP?K&brjmmAUcD1i{o9+b4z}w(FcgCv>r>L7 zy!fDUN$egaF0xH8L2WW&*bmd4guAF@FZd91JGwnM*lWa3=B3LPk5pOoTovjskC|r>1M?d{5?PPz?disV z?vQx-&64n&izB@fJ=t=H8Dw#4qSX~}cfq($VM%-N`$wllH$lVE3y1!=zoeK`Zb%{7 z`tX0<;lLkY64>Bi0?3EKv>fwGe4Z&Dm}JjS{4&X2Yvl6!Q;YpDK1bUs2W!OXQfJz|u!6pS1bOONZ`IxNOW7;t6nMS0&MX~owHi9At92x~7$owNhCs4x`JiR7)4oaRE{h`e{Z|=DkihKG zvwq;}fIuyDP9D0!yt`}bC^lT^HnQbz_QjCzx+$`>k?rby@!0GbVhVeOisn>_;0om6tUKOctaZUBn( z%?BBub^zr^P&@Z8v>+6>XHe>O#{z-b;QYBs`$ft~T-!Ybz?wXp|o87VTW)LW?YU-4jX%LjD(g1=I%j>D2i1}ry#z2n<_|z8al|?(gWrGk zE*EeZV!1o!Lrf5Lkbm}*gWR)iZ)Z2Gj#LCt`r6dmZ4s2Rhmj~in+|pz(l|imr>?q! zo&^2M5;qj9GG!iI!qFsW8b*Ef7?aC$bxt<}7b%E!%iPC~RW(YcyuNfeEd1T^kzKwK zATk;053_^>8oV&1i(dAdw&B3<>Kb#dWW5Lw$i zqtZs|Dxt59Q*6e%8ML5NVh66$m9v?p>ZdX~}a?XO`EiTtSHgf-1D=t5h zw*T>A!s0#+3Z7YuWDPPSv*sdFMPUk<9ty+-HKX*!tru4$1)-Qa^5E_G<>Ky&pC9Ia zaB*^4XmG#So`O=yYqvqUw&=qzvBHZHa+5;hsv?nUPo6x9sGV*gflkYBMdmq%1)jY? zYQDtg149*N6iJ6mL4h@Z?lbGWG?(p|$7L5Tj*eE>CnA@dMUUl0E6|^B*n8j(eCNJI zySn~ZDMLC{&FxuN_lLJD$KIsEBrLpKGZ&Ul1UYT3gtAXu8A@dRGX%qh>@#rT)tPw0 z7s>u4WH&$vR>E;9yT%NlxNKjofHj(+P7yU5{;bg54RBaEE*5T(LPUv1oZ0Xg-F$#+ zkc=|aC?Wvczem^RJuQcNy`r6VK)ntWqVtk##k0%zD!#Vv+N(o1*0Q|8=^f^gR<@_y zr~NsXKc`q*$lyZZn@kh1Yb{~6!bFzggp&DdP3Nx>2=OzJ`04E2 z*kuabvpEV}+6Nm$;y53NvnXoEOW&|%A{biN>`aDMwyPW5@cr@e@^+t|HaI~xD%$nR z21_R)u89%Vpmy)Sc1#1 zg=${rSP0^lvMMJN(*sp%6WcOW%H$r@V(wh76#hL}g(@kTi0fN1!+>id$HY~Dy-c*r z=ySg5?em|d&95Tb9*z22x|gO(vnbia!z$-xuJ&(rgG8k}{4kSJ)cWT*PB*P%GK(um z`TfOdr6}=`UAwm8=j{tOp{>~;-9Le}PK6h9GFX)rdBjXUdEZM4a7JGxG{w#$kD#}+ z3#^78oGa@v@Bw+@J&lBOEZ*?PA*oeN|z`TUZMyI9Ob{h5>ac|P1^!@*#gWFq9*ZC^B#NQ3aUD-UOsSC(Me+r zR#vZ;pv`jrTKsg~;OG8j*={maSNB}O)325kSQi3J$HYtK7Fj}%)N=z<^RfF9nfdCT zt}nq0YI!;V-o?ZD*ruT1w_1h>+!!Md$~aJDx7u@kCXA(=G8E1!nhy{Nc2iB-E_mrw z`$Q{C<%;F*y>pQpiM=I=bxrLxz(#zOux}|Q%x4pf+)>Exw$$4v`UvxT`bj=JnC!e)& zYWs3S^e`NxjY}Q1Nv7p)>8y&2>EB`2JnCjl1pemr8Eun%X>0xNU3&BZrVf+c!i8#} z(3T|mMZs&PG*^r;>N6V7r!c3GWUBxCUgWH)#r}NhSaHBAfL4z19nUk+m1zpn@;BPq zqVIT7NY0Xk5;5pZMov*!!I3=KQb?W=A18Ky7d4)0`3dgY^K7{=33b(fvH|p`M}>h< zAJOfI?taiS&$)=B zj_nK9FXy=`EeTYdC*EAUIaL6Zu`J4FsOnfjl6~{`Sh>)v36ugn{aeI#nx&3uc#`y! zm6{C$e;PmCTY$#*D&m5eSZ7cRayJw*vYfHPhRnTuImv4Xk3mOtB9;$Ibt3U^`tGEA za&p8Iag-%%F_-4#Q2o5jm9pjZelX|Xiz4AEi|3(wbA$)@vlYt^VNej3zSlfODfED* z*`KQpbvkQ^)kp5RgSC3(W4`fi$v)7%CdR_CounFZ*wV0a*yyA1CypvmA-JKec%=kE z6(cpQO??<5Tk7oY5*wTA>+q^VZeQ2(>Bk^0z@a89fgIC0@FP-?1WL$~@AG%o=k1!1 z`%`xT(DzbwL#(2#PQj~oUVN1Yn^PL_)vlf8h+uF?Z_4<(wIaosOYOXrQ#|W!U@%bv zboH8PyGisx5WU((UeR=;3}ERm97l%-JxS^t`DK$VQrfMr@OsiF0ngE@tv`XK_)@>2 z1^VR0`vVsHjTU9p`E;s%Y+VP?U1!@a#;Wr`?wH)QMe9)37i}s#<;n3tpy7H=1nsP( ze;FIQc}zzdTcyj=s{kq>PuzZjk|mkeoup7wyBXxi<8ZZ_>>b2*8+p`A`YLauB1-MR z8H*qH`M4Wv_eiJ;-rTY7QP4xEb+&uE?(V!5hnXn1e1_qo_o0z(eaANblO7y!LZSyh z%Q>#Y2@hlJ)OY02M_#(f!|_xoNXrOtLsxd=RydANu4RJ%;@VX>4aH~iThjyBZ{kWB z7v{5OJ9o-zx?x%t`ObTlz@hr(y?av(jT%Ai_-_Fo6G2nCF8UuR`^}0=rS&006>a>S zZ501+?|A>5rB1L@M24>B^5$pd8dh+`E_eE-+I7vp`qF>j;@VzUiprr={@cMx?%~4Y zk@Iql{IGQE$rtDSb$+Ku1h}k(n8I$=KW0u20oA78$MhfnMHBpkpL~BWJ?J{nVZcWs z;c(l9Fb@Xz{;!ejwSEkkgLkb>I5Da;JRrrBTb$B2?B9Y|o%Z@2Y3lN0P#(wLsL&Dg z_xb@ig*YkN7?|Y$8ZDatP)`FBhcM(pEGBJ*T}$FQbCTk2{h1+3FBW6zc=UJL9y_bDwbhhWKwur}t4)%@KpE6AjuYHL+m~`XXbE z6}--W4F~trJgFB+NM0V3y2S!1x)QbetbiWVl(kEQu z2`)c_jR5pO_$-0{n!5w+YvfHW$gBXbkYf7&of~4dG^)7Q8}c8M2*b0^v-w2kBzW>W!_E>o1B=|Gsv? zt?Nheh3SJGhR`7(|J#Auz7D$Qn3KlP!Y`j?;LYqUTbnAsgVLAhfRf&Wof^tY1RZK* zWD`L?@xowdH{9sN9nPTeB=f>L=J%)Dzuc$~`c&#Xgv((n8C$`Sh9ZFtLT8dQdDyRj zQuWt>;)M0@507cOVnXs&Q#g+^Ku#((Dj$2iVZ9$h1s8tNvm()2iFPr`-@ftQacig^ zGcsLN?waqCVG$Rs*~j>o5mqe4uNnalvmApwxBn6Y6u`6DGS9u4u~?E60rT|?jVhnN zw^31HQRe02M?P+Nz(h=>x4YZv(EI}TtzpV(ZMi4pVfP4DMMDT=uqlkk&#bgCr55DF zdGBaX5Rt~1w(1F zm%zlnCN=7v=4ZHL9592Iaw>@WM*~i-MEwRK8>$mD;Pqp6*Z? zIOa9%8e$*Gt_V`P-wkt(x}Ob(Sf z4zutyz@5oDLu2d*2pj)3q$`ivGMu9On=-y6fmMcdZ*|=~E8wPIA(^}OxNlTgdgIq9I7 zsL9X0B-p?E$i>WYji|c}pySFLsq5%KJehgpXKO3zjbP$`uG3_}ddJshGCp>zu{~36 z_wF@9>pGoRMxPvdc-JBb@oZ|bpg9xEo1>Pyd#KhB(V>5W5R$}cw0vzpSFKNBf4Hk_ zJg~#vx#9=loCMDQ4NPw-%b^9uj+E`4iV2~PCoG{9wH}(8T`aqzuynP+K(nD!SuLs8 z5RpjVDyUQf>L$O9uE-0(oqAs`XWJgfg$3!+BOlqNorlYAn3Igm*=c8j;Bcjif-|CK zXW5TXNXIWhjPhSI=K|ID*qi&;=^|$wwm+xB8||sp3)YIEA<5Q2)z`n$m4{b{ zlhm2xsO6haZ1X4$QxT?mxsH2&^Jx)Hr9QX*WATF;{e{jW#>l!(df}4Z%(3whgQu zZMiPqd(EehtE-9tUm3nyPZ$D)LYaNG>Aurb11{%l&HO7x;Zcys1Tn581-Gy1>pMXA z1NKc5tuiG2F@{5)8J|^EB}Xg2c4jp^^#uB0 zJD~<60xR5@2<)!ibonxrq#o2%PjsVflh#OCr!bh4`Hw~hy2j1_<1bQ@IU>T8;Iy+# z8j;P*${K(htnQA};I$=u%Nt61&#ZNTVBW;v3fF-E+S7377jjK5k7ZB>%c8xQ?wExtiRu^Tef|4M0y<~7KQVb!fQ92@FyJG;^Qf4f``Aec z-}dFh?S@>X6_CJlyt=bmoVz#J>Cn_~SUIJ8!i1#BDXJy-DWQzUkh*wQyA4@025M7p zk0+@^ytTgm#IwlY!4_hgUzJ){ZAWaIH?t<07#8k5`1>_Th5R=DX!T~2;i>OTWh&S~ zHT?igs0Sp{IX4g8%#R!{(tO%D6LQ|SCDE*AIOrokV&@6NtgE7p9Ppga%K}siH0_}P zTRYC_osUAjb(S^G@&)U$GWzPi85HUW(aR*fv(Is z=>hJL1B{cZ_K=^W_=p^`#&4|5HBAJ*+5NTk+Ly|8K}FX93y7Fjrrr7@U1^{Bo^*`E z;7zXE2A6FhhTG?Oct-rRo2PPQRej3yI})(qEarNFt8`%Lc$Rv{eRcqJ^*RoLIg(sH zx~1D_hra9BUl};dcCBviKmMlgF0JoVQ#br__p3LW%u1NzH7=HckjTcejOx? zIObnGa}bRWNhBhvi=t*r*B&qBC<)4*Pl%HctFfWeehTNyxToSR?^^SlY!6w0?YHz9 zJ;HCf&~Ld~YQCoKz*5hE7l_w>8SSayRDd4AS= z!VeP4#1dNlR;s0FUV06CzkMOd*%&3V6_t9ed;=F1V^jMa#uoBul{2)|c`OmPjyjbm z)Wouc!{P25$aLHX(H88<^PupR$`u9ED(}KLb8tub92Hfew9Z!;&CX8|Y6FVSg-GAI zl$J-ubHnp;O~SU=eYa^h8N*x8+rF*8efyQN-kL2V@gIN1d$lGZCBd{+fKGA*j@qlF z$nX;Ka@6hlf&$=;mO(!5L3OPxcR^b|W1I3H?G-jC^9fOYV#WUeS2zmCL$4kB@g`u# z>T?Nfd6@-+{%gRZujaPJMxyt=>+eSz5InfKR9ARf%lODNpr`4cl(C_#)Jf4^!~NB@ z3pxAmY)~D!3%DMzc_{ywe*~Ea8AP+4LBr_f_8N}YPHMVO&V3)}h79jwd-*;UP#=&y}G<*co23#h>v?<@xZ6C28pSRV} znZNU)u9cRK>d_^yx7rqr#le5SX1`c+@3=!OJ6O(gHwpAJXz?mKac4J$=1OT4jC+X} zee3F=v{^6xV^VF>bg(P++Xh%aY}Vmms^RVI?Wd4AZ{0@-=>OPpI*yC(%Q<}EqipJN zy=XFCZupbu%>H?OHZyD(=r|#PXe8<(Sea98?$a$N?PWY#;6Q^NthoRB7yvxyuiu)m zel=g}vIP9Iy&C=?^XyJX(g7CkIWh3({vOl#bxBm&Ks9nRll}LgOZS=r-JeInp^UZi z;2Vw?3c$h|%s`VXY*PO*Lj>4}@sZYmHNP$J^9Sf50c{^5z5mreA>f$#f9OJCa{JYi za7ZE25SR{saZGCEAIjOH^&z^>nUo%0`OnCa!uaT-(aoM+N$masS+Rnay~(ugL)$sk zW5ptxIsXx74h4g#**F)t2#nAg)8WSzTdi4t4oiyOp$q*sLzt!|8C?!Vgn>GBQhN0m zI>5zz^y+{9FCg50k&Bt>`j3D6o#(%h_4gMUL2Qd!(huRHYg}!Z&Y1{wz-H$5OI%mQ zWpBmDOu?gNYh1>IWuXpa)1h4SmL>SE`EG2=y+&eXr(-QPg+hiu22Z*?BRTo|7VGrU zl{y%BK`pS$>6Q*2KhBcN)2c^IV%fLs_Eo6z3*`Al#{OZ=a4LO~d#l=`rdzfl&j)EDgSkP{tOCPMj0~D_o$H###|?zZS53uA)55z zDxwXVi6S&-`t)ct70VEF|85J?a7BcI4j4T=czz6&;!6jBz1jqV#g{BbtUMV*#QHHHWNQTzaBUTSJKL!e=5%K zBzKq1LR3#9W*pUOyhim~g#ZXfF zx&as{J6X@AZxZ&nGOuGIzaD_Df%KpgeNwmeYv-9y<#7K#*DeJ8#(@vTVapX*g}E1Y zPEJK^1-TN(^1zKAx>KIF)d?1lZcfaNUc3C74*2q%O=<8LcJau#nwmNk1_(<05hT%+ z+GGgcfk50fU>#JF<@^{3nJia4AKv+Mxng!Z$P17Hx!Yw zZd32<>Jl#()N*39)K8b~wFra8EVf1yJC)ASHWqSeGUQL z_gS+GF*qE-4%AMbgIP3~wO+K~y<`0Hi|Lp&G+8MnseHo;hF3tn_|mFP1V>{zsH39& z#JHgg2U~tz{pzphLQO3^K9UO#9W?Bfat9}8WbSssY!k)v6vd+@~u|LdvE6AZb9 z79mrhCd*_UAt?k>>hGf#oxe9r~+0+kW(5**)Z90I~b6)6yX#%=rLGV$Q zYQz`A7HbdC1Lr*!g}@ zNHWa*i-qD1K&be&vvv~3M5=~3ceuH#yw$)oQOW!-=JEm~5oh&!c{9N*D;yljAShN;XS|!j=*dlOeTz=Y;t#j=uk=2viQoi z&D5>_zvACsgl%*{^=?t z2;Pi9OvvWF^}m} zb$?7Z$79phVpsnmB&QxoYE3px2D+X=-|f$u-#EbZcn+{Pl?)qa!B}}e417k|qA*)8 zvw~sc+#H^;9hsEtk#t%F^!cl)s{=std}urtD0Yy~O8^^Z(%Cz1R=~!&r$-mqI0HQj z_&+wzK&@xA+U?I@D`ZCl*bbLJ#=u39xojDVX#SF5oJcL@0FTWsGxOcddyuXaoh6y; zn>#t;u-93U$`N}S$DD-ZP_Q8r`qFvpnWMp{u4S3rnyT>5!t1HN3rzoon0ug3gdyV% zp2lS`^NH~wpj_O4;4J&87uD()uMY5SUG>yy4#S1orERB62CsM+zAzyE^g4TaS=CW5 zxvG@qDbTds-oe5Q;eBg+sy_rVDvl8{r3k+t2fM(#Uy>>H43R0^U-=uk__<7xq~3b_ z2uXdvW8ZnM+}gm%-Q7dt5*&}xeP3p7{W*YbL}t2<_;7Itr7;1MAT?_%4$kHh)z_%F|AV@j9$VgZ2ee@Qxge;;QqGd3Ho_?S4uIr!+`=UVGwn15SU4Q?bR@BO#H zt2ICK12b5p7duEa=S0WL%Il zRz_;HDbG~ORJH32$#1#Jbj0UDRWFpNc7dN{G2y~LyjE-2Zg{q=^m8(dcKdtA+a|k6 zA>INigg8RsOY&rIvlGy2p6Sesy2dTvWJgEc(#UnZWH7bNPI5L`GMketf8qHIZU6FM zGZ6A%M_kn0o?NgValSWJY_GmrJzsUnKD4frMWy_`$7+IYjb_Zv#_EtmO{F8Aajc5MywxBoWVEPI za=X@m+@Ql`!<%Jj?KDl-XfPy+;mx;*%X6pnc#u?p;z7a+um<0eiQWEwh`sE4F=3m! zf*gZ@5-K5zP=N19kR91K6CVatwequ|NYb{LC|}WPu|Foc4L$L3qo7FR5mD9JWh=&; z_^PfNb*gI1%XD~5hto0P$!uVr!Z2_CWTtrb)nt@fjbq_kX_NaD-Lk=>ka~`cevcqf zookC2UT67$(jGEwXRQpZ{Axz=B)g60p2RU%+8f1oMqN<7(^GHrZe1#on zVe2-0=oFC)EmN`xTt2jS#=Eqp@Q#6qUu9U!%FvW{KMnI}Mu6_6`nTYRj)1@t+OQor zy7_1rXOEUaOr=-}HqY7NO_E^3ML3Q;CnR(f%M342xSBK&wd%9-myt85Y)*~EU~=4d zCs{iy2KUU>*_izEHBe>&M$IZ{fe=LX1L;!{6+Ap zp?sEoE8zUUb=Os=(9hXcp>L`PA)Z%OHUQqU+Z(?k{iO`}R%0%kyytoFig|WjQ!%#_ zO(s}bgOXBX6jL|cWu)Li%|&}MTHqKwa$nlN3PIi5@c{1DKLnFX_V)I?KidF%Yb>1x zGItBD@R>scTU&9Cpys>=zXpu0(KLhcJ7)4G6VCxDY1*0vO??K8aE3kr`6Z+LWYG8j z_`!%7ZA`?}A68eoMCNzKXk-WJY7!<1>4duH@Mw%D-daV46*RTT%@6H&5WP8M4&4CZ^-bIwc zO5n`TPR}&gl`&te@EW}&i_>^SN!`x0gzP*xFfp0BakS)A_cC;-LURMc3%PR#C9=nM z)vBN)KdX`&3AbirTPbrvW8{Gpk?0fmZs+6+Pm?BC4ndF_-*7&AbML4H&_pb-IK#D>M{f*UW5&x= z8!O5=5pikfK{Ue+0VM%|pJ;uN?D=J4&2ecv<;m`>6m}lR3PkWYZjKgPN0?f0rcg|u z3?5BX4d2%&^&wyhe}RDi4KRzo_k?&gPRiM{)``Doo6_=+Lczkv$u@oHa%j{Hn9rvv(HF*yA z8L;1%jrF(JPx~&sS`CQWH#LSs^M-WXW({o$}`9_nH!h#Ep?fvtyntN z7eYex4u66x$@lLrvRM?`4wr=vgQCUvh{ubqzZP}?)>EB0wqzxh10IAGk38C8mMuXF zy*Qi4R?v4(Aq*VffLTnv@rzlUQx1B*&$cwbN!)pQZ{7ANBY}NW^X}|SYqVEf{^P>H z+8?Nk!tbjd*B&&SF}|>%u?9(GXu{UD0n#MA)U-dH^SwD10r%f^8NXxvP4;@EBmV}@M_t2O6EWsE3o~kN&foqO z?WApI;Y5!MCd_7_fdO`f%j<}_7Z%+vxnChVdK{9Ym1`7MYsl--G2*sZ=Pj5Z6elHx zU6ffd+fl#@C}a)tS5xH=0Cmk(NPt>T*0D!7E8Jtw(BwhY*BFGR8ad^AGUON(W1Si9 zX4>xwKFv6%!XsaC>pSmq@t<5UZm7(anbVr^7LzGak{OV4dGE?FK!YUusc^<@eU$gx zE)#HbcP|E~*5*rgg}H-0hwfdNlU=Fm4a&>Zh?z7eAQ=6D^DE8g{jMYA5;N{WULS7_ zTIf_}%#C9kULJyaOS0E}b;>2r2VT2|hF*)(lh!9Fl9v9M*WCIpUTXefFhi3^*R#gx z?Y0x#ve*xXvDXl9k?0i?-Ax@?4-4AfA)hBGJurVUhR+T>i48jWKr)PVU4hq&7>d1pr@{$zO$L6pa`p z;@g61PhvH{(@zcHf2I% zXN27P>zwbrXbqvDpdk4pk*7;D9|K!u-#nlouW9&)FL0`n1Wqb`bSino0}_l=|D1ph zuN$-uoB&77O|^vcEH?Lb9^6Yf*Fy+Bu@C^~FC%xSG(#`z&RCI5Ck)1(r2Z^h(9n4V znTWA+M8)emf_pPZ5(bI1wtg#PaU^irsld+`R;6d-G5_ULtFT6nP^ZGCT*dT8R6EPZ zSEFzNxOnbJjm3V6!T z)+_5=X?BwC*&3t0%5(#d#)=gwpT>jvLlLwWnU(%uaN@(}5RXG&_}LwHsIt)6-OP9` z&`&LOpX**P9NX!X_?86(?ducInZKmfbplpF14!xj$A|$q(P`r&BoTz;!hkI@z6 zw87!D?O3)m`DSe%K>)^u7fZ9%dI-zl8YN=LPziZ_qOa}sag-UN(SwY5Og~zUV#-qr z4HS7cC0b-_Q6c1;R9O49=~Xz#*vVTPdxWox{T(s(k;2-BOIt$^`ghma0b9-xg@djV zDs4xlmPVaN__>+*C37i~93QD6m9nLgw(^kKWmf)6;Vf6?yYpH6*d^iCI($yC`N`i9 zs&5h;sXF4Rl$y;r?$@hT2yG<)en%=oTjo9zp-39-aH-Zm)7y(RR87&Xk8%-QmQ7$rf-&AZkd*W#mm>YMq9?y1Jk0QX-=<`kFaW z9@e05ZZI>eihk(zKBpaQfDL#Z>z^QN_Ny6J#<)bwQpMWFAmtgUlg*s%mOXorwyDYS za#go}1T5ci2L_e1S(NcJtE@E0+D=fg8;XC-M^bJ6TASahLd+@T4}j8JyycZ?d&%_= zBhK|AO#MzOY2P2}*)5Z^$qaYI=aZJJfSwpEILuV>{N_Rffk^Tic7)mE2WW794~*ar z%|1VIewJNdNiD%iRF2=@8DX(E>Y>1q#sB)d&klK-e1S&36jtliHL3-6F_+oytuM3@ zgB@df_+mxYL><+9|8w~esHr-5ReL-sv9X>eINMKcRrjF;605ZWGpoJzB4YP(&8fg7&?o2P*3j9xdG&hY`a71qEZTXM%Df!Or==dh6uQEqF{ z=AG3dZW^>Nn)XV6Y})fbnI;})=}6c_@c^B{afkK)UscP3=qqA8B60gkbRDNd_wM46 zWs5`ky^}{evnzX*ijo~xsZo^uGamlwGafG?^>Sxxdwlyp9J^plg0Af?MDZ19r?CCa?2G5K= zk-19zc!~_R?sEjy!|~RdR1dGe#gC43MFf8N(wH`ASzVTQO`;75Th^|uI;&mCdpqvG zxDxz`dBuv@vvP4E{%>lc{)eg*{?E1G8YNM2e^tt0e(ceCB)GVNA->sXp?A~w9ZyS5`4z>!7r^!$d{e6D*&?Fzy*G>+vX2 zY^kbu?t%CUd+p7^`~A`NoS#ptv}9+ocp!17AuqKA8*(N0)Vf2C^PTG&-&l3;D6Fcg zdPnRA1()x};Y=QeF~>lONl!u9l6fwQ4ruz6^ys_L)bzJy7DkDFCV8@pgqSu|_H^T6^YK0PvXz`h{A z7$}F>Q*TVg+UKTL$&qFNRr5sUM`>wFVYg%rt>VC;^nWm}rAK;^{drLPLn%OWngK2- zFbIZl-a9>U{kM9qSQQ*n^%}a|XlN|C17AGD^8iqj2IQV|zW}p;|ETZZTOKe~WX;OJyU}_q-y{>ZpML4=NKR=x5_K{D4OqqB(V*t;=gRU=}56u#a6=U7qa zOf*Q7bxt{MD^Dp71zdFgogfvce3o_4!*zNSyam76W<}t2n_aQ5Mrn;Ht`I3rr%A~A zmr2mPXs-`~Cw~sqhF)s}h%B4!RTz&H0YFn|hfK*?W!X-TbjzlJnEU*2L3i$dtK%$X% z6iD?^S19eX_=-GPU^oJrlixfmuB%ff=c9TCwff)nchq1Yi$FK9#WyF@y$|m`kTlh; zum#k2j|I^XUMl4)8VQRvG$*%t>|k-ND(R?0Ysw+85&ml^34vZ)CtKSE*H|H0&>U?q zJG|}cA1kC7A;M!ln3|eew_Hv<&oyaQ;gQ{*q&QaX4|}YMEYHt3g^YyoBLZyC-iu zb}PR;L;bPzg4yLM@-yP`%`Fp7JfUMhQ z7V8JKBBl}??`h8xi<@y3r%L1E0^CE@F+wzkT!#H*=#jfs{=Mck<^uo`<2}* zcbhq2xt32!VZiZajQ-pszYZJrAw|5@LiBCjmW=kqYU!&^NMB9;CUoR%yz?5Cr<62q zwAE9H16xjYiu;yWYZT~nmm&U*#p^e3bg8hzc13>Q;k0BC`(F}n?eoe4htUIXg4y{M zu42RPvMJGLV8u((V);cFBN(yB^>mTKQv6%+IHOQ%g{b3{@3|S*HnWwUjkJn2A1Yvx zrwEmqNZp7RhCm?8BZftq(aLQEbA5^Dq~4Gqv?g;7=jvvyIi%P?TtA>^=*;y86>vwx zl&52&*znO6Hp~YjSJ1oz>3;`MCC6h0nQwbyqogf3LtwNL3&;#xl5mh)BwxgKx_iOn zGR#TN#52rq>nWSfbDcrT%{6-^vx@_#a!mYm#w`DWc;Lznh9co)l-hMv+Y(lVe{Q=M zCZ`aev-+)ICvW4+(!PCC>Xgo9n}>qqi{!lRJv(CcNa>vHnj;y@2QNjy6Wk#dT12@$ zPillw`jS5B?VfyQabnGs3)Am2&xHW`53a9NxkI}ckoFO8pio)-q;@40eq!AVEb$tK zA<5J>eMG^*7PG(aPxeav-qZ@b2TI=UCj;P@?C+{1) z?+LM~-z-(mMCtlzt}d%2`I0$Z>@8Eq|CVY-DN>T{DoQkiB-4b}g6r$v)Q+gw<11iM zjR$|Q(C(wvY4a%|6%H0jNq}^^jX6E7^w6b{^&*)woyN;`p#GL9Ds%^BYfxvE{m`^pvh#3-b%s%wJC*iefR}dokN< z8+^Ck@g0kzy`Mq@O6|uFTz{+K)o>PsPy;WT>hj2xV*5Jlx*3;ic-=%V@!~eIZm<@x zBFu(q;i?yKSAmtur8ps*8X=CGS`N{;YY}kvL-(4~x{5J*-<+Q^Rmez_X=N4hamfio zUP~^Es==))DZX%|&IRkz6?5R<>nPXP$3Yi8WgBb0>}6`6c$+EV`m1~x70He|mEgog zppzE}T-~A&SU5eg0PK@Y1XkqnQq`25i>eV7FXaBUDFV%QS?0u{|ByJ%It(|Ewcc?t z+%=xC1{-F5GV?F*Thj##)={!&3}Svd+mqC0y!OAid+(^G)-8H8C~A%cMMaRRprUje zy(!H`Q+f-Ch)4;&Cm^B-NU$6Pqy#~V^xjDXq$pA&Es#V&2oNCD1QG)88lPPE2)~WozsZ=tHP-&x zI%|iiejwPSWmnM{guH3C=IFX&AiXNp*E@NVQ!MU* z-Cdznl{ddOv%Y9 zxWS6((w8qT*gvKSiyRBiRsF{vQt#GFRkjn_-Vll3kdQC>yu7@(K~TE1{l6Vtkp@VQ zAYM`#46|O=9stP1W7m60)hueA@l(Nrxx#g;y*jSN;dzpS=ZqQ}0$UH#w}RiyenE^q z{iL!dx4Dt@#nxALsV#%Pv-4v+VlPNmxNsH@B%38##6@K;OvZ~0u2IhS4Yk!C4ca1Ljn7zYrv_2y z^LZK&bwRGTJ>LG~vP07H0Iin3Yp*33YwGG+P>EUKR*_o4KVOptl;j2Yv-HDNK9WzY zfqP)YnNumhjo9})!4K|9%d_YDKnT8`;B=F$d^LPmz6g5VUI6XD9qHY4CvGu0zDg}cgUWRlH03mv;4s}O{Xtly< zjgd0QrxGGkxol4ui|Bg;?w#3uvI3akw!FVvbZ-bEG%yV?z)7Ee^!L}$E_f4;GZ03< z$7df@cecs>))m<}n;IUHw8LwhpOJK(=)qS=xfHVMAl}Bt%HIq2_cB~PME;AVIqX#J zF!W_vdI;z~3P7ITzL7g?1)SbGL4rr81JQ?TU}uG!z;x$GZ0mo1FRq&n%dz8e*#NJ5Bi434H?6>y4RTC7l(mi*N>oxN>scw4#F*$W|&}w4K zq+R|su+7?9pP-fR>S?SUODo0<8nOM_s#c^W8PvT~W#r^VjsAWFC9S`dbj$Ii(5C5m z1p=fTruSiz*h3A`b5|utTcLcrNIWG@lgXZ9xgOd82h$=#`#z5@J;{$Z70*Q;aZ)V3|O{vBE2J8oiOnX(sA6 zSVQBA0gu^)Tzg7xI=$C6$hbtkH1`%gOY&1bl-3|IRER|9=H#oF)2ul+3!DlcuhHh+ z&`&p~E5T`3@tw@sB;3i4!{m!yx-%O!^v1RCpTYt*9pzDfQ(ft?VtS^u%lI>pbJ z9d_b?jd*B*6L4U%U;@UXq;>6&zkLDVzv{l%s}?$AG^>Kkn`#5y#83XyMYnYIIY|cl6TheJ<5@Z64)uHGzDXo zhzG-KcCst(S8L>wqT|S-DC1LZFZM)doS!RTZA|>g6O+})3RUQ|A-56qmZz(`waA7~ z?DoVEkWXnQ&!#qB73b!do;A8h3!XN!^VrkYOnN(Y;p1ko=DOv`VEqHKIa!PRy-IS4 zY3i>uixt(qcf*n^E$@CDp*gOM6>^})UF4r92yx8&msHIi_yQUm>zZEsVU_i>=x|Bu zZ*6bC8wirs=PW7ASCs>B{K<3c(i=?Ojmregach0N%e?u?-G!H)& zfAGIKxR7?v{}=A$J0{lcX-!Rr|16T_gMd10=u+f;Kl_sY~y$&w=Uo+^AP~Mg7lbWrM(LKcDqhKOdOV?>(^7 zcm2ouIG+r_H$$x!AkClsaX|@CuTw51$2_0m+T_qaaRMKJ-gqhC=Irxi`i{tdOJzgL zYZh0E?R@8O`qmI$$C_iJE@q2C23Or#vUm%%%D1BW|C7S~2UC0n6jM+&5k2f4*16S@ z9{8F6LF1MLuKPou-irRFmca*Jb@%N5JceX1z0?%tfWFZ4J4$Kg9mdchvdf_^^xfEH%_fCK=mD&_A4Vi2sqpS6<^6S4NJnR1pLaT`ZmYk>Z zE^KZVzk10-^|nm0KOUFny)XxeM&9O)v4C0S5m%(k|aUd-j;fE(muVCT13_ z*#nLL?tk5M>b|^kTf*XEsw6~~G%!7}wLN$rqDZQAWfiyrAza{X%0oqnHPHR+$QO-` zjLhupJ_rB>D2IUJ13sW*tgThty*CVLxkB#tL+^+bT`^NmiLN76BQ)0$7a0j4Qf$Bq zZVvGA@g8j7yTfg#v=W_gZKE#e4(YNdtN-WK2(`NOZ+7^C_KM7*loQ)Q8{1Y7pE`jNiYE8&P1uN=7J5q` zOabop+~8qc_89vR=wD`hO)V8U1cw|c6y(@m`rbETS^842h3lWvKhL!4CIR;@U2$OZ z3uuDto*;2KcPgsvd2Tvo3SwSt`-JW3WNpdzRr@h=ajC7)U~B^pSwXLRKKF4E_&Q3J z?`EnMqgBgxScP2=;{eaSxqEhQgwZ;+u6!t=GsQ2j4tZDWwDE1g+Gsz^IwX^~To&>X z)YPBVze-PkuPQI+FAZV#zHKn?w&gN$0v+azC^`?VBUdNRPU!8m+u>e= zl%sojWyMwI%GImGU`XAtYwe}X|3NO_F_M2m2L7j>Xf*&4VQK-=9pu^^=w~{rZjiG9 zdnf62&CwWRUF+@ipoNfan(yA>w6TK#FJLHA+^_at8ml}F(X$Wy^Ur)@FMeRa?jVf64^>XvbIvy?{H%qQ_c6rW z^Rk|THh)=f^gUd*f0s$0y)wbtlcStDbn`&m(wHD!wM2EMjyD#yLNNc>)=Tl)^4RQXkhAE zXpt8_GGZYYJy3UtCm;QMWU5!JO69sXlg3JSQfk`b)GHD~)3XiL;zsVO#`C)7jXDOT z#NgFNeFloKy$Xa!m}Si5${DI<#)_{zg)P)xOWEz}wo>)R>zG623nUp;)|c06hyxJY zbj?tOt$|3$3L|LY7Pf8<*K8x3`sOwKcy#sy&9hqI$#0wg3oDMSp@zghC>gJNTa%48 zuZR0eifT@8gwd_cg5|szGjLrc-_0v*kp5yYf>{_M!1aM*?a{(DBL|u$xD7HIw#ncB z5z8b{GXWzh!yiVyj5bdN_QG+P)lBKD$y?8)gFpDYUQvOI<30LriXN&dkO`vArAMs+ z!{{hF;F{d$oZA`J7WLs43Y02-$$ndQ@q)2BNM*uWhKAo_?2~8QFspiBUtf-sM`FJ9 zTrBTIqk({WNCu*Um6g2SzhYs@yq;DlIR9`QsjH2uy!trk`!VBc5vw}S$#3_u+|o8y5<=-{ zS(Q!qUQ*|->Kej)^og|tl&Q&Yh!pCN>6{q2kw85LmaZ1DzwPi@I*hfHM;(;c-bm14 zD#^J_C!KJOl#T0-Od!rFe`lWT@at!&p?BAWd|L@$Ug{d*b3-?ki0u+zg67p!9N;!* zm#=aOknmxxN3_H%DmimsNq-D$z;*jOFRgbozRBq$L!9yJw&c*_=}aNB-@BBGBz=fg z7--`ck)8?C__8lH&{PMv=;k7gxgu8OQ#{{LN6WnXU1%hFUc$`Zi9|Emo)MyVa&rf! zOO^<&%?FG}7C5bBMKD@hlnvLispNa)Rm2jP-&a4PU$y!${;CU+RL(o39Fob zYRb*sfU!^_P988Xv9fVw(Y}pDNiTn6@sIa9t@ZC7Pq$L78ejiW&sQ z*fAbvwNd?h3(bDZlka+&YBzI}yK39q@!0^#a3Np7j3y~{D={ZtA<@39JV z9wdygUb*E}MHv;K@M3j^&o{ zPm4l+f%#~sDgrWS={9HM&p0?cq=T`za&WJol97k|v`9~dxUFW{??WQc}!_d4476~Od!b)zd=WJNw( z3ROzsP58E3`t$acUqGT3-NMHyOvOs>0xRMfi1n(D>#zZ7Fyl274mxLt+H|m*SgoWL zWE*pIkW+r^KD=T%xNwRUJy}$QnC?e6Z}_8sj7Z0HJ$XphbP?_9eTd-g&@yZ2-8kR? zPgcBa9f%jjJ@YEOiM{0NruYU)Y`Cma%Umd5s*%o)=a|#Cm&Ck?v~?`>=Vv1x;l3z5 zG$N0Z_x^~qb4&_O1h?39yw>ogj7Eo%bXcqnaXYm^5yPm z7`6O#iFTIZQ@np)FY(Cyapc~gW=5k&h`+k~*OMkSOktMwvDkQJNj6AMm^QdA+6P_6 zKN%$n1)OJtysYs2fF*rGkW66`qFWp_^whe&!TP^rb%v|pFy3#e4Rz6w)vuDAMx*!=FO`nF4vl0SYm>}!O0(>I09VCH;P zHkeP29Wr?t>#F%mA*>6%`Ad}fF47}pJtD-6JvGBMWQ!bPb9^BNZExMX#e<87g?6k5lyL3L>wRM`z0oPgdn!dr`)}7bVtcC@CMIB4WVD zg%UZ#`{9T781BeeKabGZh?SGP^syp2qI+|w`VMwH#EnGdHeb{{CA;ejCx=w7$lw4U z);7X2u9|qYVogVBkbv-m+13rKYMrmf#C9Ow<(<<(YkgL3ngOS|=u+O0aZO#NsiOC} znbfYi0A@*-nXKuolxZ~zxjB&pmjcdlnsuJA_}F{Fi@|fXlqjFp5-Wjg%f$WtF|^B- z$L^zw+<$K)AdWWak@s0zDO}~dNekd(AkB6aw4}_l}de1qA5w|nv$}*b+JBE)a(^fZ~#MkL{)a` z^z^#$xh=_;(jMPnUtBh)o(tRZ8VB%wg+W@de)KJZHEY+0rIs#ZuqmvR1L#3gEW&vR?av5JQS;25G(RKlBxomsY-!fYAc`4z|K||NS`+*Rt*eP(GRG% zRo>qke_dXo8f*XFHDI$B{ej$9D(xGND2P*?xzWa3Uo!yY4=v$hc$gbEJk~@jP z4l&pham*Iw4BMch*XW~wci*bPaY@H8N?FI`lLn*8D?6m*M}_~l-G zlQf-0Jze!0i%#^Jonwa>X($A50u9h=Om`d_UN>J^4iX{E66Yn<{VEu1)->8d#Cvs+eZLYuvz5 z)17YJJAhdmzzfbxi}4QPQ#HC`@Pb@#{>X1UdJnIR;5qiBNn@&E5XA_O43)L{sEOlt zV^&|~Bf>KzYk2+2?Bipb3HJP#;OFqi=B4R3=lsg0@h9!(q3-c?IAIX;?u3oEA#Bgg zn2QKwqPnlz^^llt8_n~lVpspNa*aNsaT zpINb%jFG<2i-;@(Nvu}8oYboyK@E&fjonRY9;eS>hBgQeXsPQ(q=3y5HZG5hW!TO( zl_Vkap@-0l6jPn@hMZ|;-Jl%F#&qzWE;c9Xh#tE;eU5N7ZQRF2oFVF#EXH`u-oO8F zRcCYWrLs(a!tkkAd~I-$eza|RJn(SoU>#>3WpT6pe5AvorLJ!cj3=~`Zgeu-C!X># zSXTLEzTeY5{qpOOi9=OT^+D51FI-#p)X!A7#x-{bq;MXO_0>LwF~nX`G%;5mm+VJ5 z8NDt%{JT!E__kEQCLe_VD6G6C#>V5Gb-Ud8Pg@5eMvY_a2@`wggRLLg&+~?1Lm;6` zGG(jfxOKfVTMsX66M9WQKb-m?>ZRq86i84}`3;hj)!vGjy4*o&O))a^Sm;165DC)3 zn?YE5P>bheZjX8ROEi)(bAKZTil}Cr;7?PX`6$PcaAG93GTgIErcyw7ZnQlkR@H(% ztxfDB;>=4+NbTnNS6|QRl@{kUM#>)L+4frzRkt2z(Rc!W;>dgK@GE2px# z7#g6MRDQaWuj5OErnx1&pZah<$^&2=evhMJMB<7FLxOhodRGj}dSgAld0^rK9u^`o zK^{VezZ7vNJ=(q>xY{^Jq@kW|2K~5W=)@eb#2u(O+*DFM9GWr6oRZLc+w!ce=p+u} z9j>lnROxz7aDMnaL?E_yzrJd3HQv?AHZoe$!*Szl#Q_Ndffds2JLjJ@<{3m6d=C#K zU~vl5c$XkUhX86SLCccQwMg}#Wl^5BGJyy5Xi*o{m&=I)eKK%ZNcAR?kjhwT3cl}t$Kf-5ozySt)HvqfZRF9 z3BimINr!B7Xjvkc-=dHA zXK7>{@#Rp*Qmn~`hu^hpP5J0PoH!Ah`rR|sI|o8G@VrW}x>%J6UDOVhjc}*LJ?;8* zO)1IMulSfmSn*DdJ7G5{)0E#yyI^{-i;{pl^NMdiHbCSH;XH&OVURRvsam zwdmUh+nNmZI1I-dv8Pe;+#{R;&&xl?9!r7l2~L`)M>ovl31@z&y<=OKpea0`9s$Nw z#~areTgGdNWB$1E8mCf!+-iwyM?SIb{?+~E4;5Znowzo@BOD$$!{okdExXUgF-6g_ z$MxV@Qx3t$L6?K_PRIHN8D`3a5tg7u$Fdtf45*bw&!eUyIU{gWIZ(oR@6@>6Js`a+ zr)64Ma$easn%`w&xU@0s*w>Y!rW@odl-RxX^*qX9_3Nh!WX#|UE}N^!aPp`7FTP#E zi&mH$LwE*l{HoZ!-JeUOw%$c%tA$s;zK|=pe+vRJ=46}Nth&RVpcCk|p;KsWVRbAj zDL+Xi<{-pYLensLJ;*_ZGpwt{`kQ{+cezh(bif>ytF9{%fSikmULqlx^SfJhRHZ$W zI&(#IPHogu3dQ$VYrhDr|0UK9TW{iyl34*ZyG*=5f2PPO*3P4;6ULz|cfdD3#&M*r zujFLtg31PmoS|BV(FK2*NrT7XT8HH%lBz}|9w;YH?*Gzz*l#p|{Ava5op5^XmBygm z$G8_Fqsh%fhHj@Q4u z)ql}N;8!aot;Q-v{yK&eQvkk>=mG%J^qZC_ba1^F`DlfFIpof-moy>88bj8c;4a+C zov0k|T`oxpC?Fr#NIe09A_H~yzzG`*_M+LR6b}iyuTbtSRg2#ErL&JH!*I}UlvnW? zl%i=p>llyjY^YQ2I}vJcqb#Oss%*4&5|Wb+K91lw5u&(IJx)z}NL)!h5Z%@qLSE8| zc4jwJP=smV@6~biJ{k_b9BgIzBA^P)h^`1w`EUfnqsF9d3wP=|(!`IxMZXAOoZ1sm zdY<3NW-laxUpRu>I8{ej>{8ni(CeKMeo+sEdgF-jEH!#fHy7JX$|@JW2Wmv ziR+EWBpDv^Yzto(qgUIrbhRLi^k)Z{yLtfpP0bT)nCIcxXuY^Ow>+uE6TAe>Cj%;w zQFH}U%M%<9Mhhpr>TaUUl*w)|h3k9G<*Eo-Gd#jd!OE~Vq{v0nQiD2GX%79HqxBi#2K zBs4ebi1*e=R-zx43=Hh7IwB$78;o89xi93Tp@~{RNruSqE`1#IFLh#nyDx}gavQ#= z5Mp}t{?xd zlv((fjO*U~6nlm9T_w(>Ekp|iOr*md)1CU-9}aeLA~dX3>vcSAK??Kpr2a%rL$&La*()b@86RA7d~EbgD)%(Z-L4t33bItbN@skz)2CbuOOj2!FR=>Q z6OD)v5fr7rj+K&R__moM@umvFA7j7l3C+;ufVRZjHFZPOjSrE(T1_Tl(s!+Ps6-SN z%>~nXt|m5qiTR;5NiGyRVVrJ!mmQ)ljzPC;*`=SllR*t0zmVe6bO$U*7=mqd!E0-G(`U3o6x`zT8yODKTeV>CD3w8zGXtxe~}1`Ev$f z>xm{sHU!g{s(kY!)%*j3hieX}WA`2q>E>w`8|3Wiv|DRrxGasg%+N;Q(!1&$N?cT7 zQVZ6qbf=AA`GGGNt;!!6_QK6qjg5kAyeI`T_Jh`P-co*uS7pcsyjg~#H0xwGK zyG8Nw5vc6|FlUX-)nom>`qT#TP4}2VTDqd=ivub#P2H0RG&Bk_h_9zJ%qq>Vri-S^ zubwo8H7JtCgt%T(TDAoBp8bk^7pqnF?PfOpdE8;0e5J1V>Fj~B?h6b#)SB5dGFw{I zIQ-3~T-)0U?6q;0ZS(565H&W)opPvpTW9yvL&FoXY9^b>&Dt%xGa3?g*s4?4Mz5Su z%LDo3OOs8)QIe^qxw*Sro6+Ap7v2PZuDr!A67kKYu`dnHXk+-mbY349zp)l>?kBjw`T2RRXVW~s@%V~{ zJc>aQ9v}6sb#3nfXzVlSL9(i4>4Td9i0upwm>OhIqVoGpvtQbv7DP$Su&p z8kbloynI&O!wIV_Pi4`@cePLH2#V!)heQ1;rE7DYs_9>;u99K{Us_^;qLho<{G0F) zAGa2b3cnrVF@nx)zG^cvQpW6wzjN6!^{y2ZZ@RxkTZUXpuadKt?H+kFyyW6F98rj= zq<1l&P-5G9A{v62A+Ia#+b&o5rrcG=&*k~~tX3$6-S+sNC&5ra>9D`0x%FeP)9HJ(24s+^TRk1|()x5#NQhzn)qEIMbtmGo z-l@!dJc;O-A1=$zIb!X*6Om7o&0yhq8J^y$(xow+VMs8rC)&rc7n5^BV2sQo?2gEZ zDT|V@CB}}xv69AGXLt8V$2Qt8h~%HVHk4yi_h6<^*dYY)o9KCc{2zv(YA~GTnC$i9 zHM`LYu*>+|s!vx)TNv?gCWwl0L_n9d)vxH~@R)Mw3%QNhzp$N&!ZQYc=nX8_uBcAR zl4-{~+(t~ov+NTF@fPgqWkZ8JYN_Mg%L^7*9p(=7*&=PO5G;tGwKZ@hWDx8*ksKcR zBK7=j!nyZ#iWTX%Ut0DCB{$*s+O2!KE1~=Z{p8cbmd%X!2pyiRY*8>4N&uDcDhXX^ z5m)){de2JG?g=}(l(DS5uMg2r|Gg?JLrZ4WAYo+<;xc+jkXzNWy==}vD=8vMsmY!Z zKy{%6J#e%#NF1?r@|)mM*{Tobd?-=H&FxVc%q$sSvJrv>A`H2Lz%#^&QSPMWFlTi# z@Zr<_!xvT61SNKGrn5i!>jjEr<6ADaQ({jADJx#Z6XC4wvNE^C7%d0tPDY$~L_@R8>fPJPUEpf1j0p8SJUg zoZKWWR?B?IyAz1T*>PrUjgNlQORARa56l4<@V!dLs#yecpCHj&=$ez0_QAo2xmF;M zn1)SY<9JJTd!sv>QiR1oPw2=inA)L!F70GS!j7QTD^kwSMwal*2B>+6fsrkW-`U7V zO4_eOvg+WF$uB8*?M#+%JO4-|#Eau1*8wXSblEb$))l2b|p+D*R*I9oZA?U#OF#8~UGg7(0s`d{36P zq*oMW22>oo^e)B#eo$d9F+SEuJ9EO?o@ZCgW1QjH8Yp!@?-!q2eaR#G%?j9?buFiX zsV?HJ_7bbnYe%llJ{sI4iZv)4S4|aOEJ;H(JqQ3pvf%xXx5`xk~ zz1L@tZ?4YX$>TgzVtpmSJi2Kr11TEKRcpk?r4oF^*TS1TM^L`n5S8mo9>|s>_pzq^ zsz&yK>|agksSzHkQzZ0q$xDxz+>GXu@g8eZh`G@Ll#bP8+iR*nE!(N%oenPFyi@!m z9QnW=)a}v29@jnw8=jHM(d4Pv^00`=_+uwkEj4QNFN?4v+~EDc>bt7XALowY-IJzq z2Rp}#j#~66&#uePhE0wwJ@(32Yg>x8%UNsFDy+tB=Y}9$NiF$iZGMj_fxliy^ckCm zTroDDb2j=tpBg)a1vRSvWqk#QJG**HQsn6w+rG-oJG(;dtyvR4*&wKgdE2Y8xJNBM z@{rIH@97{kRsz%U-e)!$!`DAihgp}hKW|7-cz*sI?kqExknK-lPpNnwT06eqz#ZUJKGj0HiLvqD=K^1I5 zsB~n}SgB#-##{8}1H(PF$Qu3N@ngDG+$Vc@mOTTkpFT^-V0a(wZMC|fxEyV~U)2%R zxu{d$t7KJQNx`@0ub3<=PF!5IKf9~R<1?_CxEn6-_82#k{cc8F^3m?(N_-@G z(2(PY_GkH~ZhPw)xqaax>Z3#=v4~L9Y3Q`^j40z55OTkUALNpKt_>6Lg0&WrB-xlC zh@ymLWq#3o4XF$|ROotCQ%Ci%%2 zHf?2bi8I1}yfsfAx&2t^Bc~bqvZL*d%ZyXIMh)2%yD}LhbnupbYp5K*)5dt~qxk4( zVO$Vg3~yR@zHct<7I~0*;rYYy``>M0!jtnYTFKSW%Rgw(`C227tWI_EMIX@kpyc*w zwqQVci2I%g+cYrEg8VX5uchALWYV~+Smsz?@X5-7!-l%{9HRM#}$e;2;Ne{Hr`{!NFLTjeY&sKdz-^RBh` zd!pn_egIq_m!*F@A|ty_l=br~^~CAvrK3-Nc=E_Km5%7}SfPA=P1+ALAj|$Q<|k!b z!N6U3+}MX-m8b`aOGg@q=WeW-E93}{jS`vaTb2 z#@EPdL8`jp+l&ys!jL=L&1zk@?JtO_>bT2>wZ&*UOoKYpm?=m8XeK2cPfUbVsi2Uc+Mi(ua43Q9<2VZoi@s4D$)Y}F znQ7^~Qu8F#-qM+(Fr%@}z+=!87u}K4|9)r4BTWeJ^I~WE!jm?waHp-ysVaT?kO{M zLwbhNy=l%X4Yr3qPA}P}o*Gc2wC3$6`sZ=xypdylOTsfpnHr>QGSwEgHb!GfD%$8d%V$J z6a69v0;4CPfBq8kxwwA$oA^xig?r&?Cx2E?pv1dk7UFGgO>_8=VHz3wV#bo zXVyR6i~cMgF3+8n>VDnBZFSW z^X|67iR?QU#ME@w6}nMXX_vs{8(XUwddh!vUHq0i z({TP;|8<@(KRJb2SO0wDz5M-N6uo;s_#6w|>F54>IV4p3+!Y7{G@5Ymdg>bt8gLNm z`nFiiJ};%zoBw$kWG(CCZ{D+?%J#lYlB2r*_MhLRy%xF4#tgJvP3TI>bd<+N|Na3 z>kCL_ub^se9xo=O|HgWjS<3q8Q5mw0xnhD;cux5yY{ow}z&O1RMgE@n79bNTcy7sC zZMA?IRmNjsX?c%Jvt0$x*H-j8r&s`II_iDzuj7KG@wPVXTHT$pErM8`K;Htci~13i z9ReE&qq45I^4tLLrF`cMqqxMQj;tP%8+`@XMvI(zU-;v)LsoVR-FW+T&O$Bx?ZfXk zjE@+XXLJ@Fx`@y#C`y`{o<9G#ZF4SS=PK5uDD6!V6;o0J-Qcv9FRQEx1GYdJ!`~RO zCWR729Wz<6{X&Ii-7h_$M|5rkl~Yo1d@ibQ(hD*O3uAK>e?Hd{4l>W3FH3(#D@elm z2{3PU>ifMu^OY_)c~^?xmMJ59@@Yb=c~UP)$xH zZe^tNuF?vHG9!;)8{}7wQFmviYawvo*nU9uLOj-H*^C42H_XmGhsx_LRtz)vB15Tf z@CMRN_2O=|pP+40Fiibesa$^+%sk0Px{_Oiy=E}q90w0J566}@Uhi#9B8u+?oj81)Elp-0Gt|N?!y57=&%|b#Qj@ub=5^;2dWKD^Mnvg3e)i>Sg7Z|4p z`0Q5ew*os+Fycej->$v<`&m$j=iS>ZGfap8lx0 z2hlgV8^}TLAVK8Ln z6pCZt_qS4K3i(TEBqzbtC@3wSfTK5JdE-65cnJhtxHQ_)+>Ss(MQ znKKO4^EligA~#1FD+_bGm(k-RC=?fJkKjz0pL2zAbhpaBmkjcARTJUiUpbhqA_B6G z-P&3t%@^{OriBE_99j&TDGd=KCeT5!tK~jHz+)ntV)CUxq)=NDb zPZD@)1}ltGx$B{-g^k(p_$!LNSKM0v1nk{4ngM(rf9~nieTN60o8mg8u~zl_{@CS# zAkBHIhgK*%3%URePzAYEF*Zy=^+r@+Du2)Pxu3O`MmL&3uZ)~t< z_{=GR^l`Wehq&BjV5T;HbywlVD+j)6Cq*r~y0%kXCAdv=U*EQbvQ32wC+e9(kKXVF6J3@Kz4ub z3ATO^EV6XLc`V$}f+z-gyn0*@?v-Ngp^ExDmlclltq?uPDH>gBb;r{?DI}E}i@x=m z&&bk+HrC}KExK}Fx> zXSvDSY-;b6M8XsZ8x z&QMe+4NNIQ+XT+Q!qc8)G*z>wHRDFmV$Y%C+ext@k3X+g`fUd)df-=A^^>r1J0;6S zsod*2G9aX3^2QXm$B0G!Qv&t1t2eYh2Y?&x2DXAPG)-8z}G0# zBRwEn*#Wn!ll}fYr_ZOsuuX&Y4cow(%=o^>r~7aZj?T|4EEHBOO=8k~%WJZ;mDr33 z)qB#)a`5~b8Dc%0-l)@AI_n!C>(U)uS)~cZ1gvk|h;*N|9UHakR}j-Xd^W;j!Uqfm zcRI6NDynOn`3p*JJu)^m9lzn~I`fT2!vJSZ2EC;bP`jq!7xwp^p&x4j3)=ZGk6^r=FgOrM{1U)9`HJzlx z{QeGoMxXZb6kKph?gXsN=0z{_J3H}g59=Zf8~t}4 zsLt$Gjt7rEaJvF#am}|-nw}jw{D z6%Y{6a+tQ-*m&O(&77KESgfd)cz06u8<_@T|l{iaw{uKybrT}Z268{*Pr3V~pj=im>HUXLbYCf~S{gyV{ssdpZsg}#f zJj*$P2w=jq#Wu7r{1}n=7&IAYdZLIkVPSUmmPTYT#efYD?JmU9iK}YZjm~so_jk>> z-ghn0d_wV1Qdgm*5F37sy);5(fG%X3e*>hNuDrP@8E>~BUwNkDQJqp@-??r=16Lt8 zz^b***9W_4%CnyH!Ch)MvDmTa_Zj2Pg--G+bo&It8BH)7;$MqD^};=rv<(co5og3z zgb7UZPf=W3XE*Sf5DXQ}EnU$ppxiJ685O60OsKPwkyJ#G{V3o?CV*5`3~2)rY%u8d zyt?HHu1u`ih{+3swX6uCg`^W>L;SB|WIV%DI)slmeDp&Yz78<8hA3{=FQ!NL-S{HA zQVJ}6Rzl~sGso)*o=|1lReHw_dIe8aklzw7if5Mp}qTBSzt4x<0uuWp=Ioc|xDtbrU_$2l_4k%=;t;?ra0gSS7bn z`=^izx5vDWB>cKM_FE@NGG}U1y0glr8eDrOQ%QA74I4j(S*MrExhnlTjFKz=0dGrw%_DA+F*0j$~Sn0}FvW6!+_DR$| z?#~sz!rwT|B}XtaI)+?&#?6rAYy5e&il}HiTt~$y5rBF?i$~#keM+pgpEd8dZ*k($ zNBmtf>In=?&f%~kCVsH5zO0Ma8zyp|@CfGhQz=)EZ0k`HW3jCzq}?u$7ea8hWfjGO z3?CXAo9)o?ZtYo*Jri^?8$7x+`xh%KE7>}%gQ^9@K z5AlB($O-2E(C>NUYFHiTmri4Zj&Rs_!?9{rb-w#SEvtLkhWKB0jg6f2xi${%A`&cX zYHh+_+i!xscY)?`CY^U_Ewcw6P*>9l`hYa!9KmQrXGg27$Q3K14Usg;9g`JX1-;*P z1aWc%g&0!Ne58OMl~7K}j-@64r})VUn;?4P6N@r`TMn`D)p{I6FQW6&^<$CZDkdej zx|@wT^0W=EUT^FyudK`(alMmp29IzGY^P8H=7GoVA_Trp(SYRW)1F+<&j) z)9#zB5mC1k6N46L00wKS40 zPW&2Qd&Z4S%>9@Tn9mgwudKVKBAgMPn^8Au=8YFuK`t!bUP5LAi=XOGqn_X@1RvxQWTx_N@DFoT(O`< zfMb6d`&$v@P+RT1n984lw&#l_Ipu9Fj1gZ>L8Enb`l!LH$D9^s9A#55UTylgVRGmm=uJa0YT{%Ks{HCwPdOT}C%LRNJsv9_-6p0V{} zDm`=00cl;3n^5IculImhe$|z@YH)u%PHk{Jd1QHBC#kE>cFe)4Q7b;HGgjG13CV{m zk=PP=zx7-$O9&Zq`~982K}F|2bQIQ$SzGW4Ll7Ngq_pli&Id84cZIlaZFfdA=Qi0@csX#1*ZV za(Dw@^p?IMn=*2!!E$jf9v6jP`^k z{7ZlfM+u2f;Nkon`=?IdA0kJIIek+A;@^ zzP%mX<<0Wfw~9j=wmy`sOK9 z&Th2z#SlX~EZuLseg*${qDzA4NB7PS+FmI1{^&Z#s(N(EIPAoKaKQxj+~dfubYAcG z=0WrSS9{+X*5ulR8I>bgF*Z<|!cmTeCcOofrXqp@f^=u*A&MwXf*zzu@4Xm$ z$3hVVfu?tS);^ric5w!J)hdlg$cihMWSj$Qqbmi@@VomuH2Gl(#LZlEcaQ<# zY%&&Vipb1(`3CPFY{{}J#YZzNb!IkvF{OG*v*qtP8LMP7qgt}*hV1lz ztiHyzvLx%;lw7XjCiV|M0g&MqmC{j+&7Q~@06M8eD##AwD}34hNb{!xB9E405-OA9 zT=S70J!_K={}5>T;a_j0kBMRIaA%%VMf3IRMqFKV|FAD`Qg(I?7wD65j|kO z{bn;E_RfKh%$9n?>B$!yM>P%BDsKwG5S!q}hawfgr2xuAR>e^SODSYxT~WZyxHc;9 zR*T2@6HLFxL6-=P!X1s`k8M;ZkPuzA_DltK+|Rk_Sq-wdMnh?Z!s=m&qapD%OAXJHT1+H>VdD0o7{*q zqy8_848s>@ymLJ}9u5>or5ew?S3iR_HH&N&`Mdx?7i=r%I`^hbITI9zLi(~p25$r$wXT5*wi`UARQ8q-6ZySApfd(I^aDA# zudJ3)?|)4g;yDtwNO{D`5w5m=r;)4?Gr$ugV#rK*El7>Oq|`#D;m|DKHPC7j^TRAT zDd&|r5vo9^ZGNOCujnW>&hJ-8li#a;vRt7KbCHp=f859+Kf%WZ*6iX9T4wA8RtdNP zxx}!G-kKcV09M{~=u}o`B3|swh!;^A-a9uN>R!9CXC9SgvLs)L_U{Z^p_5S2rf&}$ z1|<8Kd&Ao|n5RN^c=fxZWl}W3Ik}3~(gd)^IQ6K7WgCcb|$_`2iT9JUk>HQeR)zrZc%K!ssfm;FBh8c&;-fk8ihJp6%Jv1qSu9o>*=sxvVgRA|*=>M*5Kr{W z5YpDb!5DaIbHtP5p9m{vZ9Sl!>Wv{5;O$yO37X3jrcC+9T9;#L0WYk5)7rj9m<99- z#qd5~7H()LfR`=6@ti3Brus>7H_5vf8qBHI^>2m1;2`W6X@#DJcxMndLCjw-^1FL$q1zE8Y&gw_a;kc1>GAM7qq`P^Y}NW})KAC8M!xh-EuM`REPBV`DB zZMhU`trSY+J}SH0l*Xc|4h>Ru706}|aC6Xx_NUhutz6CvS)osN6VPcgy{xdTMfjn-`|0yNHBUOO>G)XLjLTdjPk!B5RW)4b2~^LH50gOn=Yq z@>(#Te-k=~iCsi^0Q(7jhAjDLFIR-zctLUd9b#RQLDvY8D-4^PJEW|6Ljjg`I{0zX zT3_GEp2Tmz!ytFz3ZHuuEG&q!^#j%C<64jV?KFpoM(h!Pzj201Xdg$jC?Hlhz@cpG zU(2)@7Zv+LbMx-rmCdwp{`9h`QiOP)=~b*iu2nrtFD%T`FqJ?Shf>4?SBF$Q4Za+) zWBVvHdRlO$?dQqJn6&H6oRUduLd|0kKW`}_mGo*iz`8grHwk+BQKFtWMhGrTIpYKR zbttaJ2nnzR>##-}L;a)S?$Uo>as>kk6tzoRPx>9!+uHR&BAS~CmJb3tL}w4hYrpz1 zU=)1uBTvJ?fQd4_*p1*t3BA|}Wo!RLa@GA{&URDG6ZaBs?)0)JO~lAYD)j zc8Fk}Kg%rh;?GBkMm2Q^xKk|ZOv0x*b3l15vL(r(W>k;8CxoFp!i3!*3GR=vj@c1uNd4k7&7}~ zGxIOGlYlpr_tbHcp1K&1dwmBs`H)y|alD1`REi{GlRoX_Ig!pl-hiAQxkUoHJh8Z-yyAU|;zX_Cp7;Ik`1R~@-`tkYr z^2$V3X0F^)8a$$?^k!=6*SK7Zcbi|C%(5e6Vv zAOCyeR$2;y{QGeJwVWL)a{sQ5e_hAFeswFX{Ocb74Xpp`$wTvx2ovKS=+&zyK-Xg1 zmh_u{SS$=hL*zWXc6`Yob-PORZ#ahhU$8d`Y3&d1C3S9sy~QO0nOGsVOm*ABSpx{J z#`!-X%5iN;0B6w2S^$bF!(417XKq%pw%;^lz{N>^zP+y-dbB4| z43IF~28ShINu$|P1OW206ulvK6f!Zfh0fn7-VKPhG&|D-+U zEkB|3f&+QxmZmXihDo%)mlOsfa+lmaJWhUj`U}Qa2>6Wpe}rN5s1K-S*=>HC+H;c? zWrt?tN&SM`W_vaAwzlTgfTXnFl&5Nz3Sx>;XQzNR_{m+p9P5Wq>>;2>-U8}bZk4%5c^v_Dh$JU`-GCTsaZ6=<+e;o@T*HFY2|@y~(r11JH^Ovl}(D#9i- zc*LSEjRR)$A2e}?#RlE2LlYK7@SU0SqmXWOA<@f4#{z{fN(XXWC|VyTh0qgtWYXnJ zY`6@Pxy*f+9{!Omq+2%=WwK)_zb?A~=j}-Fd7|L3aOw~ksw-2#A)VB-R!((-v2v_} z=+L101`3%+^w_MzJg}!nvNoOZQr69{rTCkzM!Z%zE=B`jiPhRo;f&T%XPSHIx8^H> z_y(-Ky^oYe&G4A$fq(7=qI+(_c-LAyrPE14!*vCA< zN0tHBSC@LN)T<*_`UP!Uefy&tcOQg0e7+|sE?C#?WMs0=ojrTHY&v{QV(kUjy@3wm zVQzuv074j@d1+_}zx*vK?62FWuMKjPgC$UAghah0#?+5z#)TZA0CD>68)|YgfsWR7 zP!BU>JDYq-0;eohu{Tv4tFzq3Tme`Jp1mPyObMO3l(h@JOrQeCSa#ygs_o!-$!P#x zYaUz*UUV~;F?{Q2QTmq%0Bx7n_D*t~@2kn_E$q*FTka*t;2}M-M^=(ds{~Tf)-`~j z{?GC8k(47~_({sD(Kd&R+CFGj(S#AewM|HvEl)zCHiVr(Q$71?pd= z2i++hvRv2tOx5KERg9X(m+n#TI;~Uk8S!U!y$-( zmuX2JWP)t`XB>9K2v#9q9-VhVQ=pH1G@7=zTD4U6 zm0UU0QnbPgtg=11@|u-Ze&~sqsOW?~lbecbE`NP*Rw*o#pve!eXqHjj1Ut8U8|2v- zcLmT{lhipytHqH=5?C%1dp2<`$5n7>dJ(6>M#`=4*2kVDO8R&hmp=WTOo>OcR<*Oh zt*Kd3OEN-gmZ|}wX*N#ydMyCMCC}U#@t!SE7nconZ~WlBJY$sreHHd*?#4x0VNL;M z6M4hYHc8XC>LWFuCp(cIu~$F8kZb5tg{*%Ecp|C*8`Fe?{)u50kXwMt8qCQ3n@EvU z+9pSzKV^*LV$tj&xxm%38Ue_6iPQc zoI_efl*(h&%j+sSyxw!q?CGi+bYrp{i-w3z0xV}^06cm_Qm3Nq$X-WK#GYhh>3^&P z^6`-bFpsw@fj$krhTpwdHE1DbGeEh#{d-SY8uV#v_0*YE&qc8te1smyLOGm&8rnLvGgNP6;3gF`;b;XFoF2++zHCx_a0 zO&J+dk+m|jlzw8rSa+pCthFJCR{pR`Hf2S>tOdtX+XO+#&^8moMVsdh9 zP^Er#Qc$1g8wwzy1uy#y%~x>Y1-VfGu!4F>iji&B+;GF3;pIUAqpmotpd;^Qz7YNN zn{FE;mZ+&1h&XuX;vXRt#=NS&G66teZ0%$mxLbs!^3L!*pX6}S9IYy-h^1S)##tz} zU-i~+1ped${7tg*KW_yX+7)1EixKlGw@C3mtTz-h*Xa}R%EY17+p(`->7l%`Kg>OS z{U-;{wIS29@}F^4%1f}o{{#( ze9Ql$HX$X~26rmmmHwv1hEV%zB5lwkAUSPQoSdH0g_uV<>gg{#9)H#ef|Kny%3(&? zMnOTLTUJ4l@tKURleX*K>eWM@wc6i0t$DX#6*#cN61bA->3N-u%^WS&imFS2RZMI+ zkru4;3(0?kqiwjK9Ellwb?s{Mpe<_mr>v;!4#z4!dJhdBGIl~9tN9e8EoHZ=N#p$S zR5nk$$f0;8T;^^G{G3)vRb7E|4czXUomIhoyPh$B!HWeMU-54gUQD$^5G0DVU*MPb zh{>Y&pJ zZ5ftaB4ggE|Vs3z)AFW;=Dh)VRD*v8-LlTBmD3}*$4td$xu(sxY(?b+UfOSip6PY{>a z5;c7*X8Ve8gqkIR4GA+!hS<5S_x=oPueN9E!>`sP8@17}=!GmYR3sjf9BgcOmD7T0RHxP`{y3smNm`A zMG=l9G2(-An&TJzh=IV=R$xBQg8R4b4U&ZqVh1y-$XPZ?1NW9FnVInE$qf%5+S55N zvzz%)yD;fh>Tr;@P?QKpvuMhTj4m>XuETUKR}n_be_Z_pSIaRxuHFT0&|r&iDbsB{DA0!|G~S}|RM5DFx%bDemWYU%4iCV!y$!Pp4INd6W#DuD7WGMx1s7vBSfTycW2XG*Xh+}s4g9#-*F4p0qskF)rFtsbPsLv+tj$0S(aOv=yR&Y&Y{)>9v4mwDHFd{ zCo^zGx<8|2XMZ7*C1lt9aPAMCV zh7Dn+JH6%wLAX2&)oi;SA1YTS*B-Va&1os*Q8y2C|4izh+hI9wYrP=dE2^g%pDdol zdxZ-9>X+@sc)EWO>}O4C%bqyDr^Ogrt8tsB$hv^Tf#OaHZclqCPRDku&6xPWzNt>Q z(l3mYTqDATlrZiTNR=m1?nwsI)g^qK(b~{5j_4HnnH0G%c!IgYvfc>#F7yhc2_9}> zNp_u7yeMq)Y9CLZR7}oj)3`UZ6hKdOy}JpY(m;`&%%|C2{M^ zmbItdAZB`c5vNa|?ruzEhgfUP2kaZLwnCw1W~OfNDTf18;`W=J=t$l@6_g~b^ZxH9 z_naCK;Q-5&tj3d3To0;Vn6m+msZq>qme_|KhCLlK_41Tgd9+5;@smjZ{ z#HZ-3zkX2smD-eVDT!W{S0c$Mz!2y59%F26UAP1H9!#ddQ_qgu;MI0D7cLOc*+-#n zuzg!-Oh|LYu02z0HzSHkF)Nq-_Hr}EE7pw2?^s|U(GDxt0ly>sBeDPI3f6kxvze|D zo~v=&hV{`?(h_2(`0zT-w^lZDkLBDqbcvlE`}+GW-sQ90)YsdN29o<~+xj7p?9;oq z?-7!0%dukw&$HaK+b~MV{{ttw!(YRn#=MKQiBHIsk;!OI)SLovaIogndtb17o1@=t zSp}G1j*r=wOloK9SFa(o@bZYs<(d&6ACD|P#qtbUVt>@muYQAbCbRRvNLDqTjj#EV_>=CMqQesUI()7!fzXL*$MgFx^Y=RF;F zkMjC`PSxrhY^Z)ckOX*g%1ID81C**NZJ6b^v7!6tMuN%@Z}BZF`Dlb3JbyL183#3T za#*!qt0+L<<~w0E%aI~x;$~32GQOHq|FApQ-MjC|we6SqaRq7opR>sG0||*0oVoSpwh=t+^&&NV0oEyKjI>RiGFzok%%M>q>W!8n>l zG>p#IDajJM)8J5bX?~cg=I?rY`-NL7EK%Z|>~&O#RNM9!?AV|$<9uWGk>bfu^dNP@l( zEZwCkTz{&!!%QvLPxtKW;<6}CUi5CkIONpT70h`+l`NQ55#JH4wO`SRDFaE-kTAU|6o(29v%i%LEfKtrXXOf90Gqt>1cjzJe%fUjcYjZg@b)J0Mzl zhT#jseon??yS1fo*+)g+g6FL|z-)}H2PID)lbd1z8~t?myBWDGCqGKu0L-Mqab7;$ zDO1eP1zyf#7w~=KN3S6vFYd_DE^>n46?FX@SP(%`orY-$)@R$1BO}C+R^E2)tNuM zN=52w$T`%u&cN<5A@qC+lWUTg;J}!(LuO27YT>I&q93>T(Jt@q3MH;=7j%=j8ivoi#AUzV6wY4wel|iRT$E85jpP?Z|DIV zE}_jb&3)z+Ye>+(GF0Chm4q^Z(Ui9|N+5T*%OKVDDQDxl;u#fDDp4baQhuVfL>;!e zGMj>Rikyb(m-HXs6;F7Vn)Y4gwD z)f5^zO?PJuSc9S@%4(8=Q&CZj=DWGoPX-MOJj2rLqjfpUIi<`P`XZ(%j#z> z2BJIoUHq^fzV*`;vgw_Lzc+Np-%&0YI{}OP+8b0BQdCr5DB;fUfHT*$j~BQ^Gx6#; z2FD|0sr$k&nK7zLqHMwqk+64ve0xzvs|eXmVJ*Ow61~0Ly^%@iZTAHE_1YMsY~9@c zc@}uNwmay;<0@RRq9fNf-O#4@0p4bg>`5rGp=MnT1iR_02Qe*3*~vRVe&8 zES&!PjnesR==k6t)W{Ybl-1lgp`?L)>EhWC7 zb2}De-MIR5m)q?}iNumSrkZSBSVCT%U7~k>9G+Z(uJ~{tmLlIM-3VJPYw;pMDL#Sb zm*W|hh02)*hQ_iDSr+Eh5o&~uIj{cucB{=Mc2MfpZ#ntW@&z(wRh>&r?wlXY^!N>~ z28@Gf^`mP^OcTyTQ-rOa-dZ{kHhrhw9LX<{Q5oM6Cu;+BR;yY1nns9otiIrb!Tr7= z*C=DxS16+PMM6TJ>#nQP^~N@@^>_gii$MnF@Rz9)+*|y2jDkwICtu4~oKT^Q`0o`U zqc1X0IypWYGW*({0`K~zc8330YUsoiM-IiVpj1Hh*jWU>%Es@P23c%167S{0Uu$I@ z)triRiU_P8Q&U4BWp2vHr4H9t&bds;i8*dK$~=(g|K(4J@i6s;VLkW{85&po9h1u2 z7u|w8TPV_5;%B8tW96w|b6>)CCb-)+CJ;j_X_c?W?a*bidpQlGhD(NPe3CBu%!iVQ z1}-CW=Dgk#ihV&1ThZ%Ty@tbXHxS*%+^eQ>(&|Me`*702!Z30_RwQHL*s)Zt3XzH@ z))T^<$zXt8qVmPyO4&@|Xzww=8LZ`kBGj6ot5I=3c*&Ywyk{wYgX%@_*N1=@Hh^es z`jvU6WrY@>c_aII2>8lORfi%QvgR8T6)YC_bMnZTqgp-mrfj0L z?7RqK*frv?y+CR~6lsPXxjI{4o~VB31TiRp65JYDUN!1ZIY3ST1?{?| zj{HgsBog^>=+oXV5Mc^d1`5q;y!#lpPv?)@VP=C64=SA>(iD;`&2BFJB)O7x-d~Yk zYSmV@GpF%NATolRsz5NVMk%e*wB|0C`4fF}UMg{y&HCPr1a|k=3}a(M7y4e-;mfqteG`g^p9VNST0cU!6lSk4n^VE zR+YKMM;B~y3pX?N^)suio*hxnRNqdIHZLVX+i9U^SV{6w#pEgBK6_=)GhyevoY#Tu zZB`4vkG<~D$)QRY>;G8dh|^zVuIRVFb@xneN+N@j-6KX*6_6!3H$S+1#{(vD&Igdh z_h=AbCxS5ej>hkeB{~$tTkEWh-S}sKk3-UxBaMg5$zFBBah~fhwa9JbQ%g@iM5H3- zPh_eYnl|K#C@;74Y-g0*)prjhbCxfN)jB4+c-VrRRzU%F>q`pO#n{#nRNRo|?MSOEwjK_!Zbo*b=2VjHjdzz+E+yR^-M>o@%^xz+r!8Y))<~zS#~v zN(43atT`_72m+`4-~%tzRjv0$B-x~z0BT74$qs|1Ipg(BG_s1sfX@IZ$Cm^3#i9MP zkq0VQf6fTYWHz@V7^r}R8rdh;oY*=n>erL*JP^7*`Pfi7zNsA_C)y!6(&bfJcVrjN z(cIK^IMY7qGtoT^Uh9=1yOy{yZGbA=h)--Ns&rnyFm$N5b42A*z7HY)SC+5GSS?TY zxE8syKGfHEyQ1KnTP+nrn_JbANm-4Tr`!WhxteyVFC)omjZ{3e@Ktp(HnGo19}{aG z_A0ULdO$bR!AC(IlF-tovOdPCr=_KJQbUs(-|~q^#$N8+BMh#Al0beGkz&1{%GI#a z^jT`!x4{!dNrIlxpREl#$@5vL=(+y zRu6%bZeoVahTDO=S&&-J_Zm<>pomoYjtEcWZY$2pXGG=+oGy3Ya#y3bC31JU1zqUc z`0$%&0BpC4m~F%?n?#nufCKWNc!xYKOxiWlY6g2oa00ZUpXj}f7PClH|J0PEyeZ59 zhCQHYq5YoAGd%-8r~0!z8-hXwd>22#u)nVw8<%H-?zYr{Xf2%3ILX)89V>SyURABV zDI$&HU|4AC)wbGXyr~`RfsuHiFi!4n?R#B_q&v1+O+8NXk<)gn{WETF!jR8Y6BT6f zCsL<|K~5vTvbV4olR07{(a`k znh->wU72U5`U}cp)+cMCqJg;KWXDoOXD?fBUF$Qf;}7AhflB(FLQ@>?oz|Pt+D`RV z$_}Xc@Dr+2uU_4pE><_p{C3j3hf+QE)7#*0- zvCCpsxh;MqcZkyc|BEk`7na_<%NZ5 z=Xf=@{}}f5AiC#*Le17`5Li9_g^i3{{+B0Tut^RJE@m@?v zVc)&n@iF-VgG6)#asD@P^?#^0X1VWn!-A5=!P8ui2cvRch8+Ev%BBQsL znPVNGYQO!DmUE{ice#n~CdDk+f0o?-TsK!{YiWQ|<-Y$9N|j;NQu}S6NTpYsz#?p6 z?)P)wD_sm^)3`~}V#mK-R>WiX2%LYV1GM!v!?Ufe?KWh`xjRVb8D_<6yn>W97|PRx z6F=O8_S1-^)#0-C*(61B!!*PBwKXxmZvgjxyE+U`WAAd`_+>gK`5LjbvGQ)i4{qhD zzP|ol^Ss?w@^Tdm2$D2a@SCInpQO}le>GZeI z9fgo*5gJO*?x5xWezsIZ$=DM-W@spUdAkZ|wRs}bIr>7Ec&EF7d@RhUK~ka?+}J&^ zE8nie#7m9ua-)C3{BS3hyRAnyoU`-|UR<4`HDojtpd|QI7Vlbls;wwJylxh&CwmB)u)DrG*eJMhGZZ1f}n2iD6GnX!soWqf+O7MtR5~U84CX<7Ix{!dZr-OI|Cj$>?l0p&1uai?-9tddPibpeee}F1 z8xy9Wvi4VxcFY^?;|WFcS_V)EZmF**E%~}G1KmrDHIUw-})Xl+R_b4@# zIF)8*mXine(^KEK-}FRJ@}8;TrHK~2rY7dkty_XT`~D#XbN4 literal 0 HcmV?d00001 diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 6f43e4cfc7..7923d0926d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -54,6 +54,7 @@ import { import { type RuntimeEnvironmentType } from "@trigger.dev/database"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { Callout } from "~/components/primitives/Callout"; +import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -101,8 +102,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typeddefer({ ...queues, - success: false, - code: "engine-version", environment: environmentQueuePresenter.call(environment), }); } catch (error) { @@ -358,18 +357,7 @@ export default function Page() { ) : (
{code === "engine-version" ? ( -
-
-

New queues table

- - Upgrade guide - -
-
+ ) : ( Something went wrong )} @@ -468,6 +456,30 @@ function EnvironmentPauseResumeButton({ ); } +function EngineVersionUpgradeCallout() { + return ( +
+
+

New queues table

+ + Upgrade guide + +
+
+ + Upgrade to SDK version 4+ to view the new queues table, and be able to pause and resume + individual queues. + + Upgrade for queues +
+
+ ); +} + export function isEnvironmentPauseResumeFormSubmission( formMethod: string | undefined, formData: FormData | undefined From 42089a469140aadf3a332549023d90a1e2a89eb1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 14:36:53 +0000 Subject: [PATCH 32/40] Pausing individual queues working --- .../app/components/primitives/Badge.tsx | 2 +- .../v3/QueueListPresenter.server.ts | 4 +- .../v3/QueueRetrievePresenter.server.ts | 53 +++--- .../route.tsx | 176 ++++++++++++++++-- .../app/v3/services/pauseQueue.server.ts | 91 +++++++++ packages/core/src/v3/schemas/queues.ts | 2 + 6 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 apps/webapp/app/v3/services/pauseQueue.server.ts diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index beb347a337..04a033ba02 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -7,7 +7,7 @@ const variants = { small: "grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap", "extra-small": - "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 tracking-wide text-xxs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", outline: "grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap", "outline-rounded": diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index e735dbe9b5..5a3479d4ed 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -4,7 +4,7 @@ import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; import { toQueueItem } from "./QueueRetrievePresenter.server"; -const DEFAULT_ITEMS_PER_PAGE = 25; +const DEFAULT_ITEMS_PER_PAGE = 10; const MAX_ITEMS_PER_PAGE = 100; export class QueueListPresenter extends BasePresenter { private readonly perPage: number; @@ -60,6 +60,7 @@ export class QueueListPresenter extends BasePresenter { name: true, concurrencyLimit: true, type: true, + paused: true, }, orderBy: { name: "asc", @@ -88,6 +89,7 @@ export class QueueListPresenter extends BasePresenter { running: results[1][queue.name] ?? 0, queued: results[0][queue.name] ?? 0, concurrencyLimit: queue.concurrencyLimit ?? null, + paused: queue.paused, }) ); } diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts index 67de0b217b..6f297bc8ba 100644 --- a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -5,6 +5,34 @@ import { type TaskQueueType } from "@trigger.dev/database"; import { assertExhaustive } from "@trigger.dev/core"; import { determineEngineVersion } from "~/v3/engineVersion.server"; import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3"; +import { PrismaClientOrTransaction } from "@trigger.dev/database"; + +/** + * Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService + */ +export async function getQueue( + prismaClient: PrismaClientOrTransaction, + environment: AuthenticatedEnvironment, + queue: RetrieveQueueParam +) { + if (typeof queue === "string") { + return prismaClient.taskQueue.findFirst({ + where: { + friendlyId: queue, + runtimeEnvironmentId: environment.id, + }, + }); + } + + const queueName = + queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name; + return prismaClient.taskQueue.findFirst({ + where: { + name: queueName, + runtimeEnvironmentId: environment.id, + }, + }); +} export class QueueRetrievePresenter extends BasePresenter { public async call({ @@ -24,7 +52,7 @@ export class QueueRetrievePresenter extends BasePresenter { }; } - const queue = await this.getQueue(environment, queueInput); + const queue = await getQueue(this._replica, environment, queueInput); if (!queue) { return { success: false as const, @@ -47,29 +75,10 @@ export class QueueRetrievePresenter extends BasePresenter { running: results[1]?.[queue.name] ?? 0, queued: results[0]?.[queue.name] ?? 0, concurrencyLimit: queue.concurrencyLimit ?? null, + paused: queue.paused, }), }; } - - private async getQueue(environment: AuthenticatedEnvironment, queue: RetrieveQueueParam) { - if (typeof queue === "string") { - return this._replica.taskQueue.findFirst({ - where: { - friendlyId: queue, - runtimeEnvironmentId: environment.id, - }, - }); - } - - const queueName = - queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name; - return this._replica.taskQueue.findFirst({ - where: { - name: queueName, - runtimeEnvironmentId: environment.id, - }, - }); - } } function queueTypeFromType(type: TaskQueueType) { @@ -95,6 +104,7 @@ export function toQueueItem(data: { running: number; queued: number; concurrencyLimit: number | null; + paused: boolean; }): QueueItem { return { id: data.friendlyId, @@ -104,5 +114,6 @@ export function toQueueItem(data: { running: data.running, queued: data.queued, concurrencyLimit: data.concurrencyLimit, + paused: data.paused, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 7923d0926d..106569964e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, + TableCellMenu, TableHeader, TableHeaderCell, TableRow, @@ -55,6 +56,8 @@ import { type RuntimeEnvironmentType } from "@trigger.dev/database"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { Callout } from "~/components/primitives/Callout"; import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; +import { PauseQueueService } from "~/v3/services/pauseQueue.server"; +import { Badge } from "~/components/primitives/Badge"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -161,8 +164,40 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { request, "Environment resumed" ); + case "queue-pause": + case "queue-resume": { + const friendlyId = formData.get("friendlyId"); + if (!friendlyId) { + return redirectWithErrorMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + "Queue ID is required" + ); + } + + const queueService = new PauseQueueService(); + const result = await queueService.call( + environment, + friendlyId.toString(), + action === "queue-pause" ? "paused" : "resumed" + ); + + if (!result.success) { + return redirectWithErrorMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue` + ); + } + + return redirectWithSuccessMessage( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + request, + `Queue ${action === "queue-pause" ? "paused" : "resumed"}` + ); + } default: - redirectWithErrorMessage( + return redirectWithErrorMessage( `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, request, "Something went wrong" @@ -266,7 +301,7 @@ export default function Page() { Queued Running Concurrency limit - + Pause/resume @@ -287,7 +322,7 @@ export default function Page() { resolve={Promise.all([queues, environment])} errorElement={Error loading queues} > - {([q, environment]) => { + {([q, env]) => { return q.length > 0 ? ( q.map((queue) => ( @@ -295,29 +330,70 @@ export default function Page() { {queue.type === "task" ? ( } + button={ + + } content={`This queue was automatically created from your "${queue.name}" task`} /> ) : ( + } content={`This is a custom queue you added in your code.`} /> )} - {queue.name} + + {queue.name} + + {queue.paused ? ( + + Paused + + ) : null} - {queue.queued} - {queue.running} - + + {queue.queued} + + + {queue.running} + + {queue.concurrencyLimit ?? ( - Max ({environment.concurrencyLimit}) + Max ({env.concurrencyLimit}) )} + + } + hiddenButtons={ + !queue.paused && + } + /> )) ) : ( @@ -401,7 +477,7 @@ function EnvironmentPauseResumeButton({ LeadingIcon={env.paused ? PlayIcon : PauseIcon} leadingIconClassName={env.paused ? "text-success" : "text-amber-500"} > - {env.paused ? "Resume" : "Pause environment"} + {env.paused ? "Resume..." : "Pause environment..."}
@@ -437,6 +513,7 @@ function EnvironmentPauseResumeButton({ disabled={isLoading} variant={env.paused ? "primary/medium" : "danger/medium"} LeadingIcon={isLoading ? : env.paused ? PlayIcon : PauseIcon} + shortcut={{ modifiers: ["mod"], key: "enter" }} > {env.paused ? "Resume environment" : "Pause environment"} @@ -456,6 +533,83 @@ function EnvironmentPauseResumeButton({ ); } +function QueuePauseResumeButton({ + queue, +}: { + /** The "id" here is a friendlyId */ + queue: { id: string; name: string; paused: boolean }; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + + return ( + +
+ + + +
+ + + +
+
+ + {queue.paused + ? `Resume processing runs in queue "${queue.name}"` + : `Pause processing runs in queue "${queue.name}"`} + +
+
+
+ + {queue.paused ? "Resume queue?" : "Pause queue?"} +
+ + {queue.paused + ? `This will allow runs to be dequeued in the "${queue.name}" queue again.` + : `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`} + + setIsOpen(false)}> + + + + {queue.paused ? "Resume queue" : "Pause queue"} + + } + cancelButton={ + + + + } + /> + +
+
+
+ ); +} + function EngineVersionUpgradeCallout() { return (
diff --git a/apps/webapp/app/v3/services/pauseQueue.server.ts b/apps/webapp/app/v3/services/pauseQueue.server.ts new file mode 100644 index 0000000000..451b31428e --- /dev/null +++ b/apps/webapp/app/v3/services/pauseQueue.server.ts @@ -0,0 +1,91 @@ +import { type RetrieveQueueParam } from "@trigger.dev/core/v3"; +import { getQueue } from "~/presenters/v3/QueueRetrievePresenter.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { BaseService } from "./baseService.server"; +import { determineEngineVersion } from "../engineVersion.server"; +import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server"; + +export type PauseStatus = "paused" | "resumed"; + +export type PauseQueueResult = + | { + success: true; + state: PauseStatus; + } + | { + success: false; + code: "queue-not-found" | "unknown-error" | "engine-version"; + error?: string; + }; + +export class PauseQueueService extends BaseService { + public async call( + environment: AuthenticatedEnvironment, + queueInput: RetrieveQueueParam, + action: PauseStatus + ): Promise { + try { + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + error: "Upgrade to v4+ to pause/resume queues", + }; + } + + const queue = await getQueue(this._prisma, environment, queueInput); + + if (!queue) { + return { + success: false, + code: "queue-not-found", + }; + } + + await this._prisma.taskQueue.update({ + where: { + id: queue.id, + }, + data: { + paused: action === "paused", + }, + }); + + if (action === "paused") { + await updateQueueConcurrencyLimits(environment, queue.name, 0); + } else { + if (queue.concurrencyLimit) { + await updateQueueConcurrencyLimits(environment, queue.name, queue.concurrencyLimit); + } else { + await removeQueueConcurrencyLimits(environment, queue.name); + } + } + + logger.debug("PauseQueueService: queue state updated", { + queueId: queue.id, + action, + environmentId: environment.id, + }); + + return { + success: true, + state: action, + }; + } catch (error) { + logger.error("PauseQueueService: error updating queue state", { + error, + environmentId: environment.id, + }); + + return { + success: false, + code: "unknown-error", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts index 181b07752f..2b511eb44c 100644 --- a/packages/core/src/v3/schemas/queues.ts +++ b/packages/core/src/v3/schemas/queues.ts @@ -30,6 +30,8 @@ export const QueueItem = z.object({ queued: z.number(), /** The concurrency limit of the queue */ concurrencyLimit: z.number().nullable(), + /** Whether the queue is paused. If it's paused, no new runs will be started. */ + paused: z.boolean(), }); export type QueueItem = z.infer; From 03fb6e2890ce1d33e4a86ffd66b0dd7c5d910b24 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 14:39:24 +0000 Subject: [PATCH 33/40] Redirect to the correct page, to keep your place --- .../route.tsx | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 106569964e..bf18bf6a67 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -147,32 +147,25 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const formData = await request.formData(); const action = formData.get("action"); + const url = new URL(request.url); + const { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues?page=${page}`; + switch (action) { case "environment-pause": const pauseService = new PauseEnvironmentService(); await pauseService.call(environment, "paused"); - return redirectWithSuccessMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, - request, - "Environment paused" - ); + return redirectWithSuccessMessage(redirectPath, request, "Environment paused"); case "environment-resume": const resumeService = new PauseEnvironmentService(); await resumeService.call(environment, "resumed"); - return redirectWithSuccessMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, - request, - "Environment resumed" - ); + return redirectWithSuccessMessage(redirectPath, request, "Environment resumed"); case "queue-pause": case "queue-resume": { const friendlyId = formData.get("friendlyId"); if (!friendlyId) { - return redirectWithErrorMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, - request, - "Queue ID is required" - ); + return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); } const queueService = new PauseQueueService(); @@ -184,24 +177,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { if (!result.success) { return redirectWithErrorMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + redirectPath, request, result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue` ); } return redirectWithSuccessMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, + redirectPath, request, `Queue ${action === "queue-pause" ? "paused" : "resumed"}` ); } default: - return redirectWithErrorMessage( - `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues`, - request, - "Something went wrong" - ); + return redirectWithErrorMessage(redirectPath, request, "Something went wrong"); } }; From 46eed03743443127edfc0cd07d847bced9563ddc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 14:59:37 +0000 Subject: [PATCH 34/40] Added pause/resume functions to the SDK --- .../v3/QueueListPresenter.server.ts | 2 +- .../routes/api.v1.queues.$queueParam.pause.ts | 46 ++++++++++ .../app/v3/services/pauseQueue.server.ts | 22 ++++- packages/core/src/v3/apiClient/index.ts | 26 ++++++ packages/trigger-sdk/src/v3/queues.ts | 89 +++++++++++++++++++ references/hello-world/src/trigger/queues.ts | 14 +++ 6 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 5a3479d4ed..13d0126b2b 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -4,7 +4,7 @@ import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; import { toQueueItem } from "./QueueRetrievePresenter.server"; -const DEFAULT_ITEMS_PER_PAGE = 10; +const DEFAULT_ITEMS_PER_PAGE = 25; const MAX_ITEMS_PER_PAGE = 100; export class QueueListPresenter extends BasePresenter { private readonly perPage: number; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts new file mode 100644 index 0000000000..452bd81746 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -0,0 +1,46 @@ +import { json } from "@remix-run/server-runtime"; +import { type QueueItem, type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { PauseQueueService } from "~/v3/services/pauseQueue.server"; + +const BodySchema = z.object({ + type: RetrieveQueueType.default("id"), + action: z.enum(["pause", "resume"]), +}); + +export const { action } = createActionApiRoute( + { + body: BodySchema, + params: z.object({ + queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), + }), + }, + async ({ params, body, authentication }) => { + const input: RetrieveQueueParam = + body.type === "id" + ? params.queueParam + : { + type: body.type, + name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"), + }; + + const service = new PauseQueueService(); + const result = await service.call( + authentication.environment, + input, + body.action === "pause" ? "paused" : "resumed" + ); + + if (!result.success) { + if (result.code === "queue-not-found") { + return json({ error: result.code }, { status: 404 }); + } + + return json({ error: result.code }, { status: 400 }); + } + + const q: QueueItem = result.queue; + return json(q); + } +); diff --git a/apps/webapp/app/v3/services/pauseQueue.server.ts b/apps/webapp/app/v3/services/pauseQueue.server.ts index 451b31428e..f4e18eab4b 100644 --- a/apps/webapp/app/v3/services/pauseQueue.server.ts +++ b/apps/webapp/app/v3/services/pauseQueue.server.ts @@ -1,10 +1,11 @@ -import { type RetrieveQueueParam } from "@trigger.dev/core/v3"; -import { getQueue } from "~/presenters/v3/QueueRetrievePresenter.server"; +import { QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3"; +import { getQueue, toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { BaseService } from "./baseService.server"; import { determineEngineVersion } from "../engineVersion.server"; import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server"; +import { engine } from "../runEngine.server"; export type PauseStatus = "paused" | "resumed"; @@ -12,6 +13,7 @@ export type PauseQueueResult = | { success: true; state: PauseStatus; + queue: QueueItem; } | { success: false; @@ -46,7 +48,7 @@ export class PauseQueueService extends BaseService { }; } - await this._prisma.taskQueue.update({ + const updatedQueue = await this._prisma.taskQueue.update({ where: { id: queue.id, }, @@ -71,9 +73,23 @@ export class PauseQueueService extends BaseService { environmentId: environment.id, }); + const results = await Promise.all([ + engine.lengthOfQueues(environment, [queue.name]), + engine.currentConcurrencyOfQueues(environment, [queue.name]), + ]); + return { success: true, state: action, + queue: toQueueItem({ + friendlyId: updatedQueue.friendlyId, + name: updatedQueue.name, + type: updatedQueue.type, + running: results[1]?.[updatedQueue.name] ?? 0, + queued: results[0]?.[updatedQueue.name] ?? 0, + concurrencyLimit: updatedQueue.concurrencyLimit ?? null, + paused: updatedQueue.paused, + }), }; } catch (error) { logger.error("PauseQueueService: error updating queue state", { diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index d62abb7ab4..668a5c34a6 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -763,6 +763,32 @@ export class ApiClient { ); } + pauseQueue( + queue: RetrieveQueueParam, + action: "pause" | "resume", + requestOptions?: ZodFetchOptions + ) { + const type = typeof queue === "string" ? "id" : queue.type; + const value = typeof queue === "string" ? queue : queue.name; + + // Explicitly encode slashes before encoding the rest of the string + const encodedValue = encodeURIComponent(value.replace(/\//g, "%2F")); + + return zodfetch( + QueueItem, + `${this.baseUrl}/api/v1/queues/${encodedValue}/pause`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify({ + type, + action, + }), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + subscribeToRun( runId: string, options?: { diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts index 54f009226f..6788186f9d 100644 --- a/packages/trigger-sdk/src/v3/queues.ts +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -86,3 +86,92 @@ export function retrieve( return apiClient.retrieveQueue(queue, $requestOptions); } + +/** + * Pauses a queue, preventing any new runs from being started. + * Runs that are currently running will continue to completion. + * + * @example + * ```ts + * // Pause using a queue id + * await queues.pause("queue_12345"); + * + * // Or pause using type and name + * await queues.pause({ type: "task", name: "my-task-id"}); + * ``` + * @param queue - The ID of the queue to pause, or the type and name + * @returns The updated queue state + */ +export function pause( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.pause()", + icon: "queue", + attributes: { + ...flattenAttributes({ queue }), + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.pauseQueue(queue, "pause", $requestOptions); +} + +/** + * Resumes a paused queue, allowing new runs to be started. + * + * @example + * ```ts + * // Resume using a queue id + * await queues.resume("queue_12345"); + * + * // Or resume using type and name + * await queues.resume({ type: "task", name: "my-task-id"}); + * ``` + * @param queue - The ID of the queue to resume, or the type and name + * @returns The updated queue state + */ +export function resume( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.resume()", + icon: "queue", + attributes: { + ...flattenAttributes({ queue }), + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.pauseQueue(queue, "resume", $requestOptions); +} diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts index d2c540fb64..8ba777d527 100644 --- a/references/hello-world/src/trigger/queues.ts +++ b/references/hello-world/src/trigger/queues.ts @@ -18,10 +18,24 @@ export const queuesTester = task({ }); logger.log("Retrieved from name", { retrievedFromCtxName }); + //pause the queue + const pausedQueue = await queues.pause({ + type: "task", + name: "queues-tester", + }); + logger.log("Paused queue", { pausedQueue }); + const retrievedFromName = await queues.retrieve({ type: "task", name: "queues-tester", }); logger.log("Retrieved from name", { retrievedFromName }); + + //resume the queue + const resumedQueue = await queues.resume({ + type: "task", + name: "queues-tester", + }); + logger.log("Resumed queue", { resumedQueue }); }, }); From 5c9a305e6b9c615df0f8ea37ad8db51c0fa588c9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 16:34:06 +0000 Subject: [PATCH 35/40] Auto-reload the queue page every 10 seconds --- apps/webapp/app/env.server.ts | 4 +- .../route.tsx | 21 ++++++- ...ojectParam.env.$envParam.queues.stream.tsx | 55 +++++++++++++++++++ references/hello-world/src/trigger/queues.ts | 11 ++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 84e3c52b91..2f87f11b18 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -568,7 +568,6 @@ const EnvironmentSchema = z.object({ /** How long should the presence ttl last */ DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(30_000), DEV_PRESENCE_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), - DEV_PRESENCE_RECONNECT_THRESHOLD_MS: z.coerce.number().int().default(2_000), /** How many ms to wait until dequeuing again, if there was a run last time */ DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250), /** How many ms to wait until dequeuing again, if there was no run last time */ @@ -660,6 +659,9 @@ const EnvironmentSchema = z.object({ TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute + + QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(10_000), + QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), }); export type Environment = z.infer; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index bf18bf6a67..226ce3d343 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -6,7 +6,7 @@ import { PlayIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; -import { Await, Form, useNavigation, type MetaFunction } from "@remix-run/react"; +import { Await, Form, useNavigation, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useEffect, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; @@ -58,6 +58,8 @@ import { Callout } from "~/components/primitives/Callout"; import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; import { PauseQueueService } from "~/v3/services/pauseQueue.server"; import { Badge } from "~/components/primitives/Badge"; +import { useEventSource } from "~/hooks/useEventSource"; +import { useProject } from "~/hooks/useProject"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -198,9 +200,26 @@ export default function Page() { const { environment, queues, success, pagination, code } = useTypedLoaderData(); const organization = useOrganization(); + const project = useProject(); const env = useEnvironment(); const plan = useCurrentPlan(); + const revalidation = useRevalidator(); + + const streamedEvents = useEventSource( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`, + { + event: "update", + } + ); + + useEffect(() => { + if (streamedEvents) { + console.log("streamedEvents", streamedEvents); + revalidation.revalidate(); + } + }, [streamedEvents]); + return ( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx new file mode 100644 index 0000000000..007e2c4f7e --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx @@ -0,0 +1,55 @@ +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { createSSELoader } from "~/utils/sse"; + +export const loader = createSSELoader({ + timeout: env.QUEUE_SSE_AUTORELOAD_TIMEOUT_MS, + interval: env.QUEUE_SSE_AUTORELOAD_INTERVAL_MS, + debug: true, + handler: async ({ request, params }) => { + const userId = await requireUserId(request); + const { projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + slug: envParam, + type: "DEVELOPMENT", + orgMember: { + userId, + }, + project: { + slug: projectParam, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + return { + beforeStream: async () => { + logger.debug("Start queue page SSE session", { + environmentId: environment.id, + }); + }, + initStream: async ({ send }) => { + send({ event: "time", data: new Date().toISOString() }); + }, + iterator: async ({ send }) => { + send({ + event: "update", + data: new Date().toISOString(), + }); + }, + cleanup: async () => { + logger.debug("End queue page SSE session", { + environmentId: environment.id, + }); + }, + }; + }, +}); diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts index 8ba777d527..efca8fe0e5 100644 --- a/references/hello-world/src/trigger/queues.ts +++ b/references/hello-world/src/trigger/queues.ts @@ -39,3 +39,14 @@ export const queuesTester = task({ logger.log("Resumed queue", { resumedQueue }); }, }); + +export const otherQueueTask = task({ + id: "other-queue-task", + queue: { + name: "my-custom-queue", + concurrencyLimit: 1, + }, + run: async (payload: any, { ctx }) => { + logger.log("Other queue task", { payload }); + }, +}); From 110548c354f96566b6f1f14143a3bc045ed45926 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 16:42:10 +0000 Subject: [PATCH 36/40] =?UTF-8?q?Don=E2=80=99t=20use=20defer,=20it=20cause?= =?UTF-8?q?s=20a=20horrible=20UI=20flash=20with=20revalidate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/env.server.ts | 2 +- .../v3/QueueListPresenter.server.ts | 2 +- .../route.tsx | 331 ++++++++---------- apps/webapp/app/routes/api.v1.queues.ts | 2 +- 4 files changed, 147 insertions(+), 190 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2f87f11b18..4ff67c0439 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -660,7 +660,7 @@ const EnvironmentSchema = z.object({ TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute - QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(10_000), + QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), }); diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 13d0126b2b..ca39fc33d7 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -41,7 +41,7 @@ export class QueueListPresenter extends BasePresenter { return { success: true as const, - queues: this.getQueuesWithPagination(environment, page), + queues: await this.getQueuesWithPagination(environment, page), pagination: { currentPage: page, totalPages: Math.ceil(totalQueues / this.perPage), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 226ce3d343..76d37c3da2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -6,19 +6,28 @@ import { PlayIcon, RectangleStackIcon, } from "@heroicons/react/20/solid"; -import { Await, Form, useNavigation, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense, useEffect, useState } from "react"; -import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; +import { useEffect, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; +import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, @@ -29,37 +38,28 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server"; -import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { - SimpleTooltip, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/primitives/Tooltip"; -import { type RuntimeEnvironmentType } from "@trigger.dev/database"; -import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; -import { Callout } from "~/components/primitives/Callout"; -import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; import { PauseQueueService } from "~/v3/services/pauseQueue.server"; -import { Badge } from "~/components/primitives/Badge"; -import { useEventSource } from "~/hooks/useEventSource"; -import { useProject } from "~/hooks/useProject"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -105,9 +105,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentQueuePresenter = new EnvironmentQueuePresenter(); - return typeddefer({ + return typedjson({ ...queues, - environment: environmentQueuePresenter.call(environment), + environment: await environmentQueuePresenter.call(environment), }); } catch (error) { console.error(error); @@ -204,8 +204,7 @@ export default function Page() { const env = useEnvironment(); const plan = useCurrentPlan(); - const revalidation = useRevalidator(); - + // Reload the page periodically const streamedEvents = useEventSource( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`, { @@ -213,9 +212,9 @@ export default function Page() { } ); + const revalidation = useRevalidator(); useEffect(() => { if (streamedEvents) { - console.log("streamedEvents", streamedEvents); revalidation.revalidate(); } }, [streamedEvents]); @@ -238,66 +237,47 @@ export default function Page() {
- }> - - {(environment) => ( - 0 ? "paused" : undefined} - animate - accessory={} - valueClassName={env.paused ? "text-amber-500" : undefined} - /> - )} - - - }> - - {(environment) => } - - - }> - - {(environment) => ( - - Increase limit - - } - defaultValue="help" - /> - ) : ( - - Increase limit - - ) - ) : null - } - /> - )} - - + 0 ? "paused" : undefined} + animate + accessory={} + valueClassName={env.paused ? "text-amber-500" : undefined} + /> + + + Increase limit + + } + defaultValue="help" + /> + ) : ( + + Increase limit + + ) + ) : null + } + />
{success ? ( @@ -315,107 +295,84 @@ export default function Page() { - - -
- -
-
- - } - > - Error loading queues} - > - {([q, env]) => { - return q.length > 0 ? ( - q.map((queue) => ( - - - - {queue.type === "task" ? ( - - } - content={`This queue was automatically created from your "${queue.name}" task`} - /> - ) : ( - - } - content={`This is a custom queue you added in your code.`} - /> - )} - - {queue.name} - - {queue.paused ? ( - - Paused - - ) : null} - - - - {queue.queued} - - - {queue.running} - - - {queue.concurrencyLimit ?? ( - - Max ({env.concurrencyLimit}) - - )} - - + {queues.length > 0 ? ( + queues.map((queue) => ( + + + + {queue.type === "task" ? ( + } - hiddenButtons={ - !queue.paused && + content={`This queue was automatically created from your "${queue.name}" task`} + /> + ) : ( + } + content={`This is a custom queue you added in your code.`} /> - - )) - ) : ( - - -
- No queues found -
-
-
- ); - }} -
-
+ )} + + {queue.name} + + {queue.paused ? ( + + Paused + + ) : null} + + + + {queue.queued} + + + {queue.running} + + + {queue.concurrencyLimit ?? ( + + Max ({environment.concurrencyLimit}) + + )} + + } + hiddenButtons={!queue.paused && } + /> + + )) + ) : ( + + +
+ No queues found +
+
+
+ )}
diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index d5360782b4..551b3c2f34 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -28,7 +28,7 @@ export const loader = createLoaderApiRoute( return json({ error: result.code }, { status: 400 }); } - const queues: QueueItem[] = await result.queues; + const queues: QueueItem[] = result.queues; return json({ data: queues, pagination: result.pagination }, { status: 200 }); } catch (error) { if (error instanceof ServiceValidationError) { From 1d5dadbc24371bc655f8eb1f9d1c626670fa0a5e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 16:44:34 +0000 Subject: [PATCH 37/40] Remove unused number-flow package --- apps/webapp/app/components/metrics/BigNumber.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx index d89b2cb473..2097ba928b 100644 --- a/apps/webapp/app/components/metrics/BigNumber.tsx +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -1,5 +1,4 @@ import { type ReactNode } from "react"; -import NumberFlow from "@number-flow/react"; import { AnimatedNumber } from "../primitives/AnimatedNumber"; import { Spinner } from "../primitives/Spinner"; import { cn } from "~/utils/cn"; From 99e722c5c9f71266cb863c82a83d590411a16c4b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 17:01:26 +0000 Subject: [PATCH 38/40] Do all the environment concurrency lookups in parallel --- .../v3/EnvironmentQueuePresenter.server.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts index 39c2eea931..7469a2c0b1 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -11,14 +11,15 @@ export type Environment = { export class EnvironmentQueuePresenter extends BasePresenter { async call(environment: AuthenticatedEnvironment): Promise { - //executing - const engineV1Executing = await marqs.currentConcurrencyOfEnvironment(environment); - const engineV2Executing = await engine.concurrencyOfEnvQueue(environment); - const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); + const [engineV1Executing, engineV2Executing, engineV1Queued, engineV2Queued] = + await Promise.all([ + marqs.currentConcurrencyOfEnvironment(environment), + engine.concurrencyOfEnvQueue(environment), + marqs.lengthOfEnvQueue(environment), + engine.lengthOfEnvQueue(environment), + ]); - //queued - const engineV1Queued = await marqs.lengthOfEnvQueue(environment); - const engineV2Queued = await engine.lengthOfEnvQueue(environment); + const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); return { From 16ff502605fafea247d80c6ebc82cbaeae6fbd31 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 17:11:58 +0000 Subject: [PATCH 39/40] Better scrolling on the Queues page --- .../route.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 76d37c3da2..e173346ffe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -235,7 +235,7 @@ export default function Page() { -
+
{success ? ( - <> +
1 && "grid-rows-[1fr_auto]" + )} + > @@ -376,25 +381,28 @@ export default function Page() {
-
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > + {pagination.totalPages > 1 && (
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + "grid h-fit max-h-full min-h-full overflow-x-auto", + pagination.totalPages > 1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" )} > - +
1 && + "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
-
- + )} +
) : (
{code === "engine-version" ? ( From 4e3ef9f1998763655f87a50ce016b0499d40d5b2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Mar 2025 17:21:01 +0000 Subject: [PATCH 40/40] Blank state if you have no queues --- .../app/components/BlankStatePanels.tsx | 48 ++++++++----------- .../v3/QueueListPresenter.server.ts | 16 ++++--- .../route.tsx | 12 ++++- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index b0b49fa2cb..6c0dca843a 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -6,6 +6,7 @@ import { ClockIcon, PlusIcon, RectangleGroupIcon, + RectangleStackIcon, ServerStackIcon, Squares2X2Icon, } from "@heroicons/react/20/solid"; @@ -368,35 +369,28 @@ export function AlertsNoneDeployed() { ); } -function AlertsNoneProd() { +export function QueuesHasNoTasks() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + return ( -
- + + This means you haven't got any tasks yet in this environment. + + - - You can get alerted when deployed runs fail. - - - We don't support alerts in the Development environment. Switch to a deployed environment - to setup alerts. - -
- - How to setup alerts - -
-
- -
+ Add tasks + + ); } diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index ca39fc33d7..3020d718e0 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -22,6 +22,13 @@ export class QueueListPresenter extends BasePresenter { page: number; perPage?: number; }) { + // Get total count for pagination + const totalQueues = await this._replica.taskQueue.count({ + where: { + runtimeEnvironmentId: environment.id, + }, + }); + //check the engine is the correct version const engineVersion = await determineEngineVersion({ environment }); @@ -29,16 +36,10 @@ export class QueueListPresenter extends BasePresenter { return { success: false as const, code: "engine-version", + totalQueues, }; } - // Get total count for pagination - const totalQueues = await this._replica.taskQueue.count({ - where: { - runtimeEnvironmentId: environment.id, - }, - }); - return { success: true as const, queues: await this.getQueuesWithPagination(environment, page), @@ -47,6 +48,7 @@ export class QueueListPresenter extends BasePresenter { totalPages: Math.ceil(totalQueues / this.perPage), count: totalQueues, }, + totalQueues, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index e173346ffe..b6638f878d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -60,6 +60,7 @@ import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBui import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { PauseQueueService } from "~/v3/services/pauseQueue.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { QueuesHasNoTasks } from "~/components/BlankStatePanels"; const SearchParamsSchema = z.object({ page: z.coerce.number().min(1).default(1), @@ -197,7 +198,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { environment, queues, success, pagination, code } = useTypedLoaderData(); + const { environment, queues, success, pagination, code, totalQueues } = + useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -406,7 +408,13 @@ export default function Page() { ) : (
{code === "engine-version" ? ( - + totalQueues === 0 ? ( +
+ +
+ ) : ( + + ) ) : ( Something went wrong )}