From 058b29f97ca30da6d1332b0f03671602ba277319 Mon Sep 17 00:00:00 2001 From: Rebot <96078724+reboot-dev-bot@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:58:06 +0000 Subject: [PATCH] [Release] Synchronize for release --- .bazelrc | 8 + .../setup-bazel-remote-cache/action.yml | 17 +- Dockerfile | 32 + README.md | 6 +- bazel/rocksdb/rocksdb.BUILD | 1 - charts/reboot/Chart.yaml | 4 +- .../setup-bazel-remote-cache.action.yml | 17 +- documentation/docs/ai_chat_apps/examples.mdx | 4 +- .../docs/ai_chat_apps/get_started.mdx | 151 +- .../ai_chat_apps/get_started_claude_code.mdx | 4 +- documentation/docs/ai_chat_apps/what_is.mdx | 55 +- .../docs/learn_more/applications.mdx | 4 +- documentation/docs/learn_more/auth.mdx | 25 +- .../docs/learn_more/call/from_react.mdx | 8 +- .../docs/learn_more/define/methods.mdx | 4 +- .../docs/learn_more/define/overview.mdx | 2 +- .../docs/learn_more/define/pydantic.mdx | 116 +- .../docs/learn_more/implement/ui_methods.mdx | 41 +- documentation/docs/learn_more/mcp_apps.mdx | 61 +- mypy.ini | 2 + package.json | 4 +- .../v1alpha1/application/application.proto | 13 +- rbt/std/package.json | 2 +- rbt/v1alpha1/database.proto | 8 +- rbt/v1alpha1/errors.proto | 4 +- rbt/v1alpha1/options.proto | 14 + rbt/v1alpha1/package.json | 2 +- reboot-skills/skills/reboot-chat-app/SKILL.md | 78 +- reboot/aio/BUILD.bazel | 2 + reboot/aio/applications.py | 175 ++- reboot/aio/auth/BUILD.bazel | 30 + reboot/aio/auth/authorizers.py | 33 +- reboot/aio/auth/oauth_providers.py | 303 ++++ reboot/aio/auth/oauth_server.py | 864 ++++++++++++ reboot/aio/auth/token_verifiers.py | 34 +- reboot/aio/contexts.py | 119 +- reboot/aio/http.py | 18 + reboot/aio/internals/middleware.py | 25 +- reboot/aio/react.py | 40 +- reboot/aio/servicers.py | 11 +- reboot/aio/state_managers.py | 77 +- reboot/aio/stubs.py | 7 +- reboot/aio/tests.py | 65 +- reboot/aio/workflows.py | 4 +- reboot/api.py | 36 +- reboot/benchmarks/construct/package-lock.json | 30 +- reboot/benchmarks/construct/package.json | 2 +- reboot/cli/cloud.py | 19 +- reboot/cli/dev.py | 16 +- reboot/cli/init/nodejs_test.sh | 2 +- .../init/templates/backend_package.json.j2 | 2 +- reboot/cli/init/templates/package.json.j2 | 2 +- reboot/cli/init/test.sh | 2 +- reboot/demos/fig/package-lock.json | 208 +-- reboot/demos/fig/package.json | 10 +- .../ai-chat-counter-dashboard/README.md | 2 +- .../api/ai_chat_counter/v1/counter.py | 53 +- .../backend/src/main.py | 6 +- .../backend/src/servicers/counter.py | 58 +- .../mcp_servers.json | 3 +- .../ai-chat-counter-dashboard/pyproject.toml | 4 +- .../requirements-dev.lock | 4 +- .../requirements.lock | 4 +- .../web/package-lock.json | 36 +- .../web/package.json | 8 +- reboot/examples/ai-chat-counter/README.md | 2 +- .../api/ai_chat_counter/v1/counter.py | 53 +- .../ai-chat-counter/backend/src/main.py | 6 +- .../backend/src/servicers/counter.py | 58 +- .../examples/ai-chat-counter/mcp_servers.json | 3 +- .../examples/ai-chat-counter/pyproject.toml | 4 +- .../ai-chat-counter/requirements-dev.lock | 4 +- .../ai-chat-counter/requirements.lock | 4 +- .../ai-chat-counter/web/package-lock.json | 211 +-- .../examples/ai-chat-counter/web/package.json | 8 +- reboot/examples/bank-nodejs/package-lock.json | 32 +- reboot/examples/bank-nodejs/package.json | 4 +- reboot/examples/bank-pydantic/pyproject.toml | 4 +- .../bank-pydantic/requirements-dev.lock | 4 +- .../examples/bank-pydantic/requirements.lock | 4 +- .../bank-pydantic/web/package-lock.json | 308 ++--- .../examples/bank-pydantic/web/package.json | 6 +- reboot/examples/bank-zod/package-lock.json | 114 +- reboot/examples/bank-zod/package.json | 8 +- reboot/examples/bank/pyproject.toml | 4 +- reboot/examples/bank/requirements-dev.lock | 4 +- reboot/examples/bank/requirements.lock | 4 +- reboot/examples/bank/web/package-lock.json | 60 +- reboot/examples/bank/web/package.json | 2 +- reboot/examples/boutique/Dockerfile | 2 +- reboot/examples/boutique/pyproject.toml | 4 +- .../examples/boutique/requirements-dev.lock | 4 +- reboot/examples/boutique/requirements.lock | 4 +- .../examples/boutique/web/package-lock.json | 60 +- reboot/examples/boutique/web/package.json | 2 +- reboot/examples/chat-room-nodejs/Dockerfile | 2 +- .../chat-room-nodejs/package-lock.json | 30 +- reboot/examples/chat-room-nodejs/package.json | 2 +- reboot/examples/chat-room/Dockerfile | 2 +- reboot/examples/chat-room/pyproject.toml | 4 +- .../reboot-non-react-web/package-lock.json | 16 +- .../reboot-non-react-web/package.json | 2 +- .../examples/chat-room/requirements-dev.lock | 4 +- reboot/examples/chat-room/requirements.lock | 4 +- .../examples/chat-room/web/package-lock.json | 60 +- reboot/examples/chat-room/web/package.json | 2 +- reboot/examples/counter/package-lock.json | 78 +- reboot/examples/counter/package.json | 4 +- reboot/examples/docubot/api/package.json | 2 +- reboot/examples/docubot/docubot/package.json | 4 +- reboot/examples/docubot/package-lock.json | 92 +- reboot/examples/docubot/package.json | 6 +- reboot/examples/kcdc-2025/pyproject.toml | 4 +- .../examples/kcdc-2025/requirements-dev.lock | 4 +- reboot/examples/kcdc-2025/requirements.lock | 4 +- .../examples/kcdc-2025/web/package-lock.json | 54 +- reboot/examples/kcdc-2025/web/package.json | 6 +- reboot/examples/monorepo/pyproject.toml | 4 +- .../examples/monorepo/requirements-dev.lock | 4 +- reboot/examples/monorepo/requirements.lock | 4 +- reboot/examples/prosemirror-zod/Dockerfile | 2 +- .../prosemirror-zod/backend/package.json | 4 +- .../examples/prosemirror-zod/web/package.json | 2 +- reboot/examples/prosemirror-zod/yarn.lock | 54 +- reboot/examples/prosemirror/Dockerfile | 2 +- .../examples/prosemirror/backend/package.json | 4 +- reboot/examples/prosemirror/web/package.json | 2 +- reboot/examples/prosemirror/yarn.lock | 54 +- reboot/mcp/context.py | 74 +- reboot/mcp/factories.py | 135 +- reboot/mcp/helpers.py | 20 +- reboot/nodejs/index.ts | 83 +- reboot/nodejs/package.json | 4 +- reboot/nodejs/python.py | 4 +- reboot/nodejs/reboot_native.cc | 11 +- reboot/nodejs/reboot_native.cjs | 3 +- reboot/nodejs/reboot_native.d.ts | 2 +- reboot/nodejs/zod-to-proto.ts | 35 +- reboot/ping/BUILD.bazel | 1 + reboot/ping/README.md | 2 +- reboot/ping/mcp_servers.json | 3 +- reboot/ping/ping.py | 72 +- reboot/ping/ping_api.py | 63 +- reboot/protoc_gen_reboot_generic.py | 28 +- reboot/pydantic_schema_to_proto.py | 51 +- reboot/react/index.tsx | 6 +- reboot/react/internal/McpConnector.tsx | 136 +- reboot/react/internal/index.ts | 14 + reboot/react/package.json | 10 +- reboot/requirements.in | 1 + reboot/requirements_lock.txt | 4 + reboot/routing/envoy_config.py | 17 +- reboot/server/database.cc | 1214 +++++++++++------ reboot/server/database.h | 20 + reboot/server/database.py | 9 +- reboot/server/service_descriptor_validator.py | 46 +- reboot/settings.h | 12 +- reboot/settings.py | 24 +- reboot/std/package.json | 6 +- reboot/std/react/package.json | 10 +- reboot/templates/reboot.py.j2 | 270 ++-- reboot/templates/reboot_react.ts.j2 | 137 +- reboot/versions.bzl | 2 +- reboot/web/package.json | 4 +- tests/reboot/admin/BUILD.bazel | 17 + .../admin/export_import_large_data_tests.py | 127 ++ tests/reboot/admin/export_import_tests.py | 20 +- tests/reboot/aio/applications_test.py | 56 +- tests/reboot/cli/init/BUILD.bazel | 4 +- ...tput.txt => expected_multi_env_output.txt} | 0 .../cli/init/expected_nodejs_output.txt | 9 + tests/reboot/echo_rbt.golden.py | 828 +++++------ tests/reboot/effect_validation_tests.py | 12 +- .../examples/bank-zod/expected_output.txt | 1 - .../chat-room-nodejs/expected_output.txt | 1 - tests/reboot/greeter_rbt.golden.js | 2 +- tests/reboot/greeter_rbt.golden.py | 319 +++-- tests/reboot/greeter_rbt_react.golden.js | 828 ++++++++++- tests/reboot/idempotency_tests.py | 71 + .../nodejs/auth_integration_test/package.json | 2 +- .../input_error_integration_test/package.json | 2 +- .../nodejs/yarn_zod_test/backend/package.json | 6 +- tests/reboot/nodejs/yarn_zod_test/yarn.lock | 44 +- tests/reboot/ping/BUILD.bazel | 2 + tests/reboot/ping/ping_test.py | 798 ++++++++--- tests/reboot/protoc/helpers.bzl | 1 - .../pydantic/schema_validation_errors/test.py | 45 +- .../pydantic_web/default_values/BUILD.bazel | 1 - tests/reboot/pydantic_web/errors/BUILD.bazel | 1 - .../pydantic_web/nested_types/BUILD.bazel | 1 - .../pydantic_web/optional_fields/BUILD.bazel | 1 - tests/reboot/react/std/presence/BUILD.bazel | 1 - .../react/test_200_websockets/BUILD.bazel | 1 - tests/reboot/react/test_auth/BUILD.bazel | 1 - tests/reboot/react/test_errors/BUILD.bazel | 1 - .../react/test_http_fallback/BUILD.bazel | 1 - .../react/test_idempotent_writer/BUILD.bazel | 1 - .../test_long_running_fetches/BUILD.bazel | 1 - tests/reboot/react/test_mutations/BUILD.bazel | 1 - tests/reboot/react/test_reader/BUILD.bazel | 1 - tests/reboot/react/test_suspense/BUILD.bazel | 1 - .../react/test_undeclared_errors/BUILD.bazel | 1 - .../test_websockets_connection/BUILD.bazel | 1 - .../BUILD.bazel | 1 - tests/reboot/server/database_tests.cc | 234 ++++ .../BUILD.bazel | 138 +- .../pydantic_method_deleted_api.py | 2 +- ...pydantic_request_non_required_field_api.py | 22 + ...dantic_request_required_field_added_api.py | 23 + ...py => pydantic_state_field_deleted_api.py} | 2 +- ... pydantic_state_field_type_changed_api.py} | 0 .../pydantic_state_non_required_field_api.py | 18 + ..._api.py => pydantic_state_original_api.py} | 0 ...pydantic_state_required_field_added_api.py | 19 + .../test.py | 176 ++- .../BUILD.bazel | 134 +- .../service_descriptor_validator_zod/test.py | 173 ++- .../zod_request_non_required_field_api.ts | 17 + .../zod_request_required_field_added_api.ts | 18 + ..._api.ts => zod_state_field_deleted_api.ts} | 4 +- ...ts => zod_state_field_type_changed_api.ts} | 0 .../zod_state_non_required_field_api.ts | 14 + ...ginal_api.ts => zod_state_original_api.ts} | 0 .../zod_state_required_field_added_api.ts | 15 + tests/reboot/state_manager_tests.py | 6 +- tests/reboot/test_token_verifier.py | 4 +- 226 files changed, 8295 insertions(+), 2979 deletions(-) create mode 100644 reboot/aio/auth/oauth_providers.py create mode 100644 reboot/aio/auth/oauth_server.py create mode 100644 tests/reboot/admin/export_import_large_data_tests.py rename tests/reboot/cli/init/{expected_output.txt => expected_multi_env_output.txt} (100%) create mode 100644 tests/reboot/cli/init/expected_nodejs_output.txt create mode 100644 tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_non_required_field_api.py create mode 100644 tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_required_field_added_api.py rename tests/reboot/server/service_descriptor_validator_pydantic/{pydantic_field_deleted_api.py => pydantic_state_field_deleted_api.py} (80%) rename tests/reboot/server/service_descriptor_validator_pydantic/{pydantic_field_type_changed_api.py => pydantic_state_field_type_changed_api.py} (100%) create mode 100644 tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_non_required_field_api.py rename tests/reboot/server/service_descriptor_validator_pydantic/{pydantic_original_api.py => pydantic_state_original_api.py} (100%) create mode 100644 tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_required_field_added_api.py create mode 100644 tests/reboot/server/service_descriptor_validator_zod/zod_request_non_required_field_api.ts create mode 100644 tests/reboot/server/service_descriptor_validator_zod/zod_request_required_field_added_api.ts rename tests/reboot/server/service_descriptor_validator_zod/{zod_field_deleted_api.ts => zod_state_field_deleted_api.ts} (67%) rename tests/reboot/server/service_descriptor_validator_zod/{zod_field_type_changed_api.ts => zod_state_field_type_changed_api.ts} (100%) create mode 100644 tests/reboot/server/service_descriptor_validator_zod/zod_state_non_required_field_api.ts rename tests/reboot/server/service_descriptor_validator_zod/{zod_original_api.ts => zod_state_original_api.ts} (100%) create mode 100644 tests/reboot/server/service_descriptor_validator_zod/zod_state_required_field_added_api.ts diff --git a/.bazelrc b/.bazelrc index fdbcdb0e..a3cb197f 100644 --- a/.bazelrc +++ b/.bazelrc @@ -11,6 +11,14 @@ try-import submodules/dev-tools/.bazelrc # TODO: Remove the need to fallback to local. build --spawn_strategy=processwrapper-sandbox,local +# Run exclusive tests in a sandbox rather than with the bare +# local strategy. Without this, exclusive tests bypass the +# remote cache entirely (the local spawn runner does not +# consult the remote cache in Bazel 6.5). This flag defaults +# to true in Bazel 7, so this line can be removed when we +# upgrade. +test --incompatible_exclusive_test_sandboxed + # When we use C++, we use C++17. build --cxxopt='-std=c++17' build --host_cxxopt='-std=c++17' diff --git a/.github/actions/setup-bazel-remote-cache/action.yml b/.github/actions/setup-bazel-remote-cache/action.yml index b3188f78..8f221a2e 100644 --- a/.github/actions/setup-bazel-remote-cache/action.yml +++ b/.github/actions/setup-bazel-remote-cache/action.yml @@ -30,10 +30,19 @@ runs: echo "${{ inputs.environment-id }}" >> the_environment.txt fi echo $(uname -rsm) >> the_environment.txt - echo $(lscpu) >> the_environment.txt + # Exclude BogoMIPS from lscpu output: it's a + # runtime-measured value that fluctuates slightly on + # every boot (e.g. 5199.99 vs 5200.00), even on + # identical hardware, causing needless cache + # invalidation. + lscpu | grep -v BogoMIPS >> the_environment.txt if [ "${{ inputs.include-devcontainer-json }}" = "true" ]; then cat .devcontainer/devcontainer.json >> the_environment.txt fi + echo "the_environment.txt:" + echo "----" + cat the_environment.txt + echo "----" - name: Create user.bazelrc for remote cache shell: bash @@ -57,3 +66,9 @@ runs: echo "----" cat user.bazelrc echo "----" + # If there's a `public`/ folder, also write a `user.bazelrc` + # there - the `public/` folder represents a separate Bazel + # workspace that should also get the remote cache configuration. + if [ -d public ]; then + cp user.bazelrc public/user.bazelrc + fi diff --git a/Dockerfile b/Dockerfile index 707350d7..a302ccb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -316,6 +316,15 @@ RUN set -e; \ chmod +x ${BINARY_NAME}; \ mv ${BINARY_NAME} /usr/local/bin/envoy +# Install `ngrok`, useful in testing MCP servers from non-local clients +# like `claude.ai` - or to intercept traffic for inspection. +RUN curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \ + | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null \ + && echo "deb https://ngrok-agent.s3.amazonaws.com bookworm main" \ + | sudo tee /etc/apt/sources.list.d/ngrok.list \ + && sudo apt update \ + && sudo apt install ngrok + # Ensure presence of relevant system groups. RUN groupadd -f --system docker RUN groupadd -f --system ssh @@ -533,6 +542,29 @@ ARG FIREBASE_VERSION=13.26.0 RUN npm install -g corepack firebase-tools@${FIREBASE_VERSION} \ && corepack enable +# Install `kubectl krew` plugin manager, and the `resource-capacity` +# plugin. We use a shared `KREW_ROOT` so that any user can run `kubectl +# krew` without permission issues. +ARG KREW_VERSION=v0.4.4 +ENV KREW_ROOT=/opt/krew +RUN set -e; \ + case "${TARGETARCH}" in \ + amd64|arm64) ;; \ + *) echo "Unsupported arch for krew: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + TMPDIR="$(mktemp -d)" \ + && cd "${TMPDIR}" \ + && KREW="krew-linux_${TARGETARCH}" \ + && curl -fsSLO "https://github.com/kubernetes-sigs/krew/releases/download/${KREW_VERSION}/${KREW}.tar.gz" \ + && tar zxf "${KREW}.tar.gz" \ + && ./"${KREW}" install krew \ + && ln -s "${KREW_ROOT}/bin/kubectl-krew" /usr/local/bin/kubectl-krew \ + && kubectl krew install resource-capacity \ + && ln -s "${KREW_ROOT}/bin/kubectl-resource_capacity" /usr/local/bin/kubectl-resource_capacity \ + && chown -R ${UNAME}: "${KREW_ROOT}" \ + && chmod -R a+rX "${KREW_ROOT}" \ + && rm -rf "${TMPDIR}" + # Install Claude Code. We're not worried about breaking changes in this # tool (we don't use its API or CLI, it's human-driven) so we don't need # to pin its version, and in fact leave its default auto-update function diff --git a/README.md b/README.md index 43aca4d0..30cae98c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): +class UserState(Model): pass @@ -61,8 +61,8 @@ class IncrementRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( request=None, diff --git a/bazel/rocksdb/rocksdb.BUILD b/bazel/rocksdb/rocksdb.BUILD index 5041b4f6..9a3c3afb 100644 --- a/bazel/rocksdb/rocksdb.BUILD +++ b/bazel/rocksdb/rocksdb.BUILD @@ -61,7 +61,6 @@ cmake( }, lib_source = ":all", out_static_libs = ["librocksdb.a"], - tags = ["no-cache"], visibility = ["//visibility:public"], deps = [ "@com_github_gflags_gflags//:gflags", diff --git a/charts/reboot/Chart.yaml b/charts/reboot/Chart.yaml index 8f41fa25..bb4a80e9 100644 --- a/charts/reboot/Chart.yaml +++ b/charts/reboot/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: 3.3.2 name: reboot -version: "0.45.2" +version: "0.46.0" description: Reboot is a programming framework that enables transactional microservices built with the developer in mind. type: application keywords: @@ -10,4 +10,4 @@ keywords: - scalable - reactive home: https://docs.reboot.dev/ -appVersion: "0.45.2" +appVersion: "0.46.0" diff --git a/ci/templates/setup-bazel-remote-cache.action.yml b/ci/templates/setup-bazel-remote-cache.action.yml index 77f0a2ba..925b3dc0 100644 --- a/ci/templates/setup-bazel-remote-cache.action.yml +++ b/ci/templates/setup-bazel-remote-cache.action.yml @@ -26,10 +26,19 @@ runs: echo "${{ inputs.environment-id }}" >> the_environment.txt fi echo $(uname -rsm) >> the_environment.txt - echo $(lscpu) >> the_environment.txt + # Exclude BogoMIPS from lscpu output: it's a + # runtime-measured value that fluctuates slightly on + # every boot (e.g. 5199.99 vs 5200.00), even on + # identical hardware, causing needless cache + # invalidation. + lscpu | grep -v BogoMIPS >> the_environment.txt if [ "${{ inputs.include-devcontainer-json }}" = "true" ]; then cat .devcontainer/devcontainer.json >> the_environment.txt fi + echo "the_environment.txt:" + echo "----" + cat the_environment.txt + echo "----" - name: Create user.bazelrc for remote cache shell: bash @@ -53,3 +62,9 @@ runs: echo "----" cat user.bazelrc echo "----" + # If there's a `public`/ folder, also write a `user.bazelrc` + # there - the `public/` folder represents a separate Bazel + # workspace that should also get the remote cache configuration. + if [ -d public ]; then + cp user.bazelrc public/user.bazelrc + fi diff --git a/documentation/docs/ai_chat_apps/examples.mdx b/documentation/docs/ai_chat_apps/examples.mdx index b4b0b691..dc46a7d0 100644 --- a/documentation/docs/ai_chat_apps/examples.mdx +++ b/documentation/docs/ai_chat_apps/examples.mdx @@ -11,8 +11,8 @@ Code, Goose, or any compatible AI client. It demonstrates: * `UI` methods that open React UIs in the AI chat interface. -* The `Session` type: auto-constructed per MCP session, acting - as an entry point that creates other state types. +* The `User` type: auto-constructed per authenticated user, + acting as an entry point that creates other state types. * Generated React hooks (`useCounter()`) that work in both AI and browser contexts. * `App.tsx` component that implements the UI and receive AI-provided diff --git a/documentation/docs/ai_chat_apps/get_started.mdx b/documentation/docs/ai_chat_apps/get_started.mdx index e066f614..64bfb668 100644 --- a/documentation/docs/ai_chat_apps/get_started.mdx +++ b/documentation/docs/ai_chat_apps/get_started.mdx @@ -59,17 +59,20 @@ mkdir -p api/ai_chat_counter/v1 && touch api/ai_chat_counter/v1/counter.py title="Define your state and methods" description={` -Define a \`Session\` type and a \`Counter\` type. +Define a \`User\` type and a \`Counter\` type. -Naming a type \`Session\` in your \`API(...)\` is special: +Naming a type \`User\` in your \`API(...)\` is special: -- A new \`Session\` instance is automatically created for each MCP - session that connects to your app. -- All methods on \`Session\` are automatically callable by the AI. -- \`Session\` acts as an entry point: its methods create other state +- A \`User\` instance is automatically created for each authenticated + user that connects to your app. +- During development all users of your app are automatically + "authenticated" with the built-in \`Anonymous\` OAuth provider, so every + user of your app gets a \`User\` instance. +- All methods on \`User\` are automatically callable by the AI. +- \`User\` acts as an entry point: its methods create other state types (like \`Counter\`) whose IDs the AI tracks in its context window. -- Methods on non-\`Session\` types use \`mcp=Tool()\` to be +- Methods on non-\`User\` types use \`mcp=Tool()\` to be AI-callable. `}> @@ -93,16 +96,34 @@ from reboot.api import ( ) +class CreateCounterRequest(Model): + description: str = Field(tag=1) + + class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) + + +class DescriptionResponse(Model): + description: str = Field(tag=1) class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") class GetResponse(Model): @@ -115,17 +136,26 @@ class IncrementRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns " - "the ID of the new counter. That ID is " - "not human-readable; pass it to future " - "tool calls where needed, but no need to " - "tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created " + "by this user. Returns `counter_id` and " + "description for each. The `counter_id` " + "is not human-readable, but use it when " + "calling tools that take a `counter_id`.", ), ), ), @@ -140,7 +170,7 @@ api = API( "for the counter.", ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -158,6 +188,10 @@ api = API( "the specified amount.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) @@ -166,10 +200,10 @@ api = API( :::info Naming conventions -Your state class should end in `State`, e.g., `SessionState`, -`CounterState`. All fields on `Session` state must have default -values, because `Session` instances are auto-constructed for -each MCP session. +Your state class should end in `State`, e.g., `UserState`, +`CounterState`. All fields on `User` state must have default +values, because `User` instances are auto-constructed for +each authenticated user. ::: @@ -193,7 +227,7 @@ mkdir -p backend/src/servicers && touch backend/src/servicers/counter.py title="Implement your servicer" description={` -You implement one servicer class per type. \`SessionServicer\` +You implement one servicer class per type. \`UserServicer\` handles the \`create_counter\` transaction; \`CounterServicer\` handles the counter operations. Each method gets a \`context\` and optionally a \`request\`. You access state via @@ -206,7 +240,12 @@ and optionally a \`request\`. You access state via ```python # backend/src/servicers/counter.py -from ai_chat_counter.v1.counter_rbt import Counter, Session +from ai_chat_counter.v1.counter import ( + CreateCounterRequest, + CreateCounterResponse, + ListCountersResponse, +) +from ai_chat_counter.v1.counter_rbt import Counter, User from reboot.aio.auth.authorizers import Authorizer, allow from reboot.aio.contexts import ( ReaderContext, @@ -215,22 +254,39 @@ from reboot.aio.contexts import ( ) -class SessionServicer(Session.Servicer): - """Servicer for the Session state machine.""" - - def authorizer(self) -> Authorizer: - return allow() +class UserServicer(User.Servicer): + """Servicer for the User state machine.""" async def create_counter( self, context: TransactionContext, - ) -> Session.CreateCounterResponse: + request: CreateCounterRequest, + ) -> CreateCounterResponse: """Create a new Counter and return its ID.""" - counter, _ = await Counter.create(context) - return Session.CreateCounterResponse( + counter, _ = await Counter.create( + context, description=request.description + ) + self.state.counter_ids.append(counter.state_id) + return CreateCounterResponse( counter_id=counter.state_id, ) + async def list_counters( + self, + context: ReaderContext, + ) -> ListCountersResponse: + """List all counters created by this user.""" + counters = [] + for counter_id in self.state.counter_ids: + response = await Counter.ref(counter_id).description(context) + counters.append( + User.CounterEntry( + counter_id=counter_id, + description=response.description, + ) + ) + return ListCountersResponse(counters=counters) + class CounterServicer(Counter.Servicer): """Servicer for the Counter state machine.""" @@ -238,8 +294,22 @@ class CounterServicer(Counter.Servicer): def authorizer(self) -> Authorizer: return allow() - async def create(self, context) -> None: - pass + async def create( + self, + context: WriterContext, + request: CreateCounterRequest, + ) -> None: + """Initialize the counter with a description.""" + self.state.description = request.description + + async def description( + self, + context: ReaderContext, + ) -> Counter.DescriptionResponse: + """Get the counter's description.""" + return Counter.DescriptionResponse( + description=self.state.description, + ) async def increment( self, @@ -282,12 +352,14 @@ touch backend/src/main.py # backend/src/main.py import asyncio from reboot.aio.applications import Application -from servicers.counter import CounterServicer, SessionServicer +from reboot.aio.auth.oauth_providers import Anonymous +from servicers.counter import CounterServicer, UserServicer async def main() -> None: application = Application( - servicers=[SessionServicer, CounterServicer], + servicers=[UserServicer, CounterServicer], + oauth=Anonymous(), ) await application.run() @@ -689,7 +761,8 @@ touch mcp_servers.json "mcpServers": { "counter-server": { "type": "streamable-http", - "url": "http://localhost:9991/mcp" + "url": "http://localhost:9991/mcp", + "auth": "oauth" } } } @@ -743,8 +816,8 @@ next steps: the AI passes props to the React component. * **[Creating tools](/learn_more/define/pydantic#creating-tools-for-the-ai)** — how to control which methods are callable by the AI, including - exposing methods on non-`Session` state types, or hiding some - `Session` methods from the AI. + exposing methods on non-`User` state types, or hiding some + `User` methods from the AI. * **[How Reboot uses MCP](/learn_more/mcp_apps)** — learn how AI Chat Apps work under the hood. * **[AI Chat App Examples](/ai_chat_apps/examples)** diff --git a/documentation/docs/ai_chat_apps/get_started_claude_code.mdx b/documentation/docs/ai_chat_apps/get_started_claude_code.mdx index 7b3943a2..f6952b23 100644 --- a/documentation/docs/ai_chat_apps/get_started_claude_code.mdx +++ b/documentation/docs/ai_chat_apps/get_started_claude_code.mdx @@ -93,9 +93,9 @@ my-app/ └── pyproject.toml ``` -The API definition uses the same `Session` + application type +The API definition uses the same `User` + application type pattern described in the -[hand-written guide](/ai_chat_apps/get_started): `Session` is the +[hand-written guide](/ai_chat_apps/get_started): `User` is the auto-constructed entry point whose methods create other state types, and those types' methods use `mcp=Tool()` to be callable by the AI. diff --git a/documentation/docs/ai_chat_apps/what_is.mdx b/documentation/docs/ai_chat_apps/what_is.mdx index fbbe36d7..d1f0cf4d 100644 --- a/documentation/docs/ai_chat_apps/what_is.mdx +++ b/documentation/docs/ai_chat_apps/what_is.mdx @@ -61,16 +61,34 @@ from reboot.api import ( ) +class CreateCounterRequest(Model): + description: str = Field(tag=1) + + class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) + + +class DescriptionResponse(Model): + description: str = Field(tag=1) class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") class GetResponse(Model): @@ -83,17 +101,26 @@ class IncrementRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns " - "the ID of the new counter. That ID is " - "not human-readable; pass it to future " - "tool calls where needed, but no need to " - "tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created " + "by this user. Returns `counter_id` and " + "description for each. The `counter_id` " + "is not human-readable, but use it when " + "calling tools that take a `counter_id`.", ), ), ), @@ -108,7 +135,7 @@ api = API( "for the counter.", ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -126,6 +153,10 @@ api = API( "the specified amount.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) @@ -133,7 +164,7 @@ api = API( -All methods on `Session` are automatically callable by the AI. +All methods on `User` are automatically callable by the AI. Methods on other types (like `Counter`) are exposed with `mcp=Tool()`. The AI receives the state ID when it creates a new instance and uses it in subsequent calls. diff --git a/documentation/docs/learn_more/applications.mdx b/documentation/docs/learn_more/applications.mdx index 929991a4..0550d040 100644 --- a/documentation/docs/learn_more/applications.mdx +++ b/documentation/docs/learn_more/applications.mdx @@ -98,7 +98,7 @@ should use to [authenticate](/learn_more/auth) itself to your application. ## AI Chat App auto-registration -When your API includes a `Session` type or +When your API includes a `User` type or [`UI`](/learn_more/implement/ui_methods) methods, `Application` automatically registers the necessary endpoints for AI chat clients. No extra configuration is needed in your `main.py` — @@ -107,7 +107,7 @@ everything: ```python application = Application( - servicers=[SessionServicer, CounterServicer], + servicers=[UserServicer, CounterServicer], ) await application.run() ``` diff --git a/documentation/docs/learn_more/auth.mdx b/documentation/docs/learn_more/auth.mdx index 8766f0f1..9f1e6a85 100644 --- a/documentation/docs/learn_more/auth.mdx +++ b/documentation/docs/learn_more/auth.mdx @@ -162,16 +162,16 @@ was valid: +(CODE:src=../../../reboot/aio/auth/token_verifiers.py&lines=30-35) --> ```py -@abstractmethod -async def verify_token( - self, - context: ReaderContext, token: Optional[str], -) -> Optional[Auth]: +) -> VerifyTokenResult: + """Verify the bearer token. + + Returns: + * `Auth` if the token is valid and the caller is ``` @@ -179,7 +179,7 @@ async def verify_token( +(CODE:src=../../../reboot/nodejs/index.ts&lines=662-665) --> ```ts @@ -380,6 +380,17 @@ for. By default, if you don't provide an authorizer by overriding `authorizer()`, **only _application internal_ calls are allowed**. +#### `User` type default + +The [`User`](/learn_more/define/pydantic#special-state-user) type is +a special case: its default authorizer also allows calls where +the caller's `user_id` matches the `User`'s state ID. In other +words, each user can access their own `User` state without any +custom authorizer. App-internal calls are allowed as well. + +If you need different rules, you can override override `authorizer()` as +with any other state type. + :::important Why am I seeing log messages about **MISSING AUTHORIZATION**? diff --git a/documentation/docs/learn_more/call/from_react.mdx b/documentation/docs/learn_more/call/from_react.mdx index c3556d1e..1dc0e969 100644 --- a/documentation/docs/learn_more/call/from_react.mdx +++ b/documentation/docs/learn_more/call/from_react.mdx @@ -232,10 +232,10 @@ The key differences: ``` -- **Automatic ID resolution.** For `Session` methods, the hook - resolves the state ID from the MCP session automatically. For - other types (like `Counter`), the ID is provided by the - framework when the UI is opened: +- **Automatic ID resolution.** For `User` methods, the hook + resolves the state ID from the authenticated user + automatically. For other types (like `Counter`), the ID is + provided by the framework when the UI is opened: ```tsx import { useCounter } from "@api/ai_chat_counter/v1/counter_rbt_react"; diff --git a/documentation/docs/learn_more/define/methods.mdx b/documentation/docs/learn_more/define/methods.mdx index e4502862..aea0bf99 100644 --- a/documentation/docs/learn_more/define/methods.mdx +++ b/documentation/docs/learn_more/define/methods.mdx @@ -35,9 +35,9 @@ The five kinds of methods in Reboot are: ## Tool exposure -Methods on the `Session` state type are automatically exposed +Methods on the `User` state type are automatically exposed as tools callable by the AI. Methods on other state types can -be exposed with `mcp=Tool()`. To prevent a `Session` method +be exposed with `mcp=Tool()`. To prevent a `User` method from being AI-callable, set `mcp=False`. See [Creating tools](/learn_more/define/pydantic#creating-tools-for-the-ai) for details. diff --git a/documentation/docs/learn_more/define/overview.mdx b/documentation/docs/learn_more/define/overview.mdx index d344d228..4baa4350 100644 --- a/documentation/docs/learn_more/define/overview.mdx +++ b/documentation/docs/learn_more/define/overview.mdx @@ -33,7 +33,7 @@ Reboot generates: - [Python and TypeScript servers](/learn_more/implement/servicers) - [Python and TypeScript clients](/learn_more/call/from_within_your_app) - [React](/learn_more/call/from_react) -- AI chat tools (from `Session` methods and +- AI chat tools (from `User` methods and [`UI`](/learn_more/implement/ui_methods) annotations) :::note diff --git a/documentation/docs/learn_more/define/pydantic.mdx b/documentation/docs/learn_more/define/pydantic.mdx index ec55d9da..0e6f409e 100644 --- a/documentation/docs/learn_more/define/pydantic.mdx +++ b/documentation/docs/learn_more/define/pydantic.mdx @@ -10,9 +10,9 @@ for full-stack apps with Python backends. ## AI Chat App A Reboot API is centered on _types_, which contain _state_ and -_methods_. When building an AI Chat App, you define a `Session` -type (auto-constructed per MCP session) whose methods create -other state types. Methods become +_methods_. When building an AI Chat App, you define a `User` +type (auto-constructed per authenticated user) whose methods +create other state types. Methods become [tools the AI can call](/learn_more/mcp_apps): @@ -34,16 +34,34 @@ from reboot.api import ( ) +class CreateCounterRequest(Model): + description: str = Field(tag=1) + + class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) + + +class DescriptionResponse(Model): + description: str = Field(tag=1) class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") class GetResponse(Model): @@ -56,17 +74,26 @@ class IncrementRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns " - "the ID of the new counter. That ID is " - "not human-readable; pass it to future " - "tool calls where needed, but no need to " - "tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created " + "by this user. Returns `counter_id` and " + "description for each. The `counter_id` " + "is not human-readable, but use it when " + "calling tools that take a `counter_id`.", ), ), ), @@ -81,7 +108,7 @@ api = API( "for the counter.", ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -99,6 +126,10 @@ api = API( "the specified amount.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) @@ -117,42 +148,47 @@ All Pydantic fields must include a `tag` parameter using ::: :::info Naming conventions -Your state class should end in `State`, e.g., `SessionState`, +Your state class should end in `State`, e.g., `UserState`, `CounterState`. ::: -## Special state: `Session` +## Special state: `User` -Naming a type `Session` in your `API(Session=Type(...))` +Naming a type `User` in your `API(User=Type(...))` definition triggers special behavior: -1. **Auto-construction**: a `Session` instance is automatically - created for each new MCP session. You do not need an +1. **Auto-construction**: a `User` instance is automatically + created for each authenticated user. You do not need an `initialize` function or manual `create()` calls. All fields - on `SessionState` must have default values, since instances + on `UserState` must have default values, since instances are created without arguments. -2. **Auto-tool-exposure**: all `Session` methods are +2. **Auto-tool-exposure**: all `User` methods are automatically exposed as tools (unless `mcp=False`). 3. **Automatic ID resolution**: the AI never needs to specify a - state ID for `Session` methods — it is resolved from the - MCP session. - -`Session` typically acts as an entry point: its methods create or look + state ID for `User` methods — it is resolved from the + authenticated user. +4. **Default authorization**: `User` methods are accessible to + the owning user (whose `user_id` matches the state ID) and + to app-internal calls. No `authorizer()` override is needed. + See [Default authorizer](/learn_more/auth#default-authorizer) + for details. + +`User` typically acts as an entry point: its methods create or look up other state types (like `Counter`) and return their IDs. The AI tracks those IDs in its context window and passes them in subsequent tool calls. ## Creating tools for the AI -### `Session` methods are AI-callable by default +### `User` methods are AI-callable by default -All methods on the `Session` state type are automatically +All methods on the `User` state type are automatically exposed as MCP tools the AI can call. The `description` field on each method is what the AI reads to decide when and how to call a tool: ```python -SessionMethods = Methods( +UserMethods = Methods( create_counter=Transaction( request=None, response=CreateCounterResponse, @@ -166,12 +202,12 @@ SessionMethods = Methods( ### Opting out with `mcp=False` -To prevent a `Session` method from being AI-callable, set +To prevent a `User` method from being AI-callable, set `mcp=False`: ```python -SessionMethods = Methods( - # AI-callable (default for Session methods). +UserMethods = Methods( + # AI-callable (default for User methods). create_counter=Transaction( request=None, response=CreateCounterResponse, @@ -187,11 +223,11 @@ SessionMethods = Methods( ) ``` -### Exposing non-`Session` methods to the AI +### Exposing non-`User` methods to the AI -Methods on state types other than `Session` are **not** +Methods on state types other than `User` are **not** AI-callable by default. To expose them, add `mcp=Tool()`. -This is how you expose methods on the types that `Session` +This is how you expose methods on the types that `User` creates: ```python @@ -205,7 +241,7 @@ CounterMethods = Methods( description="Get the current counter value.", mcp=Tool(), ), - # NOT AI-callable (default for non-Session methods). + # NOT AI-callable (default for non-User methods). create=Writer( request=None, response=None, @@ -214,9 +250,9 @@ CounterMethods = Methods( ) api = API( - Session=Type( - state=SessionState, - methods=SessionMethods, + User=Type( + state=UserState, + methods=UserMethods, ), Counter=Type( state=CounterState, @@ -225,7 +261,7 @@ api = API( ) ``` -For non-`Session` types, the AI must specify a state ID when +For non-`User` types, the AI must specify a state ID when calling the tool. It receives the ID when it creates the instance (e.g., from `create_counter`). @@ -270,7 +306,7 @@ write (e.g., `Writer`) the state. A type can have any number of ## Full-stack You can also use Pydantic for full-stack apps that are not AI Chat Apps. -In that case you do not need the `Session` type, `UI` methods, or +In that case you do not need the `User` type, `UI` methods, or `description` fields: ```python diff --git a/documentation/docs/learn_more/implement/ui_methods.mdx b/documentation/docs/learn_more/implement/ui_methods.mdx index 9bbbd463..5f08778f 100644 --- a/documentation/docs/learn_more/implement/ui_methods.mdx +++ b/documentation/docs/learn_more/implement/ui_methods.mdx @@ -18,19 +18,17 @@ from a browser or backend. ```python -class SessionState(Model): - pass +class CreateCounterResponse(Model): + counter_id: str = Field(tag=1) -class CounterState(Model): - value: int = Field(tag=1, default=0) +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) -class GetResponse(Model): - value: int = Field(tag=1) - - -class IncrementRequest(Model): +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) ``` @@ -54,12 +52,13 @@ From the `ai-chat-counter` example: ```python -class SessionState(Model): - pass +class CreateCounterResponse(Model): + counter_id: str = Field(tag=1) -class CounterState(Model): - value: int = Field(tag=1, default=0) +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) ``` @@ -72,19 +71,17 @@ From the `ai-chat-counter-dashboard` example: ```python -class SessionState(Model): - pass - - -class CounterState(Model): - value: int = Field(tag=1, default=0) +class CreateCounterResponse(Model): + counter_id: str = Field(tag=1) -class GetResponse(Model): - value: int = Field(tag=1) +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) -class IncrementRequest(Model): +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) ``` diff --git a/documentation/docs/learn_more/mcp_apps.mdx b/documentation/docs/learn_more/mcp_apps.mdx index 8db18823..6c22889f 100644 --- a/documentation/docs/learn_more/mcp_apps.mdx +++ b/documentation/docs/learn_more/mcp_apps.mdx @@ -14,15 +14,16 @@ server exposes **tools** (actions the AI can call) and ## How Reboot uses MCP -When your Reboot API includes a `Session` type, `Application` +When your Reboot API includes a `User` type, `Application` automatically registers an MCP endpoint at `/mcp`. This means: -- **`Session` methods become MCP tools.** All methods on the - `Session` type are automatically exposed as MCP tools. The AI - calls them without specifying a state ID — the right `Session` - is resolved from the MCP session. `Session` typically acts as - an entry point, with methods that create other state types. -- **Non-`Session` methods can be tools too.** Methods on other +- **`User` methods become MCP tools.** All methods on the + `User` type are automatically exposed as MCP tools. The AI + calls them without specifying a state ID — the right `User` + is resolved from the authenticated user. `User` typically + acts as an entry point, with methods that create other state + types. +- **Non-`User` methods can be tools too.** Methods on other types (like `Counter`) are exposed with `mcp=Tool()`. The AI receives the state ID when it creates the instance and passes it in subsequent calls. @@ -36,8 +37,8 @@ serialization, transport, session management, and state routing. ## Controlling which methods are tools -`Session` methods are MCP tools by default — to opt out, set -`mcp=False`. Non-`Session` methods are **not** tools by +`User` methods are MCP tools by default — to opt out, set +`mcp=False`. Non-`User` methods are **not** tools by default — to opt in, set `mcp=Tool()`. Not every method should be an MCP tool: @@ -53,31 +54,23 @@ Not every method should be an MCP tool: For the full API reference and code examples, see [Creating tools](/learn_more/define/pydantic#creating-tools-for-the-ai). -## Sessions and `Session` state - -When an MCP client begins a new MCP session with your app, Reboot -automatically creates a `Session` state instance for that session. -`Session` acts as an entry point: its methods typically create other -state types (like `Counter`) whose IDs are returned to the AI. The AI -can then use those IDs to make further calls (like -`Counter.increment()`) directly to those states. The AI tracks IDs -in its context window, which is scoped to the chat conversation. - -Reboot issues a UUIDv7 session ID for each newly connected -session. Reboot handles persistence on the server side, so -state created during a session is durable and can be accessed -again later. - -How long a session is (re)used is up to the MCP client; different -providers have different behaviors, which are typically not -well-documented. The most common behaviors we've observed are: - -* **Many different conversations reuse the same session** - Claude, - VSCode, MCPJam, [...]. Sessions are never shared across users. -* **Most tool calls get a new session** - ChatGPT. - -Note that, despite their encouraging name, sessions do not represent -conversations. +## Users and `User` state + +When an authenticated user connects to your app via MCP, Reboot +automatically creates a `User` state instance for that user (if one does +not already exist). `User` acts as an entry point: its methods typically +create other state types (like `Counter`) whose IDs can be stored in the +`User`'s state. Such IDs can also be returned to the AI, which can use +them to make further calls (like `Counter.increment()`) directly to +those states. The AI tracks IDs in its context window, which is scoped +to the chat conversation. To remember IDs or other info across +conversations, place it in `User` state and provide methods on `User` +for the AI to use to fetch that information. + +The `User` state ID is derived from the authenticated user's identity +(e.g. the `sub` claim in an OAuth token). Reboot handles persistence on +the server side, so state created by a user is durable and accessible +across sessions and conversations. ## Connecting AI clients diff --git a/mypy.ini b/mypy.ini index b9bd3db2..649d1039 100644 --- a/mypy.ini +++ b/mypy.ini @@ -130,6 +130,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-udpa.annotations.*] ignore_missing_imports = True +[mypy-ulid.*] +ignore_missing_imports = True [mypy-urllib3.*] ignore_missing_imports = True [mypy-watchdog.*] diff --git a/package.json b/package.json index c5c1b960..b5306b87 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "@reboot-dev/reboot-std": "workspace:*", "@reboot-dev/reboot-web": "workspace:*", "@reboot-dev/reboot": "workspace:*", - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", "@bufbuild/protobuf": "1.10.1", "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", diff --git a/rbt/cloud/v1alpha1/application/application.proto b/rbt/cloud/v1alpha1/application/application.proto index 0ccf97ec..662afb98 100644 --- a/rbt/cloud/v1alpha1/application/application.proto +++ b/rbt/cloud/v1alpha1/application/application.proto @@ -3,8 +3,9 @@ syntax = "proto3"; package rbt.cloud.v1alpha1.application; import "google/protobuf/timestamp.proto"; -import "rbt/v1alpha1/options.proto"; + import "rbt/cloud/v1alpha1/logs/logs.proto"; +import "rbt/v1alpha1/options.proto"; ///////////////////////////////////////////////////////////////////////// @@ -365,6 +366,7 @@ message GetResponse { repeated Revision revisions = 2; ApplicationMetrics metrics = 3; string application_name = 4; + string application_id = 5; } ////////////////////////////////////////////////////////////////////// @@ -395,6 +397,13 @@ message PaymentMethodRequiredError {} ////////////////////////////////////////////////////////////////////// -message MetricsWorkflowRequest {} +message MetricsWorkflowRequest { + // The revision number the workflow is scheduled for. + // We need this to be able to "complete" the workflow when a new + // revision is deployed (and a new `metrics_workflow` is started), so + // the "old" revision will be marked as non-`UP` and the workflow + // will stop. + uint64 revision_number = 1; +} message MetricsWorkflowResponse {} diff --git a/rbt/std/package.json b/rbt/std/package.json index 73638178..988ecd5e 100644 --- a/rbt/std/package.json +++ b/rbt/std/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-std-api", - "version": "0.45.2", + "version": "0.46.0", "description": "Reboot standard library API.", "main": "index.js", "type": "module", diff --git a/rbt/v1alpha1/database.proto b/rbt/v1alpha1/database.proto index e8c217c5..6d04dc7c 100644 --- a/rbt/v1alpha1/database.proto +++ b/rbt/v1alpha1/database.proto @@ -516,9 +516,15 @@ service Database { rpc TransactionCoordinatorCleanup(TransactionCoordinatorCleanupRequest) returns (TransactionCoordinatorCleanupResponse); - // TODO: This should be streaming: see #3329. + // Exports all items for the given state type and shards. Fails + // with `FAILED_PRECONDITION` if the data exceeds the gRPC message + // size limit; use `ExportStreamed` instead for large datasets. rpc Export(ExportRequest) returns (ExportResponse); + // Like `Export`, but streams batched responses to handle datasets + // larger than the gRPC message size limit. + rpc ExportStreamed(ExportRequest) returns (stream ExportResponse); + // Gets application metadata from persistent storage. rpc GetApplicationMetadata(GetApplicationMetadataRequest) returns (GetApplicationMetadataResponse); diff --git a/rbt/v1alpha1/errors.proto b/rbt/v1alpha1/errors.proto index b9659990..84b77e78 100644 --- a/rbt/v1alpha1/errors.proto +++ b/rbt/v1alpha1/errors.proto @@ -98,7 +98,9 @@ message DataLoss {} //////////////////////////////////////////////////////////////////////// -message Unauthenticated {} +message Unauthenticated { + string message = 1; +} //////////////////////////////////////////////////////////////////////// diff --git a/rbt/v1alpha1/options.proto b/rbt/v1alpha1/options.proto index b4020072..78dc45d0 100644 --- a/rbt/v1alpha1/options.proto +++ b/rbt/v1alpha1/options.proto @@ -133,6 +133,14 @@ extend google.protobuf.ServiceOptions { ServiceOptions service = 50000; } +// Whether and when this state type is auto-constructed. +enum AutoConstruct { + // Not auto-constructed (default). + AUTO_CONSTRUCT_UNSPECIFIED = 0; + // One instance per authenticated user, keyed by user ID. + PER_USER_ID = 1; +} + message StateOptions { // A list of names of Reboot method definition `service`s that provide this // state `message` with methods. May be omitted if the system's default naming @@ -142,6 +150,9 @@ message StateOptions { // UIs associated with this state type (from UI() methods). repeated UI uis = 2; + + // Whether and when this state type is auto-constructed. + AutoConstruct auto_construct = 3; } extend google.protobuf.MessageOptions { @@ -173,6 +184,9 @@ message FieldOptions { // The fully qualified Pydantic type for this field if the input schema // is from Pydantic. string pydantic_type = 1; + // Whether this field is required (has no default value). + // Set when generating proto from Pydantic and Zod. + bool required = 2; } extend google.protobuf.FieldOptions { diff --git a/rbt/v1alpha1/package.json b/rbt/v1alpha1/package.json index ad59a81a..c1fde32d 100644 --- a/rbt/v1alpha1/package.json +++ b/rbt/v1alpha1/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-api", - "version": "0.45.2", + "version": "0.46.0", "type": "module", "description": "npm package for Reboot API", "main": "index.js", diff --git a/reboot-skills/skills/reboot-chat-app/SKILL.md b/reboot-skills/skills/reboot-chat-app/SKILL.md index ceda560c..d383dbe3 100644 --- a/reboot-skills/skills/reboot-chat-app/SKILL.md +++ b/reboot-skills/skills/reboot-chat-app/SKILL.md @@ -73,7 +73,7 @@ wrong means regenerating everything across 12+ files. below 2. Enter plan mode (`EnterPlanMode`) 3. Present the proposed design: - - Session type and its methods (as the MCP front door to the + - User type and its methods (as the MCP front door to the application types discussed below, creating new ones and locating existing ones) - Application types: state shape (fields, types, tags) @@ -93,8 +93,8 @@ Before writing code, analyze the user's request: 1. **Application types**: What primary things is the user managing? (counter, inventory, chat thread, etc.) Each becomes its own `Type` with its own state. -2. **Session methods**: How does the AI create instances of - application types? Each gets a `Transaction` on `Session` +2. **User methods**: How does the AI create instances of + application types? Each gets a `Transaction` on `User` that calls `.create(context)`. 3. **State shape**: Fields, types — lists, nested objects, primitives. Each gets `Field(tag=N)`. @@ -102,7 +102,7 @@ Before writing code, analyze the user's request: - `Reader` — read-only queries - `Writer` — single-state mutations - `Transaction` — multi-state atomic operations (e.g., - transfer between two accounts, or Session creating an + transfer between two accounts, or User creating an application type instance) - `Workflow` — long-running control flows with loops, scheduling, and idempotency helpers @@ -114,16 +114,16 @@ Before writing code, analyze the user's request: ## Key Framework Concepts -### Session and Application Types +### User and Application Types -Every AI Chat App has a `Session` type and one or more application +Every AI Chat App has a `User` type and one or more application types: -- **`Session`** is auto-constructed for each AI chat session. Its +- **`User`** is auto-constructed for each authenticated user. Its state is typically empty. Its methods are `Transaction`s that create instances of application types, or `Reader`s that find the IDs of existing application type instances in indexes that - have well-known IDs of their own. `Session` methods are + have well-known IDs of their own. `User` methods are automatically exposed as MCP tools. - **Application types** (e.g., `Counter`) hold the actual application state. They need a `create` Writer with @@ -132,12 +132,12 @@ types: ### Tool Exposure Control -- **Session methods are tools by default.** All methods on - `Session` are automatically callable by the AI. -- **`mcp=False`**: Opt a Session method OUT of being AI-callable. +- **User methods are tools by default.** All methods on + `User` are automatically callable by the AI. +- **`mcp=False`**: Opt a User method OUT of being AI-callable. Use for human-only actions or to reduce context bloat. - **`mcp=Tool()`**: Opt an application type method IN to being - AI-callable. Required on methods of non-Session types. + AI-callable. Required on methods of non-User types. - **`Tool()` options**: `Tool(name="custom_name", title="Title")` to override the default tool name or add a human-readable title. @@ -154,7 +154,7 @@ types: - **`Reader`**: Read-only queries. Context: `ReaderContext`. - **`Transaction`**: Multi-state atomic operations. Context: `TransactionContext`. Use when an operation must modify multiple - state instances atomically, or when Session creates application + state instances atomically, or when User creates application type instances. - **`Workflow`**: Long-running control flows. Context: `WorkflowContext`. Implemented as `@classmethod` (not instance @@ -306,14 +306,14 @@ dependencies = [ "httpx>=0.27,<1.0", "uuid7>=0.1.0", "anyio>=4.0.0", - "reboot>=0.45.2", + "reboot>=0.46.0", ] [tool.rye] dev-dependencies = [ "mypy==1.18.1", "types-protobuf>=4.24.0.20240129", - "reboot>=0.45.2", + "reboot>=0.46.0", ] virtual = true @@ -327,12 +327,12 @@ Rules: - Import only the method types you use from `reboot.api` - Helper Model types as standalone classes - State model with `Field(tag=N)` on every field -- `Session` type with empty state and `Transaction` methods +- `User` type with empty state and `Transaction` methods that create application type instances - Application types with their own state and methods - Application type methods need `mcp=Tool()` to be AI-callable - Application types need a `create` Writer with `factory=True` -- `api = API(Session=Type(...), =Type(...))` +- `api = API(User=Type(...), =Type(...))` #### Simple Example (Counter) @@ -351,14 +351,14 @@ from reboot.api import ( ) -# -- Session models. -- +# -- User models. -- class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): +class UserState(Model): pass @@ -379,8 +379,8 @@ class AmountRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( request=None, @@ -506,7 +506,7 @@ messages, etc.): - In the servicer, import helpers standalone: `from .v1. import Item` -The counter example above shows the full Session + application +The counter example above shows the full User + application type pattern. Apply the same structure for any application type, adding whatever Writers and Readers your app needs. @@ -549,10 +549,10 @@ Rules: - Import helper types standalone: `from .v1. import MyItem` - Import generated classes: - `from .v1._rbt import Session, Counter` + `from .v1._rbt import User, Counter` - Each type gets its own servicer class - (e.g., `SessionServicer`, `CounterServicer`) -- `Session.Servicer` / `Counter.Servicer` base, `allow()` authorizer + (e.g., `UserServicer`, `CounterServicer`) +- `User.Servicer` / `Counter.Servicer` base, `allow()` authorizer - Context types from `reboot.aio.contexts`: - `ReaderContext` — read-only - `WriterContext` — single-state mutation @@ -565,7 +565,7 @@ Rules: #### Simple Servicer (Counter) ```python -from ai_chat_counter.v1.counter_rbt import Counter, Session +from ai_chat_counter.v1.counter_rbt import Counter, User from reboot.aio.auth.authorizers import allow from reboot.aio.contexts import ( ReaderContext, @@ -574,7 +574,7 @@ from reboot.aio.contexts import ( ) -class SessionServicer(Session.Servicer): +class UserServicer(User.Servicer): def authorizer(self): return allow() @@ -582,10 +582,10 @@ class SessionServicer(Session.Servicer): async def create_counter( self, context: TransactionContext, - ) -> Session.CreateCounterResponse: + ) -> User.CreateCounterResponse: """Create a new Counter and return its ID.""" counter, _ = await Counter.create(context) - return Session.CreateCounterResponse( + return User.CreateCounterResponse( counter_id=counter.state_id, ) @@ -652,7 +652,7 @@ async def do_ping_periodically( ### `main.py` -Register all servicers (Session + application types): +Register all servicers (User + application types): ```python import asyncio @@ -660,7 +660,7 @@ import logging from reboot.aio.applications import Application from servicers. import ( CounterServicer, - SessionServicer, + UserServicer, ) logging.basicConfig( @@ -671,7 +671,7 @@ logging.basicConfig( async def main() -> None: application = Application( - servicers=[SessionServicer, CounterServicer], + servicers=[UserServicer, CounterServicer], ) await application.run() @@ -696,10 +696,10 @@ if __name__ == "__main__": "build:watch": "concurrently \"npm:build:watch:*\"" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-api": "^0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zod": "^3.25.0" @@ -1223,7 +1223,7 @@ Adapt the CSS module to your app's needs. The CSS variables from `.XxxRequest`, `.XxxResponse`. 6. **React bindings use camelCase:** Python `from_index` becomes TypeScript `fromIndex`. -7. **`Session` methods are auto-exposed as MCP tools.** Application +7. **`User` methods are auto-exposed as MCP tools.** Application type methods require explicit `mcp=Tool()`. Use `mcp=False` to hide a method from the AI. 8. **Application types need `factory=True`** on their `create` @@ -1235,13 +1235,13 @@ Adapt the CSS module to your app's needs. The CSS variables from 11. **Generated React import path:** `@api//v1/_rbt_react` 12. **Generated Python import path:** - `from .v1._rbt import Session, Counter` + `from .v1._rbt import User, Counter` 13. **Use `--default-config=hmr`** in `.rbtrc` (not `--default=hmr`). 14. **`UI(path="web/ui/")`** — path is relative to project root. 15. **`UI(request=)`** passes config as React component props. `UI(request=None)` passes no props. 16. **Register all servicers** in `main.py`: - `Application(servicers=[SessionServicer, CounterServicer])`. + `Application(servicers=[UserServicer, CounterServicer])`. 17. The requests and responses on the frontend are always Zod types generated from the Python Models. diff --git a/reboot/aio/BUILD.bazel b/reboot/aio/BUILD.bazel index d74784d0..d3faa162 100644 --- a/reboot/aio/BUILD.bazel +++ b/reboot/aio/BUILD.bazel @@ -33,6 +33,8 @@ py_library( ":tracing_py", ":workflows_py", "//reboot:run_environments_py", + "//reboot/aio/auth:oauth_providers_py", + "//reboot/aio/auth:oauth_server_py", "//reboot/aio/auth:token_verifiers_py", "//reboot/aio/internals:contextvars_py", "//reboot/cli:terminal_py", diff --git a/reboot/aio/applications.py b/reboot/aio/applications.py index bcc795d7..b6bf0b64 100644 --- a/reboot/aio/applications.py +++ b/reboot/aio/applications.py @@ -10,9 +10,11 @@ from log.log import get_logger from mcp.server.fastmcp import FastMCP from pathlib import Path +from reboot.aio.auth.oauth_providers import Anonymous, OAuthProvider +from reboot.aio.auth.oauth_server import OAuthServer from reboot.aio.auth.token_verifiers import TokenVerifier from reboot.aio.exceptions import InputError -from reboot.aio.external import InitializeContext +from reboot.aio.external import ExternalContext, InitializeContext from reboot.aio.http import NodeWebFramework, PythonWebFramework, WebFramework from reboot.aio.internals.channel_manager import _ChannelManager from reboot.aio.libraries import AbstractLibrary @@ -20,7 +22,7 @@ from reboot.aio.servers import ConfigServer from reboot.aio.servicers import Serviceable, Servicer from reboot.aio.tracing import function_span -from reboot.aio.types import ServerId +from reboot.aio.types import ServerId, StateTypeName from reboot.cli import terminal from reboot.controller.server_managers import ( run_nodejs_server_process, @@ -33,6 +35,7 @@ InvalidRunEnvironment, RunEnvironment, detect_run_environment, + running_rbt_dev, within_nodejs_server, within_python_server, ) @@ -45,12 +48,15 @@ ENVVAR_REBOOT_CLOUD_DATABASE_ADDRESS, ENVVAR_REBOOT_LOCAL_ENVOY, ENVVAR_REBOOT_LOCAL_ENVOY_PORT, + ENVVAR_REBOOT_OAUTH_SIGNING_SECRET, RBT_APPLICATION_EXIT_CODE_BACKWARDS_INCOMPATIBILITY, ) from typing import Awaitable, Callable, NoReturn, Optional logger = get_logger(__name__) +_MCP_PATH = "/mcp" + def _handle_unknown_exception( exception: Exception, @@ -175,34 +181,58 @@ def __init__( Awaitable[None]]] = None, initialize_bearer_token: Optional[str] = None, token_verifier: Optional[TokenVerifier] = None, + oauth: Optional[OAuthProvider] = None, ): """ - :param servicers: the types of Reboot-powered servicers that this - Application will serve. - :param legacy_grpc_servicers: the types of legacy gRPC servicers (not - using Reboot libraries) that this - Application will serve. - + :param servicers: the types of Reboot-powered servicers that + this Application will serve. + :param legacy_grpc_servicers: the types of legacy gRPC servicers + (not using Reboot libraries) that this Application will + serve. :param libraries: the libraries this Application will use. + :param initialize: will be called after the Application's + servicers have started for the first time, so that it can + perform initialization logic (e.g., creating some well-known + actors, loading some data, etc.). It must do so in the + context of the given InitializeContext. + :param initialize_bearer_token: a Bearer token that will be used + to construct the `InitializeContext` passed to `initialize`. + If none is provided, the `InitializeContext` will be + constructed without a Bearer token, and have app-internal + privileges instead. + :param token_verifier: a TokenVerifier that will be used to + verify authorization bearer tokens passed to the + application. + :param oauth: an OAuth provider (e.g. `Google` or `GitHub`) for + authenticating MCP clients. When set, the Application + automatically creates a `TokenVerifier` for the minted JWTs. + Mutually exclusive with `token_verifier`. + + TODO(benh): update the initialize function to be run in a + transaction and ensure that the transaction has finished before + serving any other calls on the servicers. + """ + if oauth is not None and token_verifier is not None: + # TODO(rjh): support _adding_ a token verifier, rather than + # demanding this is the only one. + raise ValueError( + "`oauth` and `token_verifier` are mutually " + "exclusive. When `oauth` is set, a " + "`TokenVerifier` is created automatically." + ) - :param initialize: will be called after the Application's servicers have - started for the first time, so that it can perform - initialization logic (e.g., creating some well-known - actors, loading some data, etc.). It must do so in the - context of the given InitializeContext. - - :param initialize_bearer_token: a Bearer token that will be used to construct - the `InitializeContext` passed to `initialize`. If none is provided, - the `InitializeContext` will be constructed without a Bearer token, - and have app-internal privileges instead. - - :param token_verifier: a TokenVerifier that will be used to verify - authorization bearer tokens passed to the application. + # Default to Anonymous OAuth in environments that also have a + # default signing secret (i.e. `rbt dev` and unit tests; these + # are the environments where true security isn't needed yet). + # This lets MCP clients connect without explicit config. In `rbt + # dev`, `_is_dev_default` triggers a warning nudging the + # developer to configure a real provider for production. + if oauth is None and token_verifier is None: + if running_rbt_dev(): + oauth = Anonymous(_is_dev_default=True) + elif os.environ.get(ENVVAR_REBOOT_OAUTH_SIGNING_SECRET): + oauth = Anonymous() - TODO(benh): update the initialize function to be run in a transaction - and ensure that the transaction has finished before serving any other - calls on the servicers. - """ # Get all libraries including required dependent libraries. if libraries is not None: # Check for dupes. @@ -293,13 +323,32 @@ def __init__( self._initialize = initialize self._token_verifier = token_verifier self._initialize_bearer_token = initialize_bearer_token + self._oauth = oauth + + # Validate MCP configuration eagerly at construction time so + # errors are raised consistently regardless of run environment. + seen_tools: dict[str, str] = {} + for servicer_cls in self._servicers or []: + if servicer_cls._is_auto_construct and oauth is None: + raise ValueError( + "Application includes a `User` auto-constructed state " + "type, which requires OAuth to identify the user. Pass " + "`Application(oauth=...)` - e.g. `oauth=Anonymous()`." + ) + for tool_name in servicer_cls._mcp_tool_names(): + if tool_name in seen_tools: + raise ValueError( + f"Duplicate MCP tool name '{tool_name}' registered" + f" by both '{seen_tools[tool_name]}' and " + f"'{servicer_cls.__name__}'. Use the `name` " + "parameter in `Tool()` to give one of them a " + "distinct name." + ) + seen_tools[tool_name] = servicer_cls.__name__ # NOTE: we override with `NodeWebFramework` in `NodeApplication`. self._web_framework: WebFramework = PythonWebFramework() - # Mount MCP. - self._mount_mcp(self._servicers or []) - self._rbt: Optional[Reboot] = None self._directory: Optional[Path] = None @@ -324,12 +373,15 @@ def __init__( self._run_environment == RunEnvironment.RBT_CLOUD and os.environ.get(ENVVAR_REBOOT_MODE) == REBOOT_MODE_CONFIG ): - # This application is running on Reboot Cloud, and is - # running in "config mode" rather than as a serving - # server. Don't initialize the Reboot instance; the - # config server that we'll start later doesn't need it. + # This application is running on Reboot Cloud in "config + # mode" rather than as a serving server. Don't initialize + # the Reboot instance or mount the MCP server; the config + # server that we'll start later doesn't need them. return + # We'll be serving. Mount MCP's HTTP endpoints. + self._mount_mcp(self._servicers or []) + if within_nodejs_server() or within_python_server(): # We don't need to bring up a Reboot cluster when running a # Node.js or Python server; this process is one server in the @@ -406,47 +458,62 @@ def _mount_mcp( servicers have tools — the server will accept connections and explain what's missing. """ - auto_construct_state_type_full_names: list[str] = [] + auto_construct_state_type_full_names: list[StateTypeName] = [] new_session_hooks = [] - # Find auto-construct info. + # Wire up auto-construction of states for every authenticated user. for servicer_cls in servicers: if servicer_cls._is_auto_construct: auto_construct_state_type_full_names.append( servicer_cls.__state_type_name__ ) - new_session_hooks.append(servicer_cls._auto_construct) - if len(auto_construct_state_type_full_names) > 1: - state_type_names = ( - "'" + "', '".join(auto_construct_state_type_full_names) + "'" - ) - raise ValueError( - f"Multiple auto-construct state types ({state_type_names}) are " - "defined in this application's API. Only one auto-constructed " - "state type per application is currently supported." - ) - - auto_construct_state_type_full_name = ( - auto_construct_state_type_full_names[0] - if len(auto_construct_state_type_full_names) == 1 else None - ) - - # Create MCP server and register all servicers' - # tools/resources. + async def maybe_auto_construct_based_on_user_id( + context: ExternalContext, + user_id: Optional[str], + # Capture by value to avoid the closure-in-a-loop + # pitfall. + _cls=servicer_cls, + ): + if user_id is None: + # We can't auto-construct if there's no user ID. + return + await _cls._auto_construct(context, state_id=user_id) + + new_session_hooks.append(maybe_auto_construct_based_on_user_id) + + # Create MCP server and register all servicers' tools/resources. server = FastMCP(name="reboot-mcp") for servicer_cls in servicers: - servicer_cls._add_mcp(server, auto_construct_state_type_full_name) + servicer_cls._add_mcp(server, auto_construct_state_type_full_names) + + # Create the OAuth server (if configured) before mounting the + # MCP factory so we can pass it the `MCPSDKOAuthTokenVerifier` - + # if we didn't have that in place, expired tokens wouldn't be + # caught until our `OAuthTokenVerifier`, and those errors are + # (to the MCP SDK) merely internal server errors - they wouldn't + # produce the 401 error code needed to trigger a token refresh. + mcp_sdk_token_verifier = None + oauth_server = None + if self._oauth is not None: + oauth_server = OAuthServer( + provider=self._oauth, + protected_resources=[_MCP_PATH], + ) + self._token_verifier = oauth_server.token_verifier + mcp_sdk_token_verifier = oauth_server.mcp_sdk_token_verifier + oauth_server.mount_routes(self.http) # The `type: ignore` is needed because `create_mcp_factory` # returns a closure, and mypy can't verify Protocol conformance # on closures (the closure is structurally compatible with # `HTTPASGIApp` at runtime). self.http.mount( - "/mcp", + _MCP_PATH, factory=create_mcp_factory( # type: ignore[arg-type] server=server, new_session_hooks=new_session_hooks, + token_verifier=mcp_sdk_token_verifier, ), ) diff --git a/reboot/aio/auth/BUILD.bazel b/reboot/aio/auth/BUILD.bazel index f46586f3..583a8877 100644 --- a/reboot/aio/auth/BUILD.bazel +++ b/reboot/aio/auth/BUILD.bazel @@ -51,6 +51,34 @@ py_library( ], ) +py_library( + name = "oauth_providers_py", + srcs = ["oauth_providers.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "@com_github_reboot_dev_reboot//log:log_py", + requirement("aiohttp"), + requirement("pyjwt"), + requirement("python-ulid"), + ], +) + +py_library( + name = "oauth_server_py", + srcs = ["oauth_server.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":__init___py", + ":oauth_providers_py", + ":token_verifiers_py", + "//reboot:settings_py", + requirement("pyjwt"), + requirement("starlette"), + ], +) + py_library( name = "python", visibility = ["//visibility:public"], @@ -58,6 +86,8 @@ py_library( ":__init___py", ":admin_auth_py", ":authorizers_py", + ":oauth_providers_py", + ":oauth_server_py", ":token_verifiers_py", ], ) diff --git a/reboot/aio/auth/authorizers.py b/reboot/aio/auth/authorizers.py index 38fe3fbb..ce401fa9 100644 --- a/reboot/aio/auth/authorizers.py +++ b/reboot/aio/auth/authorizers.py @@ -321,10 +321,22 @@ def is_app_internal(*, context: ReaderContext, **kwargs): return rbt.v1alpha1.errors_pb2.PermissionDenied() +def state_id_is_user_id(*, context: ReaderContext, **kwargs): + """Allow when the caller's `user_id` matches the state ID.""" + if context.auth is None or context.auth.user_id is None: + return rbt.v1alpha1.errors_pb2.Unauthenticated() + + if context.auth.user_id == context.state_id: + return rbt.v1alpha1.errors_pb2.Ok() + + return rbt.v1alpha1.errors_pb2.PermissionDenied() + + class DefaultAuthorizer(Authorizer[Message | Model, Message | Model | None]): - def __init__(self, state_name: str): + def __init__(self, state_name: str, is_user_type: bool): self._state_name = state_name + self._is_user_type = is_user_type async def authorize( self, @@ -335,6 +347,25 @@ async def authorize( request: Optional[Message | Model], **kwargs, ) -> Authorizer.Decision: + # For `User` types, allow if the caller's `user_id` matches the + # state ID. Unlike other types, `User` auth is always enforced + # (even in dev mode) because (unlike other state types) the + # default rules are production-worthy. + if self._is_user_type: + decision = state_id_is_user_id(context=context) + if isinstance(decision, rbt.v1alpha1.errors_pb2.Ok): + return decision + # Fall through to the app-internal check, but if that + # doesn't match either, return the `state_id_is_user_id` + # decision. We do NOT use the dev-mode behavior of other + # state types - in `rbt dev` it is normally possible to + # bypass auth that simply may not have been written yet, but + # unlike other state types `User` has a default security + # stance that's expected to go to produciton in many cases. + if context.app_internal: + return rbt.v1alpha1.errors_pb2.Ok() + return decision + # Allow if app internal. if context.app_internal: return rbt.v1alpha1.errors_pb2.Ok() diff --git a/reboot/aio/auth/oauth_providers.py b/reboot/aio/auth/oauth_providers.py new file mode 100644 index 00000000..784ac9ed --- /dev/null +++ b/reboot/aio/auth/oauth_providers.py @@ -0,0 +1,303 @@ +"""OAuth identity providers for the OAuth server.""" + +from __future__ import annotations + +import aiohttp +import jwt +from abc import ABC, abstractmethod +from log.log import get_logger, log_at_most_once_per +from typing import NewType +from ulid import ULID +from urllib.parse import urlencode + +logger = get_logger(__name__) + +UserId = NewType("UserId", str) + +_DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 # 24 hours. + + +class OAuthProvider(ABC): + """ + Base class for identity providers. + """ + + def __init__( + self, + *, + access_token_ttl_seconds: int = _DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + ): + # How long minted access tokens are valid, in + # seconds. + self.access_token_ttl_seconds = access_token_ttl_seconds + + @abstractmethod + def authorization_url( + self, + state: str, + redirect_uri: str, + ) -> str: + """Return the full authorization URL to redirect the user to. + + Args: + state: An opaque string that the identity provider + will echo back in the callback; used to + correlate the callback with the original + request and prevent CSRF. + redirect_uri: The URL the identity provider should + redirect the user to after authorization. + """ + raise NotImplementedError() + + @abstractmethod + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> UserId: + """Exchange an identity provider authorization code for + a user ID. + + Args: + code: The authorization code received from the + identity provider's callback. + redirect_uri: The redirect URI that was used in the + original authorization request; required by + the identity provider to validate the exchange. + """ + raise NotImplementedError() + + +class RegisteredOAuthProvider(OAuthProvider): + """ + Base class for providers that use pre-registered client credentials. + + These providers require developers to go through some manual + registration flow once, which produces a `client_id` and + `client_secret` that we need to know. + """ + + def __init__(self, *, client_id: str, client_secret: str): + super().__init__() + if not client_id: + raise ValueError( + f"{type(self).__name__} requires a non-empty `client_id`." + ) + if not client_secret: + raise ValueError( + f"{type(self).__name__} requires a non-empty `client_secret`." + ) + self._client_id = client_id + self._client_secret = client_secret + + +class Google(RegisteredOAuthProvider): + """ + Google OAuth provider (OpenID Connect). + + Obtains the user ID from the OIDC ID token's `sub` claim. + """ + + _AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" + _TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + + def authorization_url( + self, + state: str, + redirect_uri: str, + ) -> str: + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + # `openid` is the minimum OIDC scope; gives us an ID token + # with the `sub` claim (the user's ID). + "scope": "openid", + "state": state, + "access_type": "offline", + } + return f"{self._AUTHORIZATION_ENDPOINT}?{urlencode(params)}" + + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> UserId: + """ + Exchange the Google auth code for user ID. + """ + data = { + "code": code, + "client_id": self._client_id, + "client_secret": self._client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + async with aiohttp.ClientSession() as session: + async with session.post( + self._TOKEN_ENDPOINT, + data=data, + ) as response: + response.raise_for_status() + token_response = await response.json() + + id_token = token_response.get("id_token") + if id_token is None: + raise ValueError( + "Google token response did not contain an 'id_token'. Ensure " + "the 'openid' scope is requested." + ) + + # Decode without signature verification: we just + # received this token directly from Google's token + # endpoint over TLS, so the transport guarantees + # authenticity. Full verification would require + # fetching Google's JWKS from + # `https://www.googleapis.com/oauth2/v3/certs`, + # caching the keys with TTL-based rotation, and + # validating `iss`, `aud`, and `exp` claims — all + # of which add complexity and an external HTTP + # dependency at token-exchange time. Since we only + # use this token to extract the `sub` claim + # immediately after a direct TLS exchange with + # Google, the risk is minimal. + decoded = jwt.decode( + id_token, + options={"verify_signature": False}, + algorithms=["RS256"], + ) + # Since Google is an OpenID provider we can simply obtain the + # user ID directly from the ID token. + user_id = decoded.get("sub") + if user_id is None: + raise ValueError("Google ID token did not contain a 'sub' claim.") + return UserId(user_id) + + +class GitHub(RegisteredOAuthProvider): + """ + GitHub OAuth provider (plain OAuth 2.0). + """ + + _AUTHORIZATION_ENDPOINT = "https://github.com/login/oauth/authorize" + _TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token" + _USER_API = "https://api.github.com/user" + + def authorization_url( + self, + state: str, + redirect_uri: str, + ) -> str: + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + # `read:user`: minimum scope needed to call `GET /user` and + # obtain the numeric user ID. + "scope": "read:user", + "state": state, + } + return f"{self._AUTHORIZATION_ENDPOINT}?{urlencode(params)}" + + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> UserId: + """ + Exchange the GitHub auth code for user ID. + """ + # Since GitHub isn't an OpenID provider (only plain OAuth), we + # must first POST to the token endpoint for an access token, + # then call `GET /user` to obtain the numeric user ID. + data = { + "code": code, + "client_id": self._client_id, + "client_secret": self._client_secret, + "redirect_uri": redirect_uri, + } + headers = {"Accept": "application/json"} + async with aiohttp.ClientSession() as session: + # Exchange code for access token. + async with session.post( + self._TOKEN_ENDPOINT, + data=data, + headers=headers, + ) as response: + response.raise_for_status() + token_response = await response.json() + + access_token = token_response.get("access_token") + if access_token is None: + error = token_response.get( + "error_description", + token_response.get("error", "unknown"), + ) + raise ValueError(f"GitHub token exchange failed: {error}") + + # Fetch user info. + async with session.get( + self._USER_API, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) as response: + response.raise_for_status() + user_info = await response.json() + + user_id = user_info.get("id") + if user_id is None: + raise ValueError("GitHub user API did not return an 'id' field.") + return UserId(str(user_id)) + + +class Anonymous(OAuthProvider): + """ + Anonymous provider — no external identity provider. + + Generates a fresh `anon-{ULID}` user ID for every authorization. The + `authorization_url` redirects straight back to our own + `/oauth/callback` with a dummy code, so the user never sees a + sign-in page. + """ + + def __init__( + self, + *, + _is_dev_default: bool = False, + access_token_ttl_seconds: int = _DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + ): + super().__init__(access_token_ttl_seconds=access_token_ttl_seconds) + self._is_dev_default = _is_dev_default + + def authorization_url( + self, + state: str, + redirect_uri: str, + ) -> str: + # Redirect right back to our callback with a dummy code; no + # external identity provider involved. + params = {"code": "anonymous", "state": state} + return f"{redirect_uri}?{urlencode(params)}" + + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> UserId: + """Generate a fresh anonymous user ID.""" + if self._is_dev_default: + log_at_most_once_per( + seconds=60, + log_method=logger.warning, + message=( + "*** Using default Anonymous OAuth for development. *** In " + "production, set `Application(oauth=Anonymous(), ...)` (or " + "another provider) explicitly." + ), + ) + # ULID rather than UUIDv7: same entropy, but the Crockford + # base32 encoding is shorter and easier on the human eye — + # relevant here because anonymous user IDs are likely to be seen + # by humans. + return UserId(f"anon-{ULID()}") diff --git a/reboot/aio/auth/oauth_server.py b/reboot/aio/auth/oauth_server.py new file mode 100644 index 00000000..122c746e --- /dev/null +++ b/reboot/aio/auth/oauth_server.py @@ -0,0 +1,864 @@ +"""MCP OAuth Authorization Server. + +Implements the OAuth 2.0 Authorization Server endpoints needed for MCP +client authentication. All state is encoded into signed JWTs (HS256) so +that any server process can handle any request. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import jwt +import logging +import os +import rbt.v1alpha1.errors_pb2 +import time +from mcp.server.auth.provider import AccessToken +from reboot.aio.auth import Auth +from reboot.aio.auth.oauth_providers import OAuthProvider, UserId +from reboot.aio.auth.token_verifiers import TokenVerifier, VerifyTokenResult +from reboot.aio.contexts import ReaderContext +from reboot.settings import ENVVAR_REBOOT_OAUTH_SIGNING_SECRET +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse +from typing import Any, Optional +from urllib.parse import urlencode + +logger = logging.getLogger(__name__) + +# Token TTLs in seconds. +_AUTH_CODE_TTL_SECONDS = 300 # 5 minutes. +_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days. +# How long a user has to complete the identity provider sign-in +# flow (from the authorize redirect to the callback). 10 minutes +# is generous for an +# interactive login but short enough to limit the window for state-token +# replay. +_PENDING_STATE_TTL_SECONDS = 600 # 10 minutes. + +# JWT algorithm. +_ALGORITHM = "HS256" + +# Audience claim for access tokens. +_AUDIENCE = "reboot-mcp" + +# OAuth endpoint paths. Prefixed with `__/oauth/` to avoid collisions +# with developer-specified routes. The `/.well-known/` discovery paths +# (mandated by RFC 8414 / RFC 9728) stay at their standard locations. +_AUTHORIZE_PATH = "/__/oauth/authorize" +_TOKEN_PATH = "/__/oauth/token" +_REGISTER_PATH = "/__/oauth/register" +_CALLBACK_PATH = "/__/oauth/callback" + +# CORS headers for browser-based MCP clients (e.g. MCPJam, MCP +# Inspector). Allow any origin since the server is an OAuth +# Authorization Server that public clients talk to. +_CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, MCP-Protocol-Version", +} + + +def _base_url(request: Request) -> str: + """ + Derive the server's base URL from the request headers. + + Respects `X-Forwarded-Proto` and `X-Forwarded-Host` when behind a + reverse proxy. + """ + scheme = request.headers.get( + "x-forwarded-proto", + request.url.scheme, + ) + host = request.headers.get( + "x-forwarded-host", + request.headers.get("host", "localhost"), + ) + return f"{scheme}://{host}" + + +def _oauth_error( + *, + error: str, + description: str, + status_code: int, +) -> JSONResponse: + """Return an OAuth-compliant error response (RFC 6749 5.2).""" + return JSONResponse( + status_code=status_code, + content={ + "error": error, + "error_description": description, + }, + headers=_CORS_HEADERS, + ) + + +class OAuthTokenVerifier(TokenVerifier): + """Verifies access tokens minted by the `OAuthServer`. + + Decodes the HS256 JWT and checks `type`, `aud`, and `exp`. + Returns `Auth(user_id=...)` on success, `None` otherwise. + """ + + def __init__(self, signing_secret: str): + self._signing_secret = signing_secret + + async def verify_token( + self, + context: ReaderContext, + token: Optional[str], + ) -> VerifyTokenResult: + if token is None: + return None + try: + decoded = jwt.decode( + token, + self._signing_secret, + algorithms=[_ALGORITHM], + audience=_AUDIENCE, + ) + if decoded.get("type") != "access": + return None + user_id: UserId = UserId(decoded.get("sub", "")) + if not user_id: + return None + return Auth(user_id=user_id) + except jwt.ExpiredSignatureError: + return rbt.v1alpha1.errors_pb2.Unauthenticated( + message="Access token has expired." + ) + except jwt.exceptions.PyJWTError: + # Perhaps this wasn't a JWT at all; there may be a different + # token verifier that knows what this is. + return None + + +class MCPSDKOAuthTokenVerifier: + """ + Verifies OAuth JWTs for the MCP SDK's HTTP auth layer. + + Implements the MCP SDK's `TokenVerifier` protocol, where + `verify_token(token) -> AccessToken | None`. The SDK's + `BearerAuthBackend` checks `expires_at` itself and returns HTTP 401 + with a proper `WWW-Authenticate` header on expiry. + + We therefore skip expiry verification during decode (so we can + return `expires_at` for the SDK to check) but do verify the + signature, audience, and type. + """ + + def __init__(self, signing_secret: str): + self._signing_secret = signing_secret + + async def verify_token( # type: ignore[override] + self, token: str + ) -> Optional[Any]: + try: + decoded = jwt.decode( + token, + self._signing_secret, + algorithms=[_ALGORITHM], + audience=_AUDIENCE, + # Don't verify expiry here — let the MCP SDK's + # `BearerAuthBackend` check `expires_at` so it returns a + # proper 401 with `WWW-Authenticate`. + options={"verify_exp": False}, + ) + if decoded.get("type") != "access": + return None + return AccessToken( + token=token, + client_id=decoded.get("sub", ""), + scopes=[], + expires_at=decoded.get("exp"), + ) + except jwt.exceptions.PyJWTError: + return None + + +class OAuthServer: + """OAuth 2.0 Authorization Server for MCP. + + All state is encoded into signed JWTs so no shared storage is needed + across server processes. + + Expected flow: + + 0. **Unauthenticated request.** The MCP client connects to the + protected resource (e.g. `POST /mcp`) without a token and + receives HTTP 401 with a `WWW-Authenticate` header. This tells + the client that OAuth is required and where to find the resource + metadata. + + 1. **Discovery.** The MCP client fetches `GET + /.well-known/oauth-protected-resource/` (RFC 9728) to find + our authorization server. We could have handed off to an external + authorization server at this point (e.g. Auth0), but then all + application development would have to start by signing up for + e.g. Auth0, so instead we implement a basic authorization server + ourselves. Next, the client does a `GET + /.well-known/oauth-authorization-server` (RFC 8414) for endpoints + and capabilities. + + 2. **Dynamic client registration.** The client `POST /register` (RFC + 7591) with its `redirect_uris`. We return a `client_id` (a signed + JWT encoding the registered URIs). + + 3. **Authorization.** The client redirects the user to `GET + /authorize` with PKCE parameters. We redirect to the identity + provider (Google, GitHub, or straight back for Anonymous). + + 4. **identity provider callback.** The identity provider redirects + to `GET /oauth/callback` with an authorization code. We exchange + that code at the identity provider's token endpoint, receiving + the identity provider's access token (and for OIDC providers like + Google, an ID token). We extract the user ID (from the ID token's + `sub` claim for Google, or via the user API for GitHub) and then + discard the identity provider tokens — we don't store them, as + they are sensitive. We mint our own authorization code JWT + containing the user ID and redirect back to the client's + `redirect_uri`. + + 5. **Token exchange.** The client `POST /token` with + `grant_type=authorization_code`, the code, and the PKCE verifier. + We return an access token and a refresh token (both signed JWTs). + + 6. **Refresh.** When the access token expires, the client `POST + /token` with `grant_type=refresh_token` to get a new + access/refresh token pair. This OAuth server does NOT currently + re-authorize with the identity provider, since doing so would + require storing the identity provider's sensitive refresh and + access tokens. + """ + + def __init__( + self, + *, + provider: OAuthProvider, + protected_resources: list[str], + ): + self._provider = provider + self._protected_resources = protected_resources + self._access_token_ttl_seconds = provider.access_token_ttl_seconds + self._signing_secret = os.environ.get( + ENVVAR_REBOOT_OAUTH_SIGNING_SECRET + ) + if self._signing_secret is None: + raise ValueError( + f"The '{ENVVAR_REBOOT_OAUTH_SIGNING_SECRET}' environment " + "variable must be set when using `oauth`. " + "For local development with `rbt dev`, this is " + "set automatically. For production, set it to a " + "strong random secret shared across all server " + "processes." + ) + self._token_verifier = OAuthTokenVerifier(self._signing_secret) + + @property + def token_verifier(self) -> OAuthTokenVerifier: + """ + Verifier for access tokens minted by this server. + """ + return self._token_verifier + + @property + def mcp_sdk_token_verifier(self) -> MCPSDKOAuthTokenVerifier: + """ + Return something implementing the MCP SDK's `TokenVerifier` + + For use with the SDK's `BearerAuthBackend` / + `RequireAuthMiddleware`. + """ + # `__init__` raises if `_signing_secret` is None. + assert self._signing_secret is not None + return MCPSDKOAuthTokenVerifier(self._signing_secret) + + def mount_routes(self, http) -> None: + """Register all OAuth endpoints on `http`.""" + # RFC 9728: Protected Resource Metadata. MCP + # clients discover auth servers through this. + # Register both root-level and per-resource paths. + http.get("/.well-known/oauth-protected-resource")( + self.protected_resource_metadata + ) + for resource in self._protected_resources: + path = resource.strip("/") + http.get(f"/.well-known/oauth-protected-resource/{path}")( + self.protected_resource_metadata + ) + + # RFC 8414: Authorization Server Metadata. + http.get("/.well-known/oauth-authorization-server")(self.metadata) + + # RFC 7591: Dynamic Client Registration. + http.post(_REGISTER_PATH)(self.register) + http.options(_REGISTER_PATH)(self.cors_preflight) + + # Authorization and token endpoints. + http.get(_AUTHORIZE_PATH)(self.authorize) + http.get(_CALLBACK_PATH)(self.callback) + http.post(_TOKEN_PATH)(self.token) + http.options(_TOKEN_PATH)(self.cors_preflight) + + # ---- Helpers ---- + + def _make_jwt(self, payload: dict[str, Any], ttl_seconds: int) -> str: + """Sign a payload as a JWT with the given TTL.""" + now = int(time.time()) + payload = { + **payload, + "iat": now, + "exp": now + ttl_seconds, + } + return jwt.encode(payload, self._signing_secret, algorithm=_ALGORITHM) + + def _verify_jwt( + self, + token: str, + expected_type: str, + ) -> Optional[dict[str, Any]]: + """Verify and decode a JWT, checking the `type` claim. + + Returns the decoded payload, or `None` if invalid. + """ + try: + # Access tokens have an `aud` claim; others don't. + kwargs: dict[str, Any] = {} + if expected_type == "access": + kwargs["audience"] = _AUDIENCE + else: + kwargs["options"] = {"verify_aud": False} + + decoded = jwt.decode( + token, + self._signing_secret, + algorithms=[_ALGORITHM], + **kwargs, + ) + if decoded.get("type") != expected_type: + return None + return decoded + except jwt.exceptions.PyJWTError: + return None + + # ---- Route handlers ---- + + async def cors_preflight( + self, + request: Request, + ) -> JSONResponse: + """Handle OPTIONS preflight requests for CORS.""" + return JSONResponse( + content=None, + status_code=204, + headers=_CORS_HEADERS, + ) + + async def protected_resource_metadata( + self, + request: Request, + ) -> JSONResponse: + """GET /.well-known/oauth-protected-resource[/] + + Returns RFC 9728 OAuth 2.0 Protected Resource Metadata. + Tells MCP clients which authorization server to use. + """ + base = _base_url(request) + # Derive the resource path from the request URL: + # "/.well-known/oauth-protected-resource/mcp" → "/mcp". + prefix = "/.well-known/oauth-protected-resource" + resource = request.url.path.removeprefix(prefix) or "/" + return JSONResponse( + { + "resource": f"{base}{resource}", + "authorization_servers": [base], + "bearer_methods_supported": ["header"], + }, + headers=_CORS_HEADERS, + ) + + async def metadata(self, request: Request) -> JSONResponse: + """GET /.well-known/oauth-authorization-server + + Returns RFC 8414 OAuth Authorization Server Metadata. + """ + base = _base_url(request) + return JSONResponse( + { + "issuer": base, + "authorization_endpoint": f"{base}{_AUTHORIZE_PATH}", + "token_endpoint": f"{base}{_TOKEN_PATH}", + "registration_endpoint": f"{base}{_REGISTER_PATH}", + # We only support the authorization code flow (no + # implicit or client credentials). + "response_types_supported": ["code"], + # Authorization code for initial login; refresh tokens + # for long-lived sessions. + "grant_types_supported": + [ + "authorization_code", + "refresh_token", + ], + # Public clients (MCP apps running in the user's + # browser/CLI) — no client secret. + "token_endpoint_auth_methods_supported": ["none"], + # PKCE with S256 is required for all authorization + # requests (RFC 7636). + "code_challenge_methods_supported": ["S256"], + }, + headers=_CORS_HEADERS, + ) + + async def register(self, request: Request) -> JSONResponse: + """POST /register + + RFC 7591 Dynamic Client Registration. The returned + `client_id` is a signed JWT encoding the registered + `redirect_uris`. + """ + try: + body = await request.json() + except json.JSONDecodeError: + return _oauth_error( + error="invalid_request", + description="Request body must be valid JSON.", + status_code=400, + ) + + redirect_uris = body.get("redirect_uris") + if not redirect_uris or not isinstance(redirect_uris, list): + return _oauth_error( + error="invalid_request", + description="The 'redirect_uris' field is required and " + "must be a non-empty list.", + status_code=400, + ) + + # The client_id is a signed JWT encoding the registered + # `redirect_uris`. This lets the server verify them statelessly + # in `/authorize` without needing a database. Client + # registrations are normally permanent, but JWTs require an + # `exp`, so we use an effectively-forever TTL. + client_id = self._make_jwt( + { + "type": "client", + "redirect_uris": redirect_uris, + }, + ttl_seconds=1000 * 365 * 24 * 3600, # ~1000 years. + ) + + return JSONResponse( + status_code=201, + content={ + "client_id": client_id, + "redirect_uris": redirect_uris, + "token_endpoint_auth_method": "none", + }, + headers=_CORS_HEADERS, + ) + + async def authorize(self, request: Request): + """GET /authorize + + Validates the request, then redirects to the identity provider. + """ + params = request.query_params + + response_type = params.get("response_type") + if response_type != "code": + return _oauth_error( + error="unsupported_response_type", + description="Only 'code' response_type is supported.", + status_code=400, + ) + + client_id_token = params.get("client_id") + if client_id_token is None: + return _oauth_error( + error="invalid_request", + description="The 'client_id' parameter is required.", + status_code=400, + ) + + # Decode the client_id JWT. + client_data = self._verify_jwt(client_id_token, "client") + if client_data is None: + return _oauth_error( + error="invalid_client", + description="The 'client_id' is invalid.", + status_code=400, + ) + + redirect_uri = params.get("redirect_uri") + if redirect_uri is None: + return _oauth_error( + error="invalid_request", + description="The 'redirect_uri' parameter is required.", + status_code=400, + ) + + if redirect_uri not in client_data.get("redirect_uris", []): + return _oauth_error( + error="invalid_request", + description="The 'redirect_uri' is not registered for " + "this client.", + status_code=400, + ) + + code_challenge = params.get("code_challenge") + code_challenge_method = params.get("code_challenge_method") + if code_challenge is None or code_challenge_method != "S256": + return _oauth_error( + error="invalid_request", + description="PKCE is required: provide 'code_challenge' " + "with 'code_challenge_method=S256'.", + status_code=400, + ) + + mcp_state = params.get("state", "") + + # OAuth's `state` parameter is an opaque string that the + # identity provider passes back unchanged in the callback. We + # use it to carry a signed JWT with everything we need to resume + # after the identity provider redirects back: `client_id`, + # `redirect_uri`, PKCE challenge, and the MCP client's own + # state. This avoids server-side session storage, and is safe + # because... + # 1. The communication with the identity provider is over TLS + # (required by the OAuth spec), so it won't be observed in + # transit. + # 2. None of the fields are secret to either the client or the + # identity provider. + # 3. Since the token is signed, the identity provider can't + # alter it to e.g. misdirect the redirect. + pending = self._make_jwt( + { + "type": "pending", + "client_id": client_id_token, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "mcp_state": mcp_state, + }, + ttl_seconds=_PENDING_STATE_TTL_SECONDS, + ) + + # Our own callback URL. + callback_uri = f"{_base_url(request)}{_CALLBACK_PATH}" + + idp_url = self._provider.authorization_url( + state=pending, + redirect_uri=callback_uri, + ) + return RedirectResponse(url=idp_url, status_code=302) + + async def callback(self, request: Request): + """GET /oauth/callback + + Handles the identity provider redirect. Exchanges the identity provider code for a user ID, + mints an auth code JWT, and redirects back to the MCP client. + """ + params = request.query_params + + # Check for identity provider error. + idp_error = params.get("error") + if idp_error is not None: + # Try to recover enough state to redirect back to the MCP + # client. + state_token = params.get("state") + if state_token is not None: + pending = self._verify_jwt(state_token, "pending") + if pending is not None: + redirect_uri = pending["redirect_uri"] + mcp_state = pending.get("mcp_state", "") + query = urlencode( + { + "error": "access_denied", + "state": mcp_state, + } + ) + return RedirectResponse( + url=f"{redirect_uri}?{query}", + status_code=302, + ) + return _oauth_error( + error="access_denied", + description=f"Identity provider returned error: {idp_error}", + status_code=400, + ) + + state_token = params.get("state") + if state_token is None: + return _oauth_error( + error="invalid_request", + description="Missing 'state' parameter.", + status_code=400, + ) + + pending = self._verify_jwt(state_token, "pending") + if pending is None: + return _oauth_error( + error="invalid_request", + description="Invalid or expired state parameter.", + status_code=400, + ) + + idp_code = params.get("code") + if idp_code is None: + return _oauth_error( + error="invalid_request", + description="Missing 'code' parameter from identity " + "provider.", + status_code=400, + ) + + # Exchange identity provider code for a user ID. + callback_uri = f"{_base_url(request)}{_CALLBACK_PATH}" + try: + user_id = await self._provider.exchange_code( + code=idp_code, + redirect_uri=callback_uri, + ) + except Exception as e: + logger.error( + "Failed to exchange identity provider code: %s", + e, + exc_info=True, + ) + # Per RFC 6749 Section 4.1.2.1, authorization errors are + # reported back to the client via redirect with `error` and + # `state` query params. The MCP client (Claude, MCPJam, + # etc.) receives this and typically shows an "authentication + # failed" message or prompts the user to retry. + redirect_uri = pending["redirect_uri"] + mcp_state = pending.get("mcp_state", "") + query = urlencode({ + "error": "access_denied", + "state": mcp_state, + }) + return RedirectResponse( + url=f"{redirect_uri}?{query}", + status_code=302, + ) + + # Mint the auth code JWT. + auth_code = self._make_jwt( + { + "type": "code", + "sub": user_id, + "client_id": pending["client_id"], + "redirect_uri": pending["redirect_uri"], + "code_challenge": pending["code_challenge"], + }, + ttl_seconds=_AUTH_CODE_TTL_SECONDS, + ) + + redirect_uri = pending["redirect_uri"] + mcp_state = pending.get("mcp_state", "") + query = urlencode({ + "code": auth_code, + "state": mcp_state, + }) + return RedirectResponse( + url=f"{redirect_uri}?{query}", + status_code=302, + ) + + async def token(self, request: Request) -> JSONResponse: + """POST /token + + Handles both `authorization_code` and `refresh_token` grant + types. + """ + # Parse form-encoded body (standard for token endpoint). + form = await request.form() + grant_type = form.get("grant_type") + + if grant_type == "authorization_code": + return await self._token_authorization_code(request, form) + elif grant_type == "refresh_token": + return await self._token_refresh(request, form) + else: + return _oauth_error( + error="unsupported_grant_type", + description="Supported grant types: 'authorization_code', " + "'refresh_token'.", + status_code=400, + ) + + async def _token_authorization_code( + self, + request: Request, + form: Any, + ) -> JSONResponse: + """Handle `grant_type=authorization_code`.""" + code_token = form.get("code") + if code_token is None: + return _oauth_error( + error="invalid_request", + description="The 'code' parameter is required.", + status_code=400, + ) + + code_data = self._verify_jwt(str(code_token), "code") + if code_data is None: + return _oauth_error( + error="invalid_grant", + description="The authorization code is invalid or expired.", + status_code=400, + ) + + # Verify client_id matches. + client_id = form.get("client_id") + if client_id != code_data.get("client_id"): + return _oauth_error( + error="invalid_grant", + description="The 'client_id' does not match the authorization " + "code.", + status_code=400, + ) + + # Verify redirect_uri matches. + redirect_uri = form.get("redirect_uri") + if redirect_uri != code_data.get("redirect_uri"): + return _oauth_error( + error="invalid_grant", + description="The 'redirect_uri' does not match the " + "authorization code.", + status_code=400, + ) + + # Verify PKCE. + code_verifier = form.get("code_verifier") + if code_verifier is None: + return _oauth_error( + error="invalid_request", + description="The 'code_verifier' parameter is required.", + status_code=400, + ) + + expected_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(str(code_verifier).encode()).digest() + ).rstrip(b"=").decode() + ) + if expected_challenge != code_data.get("code_challenge"): + return _oauth_error( + error="invalid_grant", + description="PKCE verification failed.", + status_code=400, + ) + + user_id: UserId = UserId(code_data["sub"]) + base = _base_url(request) + + # Mint access token. + access_token = self._make_jwt( + { + "type": "access", + "sub": user_id, + "iss": base, + "aud": _AUDIENCE, + }, + ttl_seconds=self._access_token_ttl_seconds, + ) + + # Mint refresh token. + refresh_token = self._make_jwt( + { + "type": "refresh", + "sub": user_id, + "client_id": str(client_id), + }, + ttl_seconds=_REFRESH_TOKEN_TTL_SECONDS, + ) + + return JSONResponse( + { + "access_token": access_token, + "token_type": "bearer", + "expires_in": self._access_token_ttl_seconds, + "refresh_token": refresh_token, + }, + headers=_CORS_HEADERS, + ) + + async def _token_refresh( + self, + request: Request, + form: Any, + ) -> JSONResponse: + """ + Handle `grant_type=refresh_token`. + + TODO: this flow (currently) doesn't consult the identity + provider; it's therefore not possible to revoke a leaked refresh + token. In the future, when we add the capability to securely + store the identity provider's access token and refresh token, we + should use those to refresh our own access token at the identity + provider before refreshing the caller's access token. It is then + possible for the identity provider to revoke the caller's + refresh token by revoking the refresh token it has issued to us. + """ + refresh_token_str = form.get("refresh_token") + if refresh_token_str is None: + return _oauth_error( + error="invalid_request", + description="The 'refresh_token' parameter is required.", + status_code=400, + ) + + refresh_data = self._verify_jwt(str(refresh_token_str), "refresh") + if refresh_data is None: + return _oauth_error( + error="invalid_grant", + description="The refresh token is invalid or expired.", + status_code=400, + ) + + # Verify client_id matches. + client_id = form.get("client_id") + if client_id != refresh_data.get("client_id"): + return _oauth_error( + error="invalid_grant", + description="The 'client_id' does not match the refresh token.", + status_code=400, + ) + + user_id: UserId = UserId(refresh_data["sub"]) + base = _base_url(request) + + # Mint new access token. + access_token = self._make_jwt( + { + "type": "access", + "sub": user_id, + "iss": base, + "aud": _AUDIENCE, + }, + ttl_seconds=self._access_token_ttl_seconds, + ) + + # Mint new refresh token (rotation). + new_refresh_token = self._make_jwt( + { + "type": "refresh", + "sub": user_id, + "client_id": str(client_id), + }, + ttl_seconds=_REFRESH_TOKEN_TTL_SECONDS, + ) + + return JSONResponse( + { + "access_token": access_token, + "token_type": "bearer", + "expires_in": self._access_token_ttl_seconds, + "refresh_token": new_refresh_token, + }, + headers=_CORS_HEADERS, + ) diff --git a/reboot/aio/auth/token_verifiers.py b/reboot/aio/auth/token_verifiers.py index 3a5f6c79..39b99b30 100644 --- a/reboot/aio/auth/token_verifiers.py +++ b/reboot/aio/auth/token_verifiers.py @@ -1,7 +1,18 @@ +import rbt.v1alpha1.errors_pb2 from abc import ABC, abstractmethod from reboot.aio.auth import Auth from reboot.aio.contexts import ReaderContext -from typing import Optional +from typing import Optional, Union + +# Return type for `TokenVerifier.verify_token`: +# - `Auth`: caller is authenticated. +# - `None`: no opinion (defer to Authorizer). +# - `Unauthenticated`: definitively reject the request. +VerifyTokenResult = Union[ + Auth, + None, + rbt.v1alpha1.errors_pb2.Unauthenticated, +] class TokenVerifier(ABC): @@ -17,17 +28,18 @@ async def verify_token( self, context: ReaderContext, token: Optional[str], - ) -> Optional[Auth]: - """Verifies the token and returns an `Auth` if the token implies the - caller is authenticated. Returning `None` implies the caller is not - authenticated, however, it is up to an `Authorizer` to decide that or - not. - - - :param context: A reader context to enable calling other services. - :param token: The token to verify. + ) -> VerifyTokenResult: + """Verify the bearer token. Returns: - `Auth` information if the token is valid, None otherwise. + * `Auth` if the token is valid and the caller is + authenticated. + * `None` if there is no opinion (e.g. no token was + provided). The `Authorizer` will decide whether to allow + or reject the request. + * `Unauthenticated` to definitively reject the request, + bypassing the `Authorizer`. Use this when it is clear the + caller intended to authenticate but failed (e.g. an + expired token). """ raise NotImplementedError() diff --git a/reboot/aio/contexts.py b/reboot/aio/contexts.py index 9ae275e2..bdf92bd8 100644 --- a/reboot/aio/contexts.py +++ b/reboot/aio/contexts.py @@ -44,6 +44,7 @@ ) from reboot.time import DateTimeWithTimeZone from typing import ( + TYPE_CHECKING, Any, AsyncGenerator, Awaitable, @@ -57,6 +58,12 @@ TypeVar, ) +# `StateManager` is only used in type annotations in this module. +# A runtime import would create a circular dependency: `contexts` +# imports `state_managers`, and `state_managers` imports `contexts`. +if TYPE_CHECKING: + from reboot.aio.state_managers import StateManager + ContextT = TypeVar('ContextT', bound='Context') ResponseT = TypeVar('ResponseT', bound=Message) @@ -238,13 +245,6 @@ def from_grpc_metadata(cls, metadata: GrpcMetadata) -> 'Participants': return participants -class RetryReactively(Exception): - """Exception to raise when in a reactive method context and wanting to - retry the method after state or reader's responses have changed. - """ - pass - - class React: """Encapsulates machinery necessary for contexts that are "reactive", aka, those that are initiated from calls to `React.Query` and who @@ -490,6 +490,15 @@ async def cancel_unused(self): await task except: pass + # Remove all references to the task object and its + # associated gRPC call so they can be garbage-collected. + # Using `pop` instead of `del`, since it could be a case + # when the task is cancelled before it makes a gRPC call + # and thus doesn't have an entry in `self._calls`, + # `self._responses` and `self._used_response`. + self._calls.pop(task, None) + self._responses.pop(task, None) + self._used_response.pop(task, None) _channel_manager: _ChannelManager _queriers: dict[str, Querier] @@ -1186,6 +1195,9 @@ def __call__( # Type variable for `Workflow.wait()`. WaitT = TypeVar('WaitT') +# Type variable for `WorkflowContext.retry_reactively_until()`. +RetryReactivelyUntilT = TypeVar('RetryReactivelyUntilT') + class WorkflowContext(Context): """Call context for a workflow call.""" @@ -1200,6 +1212,13 @@ class WorkflowContext(Context): loop: Loop within_loop: Callable[[], bool] + # State manager and state type for this context. Set by the + # generated code so that `until()` calls (via + # `retry_reactively_until()`) can enter `reactively()` without + # knowing the state type or the state manager. + _reactively_state_manager: StateManager + _reactively_state_type: type + def __init__( self, *, @@ -1208,6 +1227,8 @@ def __init__( state_type_name: StateTypeName, method: str, effect_validation: EffectValidation, + reactively_state_manager: StateManager, + reactively_state_type: type, task: Optional[TaskEffect] = None, ): super().__init__( @@ -1221,6 +1242,9 @@ def __init__( self._cancelled = None + self._reactively_state_manager = reactively_state_manager + self._reactively_state_type = reactively_state_type + def within_until(self) -> bool: return _within_until.get() @@ -1285,44 +1309,47 @@ async def wait(self, awaitable: Awaitable[WaitT]) -> WaitT: return await awaitable_future + async def retry_reactively_until( + self, + condition: Callable[[], Awaitable[RetryReactivelyUntilT]], + ) -> RetryReactivelyUntilT: + """Helper for waiting for something within a `WorkflowContext` that + re-executes the given callable everytime that some reactive state + has changed instead of re-executing the entire workflow from the + beginning (even though it's safe to do so, it is more expensive). + """ + async with self._reactively_state_manager.reactively( + # Pylance resolves `WorkflowContext` as a partially-unresolved + # type due to the circular import between `contexts` and + # `state_managers` — even though `self` IS a + # `WorkflowContext` at runtime. + self, # type: ignore[arg-type] + self._reactively_state_type, + # Should be already authorized when a task was created. + authorize=None, + ): + assert self.react is not None + iteration = self.react.iteration -RetryReactivelyUntilT = TypeVar('RetryReactivelyUntilT') - - -async def retry_reactively_until( - context: WorkflowContext, - condition: Callable[[], Awaitable[RetryReactivelyUntilT]], -) -> RetryReactivelyUntilT: - """Helper for waiting for something within a `WorkflowContext` that - re-executes the given callable everytime that some reactive state - has changed instead of raising `RetryReactively` and re-executing - the entire workflow from the beginning (even though it's safe to - do so, it is more expensive). - """ - assert context.react is not None - iteration = context.react.iteration + while True: + try: + try: + _within_until.set(True) + result = await condition() + finally: + _within_until.set(False) + + if not isinstance(result, bool): + return result + elif result: + # NOTE: the type system is insufficient to let us + # properly exclude types and declare the correct + # overloads. Thus, we have to return `True` here, + # but tell the type checker to ignore it. + return True # type: ignore[return-value] + except: + raise - while True: - try: - try: - _within_until.set(True) - result = await condition() - finally: - _within_until.set(False) - - if not isinstance(result, bool): - return result - elif result: - # NOTE: the type system is insufficient to let us - # properly exclude types and declare the correct - # overloads. Thus, we have to return `True` here, but - # tell the type checker to ignore it. - return True # type: ignore[return-value] - except RetryReactively: - pass - except: - raise - - # NOTE: using `context.wait()` so calls through Node.js will - # be cancelled. - iteration = await context.wait(context.react.iterate(iteration)) + # NOTE: using `self.wait()` so calls through Node.js will + # be cancelled. + iteration = await self.wait(self.react.iterate(iteration)) diff --git a/reboot/aio/http.py b/reboot/aio/http.py index 27426bfa..7d9cb006 100644 --- a/reboot/aio/http.py +++ b/reboot/aio/http.py @@ -124,6 +124,11 @@ def post(self, path: str, **kwargs): assert "methods" not in kwargs return self._api_route(path, methods=["POST"], **kwargs) + def options(self, path: str, **kwargs): + # Used for CORS preflight handlers. + assert "methods" not in kwargs + return self._api_route(path, methods=["OPTIONS"], **kwargs) + @overload def mount( self, @@ -262,6 +267,19 @@ async def external_context_middleware(request: Request, call_next): log_level="warning", reload=False, # This is handled by Reboot. workers=1, + # Trust 'X-Forwarded-Proto'/'X-Forwarded-For' from any + # source so that redirects (e.g. Starlette's trailing-slash + # redirect on mounted apps) use the correct scheme when + # behind a reverse proxy such as ngrok. + proxy_headers=True, + # To trust `X-Forwarded-*` headers coming from Envoy, which + # may be running in a Docker container and therefore not on + # the `127.0.0.1` IP, we must trust these headers from more + # IPs than just `127.0.0.1` (the default). This isn't a + # problem in our case: we don't do e.g. IP-based access + # control, so spoofing an `X-Forwarded-*` header doesn't get + # an attacker anything. + forwarded_allow_ips="*", ) class Server(uvicorn.Server): diff --git a/reboot/aio/internals/middleware.py b/reboot/aio/internals/middleware.py index 27527bcc..ae2a1d7c 100644 --- a/reboot/aio/internals/middleware.py +++ b/reboot/aio/internals/middleware.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools import grpc import reboot.aio.tracing @@ -37,6 +39,7 @@ from reboot.settings import DOCS_BASE_URL from reboot.time import DateTimeWithTimeZone from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Callable, @@ -48,6 +51,12 @@ TypeVar, ) +# `StateManager` is only used in type annotations in this module. +# A runtime import would create a circular dependency: `middleware` +# imports `state_managers`, and `state_managers` imports `middleware`. +if TYPE_CHECKING: + from reboot.aio.state_managers import StateManager + P = ParamSpec('P') SelfT = TypeVar('SelfT') ReturnT = TypeVar('ReturnT') @@ -131,6 +140,9 @@ def create_context( method: str, context_type: type[ContextT], task: Optional[TaskEffect] = None, + # These parameters are used in the `WorkflowContext` only. + reactively_state_manager: Optional[StateManager] = None, + reactively_state_type: Optional[type] = None, ) -> ContextT: """Create a Context object given the parameters.""" # Toggle 'servicing' to indicate we are initializing the @@ -145,7 +157,7 @@ def create_context( f"this servicer is of type {self._state_type_name}" ) - context = context_type( + kwargs: dict = dict( channel_manager=self.channel_manager, headers=headers, state_type_name=state_type_name, @@ -153,6 +165,17 @@ def create_context( task=task, effect_validation=self._effect_validation, ) + if context_type == WorkflowContext: + assert reactively_state_manager is not None, ( + "`reactively_state_manager` is required for" + " `WorkflowContext`" + ) + assert reactively_state_type is not None, ( + "`reactively_state_type` is required for `WorkflowContext`" + ) + kwargs['reactively_state_manager'] = reactively_state_manager + kwargs['reactively_state_type'] = reactively_state_type + context = context_type(**kwargs) # Now toggle 'servicing' to indicate that we are servicing the # RPC which will, for example, forbid the construction of a diff --git a/reboot/aio/react.py b/reboot/aio/react.py index 487f7606..db5c6a10 100644 --- a/reboot/aio/react.py +++ b/reboot/aio/react.py @@ -25,7 +25,7 @@ ) from reboot.nodejs.python import should_print_stacktrace from reboot.settings import EVERY_LOCAL_NETWORK_ADDRESS -from typing import AsyncIterable, AsyncIterator, Optional +from typing import AsyncIterable, Optional logger = get_logger(__name__) @@ -311,9 +311,21 @@ async def _websocket_query( bearer_token=request.bearer_token, ) - async def consume_heartbeats() -> AsyncIterator[None]: - while True: - _ = await websocket.recv() + # When the WebSocket closes, cancel the query task so that + # `reactively()` sessions are cleaned up. Without this, + # `_query()` blocks indefinitely waiting for the next state + # change, and the `reactively()` session is never cleaned up. + query_task = asyncio.current_task() + assert query_task is not None + + async def consume_heartbeats(): + try: + while True: + _ = await websocket.recv() + except Exception: + # WebSocket closed (or errored); cancel the main query + # task to unblock `_query()` and trigger cleanup. + query_task.cancel() heartbeats_task = asyncio.create_task(consume_heartbeats()) @@ -366,6 +378,26 @@ async def Query( """Implements the React.Query RPC that calls into the 'Middleware.react' method for handling a single request.""" + # gRPC-asyncio does NOT automatically cancel the server-side + # asyncio task when the client disconnects from a + # server-streaming RPC. Without cancellation, the + # `reactively()` session inside `react_query()` hangs forever + # waiting for state changes, leaking memory. + # + # Register a done callback on the gRPC context so that when + # the RPC terminates for any reason (client disconnect, server + # abort, etc.) we cancel this task, propagating `CancelledError` + # into `reactively()` and triggering its `finally` cleanup. + # + # See https://github.com/grpc/grpc/issues/28999. + query_task = asyncio.current_task() + assert query_task is not None + + def done_callback(_) -> None: + query_task.cancel() + + grpc_context.add_done_callback(done_callback) + # NOTE: we don't need `with use_application_id(...)` like we # do for websockets because this is a gRPC method and thus our # `UseApplicationIdInterceptor` will have already done it for diff --git a/reboot/aio/servicers.py b/reboot/aio/servicers.py index 948a9792..39cbf1aa 100644 --- a/reboot/aio/servicers.py +++ b/reboot/aio/servicers.py @@ -35,10 +35,19 @@ class Servicer(ABC): # Naming: has a leading underscore to ensure it doesn't collide with # customer-API-defined methods; those can't use leading underscores. + @staticmethod + def _mcp_tool_names() -> list[str]: + """ + Return the MCP tool names this servicer would register. + + Overridden by generated code for states with MCP annotations. + """ + return [] + @staticmethod def _add_mcp( mcp: FastMCP, - auto_construct_state_type_full_name: Optional[str] = None, + auto_construct_state_type_full_names: list[StateTypeName], ) -> None: """ Register any MCP tools/resources on `mcp`. diff --git a/reboot/aio/state_managers.py b/reboot/aio/state_managers.py index f1a3e5dc..c54f71ae 100644 --- a/reboot/aio/state_managers.py +++ b/reboot/aio/state_managers.py @@ -1148,6 +1148,29 @@ async def _recover_workflow_iteration( self._recovered_workflow_iterations.add(key) + def complete_workflow_iteration( + self, + workflow_id: uuid.UUID, + iteration: int, + ) -> None: + """Remove the tracking entry for a completed iteration. + Once an iteration advances, its mutations will never be + queried again. + """ + self._recovered_workflow_iterations.discard( + ( + workflow_id, + iteration, + ), + ) + + def complete_workflow(self, workflow_id: uuid.UUID) -> None: + """Remove all tracking entries for a completed workflow. + Called when the task is marked COMPLETED so the sets + don't grow unboundedly. + """ + self._recovered_workflow_ids.discard(workflow_id) + async def get( self, idempotency_key: uuid.UUID, @@ -2554,7 +2577,25 @@ async def iterator( pass # Make sure we stop all transitive `React.Query` calls. - await context.react.cancel() + # Use `asyncio.shield()` so that if this task is being + # cancelled (e.g., the gRPC client disconnected), the + # cleanup still runs to completion. + context_react_cancel_task = asyncio.ensure_future( + context.react.cancel(), + ) + try: + await asyncio.shield(context_react_cancel_task) + except asyncio.CancelledError: + # Wait for `react.cancel()`` to actually finish. + await context_react_cancel_task + # Propagate so the outer task actually cancels. + raise + finally: + # We've already invalidated the `context.react` as part + # of `context.react.cancel()`, so now it is safe to set + # it to `None` to allow the GC to clean up any resources + # associated with it. + context.react = None @asynccontextmanager_span( # We expect an `EffectValidationRetry` exception; that's not an error. @@ -2693,6 +2734,28 @@ async def complete_task( ), ) + if state_ref in self._idempotent_mutations[state_type]: + # Clean up per-workflow tracking so the sets in + # `IdempotentMutations` don't grow unboundedly. + workflow_id = uuid.UUID(bytes=task_effect.task_id.task_uuid) + + # Since we clean workflow iterations when the iteration + # is done and we continue with the next one, we will + # miss cleaning up the workflow iteration for the last + # iteration of a loop, i.e. when we break/return/raise + # an error. If we raise a declared error - we will + # eventually call `complete_task()` which should clean + # the workflow iteration, but when the error was not + # declared - we don't want to clear the workflow + # iteration, since we might need it on a workflow retry. + self._idempotent_mutations[state_type][ + state_ref].complete_workflow_iteration( + workflow_id, + task_effect.iteration, + ) + self._idempotent_mutations[state_type][ + state_ref].complete_workflow(workflow_id) + @asynccontextmanager async def task_workflow( self, @@ -2786,6 +2849,7 @@ async def loop( ) async with self._mutator_locks[state_type][state_ref]: + completed_iteration = task_effect.iteration task_effect.iteration += 1 await self._store( @@ -2794,6 +2858,17 @@ async def loop( task=task_effect.to_sidecar_task(), ) + if state_ref in (self._idempotent_mutations[state_type]): + # The completed iteration's mutations will never + # be queried again; drop its tracking entry. + workflow_id = uuid.UUID( + bytes=task_effect.task_id.task_uuid + ) + self._idempotent_mutations[state_type][ + state_ref].complete_workflow_iteration( + workflow_id, completed_iteration + ) + on_loop_iteration( # The number of the anticipated next iteration. task_effect.iteration, diff --git a/reboot/aio/stubs.py b/reboot/aio/stubs.py index 5ef15c73..176d7382 100644 --- a/reboot/aio/stubs.py +++ b/reboot/aio/stubs.py @@ -10,7 +10,7 @@ from reboot.aio.backoff import Backoff from reboot.aio.caller_id import CallerID from reboot.aio.contexts import Context, Participants, WorkflowContext -from reboot.aio.external import ExternalContext +from reboot.aio.external import ExternalContext, InitializeContext from reboot.aio.headers import IDEMPOTENCY_KEY_HEADER, Headers from reboot.aio.idempotency import ( IdempotencyManager, @@ -298,7 +298,10 @@ async def _call( # and there isn't a user-provided idempotency key, add one so # that we can retry safely. if not reader and self._context is None and idempotency_key is None: - assert isinstance(self._idempotency_manager, ExternalContext) + assert ( + isinstance(self._idempotency_manager, ExternalContext) and + not isinstance(self._idempotency_manager, InitializeContext) + ), "Calls with `InitializeContext` should already have idempotency key" # We may perform transparent retries on this call. That # means it needs to be idempotent, and for non-readers that diff --git a/reboot/aio/tests.py b/reboot/aio/tests.py index e973e0a5..32970953 100644 --- a/reboot/aio/tests.py +++ b/reboot/aio/tests.py @@ -1,5 +1,7 @@ +import jwt import os import reboot.aio.reboot +import time import unittest from reboot.aio.applications import Application from reboot.aio.auth.token_verifiers import TokenVerifier @@ -10,10 +12,17 @@ from reboot.aio.reboot import ApplicationRevision from reboot.aio.servicers import Servicer from reboot.run_environments import in_nodejs -from reboot.settings import ENVVAR_REBOOT_ENABLE_EVENT_LOOP_BLOCKED_WATCHDOG +from reboot.settings import ( + ENVVAR_REBOOT_ENABLE_EVENT_LOOP_BLOCKED_WATCHDOG, + ENVVAR_REBOOT_OAUTH_SIGNING_SECRET, +) from typing import Any, Awaitable, Callable, Optional, Sequence, overload from unittest import mock +# Hardcoded signing secret for unit tests. Not a real +# secret — only used in-process for test JWT minting. +_TEST_OAUTH_SIGNING_SECRET = "reboot-test-signing-secret" + def assert_called_twice_with( testcase: unittest.IsolatedAsyncioTestCase, @@ -43,6 +52,54 @@ def __init__(self) -> None: # must be set before `start()` which is where # `monitor_event_loop()` reads the env var. os.environ[ENVVAR_REBOOT_ENABLE_EVENT_LOOP_BLOCKED_WATCHDOG] = 'true' + # Set a signing secret so that `OAuthServer` can be used in + # tests without manual env patching. Mirrors what `rbt dev` does + # for local development. + os.environ[ENVVAR_REBOOT_OAUTH_SIGNING_SECRET] = ( + _TEST_OAUTH_SIGNING_SECRET + ) + + def make_valid_oauth_access_token( + self, + user_id: str = "test-user", + ) -> str: + """ + Mint a valid JWT OAuth access token for use in tests. + + The resulting token will be accepted by Reboot applications in + unit tests, as if the caller had gone through the full OAuth + flow and was authenticated with the given user ID. + + It doesn't matter which OAuth provider is used by the + application; the OAuth flow doesn't in fact execute. A token is + directly minted using the application's signing secret, + emulating what happens in the final step of a successful OAuth + flow. The resulting token is valid, but the identity it presents + is just whatever the developer specified - no actual + authentication takes place. + """ + return self.make_jwt( + type="access", + sub=user_id, + aud="reboot-mcp", + # Valid for 1000 hours; much longer than any test should + # ever run. + exp=int(time.time()) + 60 * 60 * 1000, + ) + + def make_jwt(self, **claims: Any) -> str: + """ + Mint an arbitrary JWT signed with the test signing secret. + + Use `make_bearer_token` for the common case of a valid access + token; use this for custom tokens (expired, refresh, client, + etc.). + """ + return jwt.encode( + claims, + _TEST_OAUTH_SIGNING_SECRET, + algorithm="HS256", + ) @overload async def up( @@ -163,6 +220,12 @@ async def up( if application.has_http_routes_or_mounts(): local_envoy = True + # Mount MCP now that we know we have a real cluster coming up. + # This is deliberately deferred from `Application.__init__`, + # where the run environment was `InvalidRunEnvironment` (i.e. + # no `rbt dev` / `rbt serve` process detected). + application._mount_mcp(application._servicers or []) + revision = await super().up( servicers=application.servicers, legacy_grpc_servicers=application.legacy_grpc_servicers, diff --git a/reboot/aio/workflows.py b/reboot/aio/workflows.py index 800fb9c4..b1189847 100644 --- a/reboot/aio/workflows.py +++ b/reboot/aio/workflows.py @@ -1,5 +1,5 @@ import sys -from reboot.aio.contexts import WorkflowContext, retry_reactively_until +from reboot.aio.contexts import WorkflowContext from reboot.aio.idempotency import ( # noqa: F401 ALWAYS, PER_ITERATION, @@ -289,7 +289,7 @@ async def until( """ async def converge(): - return await retry_reactively_until(context, callable) + return await context.retry_reactively_until(callable) assert memoize is not None return await memoize( diff --git a/reboot/api.py b/reboot/api.py index 9774bfd5..64b87569 100644 --- a/reboot/api.py +++ b/reboot/api.py @@ -1183,36 +1183,34 @@ def __init__(self, **types: Optional[Type]): if not has_default and not is_optional: raise UserPydanticError( f"Field `{field_name}` in " - f"{AUTO_CONSTRUCT_STATE_TYPE} " - "state model " + f"{type_name} state model " f"`{data_type.state.__name__}` " "must have a default value, or " "be optional. " - f"{AUTO_CONSTRUCT_STATE_TYPE} " - "instances are auto-constructed, " - "in their default (empty) state, " - "for every new AI session connecting " - "to the application, and such a " + f"{type_name} instances are " + "auto-constructed, in their " + "default (empty) state, and such a " "fresh state must be valid." ) if AUTO_CONSTRUCT_METHOD in data_type.methods: raise UserPydanticError( - f"'{AUTO_CONSTRUCT_METHOD}' is a reserved " - f"method name for " - f"{AUTO_CONSTRUCT_STATE_TYPE} types. If " - "you want custom initialization logic, " + f"'{AUTO_CONSTRUCT_METHOD}' is a " + "reserved method name for " + f"{type_name} types. If you want " + "custom initialization logic, " "override the default " - f"'{AUTO_CONSTRUCT_METHOD}' Writer in " - "your servicer implementation instead of " - "declaring it in the API definition. " - "You may also declare alternative " - "factory methods for your own use, but " - "note the system will always use " + f"'{AUTO_CONSTRUCT_METHOD}' Writer " + "in your servicer implementation " + "instead of declaring it in the API " + "definition. You may also declare " + "alternative factory methods for " + "your own use, but note the system " + "will always use " f"'{AUTO_CONSTRUCT_METHOD}' when " "automatically constructing a " - f"'{AUTO_CONSTRUCT_STATE_TYPE}' for a " - "new AI session." + f"'{type_name}' for a new AI " + "session." ) data_type.methods[AUTO_CONSTRUCT_METHOD] = Writer( request=None, diff --git a/reboot/benchmarks/construct/package-lock.json b/reboot/benchmarks/construct/package-lock.json index 96070a0e..e3eade00 100644 --- a/reboot/benchmarks/construct/package-lock.json +++ b/reboot/benchmarks/construct/package-lock.json @@ -10,7 +10,7 @@ "license": "ISC", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "@supercharge/promise-pool": "^3.2.0", "parse-duration": "2.1.3", "uuid": "11.1.0" @@ -507,15 +507,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -544,9 +544,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -3092,13 +3092,13 @@ "optional": true }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -3115,9 +3115,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", diff --git a/reboot/benchmarks/construct/package.json b/reboot/benchmarks/construct/package.json index 11c23c05..eb5f0fff 100644 --- a/reboot/benchmarks/construct/package.json +++ b/reboot/benchmarks/construct/package.json @@ -11,7 +11,7 @@ "type": "module", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "@supercharge/promise-pool": "^3.2.0", "uuid": "11.1.0", "parse-duration": "2.1.3" diff --git a/reboot/cli/cloud.py b/reboot/cli/cloud.py index d58d5ad2..d2c0f97e 100644 --- a/reboot/cli/cloud.py +++ b/reboot/cli/cloud.py @@ -589,11 +589,20 @@ async def cloud_up(args: argparse.Namespace) -> int: continue if revision.status == Status.UP: + url = _application_url( + ApplicationId(up_response.application_id), + args.cloud_url, + ) terminal.info( f"✅\n" "\n" - f" '{args.name}' revision {revision.number} is available at: {_application_url(ApplicationId(up_response.application_id), args.cloud_url)}" + f" '{args.name}' revision {revision.number}" + " is available:\n" "\n" + f" Your API is available at: {url}\n" + f" MCP clients can connect at: {url}/mcp\n" + " You can inspect your state at: " + f"{url}/__/inspect\n" ) return 0 @@ -690,7 +699,9 @@ async def cloud_down(args: argparse.Namespace) -> None: if organization_name is None: terminal.fail( f"User '{user_id}' does not have an application " - f"named '{args.name}'" + f"named '{args.name}'. If the application " + "belongs to an organization, try adding " + "--organization=." ) else: terminal.fail( @@ -866,7 +877,9 @@ async def _cloud_logs( if organization_name is None: terminal.fail( f"User '{user_id}' does not have an " - f"application named '{name}'" + f"application named '{name}'. If the " + "application belongs to an organization, " + "try adding --organization=." ) else: terminal.fail( diff --git a/reboot/cli/dev.py b/reboot/cli/dev.py index 1a069763..14148991 100644 --- a/reboot/cli/dev.py +++ b/reboot/cli/dev.py @@ -67,6 +67,7 @@ ENVVAR_RBT_STATE_DIRECTORY, ENVVAR_REBOOT_LOCAL_ENVOY, ENVVAR_REBOOT_LOCAL_ENVOY_PORT, + ENVVAR_REBOOT_OAUTH_SIGNING_SECRET, ENVVAR_REBOOT_USE_TTY, RBT_APPLICATION_EXIT_CODE_BACKWARDS_INCOMPATIBILITY, ) @@ -481,6 +482,7 @@ async def _check_local_envoy_status( tls_certificate: Optional[str], root_certificate: Optional[str], tracing: Tracing, + nodejs: bool, ) -> None: """Checks if the application is up and running. Optionally exits as soon as the health check has passed. @@ -608,9 +610,14 @@ def create_channel( if is_application_serving: terminal.info("Application is serving traffic ...\n") + # MCP server and endpoint is not supported for Nodejs currently. + mcp_line = ( + "" if nodejs else + f" MCP clients can connect at: {protocol}://{address}/mcp\n" + ) terminal.info( f" Your API is available at: {protocol}://{address}\n" - f" MCP clients can connect at: {protocol}://{address}/mcp\n" + f"{mcp_line}" f" You can inspect your state at: {protocol}://{address}/__/inspect\n", color=Fore.WHITE, ) @@ -1292,6 +1299,7 @@ async def __dev_run( tls_certificate=args.tls_certificate, root_certificate=args.tls_root_certificate, tracing=tracing, + nodejs=bool(args.nodejs), ), name=f'_check_local_envoy_status(...) in {__name__}', ) @@ -1335,6 +1343,12 @@ async def __dev_run( env[ENVVAR_RBT_CLOUD_API_KEY] = args.api_key env[ENVVAR_RBT_CLOUD_URL] = args.cloud_url + # Set a signing secret for the MCP OAuth server. For local + # dev we use the application name (insecure, but convenient). + # Fall back to a fixed string when `--name` wasn't provided. + if ENVVAR_REBOOT_OAUTH_SIGNING_SECRET not in env: + env[ENVVAR_REBOOT_OAUTH_SIGNING_SECRET] = args.name or "reboot-dev" + if tracing == Tracing.JAEGER: # TODO: dynamic port. See comment in `_run_jaeger()`. env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT] = "localhost:4317" diff --git a/reboot/cli/init/nodejs_test.sh b/reboot/cli/init/nodejs_test.sh index ef025590..4c61f630 100755 --- a/reboot/cli/init/nodejs_test.sh +++ b/reboot/cli/init/nodejs_test.sh @@ -57,7 +57,7 @@ npm init -y # order to do `rbt init`. npm install --no-save $REBOOT_NPM_PACKAGE $REBOOT_API_NPM_PACKAGE -EXPECTED_RBT_DEV_OUTPUT_FILE=$(rlocation "$(dirname "$0")/expected_output.txt") +EXPECTED_RBT_DEV_OUTPUT_FILE=$(rlocation "$(dirname "$0")/expected_nodejs_output.txt") # When running in a Bazel test, our `.rbtrc` file ends up in a very deep # directory structure, which can result in "path too long" errors from RocksDB. diff --git a/reboot/cli/init/templates/backend_package.json.j2 b/reboot/cli/init/templates/backend_package.json.j2 index 399b405c..b2d14e79 100644 --- a/reboot/cli/init/templates/backend_package.json.j2 +++ b/reboot/cli/init/templates/backend_package.json.j2 @@ -4,7 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "^5.5.2" } } diff --git a/reboot/cli/init/templates/package.json.j2 b/reboot/cli/init/templates/package.json.j2 index 8542f0cf..765f522c 100644 --- a/reboot/cli/init/templates/package.json.j2 +++ b/reboot/cli/init/templates/package.json.j2 @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@types/jest": "^27.5.2", "@types/node": "^20.11.5", "@types/react": "^19.2.1", diff --git a/reboot/cli/init/test.sh b/reboot/cli/init/test.sh index adce999a..4c262c83 100755 --- a/reboot/cli/init/test.sh +++ b/reboot/cli/init/test.sh @@ -51,7 +51,7 @@ rbt generate PYTHONPATH=backend/api/:backend/src/ python test.py # Run 'rbt dev run' to make sure that the generated '.rbtrc' config works. -EXPECTED_RBT_DEV_OUTPUT_FILE=$(rlocation "$(dirname "$0")/expected_output.txt") +EXPECTED_RBT_DEV_OUTPUT_FILE=$(rlocation "$(dirname "$0")/expected_multi_env_output.txt") actual_output_file=$(mktemp) rbt dev run --terminate-after-health-check > "$actual_output_file" diff --git a/reboot/demos/fig/package-lock.json b/reboot/demos/fig/package-lock.json index 9b61a373..eb96e73e 100644 --- a/reboot/demos/fig/package-lock.json +++ b/reboot/demos/fig/package-lock.json @@ -10,11 +10,11 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@radix-ui/react-icons": "^1.3.0", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-std": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-std-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-std-react": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", @@ -1027,9 +1027,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1353,15 +1353,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -1390,9 +1390,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1416,15 +1416,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1452,45 +1452,45 @@ } }, "node_modules/@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-std-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.45.2.tgz", - "integrity": "sha512-tohcL7WQT5Z1CUkryJlSHvQ1Koxn5LgDuLZctw0/m+2DLasgTW5eU+e1pCrLMxBpuiUK52mVDk7e93mNeT+lvQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.46.0.tgz", + "integrity": "sha512-QVQj7DsjsbdXAPmIRN3xrjnabYfB1nIk00FQxEs31sXQwdP1Mcr0QddwzyhrtoX84t5+HNV5YZWz2vP+PLcQQg==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -3614,9 +3614,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -4083,9 +4083,9 @@ } }, "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4342,9 +4342,9 @@ } }, "node_modules/jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7146,12 +7146,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } }, @@ -7767,9 +7767,9 @@ "dev": true }, "@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", "requires": {} }, "@humanwhocodes/config-array": { @@ -7997,13 +7997,13 @@ "requires": {} }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -8191,9 +8191,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -8208,14 +8208,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -8232,41 +8232,41 @@ } }, "@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "requires": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "requires": { "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-std-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.45.2.tgz", - "integrity": "sha512-tohcL7WQT5Z1CUkryJlSHvQ1Koxn5LgDuLZctw0/m+2DLasgTW5eU+e1pCrLMxBpuiUK52mVDk7e93mNeT+lvQ==", - "requires": { - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.46.0.tgz", + "integrity": "sha512-QVQj7DsjsbdXAPmIRN3xrjnabYfB1nIk00FQxEs31sXQwdP1Mcr0QddwzyhrtoX84t5+HNV5YZWz2vP+PLcQQg==", + "requires": { + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -9454,9 +9454,9 @@ } }, "express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "requires": { "ip-address": "10.1.0" }, @@ -9789,9 +9789,9 @@ } }, "hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==" + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==" }, "http-cache-semantics": { "version": "4.1.1", @@ -9973,9 +9973,9 @@ "dev": true }, "jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==" + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==" }, "js-sha1": { "version": "0.7.0", @@ -11776,9 +11776,9 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "requires": {} } } diff --git a/reboot/demos/fig/package.json b/reboot/demos/fig/package.json index 653b6ba9..643d456a 100644 --- a/reboot/demos/fig/package.json +++ b/reboot/demos/fig/package.json @@ -11,11 +11,11 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@radix-ui/react-icons": "^1.3.0", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-std": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-std-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-std-react": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", diff --git a/reboot/examples/ai-chat-counter-dashboard/README.md b/reboot/examples/ai-chat-counter-dashboard/README.md index 94836bc3..45ca43df 100644 --- a/reboot/examples/ai-chat-counter-dashboard/README.md +++ b/reboot/examples/ai-chat-counter-dashboard/README.md @@ -29,7 +29,7 @@ The project includes `mcp_servers.json` for testing with MCPJam Inspector: ```bash # In another terminal, run MCPJam: -npx @mcpjam/inspector@v2.0.4 --config mcp_servers.json --server counter-server +npx @mcpjam/inspector@v2.0.18 --config mcp_servers.json --server counter-server ``` This opens a browser-based inspector where you can test tools. diff --git a/reboot/examples/ai-chat-counter-dashboard/api/ai_chat_counter/v1/counter.py b/reboot/examples/ai-chat-counter-dashboard/api/ai_chat_counter/v1/counter.py index 5599dd88..926bfbf0 100644 --- a/reboot/examples/ai-chat-counter-dashboard/api/ai_chat_counter/v1/counter.py +++ b/reboot/examples/ai-chat-counter-dashboard/api/ai_chat_counter/v1/counter.py @@ -12,16 +12,34 @@ ) +class CreateCounterRequest(Model): + description: str = Field(tag=1) + + class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) + + +class DescriptionResponse(Model): + description: str = Field(tag=1) class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") class GetResponse(Model): @@ -39,17 +57,26 @@ class DashboardConfig(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns " - "the ID of the new counter. That ID is " - "not human-readable; pass it to future " - "tool calls where needed, but no need to " - "tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created " + "by this user. Returns `counter_id` and " + "description for each. The `counter_id` " + "is not human-readable, but use it when " + "calling tools that take a `counter_id`.", ), ), ), @@ -72,7 +99,7 @@ class DashboardConfig(Model): "on the topic of counting things.", ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -90,6 +117,10 @@ class DashboardConfig(Model): "the specified amount.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) diff --git a/reboot/examples/ai-chat-counter-dashboard/backend/src/main.py b/reboot/examples/ai-chat-counter-dashboard/backend/src/main.py index 95aefe3b..bc98a692 100644 --- a/reboot/examples/ai-chat-counter-dashboard/backend/src/main.py +++ b/reboot/examples/ai-chat-counter-dashboard/backend/src/main.py @@ -1,11 +1,13 @@ import asyncio from reboot.aio.applications import Application -from servicers.counter import CounterServicer, SessionServicer +from reboot.aio.auth.oauth_providers import Anonymous +from servicers.counter import CounterServicer, UserServicer async def main() -> None: application = Application( - servicers=[SessionServicer, CounterServicer], + servicers=[UserServicer, CounterServicer], + oauth=Anonymous(), ) await application.run() diff --git a/reboot/examples/ai-chat-counter-dashboard/backend/src/servicers/counter.py b/reboot/examples/ai-chat-counter-dashboard/backend/src/servicers/counter.py index 50397724..1c1d3bd5 100644 --- a/reboot/examples/ai-chat-counter-dashboard/backend/src/servicers/counter.py +++ b/reboot/examples/ai-chat-counter-dashboard/backend/src/servicers/counter.py @@ -1,4 +1,9 @@ -from ai_chat_counter.v1.counter_rbt import Counter, Session +from ai_chat_counter.v1.counter import ( + CreateCounterRequest, + CreateCounterResponse, + ListCountersResponse, +) +from ai_chat_counter.v1.counter_rbt import Counter, User from reboot.aio.auth.authorizers import allow from reboot.aio.contexts import ( ReaderContext, @@ -7,22 +12,39 @@ ) -class SessionServicer(Session.Servicer): - """Servicer for the Session state machine.""" - - def authorizer(self): - return allow() +class UserServicer(User.Servicer): + """Servicer for the User state machine.""" async def create_counter( self, context: TransactionContext, - ) -> Session.CreateCounterResponse: + request: CreateCounterRequest, + ) -> CreateCounterResponse: """Create a new Counter and return its ID.""" - counter, _ = await Counter.create(context) - return Session.CreateCounterResponse( + counter, _ = await Counter.create( + context, description=request.description + ) + self.state.counter_ids.append(counter.state_id) + return CreateCounterResponse( counter_id=counter.state_id, ) + async def list_counters( + self, + context: ReaderContext, + ) -> ListCountersResponse: + """List all counters created by this user.""" + counters = [] + for counter_id in self.state.counter_ids: + response = await Counter.ref(counter_id).description(context) + counters.append( + User.CounterEntry( + counter_id=counter_id, + description=response.description, + ) + ) + return ListCountersResponse(counters=counters) + class CounterServicer(Counter.Servicer): """Servicer for the Counter state machine.""" @@ -30,8 +52,22 @@ class CounterServicer(Counter.Servicer): def authorizer(self): return allow() - async def create(self, context) -> None: - pass + async def create( + self, + context: WriterContext, + request: CreateCounterRequest, + ) -> None: + """Initialize the counter with a description.""" + self.state.description = request.description + + async def description( + self, + context: ReaderContext, + ) -> Counter.DescriptionResponse: + """Get the counter's description.""" + return Counter.DescriptionResponse( + description=self.state.description, + ) async def increment( self, diff --git a/reboot/examples/ai-chat-counter-dashboard/mcp_servers.json b/reboot/examples/ai-chat-counter-dashboard/mcp_servers.json index 63f4c1df..3529238f 100644 --- a/reboot/examples/ai-chat-counter-dashboard/mcp_servers.json +++ b/reboot/examples/ai-chat-counter-dashboard/mcp_servers.json @@ -2,7 +2,8 @@ "mcpServers": { "counter-server": { "type": "streamable-http", - "url": "http://localhost:9991/mcp" + "url": "http://localhost:9991/mcp", + "auth": "oauth" } } } diff --git a/reboot/examples/ai-chat-counter-dashboard/pyproject.toml b/reboot/examples/ai-chat-counter-dashboard/pyproject.toml index 0c633de1..b8335661 100644 --- a/reboot/examples/ai-chat-counter-dashboard/pyproject.toml +++ b/reboot/examples/ai-chat-counter-dashboard/pyproject.toml @@ -6,14 +6,14 @@ dependencies = [ "httpx>=0.27,<1.0", "uuid7>=0.1.0", "anyio>=4.0.0", - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] dev-dependencies = [ "mypy==1.18.1", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies. diff --git a/reboot/examples/ai-chat-counter-dashboard/requirements-dev.lock b/reboot/examples/ai-chat-counter-dashboard/requirements-dev.lock index a5154188..0d48cc33 100644 --- a/reboot/examples/ai-chat-counter-dashboard/requirements-dev.lock +++ b/reboot/examples/ai-chat-counter-dashboard/requirements-dev.lock @@ -190,10 +190,12 @@ python-dotenv==1.2.1 # via pydantic-settings python-multipart==0.0.21 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/ai-chat-counter-dashboard/requirements.lock b/reboot/examples/ai-chat-counter-dashboard/requirements.lock index 8f0a8b59..1d38fdeb 100644 --- a/reboot/examples/ai-chat-counter-dashboard/requirements.lock +++ b/reboot/examples/ai-chat-counter-dashboard/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/ai-chat-counter-dashboard/web/package-lock.json b/reboot/examples/ai-chat-counter-dashboard/web/package-lock.json index 1e8878c7..bd1f749d 100644 --- a/reboot/examples/ai-chat-counter-dashboard/web/package-lock.json +++ b/reboot/examples/ai-chat-counter-dashboard/web/package-lock.json @@ -8,10 +8,10 @@ "name": "ai-chat-counter-dashboard-web", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "^0.45.2", - "@reboot-dev/reboot-react": "^0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zod": "^3.25.0" @@ -883,9 +883,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -910,15 +910,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -946,12 +946,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/ai-chat-counter-dashboard/web/package.json b/reboot/examples/ai-chat-counter-dashboard/web/package.json index 12078863..be009499 100644 --- a/reboot/examples/ai-chat-counter-dashboard/web/package.json +++ b/reboot/examples/ai-chat-counter-dashboard/web/package.json @@ -13,10 +13,10 @@ "build:watch": "concurrently \"npm:build:watch:*\"" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-api": "^0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zod": "^3.25.0" diff --git a/reboot/examples/ai-chat-counter/README.md b/reboot/examples/ai-chat-counter/README.md index bafee475..422abc87 100644 --- a/reboot/examples/ai-chat-counter/README.md +++ b/reboot/examples/ai-chat-counter/README.md @@ -28,7 +28,7 @@ The project includes `mcp_servers.json` for testing with MCPJam Inspector: ```bash # In another terminal, run MCPJam: -npx @mcpjam/inspector@v2.0.4 --config mcp_servers.json --server counter-server +npx @mcpjam/inspector@v2.0.18 --config mcp_servers.json --server counter-server ``` This opens a browser-based inspector where you can test tools. diff --git a/reboot/examples/ai-chat-counter/api/ai_chat_counter/v1/counter.py b/reboot/examples/ai-chat-counter/api/ai_chat_counter/v1/counter.py index d5288f5a..d219f313 100644 --- a/reboot/examples/ai-chat-counter/api/ai_chat_counter/v1/counter.py +++ b/reboot/examples/ai-chat-counter/api/ai_chat_counter/v1/counter.py @@ -13,16 +13,34 @@ ) +class CreateCounterRequest(Model): + description: str = Field(tag=1) + + class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) + + +class DescriptionResponse(Model): + description: str = Field(tag=1) class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") class GetResponse(Model): @@ -35,17 +53,26 @@ class IncrementRequest(Model): api = API( - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns " - "the ID of the new counter. That ID is " - "not human-readable; pass it to future " - "tool calls where needed, but no need to " - "tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created " + "by this user. Returns `counter_id` and " + "description for each. The `counter_id` " + "is not human-readable, but use it when " + "calling tools that take a `counter_id`.", ), ), ), @@ -60,7 +87,7 @@ class IncrementRequest(Model): "for the counter.", ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -78,6 +105,10 @@ class IncrementRequest(Model): "the specified amount.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) diff --git a/reboot/examples/ai-chat-counter/backend/src/main.py b/reboot/examples/ai-chat-counter/backend/src/main.py index 65a286ba..5467ed80 100644 --- a/reboot/examples/ai-chat-counter/backend/src/main.py +++ b/reboot/examples/ai-chat-counter/backend/src/main.py @@ -1,12 +1,14 @@ # backend/src/main.py import asyncio from reboot.aio.applications import Application -from servicers.counter import CounterServicer, SessionServicer +from reboot.aio.auth.oauth_providers import Anonymous +from servicers.counter import CounterServicer, UserServicer async def main() -> None: application = Application( - servicers=[SessionServicer, CounterServicer], + servicers=[UserServicer, CounterServicer], + oauth=Anonymous(), ) await application.run() diff --git a/reboot/examples/ai-chat-counter/backend/src/servicers/counter.py b/reboot/examples/ai-chat-counter/backend/src/servicers/counter.py index ba165a7f..ecdca828 100644 --- a/reboot/examples/ai-chat-counter/backend/src/servicers/counter.py +++ b/reboot/examples/ai-chat-counter/backend/src/servicers/counter.py @@ -1,5 +1,10 @@ # backend/src/servicers/counter.py -from ai_chat_counter.v1.counter_rbt import Counter, Session +from ai_chat_counter.v1.counter import ( + CreateCounterRequest, + CreateCounterResponse, + ListCountersResponse, +) +from ai_chat_counter.v1.counter_rbt import Counter, User from reboot.aio.auth.authorizers import Authorizer, allow from reboot.aio.contexts import ( ReaderContext, @@ -8,22 +13,39 @@ ) -class SessionServicer(Session.Servicer): - """Servicer for the Session state machine.""" - - def authorizer(self) -> Authorizer: - return allow() +class UserServicer(User.Servicer): + """Servicer for the User state machine.""" async def create_counter( self, context: TransactionContext, - ) -> Session.CreateCounterResponse: + request: CreateCounterRequest, + ) -> CreateCounterResponse: """Create a new Counter and return its ID.""" - counter, _ = await Counter.create(context) - return Session.CreateCounterResponse( + counter, _ = await Counter.create( + context, description=request.description + ) + self.state.counter_ids.append(counter.state_id) + return CreateCounterResponse( counter_id=counter.state_id, ) + async def list_counters( + self, + context: ReaderContext, + ) -> ListCountersResponse: + """List all counters created by this user.""" + counters = [] + for counter_id in self.state.counter_ids: + response = await Counter.ref(counter_id).description(context) + counters.append( + User.CounterEntry( + counter_id=counter_id, + description=response.description, + ) + ) + return ListCountersResponse(counters=counters) + class CounterServicer(Counter.Servicer): """Servicer for the Counter state machine.""" @@ -31,8 +53,22 @@ class CounterServicer(Counter.Servicer): def authorizer(self) -> Authorizer: return allow() - async def create(self, context) -> None: - pass + async def create( + self, + context: WriterContext, + request: CreateCounterRequest, + ) -> None: + """Initialize the counter with a description.""" + self.state.description = request.description + + async def description( + self, + context: ReaderContext, + ) -> Counter.DescriptionResponse: + """Get the counter's description.""" + return Counter.DescriptionResponse( + description=self.state.description, + ) async def increment( self, diff --git a/reboot/examples/ai-chat-counter/mcp_servers.json b/reboot/examples/ai-chat-counter/mcp_servers.json index 63f4c1df..3529238f 100644 --- a/reboot/examples/ai-chat-counter/mcp_servers.json +++ b/reboot/examples/ai-chat-counter/mcp_servers.json @@ -2,7 +2,8 @@ "mcpServers": { "counter-server": { "type": "streamable-http", - "url": "http://localhost:9991/mcp" + "url": "http://localhost:9991/mcp", + "auth": "oauth" } } } diff --git a/reboot/examples/ai-chat-counter/pyproject.toml b/reboot/examples/ai-chat-counter/pyproject.toml index 8fc14d2c..4d220430 100644 --- a/reboot/examples/ai-chat-counter/pyproject.toml +++ b/reboot/examples/ai-chat-counter/pyproject.toml @@ -6,14 +6,14 @@ dependencies = [ "httpx>=0.27,<1.0", "uuid7>=0.1.0", "anyio>=4.0.0", - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] dev-dependencies = [ "mypy==1.18.1", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies. diff --git a/reboot/examples/ai-chat-counter/requirements-dev.lock b/reboot/examples/ai-chat-counter/requirements-dev.lock index a5154188..0d48cc33 100644 --- a/reboot/examples/ai-chat-counter/requirements-dev.lock +++ b/reboot/examples/ai-chat-counter/requirements-dev.lock @@ -190,10 +190,12 @@ python-dotenv==1.2.1 # via pydantic-settings python-multipart==0.0.21 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/ai-chat-counter/requirements.lock b/reboot/examples/ai-chat-counter/requirements.lock index 8f0a8b59..1d38fdeb 100644 --- a/reboot/examples/ai-chat-counter/requirements.lock +++ b/reboot/examples/ai-chat-counter/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/ai-chat-counter/web/package-lock.json b/reboot/examples/ai-chat-counter/web/package-lock.json index d5856698..6790b86a 100644 --- a/reboot/examples/ai-chat-counter/web/package-lock.json +++ b/reboot/examples/ai-chat-counter/web/package-lock.json @@ -8,10 +8,10 @@ "name": "ai-chat-counter-web", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "^0.45.2", - "@reboot-dev/reboot-react": "^0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zod": "^3.25.0" @@ -820,33 +820,13 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.1.2.tgz", - "integrity": "sha512-Gx4TEo3/F8yq1Ix6LdgLwMrKqfZqD7++eakZdbMUewrYtHeeJn3nKpeNhgEfO7nYRwonqWYomOAszWZWJS0IbA==", - "hasInstallScript": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.2.0.tgz", + "integrity": "sha512-ijUQJX/FmNq8PWgOLzph/BAfy84sUZxoIRuHzr+F37wYtWjhdl8pliBJybapYolppY+XJ8oqjFZmTOuMqxwbWQ==", "license": "MIT", "workspaces": [ "examples/*" ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" - }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -902,153 +882,10 @@ } } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz", - "integrity": "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.6.tgz", - "integrity": "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.6.tgz", - "integrity": "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.6.tgz", - "integrity": "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.6.tgz", - "integrity": "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.6.tgz", - "integrity": "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.6.tgz", - "integrity": "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.6.tgz", - "integrity": "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.6.tgz", - "integrity": "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.6.tgz", - "integrity": "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.6.tgz", - "integrity": "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1073,15 +910,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1109,12 +946,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -1181,6 +1018,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1194,6 +1032,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1263,6 +1102,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1388,6 +1228,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1443,6 +1284,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1484,6 +1326,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/reboot/examples/ai-chat-counter/web/package.json b/reboot/examples/ai-chat-counter/web/package.json index 0830bf6e..1de9ca1e 100644 --- a/reboot/examples/ai-chat-counter/web/package.json +++ b/reboot/examples/ai-chat-counter/web/package.json @@ -11,10 +11,10 @@ "build:watch": "npm run build:watch:clicker" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-api": "^0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zod": "^3.25.0" diff --git a/reboot/examples/bank-nodejs/package-lock.json b/reboot/examples/bank-nodejs/package-lock.json index f7d71af9..7c36600e 100644 --- a/reboot/examples/bank-nodejs/package-lock.json +++ b/reboot/examples/bank-nodejs/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.0", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "@types/node": "20.11.5", "typescript": "5.4.5" }, @@ -510,15 +510,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -547,9 +547,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -3119,13 +3119,13 @@ "optional": true }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -3142,9 +3142,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", diff --git a/reboot/examples/bank-nodejs/package.json b/reboot/examples/bank-nodejs/package.json index c9ca3721..fae329f0 100644 --- a/reboot/examples/bank-nodejs/package.json +++ b/reboot/examples/bank-nodejs/package.json @@ -5,8 +5,8 @@ "type": "module", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "typescript": "5.4.5", "@types/node": "20.11.5" }, diff --git a/reboot/examples/bank-pydantic/pyproject.toml b/reboot/examples/bank-pydantic/pyproject.toml index 1e7423dc..142168e1 100644 --- a/reboot/examples/bank-pydantic/pyproject.toml +++ b/reboot/examples/bank-pydantic/pyproject.toml @@ -1,7 +1,7 @@ [project] requires-python = ">= 3.10" dependencies = [ - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] @@ -9,7 +9,7 @@ dev-dependencies = [ "mypy==1.18.1", "pytest>=7.4.2", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/bank-pydantic/requirements-dev.lock b/reboot/examples/bank-pydantic/requirements-dev.lock index b0db4e88..39e69215 100644 --- a/reboot/examples/bank-pydantic/requirements-dev.lock +++ b/reboot/examples/bank-pydantic/requirements-dev.lock @@ -199,10 +199,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/bank-pydantic/requirements.lock b/reboot/examples/bank-pydantic/requirements.lock index 93611150..997670d5 100644 --- a/reboot/examples/bank-pydantic/requirements.lock +++ b/reboot/examples/bank-pydantic/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/bank-pydantic/web/package-lock.json b/reboot/examples/bank-pydantic/web/package-lock.json index d91988ef..2cc71cab 100644 --- a/reboot/examples/bank-pydantic/web/package-lock.json +++ b/reboot/examples/bank-pydantic/web/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "dependencies": { "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-api": "^0.45.2", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-std": "^0.45.2", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", "@tailwindcss/vite": "^4.1.11", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -973,13 +973,10 @@ } }, "node_modules/@gar/promise-retry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", - "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", "license": "MIT", - "dependencies": { - "retry": "^0.13.1" - }, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -1225,9 +1222,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1257,16 +1254,25 @@ "node": ">=10" } }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -1295,9 +1301,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1321,15 +1327,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1356,32 +1362,32 @@ } }, "node_modules/@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -3289,9 +3295,9 @@ } }, "node_modules/cacache": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", - "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", "license": "ISC", "dependencies": { "@npmcli/fs": "^5.0.0", @@ -3303,17 +3309,16 @@ "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" + "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4433,9 +4438,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4445,12 +4450,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -5405,13 +5410,14 @@ } }, "node_modules/make-fetch-happen": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", - "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", @@ -5566,10 +5572,10 @@ } }, "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "license": "ISC", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", "dependencies": { "minipass": "^3.0.0" }, @@ -5978,9 +5984,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -6285,15 +6291,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7017,30 +7014,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/unique-filename": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", - "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", - "license": "ISC", - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/unique-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", - "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8032,12 +8005,9 @@ } }, "@gar/promise-retry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", - "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", - "requires": { - "retry": "^0.13.1" - } + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==" }, "@hono/node-server": { "version": "1.19.11", @@ -8198,9 +8168,9 @@ }, "dependencies": { "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==" + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==" } } }, @@ -8219,14 +8189,19 @@ } } }, + "@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==" + }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -8427,9 +8402,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -8444,14 +8419,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -8468,29 +8443,29 @@ } }, "@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "requires": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "requires": { "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -9412,9 +9387,9 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" }, "cacache": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", - "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", "requires": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", @@ -9425,14 +9400,13 @@ "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" + "ssri": "^13.0.0" }, "dependencies": { "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==" + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==" } } }, @@ -10186,19 +10160,19 @@ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" }, "brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "requires": { "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "requires": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" } }, "minipass": { @@ -10773,12 +10747,13 @@ } }, "make-fetch-happen": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", - "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", "requires": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", @@ -10876,9 +10851,9 @@ } }, "minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "requires": { "minipass": "^3.0.0" }, @@ -11145,9 +11120,9 @@ }, "dependencies": { "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==" + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==" } } }, @@ -11331,11 +11306,6 @@ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, "reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11825,22 +11795,6 @@ "@typescript-eslint/utils": "8.46.0" } }, - "unique-filename": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", - "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", - "requires": { - "unique-slug": "^6.0.0" - } - }, - "unique-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", - "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", - "requires": { - "imurmurhash": "^0.1.4" - } - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/reboot/examples/bank-pydantic/web/package.json b/reboot/examples/bank-pydantic/web/package.json index f18845e2..4bb1cdb0 100644 --- a/reboot/examples/bank-pydantic/web/package.json +++ b/reboot/examples/bank-pydantic/web/package.json @@ -5,9 +5,9 @@ "type": "module", "dependencies": { "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-api": "^0.45.2", - "@reboot-dev/reboot-std": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", diff --git a/reboot/examples/bank-zod/package-lock.json b/reboot/examples/bank-zod/package-lock.json index 9a9a1452..1882821a 100644 --- a/reboot/examples/bank-zod/package-lock.json +++ b/reboot/examples/bank-zod/package-lock.json @@ -9,10 +9,10 @@ "version": "0.1.0", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", - "@reboot-dev/reboot-std": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", "@tailwindcss/vite": "^4.1.11", "@types/node": "20.11.5", "lucide-react": "^0.525.0", @@ -1196,15 +1196,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -1233,9 +1233,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1259,15 +1259,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1294,32 +1294,32 @@ } }, "node_modules/@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -7379,13 +7379,13 @@ "optional": true }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -7402,9 +7402,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -7419,14 +7419,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -7443,29 +7443,29 @@ } }, "@reboot-dev/reboot-std": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.45.2.tgz", - "integrity": "sha512-ZpdblWHx7Wfps/935GSxeni7odQvwGvsj/PN+w6F4Kg01QtwCY7/OCeYL0B6MwhYIUDgXJMSCL1d4QopnEgnGg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std/-/reboot-std-0.46.0.tgz", + "integrity": "sha512-Sd915ak5YKuLcEteA/ULzJsehiDp3/TSrPHXIw1Osza9Yqakewntv47QsLgTnqCBxhpvQ49WbTqK6iEJQ1Sgqw==", "requires": { - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "requires": { "@scarf/scarf": "1.4.0" } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/bank-zod/package.json b/reboot/examples/bank-zod/package.json index 8b8fdf62..ebe7f068 100644 --- a/reboot/examples/bank-zod/package.json +++ b/reboot/examples/bank-zod/package.json @@ -9,10 +9,10 @@ }, "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-std": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "@tailwindcss/vite": "^4.1.11", "@types/node": "20.11.5", "lucide-react": "^0.525.0", diff --git a/reboot/examples/bank/pyproject.toml b/reboot/examples/bank/pyproject.toml index dea372af..6f2e8e16 100644 --- a/reboot/examples/bank/pyproject.toml +++ b/reboot/examples/bank/pyproject.toml @@ -1,14 +1,14 @@ [project] requires-python = ">= 3.10" dependencies = [ - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] dev-dependencies = [ "mypy==1.18.1", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/bank/requirements-dev.lock b/reboot/examples/bank/requirements-dev.lock index 3469ab0a..74fb6107 100644 --- a/reboot/examples/bank/requirements-dev.lock +++ b/reboot/examples/bank/requirements-dev.lock @@ -190,10 +190,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/bank/requirements.lock b/reboot/examples/bank/requirements.lock index d3993b2a..29a12988 100644 --- a/reboot/examples/bank/requirements.lock +++ b/reboot/examples/bank/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/bank/web/package-lock.json b/reboot/examples/bank/web/package-lock.json index abcbb546..656423f0 100644 --- a/reboot/examples/bank/web/package-lock.json +++ b/reboot/examples/bank/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", @@ -1224,9 +1224,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1260,15 +1260,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1295,12 +1295,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -6569,9 +6569,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -6591,14 +6591,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -6615,11 +6615,11 @@ } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/bank/web/package.json b/reboot/examples/bank/web/package.json index 7090004d..e5854724 100644 --- a/reboot/examples/bank/web/package.json +++ b/reboot/examples/bank/web/package.json @@ -6,7 +6,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", diff --git a/reboot/examples/boutique/Dockerfile b/reboot/examples/boutique/Dockerfile index a2edfd22..13bf3eb5 100644 --- a/reboot/examples/boutique/Dockerfile +++ b/reboot/examples/boutique/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/reboot-dev/reboot-base:0.45.2 +FROM ghcr.io/reboot-dev/reboot-base:0.46.0 WORKDIR /app diff --git a/reboot/examples/boutique/pyproject.toml b/reboot/examples/boutique/pyproject.toml index 1e7423dc..142168e1 100644 --- a/reboot/examples/boutique/pyproject.toml +++ b/reboot/examples/boutique/pyproject.toml @@ -1,7 +1,7 @@ [project] requires-python = ">= 3.10" dependencies = [ - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] @@ -9,7 +9,7 @@ dev-dependencies = [ "mypy==1.18.1", "pytest>=7.4.2", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/boutique/requirements-dev.lock b/reboot/examples/boutique/requirements-dev.lock index 61cc5c55..31a9f3ca 100644 --- a/reboot/examples/boutique/requirements-dev.lock +++ b/reboot/examples/boutique/requirements-dev.lock @@ -197,10 +197,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/boutique/requirements.lock b/reboot/examples/boutique/requirements.lock index d3993b2a..29a12988 100644 --- a/reboot/examples/boutique/requirements.lock +++ b/reboot/examples/boutique/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/boutique/web/package-lock.json b/reboot/examples/boutique/web/package-lock.json index 2525d958..d6e356e5 100644 --- a/reboot/examples/boutique/web/package-lock.json +++ b/reboot/examples/boutique/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", @@ -1224,9 +1224,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1260,15 +1260,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1295,12 +1295,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -6649,9 +6649,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -6671,14 +6671,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -6695,11 +6695,11 @@ } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/boutique/web/package.json b/reboot/examples/boutique/web/package.json index 5ae92fd7..34da53bb 100644 --- a/reboot/examples/boutique/web/package.json +++ b/reboot/examples/boutique/web/package.json @@ -6,7 +6,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", diff --git a/reboot/examples/chat-room-nodejs/Dockerfile b/reboot/examples/chat-room-nodejs/Dockerfile index 8f781bae..70ca4727 100644 --- a/reboot/examples/chat-room-nodejs/Dockerfile +++ b/reboot/examples/chat-room-nodejs/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/reboot-dev/reboot-base:0.45.2 +FROM ghcr.io/reboot-dev/reboot-base:0.46.0 WORKDIR /app diff --git a/reboot/examples/chat-room-nodejs/package-lock.json b/reboot/examples/chat-room-nodejs/package-lock.json index 9956f4be..6cba2031 100644 --- a/reboot/examples/chat-room-nodejs/package-lock.json +++ b/reboot/examples/chat-room-nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "@types/node": "20.11.5", "typescript": "5.4.5" }, @@ -509,15 +509,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -546,9 +546,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -3118,13 +3118,13 @@ "optional": true }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -3141,9 +3141,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", diff --git a/reboot/examples/chat-room-nodejs/package.json b/reboot/examples/chat-room-nodejs/package.json index 9d0c4d28..f9d1f668 100644 --- a/reboot/examples/chat-room-nodejs/package.json +++ b/reboot/examples/chat-room-nodejs/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "5.4.5", "@types/node": "20.11.5" }, diff --git a/reboot/examples/chat-room/Dockerfile b/reboot/examples/chat-room/Dockerfile index 27041c2c..08538d0e 100644 --- a/reboot/examples/chat-room/Dockerfile +++ b/reboot/examples/chat-room/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/reboot-dev/reboot-base:0.45.2 +FROM ghcr.io/reboot-dev/reboot-base:0.46.0 WORKDIR /app diff --git a/reboot/examples/chat-room/pyproject.toml b/reboot/examples/chat-room/pyproject.toml index 1e7423dc..142168e1 100644 --- a/reboot/examples/chat-room/pyproject.toml +++ b/reboot/examples/chat-room/pyproject.toml @@ -1,7 +1,7 @@ [project] requires-python = ">= 3.10" dependencies = [ - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] @@ -9,7 +9,7 @@ dev-dependencies = [ "mypy==1.18.1", "pytest>=7.4.2", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/chat-room/reboot-non-react-web/package-lock.json b/reboot/examples/chat-room/reboot-non-react-web/package-lock.json index 41ac6361..7c071579 100644 --- a/reboot/examples/chat-room/reboot-non-react-web/package-lock.json +++ b/reboot/examples/chat-room/reboot-non-react-web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot-web": "^0.45.2", + "@reboot-dev/reboot-web": "0.46.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", @@ -71,9 +71,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -98,12 +98,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/chat-room/reboot-non-react-web/package.json b/reboot/examples/chat-room/reboot-non-react-web/package.json index 536ad773..2694ac85 100644 --- a/reboot/examples/chat-room/reboot-non-react-web/package.json +++ b/reboot/examples/chat-room/reboot-non-react-web/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot-web": "^0.45.2", + "@reboot-dev/reboot-web": "0.46.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", diff --git a/reboot/examples/chat-room/requirements-dev.lock b/reboot/examples/chat-room/requirements-dev.lock index 61cc5c55..31a9f3ca 100644 --- a/reboot/examples/chat-room/requirements-dev.lock +++ b/reboot/examples/chat-room/requirements-dev.lock @@ -197,10 +197,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/chat-room/requirements.lock b/reboot/examples/chat-room/requirements.lock index d3993b2a..29a12988 100644 --- a/reboot/examples/chat-room/requirements.lock +++ b/reboot/examples/chat-room/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/chat-room/web/package-lock.json b/reboot/examples/chat-room/web/package-lock.json index dec68d60..a2325bb4 100644 --- a/reboot/examples/chat-room/web/package-lock.json +++ b/reboot/examples/chat-room/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", @@ -1223,9 +1223,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1259,15 +1259,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1293,12 +1293,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -6509,9 +6509,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -6531,14 +6531,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -6553,11 +6553,11 @@ } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/chat-room/web/package.json b/reboot/examples/chat-room/web/package.json index 5ea9632d..32d59845 100644 --- a/reboot/examples/chat-room/web/package.json +++ b/reboot/examples/chat-room/web/package.json @@ -6,7 +6,7 @@ "dependencies": { "@bufbuild/protobuf": "1.10.1", "@eslint/js": "^9.34.0", - "@reboot-dev/reboot-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/eslint__js": "^8.42.3", diff --git a/reboot/examples/counter/package-lock.json b/reboot/examples/counter/package-lock.json index 286f88b1..4facd984 100644 --- a/reboot/examples/counter/package-lock.json +++ b/reboot/examples/counter/package-lock.json @@ -9,8 +9,8 @@ "version": "0.0.0", "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "next": "14.2.13", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -1343,15 +1343,15 @@ } }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -1380,9 +1380,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1407,15 +1407,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1442,12 +1442,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -9622,13 +9622,13 @@ "optional": true }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -9645,9 +9645,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -9662,14 +9662,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -9686,11 +9686,11 @@ } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/counter/package.json b/reboot/examples/counter/package.json index 20ed7c52..c12c093a 100644 --- a/reboot/examples/counter/package.json +++ b/reboot/examples/counter/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@bufbuild/protobuf": "1.10.1", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "next": "14.2.13", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/reboot/examples/docubot/api/package.json b/reboot/examples/docubot/api/package.json index 44d3e554..546318f0 100644 --- a/reboot/examples/docubot/api/package.json +++ b/reboot/examples/docubot/api/package.json @@ -7,7 +7,7 @@ "prepack": "rbt generate && tsc" }, "dependencies": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "^5.2.2" }, "files": [ diff --git a/reboot/examples/docubot/docubot/package.json b/reboot/examples/docubot/docubot/package.json index c059394b..9004eb29 100644 --- a/reboot/examples/docubot/docubot/package.json +++ b/reboot/examples/docubot/docubot/package.json @@ -8,8 +8,8 @@ }, "dependencies": { "@reboot-dev/docubot-api": "0.1.0", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "create-temp-directory": "^2.4.0", "openai": "^4.52.7", "puppeteer": "^22.14.0", diff --git a/reboot/examples/docubot/package-lock.json b/reboot/examples/docubot/package-lock.json index fd311642..ffebd8bc 100644 --- a/reboot/examples/docubot/package-lock.json +++ b/reboot/examples/docubot/package-lock.json @@ -18,9 +18,9 @@ "@radix-ui/react-slot": "^1.0.2", "@reboot-dev/docubot": "workspace:*", "@reboot-dev/docubot-api": "workspace:*", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.24", @@ -54,7 +54,7 @@ "name": "@reboot-dev/docubot-api", "version": "0.1.0", "dependencies": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "^5.2.2" } }, @@ -63,8 +63,8 @@ "version": "0.1.0", "dependencies": { "@reboot-dev/docubot-api": "0.1.0", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "create-temp-directory": "^2.4.0", "openai": "^4.52.7", "puppeteer": "^22.14.0", @@ -1651,15 +1651,15 @@ "link": true }, "node_modules/@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -1688,9 +1688,9 @@ } }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1722,15 +1722,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1790,12 +1790,12 @@ } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", @@ -11362,8 +11362,8 @@ "version": "file:docubot", "requires": { "@reboot-dev/docubot-api": "0.1.0", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "@types/node": "^20.12.4", "create-temp-directory": "^2.4.0", "openai": "^4.52.7", @@ -11383,18 +11383,18 @@ "@reboot-dev/docubot-api": { "version": "file:api", "requires": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "^5.2.2" } }, "@reboot-dev/reboot": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.45.2.tgz", - "integrity": "sha512-9lbRqlYSqR5hUXhSF8szSzffbFvrEyUlZ2CYU2Y/WpbzZ+MdMLDGJuDs+MjmC/Mho7Nhojrk4iv+zhPIIysfgA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot/-/reboot-0.46.0.tgz", + "integrity": "sha512-mRRV5ItpZTibfnn9uP3R3S4iTgOJa3ekvA9EHYzB0KD+iVXjM7HDPdx4LuMQAPS3U1afcd2xakBmXcgJx3jBzw==", "requires": { "@bufbuild/protoc-gen-es": "1.10.1", "@bufbuild/protoplugin": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "@standard-schema/spec": "1.0.0", "chalk": "^4.1.2", @@ -11418,9 +11418,9 @@ } }, "@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "requires": { "@scarf/scarf": "1.4.0", "typescript": "5.4.5", @@ -11440,14 +11440,14 @@ } }, "@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", - "requires": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", + "requires": { + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -11476,11 +11476,11 @@ } }, "@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "requires": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/docubot/package.json b/reboot/examples/docubot/package.json index 50288cee..9406cdee 100644 --- a/reboot/examples/docubot/package.json +++ b/reboot/examples/docubot/package.json @@ -19,9 +19,9 @@ "@radix-ui/react-slot": "^1.0.2", "@reboot-dev/docubot": "workspace:*", "@reboot-dev/docubot-api": "workspace:*", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.24", diff --git a/reboot/examples/kcdc-2025/pyproject.toml b/reboot/examples/kcdc-2025/pyproject.toml index a95b82b9..e22a1928 100644 --- a/reboot/examples/kcdc-2025/pyproject.toml +++ b/reboot/examples/kcdc-2025/pyproject.toml @@ -5,13 +5,13 @@ requires-python = ">= 3.10" dependencies = [ "langchain>=0.3.27", "langchain-anthropic>=0.3.18", - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] dev-dependencies = [ "mypy==1.18.1", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/kcdc-2025/requirements-dev.lock b/reboot/examples/kcdc-2025/requirements-dev.lock index 413a96cd..be0338b8 100644 --- a/reboot/examples/kcdc-2025/requirements-dev.lock +++ b/reboot/examples/kcdc-2025/requirements-dev.lock @@ -230,12 +230,14 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via langchain # via langchain-core # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/kcdc-2025/requirements.lock b/reboot/examples/kcdc-2025/requirements.lock index 35b6942a..c32c7d73 100644 --- a/reboot/examples/kcdc-2025/requirements.lock +++ b/reboot/examples/kcdc-2025/requirements.lock @@ -226,12 +226,14 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via langchain # via langchain-core # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/kcdc-2025/web/package-lock.json b/reboot/examples/kcdc-2025/web/package-lock.json index 70d68da3..9abee58a 100644 --- a/reboot/examples/kcdc-2025/web/package-lock.json +++ b/reboot/examples/kcdc-2025/web/package-lock.json @@ -16,9 +16,9 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.7", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-std-api": "^0.45.2", - "@reboot-dev/reboot-std-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-std-react": "0.46.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "emoji-picker-react": "^4.9.2", @@ -1750,9 +1750,9 @@ "license": "MIT" }, "node_modules/@reboot-dev/reboot-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.45.2.tgz", - "integrity": "sha512-LAIFqjse2xpDnpGZq8/koB296ougfitcriAOJctlqUiWMds/WdHfInXyzJh9yZzF0MIXS8zAa7zr5lFqerwCoQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-api/-/reboot-api-0.46.0.tgz", + "integrity": "sha512-GGKEqcMHRvV67eoU7W5JitOgs3vrM6i1naxHJlZcOMjw/BHQXcYNAudX3ZfcRpJWfvJ0An+7tT7sStfAGCXhog==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0", @@ -1786,15 +1786,15 @@ } }, "node_modules/@reboot-dev/reboot-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.45.2.tgz", - "integrity": "sha512-A50MGLf35odF6Ss+DPPNjEHzXcAhdg4CStTuP2v33cM82u5sKiStS/OmYmNmqudNcRgB6mCGLW8Fssq7IZJXag==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-react/-/reboot-react-0.46.0.tgz", + "integrity": "sha512-v9fo5ZxgL4dpXvUWHWhbu6n4lO3koWuEKukNRpZ53cTZFzNlbghkA58z39Hh/Q13pvefK9NsnvCPFsWYDZCQrA==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", @@ -1822,34 +1822,34 @@ } }, "node_modules/@reboot-dev/reboot-std-api": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.45.2.tgz", - "integrity": "sha512-mIp0plOvi9sm6RfJJG+PXF0bwIpf/Zxe+x4J6O9x3eZdDPxRvW3zDD02JmBV7wBkPGjWtAe+NUMDxKj67Z9jHA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-api/-/reboot-std-api-0.46.0.tgz", + "integrity": "sha512-SPt7RsLdJoGxxrf/896yC7/lqBNqVmAPL2BFpZbyCV+Bb205VAodm7ERylhvaFEU7PgucKOrjR7WXXSTeXDo6w==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-std-react": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.45.2.tgz", - "integrity": "sha512-tohcL7WQT5Z1CUkryJlSHvQ1Koxn5LgDuLZctw0/m+2DLasgTW5eU+e1pCrLMxBpuiUK52mVDk7e93mNeT+lvQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-std-react/-/reboot-std-react-0.46.0.tgz", + "integrity": "sha512-QVQj7DsjsbdXAPmIRN3xrjnabYfB1nIk00FQxEs31sXQwdP1Mcr0QddwzyhrtoX84t5+HNV5YZWz2vP+PLcQQg==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0" } }, "node_modules/@reboot-dev/reboot-web": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.45.2.tgz", - "integrity": "sha512-PaLcs05GGGw/NSJYAzUDB9aAlOBMhGsxuExapuimd0wMEymCXLoSPMwrl0hn95asnbugzKrG3QdaBP09j+CZ/g==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@reboot-dev/reboot-web/-/reboot-web-0.46.0.tgz", + "integrity": "sha512-NoIFbgX3hdGWOnuRSFE2W1xwsM5Le4MmwztoarlOsbfh3ATCMUSqrR7OspIzBpo6vgKYM5wO1QdQZA7GwUqLYQ==", "license": "Apache-2.0", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/reboot/examples/kcdc-2025/web/package.json b/reboot/examples/kcdc-2025/web/package.json index 659c46cc..249c5c49 100644 --- a/reboot/examples/kcdc-2025/web/package.json +++ b/reboot/examples/kcdc-2025/web/package.json @@ -18,9 +18,9 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.7", - "@reboot-dev/reboot-react": "^0.45.2", - "@reboot-dev/reboot-std-api": "^0.45.2", - "@reboot-dev/reboot-std-react": "^0.45.2", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-std-react": "0.46.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "emoji-picker-react": "^4.9.2", diff --git a/reboot/examples/monorepo/pyproject.toml b/reboot/examples/monorepo/pyproject.toml index 1e7423dc..142168e1 100644 --- a/reboot/examples/monorepo/pyproject.toml +++ b/reboot/examples/monorepo/pyproject.toml @@ -1,7 +1,7 @@ [project] requires-python = ">= 3.10" dependencies = [ - "reboot==0.45.2", + "reboot==0.46.0", ] [tool.rye] @@ -9,7 +9,7 @@ dev-dependencies = [ "mypy==1.18.1", "pytest>=7.4.2", "types-protobuf>=4.24.0.20240129", - "reboot==0.45.2", + "reboot==0.46.0", ] # This project only uses `rye` to provide `python` and its dependencies, so diff --git a/reboot/examples/monorepo/requirements-dev.lock b/reboot/examples/monorepo/requirements-dev.lock index 61cc5c55..31a9f3ca 100644 --- a/reboot/examples/monorepo/requirements-dev.lock +++ b/reboot/examples/monorepo/requirements-dev.lock @@ -197,10 +197,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/monorepo/requirements.lock b/reboot/examples/monorepo/requirements.lock index d3993b2a..29a12988 100644 --- a/reboot/examples/monorepo/requirements.lock +++ b/reboot/examples/monorepo/requirements.lock @@ -186,10 +186,12 @@ python-dotenv==1.2.2 # via pydantic-settings python-multipart==0.0.22 # via mcp +python-ulid==3.1.0 + # via reboot pyyaml==6.0.2 # via kubernetes-asyncio # via reboot -reboot==0.45.2 +reboot==0.46.0 referencing==0.37.0 # via jsonschema # via jsonschema-specifications diff --git a/reboot/examples/prosemirror-zod/Dockerfile b/reboot/examples/prosemirror-zod/Dockerfile index 05699a9a..4b856125 100644 --- a/reboot/examples/prosemirror-zod/Dockerfile +++ b/reboot/examples/prosemirror-zod/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/reboot-dev/reboot-base:0.45.2 +FROM ghcr.io/reboot-dev/reboot-base:0.46.0 WORKDIR /app diff --git a/reboot/examples/prosemirror-zod/backend/package.json b/reboot/examples/prosemirror-zod/backend/package.json index 1d939fe7..24451fca 100644 --- a/reboot/examples/prosemirror-zod/backend/package.json +++ b/reboot/examples/prosemirror-zod/backend/package.json @@ -10,8 +10,8 @@ "@bufbuild/protobuf": "1.10.1", "@monorepo/api": "workspace:*", "@monorepo/common": "workspace:*", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "prosemirror-model": "^1.23.0", "prosemirror-transform": "^1.1.0" } diff --git a/reboot/examples/prosemirror-zod/web/package.json b/reboot/examples/prosemirror-zod/web/package.json index 92b29ad1..f3e9bab0 100644 --- a/reboot/examples/prosemirror-zod/web/package.json +++ b/reboot/examples/prosemirror-zod/web/package.json @@ -14,7 +14,7 @@ "@monorepo/api": "workspace:*", "@monorepo/common": "workspace:*", "@nytimes/react-prosemirror": "^0.6.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "next": "15.0.5", "prosemirror-collab": "^1.3.1", "prosemirror-history": "^1.4.1", diff --git a/reboot/examples/prosemirror-zod/yarn.lock b/reboot/examples/prosemirror-zod/yarn.lock index b28be0a2..5a027d83 100644 --- a/reboot/examples/prosemirror-zod/yarn.lock +++ b/reboot/examples/prosemirror-zod/yarn.lock @@ -740,7 +740,7 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/ext-apps@npm:^1.0.1": +"@modelcontextprotocol/ext-apps@npm:1.2.0": version: 1.2.0 resolution: "@modelcontextprotocol/ext-apps@npm:1.2.0" peerDependencies: @@ -757,7 +757,7 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.26.0": +"@modelcontextprotocol/sdk@npm:1.27.1": version: 1.27.1 resolution: "@modelcontextprotocol/sdk@npm:1.27.1" dependencies: @@ -805,8 +805,8 @@ __metadata: "@bufbuild/protobuf": "npm:1.10.1" "@monorepo/api": "workspace:*" "@monorepo/common": "workspace:*" - "@reboot-dev/reboot": "npm:0.45.2" - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot": "npm:0.46.0" + "@reboot-dev/reboot-api": "npm:0.46.0" prosemirror-model: "npm:^1.23.0" prosemirror-transform: "npm:^1.1.0" languageName: unknown @@ -831,7 +831,7 @@ __metadata: "@monorepo/api": "workspace:*" "@monorepo/common": "workspace:*" "@nytimes/react-prosemirror": "npm:^0.6.2" - "@reboot-dev/reboot-react": "npm:0.45.2" + "@reboot-dev/reboot-react": "npm:0.46.0" "@types/node": "npm:^20" "@types/react": "npm:^18" "@types/react-dom": "npm:^18" @@ -1011,27 +1011,27 @@ __metadata: languageName: node linkType: hard -"@reboot-dev/reboot-api@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-api@npm:0.45.2" +"@reboot-dev/reboot-api@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-api@npm:0.46.0" dependencies: "@scarf/scarf": "npm:1.4.0" typescript: "npm:5.4.5" zod: "npm:^3.25.51" peerDependencies: "@bufbuild/protobuf": 1.10.1 - checksum: 10c0/7f27d7b729dd96d847a82d10cc91bfda03fce6ad824789d42e3e35c33265a9c1669ad983d348e4acaf79c41b2f0dd85531768d76d771ecbd6dfc29cdf6c2fe3b + checksum: 10c0/988d942f13c68029429e5c763a65e952f4b1fa029ba3445d2c5481a986f96e424bfbcb07f1a957198763bec6779cccf64d13900c0334999f598b23c6168217e3 languageName: node linkType: hard -"@reboot-dev/reboot-react@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-react@npm:0.45.2" +"@reboot-dev/reboot-react@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-react@npm:0.46.0" dependencies: - "@modelcontextprotocol/ext-apps": "npm:^1.0.1" - "@modelcontextprotocol/sdk": "npm:^1.26.0" - "@reboot-dev/reboot-api": "npm:0.45.2" - "@reboot-dev/reboot-web": "npm:0.45.2" + "@modelcontextprotocol/ext-apps": "npm:1.2.0" + "@modelcontextprotocol/sdk": "npm:1.27.1" + "@reboot-dev/reboot-api": "npm:0.46.0" + "@reboot-dev/reboot-web": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" "@types/uuid": "npm:^9.0.4" js-sha1: "npm:0.7.0" @@ -1042,15 +1042,15 @@ __metadata: "@bufbuild/protobuf": 1.10.1 react: ">=18.0.0" react-dom: ">=18.0.0" - checksum: 10c0/4de84f6bb4aa3d07ff9213b193532c838a213f7bb41b182b639e8a0a47449f4451888b7d49a651cc2eb22388561dcc5aecb910eb6a4addf657013a3465e27548 + checksum: 10c0/6d832caa130c2e467e8f3430abebbe903f21128ec9cbfd55185644dd9bf914d439fe9c8d5064541b5641463432858c4da67aefb4fa3c4470e2a675603a39cb83 languageName: node linkType: hard -"@reboot-dev/reboot-web@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-web@npm:0.45.2" +"@reboot-dev/reboot-web@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-web@npm:0.46.0" dependencies: - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" js-sha1: "npm:0.7.0" lru-cache-idb: "npm:^0.5.2" @@ -1059,17 +1059,17 @@ __metadata: uuid: "npm:11.1.0" peerDependencies: "@bufbuild/protobuf": 1.10.1 - checksum: 10c0/ddd4fc45326d120174b7b5de9308b41a71002e99d6a1c0d085206f37288879e7b3d3d3e492a34b34db2b292951959348e8be871a2e3c00a7aad5ae0048472339 + checksum: 10c0/1e0068cb15c420a8c93b68107e18c3d0e6fb3b378a54e484029bda5b008893261919d05d9094449c3b261dd6ecd12a0d19eee6a36d4bde3c93a89795ac2afcf2 languageName: node linkType: hard -"@reboot-dev/reboot@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot@npm:0.45.2" +"@reboot-dev/reboot@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot@npm:0.46.0" dependencies: "@bufbuild/protoc-gen-es": "npm:1.10.1" "@bufbuild/protoplugin": "npm:1.10.1" - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" "@standard-schema/spec": "npm:1.0.0" chalk: "npm:^4.1.2" @@ -1090,7 +1090,7 @@ __metadata: rbt: rbt.js rbt-esbuild: rbt-esbuild.js zod-to-proto: zod-to-proto.js - checksum: 10c0/2d233aa036c63e05baeae34f62a9f7f2d593668f58e9f6269169f01899aa9ca776ca42e340a186246ab5118e2229a00e5a7f4e3bc68ce6cca3d92dfa39ee4a4b + checksum: 10c0/66d746c93408bc2fcf4c56b0ab508d4ebdd9fad480b5bffaa86de35b2ea90123cf2e4bba783970109555957f6b08e6985a0331eb904a477b4d8d48a1546dcbb9 languageName: node linkType: hard diff --git a/reboot/examples/prosemirror/Dockerfile b/reboot/examples/prosemirror/Dockerfile index 05699a9a..4b856125 100644 --- a/reboot/examples/prosemirror/Dockerfile +++ b/reboot/examples/prosemirror/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/reboot-dev/reboot-base:0.45.2 +FROM ghcr.io/reboot-dev/reboot-base:0.46.0 WORKDIR /app diff --git a/reboot/examples/prosemirror/backend/package.json b/reboot/examples/prosemirror/backend/package.json index 1d939fe7..24451fca 100644 --- a/reboot/examples/prosemirror/backend/package.json +++ b/reboot/examples/prosemirror/backend/package.json @@ -10,8 +10,8 @@ "@bufbuild/protobuf": "1.10.1", "@monorepo/api": "workspace:*", "@monorepo/common": "workspace:*", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", "prosemirror-model": "^1.23.0", "prosemirror-transform": "^1.1.0" } diff --git a/reboot/examples/prosemirror/web/package.json b/reboot/examples/prosemirror/web/package.json index 92b29ad1..f3e9bab0 100644 --- a/reboot/examples/prosemirror/web/package.json +++ b/reboot/examples/prosemirror/web/package.json @@ -14,7 +14,7 @@ "@monorepo/api": "workspace:*", "@monorepo/common": "workspace:*", "@nytimes/react-prosemirror": "^0.6.2", - "@reboot-dev/reboot-react": "0.45.2", + "@reboot-dev/reboot-react": "0.46.0", "next": "15.0.5", "prosemirror-collab": "^1.3.1", "prosemirror-history": "^1.4.1", diff --git a/reboot/examples/prosemirror/yarn.lock b/reboot/examples/prosemirror/yarn.lock index aa45509b..2aeb3c2a 100644 --- a/reboot/examples/prosemirror/yarn.lock +++ b/reboot/examples/prosemirror/yarn.lock @@ -713,7 +713,7 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/ext-apps@npm:^1.0.1": +"@modelcontextprotocol/ext-apps@npm:1.2.0": version: 1.2.0 resolution: "@modelcontextprotocol/ext-apps@npm:1.2.0" peerDependencies: @@ -730,7 +730,7 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.26.0": +"@modelcontextprotocol/sdk@npm:1.27.1": version: 1.27.1 resolution: "@modelcontextprotocol/sdk@npm:1.27.1" dependencies: @@ -776,8 +776,8 @@ __metadata: "@bufbuild/protobuf": "npm:1.10.1" "@monorepo/api": "workspace:*" "@monorepo/common": "workspace:*" - "@reboot-dev/reboot": "npm:0.45.2" - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot": "npm:0.46.0" + "@reboot-dev/reboot-api": "npm:0.46.0" prosemirror-model: "npm:^1.23.0" prosemirror-transform: "npm:^1.1.0" languageName: unknown @@ -802,7 +802,7 @@ __metadata: "@monorepo/api": "workspace:*" "@monorepo/common": "workspace:*" "@nytimes/react-prosemirror": "npm:^0.6.2" - "@reboot-dev/reboot-react": "npm:0.45.2" + "@reboot-dev/reboot-react": "npm:0.46.0" "@types/node": "npm:^20" "@types/react": "npm:^18" "@types/react-dom": "npm:^18" @@ -971,27 +971,27 @@ __metadata: languageName: node linkType: hard -"@reboot-dev/reboot-api@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-api@npm:0.45.2" +"@reboot-dev/reboot-api@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-api@npm:0.46.0" dependencies: "@scarf/scarf": "npm:1.4.0" typescript: "npm:5.4.5" zod: "npm:^3.25.51" peerDependencies: "@bufbuild/protobuf": 1.10.1 - checksum: 10c0/7f27d7b729dd96d847a82d10cc91bfda03fce6ad824789d42e3e35c33265a9c1669ad983d348e4acaf79c41b2f0dd85531768d76d771ecbd6dfc29cdf6c2fe3b + checksum: 10c0/988d942f13c68029429e5c763a65e952f4b1fa029ba3445d2c5481a986f96e424bfbcb07f1a957198763bec6779cccf64d13900c0334999f598b23c6168217e3 languageName: node linkType: hard -"@reboot-dev/reboot-react@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-react@npm:0.45.2" +"@reboot-dev/reboot-react@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-react@npm:0.46.0" dependencies: - "@modelcontextprotocol/ext-apps": "npm:^1.0.1" - "@modelcontextprotocol/sdk": "npm:^1.26.0" - "@reboot-dev/reboot-api": "npm:0.45.2" - "@reboot-dev/reboot-web": "npm:0.45.2" + "@modelcontextprotocol/ext-apps": "npm:1.2.0" + "@modelcontextprotocol/sdk": "npm:1.27.1" + "@reboot-dev/reboot-api": "npm:0.46.0" + "@reboot-dev/reboot-web": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" "@types/uuid": "npm:^9.0.4" js-sha1: "npm:0.7.0" @@ -1002,15 +1002,15 @@ __metadata: "@bufbuild/protobuf": 1.10.1 react: ">=18.0.0" react-dom: ">=18.0.0" - checksum: 10c0/4de84f6bb4aa3d07ff9213b193532c838a213f7bb41b182b639e8a0a47449f4451888b7d49a651cc2eb22388561dcc5aecb910eb6a4addf657013a3465e27548 + checksum: 10c0/6d832caa130c2e467e8f3430abebbe903f21128ec9cbfd55185644dd9bf914d439fe9c8d5064541b5641463432858c4da67aefb4fa3c4470e2a675603a39cb83 languageName: node linkType: hard -"@reboot-dev/reboot-web@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-web@npm:0.45.2" +"@reboot-dev/reboot-web@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-web@npm:0.46.0" dependencies: - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" js-sha1: "npm:0.7.0" lru-cache-idb: "npm:^0.5.2" @@ -1019,17 +1019,17 @@ __metadata: uuid: "npm:11.1.0" peerDependencies: "@bufbuild/protobuf": 1.10.1 - checksum: 10c0/ddd4fc45326d120174b7b5de9308b41a71002e99d6a1c0d085206f37288879e7b3d3d3e492a34b34db2b292951959348e8be871a2e3c00a7aad5ae0048472339 + checksum: 10c0/1e0068cb15c420a8c93b68107e18c3d0e6fb3b378a54e484029bda5b008893261919d05d9094449c3b261dd6ecd12a0d19eee6a36d4bde3c93a89795ac2afcf2 languageName: node linkType: hard -"@reboot-dev/reboot@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot@npm:0.45.2" +"@reboot-dev/reboot@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot@npm:0.46.0" dependencies: "@bufbuild/protoc-gen-es": "npm:1.10.1" "@bufbuild/protoplugin": "npm:1.10.1" - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" "@standard-schema/spec": "npm:1.0.0" chalk: "npm:^4.1.2" @@ -1050,7 +1050,7 @@ __metadata: rbt: rbt.js rbt-esbuild: rbt-esbuild.js zod-to-proto: zod-to-proto.js - checksum: 10c0/2d233aa036c63e05baeae34f62a9f7f2d593668f58e9f6269169f01899aa9ca776ca42e340a186246ab5118e2229a00e5a7f4e3bc68ce6cca3d92dfa39ee4a4b + checksum: 10c0/66d746c93408bc2fcf4c56b0ab508d4ebdd9fad480b5bffaa86de35b2ea90123cf2e4bba783970109555957f6b08e6985a0331eb904a477b4d8d48a1546dcbb9 languageName: node linkType: hard diff --git a/reboot/mcp/context.py b/reboot/mcp/context.py index ef1e5d5c..e00f5ad8 100644 --- a/reboot/mcp/context.py +++ b/reboot/mcp/context.py @@ -1,80 +1,64 @@ -"""MCP context and session management for Reboot. +"""MCP context helpers for Reboot. -Provides helpers for accessing the Reboot `ExternalContext` and -MCP session identity from within MCP tool/resource handlers. +Provides helpers for accessing the Reboot `ExternalContext` +from within MCP tool/resource handlers. """ from mcp.server.fastmcp import Context from reboot.aio.external import ExternalContext from reboot.mcp.helpers import \ - get_mcp_session_id # noqa: F401 — PEP 484 re-export for `reboot.py.j2` -from reboot.mcp.helpers import _MCP_SESSION_ID_KEY + get_mcp_user_id # noqa: F401 — PEP 484 re-export for `reboot.py.j2` +from reboot.mcp.helpers import _MCP_USER_ID_KEY from reboot.uuidv7 import uuid7 from starlette.requests import Request # Key used to store ExternalContext in request.state. _REBOOT_CONTEXT_KEY = "reboot_external_context" -# Key used to track whether this is a new MCP session. -_MCP_NEW_SESSION_KEY = "mcp_new_session" - # HTTP header name for MCP session identity. MCP_SESSION_ID_HEADER = "mcp-session-id" -def get_reboot_context(ctx: Context) -> ExternalContext: +def get_reboot_context(context: Context) -> ExternalContext: """ Get the current Reboot `ExternalContext` from MCP `Context`. """ - request = ctx.request_context.request + request = context.request_context.request if request is None: raise RuntimeError( - "No HTTP request in MCP context - this shouldn't happen" + "No HTTP request in MCP context — this shouldn't happen" ) - ext_ctx = getattr(request.state, _REBOOT_CONTEXT_KEY, None) - if ext_ctx is None: + external_context = getattr(request.state, _REBOOT_CONTEXT_KEY, None) + if external_context is None: raise RuntimeError( - "No Reboot context available - are you inside an MCP tool?" + "No Reboot context available — are you inside an MCP tool?" ) - return ext_ctx - - -def is_new_mcp_session(ctx: Context) -> bool: - """ - Check whether this request started a new MCP session. - - Returns `True` when the client did not send an `Mcp-Session-Id` - header (i.e. we just generated a fresh UUIDv7). Returns `False` for - all subsequent requests in the same session. - - Args: - ctx: The MCP `Context` passed to your tool or resource - handler. - """ - request = ctx.request_context.request - if request is None: - return False - return getattr(request.state, _MCP_NEW_SESSION_KEY, False) + return external_context -def _set_session_id(request: Request) -> str: +def _init_session_state(request: Request) -> tuple[str, bool]: """ - Determine and store session identity on `request.state`. + Determine session identity from the request. Reuses the client's `Mcp-Session-Id` header when present; otherwise - generates a fresh UUIDv7. Also records whether the session is new - (no header) so that `is_new_mcp_session` can report it. + generates a fresh UUIDv7. Returns: - The session ID string. + A `(session_id, is_new)` tuple. `is_new` is `True` when + the client did not send a session ID header (i.e. this is the + first request in a new session). """ incoming = request.headers.get(MCP_SESSION_ID_HEADER) session_id = incoming or uuid7().hex - setattr(request.state, _MCP_SESSION_ID_KEY, session_id) - setattr( - request.state, - _MCP_NEW_SESSION_KEY, - incoming is None, - ) - return session_id + return session_id, incoming is None + + +def _set_user_id(request: Request, user_id: str) -> None: + """Store the authenticated user ID on `request.state`.""" + setattr(request.state, _MCP_USER_ID_KEY, user_id) + + +def _get_user_id(request: Request) -> str | None: + """Retrieve the authenticated user ID from `request.state`.""" + return getattr(request.state, _MCP_USER_ID_KEY, None) diff --git a/reboot/mcp/factories.py b/reboot/mcp/factories.py index 7522d117..768028fd 100644 --- a/reboot/mcp/factories.py +++ b/reboot/mcp/factories.py @@ -6,21 +6,24 @@ import anyio import logging +import mcp.types from log.log import log_at_most_once_per +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend from mcp.server.fastmcp import FastMCP from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.streamable_http import StreamableHTTPServerTransport from reboot.aio.external import ExternalContext from reboot.mcp.context import ( - _MCP_NEW_SESSION_KEY, _REBOOT_CONTEXT_KEY, - _set_session_id, + _get_user_id, + _init_session_state, + _set_user_id, ) from reboot.mcp.ui import _resource_meta from starlette.requests import Request from starlette.responses import JSONResponse from starlette.types import Receive, Scope, Send -from typing import Any, Awaitable, Callable, Sequence +from typing import Any, Awaitable, Callable, Optional, Sequence logger = logging.getLogger(__name__) @@ -84,8 +87,9 @@ async def _read_resource_with_dynamic_meta( def create_mcp_factory( *, server: FastMCP, - new_session_hooks: Sequence[Callable[[ExternalContext, str], + new_session_hooks: Sequence[Callable[[ExternalContext, Optional[str]], Awaitable[None]]], + token_verifier: Optional[Any], ) -> Callable[ [Callable[[Request], Any]], Callable[[Scope, Receive, Send], Any], @@ -95,10 +99,14 @@ def create_mcp_factory( Args: server: A pre-configured `FastMCP` server with tools/resources already registered. - new_session_hooks: Async callables to invoke when a - new MCP session starts (e.g. to auto-construct state). - Arguments passed: an external context, and the new MCP - session's ID. + new_session_hooks: Async callables to invoke when a new + MCP session starts (e.g. to auto-construct state). Arguments + passed: (external_context, user_id_or_none). + token_verifier: Optional MCP SDK `TokenVerifier` + (from `mcp.server.auth.provider`). When set, it can reject + requests with missing or expired bearer tokens with an HTTP + 401 code before reaching the MCP protocol layer (after which + every failure is an internal server error). """ def factory( @@ -110,6 +118,18 @@ def factory( has_mcp_tools = len(server._tool_manager.list_tools()) > 0 + @server._mcp_server.set_logging_level() + async def handle_set_logging_level( + level: mcp.types.LoggingLevel + ) -> mcp.types.EmptyResult: + # This method is here to silence an error that e.g. MCPJam + # gets when trying to call `logging/setLevel` when it first + # connects to an MCP server. + # + # TODO: do we want to actually adjust the log level in some + # way? + pass + async def mcp_asgi_app( scope: Scope, receive: Receive, send: Send ) -> None: @@ -119,8 +139,8 @@ async def mcp_asgi_app( # MCP client to do. if not has_mcp_tools: NO_TOOLS_MESSAGE = ( - "MCP client correctly connected, but no `Session` type or " - "MCP-enabled method was found in the API." + "MCP client correctly connected, but no auto-constructed " + "state type or MCP-enabled method was found in the API." ) log_at_most_once_per( seconds=300, @@ -137,26 +157,83 @@ async def mcp_asgi_app( await response(scope, receive, send) return - session_id = _set_session_id(request) + # Validate the bearer token early — before session hooks or + # MCP transport — so expired or invalid tokens get a proper + # HTTP 401 with `WWW-Authenticate` instead of a 500 later. + authenticated_user_id = None + if token_verifier is not None: + backend = BearerAuthBackend(token_verifier) + # `authenticate` returns a Starlette `(AuthCredentials, + # BaseUser)` tuple, or `None` on failure. We only need + # the user object (`[1]`) — `[0]` holds scopes which we + # don't use. + credentials = await backend.authenticate(request) + if ( + credentials is None or not credentials[1].is_authenticated + ): + # Return a 401 with a `WWW-Authenticate: Bearer` + # challenge. `invalid_token` is the standard error + # code defined by RFC 6750 Section 3.1 for expired, + # revoked, or malformed tokens. Per RFC 9728, + # include `resource_metadata` so clients can + # discover where to authenticate. + base = str(request.base_url).rstrip("/") + resource_metadata_url = ( + f"{base}/.well-known/oauth-protected-resource" + ) + response = JSONResponse( + status_code=401, + content={"error": "invalid_token"}, + headers={ + "WWW-Authenticate": + "Bearer " + 'error="invalid_token", ' + f'resource_metadata="{resource_metadata_url}"' + }, + ) + await response(scope, receive, send) + return + # Extract `user_id` from the authenticated credentials. + # `BearerAuthBackend` sets `username` to the + # AccessToken's `client_id`, which our adapter sets to + # the JWT `sub` claim (the user ID). + authenticated_user_id = ( # noqa: F841 + credentials[1].username or None + ) + + # We replicate the MCP SDK's `StreamableHTTPSessionManager` + # logic here so that we can detect when a new session starts + # (first request without an `Mcp-Session-Id` header) and run + # auto-construction hooks before any tools execute. The + # SDK's manager provides no callback or hook for this. + session_id, is_new_session = (_init_session_state(request)) + + # Store the authenticated user ID (if any) on + # `request.state` so MCP tools can access it via + # `get_mcp_user_id()`. + if authenticated_user_id is not None: + _set_user_id(request, authenticated_user_id) - # Inject Reboot context into `request.state`. - # Accessible in MCP tools via - # `ctx.request_context.request.state`. - ext_ctx = external_context_from_request(request) + # Inject Reboot context into `request.state`. Accessible in + # MCP tools via `ctx.request_context.request.state`. + external_context = external_context_from_request(request) setattr( request.state, _REBOOT_CONTEXT_KEY, - ext_ctx, + external_context, ) - # On new sessions, construct state for any - # auto-constructed state types. - if getattr(request.state, _MCP_NEW_SESSION_KEY, False): + # On new sessions, auto-construct `User` state if we have an + # authenticated user. We do this only when a new session + # starts; that's an optimization - it would be safe to e.g. + # do it for every tool call, but that would ~double the + # number of operations for every tool call. + if is_new_session: + user_id = _get_user_id(request) for hook in new_session_hooks: - await hook(ext_ctx, session_id) + await hook(external_context, user_id) - logger.debug(f"[{ui_id}] {request.method} " - f"{request.url.path}") + logger.debug(f"[{ui_id}] {request.method} {request.url.path}") # Per-request task group — clean lifecycle, no # zombie tasks. @@ -167,6 +244,20 @@ async def mcp_asgi_app( event_store=None, ) + # Make session validation lenient: return + # the session ID in responses (so clients + # can send it back) but don't reject + # requests that omit it. Some MCP clients + # (e.g. "claude.ai") send notifications + # from a separate internal service that + # doesn't propagate the session ID. + async def _lenient_validate_session(request, send): + return True + + transport._validate_session = ( # type: ignore[assignment] + _lenient_validate_session + ) + async def run_server( *, task_status: anyio.abc. diff --git a/reboot/mcp/helpers.py b/reboot/mcp/helpers.py index e7ac4299..a5d4db77 100644 --- a/reboot/mcp/helpers.py +++ b/reboot/mcp/helpers.py @@ -7,24 +7,24 @@ from typing import Any -# Key used to store MCP session ID in request.state. -_MCP_SESSION_ID_KEY = "mcp_session_id" +# Key used to store user ID in request.state. +_MCP_USER_ID_KEY = "mcp_user_id" -def get_mcp_session_id(ctx: Any) -> str | None: - """Get the current MCP session ID from MCP `Context`. +def get_mcp_user_id(context: Any) -> str | None: + """Get the authenticated user ID from MCP `Context`. Call this from within MCP tool or resource handlers to get - the session ID assigned to this MCP connection. + the user ID extracted from the OAuth bearer token. Args: - ctx: The MCP `Context` passed to your tool or resource - handler. + context: The MCP `Context` passed to your tool or + resource handler. Returns: - The session ID string, or `None` if unavailable. + The user ID string, or `None` if unavailable. """ - request = ctx.request_context.request + request = context.request_context.request if request is None: return None - return getattr(request.state, _MCP_SESSION_ID_KEY, None) + return getattr(request.state, _MCP_USER_ID_KEY, None) diff --git a/reboot/nodejs/index.ts b/reboot/nodejs/index.ts index d028b64f..b04e4cd5 100644 --- a/reboot/nodejs/index.ts +++ b/reboot/nodejs/index.ts @@ -517,6 +517,42 @@ export class WorkflowContext extends Context { delete store.loopIteration; } } + + retryReactivelyUntil(condition: () => Promise): Promise; + + retryReactivelyUntil(condition: () => Promise): Promise; + + async retryReactivelyUntil( + condition: () => Promise + ): Promise { + let t: T | undefined = undefined; + + await reboot_native.workflow_retry_reactively_until( + this.__external, + AsyncLocalStorage.bind(async () => { + const store = contextStorage.getStore(); + assert(store !== undefined); + store.withinUntil = true; + try { + const result = await condition(); + if (typeof result === "boolean") { + return result; + } else { + t = result; + return true; + } + } catch (e) { + const error = ensureError(e); + console.error(error); + throw error; + } finally { + store.withinUntil = false; + } + }) + ); + + return t; + } } // Helper for clearing a specific field of a protobuf-es message. @@ -1224,49 +1260,6 @@ export namespace Application { } } -export async function retryReactivelyUntil( - context: WorkflowContext, - condition: () => Promise -): Promise; - -export async function retryReactivelyUntil( - context: WorkflowContext, - condition: () => Promise -): Promise; - -export async function retryReactivelyUntil( - context: WorkflowContext, - condition: () => Promise -): Promise { - let t: T | undefined = undefined; - - await reboot_native.retry_reactively_until( - context.__external, - AsyncLocalStorage.bind(async () => { - const store = contextStorage.getStore(); - assert(store !== undefined); - store.withinUntil = true; - try { - const result = await condition(); - if (typeof result === "boolean") { - return result; - } else { - t = result; - return true; - } - } catch (e) { - const error = ensureError(e); - console.error(error); - throw error; - } finally { - store.withinUntil = false; - } - }) - ); - - return t; -} - // NOTE: we're not using an enum because the values that can be used in // `atMostOnce` and `atLeastOnce` are different than `until`. export const ALWAYS = "ALWAYS" as const; @@ -1369,7 +1362,7 @@ async function memoize( } catch (e) { const error = ensureError(e); // We handle printing the exception for `until` in - // `retryReactivelyUntil`. + // `WorkflowContext.retryReactivelyUntil`. if (!until) { console.error(error); } @@ -1573,7 +1566,7 @@ export async function until( // to appease the TypeScript compiler which otherwise isn't happy // with passing on these types. const converge = () => { - return retryReactivelyUntil(context, callable as () => Promise); + return context.retryReactivelyUntil(callable as () => Promise); }; // TODO: should we not memoize if passed `ALWAYS`? There still might diff --git a/reboot/nodejs/package.json b/reboot/nodejs/package.json index e520e7d3..383e1503 100644 --- a/reboot/nodejs/package.json +++ b/reboot/nodejs/package.json @@ -2,7 +2,7 @@ "dependencies": { "@bufbuild/protoplugin": "1.10.1", "@bufbuild/protoc-gen-es": "1.10.1", - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "chalk": "^4.1.2", "node-addon-api": "^7.0.0", "node-gyp": ">=10.2.0", @@ -22,7 +22,7 @@ }, "type": "module", "name": "@reboot-dev/reboot", - "version": "0.45.2", + "version": "0.46.0", "description": "npm package for Reboot", "scripts": { "preinstall": "node preinstall.cjs", diff --git a/reboot/nodejs/python.py b/reboot/nodejs/python.py index fbf9711a..b3b858d4 100644 --- a/reboot/nodejs/python.py +++ b/reboot/nodejs/python.py @@ -15,7 +15,7 @@ from reboot.aio.aborted import Aborted, SystemAborted from reboot.aio.auth import Auth from reboot.aio.auth.authorizers import Authorizer -from reboot.aio.auth.token_verifiers import TokenVerifier +from reboot.aio.auth.token_verifiers import TokenVerifier, VerifyTokenResult from reboot.aio.contexts import Context, ReaderContext, WorkflowContext from reboot.aio.directories import chdir from reboot.aio.external import ExternalContext @@ -366,7 +366,7 @@ async def verify_token( self, context: ReaderContext, token: Optional[str], - ) -> Optional[Auth]: + ) -> VerifyTokenResult: cancelled: asyncio.Future[None] = asyncio.Future() try: # TODO: See the note before the call in `NodeAdaptorAuthorizer`. diff --git a/reboot/nodejs/reboot_native.cc b/reboot/nodejs/reboot_native.cc index 291722f7..5737dd9d 100644 --- a/reboot/nodejs/reboot_native.cc +++ b/reboot/nodejs/reboot_native.cc @@ -2629,7 +2629,7 @@ Napi::Value WorkflowContext_loop(const Napi::CallbackInfo& info) { } -Napi::Value retry_reactively_until(const Napi::CallbackInfo& info) { +Napi::Value workflow_retry_reactively_until(const Napi::CallbackInfo& info) { auto js_external_context = NapiSafeReference(info[0].As>()); @@ -2641,7 +2641,7 @@ Napi::Value retry_reactively_until(const Napi::CallbackInfo& info) { return NodePromiseFromPythonTaskWithContext( info.Env(), - "retry_reactively_until(...) in nodejs", + "workflow_retry_reactively_until(...) in nodejs", js_external_context, [js_external_context, // Ensures `py_context` remains valid. py_context, @@ -2663,8 +2663,7 @@ Napi::Value retry_reactively_until(const Napi::CallbackInfo& info) { }); }); - return py::module::import("reboot.aio.contexts") - .attr("retry_reactively_until")(py_context, py_condition); + return (*py_context).attr("retry_reactively_until")(py_condition); }); } @@ -2886,8 +2885,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { Napi::Function::New(env)); exports.Set( - Napi::String::New(env, "retry_reactively_until"), - Napi::Function::New(env)); + Napi::String::New(env, "workflow_retry_reactively_until"), + Napi::Function::New(env)); exports.Set( Napi::String::New(env, "memoize"), diff --git a/reboot/nodejs/reboot_native.cjs b/reboot/nodejs/reboot_native.cjs index 03db81f1..c6f3f84f 100644 --- a/reboot/nodejs/reboot_native.cjs +++ b/reboot/nodejs/reboot_native.cjs @@ -78,7 +78,8 @@ exports.Context_generateIdempotentStateId = reboot_native.exports.Context_generateIdempotentStateId; exports.WriterContext_set_sync = reboot_native.exports.WriterContext_set_sync; exports.WorkflowContext_loop = reboot_native.exports.WorkflowContext_loop; -exports.retry_reactively_until = reboot_native.exports.retry_reactively_until; +exports.workflow_retry_reactively_until = + reboot_native.exports.workflow_retry_reactively_until; exports.memoize = reboot_native.exports.memoize; exports.Servicer_read = reboot_native.exports.Servicer_read; exports.Servicer_write = reboot_native.exports.Servicer_write; diff --git a/reboot/nodejs/reboot_native.d.ts b/reboot/nodejs/reboot_native.d.ts index 14276dea..a5211847 100644 --- a/reboot/nodejs/reboot_native.d.ts +++ b/reboot/nodejs/reboot_native.d.ts @@ -85,7 +85,7 @@ export namespace rbt_native { idempotency: IdempotencyOptions ): Promise; function WriterContext_set_sync(external: NapiExternal, sync: boolean): void; - function retry_reactively_until( + function workflow_retry_reactively_until( external: NapiExternal, condition: () => Promise ): Promise; diff --git a/reboot/nodejs/zod-to-proto.ts b/reboot/nodejs/zod-to-proto.ts index a47f0aa2..e7c6c1cb 100644 --- a/reboot/nodejs/zod-to-proto.ts +++ b/reboot/nodejs/zod-to-proto.ts @@ -115,6 +115,13 @@ const generate = ( const field = toSnakeCase(key); + // A field is "required" means that validation will fail if the + // field is not provided, i.e., it does not have a `default` value + // and is not `optional`. If a field was `optional` the default + // value `undefined` is applied. + const isRequired = + !(value instanceof z.ZodOptional) && !(value instanceof z.ZodDefault); + if (value instanceof z.ZodPipe) { value = value.in; } else if (value instanceof z.ZodOptional) { @@ -156,14 +163,18 @@ const generate = ( value = value._zod.def.innerType; } + const requiredString = ` [(rbt.v1alpha1.field).required = ${ + isRequired ? "true" : "false" + }];`; + if (value._zod.def.type === "string") { - proto.write(`optional string ${field} = ${tag};`); + proto.write(`optional string ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "number") { - proto.write(`optional double ${field} = ${tag};`); + proto.write(`optional double ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "bigint") { - proto.write(`optional int64 ${field} = ${tag};`); + proto.write(`optional int64 ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "boolean") { - proto.write(`optional bool ${field} = ${tag};`); + proto.write(`optional bool ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "literal") { // Make the name of this nested type be the PascalCase property // TODO: ensure `key` is already camelCase. @@ -199,7 +210,7 @@ const generate = ( proto.write(`}`); - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "array") { // Make the name of this nested type be the PascalCase property // TODO: ensure `key` is already camelCase. @@ -211,7 +222,7 @@ const generate = ( proto.write(`}`); - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "record") { // Make the name of this nested type be the PascalCase property // TODO: ensure `key` is already camelCase. @@ -223,7 +234,7 @@ const generate = ( proto.write(`}`); - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (value instanceof z.ZodDiscriminatedUnion) { // `instanceof` b.c. type = "union". // Make the name of this nested type be the PascalCase property @@ -236,7 +247,7 @@ const generate = ( name: typeName, }); - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "object") { // Make the name of this nested type be the PascalCase property. const typeName = key.charAt(0).toUpperCase() + key.slice(1); @@ -247,12 +258,14 @@ const generate = ( name: typeName, }); - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (value._zod.def.type === "custom" && "protobuf" in meta) { const typeName = meta.protobuf; - proto.write(`optional ${typeName} ${field} = ${tag};`); + proto.write(`optional ${typeName} ${field} = ${tag}` + requiredString); } else if (iszjson(value as z.ZodType)) { - proto.write(`optional google.protobuf.Value ${field} = ${tag};`); + proto.write( + `optional google.protobuf.Value ${field} = ${tag}` + requiredString + ); } else { console.error( chalk.stderr.bold.red( diff --git a/reboot/ping/BUILD.bazel b/reboot/ping/BUILD.bazel index 02a0aae7..bc2b837a 100644 --- a/reboot/ping/BUILD.bazel +++ b/reboot/ping/BUILD.bazel @@ -158,6 +158,7 @@ py_binary( "REBOOT_LOCAL_ENVOY": "true", "REBOOT_LOCAL_ENVOY_PORT": "9991", "REBOOT_LOCAL_ENVOY_USE_TLS": "False", + "REBOOT_OAUTH_SIGNING_SECRET": "ping", }, main = "ping.py", visibility = ["//visibility:public"], diff --git a/reboot/ping/README.md b/reboot/ping/README.md index a11a99cf..c3a1984b 100644 --- a/reboot/ping/README.md +++ b/reboot/ping/README.md @@ -17,7 +17,7 @@ The application contains MCP functionality. Test it using MCPJam: ``` # From the root of the Bazel repo: -npx @mcpjam/inspector@v2.0.4 --config reboot/ping/mcp_servers.json --server ping-server +npx @mcpjam/inspector@v2.0.18 --config reboot/ping/mcp_servers.json --server ping-server ``` ## On the local cluster diff --git a/reboot/ping/mcp_servers.json b/reboot/ping/mcp_servers.json index e76f7a8c..0728ee69 100644 --- a/reboot/ping/mcp_servers.json +++ b/reboot/ping/mcp_servers.json @@ -2,7 +2,8 @@ "mcpServers": { "ping-server": { "type": "streamable-http", - "url": "http://localhost:9991/mcp" + "url": "http://localhost:9991/mcp", + "auth": "oauth" } } } diff --git a/reboot/ping/ping.py b/reboot/ping/ping.py index 9a4f64fa..38474e68 100644 --- a/reboot/ping/ping.py +++ b/reboot/ping/ping.py @@ -6,6 +6,7 @@ from datetime import timedelta from reboot.aio.applications import Application from reboot.aio.auth.authorizers import allow +from reboot.aio.auth.oauth_providers import Anonymous from reboot.aio.contexts import ( ReaderContext, TransactionContext, @@ -15,18 +16,23 @@ from reboot.aio.external import InitializeContext from reboot.controller.settings import ENVVAR_REBOOT_MODE from reboot.ping.ping_api import ( + CounterEntry, + CreateCounterRequest, CreateCounterResponse, DescribeResponse, + DescriptionResponse, DoPingPeriodicallyRequest, DoPingPeriodicallyResponse, DoPingResponse, DoPongResponse, IncrementResponse, + ListCountersResponse, NumPingsResponse, NumPongsResponse, ValueResponse, + WhoAmIResponse, ) -from reboot.ping.ping_api_rbt import Counter, Ping, Pong, Session +from reboot.ping.ping_api_rbt import Counter, Ping, Pong, User logging.basicConfig(level=logging.INFO) @@ -116,17 +122,39 @@ async def num_pongs( return NumPongsResponse(num_pongs=self.state.num_pongs) -class SessionServicer(Session.Servicer): - - def authorizer(self): - return allow() +class UserServicer(User.Servicer): async def create_counter( self, context: TransactionContext, + request: CreateCounterRequest, ) -> CreateCounterResponse: - counter, _ = await Counter.create(context) - return CreateCounterResponse(counter_id=counter.state_id) + counter, _ = await Counter.create( + context, description=request.description + ) + self.state.counter_ids.append(counter.state_id) + return CreateCounterResponse( + counter_id=counter.state_id, + ) + + async def list_counters( + self, + context: ReaderContext, + ) -> ListCountersResponse: + counters = [] + for counter_id in self.state.counter_ids: + response = await Counter.ref(counter_id).description(context) + counters.append( + CounterEntry( + counter_id=counter_id, + description=response.description, + ) + ) + return ListCountersResponse(counters=counters) + + async def whoami(self, context: ReaderContext) -> WhoAmIResponse: + user_id = context.auth.user_id if context.auth else "unauthenticated" + return WhoAmIResponse(user_id=user_id) class CounterServicer(Counter.Servicer): @@ -134,16 +162,30 @@ class CounterServicer(Counter.Servicer): def authorizer(self): return allow() - async def create(self, context) -> None: - # We don't need any non-default values in our state; it just - # needs to exist. - pass + async def create( + self, + context: WriterContext, + request: CreateCounterRequest, + ) -> None: + self.state.description = request.description + + async def description( + self, + context: ReaderContext, + ) -> DescriptionResponse: + return DescriptionResponse( + description=self.state.description, + ) async def increment( self, context: WriterContext, ) -> IncrementResponse: self.state.value += 1 + print( + f"Counter('{context.state_id}'): incremented to {self.state.value} " + f"by user '{context.auth.user_id if context.auth else None}'" + ) return IncrementResponse(value=self.state.value) async def value( @@ -188,12 +230,18 @@ async def main(): servicers=[ PingServicer, PongServicer, - SessionServicer, + UserServicer, CounterServicer, ], # We choose to not call the initialization method # `initialize`, to exercise that that is allowed. initialize=start_periodic_ping, + oauth=Anonymous( + # Set a short access token TTL so that most manual tests + # with this app naturally also exercise the access token + # refresh flow. + access_token_ttl_seconds=30, + ), ) await application.run() diff --git a/reboot/ping/ping_api.py b/reboot/ping/ping_api.py index 0ee41a6b..c1f773d8 100644 --- a/reboot/ping/ping_api.py +++ b/reboot/ping/ping_api.py @@ -52,20 +52,41 @@ class PongState(Model): num_pongs: int = Field(tag=1, default=0) -# -- Session models. -- +# -- User models. -- + + +class CreateCounterRequest(Model): + description: str = Field(tag=1) class CreateCounterResponse(Model): counter_id: str = Field(tag=1) -class SessionState(Model): - pass +class CounterEntry(Model): + counter_id: str = Field(tag=1) + description: str = Field(tag=2) + + +class ListCountersResponse(Model): + counters: list[CounterEntry] = Field(tag=1, default_factory=list) + + +class WhoAmIResponse(Model): + user_id: str = Field(tag=1) + + +class UserState(Model): + counter_ids: list[str] = Field(tag=1, default_factory=list) # -- Counter models (simple counter). -- +class DescriptionResponse(Model): + description: str = Field(tag=1) + + class IncrementResponse(Model): value: int = Field(tag=1) @@ -76,6 +97,7 @@ class ValueResponse(Model): class CounterState(Model): value: int = Field(tag=1, default=0) + description: str = Field(tag=2, default="") api = API( @@ -121,15 +143,32 @@ class CounterState(Model): ), ), ), - Session=Type( - state=SessionState, + User=Type( + state=UserState, methods=Methods( create_counter=Transaction( - request=None, + request=CreateCounterRequest, response=CreateCounterResponse, - description="Create a new Counter. Returns the ID of the new " - "counter. That ID is not human-readable; pass it to future tool " - "calls where needed, but no need to tell the human what it is.", + description="Create a new Counter with a " + "description of what it counts. Returns " + "the `counter_id`, which is not " + "human-readable but should be passed to " + "future tool calls that need it.", + ), + list_counters=Reader( + request=None, + response=ListCountersResponse, + description="List all counters created by " + "this user. Returns `counter_id` and " + "description for each. The `counter_id` is " + "not human-readable, but use it when " + "calling tools that take a `counter_id`.", + ), + whoami=Reader( + request=None, + response=WhoAmIResponse, + description="Returns the authenticated user's ID.", + mcp=Tool(), ), ), ), @@ -143,7 +182,7 @@ class CounterState(Model): description=("Interactive clicker for the Counter."), ), create=Writer( - request=None, + request=CreateCounterRequest, response=None, factory=True, ), @@ -159,6 +198,10 @@ class CounterState(Model): description="Get the current counter value.", mcp=Tool(), ), + description=Reader( + request=None, + response=DescriptionResponse, + ), ), ), ) diff --git a/reboot/protoc_gen_reboot_generic.py b/reboot/protoc_gen_reboot_generic.py index 345a62f1..b5038282 100644 --- a/reboot/protoc_gen_reboot_generic.py +++ b/reboot/protoc_gen_reboot_generic.py @@ -30,10 +30,7 @@ has_service_options, is_reboot_state, ) -from reboot.settings import ( - AUTO_CONSTRUCT_PROTO_METHOD, - AUTO_CONSTRUCT_STATE_TYPE, -) +from reboot.settings import AUTO_CONSTRUCT_PROTO_METHOD from reboot.version import REBOOT_VERSION from typing import Any, Literal, Optional, Sequence @@ -215,6 +212,10 @@ class ProtoState: full_name: str # Name including package. implements: list[str] # Full names of services. uis: list[ProtoUI] # UIs from UI() methods. + # Proto `AutoConstruct` enum value. + auto_construct: options_pb2.AutoConstruct.ValueType = ( + options_pb2.AUTO_CONSTRUCT_UNSPECIFIED + ) @dataclass @@ -694,6 +695,7 @@ def make_full_name(service_name: str): full_name=state.full_name, implements=implements, uis=uis, + auto_construct=state_options.auto_construct, ) @staticmethod @@ -901,8 +903,9 @@ def _base_services_for_state( # Note: duplicate methods between services are checked for during # client generation. - # Validate that auto-constructed states have the auto-constructor. - if proto_state.name == AUTO_CONSTRUCT_STATE_TYPE: + # Validate that auto-constructed states have the + # auto-constructor. + if proto_state.auto_construct != options_pb2.AUTO_CONSTRUCT_UNSPECIFIED: has_auto_construct = any( method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD for service in base_services @@ -910,13 +913,14 @@ def _base_services_for_state( ) if not has_auto_construct: raise UserProtoError( - f"State type '{AUTO_CONSTRUCT_STATE_TYPE}' requires a " - f"'{AUTO_CONSTRUCT_PROTO_METHOD}' Writer method. Add to " - "your service:\n" + f"State type '{proto_state.name}' requires " + f"a '{AUTO_CONSTRUCT_PROTO_METHOD}' Writer " + "method. Add to your service:\n" f" rpc {AUTO_CONSTRUCT_PROTO_METHOD}" - "(google.protobuf.Empty) returns (google.protobuf.Empty) " - "{\n" - " option (rbt.v1alpha1.method) = { writer: {} };\n" + "(google.protobuf.Empty) returns " + "(google.protobuf.Empty) {\n" + " option (rbt.v1alpha1.method) = " + "{ writer: {} };\n" " }" ) diff --git a/reboot/pydantic_schema_to_proto.py b/reboot/pydantic_schema_to_proto.py index 4cde1799..51c39b87 100644 --- a/reboot/pydantic_schema_to_proto.py +++ b/reboot/pydantic_schema_to_proto.py @@ -18,8 +18,14 @@ to_snake_case, ) from reboot.fail import fail +from reboot.settings import AUTO_CONSTRUCT_STATE_TYPE from typing import Dict, List, Literal, Optional, Union, get_args, get_origin +# Proto `AutoConstruct` enum value name for per-user +# auto-construction. Must match the enum in +# `rbt/v1alpha1/options.proto`. +_PER_USER_ID = "PER_USER_ID" + def _pydantic_field_type_string_from_type( field_type: typing.Type, @@ -90,15 +96,20 @@ async def _write_field_maybe_with_type_string_annotation( proto_field_name: str, proto_field_type_string: str, tag: int, + required: bool, field_type_string: Optional[str] = None, ): await proto.write( f" optional {proto_field_type_string} {proto_field_name} = {tag}" ) + annotations = [] if field_type_string is not None: - await proto.write( - f' [ (rbt.v1alpha1.field).pydantic_type = "{field_type_string}"]' + annotations.append( + f'(rbt.v1alpha1.field).pydantic_type = "{field_type_string}"' ) + required_string = "true" if required else "false" + annotations.append(f'(rbt.v1alpha1.field).required = {required_string}') + await proto.write(f' [{", ".join(annotations)}]') await proto.write(";\n") @@ -128,6 +139,9 @@ async def generate( discriminator: Optional[str] = None, # UIs associated with this state type. uis: Optional[List] = None, + # Auto-construct enum value name for this state type, + # or None for non-auto-constructed types. + auto_construct: Optional[str] = None, ): origin = get_origin(schema) args = get_args(schema) @@ -138,12 +152,18 @@ async def generate( await proto.write(f"message {name} {{\n") if state: - if uis: - # Generate state option with UIs. - # Proto text format uses repeated field - # names, not array syntax. + if uis or auto_construct: + # Generate state option with UIs and/or + # auto-construct annotation. Proto text + # format uses repeated field names, not + # array syntax. await proto.write(" option (rbt.v1alpha1.state) = {\n") - for ui in uis: + if auto_construct is not None: + await proto.write( + f" auto_construct: " + f"{auto_construct}\n" + ) + for ui in (uis or []): ui_fields = [ f'name: "{ui["name"]}"', f'title: "{ui["title"]}"', @@ -206,6 +226,12 @@ async def generate( tags[tag] = field_name + # In Pydantic if a class has an `Optional` field, that field + # should be explicitly set to `None`, otherwise it will fail + # during validation. So the "required" in Pydantic means + # that the field has `default` or `default_factory` specified. + required = field_info.is_required() + field_type_string: Optional[str] = None if add_type_string_annotation_to_proto: field_type_string = _pydantic_field_type_string_from_type( @@ -256,6 +282,7 @@ async def generate( proto_field_name, type_name, tag, + required, field_type_string, ) continue @@ -273,6 +300,7 @@ async def generate( proto_field_name, "string", tag, + required, field_type_string, ) elif inner_type == int: @@ -282,6 +310,7 @@ async def generate( proto_field_name, "double", tag, + required, field_type_string, ) elif inner_type == float: @@ -291,6 +320,7 @@ async def generate( proto_field_name, "double", tag, + required, field_type_string, ) elif inner_type == bool: @@ -300,6 +330,7 @@ async def generate( proto_field_name, "bool", tag, + required, field_type_string, ) elif inner_origin in (list, List): @@ -317,6 +348,7 @@ async def generate( proto_field_name, type_name, tag, + required, field_type_string, ) elif inner_origin in (dict, Dict): @@ -333,6 +365,7 @@ async def generate( proto_field_name, type_name, tag, + required, field_type_string, ) elif inner_origin is Literal: @@ -375,6 +408,7 @@ async def generate( proto_field_name, type_name, tag, + required, field_type_string, ) elif isinstance(inner_type, @@ -391,6 +425,7 @@ async def generate( proto_field_name, type_name, tag, + required, field_type_string, ) elif not field_args and inner_origin is None: @@ -658,6 +693,8 @@ async def generate_proto_file_from_api( name=type_name, state=True, uis=uis if uis else None, + auto_construct=_PER_USER_ID + if type_name == AUTO_CONSTRUCT_STATE_TYPE else None, ) await proto.write('\n') diff --git a/reboot/react/index.tsx b/reboot/react/index.tsx index 8b941f88..b34e5390 100644 --- a/reboot/react/index.tsx +++ b/reboot/react/index.tsx @@ -27,6 +27,7 @@ export { McpAppContext, useMcpApp, useMcpToolData, + useRefreshMCPBearerToken, type McpAppContextValue, } from "./internal/index.js"; @@ -246,7 +247,10 @@ export const RebootClientProvider = ({
Loading MCP...
} > - + {children} diff --git a/reboot/react/internal/McpConnector.tsx b/reboot/react/internal/McpConnector.tsx index b0ae360a..7cd51627 100644 --- a/reboot/react/internal/McpConnector.tsx +++ b/reboot/react/internal/McpConnector.tsx @@ -14,15 +14,17 @@ import { useHostStyleVariables, type App as McpApp, } from "@modelcontextprotocol/ext-apps/react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { McpAppContext, type McpAppContextValue } from "./index.js"; import { useAppSafe } from "./useAppSafe.js"; export default function McpConnector({ appName, + setBearerToken, children, }: { appName: string; + setBearerToken: (token?: string) => void; children: ReactNode; }) { // Merged tool data from ontoolinput + ontoolresult. @@ -30,39 +32,150 @@ export default function McpConnector({ null ); + // To open this UI, the MCP client was required to call an MCP tool. + // That MCP tool will, amongst other things, return a bearer token + // this UI can use. If that bearer token expires we must (under the + // hood) re-call the tool to refresh the token; capture the tool's + // input arguments (request params) and record the tool name, so we + // can call it again later. + const toolInfoRef = useRef<{ + name: string; + arguments: Record; + } | null>(null); + + // Coalesce concurrent refresh calls. + const refreshPromiseRef = useRef | null>(null); + + // Access `setBearerToken` via ref so callbacks always use the + // latest version without depending on its identity. In practice + // `setBearerToken` is a React `useState` setter (stable), but + // the ref avoids coupling to that assumption. + const setBearerTokenRef = useRef(setBearerToken); + setBearerTokenRef.current = setBearerToken; + const { mcpApp, isConnected, error } = useAppSafe({ appInfo: { name: appName, version: "1.0.0", }, capabilities: {}, - onAppCreated: (createdMcpApp: McpApp) => { - console.log(`[${appName}] Connected to MCP host`); - // Capture tool input arguments (e.g. request params). + // To open this UI, the MCP client was required to call an MCP tool. + // Store required information about that tool (see the comment on + // `toolInfoRef`). + onAppCreated: (createdMcpApp: McpApp) => { createdMcpApp.ontoolinput = (input) => { + // If for any reason the host doesn't provide the tool name, set + // `toolInfoRef` to `null` — this disables bearer token refresh + // (our only option; we can't re-invoke a tool without knowing + // its name). + const toolName = (createdMcpApp as any).getHostContext?.()?.toolInfo + ?.tool?.name; + + toolInfoRef.current = toolName + ? { + name: toolName, + arguments: (input.arguments as Record) ?? {}, + } + : null; + setToolData((prev) => ({ ...prev, ...(input.arguments ?? {}), })); }; - // Capture tool result — for Session types the session - // ID is returned here (not in tool input arguments). + // Capture tool result — for User types the user ID is returned + // here (not in tool input arguments). Also extract `bearer_token` + // if present. createdMcpApp.ontoolresult = (result: any) => { + // Try `structuredContent` first (ChatGPT wraps tool data here), + // then fall back to `content[].text`. + const sources: string[] = []; + + // `structuredContent` may be an object with a `text` string, or + // the data itself. + const sc = result.structuredContent; + if (sc != null) { + if (typeof sc === "string") { + sources.push(sc); + } else if (typeof sc.text === "string") { + sources.push(sc.text); + } else if (typeof sc === "object") { + // Already parsed — merge directly. + setToolData((prev) => ({ ...prev, ...sc })); + return; + } + } + const text = result.content?.find((c: any) => c.type === "text")?.text; - if (text) { + if (text) sources.push(text); + + for (const src of sources) { try { - const data = JSON.parse(text); + const data = JSON.parse(src); setToolData((prev) => ({ ...prev, ...data })); + + // Forward bearer token to the RebootClient. + if (typeof data.bearer_token === "string") { + setBearerTokenRef.current(data.bearer_token); + } + + return; } catch { - // Ignore malformed tool results. + // Try next source. } } }; }, }); + // Refresh bearer token by re-invoking the UI tool through the MCP + // host. The host refreshes its own access token (via refresh token) + // if needed before proxying the call, so we always get a fresh token. + const refreshMCPBearerToken = useCallback(async (): Promise< + string | undefined + > => { + // Coalesce: return in-flight promise if one exists. This is + // safe even if `mcpApp` has changed since the promise was + // created: the bearer token is minted by our server (not + // tied to the MCP connection), so a successful result from + // the old app is still valid. If the old connection died, + // the promise rejects and the `finally` block clears the + // ref, so the next attempt uses the new `mcpApp`. + if (refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + + if (!mcpApp || !toolInfoRef.current?.name) { + return undefined; + } + + const promise = (async () => { + try { + const result = await (mcpApp as any).callServerTool({ + name: toolInfoRef.current!.name, + arguments: toolInfoRef.current!.arguments, + }); + + const text = result?.content?.find((c: any) => c.type === "text")?.text; + if (text) { + const data = JSON.parse(text); + if (typeof data.bearer_token === "string") { + setBearerTokenRef.current(data.bearer_token); + return data.bearer_token as string; + } + } + return undefined; + } finally { + refreshPromiseRef.current = null; + } + })(); + + refreshPromiseRef.current = promise; + return promise; + }, [mcpApp]); + // Get initial host context once mcpApp is connected. const initialContext = useMemo(() => { return mcpApp?.getHostContext() ?? null; @@ -83,8 +196,8 @@ export default function McpConnector({ // etc.) delivers IDs by invoking the UI tool, which fires // `ontoolinput`/`ontoolresult` events. The `ids` field maps fully // qualified state type names to their IDs (e.g. `{"rbt.ping.v1.Ping": - // "...", "rbt.ping.v1.Session": "..."}`). Without IDs the generated - // hooks (e.g. `usePing()`, `useSession()`) can't connect to the right + // "...", "rbt.ping.v1.User": "..."}`). Without IDs the generated + // hooks (e.g. `usePing()`, `useUser()`) can't connect to the right // state instances, so we show a loading state until they arrive. const hasIds = toolData?.ids != null && typeof toolData.ids === "object"; if (!isConnected || !mcpApp || !hasIds) { @@ -98,6 +211,7 @@ export default function McpConnector({ const contextValue: McpAppContextValue = { mcpApp, toolData, + refreshMCPBearerToken, }; return ( diff --git a/reboot/react/internal/index.ts b/reboot/react/internal/index.ts index 31cb7620..4f74ac34 100644 --- a/reboot/react/internal/index.ts +++ b/reboot/react/internal/index.ts @@ -13,6 +13,9 @@ export interface McpAppContextValue { // Merged data from ontoolinput (arguments) and // ontoolresult (parsed result content). toolData: Record | null; + // Re-invoke the UI tool via the MCP host to obtain a + // fresh bearer token. Concurrent calls are coalesced. + refreshMCPBearerToken: () => Promise; } export const McpAppContext = createContext(null); @@ -32,3 +35,14 @@ export function useMcpApp(): any | null { export function useMcpToolData(): Record | null { return useContext(McpAppContext)?.toolData ?? null; } + +/** + * @internal Used by generated code to refresh an expired + * bearer token by re-invoking the UI tool through the + * MCP host. + */ +export function useRefreshMCPBearerToken(): + | (() => Promise) + | null { + return useContext(McpAppContext)?.refreshMCPBearerToken ?? null; +} diff --git a/reboot/react/package.json b/reboot/react/package.json index ff3d03f1..42c4bf1b 100644 --- a/reboot/react/package.json +++ b/reboot/react/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-react", - "version": "0.45.2", + "version": "0.46.0", "description": "npm package for Reboot React", "main": "index.js", "type": "module", @@ -20,15 +20,15 @@ }, "author": "reboot-dev", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@types/uuid": "^9.0.4", "js-sha1": "0.7.0", "tslib": "^2.6.2", "typescript": "4.8.4", "uuid": "11.1.0", - "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/ext-apps": "1.2.0", + "@modelcontextprotocol/sdk": "1.27.1", "@scarf/scarf": "1.4.0" }, "license": "Apache-2.0", diff --git a/reboot/requirements.in b/reboot/requirements.in index 59416f6b..3306924d 100644 --- a/reboot/requirements.in +++ b/reboot/requirements.in @@ -23,6 +23,7 @@ pathspec==0.12.1 # Latest as of 2024/04/22. protobuf==5.28.3 # Aligned with `grpcio`. psutil==6.0.0 # Latest as of 2024/09/10. pyjwt==2.10.1 # Latest as of 2024/11/27. +python-ulid==3.1.0 # Latest as of 2026/03/12. pyprctl==0.1.3 # Latest as of 2023/06/04. pyyaml==6.0.2 # Latest as of 2024/09/16. tzlocal==5.3 # Latest as of 2025/02/13. diff --git a/reboot/requirements_lock.txt b/reboot/requirements_lock.txt index bc9ed0fb..548d01b5 100644 --- a/reboot/requirements_lock.txt +++ b/reboot/requirements_lock.txt @@ -1186,6 +1186,10 @@ python-multipart==0.0.22 \ --hash=sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155 \ --hash=sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58 # via mcp +python-ulid==3.1.0 \ + --hash=sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619 \ + --hash=sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636 + # via -r reboot/requirements.in pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ diff --git a/reboot/routing/envoy_config.py b/reboot/routing/envoy_config.py index d75fe4a2..a29c4359 100644 --- a/reboot/routing/envoy_config.py +++ b/reboot/routing/envoy_config.py @@ -47,10 +47,10 @@ ) from reboot.run_environments import on_cloud from reboot.settings import ( + ENVOY_PER_CONNECTION_BUFFER_LIMIT_BYTES, ENVVAR_LOCAL_ENVOY_MODE, ENVVAR_RBT_DEV, ENVVAR_RBT_MCP_FRONTEND_HOST, - MAX_GRPC_RESPONSE_SIZE_BYTES, ) from urllib.parse import urlparse @@ -690,9 +690,18 @@ def _filter_http_connection_manager( string_pb2.StringMatcher( safe_regex=regex_pb2.RegexMatcher( # TODO(rjh): deprecated; can remove? + # We do not know or control where + # frontends get hosted, and neither + # do our customers: depending on the + # MCP client used, a sandbox may + # have any origin. Permit all + # origins. Note that the correct + # incantation for Envoy is `".*"`, + # not `"*"` (which only matches a + # literal star). google_re2=regex_pb2.RegexMatcher. GoogleRE2(), - regex="\\*", + regex=".*", ), ) ], @@ -858,7 +867,7 @@ class ListenerConfig: ], # See: https://github.com/reboot-dev/mono/issues/3944. per_connection_buffer_limit_bytes=UInt32Value( - value=MAX_GRPC_RESPONSE_SIZE_BYTES + value=ENVOY_PER_CONNECTION_BUFFER_LIMIT_BYTES ), ) for listener in listeners ] @@ -964,7 +973,7 @@ def _cluster( ), # See: https://github.com/reboot-dev/mono/issues/3944. per_connection_buffer_limit_bytes=UInt32Value( - value=MAX_GRPC_RESPONSE_SIZE_BYTES + value=ENVOY_PER_CONNECTION_BUFFER_LIMIT_BYTES ), ) diff --git a/reboot/server/database.cc b/reboot/server/database.cc index 78c5bfba..dbf64e00 100644 --- a/reboot/server/database.cc +++ b/reboot/server/database.cc @@ -215,6 +215,10 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { grpc::ServerContext* context, const ExportRequest* request, ExportResponse* response) override; + grpc::Status ExportStreamed( + grpc::ServerContext* context, + const ExportRequest* request, + grpc::ServerWriter* responses) override; grpc::Status GetApplicationMetadata( grpc::ServerContext* context, const GetApplicationMetadataRequest* request, @@ -249,10 +253,6 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { expected LookupOrCreateColumnFamilyHandle( const std::string& state_type); - // Iterate through and return a printable list of all the keys stored in the - // database, for debugging purposes. - std::string ListDatabaseKeys(); - // Lookup an ongoing transaction for the specified state type and actor // or fail if no transaction exists. expected> LookupTransaction( @@ -276,28 +276,33 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { expected ValidateNonTransactionalStore(const StoreRequest& request); // Helper for recovering tasks. - void RecoverTasks( + grpc::Status RecoverTasks( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids); // Helper for recovering transactions. - expected RecoverTransactions( + expected RecoverTransactions( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids); // Helper for recovering uncommitted tasks within a transaction. - void RecoverTransactionTasks( + grpc::Status RecoverTransactionTasks( + const grpc::ServerContext& context, Transaction& transaction, stout::borrowed_ref& txn); // Helper for recovering uncommitted idempotent mutations within a // transaction. - void RecoverTransactionIdempotentMutations( + grpc::Status RecoverTransactionIdempotentMutations( + const grpc::ServerContext& context, Transaction& transaction, stout::borrowed_ref& txn); // Helper for recovering idempotent mutations within some shards. - void RecoverShardsIdempotentMutations( + grpc::Status RecoverShardsIdempotentMutations( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids); @@ -316,30 +321,49 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { // Helper function to determine which shard owns a given state_ref. std::string GetShardForStateRef(std::string_view state_ref); - // Helper for sending a response to the client and clearing the batch. + // Iterates RocksDB and calls `on_item` for each matching item. + // If `on_item` returns a non-OK status, iteration stops early and + // that status is returned to the caller. + grpc::Status _Export( + const grpc::ServerContext& context, + const ExportRequest& request, + const std::function& on_item); + + // Helper for sending a response to the client and clearing the + // batch. Returns `CANCELLED` if `Write()` returns `false` (stream + // has been closed), which indicates the client has disconnected or + // cancelled the RPC. template - void WriteAndClearResponse( + grpc::Status WriteAndClearResponse( grpc::ServerWriter& responses, T& response, - size_t& batch_size) { - if (batch_size != 0) { - responses.Write(response); + size_t& estimated_batch_bytes) { + if (estimated_batch_bytes != 0) { + if (!responses.Write(response)) { + return grpc::Status::CANCELLED; + } response.Clear(); - batch_size = 0; + estimated_batch_bytes = 0; } + return grpc::Status::OK; } - // Helper for sending a response to the client and clearing the batch - // if the batch size has reached the maximum batch size. + // Helper for sending a response to the client and clearing the + // batch once its estimated byte size reaches the flush threshold. + // Returns `CANCELLED` if `Write()` returns `false` (stream + // has been closed), which indicates the client has disconnected or + // cancelled the RPC. template - void MaybeWriteAndClearResponse( + grpc::Status MaybeWriteAndClearResponse( grpc::ServerWriter& responses, T& response, - size_t& batch_size, - const size_t& max_batch_size) { - if (++batch_size == max_batch_size) { - WriteAndClearResponse(responses, response, batch_size); + size_t& estimated_batch_bytes, + size_t estimated_item_bytes) { + estimated_batch_bytes += estimated_item_bytes; + if (estimated_batch_bytes >= rbt::kBatchFlushBytes) { + return WriteAndClearResponse(responses, response, estimated_batch_bytes); } + return grpc::Status::OK; } // Mutex owning instance members. @@ -354,6 +378,12 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { // be a relatively small list and it will be faster to just iterate // through it when doing a lookup. std::vector column_family_handles_; + // A separate mutex for `column_family_handles_`. The main `mutex_` + // is not acquired during `Find()`, but + // `LookupColumnFamilyHandle()` is called from `Find()` and must not + // race with `LookupOrCreateColumnFamilyHandle()` which modifies + // `column_family_handles_`. + std::mutex column_family_handles_mutex_; std::unique_ptr db_; @@ -367,6 +397,13 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { // Cache capacity large enough to be helpful but not so large that // it's more than O(10s of MB). static const size_t SHARD_FOR_STATE_REF_CAPACITY = 8192; + // A separate mutex for `shard_for_state_ref_` cache. The main + // `mutex_` is not acquired during `_Export` iteration, but + // `GetShardForStateRef` is called from the export loop and must not + // race with other RPC threads that also access the cache. `Cache` is + // not thread-safe by default so we need to protect it with its own + // lock rather than the broader `mutex_`. + std::mutex shard_cache_mutex_; // We track ongoing transactions indexed by 'state_ref' because there should // only ever be a single transaction per actor and we want to be able to @@ -380,6 +417,18 @@ class DatabaseService final : public rbt::v1alpha1::Database::Service { // to use the "legacy" format (i.e. not providing a coordinator state ref). // This should only be true for tests. bool allow_legacy_coordinator_prepared_ = false; + + // Optional hook invoked only in tests at specific long-running RPC + // call sites. See `TestOnlyLongRunningRPCHookSite` for the set of + // call sites. + std::function + test_only_hook_for_long_running_rpc_; + + public: + void SetTestOnlyHookForLongRunningRPC( + std::function hook) { + test_only_hook_for_long_running_rpc_ = std::move(hook); + } }; //////////////////////////////////////////////////////////////////////// @@ -616,32 +665,9 @@ expected ValidateServerInfo( //////////////////////////////////////////////////////////////////////// -std::string DatabaseService::ListDatabaseKeys() { - std::ostringstream stream; - stream << "{"; - for (rocksdb::ColumnFamilyHandle* column_family_handle : - column_family_handles_) { - stream << "\n " << column_family_handle->GetName() << ": ["; - std::unique_ptr iterator(CHECK_NOTNULL(db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle))); - - iterator->SeekToFirst(); - while (iterator->Valid()) { - stream << "\n " << iterator->key().ToStringView() << ","; - iterator->Next(); - } - stream << "],"; - } - stream << "\n}"; - return stream.str(); -} - -//////////////////////////////////////////////////////////////////////// - expected DatabaseService::LookupColumnFamilyHandle(const std::string& state_type) { - // TODO: ensure `mutex_` is currently held by this thread. + std::unique_lock lock(column_family_handles_mutex_); // TODO(benh): make 'column_family_handles_' be a map? auto iterator = std::find_if( @@ -666,7 +692,7 @@ DatabaseService::LookupColumnFamilyHandle(const std::string& state_type) { expected DatabaseService::LookupOrCreateColumnFamilyHandle( const std::string& state_type) { - // TODO: ensure `mutex_` is currently held by this thread. + std::unique_lock lock(column_family_handles_mutex_); // TODO(benh): make 'column_family_handles_' be a map? auto iterator = std::find_if( @@ -1288,7 +1314,7 @@ grpc::Status DatabaseService::ColocatedRange( rocksdb::ReadOptions read_options = rocksdb::ReadOptions(); read_options.iterate_upper_bound = &end_key; - rocksdb::Iterator* it; + std::unique_ptr it; if (request->has_transaction()) { // Invariant: if this call is made within a transaction, the transaction // must have already been stored. See: @@ -1304,9 +1330,9 @@ grpc::Status DatabaseService::ColocatedRange( request->transaction().state_type(), request->transaction().state_ref())); } - it = txn.value()->GetIterator(read_options, *column_family_handle); + it.reset(txn.value()->GetIterator(read_options, *column_family_handle)); } else { - it = db_->NewIterator(read_options, *column_family_handle); + it.reset(db_->NewIterator(read_options, *column_family_handle)); } // Seek to the start of the range to scan. @@ -1396,7 +1422,7 @@ grpc::Status DatabaseService::ColocatedReverseRange( rocksdb::Slice end_key = rocksdb::Slice(end_key_str); read_options.iterate_lower_bound = &end_key; - rocksdb::Iterator* it; + std::unique_ptr it; if (request->has_transaction()) { // Invariant: if this call is made within a transaction, the transaction // must have already been stored. See: @@ -1412,9 +1438,9 @@ grpc::Status DatabaseService::ColocatedReverseRange( request->transaction().state_type(), request->transaction().state_ref())); } - it = txn.value()->GetIterator(read_options, *column_family_handle); + it.reset(txn.value()->GetIterator(read_options, *column_family_handle)); } else { - it = db_->NewIterator(read_options, *column_family_handle); + it.reset(db_->NewIterator(read_options, *column_family_handle)); } // Seek to the start of the range to scan. @@ -1461,7 +1487,6 @@ grpc::Status DatabaseService::Find( grpc::ServerContext* context, const FindRequest* request, FindResponse* response) { - std::unique_lock lock(mutex_); REBOOT_DATABASE_LOG(1) << "Find { " << request->ShortDebugString() << " }"; // Validate that shard_ids are provided. @@ -1476,15 +1501,19 @@ grpc::Status DatabaseService::Find( request->shard_ids().begin(), request->shard_ids().end()); + // `LookupColumnFamilyHandle()` acquires `column_family_handles_mutex_` + // internally, so we do not need to hold `mutex_` here. Column family + // handles are never deleted once created, so the returned pointer + // remains valid for the lifetime of this call. expected column_family_handle = LookupColumnFamilyHandle(request->state_type()); if (!column_family_handle.has_value()) { // Return empty results for unknown state types rather than an error. // This is important because callers (like SidecarStateManager.actors()) - // may query for state types that haven't been created yet. A state type's - // column family is only created when the first actor of that type is - // stored, so returning an empty result correctly indicates there are no - // actors of this type yet. + // may query for state types that haven't been created yet. A state + // type's column family is only created when the first actor of that + // type is stored, so returning an empty result correctly indicates + // there are no actors of this type yet. return grpc::Status::OK; } @@ -1492,6 +1521,13 @@ grpc::Status DatabaseService::Find( // in total order. This is necessary because we have a prefix extractor // configured, and using the default ReadOptions could cause the iterator // to skip keys due to prefix optimization. + // `db_->NewIterator()` is thread-safe and captures an implicit + // snapshot of the DB state at the moment of iterator creation. + // See https://github.com/facebook/rocksdb/wiki/Iterator#consistent-view + // https://github.com/facebook/rocksdb/wiki/RocksDB-FAQ + // Concurrent writes after that point are invisible to the iterator, + // so we get a consistent read without holding `mutex_` across the + // iteration. rocksdb::ReadOptions read_options = NonPrefixIteratorReadOptions(); std::unique_ptr it( db_->NewIterator(read_options, *column_family_handle)); @@ -1512,7 +1548,33 @@ grpc::Status DatabaseService::Find( // Collect up to 'limit' entries going forward. uint32_t added_count = 0; + + // We don't know how many iterations we'll need to do to find `limit` + // matching entries. To perform `IsCancelled()` checks with the + // same frequency we use a separate counter. + uint8_t cancellation_check_counter = 0; + while (added_count < request->limit() && it->Valid()) { + // Despite the RPC is unary, it may be a "long-running" RPC if the + // limit is large, so we want to check is the context is cancelled + // so we will return early. + // https://github.com/reboot-dev/mono/issues/5349 + // + // Performing `IsCancelled()` checks is relatively expensive due to + // a `seq_cst` atomic load and there is no reason to check on every + // iteration. + // + // TODO: Remove that code once we make `Find` be a streaming RPC + // or support pagination. + // https://github.com/reboot-dev/mono/issues/5360 + if (++cancellation_check_counter == 100) { + // To not overflow the counter, we reset it each time we do a + // check. + cancellation_check_counter = 0; + if (context->IsCancelled()) { + return grpc::Status::CANCELLED; + } + } std::string_view key_view = it->key().ToStringView(); const std::string prefix = STATE_KEY_PREFIX ":"; if (key_view.size() < prefix.size() @@ -1546,7 +1608,33 @@ grpc::Status DatabaseService::Find( // reverse them since we want to return in forward order. std::vector state_refs; uint32_t added_count = 0; + + // We don't know how many iterations we'll need to do to find `limit` + // matching entries. To perform `IsCancelled()` checks with the + // same frequency we use a separate counter. + uint8_t cancellation_check_counter = 0; + while (added_count < request->limit() && it->Valid()) { + // Despite the RPC is unary, it may be a "long-running" RPC if the + // limit is large, so we want to check is the context is cancelled + // so we will return early. + // https://github.com/reboot-dev/mono/issues/5349 + // + // Performing `IsCancelled()` checks is relatively expensive due to + // a `seq_cst` atomic load and there is no reason to check on every + // iteration. + // + // TODO: Remove that code once we make `Find` be a streaming RPC + // or support pagination. + // https://github.com/reboot-dev/mono/issues/5360 + if (++cancellation_check_counter == 100) { + // To not overflow the counter, we reset it each time we do a + // check. + cancellation_check_counter = 0; + if (context->IsCancelled()) { + return grpc::Status::CANCELLED; + } + } std::string_view key_view = it->key().ToStringView(); const std::string prefix = STATE_KEY_PREFIX ":"; if (key_view.size() < prefix.size() @@ -2316,16 +2404,137 @@ grpc::Status DatabaseService::TransactionCoordinatorCleanup( //////////////////////////////////////////////////////////////////////// -grpc::Status DatabaseService::Export( - grpc::ServerContext* context, - const ExportRequest* request, - ExportResponse* response) { - std::unique_lock lock(mutex_); +// Estimate the serialized size of an `Actor` without CPU-expensive +// protobuf serialization. The dominant "expensive" fields will always +// be `bytes` fields, so we can just sum the sizes of those and add +// some metadata size for the other fields. +// NOTE: it is not a "real" size but the estimation only, but it is fine +// since we have a batch size threshold as twice lower than the max +// size we can transport over the wire and we also do not expect the +// data to be close to the transport limit. +size_t EstimateActorSize(const Actor& actor) { + return actor.state_type().size() + actor.state_ref().size() + + actor.state().size(); +} + +// Estimate the serialized size of a `Task` without CPU-expensive +// protobuf serialization. The dominant "expensive" fields will always +// be `bytes` fields, so we can just sum the sizes of those and add +// some metadata size for the other fields. +// NOTE: it is not a "real" size but the estimation only, but it is fine +// since we have a batch size threshold as twice lower than the max +// size we can transport over the wire and we also do not expect the +// data to be close to the transport limit. +size_t EstimateTaskSize(const Task& task) { + // `TaskId` data. + size_t size = task.task_id().state_type().size() + + task.task_id().state_ref().size() + task.task_id().task_uuid().size(); + + // Timestamp field (`int64` + `int32`) + `uint64` for `iteration`. + size += 24; + + // `method` and `request` data. + size += task.method().size() + task.request().size(); + + if (task.has_response()) { + size += task.response().value().size() + task.response().type_url().size(); + } + if (task.has_error()) { + size += task.error().value().size() + task.error().type_url().size(); + } + return size; +} + +// Estimate the serialized size of a `IdempotentMutation` without +// CPU-expensive protobuf serialization. The dominant "expensive" +// fields will always be `bytes` fields, so we can just sum the sizes of +// those and add some metadata size for the other fields. +// NOTE: it is not a "real" size but the estimation only, but it is fine +// since we have a batch size threshold as twice lower than the max +// size we can transport over the wire and we also do not expect the +// data to be close to the transport limit. +size_t EstimateIdempotentMutationSize(const IdempotentMutation& mutation) { + // `IdempotentMutation` data fields. + size_t size = mutation.state_type().size() + mutation.state_ref().size() + + mutation.key().size() + mutation.response().size(); + + if (mutation.has_workflow_id()) { + size += mutation.workflow_id().size(); + } + + if (mutation.has_workflow_iteration()) { + // `workflow_iteration` is a `uint64`. + size += 8; + } + + // To avoid for-looping over all task IDs, we can estimate the size of + // each `TaskId`, since it has 2 strings (overestimate them as 100 + // bytes each) and a UUID bytes field (32 bytes). + size += mutation.task_ids().size() * 232; + return size; +} + +// Estimate the serialized size of a `Transaction` without +// CPU-expensive protobuf serialization. The dominant "expensive" +// fields will always be `bytes` fields, so we can just sum the sizes of +// those and add some metadata size for the other fields. +// NOTE: it is not a "real" size but the estimation only, but it is fine +// since we have a batch size threshold as twice lower than the max +// size we can transport over the wire and we also do not expect the +// data to be close to the transport limit. +size_t EstimateTransactionSize(const Transaction& transaction) { + size_t size = transaction.state_type().size() + transaction.state_ref().size() + + transaction.coordinator_state_type().size() + + transaction.coordinator_state_ref().size(); + + // To avoid for-looping over all transaction IDs, we can estimate the size + // of each transaction ID, since it is a UUID bytes field (32 bytes). + size += transaction.transaction_ids().size() * 32; + + for (const auto& task : transaction.uncommitted_tasks()) { + // Each task has a serialized `request` and `response_or_error`, + // which is tricky to guesstimate, but hopefully we won't have a ton + // of uncommitted tasks at a time and this will be good enough. + size += EstimateTaskSize(task); + } + for (const auto& mutation : transaction.uncommitted_idempotent_mutations()) { + // Each idempotent mutation has a serialized `response` which is + // tricky to guesstimate, but hopefully we won't have a ton of + // uncommitted mutations at a time and this will be good enough. + size += EstimateIdempotentMutationSize(mutation); + } + return size; +} + +// Estimate the serialized size of a `PreparedTransactionCoordinator` +// without CPU-expensive protobuf serialization. The dominant "expensive" +// fields will always be `bytes` fields, so we can just sum the sizes of +// those and add some metadata size for the other fields. +// NOTE: it is not a "real" size but the estimation only, but it is fine +// since we have a batch size threshold as twice lower than the max +// size we can transport over the wire and we also do not expect the +// data to be close to the transport limit. +size_t EstimatePreparedTransactionCoordinatorSize( + const PreparedTransactionCoordinator& coordinator) { + size_t size = coordinator.state_ref().size(); + + // To avoid for-looping over all participants, we can guesstimate the + // size of each participant. Overestimate each `string` field as 100 + // bytes and each map value is a list of ~50 entries. + size += (coordinator.participants().should_commit_size() + + coordinator.participants().should_abort_size()) + * (100 * 50); + return size; +} - REBOOT_DATABASE_LOG(1) << "Export { " << request->ShortDebugString() << " }"; +grpc::Status DatabaseService::_Export( + const grpc::ServerContext& context, + const ExportRequest& request, + const std::function& on_item) { + REBOOT_DATABASE_LOG(1) << "Export { " << request.ShortDebugString() << " }"; // Validate that shard_ids are provided. - if (request->shard_ids().empty()) { + if (request.shard_ids().empty()) { return grpc::Status( grpc::INVALID_ARGUMENT, "shard_ids are required for Export request"); @@ -2333,24 +2542,41 @@ grpc::Status DatabaseService::Export( // Convert to unordered_set for efficient lookups. std::unordered_set shard_ids( - request->shard_ids().begin(), - request->shard_ids().end()); - + request.shard_ids().begin(), + request.shard_ids().end()); + + // `LookupOrCreateColumnFamilyHandle()` acquires + // `column_family_handles_mutex_` internally, so we do not need to + // hold `mutex_` here. Column family handles are never deleted once + // created, so the returned pointer remains valid for the lifetime of + // this call. expected column_family_handle = - LookupOrCreateColumnFamilyHandle(request->state_type()); + LookupOrCreateColumnFamilyHandle(request.state_type()); if (!column_family_handle.has_value()) { return grpc::Status( grpc::UNKNOWN, fmt::format( "Failed to begin export for '{}': {}", - request->state_type(), + request.state_type(), column_family_handle.error())); } - rocksdb::Iterator* it = db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle.value()); + // `db_->NewIterator()` is thread-safe and captures an implicit + // snapshot of the DB state at the moment of iterator creation. + // See https://github.com/facebook/rocksdb/wiki/Iterator#consistent-view + // https://github.com/facebook/rocksdb/wiki/RocksDB-FAQ + // Concurrent writes after that point are invisible to the iterator, + // so we get a consistent read without holding `mutex_` across the + // iteration. + rocksdb::ReadOptions read_options = NonPrefixIteratorReadOptions(); + std::unique_ptr it( + db_->NewIterator(read_options, *column_family_handle)); + + if (test_only_hook_for_long_running_rpc_) { + test_only_hook_for_long_running_rpc_( + TestOnlyLongRunningRPCHookSite::EXPORT_RIGHT_AFTER_IMPLICIT_SNAPSHOT); + } for (it->SeekToFirst(); it->Valid(); it->Next()) { std::string_view key = it->key().ToStringView(); @@ -2360,7 +2586,7 @@ grpc::Status DatabaseService::Export( grpc::UNKNOWN, fmt::format( "Unrecognized entry for '{}': {}", - request->state_type(), + request.state_type(), it->key().ToStringView())); } @@ -2369,17 +2595,20 @@ grpc::Status DatabaseService::Export( ExportItem item; std::string state_ref; + size_t estimated_item_bytes = 0; if (key_type_prefix == STATE_KEY_PREFIX) { state_ref = std::string(GetStateRefFromActorStateKey(it->key().ToStringView())); auto* actor = item.mutable_actor(); - actor->set_state_type(request->state_type()); + actor->set_state_type(request.state_type()); actor->set_state_ref(state_ref); actor->set_state(it->value().ToString()); + estimated_item_bytes = EstimateActorSize(*actor); } else if (key_type_prefix == TASK_KEY_PREFIX) { auto* task = item.mutable_task(); CHECK(task->ParseFromArray(it->value().data(), it->value().size())); state_ref = task->task_id().state_ref(); + estimated_item_bytes = EstimateTaskSize(*task); } else if ( key_type_prefix == IDEMPOTENT_MUTATION_KEY_PREFIX || key_type_prefix == EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX @@ -2390,21 +2619,26 @@ grpc::Status DatabaseService::Export( auto* mutation = item.mutable_idempotent_mutation(); CHECK(mutation->ParseFromArray(it->value().data(), it->value().size())); state_ref = mutation->state_ref(); + estimated_item_bytes = EstimateIdempotentMutationSize(*mutation); } else { return grpc::Status( grpc::UNKNOWN, fmt::format( "Unrecognized entry for '{}': {}", - request->state_type(), + request.state_type(), it->key().ToStringView())); } - // If this item doesn't belong to one of the requested shards, skip it. - if (!BelongsToShard(request->state_type(), state_ref, shard_ids)) { + // If this item doesn't belong to one of the requested shards, + // skip it. + if (!BelongsToShard(request.state_type(), state_ref, shard_ids)) { continue; } - *response->add_items() = std::move(item); + if (grpc::Status status = on_item(std::move(item), estimated_item_bytes); + !status.ok()) { + return status; + } } if (!it->status().ok()) { @@ -2412,13 +2646,69 @@ grpc::Status DatabaseService::Export( grpc::UNKNOWN, fmt::format( "Failed to export '{}': {}", - request->state_type(), + request.state_type(), it->status().ToString())); } return grpc::Status::OK; } +grpc::Status DatabaseService::ExportStreamed( + grpc::ServerContext* context, + const ExportRequest* request, + grpc::ServerWriter* responses) { + ExportResponse response; + size_t estimated_batch_bytes = 0; + grpc::Status status = _Export( + *context, + *request, + [this, context, responses, &response, &estimated_batch_bytes]( + ExportItem&& item, + size_t estimated_item_bytes) -> grpc::Status { + *response.add_items() = std::move(item); + return MaybeWriteAndClearResponse( + *responses, + response, + estimated_batch_bytes, + estimated_item_bytes); + }); + if (!status.ok()) { + return status; + } + // Flush any remaining items. + return WriteAndClearResponse(*responses, response, estimated_batch_bytes); +} + +grpc::Status DatabaseService::Export( + grpc::ServerContext* context, + const ExportRequest* request, + ExportResponse* response) { + size_t estimated_batch_bytes = 0; + grpc::Status status = _Export( + *context, + *request, + [response, &estimated_batch_bytes]( + ExportItem&& item, + size_t estimated_item_bytes) -> grpc::Status { + *response->add_items() = std::move(item); + estimated_batch_bytes += estimated_item_bytes; + // Return `FAILED_PRECONDITION` to stop iteration once over + // the size limit. + if (estimated_batch_bytes >= rbt::kBatchFlushBytes) { + return grpc::Status( + grpc::FAILED_PRECONDITION, + "Export data exceeds the gRPC message size limit; " + "please upgrade Reboot CLI to be compatible with " + "streamed exports."); + } + return grpc::Status::OK; + }); + if (!status.ok()) { + return status; + } + return grpc::Status::OK; +} + //////////////////////////////////////////////////////////////////////// grpc::Status DatabaseService::GetApplicationMetadata( @@ -2512,68 +2802,71 @@ grpc::Status DatabaseService::StoreApplicationMetadata( //////////////////////////////////////////////////////////////////////// -void DatabaseService::RecoverTasks( +grpc::Status DatabaseService::RecoverTasks( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids) { RecoverResponse response; - // We don't know what is the best batch size for this, so we - // just use a number that is small enough to not cause - // performance issues, but large enough to not cause too many - // round trips. - // TODO: This should be configurable and pick the better value for default. - static size_t RECOVER_TASKS_BATCH_SIZE = 256; - - size_t batch_size = 0; - - for (rocksdb::ColumnFamilyHandle* column_family_handle : - column_family_handles_) { - std::unique_ptr iterator(CHECK_NOTNULL(db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle))); - - // Only want to recover pending tasks! - static const std::string& TASK_PENDING_KEY_PREFIX = - TASK_KEY_PREFIX ":" + Task::Status_Name(Task::PENDING); - - // TODO: investigate using "prefix seek" for better performance, see: - // https://github.com/facebook/rocksdb/wiki/Prefix-Seek - iterator->Seek(rocksdb::Slice(TASK_PENDING_KEY_PREFIX)); - - while (iterator->Valid() - && iterator->key().ToStringView().find(TASK_PENDING_KEY_PREFIX) - == 0) { - Task task; - CHECK(task.ParseFromArray( - iterator->value().data(), - iterator->value().size())); - - CHECK_EQ(task.status(), Task::PENDING); - - if (BelongsToShard( - task.task_id().state_type(), - task.task_id().state_ref(), - shard_ids)) { - *response.add_pending_tasks() = std::move(task); + size_t estimated_batch_bytes = 0; + + { + std::unique_lock lock(column_family_handles_mutex_); + for (rocksdb::ColumnFamilyHandle* column_family_handle : + column_family_handles_) { + std::unique_ptr iterator( + CHECK_NOTNULL(db_->NewIterator( + NonPrefixIteratorReadOptions(), + column_family_handle))); + + // Only want to recover pending tasks! + static const std::string& TASK_PENDING_KEY_PREFIX = + TASK_KEY_PREFIX ":" + Task::Status_Name(Task::PENDING); + + // TODO: investigate using "prefix seek" for better performance, see: + // https://github.com/facebook/rocksdb/wiki/Prefix-Seek + iterator->Seek(rocksdb::Slice(TASK_PENDING_KEY_PREFIX)); + + while (iterator->Valid() + && iterator->key().ToStringView().find(TASK_PENDING_KEY_PREFIX) + == 0) { + Task task; + CHECK(task.ParseFromArray( + iterator->value().data(), + iterator->value().size())); + + CHECK_EQ(task.status(), Task::PENDING); + + if (BelongsToShard( + task.task_id().state_type(), + task.task_id().state_ref(), + shard_ids)) { + size_t estimated_task_bytes = EstimateTaskSize(task); + *response.add_pending_tasks() = std::move(task); + + if (grpc::Status status = MaybeWriteAndClearResponse( + responses, + response, + estimated_batch_bytes, + estimated_task_bytes); + !status.ok()) { + return status; + } + } - MaybeWriteAndClearResponse( - responses, - response, - batch_size, - RECOVER_TASKS_BATCH_SIZE); + iterator->Next(); } - - iterator->Next(); } } // Flush any remaining tasks. - WriteAndClearResponse(responses, response, batch_size); + return WriteAndClearResponse(responses, response, estimated_batch_bytes); } //////////////////////////////////////////////////////////////////////// -void DatabaseService::RecoverTransactionTasks( +grpc::Status DatabaseService::RecoverTransactionTasks( + const grpc::ServerContext& context, Transaction& transaction, stout::borrowed_ref& txn) { CHECK_EQ(transaction.uncommitted_tasks_size(), 0); @@ -2628,11 +2921,14 @@ void DatabaseService::RecoverTransactionTasks( iterator->Next(); } + + return grpc::Status::OK; } //////////////////////////////////////////////////////////////////////// -void DatabaseService::RecoverTransactionIdempotentMutations( +grpc::Status DatabaseService::RecoverTransactionIdempotentMutations( + const grpc::ServerContext& context, Transaction& transaction, stout::borrowed_ref& txn) { CHECK_EQ(transaction.uncommitted_idempotent_mutations_size(), 0); @@ -2689,11 +2985,14 @@ void DatabaseService::RecoverTransactionIdempotentMutations( iterator->Next(); } + + return grpc::Status::OK; } //////////////////////////////////////////////////////////////////////// -expected DatabaseService::RecoverTransactions( +expected DatabaseService::RecoverTransactions( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids) { std::unique_ptr iterator( @@ -2705,14 +3004,7 @@ expected DatabaseService::RecoverTransactions( RecoverResponse response; - // We don't know what is the best batch size for this, so we - // just use a number that is small enough to not cause - // performance issues, but large enough to not cause too many - // round trips. - // TODO: This should be configurable and pick the better value for default. - static size_t RECOVER_PARTICIPANT_TRANSACTIONS_BATCH_SIZE = 256; - - size_t batch_size = 0; + size_t estimated_batch_bytes = 0; while ( iterator->Valid() @@ -2749,30 +3041,46 @@ expected DatabaseService::RecoverTransactions( // Now recover any tasks for our actor that we'll need to dispatch if // the transaction gets committed. - RecoverTransactionTasks(transaction, *txn); + if (grpc::Status status = + RecoverTransactionTasks(context, transaction, *txn); + !status.ok()) { + return make_unexpected(status); + } // Now recover any idempotent mutations for our actor that are part of // the transaction. - RecoverTransactionIdempotentMutations(transaction, *txn); + if (grpc::Status status = + RecoverTransactionIdempotentMutations(context, transaction, *txn); + !status.ok()) { + return make_unexpected(status); + } } else { // Transaction just started when we called // `LookupOrBeginTransaction()`! CHECK_EQ((*txn)->GetState(), rocksdb::Transaction::STARTED); } + size_t estimated_transaction_bytes = EstimateTransactionSize(transaction); *response.add_participant_transactions() = std::move(transaction); - MaybeWriteAndClearResponse( - responses, - response, - batch_size, - RECOVER_PARTICIPANT_TRANSACTIONS_BATCH_SIZE); + if (grpc::Status status = MaybeWriteAndClearResponse( + responses, + response, + estimated_batch_bytes, + estimated_transaction_bytes); + !status.ok()) { + return make_unexpected(status); + } iterator->Next(); } // Flush any remaining participant transactions. - WriteAndClearResponse(responses, response, batch_size); + if (grpc::Status status = + WriteAndClearResponse(responses, response, estimated_batch_bytes); + !status.ok()) { + return make_unexpected(status); + } // Now recover any prepared coordinator transactions. // @@ -2781,13 +3089,6 @@ expected DatabaseService::RecoverTransactions( // committed and thus deleted the record of the transaction but we // have not yet completed the coordinator's "commit control loop". - // We don't know what is the best batch size for this, so we - // just use a number that is small enough to not cause - // performance issues, but large enough to not cause too many - // round trips. - // TODO: This should be configurable and pick the better value for default. - static size_t RECOVER_COORDINATOR_TRANSACTIONS_BATCH_SIZE = 256; - // First, handle legacy coordinator transactions (for backward compatibility). // // Legacy coordinator transactions were written before we associated them with @@ -2838,11 +3139,17 @@ expected DatabaseService::RecoverTransactions( *coordinator_entry.mutable_participants() = std::move(participants); - MaybeWriteAndClearResponse( - responses, - response, - batch_size, - RECOVER_COORDINATOR_TRANSACTIONS_BATCH_SIZE); + size_t estimated_prepared_transaction_coordinator_bytes = + EstimatePreparedTransactionCoordinatorSize(coordinator_entry); + + if (grpc::Status status = MaybeWriteAndClearResponse( + responses, + response, + estimated_batch_bytes, + estimated_prepared_transaction_coordinator_bytes); + !status.ok()) { + return make_unexpected(status); + } iterator->Next(); } @@ -2899,82 +3206,95 @@ expected DatabaseService::RecoverTransactions( iterator->value().data(), iterator->value().size())); - MaybeWriteAndClearResponse( - responses, - response, - batch_size, - RECOVER_COORDINATOR_TRANSACTIONS_BATCH_SIZE); + size_t estimated_prepared_transaction_coordinator_bytes = + EstimatePreparedTransactionCoordinatorSize(coordinator_entry); + + if (grpc::Status status = MaybeWriteAndClearResponse( + responses, + response, + estimated_batch_bytes, + estimated_prepared_transaction_coordinator_bytes); + !status.ok()) { + return make_unexpected(status); + } iterator->Next(); } // Flush any remaining coordinator transactions. - WriteAndClearResponse(responses, response, batch_size); + if (grpc::Status status = + WriteAndClearResponse(responses, response, estimated_batch_bytes); + !status.ok()) { + return make_unexpected(status); + } return {}; } //////////////////////////////////////////////////////////////////////// -void DatabaseService::RecoverShardsIdempotentMutations( +grpc::Status DatabaseService::RecoverShardsIdempotentMutations( + const grpc::ServerContext& context, grpc::ServerWriter& responses, const std::unordered_set& shard_ids) { RecoverResponse response; - // We don't know what is the best batch size for this, so we - // just use a number that is small enough to not cause - // performance issues, but large enough to not cause too many - // round trips. - // TODO: This should be configurable and pick the better value for default. - static size_t RECOVER_IDEMPOTENT_MUTATIONS_BATCH_SIZE = 256; - - size_t batch_size = 0; - - for (rocksdb::ColumnFamilyHandle* column_family_handle : - column_family_handles_) { - if (column_family_handle->GetName() == "default") { - continue; - } - - std::unique_ptr iterator(CHECK_NOTNULL(db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle))); - - // TODO: investigate using "prefix seek" for better performance, see: - // https://github.com/facebook/rocksdb/wiki/Prefix-Seek - iterator->Seek(rocksdb::Slice(IDEMPOTENT_MUTATION_KEY_PREFIX)); - - while ( - iterator->Valid() - && iterator->key().ToStringView().find(IDEMPOTENT_MUTATION_KEY_PREFIX) - == 0) { - IdempotentMutation idempotent_mutation; + size_t estimated_batch_bytes = 0; - CHECK(idempotent_mutation.ParseFromArray( - iterator->value().data(), - iterator->value().size())); + { + std::unique_lock lock(column_family_handles_mutex_); + for (rocksdb::ColumnFamilyHandle* column_family_handle : + column_family_handles_) { + if (column_family_handle->GetName() == "default") { + continue; + } - // Only send this idempotent mutation if its state ref falls within one of - // the requested shards. - if (BelongsToShard( - idempotent_mutation.state_type(), - idempotent_mutation.state_ref(), - shard_ids)) { - *response.add_idempotent_mutations() = std::move(idempotent_mutation); + std::unique_ptr iterator( + CHECK_NOTNULL(db_->NewIterator( + NonPrefixIteratorReadOptions(), + column_family_handle))); + + // TODO: investigate using "prefix seek" for better performance, see: + // https://github.com/facebook/rocksdb/wiki/Prefix-Seek + iterator->Seek(rocksdb::Slice(IDEMPOTENT_MUTATION_KEY_PREFIX)); + + while ( + iterator->Valid() + && iterator->key().ToStringView().find(IDEMPOTENT_MUTATION_KEY_PREFIX) + == 0) { + IdempotentMutation idempotent_mutation; + + CHECK(idempotent_mutation.ParseFromArray( + iterator->value().data(), + iterator->value().size())); + + // Only send this idempotent mutation if its state ref falls within one + // of the requested shards. + if (BelongsToShard( + idempotent_mutation.state_type(), + idempotent_mutation.state_ref(), + shard_ids)) { + size_t estimated_idempotent_mutation_bytes = + EstimateIdempotentMutationSize(idempotent_mutation); + *response.add_idempotent_mutations() = std::move(idempotent_mutation); + + if (grpc::Status status = MaybeWriteAndClearResponse( + responses, + response, + estimated_batch_bytes, + estimated_idempotent_mutation_bytes); + !status.ok()) { + return status; + } + } - MaybeWriteAndClearResponse( - responses, - response, - batch_size, - RECOVER_IDEMPOTENT_MUTATIONS_BATCH_SIZE); + iterator->Next(); } - - iterator->Next(); } } // Flush any remaining idempotent mutations. - WriteAndClearResponse(responses, response, batch_size); + return WriteAndClearResponse(responses, response, estimated_batch_bytes); } //////////////////////////////////////////////////////////////////////// @@ -2983,37 +3303,41 @@ void DatabaseService::RecoverShardsIdempotentMutations( // (i.e. from before #2580). expected DatabaseService::MigratePersistence2To3( const RecoverRequest& request) { - for (rocksdb::ColumnFamilyHandle* column_family_handle : - column_family_handles_) { - std::unique_ptr iterator(CHECK_NOTNULL(db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle))); - - // TODO: investigate using "prefix seek" for better performance, see: - // https://github.com/facebook/rocksdb/wiki/Prefix-Seek - iterator->Seek(rocksdb::Slice(TASK_KEY_PREFIX)); - - while (iterator->Valid() - && iterator->key().ToStringView().find(TASK_KEY_PREFIX) == 0) { - Task task; - CHECK(task.ParseFromArray( - iterator->value().data(), - iterator->value().size())); - if (task.has_response() - && task.response().type_url().find("type.googleapis.com") != 0) { - rocksdb::Status status = db_->Delete( - DefaultWriteOptions(), - column_family_handle, - iterator->key()); - if (!status.ok()) { - return make_unexpected( - fmt::format( - "Failed to delete stale task: {}", - status.ToString())); + { + std::unique_lock lock(column_family_handles_mutex_); + for (rocksdb::ColumnFamilyHandle* column_family_handle : + column_family_handles_) { + std::unique_ptr iterator( + CHECK_NOTNULL(db_->NewIterator( + NonPrefixIteratorReadOptions(), + column_family_handle))); + + // TODO: investigate using "prefix seek" for better performance, see: + // https://github.com/facebook/rocksdb/wiki/Prefix-Seek + iterator->Seek(rocksdb::Slice(TASK_KEY_PREFIX)); + + while (iterator->Valid() + && iterator->key().ToStringView().find(TASK_KEY_PREFIX) == 0) { + Task task; + CHECK(task.ParseFromArray( + iterator->value().data(), + iterator->value().size())); + if (task.has_response() + && task.response().type_url().find("type.googleapis.com") != 0) { + rocksdb::Status status = db_->Delete( + DefaultWriteOptions(), + column_family_handle, + iterator->key()); + if (!status.ok()) { + return make_unexpected( + fmt::format( + "Failed to delete stale task: {}", + status.ToString())); + } } - } - iterator->Next(); + iterator->Next(); + } } } @@ -3029,156 +3353,161 @@ expected DatabaseService::MigratePersistence2To3( // 2. Renames task keys so we can just recover pending tasks. expected DatabaseService::MigratePersistence3To4( const RecoverRequest& request) { - for (rocksdb::ColumnFamilyHandle* column_family_handle : - column_family_handles_) { - if (column_family_handle->GetName() == "default") { - continue; - } - - std::unique_ptr iterator(CHECK_NOTNULL(db_->NewIterator( - NonPrefixIteratorReadOptions(), - column_family_handle))); - - // To do the rename atomically we need to use a write batch. We - // also want to batch writes together because for large enough - // databases doing a write for every single idempotent mutation or - // task is prohibitively expensive - rocksdb::WriteBatch batch; - - size_t entries = 0; - - REBOOT_DATABASE_LOG(1) << "Migrating idempotent mutations for '" - << column_family_handle->GetName() << "'"; + { + std::unique_lock lock(column_family_handles_mutex_); + for (rocksdb::ColumnFamilyHandle* column_family_handle : + column_family_handles_) { + if (column_family_handle->GetName() == "default") { + continue; + } - // TODO: investigate using "prefix seek" for better performance, see: - // https://github.com/facebook/rocksdb/wiki/Prefix-Seek - iterator->Seek(rocksdb::Slice(IDEMPOTENT_MUTATION_KEY_PREFIX_V3 ":")); + std::unique_ptr iterator( + CHECK_NOTNULL(db_->NewIterator( + NonPrefixIteratorReadOptions(), + column_family_handle))); - // Helper to determine if the iterator is still valid. - std::function valid = [&]() { - return iterator->Valid() - && iterator->key().ToStringView().find( - IDEMPOTENT_MUTATION_KEY_PREFIX_V3 ":") - == 0; - }; + // To do the rename atomically we need to use a write batch. We + // also want to batch writes together because for large enough + // databases doing a write for every single idempotent mutation or + // task is prohibitively expensive + rocksdb::WriteBatch batch; - while (valid()) { - IdempotentMutation idempotent_mutation; + size_t entries = 0; - CHECK(idempotent_mutation.ParseFromArray( - iterator->value().data(), - iterator->value().size())); + REBOOT_DATABASE_LOG(1) << "Migrating idempotent mutations for '" + << column_family_handle->GetName() << "'"; - expected idempotent_mutation_key = MakeIdempotentMutationKey( - idempotent_mutation.state_ref(), - idempotent_mutation.key()); + // TODO: investigate using "prefix seek" for better performance, see: + // https://github.com/facebook/rocksdb/wiki/Prefix-Seek + iterator->Seek(rocksdb::Slice(IDEMPOTENT_MUTATION_KEY_PREFIX_V3 ":")); - if (!idempotent_mutation_key.has_value()) { - return make_unexpected(idempotent_mutation_key.error()); - } + // Helper to determine if the iterator is still valid. + std::function valid = [&]() { + return iterator->Valid() + && iterator->key().ToStringView().find( + IDEMPOTENT_MUTATION_KEY_PREFIX_V3 ":") + == 0; + }; - rocksdb::Status status = batch.Put( - column_family_handle, - rocksdb::Slice(*idempotent_mutation_key), - iterator->value()); + while (valid()) { + IdempotentMutation idempotent_mutation; - if (!status.ok()) { - return make_unexpected( - fmt::format( - "Failed to rename idempotent mutation: {}", - status.ToString())); - } + CHECK(idempotent_mutation.ParseFromArray( + iterator->value().data(), + iterator->value().size())); - status = batch.Delete(column_family_handle, iterator->key()); + expected idempotent_mutation_key = + MakeIdempotentMutationKey( + idempotent_mutation.state_ref(), + idempotent_mutation.key()); - if (!status.ok()) { - return make_unexpected( - fmt::format( - "Failed to rename idempotent mutation key: {}", - status.ToString())); - } + if (!idempotent_mutation_key.has_value()) { + return make_unexpected(idempotent_mutation_key.error()); + } - entries += 1; + rocksdb::Status status = batch.Put( + column_family_handle, + rocksdb::Slice(*idempotent_mutation_key), + iterator->value()); - iterator->Next(); + if (!status.ok()) { + return make_unexpected( + fmt::format( + "Failed to rename idempotent mutation: {}", + status.ToString())); + } - // Check if we don't have any more to migrate or if we've hit - // our batch size and should do a write. - if (!valid() || batch.GetDataSize() == (32 * 1024 * 1024)) { // 32 MB - // Write the batch then instantiate a new one. - status = db_->Write(DefaultWriteOptions(), &batch); + status = batch.Delete(column_family_handle, iterator->key()); if (!status.ok()) { return make_unexpected( fmt::format( - "Failed to rename idempotent mutation key(s): {}", + "Failed to rename idempotent mutation key: {}", status.ToString())); } - batch = rocksdb::WriteBatch(); + entries += 1; - REBOOT_DATABASE_LOG(1) - << "Migrated " << entries << " idempotent mutation(s)"; - } - } + iterator->Next(); - CHECK_EQ(batch.Count(), 0) << "Should have a new batch"; + // Check if we don't have any more to migrate or if we've hit + // our batch size and should do a write. + if (!valid() || batch.GetDataSize() == (32 * 1024 * 1024)) { // 32 MB + // Write the batch then instantiate a new one. + status = db_->Write(DefaultWriteOptions(), &batch); - entries = 0; + if (!status.ok()) { + return make_unexpected( + fmt::format( + "Failed to rename idempotent mutation key(s): {}", + status.ToString())); + } - REBOOT_DATABASE_LOG(1) << "Migrating tasks for '" - << column_family_handle->GetName() << "'"; + batch = rocksdb::WriteBatch(); - // TODO: investigate using "prefix seek" for better performance, see: - // https://github.com/facebook/rocksdb/wiki/Prefix-Seek - iterator->Seek(rocksdb::Slice(TASK_KEY_PREFIX_V3 ":")); + REBOOT_DATABASE_LOG(1) + << "Migrated " << entries << " idempotent mutation(s)"; + } + } - valid = [&]() { - return iterator->Valid() - && iterator->key().ToStringView().find(TASK_KEY_PREFIX_V3 ":") == 0; - }; + CHECK_EQ(batch.Count(), 0) << "Should have a new batch"; - while (valid()) { - Task task; - CHECK(task.ParseFromArray( - iterator->value().data(), - iterator->value().size())); + entries = 0; - rocksdb::Status status = batch.Put( - column_family_handle, - rocksdb::Slice(MakeTaskKey(task.status(), task.task_id())), - iterator->value()); + REBOOT_DATABASE_LOG(1) + << "Migrating tasks for '" << column_family_handle->GetName() << "'"; - if (!status.ok()) { - return make_unexpected( - fmt::format("Failed to rename task key: {}", status.ToString())); - } + // TODO: investigate using "prefix seek" for better performance, see: + // https://github.com/facebook/rocksdb/wiki/Prefix-Seek + iterator->Seek(rocksdb::Slice(TASK_KEY_PREFIX_V3 ":")); - status = batch.Delete(column_family_handle, iterator->key()); + valid = [&]() { + return iterator->Valid() + && iterator->key().ToStringView().find(TASK_KEY_PREFIX_V3 ":") == 0; + }; - if (!status.ok()) { - return make_unexpected( - fmt::format("Failed to rename task key: {}", status.ToString())); - } + while (valid()) { + Task task; + CHECK(task.ParseFromArray( + iterator->value().data(), + iterator->value().size())); - entries += 1; + rocksdb::Status status = batch.Put( + column_family_handle, + rocksdb::Slice(MakeTaskKey(task.status(), task.task_id())), + iterator->value()); - iterator->Next(); + if (!status.ok()) { + return make_unexpected( + fmt::format("Failed to rename task key: {}", status.ToString())); + } - if (!valid() || batch.GetDataSize() == (32 * 1024 * 1024)) { // 32 MB - // Write the batch then instantiate a new one. - status = db_->Write(DefaultWriteOptions(), &batch); + status = batch.Delete(column_family_handle, iterator->key()); if (!status.ok()) { return make_unexpected( - fmt::format( - "Failed to rename task key(s): {}", - status.ToString())); + fmt::format("Failed to rename task key: {}", status.ToString())); } - batch = rocksdb::WriteBatch(); + entries += 1; + + iterator->Next(); + + if (!valid() || batch.GetDataSize() == (32 * 1024 * 1024)) { // 32 MB + // Write the batch then instantiate a new one. + status = db_->Write(DefaultWriteOptions(), &batch); + + if (!status.ok()) { + return make_unexpected( + fmt::format( + "Failed to rename task key(s): {}", + status.ToString())); + } - REBOOT_DATABASE_LOG(1) << "Migrated " << entries << " task(s)"; + batch = rocksdb::WriteBatch(); + + REBOOT_DATABASE_LOG(1) << "Migrated " << entries << " task(s)"; + } } } } @@ -3300,6 +3629,11 @@ std::string DatabaseService::GetShardForHash( // Helper function to determine which shard owns a given state_ref. std::string DatabaseService::GetShardForStateRef(std::string_view state_ref) { + // `Cache` is not thread-safe by default. `_Export` calls this + // without holding top level `mutex_`, so we use a separate lock to + // protect the cache. + std::lock_guard cache_lock(shard_cache_mutex_); + // Check if we have this cached. // // TODO: improve stout to support C++20 "heterogeneous lookup" to @@ -3342,6 +3676,11 @@ grpc::Status DatabaseService::Recover( grpc::ServerWriter* responses) { std::unique_lock lock(mutex_); + if (test_only_hook_for_long_running_rpc_) { + test_only_hook_for_long_running_rpc_( + TestOnlyLongRunningRPCHookSite::RECOVER_RIGHT_AFTER_MUTEX_ACQUIRE); + } + REBOOT_DATABASE_LOG(1) << "Recover { " << request->ShortDebugString() << " }"; // Validate that shard_ids are provided. @@ -3368,7 +3707,10 @@ grpc::Status DatabaseService::Recover( REBOOT_DATABASE_LOG(1) << "Recovering tasks"; - RecoverTasks(*responses, shard_ids); + if (grpc::Status status = RecoverTasks(*context, *responses, shard_ids); + !status.ok()) { + return status; + } // NOTE: newer versions of Reboot recover idempotent mutations on // demand via `RecoverIdempotentMutations()`, but for backwards @@ -3376,16 +3718,20 @@ grpc::Status DatabaseService::Recover( if (!request->skip_idempotent_mutations()) { REBOOT_DATABASE_LOG(1) << "Recovering idempotent mutations"; - RecoverShardsIdempotentMutations(*responses, shard_ids); + if (grpc::Status status = + RecoverShardsIdempotentMutations(*context, *responses, shard_ids); + !status.ok()) { + return status; + } } REBOOT_DATABASE_LOG(1) << "Recovering transactions"; - expected recover_transactions = - RecoverTransactions(*responses, shard_ids); + expected recover_transactions = + RecoverTransactions(*context, *responses, shard_ids); if (!recover_transactions.has_value()) { - return grpc::Status(grpc::UNKNOWN, recover_transactions.error()); + return recover_transactions.error(); } return grpc::Status::OK; @@ -3399,6 +3745,12 @@ grpc::Status DatabaseService::RecoverIdempotentMutations( grpc::ServerWriter* responses) { std::unique_lock lock(mutex_); + if (test_only_hook_for_long_running_rpc_) { + test_only_hook_for_long_running_rpc_( + TestOnlyLongRunningRPCHookSite:: + RECOVER_IDEMPOTENT_MUTATIONS_RIGHT_AFTER_MUTEX_ACQUIRE); + } + REBOOT_DATABASE_LOG(1) << "RecoverIdempotentMutations { " << request->ShortDebugString() << " }"; @@ -3416,18 +3768,14 @@ grpc::Status DatabaseService::RecoverIdempotentMutations( RecoverIdempotentMutationsResponse response; - // We don't know what is the best batch size for this, so we - // just use a number that is small enough to not cause - // performance issues, but large enough to not cause too many - // round trips. - // TODO: This should be configurable and pick the better value for default. - static size_t RECOVER_IDEMPOTENT_MUTATIONS_BATCH_SIZE = 256; - - size_t batch_size = 0; + size_t estimated_batch_bytes = 0; - // Helper for recovering idempotency keys given a prefix. + // Helper for recovering idempotency keys given a prefix. Returns + // a non-OK status if the client disconnected; the caller should + // exit early. auto recover = [&](const std::string& start_prefix, - std::optional end_prefix = std::nullopt) { + std::optional end_prefix = + std::nullopt) -> grpc::Status { if (!end_prefix.has_value()) { end_prefix = start_prefix; } @@ -3444,16 +3792,23 @@ grpc::Status DatabaseService::RecoverIdempotentMutations( iterator->value().data(), iterator->value().size())); + size_t estimated_idempotent_mutation_bytes = + EstimateIdempotentMutationSize(idempotent_mutation); *response.add_idempotent_mutations() = std::move(idempotent_mutation); - MaybeWriteAndClearResponse( - *responses, - response, - batch_size, - RECOVER_IDEMPOTENT_MUTATIONS_BATCH_SIZE); + if (grpc::Status status = MaybeWriteAndClearResponse( + *responses, + response, + estimated_batch_bytes, + estimated_idempotent_mutation_bytes); + !status.ok()) { + return status; + } iterator->Next(); } + + return grpc::Status::OK; }; auto timestamp = []() { @@ -3497,9 +3852,12 @@ grpc::Status DatabaseService::RecoverIdempotentMutations( idempotent_mutation_key_prefix.error()); } - recover(*idempotent_mutation_key_prefix); + if (grpc::Status status = recover(*idempotent_mutation_key_prefix); + !status.ok()) { + return status; + } - CHECK_LE(batch_size, 1) + CHECK_LE(response.idempotent_mutations_size(), 1) << "Only expecting at most a single idempotency key"; } else if (workflow_id.has_value() && workflow_iteration.has_value()) { CHECK_EQ(workflow_id->size(), 16) @@ -3509,64 +3867,83 @@ grpc::Status DatabaseService::RecoverIdempotentMutations( // mutations are stored at the iteration scope because only // `.per_iteration()` sets the `workflow_iteration` header, // and it uses deterministic UUIDv4 keys. - recover( - fmt::format( - WORKFLOW_ITERATION_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}:{}", - request->state_ref(), - *workflow_id, - *workflow_iteration)); + if (grpc::Status status = recover( + fmt::format( + WORKFLOW_ITERATION_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}:{}", + request->state_ref(), + *workflow_id, + *workflow_iteration)); + !status.ok()) { + return status; + } } else if (workflow_id.has_value()) { CHECK_EQ(workflow_id->size(), 16) << "Expecting workflow id to be the raw 16-byte format"; // Recover this workflow's non-expiring mutations. - recover( - fmt::format( - WORKFLOW_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", - request->state_ref(), - *workflow_id)); + if (grpc::Status status = recover( + fmt::format( + WORKFLOW_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", + request->state_ref(), + *workflow_id)); + !status.ok()) { + return status; + } // Recover this workflow's expiring idempotent mutations that have // an expiration timestamp _after_ the current time (in // milliseconds since Unix epoch). - recover( - fmt::format( - WORKFLOW_EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}:{}", - request->state_ref(), - *workflow_id, - timestamp()), - // Don't recover past this workflow's expiring idempotent - // mutations, which may have timestamps that are larger than - // `timestamp()`. - fmt::format( - WORKFLOW_EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", - request->state_ref(), - *workflow_id)); + if (grpc::Status status = recover( + fmt::format( + WORKFLOW_EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}:{}", + request->state_ref(), + *workflow_id, + timestamp()), + // Don't recover past this workflow's expiring idempotent + // mutations, which may have timestamps that are larger + // than `timestamp()`. + fmt::format( + WORKFLOW_EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", + request->state_ref(), + *workflow_id)); + !status.ok()) { + return status; + } } else { // Recover non-expiring idempotent mutations. - recover( - fmt::format( - IDEMPOTENT_MUTATION_KEY_PREFIX ":{}", - request->state_ref())); + if (grpc::Status status = recover( + fmt::format( + IDEMPOTENT_MUTATION_KEY_PREFIX ":{}", + request->state_ref())); + !status.ok()) { + return status; + } // Recover expiring idempotent mutations that have an expiration // timestamp _after_ the current time (in milliseconds since Unix // epoch). - recover( - fmt::format( - EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", - request->state_ref(), - timestamp()), - // Don't recover past this state ref's expiring idempotent - // mutations, which may have timestamps that are larger than - // `timestamp()`. - fmt::format( - EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}", - request->state_ref())); + if (grpc::Status status = recover( + fmt::format( + EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}:{}", + request->state_ref(), + timestamp()), + // Don't recover past this state ref's expiring idempotent + // mutations, which may have timestamps that are larger + // than `timestamp()`. + fmt::format( + EXPIRING_IDEMPOTENT_MUTATION_KEY_PREFIX ":{}", + request->state_ref())); + !status.ok()) { + return status; + } } // Flush any remaining idempotent mutations. - WriteAndClearResponse(*responses, response, batch_size); + if (grpc::Status status = + WriteAndClearResponse(*responses, response, estimated_batch_bytes); + !status.ok()) { + return status; + } return grpc::Status::OK; } @@ -3579,8 +3956,8 @@ expected> DatabaseServer::Instantiate( std::string address) { grpc::ServerBuilder builder; - builder.SetMaxReceiveMessageSize(kMaxSidecarGrpcMessageSize.bytes()); - builder.SetMaxSendMessageSize(kMaxSidecarGrpcMessageSize.bytes()); + builder.SetMaxReceiveMessageSize(kMaxDatabaseMessageTransportBytes); + builder.SetMaxSendMessageSize(kMaxDatabaseMessageTransportBytes); std::optional port; @@ -3629,6 +4006,15 @@ void TestOnly_EnableLegacyCoordinatorPrepared(grpc::Service* service) { } } +void SetTestOnlyHookForLongRunningRPC( + grpc::Service* service, + std::function hook) { + auto* db_service = dynamic_cast(service); + if (db_service) { + db_service->SetTestOnlyHookForLongRunningRPC(std::move(hook)); + } +} + //////////////////////////////////////////////////////////////////////// } // namespace rbt::server diff --git a/reboot/server/database.h b/reboot/server/database.h index 7b136c29..2b8535a4 100644 --- a/reboot/server/database.h +++ b/reboot/server/database.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "glog/logging.h" @@ -119,6 +120,25 @@ class DatabaseServer final { // This should only be used in tests. void TestOnly_EnableLegacyCoordinatorPrepared(grpc::Service* service); +// Identifies the exact call site where the test-only hook fires. +enum class TestOnlyLongRunningRPCHookSite { + // `_Export`: immediately after `NewIterator()` captures the implicit + // snapshot, before the iteration loop begins, so that we can check + // that the new data came after the snapshot is taken won't be part + // of the export. + EXPORT_RIGHT_AFTER_IMPLICIT_SNAPSHOT, + // `Recover`: immediately after `mutex_` is acquired, so that we know + // the server has started processing the RPC. + RECOVER_RIGHT_AFTER_MUTEX_ACQUIRE, + // `RecoverIdempotentMutations`: immediately after `mutex_` is + // acquired, so that we know the server has started processing the RPC. + RECOVER_IDEMPOTENT_MUTATIONS_RIGHT_AFTER_MUTEX_ACQUIRE, +}; + +void SetTestOnlyHookForLongRunningRPC( + grpc::Service* service, + std::function hook); + //////////////////////////////////////////////////////////////////////// } // namespace rbt::server diff --git a/reboot/server/database.py b/reboot/server/database.py index 899cc0f1..5efb0b31 100644 --- a/reboot/server/database.py +++ b/reboot/server/database.py @@ -567,16 +567,15 @@ async def recover_idempotent_mutations( async def export( self, state_type: StateTypeName, shard_ids: list[str] ) -> AsyncIterator[database_pb2.ExportItem]: - # TODO: Should be streaming. stub = await self._get_database_stub() - response = await stub.Export( + async for batch in stub.ExportStreamed( database_pb2.ExportRequest( state_type=state_type, shard_ids=shard_ids, ), - ) - for item in response.items: - yield item + ): + for item in batch.items: + yield item async def get_application_metadata( self, diff --git a/reboot/server/service_descriptor_validator.py b/reboot/server/service_descriptor_validator.py index 3a466981..b8307b11 100644 --- a/reboot/server/service_descriptor_validator.py +++ b/reboot/server/service_descriptor_validator.py @@ -16,6 +16,7 @@ from reboot.aio.exceptions import InputError from reboot.api import snake_to_camel, to_snake_case from reboot.options import ( + get_field_options, get_file_options, get_method_options, is_reboot_state, @@ -307,7 +308,7 @@ def legal_diff_mcp_change( ) -> bool: """ Changing MCP options is always allowed. - + MCP clients are short-lived; they reconnect frequently and will discover any changes to the MCP schema as soon as they do. They are therefore very robust to changes in the MCP schema and don't need to @@ -888,6 +889,49 @@ def _compare_messages( processed_field_messages_names, ) + if schema_type != SchemaType.PROTO: + for field_number, updated_field in ( + updated_message.fields_by_number.items() + ): + field_label = _user_field_name(updated_field.name, schema_type) + schema_ref = _user_visible_schema_ref(updated_message, schema_type) + if field_number in original_message.fields_by_number: + # Check if the `required` option changed on an existing + # field. + original_field = ( + original_message.fields_by_number[field_number] + ) + original_required = ( + get_field_options(original_field).required + ) + updated_required = (get_field_options(updated_field).required) + if original_required != updated_required: + if updated_required: + error_cause = 'became required' + else: + error_cause = 'is not required anymore' + exceptions.append( + f'Field `{field_label}` in {kind_label} ' + f'{schema_ref} {error_cause}. This is a ' + 'backwards-incompatible change. To continue, ' + 'revert the change or use `expunge` to clear ' + 'all existing state data.' + ) + else: + # Error if a new field is `required`. + # NOTE: Removing both required and non-required fields + # is treated as a backwards-incompatible change, + # and handled above. + if get_field_options(updated_field).required: + exceptions.append( + f'Field `{field_label}` was added to ' + f'{kind_label} {schema_ref} as a required ' + 'field. Adding required fields is a ' + 'backwards-incompatible change. To continue, ' + 'add the field with a default value or use ' + '`expunge` to clear all existing state data.' + ) + return exceptions diff --git a/reboot/settings.h b/reboot/settings.h index fd90c4c6..bcefe48b 100644 --- a/reboot/settings.h +++ b/reboot/settings.h @@ -4,18 +4,18 @@ #pragma once -#include "stout/bytes.h" - //////////////////////////////////////////////////////////////////////// namespace rbt { //////////////////////////////////////////////////////////////////////// -// gRPC max message size to transmit large actor state data. -// TODO: We have increased this as a short-term workaround for #3411 and -// #3329. -constexpr Bytes kMaxSidecarGrpcMessageSize = Megabytes(1024); +// gRPC max message size to transmit large actor state data, 100 MB. +constexpr size_t kMaxDatabaseMessageTransportBytes = 1024 * 1024 * 100; + +// We will flush the batch when the total "estimated" message size +// exceeds this threshold. +constexpr size_t kBatchFlushBytes = kMaxDatabaseMessageTransportBytes / 2; //////////////////////////////////////////////////////////////////////// diff --git a/reboot/settings.py b/reboot/settings.py index 79e1011e..36dc8ad0 100644 --- a/reboot/settings.py +++ b/reboot/settings.py @@ -5,9 +5,12 @@ # gRPC max message size to transmit large state data. MAX_DATABASE_GRPC_MESSAGE_LENGTH_BYTES = 100 * 1024 * 1024 -# gRPC max response size (our limit; gRPC doesn't specify a limit -# normally). See: https://github.com/reboot-dev/mono/issues/3944 -MAX_GRPC_RESPONSE_SIZE_BYTES = 4 * 1024 * 1024 +# Envoy per-connection buffer limit for both listeners and clusters. +# Must be at least as large as `MAX_DATABASE_GRPC_MESSAGE_LENGTH_BYTES` +# so that large chunks of data (probably for state) transfers will not +# degrade performance by triggering backpressure too early. +# See: https://github.com/reboot-dev/mono/issues/3944. +ENVOY_PER_CONNECTION_BUFFER_LIMIT_BYTES = 100 * 1024 * 1024 # grpc.keepalive_time_ms: The period (in milliseconds) after which a # keepalive ping is sent on the transport. @@ -132,6 +135,13 @@ ENVVAR_REBOOT_LOCAL_ENVOY = 'REBOOT_LOCAL_ENVOY' ENVVAR_REBOOT_LOCAL_ENVOY_PORT = 'REBOOT_LOCAL_ENVOY_PORT' +# Shared secret used by the MCP OAuth server to sign JWTs +# (HS256). Must be the same across all server processes. For +# `rbt dev` this is set automatically to the application name; +# for production the operator must provide a strong random +# secret. +ENVVAR_REBOOT_OAUTH_SIGNING_SECRET = 'REBOOT_OAUTH_SIGNING_SECRET' + # The level of tracing to use. This is a Reboot-internal environment # variable; it is not expected to be set by developers using Reboot. ENVVAR_REBOOT_TRACE_LEVEL = 'REBOOT_TRACE_LEVEL' @@ -267,10 +277,10 @@ REBOOT_DISCORD_URL = 'https://discord.gg/cRbdcS94Nr' REBOOT_GITHUB_ISSUES_URL = 'https://github.com/reboot-dev/reboot/issues' -# State type name that triggers auto-construction for every MCP session. -AUTO_CONSTRUCT_STATE_TYPE = "Session" +# State type name that triggers auto-construction per +# authenticated user. +AUTO_CONSTRUCT_STATE_TYPE = "User" -# Name of the method injected for auto-construction on the -# above state type. +# Constructor method name for auto-constructed state types. AUTO_CONSTRUCT_METHOD = "create" # As used in Python. AUTO_CONSTRUCT_PROTO_METHOD = "Create" # As used in protobuf. diff --git a/reboot/std/package.json b/reboot/std/package.json index 9e84117e..ead32e2e 100644 --- a/reboot/std/package.json +++ b/reboot/std/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-std", - "version": "0.45.2", + "version": "0.46.0", "description": "Reboot standard library.", "main": "index.js", "type": "module", @@ -10,8 +10,8 @@ }, "author": "reboot-dev", "dependencies": { - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot": "0.46.0", "@scarf/scarf": "1.4.0" }, "license": "Apache-2.0", diff --git a/reboot/std/react/package.json b/reboot/std/react/package.json index e60b50d3..a1bb38df 100644 --- a/reboot/std/react/package.json +++ b/reboot/std/react/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-std-react", - "version": "0.45.2", + "version": "0.46.0", "description": "Reboot standard library for React.", "main": "index.js", "type": "module", @@ -10,10 +10,10 @@ }, "author": "reboot-dev", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-react": "0.45.2", - "@reboot-dev/reboot-std-api": "0.45.2", - "@reboot-dev/reboot-web": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-react": "0.46.0", + "@reboot-dev/reboot-std-api": "0.46.0", + "@reboot-dev/reboot-web": "0.46.0", "@scarf/scarf": "1.4.0" }, "license": "Apache-2.0", diff --git a/reboot/templates/reboot.py.j2 b/reboot/templates/reboot.py.j2 index 2ad243c4..8dfd31cc 100644 --- a/reboot/templates/reboot.py.j2 +++ b/reboot/templates/reboot.py.j2 @@ -11,7 +11,9 @@ reported in user code. #} {# Constants we use throughout template. #} {% set constants = namespace(has_read=false, has_write=false) %} -{% set AUTO_CONSTRUCT_STATE_TYPE = "Session" %} +{# Auto-construct enum values from proto `AutoConstruct`. #} +{% set AUTO_CONSTRUCT_UNSPECIFIED = 0 %} +{% set AUTO_CONSTRUCT_PER_USER_ID = 1 %} {% set AUTO_CONSTRUCT_PROTO_METHOD = "Create" %} {% set AUTO_CONSTRUCT_METHOD = "create" %} @@ -461,7 +463,8 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl # realize where they are missing authorization). if authorizer_or_rule is None: return IMPORT_reboot_aio_auth_authorizers.DefaultAuthorizer( - '{{ state.proto.name }}' + '{{ state.proto.name }}', + is_user_type={{ state.proto.auto_construct == AUTO_CONSTRUCT_PER_USER_ID }}, ) if isinstance(authorizer_or_rule, IMPORT_reboot_aio_auth_authorizers.AuthorizerRule): @@ -882,61 +885,55 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl {% if method.options.proto.kind == 'workflow' %} @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_{{ method.proto.name }}_reactively( + async def run_{{ method.proto.name }}_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_{{ method.proto.name }}( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_{{ method.proto.name }}( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method {{ state.proto.name }}.{{ method.proto.name }} ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method {{ state.proto.name }}.{{ method.proto.name }} ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass {% endif %} - return await run_{{ method.proto.name }}{% if method.options.proto.kind == 'workflow' %}_reactively{% endif %}( + return await run_{{ method.proto.name }}{% if method.options.proto.kind == 'workflow' %}_workflow{% endif %}( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -949,6 +946,14 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl method='{{ method.proto.name }}', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) {% endif %} @@ -1075,9 +1080,6 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl IMPORT_reboot_aio_types.assert_type(response, [{{ method.output_type }}]) yield response {% endif %} - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -1341,6 +1343,25 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl return effects.response # type: ignore[return-value] {% elif method.options.proto.kind == 'transaction' %} assert transaction is not None + # Re-check for an idempotent mutation now that we hold the + # transaction semaphore. This is a fix for a potential race: + # https://github.com/reboot-dev/mono/issues/5361 + # + # If a previous call's commit ran (updating the bloom filter + # and, for constructors, making the state visible in memory + # via `self._states`) after our initial idempotent mutation + # check but before we acquired the semaphore, then without + # this re-check, constructors could raise + # `StateAlreadyConstructed` and non-constructors could + # re-execute the mutation, potentially corrupting state. + idempotent_mutation = await self._state_manager.check_for_idempotent_mutation( + context + ) + if idempotent_mutation is not None: + await self._state_manager.transaction_participant_abort(transaction) + response = {{ method.output_type }}() + response.ParseFromString(idempotent_mutation.response) + return response async with self._state_manager.transaction( context, self._servicer.__state_type__, @@ -1769,10 +1790,16 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl method=method, context_type=IMPORT_reboot_aio_contexts.ReaderContext, ) as context: - return await self._token_verifier.verify_token( + result = await self._token_verifier.verify_token( context=context, token=headers.bearer_token, ) + if isinstance(result, IMPORT_rbt_v1alpha1.errors_pb2.Unauthenticated): + raise IMPORT_reboot_aio_aborted.SystemAborted( + result, + message=result.message or None, + ) + return result return None @@ -2385,12 +2412,24 @@ class {{ state.proto.name }}Authorizer( # NOTE: using `_` prefix for `_default` so as not to collide # with any method names since a prefixed `_` is forbidden by # our protoc plugins. +{% if state.proto.auto_construct == AUTO_CONSTRUCT_PER_USER_ID %} + _default: IMPORT_reboot_aio_auth_authorizers.AuthorizerRule[ + {{ state.pb2_name }}.{{ state.proto.name }}, + IMPORT_google_protobuf_message.Message, + ] = IMPORT_reboot_aio_auth_authorizers.allow_if( + any=[ + IMPORT_reboot_aio_auth_authorizers.state_id_is_user_id, + IMPORT_reboot_aio_auth_authorizers.is_app_internal, + ], + ), +{% else %} _default: IMPORT_reboot_aio_auth_authorizers.AuthorizerRule[ {{ state.pb2_name }}.{{ state.proto.name }}, IMPORT_google_protobuf_message.Message, ] = IMPORT_reboot_aio_auth_authorizers.allow_if( all=[IMPORT_reboot_aio_auth_authorizers.is_app_internal], ), +{% endif %} ): {% for service in state.services %} {% for method in service.methods %} @@ -2550,7 +2589,7 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): def token_verifier(self) -> IMPORT_typing.Optional[IMPORT_reboot_aio_auth_token_verifiers.TokenVerifier]: return None -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} _is_auto_construct = True {% else %} _is_auto_construct = False @@ -2566,19 +2605,36 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): {% endfor %} {% if state_has_mcp.value or state.proto.uis | length > 0 %} +{% set mcp_name_prefix_for_names = "" if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED else (state.proto.name | to_snake) + "_" %} + @staticmethod + def _mcp_tool_names() -> list[str]: + """Return the MCP tool names this servicer registers.""" + return [ +{% for service in state.services %} +{% for method in service.methods %} +{% if method.options.proto.mcp and method.options.proto.mcp.tool %} + "{{ method.options.proto.mcp.name if method.options.proto.mcp.name else mcp_name_prefix_for_names + (method.proto.name | to_snake) }}", +{% endif %} +{% endfor %} +{% endfor %} +{% for ui in state.proto.uis %} + "{{ mcp_name_prefix_for_names }}{{ ui.name }}", +{% endfor %} + ] + @staticmethod def _add_mcp( mcp: IMPORT_mcp_server_fastmcp.FastMCP, - auto_construct_state_type_full_name: IMPORT_typing.Optional[str] = None, + auto_construct_state_type_full_names: list[IMPORT_reboot_aio_types.StateTypeName], ) -> None: """Register MCP tools and resources for {{ state.proto.name }}.""" -{% set mcp_name_prefix = "" if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE else (state.proto.name | to_snake) + "_" %} +{% set mcp_name_prefix = "" if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED else (state.proto.name | to_snake) + "_" %} {% for service in state.services %} {% for method in service.methods %} {% if method.options.proto.mcp and method.options.proto.mcp.tool %} {% set tool_name = method.options.proto.mcp.name if method.options.proto.mcp.name else mcp_name_prefix + (method.proto.name | to_snake) %} {% set tool_title = method.options.proto.mcp.title if method.options.proto.mcp.title else method.proto.name %} -{% set tool_description_suffix = "" if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE else " on " + state.proto.name %} +{% set tool_description_suffix = "" if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED else " on " + state.proto.name %} {% set tool_description = method.options.proto.mcp.description if method.options.proto.mcp.description else "Invoke " + method.proto.name + tool_description_suffix + "." %} {% set request_type = state.proto.name + "." + method.proto.name + "Request" %} @@ -2590,7 +2646,7 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): ) async def {{ tool_name }}_tool( ctx: IMPORT_mcp_server_fastmcp.Context, -{% if state.proto.name != AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct == AUTO_CONSTRUCT_UNSPECIFIED %} {{ mcp_name_prefix }}id: str, {% endif %} {% if method.input_type_fields %} @@ -2599,8 +2655,10 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): {% endif %} ): reboot_context = IMPORT_reboot_mcp_context.get_reboot_context(ctx) -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} - {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_session_id(ctx) +{% if state.proto.auto_construct == AUTO_CONSTRUCT_PER_USER_ID %} + {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_user_id(ctx) +{% endif %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} # State is auto-constructed on session init; # see `_auto_construct`. {% endif %} @@ -2632,7 +2690,7 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): # Resource for '{{ method.proto.full_name }}'. {% if method.input_type_fields %} @mcp.resource( -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} "{{ state_scheme }}:///{{ method_path }}{% for name in method.input_type_fields.keys() %}/{{'{'}}{{ name }}{{'}'}}{% endfor %}", {% else %} "{{ state_scheme }}://{{ '{' }}{{ mcp_name_prefix }}id{{ '}' }}/{{ method_path }}{% for name in method.input_type_fields.keys() %}/{{'{'}}{{ name }}{{'}'}}{% endfor %}", @@ -2644,7 +2702,7 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): ) async def {{ resource_name }}_resource( ctx: IMPORT_mcp_server_fastmcp.Context, -{% if state.proto.name != AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct == AUTO_CONSTRUCT_UNSPECIFIED %} {{ mcp_name_prefix }}id: str, {% endif %} *, @@ -2653,9 +2711,12 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): {% endfor %} ): reboot_context = IMPORT_reboot_mcp_context.get_reboot_context(ctx) -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} - # State is auto-constructed on session init; see `_auto_construct`. - {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_session_id(ctx) +{% if state.proto.auto_construct == AUTO_CONSTRUCT_PER_USER_ID %} + {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_user_id(ctx) +{% endif %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} + # State is auto-constructed on session init; + # see `_auto_construct`. {% endif %} # Construct request from URI params, coercing types # as needed. @@ -2681,7 +2742,7 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): {% endif %} {% else %} @mcp.resource( -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} "{{ state_scheme }}://", {% else %} "{{ state_scheme }}://{{ '{' }}{{ mcp_name_prefix }}id{{ '}' }}", @@ -2691,15 +2752,18 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): description="{{ resource_description }}", mime_type="application/json", ) -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} async def {{ resource_name }}_resource(ctx: IMPORT_mcp_server_fastmcp.Context): {% else %} async def {{ resource_name }}_resource(ctx: IMPORT_mcp_server_fastmcp.Context, {{ mcp_name_prefix }}id: str): {% endif %} reboot_context = IMPORT_reboot_mcp_context.get_reboot_context(ctx) -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} - # State is auto-constructed on session init; see `_auto_construct`. - {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_session_id(ctx) +{% if state.proto.auto_construct == AUTO_CONSTRUCT_PER_USER_ID %} + {{ mcp_name_prefix }}id = IMPORT_reboot_mcp_context.get_mcp_user_id(ctx) +{% endif %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} + # State is auto-constructed on session init; + # see `_auto_construct`. {% endif %} {% if method.options.proto.constructor %} _, response = await {{ state.proto.name }}.{{ method.proto.name }}(reboot_context, {{ mcp_name_prefix }}id) @@ -2715,35 +2779,40 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): {% if state.proto.uis | length > 0 %} # UI tools — allow AI to trigger loading of UIs. -{% set ui_suffix = "" if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE else " for " + state.proto.name %} +{% set ui_suffix = "" if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED else " for " + state.proto.name %} {% for ui in state.proto.uis %} @mcp.tool( name="{{ mcp_name_prefix }}{{ ui.name }}", title="Show {{ ui.title }}", description="{{ ui.description if ui.description else 'Opens the ' + ui.name + ' interface' + ui_suffix + '.' }}", - meta={"ui/resourceUri": "ui://{{ state.proto.name | to_snake }}/{{ ui.name }}"}, + meta={"ui": {"resourceUri": "ui://{{ state.proto.name | to_snake }}/{{ ui.name }}"}}, ) -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} async def {{ mcp_name_prefix }}{{ ui.name }}_tool( ctx: IMPORT_mcp_server_fastmcp.Context, ) -> dict: - return { - "ids": { - "{{ state.proto.full_name }}": - IMPORT_reboot_mcp_context.get_mcp_session_id(ctx), - }, + _ids: dict[IMPORT_reboot_aio_types.StateTypeName, str | None] = { + IMPORT_reboot_aio_types.StateTypeName("{{ state.proto.full_name }}"): + IMPORT_reboot_mcp_context.get_mcp_user_id(ctx), } + # Also include all other auto-construct IDs. + for _auto_construct_full_name in auto_construct_state_type_full_names: + if _auto_construct_full_name not in _ids: + _ids[_auto_construct_full_name] = ( + IMPORT_reboot_mcp_context.get_mcp_user_id(ctx)) + return {"ids": _ids} {% else %} async def {{ mcp_name_prefix }}{{ ui.name }}_tool( ctx: IMPORT_mcp_server_fastmcp.Context, {{ mcp_name_prefix }}id: str, ) -> dict: - _ids: dict[str, str | None] = { - "{{ state.proto.full_name }}": {{ mcp_name_prefix }}id, + _ids: dict[IMPORT_reboot_aio_types.StateTypeName, str | None] = { + IMPORT_reboot_aio_types.StateTypeName("{{ state.proto.full_name }}"): {{ mcp_name_prefix }}id, } - if auto_construct_state_type_full_name is not None: - _ids[auto_construct_state_type_full_name] = ( - IMPORT_reboot_mcp_context.get_mcp_session_id(ctx)) + # Include all auto-construct IDs. + for _auto_construct_full_name in auto_construct_state_type_full_names: + _ids[_auto_construct_full_name] = ( + IMPORT_reboot_mcp_context.get_mcp_user_id(ctx)) return {"ids": _ids} {% endif %} @@ -2814,14 +2883,27 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): pass # End of _add_mcp. {% endif %} -{% if state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} +{% if state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} @staticmethod async def _auto_construct( context: IMPORT_reboot_aio_external.ExternalContext, - session_id: str, + state_id: str, ) -> None: - """Construct an empty `{{ AUTO_CONSTRUCT_STATE_TYPE }}` state""" - await {{ state.proto.name }}.{{ AUTO_CONSTRUCT_METHOD }}(context, session_id) + """Auto-construct a `{{ state.proto.name }}` state.""" + # Some states have `_auto_construct` called many times, e.g. a + # per-user state may get auto-constructed at the start of every + # MCP session. We derive a deterministic idempotency key from the + # state ID so that repeated calls (even from different contexts) + # are a NOOP. We prefer this over catching the + # `StateAlreadyExists` error that would otherwise result, since + # that logs an `ERROR` to user-visible logs. + idempotency_key = IMPORT_uuid.uuid5( + IMPORT_uuid.NAMESPACE_URL, + f"urn:dev.reboot:auto-construct:{{ state.proto.name }}:{state_id}", + ) + await {{ state.proto.name }}.idempotently( + key=idempotency_key, + ).{{ AUTO_CONSTRUCT_METHOD }}(context, state_id) {% endif %} def ref( @@ -3392,10 +3474,10 @@ class {{ state.proto.name }}BaseServicer(IMPORT_reboot_aio_servicers.Servicer): yield # Necessary for type checking. {% endif %} {% elif method.options.proto.kind == 'writer' %} - {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} + {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} # Concrete no-op default for `{{ AUTO_CONSTRUCT_METHOD }}`. # Override in your Servicer to run custom initialization - # on new {{ AUTO_CONSTRUCT_STATE_TYPE }} sessions. + # on new {{ state.proto.name }} instances. async def _{{ method.proto.name }}( self, context: IMPORT_reboot_aio_contexts.WriterContext, @@ -3526,10 +3608,10 @@ class {{ state.proto.name }}SingletonServicer({{ state.proto.name }}BaseServicer yield # Necessary for type checking. {% endif %} {% elif method.options.proto.kind == 'writer' %} - {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} + {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} # Concrete no-op default for `{{ AUTO_CONSTRUCT_METHOD }}`. # Override in your Servicer to run custom initialization - # on new {{ AUTO_CONSTRUCT_STATE_TYPE }} sessions. + # on new {{ state.proto.name }} instances. async def {{ method.proto.name }}( self, context: IMPORT_reboot_aio_contexts.WriterContext, @@ -3860,10 +3942,10 @@ class {{ state.proto.name }}Servicer({{ state.proto.name }}BaseServicer): {% for service in state.services %} {% for method in service.methods %} # For '{{ method.proto.full_name }}'. - {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.name == AUTO_CONSTRUCT_STATE_TYPE %} + {% if method.proto.name == AUTO_CONSTRUCT_PROTO_METHOD and state.proto.auto_construct != AUTO_CONSTRUCT_UNSPECIFIED %} # Concrete no-op default for `{{ AUTO_CONSTRUCT_METHOD }}`. # Override in your Servicer to run custom initialization - # on new {{ AUTO_CONSTRUCT_STATE_TYPE }} sessions. + # on new {{ state.proto.name }} instances. async def {{ method.proto.name }}( self, context: IMPORT_reboot_aio_contexts.WriterContext, diff --git a/reboot/templates/reboot_react.ts.j2 b/reboot/templates/reboot_react.ts.j2 index 05d4914e..7fda8a9e 100644 --- a/reboot/templates/reboot_react.ts.j2 +++ b/reboot/templates/reboot_react.ts.j2 @@ -15,6 +15,7 @@ import * as reboot_api from "@reboot-dev/reboot-api"; import React, { useEffect, useMemo, + useRef, useState, } from "react"; import { v4 as uuidv4 } from "uuid"; @@ -33,6 +34,7 @@ import { v4 as uuidv4 } from "uuid"; import type { App as McpApp } from "@modelcontextprotocol/ext-apps/react"; import { useMcpApp, useMcpToolData } from "@reboot-dev/reboot-react/internal"; {% endif %} +import { useRefreshMCPBearerToken } from "@reboot-dev/reboot-react/internal"; {% if proto.messages_and_enums|length > 0 or states|length > 0 %} // NOTE NOTE NOTE // @@ -1408,6 +1410,7 @@ export const use{{ client.proto.state_name | to_camel }} = ( const url = rebootClient.url; const bearerToken = rebootClient.bearerToken; + const refreshMCPBearerToken = useRefreshMCPBearerToken(); const [instance, setInstance] = useState(() => { return {{ client.proto.state_name | to_camel }}Instance.use( @@ -1595,6 +1598,16 @@ export const use{{ client.proto.state_name | to_camel }} = ( }, setIsLoading, (status: reboot_api.Status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if ( + status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken + ) { + refreshMCPBearerToken(); + } + const aborted = {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted.fromStatus(status); console.warn( @@ -1639,15 +1652,30 @@ export const use{{ client.proto.state_name | to_camel }} = ( // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); + const suspensePromise = useMemo( () => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); - } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => {}); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => {}); + } + } + return suspensePromiseRef.current; }, - [reader, options.suspense] + [options.suspense, reader, response, aborted] ); if (options.suspense) { @@ -1728,10 +1756,107 @@ export const use{{ client.proto.state_name | to_camel }} = ( if (aborted) { return { aborted }; + } else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch( + new Request( + `${rebootClient.url}/__/reboot/rpc/${stateRef}/{{ proto.package_name }}.{{ service.proto.name }}/{{ method.proto.name }}`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + } + ), + options + ); + if (retryResponse.ok) { + return { + response: + {{ client.proto.state_name }}{{ method.proto.name }}ResponseFromProtobufShape(({{ method.output_type }}.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted( + new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + } + ) + }; + } catch (e: unknown) { + return { + aborted: new {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted( + new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + } + ) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted( + new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + } + ) + }; } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if ( + status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken + ) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set( + "Content-Type", "application/json", + ); + retryHeaders.append( + "Connection", "keep-alive", + ); + retryHeaders.append( + "Authorization", `Bearer ${newToken}`, + ); + try { + const retryResponse = + await reboot_web.guardedFetch( + new Request( + `${rebootClient.url}/__/reboot/rpc/${stateRef}/{{ proto.package_name }}.{{ service.proto.name }}/{{ method.proto.name }}`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + } + ), + options + ); + if (retryResponse.ok) { + return { + response: + {{ client.proto.state_name }}{{ method.proto.name }}ResponseFromProtobufShape(({{ method.output_type }}.fromJson(await retryResponse.json()))) + }; + } + } catch { + // Fall through to return the original + // aborted error. + } + } + } + const aborted = {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted.fromStatus(status); console.warn( diff --git a/reboot/versions.bzl b/reboot/versions.bzl index 4e061f3d..04d0d054 100644 --- a/reboot/versions.bzl +++ b/reboot/versions.bzl @@ -20,4 +20,4 @@ packages in multiple BUILD.bazel files. # # NOTE: if this variable name is ever changed, it must also be updated in # tests/reboot/versions_test.py and in bazel/release_scripts/update_versions.py. -REBOOT_VERSION = "0.45.2" +REBOOT_VERSION = "0.46.0" diff --git a/reboot/web/package.json b/reboot/web/package.json index 03322886..bbf93227 100644 --- a/reboot/web/package.json +++ b/reboot/web/package.json @@ -1,6 +1,6 @@ { "name": "@reboot-dev/reboot-web", - "version": "0.45.2", + "version": "0.46.0", "description": "npm package for Reboot Web", "main": "index.js", "type": "module", @@ -10,7 +10,7 @@ }, "author": "reboot-dev", "dependencies": { - "@reboot-dev/reboot-api": "0.45.2", + "@reboot-dev/reboot-api": "0.46.0", "@scarf/scarf": "1.4.0", "js-sha1": "0.7.0", "lru-cache-idb": "^0.5.2", diff --git a/tests/reboot/admin/BUILD.bazel b/tests/reboot/admin/BUILD.bazel index 8afa89f9..9a54a73c 100644 --- a/tests/reboot/admin/BUILD.bazel +++ b/tests/reboot/admin/BUILD.bazel @@ -22,3 +22,20 @@ py_test( "//reboot/ssl:localhost_py", ], ) + +py_test( + name = "export_import_large_data_tests_py", + srcs = [ + ":export_import_large_data_tests.py", + ], + main = "export_import_large_data_tests.py", + deps = [ + requirement("grpcio"), + "//reboot:settings_py", + "//reboot/admin:export_import_client_py", + "//reboot/aio:secrets_py", + "//reboot/aio:tests_py", + "//reboot/ssl:localhost_py", + "//tests/reboot:echo_servicers_py", + ], +) diff --git a/tests/reboot/admin/export_import_large_data_tests.py b/tests/reboot/admin/export_import_large_data_tests.py new file mode 100644 index 00000000..59f2a138 --- /dev/null +++ b/tests/reboot/admin/export_import_large_data_tests.py @@ -0,0 +1,127 @@ +import grpc +import tempfile +import unittest +from log.log import get_logger +from pathlib import Path +from rbt.v1alpha1.admin import export_import_pb2_grpc +from reboot.admin import export_import_client +from reboot.aio.applications import Application +from reboot.aio.external import ExternalContext +from reboot.aio.secrets import MockSecretSource, Secrets +from reboot.aio.tests import Reboot +from reboot.settings import ADMIN_SECRET_NAME +from reboot.ssl.localhost import LOCALHOST_CRT_DATA +from tests.reboot.echo_rbt import Echo +from tests.reboot.echo_servicers import MyEchoServicer +from typing import Optional + +logger = get_logger(__name__) + +TEST_ADMIN_SECRET = 'test-admin-secret' + +# Target total data volume that exceeds the 100 MB gRPC message limit. +# This verifies that streaming batching (flush at 50 MB) works. +NUMBER_OF_STATES = 150 +# Each state stores one ~1 MB message, so the total serialized state +# data is ~150 MB + ~150MB for each `IdempotentMutation` which will +# store the response message of the same ~1 MB size. +MESSAGE_SIZE = 1 * 1024 * 1024 + + +class LargeDataExportTestCase(unittest.IsolatedAsyncioTestCase): + """Tests that export/import round-trips work when total data exceeds + 100 MB. + + Before the streaming export change this would have produced a single + `ExportResponse` exceeding the gRPC message limit, causing + RESOURCE_EXHAUSTED. Streaming with byte-size batching solves this. + """ + + async def asyncSetUp(self) -> None: + Secrets.set_secret_source( + MockSecretSource({ADMIN_SECRET_NAME: TEST_ADMIN_SECRET.encode()}) + ) + self.rbt: Optional[Reboot] = None + + async def asyncTearDown(self) -> None: + if self.rbt is not None: + await self.rbt.stop() + self.rbt = None + + async def start( + self, + ) -> tuple[ExternalContext, export_import_pb2_grpc.ExportImportStub]: + assert self.rbt is None + self.rbt = Reboot() + await self.rbt.start() + await self.rbt.up( + Application(servicers=[MyEchoServicer]), + local_envoy=True, + # For SSL/TLS test coverage. + local_envoy_tls=True, + ) + context = self.rbt.create_external_context(name=self.id()) + channel = grpc.aio.secure_channel( + self.rbt.localhost_direct_endpoint(), + grpc.ssl_channel_credentials( + root_certificates=LOCALHOST_CRT_DATA, + ), + ) + stub = export_import_pb2_grpc.ExportImportStub(channel) + return context, stub + + async def stop(self) -> None: + assert self.rbt is not None + await self.rbt.stop() + self.rbt = None + + async def test_large_export(self) -> None: + context, stub = await self.start() + + # Create states whose total state exceeds 100 MB. + large_message = "x" * MESSAGE_SIZE + for i in range(NUMBER_OF_STATES): + echo = Echo.ref(f"large-{i}") + await echo.Reply(context, message=large_message) + + with tempfile.TemporaryDirectory() as directory: + directory_path = Path(directory) + + # Export must succeed despite total data > 100 MB. + await export_import_client.do_export( + stub, + directory_path, + admin_token=TEST_ADMIN_SECRET, + ) + + # Verify total exported data exceeds 300 MB. + total_bytes = sum( + p.stat().st_size for p in directory_path.iterdir() + ) + self.assertGreater(total_bytes, 300 * 1024 * 1024) + + # Bring up a fresh instance and verify it has no state. + await self.stop() + fresh_context, fresh_stub = await self.start() + + for i in range(NUMBER_OF_STATES): + with self.assertRaises(Exception) as exc: + await Echo.ref(f"large-{i}").Replay(fresh_context) + self.assertIn( + "StateNotConstructed", + str(exc.exception), + ) + + await export_import_client.do_import( + fresh_stub, + directory_path, + admin_token=TEST_ADMIN_SECRET, + ) + + for i in range(NUMBER_OF_STATES): + response = await Echo.ref(f"large-{i}").Replay(fresh_context) + self.assertEqual([large_message], response.messages) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/reboot/admin/export_import_tests.py b/tests/reboot/admin/export_import_tests.py index c8aa5200..c1b8e722 100644 --- a/tests/reboot/admin/export_import_tests.py +++ b/tests/reboot/admin/export_import_tests.py @@ -49,8 +49,8 @@ async def asyncTearDown(self) -> None: await self.rbt.stop() self.rbt = None - async def up( - self, *, name: str, servers: int + async def start( + self, *, servers: int ) -> tuple[ExternalContext, export_import_pb2_grpc.ExportImportStub]: assert self.rbt is None self.rbt = Reboot() @@ -72,7 +72,7 @@ async def up( stub = export_import_pb2_grpc.ExportImportStub(channel) return context, stub - async def down(self) -> None: + async def stop(self) -> None: assert self.rbt is not None await self.rbt.stop() self.rbt = None @@ -113,9 +113,7 @@ def _assert_contents( async def _do_test(self, *, servers: int) -> None: # Start an application with one name. - context, stub = await self.up( - name=f"first-attempt-with-{servers}", servers=servers - ) + context, stub = await self.start(servers=servers) # When there are no states, we expect that `export` creates empty files. with tempfile.TemporaryDirectory() as d: @@ -173,12 +171,10 @@ async def _do_test(self, *, servers: int) -> None: }, ) - # Take the application down, re-launch it with a different name, and - # confirm that it is empty. - await self.down() - context, stub = await self.up( - name=f"second-attempt-with-{servers}", servers=servers - ) + # Take the application down, re-launch it with, and confirm that + # it is empty. + await self.stop() + context, stub = await self.start(servers=servers) with self.assertRaises(Exception) as exc: await Echo.ref( list(messages_by_state_ref.keys())[0].id, diff --git a/tests/reboot/aio/applications_test.py b/tests/reboot/aio/applications_test.py index d5a489f0..cef8dcac 100644 --- a/tests/reboot/aio/applications_test.py +++ b/tests/reboot/aio/applications_test.py @@ -13,15 +13,15 @@ # Minimal `Servicer` stubs for testing `_mount_mcp` behavior. # Only the attributes accessed by `_mount_mcp` need real values. -class _StubAutoConstructA(Servicer): - __service_names__ = [ServiceName("test.v1.ServiceA")] - __state_type_name__ = StateTypeName("test.v1.StateA") +class _StubUserA(Servicer): + __service_names__ = [ServiceName("test.v1.ServiceC")] + __state_type_name__ = StateTypeName("test.v1.UserA") _is_auto_construct = True -class _StubAutoConstructB(Servicer): - __service_names__ = [ServiceName("test.v1.ServiceB")] - __state_type_name__ = StateTypeName("test.v1.StateB") +class _StubUserB(Servicer): + __service_names__ = [ServiceName("test.v1.ServiceD")] + __state_type_name__ = StateTypeName("test.v1.UserB") _is_auto_construct = True @@ -112,18 +112,42 @@ async def test_incorrect_arguments(self) -> None: str(e.exception), ) - async def test_multiple_auto_construct_types_raises(self) -> None: - """Tests that having multiple servicers with - `_is_auto_construct = True` raises a `ValueError`.""" + async def test_multiple_auto_construct_types_ok(self) -> None: + """ + Multiple auto-construct types of the same kind are allowed. + """ + # Should not raise. + Application(servicers=[_StubUserA, _StubUserB]) + + async def test_duplicate_mcp_tool_names_raises(self) -> None: + """ + Two servicers registering the same MCP tool is an error. + """ + + class _FooServicer(Servicer): + __service_names__ = [ServiceName("test.v1.Svc")] + __state_type_name__ = StateTypeName("test.v1.Foo") + _is_auto_construct = False + + @staticmethod + def _mcp_tool_names() -> list[str]: + return ["foo_bar"] + + class _FooV2Servicer(Servicer): + __service_names__ = [ServiceName("test.v2.Svc")] + __state_type_name__ = StateTypeName("test.v2.Foo") + _is_auto_construct = False + + @staticmethod + def _mcp_tool_names() -> list[str]: + return ["foo_bar"] + with self.assertRaises(ValueError) as e: - Application(servicers=[ - _StubAutoConstructA, - _StubAutoConstructB, - ]) + Application(servicers=[_FooServicer, _FooV2Servicer]) msg = str(e.exception) - self.assertIn("Multiple auto-construct state types", msg) - self.assertIn("test.v1.StateA", msg) - self.assertIn("test.v1.StateB", msg) + self.assertIn("Duplicate MCP tool name 'foo_bar'", msg) + self.assertIn("'_FooServicer'", msg) + self.assertIn("'_FooV2Servicer'", msg) if __name__ == "__main__": diff --git a/tests/reboot/cli/init/BUILD.bazel b/tests/reboot/cli/init/BUILD.bazel index f6ba95ac..b992b983 100644 --- a/tests/reboot/cli/init/BUILD.bazel +++ b/tests/reboot/cli/init/BUILD.bazel @@ -56,7 +56,7 @@ multi_env_sh_test( "$(location //reboot/cli/init/templates_for_bazel_test:test.py)", ], data = test_packages + [ - ":expected_output.txt", + ":expected_multi_env_output.txt", ":test_packages", "//reboot/cli/init/templates_for_bazel_test:test.py", ], @@ -91,7 +91,7 @@ sh_test( timeout = "long", srcs = ["//reboot/cli/init:nodejs_test.sh"], data = test_packages + [ - ":expected_output.txt", + ":expected_nodejs_output.txt", ":test_packages", ], env = nodejs_env, diff --git a/tests/reboot/cli/init/expected_output.txt b/tests/reboot/cli/init/expected_multi_env_output.txt similarity index 100% rename from tests/reboot/cli/init/expected_output.txt rename to tests/reboot/cli/init/expected_multi_env_output.txt diff --git a/tests/reboot/cli/init/expected_nodejs_output.txt b/tests/reboot/cli/init/expected_nodejs_output.txt new file mode 100644 index 00000000..c03890f3 --- /dev/null +++ b/tests/reboot/cli/init/expected_nodejs_output.txt @@ -0,0 +1,9 @@ +Using .rbtrc (from .rbtrc) and working directory . + +Running `generate ...` (use --verbose to see full command) ✅ + +Application is serving traffic ... + + Your API is available at: http://127.0.0.1:9991 + You can inspect your state at: http://127.0.0.1:9991/__/inspect + diff --git a/tests/reboot/echo_rbt.golden.py b/tests/reboot/echo_rbt.golden.py index a52e806e..c3ab5359 100755 --- a/tests/reboot/echo_rbt.golden.py +++ b/tests/reboot/echo_rbt.golden.py @@ -17,7 +17,7 @@ # may be invalid (broken) if the generated code is mismatched with the installed # libraries. import reboot.versioning as IMPORT_reboot_versioning -IMPORT_reboot_versioning.check_generated_code_compatible("0.45.2") +IMPORT_reboot_versioning.check_generated_code_compatible("0.46.0") # ATTENTION: no types in this file should be imported with their unqualified # name (e.g. `from typing import Any`). That would cause clashes @@ -921,7 +921,8 @@ def convert_authorizer_rule_if_necessary( # realize where they are missing authorization). if authorizer_or_rule is None: return IMPORT_reboot_aio_auth_authorizers.DefaultAuthorizer( - 'Echo' + 'Echo', + is_user_type=False, ) if isinstance(authorizer_or_rule, IMPORT_reboot_aio_auth_authorizers.AuthorizerRule): @@ -1868,6 +1869,14 @@ async def run_Reply( method='Reply', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'Replay' == task.method_name: @@ -1934,6 +1943,14 @@ async def run_Replay( method='Replay', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'WaitFor' == task.method_name: @@ -2000,6 +2017,14 @@ async def run_WaitFor( method='WaitFor', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'SearchAndReplace' == task.method_name: @@ -2069,6 +2094,14 @@ async def run_SearchAndReplace( method='SearchAndReplace', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'FailOnceShouldBeRetried' == task.method_name: @@ -2138,6 +2171,14 @@ async def run_FailOnceShouldBeRetried( method='FailOnceShouldBeRetried', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'FailOnceShouldBeRetriedWorkflow' == task.method_name: @@ -2192,60 +2233,54 @@ async def run_FailOnceShouldBeRetriedWorkflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_FailOnceShouldBeRetriedWorkflow_reactively( + async def run_FailOnceShouldBeRetriedWorkflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_FailOnceShouldBeRetriedWorkflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_FailOnceShouldBeRetriedWorkflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.FailOnceShouldBeRetriedWorkflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.FailOnceShouldBeRetriedWorkflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_FailOnceShouldBeRetriedWorkflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_FailOnceShouldBeRetriedWorkflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2256,6 +2291,14 @@ async def run_FailOnceShouldBeRetriedWorkflow_reactively( method='FailOnceShouldBeRetriedWorkflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TooManyTasks' == task.method_name: @@ -2325,6 +2368,14 @@ async def run_TooManyTasks( method='TooManyTasks', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'Hanging' == task.method_name: @@ -2379,60 +2430,54 @@ async def run_Hanging( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_Hanging_reactively( + async def run_Hanging_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_Hanging( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_Hanging( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.Hanging ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.Hanging ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_Hanging_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_Hanging_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2443,6 +2488,14 @@ async def run_Hanging_reactively( method='Hanging', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'ReactiveWorkflow' == task.method_name: @@ -2497,60 +2550,54 @@ async def run_ReactiveWorkflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_ReactiveWorkflow_reactively( + async def run_ReactiveWorkflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_ReactiveWorkflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_ReactiveWorkflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.ReactiveWorkflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.ReactiveWorkflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_ReactiveWorkflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_ReactiveWorkflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2561,6 +2608,14 @@ async def run_ReactiveWorkflow_reactively( method='ReactiveWorkflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'ControlLoop' == task.method_name: @@ -2615,60 +2670,54 @@ async def run_ControlLoop( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_ControlLoop_reactively( + async def run_ControlLoop_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_ControlLoop( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_ControlLoop( + context, + validating_effects=validating_effects, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, + ) + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.ControlLoop ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.ControlLoop ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_ControlLoop_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_ControlLoop_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2679,6 +2728,14 @@ async def run_ControlLoop_reactively( method='ControlLoop', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'AtMostOnceWorkflow' == task.method_name: @@ -2733,60 +2790,54 @@ async def run_AtMostOnceWorkflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_AtMostOnceWorkflow_reactively( + async def run_AtMostOnceWorkflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_AtMostOnceWorkflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_AtMostOnceWorkflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.AtMostOnceWorkflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.AtMostOnceWorkflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_AtMostOnceWorkflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_AtMostOnceWorkflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2797,6 +2848,14 @@ async def run_AtMostOnceWorkflow_reactively( method='AtMostOnceWorkflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'WorkflowCallingWorkflow' == task.method_name: @@ -2851,60 +2910,54 @@ async def run_WorkflowCallingWorkflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_WorkflowCallingWorkflow_reactively( + async def run_WorkflowCallingWorkflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_WorkflowCallingWorkflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_WorkflowCallingWorkflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.WorkflowCallingWorkflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.WorkflowCallingWorkflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_WorkflowCallingWorkflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_WorkflowCallingWorkflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -2915,6 +2968,14 @@ async def run_WorkflowCallingWorkflow_reactively( method='WorkflowCallingWorkflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'RaiseValueError' == task.method_name: @@ -2984,6 +3045,14 @@ async def run_RaiseValueError( method='RaiseValueError', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'RaiseSpecifiedError' == task.method_name: @@ -3053,6 +3122,14 @@ async def run_RaiseSpecifiedError( method='RaiseSpecifiedError', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'FailingWorkflow' == task.method_name: @@ -3107,60 +3184,54 @@ async def run_FailingWorkflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_FailingWorkflow_reactively( + async def run_FailingWorkflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_FailingWorkflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_FailingWorkflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Echo.FailingWorkflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Echo.FailingWorkflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_FailingWorkflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_FailingWorkflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -3171,6 +3242,14 @@ async def run_FailingWorkflow_reactively( method='FailingWorkflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) @@ -3221,9 +3300,6 @@ async def __Reply( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -3685,9 +3761,6 @@ async def __Replay( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -4121,9 +4194,6 @@ async def __WaitFor( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -4542,9 +4612,6 @@ async def __Stream( ): IMPORT_reboot_aio_types.assert_type(response, [tests.reboot.echo_pb2.StreamResponse]) yield response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -4844,9 +4911,6 @@ async def __RegexStream( ): IMPORT_reboot_aio_types.assert_type(response, [tests.reboot.echo_pb2.RegexStreamResponse]) yield response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -5168,9 +5232,6 @@ async def __SearchAndReplace( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -5637,9 +5698,6 @@ async def __FailOnceShouldBeRetried( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -6101,9 +6159,6 @@ async def __FailOnceShouldBeRetriedWorkflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -6488,9 +6543,6 @@ async def __TooManyTasks( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -6952,9 +7004,6 @@ async def __Hanging( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -7334,9 +7383,6 @@ async def __ReactiveWorkflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -7716,9 +7762,6 @@ async def __ControlLoop( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8098,9 +8141,6 @@ async def __AtMostOnceWorkflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8480,9 +8520,6 @@ async def __WorkflowCallingWorkflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8867,9 +8904,6 @@ async def __RaiseValueError( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -9336,9 +9370,6 @@ async def __RaiseSpecifiedError( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -9800,9 +9831,6 @@ async def __FailingWorkflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -10252,10 +10280,16 @@ async def _maybe_verify_token( method=method, context_type=IMPORT_reboot_aio_contexts.ReaderContext, ) as context: - return await self._token_verifier.verify_token( + result = await self._token_verifier.verify_token( context=context, token=headers.bearer_token, ) + if isinstance(result, IMPORT_rbt_v1alpha1.errors_pb2.Unauthenticated): + raise IMPORT_reboot_aio_aborted.SystemAborted( + result, + message=result.message or None, + ) + return result return None diff --git a/tests/reboot/effect_validation_tests.py b/tests/reboot/effect_validation_tests.py index f23f253b..a59a1a48 100644 --- a/tests/reboot/effect_validation_tests.py +++ b/tests/reboot/effect_validation_tests.py @@ -268,10 +268,16 @@ async def test_task_workflow(self) -> None: "constructor_writer", "workflow", "reader", - "writer", - # TODO: This second call to the `Reader` is due to eager retry of - # the reader methods that the workflow has consumed: see #3152. + # This second call to the `Reader` is due to `memoize` + # effect validation: `callable_validating_effects` inside of + # the default `at_least_once` readers behavior. "reader", + # There is no second call to the `Writer`, since the + # workflow idempotency ensures that the second attempt of + # the workflow re-uses the result of the first `Writer` + # call instead of making a second call to the `Writer` and + # we don't retry writers inside a workflow. + "writer", "workflow", ) diff --git a/tests/reboot/examples/bank-zod/expected_output.txt b/tests/reboot/examples/bank-zod/expected_output.txt index a767e553..c03890f3 100644 --- a/tests/reboot/examples/bank-zod/expected_output.txt +++ b/tests/reboot/examples/bank-zod/expected_output.txt @@ -5,6 +5,5 @@ Running `generate ...` (use --verbose to see full command) ✅ Application is serving traffic ... Your API is available at: http://127.0.0.1:9991 - MCP clients can connect at: http://127.0.0.1:9991/mcp You can inspect your state at: http://127.0.0.1:9991/__/inspect diff --git a/tests/reboot/examples/chat-room-nodejs/expected_output.txt b/tests/reboot/examples/chat-room-nodejs/expected_output.txt index 6c2afd19..53eded8c 100644 --- a/tests/reboot/examples/chat-room-nodejs/expected_output.txt +++ b/tests/reboot/examples/chat-room-nodejs/expected_output.txt @@ -6,6 +6,5 @@ Running `generate ...` (use --verbose to see full command) ✅ Application is serving traffic ... Your API is available at: http://127.0.0.1:9991 - MCP clients can connect at: http://127.0.0.1:9991/mcp You can inspect your state at: http://127.0.0.1:9991/__/inspect diff --git a/tests/reboot/greeter_rbt.golden.js b/tests/reboot/greeter_rbt.golden.js index dc96d731..e4bdf858 100755 --- a/tests/reboot/greeter_rbt.golden.js +++ b/tests/reboot/greeter_rbt.golden.js @@ -5501,6 +5501,6 @@ Greeter._ConstructIdempotently = (_j = class { export function importPys() { reboot_native.importPy("tests.reboot.greeter_pb2", "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"); reboot_native.importPy("tests.reboot.greeter_pb2_grpc", "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"); - reboot_native.importPy("tests.reboot.greeter_rbt", "H4sIAAAAAAAC/+y9bXfbSJIu+F2/Ai1/EFkjs7pm7717V304e922q9c7XS9HdrXPXo8PBZGghDJFcADQKnVN/feNyBcgkcgEEiBIAWT4dJckEpnIl4jIeCIjn3zhPfmb5ZW3CBP/dhWcvfDCJIrTKy/5Em5my5B9FG+X8Mg6+k8f/nh42jxlz78M4jiKX86jRTA9X27X85dxkG7jdfLyq7/aBudn8O+F9yGCwql3F6yD2E8DDx/3Hu+DOPDChw28Llh4a/8hSLyH8O4eH0y95N5fRI/wBTy39nxvmwQxVJVsgnm4DOHRJHoIWCkvXHvpfRDG3iaO0sjDRnvw8zbAj70EH/ETL1oHXrT0om2cvRTqY6+99EbLKPaC3/yHzSq4grfFwX9ugySFuoIVb9vCu9luw8XN2HsMvNtwvfD81UrUlMDrZF3wTj/1fOgaVHkbLhbQemjgBWvbhedDwRR7Dt/CQPhrbx18DWIYktUqXAQTHK73KTzlxwtZ++RsGUcP3my23MLYBrOZ+AIqg2H10zBaJ9jDdz/8/NP1B/mU8iWbg3ts0WoVPYbrO++HX95/8PzNJvBjGCfWFhyrGPsMg4S/i5dfekm4nuPXUZJ9iGLgP+EIh2uY6HDhjW7j6EuwHnshLy3nesEnO8SpTR78dH6PUxqm9/wd6ySFYWQzsQpvYz+GmZ2cie7FwW0UpRMYngR6gc3OO8m/m+Xfndm+mMAr519mWYNm2CD4z8MGBgdEeHT+58l/+++Tfz0f4zC9+vDh7Y8f3v30I8q7lz5tYEaZfEEPmGAl99EWROJWEV3ZHZDA7fo/tzAeIDbYJeUfE9RRMLmbeDdsNqFq7JHo6qv10814ApMEsvPIXjD3QeK9+cpP7oOkWBd7H+rDy0WwDNfQgocApmchZO/e/6pIPr544v2SBMU6ltvV6ull1lghu6KBYih5EyesbWyqAn+RTY6fPK3nYaRMifhEPnC7DVdpWJBM+ZF8ZB6t0+C39Ksfq08pn8oHF37q41Akgfqg8ql88C6K7lbBhCnb7XY5WQTJPA43KWh3Xo4/NJMPzfKHbNX8mkTrGWjJA6q2tR7lKVtFMMiJfxdUVCKeyCqIN3P1afhT/WoG+pNukwkffFU/su/4V9yEKEWk5CmfGEuzwuJZ7KDyFP4pv4rU4lE2H2nsz4Nbf/5F+Tb7TD6EdlX5Hv+UX23C+ZeVOlz8g6KFKJkF+fUqupvA/5Xv4S/8PyjAC6bcV154twbr94mX+Jy1m2un0mj2gWaZ/DCaYEei5bJsmuDLmfhSFsMFMo2iVdFai8/4DPm388y63yY4VClXblXRbuez4pe8LOhDkIYP0jLlfxdUhn2U/WIuib8vglXqm4pmX9rL/hMXW0tR/E5IY1E51ApA+B42s83tv1ZoSuG5yhofY1zq4qSmQvUxY32T4GGTPrFaRM1v8YOKKrMCM/akQX5wFo1LG8qP+LLQGDQIohqhosZeKSqcdSf95yqa+9JrQTdrxj7Qpks8Nit8b2j6HD0gY7vxG0uBIJ4VtF0rxb42FeWLQmIpKb41FLyHRSuILeXEl4Zi4IvBZ2mwnj+ZiyoPmIpDe+K1v0rA+wBHLFjNHvw1mPXYUpl8fKY9Xln1A3iXq+ARfc2aWvMnKytM/eQLNMEHj6muRuVRhyoBLWyY7xe71Zs/b6h8s4L14yFYp+a6sq8NRcFn+hrOreKQfW0qCroUyGmxlS88Y6xke2stC1+Z7AMOiMU64FemIsxrNRfBrwxFQHnYBJhLyW8NBR+j+MsSQIXlfdnXhqL+FtxYYyn8xlKA/SeKw39aJwEfmClP2SpKEa8gTkAHuLIy7UlThbccCZjr4F9qxZIgBVf4zvBe+Y1WYA2w5ddksnmCnq3LpfjXM/41N/eioLo4vwEB/QB/fwQIgT//d9Hyi7rYWm16NGvSLcCy7/zV5t7/Ti1+C8BLfGx6dCIbWViw1FKz/AmbC+2vn2rWcfGErCB5UgcZ/pJfPMw3zCIE8WTpJyn8qTwHf834lzPxpTYfWFqsO+URxNLiS0OxbWgusQ0ZBF0sQoTtsBo+QamXwW/cHYTFVoCDhIURgvX2AUApW9jBYOOYPESLLYyVWO3BO0om4rV3cRCAEqu+y+gMgeDraBXFl+JXwHjxdp6+Wi/eAxoKroP5FmD01+AH/t5rHhVxfjrZwDOBeDwOQKCKNYiP1Mfe+GuwndE2+R4jL0nh+bcYa0Jx/AfGlvhnfwvSj/fRKnif6rX/DXts+kR93Q+4yrAhKDypfqw+fg3+QuWgmB8oVlH8ln/6PkhfLX4N5il8Uaiw+IVaEYz55hHbWXw+/1R7uGY6HabwAzz892h9d71dY2Dl+0B/OZoJ/ttHYffzClh05RfQKE8GLQCTx8EyiMGFCpRYV1Ht/U04USJZBsOAT9yn6cbBZtRHCaqeynx52wNFQGKyf9Gm1IvC99z7qf22EAawqXnd97ySM0DD6JZONYQ84c4/fjeazTA6NJuxKfwYeI/R+iL1WNwPo7k/Py38dRrOGRwJ0AYFgHAf71kY9j54YsHQ7XrBopzCZsAoTM7Y88nsNgBhmmVfBYsrD5bAT/DXZ2gW/DqCF7M4j/cLiFJ6xSRsA3+fnf3y4/u3H+Ap9gU+d3YG4sU1PYg/RD/j3IzYi67kpxNmKy69bL0QX9sGaiLKjdUXK2/5Howtfw/73rE2ridhAr4vWHtAW6IcPL+CDn0PzjBqjffy34rt5o3gUXb+LrUtRZMq+y+K8A/zcSg+zN9lbXbx4UIrZM1n9pZoY5S3xfF9xYFo2xZmqrRBYZ+Vx4R97DgkvIpiK3h5ayNK4yGa4fYu82i0bMa79Wab8tWWNyYNU9wEKQaBf9pwl4Sr5X9xheMyjMahweO+XM4cywi1wzAvWDNjp3F7ATeYfkQXlevVkn0QJmyHARaYEevVJa90zLdh8BO1KPtUK3YmA+a8fPYntJH/IZrHRt0Pk8D7ABCLeSp5WRZwP3+Nmz1RmhvBzAJJv45tvUBx71wreuEmFxdXYsPqgrX2QnZOrw5eg6IRxrDusvddQIMu8qfGljHEmS4MId9+cxxBVnooA4iN7Xz8MtEvDGL2qfNI5vUMZTizFu8wplyzZzM/vktmM9yCnjMv4dIr7Veh4/D7H06mIB8uWfMnoT1YCfvNQRtMtTARwkrwF1eJMFWUDx7Wlv11ppp6o13MZ/ybb2R1QkoKa0IBGNU4DYVnaxbIwrP1y3Th8eYeg6FlxkY7N8TBXVCfdBsMt1Vafbixr1BulKm5jdtQchQaLvwPQerjlq21SK7QWLjWBVDb5+IBHOPqpY7BnhcvOX2FIZQfOg9jVkv2Cc56j8dSNrjBeBbleD9rWBeLjz6jpnqy7mNd8g/jyqMOn+vCY4pu1aw/piI1ltdUpH4RMJVqvijZm1vVoaatc1ipDAUaDZvbmmEo03j5sra0oittG1Za05qiU1Ymvg3T2I+fZPKOtWyTPk9+hP8ECxGJ1V4ZY9JgOvOXWMF3syQAS7iwvhZjSrXLqaEJLqvqCWAaw8h0i2wsI6uLVXGE9W/dR7pUbx7kaC2fA5govdsNJqz9uDjMs1GXC3NtfMJ5vs31Z1+jcej/7Bk70WAGsZf78cR2gvANNd9YdUmu2Sv0T9sIn+l15onAVxq/MbqKhpl29Rg/xP468dkGUgvnsab0XvzImnfuw6WseeUObXZwNKvL7sHnrH5h9+5n9fs6aC45pY5jTf4p+afkn5J/Sv4p+add+qfVq467q/r0IcqyJF/zbFBnR7WiLHdHrOlpE3bUxMXJq3iH1S2tea3uKlW8onULnZxQe8k2w2dy4+xvsPmcXYydq5NZ3bqCi2lzvexVFB2vFqbKrHX2F7bTubfi3MIuumepYy86aHnXPnTR8qqdW9xYN8017ENHzW/ag66aX9RZaxvrrrmqA+iw+cXOumxMN3dT4YqiXWluxSs6UtiKN7Rtn4t62gvWBG8qStYLv71s4whObQ8curprg0sxnGQVBBt+sop7nok1MhKu0/rAiP31LlGRcmsKgK78tTOaM9ScfQcd6wWSqxi8HNGVO9IAzkFP94Pm7BNnAkOGPrAzFaWPzcbcPkwtbfjHOIQP2xnxYtn9WPHiO/ZixouvaN3C5oa8ULIj/6riDd34VRUv2Ll1Ln5URRX78Z8qXuiczVs8EumW1Wsq45LQGsQO6bSmylvm9waxltFqqrtxk1wyfQ0l6gbIUKQ+69ZQqHkGsLWxVd1p3TYHTTIV3YsGmV7kqjnf++EKzxe//W0eMGfMUXus5Tpapaz1d7NCWatv1TIHXbKV6mZVstXeyYpkq3ynVjnoj634XnTI9rKmevSKM1801CKtVMc6pNXerQZplbdoVQPtKZbpVneKdXeqOcWqd2hRA60pFt6rzhRf5aoxOl9Cjaroj9c4Ivrj9XKpl2jurZmbaOtAkxY5qIj2cDe6oVXaiVJodbZpg4MaaKX2Iv/aO1wFv8T34iT/llIdLRWW2rtZKiyVt2iVgx6Yy9RYC3OhWtE0F2sMXaqaXN2tHVpYitbWnlUsxmgLXWtQQkqQc5EkWC0bPC4oqBqUuA38GGaCUZ416gpOZIMCSPLapN/p9rbB4wo5Y4OUSU7fV9EuF2IKs5C5xOT3dcCyL1F388jsdNJSD7Pbcog4R1UxZa00LzVJagrP1ZBGVTR8D4MqmL2Ko8o/bDCsKsHYsMaVt7zzgUUTX9yMgw/ct9+w9OAGE1vd+UCKxa8wlpKw0XU4ZR2DG1HR8M4HVfUPCiOrfuE8vIXaBjfGauv3YF+xPZp1ZWz37raV1TBAy4pFOh9Q9DgLw8muHXAdTFZ6cEOJre5+gQJfvLhAwQfuCxSWHt4CBa3ufCAVlFIYT5V73nVY1bp6d0CpbnSVxnd+SkmCOk1i+YcNpFbUMrixlS3vA/Fae8IZJ2BnPg7Cx4MfAOExITdAY65NOP28OhGlq/fjjblZ6PNyhtvV0s2FNVUjHT2sSTKOu7tuphoLbg1Wq37g5K2Yh46t6nzg2CU99cu0qR62pGEt7Jqg+hXKOPRozdnQwy/uxtlUlWq6sEb1WhA3g2RuoFBa3kj+hzHsblZ/Z/6lKtLvOiKmqrJ1x7yryjqQH1UVb3Ggvr4nTp1u3XAX+qaKku0G25E3qaJw86P1tZ1w6e7ObTZE+1uekNdfUk+yVNE0txhx+aR10/PV7qeqzXcVPPcx3IohVIPJnZ2h1t+1L9+o9iSten5Wnpo10qtUjJDrylB1kUXNwlBVtMZUVRWtt65VpZuvCvXdcOlw21Y7LAkVBVsNs5txrSjbeD2o7YFDV3dtsEP6REUNe0mlqHifq/o6X85Td0WEaz11VyW41uNwmYNrVS3unGjW28aD1EnnXC6xcKxl90lzvHPCsaLmt2I06mjT4em0XyWncxFs0vudzgC6vt7FsWStKbiV7BNnp5KX711c13WIcseRdaQPJ/0KM2JyB3lLsRL2m/k6AMf+V64rZy8q/nl/D+78+ZN3d/3za+99dr9mVRF2Gz0McBIwihUc6zhYBV/9deqNovXqaewto9jLL+tk15qHD5uVuPbTW+XvhMrEg3hPu+9d800yEQqbeO+Y+Idx9oY08uarEOpJJlyZf/C/BLwTf4s3c9EFH2+GZwPwwnulvi9rFp//uY93Yd3itVdx4CWbYB4uwzm2eO3d4BM3l6KW24Bf6W6qK/FGfuJlV9R7t0/sSj/2zA1Tg/mNqGaz2t6F67G3iJjAJPfs+tf1E/T44QEG89YX18YnXpTihau8KdEtktjcTEQWGX/tjF+Bjf/lFrLiStSJMjBXUmTDJNnespeNCnVeVt86Nnm9iuZfpLCoJoJLr/o1m4hC5eOd344X+/3A75etaET5KVtbuGVjtxJy07Y8/2X9ZR09risk5+L3Qk1/XJyjqvGZKw2A48SIXpyfn4PQ8s/xY65ADyDnoAlgV6MkCdnHkXcfJbpCYQ03hRm68UCwuGJNoO4zsX4twRjh7WWzmQh281pm/Jb5sox9aiAUn5UJwconM2vlYACt3+VNFR+zq+wS1l4m8aswST9Z7smVI/sjFPlckg+XUqPiysR6eDH+rLSKxXaxHGtY3i5ccPNXFq1sbjUW7Ca+e/8rmgB0D6J5yAwIv4oP653o7c69AGzAMlwFs/z+w7wBlrtV80cn30PRN9mfpfGx71i9ff/6+t3PH366zpvBV70UG583Id2Cxf9UG6YySE/uiFjcq+LHr/3VCvXkU2G1/8RtZrZws9fgbb/v2bWwny8LT7NhlX98/sx+/azKsND9aZ04j8YK5+RilkbyGtqHIL2PFngpUeVAYKHCYORV6FMk33tpfFNmjCyG8PA2yWC3D2SaDG8+TguldJQM1X4MlUGWTt5eGcakvdmqxisCIMjXeD+Ei8UqeAQ3umPUkgEWmLIcmMjvEZlAlTZscukF7PAtqxOxwNIHRMxsZhI9BPIxdrfuzF8l0cxLtvP7HA3FCG9eeN9DcYCojIYLwMpqBTU/MtjiIRjxwQLfIV5haZvw+tsnvN9W/M2vvJ+zq5cR/UN9/hbGOA7/yT+D+Zp/SSYwMIEoAvr3NQTdA3DCnoWXQw8e+OOjYHI3uYRabiQ8448kTBpvxpMztNq8sTPWMJ50gDgaYCyI0vJCfv/ydyHmmAcwwf/8t9H4jwu5aGXXvvDByCfZsGzJKpPZQ/bYJC8Bdr68qlgSrr+5LGlQFpb7KyCzssL7m81KDLF69KRks1/lz71bFN8Col9Vkqt/oRAz5g/+2r/D9hkWcvWBhF88/AP/K69ls/LnTL5nXBhNFWXPTH6Wv71mD+fVzAGfroNVVXPyCdIensxe8w9KjeN3Zc99kNDqGpUHJx/w99f4q1IRE0CuCUrrLAZaeQWK9qxYOpl8wL//If5ULHKwXIJZmYk7taFKU6OF0iSTt+zpf2QPXyoW0l/kR5785Gk9hwXg7dfAEI9LtpsgHo0nZZkuy+W0+GdxKclkcJr9pj1QdB7yy8bLsopPYojQ4JsINboYl9+euU1QdXFRVNbUSjfo3PSqH9iCkpxrb9RWUl0PpvoHxcc1EZ5qfxcfLsnFtPTJpRp8LDqkhUWc/6o/oSp6lmsk/i4+yxVlESYbvk4bZ1HXq/xxrlxvsr9bS5uscspaJf8qPqMo9VT5vfgQ05Up+682QxGu3CixUHRqGKhJ4QnjBLzwWLiVLd0MAkRLL4A2eNxJuUiyE2hJJJZ1fD47mJawJfo2UCqEVRUMB8z7P+ExGOiIVT6PwH1A16DgQrNGi6q44t0+Cf9oxq/tzOPSDP9YvGgReZ/I/BYWsi4M1gW/cfbC9e7y4lBfME27cLzMVCurcnNfNLvRQ6vJQvi9a6UGPuOL2uPilbVojKrNazNQ+l20o9KsrpkzlzVuX4G96aIhw5VWV4nFpnFrNE6PxuUlJ0LjglpW50Xj8/K6ppi2fi5aZuppdZuyFS7aJX1oNdduXl10sDWcv/MP1XqDfGVYB5FmuMRNlkvcV8JbBBDSLePoAQBUvF0FbPsumGPF8dNE2RRdygKzvLIZlpiFy1lWQlsL8ycj/rDV63RwdZgbmlcJQCL73fuvZs9fb1dB0RHKVz7z7lFFZVdnhapeeO+WEjOK1gFy5WObSFS5uMxiNrDywej621WqVaNU8HgfwoILmDd6TNgEbjY5Foba82/CtVbLIvjqPUSLwBvhpvcquks47AY8iNYtYWHKYLVhDQEgHWvlYcXDdRqaEHAn4Ikh9YcwSVg0QEXR40mhMDa0JAESI1+VZlwMiMPYv+HjlU/BqFRZviSD7S59Oz7TG6reCVJq82Vz6Rpb+ycaVdf4mRCKabk5dd0RLzIU1HxmRcKmLdQ7CwDlRZQQWsGF5I5TIRgD5bTm7BOgQgctjS+WG41Rn4qfWbziEISFaUwidsufvAD3TBPPT3jGh0z3SLje8B12vsUKHzyoHnGIru/qyXuJ+riIuC8NZVigGT7a8jLejVjBb7zHGKwAGnRuHB7D1UqpEDyKBSsA83IXopkotGji/bSWrX0MLlYrMPqYCBLxQBhqO265KxViTE6+M+HV+8U6WYDPl5kDUBur/xK7wuN0Sm3+1yhEhJDGT2hFGLLh4EECEuhQel+uTpeZ7OsZ7w2iA4mjLSiBbUMgAYoBAlRg50nZhyqvWvb8HbadjsXZ5vpFJQ6vbIbiiu3j/TKTE1f8QlBabD/xP64soXnDRkp9zLzYwXLUXFfcvBm6ZrIwkdzdAMlIZaMZ5o2D5VV1vOY6KOzEyAQnrPVdihktUeyKL/MBOD8/fycD6Dx6DAj6Jo/KTmRbxzds409jbJD7FnNmQmXorDgm9+CJglpOy50T30z+H/6zvNZo8Qr2qqqgRR4Fg+GcZr8VHxofMGrGtXx6LkbxXI+AsOFi1U8rApHXbHxe6yQZisXnssWskimQAgYl8B9AXGYxq2qmnJ+bfQm0pbPExlEOdU1mM2XcZpdmz3qKyqa0F9ceViwpOiC89Wih+bG9S09r3xiTzkwl8d8Tyyhk3+qKBr2dpzMAIKp3UNxKEDJo0j1NPC/PirN6lR9OVtJowcZjKz32QwSD67SWb2xWexR1Kn1Z2MFmuzW//PLuzefPRWW/Zu4XW/NzGiFQedyzwsXuQgTOvDuAcJjop94bx02vEh5j2AyrksghI0Liw3DBJpUF5Pj8ZDwPmwVzudiiymwHOCawDC6X4Miv06xpE9Wpwe0vbCd4hCM2s5MNSEZyH21h/vmm94rFGb1gnWxZ7ijWn/LtxIJJZjuCQk7R7n0NxA4gfJzG/nIZzieKcrF8YKYBetB5ImLxUHoGLdLzbaUIVRkt+YzBXI296VTRPKa4+Yj8+NOHt1ce7ol62zU4wB5XbiGefNMy2W42zCMoWO8X3o/CowItCdfMewM52G48BqQS5j2K/UtW/0JETyP4Ih+YlQ8TXUuv5yjAYHgLm+X+HazHd5i9oFsr0C6zrOO2RA6Ww6Un98anefxUh8Prrz6IM4gc63koHD3hOXPRwgRQJl5M+BZMSnQgK13k223KRyy9j6Pt3T0YU4C3ecrpNcqtVhi9Sug57jNzd1l/720AqpjXwbestUpQfNluhew0zN0C9y5g4So8CigblwQBsctr7vnfopTto+NOODOdWQyde71rMMaFN3Ef9rxU0/Kce03exe/8yT9YwrcsrW7jZ5nU5VrO/2Nt+PBN5D1FW6H13m0cPSaY8enfetEGBot5+yC7K9QH0JsEPRtDNZjqjjqv6OclYiyOFnJ7pHyP8QzQnDuGQf7vYp3jItRlYKqwrjBv1Oc++uT9U5IGD8JjH1mDTLfp7Ot3/mpz7383ETgCfeZ3fBj5EI/GZUdIKNjUiOCr56aqV3y5ZSZEAG9uOlmaNuIzVP58x3VVVEOxEXFmcjhcnEnVobzX1+WDuHSKWyd6U/5+R8fOEDZhqmfoRezPcbyTjb8eWcYBh2C6PP9dJoNoo/PH6EL7KgRhGJ8bhhVewms7Zx0fjcU6DMvn6slUgq/Za+Z6eiD2oKwPbF8y8X5+giEEZUODiYYOJ+E9yx2blKrZsGclnJ5PP8TboPyyVQDNmNrH6AP8DP6OD01e//L+w08/vL3WhvzKNpE8gWbq+Y9+KBwB8K2fbgMehnni8R1zrEyXVk146uJlimtZkeNV2L8bjW01TH72Y35i730ao/UveGuGN9fginz2zX03IokWiKKMLNQ5yD41NyJXWC68lQEMm0Y72x61qVNVfOyPikmYxqbtGQtqrcRTzrgqKQAre1/srtgEuhesF6NSxfbaYOWAB694mt8iCvgRMPAw8XgN+KPgvKM/Po82LPw238a4BK+eripqTILAu0/TTXL17bd3IK3bW0we+JbP8ctF8PVbdFPBRfsWT7MEybf/+j/+j/8xsVb4vxyz17j8xdv1bLlds33tWfqI0b00kqkjwYynkiT20c3hKlTEA04jmXgCkF2Uv2JXtFfl4moetX281Di8YtHEqyuL1Wp1af2pf6xS7tV/5UGZlj+qrqZCLjM4LO28Mh0VxcC/KeAg7085Z1X1FHBPSmHEqtCzcWVNxQZonFmmf8HKsXEsglPdsFZmY74KfHVDRvcTi/khBNoItBFoezbQZs3bIr0kvSS9fEa9NKY+Hklwxdy7Ewy2GAeCgi87BV/MwtUsGFOTbEphmPZhGFfdp7AMhWUOE5YxG+FnCdOYm0JhGzVsY1kzKYxz2DBOzbGao/RU9V6evMeqDQh5rh16rrqwkQfbSw+23iaQJ0ue7HN4srpx7oFHqzeJPFu7Z1taW8nDPbCHazzqfSyOralzp+jPGsaB3Njd3FiTaHWUDFdBp0Au7Q4urZs1IE+WPNkDebIms/w8DqypJeS3FvxW4xpK7uqzuquSP4gSeSiRhxJ5nu9UVJGP61hORxV6dYqnpNQBILy422mpgjB1dWrKQG9HCLE9QqzTeIKGBA0PdIqqYHqf5zRVoQkEBgunqoorI6HAw6JAA2frkfic5Z6doN9ZGgTyPXfyPctCRWk2PfE4XfSdvE7yOg/jdZYN77N4nuVmkPepep+G9ZE80OfxQDO+2iPzP2W/Ttj7lCF88j278D2lQJHn2TPP067p5HeS33lYv1Oa3Gf1Oq1bt+RzqqsieZyH9Tjzqwko2YWSXSjZ5dmSXUq3rpE+kj6SPj6bPlru/COtJK0krXw2rTTf93kkUVJj504wVGoaB4qX7hQvNYpWR+miFXfqUiS1fSTV0RpQOJXCqYcJpxrN8rPEVI0tocCqGlg1r6EUXT1sdNXhEnkClAQoCVAeEFDqJoPkj+TPPDdo75bRdu0mfr+sEYPc+7ergAPNgjg+PG2eJuaLeB+2xaMwz3oTr7Ovdvhbcwt3tTpcY+oAXXk5M1htA1RfiNtnHwOEWdEDKAgOBlqMFASBzTSojFjUYZ0N5LqsVcOtzeM9DNsjLtdogW7U+9sx1LRNXsNiPvnlx1f/ePXu76/++ve3N6CIWk0sBiKmCNsA5i6cY6WAawBi4Rf8ZUXHQKsljcC0rAFdgJM2//LtKkoSNtPRes1uPQnTp+Kq/kKr4MNPb34a3Qbr+/EVNORrmITiCuJFMA+ZNYIZhVYFYJwYaIKZSaJ1uRk4nt5NQXPGN1x4EKaxm4i9CG0RDvIaxzAOtGoeAxAtcFvAGUMXXAzAKJjcTS6l7bwEBQaA/GvpkmTNR7r0gnQ+LnYe2zi7hYGKlktjuFB8N/kr/6lJHjhdMNAYeLoyRLk+YlzrC1r55Xa1erkED/AOlOXu+ufX7MWXXiKuJQ6XhaubDXU9Ak5/CBOQQPTjRuEkmKgXQ+PqhEawcCW0oRp+SXTAgdMYwDwujTBN6+jRu4tw1pj8hXf3KZ+gCcbqDBWB0xqAMMGU5FiWVyWkDxq3vku8VQgDwIGToRYJrnBtWi9wOKCB6f3EEFNiV1ibr6OWnUfsgLX+bevH4JfjDdG3T96NMLo3E0NgdHtbYXS4/haDPe+hyMgemoJVBfRslQW7wB7M5GdpZEe+5ru5/cUCrHViu5zbEliqvKzbVsZweXcZ2bp9Wv6EWYIpG25hyM0dcYpiwWLig8z4Mn42SSM2UTP5hcmjKLcJLOzVWW0YA1teeoqjKE818ugcvQqj6838Lfo5GFVjDo/5FaDu7NsJWvIRuyS9fsWww+e8qdJcjezwHWMr4XobmGE0rmdzhMJhumX32we8pfIm+kDaG1gMg8dL7AmaEOiuj+vDysdb63nfzmwBm22SBUCkvYXZ49/MmMs1wUVihh0a4X/GtkEUtSnqbx8k7tJKZ51LoXBg+et4ZdUaxp+xawhf3QrXwDdqA5NjXHbwJxtGe3vY12e17ai9yZqiGk6okvkx3C8M2sLKDLnwsd8PpGTYsYArQZvB/naCLo8FWbYLYjS45NMB0qilCdgQsCFgQ8CGgM1ggY1qzgneELx5TnijyuLzghxrSw4JddzufyaXjVw2ctnIZSOX7VRcNsu6QN4beW/P6b1ZxPJ5HTmXRh3WpzPdsE3h7OcIZ5vngsLbAw9v111+TKr23Kqmzwmp3NBVznwbI2naM2iaaSpIwY5LwYz3R7Wln6PYH8X+KPZHsT+K/Q0h9mdaCCjyR5G/Z438mYTymeN+tU06aNKqdtEgAaNnSF4tzAEhooEjItNdSqRWh1er8jyQah2JauWXRJBiPZ9iyVkgtRq4WlmYsPtKWpB3Oms46gfTsNtMr7+GkrlihNKyjh7HruNRTUjskNaoVUCZjRTdpOgmRTcpujnY6KZm0SmuSXHN54xrauL4vBHNqsYcMpbpQjPocibFVA25cOTCkQtHLhy5cIN14Yx2nRw5cuSe9WCxSSif+YRxbZMO6dRZrj2huP/h4/7GqaDg/8CD/02J2l24ZeuqJDRFaIrQFKEpQlODRVO1Np6QFSGrZ2WkrRPQZyarbdS8/SKulveCdAEuDnYxCCGKg9wPol3zsQiTDbrDtis+Uj/5YrrfAz9PJh/gv2+Zz5GX+Cb/FRF6dqUbv3sNzM73Psiz+tBsFUWbGWbZs0kxvS6/SI69eCabDcL20/rvUPydLP0appTdczL1Riv/4Xbhe1nN3IHN3zRLoIbFdgVtQ40bl68ccWsCDsO1uGTkp5gvHYXbSN7IZ/mFJKwC9AG5vx142/UKJti7KAwY07MEcI4CYFK88RNBCo9gzH0Qyl+3oNXBOtnGQZKvEfgOD9R/y8BW8FuIrldWD14mKJ+Ft8iL+LhjmadofXP79I2nd/cv0pnNakNhC1MUHO6BwFBFpWLsihT1jhR5MwouvfjwRLl+smju4OGiKJkuWFXQlHzOu2D1CsvHxnMexRhCYlcwTc4sfseo9lIWPtegqVxwdMj7SxJwE7sKwVMWFhaRGisO1mgdPHrJHExbDk0eA5Yjt010aMYiZyjRODDC0b8RFwbeMHf+RtzTd4OS8bBdpeEGL/oB3xxFTquOIVw2EgBuRzB1UPcTx9UpC84hwMgqYWLEbhAes5UDYJFW332YMsTos3uEdFdFNv4i4bdeCT8B/RuQSPS9z4rwQ73W0XZzgui8yVBkNwzLzMPXtpsVvyl/ZLswsmy0mJ24anoRLA7m7FE0rMVdsFje/E3JiE5Ln5gLtrz/kd2iioB/FaQWh8/q3HNF0y6GHAnfQU6bHf5pIzV1ujozQ13Z7bIzFyBWeXeH/CdaXrivCYNjP+MZ+pEQURB+vOsNDHY6crvy6dJTjdd4XN3B2wDUNub3L09n2WIFoncXzvnHtuuCMyub3yBpuD1a+XbyLv+9/mpTkCY/mS4vcJH0fhcVb7fhYvLLL+/ejFjMcMq6ytQDPmc/8YnxHxc1V49WzN24DqsJ6R0xrVIvCWUmfVwhu3yR0AoYny/CVGYewGl8DYY5wAX2rR2fcuMKHjKaSx6n9Lk1zvHeXNaD4RefL9rxpOoeVWaVSitzyH2aWVbfyDIfddfhJrBsoONVKxTs0tPap1q721WVWVzwbE5G4/qGjTHgIRDpuO523ErlMIkiH8exy9XD/NEdLqFluMZBdA1zIEYfVwLx0VU15GWXloN4LC9+l3Xkl/nxgMVsNl/5STKbwW8PEbrms9kfE6fH/xM8XfSQoMBFc43KoyyoWHjrdbgMoXN8P6CiPtYibxmugkrFUwYALw/nzoF8y0zI4e2TvOh9pvjCGNscVV7HLhzpS+/TZ2cVFdcOi4FVxPlZBZaL49mZNRhY5RbywDD7UvqBZtMg76OvvbbSblmKod8pe7VrODh3Rgy4Gl1tFn0EP2BZtMNZubHzbff567BiJk7G3RfltR/g1x/hObPIXYytEWEQxanEdJdVzi1723QX3106w1OzRzyu2lmVVxENCHWyFhPoPAzoZINNmJMw53NhTosAGiCnsAs7IE61hoMCToJnBM8InhE8OwV4xh3OU0FnluWLwNnzgzMhiD3GZoXLqoYE0Yr3ZBFSOwRSq77BhgAbAbbDALb6m5Q03Ga4VK8dfDNURNuGtG1IuJRwKeFSwqU1uLTgbJ8KPK1erAmlPj9KLYplj8Gq7ZJlwq2EW6twq/MlrARhCcIeBsI2uhdYQ7OWsgRsCdgSsCVgS8CWgO2Bga3NMT8VjOu8mhPcfX64axXWXiNf4/Xn/cO9tdeZEtLdL9I1yAnhXMK5z4dznQTSiHINJV0wbo0JoqxbAoEEAgkEEgjsHASafNTTgYBOCx0BwD4AQKOgDgb+vf2NOyEEAwkGusBATV4IDhIc7AccrBXMWlio1UDwkOAhwUOChwQP+w4PdR/2NGFi7QJIcLFvcLEkuH2GjdDbv0fru+vtGmmrvw9gJSW0SGhRR4sGMSGQSCDx2UCikzyasKGh4E5ZsRUVEk4knEg4kXAi4cSucaLJaT0ZeOi09BEq7AEqNIrpcMDgxxidVEKDhAar0SCXE4KDBAd7AgdtAlmPB3nJoe0RMhtMJyMJzRKaJTRLaHbYaFZ43ScKZ21LN+HZ3uFZKah9vlYkSD/eR6uAdX5414uAIhCS3e/FIqqAEIIlBPtcCLZGEA3ItVBitwtHDDXR3iWhPUJ7hPYI7XV98UjBJT2ZC0iqlzdCdz24iKQomD1Gdd/74eojuL5vmdWDOaMtSgJ2GrAryQiBOwJ3zwXuHITRAPBKpejoIsE6gnUE6wjW9Q/WlX3SU4F2Dosbwbvnh3cGAR0AxBMGjgAeATwLwLM6IATvCN4dFt45+cIauBNlCNoRtCNoR9COoF1/oZ30RU8N2FntAMG6/sC6TDh7DOpk7YNKxJSNposYD4PrPlqRDwG6owN0fLgq5tx5kDQ/tD1uqq6+5cDVu6uEmgg1EWoi1HQ0qClz9o4HLqkf/S/DGWsRQ0tmD+FisQoewamaPPhPt4AhwLFZbtfsQrlZ+oiDCX2TTqtcNxy8Inifz66iWxnObtscmcvuXanmXlBVw2tXd1Ehl0NUMQVjWaKUL7xXK3jnAhRqCxIZh/8EzWEeLzrdzCdfMA1jy4qxiqzg1Lw0jFu4QC+8j7wNF3GgTJgnJgy+qNLrIA6jRYhr6pOXhg8BeOY6llhFdxU1sCd9T3qj3kN4d596t4F3v13fXXrhJJhc1hgXACuxd48G1rvd3tkNDLNiU93NEOEC/LJ6iazGAq18ww4cuOrV0/6NnKwp8zLQ+KLBL7+ZrY7ef8dxTgLo1CKxVvl4D/bd+xBva1bTBTOpm2C9QDmTnrc2LfhZ/Sh/wmn7XD/IordT8XMXZ+SF9/o+mLPlEHTma8DqXnhYK47A/L6mdALodbVgwQQvms+3sagpDmoKlnVzUs2/sfRWwXqEoz3G+Mafr+q5NRJQJaMUIMCXIiPQM8pNbY2g/GhrYeXBA7puPujy4n0arlYeigD2dgn+hoheiCU9Wwq8C6caLzDqIdZiz19CDbDAvoz5qWEMiWTRGjmy9fWOXYXO+5epm/6opiNcbwMXP0EgRXQWRuYWLcM1WuerKr8Lp5PVhMIyqvGN2IMcBI3qVOXHIGAhJ1hpt2yN4LouVzlcKsJlTR08HhSiDAp3G3wWXFygsRepB36f59dUIcSPS+Mijwsp1flJTR3r4CsTmzQO4bfFJaw1ad6KOcarwEvcpvW9UV57G8x9WL7E6oujz/wVhzrsToLTql/0U7Gyykd5i+ur24AFqQniOzmOpx7IZ8upHChoVDOao/5uDuQYrMe7Am/8NaxZ0Tb5PgxWi4RSvWhLQAO/moTQzgClej1XqletKBpSvbQyO1E1mOsiskEiG6RtGdqWoW0Z2pap2ZbRve1TSWarXbgpme358WpJOHsMW9+nUQyjPN/GSfg1+CFIEnDpB5XZZuwBpbkdBtMaB5+QLSHb50K2jgJpwLcWO7IDyq2qkbAuYV3CuoR1CesS1q3BumYX/VQQr+OCTrj3+XGvRVB7jH6vYaoHDX5NHSDsexjsaxp7gr4EfZ8L+rrJowH5mo3IDsC3okKi9yCUSCiRUCKhxI5RotGVPRWQ6Lb0EUZ8foxoFtMeQ0SoNUnj7Tx9tV4Mf7O0tjcEHg8DHmsngpAkIcnnQpIthNMAKx1szQ4Y07V22miljVaC0AShCUIThK6B0PWu/qng6RYOAIHr5wfXDgLcK6R9psDfDJ+tI1ZFwhgcGJwTypwbBhjUOJ2BIciA8tQ7Zx+eS2KCAt7mdCTn8s/zs4IyeNfbNbIuMC+iKHTL81dpisdkOSHB76UX/8Et38XvegDgjwvvXKsqWnsXciI56Ye3iAIOGoPfADLmBcTQvJCutLSkczHVCbdBOaSczV4z1cubj0qRz4ATdoxD7rUX55ZJ8JVXd8V7XkC42hVFeFulg35mwKbVHFBj7+W/ZawevLK34qkzKxBj/YDFIcBK51JTwDL7i8VI4iOuruCiFYqiXixmYiDke5m+guBJJvwMxbDnLj3wBsN1mIaAHtgn09JL2BJmadV4rJvoDDqW7aLKQpmzS+kS0RhG8mYrnTc/JuZ7Kn7Wa/2ZASx+iPjgqW/jDdAGwmY+OQsN+2N0ZgPe5Q58qhVSXlKjCyr2iY28oEhDiyJkVloJXJKYo1RuGGe6mfIf5dYpaFGGU+xTplif6UVROy5coj5OIZBKwRmbvAqjnhr8ByZsFjGT8ze1TyRbM/I4B/uz/BSo2CqaI2nabLtBgVGKlL6ydc7k0hdN6TXO9nVGVXKlxRXZ1xhYzNlMLr1ft0nqgcvH1zzwhzf+HbSm6A4XcUZ9Q/hw/yOTQ/ZmvTlvopweystltrMmNYI7L7x3HEZwGC4f8hbbgHECccjBgsbM3eetPCuhCd5UVpOsIgTrCq6lFy2zB7DjN7+sv6yjx/WNVomMXvvefBUG65TFsNPYXwPgj+Hv1RNvy0SP9ds7D2tC1vyR+NCAJ7hbIr7XWvX36M7z10/ebLu+99cLQNoz/iRq0PwLNnAOTgUM1YP/BVCSPjSBn4QwrOhcLYLb7d0dhtqKz2glfvzpw9urnLQIbFVGPiaRH0wmxlKQf+s2EIRJ5dj8zWZ7Cz76t3xgvoWB+TYjm/y2FE3ZPN3IGdMC6XxcmKm/0piSf2IMSf7qE375WfDUWUvnq7ewTq8MQ45xogQbgpENOWmXrkGVsWmv58eIDSMOveByDEHDYIDW0SK4wdGE0fYF7yOON9u9KANjXdZmWP7XZLZ5gpVgPeGkcQD2YZRnTDqYcNioulxZ15bnv0jR80bQaiNUkQvP2JNRhz/+UtA66OSFULyL/1ife/9ifd/FxeRXsFBZdBj7cAudmYAMP/jpLCPGyjTKjJPHFj3bKTxWEw4TPbRFt4rbfmznCJAvygTOuAc6iuAAhOHRB/uTRpZKwvV8tV1wY3exgaEBR2EiURN3CyTiAC/FUglSokELcAVcc1bR6I43A8eZb4N9CddoPi01nCsW6PwvgtkxTC8AwW03yLYZrDbL7Qrrs9SQWaRLtCcMHQW/bSKYpBDDIQ9gddnSZB0HLhLwxMQClBkMni7Pt61E+LzGuTUHCkFNzcJTsEUKQSLufBoL4AMmY6RWNLaWVp/CpWgRzFewkom4mayNi+/YlSb2hfcxUNZbWSdfnBO+gnJ61nv/a2CpYh49BN4SEBS0PWIyh6u/JHIF+c9rgCcsldwIRb1hJHs4VFkEQWxT4+e2MKRaXm5as/cxjth1cfM3TCx1iBfJUZh4H/D10JfoEZlmFwH4ehHqglWXE5T0Jw9AKVPn4njisg6fhrF3w2m8biy1YCAVzBsbSmjzGrsiWCvnuL6yvXVks7TGrV+osVzc2uXvBWOW+VPmHAQh8dKt5pHhhMm1PXLahPFzef6zso7kioyzqw3XbrptXzhYaoibTjN9thg8J3V2V8Su3IruXYvmU3xYF6NLN8O+WyWgDQjFo5+gK+2X1BtV9dGmkLmVhcUROpcBF/MUduDd7O7hdObldObpdOPtdOPxdOD1OHo++/F+tDB+XTjAZeMelATHbwM4OX0CwYBesfHDNfj659e4gt0G+Zb9X/hwowBtkwDHWpMfVBswUjCdyly5RzA4Z2a+YSrGcPImwCQz1n5UxQX7kztSen/AP9omnMg4CQJuAMS6Knj8cdtDRIQY57SIAkOb2XvPdB+DNUGIeYhYP4IGBOg2rGEmg8WVJ9srGO5X4QNIVrT0vvvzn7XaeAlZaTLx3gdcvViZxMMlQu+R592n6Sa5+vbbjEsUPBv84y72H1B7Xt5tQccT/v1LXtW3Z2f7WWFcVpZmC4pZ0pfnv7PwsjrZ48lsJrbLf7+48i68fwE5i4uPSFL10hdj79+8P/PNqYsLWLzMrz1nPiT8T0oRY38WySqFec+nXUznZS4kqCFglDbcdYOy2dTB0mh+r0kS2s28be11X3OL41YDw3Zc+dqveG0trNo7qxw8pyx0LQ/VkfW/+knwNqM795Oc+1y3RF24vMM1RNmwWKxQ/r1qgvJPHe1Pc5/aXa+VxvRVqXd2X3d2W3dzV3dzU3dwT2vc0rbG0ir6V97v2cd/2EyM8WYLa25AHDxEXwNDegArbrg9C8cYNyKya7KSjb8enRW8QRg93GkDh/bGuD93k9u7v7CIk3BwZRRKSYQJUpFVNoNXZaWmLG//LO+4kihSnSfSOndjhwQT57QP/HcXb+Yz/WX65o/03eFZZiLei5wI8Wq5L6QkkzhmAVypGUvxE7sEJojD5RO/XQPTw9HS+uJX9h3utrH0HuXmHSlPeDWXdosoTyPgtfKE86Itk+lz2ba1+OAyzwLjmnJmzLNSriHK1kUW5gQ7gLrKLjrBiOmWK/WfzsrbgkyIFxF/EJQGygUzFrFFQ3TBtkj9OZ+L1dNofCFvLFFqCEUkBEuhS8IQnqcUlaHUGViGGb80SSkum662OigUTwW24puWuml+gU3CoHPxnRs/TsN5uMGnR7DkhbByQh1sH7xchbxNp/hmUFaZpZBPeJbIkc7kyGrzXjwzM8dpmkHXZoaSRYEQgmCabty6NLxY2Wm4siUZOSrEaGysYPKzHycBpkS9T2N0hQzNmMiHjVkj8su8M3WnjDSpO6s9X2RM4r+0bhZPjVvF+fPscJDSitIRQFXQrFOgWgdFGhOWpdb4UkG+MF565eM/Nc5WzWA/MktuPaJ2edakkTPL49VTw1qZ3Wyo2s7s01FFsqxIk6pMB7elTlWaYesVjFPV6Dtl0hkSaA15T7lUTZXfyw/i9lru20TxFO/cM6Va/ec2BO2reZRJu0x15OIAgKi8USJugtNuynZITaxISawcvG4P/J1Zci15jydZlr6owfA8Rw9XuGSu8V4z30vgleB/C98vueFOODuBwZdBWBFFmi87QmvYD3jhPbL7ANfipjSR6gNQDU0G2r9zgAxrcCLm3ohpMbzhpVhRk+0te1mQnBk3DtkqHvGtUrTDLHCIv8sa4SWgPND/ZIxpPVG8YBuaUPY3tqhbzyXLe/KydQZ3GVkWT3i3BifiE3/uJUzNNvh8pruuCVgBdpal2of9pgN3VmizyZ3VTinUuKY2H1T1PbcgQ5+c/WbXpfnzVXvnHPS19jyHtIBGw7fXYw+ao3t51uosgxmMjM92coZaeNpSqv+EwayA4T2llscgU3LpcUsbgXg/Qd0UxiK7FzDlMUKlFq54LNEUD7NLR3CU37Do3XCTdnPJNilug1X0OJ6Q8396zn9D37282SXuqmWyzi6slRKuLH7o2/1J8zjZLbIYpDS9UBzDStiltN+ZSqrm13rbrPrQBKMnMHchSi6eFPTBHfDLTkmhTPn5sSEGavZs6vfYy6vMh1fv/3327s0MD3lXHWqLR5Yj4VWD+enPn5VTyWPXc23WY1zKUi+9OAJyhwdymkruEqjaMVhl8hOscSpD44cBQ50hGOHV3WFoGxTKchCl5MrrHoQQIJ9GsushOWeOlfGkCvpK4hQVk+TnCiWFiaUdYpEtzTC38PJrJ4ybo/bywlTQQHnAyDKADse2ao9+5We7PuGPz24gnbsEEm/y88SqT7AzrK9zOqq9CEfPo6X3Ue1rVGde7uqJ1HgjFkKUikzKS8ezHIY5qndF7E+I89dv12n8tIkwgWzJNnrXL+WZPcBlKbJaMRQDVghz1TGaEcLf3h2mqrEvBIjKwxh72oBrHX1ouvMl5MFoHLTYyIQZe7VlI/WP8ZmmTbJ4kcuhcDICz8RvfVhk04CnrtyId91MCuA7Wi/D+CHbpBeQmIe02FkCdAJ42Oo24AwDmH13X4DNYjIm9kPlYu1Gv/prMBN1iuDJZuXP2e74jJ8fnPCvGbDzcT2z+kq1ZBbyOctK43BUNWuczHx4lb+yIiszSpIQj15mJHgA7GNvwc6yLwJxJgA3qpUeeO/enOknC3yeSIDonLmclyx7n6Uk+Ksk8mDxT4PS60Kd8E9MEDtHC+sbtMbjEQnWtqyP+Nt6jYkO/sK7w0o3m9IBRRnxVHIWEDbApzwxQ+lRwhvtjR5RdIJS7zDBc3I38fwlcjfcxLd4MOHrDXRufu9HifcQrb8ETyy2CsgATIT3vThbUuqfn+BBXH7Ak/nBpQOw2uFIto4VFg1W2JoQw0zEe5ZD8DpaBJNffnz1j1fv/v7qr39/a3DezhUx8S5+N8vrHxfi9M12vZhgzvtTtDXkFp1j2uscFXWBc8RO6iq1cyhyKbaj/SfUUxnhMlSGxROWAoR6nqR4FpUNL2YGnFeeU2eJRUhtuWZyxIaWHSEK5piiAvAJbT9SQE7U6IpR9/8klF/oelg63SwT+H786QM/5iwIPXkBkARY4Q87pe95yy9+Lzb8jwtpeAsdzQ9VnRvqEgr5F2leL343jRKrusNJyUoaEnKyE8azh3CxWAWPIHWSq2G7nmV5Oukj8kWlUUbtIneFtLgF24mAksXRr09cabfYmqL3lmwXU5BbS3gpkpqYmCpBrI2woTrOoWLuMsmlAN22zZu6eEEFgWAN+jS5RlP1D1ffsjIvQRsAl62TRl09LINUVQxhx60V+/gK988JRxW4yrhoVUlUpXBUikHLLeOGAqedH+6C0IOvM7uRepSPxdia15jLkOcrKmIMwyNyGNHAKkR5TtR5L7wfxf4YOzJr3s/hyeqlnSjlALLYv7opL7MzdLuyBtwwlgtTqivbwWbUHPwEs8TqnsTqE+8n3A3JRtxQibX5sg7JsSjIUeawmBm22wH/sO/F8Wt8ip8xuo8S4UzzP4MH0KCvQSHcbayPef/hA54Y4DthTEOZKCXBWuxkqp4zPBHASv8EOvwXQ30Jbr9xWMT4Vy+wzhVyNgd84FikGHxrk2jnx4/uYGq2txivEaQiL/H0AbjX0bdhkoDif/vf//w/vzuzH0/WT93nq18+ILhTUr/+FW2YVt66H1VSCv7LBJSLoRf7MlkKBU1F0dIX3r+YNyJArZi057Ekx+XQ5pAWNIX/sHFuNPS22x0l3MdxQscjhZX20/V4C3/Xz+xd7DB+VkZ5L+uMIPXJCX1YLoHJwAhmA74xrh5LUKsM1znjgcjSN6UKMSAAJdEr4UiV18+CXxLeLyLMYbp9QsfZ365S0/kKzDgwqDRKGP+PUObv/tv//L/+Tx4qSKDtgZmJ4YXcgWabz3hyghMSeTwxQEITPMWBOVOIFv2lYf5cTvNc5Kd5stn5j/VF+2Mxo3Hr80j8IM+nqsNBxRMSn52iqEhXxnkiNCHE+b8TOsQH4U/lwtZ8OFaSMUYZlIlzgVhmN28BD8wU8zwyaqrgt2C+ZWeWvoa+kYQJoPGviYveGs+MSO3MmMYsa/clrdnHuWa33dHZYVfHmlTgvJZnH7Ngov38ksR6LIIvBngkfo6v7OzRLDQyLqVuzhj+3IWd9pq9+xDstKzIjuS0dZXrcZ4SiBsm5aw2y+0200+WcbYgG0MlnGU/n5NvNovQuQdViK6V6FqJrlU7ud1rulb2k9haO2Nr5VabyFqJrHWoZK0lCSauVsOgE1drXgdxtWqBlb5ytTqotn3ZIKrWPlC1duZfdOlj2JM9iKmVmFo7d3kc3Z69uD6mg3pE1EpErQMlapUSTzytHvG07p2nNbOvRNPaKo/laGla3cwQsbQSS+upsLRmpnIPJK0bP0mGy7tamQDRNilhh8SJXrOuWrIkeky6ygX/zJRz0Bu+E3XT7Fh5KyWXRnYKt55kw5lgw5ba4M6t4cCrUXmqqRFJZ8xasT92lJzNJB91A0kkz2GyJsjo1JD1KUQ9YYZ0OiBmYi+sXAm+2X1RGCR3YTH36eipC02m5KDMhYXxJuJCIi4k4kIiLiTiwmEQFx6pIz8Q3kIN6hnaTrSFHaCqZsjKEV3VIiyL5SixFrJIzwnRFlbAMtEyFYwQaSGRFhJpYX3MYDCkhXuJXndOWWgJGxNjYXkxJ8ZCYixUekeMhcRYSIyFxFjozlhoWWtNMfuBExZWQJ/azYRGuNPkFxFfYSVfYVXwwHU/xZIgYR/eHekKK+SJ2AqJrZDYCon5yHJAj9gKia2Q2AqJrZDYCq3hU2IrJLZCWrPrtmSIrbCarfB9kL5a/Mozz3YhLbSk6u2BtFBt8Y7chRnboFKl2DE9OsJC80S3200/Wd7CouwNm75Q7ctzshhWKOHorFEugkM+A09VyHIw2J/lp0D1VhGebVvMthsUIaVI6avG23tExkhkjETGOEgyRtVGESdjZ5yMhaWIqBmJmnGo1Iw2QSaGRsPYE0NjXgcxNGrBpL4yNLpruH0RIaLGPhA1du10dOl42JNdiK+R+Bo794McfaF9+kOmY4pE20i0jQOlbdQEn9gbPWJv3Dt7o25ticSxVXrP0ZI4NjJKxOVIXI6nwuWoG06idNTSRFyyRHbM3NghyaTXBI+mlIFB8DwWlMLEXuTOnyVZbf5EZFWnR1ZVxc1mUo7RuAvOK6cDYr2hOTLsKx8rbekgCIP2ywNUk3lVaZ4PTQfkTJ20M2/QZRvioJwKp0Ct6pzs2BOG1Z35bmT2/kfBRZmx+AlnMbnh7vh8BX5oRk8pWCkvMYnp0XSU5JGd15DsliJVCEAb2g80iecAHtbgY8y9EVNpeMNLsdQm21v2ssB0VGAZ8uVd8jog2wIGFfF3WSO8BDQpxbxmTAuK4gWnBFmGv7HVfmI7gSpphbIFCPckWRYQPwv4iT/3EqZmG3y2s9e6OL3fdOb/DpLL1pgPe/SUthX2+6DMthbviQhuCTMQwS0R3BLB7QAIbo8b+Q2E59Yc6jJ0gehujxTmnjzrbT1izhpYAjHEgUscuMSBW5+/ORgO3ANs93XOiFu9z0bEuOVln4hxiRhX6R0R4xIxLhHjEjGuOzFu9ZJr2gAYOD9uPUiq3aBoBFRNzhLR5FbS5DoEHXbco7GP8o5sufXSRaS5RJpLpLlEwGc5M02kuUSaS6S5RJpLpLnWeCuR5hJpLq3ZdXs4RJpbTZr7IR/brvhzlSoHRqLbMjx0JLS6taLQbueeGHaPgGHXIhvPSbabRf/cgzTEUksstcRSqx097zVLrcXuEGFtZ4S1NstO3LXEXTtU7loHmSYaW8M0EI1tXgfR2Grhnb7S2LZSdvvSQoy2fWC03aNX0qVnYk9RIXJbIrft3FFydJYO5DCZTisSzy3x3A6U59auA0R56xHl7d4pbytsMLHftsrTOVr227amiohwiQj3VIhwK8wpceJqWSANk0AOQI9blUNCHLn74Mi16QvR5RL1FdHl1p5OakuaVL3BfbTMuSy/09LkJjRFShX74yp6RuIh97yrSit/RBxEki3GxEJkOfTaKrOx33S6GWdOZW5DOx6hPHOzorfqcNcQDo0dzpsaDbWJybahq0qktidIautmNInflvhtycknflvityV+W4Jq1gnqL9VtbcTK0BtivSXwuQP4HAYBbiO4K4/PmcsQLW5hhokWl2hxqwMbA6HFPeyOHzHkEkMuMeQSQ66yyBFDLjHkEkMuMeT2lyG3EYqq3fhoBGpNfhOR5VaS5TaLVbju/VSlodlHfUfy3EaCRzy6xKNLPLrEyWc5tE08usSjSzy6xKNLPLrWAC3x6BKPLq3ZdZs+xKNbx6P79CF6LTeSX+vguTmL7jVrS4cEupzMYJIdxA0eNukTK/MWf2vLmVtT7RGy5FZOdLvN/WPnyK0RkuGy4hpkgThxTW4GceISJy5x4nbEiWuwOsSI2yEjrsmqEx8u8eEOlw+3RqKJDdcwCcSGm9dBbLhakKa/bLiNVd2+rBAXbj+4cPfkj3Tpk9jzT4gJl5hwO3eRHN2kg7hKplONxINLPLiD5cE1awCx4HrEgnsAFlyL/SUO3FY5NkfMgdvGTBEDLjHgng4DrsWUEv+tlr3RKHmjeULFDukefeC6dc7w6DW7rUkXzkz5Ej3im7Hv8x0rMahkLsmOKNdTmjSgM3HL1nAnMnEgMak86zVuwk0Ts1bsj5sm55LJZ+GyTIXC87UaUG82TZfqCfGm0zE6M0Nlg8Xkm13WlT5TUtZlfJ0ACWW9tdkHBWXNwBPpJJFOEukkkU4S6eRQSCdPAgQMhnKyEkYa+kKEk3tAaM1QmiNSq0VrFktDdJPuEC8jmzSUIKrJwuwS1SRRTVbFIwZENbnX4HrbgIZzVJvYJMvrP7FJEpuk0jtikyQ2SWKTJDbJEpuk8yJr2ggYPH+kMyyq3bFohFFNnhGxR9awR7oHHlw3bSwZHfbh3pk20lneiDSSSCOJNJIIqCxnG4k0kkgjiTSSSCOJNNIaaiXSSCKNpDW7bvuGSCObkEa+/Y1Hbog88kTII60T3m7Lnkgk7X0ZDImkJhNEJlnPTkBkkgoEIzJJIpNsTyapWR8ildwTqaRu5Ylcksglj4NcskKyiWTSMBlEMpnXQSSTWlBnGCSTjVTevswQ2WT/yCb34Kd06avY01aIdJJIJzt3nRzdp4O6UKbTjUQ+SeSTR0E+WdYEIqH0iITywCSUBntMZJStcndOhIyyqdkiUkoipTxNUkqDaSVySi1LpFWSCJFUDp6kUteNIZFVmvcRibTSnAniTolSnx1C5JV7I69skq51XCSWjosOkVmeBplltRUiUksitSRSSyK1JFJLIrUksDBYcksr/DT0iUgu94jomqE6R2RXi+4sFojILptDQiPppVaSyC8Ls03kl0R+WRXHGCj55d6C90SCSSSYRIJJJJhEgkkkmESCSSSYPSXBdIJLtTsejTCsyUMiMswGZJhuAYphkGI6yR+RYxI5JpFjEtGW5UwmkWMSOSaRYxI5JpFjWkOxRI5J5Ji0Ztdt7xA5Zg05JoCwv0fru+vtGq3p90E6v+8VJ6a1iKnl1zqqJKJM1TUsEWVWTn67XX7ix7T3pc/8mAZRIFrMet4EosVUwBfRYhItZiNaTIPRITbM7tgwTTadSDCJBHOwJJg1Ak3cl4Y5IO7LvA7ivtRiNr3lvmys6fZFhSgve0F5uSdnpEuHxJ6TQkyXxHTZuX/k6CMdwk8ynXQkgksiuBwqwaVZAYjX0iNey/3zWlqsL9FZtsq2OV46yzZGilgsicXyZFgsLYaUyCu1LI4mSRwdJVYQkeXzE1ma1KPn/JX2DT+irTQnaFSSnLglbRBbZZdslU1zpgZPUtlgcfmm83WGCCv7TFhZb3+Ip5J4KomnkngqiaeSeCpPGhQMhZ6yElQaukKslN0DtmagzRG41YI3i5khMkpnxCfPptiBDVFPEvUkUU/WRyeGQz15+NA70VASDSXRUBINJdFQEg0l0VASDWV/aCidgVLt9kUj0GpyjIh9spp90j0Q0VvSSWdpI65J4pokrknirbKcgSSuSeKaJK5J4pokrklr7JW4Jolrktbsuv0c4ppsxDX5UUsNaE42aUkabE826XwVWDNeSUumC2++2HM9dnLJj5ZEkGbb9sQuae/LcNgluSw8J72ki0aOzhqlNjikR/DMhyylg/1Zfgr0cBXhsb/FbLtBMVKKlL5qvC1IdJlEl0l0mcdAl8mNFfFl7osvU6xSRJhJhJlHQphZlmhizDRMAjFm5nUQY6YWeRoIY6aLqtuXFaLM7CFlZnf+SJc+iT2ThjgziTOzcxfJ0U06iKtkOnZJpJlEmnkcpJmZBhBrpkesmYdmzcztL9FmtkocOhXaTEczRbyZxJt5oryZuSkl4kwtJaVRRkrzLJEdcliIJHMvJJlCF0xETe5UYZLA50/Ey3V6vFxO/HOmgg0JvZzOpPWVw6mwNX2szK6DID46KJ+RNa+r0lYfmtDImQtqZ+ajyzbURzldTxXvrEM6ZU+IZ3cm55GnBT4KDs6MvVA4jMkN983nK/BFM1pOwcZ5iSlSj6ajK4/sfIhk9RSJSIDg0KKgtTwHJLEGz2PujZiSwxteinU32d6ylwWmownLkK/1km0COSAw+Ii/yxrhJaBbKeZRY9JRFC84Ucky/I0t/RPbAVjJgZStRrityXKM+NnDT/y5lzA12+CzM6lvteP7zS4+MBH4DofA12i/icGXGHwJKRCDLzH4EoPvaaO/YVL46iEvQ1+Iw/fYMS+R+LrDZzOLLy9BNL7aYTai8SUaX3sa6FBpfLveCCTKXqLsJcpeouwlyl6i7CXKXqLs7StlbxUsqt2xaIRRTZ4RcfY24eytDDzsuGljH+5uSXur5I1Ye4m1l1h7iQHQcg6bWHuJtZdYe4m1l1h7raFWYu0l1l5as+u2b4i1t5q1929B+vEepIUh2F3Yei0XxLRn67UXUZtcuj+xGXdvXbuOjrfXMt/tduiPna+3TjqGSthbEILnJOrNYnrugRYitiViWyK21Q6X95rYtmBtiNC2M0LbohUnIlsish0qka1VkonA1jD4RGCb10EEtloQpq8Etg1U3L6MEHFtH4hrO/c7uvQ97GkkRFhLhLWdu0KO7tBeXSLT6UIiqiWi2oES1eqSTwS1HhHU7p2gtmRviZi2VW7M0RLTNjNLREhLhLSnQkhbMp1ERKtlWTglWeya+LBDkkYf6GjdMzF6zEdbVIUzU15Db2hdTNtyx0rmKclBsoPC9awhzowhdckU7iwhDgwhlSevGjGYxqwV+6N9yWla8tE3MGby/ClrUo7Ok+mevtQTfkynw2wmDkenNeOb7paPPjM51uZhHT2VY5WR2QeFY92IE4cjcTgShyNxOBKH4zA4HI/c2R8Id6MFHhr6QJyNHSKwZijMEYnVojGLRTl5rkYHCCdaaAIsxM1I3IzEzVgfZxgMN+NBYuOtAxXOQWmiaCwv90TRSBSNSu+IopEoGomikSgaSxSN7qusKcI/cI5GBzhUuwXRCJOafCLiZqzkZnQJMLjuwlhSMOzDvCMno4N8ERcjcTESFyPxOlmOFBIXI3ExEhcjcTESF6M1tEpcjMTFSGt23XYNcTFWczFikOsjvDJb93rFx+h8HVYzBkbn+zmOhICxYpLbbb0fOwlj3TXuA+VgLMkB8TDWEwEQD6MCr4iHkXgYm/AwliwOcTF2xsVYtubEx0h8jEPlY6yUZuJkNEwAcTLmdRAnoxaM6SsnY0M1ty8nxMvYB17GvfggXfoh9jQS4mYkbsbO3SJH12jv7pHp5CDxMxI/40D5GU3STxyNHnE07p2j0Wh3iaexVd7M0fI0NjdPxNVIXI2nwtVoNKHE16hlYjgnYjRPjhg4S6NztkaPSRrLOtBvokbbvh2RNZozLKqoQlyyLoiwsUPCxmbpTkMnbXReOL7ZZQ3pM1VjXbbW0TM11lmYfbA11gw6kTUSWSORNRJZI5E1DoOs8QQc/oEQNlZARUM/iLSxYyTWDI05IrJaVGaxLidP3OgI5eTJG/1pInAszCoROBKBY1XMYTAEjnsMlrcNWjhHqYm1sbzeE2sjsTYqvSPWRmJtJNZGYm0ssTY6L7KmYP/ASRsdoVDtjkQjTGryioi4sZK40TXI0FfyRkc5IwJHInAkAkcig7KcPyQCRyJwJAJHInAkAkdraJUIHInAkdbsuu0aInB0I3AsHZ8h+sZjo2+sJAcg8kbx79jJG4UUEHVjPUcAUTcqwIqoG4m6sQ11oxBPIm7snLhRWnKibSTaxqHTNhpkmUgbDcNPpI15HUTaqAVg+k7a6KTk9qWEKBv7RNnYoffRpQdiTx8hwkYibOzcIXJ0ivbsGJnODhJdI9E1DpyuMZd9Imv0iKzxYGSNis0lqsZWGTJHT9XoapqIqJGIGk+NqFExn0TTqOVbOKZbEEnjgEkapfwPg6KxuD9HBI3mLAoXWhB7ZgXRM+6BntElnelYyBlrlguiZjx2akazbSFiRiJmJGJGImYkYkYiZjxVN39gtIwlcGjoBZEydoq+miEwRxRWi8QsdoUoGV3gm0bIKJ4lOsbCjBIdI9ExVkUZBkfH2HlQnMgYiYyRyBiJjJHIGImMkcgYiYyxd2SMtenfRMVoQpsHpmKsDi30nYixUsaIhpFoGImGkSidLCcKiYaRaBiJhpFoGImG0RpSJRpGomGkNbtum4ZoGKtpGD9G8ZflKnrchX9R1lGCmPsmVLRSO8oWXYs4QQW1YimrCuPm3IkRjFhMJcEJlGKOx2KM8PUFwreLhIdTY24nUZa3D9wswmL74H9By5ds48AUar6ZZdkSs5nkk9DO+QvlKOdWZAUnsLbiepWUdaSqFKjKqPj9eFcGyLJ0Nd7mb87puFeSRmeRGypdo+wH8TTWkwMQT6OCvIinkXgam/A0SkNDBI2dETRmtpuYGYmZcajMjCYhJkpGw7gTJWNeB1EyasGYvlIyumm3ffEgLsY+cDF26Wh06WzYE0eIhJFIGDv3fRz9n335QKYDgsS+SOyLA2VfVISeaBc9ol3cO+2iamWJb7FVKszR8i06GyMiWiSixVMhWlQN5h4YFus2pxHQjw2cjFYWq7rkhqOlr3LfpT56IivLfvY+GKycR524rIjLirisiMuKuKyGwWWlpSoQidWhSayyRZzYqzpir6pI81Nngmirnpu2qjqFVjQudzCJqEqZQyKqIqKqqo3hwRBV1QUyDsdQ1eLIBXFVlVd14qoiriqld8RVRVxVxFVFXFUlrqoWy60plr9P1io0OtlGoO1MqPeAwVFcOGWI708297iWAsuK4GvZr6qxlBMPlBPdVWueIdMZOCIiIiKi3FUgIiIiIiIiIiIiUt5FREREREREROqBayIiojWbiIiGRUT0xl+DMY22yfdhsFokO/ERmXO2+HWfdkgt9tIMUXVrEa3R1zoobEZnJNMNtFrFdlkFhxGa88VM9E/WwnLmcr6FfE9QbH+GySxch2nor3jJ6SgTVRYkZiFaPmjJ7DbAhmd7q+wA3q7kQNYZb7enOlVGoSsqIcNW64eIj6L6Nt6A8X6Zh+quJB0o35AmBc9JO1Stf6OzRnvQDvvYfIs623tnf5afAq1bRZh0vphtNyg6SpHSV423dYhAiQiUiEBpkARKmpkiHqXOeJT0NYnolIhOaah0ShWyTKxKhuEnViWV64BYlbwhsCo1UnL7UkLkSn0gV9qD99GlB2IWHQUHEccScSx15xA5OkV7doxM59eIaomolgZKtVSWfWJc8ohxae+MSwabS8RLrVJ/jpZ4qalpIv4l4l86Ff4lg/ncAw0TJ1WynI6Q6R/ZMYhk4ytHG5gTCCOD23Lgx94YN/NucqP2FxaDEn6tjEtNlNyPVKZ+w6uyUvwQ+FneFyWRxDGPZPfcjh0yUZzTQqwnNS0HOmwHOOW2kpJs4nwL+m6sEDswQlg5CDJaCF0fTOw27vxKkvXkT0RmdHpkRtC0Go0YjbtgQXI66dMb4hvzFvMR8d8Ukz2HwB+zX1qY+mysSst8aHYYZzKdnWlkLtvwyOScKKrZa5T4WJHwWDmM5i9bptKNd+c+kVn9HwVNYUbwJlzE5Ib74fMVeJ8Zc6EgLLzExKZH0xGTR3aOQxIfivQhQGtoRdA2ngNqWIOHMfdGTLHhDS/FKptsb9nLAtMRgmXIV3Z5xh9P3mNYEX+XNcJLQJ9SzHfGVKEoXnB6iGX4G1voJ7ajnZJiJlt7cHuSZQbxM4Kf+HMvYWq2wWc7vamjq/tNl15vn1lP6zJkj57rtNp674PytN5nIqJTwgZEdEpEp0R0OgCi06PHewPhO7UGtgy9INrT48W3J89+6gSVRRvN0IW4UIkLlbhQ6xM4B8OFerANvrZhC+edNSJGLa/7RIxKxKhK74gYlYhRiRiViFFLxKjOi6wp3L9POlQQ51oG06vKHb9aGlMnUFS7I9EIm5p8ohpmU/s5oUqGU2UkXDZdGnV1r5swzTZjOtqUsQ90kbqqGlsVCJq4sDnJWKW4VApGy43ohiI4Ju5c4s7NvUniziXuXOLOJe5c5V3EnUvcucSdq5Qn7lxas4k7d2Dcue9T8I6vQSPjJPwa/MAXlWEw6Bqb3hGPrrHuY2XTrZGBdjv1x86p21QseUVDpdo1dqoPhLtVikq0u0S7S7S7RLvbG9pdo7Ei8t3OyHfNqxRR8BIF71ApeGslmoh4DZNARLx5HUTEq4Wp+krE20LV7csK0fH2gY53b/5Ilz6JPdmGSHmJlLdzF8nRTTqIq2Q6cUnUvETNO1BqXpsGEEGvRwS9eyfotdpfoultlWV0tDS97cwUkfUSWe+pkPVaTSlR9mr5K43SV7pKKRk4fW+7zIVBsPqaFYe4fYm/qz23bzt1OUXK36rtbSL+dTrgNkTiX9fcsEoTTvS/xf0bM/1v80xNIgEmEmDNZ84Ogzdynr/p3o/uMyFwy/Teo+cJdjH2+2ALbu2FEYkwgRAiESYSYSIRHgCJ8IkgyIFQCddE0wx9IULhY8fNJ08r3ACCy5ZWgCGiGCaKYaIYrk9HHQzF8LNsSLYOiuy4E0gsxGVngViIiYVY6R2xEBMLMbEQEwtxiYV417XXtMcwcHLiBtCqdjOkEc41+VFEUVxJUdwkeNFXouIG8kZ0xURXTHTFRH1oOVNOdMVEV0x0xURXTHTF1nAt0RUTXTGt2XVbQERXXE1XfA1Fu2QrvmZNOQRbsanlO5IVN3yXHkE6EvbiapFolw9wsuTFVZIzVO5iU5+ek7o4iwy6h2uI6peofonqVztu32uqX5PRIabfzph+jTadiH6J6HeoRL91Ak08v4Y5IJ7fvA7i+dXiO33l+W2u6fZFhWh++0Dzuy9npEuHxJ6vQiy/xPLbuX/k6CMdwk8ynYgkkl8i+R0oya9FAYjj1yOO371z/NqsL1H8tsrMOVqK31ZGihh+ieH3VBh+bYaUCH61jI8mCR8dJWHskDfSa3pft6yQHrP7GpXmzJRj0RtCm4ptwGNlRJXsKNnh53raFGfKFMdUDne2FAemlMrTY43YYGPWiv3R3+R0NfkkGNhHeYKXNV1I5xxtnF/VE8pRp3N5JlrMJkvON52vPoMkxaxMGzt6TkwHq3RQSsyq2SBGTGLEJEZMYsQkRsxhMGKeBoAYCCFmNQA1dIX4MLsHd80AniPIqwV6FjNz8nSY7uhQNLQCBBEZJpFhEhlmfSRjMGSYzxC875wK0y1qTkyYZTeBmDCJCVPpHTFhEhMmMWESE6Y7E6bb0mvaWBg4EaY7qKrdAGkEcE1OFPFgVvJgNghauO4BWXJL7KO9Iw2mu7QRCyaxYBILJjFqWU5cEgsmsWASCyaxYBILpjVOSyyYxIJJa3bd3g+xYFazYL6Wm8iv1otGF465JGB+yCfuELyYtX3ZF0mmw4uPlDGzgfi0yx84WfpMZ5kaKpdmbQeJWLOer4GINRXsR8SaRKzZhFiz1gIRy2ZnLJv11p4oN4lyc6iUm42km/g3DRNC/Jt5HcS/qUWW+sq/uaPa25cbIuPsAxnnQXyWLv0WewYNMXMSM2fnbpSjK3Vwd8p0TJNoOommc6A0nS7aQJydHnF27p2z08kuE4Fnq6SioyXw3N18EZsnsXmeCpunk4klak8tjaV1Fss+kkp2zYzpNfNni1SXHtOA1mubiWrKnexMUhD9iZjFTo9ZrIpXz1mNRuMuWMucDtP1hqjKdV/+WGlvefarpclNSKGUKvbHDPWMNE9tUsgqF4Yj4nySbDwm1icbP+9u2Zw9Ieu1ZK5m7ESVyRntGJvybNWK3qoDX0PtNO6Sg7i1b/zNft3kQbITu2flHj1VcVPje1De4ib+FZEYE9QgEmMiMSYS4wGQGJ8gNhwIo3GDWJqhX0RvTLj3hLiOWyJt0WpXsEUsyMSCTCzILtGVgbAg92qfs3N+5BZ7i0SWXHY6iCyZyJKV3hFZMpElE1kykSW7kyW3WIdN+xwDZ05uCdFqN2caYWeTr0U0ypU0ym2DI677U1Wpe/bx35FYuaUwEssysSwTyzIxNlrO1RPLMrEsE8sysSwTy7I1Dkwsy8SyTGt23d4SsSyXWZZZbMa6729NtVWSAK5wN2y3hFl8c4OADD4+eQX/+WzYOrLUkl3Gyx5D7J4YDpRVN0F8jDYAPaJPn6rflUUJPn++1Gp+hfPA6sAGfP6s5OGen59fs8lC7gkZamPUFizrU06Sn5l3NFt3ALFlaqMS27tGK5l4Nz8H8QPoLZR4E6xDpP0C72EOHo73Ss557DGgGSQYVxbkYZ5OVlwMbv4zUOgvodnqUboof8iT4US+W8g4zjAgDd5L9s2DfxfOeVpQIV4sJeY2ALsa82QfTNObZTHKGSvKv5nNjEJfDF8Ie8IDFn6h++VYRx6/zJVDkF+7zj2Tq7J5g6WExa0ygymnMm/SJAt8+95N4Y6smxKD6SLYwHLBqV+jfCnDlVXaokKZPIUJpsIeO5Nxs5GFcOlvQbYD6SVbLtKcwJVFNgrCOqmKzIEt2zyxbT4+kzw3TGyPYKpuoSqD5TSE2/YezxOxPMUWWkmdd7kDjSUOybC1Nd1PJsrBD5N/+Lcg1cQLeXfCxDgxhcGeyee0ELQiqA0yxioHq1ki09Sdbv2yPmHxLTpZ83xzyTBQBhZAY2CvFfu6fdzNPfzUlo/spy+X7anMsIVgZVAzg0XrevT1yFzRZ7dALOptxiVl9GthPeVWGpY8HaXDwrqJo6+IMx+iODBby0KuZCwJmiWI09UBsdxDxHZnZn9M7M8IvHduCb9k/RpZaEeUtTvbM5TN++PCylbCV0Xc+V9zNgSZgnDBm2oWQqXB9qpZUIifr7j4XdF0KAJDbSt1Y7K3o3ynPMvYmOAO6/jGwEzLsWhwZuZrz/RXrPE3mJt5cynJzL2bAlvJDV8cg5BFiH2tSoMvlfNvMpcKlt8blih6M/Z4DOtG0xt9+TbkJIDvo9Nimm1DvbaPjfuSu9esdaoDfu5CffwID6insyR1JU22XLvd2SKb2HdJyGJRmv8v2rJYQ9Ef56S3MG6HVb9SumHWotKBz6rkfSvabIMpef8VcOoA8IwYswDN/pGfZ+CYRJxlwBCH6WhDDqpUWCbwHdYyikQbxt6NKlTy9TdedPsrGOmsMKxWi+2cJ/LlxyryFy6VT/FaiNtAfmlBa1CCr06q510ERKXDKLvhMis2OxwyUUdtfgrw5BmQiSFBoChik+ozPM5ogJWfmmTy0sFWcRE8e1Hxz3vNr1l5n25vE6/qyTORrZYE2UmtOFgFX32RXi1Ds/4cN9I4Vdg1G3RPMpB573Hb5OyF/AAEWAsqR8sUlVtWtUoikfKHlIb4yrtgzUK+C0YixujNH9hzYITO5ivAId4sC1Rsb0emMxDQ0wl+Kc+oGE6G7SqySgSSXRA2mzmsBS7n1OUJ9f8yPASfMwM1eSt+Md/5hgveVXX3rtUcYlXqrMEhWIu0oKPKP/WREylmAiEjQ2zvhNlipD+TO2R8p+IiKaxDl+wUYXYPgVI5OzObYDpOmD4xrrgsMfclvgGWCsZRySn50xh3IDBPQgihZNqVQSW00mfqliVPa8Z03TjAOFQI4jbx3vFbci6FGy5vBMB1KcYT46IvYtsRc1lfygVEPZWNp7rQl47AYMThQm61wJqbBJyb7TfsD5gZdTDMZ8zfyeETrromBgAL7qNH3GJBgr3Eu1En9gZ5ydk7EwBObAVYrZ7U099PWk9lVG+zjRlJH56p97/wvHlwC3hIVKERY5OKKbAN0hNlmQnfnHr3ppSgWPT3s9xCd+UYG1hAxSywDdzSKOIm1VoQKGtDCJZ/pd2bVHQjMgyufmy5iQtjqCgY/M9yM3T5EPt7796APN0GoAga0s8GU2lG9ll+RKB094lazmWKDDcXFvLB8xONFccalLWMUUeOEKTrhpS17h5z+Vf6FXgT7fNi7c535+WnCi1nK7RF3F0cdYMuts9z+2kSoKndAcjmYZr9dmmmznuF2BMFjI9QTo0h7GHCRY6lzWMUn22S30Xi0hKWbqHUxtKELjHHgltavsGLWQ8ZbYF4ETdq92hq8QqDeRwl7E4VpTK+NJ9pcyuzYWfanE7gLdlnIklPiz4KWiXDwe/yee/xWbEUW9jllInsbEEawnyICvYTnnNfPDPMvBHR2nHmq2R7d+jgsUdU70U6KC5+hIvzUPB3jS6EhQ/nv5pRadZUHcVflqvocTdX5pvn9mpcQuGZAfjkjEI8d5KkZjnSDaakyfqpZPHUWOoqvFNraB3M4Fie+xQalHkykuvnSg/ZsAdrT2xK5gv2s+oGY5n90MDD4SLzNzAXP4jCRXlrL6naOtegTUqpybv89yYktWKo9EMqHccFlIljK1dtpfwxe5XCVCs1i5D/1OO35l6cqcEqWHPkkcPsTktVSOSdxmeVJwLGpj15XMR1UvgSQwd7qC74YqLayEfLFlqx8XnULYp8JMulC6NV/nq79uMnxithoqBA82j9kssYD/W4yaOBLMREdMN+lshsdF2fyl/Kjzh6bjzSBFN5ZTuzonqU7EI9S8bNuDpOhWXPLMBJCFTzpaIEn34RiZqKFfGEw5bdj5ts46pTvDjfGOth96VzzxbDPXj9noCXXrxd6VmMqmIIEJDTOK2eahVlWq84+SLFXlNz7lrXtamL4lljmpKHqoqpBdMCajTNQW6VmZsqv5syp9mJEkm1x0I4N6ou3fBcetFyQ5ZoQfHK2IephgrWvgRPdi1RBK4qd1IhGWQoJ5c3cXWldzPxV4/+Eztuw3c/jOmolyLv9yF4iMJ/GrKPVTI5WEt5pVdVJwNzRR3ZaUu0AansbKHeslY/CmUGrzWdrQI/SWfR2nbsY1RzyduV8SyAmuVfUUEUh3eY5gyIMEQyIUzuzkK9/LNwXVNHFviabBAAp1ia7VnceI/fRoyHACsaV96llt1WzoDsjTbYN/Yb1pYXvxcdkT8mv0v/4Q9v9DsSq2i1jf8YX1RdnZddAs6ZFO/ZpV647XXz89vr2cefrv/9+7//9PGmogZ5RB7jnRi0ywaF3fIR4FYdT+ivqIPf1yroI2+DAKbB51twMRvu2ycRfaioY8s2BMoTM2nA7pcLq9p7V/693Iep3HBhC6x5K2YXD2N8VqnpOjD5ED99iLIjp6/1ncIaoGIsTcBFAS78+r5Jds0UPJs+sXl8i78dB2IxikE9gqmSnlNENMbxeC6EUyO4jtDG2CWCOgR1COoQ1CGoQ1CHoA5BncauRg3GqUI42p5SS6Sj1UKI57QRjyYOTZGPWZoIAVl35IePhLSuESIiRESIiBARISJCRISICBHtGRGByf57tL673q7xPOn3QTq/dwdChsKEf04O/xikwAH22GXnJNGOYTgGDnIMPSJsQ9iGsA1hG8I2hG0I2xC26Rrb6CdtgvTjfbQK3hfP6NWduFFLEZxxPnkTxEdy5kadf4ezNwZxOckzOOo49PMsjuluX/MpHLUvBFoItBBoIdBCoIVAC4EWAi3NfYxGOzJ4+SYyV2WX5TgDl1JJAi+nthdTEoF6/GKTmlPEMKWxGPYWTKk7BGUIyhCUIShDUIagDEEZgjL7zS2T7keJwt8Rx4hyhGJOFcUIAXDHMEWJOWUEY/X0h4hfRGcIvRB6IfRC6IXQC6EXQi+EXjrPHtMBDHJkX+MVH0n4NfiB3x/mjGJMhQnKuGSTmUfumGidTT2sRzkVEnWKUMc0HL3LO6uSZUcUZKqCoBBBIYJCBIUIChEUIihEUKgj/6MeIBUukOI3A+39Aim66mm3q57oWibjtUxFGPQabz50R/f88RKe3yNm7nO4oBrPy7HSEbwF5haG1hXYGrxtw32LFZ637nV3eHd0Q/+84JvvHn4oVi68+Qs+yNoqn/nyZcjr4MbXuPBO7rsRAPO2liBvvRfuGMbo+JrwrqcM/5nnq0GwhJffR3jEivuc4iNF2+AYEbEIRDssiWIz1f42jJPqIKqPF11HDVupGIhftWoZrcPHZC497kDAQ87xmdO4w7DVJYMOpqZDM9O1iRH3DF6etTn3W757z/VWQuNabsBJNitkDdDufkFfw8v5akyOi8NqVu3O1LpWpd9D1xa/BgATvrq7wWohcoZd7EdxxBxdYsMwk2O8H8dYHephuMdqi0/bSa6YuwYLmlpL/xxmk/1wdJsrBYWc5z06z3TRXsss9oG71ebL8Fq52Q4XwrW9Su9AbnijdK0db5A7An+c7qoho2G4T6YD41F5l8qut9IM1Ji4XMJyDEblZOneT8uEmCjZ21mOWl7ylnzuw7ETrjzmx2ceeH5HW/vAS1NUsI39cbs0oTDCFBDcT0DQOObDiAwam37aIUKX2Wy/PPLqni1ouJeLOyxSQ/HCw222nxCNeUOe8aFvuxeoxtttv9tpt5sSlPdiO143ny35uY/AGT9FItCTQullss5WFqCGtLINzedgwLkbw+URGYNT4dI6SUMg+a52MgNGDWjOkzU4E1BFEjVIA6BZgDf++i6Io23yfRisFomzBdDKUTyuw3iceWwpErefSJw22sOIwWmNPu3oW/UMNljstIoGHnGrkxGKtR0s1vY+jeKgNeeTsTStuE558eahc02Qrxh4Wo73lClvGvOBpMybmn7iufMOs9kkid5UXQ+z6ausjmtavZMw0Rp+sDX8dGkbu+BVHHgszUit2CqgVs8v2JKX8bn32dw5gXbjIxxm1E2jOBJkODuRHH0zBL4jIjkikiMiOeqc5EjHDzU92W7DxeSXX969+bwXmiQCucSTRDxJxJNEUJR4kogniXiSiCeJeJKGyJO0N696B6Yl8q2JaomolohqiaiW+uN/K1HBVuu2pTwt4T1ewqvnjFbzfZ2TNg/7QE5Kmxt/4melnWa0EQ2RscKjWvldJYmcgAM6AUS3SHSLRLdIdItEt0hGg+gWiW6R6BbJhBDdItEtEt0iHe/ebyyyA8JGikQSYyMxNhJjIzE2EmMjMTYSYyMxNhJjIwF9YmwkxkZibCRDQIyNxNhIjI0U0jtYSG83zkcK5hHpI5E+EukjkT4S6SOdEHAkfdzfab8OaCNpRSfeSOKNJN5I4o0k3kjijSTeSOKNPEneSI1oTyY6v1ovdkMXtTUR0nCi56sfxsMx9zlOKSGQfZH61U3AQPj+6rpx4lSADWe5CUtgXdU9JBB0NYCu3IKNhY+QzB6RjEZb/cFPviQ7cVb3l6j6G+KsPiXO6i4oNE/ZJZYvvE1nX7/zV5t7/7tJiuaBLQtoKN4tDuD01pJckmO7u2NroiftqfNq5gg9KQfVNFtN8sjLhLJ9cDQryGIbCgM5jF05jIqnqH+1jGJvhEPkffVX22DshapjOUljP1zBm2Zy7EfjK1y98WVXXni3Bs//00OYzC89P03jl7Bih+tg8bn0HjZLSw/e5E2nBn2S5vPDq/f/Pnv3ZoaLypWxFsUDdlnbRtZKigvEtGOT0WitmIDKwvI9qqkH+8bW3Km+/o747E1un6B99koMWMIPQYwLfZ9A3ydCTyfvn5I0eCglMZuMozoLQRxHMZ+Gd2vuito698DxImOQY7KWKbwHgpXgByik2Hcvmd8Hi+3KBN3HxNN8/F4kEToeME+D6JmJnpnomcntJLeT3E5yOx3dTmIcPxlnlIjGiWiciMaJaJyIxsmdJXeW3NnjdGf3z51PrmwPXNmGJPbkyHbhyNZfV9BbN9blaoATc2LrZ7ORC1t7AcXgjry7XyhBDis5rOSwksO6q8N6mHtcyIHtmQPb4AIVcmS7dmSrr9AZhENbdz3NCTu21bPb2sGtvCRp4I6uy2VH5PCSw0sOLzm8LRzevd8xRu7t87u3Da/7Iq+282uETLe6DeMWIfMdaqd8iZBpLpu4rrW39A3PY3W9do8cVXJUyVElR3VnR5VuuzwJV5WuvKQrL5t4HnTlJV152dxfpSsvyWElh5Uc1k4d1n3c4koO6vMzUbnerkqOaQeMVBX35faVmaryqtrTYqiqmL0GDmjFjcd9OIRlvMW4pXiQx0keJ3mc5HG28zj3eYE4eZ7P7nk2utGbvM/dvc+6+9p76oHW35F+Ul5o3Sw28ERLVQ08DFovKeSQkkNKDik5pLs5pKWmO7qjohw5o/11RotTRK7onl1RMdzDckRFo8kNtc9gCyfU6qsN0gW1yQg5oOSAkgNKDmg7B1TeeeXsecoC5HL2z+XU5oZ8zT35mnKch+FkytaetndpmbMGbqWsoX8b7LneN2I4tQoGuZTkUpJLSS5lO5fyjb8GbyHaJt+HwWqROHuWWjlyMPvnYJqniPzMPfmZ2nAPw93UGn3aXmf1DDZwPrWKBh7TrJMRckDJASUHlBzQljeTpiCa18F8Gyfh1+AH/hL3K0pNpckZ7eFdpRUTRS7pvi4tNQ36QG4vNTX9xK8xdZjNBk6qsboeXgplNhzNbjh1EibyY8mPJT+W/Nh2fuw1jHFrN9ZUmLzY/nmxFfNETuyenFjTmA/DhzW1/LRdWIe5bODBmmrrnwNrthmN/FcnQSL3ldxXcl/JfW3nvmb3c7xaL3YLydbWRI5t/xxb10kjL3dPXm7tBAzD5a3txmn7v01nuYEzXFt1/zxjB6PTyE1uLnzkM5PPTD4z+cyuPvPZ2XwFapNtavO1IEYxSK640zOb84vtrgwSKL5KJpyhWVyBx8uhEz6bheswnc1svnbjqo1OcCYSV9Vr5rXqCLV0cXP9sr2KW6EZNy2i1d4n1w5+Hp8V10nxGLRC/KZ9n3Uensh+5zPwQk6rl2yCebgM58I7S650sATLXwMSXP54CfaoUyKErs6hB5EN0vAhyH7x/svTv8L/LIKVjlMKaEOZBBRdZsfeLpfBPL0qtQlqCdbJNg5m937Cav8nVDp6vId1Rz6TzwLToanDi2ze/j4dfYuDz2eZ+/cXfLIuzC61REvqhBohkREWsWnQWigGcDoqdpvN5BvsMPyCB8rx5/+GcZ+so8fR2PuXrOSYORD5Gl72H8WDl3ZJ0TwG5nZkxUyorqBrEzG3/mYTrBcj/EN5VKyj+OmZTimNo+lOJY0/SYkGoUSsqmodUqeTVKitCr0P0leLX0ESAOS4J00qhUihBqFQ6pRV65Vhckm92qoX4IV14s9R3FtpmqU8Kd0glM4ye9X6Vz3lpIrtVVG9RV7AvwaKaChNajgQNTTMXZ0S2qebVLAbFXz7Gw+67aaKWi2kkgNUSW0Om6imefpJRVurqOHm6ra3yrLCpJDDUMj6K911PbRPNqlfR+q3l1udSQEHoIDGW2qrNbD+bmhSQZdNhT3cU0kq18tNhor7+PTNBtdbLknFHFRsnxdzkar1UdXqLh3S1K3R1V6kcg1UruurR0jd+qxu5ssVLMrmcHUJqZqDqnVHsk7K1UflsnBLa1rlws5O6uSgTvsimCXl6qNyVVNoajrWgKCWVM0lGewAVHqkdr1MD3M4S6bniTU95Ukq6KCC+2cBIgXsowI6EJto+teUSojUz0H9npPFgBSzl8d5Gp641k/67MKLQCprVNmzsxcV/7xXW5i+OPxnECde1YNnL2C1XQVf/XXqpZFkaYiTv3hhHCtfzFdhsAbZOjvLPB8hebp64mevVqGfgMRbD62LSs4yM87nH2W6qr7/yFXKehxePVWmFPivmsY0KmHISS4UrLkywO0lFbkljv0y7Ne5lTRjSsexqVBwtxoqFnW3ClztjXYQOdcZrvplo+vDE+w/QrUmeZFPul5cegbh/nx5Jk7zOumPXicr6aoshtez8m+CORi5aF1VtlHXJ7JG9zPYyjLPFda6yJ9V04lUNOsaTO4nzYbnMB3eeWn50nLSGP/lfAll/qL5sXSEFe9TP8yHVuu6cXcc3VDXmj71pvIoVl2nkgAadnS9spxa6lP/XM/S1XU1zeuZ9XYyu+qs8SBMvzrqcjCrfk6fZinjCOH1lEhYjqanlccn+tvdumM+jSc4EBX2f6Z37boJTPWqty6nRmrnF56eraCWWcyrmS2Psp/GnO8e99JyBqH5dD4eaU8LkYpeueyVGe21CAQco0cszoOsx9OxUmpqn7pWnx5d170l1DBDqlRYII+yg1q2Yx87Z8u1dZ87//g6J/Pp+tQna+JmXWcej6kzWsS8T32qywGs69pClp8tj65vxt2BXsWjnHbNa8NtWAs0VVQzezjWjpq2jvrUS6fkpLpOIm14vyezk27W7uL1aqulcaZL7XZSFqXx14vZADS4+yF44f3404e3V96WkUvfzG68TRwsw98Yz/TNbBEs/e0qvfGSCPnZkfAdMxWi1SpcBEol7NIDf/0kclo8zGlJPKhzHni+qDJYsPrDBOu+DReLYO3dPimVRNuYU/3Pvc1qexeuk0n2rWzJ1a4jXZcvcWmaVp5sMJPJBlI0JqUbCz67bez6K3CAZuGymP8Cn04/OZQOk5m/2cxCQSb+WUl6KbFZh0uxaVrg6wdxF5vC6sdFjnnOhv4P5FJ/iwzm5Vyd5flrf42FOQ31k3cbgRRIYmL2kou5/CNrvxfDnCTnxSwePVeHt20q2w6yyGtV+8Umr9Stv+mfdtQrzhTLO3Unfm/UJ97cqWg29IjVqHaosMlT6pi6vbKH/hWIA3k3C+1p2t1iZ6Za56D76gvVUbBue5VGxLL3tIfBsREs8nGytrjpmNm7Pq0YFhhLS/uKw2reeTKMqmH7Zy9jamLLkyNqbmzzAbV0emofDzachqZVDqa+y1MzqtpWy95HVyc+s4yy3oudh7s0LFOHoStNgNb6wkSYt2PKw2/YE9nHqJvYrcRgm1vaeIgtHZ5ahwKH09Cs6lHkuyB1w/ix9NR+xlGQFNkG8lF+veNIik5P7eNRHkvetIJbUtyRKDso6rbAPhyVAtuMcFiKbWrsumhdmpY6ie6M+l51QAyh/tKglOLtexiYMjcIHxxD+5oOkKmLU2PHYaBK7TAPloitW4fqVfn7jgdKsjrow+Rnn7ccJNm1qaG7ygCJ96vDIwPapVH5aPiio+HIzuHzcXjM/2zU/azp07wX0FlZu9pLPRxc6q0Wk91Dp/Xz0bzvesOajkGpY9NyX2FMtJcXMJI5SFNGS6boyD5gk/GojsBP5rY2RlKWLk+tg4HoytQudSDNEc7SOJrCjHsYRuOxRD6K5oY2HURLd6e2cYAhNLWpEFdxiR6Wwy51Ibx9RGRqz5aJYI1LjxrHcpyGaeo4nBgJquuN1gAZOoR3yF/145hZjxwOUyin9q5AA+Nmt95ds+sOS7feVSev6GdUPhvOg1YX1Q7IZN365sujH98llUc1XY6lFAKOygjhBZdVl8+K8xMXmqDzU3j87k02ifpFdtqQT+f6gOrHJIvDM/eTdOR2vu1SVqGdhczFPFg17DMPJdZ1Wbt0zLnHTJQa9VdGvnnRcTeDWDiJ0f0YFsJwdUNpvhJnaCNqyq/vfmBtoc66Ma69gYiG2zzcpiho/WBX3jHT06GuObK799HVo6DNRtl6jQiNthxtU/SzdpArL4IYmtGoyr3f+4CLMGnDEde5/0mcpZtWCKTWumtmOvfBuW2mpPXux7Yci60b3woub5JYbVRl4NZ1TK1X2p/8iGax37qhLJPx0hiKMdRDyXVDaSViHZottWRO7wEMG2N6tai4mndscHitKh+y+zE3RqzrhryadXFoI16Vgtz9gNcHsWuDiO6ke0ObCufEYId5SQLjQMrA8G06+/qdv9rc+99NAtyGSFgLfg7ihzDBWPCbYB2CMyFY1V5430exUwx4onMkajFfa0R+h7h7mU6xzOfTSVi8II2jQpYrDE9xo2I8CX6D6dNhRKUscjks5narwlSi93OfHh6u1mdHC0/vY3LEpoglM758NVaJ+2dvM5fl8HY1cUk567+DmSsEcPUJdLkn/lnm0cojs7fpLOXT9ntabSH6Seka5JqQfA8m24U/aG/zXplT3XcZMO0bTHa5i/6Z5r+ObGiPs2/PAB/S5OvbGpMubkPvgTBU8REdTihM6ek9lw7TNsxkh/u3n0cW6liM9icC9kT6QU282A6a7HL1cx+m3kB4dMC5zzP/+z35xd2qSZvLhp8HtVlZkvaH3sqHF/o9t+Xdsknbm26fZY6r2ZT2Ns+W8xfDmGu5hzdpd8Hqs86ziXvpALOsHCHp9xxnu4qThld6PsusGgmb9jad6tmYfs+ivq85aXeh5LPMaRWp096m1nTUp+cBVOM+02SX6wyfJ6RayxWzv9iq/SBHv+feuME72eEavWeZ+VqaqL1NvP1gVb/nvX6fedLVZW7PIhHNOKT2t/npetzrmaSl5vKvazYW3nt5mVfdDWB/9ZPAY1chBYz/il0DFsQvk3AReOHDZhU8BGtoIYwbrItLWX92WdgE6nhnuS6scMMSvki2alSeuLxC+ZAgi8on0OHCo/xhoVU/Rovg5a0//wLud/YKz09Tf37v+d7/+967jcMFTugtbrHAN168XeOVbhPvYwBaBH2IYSBSUR8gtfQ+8G6zUUMCsoenzZPnzxHKJewnG0y8EBBeId+Kxyfx4r4FKKio7MYwNDfeKJjcTbxwzesXvGXS+0zGXMlnvybZkOEFfkEcrOelg3qv1k/cvMzyh2fZQ0Imv/oxMy74+z/8+FP1gT21rZ8VVjFzZbkyXPwcR19BpuQAoaSog8PHFdQMOpJyGxZGUn0m3kVeEUzLOoBhTO99Jm+3geffrgL8dRFBRatwHXgsOpaw06No7xP4nEm0Uo+fDapyg6HQZiVhYayNIEsKSmYz6HpO4Ga9o5GXsd/QKIy7vKjxs3xXdv0jex172073QJbrnbnc0cdLLcNVAHYumcfhBuxhddE3b9+/vn7384efrg1XgqHNVEjgku0GjMF4kn0/LvH/8amOvPtotWDaFzFBeQgXi1XwiLoJCvgIkuOv8+lXCQC5IMCbAyQOA5PNPhlNJpPxxTjn8XuhlPlrMPe3oOAXs/w1F/L4M4jTavXkbeLwK8bo0nv4fBHBKx4Cf61UAhWApXnwn7BZmyhJwlsolkENLLi+Sy69223KK2H1ew+w3ii1rMIvARS7g7WHacgTqMQWRuLe/wpiv0LZfvIiMNgx4y1USgqGO6ULo/HFRDuCnH9Ze8ZXaOoP/z9779rdOI6kDX73r2A7P9ieVqune/fsB8/qnXblpTrPVFXm2s7OdzZPHpqWIJudMqUlqXS5a+q/LwIAKYIEQFAkJV6iTrftlEhcIgIBxIMHgfSNJGfjTs3FGsvXF95ms/LnbH5x/cWl1sqvds+9X2Qvk4LZyvjmDXtEeomNgicvoDN5qHpRekCMsJ/5v3albFbenE2OLp/xVAWlz0w/Jn+9Zg9nFliPXhCQlak5SULFyM09PHVf8w8KjWN3ibpzOssRc4mZB9lltNFr+DNT0PobCVwqQJ/GxmHZfbz5lZj8djS9hX//Q/wzc96bsCtw3e/eyl94Us591XqTX5j7j/RhOT3uS/qumEWmb7+nEmfLRq1JX2qHR+ZCxsJbuZt7xfcz2eSLtj6T/zkplMLsepb+pbrKV9jBTPqX/GDeTGf5D+THcxY2y/1bfjhjPLPM37mHJBuYyf+UHy2YwazwSX6hTPU9Yz+zi+Tc+j6vzJ3H2kUKfGrKBBWWFq6ONXbXUNung/1aiEtk7yoLbt/2mkekoQkuZHfdZikIdEy8oy6EQEyStpKuRS28/j2hagh5Y7Q+hdaiuLI7RX+J9+06Wfh+UX46dfkW7Y24hDnTvV3eP4OfEevY6QOJzzN3MfMUK0l+RF06lGseRmgSopz9DJzk4CG/0nXoAtpny9k78cndf2QWrbvFK3VJL+utSI/M1g88HIENhzVdUvA47T/PcmzqvH6VcpOb+8q5/fDmw/ljHG+iyz//+YFWsL2fztdPf+Yy+9OCfP/z0zpY/5l2iYarf/4//vrX/+vi0vEWC1jgbdZhzALLOV03QWPXdBkTZn1hJpvyDgoJ1s+8W97q2XuJwN+98N6JUCFTAA8F+Ooj4nGE0JzJ/RY5ydyL0q/SK7nTC9KnBf8rbIrf0W5lfpNcN98vWVthoegs/EVwtkuP44kRwgc9rG9hIRnF/mrlEBrTbDep5pkk/pRM6NJ7+Qr5UtOLzyIIbGk4tIB4F4oAS4XonsmO6kkWXHa0zrL/mNjMfMLouHnyqTs6L11ziQczwYL6buGim8m5mgwSVS3FdkiiDTVOUrbmKcnBXUxtns6c2pJXfhQrHDi/IB5WaVw6X9VlUwe2WlM7Jwt3u6FKiUsqirebFQFvO9E9dv9CRff1q6K+i8uSbPN8DQ7wUhizf5xzvMv5UqaNrxlvpQwWs/BZqq1Z8seEy5ivSyYKocyKH+15NISbNv8oMfAu2W9pQiEhMbTQyhZateiddX6x1MpxhkGdoxx8OGS/6NWgkOn+ODS6NDRUujnWADlphP/KB4vyiS6OmrJT+jhODjlOSrTR/ZGhpirxMZH7DkcDjoY+joYGGF1iQaV6ol8rKzWrA5dYnVpimZR0vBmFdZFt7vLF0WtvtQKclLaMp1AuckOAf3Cmf+ds4szXDG4N4tltuCUSUKV671yu4yO7Em69+qKv42tG/ztelusCxlY+LC3H4c7YMvh4lqcx1TdQNs/pdJqVQUKw5lezn+zlWhJQUDskkpPugggyS984UUgOdnoUNVqx1JLe5G/rkXYVRFezNZyengLBTeKn8PM5Ao3eEUmm9Fl9rpfiLgDrOwe4zy8y+wpTXrILWwir84vCe5ALRVFcWuQGNgxpdxjWrSx5tV5vFAWnhafFJF1TPCx/cjFlyhH1ZPzE3+RRA2SF1dpbKLTLmRklBuUv6Ly+jkkwf3E9YH/lkp3bkhZz5iAXwA/XXVoPpi/g9b/mphl3zeaHKEO5yjYEYHY+g0TqbajMA+cXxgkbnPsl+wnMHPD00J7MO8zGiztV6XgfjZ6qeccuaHT/Jjc3HaD6Ozmg6+iWzlQfSbhch0+OFzinWdrkaUMTmzwLFQyiwrSkmZKKReZmJIWlZuxuIuxnktXshIl7Bj90W+d8ceS+T2qPVy+X8rJYv0BSDxaFBWTXTkoDLz4uhpmBTVF853H9rLLllG48/fv6OZeR7VKtbeUSTv2kJ0jb7LfmmUd2IRX9KUvWtBRsZjlosySsuyxUpwF01V0qSniifCYZSplRoS4M/vtGXmYKWuDu1emT94245NeNH0K2gexQo++eX0y0RVOdlRR99dPnq/++UZdwwS8cTk1gZrROXhL3D5X6z0xvljFHc3/SBmkabdTIRLFylj76G9iNP+dUf425uzp7r+QiMrJR0kIzSnq/+3ui86D7z9yHGGE2iEg6FXyx70keBBGcsEQVEm1USRCrQhRLGV/i4E2m7Gdx8T0nPi1UPLGafDGNvSoc8YW6h1zUWtEk14mppSM7yGIJU5kzNNW7T2nS3xUlHYew7TjVdHaY8Oj9srwHXEwn6kStu6MetQ96FCp45XyKCBtEmXY7QmhwngI8vRNtQyKO1FDjUBQSskN+oLV7AjYEq1mycJbrFR0ZCRWNXbs2LbwNRlb0z3TdpJntZJHMcv+eGF4KyVLB3lO/AafD4pSW6Imkv3Ce5C571udO9/Ydf+Fu4tyJg3r0TxLPHWbd6Zm4qWZG39WgoCIm//EqTA9s+eG8GaPiTjR2yI8TGqvxFl7sGR7JGM/MN80NXDgfgtVLenhnA57mTqTBYIq8Y5TQpPGRWkbZFzQtu6AuxsktToyuKPdsqRdKaX3JDcN8NJsWJV7srogXxe66wKXN/qf/Zse6vWRiooX5wBEl99uHB7BVP5ivtgs2qEsKWYc+fcNb8QWPc05LeyABxGRA/mSf+UFJGZwUGjGC6F0eW7xznv+8dryyMpKIL4himNJpSf/cRnHJS3c5Zd1NjS8skyhWuCpaydlvBf/6+5lz/hsNhs5zhV/8fnE6KWkQP5L2DFNvIE5t8QOIdx/fXrufP1z/17ufPny+KynlXhwv84IXZwMuNZEmuEg6MQVRSQHRY/EM2D2BA2IesIXn4H/Wy7JWvHAXHoo1QVGzZmmbRkBWGtpCDCGEduGcPfeh/5ZH8JV2Lw0TfpEt/y5cP7HNn3PZO+SX9SWAaimqpgcLaq+/mwK528bFNMhIKT62O85E5t9c/jitcEUfh60+RTCiXqZLEOgglVoLEj26+us3395C6sCtSltCO+mfm6hrBArYT6P80olQoNfK77KI9olujqTShUYysFuDYKVyme3+LMWzMqgVGnZrhi20x+273KRbsOVtkI3lGe5fCbc1wZO0tPOhw9w5+R0dxT6xWA3r/Fd6RJtKkQhS4t5bGaX4wuH8ovi7lnssjpWZ/E9D6Zu1H6Tpo6a7j1Smab2t8DdTRogMwPrkvdwTyDjtLrcBv44ifgbUKl4n+iaJto0zgJV1FJ9Rea4+73rg/GQ7PxWHjO6p3ZAo0+3r9MkW5kKbzSaJZvDFLGjVJhNuaLW4oZVA4BVS3HDp/xhu5j+Ll+WcSDl5ZtSfhZ3VopSfnyatK38x2xfaGlUhytZla9CXnilZtZn1yJBXtRsR303/zn+rLSOXoyGZ90ypcvbfAOL4J9SjHovsu+lr9tf7N4Zl2P6N1iwwk5VytrjMZ8ZNl4jEzt3u4QTQZTsuup0sCfq9Y4JhmQ3FhiDRvJfs34DNsEyZiz+HZA6YGFmwgah5b4d4R/M1G2UlUkien5Xu706LL2lfyW3lSkLgq3HttlD56ksaLX+cJUNjSpdOD9RjuMl3qmGU39AqcUnbLTXTT5/ev/na9M5rra3opoZpcauUZU8JFjC4WJJHKetjOC3dSd3r/WSjtQgMKfdZ92wj34ZN/mhgK7a4iVq1Zeo9Vt2s5QUv5/GXf/+qhgCSUfD+zVv63e3bX17/t/tfb//b/fvbqzdvr9luZwzJQBMBXOgnOb7Y+Ie32pYtNfjm4Js1mznBPZ79VrVlv5/tBjNddoQQxZ7qt7a00jFsP1tM53+clewan1ftF9z2W9wKNcAemq4xP6Mk6Wwjkiw89W0XjZyVrhqmy3D9lHOgqa3oW90wyWZiUhV4mTNpSJ2V7nTy0WlasfMgxAR7sGHKK0ze05vUK4dFQ2CSz7tNZLajvOG0cJZhl5pn4vf+oC/LUAvkUF7zgtx7soRk2inj5ixzzSXkVD2/OEv2xg0l+kux2qevwPABl+E5maISHg/L3017d/bdVFzS9WyviVRc/MgzcC3WkKGL7/yvT4zb+2tqYnKbNl4Y+3N/A2+few+eH1xAmUCCsChSgG65lp1F6QlL/Vb9bs5309VamRexp9zxIJ4KzlXUY65kh4Uk1mp8/KKqR8o73Ez/rZxuhjO0IpnjC7typon0L5w/zJx/N5aUPLrzPPm0Yc8hjReIuLT8BzhQzKa28wurcqcfPTonATHhJgYk29zesiIZbFMNEdl1bOPPv63IFHbFo/TE8fQ7dMagKqGuHSrEcncnp5YhwX6itiy2VGphfJEA/txijbBbK7BD0AshCpYYD1p09lu2PVNX5OamawOYlZwz4e2dU8taRIdo8eTXDZkDJStTj+hjppr/4A4aYA9Iy/pAn7er6hQ8xxkUesZDOiiC1+l4S7hGkBYMLpkHcdR38br/s7z4EpUKz8WL0z/K8WL9IiLndvITx4m9kzFTvJSV88y2Cz/aeDG1z9BchAVpUZqxM30pc0aVZPScuz+zCfHIIpLo01Ve3Fu2WWLkG5bBUixgxBQKaw1g1X3zA8YxTJLtcrcPK4Vcfnh9JVwsEc9W+gxOBEisjCgHSr2jS1aOhi5pedPSAtO0vyUmkQLoO6uYZf42v8jsKcdJm8iZosW1oReXFiKgXt/3VrTNbOHB+a+7ZPuQUB/m1zjxRVMbC+AFLlI2rdzYaVrl7VrMY1b+bQET1ZMf+BFdZBkC9AqOK9nF2DW1GvlPP7OmHGIxQnnKjExdFiWJttyueUsyL0+cys1qYdrde+rlE+P1fvMubelphVoqzr32RZ8u/AWbYtN0voCxzNdhCBMun4f/0644GyulLrTKpkU+SQ61xwSJg+/KKxSrYng4u5SeOHYqPr3h9GWR2JmzmHlp7MocccEx/SxFse9OmxjOPC5MAIDlaXL87Deoe5r59ncZETu1GkLyoSBGrLcNM1QN/OPM4eewJVIML/js1Pmjor4/Oqdn5YIiq1xjrWGoak0F6g20M4cvQXUWulIuFgQdAfyKW7gYgNp4HL7YmeBq/QB3G/BfE6tXshBZejeC7ZopJ7FZ5m+7l4vUiFnxI7uijDeSaV/KUFE0u+h7DkoBEQG0wk4JZbK3i7t+rBdrE7E64aWJ8CZm6zQDupIFbtjiMcnsvuDJqv5g6w8BJUi3NNirF4CB/3u5DIT+lKikOi26nZnzlYX9xPvK+cgOavEFrr/MrPsevQiEKpZ6f7AuMnd8irMK5EXgH5paBdZZDZbDTEU/atoftNzn1cE5s4ogkXWrGQwzk5GaxfZpEyWrq6Z6YzH0BcyoCE/gdonNiqrxXAwNq8W1EmkADpqUNIRniCjPlSGYHLlIST4IJGFjUra0qZQeRH9uf1ZGh+QUTw25U81BVZ/DmujSmSQS2h39GpuI3t++vb66ff/hl0lJypcrxWHv09PTv5MVnOPjDwHKsGF3HcIi9p7EAK+xvSX21R2z8DsOw7GZqnDzpx9mwAZ+WBZe3MVodyzdwZEzzlTKA9PRHC4Smbm2wVYy2p3h6gGhMtvVEcy1qfhk6ePZjHrc1+bPAw3JBLtxrEl34CsNajhOr0mekJslRabO3CWm1eY8PoXsOdvteT9NQvf37uf0/3Tm9uZx5lRAhqzPX9Nd4GblA9REBcu7wEvvRMnfAG55iUrmXjuGSv6yjt8nd1uTBcMnrUXL/llZsuytOoKtd8m6+TR8BbmK55sXq+IiGXvpZl/uoPXK15ZYy1p120mTIr/dbSzVkr6mnDqKyBQ5Hm283K5fJ5dril7voQtFKcfzO1Y3ZDC5lzzZnqTf/spPvjUj8VxpKHnTTUjvSDx/rC5wRSEdnFlVzSy6m+MJX7qGam/pf87RTA4953bSzH8k8efH9YqwRldfKmbf7uKSMdu+qktHGv43KOh3nr/67MePb3+dExYYVhZ2oQT02EoJX3Fa297yFe+jdCXpJuBAZbEmL9ZyvDq4bg+ZaQd9UkkbK2b17XH2Qsy938HAMdfCoy4fTDeUVYjUVaV0MWRXX4NlHy2artFqUi3gFWtrRVVIB1ceqmZW0In69eZVkgaDV8GimVFTWmJXsZbShleBdMvLqqDLkxO+Xyu6dkNjmRWJAcHiyPu5Asy/EMde/0b97YaE8ctJsjXA5JTfGbDdFTg/0e4EnNSE/l85tyxB7b03//bshYvIAWqFF/v3K+IstmGauJsE3hP8g5OnWErwNBH4q+RIHU92eybb6tkkzRQQkGda/oInEhevLtaEUYf8RAOMMk7tzA+o4qFI2E1KW8u4/ax6+phckaCHJi31I2is4N7vxsqxtzCS73U7Fvnv8yb7ynmzU8uT/yDSEXCm80cvmnur19SSzkByZ1EA6cvm7N+5PE+vnEROgfPxhX4VpJYVTTiJf7VilUilfKdfZ3MmsETBVK4eJMECRYOOgbgImVZoAex0J1D2OLkZ7mV4AP2JHmTK4Yaht0ZgR0RwchKSxzAaOpwmL94l88qBBBOhvyCcLSgJRTTf+ROYD2tg8vDOJiWThnrYc/woZmqKhb1Zti83zxmXdjMzkq0jYyGT4pA+5BAlvwItDvL31xinu9E2b3m02WdxbmwANrtDOD4HfOSdzuR7zcZm7mv0vj3yvg+yZY3U+TYxRh/6PEZb4RqMz093gzORfm/clFc/hc67R847ItRuivY2+hW0Ri4dWkg3MTTbJiuNz313k3SVfK9pnd58tC+gk++Rk8+kqnDR4asdvoWMBjp02+VIjnEK6BTXc2cPimaZzEf5OPr9Xvn9F7gTYp5oUZ3xE8GavYZ5uXCHNdIPQ/Ae+3TRGaK62jpyzbM1qsJrOI30eRohQp04n7Q5n+ilPGxf0Op5lhHOL506l5PahNUxHPPTOIn0aRKhKnRXVIeuyCToLmVLxKlj/6mjTLZDGuXtnrgb/fxw7JODGmPghVrbTvI4ThG9niJU2dLHvUtRKqIO7VC3M4TbOQc8QkJoN84zp6wy8/FlzWPo3/tEFCWx+wzK4wllcenfBGVUJ9N+j+P2chCMz9F3KJdC8n2hSXpDUTyKTr9HTn9J9efCNQQuKdofOv69R7VRrsMY122lSRnvFHD0dC957YsGlZtJ+iA6/146fy9veej6G3D93vDGc+PZm8bi7f/GEmcoE5UU01LNV1HTWakSDe9SS+lsQJ98Cp15N505NZfpc8GItC58QP7aMKqe+zKq2krpNr51dGdS0yXfl2ai0z6IrrdH6+hFoj13mTO80e+I6kXToZ3Q5oZpuwkjR5huoVuJL3ffWyXlK3kcfXyfMjGADqlJCSW6T3lbxJwMZRLqUnaGVgZwq3lpx+f8u5VfN/neLp2u+Wn0/D3y/HAtJDr+VkZ4mWiHNMYPlyF7hOmLO57pO82eWj2xd4VXcVbpU1Lk9CApfc/F6KI0Z3I1eY1omJvS9e+T/X5UN9u+cj6H3oY7HubFuBNakO9kBbcVnEWJvVPn5zl30cYL7lIb97NugM5NMBLIwtmyW+j9OHKW29Xq5U//39Zb+UuffiPcJ3i9nXMAroBChlAYLWcKVSquPgaRuVDQbHmq0u352W9CC1P+rL/4/eziVHF9PS0/Keg3fTPSTrDLn9kL/OqG34Vwz1WFr0CQM32ptyCxn+Ch6etPN7cffn57XSxkw6TmRhsypy2Yz27DbcZacrdKQ+tgUclMw5klNiZZzDs6BX6E23/OxXMXhoupZdO5XfMXC43M+PbXioT3Vrd4K5y5sluKO7dzN17vk3V9PBcv46hvYNRzG+n0oM+aS+mY50ZCX1bdM09H9Y/FROqNDupJ6ajW+yjJzhMXxT1S0rGLOom+R35rOPqLBvyFZDiddhsKG6q0YlAZk826QT20Orx6MKeXxsvu0Yk07UR0htRpf2JODlzJtZSkDbbxMqVjsdMOR5/KWHI3ncrx28ItyuhMGnEmKjPpuCvRJ4+tHeGUDJtORTzGtLh1IyCbVLhad9OZHLHodvrgdvLm0iP3o04x2rAb0g6nDrsjTRLV2m5Jnzg16406lVFUGyzZJR9Ez3RQz6QynW47JL0V1fdDxoHULfdjyM3ZsNeR8nHq3c6xE1Xi4qcXLkaYSZ98jJQosRp4Y0qhaAXdmMdYl/eZFUkds/vN3ch2qN9HNqdNKzuJgD6k2Z1nyVq6vQOtMJz6O9Hq0dKtHWlVBsG6SxFd1sCMJ+lQOj1cgnTTfRRNpNMuRJe1rbYbMQyVTrkSbS66ptyJnH9O4UyOnpgNXUm3XUliIL1wJHIWsMbcyJUqh1znnEgus1ldF5LLZpbxHcWsXntAIKUJiOwdgzZGMeX7QhdR20WkdtBp35BLYFUJ1sgbkA2S8VmZrszKW1R0CTWzaGVGdGfSS2mHcmkiG1wdHHLo5w2m0x5AbTuVHIEmPZKNP9COrQ5jmqYz2FnCfLdyGOnZq3YnFau+j4uKNrj0SpvqNqneYF7V2PUmO7Oi2ZsHZIc9jiE9UMbhdCtvjtZf2CXZqPg6epsWvI3SoDrtbAy2VRvvMA+vToEepjFSF/mwzUaTTSfQ8TQt+uwB1RM61CkLnVgbSQpKja/b+QssTbBaagNbW7TKemA/uo+xxDo5Ybnid2c0eTKgc/HvH7yIJJ9RjbDXXeE3hPpFS797IfN+8Pc/vPBLWpN4jDYMLOMD26ryVl8kr/OVPf2V6tVY6E5UZ1Tw31mGIm8+p3KEwc+axbIcEW/+yHzCxPGnZDoBvxAS58l7Ycl5dqU8bVexv1kRlnKNhJFDfqXaEfl5AqqnkATxir61jXmhT/7DY+w8et+lYjxn4S+XBB6mbgaacXe2U49I7jT7ZR0IpaXTyVVAfRN9IZgTZ70U7iuktrFwuFrS3rBSud9xk1eiS1rvPP5C7WuSVyDI8rffeT1slkleYgN/4iR+5ZL+FWbGWlp29twvL3K6q7jwOH06/RKuzDxPyt9ZnL/cPU39LUhDHuKZstjIcV0mA9c9v1A+N3Wf/MViRZ69cPfO7qNil74kjfqaaW4+GVX6Ob9JYRPCVBK/pILkN1Yy7ynnQoUxIU+tKhFyPYKEJMnw55Vi4YmMrrcBpO1iGYyKHuNUWJ2TNBeKWgfUckNCfbUXxGym4vNg0pg7MT2eahZOQiCsZCEN3vqIxLHIFyZLZALJy1zVsuJiWKLhTX293rzAxHKe9vpiv9xSI0xN2FYKrWLWMU1OrPz3mCawT2kCFamkhn6pTybpX+cHTwPX3WcycI3wmvuWEo0VL75WZw7LfY2+sU8X1hcTco3HNT50e+A0cBFOMaHQCO+/aTfZWvFeDGPiI/VT6DP7dI0NoZahTvkzHt+pEUK3h1V9j2rO1jY+53rgpHQFqzCnBVMYSEnyL3TBvXDB8U6LLrpjOg4tBNLbkdiE19anvBujzz5MZj+FiegTrykNxJCeDB11Txz1ixszUxE3j8xVKajG5KfL5NG34de0d1ZnChy7l24/IWKJuajz1JWajSaLG3rvfnpvItSJbtxaMH0foA34d33KxRG69cNkliwai1WqSPPT6Lv75LupCt0V1aEbciW6y2LyxRF57DJx9GvoNe6VpZSUo3fLrWXeLDMOKTFiuXXI6Q/RM/fUMz8rklCO2TU/93z4NcBoU+T6HCGzreWUpkWijjlHqeYx9L59YryR2H0G5XES/mi5bzoxdH1w1fetugyo4/Ovh0j0WjADXS5OhSlos1air+2Fr11S/blwYMol6vyo4/G3RlH0ZbA153vldLHj9bztZcXVmoKcutRgCLk0n+hze+ZzPVUy2TF6XK+Pg6y+r83l1R2Lk/0bSwSQcTU7kyhmTJ2voqbTCScazqWDVdiAKWswetguelhqLtNnZdrdoftVw6h67suoqu9S1fmNx7d8bT+Nc0HxpXmZtQ+ic+3R8nWRaM9dKrIYj2f1qpdDH4ZYA0eXDekQR3iG+UD5r4unLu0yNZY8jh64T8ebQYfUaIQS3SdV5sERHXQuE0ffBl9932xIoT0+13ygTOEF47BL/W1+Gv1yj/wyZB1Ft5wMuzJp9Gvg1ffJtqnER5g98lgZ04sJ8qqnQK/wKjrzPuWkTE+O0fdcXHLLKSurCWdQo9YwE+yVLTh7dURbmUBrXw2hSRxa+oLikgfiRI/r7WrB0657AReATw3Vi76xQRo/bqOkt86GhMUx9MpZkfiMPbT0wyc2IGg50faJ8WLAkQnHFG3Dgj+4c6Uk1Hc7N0CLIGFszGWdvJW+o3k4SrKm79JMx+GLnPC6sSsval57oUzXnmaYz99dIadv3+vKjGavzah5dUbSUbg+gw9AXSWN3JNRfleG4r4M050Z2bGpuBijUE7udgxppGqvwNhdg5Hm63+tyNpsfeeFxQ1AxRsu5E+WfkAHTW5IGUYjjNqLvTIWZ1x0W6l863poTQLTsufRP6N/7pF/5qOvV+45OzCre2dpmFZxzj8W00YPxzcrEntmr6JtN51w7Rtojbn3LF9Dv41+u0d+WxqSvXLfitFa3Yurxm4VZ672aMPy6ea8zRn3fuCExuju0d2ju6/m7nVDtFee35wuufokUJJNucp8UOoChzY16JNDSxPDYbImW84ID+v1w4pMN6DV++1ySqhTfWG+/S38lZkESp5Et49uvyduXzUAe+b09RmY93H5hgTN1Ry+0bUN2d2rs01r3X77aZjR/aP7R/df6v7zA7HH04A6cXPd6UCT13n/aUHr+gY2PeiTVWdnhcNkca6LDtllnsUZAmeIQcwQqkHZr4lBP173mA8MiaQrTQNGXzdo7y8lxda7/9ayRWMwgK4eXX25qxcDsM++Xso8XdvZy4mpa3j7z4rM5ANiYSqybGfZmC2nn67NyjQn1DWzM0mI3h69fT94mdI47Bc/UzFE9+BpqlJiV+Jrqj3ZsLy5Lq93xqMfIuE1LtrRjaMbV7jx4uDrlSvXpdKu7s61mbaruHSDKxumW5dThiucenu5tNGlo0tHl25w6cnQ66VDl3N17+/Oc6m893HmV6qU7cNx5bmM5BkfXszMvQeIXppE2N5Ba7ETU87uhpxTDce0j1PayyE154yacUSp/aiqaMT7mD1PzutoPE4ueXWZq5HdTN7ytP4l51s+K/OVWzmUEmciO5KLmlm0M96g/fTSdaHX0lS5uMLDFd4QVnj5odirFZ56lFZf4WnyXVdZ4Wld2sDOzhtSD2YP0R8on3Xt45V2+b6qvo8HLnEO6NP5euVo7ddBe8NA3uPEvWlYVzp6b/aDw5obDGnDM1PDgfJp150Z7LIAV3wd5wWcF3o0LyiHaq+mBcMorj4rmMZ0lUnB7AGHNSfYpi3PprE9Vj7v2mluqycSrlMWTiY4mfQpOW7psO5X3lzLwb5HSl3boV8p2669U+3JBHRy8srwn/N65ZOADlLTQyevnFu4O8GjLiB1DH9aMqty6Nvhy2btQyFw44AXvDjXzPhYh6f0H9QwvSBm2fPX8SMtbS4qBU+b3qHgnD8/rqnbYBdc0Gdpfxc8N7//8Binzzn3Hn0Eio4m1Fk6z2S1okXSv9bLmFC/S1gCflEDff+J+pLvJLqYUkk4V3HszR/B5ZNfNyt/DlX5yRUJ/6ISg5pPA48q/NS5W1BZwjd3zvoesv9EU+dK9W2S3p9PJ7SatLipc7Ol9YnXHS9kTffB1b5Qq6Oq21Crpk6Rtj8k9O+IBOwGgdWaPsPKmTj3W7gsAOare8LmGyqkBa0FxJ2ULL386fb1lKqMOuNHsoLZa7kN2FzuLPzIe7r3H7a07RHMUYkYaHM8JpvkRgTWgGxXQDJFifB5gN+a4K3gNpqXdFaVRczF8X7JSi8UdMLmjqQE+Aae/xMdniFht2tEMVwqQXv/HaZHbiLrbejMt1G8fnLu3tACb+lrQB+A3/8vTKvcBE9gvUQCmIfdRy9yk9L5WP43PhThDpV0SQQ6oh7zA5vKvdUX8XHS6PQP53+c/FfwY0FWsfeVOkEYg5MTtoQxlyzcNStB1RNjRdwl+EsqwXTGhO5MHF27M/5bOFXLdkzh2pS0GFYL91CiGPjg5ER4Jfdm/kgW2xW5pVr4hxdSgchSEJ+fn2leOJs4Z+l1xsT7dk2WJCTgp9MnDY9wLDx98CJt1vsFedqsqbOgVl+5iYaXG25u2t5PdFSvXlOX4d3zuspbWXgFymNXV6dTBn2PrmDXATeFZAa5VJR8tfKpe5oV3kzeOckVfSnu6KhSZloUW0mlbavQGv7q2+US3JLFiz/QeSSdN8VrvIyrLV3/hP6/rFq+e1h0mkdH+vfKjiPxYqT7BvYqTipBKlNERHUK5UXwpmZzb+/f8WxD5bT5NYrMNlORXnCvohXlKMqv0XZVQbwL5mSJjfamJI9i4x3TJwQzVVXCLjGVXd6PssIVpatz2DTbA01Gm/o90Sdd2EvbhvIM9bXTGelUcW11mM4Y12656qTcfi5QUZCqhrpeNpmxdOdC6opbe0qktqjVxOem2pujQddubY40WbeZBQLvPgaQL4S3VE032qsCdVHqWhqSswml2m/aMxRoqrHOTGsqkXfTsOWzV5WG8gz11eijqUCxhrbEHvdbCVsWbtuSOoty29K5WFwOEu7gZ9fdBZRZ3BgwNr69A834BYBqJUp+KrBaHgTyEOHWi77tQIbT09PrBKCK4A5SEeUu+I5LyGdSBmhl7zjlYCbcAsk3UPgmC/1fsI5pKfM1Hf6xHxDnnsw9QA6fCYfYwhda3G7TY81xoxcGPUXkyaPR8TxKiiS8ERn4KWnP+TrMHA9YrZxoDTs75GKa7dkOqP4bk0Du3lh+S3Mc+iSfO3y+iiaqq02NO3Ni2bcDhDIPEbE0nObWiHIt/yb/Ezrv+otdpfex+/0v3mrz6P1lCl9GfDlH/3q/0DL9Bf5Du5Rs1kySkmfidwbRZxuYrh/4sevKMpG3KnsnFED6APTLb7K9IRsSLMCmqAHx+315i2GQObD/AzfqAp67jdmfXgKiexsAUdmdxRe5Qp8Bk3+Bt+AXjIlvwfqZFZ95y3n/hsGu9GkO07KHfNAPgHVykQybzQlq+kBH4bP3cicuKIYh/wSjzo/l/btXucL4ndU+7/ByG8M+KG0F+XXDbjZeO9F2s6GLJGcerqPoT9k2A0AeTei7uSLFWHz054/OnG0EZDcrmRwyiPYG/BHsWwY5gShLfSRhbkOS70JmXs2ahBnJ3TnQq93r7xcJKCzve0rIbTp8yu1dsQ2najKtM9nFlL9QdHb+6AUBWbnUR9KJI8y8mvtG8a4YNDBV8b8ynpGuuaiCxNozcQHisXN4PQuSG0eb0u9IDcj7GbbHRx1NvhqhwR9JQEKPzptfGFzPQfvd3cUS4vVVrp16/ysonG99sWmE71z50SPb3eLNi9g+eCjKmMKcIe1BpqQO1lBaFOvJ+X6X/6Z+k+sr3UzP6Q+oBOln8ZqvCdS7m3ZLA0kF090S42JSvdBrslSWF5Llher8leJc3vY+s6hR2pP7EG7mzKiiG/r4uRCGorQCayKVMVAmVGsnqD+afgq88OWazf0LAOMNm8f02xk3PGCHZN65o99Rf8iUvaNpUHsC8WjLg/pdvhCZwd/Tz9Sy9FvT/ElOXjiFR0/1z4oN7Jl5rEIhYgV8nqwDJI1eGFvjLbzYU3AZHhmLNZr+nf/WC3RH76A2M2vQ2KSBK3nTmcr36gu4mNJRByboJv09N1TncTSBtVvuzpR2Zyq+nt68RDF5EtCDjmOg/FhyPW7iq6hxc4YEWF3hPcIgGceyObDHTeAKd/VYorMg+3Y6X9P1zywdVWyUgqK20Wv6zfSXD7fuuw+ffnlzqTdRdnm8ZbPMNqSyctZMbuafAlhNBbfMXetV7cC2KZ/4T7QNLop3pfLrfAxy9bhUX1ykJasSjny4CfLhegHHPa6CF+WSJFVKxMunz7zzVpGm+f5SYz7TQkOnn2Ht9iEg6+X5aeHb0wtQfPr5qUHF+VdpC63bkHyiLF0v9ZxAgBbVTvvYT7WoBT2wWLyIirWa1L04TSfwC+cPVPanJ0aLs98cPL/QGot+yEEXUhHzBZS5vakU37y9eX39/uPth+spEA7ZXKb2f13wG++D797KX1yFD9snEsTnJRPNE8dxZsaHlqdsAcrYkJ8+vX/jJCTE7ZbOafDJ+f0LVZ48D7M5mz1y8btzWlLBowfoTWoL6yWPX89+M6np97OSck+B4MSjQsY+YkVaWtnZf5QVDoDQy3rLRp8IwD2+VF8vRSgehhCQ8kXQfxqWPqV+nEVyrmGSy0/lOx6B6JcwrhPt26+c90GCDfyvmfPv0//z36d/zYbVtEd8+ADfDoCEOwF77+bRO/3C0V8qhtz76FyeR2DVErGiBNwMf2aGoGGMJQuzbbRbOJtKNUyrSj9Lp+SNN/92zgsqeZmN96w+OL+Jv5sWYaWL/ztVhdgTAXwxXD+DyS3IfEXNcMEVE1G1AEVu4WzW63D18h+G8lPQxvOfQKHkabtivPFYlOLTHtNWLGDFKUBSGejJ4qnF8qnNRXRACOCVi2LanXWVyS9q9GKevrXmknxxYXhVYh9LXijLXk7KUcEUufh+usMmLrK0nz1JTNLLRUg+0Ytef+KJi4TAxQhVYvEiN+VTQJeXX060Ab1U7I90YLNiJpYv8CGVe+Xrrk0/v739+4c37sfrD7cffvj0zn17ff3h2r39749vby6dlR/FX2As69a+YjKdis2Rr7AA/qKqpsHy5cFgaL/zR1uhXn98vdeL129/+EBDqMyrJ4ohlYQVb+WlKD+v9FF0tUO2kbZbQBmpNkQ/FA3PdBZCzktNwJktmilUG2tFcfh1vz0O0cjqcsptW9i0MKUl53Yo1mzxHRGxy0bXp1vC+LyAAPP9RXaiJ3DW4YLA8iJXApsdBO+c/m8drF6Azr/gPHd2eKFYXq4Mtr4SfeabANOioDh4k+/kDaBNwZzwsanQt2Iglg7GCgNQPiCj3SiLthu4omGamkZupuCLc6HIJDRXPJEElTxWVJWgGAfp8yc2UCwPGdk/BEAmlzbJqiPXDQ7i8LY8ZBZ28LnLFlnsXWW5uaLokpSVJk69FSf3V87P2yjmi12xGkvOLsHmWLr6EgfZ+LxfxMt5izWo09UP9NO3b1SaEC/CL7Mq5X/TbuU+2EXwbBWTjOayXZSdINmGAXfKhl2SnAWoC82pZFe8YmAZ6lJaYVndIMnCZk1OIYY6ZUWoqxCi1W0JSQ7T2L28hnQMgGxcAfv+Iga6tAmBch4kGcm0GL5k5uOpWMKCxJ6/itRZC7dRcWkNJar84OTEsPDO2DcPAzMGviLBufzphfO/nH/n5l30bAkEnB0Kl7pjgEA1EG4ogUfEb8kvzXSdynXDWqrcOlWhoYDYinicYl/8/J4EjxeXjreKGDsFNv1D54HEcXIAi8EDgGJFzHhyZdwJsQod3zGwzA/mq+2CFwCncwPnTojkDoLHJ+8byRWzIPfbhwd2js+LfBpDnJxUEvWFremzOQCmFvjNXQobBtJH8hIMJtsrf30tFj56wonSjrM6LNYt/UsRZCbdlJ5LhJ2PSm2E4MNw5PNQtvu5bpsjCeao6PS2Ukoig89nTuMgDwt5WMjDQh4W8rCQh9VrHpZ0oq9DNCz5rCKysJCFhSwsZGEhCwtZWMjCQhbWEVhY0oIESVhIwmqDhCUZ2XA4WOw3UrCQgoUUrO5TsCQf1AgDKw+eI2MKGVPImELGFDKmkDGFjClkTCFjChlTyJhCxhQypobJmMomKEXiFBKnkDiFxCkkTiFxqtfEKVXW7Q7xp5TZxZFGhTQqpFEhjQppVEijQhoV0qiOQKNSrUuQTYVsqjbYVCpbGw6pKts75FYhtwq5Vd3nVqk8UmNJrrKF75nqSlGEDshHEheSuJDEhSQuJHEhiQtJXEjiQhIXkriQxIUkLiRxDZPEpbm5GvlcyOdCPhfyuZDPhXyuXvO5NPMbUruQ2oXULqR2IbULqV1I7UJqF1K7kNqF1C6kdrVK7dLEIsjyQpYXsry6z/IqgRKazqll9hZI0EKCFhK0kKCFBC0kaCFBCwlaSNBCghYStJCghQStwRG0Xm7Xr5O1lmAOID0L6VlIz0J6FtKzkJ7Vc3qWYnY7HjlLbJskU/eUPG1ivqX+Fv5COhbSsZCOhXQspGMhHQvpWEjHapGOVbISQQIWErBqELBKrGtIlCtFfIGEKyRcIeGqD4QrAzjQPN1K7ymQbIVkKyRbIdkKyVZItkKyFZKtkGyFZCskWyHZCslWgyZb5ZgaSLpC0hWSrpB0haQrJF0NiHSVGxpIvkLyFZKvkHyF5CskXyH5CslXSL5C8hWSr5B8VZt8lYszkISFJCwkYfWNhKUBC9olY6k9B5KykJSFpCwkZSEpC0lZSMpCUhaSspCUhaQsJGUhKWtopCwSxT+tg4drTmF6R+L5I3KxkIuFXCzkYiEXC7lY/eZiKSY3pGAhBQspWEjBQgoWUrCQgoUULKRgIQULKVhIwdqHgqUIL5B5hcwrZF71gHllgAYaJ1zp/QTyrJBnhTwr5Fkhzwp5VsizQp4V8qyQZ4U8K+RZIc9q2Dyrz6EPQSgSrZBohUQrJFoh0QqJVgMiWvHZDZlWyLRCphUyrZBphUwrZFoh0wqZVsi0QqYVMq3qM614fIFUK6RaIdWqd1QrGRxohGsFzylrebtc0oFeYCeA371a+V60czE/eBG5IeF3f65zN6KsUlAfmV3I7EJmFzK7kNmFzC5kdiGzC5ldyOxCZhcyu5DZNUxm148k/vy4XhG+w4uMLmR0IaMLGV3I6EJGV58ZXdKsdjwmV0wiqncBCzzwtjGhiHYilQupXEjlQioXUrmQyoVULqRytUjlKluKIJcLuVw1uFxl5jUcMpcUWiCJC0lcSOLqPolLiQc0nShL5RmQR4U8KuRRIY8KeVTIo0IeFfKokEeFPCrkUSGPCnlUA+NRvaNt/ezHj2/Z7gr1Z8ilQi4VcqmQS4VcKuRS9ZpLVZjZMDMW0qmQToV0KqRTIZ0K6VRIp8LMWJgZC9lUmBlrDzJVIbZAQhUSqpBQ1X1ClRYUaJpUpfMQSKxCYhUSq5BYhcQqJFYhsQqJVUisQmIVEquQWIXEqoESq0RUh7QqpFUhrQppVUirQlrVIGhVYl5DUhWSqpBUhaQqJFUhqQpJVUiqQlIVkqqQVIWkqhqkKmFWSKlCShVSqvpDqcoBAm0RqmTvYEenkvkz1rwZbXJAVgI05h9A01CSpKwrybRpMkRGVwVBIgmsRRJYZWNG5pg1cyzrV/4HeWTII0MeGfLIkEeGPDLkkSGPDHlkyCOz4JGluz0q/BY2AeRc9fKq/Uw7vgqYvI6v9lmANUhUQ6IaEtWQqIZENSSq9ZqolkxoHbxGMd805KohVw25ashVQ64actWQq4ZctRa5atZrEmStIWutjYsV83Y2HP5a0jMkriFxDYlr3Seu5T1R04y1nD9AqhpS1ZCqhlQ1pKohVQ2pakhVQ6oaUtWQqoZUNaSqIVUNqWpVqGpvvOCBhOtt9M4nq0WEjDVkrCFjDRlryFhDxlqvGWu5eQ1TqyFdDelqSFdDuhrS1ZCuhnQ1TK2GqdWQpIap1fagpuUiC2SoIUMNGWrdZ6hpAIFGiGrwXK78t8slHdwFngN42auV70U7h/KDF5EbEn7350XnIkoxAPZ4FSZehYlXYeJVmMgLQ14Y8sKQF4a8MOSFIS8MeWHICxvmVZg38Tok12S+DSP/OxFlIGsLWVvI2kLWFrK2kLXVa9aWcnbrYNIxYzuR0oWULqR0IaULKV1I6UJKF1K6WqR07bdAQaYXMr3aSEdmNLrhEMCU3UQaGNLAkAbWfRqY0Uc1RgZT1rInJcxUVunOANLDkB6G9DCkhyE9DOlhSA9DehjSw5AehvQwpIchPWyY9LBr4i2QHYbsMGSHITsM2WHIDhsUO0w1uXWQHGZqJnLDkBuG3DDkhiE3DLlhyA1DbtgxuGGm9QlSw5Aa1gY1zGRzw2GGqXqJxDAkhiExrPvEMJOHavo2S4OfQKYWMrWQqYVMLWRqIVMLmVrI1EKmFjK1kKmFTC1kag2MqfU6WWZdBQtM6oW0LaRtIW0LaVtI2xoebat0pusgh8u6zUjoQkIXErqQ0IWELiR0IaELCV3HIHRZL1aQ3YXsrjbYXdYGOByqV2mXkfeFvC/kfXWf92Xtu5omgdl6EGSEISMMGWHICENGGDLCkBGGjDBkhCEjDBlhyAhDRtggGGGZiPAz8b5dkyUJYVl0rthi9+dfRNTq3gj+F2B6//DCrxADpgvfhBzGRtQls0zti/stgF85n2FlKHNCkhl/QrtIexGBDXt8N5BBoILHkn3pgYa7gXP/kmX0yFN9o9wRuRN8uzHLUVLuU75fGNfwVYQtv3lPqHlRt7f+RoLqIUAkEoRr31QkEy+WlF/tqskvpaSXdOdWuSsvb/pyaM4v4EoJtOq6OxID2zNw3fyATzSXH9eKhmW1A3Ne9t+K56lbf9qsYzoCXxLGRgWby7w9fb/7+2dekHLHj1cbsn11Rl8o0+c1exSYE4bynkM/tizvM3u0rDyBhdqVKB4uKZOTFmwKTLkihtKyg4k+lf2nyizEgGCrfP5n2RI0sbki10rjNQzr0HS4TAtcK24JTVA6uaGYiJ3po9wGrB69Db0g8uagILuihTHUI5gyeRcGwGV+NVoYTPogtPjorFiBGvoWfZvNVTTYIgkmp3L141l7nRUtWkW+Uixllf1Xbk+nwlI4vDKhqV5JWY7yIn1lrOcP6VuKqKG4RcENy1utpj/7v5KFMJKIrTbVmjpl4NadtLC6Y5skd0LXd3xzli5e1BuTy9Oz31gHkuH/+5kDW66bkHz319to9UJVRz0OA87oOsbTlHO68JesAbFzJxp+B9gbLPsFG39FRwlZTHUFvA+imCo2oaR5TkCelV0j30n4sqsFWgVCg6BB18dEGlNqn+eFDl/cTU9L7E/ybhn7yzk3Pi014dyO74Z286bGDWXm4LIRlX10Vqygn24o1390Q+iGDuqGMvaXd0PCGQzEEWWW2zpXlF2+lzoj6eGZqpqeOqS8FNAloUs6rEvKWmDOKbFweBgeKY3XNe5oF/mXDajMk7NC6f30QnLn0QWhCzqoC9qZ387/8O0H95qA1/hOVi+X8raSfmdA7aUUKHnLUL40pi9LIejiy/WwePtjpAYkXY2mp39rnjXhntIrf5M7taaGuFp7C81hSWZzRV27LpCNioA8fCO8heteVphAzFNTFQhTnsVUDRQn6IAyumYqjaCtyehiv8XROdXbmVesDZf5Q/59pLGcIpnhCpTwPhYnanPNU56Uhf+m0ynq20bfDSpP4+dgz8rsQ/7H+RQAc2/mfPrl5u2taj+bH03UFrPw5zGUBcQUYMoZS2zPyPIGBAkQ2Dao/xCsQ/LlyY/mX0+UdHu+6R6JVARw7mNBPDYRskmfztl0rRNstvHEOfenZDpRFMN23lNGy9InqwWnYFxMgD0fPa639BPIa3Lmuov19n5F3G0AJ1jna9jZd88UhX73Qt+jT/L96+9r6re94MVh66PY91asBlgbLaknjyPeXNi/5j06i1QN9UL6UgxHaBXf3j6yBoJDp03aPcwyqvDMKwHbLvcD5+MLrSTIszl5Ob50fIDRQgWHjhV0v6Z9F59Qu1mDiLaK03ivoDF83J85Pl/ZTCu4hlfO2zSDxJ9Csajg7FDOMgViC52+4LySLyfzWC8dQsVJTXGqEtT51QWkokicC124+FQyE2ete/6Hi9TOmEwgvQU/KkE1zNLUsFWZ56zWwMLxn8hEGKSfHgh5IjSeunQ4qh0BQzE9GTIdvFtUzY7qFlh5Sxc9cTOe2IYDnLHFifOlQlBvbYuTCqb49UIxQD/9b8d/ol78O4Ezl5fO/JHMv/GhGnBHQP1u5HNR00mCn810nuHQ43xOw9YgBp66omTOLPKch+uPr5PcCWxumlaVJY3/0jFTlGv2m5lqtFw0UF86aKzqM4z5agP9q5LPnh6dTBPuqH3KRLm01pwoFBCJuiTbQ9au/ApjZjNqZ6almeaZHY36AFlGlFQ46uYaT5CzxYNonOm5xO9on9Wfj9NLYz/RK+Wo1vh+It3VVk2ksjKEtWWNDc7svYc15DtYGhrSlrAMLPDDIjtK8od1mg83zTRSadbjTgEOEv0sXtemjHAlGMBQSwbBUC1dqK5oIf6i8uzM3pq+Zn+9f2P0G656XF9WylYkT3MZAyxbPVzoToJnSplmx565fXn1MgMuFmRTqQTkWFYsaz1XuR4KSmvPva+FlrW18RBARqGqVMVp8Fm/kpla7VcsmknllfOZ047Tc0ZJnMGOVjMRswx3SUpBZr9nkUDRHA7uQ/4cHjz4D4+xpiI4B05Dmvk29OMXWNMkKF/k/Alqm3sBO64H37w4cQgHoCCqFOzDJO9mggVDTKmpCRoKwTFt5pzGsDwmjeDsOAvUJrmEfpDFKiS0TtFHGqt72xXLivin5KCfpiZvGz9OWErF7yQMIaciEwOoDBa4LBDjcZ4kMPWx81cn2oP3XPQ8eUU+deLdxHlcPwNqPmFn5e+ydnTHFoLQluT8mHIxyCsSJPOdZJKz8pttSNeYrHYamIrjHJEIWLO5UyF21RReaDYA/YHDk3Pk2sxgiqn9GEtHhM2IzriiktEsOS1VZpj8Gs84Mi0SKxbmGCVXvDib6GftXB6wrKhssoEZ5oLEr+Xwe6NIuSX8yOKO9TZUJyhVZiUVDiIdmwpwZ1fBzkalkxQRocMyDr0lnKKM16WZ6rR9lE2uZL+C7SG25r/z1pJtWPqNar0l0tVpTMwqmZ205Zsfl2WbyjvhlmwsFyxYrZSJNgsiE8FMEpRVokZp/P9xlpWZIj+e6n26wA5f3Htv/m29XGokLb6d/sB/K1LAPD/6K8JSeplMgBWvDWC0aSJ3CDXDaCXz2TsrZ9nSVM7OKSdNEiHKWUluKWvz4ZASnWTctPHu5UlJ2alAValbGFy7y9LJtoT1XItcwUkTjM9eTP8fsJzyAo3N44UkmS5LyxIBHKTlPGNKOJtYvZMk3VTElrdrng3GqpxctGr1zsX0hoR0bef/i9yub+KQev2ypGS5VAaloWzWC5hfuzBbFR9lsKJKHEOaoMcFKD2xusvStr1yXq+or2Xzm3AfYquC50KCXDoWhdAxwSF9WkzAZmH/ia2y6UC3eH3hR9RXBGQOmSMsTD/nDKdz6MN5idB2mz/wIszfsD0hIokgpqt8vofDCrcoaZcEDjZZ6BpgRVghIn0ULN2Bl2JRUoYP5HwjL2wdy5g0IZlDNpHFf4BgQ5Y93aI4iHzuE1ZMmh4w2criU1fE9WtR2jkNsoCrs3q5oO+GLNnVloYAW9hGDNjCOxbbWxaliYiM71cWErJrFojQoaKhT//uRQxo2mXYPL24tBrrMDH5wZacnNh4kXRkGZJCShsIJbnX8uVOP3ohT3gl3I6ir+V5tpL/Xti2rOxB8ym1srXr8oFJSW9VJ85LEt0KRCCbPp86A2mo83w2/I6L8HsuRbtcDn8SnAoth28ckunDdMJT5viMOXZP8hlz5DK2G+p6CQ3ZIdtixsMFMR+uSfo/QxFwVNGD6wXYhvc/AVbg76/ZlRkvxgSB+mQ5dPXBloCsDNgMd7n46XKUZxYosevV+gFWVSxdQfkMeZowz9gmK7RduWziqUwi/s+yDJacO7f0fLgjhS3/PCftTXKO/+w39sfvpXkpWSvZ5Q1cqtPpacl0aZwtWWrnwqxRMkpTH2HQ6C6T8/mFIZezyI5j1uErGqqy1E9+vBU50IVBJre+8EEC6RHJ84ThBWJrLpcocWqXRJKLJTHKXQYPtrjgp8ZhrjhPFhNmcfnLpGQrMFWmtko7VyLNnpRS0sqr82fLV2yZRKV1mqbIm1FadzEdlal15oSS+bwp/0XIhtnJOvQffNjAXW6DOQdFE8RVEDjobL2mkwRLzQTDLFdSYvrgGoB8wT3mVtxeAquKsyjwvhEXYMSzlPSiupsFHoZqZJtkM2eyh1SDRncbvtyu08yOAt0YFY1SKYHu0io1zW2LZjle++ilcssUh3RHpDsi3XGAdEfTLNZB+mNrHhFphl2mGZqs9BC0Q3P9tWiIpqKboiUamz9GmiJSCtWUQpOhWFEMkRSIpEAkBSIpEEmBSApEUiCSApEUiKRAJAUiKRBJgV0hBSpDvP1IgqZoEUmDSBpE0iCSBo9LGhT3yCb3lkyp3mJ+L/lb+Ks7bEHjdgWyB5E9uAd7UD3TI5sQ2YStswmVptdNdmF5U5FtuDfbkI55iCfTe1WTEJRarVLujRHOcgjLiImJuWb2haBYaPZhiIpjtJteK9tWkUhgRAIjEhgHT2BUz3bDITLae0okNPaH0Ki22sMTG3XtaJDgqK6iHaKjpjtIeETCoxp3VRsMEh+R+IjERyQ+IvERiY9IfETiIxIfkfiIxEckPiLxscfEx5wnaoIAqY4ekQiJREgkQiIREomQexAhNdsdSIhEQmRtQmR+BYDESCRGHpgYmTPBPhAkTU1GomRzRMkEMtEyJnOKqMOAoy7zJ7oIvt4GAX38HYnnj+MiTCoE0GGepLK1rdEjx2oc7d/ZGq2of3JhCehGMDEuIm2tfhA3dd9qXfMpMQ3kWSLPEnmWQ+RZ6ifJ/lyT3QuXi8zNTjM39ePgIIRNU/X1eJr6khujZxoaP/LbsoueCe/Drsrl1FuX9fXYRTXMih/hfdjIAEUGKDJAkQGKDFBkgCIDFBmgyABFBigyQJEB2nEGqCJA3JP4qQ81ke+JfE/keyLfE/mednxPw94I0jyR5rkPzVM1zSO7E9md7bM7FZbXUVJnWUuRy7k/lxPW8rDKdEMuXXcJ4gUGp0LqNbh5P5L48+N6RW7UMeuAGZtSz7tL1cw1sy2O5vjsoFfK1CkKqZJIlUSq5ACpkqrZqc8pKG09HxIXu0xcVFnlIRiL6nprURVVRTbFUVQ2F1NGIs0wsRCVgWCKSCQIIkEQCYJIEESCIBIEkSCIBEEkCCJBEAmCSBDsFUFQCu32YwaqokOkBCIlECmBSAk8LiVQmm4euLdi/lJ4ru5wApX7DUgGRDLgHmRAeUpHFiCyAFtnAUom1036n76JyPvbm/cHgeIzSJXHZrBjlBVzDYLXO+qRAK9+m/rVMZH9Cr3vLuFP0dS2SH/jtIneKdWkMCQAIgEQCYADJADqZqw+kwCreEEkAnaZCKizzkOQAfV11yIE6optihSobTYSA5EYmFiJzkiQHIjkQCQHIjkQyYFIDkRyIJIDkRyI5EAkByI5EMmBvSIHFsK7/QiCuigRSYJIEkSSIJIEMW+gFUdQux2BPEHkCe7BEyzO7sgVRK5g61zBgtl1ky9obiZyBvfmDIL/cMF77HwhNdSCuBvgiQmNjZI5KPrefd5g2tC2WYNjsoaeKVSvLOQLIl8Q+YID5gvK89QQ2ILl/g+5gn3gCsqWeUimYL7mRniCcqFNswRzTUaOIHIE87ClbCLIEESGIDIEkSGIDEFkCCJDEBmCyBBEhiAyBJEhiAzBXjIERXBXjx8oR4jIDkR2ILIDkR2I7MBK7MDc9gNyA5EbWIMbmMzryAxEZuDBmIHC6LrNC1Q1ElmBDbAChX/McAKFjGtwwGDj+xoA5Yh6wJ85vWdUtECVALrLDVS3ti2C4GiNo4+qLVEb8gWRL4h8wQHyBQ0TWJ9JgxXdITIHu8wcNNjoIeiDxuprcQgNJTdFJDQ1HtmEyCZMDMVgJ0gpREohUgqRUoiUQqQUIqUQKYVIKURKIVIKkVKIlMJeUQpVEd5+vEJDrIjkQiQXIrkQyYUdvZ/YtC3QHcqhqZXIO0Te4R68Q+Xkj+RDJB+2Tj5UWV43GYilLUUa4t40RHBS1DMK4boJs2emZBvt+gl8pIRosno5B2gn50WpM9mGQarDz8T7dk2WdBUWzMnUvd69e1KCQTDYqBR/2GEd/HlDoCohKfzp7Ec5YsOuz3TYRzSyfZ+sNumS7lzelPqRBDQGmifbyNKjN/NHstiuWCT+Dy/8epGLi91nKiFoMBfRpVpyVkXLBYOqXNcPfBrJFYUN/S+K6N+KHzXXvGLZmQW8isOT+Xr6fvd3TlGXyr5Nc3Klli1/oHkrG1PMsg0sCjcS/asjXLrOKtvhhOUVrM3SP3Y0oPQr+LEgq93OrIKlY6GioijFaFZJlI418Tbf/lRDkcoXbUBF9ZuxF32L1C+ALGfwQ/11RpWzgqpLoUqm7433HPRL2Xn3ewNd0GrZ9NKw1bsj28K8aKtjgeJe7k0TlFTF8NpL1e6XZvTxHdvQvBnEV1TX2wCs5q15iXR6x3p/cQdFpvAFx2mi7WbDjys8863slJppijNOP64IbKjCkuHRAfwDNm2zgM8L7FBtI7HxSjvLsCRDifRb/wmaAhEiQHm0hD+c2lJghKXzZbmQ/A+05hshzFRfTBtTyVlOXbVx6M05UZFpCBjNNGNlpTZc7SzAc+jH5GCGzgYw1BheKqX+Plj5AfnMnoDtVghov4BRf7XyrJwJz/Y3Z+zXObx7oRhs6oFS81xFD2Rp++A1ibaruKrU6xWf9YKWJVQ6PzF69ZQNirqyP+gUxbWJc5RujmLycb97K58uGKnzcslySeZx1J15azdC1N+BrQKUTm1tBn9rigcqPeeFM0JbvlFTb/XsvWgWk9vAzwhtVu1lVvNm7QfxTPRxuvtItfdZa6JmBtDgKb3Ui92GXhB5DJ3a58CL8mHtkYvK5zjZ7+Mc3Mw1gW/pNL5oGJleG1SSZr6Dw4Jmvvn/OJ8CIGnOnE+/3Ly9LRaR0Ay0xSz8eQxlTRwosKTEWsaUNxQ874nnPYfpAlQev4MnHYfodQZ7QDFrS4c4kSjXV+sIYrYo7RGpakcOpdb1/YyhfMhu9y9akblAHc9NNJ7NPbTVbEiZHsweyuIP738askhjH9FhyP1VpjwwmbVyqxOSifuewQ89rzSloSZ/2B7iaOHMoBkr2NG1cwG9JtbILSkmZbKelKla90AmsnbdzOGCKls0vWJXMIpjYpo1gsQbEl8t/kkYZWJ8GEC298eFAuSWtIQIjFPZ7S/RvUSoNdfpXnjvx6EXviRsKW15WrqzwqKnv9AfZCGYVhbNCOEQKhXJEgr9C41tqcIW2qbQJqyqRAx7WrrGihG1QNRi2KiFYkT3B7xAz9i4ZxwspKJQ0CGQFWW1tQAWRYkN4SyqtiLcom586nqsMJeCg7F6S+kPELbpFmyjGDTW6E1qRLP0Lz2OU7ChWeET/ctKU5opP+0fPGQOPBElagslousOd+cHZ1LoVANHyKyjx40faQRxXChJ26iWUKXRWwOGUZ0Ko+rbf7ltI+yEsNOwYSfz1IYIFLrOQYNRZvM/BC5V1oJaEJW58IbQqpIeIHCFwBUCVwbgyjx+EMM6LIZlHeYinNUWnBXvVODmoS2NemrhGi+369eQyynczmOxvh4jxqUQw7ERLmWTWsO3Rm0HXVVimYIQokGIZugQjd4zd/Umtz1H/4BxBr0OD4MymOqviTHoi24MYTC0ftT4Akbw3Yjg9fZpecdalwNiq3UxhsPthcMvcIPHPFFBImQWDSt001gMlFvQjD0mzhXXpdi40LSDxMijtY+uK9VWYRg7Y+w8pthZ7cH7FUNbe4WRxNJqnR4+pta1o8HYWl1FKzG2pjcYa2Os3alYW22nA4u5S9fZGHsfLPZOVizaIDynrDrBFtXVT+vg4XobBPTxdySeP44wBldI4ciht7JFbUXcozaC9nnD0Yo6I3YThmAsRdpa/SCuRLKtZyYlJoChO4buAw/d9Y6/P8cSuuJehgsG6K3kIBiAqfp6ob++5KYifkPbkbSvbnxxPCObvmP4gN6qran0RS3Pih/1kNpuFUsgmNAamADyWlEFuCHXgLsEFQCEoNBMc0Ejv3xn9NABF0OnsIOkSYcBD8ZmB11VYpmCMLbH2H5Usb3kmTu/HV9t9I8l8pZ0eITQO1d/k7G3VHQ7wbfcetxmxzC6W2G0ZJ/93163WxdjJHy4SJjf41kMhblu6lyPSOLPj+sVYXecjvD6y2z3j3wNptyUtq7DHKe+u6Y0nUIwtsXYduDXTyo8btdjWstRPtxrHhU6O8h1j8p66137qCiyqesfVa3FWBVj1SPHqiq77H2MWrKOxdi0tSsXSew+g+TdCEQPZpZVRY3Q5J3nrz7TSfLtr3PCxD6+cLQgguOGpIrmtBSWjlj3XVSeSTEYomKIOuwQVeeFux6mVhjxgw1Vdbo7RLiqr7tWyKortqGwVdtqDF0xdD1y6Kqzzd6HrxbrXQxh2wphl1T4Lizp6FJCiJ+aXEElDYQzV/frMCaL8QayQgDdCGPTxrQcxI5O691TnF4pGL5i+DqO8FX2vX0JXkvH+uBDV1lvhwxc8zU3ErbKhTYctOZajCErhqwdCVllyxxMwKpd22K42n646nHhZ4JVoY4aQUuyZGkjWjlszJnUdtxgc9eKlqLM/iusQ6JXiBUDRAwQ+zFgNI6v65Fe+TCF8UjCkApBjAs32m42KxbunWsW+TR+oCZ+/kVaSWZCrvjCWdKVXgwG+MWkUXaiJlFRNXDg61dN4zLrrOXpWSKAM27Tz+KftP3UtLdUgfd03NOgdrFd0cl+SZeO9Kmz3/Jh5MXUdWEcu+7vZ85333Pu+BruC/VyX6dJAefsnxep1M/nSdf4F3enyhbrQwD7vsy9gIVWtDtgIklfzD05PdlrFbzfevSLtof2Y35SoQx7VwD/fVV/rBsZM/2QUS2IR4Or5NzjIQCVQpU14Y58eYhzGKNYwy3QcpQbbbzn4DzjHLUvWrkT8zxe9o7FgxeWuAECOFYATqeMRoz13FC3TsrGDKFFE+svfpKuSWZpkFcj/H7jBQ8kXG8jnUKGvrWfE8Bx0ZZCY1oCXUar9fZzANMB7S282KuR+Zf7ctb82qUIA6pXDMAgNYsQeq5Zyj3xQhK68fobCWqLBnRds5Dt1l/UlW28va9ZRGarQFtSFIdWjfFi4hr6VF5MQ/5M76sQ0ERAc9iMF/WSpD9p8HEKxCkQp8CqU+BgAUu1OzsEbqmruRYRTF1oQ0QwTYvxggZ145OZZnctg+HhxO7tnuXD1OphmBusHkxukbN5NuvnLZsMErR6FHy2Xc+oZ7Z6MON/LQvmXhbv0+gW3U/tf6xR22Q8zpI/JoY9V1b0LNQBbvkF3Cz5Q/8oDMQZ/NA/IobgbF6225kdf7PsP0wtBQXM+C/9YzD6ZvDD0BE67mbwQ/9IZsTNjFzB/MJmlvzRvytNSmFLZG22teuwSETvMggkoi4jp40acPRNvA7JNZlvw4guVH/mWMv4tiKUYjjuhoSmSS1tS4zcDg6BzDCRaquCTM3RlNc0feA24G7u/zrNK6VKBFzXhsrsAwFhBISHDQibJoY+wcLddz6DBeFMJnQIKM5cfy1AzlR0Q7CcsfUIzunAOT5FIsTTKYjHZMsVgB722kz87h+UYBlqIKDQFqAQgQKo4IQGEp4/tVOlampEldd0KYnggkoKx8UW1C1qCVoYtxF0VIUl6sHAHgP7YQf2Bqfc9WOv1Yb+YONqgwYPEVYbq68VVRtKbiioNrUdTwRinHzkONlgnr1Pf2S3Gsbgt63gN6TyV8a+KsXUiHroeiWKw+08vgoWuMnOZp1SkRw3KLZoXksRMtrKAffCFmQTP9bgvLdmM1XsAeNzjM+HHZ/bThb92YTviuMZLCBgazKHQAfs21ILKrCtpiHcwLpXuDGvbjzzAbgt3y24wdaqrbfomZZn7Gf/tuf3CEYQrWgLrZgnynC9YOHqN+5LlbaTQVloCgFIKs949XLOTvU4NGTwIvPJXLEWomsf53H9rFqSZvQ0/TvLo2R+5uPba/fzh+v/evfTh89y5k9qs9epybrvM+1N5kb3RuStvKVj5x9e+FV2j1L4tadMaEe/kd2pZzhYNP306f2b3vW/0L+T/NkueVjZG8OJYVGclZ1m3Z2KVF1gVszlK/ec9ItFNixi4W8tCiy6VNkpa07WZQ+iGeI5EZpNM4+rfThT64z9VHtgqrEZ/b/6S6qMGf1/WYbQC9nsNlSMSV61qq7mQilvKGQqWTMrUFEv5Of12J15LVV8opRwUUIgunJP8P727fXV7fsPv0xMAvVWz95LxHq0dzNBz+b2PMGsRn7d+CGVkTQv03dVaWLLu3j10+er/77R9m2+omshx/1EZ9zV60c4ABfdUOVFS59E57LKfiQBCf15Okz5O3R1BHATjFXIrizVI/VAmAHVt/xMPouIBXaSK0A0IW9iSdO+fPk6yX11BYs19p2+MzLw53JgEH4a3pG7D3ZDV1eBTxdn58oELFYIh1qI5elY9sqN3JYwizVZCTRnt5dKKU6LdkY9SuEzzbtJDoNZIkDdc6Jd8KD4U/Mk9Ik+Bb90WPScDzWVPynEFEV1Jp49mm5BYm5S2onuDLlCQhPDw8az5LI01M8w0GYnjNLoYSeYKHU+lgOGtnXBMDW9xRrMy6EyXRWtrDjD7DQHeHZI4zUNGpMm2pgJ9cnyOr/QZcdPO3KeFFGerD550pQ99523ishJTRM7jGklsq1vVNlpLT8pyYvAS/VK0moea8PbW7Vuv0lC6z7lOqnlyh/UcroFGQFVoMLgrjelmWIP5ZoncwjLi8nXy+YuOzBa/hf7Dn4t9aY5h5V6nsvyJNsqe5gyjYnm6AGwKlJWS6hgPLNKDsYqE0oijZnFBCaZQqnUq5ETWNkHuCKqOp+E/T7yHV1VBqpoL58IvzbOIumeotrfUwWKQM2sg6UpMxf+PIayJjBPfa2ySduudex0jmwQZIMcbTSrvHF/SBkjciAtXn7V9JqwCgUia3cN0RyyRSKVQdN45o9tEk4WM4Ui7aELtIeslVtTG0DrM/gxqZuJsrlQUEtl0KyIrf2aFW3Birpw2GBUSYywCUhLJbJPUCrNS8OiZ7BUScmIqhG63YYvt+uUwyGmyk7G3MqW9igG17S/rZi8+4odhlb0ssbYGGPjo8fGJq/Z+Uu22xzIA41JTfpuKEY1VYFn+DG6PHZ0abJPy0P8rceHlqszjBcPGi8a55BhxY9x+OLGbGISLP8dxUsphcYikdypzR6EmrkW9zbkLPTjMKFnlxU+LC2Vyx5DUgxJOxaSqr3rgENT+wE+ihBVrf9WQlV1VRiyYsjarZBVbafdDF1LV3cYwh4xhNXMNQMPZZMMQdqYNieWOqEOtdWf1sHD9TYI6OPvSDx/7GZIq2honyJZZfNbC2C7rtX22YnRijoAN/afCI174NhV1FT+qIPqXatNjIQxEj5+JKx3yv3hMffTUww1ttZbVFMhtb4GJCxrGl8cIshI7lgArrdqa4JyUcuz4kdHYyTbrWkxWj9stG6YtAYWpIOZrGhX3ZD31V1CZyE0V8igzllUEn9+XK8IO5DczcPD2Rb26RCx3O7WDhN3VoH91kJRthgDYwx8/MO7Cm84qN1f2wE71EOyCv02dVhWUTTu5mIwefTjrQq77MrubcnqCuO/wx5QVc0NAzuoSmL3GfroRtBJGCXZTtcIFN55/uozXc69/XVOmIl1MtortLJHEZ+i7W1Ffd1WZv+1oZYxRoAYAR49AtR5yEFFgVUG70AjQZ2eG4oGdcVjRIgR4bEjQp1tdiUqtFh9YWR40MhQO18MKzpc0m66sCJzSdJROmoKnW8gsLi6X4cxWXQ6RhRt7GGEmLa87fiwi2rsuyZU8sXIECPDzkSGsl8cZFxYPmwHHhXKOm44JpQLx4gQI8KuRISyZXYtHtSutjAaPEo0mJslhhoLerybmUhQdLxGAHFN1z7l90l3IBhUNbRHEaG6+W2FhZ3X6iB0opU0RokYJR49SjQ4zEGFihVH8UDjRYO2GwoaDTVg5IiR47EjR4N5diV8tFuVYQx50BjSNH0MK5CEu1ipuYiuusl6caZcw+76CfbPL3JmF+06uyuCc4PBwmTOS+4snqnv8i3akMJaLuQmR/NHstiucgOsWH4udcPzIwnKFjYLurhkh5eTP3brqfQr+LEgq9grLneySx33RjQT7hT/hxcqJcqvsk06xJco/DNvs1nBWpc2jw6mSXKJvBd9iyasKzP4UbzcOqn1sv491HITKqwJ+bLravf6+4ViWcT6or+f3bDkug29IPLYUBSrLvWyV7NEUz6cpNCa5lJlfU2XSbfQ3pt4e//V7sru9s1NMYgqaCnz1vT97m/DIh4+1t0WLhsL3HMvfaB5i9kAfZj91t1DTgVJHyFBtA2J++hFTCT/om05z4wD9buZPsr3kOedvdBxOs8I6+ziFcFF6+/Vdc7Ji/ex+/0v3mrz6P1lyoTtbu7/OoVB9n7Rn/ua6yhjrDeuNmQBee3aQXOdVDne69sVK7PBkLLBilNtnfL1QgFGfvrfjv+0CakLe6LRxKVDV3DzbxziDIhPV/2hs1lHPpeE44UPW3jOefYix5vP6aQWxFR1L4qSH+iqn8auzsP1x9eOsEg2SKZVOx7QDxOTLgoh+81MebNvA/VlgAuL+vCe40ZANbyVuEvAWq9vHHZ3ca7ttMQCoDc0Erqlf8CuOPz+f6keYFCeWz47DdbP5xfOH7PoHYQMuQGsEW32lYk+OCvCScwycwWohJLIsdJczX3lj+Fm/rN4XeumXAmLc5sJEJVAn6Lqe+KFJHTj9TcSGOpmiwTRfpWfddV+UD3u7abwzGgtWwupx6vcrGnWx5nbl1e7vDGRFmRTaVa8thXLKslVnv3yRLGguFosEvgNNrL9YLkOn1iMD9im2CNmzZ+elPRZPeDOi7p4JB5saE9vr27+y715/fe3bz799HaiGa47FzP1ozVv3fkFl9vuOz42z84uFDAwdRTnUlOpy4+3G9ghUDo1WFPSUcD6lN8lYOvN0j3HYhtMt6mX7gtkRuQsN/jVL6QuPdtt9aNZ+5jlbclq51MAnxm54Z3kzg2Jrxb/JLST30lXIaNsG0eEHHVZNe2H9l7S9ZrxvRfe+3HohS/J3pS2PEibGU1526cPYisF9Kuwv+kv9AdZiH0ti2aE5DssAbwlFPoXkaFW2xTahNVR8CzJ5gYAaylU1x90C4cAgm3dB9sUpnEIzE1ZbS3oTVFiQwicqq3DAOJSF2WFxhUckdVbSr+BgN7hAD2F+VrjeqmBzNK/9AhfwT5mhU/0LyvNZKb8FIFDBA4ROETgEIHDBoFDM1yB+GG38EMaVLm7xdtMCvzr3Oa4C4X6gCxqmjsikLEnCkOwZZh4o878BgA9mn0LopA4MBCFbA6FNI+2QwCSZS2od9eosfCmrhs19wARS0Qse4JYmi0ZwUsELxG8RPASwUsELwV4aQ2DII7ZsbuOd4pz85imRqm10LKX2zWNrqj/3M5jEWZ1F9xUNHZU0GYPlNVRSZdJcRD4nH54dDWXGcJMR4eZ9EZzGJDJVH9NiElfdGMAk6H1PYKXEMBpH8DRW8q+mdcQD0E8BPEQxEMQD7HBQ6xiJ0RDuoaGvNDOg2a54hIdMzBEodHGoutc6rp+QCK5Ro8WGum48noGkeSlOTioRD1sEDJByMQCslAbz+GhE107GoRQ1FW0AqVoeoOQCkIqGkhFbTEIrSC0gtAKQisIrRwKWimNvRBi6TjEkqTv12ItORXXCdupCfy0Dh6ut0FAH39H4vljZ6EWRVvHhLD0QFXtnx2KVnRg8+UbJy9H2lr9ID7OCTSVooaA2ejHX3/OnnXFfhAOag4O0tvlQVAgU/X1wB99yU1hPoa2D+NwVnG846mpAyJEevuyPjJV1OCs+BEeYUJcCXElxJUQV2oSV7KKOBFO6hicBGpYUbW5IdebuwTFAYik0GdzgMTn0Kczfk/AI97Y8aJH3VRW93k5SikOD9uRhgfycBB4sUE+JKM5AvKSq79J6EUquh3sRW498mwQRdGhKJKlIL8GcRDEQRAHQRzkYDiILnZCIKTrQMgz01wRCeEarRFd/0jiz4/rFbmJ6VzUVQhEauSIoI9OK6fzkIcsvQFAHaphgBAHQhxKiEFlLIeANtT11oI0VEU2BGUoW4sQBkIYKYShshCELhC6QOgCoQuELtqDLkpiH4QsugVZPJCY+neqLzcChcH8mVVgjSD4neevYDJ7++ucsFHaVZSi0NARIRWdV1Ln0YqiBAeAWOiGBKIWiFoo0QOdwRwCudDXXQu90BXbEIKhbTWiGIhipCiGzkoQyUAkA5EMRDIQyWgPybCIjRDN6BaasaQqc5+pzlySKI1aREGRDQTMV/frMCaLrmMaopkjRDQ6qqDe4BmJ/AaEZsiDAbEMxDKMeIJsLodEMvI1N4JjyIU2jGLkWowYBmIYBQxDthFEMBDBQAQDEQxEMNpHMLSxEOIXXcUvPK6yDHohlFgjNP5Mm7xc0Wmso6BF0r4RoRVdVUnnYYpUcAPAJ3J2j8AEAhNKeCBnJ4dAJApV1oIicqU1hEHk24jgA4IPKfiQMw5EHRB1QNQBUQdEHdpDHfQxDcIN3YIbnoWmqPYTpdWIZd94wQMJ19tIN7d2A2XINXNEYEPHFdT+VRyJe6hxAQf3Aaz5tUuJNrQDpGYxEVktaxYhtFezlKwzrS0a0HXNQrZbf1FXtvH2vmYRmfnLvIK0aAxduLuGPpUX0w4Ul3crA0Dk1HNEf+4cQkeHjg4dHeLVx8Wr1V70ELC1ruZa6LW60IZAbE2Lh3ElVhZf4hdhGR5OrNTuWT61WD0ME4jVg8klqDbP5lEsiyaDAK0eBcdu1zPqvq0ezDhpy4K5K8YbzA63Y6H2BNaXl6VoWPLHRPuoqHwW6iCQ/ApulvyhfxQG2Qx+6B8Rw2s21y3alWhd9h+mloLiZvyX/jEYWTP4YegIHVMz+KF/JItUZv42lcmH0yz5Ay+Rw/0n3H/C/Sfcf2pw/6kU5sZtqG5tQy0ShblLpjFqDDkd1tj0uInXIbkm820Y0Vj4ZxJF3kNnE6YrGzuiHapeKOsQ8C3ruLYquGcgmvKapg/cdpha8qI7yn6AWokD2BUwjc4+7Q103rgQg20MgzXZ7CGQWHP9tfBYU9ENobLG1g8Fm2WdQoTvcAifyaoq4HzstZn4jUgSIkmIJCGShEhSg0iSZTiKeFK38KQI1Eb1IfTmJkucmTo0rYFXXNNB0RdsSdXWEUFLfVBV509dK4U4AGTHMDbwNDYiK0pkw2AzhwBWjNXXwlUMJTcEq5jajqe3ESlJkRKDoeBJbsQ/EP9A/APxj/bwD7uYCeGPbsEfIdWaEv1QqbNGRE3X/NRjbufxVbDoFcumtOEjgkV6p8T2CRILsokfa5yGawd6KVfUAHAY25HZH7bNEY0JsZ7GsB5buzwE8GPfllookG01DUFC1r0aBuuGuQXk3BwOSbK1L2v+DdPgjP1E7g1iT4g9IfaE2FOD2NMegSkCUd0CouaJCl0vWLh6Vk6pqncyoOPPufsc+nxGB+O5c+ZewIY9eCzHC15ESyPaVOfOvREmf0e7mSlmE5LvEHl4zjMrzVnSid9ZrGFMe87du/V6GpLl+cUdLXHhxOELfCGVkIylqfP39TMtLJw4z1TOHi2UCpS2Zf28K51+kjyfKQImRHiJmslOWKIFn4n37ZosSUhtkzYempd58w6O2CctpHqGOZw6CShMmBAtwuWC0kpg/Z2aP4uhnMhbkviFB2qs6RFrgyxoZfed8yUsAmNo0MVO//MVnWycXAsuZWMGWIMOwcCng/Vcme+pOGa8zWblz5m/NaUI0s2AV7vX3y++Fotn7ipf6msqEe9+Rb5UC5DVIEXyfJJw0/Qw/ZyEtDvTt+KPJPRO4yaI/aObeHv/1QqNAIMrk1m6skv+2DWtuOjTYyE2+aAqLbg0qKl6smfDg08+9FX2W/MMG4MzhwTRlrqnRy9infsXLfUcvpqxhbLm3Ww6lVm2x3mnLbTFXBRYkrCzGrgtK7ENbFYa82YTbgKLZ79HhLf3XW/tI6aB90RqZpArTX+48OcxlEXXSLTAo+D53BAOhdkf1zpUg70/EP6QDPKV8yFYvTh3fFl6F7HV7V28Uzn9KHpcb2nYcHeXLPHoGnPieIqy7pIE4nfpS9HGew7oC9N2tyMke544VTcuxrNzkR1yh9idkOurtQORLaqhXQapdcPYSQDvZJXLr5iEEXcd2t51yNqb9c4CaHQGPyZ1k/xdnJSOl4z/sHW3moEjcIdzjgAmR51J+N2fizj1vDQlYLY9JVn0QrLMPj510481sphqlt7WyCGrW0yJs9y2iLrKi6mA8XArCLeCcCsIt4IGvhWUQM9N7QEZPHaP93l6tYfDEkAlC5o6md1IfLX4J6Gd/E4GAFtmuzOm/HzD0GL7mJGXSKkmcOSF934ceuGLu3faNoWpTn+hP8jCLo8bd+zfYbXgLaHQv7gRoWrQb77RJqyOk3kwa57jglYVWu4PwoqjBQFfBHwbSvhYNOCD5HlUVVsvvWOxxKayOiraOgwwOHWkVohwwV1aXmCj8G4IKh8wfWTRfK2x5dRAZulferCzYB+zwiemm1gUZjJTforgdTl4bY68EMNGDBsxbMSwEcPuHIZd7rgRyj4MlE3Da3e3QJ5JaFENTDQTbw4M5Nb0bER49/B0i2DeMKFvnaWOCwU3eywExHEMISA+NkDc7BMOgY2XtaAWTG4uvCHEvKQHCJ4jeN4T8NxsyYijDx1Ht47oEFJHSB0hdYTUEVLvHKReyYcjun4YdD0TQbt5pF2jsFrA7MvtOs0bJNYkg4DcFf0aFeA+LL12/kYvtcDHhhrrB92wrv9C8HN04KfetA8DfZrqrwl86otuDPY0tB4vKkNYMQMr6i1l35vKRo3SWS0DEaNDjA4xOsToEKPrIEZn7cERoTsUQvdCO+busnIL/TGATqGtxmCcXPbiwcF0uf6NFq4bjp57BtvlBT9m+E49GBHGQxhvMDCe2sQPD+fp2tEgrKeuohV4T9MbhPkQ5tPAfGqLQbivJtxXuoxE2A9hP4T9EPZD2K/jsJ+VJ0f470jwX3K7mBYHzKmvDk5E1fvTOni43gYBffwdieePQ4ABFd0aE/o3LK22f6w3WlF3wVd6/MROpK3VD+LjnCNX6XRkeKJ+VPfnBHlXTA2hyrFBlfrRcxCE0lR9PWBSX3JTeKSh7cM4Yl30Snj2+YDopd6+rA8+FzU4K36EB5EtME+rxTNCnQh1ItSJUCdCnd2DOq0dOCKcB0I4QcQrqhI35Dpxl6AUwDUVumoO+OLrkeHhmbz28QKavddr92mMSoGPGm6UBh3SFhELHA4WKJn2EcDAXP1NooFS0e3AgXLrkZaIwJ4O2JMsBemIdaE53TIQsTnE5hCbQ2wOsbmuY3MmD47g3LHAOR4GFtE5rq0aMM6PJP78uF6RG5joBwDLSf0ZERw3FD12HoaTBT0u+E01uBB2Q9itx7CbyqQPAbep660Fs6mKbAheU7YWYTWE1VJYTWUhCKdVhtNKlnEIoyGMhjAawmgIo3UORrPw3AifHQY+eyAxddpUF3y+hUVKVjk1UJZ3nr+CGertr3PCht4AELNCn0aEmg1Jn51HzorCHhd6phtoiKAhgtZjBE1n1odA0fR110LSdMU2hKZpW42IGiJqKaKmsxJE1SqjahbLPETWEFlDZA2RNUTWOoesWXpvRNcOg64tqTrcZ6oPlyQKoaZbUFIDqMzV/TqMyWJAGJvo0QgRtv7rsjf4WiLqcaJr8hBDbA2xtQFga7JRHxJZy9fcCK4mF9owqpZrMWJqiKkVMDXZRhBR2xtR0y7rEE9DPA3xNMTTEE/rLJ5m9N2Iph0aTfO4OjJYmlBQDfTls4jwBgChJV0ZEXY2AO11HjRLZTwutCw3mhAmQ5isxzBZzpoPgY8VqqwFjOVKawgRy7cRoTCEwlIoLGcciIFVxsD0yzMEvxD8QvALwS8EvzoHfpmdNqJeh0G9kpCKmmmikBo4yRsveCDhehvp1i69A7tyPRoR5jUcXbZ/b2XiUGrcVsldLGt+7VKiDe0AqVlMRFbLmkUI7dUsJet+a4sGdF2zkO3WX9SVbby9r1lEZsYzLzYtGgPhlaFP5cW0gwjnPdC4gGH1zNOfu3zRJ6JPRJ+I2ya4bVK+baL29YfYPdHVXGsTRV1oQ3spmhYP46rpLLbGL5g2PJxYqd2zfAK0ehimOasHhV1bPZtH8CyaDAK0ehSmH7ue0UnG6sHMVGJZMJ8w8Gbww22cqT2B9aXgKSCY/KHfGRKVz0Id/JNfZ86SPwy7TXSQzeDHpHTzbK4LLZSAZfYfppaC4mb8l/4xGFkz+GHatdvez+CH/pEsWJv5u2wnkFad/IGXs5dvg5YidrgbiruhuBuKu6G4G9q53VAr342boofZFF0kynCXTBvUanP6qbGvdhOvQ3JN5tsw8r+Tn0kUeQ9DuO9J2a8R7ZcOTa+H2CFgMtJWBZevRVNe0/SBmxnTYF7KR9mdUut7XHtUpjHfp52qztsh7giMbEfANLIOsS9grr/W7oCp6Ib2CIytH8pOAesU4s2Hw5tNVlUBdWavzcRvxDXLcU3LlTWim4huIrqJ6Caim51DNyt4cMQ4D4NxRqASKmuhEzdZT87UwEYNYOyaWvoA8U5Vt0YEdw5Mq51Pj6KU97jQRsOIw7QpiPb1GO0zWPYhwD5j9bWwPkPJDUF9prZjmhVE71L0zmAomHKlMiZnt/xDSA4hOYTkEJJDSK5zkJy9A0dE7jCIXEg1ogTkVKqqgdzQlQd1g9t5fBUshkpGLO3jiJC6Ieu7fXLYgmzixxrn0ttBA8t1Oi5o0Ha894eUeES7Q/hxZPCj7eg5BBZp35ZawKRtNQ2hlNa9GgY5kTkvpCYeDty0tS9rmiLT4Iz9RIpiORy6xxobsVHERhEbRWwUsdHOYaN7enMESg8DlM4T9bg0MHX1RMZSNe5kAJgKj0plkmQhPU8uUof5pMyZp/NU8scOhChOYUWMgAXy6UUyxPt2TZYkpFZDpu4NNPkyJziYdn2IJXeRN43MVyvn9J7axOku/HbAwdLINCS5EqIXGqdS3c+daPvghQ4dwc7dhppTUiAL9rfBiorReSZnhQKekyaALYTrlbNarzcTqmMqMH/+6IDmQcEvUPmuunwz5Mphmci8XAE5SNKQzUzrTLHCnD4Q6otOcv48k8hM777lpcrcAldIUqrbrW6NqaKmORFkWj3l4nZByOcX2lKYu02L2qlSs6TlVgIGPmMrNUUsZi0Q+jEJ6ZCYvg/82PdW/r+IlUhYa1M/Ga9ezhXtOlG8aBov58q0rlPX22xW/pyJFxJOiU/ZJDJx0vpONF50vqJLGycZkXI6CQITn0+77rrqyov+WW5M5UXp1e7194uvxeJZr/KlvqbuwLtfkS9fKkFlZtA3NwSUD6fm8Vb8kYBwKYDCYrybeHv/1Qo8PYBbVszpzazqNVtHapekslxahvyB5i1mA/Rh9lvzDAiSPkKCaEsn2UcvYiL5F22LyTPwd7MpFGdZOeWXHkLHbDoC+xPWWWPLi5XYyrZWDWuuvovJfh9np1JqAoy+xrcle66j9neAAu+J1ExjXZqDfeHPYyiLzna0QJstpX0MI6/0g+1NHtMSVIO4P9uPvTW+ZncQZQOaVFm7XIxn/zBr4ofYI5Trq7cRmC2roc0+qXnD2NADd2CVB7uYwBw3/9re/Mvam/UGH2h0Bj8mdRNkX+AmE24y4SYTbjINe5PJdcWmOutTY3tNmjC45/tJCig2XbOXSkndICH9WUYPw9rWYpklk1m9TiJaEl8t/kloJ7+T/mNg2d4cFwrLtqQVRGwYimsfm/ASIdUEKLzw3o9DL3xx984Aq7DO6S/0B1nYpYTlfvI7rFa8JRT6FzciVGH6HR/ahFUVqGQPq9VY5KhQO4Vi+wPe4QBpdIAgpHiE/MdFuzlI2mNVtTXTHReLbCrLsaKxw4AbUwdmhTkW3JTl9YIKr4Kw5QHTKRfN1xq9TA1klv6lxzEL9jErfGK6J09hJjPlpwiPIjyK8CjCowiPNpg52IiJDA8lzUcjCJZq0hcTOibSVeJMQipqQHAZduuwYFRNx46LqGoa1Qq4OjjNIozUKRipni2X2+mo0Fezt0IgFkcQYrKHx2TNo/IQ8GxZC+ohtebSGwJtS7qA+C3itz3Bb82WjFAuQrkI5SKUi1AuQrkcyrVGYIaH6hpCGwR41QBvJt2omwd7NeKshQ6+3K7TfDEithsC6qvo1rExX0WTWkJ8B6XTLiqkTNgjAy31g62r99PtYQSIvB0DedOb1mFwN1P9dVE3fdmNYW6G5uMlcYhpZTAtvaVY3hKHEBFCRAgRIUSEENE+EJFVyDZEgEiz/kZ4SAcPvVB5u7tUwLscsEpZNoYj5KKQoWFEueK6hBXlmnYAzGgwuu6ygmyFP2IsST0o+4UpWRkHYkvHxpbUpnZ4jEnXjiaxJnUdrWBOmu4g9oTYkwZ7UlsMYlCIQSEGhRgUYlAHwqBKQ8ChY1GKdTtiUpaYVBJeaMGpnHDrABfU+n5aBw/X2yCgj78j8fxxANiUoldHhqQULWoHiRqUQts/ahetqKPgK1FO4Y/qXp7egMpL1DkuSEs/lvtzoLMLVoYg2RFAMr3xHgQbM1VfExLTF90UEmZo/DCOOxa9Ap5DPCBuprcv60OIRQ3Oih/hoUBE2xBtQ7QN0bYG0TarMHeAIJtmuY/YmgZbA8WvqMDckEvMXYLIAFFTSLI53OVzCHduDw5J493qFJTGm3QILK3vOu2iQsqEPWaoSxpsnWdt2RsBAlFHB6Ik0zoCEpWrv1EoSiq7HSxKbj6ysRBV0qFKkqUgCwtxIcSFEBdCXOhQuJAuZBs8MLRbfyMyZIsMPTOZFaEhLssaOMKPJP78uF6Rm5hOf/3HhKTuHBcLkprSCgY0EN11SQE64Y4K61ENoq5jPBbKRmzn8NiOypQOgemo662H5ajKbAjDUTYXsRvEblLsRmUhiNkgZoOYDWI2iNm0htmUhFjDw2oK62jEaNQYzQOJ6VRCJeVGICqYqbOiqxHWv/P8Fcybb3+dE+YQ+g/LFLp0XGim0JxW4JkB6bFrijAJeVRQjW5gdR2usVQ8QjaHh2x0JnUI2EZfdz3oRlduQ/CNttkI4SCEk0I4OitBGAdhHIRxEMZBGKc1GMciFBselKNcYyOco4ZzllRY7jOVFo0BhLioARZE2AAccHW/DmOyGA6oIzrUDUhHNKZVQKf3GuyWEvQCHiWUIw+nvgA5RpUjjHM8GEc2p0OCOPmam4Fw5FIbBnByTUb4BuGbAnwj2wiCNwjeIHiD4A2CN62DN9qwa7jQTWZVjcBNGXDjcWFlYBshvhohfxJs9B+tSWo7LkyTtKIVfKb/yuqI2BUiHRUUkxsrXcdgzNpF8OXw4EvOgA6BuhSqrAe35IprCGfJNxIBFgRYUoAlZxyIrCCygsgKIiuIrLSGrOgDpuFBKtlFMmIpaizlWciI2lgirhrh+BsveCDhehvpJvC+QSi5Dh0XSck1phVAZTAabP8WpcSf1bg7iTst1vzapUQb2gFSs5iIrJY1ixB6rllK1vvXFg3oumYh262/qCvbeHtfs4jMhGte8lo0hkYarqFP5cU04Jv0fmdU4KN6lunPfXLoCdEToies4gkRoT88Qq/2socA6nU118Pr1aU2BNtrmjyMmw6zkBq/39DwcGKmds/yucfqYZhhrB5M7t22eTYP3Fk0GQRo9Sh4frueUf9u9WDGi1sWzH01Xkx5uD0atSewvpMyBQCTPybaR0Xls1CHsuSXeLPkD/2jMMhm8EP/iBhes7luVa8EKLP/MLUUFDfjv/SPwciawQ9DR+iYmsEP/SNZcDbzt6lMPpxmyR94NyjuuOGOG+644Y5bcztupYj68DbeFCEw7r+p998WiajcJZMVtbyc9Gps5tzE65Bck/k2jGjg/TOJIu9hADc+KLt13K05ZZNa2aAbmE4PAU4zEWmrgptXoimvafrA9elu7v86zQu5CghYxx7KdD2qrRHTWO/TBkm3bRDh6MPD0SbLPgQoba6/HjRtKrshgNrY/KHA1KxTCHYeDuw0WVUFyJO9NhO/EVRDUA1BNQTVEFRrDlSzjIKHB61pF/UIsKkBtggERk1ASMxNFlUzdXRdA5m5puNweGCbqlfHxdpULWoFahuWQjuojhJRjwroMoyzricjsLcAxJkOjzMZDOsQMJOx+nook6HohkAmU+MxkQHiRiluZDAUTGqAaBCiQYgGIRrUGhpkF6gNDwzSLbwRC1JjQSGVlxIKUgmyBnBAowzqo7fz+CpYDJSDVdrF42JEpc1rBTAasN7b58gsyCZ+rHEqtBX9V9HtqOAq2/HfH45WF+wP8bHD42O2lnwIsMy+LfWQM9t6GoLRrLs1DN4W8yTI2joc+mZrX9YMLqbBGfuJ7C3E6xCvQ7wO8brm8Lo94uThgXdWIQIieWokb54Iz/WChavneJUKeSeDXagPMKEs+GICiXxuL5sA7ERzTIdOt5cnCkvh4+1cmZps6q2evZeID35R4xTuxPEDd0uFvzq/UC4fNY6JFbmhBu3TJjGPpyx5tV5vztUTBis8LSZJK6t4WP7kYsqkLerJmGQZ8rZT13NIG92qvuA/VkuYAqA/UMO9IeF3f05V+D6g8wX5zJ54TedW735FvsBM9VUuI4c6cLQIflJLZRM3vHORs5PijCehVL2Xk+2D1yTarmJbie5fbHZwWr5tUM9IxW8y6DqyPT09/UhCWPg4XuCc+uw13ulThzspx0uTWk/p4+N1rAqDy5jYRJjKhClqBj9ONKuDV4nKnGhD5v7Sn4sZO7rcww2xsuRmqTFxWzz8mq2gTWj4buhwA7N59Db0gshjqx+7ohtD5cu23thv5fZaW4j5v+WraSHmlmuVVkiiwyKtaxNTIdrgnjbYa6OC/wLvidTI9Fqa6Xjhz2Moh4ZItDBDaXtZeN6Cy7cc0awb2OzMetw9NzRdHEXHHEXt7F5a71w2v2uZNcmLmnWV7UrKdZ3su+mYLUaNh1fbVZSaVcSE9981PNCOoagGxpI++a02Y/FJ7R1F9W5ihZ3EY+4i7reDqNw9zNqR1Q4haGwGP0pAZX3G2wL0+jkJXe+SAO9u4lABOKf3XkhDWxAGdUQhyb13J8eEd/zBibMNViSKnGdyFpJdXAxOJVznwVqIPScOfeD50Z8/OgDJAvL6AtU5dMERw1Q9d6Ltgxc6NPRWNSET3d7Jru9V3g8nLWPFn4q2scj6NNeIXf/z8HIiDecuDdenJ4WdJGkqTOzxspTLkHG99osSDXWhBHAobMRmwYdMS8oACAsgolCVApRQ1GgAJqRKpWINIIV+A52DForIrNLOh9U+Ud6BUBdl9j/nF7Y0ALKqZE7psvV9QJce3sr/F6lgUKnQU0uPVy/n/RPiyeE4A3tt2u+7X3+Avfq99+n32aOvtT9fZW9ev20qrfFhtv4YruN10dbze9Uhi2Szw9E4TEqtv72d48ob2Tnc96TZDd0GNnN1G7ks0WGyDtsDxbsh8dXin4R26DtpEszrLvSb7fGYEGC53w0CweMxod5jTl6ipxrAkxfe+3HohS/u3hlZFUNw+gv9QRZ2KVpD8h3mfm8JBf7FjQi1Af3dY7T6lS38VXGQaAZB25By78BfhcIRA8bx2MR4HBwqrVBG2+C0ssq9MWpFaboosEquYkUb+wtYpwO/FLUuDO/SN5SjEUHv5kFvhUlaYd+p8mfpX+oQtqD7WeGTiQbhUpjATPnpqIH1vkLcjeDOlTHni6k+1EOAuSrA3FNZIs6MOLP+DJgMNKtW7xXwZk6ulfHm8lHTrxNOWrpwx3FnGrq5u0XsTMI/9sAQM4jG+BBpTefHBE5rRdAgTj1KG0OIbOgQ2f5Dp3xoIJCdw7bMrhoxbRywDQ/YwcHb5hHUNtJdVvveoLe54Abw75KWIxSOUPgRoXCzdSIqjqj4gFFxq8ASAfKqAHn/xYpYOWLltlh5SVRQBTZP/JUEnFcaTYihHwJDj3cqcfN4ukZde8GeL7frNIeX8MuYt6EOXK8Q6LjAeqUAGoXq0WYR/m/E6MqMCtN/HAg41zvN0cPm+xr6AMFhvZW0Dw2b6q4BDOuLbSKDh7HZPUCFEYNtDoPVW0IpAovZNDCbBmbTUKO7pbEIYrvVsd1+CxWRXUR2LbNtGNfzNbNvVBhGmI3jIIjuCxWDu7tZQeiKAboKVdWGxnKhOkJkTcG6uaLGC+8WBNEazIu2jHBvY0Zoa2QI/x4B/lU7V4SBaw6AgcPBaqs5LCysa0ND8LC6+OZhYk03EC4eLVystgiEjRE2Rti4AdjYGNsgfFwPPu6vcBFGRhh5LxhZEw80CidbDSuElY8BKydeVYsv53S3DzZHdfrTOni43gYBffQdieePCMnVgJcV8hwVqqzsf5NgMhosYsgsNdGKenM39p+IOMwZaWvyg9j61P5+9ltinwg/HwZ+1jtfzNnRgSEzPORab3CtA9amqvfHqfWlNgJPGxrd39QWxWGFuSdaALL1tmOVeKKopVnxI7x/EKFvhL4toe/SSAwR78qId79likA3At22QLchaqiLb1sPIoS1DwFrg3xXVB9uyBXiLkEjAGYrFFUfEuQIx0hySqu6PmK8ORFAe4DzGKwLzaNM/Zgx2QwcSY4IGb97muTQ8VLJSg4MmObqbgoxlYptIh+wqdVI5B0v/ilZAhJ4+48nHi2tbfn6FnG8mjhe74SKQB4CedYpbU0L2pr3wFUYR5jM9jhgHldbEc3jutoDcPmRxJ8f1ytyE3sxQWrf/uCgJMgxgYK5jjcIBqJtIrS4p5HpjAi5oQcBKFXOEIHJigY9OEBSZRVtA5HqOvcGIFXFNcHVVDYTEccRIY4qC0CkEfmSyJfciy9piB0QYK0KsPZVmAisIrBqyZBUrsdrUiMthg1yIg8Aoz6Q2H0GRbgRaALWXFnN7IFMvfP8FSy13v46J8zSEJ3aHzktCHNM6Kmi8w0iqGiniKLWNDaTMSGaehA0VecgEVHdw7gHh6rqrKNtZFVf797oqq7IJhBWbXMRZR0RyqqzAkRaEWlFpHUvpLUkxkC0tSra2meBIuKKiKsl4qpdr9dEXS2HDyKvB0Bel1QXLsxL1FUKbVBjKWioBrJ1db8OY7JAXKs+/ipEOUb0Ne16C9grWigir3sYmt6QEHU9KOoqu0XEXCub9WARV9kyDoW35mutjbbKBTaJteaaikjrCJFW2QYQZ0WcFXHWWjirMp5AlHVflLV/4kSMFTHWihhrbn3eEMJqHDqIrx4UX/W4LjLoqtDOHshVMoE3AFnpIvRKsX81ODN5+WA4phQR72pvEErsp0KOLF6F+MqRs1fO+0CMv0gsuGExvSB02RE8sHgBxu3/3961NbeNHOt3/gqU/EAyh4ZP9lwelGIlii0nSuz1liSXT46igiASkrCmCBYASsvs2f9+uucCDoABMLhQ4qW3amVKHAzm0tPT39c9PQC+EMSMrIFve/YoU8UCVSvUEkXuvWfdIdKx5i78PhyhdR89BEv4Cy7/vuNMg+XtzAP7FdRsNIFWTR2nn6nwyQ19F0pFqEDcp8CfWu58ZXFrBiwiVjtqmbuZP4kj3kzUGLwn/SjbQDeEB2A8owwisS4fWKMib3YHzVgXxA2LoaQnfCNoPsAjP62gctCBQaYOfz71JxhnzwgelNFEo2EltwH0VfyFaU0YEhiLTCV9Kd19C+1E2IXsfRD+AiW1RaxijeWGa8sLQ+i4kHUnWi4WM0byDYZaOAliO7gqMv3jIYJoK0bhujJlnUf1SOfr63LQcHfUl53uc3mVkA3aDmK7hMm6hTU8efCmyxlsuHdgS0Gp/q9Z8nBoOw6uS8f5rW89+a51w22rK9BS17asYMB+HSYjPZjIbvEvbo56OlTZpg8Td86MT+gGioJpH456vbrWeq8WlrqqQfDXWK/X+TcVCe24WJpHvVKWas8Y7ox62jS1nXtdC+45W9f2k85VJGwt0kBDYRswbRnUFy3c5/lAUUpdkSOpKTPhSUwppeFhcfBmkrB1giDWaGaJGl0oxia5Y5HZHZyf7N/jBMw0gJEf3Pm9FwbLSDfQ+3ppR6bThxTdlOt6h5TEQcnSzt9FKwnWhjfQ8s2DjUyrGoT8Na8CeYkWjwuRaVGDSj23GgqcxxYVLJf+tM04xsvbFo8rslfuEapohBt7Tkk/yqtoqeqKVRldN5Mhq/RbKF3yTYqVFCsp1n3kv/Qab9M0WNFbG0d46ivs4KKkgpbu7q3yaiQIv0u+oKCUwupyfKFUFkTNW1lIyGtluWyMSUUTcZAqi6FGrO4F6L3KQop2M6iQ67B1QQrN7So0V796jWi4JGhHfhgVOKJYleNQx7ZkzZax/KAvhgtkjD/0X4ulMZ7oDF5tAJH6S1HLcFLG/B99EVwVY/xR0GhYD2P8UR2dpHwuqosvhbH8MKIbx+jGMdMbx0qJOgobrhs2vLvDSWHDFDZsestYAepreb+Y0dqhm8VewqE4lVPhsPDECOQkMzsNfEIXcRB6595kGUYA3D/zKJrD8DJqu35IvsaCAejQ43iA0rUH9DibpcLq8YLDyOa12/dclJzF7Q92dp5N6cqmYlglZuQTylCLZQqPPENbLvp7x9eXSeOmWfvydzfm7suq7YDBL231LvP4/NANscads8ZlEmPIHbNHxuJfYjGJxTRmMQ2Mf+Iy63KZuz6oxGgSo2nKaJZaxy15zRrriNjNl2A3I5wQGGkxI/JAH4iOdqoakFGYI3GTXNSh5aDVjech0af6/nfInpLAEiXbichViBQlp30R+rVEX1KG2mZSvnekaImMbJoTLX11Y0q0pNYustaWNZpS1x4Q01kiCJS/VilA+Wspf60J2ckp3GoEQgxuXQZ3x8eUCFwicA0z2ZbZ8S3T2ZovIspp+wLkLU6RlrvVzVMDJgzULqzx5SQ+mU8POGK1chgOiX41GIwOudgDl8CdD+2beov4oeEp/87Fro5YURRrhlEyVYIU0boFYr93BK2p9G2arTVvR2Pq1vQVHUS2Gvdmd6Nc2UqkGNfumV9T2TGKd2WzNGY/KdaVYl2NY11rwgNiTeuypvs0wEShEoVqGgNrbHfXiYeV2ixFqTZcYRQd+xIE60ROjuPOp05xrGzlJPI+T2awJi3nYxDCcjtejwNMT5RVEWe4nd7OPKYi1kWRvYDpBB3vOAOW6smqejij//EhG98I7YafTVg5tkgoJLI5o8z+fdlb12Z+FF9l3s9V2HUnTC3JxNZxvHgdUYvcqJUJe6f+JMZ6QDdDZVWUVjMBzAoY3UsnGnig99KRekizhepOst3U+45qo3qMqjodh3efVkGjmWqrymKbTytcfimTaA3bH/zAvnfRp6HHSr+7qrpmyQ69u/KIQX9aHMVna2yfRmxIsR6oesagIL8xEtMEzxVQ6k8j7RPXdG9Y63vD9lVERYtUXWd8MZm6G4zxx6iyqGEi5aSvW7FWdofjYAmVpF+nSbI5Lz6Z/uxBh54OJYOh0uNXBPHpZnSJ5Q9nSjdn7rpyAFvYvG5468ehG66cxinSNLJq/wg/vGl1zjS+oT2hE8G9wwp/D6gSJqf4thR4/ayW5V1XhgtklFgBYgV2NDlkfn1uN4wnvdaRXquZhDDfX+IXRKMTkawkGXKCZ3Dzj0ZOdpGjKLbpiKogqmLPJVVmNssr0drERaJsxsmnagojp3fGub9UV6JVRWPtX4kh6TRHmgfjm+wx4xT0aICuFRv18LiTgs6/Io1S2KIuGZWDnHMCIa8LQlpIdrXkEuVClMtuUi7lWxCxL4em+OoRMeXSQ5wMcTLmSNfIKiR6huiZwxFa0cZyLUukDZE2VaRNvJYgJ0vgFEhXI1y/ugyS0z/CSqVTEG34Ic2Avio7pG1Pt9wQydA28U0dCkHVJBOJQiQKLdHZlYn23yJipp2GqMs3FA/J4bENuwSTKnd1QvaE7A9FZBNcX6zNaqF6gsN14fDKidlWLxJaiHljaFgzJ61xTMY8IDzTFSbOVLU12DjXrs1hZJKtXcHKDYTCdNIJOxN2piU7u6qzS+wQhjbTHG2wtH6ICFPvCkAptQIIWxO2PjTR1WJsvZYjrP2SWFvu+4WgOzNJTQASTOqnYH5/vpzPoehHL548EC5qgbk14/maUFvbnE4RNgnQlh96iGag9pzYf/REwFDU5oaRTsSrQnwIohNEp8U/uzLYVLb72MF2qJ6aYL94sClKXzQ6P687GUZfaboQG0BswIFIrCQBirVf7ej5vJYY5/9E0eudUgi4AGYwf07IJ9C5wxlE4kAzse3hHre6DiQFga7r24PtZXs2CO4PYba3b7qqpoPQMqHlvcC1KY263S7nGmu5FfpMDQm5mHfGMtftlAQmCUweisjq0WRKm5Er+UVx4DMb+zwQ5HPS5OI2L/72EMy8ixisIvL4tbjUTx3I17zcL92OTi/5I1nZUlRae9KLJpVQKKFQWpKzqzKtvtWY1kQT1LzUTjMEhGG3+K6v4l2asCth130XVXk9nUZrEVbd5EVyXuw844g7EQ45XimnTkEDuPHR9WffwE47/WXisbEmyNEcnuYG8xUhqqYtXcJUkptthqqNJr9scgmyEmSlpTm7qtL0Ww1bTbVCPehaNBQEX7cXE1Ts3gRhCcIegriK1hVpMIKyG4SydzDoDppbsFGLYQdxzk1FC2hychuEsTclYNIe0Iqh3AI4m7RkE2CWJGZ7oWyNiS+eWIKxBGNpWc6uyvX7ToDYcn3QDMKmh4EA7PYjAu2OTfCV4Ov+C2sGvKZ1F0HXF4GuLh90BbiKaWgAQj6483svDJaRbsr29aBoptOvCDBzLekSYB7U3G4uRwosUXfqxm7DzCh8k2BNblUDl4wWVSC6avG4mMsWNdx6AGpDJw6+e/NWQ4Fz2aKC5dKfthnHeHnb4nF/6j0yCD1Ztbjrl0XiOCX9KK+iE01UrGmI8SDGYze5Cb1psN1JvGiDog2KNqgmFJx+tVMWOdFoqVgMLm7narK6HJ+0yoKoCioLyaTLVeXUZW3QRBylymK4RKt7AQuxspCy3Awq5ItqF5P5laJRIk+JPN1/YRVt0+86tbP3Se08lh9MLq1nrxqHOsZL/wBX2GP5ofoRVN1j/FFdVAzbeKIz4HX/qZp8rP5i0hOUyjH/p7o46vcx/jDoMGj5Mf6oLqro+rHy2eQdXPGP5QfKytgltz6VK9JhJEIEai6zSBvQrxdxEHrn3mQZRv6T95mzFIdBsGu7/oo0e0F7uiTbD3C2N8losOErfAVmz4ls/gb7nk+ys7j9wc5OQC2E2VhKqqSA6FCiQ3eTDi1T5NtOim67CqlHVZXNBBFWCWHFdd8O0iMG9gORJESSHIrIihaWab0GhAl7fCz+JQjdJYSOcKZArMVUOVIVj/U2cQOEhdHzmwRYh3bMSjeer4jR9c3pEqKTAG35qaumIlAxxQS/CX7TAp1dGSj+rT6EVUM91MPWJQNCx7G2F39U7+eEmAkxH4jEigaWqDI6nbVB+BvCuGvRr25CGmAX2P+jOFxO4pP59IAdy5XD8IoA1qBtXaLZA5eIzXmOpt4ifujsGuxOpKLOrBPaJbS7m7jUVLlvt+N5O9RHPQBsOvLkaBaNZpO8i27mmlYDAWgC0IcovqK1pnqxtiua6Y8x+0lu6C5x+ETOmOPOp06xU7pyZnmf/zSZwQrnr+/xibvD0YT1M5jMohGMapTd689AcNCQZScc2Y4uZd/5yJ487mXWWeb7AVQ6LHl/aglhK3rGhy7zqgCxQmSzizzOpnkDRzFujE7HslOdaiWpAfjmud/PvTsv9EAPXmn/ajsXkwdvupyxwLtaD3KfTfL4sSIs3wCRLBeLAE8NwggjzLlRNdLwhuEJ5Yl5YN3I4bzBdTafrVCjzyMfxNllUovWMkrwLfwBJhw/Yu2AW3oqUoDXwQJgjRvJX0PmikLtHKA6lRKOj8PC8aE/ShXJuxi2uFFk4gbeNcWlAJ2AugBlTNx5P8YrWyxXqSGUo4RtDJYxYJ8nQFpuBJ0EGCTGYL2MwHxUzxridB7rDlvDVJcgCgEObGhNdpeBFyjHN/P1s9Xh+qAQzpegKh690zAMCnad/mc/inBKxRaV1CwhJQwZ/8vNH6y+vgoEwKtgCSoIK2J4jg0zEwsYMOuc9e+P/TLtKDo2Z+dHk+1enm6qgb2GLQbjRsgzipI3TdrvquIMQmKhQKPkgtLlpWB3cC3ZELuyo2D3PPkTdmOtWEl/Bl19If5qI77mH2Gz0QtAUsNLSIB82QuIQEarp7RUvv1vrMsvH74MHuJ4ER2/e3cPL1ve2pPg8R0XlLdT7+ndYzAP3kEfwdh49x8//PDfw2PLnU4TnYZrX+o1rk/cxWKGBAXuy7bmnbDTgJw+8266s2d3FeGKX0VSFHB7VSrhPMcE1FaMDM2DJ4c4X7nyFJ5YywPm1IE2WQ2/WwoWx52tO90WCa3O9qux0QYw0nT77I61nbFbU3+KqjJaeBP/boWkDdvgLH5OHFTpo7uCdoLhYnmgZJeLRDLYyLwFKM8okNRzupeiaYPD149g+57A9jG1GGcEyhjE2gp4m5hN3mtx4lFK+Fh+SBdRhNRcQF9aODcmmJVCWXHC0kj+9JJnMIXS2ish/3OWoMIJs86vJw5QziwFCLabMXQcOeSIXFuy/SlzVjEg+RgJuJY3c7XgGT4qh3RNCEz1JQ1YSqf+q8tfoW2DUrF9tv6sa07TNhi9giGDeLmYFRj0o9zk5ZjOxElCK6fzlVNfeNssoc3KcQfNMX6bgphjP555DRMgober4aPu9GcPRPGpyfOdLsrKhVfuq6TVWLGPvfgqbdaKLVi9O7MpkgbhfJ3kI24k93UzspBaO7oF+/mI4YkIoxaUZ24WYFjL4pIBiUbWcj7zEMV7/dBbEx24+MNA5aRnQbBAfk6ERCDzjHhixYIjQHPFqFEmAGvu3RBBTfbViD0ZwEgxaW+UYl9lS1iVR6ItyG7MjjIvXvdVZc1lr60bm0Mj9irN4q65hKUM6nSZ05ZM7nXvgO/1ivzcVSpYx8KlWp2h3tSBYE62qhfU9cJnldpIr+o1jKAy20Xuv2zl1Z7O7BOVLvl8+7vy0Js33qzFlc0sUNDFUQBMO1cm7WNxS1WFEo2rL1nhpzaY+jq+6O7ndHtls3DS811Kv7xUVCsWq3QvqyvcyIfMJG7Mfur9vShsY/yh/zoRs3HyaVQSIuHN6utXE/WVVV21lOp2Sn1bid8iaa8l6cUaSvRooFkLVfNdGG6T7XuT7b54Docj6+hs/uTOMPY0vF8+evOYAVTb+gB/QufQAnp1/M/5kfXP1JNHlvXWOrH6sj19TkuL8Ddk+KEWqy/SzUAr7JTR0f9jQZV90RNRH5p+RRWq3er/8ahUOHdmvTWWV5Pl1+tYQZcq5xLFXKmUhyl7t8DeKY//XMc9tXRDq5Fh+j1K0DYJQuFtThpgc9jjMNgzGGqrQGViFUdRyTdkUFfBixDXFbwneZda4xpCap/J/3VoZ3nkWjFnKsQoKpHIZlGBkjCwbMRjLdFIaK+zuY9R+P6/PEPhkEOaCGs8Ww22fqgUsCrT6jZAwH8JF5PP4nENDFadgCW1K7FUGQWgDbBOz0dR07g2Z7+kA6uroPcwq1+Up201U7y+PdkxTQdjJxWUvSSbx73sRekhzrxM/TIzsgraL9KrRvpXwx3AdA7EGGNiYRt//OdgaBKQnGMh1kru3pujAvTWjYqTwvpVxr9FAXDYpiQXqnxJ8k3REhLxBfzpQjqFF/oRygz6qWR7Ym/9zA8g9QsiXLnbYNzn+qKvL6TmU84C/PLFnQTEqdt9Pjg5pXDSnvysjPXy+hafXt4mFknyRtvh0YCq8h1mozMGOfMkeT7dt4ytwtlUNFZ+wiNaeRmQ6pm3rUIhV2jTWhMgppUnLVd1gb5o6WhXbXPDXHBF+9DllmHLupBlHreC8cjsQwNnNnrTUlssC0uGjdqNjIJ0rd+NrIfg+bjC+P5r8KyN9VTL/HR67nz7cv73j5++fEvHPSfR1mdKS9u68fU9h+5899YX1zBN+/Xr2Ydt6mVlT/TR3eaTqnMlqaNSYMckg5WvSB28eq4uGNOSiPCqQctGyGuLZ1SlqpQMopOV4hpliWM+Zj/zKgeGdAz/57+A0RrD/6MKlaQVhBQE6UQQhrnhhNrSdjmrsapVa6j1Us3q5aYiPaQ4ztWr9ezy9Pzk8uzLj2YTIHArNKZuC1EeypvziJuD98vCD2FsUvslPDsY1u3dyadvJ/+4KIwjxN2V9RDsseTz4C4M/gU76mW49PieyaOci1ZiT7eujs25mka5DTQ2ye4ehn798MY2h7JbBpxsNP9GuzCDNsk3SEA3E0X4Oik/2oTZtAy1aRtus6m1UDNWjxbAhgP39lCF00IsWIhvrK//Y/mPixB2IPRBHluTB2/ynfsA557PDtEsgsjnjVr7Kp/dyHIneMRoHsPQrzK13kPPMPbt/vyn98mdncy/WYc6nsMfpRwKGllxIajfjPWxAC1fpnDWJi8r5PM6i2vrJvSu1H3XdVhb+9C2BklmKuLZDGPaHD0RWegWYSekM0dy62SUOS70HPLTqZcwzPxo6t3R6S8L1B/ze+suWIbxg3aR8gPjlS78kXUPje7/KqReNxJD2xFE/W/9I02YmnmomnG4mnnIWrE3I5mvqpRB+qlrFOfRbBbBngmnWzCJJpleegYLqnHcmVHsmUH8mXEMmonjuptYtNbxaNsjztsuykZiXK0/0o7akhCy8oFvHTtWfxIiDyylwlkQhp06GWBu9jGWynRWqvpUd4YqF8KeBLrylsNkMHzDLfi0FXcyX40QIV/3esWy6FSFJfA3wMBsaQCMCuNtp/SKYrO4hfYdfrHu9ApDmdLbSL4nv5P9LAzb2/68WVlHdO9NyX+WTPMCgo7ZkKbuAhOtWmXP9ADVYqqZ2xV7yP45UhLAPMKCQzXIc0KwDLCTCZ6VEvlX2VigFsfyb5/gXa4NFZ57M+/J5dpTVoZJucJQ+YIPa2T3etzRIa8WE+WxMSfYAdDVcqIxz8fMi4O5DGMJh8eVZ1odlBXnDtTjBHcYzL5T4Ci7W4JwrTkGmVnvI/vzuhh/yzEGDuU8Zs8PPtjz6MNJr7op8+kvvPkU95uxPoUf/i0vxVe8WdcjTWDroxcs4/F/jVCA+CYW9YqVwRvrPeMrQDk+e/0nnudkarE0RDCHs+AeE2i54ZwbJjwZih9m6mCptB7cCDZEb24lY8ok3mex1Tw5S7icY0V2Vi/PvPkAh2NojcfWv+eVEzTjHuZatEOvn+6O3mMrWKZjtpT6v/IPv/W1TVslqVww19eRts6jP3+9tL6dWifnp9bF5dmnT9a3k7PLsx//wtP0xSDsuBxiz7b+ESxZria5wBewdaJ1UVCxTHNlJy26YQtATsa6bazx63aDxsHg9oJqYRD7MUiWBQPt4ap0wxXTPmiZMPnChkcBjkwyo5g8Z+49YY6zyWQZ2ke96kBaqd3S6VNAIX1XNemPwTPUDK1mWiJeItFl3TBBv2Fd5HKMnWItefYs1gOligf3CdUJdAj0fOhDM6eW98vEW6wzytx7ccRFZKo/zPnjl8vTY56m5pmJIbP7oNJ1RWLIheiwAvCeJy+tjoPl/UMyNWxi3Bmmh1sVCD76kCP4oFTyGIS4fXhumCynzFvlYGBrH1biMCxYKqnTpfGEzR+u0egZGhM8819X6z6tx4JrFj7WvcR57jj+HLSgM8C0coq+YlnmnJ+jdVawdUq6sfh2nbtRKTcYWlnPgxvH4Vt4mT/3ptfrV7tL6HDo/wueYS9HLtaY4cOHnXUNkX2SfL7ORQFkm5t5c0E/jTqibCcoA4PUAI56mfR7xzUcJOuHf46CubSa1N0FBwx+W3dXlFmHROGTNrpEo4FaiWLosCANeEB8wzK/9dkf+2opziD1H4JnTH0uS6vRRus6rlixazVOl32vC9SSgTORCI3QnkjibdQdMRLzK+q9DwKwAhyW6f52ecd6j/v7oxvbIkvoZfC3SI2HSS+OaLlAAbaZzZ6ch7DZxIppGhZZjKKt2FMYoKsKznzdbzU8bVTrKU2YzHUuZ1i7oSk6NpIeqFQElIhM0nAABkKgDoaWnNS8eR3lpHl10eQNc6uXRfhuZPny2OGslwHtFJZ1lrmoRplvT3Dgk6S01zV0gcYhLKOX2ZgdF4mEyLUrxSHDlrDbGzQ+i9CdYHujhatZVRz7MuR/d/SrNHYyUeu/DfqZr3yw1oZHmoR58BJe25HoEiIxBQ8c6bL54e0R8BDbGW+DJ0wTCPumJ6EKR2TIASAFdDEJ/YUmR+KClXV47kF/woKx8i8DDOPNxsWjdAn/ep+wkP3+68Xll8+n5xkEmrd62YSHXrSciXMCCUgQs6q1AWsve1b1sAplN5aEDUiDViKstxZzoFnvg8WqWjo6lBBzKelEUgqkhWv+lLAUmAJqqQIvBtexOJJIARY7GgyE7Sc3jLwP/iQuT7WuNuqKH87tX5dnVOcGnHoUxhmUJGEvOiNY5jjr82Yxy0dtYcnow7in+yKquC71+SEnzAsyDAz6vOAVbGvnJXtbav29oPlXarxl9vXMjp4XFLGzjHbMztNZavWstJYWWl3rTM5Mkm1bDjxbBmOQ/eQckGD5LKlX5V1YxxXZIRvYcOotwpk0/cdW6lTcPW+Us7j9QZ6QGymTwjjwkkdSHhY1EVfVAzwASSEWt8swU/ZbMR87bZRttxXMB5jzP17IT3tbN9Chx0WAtxYgxrjZR6P4dgXrhDl3leNft7Hz9Ht3tnhwf+/MQQx/jtjCSQ+H3v747s+n44p6dPtCRrdUVSEUS7ENZHSIdi1TSjp2fRbssmPEBYJYXAFnolPnOsdrAjv3XUlFQfDdXzeA/1oSf7JYODJ3e/KQ+seSR5fxw7jc5GTxBuvbLWx8pDCdTW7DU5+y44Abvw4Tz5IUFiX2qdxpcW7rNVx50rz9eMZdU0Htphf8mckWz9COnjmuoS6DixiPUxWZ6WLzHIt/zR4c6oqlrfqEmC9w5F3hkF2vFUr622xtXAUJIz/jICygrzOvY8yONpo0DlfHu4K740S9Hgq+1sx84hJZj8agwntQjme1DokyjVqAZnKyX1xkvaeODPPoqH61Zy/x8d1IOY8eMGDoRnXsoXOUeYELKpsEYehN4tlq7adkHjsxzOgcFc5W5pfjHuuCujD5VtJvuwx462a07CbMrBQU+e35AAw01WeCcZi3Lvv0e9l2FpeWl8V13yIvFtUPsL0aNkOZps8g7+vRvdE07sa69SYu92H7kaYufq0VN4hu0Kl8E68PA/ErruBF709+xLdC77zJUsOWvLEe4Z0+zKYV+fjRnXvBMpqtbJ33oGKO9EtVMANsSZVFexgs8eKF00+HG/VHphQTc/VqBOFYM1Sf3e8IrzEjspRq5jW+UUIHxKiIsEQYM+Ves3VNirsb718Jg+c5y/7Gfd9CoOEr7NQynDNftKaalKve+o7RUm7IblGGKoJlOPGwihkMCFMKPr9SRycE/v0D3vKG8rZkoUThcs5iT4I7MIgfg3DF4haCMPJG/EUIMjU13YXBI3TPZ6GbUoR55AlOPg/zD8WuY5esJ/5JY8BpZkwbR6epalf2cyEAjLBF2pfbUofFndcBlefsCXs9VGZaxVRH+HeiTfZf3YgF4A4EL17Qg8ZitSHRyogX90qYSVfHElZPyjqTtBJpq+NkYbiggoA0ksK0qNvFnoxyw6+mvC4j5o7olwbhD6puIy78Xuy9J7dBCBtOcTHcIhzenvIRMvVp1RpnMQijymfSbw8XE9FmNtkXvPkVNw0P27vAhHWcm89QkND9ndrVWJMNNE/hzsD8LIb8vLoSxfjJJiiJ4/K3ZR59nXvs9Ik3lXsRs2oElZ4LW2Hroo3H45zda/sSHg/2SA2Hhyif9Xd0Rf2aUL780t9Rr0uqV1K8rHt9g4s2i5ndxoxuaybXkMFtwNyWMLa1mdoGDK1GqVYzsk2Z2HoM7LAwO1ltprUWw1rBrHbHqm6KUc2xqZsh8GoRd4WEXQlRV0TQZc9ydEDIdUHElRJwDYi3rgi3+mSbKdEmh345n/nfPTZmJTTZCIf/wxd8JlOLgxPnsCNj5jQdI+UyFYlL6wUfN2FnHRgXt2beeJEo82CGjwMMBtJy67EjsS5sRVgdP8zzLA5tYaqSbPaSIJhad9CVW1fmQkGGCXOZ5I/ijFgrkbjKVsPkAZ4IHxMWR/ab304surAWZfheczQpK4JNOMUmfKIxl5jwiEWmQfbIY4qM0lGH3dCGHVCGndCF3VCFrWjCCoowMyM5arCKFtwI+1TIOg1zJ6PrIvcy1F6G2LmEl4F1M6DeDUivC9BbgnPjWyp6vTZgvAqvpuBV13CVVZ5Hqxcw6fJ0/26E6aktroFd04/tUMie2nAK3KPAPQrcqxe4p64fCt+j8D0K36PwPQrfo/A9Ct+j8D0K39vu8D0D242C+CiIj4L4KIiPgvgoiI+C+DoP4lN3YArlo1C+Vwrl07H3XXtIUkR7zlGi3K3Tlc8kf10POU46dJwUzBj5UMiHsg8+FIUgeBlHSsF6Ip8K+VTIp0I+FfKpkE+FfCrkUyGfynb7VOqZceReIfcKuVfIvULuFXKvkHulc/dKwWZMnhbytOyxp6WImdc4XVaXwXt5SVCOqdyC3ApctG25sGzvcRGv2DOn+ElxsFSU3L90CtrJo/QKNdhfSq9Q8iulV6D0Ct2Re5ReoQlpR+kVUpVQeoV6vKTCSZqZCpRugdIt7Ee6Ba3EU/qF0r92k36hAod1j3U1E12FdE9/4WiBEO8OI97MJBLyJeRLyJeQLyFfQr6EfAn56pBvtclACJgQ8D4i4IzkExLedyScmXANIgZr9VMwv4e659CEj148ediNtPq6lucP3B0eOtYMC4FiAsUEigkUEygmUEygmECxAMVmlgJhYcLCe4KFNQJPEHgPIbBmniuRL0+tv1XJ+TfgA97mRDK6+aA0MpRGhlLx18wgo1tIlD+mKVVkQBk1po5aUEglVJI5pdSWWmpGMVU0nfLHUP4Yyh9D+WMofwzljznc/DE1jDjKHkPZY3Zhe6fsMZQ9hrLHdChpJdKWDDllj2mdPUa3FVPuGKNJNJxayh2zbU4TQb/nvCZ/8eJvD8HMQ9HwdiNQMNXkGin5xav2L0QwNSAUG0ixgRQbSLGBFBtIsYEUG0ixgRztVZkIFBRIQYH7ERSYknSKBnyBaMA6VFMXyDY1w3lE+9H1Z99A4ZxKzUJ5YHYDxuYmjqAsQVmCsgRlCcoSlCUoS1BWWJMGZgLBWYKz+wFnc9JOkHb/DrjlJrkY1YrpJ0y7W5hWTBshWkK0hGgJ0RKiJURLiJYQbRrRFhsJhGcJz+4XnhWyTmh2f9GsmFuJZf80mUH7OTDKgNtvwg5ez9FkFtVM1iKqyMHaBii1EALLl8gbPl8Hr0rUsBnEKvtIUJWgajdQdTvR5xvrkz//bi0X3JrWmEXsQAqaOWIMEhjlx0ot0nDA0v5c2A7Wkw9IIBk7KDIY3kARUA8J0FLqgIlfuPd42u0mjUvA5Oe2NBhM9w/MpLF/juysZrTXNil0Pfm8eagtoS++dRbZzhoLO/a9FytSLLau5AEV9dVH7rySduhd1kEInhD8ayH47PAnGr0Uw8tCO43i+SC/IIpnCmpzIL7EbiL0Tuh9P9C7FHKC7R3D9jph1VkU2jV+l/XnndAf3Pm9B6ufdyDaquSqhY9kGt3iSpEtTraa6SSlWaU0q5RmtV6a1cwSogSrTXkyA76sMW/Wgj8r4dHM+bS2vFozfq2i6ZRglRKsUoJVSrBKCVYpwerBJlg1M98otSqlVt2FjZ1Sq1JqVUqt2qGklUhbMuSUWrVtatXMJkxJVY2mz3BSKanqq4c2Zmn2nIfkIgaweQ4mdxj5T95nL4rce283/CTaptdIr1rwfDZScoudKNoekCuFXCnkSqnnStEuJHKokEOFHCrkUCGHCjlUyKFCDhVyqGy3Q6WOEUduFXKrkFuF3CrkViG3CrlVOneraLdicq6Qc2WzzpVmVH/XPhc9K5/zvGBWwy4dLy93n52u5TX8LvrHXzNBxSYTKup6S6kqajDFlKqi5FfKqkhZFbsjAiknQxOCj7IqpiqhrIr1OMyEvzS0FCg5AyVn2I/kDDqBp0QNpX/d8AV4ZdCsa5ise1ceJQO+AvNuOYlP5tPOYxUv1/vyS+Dmyr7UANEGde1QIGNlbyiokYIa9yGoUUECLxPZWLmyKMqRohwpypGiHCnKkaIcKcqRohwpynG7oxybGnQU8UgRjxTxSBGPFPFIEY8U8dh5xGPltkzRjxT9+ErRj8a+gq5dPNW0PkxTr/em5D/rXAJTZnVZLnoM0O1f9lDvjfU1grbcruQNNNY3z/2+rspHePfozWGewBBlRp87AYtRKnUAgFNGiUNNiI/fPsErXRsaAypZhD5MZj5UENm9HrsnTKqI1IsUH8cguXdBLXCl/avtXMAOM13OvGuY8oxHjKHnPPCHnSkM/al3XeAP+53iGoMK3NtZjkp6L/5+dVWgYh75rNli9q5HmQpO0MzFGq7XL3O53nN4Y/HnVWoN2rAGbVHIFkryOudV0zxe2bikDqYZE/ccSKTiYIPfjrMvA/NLfa1qPOdIM2NlrDZiJOvP5tEXmkAifTlRg1zxNJivfDtbpiBD1gJ/c7w81pfTxL5S7M/8HF2soth7zF3rnhkQ1XC1WaV8h/g6/z4HxKfbIsQEoo5VmvnbH6yjov3i6FJEQC2jJQzViqM4tu5dWCveAv40h3GDP8mxkW8ZWc8P/uRBovtouViwDuGzSXKef84LX20dXXgeQ6wz/9GPIwtDmI6thzheRMfv3iVVTL0n/OUe7HU0Id/eL2GNRvz7t/zRd0eVMT5cwYuhxdm1p8vHhcZO+FUfYsS36P6xicCI9XMZfPAnJQ6mlMCgV0KYLqaRDL8VBDMKyf6zC1KbMAUguQltcJyNV/EjH7YZxLmDpNAopXd0QSvGQ1o8rJsa2vUwQE8qh7bYbvqtV16uKlCptdgl1liXoyMrbShn2d00Ejttqx2Ve1uVvcU0AiWVLcv6v3rBKuXlMxeMagvD35lz0z4VH/JhMGJ4sr1A48/5AGbuJXzAy1Px3/8N5gqahaF7XAQxGDSrKqeV0iTlKfts/Xl7TYK2FkBPr8qkt8VYejJKLnaj74khce/F6BjKLyphfl4IN9AlPFSgAmVgQqkXiAOf0LtLXOhO8qeRyakLvpAyoRuDZNCkNI7lh643Shy1s2mn+gqrtPEHiHYbncXZqJPpVI4CMlL+nDcGN8k4YPYIDCGApNi1Ve3E/qLTRCjNkf0XwPGfRSkQmnRnBvmnHnjstX15cvF35+L9X08/fP10up4e248C3q7BUD1SotjRfDxyAgp2mBcOhrYTM0kUUjQcCcEYDnQHWtLioiiQsfI5XUgOyVh+0LbSTJzyotRCjMTApGXit17x/sXD4OvvXo23rNQ5w4otaNt3tw1uJclXAdvrotJdRpRZ4y4ma7PAnUYDtRJ1t+h0d81FiMBm1FcK90HTyFYeFy23LGxMz5gYfFt5IK803ZnvRmPxoqtUC67ZBb19VqKv2Tq+e6vSB+F73WMPwXNBDFT56J18+nbyjwvtgzB25T14dldRf2R9dGeRNyw+I1jegJ9Oz52zy9Pzk8uzLz82aQdo2jNYF2zz6Jc0QxudkD2W2MsoFufBnU9n3lok7pbzSRwEs8gGcB/7biaIMrcBCL2W2wHS703FCYrOst4d8W8u8YujYc0dYpjdAVQX/yQXACppmnGq6yMtv4I6ZlxljsnOWv9m9QXR0je4sJxXrv6SLqZqqnHKGi3ZX3gAxgvuL7QT0E5AO8E+7AQoORISFIvN84M3X8tLdrUhzwAQ8nHBwx7kbxkuDOtgzqu/wRIRDqykw0kTrq/6WLB/rb2+VtXxCSlUlNZBh1ONUHKCYA3pFO4Wzg/HAHuiEWIj9FNjzzDcNzZjA4i9p8IGMOpybUOBbIC1DaAEXpIhQIYAGQJkCJAhQIbACxoCQrWTKfDqdICciZezA4hFJpOBTIYDMxlEgK/WbFiXamsy1DYXerVthRI7odRG2KR9YLRNdrqL9N5YK3dxd2x5c9wae/8PjAwM6uLxGAA="); + reboot_native.importPy("tests.reboot.greeter_rbt", "H4sIAAAAAAAC/+y9bXfbOJYu+l2/gu18sJRxVF1n5sw9170096STVN+sqbflpDrrnkyWTEuUzYosakjKLnd1/fe7N15IEARIkKJkUdpZ3WVbIkBgY79j48EL78lfLy69eZj4N8tg8MILkyhOL73ka7ieLkL2UbxZwCOr6L99+OP+af2UPf8qiOMofjWL5sHkbLFZzV7FQbqJV8mrB3+5Cc4G8O+F9zGCxql3G6yC2E8DDx/3Hu+COPDC+zW8Lph7K/8+SLz78PYOH0y95M6fR4/wBTy38nxvkwQxdJWsg1m4COHRJLoPWCsvXHnpXRDG3jqO0sjDQXvw8ybAj70EH/ETL1oFXrTwok2cvRT6Y6+98IaLKPaC3/z79TK4hLfFwX9vgiSFvoIlH9vcu95swvn1yHsMvJtwNff85VL0lMDrZF/wTj/1fJgadHkTzucwehjgORvbuedDwxRnDt8CIfyVtwoeghhIslyG82CM5PqQwlN+PJe9jweLOLr3ptPFBmgbTKfiC+gMyOqnYbRKcIbvf/j5p6uP8inlS7YGdzii5TJ6DFe33g+/fPjo+et14MdAJzYWpFWMcwYi4e/i5RdeEq5m+HWUZB8iG/hPSOFwBQsdzr3hTRx9DVYjL+St5VrP+WKHuLTJvZ/O7nBJw/SOv2OVpEBGthLL8Cb2Y1jZ8UBMLw5uoigdA3kSmAUOO58k/26afzewfTGGV86+TrMBTXFA8J/7NRAHWHh49ufxv/37+M9nIyTT648f3/348f1PPyK/e+nTGlaU8RfMgDFWchdtgCVuFNaV0wEO3Kz+ewP0ALbBKSn/GKMOg/Ht2Ltmqwld44zEVF+vnq5HY1gk4J1H9oKZDxzvzZZ+chckxb7Y+1AeXs2DRbiCEdwHsDxzwXt3/oPC+fjisfdLEhT7WGyWy6dX2WAF74oBClLyIY7Z2NhSBf48Wxw/eVrNwkhZEvGJfOBmEy7TsMCZ8iP5yCxapcFv6YMfq08pn8oH537qIymSQH1Q+VQ+eBtFt8tgzITtZrMYz4NkFofrFKQ7b8cfmsqHpvlDtm5+TaLVFKTkHkXb2o/ylK0jIHLi3wYVnYgnsg7i9Ux9Gv5Uv5qC/KSbZMyJr8pH9h3/iqsQpYnkPOUTY2vWWDyLE1Sewj/lV5HaPMrWI439WXDjz74q32afyYdQryrf45/yq3U4+7pUycU/KGqIklqQXy+j2zH8X/ke/sL/gwC8YMJ96YW3K9B+n3mLL9m4uXQqg2YfaJrJD6MxTiRaLMqqCb6cii9lMzSQaRQti9pafMZXyL+ZZdr9JkFSpVy4VUG7mU2LX/K2IA9BGt5LzZT/XRAZ9lH2i7kl/j4Plqlvapp9aW/7DzS2lqb4neDGonCoHQDz3a+n65v/USEphecqe3yM0dTFSU2H6mPG/sbB/Tp9Yr2Int/hBxVdZg2m7EkD/+AqGk0b8o/4sjAYVAiiGyGixlkpIpxNJ/3HMpr50mtBN2vKPtCWSzw2LXxvGPoMPSDjuPEbS4MgnhakXWvFvjY15UYhsbQU3xoa3oHRCmJLO/GloRn4YvBZGqxmT+amygOm5jCeeOUvE/A+wBELltN7fwVqPbZ0Jh+fao9Xdn0P3uUyeERfs6bX/MnKDlM/+QpD8MFjqutRedShS4gW1sz3i936zZ83dL5egv24D1apua/sa0NT8JkewpmVHbKvTU1BlgK5LLb2hWeMnWxurG3hK5N+QIJYtAN+ZWrCvFZzE/zK0ASEhy2AuZX81tDwMYq/LiCosLwv+9rQ1N+AG2tshd9YGrD/RHH4D+si4ANT5SlbRynGKxgnoANc2Zn2pKnDGx4JmPvgX2rNkiAFV/jW8F75jdZgBWHLr8l4/QQzW5Vb8a+n/Guu7kVD1Ti/BQb9CH9/ghACf/6fouYXfTFbbXo0G9INhGXf+sv1nf+t2vwGAi/xsenRsRxkwWCprab5EzYX2l891dhx8YTsIHlSiQx/yS/uZ2umEYJ4vPCTFP5UnoO/pvzLqfhSWw9sLexOmYLYWnxpaLYJzS02IQtB5/MQw3awhk/Q6lXwG3cHwdiK4CBhaYRgtbmHoJQZdlDYSJP7aL4BWglrD95RMhavvY2DAIRY9V2GAwwE30TLKL4Qv0KMF29m6evV/ANEQ8FVMNtAGP0Q/MDfe8WzIs5PJ2t4JhCPxwEwVLEH8ZH62Ft/Bboz2iTfYeYlKTz/DnNNyI5/x9wS/+xvQfrpLloGH1K997/hjE2fqK/7Aa0MI0HhSfVj9fEr8BcqiWJ+oNhF8Vv+6YcgfT3/NZil8EWhw+IXakdA8/UjjrP4fP6p9nDNcjos4Ud4+PtodXu1WWFi5btAfzmqCf7bJ6H38w5YduUXkChPJi0gJo+DRRCDCxUoua6i2PvrcKxksgyKAZ+4S9O1g86ozxJUPZX58rYHigGJSf9F69IsCt9z76f220IawCbmdd/zTgYQDaNbOtEi5DF3/vG74XSK2aHplC3hp8B7jFbnqcfyfpjN/flp7q/ScMbCkQB1UAAR7uMdS8PeBU8sGbpZzVmWU+gMoMJ4wJ5PpjcBMNM0+yqYX3pgAj/DX19gWPDrEF7M8jzeL8BK6SXjsDX8PRj88uOHdx/hKfYFPjcYAHtxSQ/ij9HPuDZD9qJL+emY6YoLL7MX4msbocai3Uh9sfKW70DZ8vew7x1743ISJuD7graHaEu0g+eXMKHvwBlGqfFe/Udx3HwQPMvO36WOpahS5fxFE/5hTofiw/xd1mEXHy6MQvY8sI9Eo1E+Fsf3FQnRdixMVWlEYZ+VacI+diQJ76I4Ct7eOogSPcQw3N5lpkbLYbxfrTcpt7Z8MGmY4iZIMQn805q7JFws/8kFjvMwKocGj/vSnDm2EWKHaV7QZsZJ4/YCbjD9iC4ql6sF+yBM2A4DGJghm9UF73TEt2HwE7Up+1RrNpAJc94++xPGyP8Qw2NU98Mk8D5CiMU8lbwtS7ifvcHNnijNlWCmgaRfx7ZeoLl3pjU9d+OL80uxYXXORnsuJ6d3B69B1ghjsLvsfecwoPP8qZGFhrjSBRLy7TdHCrLWfSEgDrZz+mWsXyBi9qkzJfN++kLObMRb0JRL9nTqx7fJdIpb0DPmJVx4pf0qdBx+/8NJFeTkkj1/FtKDnbDfHKTB1AtjIewEf3HlCFNHOfGwt+yvgarqjXoxX/GXL2V3gksKNqEQGNU4DYVnawxk4dl6M114vLnHYBiZcdDOA3FwF9Qn3YjhZqXVhxv7CuVBmYbbeAwlR6Gh4b8PUh+3bK1NcoHGxrUugDo+Fw/gGK2XSoMdGy+5fAUSyg+dyZj1kn2Cq37AtJQDbkDPIh/vxoZ1YXz0FTX1k00f+5J/GC2PSj5Xw2PKbtXYH1OTGs1ralJvBEytmhsl+3CrJtR0dA6WytCgEdncbIahTWPzZR1pxVTaDqxk05pGp6xNfBOmsR8/yeIda9smcx7/CP8J5iITq70yxqLBdOovsINvp0kAmnBufS3mlGrNqWEILlb1BGIaA2W6jWwslNXZqkhh/Vt3Spf6zZMcrfmzBwulT7vBgrWni8M6G2W5sNbGJ5zX29x/9jUqh8NfPeMkGqwgznI3nthWIXxDyTd2XeJr9gr90zbMZ3qdeSHwlcZvjK6iYaVdPcaPsb9KfLaB1MJ5rGm9Ez+y5p27cClrXrnFmB0czeq2O/A5q1/YvftZ/b4OhktOqSOtyT8l/5T8U/JPyT8l/7RL/7Ta6ri7qk8fo6xK8g2vBnV2VCvacnfEWp42ZkdNXJy8indY3dKa1+quUsUrWo/QyQm1t2xDPpMbZ3+DzefsgnauTmb16Aoups31sndRdLxaqCqz1Nlf2E7m3olzC9vInqWPncig5V27kEXLq7YecWPZNPewCxk1v2kHsmp+UWejbSy75q72IMPmFzvLsrHc3E2EK5p2JbkVr+hIYCve0HZ8LuJpb1iTvKloWc/89raNMzi1M3CY6rYDLuVwkmUQrPnJKu55JtbMSLhK6xMj9te7ZEXKoykEdOWvnaM5Q8/ZdzCxg4jkKoiXR3TliTQI52Cmu4nm7AtnCoYMc2BnKkofm5W5nUwtdfinOIQP2ynxYtvdaPHiO3aixouvaD3C5oq80LIj/6riDd34VRUv2Hp0Ln5URRe78Z8qXuhczVs8EulW1Wtq41LQGsQO5bSmzlvW9waxVtFq6rvxkFwqfQ0t6ghkaFJfdWto1LwC2DrYqum0HpuDJJma7kSCTC9ylZzv/HCJ54vf/TYLmDPmKD3Wdh1ZKWv/3Vgoa/etRuYgS7ZW3VglW++dWCRb51uNykF+bM13IkO2lzWVo9cc+aKhFGmtOpYhrfduJUjrvMWoGkhPsU23slPsu1PJKXa9xYgaSE2x8U5lpvgqV4nR8RJqREV/vMYR0R+v50u9RXNvzTxE2wSajMhBRLSHu5ENrdNOhELrs80YHMRAa7UT/tfe4cr4JbwXJ/63tOrIVFh678ZUWDpvMSoHOTC3qdEW5ka1rGlu1jh0qRpy9bS2GGEpW1t7VrGYoy1MrUELyUHOTZJguWjwuICgatDiJvBjWAkGedZoKriQDRogyGuTeaebmwaPK+CMDUomOXxfxbhcgCnMTOaSk9/VActDybqbKbPVSUs9zW6rIeIYVcWStdK61BSpKThXfaKqGPgOiCqQvYpU5R82IKsKMNYvuvKRd05YVPHFzTj4wH37DVv3jpg46s4JKYxfgZYSsNGVnLKP3lFUDLxzoqr+QYGy6hfO5C301jsaq6PfgX7F8WjalaHdu+tW1kMPNSs26Zyg6HEWyMmuHXAlJmvdO1LiqLs3UOCLFw0UfOBuoLB1/wwUjLpzQipRSoGeKva8K1nVvg7ugFIddZXBd35KSQZ1GsfyDxtwreild7SVIz8E4LX2gDNOgZ35OAinBz8AwnNCbgGNuTfh9PPuRJau3o831mahz8sRbpcLNxfW1I109LAniTju7rqZeiy4Ndit+oGTt2ImHbPqnHDskp56M23qh5k07IVdE1RvoYykR23OSA+/uCtnU1eq6sIe1WtB3BSSeYBCaPkg+R/GtLtZ/J3xl6pAv+uAmKra1h3zrmrrAH5U1bzFgfr6mThNuvXAXeCbKlq2I7YjblJF4+ZH62sn4TLdrcdsyPa3PCGvv6QeZKliaG454vJJ66bnq91PVZvvKnjuY7gVJFSTyZ2dodbftSvfqPYkrXp+Vp6aNcKrVFDI1TJUXWRRYxiqmtaoqqqm9dq1qnVzq1A/DZcJtx21g0moaNiKzG7KtaJtY3tQOwOHqW47YIfyiYoedlJKUfE+V/F1vpyn7ooI137qrkpw7cfhMgfXrlrcOdFsto2J1MnkXC6xcOxl+0VzvHPCsaPmt2I0mmhT8nQ6r5LTOQ/W6d1WZwBdX+/iWLLRFNxK9omzU8nbH1xe15VEuePIJnIIJ/0KK2JyB/lIsRP2m/k6AMf5V9qVwYuKf973wa0/e/Jur35+433I7tesasJuowcCJwGDWEFax8EyePBXqTeMVsunkbeIYi+/rJNdax7er5fi2k9vmb8TOhMP4j3tvnfFN8lEKmzsvWfsH8bZG9LImy1D6CcZc2H+wf8a8En8LV7PxBR8vBmeEeCF91p9XzYsvv4zH+/CusFrr+LAS9bBLFyEMxzxyrvGJ64vRC83Ab/S3dRX4g39xMuuqPduntiVfuyZayYGs2vRzXq5uQ1XI28eMYZJ7tj1r6snmPH9PRDzxhfXxidelOKFq3wo0Q2C2FyPRRUZf+2UX4GN/+UasuJK1LFCmEvJsmGSbG7Yy4aFPi+qbx0bv1lGs6+SWVQVwblX/ZotRKHz0dZvx4v9fuD3y1YMovyUbSxcs7FbCblqW5z9svq6ih5XFZxz/nuhpz/Oz1DU+MqVCOC4MGIWZ2dnwLT8c/yYC9A98DlIAujVKElC9nHk3UWJLlDYw3Vhha49YCwuWGPoeyDs1wKUEd5eNp2KZDfvZcpvmS/z2OcGTPFFWRDsfDy1dg4K0PpdPlTxMbvKLmHjZRy/DJP0s+WeXEnZH6HJlxJ/uLQaFi0Tm+H56IsyKpbbxXZsYPm40ODmryxq2VxrzNlNfHf+A6oAdA+iWcgUCL+KD/sd6+POvQAcwCJcBtP8/sN8AJa7VfNHx99B07fZnyX62Hes3n14c/X+548/XeXD4FYvxcHnQ0g3oPE/16apDNyTOyIW96r48Rt/uUQ5+Vyw9p+5zswMN3sN3vb7gV0L++Wi8DQjq/zjyxf26xeVh4XsT+rYeThSMCfn0zSS19DeB+ldNMdLiSoJgY0KxMi70JdIvvfC+KZMGVkU4f51kkFv70k1Gd58nBpKmSgpqt0oKgMvnby+MtCkvdqqjldEgCBf4/0QzufL4BHc6I6jlixggSXLAxP5PUYm0KUtNrnwAnb4lvWJscDCh4iY6cwkug/kY+xu3am/TKKpl2xmd3k0FGN488L7DppDiMpguCBYWS6h50cWtngYjPiggW8xXmFlm/D6mye831b8za+8n7GrlzH6h/78DdA4Dv/BP4P1mn1NxkCYQDQB+XsIQfYgOGHPwsthBvf88WEwvh1fQC/XMjzjjySMG69H4wFqbT7YKRsYLzrAOBrCWGClxbn8/tXvgs2xDmCM//m34eiPc2m0smtfODHyRTaYLdllMr3PHhvnLUDPl62KpeD65UVJgrK03F8hMisLvL9eLwWJ1aMnJZ39On/u/bz4FmD9qpZc/AuNmDK/91f+LY7PYMjVBxJ+8fAP/K+8l/XSnzH+nnJmNHWUPTP+Wf72hj2cdzOD+HQVLKuGky+Q9vB4+oZ/UBocvyt75gOHVveoPDj+iL+/wV+VjhgDcklQRmdR0MorkLWnxdbJ+CP+/Xfxp6KRg8UC1MpU3KkNXZoGLYQmGb9jT/89e/hC0ZD+PD/y5CdPqxkYgHcPgSEfl2zWQTwcjcs8XebLSfHPoinJeHCS/aY9UHQe8svGy7yKT2KK0OCbCDE6H5XfnrlN0HXRKCo2tdINOjO96gdmUJIz7Y2aJdXlYKJ/UHxcY+GJ9nfx4RJfTEqfXKjJx6JDWjDi/Ff9CVXQs1oj8XfxWS4o8zBZczttXEVdrvLHuXC9zf5uzW2yywkblfyr+Iwi1BPl9+JDTFYm7L/aCkVouZFjoenEQKhx4QnjArzwWLqVmW4WAkQLL4AxeNxJOU+yE2hJJMw6Pp8dTEuYib4JlA7BqoLigHX/BzwGhI5Y57MI3Ad0DQouNBu06IoL3s2T8I+m/NrOPC/N4h+LFy0y72NZ38JS1gVinfMbZ89d7y4vkvqcSdq542WmWlsVm/u82Y0eWk8WwO9tOzXgGZ/XHhev7EVDVG3emwHS77wdlGZ1zxy5rPH4CuhN5w0RrrS+Sig2jUejYXo0bi8xERo31Ko6zxufl9clxbT1c96yUk/r21StcN6u6EPruXbz6ryDreH8nX+o2hv4K4t1MNIMF7jJcoH7SniLAIZ0izi6hwAq3iwDtn0XzLDj+GmsbIouZINp3tkUW0zDxTRrodnC/MmIP2z1Oh1cHeaG5l1CIJH97v2z2fNXm2VQdIRyy2feParo7HJQ6OqF934hY0YxOohcOW0TGVXOL7KcDVg+oK6/WaZaN0oHj3chGFyIeaPHhC3gep3HwtB7/k240nqZBw/efTQPvCFuei+j24SH3RAPonZLWJoyWK7ZQCCQjrX2YPHQTsMQAu4EPLFI/T5MEpYNUKPo0bjQGAda4gAZI1+WVlwQxIH2bzm98iUYljrLTTLo7gvj12Eyxfkyp2LyHXh6Qfm50UCfkXp5SGlyF83ZcGQlhBh93Syngnsm5eHUTUe8yNBQc64VVpy00ANZpihvouTaCr4m97AKWRtopw1nl5EsTNAy+GK74QgFr/iZxX0OgVmYaCViW/3JC3BzNfH8hJeGyLqQhAsY34rne7Hwwb3qOofoIy+fvFcouPOIO93QhmWk4aMNb+NdC1N/7T3GoC5Q83Mt8hgul0qH4HrMWQNYl9sQ9UlhRGPvp5Uc7WNwvlyCdcCKkYhnzFAt4N680iEm7+Q7E969X+yTZQJ9WWIAvbH+L3AqPKGn9OY/RCGGEmn8hOqGhUA8ypCRC0wovSt3p/NM9vWUzwbDCBlwW8IJtl+BSCmGWKEiyB6Xna2yebMX+rB9d2zOduHPKwP2ymEoPtsu3i9LPtE1KGSvxT4V/+PSksM37LjUJ9eLEyyn13XBzYehSybLJ8ltEOCMVA6aBcdxsLisTuxcBYUtG1kJhb2+T7H0JYpdA9GcAGdnZ+9lpp2nmSHUvs7Tt2M51tE12yHUoB3kBseMqVCZYyvS5A5cVhDLSXly4pvx/8t/lm2Nlthgr6rKbuTpMiDnJPut+NBoj+k1LuWTM0HFMz1VwsjFvYGKjOUVo88bHU1D0fict5hWMmVcQKEE/j2wyzRmXU2Vg3bTr4FmOkuwHeWc2Hg6Veg2vTC74BMUNmW8aHtYs6TogPDRo4bm5/suPG18I6xOM7XEf0+s9JB9qwsazHaWTiFSUb2D4p6D4EGT7GnseTEoruplfopZqbcFHY+j9NgPkTWuk1q+A1rtUdSJ9EVhq5tt6/zyy/u3X74Uhf2KuV/M5ud4QyDyuLmFxu5cZNi8W4j1sCJQvWCOq14lj8aCOOxKhhgZYhInwzlbVJa54+uTAUKs58zlYkaV6Q5wTMAMLhbg8a/SbGhj1anBfTIcJ3iEQ7ay4zVwRnIXbWD9+e74kiUkvWCVbFiRKfaf8n3HgkpmW4eCT1HvPQRiqxA+TmN/sQhnY0W4WOEwkwA9Oz0WSXtoPYUR6YW5koWqlJZ8xqCuRt5kokgeE9ycIj/+9PHdpYebp95mBQ6wx4VbsCff3Uw26zXzCAra+4X3o/CoQErCFfPegA82a49FXAnzHsVGJ+t/LtKsEXyRE2bpw0LX4vA5MjAo3sKuun8L9vgWyxx0bQXSZeZ13L/Io+pw4clN9EmeaNXj5tWDD+wMLMdmHgpHT3jOnLWwUpSxF2O+OeMSPeKVLvLNJuUUS+/iaHN7B8oU4uC8NvUK+VZrjF4lzBw3pLm7rL/3JgBRzPvge9taJ8i+bFtDThrWbo6bHGC4Co9COI4mQcTiZZt79rcoZRvuuGXOVGeWbOde7wqUceFN3Ic9K/W0OONek3f+O3/yD1YZLlur+/1ZyXW5l7P/Whk+fBt5T9FGSL13E0ePCZaG+jdetAZiMW8feHeJ8gByk6BnY+gGa+JR5hX5vMAYi0cLuT5SvsfEB0jOLYtB/p9in6NiqMuCqYJdYd6oz3308YenJA3uhcc+tGajbtLpw7f+cn3nfzsWcQT6zO85GTmJh6OyIyQEbGKM4KvXpmpW3NwyFSICb646WT03xmco/PnW7LIohmLHYmByOFycSdWhvNPt8l5cOsWtE7Mpf7+lY2dImzDRM8wi9mdI72Ttr4YWOiAJJouz32XViEadP4bn2lchMMPozEBWeAnv7YxNfDgSdhjM5/LJ1ILb7BVzPT1gexDWe7aBmXg/PwEJQdhQYaKiw0X4wIrMxqVu1uxZGU7PJh/jjSFvtgxgGBM7jT7Cz+B7fGj85pcPH3/64d2VRvJL20LySpuJ5z/6oXAEwLd+ugl4GuaJ53fMuTKdWzXmqcuXKa5lRTFYYaNvOLL1MP7Zj/nRvg9pjNq/4K0Z3lwTV+Srb567MZJoEVGUIwt1DbJPzYPIBZYzb2UCwybRzrpHHepEZR/7o2IRJrFpH8cStVbGU85xVVIIrOxzsbtiY5hesJoPSx3bewPLAQ9e8nrAeRTws2LgYeI5HPBHwXlHf3wWrVn6bbaJ0QQvny4rekyCwLtL03Vy+c03t8CtmxusMviGr/GrefDwDbqp4KJ9g8deguSb//Hv//rvY2uH/9uxzI3zX7xZTRebFdsAn6aPmN1LI1ljEkx5zUlip24erkJHPOE0lBUqELKL9pfsLveqol3No7bTS83DKxpNvLqyWa1Ul+xP/WOVfK/+KxNlUv6oupsKvszCYannleWoaAb+TSEO8v6Ug1tVLwH3pBTorAo5G1X2VByABq5l+hcsHQfHMjjVA2ulNmbLwFc3ZHQ/sVhIQkEbBW0UtD1b0GYt8CK5JLkkuXxGuTTWSB5JcsU8uxNMthgJQcmXrZIvZuZqloypqUqlNEz7NIyr7FNahtIy+0nLmJXws6RpzEOhtI2atrHYTErj7DeNU3P+5ig9VX2WJ++xagQhz7VDz1VnNvJgD9KDrdcJ5MmSJ/scnqyunA/Ao9WHRJ6t3bMt2VbycPfs4RrPhB+LY2ua3Cn6swY6kBu7nRtrYq2OiuEqcBfIpd3CpXXTBuTJkie7J0/WpJafx4E1jYT81oLfarSh5K4+q7sqgYaokIcKeaiQ5/lORRWBu47ldFRhVqd4SkolAMWL252WKjBTV6emDDh4FCG2jxDrJJ5CQwoN93SKqqB6n+c0VWEIFAwWTlUVLSNFgfuNAg3grkfic5ZndoJ+Z4kI5Htu5XuWmYrKbA7E43SRd/I6yevcj9dZVrzP4nmWh0Hep+p9GuwjeaDP44FmeLVH5n/KeZ2w9ylT+OR7duF7SoYiz/PAPE+7pJPfSX7nfv1OqXKf1eu0bt2Sz6laRfI49+tx5lcTULELFbtQscuzFbuUrmcjeSR5JHl8Nnm0XA5IUklSSVL5bFJpvhj0SLKkxsmdYKrURAfKl26VLzWyVkflohWX71ImtX0m1VEbUDqV0qn7Saca1fKz5FSNI6HEqppYNdtQyq7uN7vqcNs8BZQUUFJAuceAUlcZxH/Ef+a1QX23iDYrN/b7ZYUxyJ1/swx4oFlgx/un9dPYfBHv/aZ4FOZZb+J19tX2f2tu4a5Wh2tMHUJX3s4crLYJVF+I22cfAwyzonsQECQGaowUGIGtNIiMMOpgZwNpl7VuuLZ5vAOyPaK5Rg10rd7fjqmmTfIGjPn4lx9f//31++9f//X7d9cgiFpPLAcilgjHAOounGGnENdAiIVf8JcVHQOtlzQC1bKC6AKctNnXb5ZRkrCVjlYrdutJmD4VrfoLrYOPP739aXgTrO5GlzCQhzAJxRXE82AWMm0EKwqjCkA5saAJViaJVuVhID2964LkjK4582CYxm4i9iLURUjkFdIwDrRuHgNgLXBbwBlDF1wQYBiMb8cXUndegABDgPxr6ZJkzUe68IJ0NipOHsc4vQFCRYuFMV0ovhv/lf/UOA+cLiA0Jp4uDVmuT5jX+opafrFZLl8twAO8BWG5vfr5DXvxhZeIa4nDReHqZkNfjxCn34cJcCD6ccNwHIzVi6HROqESLFwJbeiGXxId8MBpBME8mkZYplX06N1GuGqM/8Lbu5Qv0BhzdYaOwGkNgJlgSfJYlncluA8Gt7pNvGUIBOCBk6EXGVyhbVrNkRwwwPRubMgpsSuszddRy8lj7IC9/m3jx+CX4w3RN0/etVC612NDYnRzU6F0uPwWkz0foMnQnpoCqwJytsySXaAPpvKzNLJHvua7uf35HLR1Yruc25JYqrys29bGcHl3ObJ1+7T8CdMEE0ZuocjNE3HKYoEx8YFnfJk/G6cRW6ip/MLkUZTHBBr2clCbxsCRl57iUZSnKnl0jl6H0dV69g79HMyqMYfH/AoQd/btGDX5kF2SXm8x7OFzPlSprob28B1zK+FqE5jDaLRnMwyFw3TD7rcP+EjlTfSB1DdgDIPHC5wJqhCYro/2YenjrfV8bgNbwmaTZAkQqW9h9fg3U+ZyjdFITHFCQ/zPyEZE0Zsi/nYicZdWOuucC4UDy1/HO6uWMP6MXUK4dStcA99oDIyP0ezgT0ZG+3jY14PacdTeZE1ZDaeokvkx3C8M2oaVWeTCab+bkJLFjoW4EqQZ9G8n0eWxRJbtkhgNLvl0CGnU1hTYUGBDgQ0FNhTY9DawUdU5hTcU3jxneKPy4vMGOdaR7DPUcbv/mVw2ctnIZSOXjVy2U3HZLHaBvDfy3p7Te7Ow5fM6ci6D2q9PZ7phm9LZz5HONq8Fpbd7nt6uu/yYRO25RU1fExK5vouc+TZGkrRnkDTTUpCAHZeAGe+Pags/R7k/yv1R7o9yf5T760Puz2QIKPNHmb9nzfyZmPKZ8361Q9pr0ap20SAFRs9QvFpYA4qIeh4Rme5SIrHav1iV14FE60hEK78kggTr+QRLrgKJVc/FyoKEfaigBfmks4GjfDAJu8nk+iGUyBVD5JZV9DhypUc1ILFDWaPWAVU2UnaTspuU3aTsZm+zm5pGp7wm5TWfM6+psePzZjSrBrPPXKYLzKDLmRRTN+TCkQtHLhy5cOTC9daFM+p1cuTIkXvWg8UmpnzmE8a1Q9qnU2e59oTy/vvP+xuXgpL/PU/+NwVqd8GWreuSoimKpiiaomiKoqneRlO1Op4iK4qsnhWRto5BnxmsttHwdhtxtbwXpIvgYm8Xg1BEsZf7QbRrPuZhskZ32HbFR+onX033e+Dnyfgj/Pcd8znyFi/zXzFCz65043evgdr5zgd+Vh+aLqNoPcUqe7YoptflF8mxF0/lsIHZflp9D83fy9ZvYEnZPScTb7j072/mvpf1zB3Y/E3TBHqYb5YwNpS4UfnKEbchIBmuxCUjP8XcdBRuI3krn+UXkrAO0Afk/nbgbVZLWGDvvEAwJmcJxDlKAJPijZ8YpPAMxswHpvx1A1IdrJJNHCS5jcB3eCD+GxZsBb+F6Hpl/eBlgvJZeIu8iI87lnmJ1subp5eePt2/SGc26w2ZLUyRcbgHAqSKSs3YFSnqHSnyZhQ0vfjwWLl+sqju4OEiK5kuWFWiKfmcd876FZqP0XMWxZhCYlcwjQcWv2NYeykLX2uQVM44esj7SxJwFbsMwVMWGhYjNdYctNEqePSSGai2PDR5DFiN3CbRQzOWOUOORsIIR/9aXBh4zdz5a3FP3zVyxv1mmYZrvOgHfHNkOa07FuEySkBwO4Slg76feFydsuQcBhhZJ4yN2A3CI2Y5ICzS+rsLUxYx+uweId1VkYM/T/itV8JPQP8GOBJ970Ex/FCvdbTdnCAmb1IU2Q3DsvLwje1mxZflj2wXRpaVFtMTl00vgkViTh/FwFrcBYvtzd+UlOik9Im5Ycv7H9ktqhjwL4PU4vBZnXsuaNrFkEPhO8hls4d/GqUmTldnZlFXdrvs1CUQq7y7Q/4TIy/c14TJsZ/xDP1QsCgwP971Bgo7Hbpd+XThqcprNKqe4E0AYhvz+5cn08xYAevdhjP+se264EzL5jdIGm6PVr4dv89/r7/aFLjJTyaLczSS3u+i480mnI9/+eX92yHLGU7YVJl4wOfsJz4x+uO85urRirUb1cVqgnuHTKrUS0KZSh9V8C43EloD4/PFMJWpB3Aa34BiDtDAvrPHp1y5goeM6pLnKX2ujfN4byb7wfSLz412PK66R5VppZJlDrlPM836G1rWo+463ATMBjpetUzBLj2tfaq1u13VmcUFz9ZkOKof2AgTHiIiHdXdjlspHCZW5HQcuVw9zB/d4hJaFtc4sK5hDQT10RKIjy6rQ152aTmwx+L8d9lHfpkfT1hMp7OlnyTTKfx2H6FrPp3+MXZ6/L/B00UPCRqcN5eoPMuCgoW3XoeLECbH9wMq+mMj8hbhMqgUPIUAeHk4dw7kW6aCD2+e5EXvU8UXxtzmsPI6duFIX3ifvziLqLh2WBBWYednZVjOjoOBNRlY5RbyxDD7UvqBZtUg76OvvbbSrlmKqd8Je7VrOjh3RgxxNbraLPsIfsCiqIezdiPn2+7z12HHjJ2Muy/Kaz/Crz/Cc2aWOx9ZM8LAihMZ011UObfsbZNtfHfpDE/sHvELD/yvtY+XY3MKeMIL5xsg7BMmjiL+snRyvVml4RL309C6Jt4QTy1dawMcM20yZem28CGASFW2Glm6xUgvQB9N7NuxVvgWFhSyWBEvbM1fb+knXD1EnOPGlkR6NqRCKDIxhCcXbj2wxavZYJFqDDW0wn5TV7YdVW2Ny7ukepQ2YCOmrMF+sgaM2JQ0oKTBcyUNLAxoyBkIvbBFykDtYa8ZA4qvKb6m+Jri61OIr7nDeSrhtcV8UXT9/NG1YEQKrim43lVwXbgurk8xdvGmOgq19xFqV98hRRE3Rdz7ibjr7zLTAm/DtZbt4m9DR7RxTxv3lFigxAIlFiixUJNYKDjbp5JfqDbWlGZ4/jRDkS0p20DZhl1lG2z31FPigRIPVYkH53usKQdBOYj95CAaXa2upSMsbSkzQZkJykxQZoIyE5SZ2HNmwuaYn0qSwtmaU77i+fMVVmal1AWlLnaXunj6GGUgMWINDjFxUXulN6UqdpuqMPAJJSooUfF8iQonhjSmKQwtXZIUNSqIDi5QFE9RPEXxFMV3HsWbfNTTieGdDB1F8IcQwRsZleJ3it/3E7+/+417kRTHUxzvEsdr/ELxPMXzhxHP1zJmbVyv9UDxPcX3FN9TfE/x/aHH97oPe5pxfq0BpHj/0OL9EuNS3E9x/87ifmDX76PV7dVmhZenfBeAK0ThPoX7erhvYBOK8inKf7Yo34kfTcG9oeFWBwsqOqRAnwJ9CvQp0KdAv+tA3+S0nkx872T6KKw/gLDeyKYUzVM0v6do/lOMUQaF8xTOV4fznE8onqd4/kDieRtD1gf0vGXfdumZDiZ0AEpHUDqC0hGUjuh3OkJ43Seaj7CZbkpIHFxCQjIqZSQoI7Gz2wmD9NNdtAwY9/bvlkLQZJSK2O39hCqDUAqCUhDPlYKoYURD6qHQYrt7Cw09UfUAhesUrlO4TuF61/cXFlzSk7nHsNq8UXh+APcZFhmTwnIKy3cVln/nh8tPELu8Y2YL5k5FAhSZa5F5iUcoOqfo/LmicwdmNETopVZ0fJ/icorLKS6nuPzw4vKyT3oqsbmDcaP4/PnjcwODUoxOMfquY3RhoShCpwjdEqFbPUiKzyk+32987hTMaNG5aEOxOcXmFJtTbE6x+eHG5tIXPbXI3KoHKC4/nLg8Y06Kyikq31VULqnfq1p2Oegr4VBSYL7bwPyTNXSliPzoInJOroo1dyaSFki0D3yru29JuPp4g8JeCnsp7KWw92jC3szZO554V/3ofxtwRkQSNJneh/P5MngEp2p87z/dQBAIjs1is2IXi0/TRyQmzE06rdJuOHhFFX6EzY256N6RMiyn1e6/8D6hm/kYnMeBMkZPjBG+sDRbB3EYzUM0IE9eGt4H4IbqjvMyurW0Zk/5niSXdx/e3qXeTeDdbVa3F144DsYXVil6gR557N2hFvFuNrdjq1+WR+fSjoqEBn5ptwHVjm5jp2cnXon5U7kQE2YuUYug5iq/jal5738iLZMAJjFPjN093oGS8j7GmwqTMGc6YR2s5sg30nXUyI6fVVPyMy7Jl2pCitlNxM82jtwL781dMGP6G3j+IWB9zj3sDWc7u6tomUCotZyzyNeLZrNNLHqJq5R9WaYqlf4yWA2RoiMMwv9crZfBjAWxcXUxApWsIMI75IfK3kBYMRwCtYgICvUO0uL8Qxoulx4uLc5uAYZQhNXC1mRayjuv7e0cQ3FhIDx/gSmcOHgVczgHjNOzFIKk4vkWPpSkzb9M6mVAFfVwtQnqnHwRrqDFGpZHsQhXqDHNCyvElfWATDCsMMzsIe59D6vY/ceA5738WbphuprLJzovTEOCyg4XFe15AiJEnhL+HRhJVPAwwPPUA0fD8yuaC3bi3DXPkxBKV35S0X4VPDBWSOMQfptfgL5P87fPMDEC7sgmrZ6B8rqbYOaD+RAWD6nMUgM17Rm17WtRFVXnDhB2Ynd32Qiru1mDxNek9R08kVNP7DPDJskEg2qGHHe4mwW5S0+7BLRLsKtdgrf+CoYbbZLvwmA5T6h2j7YItGBY4xDaKaDaveeq3atlRUPtntZmK/Qbc18EwEsAvLRNQ9s0tE1D2zQ12zS6t30q1Ym1hpuqE58/4VBiTso7UN5hV3mHD2kUg5jMNnECA/shSBIYfq9KFY0zoLrF/SQljMSn1ASlJp4rNeHIkIYEhUWPbJGmqOqRkhWUrKBkBSUrKFlByYqaZIXZRT+VlIWjQafExfMnLiyMSukLSl/sKn1xBbLa6+yFaQKUvNhP8sJEe8pdUO7iuXIXbvxoSF2YlcgWmYuKDgkxicJ8CvMpzKcwv+Mw3+jKnkqU72b6KMh//iDfzKYU41OMv6sYH6iepPFmlr5ezftfrlA7G4r+9xP91y4EpQIoFfBcqYAWzGnICzjomi2SBK69U6kDlTpQDoRyIJQDoRxITQ6k3tU/lYRICweAsiPPnx1xYGBKlVCqpLtUyUDJX2QB9ipiPJAw8CgWj4u35qSAd8fpFDR5lumYeGfswzOJl1RImHBkszP559mgoM28K1yN+4C5gUUKLM5epylCRfC1+7304j+46Tr/Xc/g/HHunWldRSvvXEoixxXz5lHAo/7gN4j58waCNC9kLCRN4UzIasKNSJ4TmE7fMN2ZDx8XLF8Bp+A/DnnYVRROttCXnjWSEkPMG4hYqaIJH6uMsAaG5EI1MuLIe/UfGaAY7+ydeGpgjaTZPMC6B9jpTKo6MK3+fD6UAS7navCxC02Ry+dTQQj5XqZwgfHk9T5ZGMqeu/DAnQ9XYRpC+Mc+mZRewnwQy6hGI93GZrF/WUhVZOZcRHWOaJwH4MNWJm9TJWwdJ+JnvdQPDNH+x4gTT30bH4BGCJv948B37I/hwJY5KU/gcy2T8pYaCmFxTozyAjYUNYrgWakl0KdgpqQ8MA6wN+E/yqNTwv1MxVuXTNE+k/OidJy7pO2ccliVjDMyuYVGOTU4gIzZLGwm129iX0hmM/JEFfuz/BSI2BItK/DYZo0MozQpfWWbnCkmK6pSPsu/Z8t/FZTUEcZAOQCkl7PKhffrJkk98N659VtLf6foChRDxq3DxBfeex5+8fSFfMibbwKGFMhDNZZsZ2ESH+WgFIUJ1wx7kl2EoNTAJfeiRfYATvz6l9XXVfS4utY6kVl/35stQ3CmmFOVxv4qWYN7sEqXT3wsY32PxD55UMXZ8IfiQ0Mcxr0B8b02qu+jW/AwnzxwAe/A01wCl/AnkXFnX3GAM7DlQKp7/ytElzppAj8Jgazo08yDm83tLaYoi89oLX786eO7yxzWEFREBi0qI2ZYTMxBIeLmTSDgFMt7GtfrzQ3ENt9wwnwDhPkmwz3+ppSFWj9dyxXTNiA4XZiGvdRA+39iOIr+8jN++UUgzVpb50ZTKIXXBpJjfi3BgWBGSC7ahWsyamTaI/sxYmRE0vNdHdxzQAKtonlwjdQEavtLGNL8idGb7fqUPXCd16bY/tdkun4CBbwac0jY6ToGKk8ZdzDmsAF3umKsLs5+kaznDWHUxhBP6vuRJ7M1f/ylIHUwyXMheOf/tTrz/sX6vvPz8a+gobKsOs7hBiYzBh6+99NpBp+ZSZQrKDGXs63SijVpRDFDW1awuF3KdtzmIJzAE7jiHsgo+uTADI8+6J80skZps+VmzpXd+RpIA/Z5LIMVbo2low/OgaUTBEuFEaDhWfk8zrjlw0A68+3Dr+EK1aelhzNFA539ReAzh+k5BE6bNWJiB8v1YrPE/iw9ZBrpAvUJC0qC39YRLFKIaaR70LrMNFnpwFnCGq7e8/TBZHG2acXCZzU+pTnBCmJqZp6CLlKgkDGFYGyAD5iUkdrRyNpafQpN0TyYLcGSiXyj7I2zb1lYRlaM9kCxt7JPbpwTbkE5gPqd/2CDTJ9F94G3gMAFxh4xnkPrL6HWgf/zHuAJWypFCOo1g+FFUmWBu9jex8/tuO15e7nZz97HkNxXxU3zMLH0IV4kqTD2PuLrYS7RI+LBz4OHYBmhLFhlOUFOf/IgFmTiXKQnmnX4NIy9a44gacvbYAIa1BsjJYx5hVMRWNUztK8sSYUY1tZ8/ws1B45b4vy9oMwyf8pcuyE4XnqzPKOeML62Z5yb4Hsvzn5W7EguyLi6Grm2k2274WAlNW4yzeTZovCcxNldELtyK7p3LZov8X5djC7dDPsunwhtgCke/QRdab8k3iiq9lstMi0LxhEmlwUultzs9t7N9h5OZ15OZ55ON95ONx5PB16Po+ezG+9Hy57XpQNcCh5ASJB+a4iT0ydgDJgVox/a4Kuf36AFuwnyUoe/cHIjA22SAGmt8Q+KDSgpWE5lrdwzGByuOd9oFjQcvw2wOI+NH0Vxzv7kjpQ+H/CPNgm/3iAJAq4AhF3lt9uw3YY0fpIGWiZfYczsvQPdx2BDEGweYqwfwQACdBtWsJLB/NKT4xX30CzDe+CsaOF9++c/a73xFrLTZOx9CLh4sTaJhyZCn5Hn3aXpOrn85psMxho8G/zjNvbvUXpe3W5AxhP+/Sve1TeDwW4sjItlaWZQzJy+OPudZXXVxR6Np1NRZvD7+aV37v0L8FlcfERenVL6YuT9h/dnvid0fg7Gy/zaM+ZDwv8kF7E7IkSRT2Hd82UXy3mRMwlKCCilNXfdoG22dGAaze81cUK7lbfZXnebW6RbTRi2peVrb/Haalh1dlY+eE5e6JofqhPaf/WT4F12KYqf5Dek6JqoC5e3v4ooI4tFC+Xfqyoo/9RR/zT3qd3lWhnMoQr11u7r1m7rdu7qdm7qFu5pjVvaVllaWf/S+z37+A+bijHecWXdko+D++ghMOzKs+aGixyRxrgRkd3YmKz91XBQ8AaBerjTBg7ttXF/7jrXd39hGSfh4MoslFJ/EqSiGm8Kr8paTdh5h0E+caU+o7o8o3XJxBZ1Hc7VFvjvNl7PpvrL9M0f6bvDs0xFfBClCOLVcl9IqeFw3Hy/VAuF4id29VsQh4snfg8XltWjpvXFr+w73G1jVTXK3XqSn/xNeqddaM1373mvvFC/qMtk2WG2Wyw+uMir57ikDIzlTcoFg5ldZGlO0AMoq+waNMyYbrhQ/2lQ3hZkTDyP+IMgNNAumLKMLSqic7ZF6s/4WmDV2bm810zpIRSZEGyFLgmL8DylqUylTkEzTPl1iEpzOXR11EGheSpiK75pqavmFzgkTDoX37n24zSchWt8eggmLwTLCX2wffByF/JOveKbQVhlcUC+4Fn9RDqVlNXWvXjWaIbLNIWpTQ0tiwwhGMG03Lh1aXixstNwaavtcRSI4cjYwfhnP04CrET6kMboChmGMZYPG4s15Jf5ZOpOZ2lcN6g9l2U8/HBh3SyeGLeK8+fZoSplFKWjkyqjWZdA1Q4KNyasOGzQtCCSG8YLr3xsqsbZqiH2I9Pk1qN9F4NGVZuWx6uXho0S1GcUh/8IJqruzD4dVhQZi+qkyjJ6W8VSpRo2bwrAkCaq0ncqYDPUrRrKjXKumii/lx/E7bXct4niCd6ca6pw+u9NCNJX8yjjdllhyNkBAqLyRom487Wo61wqAisqASuJ1+1ByYGlxJHPeJydbhA9GJ7n0cMlmswV3nbqewm8Evxv4fsl19wJZydXuBkEiyiqa9nRY8N+wAvvkd32uxL3p4pSHwjVUGWg/juDkGEFTsTMGzIphje8EhY12dywlwXJwLhxyKx4xLdKUQ+zxCH+LnuEl4DwwPyTEZb1RPGcbWhC29+YUbee55a342Z2BncZWRVPeLsCJ+Izf+4VLM0m+DLQXdcEtAA7A1Ttw77swJ0V0mxyZ7XTHTWuqc0HVX3PDfDQZ2e/2dU0f7ls75yDvNaeg5Ea0Kj4dnpcRHN0LwatzoCYg5HRYCtnqIWnLbn6T5jMCopnRV5gYynk0uOWOgLj/QRlUyiL7PbglOcIlV644LFTFggCIB3BYX7vsnfNVdr1BdukuAmW0eNoTM7/6Tn/DX338maXuIme8Tq7jl5yuGL80Lf7k+ZxsvviMUlpeqE4vpawq+e/NbVU1a/1Xnn1oTFmT2DtQuRcPGHpgzvgl52SQpvy8yNDDtTs2dTvsZetzMfXH/5z+v7tFA/HVx0GjIeWo/RVxPz85y/Kae7R1qenFFMvvTgK5PYfyGkiuU2iastklclPsOapDIPvRxjqHIJRvLp9GNomCmU1iJJz5UU1ggkQhyTZ9myaMzbNaFwV+krAGTUmyY/zSegXyziEkS2tMNfw8munGDeP2suGqSCB8oCRhYAOp6VqT1zlR6o+448vbkE6dwlkvMmP8ao+wdZhfZ3TUe1FOHoeLb2Pal+juvJyW0+kxhuxAMlUVFJeOJ7lMKxRvStif0Ice363SuOndYQFZAu20bt6JQ+sQ1yWIhqYPLiPteqYzQjhb+8WS9XYFyKIytMYO9qAa519aLrzJfjBqBy03MiYKXt1ZEP1j9FAkybZvIiBUTgZgUfRNz4Y2TTgpSvX4l3X40LwHa0WYXyfbdKLkJintNhZAnQCeNrqJuAH+7H67q4QNovFGNvPcgvbjX71QzAVfYrkyXrpz9ju+JSfHxzzr1lg56M9s/pKtSAg8jmLpXE4IZoNTlY+vM5fWVGVGSVJiEcvM/BACOxjb86OkM8DcSYAN6qVGXjv3w70kwU+LyTA6Jy5nBesep+VJPjLJPLA+KdB6XWhDpQoFoiBSIB9g9F4PCPBxpbNEX9brbDQwZ97t9jpel06oCgznkrNAoYN8CkvzFBmlPBBe8NHZJ2gNDss8Bzfjj1/wRE4bvBgwsM1TG5250eJdx+tvgZPLLcKkQGoCO87cbakND8/wYO4/IAn84NLB2C1w5HMjhWMBmtsLYhhKuIDqyF4E82D8S8/vv776/ffv/7r9+8MztuZwibe+e9mfv3jXJy+2azmY6x5f4o2htqiMyx7naGgznGN2EldpXceilyI7Wj/CeVUZrgMnWHzhJUAoZwnKZ5FZeTFyoCzyuPhrLAIIUFXjI8YadkRomCGJSoQPqHuR+jMsZpdMcr+n4TwC1kPS6ebZQHfjz995MecBRAqbwCcABZ+v0v6gY/8/PfiwP84l4q3MNH8UNWZoS8hkH+R6vX8dxOVWNcdLkrW0lCQk50wnt6H8/kyeASukxAJm9U0q9NJHxFnK40yRBW5K6TlLdhOBLQsUr++cKWdsTVl7y3VLqYkt1bwUsQSMSF8Alsbw4bqPIcac5fBQUXQbdu8qcsXVAAv1kSfJtdoov7h6ltW1iVoBHDZOmk01f0ib1XlELbcWrHTV7h/TnFUAeONs1YVR1UyRyUbtNwybshw2vnhLgA9uJ3ZDtSjfCzGNrzGGJC8XlFhYyCPqGFEBasADDpBDr7wfhT7Y+zIrHk/hxerl3ailAPIYv/qumxmp+h2ZQO4ZigXplJXtoPNoDn4CWYZq3syVh97P+FuSEZxQyfW4cs+JDalAEeZgTEzbLdD/MO+F8ev8Sl+xuguSoQzzf8M7kGCHoJCutvYH/P+w3s8McB3wpiEMlZKgpXYyVQ9ZwSYA0v/BDL8F0N/CW6/8bCI4daeY59LxLoOOOFYphh8axNr58ePbmFpNjeYrxGgIq/w9AG419E3YZKA4H/zP//8v74d2I8n66fuc+uXEwR3SurtX1GHae2t+1EloeC/jEG4WPRiN5OlVNBENC194f2LeSMCxIpxe55LcjSHNoe0ICn8hw1zo6G33e4o4S6OEzoeKazUn67HW/i7fmbvYofxszbKe9lkBKhPDujDaglMCkYgG/CNcfVYgtpluMoRD0SVvqlUiAUC0BK9Eh6p8v5Z8kuG9/MIa5huntBx9jfL1HS+AisODCKNHMb/I4T523/7X//3/8VTBQmMPTAjMbyQO9Bs8xlPTnBAIo8XBsjQBE9xYM0URov+wrB+Lqd5zvPTPNnq/NfqvP2xmOGo9XkkfpDnc9XhoOIJiS9OWdQX3pXAidCYENf/VsgQJ8Kfyo2t9XCsJUOMMggTxwKxrG4+Ap6YKdZ5ZNBUwW/BbMPOLD2EvhGECULjXxMXuTWeGZHSmSGNWWz3Bdns47TZbXd0ttjVsRYVONvy7GOWTLSfX5KxHsvgCwIPxc/RpR11m6VGRqXSzSmLP7cBhb1i794HKCxrsiUmbF3nep6nFMT1E+lVW+V2m+knC/Ra4I2+4ryyn88J85pl6DpNqhBKKqGkEkoq+0kgqZ2BpHJlSRiphJHaV4zUEgcTRKqB6ASRmvdBEKlaPuNQIVIdRNtuNggh9RAQUjvzL7r0Mew1FgSQSgCpnbs8jm7PTlwf0/k4wkclfNSe4qNKjid4VI/gUXcOj5rpV0JHbVU+crToqG5qiMBRCRz1VMBRM1W5A2zUtZ8k/YU7raw7aFsLsEW9wkGDnVqKEw4Y65Qz/sC01X8wMCPqptmxwkVKCIvs8Gs9toUzroWtosAd0sIBzqLyMFEjbMyYjWJ3oCQ5iEhOdQM2Iy8dstal6IiM9ZU7BwLI6HQuywQaWGkJXm5vFHoJGVgsOTp6xECTKtkrYGCB3oQXSHiBhBdIeIGEF9gPvMAjdeR7AheohXqGsRNaYAdRVbPIyjG6qo2wLJqjBBbIMj0nhBZYEZaJkanBCGEFElYgYQXW5wx6gxW4k+x150iBlrQxAQWWjTkBBRJQoDI7AgokoEACCiSgQHegQIutNeXse44TWBH61G4mNIo7TX4RwQRWwgRWJQ9c91MsBRJ28m6JEljBTwQSSCCBBBJIgEOWA3oEEkgggQQSSCCBBBJoTZ8SSCCBBJLNrtuSIZDAapDAD0H6ev4rrzzbBivQUqq3A6xAdcRbQgZmIH9Kl2LH9OhwAs0L3W43/WThAou812/UQHUuzwkeWCGEw0GjWgSHegZeqpDVYLA/y0+B6C0jPNs2n27WyEJKk9JXjbf3CAORMBAJA7EJBqKqGggKsTMoxIIFIEREQkTsKyKijZEJGNFAewJGzPsgYEQth3OowIjuEm43IoSPeAj4iF07HV06HvYaE4JJJJjEzv0gR19ol/6Q6XQgoSUSWmJP0RI1xifQRI9AE3cOmqhrW8JObFVVc7TYiY2UEkEoEoTiqUAo6oqTkBS16gyX4owtCya2qO04aFxF0059L+AVC0JhAg1yh62SYDJ/Ioyo08OIqoJEMwnHcNQF1JTTuayDQRcy7CsfK1poL3B6dgu/U1PwVKme943C44xYtDVcz0UbvJ4cgaaAaOpcY3ggwKZbw8zIovlPAgIyA88TzmJyzd3x2RL80AwVUoBBXmAR06PpBMcjOyYhQSVFqRAEbag/UCWeQfCwAh9j5g2ZSMMbXglTm2xu2MsCU4X+IuTmXcIpIMgBJhXxd9kjvAQkKcVyYiwLiuI5R+JYhL8xaz+2HfyUaD6ZAcI9SVYFxI/gfebPvYKl2QRf7KCxLk7vy878315CyBrLUI8eSbZCf+8VUNbiPRGuLMUMhCtLuLKEK9sDXNnjjvx6Ai9rTnUZpkAos0ca5p482Gx9xJwNsBTEEPQsQc8S9Gx9/WZvoGf3sN3XORBt9T4b4dGWzT7h0RIerTI7wqMlPFrCoyU8Wnc82mqTa9oA6DksbX2QVLtB0ShQNTlLhE5biU7rkHTYco/GTuUtQWrruYuwagmrlrBqCffOcmaasGoJq5awagmrlrBqrflWwqolrFqy2XV7OIRVW41V+zGnbVewtUqXPcOubZkeOhI021pWaLdzT8C2RwBsa+GN58S4zbJ/nSZpCByWwGEJHNYi7oQT2xlOrE2hEmQsQcb2FTLWgacJPdawDIQem/dB6LFaVuVQ0WNbCbvdtBCQ7CEAye7QK+nSM7FXhhCmLGHKdu4oOTpLe3KYTIcECV6W4GV7Ci9rlwFCmvUIaXbnSLMVOphAZ1uVxxwt6GxbVUX4s4Q/eyr4sxXqlKBoteKLhrUXe0ClrSrdIGjaXUDT2uSFUGoJcYpQamsPBbXFKqre4D5awFpWVmkZ8qBUXMzYWOaB8tXPAe/AFcu3eqLlvLQ3VDw5kwT3/voO1M04O4XrMwxMrj5476G/9NABsuc/HApV//Xfv60s4ni/YHme4CGMNlyL4dnw6B4zNjBgb7hZc/vG1MPNMorucUcIOEfrCNTbBRu+AqKD2t3/KhuzxfEeQn6QPkTNeB/FT1o/D6HvXSsikVyPxOn1aBN7oi7QtAZaP3zJcCNHnMNHQzFjQD9zUWQv1uBC6FggZrRJTUfoY8EBF4XJwR9Yw1/e93rhXbMSx9dcub/JaxOv2WYZonmUOyoduH/F68O5Ey5neZEzB0bXURxv1mx5GLG0wHF3ytqksMti1lRvlyoybTpxquiOKT+Crnw7shYtbmkQOjIKVaV7zWzD7lDJnhFizL3CstKxPCK0MYkLZcIbsxxvb1XDfNjA2Rk6VmU5VTvEsLxGu2K2KrlroMVGDifLjb6hCbO6YXRM8NUnCF/tpjQJyZqQrCmvQEjWhGRNSNaUHbIu0OGCWtcmyQ2zIXxrCj63CD77AXXdKNyVB2XNbQgAu7DCBIBNANjViY2eAGDvt8iAsLAJC5uwsAkLWzFyhIVNWNiEhU1Y2IeLhd0oiqrd+GgU1Jr8JoLFroTFbparcN37qap8tVN9S5jsRoxHiNmEmE2I2YS+acGJIMRsQswmxGxCzCbEbGuClhCzCTGbbHbdpg8hZtchZj99jLLTDW/04Lk5XvYVG0uHUNkcP2Wcnf0P7tfpE2vzDn9ri45d0+0R4mFXLnS7zf1jR8OuYZL+4l8beIHQrwn9mtCvjxH92iDshH3dIfa1SZkS8jUhX/cX+bqGown32rAIhHud90G411pu5HBxrxuLut2sEOr1YaBe78gf6dInsZd9EOY1YV537iI5ukl7cZVMhwkJ8ZoQr3uLeG2WAMK79gjveg941xb9S2jXrUpbjhjtuo2aIqxrwro+HaxriyolpGutaKJRzUTzOoYtqiwOAdXaubDioHGsTbIwMJUpHBDMi32f71ghgCVgSHYyuB5JpAGKiFuRhDt+iAN2SOURq1ETSJiYjWJ3kDA5hEu+ChdlBBJeJtUA8bJpldKB4F06nV4zA0M2MCYvt7Erh4wEWVdodQLYj/XaZhfIjzWEJ6xHwnokrEfCeiSsx75gPZ5EENAbpMfKMNIwF8J53EGE1ixKc4zUaqM1i6YhlEf3EC/DeDS0IITHwuoSwiMhPFblI3qE8LjT5HrbhIZzVptAHMv2n0AcCcRRmR2BOBKII4E4EohjCcTR2ciaNgJ6D9voHBbV7lg0ilFNnhGBNtaANronHlw3bSwVHXZyb43W6MxvhNVIWI2E1Ui4T5azjYTVSFiNhNVIWI2E1WhNtRJWI2E1ks2u274hrMYmWI3vfuOZG8JsPBHMRuuCt9uyJ+xG+1x6g92o8QRhOBKGI2E4HjuGoyb0hOW4IyxHXbkSpiNhOh4HpmMFZxO2o2ExCNsx74OwHbVcSj+wHRuJvN3MEMbj4WE87sBP6dJXsVeLENYjYT127jo5uk97daFMhwoJ85EwH48C87EsCYT96BH2456xHw36mDAgW5XMnAgGZFO1RViQhAV5mliQBtVKmJBacUar2gzChuw9NqQuG33CiDTvIxJWpLkAwx2JpL4ogzAjd4YZ2aRK6riwIx2NDmFIngaGZLUWIixJwpIkLEnCkiQsScKSpGCht5iS1vDTMCfCltxhRNcsqnOM7GqjO4sGIozJ5iGhEWtSa0mYk4XVJsxJwpysymP0FHNyZ8l7wp4k7EnCniTsScKeJOxJwp4k7MkDxZ50CpdqdzwaxbAmD4kwKBtgULolKPqBRenEf4RJSZiUhElJ+FaWM5mESUmYlIRJSZiUhElpTcUSJiVhUpLNrtveIUzKGkxKCMK+j1a3V5sVatPvgnR2d1BQlNYmppFf6VEl4VOqrmEJn7Jy8dvt8hMspX0uhwxLaWAFQqMkNEpCozxCNEqDrBMIZXcglCZVStiThD3ZW+zJGoYmyEnDGhDkZN4HQU5qqZKDhZxsLOl2o0JIkweBNLkjZ6RLh8ReCkIAkwQw2bl/5Ogj7cNPMh0wJFxJwpXsK66kWQAITtIjOMndw0latC+hSLYqcjleFMk2SorAIwk88mTAIy2KlDAjteKJJrUTHdUzEH7k8+NHmsTjwGEj7Rt+hBZprouoxBZxq5UgkMguQSKblir1HhuygXF52bmdIZzIQ8aJrNc/BA9J8JAED0nwkAQPSfCQJx0U9AUVsjKoNEyFwCC7D9iaBW2OgVtt8GZRM4QB6RzxySMh9sCGEB8J8ZEQH+uzE/1BfNx/6p3QHwn9kdAfCf2R0B8J/ZHQHwn98XDQH50Dpdrti0ZBq8kxItDHatBH90TEwWI9OnMbQTwSxCNBPBJclOUMJEE8EsQjQTwSxCNBPFpzrwTxSBCPZLPr9nMI4rERxOMnrTSgOcajpWiwPcaj8w1czeAcLZUufPhiz/XYMR0/WQpBmm3bE6ijfS79AXXkvPCcqI4uEjkcNCptcCiP4JUPWUkH+7P8FMjhMsJjf/PpZo1spDQpfdV4W5BQKgmlklAqt0Cp5DqCYCp3BVMpjAPhVBJO5ZHgVJY5moAqDYtAQJV5HwRUqSV8egJU6SLqdrNCSJUHiFTZnT/SpU9iL2AhqEqCquzcRXJ0k/biKplOOxJWJWFVHgdWZSYBBFbpEVjlvsEqc/1LaJWt6nVOBa3SUU0RXCXBVZ4oXGWuSgmvUqsEaVQI0rw4Y4vSEcKm3Ak2pZAFEz6SO0KXxM35E8FhnR4clhPsm6lhQxwtp6NghwqdVNiaPlZA1V7gDe0VRshaTlWpq/eNI+QMwbQ14NBFG8ShHCWnCu7VoYrxQPBet8bEkUX6nwT0ZQYaKBzG5Jr75rMl+KIZGqYAwbzAEqlH04mRR3YsQ4JpikIkiOBQo6C2PINIYgWex8wbMiGHN7wSdjfZ3LCXBaYTAYuQ23oJ8oDQC5h8xN9lj/ASkK0Uy5ex6CiK5xwfZBH+xkz/2HbuVEIPZdYItzVZjRE/8veZP/cKlmYTfHHG0q12fF9u4wMTbm5/cHON+puAcwk4lyIFAs4l4FwCzj3t6K+fyLl6ysswF4LOPfaYl7Bz3cNnM3gub0HoudoZMkLPJfRcexloX9Fzu94IJKRcQsolpFxCyiWkXELKJaRcQso9VKTcqrCodseiUYxq8owIKrcJVG5l4mHLTRs7ubvFyq3iNwLLJbBcAssl4D3LOWwCyyWwXALLJbBcAsu1ploJLJfAcslm123fEFhuNVju34L00x1wC4tgtwHJtdzL0h4k195EHXLp2sJmkLl14zo6uFzLerfboT92mNw67ugrTm6BCZ4THzfL6XWaaCE8WcKTJTzZgpATjmxnOLJF5Un4sYQf21f8WCsnE26sgfiEG5v3QbixWu7jUHFjG4i43YwQXuwh4MV27nd06XvYqzcIJ5ZwYjt3hRzdoZ26RKZDfYQPS/iwPcWH1TmfcGE9woXdOS5sSd8SHmyrkpSjxYNtppYIB5ZwYE8FB7akOgn/VStucKpt2LbeYIvaiENAgXUvgDhgGNiiKAxM5QQHg6Zi2pY7VgxNicmRnc+tB+twBuqoq2FwB+dwAOaoPPDUCDg0ZqPYHdpKjo6SU98AVMnLlqy1MDo8pXvV0IHAUjqdITNBJzrZjJfdmY9DBlCsLX86egTFKiWzC+TEOooTdCJBJxJ0IkEnEnRiP6ATj9zZ7wlkoiU8NMyBoBI7jMCaRWGOkVhtNGbRKCcPkegQwokRmgIWgkQkSESCRKzPM/QGEnEvufHWiQrnpDQhI5bNPSEjEjKiMjtCRiRkREJGJGTEEjKiu5U1Zfh7Do3oEA7VbkE0iklNPhFBIlZCIrokGFx3YSwlGHYybwmF6MBfBIFIEIgEgUhwSpYjhQSBSBCIBIFIEIgEgWhNrRIEIkEgks2u264hCMRqCERMcn2CV2Z276BgEJ1voWoGfOh8LcaR4B5WLHK7rfdjxz6suz29p9CHJT4g+EOCPyT4w+ODPywJOkEgdgaBWFaiBINIMIh9hUGs5GaCQjQsAEEh5n0QFKKWAzlUKMSGYm43JwSHeAhwiDvxQbr0Q+zVGwSJSJCInbtFjq7Rzt0j04E9gkUkWMSewiKauJ+gET2CRtw5NKJR7xI8YqtylaOFR2yunggikSASTwUi0ahCCSZRK4Bwrn9oXpPQc3BE5yKJA8ZGLMvAYeMj2vbtCCPRXNhQhdDhUuxAOIkd4iQ2qzLqO1ais+F4uY0NOWSExLoiqaMHSKzTMLsASawhOmEkEkYiYSQSRiJhJPYDI/EEHP6e4CRWhIqGeRBWYseRWLNozDEiq43KLNrl5PESHUM5eeBFf5pwEwurSriJhJtYlXPoDW7iDpPlbZMWzllqAkss23sCSySwRGV2BJZIYIkElkhgiSWwRGcja0r29xwr0TEUqt2RaBSTmrwiwkusxEt0TTIcKmaiI58RbiLhJhJuImEwWc4fEm4i4SYSbiLhJhJuojW1SriJhJtINrtuu4ZwE91wE0vHZwg18dhQEyvBAQgzUfw7dsxEwQWEmEiIiYSYeLyIiYI9CS+xc7xEqUAJLZHQEvuOlmjgZcJKNJCfsBLzPggrUct7HDpWopOQ200JISUeElJih95Hlx6IvWqDcBIJJ7Fzh8jRKdqxY2Q6skcoiYSS2HOUxJz3CSPRI4zEvWEkKjqXEBJbFaYcPUKiq2oifETCRzw1fERFfRI6olbm4FjlQNiIPcZGlPzfD2TE4v4c4SKaixdc0DjsBQ2EirgDVESXKqJjwUSsMReEiHjsiIhm3UJ4iISHSHiIhIdIeIiEh3iqbn7P0BBLwaFhFoSF2Gn01SwCc4zCaiMxi14hJESX8E3DQRTPEgpiYUUJBZFQEKuyDL1DQew8KU4YiISBSBiIhIFIGIiEgUgYiISBeHAYiLXl34SAaIo294yAWJ1aOHT8w0oeI/RDQj8k9ENCUrKcKCT0Q0I/JPRDQj8k9ENrSpXQDwn9kGx23TYNoR9Wox9+iuKvi2X0uA3soeyjFGLuGsfQiqgoR3Ql8gQViIalqirMm3MnRiBiMZEEJ1CyOR6LMYavLzB8O094OjXmehJ5eXPP1SIY23v/K2q+ZBMHplTz9TSrlphOJZ6Eds5fCEe5tiJrOAbbivYqKctIVSsQlWHx+9G2wItl7mq8zd8cSnGn2IjOLNdXlEQ5D4JHJHhEgkc8PnhEKd+Ei9gZLmKmMgkQkQAR+wqIaGJiQkI00J2QEPM+CAlRy4EcKhKim3TbjQdBIB4CBGKXjkaXzoa9XoOwDwn7sHPfx9H/2ZUPZDqXR6CHBHrYU9BDhekJ7dAjtMOdox2qWpZgDltVoBwtzKGzMiJ8Q8I3PBV8Q1Vh7gDYsG5PGAP6kQEK0QoeVVdTcLSoUe6bw0ePH2XZRt4FcJQz1QlCiiCkCEKKIKQIQqofEFJaqQJhR+0bOyoz4gQa1RFoVEV1nboShBb13GhR1ZWrYnC5g0n4UMoaEj4U4UNVbQz3Bh+qLpGxP2CoFicdCCKqbNUJIoogopTZEUQUQUQRRBRBRJUgolqYW1Muf5dgUah0so1A21FM7x6To2g4ZYrvTzb3uBZ5yhrB14JOVcdSTvBLTihTreF9TEfPCP+H8H9yV4Hwfwj/h/B/CP9HeRfh/xD+D+H/qAeuCf+HbDbh//QL/+etvwJlGm2S78JgOU+2ggEy12zxWzbtIbXYSzNk1a1NtEFf6UFhMxQhWW6g9Sq2yyqgg1Cdz6difrIXVjOX4y3ke4Ji+zNMpuEqTEN/yVtOhhmrsiQxS9FyoiXTmwAHnu2tsgN422LyWFe83Z7qRKFCVwg+hq3WjxGnovo2PoDRbgF/6m4C7SnMj8YFz4n2Uy1/w0GjPWiHfWy+RZ3tvbM/y0+B1C0jLDqfTzdrZB2lSemrxts6hFtEuEWEW9QEt0jTDgRf1Bl8kW4KCMWIUIz6imJUwcsEZmQgP4EZqRADBGbk9QHMqJGQ200JYRodAqbRDryPLj0QM+socRBBGxG0UXcOkaNTtGPHyHRsjBCOCOGopwhHZd4noCOPgI52DnRk0LmEd9Sq4uZo8Y6aqiaCPSLYo1OBPTKozx2gH3EsI8uhBFl1kZ0+SNa+cqKAOYFAGdyWAz/22riZd50rtb+wHJTwa2VeaqyUXKSy4hpelbXiZ68H+VyU+g3H8o3tSyq2KABxrsawHpC0nKOwnZuU20pKjYfznd/bgTFsAcRgPfqfoTHo8mAClXGHNZJgI38iDKHTwxCCodVIxHDUBfiQ0wGbg8GbMW8xHxHsTLHGsg+wLbtFY6kvgqrUzPsGZXHGsNkaveWiDXxLDkWiqr1G9YYVdYaVZDR/2bKCbbQ95Igspv8k0AEzXDXhIibX3A+fLcH7zAADBU7gBRY2PZpOdjyy4xMSb1CUD0G0hloEdeMZRA0r8DBm3pAJNrzhlbCyyeaGvSwwVe4vQm7Z5dF6PPCOaUX8XfYILwF5SrHMGEuFonjOURkW4W/M0I9tJyolsktme3B7klUG8aN5n/lzr2BpNsEXO6qoo6v7skuv95DBRusKU48eYrRae+8CabTeZyJ8UYoNCF+U8EUJX7QH+KJHH+/1BGbUmtgyzILQRo83vj150FGnUFmM0Ry6EAQpQZASBGl9AWdvIEj3tsHXNm3hvLNGeKRlu094pIRHqsyO8EgJj5TwSAmPtIRH6mxkTen+XaKQAjvXAodeVu741aKHOgVFtTsSjWJTk09UAyhqPydUCSyqUMJl06XRVHe6CdNsM6ajTRk7oYuIUdWxVQEXiTObE49VskslY7TciG7IgiOCrCXI2tybJMhagqwlyFqCrFXeRZC1BFlLkLVKe4KsJZtNkLU9g6z9kIJ3fAUSGSfhQ/ADNyr9AK41Dr0j+Fpj38cKYlvDA+126o8dyrYpW/KO+opwa5zUIeDcVgkqod0S2i2h3RLarVFHEOZtZ5i3ZuNAyLeEfNtX5Ntajib8W8MiEP5t3gfh32rZoUPFv20h6nazQii4h4CCuzN/pEufxF7jQli4hIXbuYvk6CbtxVUyHXQkRFxCxO0pIq5NAggX1yNc3J3j4lr1L6HjtiruOVp03HZqijByCSP3VDByraqUkHK1spFGVSNdVXL0HDW3XcFAL8B0zYJDkLoEm9UeUreduJwi0m7V9jbh7TqdK+sj3q5rSValCifU3eL+jRl1t3mBJGHvEvau5jNnZ7AbOc8vu/ejDxmHt2VV7dHD87oo+12A9Lb2wgi7l4IQwu4l7F7C7u0Bdu+JRJA9QfCtyaYZ5kI4vsceN588mm+DEFyOtCIYImRfQvYlZN/6ctTeIPs+y4Zk66TIljuBBP5bdhYI/JfAf5XZEfgvgf8S+C+B/5bAf7e1vaY9hp5jAjcIrWo3QxrFuSY/ipCBK5GBmyQvDhUfuAG/EUowoQQTSjAhDlrOlBNKMKEEE0owoQQTSrA1XUsowYQSTDa7bguIUIKrUYKvoGmXIMFXbCj7AAk2jXxLjOCG79IzSEcCGlzNEu3qAU4WM7iKc/oKGWya03MiBmeZwU7TNYSwSwi7hLBrknUC2O0MYNeoSglfl/B1+4qvW8fQBK9rWAOC1837IHhdLa1yqPC6zSXdblQIXfcQ0HV35Yx06ZDYy0QIXJfAdTv3jxx9pH34SaaDiIStS9i6PcXWtQgAQet6BK27c2hdm/YlZN1WBTFHi6zbSkkRsC4B654KsK5NkRKurlZo0aTOoqPahy3KNQ4aVdetGOOAQXWNQjMwlTYcDI5MxTbgsQKRSlCS7MxxPVqJM1KJYwWFO0iJA0BJ5aGtRiCsMRvF7lBncpSYfBEMoJ+8rspapaNDfTYuazoQpE+n43AmNMomJudl59anl1iUldVaRw9F6aCV9opEWbUaBERJQJQERElAlARE2Q8gytMIIHqCQ1kdgBqmQjCU3Qd3zQI8xyCvNtCzqJmTR6F0jw7FQCuCIMKgJAxKwqCsz2T0BoPyGZL3nSNQumXNCYCy7CYQACUBUCqzIwBKAqAkAEoCoHQHoHQzvaaNhZ7jT7oHVbUbII0CXJMTRfCTlfCTDZIWrntAltoSO7W3RJ905zYCnyTwSQKfJCAry4lLAp8k8EkCnyTwSQKftOZpCXySwCfJZtft/RD4ZDX45Bu5ifx6NW90z5dLAebHfOH2AUdZO5ddYVM6vPhIgSobsE+7+oGTRa105qm+QljWTpDwLAnPkvAsjw/PslbwCdyyM3DLeiVLSJeEdNlXpMtG3E2wl4YFIdjLvA+CvdQSOocKe7ml2NvNDWFgHgIG5l58li79FnvhCgFiEiBm526Uoyu1d3fKdDqS0DEJHbOn6Jgu0kBQmR5BZe4cKtNJLxNuZqtanqPFzdxefRGIJoFongqIppOKJURNrXqkdfHILmo5ti1IOWjAzRYVJgeMvlkvbSaEJ3eMMYn88ycC9Do9QK8qODtnMRqOugALczrDdjD4UK778seKNsuLTi1DHpRKrxmXy2RUzgc53CB4fPluVLScl7aviueKkuDeX98B2cfZGWYfnIbfhHbhvYf+0kM/y56EcSjj/dd//7ayCuX9giWbgocw2nAlhyfro3tMG8GAveFmza0j0x43yyi6x00r4CGtI9B+F2z4ClQRmgH/q2zMFsd7CDkMQYiK8z6Kn7R+HkLfu1aEI7keibP/0Sb2RAWkaQ20fviS4V6TQDFAOzJjcEpzcQRBrMGFUMFAzGiTmgAIYsEBF4XJwR94wqG8NffCu2bFnK+57n+TV2Fes/08xEgpd1SCK3jFq+e5ry9neZEzB4bzURxv1mx5GLG0SHV3utykz8ti1lStl2pPbdpxquiOKT/Ar3w7qgIp7txedGQzqgoSm5mO3QHDPSPKW5sK0koH9Ygg3yQYlwn0zQbPvV0x94FgdVuURwZOVlkk1g6wLS9Wr5itSvgaZLdRlxDkrWP0l7sN13sJTu5elH/0SOVNle9eYcub2G3CMKeUB2GYE4Y5YZj3AMP8BHNUPQE0b5DTN8yL0M0p7j0hqPOWkbYYtWuwRSDoBIJOIOgu2ZWegKAfVL1F5/DoLWocCCu97HQQVjphpRe3KgkrnbDSCSudsNIdsdJb2GHTPkfPgdNbhmi1mzONYmeTr0Uo6pUo6m2TI677U1UlxHb6b4mr3pIZCWSdQNYJZJ0AWy34HgSyTiDrBLJOIOsEsm7NAxPIOoGsk82u21sikPUyyDrLzVj3/a2ltkoRwCXuhm1XMItvbpCQwcfHr+E/XwxbR5Zesru42WMYuyeGg63VQxAfow5Aj+jz5+p3ZVmCL18utJ5f4zqwPnAAX74odbhnZ2dXbLHYuSqRamMQO6zqUy6Sn6l3VFu3EGLL0kYlt3eFWjLxrn8O4nuQW2jxNliFeJYHvIcZeDjea7nmsccCzSDBvLIAMfR0rPJicvMfgQLDC8NWj/RG+UOeTCfy3UKGtYgJaTyOJr+592/DGS8LKuSLJcfcBKBXY17sg2V60yxHOWVN+TfTqZHpi+kLoU94wsIvTL+c68jzl7lwCOx717VnfFVWb2BKWN4qU5hyKfMhqQfsrgtX5F2XkJTnwRrMBYegjnJThpZV6qJCm7yECZbCnjuTebOhBfjtb0G2A+klG87SHEiaZTYKzDquysyBLls/sW0+vpK8Nkxsj2CpbqErg+Y0pNt2ns8TuTxFF1ox3be5ApEVDsm0tbXcTxbKwQ+Tf/i3INXYC/G/wsS4MAViT+VzWgpaYdQGFWOVxGpWyDRxv23hor5g8R06WbN8c8lAKAMaqTGx1+ryBTvdzTP83BYX8aevF+0hFXGEeIQTMVjmrfvR7ZG5oy9uiViU2wzTzujXgj3lWhpMnh6lg2Fdx9EDxpn3URyYtWWhVjKWQPEyiNPFAWO5+4jtzkz/GNufEfHemSX9ks1raIE/Umx3tmcoh/fHuRU1iVtF3PlfcVQWWYJwzodqZkJlwPauWVKIn684/12RdGgCpLa1ujbp22G+U55VbIxxh3V0bUDI5rFoMDDfG5HJr7Dx11ibeX0hL1XwrguoSdfcOAYhyxD7+vHosi+V4wAzlwrM7zUrFL0eeTyHda3JjW6+DTUJ4Pvo8Lxm3VAv7SPjvuT2PWuT6uCegEJ//AgPiKczJ3XFTbZau+1Ra5vodwkMZRGa/y/asFxD0R/n4NtAt/2KX6ncMBtR6cBnVfG+NdpsE1Py+SvBqUOAZ4wxC6HZ3/PzDDwmEWcZMMVhOtqQB1VqWCbiO+xlGIkxjLxrlank66+96OZXUNJZY7BW882MF/LlxyryFy6UT/F6mptAfmmJ1qAFt06q510MiEqHUbaLy6yx2f4iE5Vqs1MIT54hMgG+3yxTLWooMtm4+hSPczzA2k9MXOlS9VA0h3zYHZk/g9Hg6qUZ1LkYUy2ANn9uLNGWgd3zBFeTw/rQSUmnc1EdvKj4573h12J9SDc3iVf15EBU9SVBdqItDpbBgy/K0GUK25/hhiOHdrxi5PMkYqT3AbeXBi/kByDoWvI9WqSoBGVXyyQSpZEIQYuvvA1WLDU+Z6CP7DqKe/YcKOvBbAnxmjfNEjqbm6HprAjMdIxfyrM8hhN024q2kqll9yhOpw420+U8vzzJ/0/DQ/A5U+Tjd+IX89WY6BhcVk/vSq21VmXTmkQDm60lZ1W8wE8c+DZjCJlBY3tMzGYhXKXcSeQ7OudJwV5fsNOW2b0xSufsbHGCZUth+sSwPbMC5lf4BjCpDFOYX6GSxrhTg/UkggklMrpMvqE1G6hbu7z8G8ua42DOwZCSsfee32p2IcIVeYML2u8YT9aLuYjtWaz5fSUNrXp6HU+/odBHoFbjcC63pMA3SQKOpfkbzidMC8Qwn8V/L8knQhqNDSB8uosecSsKAVET71pd2Gu8R4K9M4EAk1nK5fJJPSX/pM1UZj/Xm5iBqiL2QAa/lXB6qrCPbFGxVLhBGadsM+abeO/flgo5i4Ygq8F0F46RAbVZrALb6C5RETfzVgLwXiMh2Melds9d0d3KchXqx5abEzHXjIzB/ywPQ+cPsQ/6/i3w000AgqBlRDJiKsPIPsuPUpTuqlLbuSyR4YLXQt18fvKz4viHEp8wqN8hJjN0RcpGd4dnHpb6TaFj7fMLM1RZ3RWj+elLyxkUzdVxZ0ddoYsyg7LtVzllYneTsnWYZL9dmKFOX2OMjgzGKZRDiAh9mHCWY8cLcLeDFRPcRuKSKVaWovTGyqkusBaFa1q+EY7VIRm8g3gRV2p3qGrxyplZHCXsDiylM26aB9rayqrhqbamY3hL9pkoZtSytAJ+ynBAvnwufjQotmKGXS6ZqGIX4CrMh6hAieFnE4pnq5k3IkY7ynyVbI8T3WD2iOq9SAfFxY9wcR4KcYHRhbDgBv2zGfRxTddR/HWxjB63c2VePrdX47JlkCmAz87RmucOJtWslrzBkjSxn0q1U42mrooKaxWtgxocyfOxQoIyT0ZiIl3qqS32YO3JVokQwn5WXfQuq0QaeDicZf4G6uIH0bjIb+05VbNzDcaktBq/z39vAiouSKUf5uk4f6IsHLNctZ3yx+xdClWt9Cy2RiYev1z8fKAm9cDmyKOZ2R3EKpPIq98HlScnRqbaBTTi+iUeJSQT9lBdksoESZJTy5aCsuGe1BlFTsly6wK1yl9vVn78xPA3TFAdqB6tX3Ie4ykxN340gKqYAIHYzxLojy7rE/lL+RFHz41n5GApL21ne1SPkl2AaqlMGlUf+MG2A0vgJBiquakohU+/iIJWRYt4wmHL7jNPNnHVaWdcb8z13ASsGAb9WUz34HWpEt053iz1ak9VMEQQkMNd6dBRBkGZ1AtObqTYa2rOp+uyNnERPGvuV+J1VSHaYPlEjaQ58K2ychPld1OFOTt5IyEJWQrnWpWla37mQIzcUE1bELxy7MNEQw3WvgZPdilRGK6qxlQBYxRw25LfxFXD3vXYXz76T+xYEt8lMpbtXoj6aAQWD/9hqNJWQffAlvJOL6tOUOaCOrTDu2gEqZxsod+yVD8KYQavNZ0uAz9Jp9HKdjxmWHMp56XxzIR6GqKigygOb7EcHCLCEEGXsAg+S/Xyz8JVTR9Z4mu8xgA4xdZsb+fae/wmYngN2NGo8u5LltXDXlgge60R+9p+I+bi/PeiI/LH+HfpP/zhDX9HABqtt9Efo/Oqq05//Onju8v8hqY7dgkjbg9e//zuavrpp6v//O77nz5dV/QgoQQw34lJu4wo7FamALc0+cGHij74/doCZvMmCGAZfL5VGTNy3zyJ7ENFHxu2IVBemHEDFMScWdXZu+IU5j5M5bYUM7DmDattPIzRoFLS9cDkY/z0McqO5r7Rd1RrAhVjawpclMCFX7c6zq4FhGfTJ7aO7/C344hYjGxQH8FUcc8pRjRGejxXhFPDuI6hjXFKFOpQqEOhDoU6FOpQqEOhDoU6jV2NmhinKsLR9pRaRjpaLxTxnHbEo7FD08jHzE0UAVl35PsfCWlTo4iIIiKKiCgiooiIIiKKiCgi2nFEBCr7+2h1e7VZ4bnb74J0duceCBkaU/xzcvGPgQscwh4775xktGMgR8+DHMOMKLah2IZiG4ptKLah2IZiG4ptuo5t9JM2QfrpLloGH4pn9OpO3KitKJxxPnkTxEdy5kZdf4ezNwZ2OckzOCodDvMsjukOZPMpHHUuFLRQ0EJBCwUtFLRQ0EJBCwUtzX2MRjsyeEkpIldllwo5By6llhS8nNpeTIkF6uMXG9ecYgxTokW/t2BK06FQhkIZCmUolKFQhkIZCmUolNltbZl0P0qo1Y5xjGhHUcypRjGCAdxjmCLHnHIEY/X0+xi/iMlQ9ELRC0UvFL1Q9ELRC0UvFL10Xj2mBzCIkX2FV3wk4UPwA78rxzmKMTWmUMalmsxMuWOCdTbNsD7KqeCoUwx1TOQ4uLqzKl52jIJMXVAoRKEQhUIUClEoRKEQhUIUCnXkf9QHSIULpPjNQDu/QIquetruqie6lsl4LVMxDHqDNx+6R/f88VI8v8OY+ZDTBdXxvKSVHsFbwtwCaV0DW4O3bbhvscLz1r3uDu/YbuifF3zz7dMPxc6FN3/OiaxZ+cyXL4e8Dm58jQvv5L4bA2A+1lLIW++FO6YxOr5Oveslw3/m9WqQLOHtd5EescZ9TvmRom5wzIhYGKJdLIlsM9H+NtBJdRDVx4uuoxZbqTEQv2rVQq3952QuPO5AwEPO+ZnTuMOw1SWDDqqmQzXTtYoR9wxeDNqc+y3fved6K6HRlhviJJsWsiZot7+gr+HlfDUqx8VhNYt2Z2JdK9IfYGrzXwMIEx7c3WC1ETnDLvqjSDFHl9hAZnKMd+MYq6Tuh3usjvi0neSKtWtg0NReDs9hNukPR7e5klHIed6h80wX7bWsYu+5W22+DK+Vm+1wIVzbq/T25IY3Ktfa8ga5I/DH6a4aUhqG+2Q6UB6Vd6lseytNT5WJyyUsx6BUThbu/bRUiAmSvZ3mqMUlb4nn3h894Ypjfnzqgdd3tNUPvDVlBdvoH7dLEwoUpoTgbhKCRpr3IzNoHPpppwhdVrO9eeTdPVvScCcXd1i4hvKF+9tsPyEY84Y4433fdi9AjbfbfrfDbjcFKD+I7XhdfbbE5z4CZ/wUgUBPKkovg3W20gA1oJVtYD57E5y7IVwekTI4FSytk1QEEu9qKzVglIDmOFm9UwFVIFG9VACaBnjrr26DONok34XBcp44awCtHeXjOszHmWlLmbjdZOI0avcjB6cN+rSzb9Ur2MDYaR31PONWxyOUa9tbru1DGsVBa8wnY2uyuE518WbSuRbIVxCezPGOKuVNNO9Jybxp6CdeO++wmk2K6E3dHWA1fZXWcS2rd2ImsuF7s+GnC9vYBa5iz3NpRmjFVgm1enzBlriMz73P5o4JtB0eYT+zbhrEkQDD2Qrk6GUf8I4I5IhAjgjkqHOQIz1+qJnJZhPOx7/88v7tl53AJFGQSzhJhJNEOEkUihJOEuEkEU4S4SQRTlIfcZJ25lVvgbREvjVBLRHUEkEtEdTS4fjfSlawld22tCcTfsAmvHrNyJrv6py0mew9OSltHvyJn5V2WtFGMETGDo/K8rtyEjkBe3QCCG6R4BYJbpHgFglukZQGwS0S3CLBLZIKIbhFglskuEU63r3bXGQHgI2UiSTERkJsJMRGQmwkxEZCbCTERkJsJMRGCvQJsZEQGwmxkRQBITYSYiMhNlJKb28pve0wHymZR6CPBPpIoI8E+kigj3RCwBH0cXen/TqAjSSLTriRhBtJuJGEG0m4kYQbSbiRhBt5kriRGtCeLHR+vZpvF13U9kSRhhM8Xz0Z94fc57ikFIHsCtSvbgF6gvdXN40ThwJsuMpNUALruj5AAEFXBeiKLdiY+SiS2WEko8FWf/STr8lWmNWHC1T9kjCrTwmzugsIzVN2ieULb9Lpw7f+cn3nfztOUT0ws4CK4v18D05vLcglObbbO7YmeNIDdV7NGKEn5aCaVqtJHXkZUPYQHM0KsNiGzEAOY1cOo+Ip6l8totgbIom8B3+5CUZeqDqW4zT2wyW8aSppPxxdovXGl1164e0KPP/P92Eyu/D8NI1fgcUOV8H8S+k9bJUWHrzJm0wM8iTV58fXH/5z+v7tFI3KpbEXxQN2sW1DaydFAzHpWGU0shVjEFkw38OafnBuzOZOdPs75Ks3vnmC8dk7McQSfghsXJj7GOY+FnI6/vCUpMF9qYjZpBzVVQjiOIr5MrxfcVfUNrl7Hi8yBDnGa5nAe8BYCX6ATIpz95LZXTDfLE2h+4hwmo/fiyRAxz3WaRA8M8EzEzwzuZ3kdpLbSW6no9tJiOMn44wS0DgBjRPQOAGNE9A4ubPkzpI7e5zu7O6x88mVPQBXtiGIPTmyXTiy9dcVHKwb63I1wIk5sfWr2ciFrb2AondH3t0vlCCHlRxWcljJYd3WYd3PPS7kwB6YA9vgAhVyZLt2ZKuv0OmFQ1t3Pc0JO7bVq9vawa28JKnnjq7LZUfk8JLDSw4vObwtHN6d3zFG7u3zu7cNr/sir7bza4RMt7r14xYh8x1qp3yJkGktm7iutbf09c9jdb12jxxVclTJUSVHdWtHlW67PAlXla68pCsvm3gedOUlXXnZ3F+lKy/JYSWHlRzWTh3WXdziSg7q8yNRud6uSo5pB4hUFfflHioyVeVVtaeFUFWxeg0c0Iobjw/hEJbxFuOW7EEeJ3mc5HGSx9nO49zlBeLkeT6759noRm/yPrf3Puvuaz9QD7T+jvST8kLrVrGBJ1rqqudp0HpOIYeUHFJySMkh3c4hLQ3d0R0V7cgZPVxntLhE5Iru2BUV5O6XIyoGTW6ofQVbOKFWX62XLqiNR8gBJQeUHFByQNs5oPLOK2fPUzYgl/PwXE5tbcjX3JGvKencDydTjva0vUvLmjVwK2UPh7fBnst9I4RTK2OQS0kuJbmU5FK2cynf+ivwFqJN8l0YLOeJs2eptSMH8/AcTPMSkZ+5Iz9TI3c/3E1t0KftdVavYAPnU+uo5znNOh4hB5QcUHJAyQFteTNpCqx5Fcw2cRI+BD/wl7hfUWpqTc7oAd5VWrFQ5JLu6tJSE9F7cnupaegnfo2pw2o2cFKN3R3gpVBmxdHshlMnZiI/lvxY8mPJj23nx14BjVu7sabG5MUenhdbsU7kxO7IiTXRvB8+rGnkp+3COqxlAw/W1NvhObBmndHIf3ViJHJfyX0l95Xc13bua3Y/x+vVfLuUbG1P5NgenmPrumjk5e7Iy61dgH64vLXTOG3/t+kqN3CGa7s+PM/YQek0cpObMx/5zOQzk89MPrOrzzwYzJYgNtmmNrcFMbJBcsmdnumMX2x3aeBA8VUy5gjN4go83g6d8Ok0XIXpdGrztRt3bXSCM5a4rLaZV6oj1NLFzeXL9iquhaZctYhRe59dJ/hlNCjaSfEYjEL8pn2fTR6eyH7nK/BCLquXrINZuAhnwjtLLvVgCcxfAxBc/ngp7FGXRDBdnUMPLBuk4X2Q/eL909O/wv/Mg6UepxSiDWURkHWZHnu3WASz9LI0JuglWCWbOJje+Qnr/R/Q6fDxDuyOfCZfBSZDE4cX2bz9XTr6FgefrzL378/5Yp2bXWoZLakLagyJjGERWwZthIKAk2Fx2mwl3+KE4Rc8UI4//w/QfbyKHocj71+yliPmQOQ2vOw/igcv7JyieQzM7ciamaK6gqyNxdr663Wwmg/xD+VRYUfx04EOKY3UdIeSxp8kRL0QItZVtQypy0ki1FaEPgTp6/mvwAkQ5LgXTSqNSKB6IVDqklXLlWFxSbzaihfEC6vEnyG7t5I0S3sSul4InWX1quWveslJFNuLonqLvAj/GgiioTWJYU/E0LB2dUJoX24SwW5E8N1vPOm2nShqvZBI9lAktTVsIprm5ScRbS2ihpur294qyxqTQPZDIOuvdNfl0L7YJH4did9ObnUmAeyBABpvqa2WwPq7oUkEXTYVdnBPJYncQW4yVNzHp282uN5ySSLmIGK7vJiLRO0QRa3u0iFN3Bpd7UUi10Dkur56hMTtkMXNfLmCRdgcri4hUXMQte5A1km4DlG4LNjSmlS5oLOTODmI064AZkm4DlG4qiE0NRlrAFBLouZSDLYHKD0Su4MsD3M4S6bXiTU95Uki6CCCu0cBIgE8RAF0ADbR5K8plBCJn4P4PSeKAQnmQR7naXjiWj/psw0uAomsUWQHgxcV/7zXG1i+OPxHECde1YODF2Btl8GDv0q9NJIoDXHyFy+MY+WL2TIMVsBbg0Hm+QjO08UTP3u9DP0EON56aF10MsjUOF9/5Omq/v4rFynrcXj1VJnS4J81g2nUwlCTXGhYc2WA20sqaksc52XYr3NraY4pHWlTIeBuPVQYdbcOXPWNdhA5lxku+mWl68MT7D9CtMZ5k8+6XFx4Bub+cjEQp3md5Efvk7V0FRbD61n7t8EMlFy0qmrbaOpj2aP7GWzFzHOBtRr5QTWcSMWwrkDlftZ0eB6mwzsvLF9aThrjvxwvoYxfNDuWibDmhzQP86HVumncHsc0VFtzSLOpPIpVN6kkgIEd3awsp5YOaX6uZ+nqpprm/UwPdjG7mqzxIMxhTdTlYFb9mj5NU4YRwvspgbAczUwrj08c7nTrjvk0XuBAdHj4K73t1E3B1EHN1uXUSO36wtPTJfQyjXk308VRztNY833As7ScQWi+nI9HOtNCpuKgXPbKivbaCAQco0dszpOsxzOxUmnqIU2tvjy6bnoL6GGKUKlgII9yglq14yFOzlZr6752/vFNTtbTHdKcrIWbdZN5PKbJaBnzQ5pTXQ1g3dTmsv10cXRzM+4OHFQ+ymnXvDbdhr3AUEU30/tjnahp6+iQZulUnFQ3SYQNP+zF7GSatbt4B7XV0rjSpXY7KcvS+Kv5tAcS3D0JXng//vTx3aW3YeDS19Nrbx0Hi/A3hjN9PZ0HC3+zTK+9JEJ8dgR8x0qFaLkM54HSCbv0wF89iZoWD2taEg/6nAWeL7oM5qz/MMG+b8L5PFh5N09KJ9Em5lD/M2+93NyGq2ScfStHcrktpevqJS5My8qLDaay2ECyxrh0Y8EXt41dfwkO0DRcFOtf4NPJZ4fWYTL11+tpKMDEvyhFLyU063AhNk0LeP3A7mJTWP24iDHP0dD/jljq7xDBvFyrszh746+wMYehfvJuIuACCUzMXnI+k39k4/diWJPkrFjFo9fq8LFN5NiBF3mv6rzY4pWm9Tf9045mxZFi+aRuxe+N5sSHOxHDhhmxHtUJFTZ5ShNTt1d2ML8CcCCfZmE8TadbnMxEmxxMX32hSgXrtleJIpa9px0QxwawyOlkHXFTmtmnPqkgC9DSMr4iWc07TwaqGrZ/dkJTE1qepKh5sM0Japn0xE4PRk7D0CqJqe/y1FBV22rZOXV14DMLlfVZbE3uElkmDqQrLYA2+sJCmLdjyuQ37InsguomdCtBbPNIG5PYMuGJlRRITsOwqqnId0HqyPip9NRu6ChAimyEfJRfb0lJMemJnR5lWvKhFdyS4o5E2UFRtwV24agU0GaEw1IcU2PXRZvSpDRJdGfU96oEMaT6S0Qp5dt3QJgyNggnjmF8TQlkmuLEOHEgVGkcZmKJ3LqVVK/L33dMKInqoJPJzz5vSSQ5tYlhugqBxPtV8siEdokqnwxfdESO7Bw+p8Nj/mej6WdDn+SzgMnK3tVZ6ung0my1nOwOJq2fj+Zz1wfWlAaliU3KcwWaaC8vxEjmJE05WjJlR3YRNhmP6oj4yTzWxpGUZcoTKzEwujKNSyWkOcNZoqMpzbgDMhqPJXIqmgfalIiW6U5sdAASmsZUyKu4ZA/LaZe6FN4uMjK1Z8tEssZlRo1zOU5kmjiSEzNBdbPRBiBTh/AO+at+HDObkcNhCuXU3iVIYNzs1rsrdt1h6da76uIV/YzKF8N50Oqm2gGZbFovvz768W1SeVTT5VhKIeGoUAgvuKy6fFacnzjXGJ2fwuN3b7JF1C+y00g+mekE1Y9JFskz85N06Ha+7UJ2oZ2FzNk8WDacM08l1k1Zu3TMecaMlRrNV2a+edNRN0QsnMTonoaFNFwdKc1X4vSNoqb6+u4Ja0t11tG49gYiIreZ3KYsaD2xK++YOVBS1xzZ3Tl19SxoMypbrxEhaktqm7KftUSuvAiib0qjqvZ+5wQXadKGFNex/4mdpZtWSKTWumtmOPfeuW2movXuaVvOxdbRtwLLmzhWo6pM3LrS1Hql/clTNMv91pGyDMZLNBQ01FPJdaS0ArH2TZdaKqd3EAwbc3q1UXE17ljv4rWqesjuaW7MWNeRvBp1sW8UrypB7p7g9Uns2iSiO+he35bCuTDYYV2SwEhImRi+SacP3/rL9Z3/7TjAbYiEjeDnIL4PE8wFvw1WITgTAlXthfddFDvlgMc6RqKW87Vm5LfIu5fhFMt4Pp2kxQvcOCxUuQJ5ihsVo3HwGyyfHkZU8iLnw2Jtt8pMJXg/9+Xh6Wp9dbT09C4WR2yKWCrjy1djlbB/drZyWQ1vVwuXlKv+O1i5QgJXX0CXe+KfZR2tODI7W85SPe1hL6stRT8uXYNck5I/gMV2wQ/a2bpX1lQfOg+Y9g3G29xF/0zrXwc2tMPVt1eA92nx9W2NcRe3oR8AM1ThEe2PKUzl6QfOHaZtmPEW928/Dy/UoRjtjgXshfS9WnixHTTe5urnQ1h6A+DRHtc+r/w/7MUv7laN21w2/DxRmxUlaXfRW/nwwmGvbXm3bNz2pttnWeNqNKWdrbPl/EU/1lru4Y3bXbD6rOtswl7awyorR0gOe42zXcVxwys9n2VVjYBNO1tO9WzMYa+ivq85bneh5LOsaRWo086W1nTU58ATqMZ9pvE21xk+T0q1Fitmd7lV+0GOw1574wbveItr9J5l5Wthona28PaDVYe97vX7zOOuLnN7Fo5ohiG1u81P1+Nez8QtNZd/XTFaeB/kZV51N4D91U8Cj12FFDD8K3YNWBC/SsJ54IX362VwH6xghEA3sIsL2X92WdgY+nhvuS6scMMSvkiOalheuLxD+ZAAi/r/2Xu37sZxLF3w3b+C5Xiw1aVSXWbWeXCPTpczLlkxnZkRYzszTp9YsWhagmxW0JSGpMKpys7/frABkAJJAIREUiKpnavKdkgkbvsC7A8fNrYCtLjwaPuwsKqflnPypwdv9pUuv7MqHC9JvNmT4zn/763zEPlzEOgDbLHQb5xoHcKVbhPnE6FWRPsQ0YFIRHk0UkueiPOQjRokIHverDaON4NQLma/2WDChYC0irRWOD4JF/fNqYGKwu4VQ3PvXJLJ48TxQ16+yFuWrj7jETdy959xNmRwgR+JSDgrHdS7Djfcvbjbh93sIaGT37yIORf4+xcv+mw+sCe39YuUVUxd2NYYLj5Gy29Up9IBAk2RB4ePKzUz2pGE+zB/mZrPxLnYFkTFEhI6jMmTx/TtgTjeQ0Dgz/mSFhT4IXEYOhaz06Pg72P6OdNoqRwvG1TpBkNhzRJhYVQYQUYKil2Xdn2bwE17RyN/R39Do3Du6UWNX9K6susfWXWstlr3QJbLdW3u6ONvLfyAUD8XzyJ/Rf2h+dU3b29f37z/ePfhRnElGPhMKQlcvF5RZzCaZN+PSvn/uKiXztMymDPrWzJFefbn84C8gG1SA3yhmuOFW/HLCQC5ItCaCSQOoy6bfXI5mUxGF6NtHr9X0jvfkZm3pgZ+4W6ruUiPP1N1CoKNs4r8b4DRJU/08/mSVvFMvFAqhBZAPc2zt4FmrZZx7D/Q17JQA14MH+Ox87BOeCGsfOeZzjdSKYH/ldDXHuncwyxkQ01iTUfiyftG1T4A3d44S+qwI5a3UHpTZLiTunA5upgUjiBvv6w84yss9cfsjTRn41bM5Rqr1xfeahX4Mza/uP78Sqvl19vn3s/ly6RgtjK+ecseyb3ErODZC+lMHqlezD0gLOxH/q9tKavAm7HJ0eUznqqg7JnJx/Sv1+xhaYH15IUhCUzNSRMqxm7h4Yn7mn9Qahy7S9Sd0VmOmEuUHmSX0cav4U+poOVXErp0AH0aG0dV9/EWV2L5t+PJHfz7F/FP6bw3YVfgut+8wJ97uZz7qvUmvzD3l+zhfHrcTfaumEUmb79lI86WjVqVvtKah3QhY+mtws294vtpXuXLuj7N/3NcKoXp9TT7S3WVr9CDae5f+QeLajotfpB/vKBh08K/8w9LyjOV/i48lNOBaf6f+UdLajAtfVJcKFN5T9lPeZFcWN8Xhbn1WNtIgU9NUlBhqeHqWGN7DbV9Otgvpbgk713zA7dve80WaWiCC9ld1zIFgdrEO+pCCMQkWSvpWtTC6z8QKoaIN0brU2gtiiu7M/SXeF9v0oXvZ+WnE5dv0d6KS5il7m3z/hn8jFjHTh5JcindxcxTrKT5EXXpUG54GKFJiHLxI3CSw8fiStehC2ifLWfvxSf3/y4tWreLV+qSNsu1SI/M1g88HIENhyVdUvA47T8uCmzqonyV45Zv7ivn7sObD5dPSbKKr/7850dawfphMls+/5mP2Z/m5Nufn5fh8s+0SzRc/fP/9be//Y/RlePN57DAWy2jhAWWM7pugsYu6TImkn2hlE15C4WEyxfeLS948TYx+LsN750IFaQCeCjAVx8xjyOE5Ezut8xJ5l6UfpVdyZ1dkD4p+V+hU/yOdiv1Gxe6+X7B2goLRWfuz8OLbXocT1gIN3pY38JCMk78IHAIjWnWq0zybCT+lE7oufeKFfKlppdcxBDY0nBoDvEuFAGaCtE9Gzsqp/zAydY6lf8xtpn5hNJx9eRTd3xZueYSD0rBgvpu4bKbKbgaCYnaLcV2ROIVVU5SteapyMFdTm2ezZzakgM/ThQOnF8QD6s0Pjpf1GVTBxYsqZ6TubteUaEkFRUl61VAwNuOdY89bOjQffmiqG90VZFtnq/BAV6KEvaPS453OZ+rpPFF8lbKYFGGzzJpTdM/xnyM+bpkrBiUafmjPY+GcNXmH6UK3iX9rUwoJEYMNXRnDd216K12fraUynHMoM5RDm4O8he9Moo83R9No0umoZLNsQzkrBH+KzcW5RNdtJqqU/poJ4e0kwppdN8y1FQlbhOF79Aa0Br6aA0NMLrEgkr1RL9WVmpWBy6xOrXEMgnpeDMK6yLb3OWLo9deEABOSlvGUyiXuSHAP7jQv3MxdmZLBreGyfQuWpMcUKV67zJfx0d2Jdwy+Kyv44sk/y0vy3UBY6s2S0s73CqbhI/LPI2JvoF59ZxMJvIYpARrfjX72V6uJQUFtSaRnnQXRJBp9saZYuRgp0dRoxVLLe1N8bae3K6C6Kpcw/n5ORDccvwUfj5HoNFbIsmEPqvP9VLeBWB95wD35UjaV5jwkl3YQgguR6X3IBeKorisyBVsGNLuMKxbWXKwXK4UBWeFZ8WkXVM8nP9kNGHCEfVIfuLveasBskKw9OYK6XJmRoVC+XM6ry8TEs42rgfsr0Kyc1vSYkEd8gXww3VX1sb0Gbz+l8I04y7Z/BBLlCu5IQCz8xkkVm9DSQ9cjowTNjj3K/YTmDng6aE90jtMx8s7VZm9n4ycdvOOXZDo/k1ubjpA8XfSoOvIls5UH0m0WEbPjhc65zJt8ryhiS0/C5UUYodpSTMllYsszEgKTZX0biz0ZyxLdsyGewo/dFvnfHHkvk9rT4LNVX5ZrF8gqY1FoQHy2kmp4OXHhZkZ2BTld56WLypdzujGk38sXwoZ2a7U0lYu4dRPeoK0zX5rnnliF1LRn/mRNS0Fm1kO2iwJ6y4L1WkAXXWXyiM8Vj6TmpJkFerC4L+vZDNV0AK3r06eva/EJb+u/AiyDcimRt+9HI21RVOZVRR9/cOn6/+6VZcw4hcOZyowNWonL4n7h536z1RvKqmjuT9ZgzSNNkpkrFg55z76O+iNP+NUf426uzp938lFSGOjpIVKQnq//Xus86D7z9yHsDAbRCSbCj7b96QIgghOWCqKHG1USRDbhSiWMb7EwRup7Bdx8T0nPs1VPLGafDGNvioc8UjdQz7U2qFJrxNTj07eQZZLmOQ5QxO9+8xN+tuicschbDtOJS2bCY/er6p7wIfpTJ2odXvUo/ZBj1IFr5yfY8KMSGq3IwYNzlOAp3fidUTEkRqqHIpCInbID6T2QECHYDVL5s5iGVDLSKlo7Nq1SeltULKyf6brJs1slx+SaeHfY8NLEVko2HvqN+B0WJLREj2R9BfOk9zLZ33udW/f8xfux869OKhH/yTJzGHanZ2Jm2hm9G0NCipi+h+vwvTAmh/OmzIq7lijh/w4obEab+4lnuERSXmmvmlu4IPzIQw22eGdFXiae5EGgwnynlFC08bH6jGSX9C0bERdjFNYnBhdUeHZSi+U0frSG4a5NZsWJV7iBsSLE3dZ4tLK/+m/2bJur9gw0cJ84IiSh/XjI+iqH86C9ZwZdUUhy8inb3gBX/A4l7S0RxJCTAbkT/aZH1aUwUmhMSOI3hexxXvn5c9Lx6sqI434wjiBKZ2W9M91nFS8dF8Q1v3E+MIijWKFq6KVXPxW8q+/XziXv9Fg6LJQ+Oj30fm4okH8SNoLTL2hOLXFDyDef3x74376cPOf73748Om+opQHcbzMCzfOClxqOprgIunEFMYVBcRP5TNgDwQOiHnAFp6B/1kuqlqx4S48EmuCsmTNo22yAHk0tIUYQgjtwlk+96H/lkfwO+1eGib8Mlv+XbR8Zps/l3nvUFzWVwCqlaiaHiyovf5uCuRuGxfTICOV+Nj2OBOZfXX547TCgD4OW32KYES9TM9BoIMUai1I9Ojir998ew2pA7cqdQn1pH9uoq4SKGA/jfArJ0KBXiu/kxHtM90cSUcXGsnAbg2ClY3LdPtnJZ4loVao2K0ptpAe1+9qlW5Bl9ehHMsz3H8n3NYET9LSLocOcxfG7+go9pnFaljnv7Ij2nQUiSAl7r2VUYkvHM4vir9ruceyrUzz/zSUvlr6YZY+arL9SKWa1tsKfzdlhJAA1mdv80Ag47S7WIf8OorkBVCrZJnKm6TSNs4AVtpRfkblufq864Hzk+38VDYZ3VNbk6iS7evsyRbmQpvNphzN4LN5oFWbTLih1eKGVgqB75Diho/+99Fq9qN4OZ8TqTCekvhl2Fk9lPnnJ2nrql+U+0JboypE2Tq5Bn3pUsmqzawnhryq3Yj4bvIP/lutGYUcDem8Z0qVs/8GEMc/oR61LbLvJq/ZX+/fGJZh+zdas8BMV8pycdJnxk2XmCTO/fbhFNBlOy66nawc9HvPBoZlNhQbgkTzXrp/AzrDMmXO/xyRGWBiZM4MUfPeFvGOZ0tmZRWjkD4/rdzfnZRf0r5S2MrNDQJfjWu3hapXXzlr+eM0NY0JXTo9Uo/hpt+pzKi4oVXhktZrqqY///z+zZemd15rbUU3ZablrVKWPSWcg3GxJI+5rI/RpHInda/3043WMjCk3Gfds418Gzb9o4Gt2PIm6q4tU++x6mYtL9xcJp//8kUNAaRW8P7NW/rd3dufXv+X+59v/8v9x9vrN29v2G5nAslA0wEY6Sc5vtj4xQvWVUsNvjn4ZslmTnCPF7/t2rLfL7bGTJcdEUSx5/qtLe3oGLafLabzP04rdo0vd+0X3PZb3go1wB6arjE/oyTprGOSLjz1bReNnFauGiaLaPlccKCZruhb3TDJZmwSFXiZi5xJXVTudHLrNK3YeRBigj2YmfIK0/f0KvXKYdEQqOTLdhOZ7SivOC2cZdil6pn6vT/oyzLUAjmUl7wg94EsIJl2xri5kK65hJyql6OLdG/cUKK/EKt9+gqYD7gMz5GKSnk8LH837d3FN1NxadflXpNccckTz8A1X0KGLr7zvzwzbu8vqYrl27TyosSf+St4+9J79PxwBGUCCcKiSAG6FVp2EWcnLPVb9ds5381Wa1VexJ5yx4N4OnCuoh5zJVssJNVW4+OjXT1S0eFK/bdyuhJnKCDS8YVtOZN09EfOH6bOX4wlpY9uPU8xbdhLROMFIi4t/w4OFLOp7XJkVe7ko0fnJCAm3CaAZJvbW1Ukg212Q0S2HVv5s68BmcCueJydOJ58g84YRCXEtUWFWO7u9NQyJNhPxSZjS5UaxhcJ4M8t1gjbtQI7BD0XQ8ES40GLLn6T2zNxRW5uujaAWcm5EN7eObesRXSIFk9+XZEZULKkekQfpWr+nTtogD0gLesjfd6uqnPwHBdQ6AUP6aAIXqfjLeAaQVowuGQexFHfxev+j+riK0QqPBcvTv8ox4v1i4iC2ylOHGf2TsZM8VJWzjPbzv145SVUPyNzERakxdyMLfWlyhntNEYvhfszmxie/BDl6NO7vLj32MrEyDcsg6VYwIgpFNYawKr76oeMY5gm2+VuH1YKhfzw+kr4sMQ8W+kLOBEgsTKiHAj1ni5ZORq6oOVNKgvM0v5WqEQGoG+1Yir9bX6R6VOBkzbOZ4oW14aOriyGgHp93wtom9nCg/Nft8n2IaE+zK9J6osmNhrAC5xnbNp8YydZlXdLMY9Z+bc5TFTPfujHdJFlCNB3cFzpLsa2qbuR//Qza8YhFhbKU2ZIdVmUJNpyt+QtkV4eOzs3q4Vpd++pl0+MN/vNu7Sl5zvUsuPca1/0+dyfsyk2S+cLGMtsGUUw4fJ5+D/sirPRUupCd9m0KCbJofqYInHwXXWFYlUMD8tL6bFjJ+LzW05fFomdOYuZl8auzBEXHNPPMhT7/rwJc+ZxYQoALM7T42e/Qd0T6dvf84jYuZUJ5Q8FMWK9bZihauAfpw4/h50jxfCCL86dPyrq+6NzflE9UCQoNNYahtqtqUC9gXYW8CWozkJWysWCoCOAX3FLFwNQHU+ijZ0KBstHuNuA/xpbvSJDZNndCLZrpsKITaW/7V4uUyOm5Y/sijLeSKZ9SaKiaHbR9zRKAREBtMJOCUnZ28VdP9aLtbFYnfDSRHiTsHWaAV2RgRu2eEwzu895sqo/2PpDQAmyLQ326ggw8L9Uj4GQnxKVVKdFt1NzvrKwn3hfOR/ZQS2+wPUX0rrvyYthUMVS7w/WRRaOT3FWQX4R+IemVoF1VoPVMFPZj5r2By33eXVwznRHkMi61QyGmeaRmvn6eRWnq6umemNh+gJmVIQncLvEKqBivBSmYbW4ViINwEHLJQ3hGSKqc2UIJkchUsofBMphY7lsaZNcehD9uf1pFR2SUzw15E41B1V9DmusS2eSjtD26NepDdH7u7c313fvP/w0rkj5cq047H1+fv4PEsA5Pv4QoAwrdtchLGIfSALwGttbYl/dMw2/5zAcm6lKN3/6kQQ28MOy8OI2Rrtn6Q6OnHFmpzwwHc3hkiMz11bYnZR2q7h6QKhKd3UEc20qvvzo49mMetzX5s8DDUkFu3GsSXfgKwtqOE6vSZ5QmCVFps7CJaa7zXl8CtlzttvzfpqU7u89zOj/6cztzRLpVIBE1uev6S5ws/IBaqKC5V3glXeiFG8At7xERbrXjqGSPy2T9+nd1mTO8EnroWX/3Hlk2Vt1BrbeJevm0/A7jKt4vvlhVVwkYz+68ssd1N78tSXWY6267aTJIb/bbizVGn1NOXUEIRV5OtLY3C1fp5dril7vIQtFKcfzO1Y3ZLBxr3iyvZF++ys/+dbMiBdKw5E33YT0jiSzp90HXFFIB2dWVTPL7uZ4g5+7hmrv0f9UoJkces7tpJp/T5JPT8uAsEbvvlSU3+7iklFu365LRxr+NzjQ7zw/+OQnT29/nREWGO482KUS0GMrR/ia09r2Hl/xPo5ubnRTcGDnYU1frOV4dXDdHmOmNfq0kjZWzOrb4+wHsfB+BwPHQguPunww3VC2Q6SuKqWLIbv6Giz7aNF0jVaTYgGvWFsqqkI6uPJQNXMHmahfb14kWTB4Hc6bsZrKEruKtVQ2fBdIt7qsHWR5dsb3a0XXbmksE5AEECyOvF8qwPyROPb6d+pvVyRKNmfp1gAbp+LOgO2uwOWZdifgrCb0/8q5YwlqH7zZ1xcvmscOUCu8xH8IiDNfR1nibhJ6z/APTp5iKcGzROCv0iN1PNntRV5XL8ZZpoCQvNDy5zyRuHh1viSMOuSnEmCUcapnfkgFD0XCblLWWsbtZ9XTx/IVCXpo2lI/hsYK7v3WVo69hZF+r9uxKH5fVNlXzputWJ79R5GOgDOdP3rxzAteU026gJG7iENIXzZj/y7keXrlpOMUOh839Ksw06x4zEn8QcAqyZXyjX4t50xgiYLpuHqQBAsEDTIG4iJkWqEFsNOdQNnj5Ga4l+ER5Cd6IJXDFUOvjcCOiOHkJCSPYTR0OE1evkvmlQMJJiJ/TjhbMDcoovnOn0B9WAPTh7c6mVNpqIc9x49iZqpY2ptl+3KzgnJpNzPjvHZIGjIum/QhTZT8CrQ4yN9fw0631jZr2drsszg3ZoDN7hCengM+8k5n+r1mY7PwNXrfHnnfx7xmnajzbcJGH/tso61wDU7PT3eDM5F9b9yUVz+FzrtHzjsmVG/K+nbyK2jNuHRoId2EabZNVjo9991N0lX6vaZ1evXRvoBOvkdOXkpV4aLDVzt8izEaqOm2y5E8xSmgU1zPrT4ommVSH+Xj6Pd75fc3cCfELJWiOuMngjV7mXn14A7L0g9D8D716aIzRHW1dhSaZ6tUpddwGunzNEKEOHE+aXM+0Y/ysH1Bq+dZTnB+6dS5nEwnrI7hmJ/GSaRPkwgVoRtQGboik6C7yGsiTh37Tx1VYzskK2/3xN3Jzw/HPjmoUQZeqLXupI/jFNHrKUKVLf20dykqh6hDO9TtmHA754BPkBDajfPMGavMfHxZ8xj69z4RRUnivoDweEJZXPo3QRnVjWm/7bi9HASn5+g7lEsh/b7UJL2iKB5Fp98jp7+g8nPhGgKXlPUPHf/eVm0c12HYdVtpUk53Cjh6upei9EWDqtUkexCdfy+dv1fUPHT9Dbh+b3j23Hj2plPx9n9niTOUiUrKaalmQdx0VqpUwtvUUjod0CefQmfeTWdO1WXyUlIirQsfkL82WNVLX6yqrZRup7eO7kxquvT7ykx02gfR9fZoHT1PpecuCop38jui+qHp0E5oc2babsLIE0y30K3El9vvrZLyVTyOPr5PmRhAhlSlhBDd56IuYk6GqhHqUnaGVgy41by0p+f8u5VfN/3eLp2u+Wn0/D3y/HAtJDr+Viy8amiHZOOHy5B9gumLO57pO8ueunti7x1exVmlT0mRs4Ok9D0Xo4vKnMm7jdcJmbkpXf8+2e9P6mbbV86nyFtxx8O8GHdCc/KNBHBbwUWc6jt1fp5zH6+88D7TcV92A3RuAksgc2fNbqH3k9hZrINg86f/f+0F/sKn3wj3CV5v6xyAK6AYQyiMljOBKhVXH8OQuVDQdHGuku3lxW9CChP+rD///WJ0rri+npafFvSbvhlZJ9jlz+wFfnXD72JwL1WFBzCQU32pdzBiP8BDk9c/3959+PHtTbmQFRs1N16RGW3BbHoXrSVtKdwqDa2DRSVTDWea6lhOY97RKfAj3P5zKZ4bGS6mzqvO3ZK/WGqk5NtfKxLeW93irXDmym4p7twu3Hi9T9b107l4Ga2+AavnOtJpo5fVpdLmuZLQl1X3zFOr/r6cSL1Rox5XWrXeR+X0PHVR3COlHRvVSfR94reGo79owF/kFKfTbkOhQzutGFTKZLNuUJtWh1cP5vTSeNk9OpGmnYhOkTrtT8zJgXdyLRVpg228TKUtdtrh6FMZ59xNp3L8tnCLMjqTRpyJSk067kr0yWNrRzgVZtOpiMeYFrduBGSTClfrbjqTIxbdTh/cTlFdeuR+1ClGG3ZDWnPqsDvSJFGt7Zb0iVNlb9SpjKLaYMku+SB6poN6JpXqdNsh6bWovh8yGlK33I8hN2fDXieXj1Pvdo6dqBIXP71wMUJN+uRjcokSdwNvTCkUraAbs411eZ9ZkdRR3m/uRrZD/T6yOW1a1UkE9CHN7jzntKXbO9AKxam/E622lm7tSKsyCNZdiuiyBkqepEPp9HAJ0k33UVaRTrsQXda22m7EYCqdciXaXHRNuZN8/jmFMzl6YjZ0Jd12JamC9MKR5LOANeZGrlU55DrnRAqZzeq6kEI2M8l3lLN67QGBVCYgsncM2hjFlO8LXURtF5HpQad9QyGB1U6wRlGBbJCMT8p0ZVbeYkeXUDOLlmTRnUkvpTXlykQ2uDo4pOkXFabTHkCtOzs5Ak16JBt/oLWtDmOapjPYMmG+WzmM9OxVu5OKu76Pi4o2uPRKneo2qd6gXrux6016ZkWzNxtkhz2OIT2Q5HC6lTdH6y/skmzs+Dp6mxa8jVKhOu1sDLpVG+8wm1enQA+TjdRFPmyz0cjpBDqepkWfPWD3hA51ykIn1kaSgkrl63b+AksV3C21ga0uWmU9sLfuYyyxzs5YrvjtGU2eDOhS/Ps7LybpZ1Qi7HVX+A0hftHSb17EvB/8/YsXfc5qEo/RhoFmfGBbVV7wOed1vrCnv1C5GgvdDtUFHfhvLEORN5vRcQTjZ81iWY6IN3tiPmHs+BMyGYNfiIjz7G1Ycp5tKc/rIPFXAWEp10gUO+RXKh2RnyekcopImAT0rXXCC332H58S58n7livGc+b+YkHgYepmoBn3F1vxiORO05+WoRBaNp1ch9Q30RfCGXGWC+G+Iqobc4eLJesNK5X7HTd9Jb6i9c6Sz1S/xkUBwlj+9juvh80y6UvM8MdO6leu6F+RZGtZ2fK5X17kZFtx6XH6dPYlXJl5mZa/1Th/sX2a+lsYjbyJS2Uxy3FdNgauezlSPjdxn/35PCAvXrR9Z/tRuUuf00Z9kZpbTEaVfc5vUlhFMJUkm2wg+Y2VzHvmc6GCTeSnVtUQcjnCCOVGhj+vHBaeyOhmHULaLpbBqOwxzoXWOWlzoahlSDU3ItRXe2HCZio+D6aNuRfT47lm4SQGhJUsRoO3PiZJIvKF5UdkDMnLXNWyYjSsoeFNfb1cbWBiucx6Pdovt9QJpiZsK4VWOeuYJidW8XtME9inNIGKVFJDv9RHSvrXeeNp4Lp7KQPXCV5z31KisfLF1+rMYYWv0Tf26cL6ckKu03GNj902nAYuwiknFDrB+2/aTbZWvhfDmPhI/RT6zD5dY0OoZqhT/pyO79QMQrfNqr5HNWdrOz3neuCkdCWtMKcFUyhIRfIvdMG9cMHJVoouumNqhxYD0ltLbMJr61PenaLPPkxmP4WK6BOvKRXEkJ4MHXVPHPXGTZiqiJtHZqoUVKfkp6vGo2/m17R3VmcKPHUv3X5CxAp1Ueepq1QbTRY39N799N5EiBPduPXA9N1AG/Dv+pSLJ+jWD5NZsqwsVqkizU+j7+6T76YidAMqQzfiQnQX5eSLJ+Sxq4ajX6bXuFfOpaQ8ebfcWubNKuXIJUas1o58+kP0zD31zC+KJJSn7Jpfem5+DTDaFLk+T5DZ1nJK0zJRx5yjVPMYet8+Md5I4r6A8DgJ/2S5b7ph6Lpx1fetugyop+dfD5HotaQGulycClXQZq1EX9sLX7ug8nPhwJRL1PlRT8ffGoeiL8bWnO/Np4s9Xc/bXlZcrSrkU5caFKGQ5hN9bs98rqdKJnuKHtfro5HV97WFvLqn4mT/zhIBSK5mqxLljKmzIG46nXAq4UI6WIUOmLIGo4ftooel6jJ5UabdHbpfNVjVS1+sqr5LVec3Pr3la/tpnEuCr8zLrH0QnWuPlq/zVHruQpHF+HRWr/px6IOJNXB02ZAO8QTPMB8o/3X51KVdpsaKx9ED9+l4M8iQKo0Qovusyjx4Qgedq4ajb8ZX3zcbUmifnms+UKbwknLYpf42P41+uUd+GbKOoltOza5qNPplePV9sm0q8RPMHnmsjOnlBHm7p0Df4VV05n3KSZmdHKPvubjkzqes3G1wBmW1hplgr2zB8tURbWUCrX01hCZxaOULikseiBM/LdfBnKdd90I+AD5VVC/+yow0eVrHaW+dFYnKNvTKCUhywR5a+NEzMwhaTrx+ZrwYcGTCMcXrqOQP7t1cEur7rRugRZAoMeayTt/K3tE8HKdZ07dpppNok0943diVFzWvvVCma88yzBfvrsinb9/ryoxmr82oeXVG2lG4PoMboK6SRu7JqL4rQ3FfhunODNk2FRdjlMop3I6Rs1TtFRjbazCyfP2vFVmbre+8sLgBqHzDRf6ThR9SoymYlMEawWpHe2Usllx0W6l863poTQLTqufRP6N/7pF/5tbXK/csG+bu3jlnprs45+/LaaOH45sViT3lq2jbTSdc+wZaY+49y9fQb6Pf7pHfzplkr9y3wlp39+Iq293Fmas92rB8ujlvs+TeD5zQGN09unt097u5e52J9srzm9Ml7z4JVGRT3mU+qHSBQ5sa9MmhcxPDYbImW84Ij8vlY0AmK5Dqw3oxIdSpbphvfwt/SZNAxZPo9tHt98TtqwywZ05fn4F5H5dvSNC8m8M3urYhu3t1tmmt228/DTO6f3T/6P4r3X/REHs8DagTN9edDjR5nfefFrSub2DTgz5ZtTwrHCaLc110yC7zLM4QOEMMYoZQGWW/Jga9ve4xHxgSSe80DRh93aC9fy4ptt79t5YtGoMBdPXo6qtdvTDAPvv6XObp2s4+n5i6hrf/pMhMPiAWpiLLtszGbDn9dG1WpjmhrpmdSSL09ujt+8HLzNlhv/iZChPdg6epSom9E19T7cmG5c11eb0lj36IhNe4aEc3jm5c4cbLxtcrV65Lpb27O9dm2t7FpRtc2TDdej5luMKpt5dLG106unR06QaXnppeLx16Plf3/u68kMp7H2d+rUrZPhxXXshILvnwcmbuPUD0yiTC9g5ai52YcnY35JxqOKZ9nNJeDqk5Z9SMI8r0R1VFI97H7HkKXkfjcQrJq6tcTd7NFDVP618KvuWTMl+5lUOpcCZ5RzKqmUVb8gbtp5euC71WpsrFFR6u8IawwiuaYq9WeGor3X2Fp8l3vcsKT+vSBnZ23pB6UD5Ef6B81rWPV9rl+9r1fTxwiXNAn87XK621XwftDYa8x4l7k1nvdPTe7AeHNTcY0oZLU8OB8mnXnRnssgDv+DrOCzgv9GheUJpqr6YFgxXvPiuYbHqXScHsAYc1J9imLZfT2B4rn3ftNLe7JxKuUxZOJjiZ9Ck5bqVZ9ytvrqWx75FS19b0d8q2a+9UezIBnZ29MvznvA58ElIjNT109sq5g7sTPOoCMsfwpwXTKoe+HW1WSx8KgRsHvHDj3DDlYx2e0H9QxfTChGXPXyZPtLSZqBQ8bXaHgnP58rSkboNdcEGfpf2d89z8/uNTkj3nPHj0ESg6HlNn6byQIKBF0r+Wi4RQv0tYAn5RA33/mfqSbyQeTehIONdJ4s2ewOWTX1eBP4Oq/PSKhH/REYOaz0OPCvzcuZ/TsYRv7p3lA2T/iSfOterbNL0/n05oNVlxE+d2TesTrztexJrug6vdUK2joltRraZOkbY/IvTvmITsBoFgSZ9h5YydhzVcFgDz1QNh8w0dpDmtBYY7LTn38s93rydUZNQZP5EAZq/FOmRzuTP3Y+/5wX9c07bHMEelw0Cb47GxSW9EYA2QuwIjUx4RPg/wWxO8AG6j2WSzan6I+XC8X7DSSwWdsbkjLQG+gef/RM0zIux2jTiBSyVo77/B9MhVZLmOnNk6TpbPzv0bWuAdfQ3oA/D7f8O0ylXwDNZLJIR52H3yYjctndvyv3FThDtUsiURyIh6zA9sKveCz+LjtNHZH85/O8Wv4MecBIn3hTpBsMHxGVvCmEsW7pqVoOqJsSLuEvwFHcFsxoTujB1duyX/LZyqZTsmcG1KVgyrhXsoUQx8cHYmvJJ7O3si83VA7qgUfvEiOiD5URCfX15oXrgYOxfZdcbE+3pDFiQi4KezJw2PcCw8e3CUNev9nDyvltRZUK3fuYmGlxtubtben6lVB6+py/AeeF3VrSy9AuWxq6uzKYO+R1ewy5CrQjqDXClKvg586p6mpTfTd84KRV+JOzp2KTMriq2ksrbt0Br+6tvFAtySxYvf0XkkmzfFa7yM6zVd/0T+v6xavn1YdJpHR/r3qo4j8WJy9w3sVVyuhFyZIiKqUygvgjdVzr29f8flhubT5tcoUm6mIr3gXkUrylGUX6PtqoJ4F8zJEhvtTUUexcY7pk8IZqqqgl1iKru6H1WFK0pX57BptgeajDb1e6JPurCXtA3lGeprpzO5U8W1xWE6Y1y75aqTcvu5QEVBqhrqetl0xtKdC6k73NpTIrWHWk18bqq9BRp07dYWSJN1m1ki8O6jAMVCeEvVdKO9KlAXpa6loXE2oVT7TXuGAk011plpTSXybhq2fPaq0lCeob4afTQVKNbQltjjfithy8JtW1JnUW5bOh8Wl4OEW/jZdbcBpYwbA8bGt3egGT8BUK1Eyc8FVsuDQB4i3Hnx1y3IcH5+fpMCVDHcQSqi3DnfcYn4TMoALfmOUw5mwi2QfAOFb7LQ/4XLhJYyW1LzT/yQOA9k5gFy+EI4xBZtaHHbTY8lx402DHqKybNHo+NZnBZJeCMk+Cltz+Uyko4HBIETL2Fnh4wmcs+2QPXf2QgU7o3ltzQnkU+KucNnQTxWXW1q3JkTy74tICQ9RMTScFJYI+Zr+bf8P6Hzrj/fVvqQuN/+6gWrJ++vE/gy5ss5+tf7uZbpL/Af2qV0s2acljwVvyVEn21gun7oJ66bH5P8VmXvBgWQPgD9iptsb8iKhHPQKapA/H5f3mIwMgf2f+BGXcBz1wn700tBdG8FICq7s3hUKPQFMPkNvAW/wCa+hssXVrz0lvP+DYNd6dMcpmUP+SAfAOvyRTJstjBQk0dqhS/e5l5cUAwm/wxW5yf5/btXhcL4ndU+7/BincA+KG0F+XXFbjZeOvF6taKLJGcWLeP4T3KbASCPx/TdQpHCFp/82ZMzYxsB8mYlGwcJ0V6BP4J9y7AwIMpSn0hU2JDku5DSq7JKmJHcrQO93r7+fp6Cwvl9zxxym5lPtb4rtuFUTaZ1pruY+S8UnZ09eWFIApf6SDpxRNKrhW8U7wqjgamK/yV5RrrmogISa8/UBYjHLuF1GSQ3WpvS7+QaUPQzbI+POppiNUKC35OQRB6dNz8zuJ6D9tu7i3OI15d87dT7X0PhfOuLTSN858qPn9juFm9ezPbBI1HGBOaM3B5kRupgDaVFsZ5c7nf5b+Y3ubyyzfSC/IBKkH2WLPmaQL27abc0yIlgsl1ijMa7F3pDFsryIrIYqc5fKc7lrR+kRY1Sn9zHaDVjShXf0scvxWAoSiuxJrIxBsqEau0E9ceTn0Mv2tywuX8OYLxh85h+O+WKB+wQ6Z17+h31h0zYW5oG1ScYHm15UL/LFyJT+HvyiWqWfmuaP8nJC+fw6Ln+WbGBPTXbKhQiVsCX6TogJ9GRsTXe3Es8BZfhibFY48k/+G/9gG7pHVRnpg0qW85wc950qvK9+gJGE2p1oIJu2t9LQ3UeRxNYu/PdmdDuTMTXk9tNnJBnAT3oOAbKj3Oux019FVVuzpAArSu9Rxgk41g2B/a4CVzhrrYlOguybyezJV3/TDOrYlYKglrHr+k3k58+3LnvPvz805srvYqyy+Mtm2XWIZWWs2ZyNf85hNVUeMfctV7UDmyb8on/TNvg8vAGKr/ObZCLx6Xy4kNasSrhyIebIh+uF3Lc4zrcKJckmVBiXj595p0XxJrm+wuN+kxKDZ18grXbh5AsF5fnpW/PRyD47PNzg4iLr9IWWrch/URZun7UCwMCtKh22sd+qoda0APLxYuoWCtJ3YuTbAIfOX+gY39+ZtQ4+83By5FWWfQmB13IhpgvoMztzUbxzdvb1zfvP959uJkA4ZDNZWr/1wW/8T785gX+/Dp6XD+TMLmsmGieOY4zNT60OGcLUMaG/Pnn92+clIS4XtM5DT65fNhQ4eXnYTZns0dGvzvnFRU8eYDeZLqwXPD49eI3k5h+v6go9xwITjwqZOwjVqSlll38e1XhAAhtlmtmfSIA9/hSfbkQoXgUQUDKF0H/YVj6VPpxFsm5hkmuOJVveQSiX0K5zrRvv3Lehyk28D+nzl8m//dfJn+Tw2raI24+wLcDIOFewN7befRev3D0FwqTex9f5ucRWLXErCgBN8OfkgkabCxdmK3j7cLZVKphWlX6WTolr7zZ10teUMXLzN5leXB+E383K8JKFv9PJgqxJwL4YrR8AZWbk1lA1XDOBRNTsQBFbu6slsso2Py7ofwMtPH8ZxAoeV4HjDeeiFJ82mPaijmsOAVImgd6ZDy1XD7VuZgahABe+VBMurOuMvlFjVzM07dWXdIvRoZXc+zjnBeS2ctpOSqYohDfT7bYxEim/exJYsq9XIbkU7no5SeeGKUELkaoEouXfFN+Duny8vOZNqDPFfs9NWxWzNjyBW5ShVe+bNv049u7f3x44368+XD34buf37lvb24+3Lh3//Xx7e2VE/hx8hlsWbf2FZPpRGyOfIEF8GdVNQ2WnzcGQ/udP9oO6s3H13u9ePP2uw80hJJePVOYVBpWvM0vRfl5pY+iqx3SjazdAsrIpCH6oWi41FkIOa80AadcNBOoNtaKk+jLfnscopG7j1Nh28KmhRktubBDsWSL75iIXTa6Pl0TxucFBJjvL7ITPaGzjOYElheFEtjsIHjn9H/LMNgAnX/Oee7s8EK5vEIZbH0l+sw3ASblgeLgTbGTt4A2hTPCbVMhb4UhVhrjDgaYPyCj3SiL1yu4omGSqUZhpuCLcyHINDRXPJEGlTxWVJWgsIPs+TMbKJaHjOwfAiDLlzaWxVHoBgdxeFsepYUdfO6yRRZ7V1luoSi6JGWliVNv5cn9lfPjOk74YlesxtKzS7A5lq2+xEE2Pu+X8XLeYg3qdP0d/fTtG5UkxIvwyyzK/L9ptwofbCN4topJrblqF2U7kGzDgDtlwy5JQQPUhRZEsi1eYViGupRaWFU3jGRps6YgEEOdeUGoqxBDq9sSyjlMY/eKEtIxAOS4Avb9RQx0ZRMCFTxIasm0GL5k5vZULmFOEs8PYnXWwnVcXlpDiSo/OD4zLLwl/eZhoKTgAQkv85+OnP/p/IWrd9mzpRCwbApXumOAQDUQbiiFR8TvnF+a6jpV6Ib1qHLtVIWGAmIr43GKffHLBxI+ja4cL4gZOwU2/SPnkSRJegCLwQOAYsVMeQpl3IthFTK+Z2CZH86C9ZwXAKdzQ+deDMk9BI/P3ldSKGZOHtaPj+wcnxf7NIY4O9tpqEe2qs/mAJha4Dd3KcwMch/ll2Aw2V77yxux8NETTpR6LMuwXHfuX4ogM+1m7rl0sItRqc0g+GCOfB6Su1/otjmSYI6KTm+BciQkfF46jYM8LORhIQ8LeVjIw0IeVq95WLkTfR2iYeXPKiILC1lYyMJCFhaysJCFhSwsZGEdgYWVW5AgCQtJWG2QsHJKNhwOFvuNFCykYCEFq/sUrJwPaoSBVQTPkTGFjClkTCFjChlTyJhCxhQyppAxhYwpZEwhYwoZU8NkTMkJSpE4hcQpJE4hcQqJU0ic6jVxSpV1u0P8KWV2caRRIY0KaVRIo0IaFdKokEaFNKoj0KhU6xJkUyGbqg02lUrXhkOqknuH3CrkViG3qvvcKpVHaizJlVz4nqmuFEXogHwkcSGJC0lcSOJCEheSuJDEhSQuJHEhiQtJXEjiQhLXMElcmpurkc+FfC7kcyGfC/lcyOfqNZ9LM78htQupXUjtQmoXUruQ2oXULqR2IbULqV1I7UJqV6vULk0sgiwvZHkhy6v7LK8KKKHpnFpmb4EELSRoIUELCVpI0EKCFhK0kKCFBC0kaCFBCwlaSNAaHEFrc7d8na61BHMA6VlIz0J6FtKzkJ6F9Kye07MUs9vxyFli2ySduifkeZXwLfW38BfSsZCOhXQspGMhHQvpWEjHQjpWi3SsipUIErCQgFWDgFWhXUOiXCniCyRcIeEKCVd9IFwZwIHm6VZ6T4FkKyRbIdkKyVZItkKyFZKtkGyFZCskWyHZCslWSLYaNNmqwNRA0hWSrpB0haQrJF0h6WpApKuCaSD5CslXSL5C8hWSr5B8heQrJF8h+QrJV0i+QvJVbfJVIc5AEhaSsJCE1TcSlgYsaJeMpfYcSMpCUhaSspCUhaQsJGUhKQtJWUjKQlIWkrKQlIWkrKGRskic/LAMH284hekdSWZPyMVCLhZysZCLhVws5GL1m4ulmNyQgoUULKRgIQULKVhIwUIKFlKwkIKFFCykYCEFax8KliK8QOYVMq+QedUD5pUBGmiccKX3E8izQp4V8qyQZ4U8K+RZIc8KeVbIs0KeFfKskGeFPKth86w+RT4EoUi0QqIVEq2QaIVEKyRaDYhoxWc3ZFoh0wqZVsi0QqYVMq2QaYVMK2RaIdMKmVbItKrPtOLxBVKtkGqFVKveUa3y4EAjXCt4TlnL28WCGnqJnQB+9zrwvXjrYr7zYnJLom/+TOduRFmVoD4yu5DZhcwuZHYhswuZXcjsQmYXMruQ2YXMLmR2IbNrmMyu70ny6WkZEL7Di4wuZHQhowsZXcjoQkZXnxlduVnteEyuhMRU7gIWeORtY4Mi2olULqRyIZULqVxI5UIqF1K5kMrVIpWraimCXC7kctXgclWp13DIXLnQAklcSOJCElf3SVxKPKDpRFkqz4A8KuRRIY8KeVTIo0IeFfKokEeFPCrkUSGPCnlUyKMaGI/qHW3rJz95est2V6g/Qy4VcqmQS4VcKuRSIZeq11yq0syGmbGQToV0KqRTIZ0K6VRIp0I6FWbGwsxYyKbCzFh7kKlKsQUSqpBQhYSq7hOqtKBA06QqnYdAYhUSq5BYhcQqJFYhsQqJVUisQmIVEquQWIXEKiRWDZRYJaI6pFUhrQppVUirQloV0qoGQasS8xqSqpBUhaQqJFUhqQpJVUiqQlIVkqqQVIWkKiRV1SBVCbVCShVSqpBS1R9KVQEQaItQlfcOdnSqPH/GmjejTQ7ISoDG/AI0DSVJyroSqU3jITK6dhhIJIG1SALbWZmROWbNHJP9yn8jjwx5ZMgjQx4Z8siQR4Y8MuSRIY8MeWQWPLJst0eF38ImQD5XfX7VfqG1rxImr+OrfRJgDRLVkKiGRDUkqiFRDYlqvSaqpRNaB69RLDYNuWrIVUOuGnLVkKuGXDXkqiFXrUWumvWaBFlryFpr42LFop4Nh7+W9gyJa0hcQ+Ja94lrRU/UNGOt4A+QqoZUNaSqIVUNqWpIVUOqGlLVkKqGVDWkqiFVDalqSFVDqtouVLU3XvhIouU6fueTYB4jYw0Za8hYQ8YaMtaQsdZrxlphXsPUakhXQ7oa0tWQroZ0NaSrIV0NU6thajUkqWFqtT2oaYXIAhlqyFBDhlr3GWoaQKARoho8Vyj/7WJBjbvEcwAvex34Xrx1KN95Mbkl0Td/VnYuohQDYI9XYeJVmHgVJl6Fibww5IUhLwx5YcgLQ14Y8sKQF4a8sGFehXmbLCNyQ2brKPa/EVEGsraQtYWsLWRtIWsLWVu9Zm0pZ7cOJh0zthMpXUjpQkoXUrqQ0oWULqR0IaWrRUrXfgsUZHoh06uNdGRGpRsOAUzZTaSBIQ0MaWDdp4EZfVRjZDBlLXtSwkxlVe4MID0M6WFID0N6GNLDkB6G9DCkhyE9DOlhSA9DehjSw4ZJD7sh3hzZYcgOQ3YYssOQHYbssEGxw1STWwfJYaZmIjcMuWHIDUNuGHLDkBuG3DDkhh2DG2ZanyA1DKlhbVDDTDo3HGaYqpdIDENiGBLDuk8MM3mopm+zNPgJZGohUwuZWsjUQqYWMrWQqYVMLWRqIVMLmVrI1EKm1sCYWq/TZdZ1OMekXkjbQtoW0raQtoW0reHRtipnug5yuKzbjIQuJHQhoQsJXUjoQkIXErqQ0HUMQpf1YgXZXcjuaoPdZa2Aw6F6VXYZeV/I+0LeV/d5X9a+q2kSmK0HQUYYMsKQEYaMMGSEISMMGWHICENGGDLCkBGGjDBkhA2CESZFhJ+I9/WGLEgEy6JLxRa7P/ssolb3VvC/ANP7xYu+QAyYLXxTchizqCummdoX91sAv3I+wcowzwlJZ/wx7SLtRQw67PHdQAaBCh6L/NIjDXdD52EjM3ryU32j3JF8J/h2o8xRUu5Tvp8b1/C7DHb+zQdC1Yu6veVXEu4eAsQiQbj2TUUy8XJJxdWumvxSSXrJdm6Vu/L5TV8OzfklXCmFVl13S2JgewauWzT4VHJFu1Y0TJYOzHnyvxXPU7f+vFom1AI3KWNjB52T3p683/79Iy9IuePHq43YvjqjL1TJ84Y9CswJQ3kvkZ9YlveJPVpVnsBC7UoUD1eUyUkLNgVmXBFDabIx0afkf6rUQhgEW+XzP6uWoKnOlblWGq9hWIdm5jIpca24JjRB6eSKYiJ2Zo9yHbB69C7ywtibgYDsihbKUI9gysa7ZABXxdVoyZj0QWj50Wm5AjX0Lfo2nalosGUSTEHk6sdlfZ2WNVpFvlIsZZX9V25PZ4OlcHhVg6Z6JWM55hfpgbGeP2RvKaKG8hYFVywvCCY/+r+SuVCSmK021ZI6Z+DWfW5hdc82Se6FrO/55ixdvKg3JhfnF7+xDqTm//uFA1uuq4h885frONhQ0VGPw4Azuo7xNOWcz/0Fa0Di3IuG3wP2Bst+wcYPqJWQ+URXwPswTqhgU0qa54TkRdk18o1Em20t0CoYNAgadH1MR2NC9fOy1OHR/eS8Qv9y3k3Sv4Jz49NSE87t+G5oO29q3JA0B1dZlPzotFxBP91Qof/ohtANHdQNSfpXdEPCGQzEEUnLbZ0rkpfvlc4o9/BUVU1PHVJxFNAloUs6rEuSNbDglFg4PAyPlMXrGne0jfyrDEp6cloqvZ9eKN95dEHogg7qgrbqt/U/fPvBvSHgNb6RYHOV31bS7wyovZQCJW8Zys/Z9FUlBF1+uR4Wb3+M1ICkq9H07G/NsybcM/fK3/OdWlJFDJbeXHNYkulcWdauC2SjMiAP3whv4bpXO0wg5qlpFwgzP4upGihO0AFldMlEGkNbU+tiv8XROdXb0ivWisv8If8+1mhOmcxwDUJ4n4gTtYXmKU/Kwn+TyQTlbSPvBoWn8XOwZ2X2If/t/BwCc2/q/PzT7ds71X42P5qoLWbuzxIoC4gpwJQzltiekhUVCBIgsG1Q/zFcRuTzsx/Pvpwp6fZ80z0WqQjg3MeceGwiZJM+nbPpWidcrZOxc+lPyGSsKIbtvGeMloVPgjmnYIzGwJ6Pn5Zr+gnkNblw3fly/RAQdx3CCdbZEnb23QtFod+8yPfok3z/+tuS+m0v3DhsfZT4XsBqgLXRgnryJObNhf1r3qOLWNVQL6IvJXCEVvHt3RNrIDh02qTtwyyjCs+8ErLtcj90Pm5oJWGRzcnL8XPHBxgtVHDoWEEPS9p38QnVmyUM0VpxGu8VNIbb/YXj85XNZAfX8Mp5m2WQ+FMkFhWcHcpZpkBsodMXnFfy88k8lguH0OGkqjhRDdTl9QhSUaTOhS5cfDoyY2epe/67UaZnbEwgvQU/KkElzNLUsFWZ5wRLYOH4z2QsFNLPDoQ8ExpPXTkc1Y6BoZidDJkM3i2qZkd1C6y8pYueuBlPbMMBlnRx7HzeIai31sXxDqr4ZaQw0J//l+M/Uy/+jcCZyytn9kRmX7mphtwRUL8b+3yo6STBz2Y6L3DocTajYWuYAE9dUTJnFnnO483H12nuBDY3TXYdSxr/ZTZTHlf5m6nKWkYN1JcZjVV9BpvfzdC/KPns2dHJLOGO2qeMlUtrzYlCAZGoS7I9ZO3mX2HMbEbtlFoqNc/saNQHyKShpIOjbq7xBDlbPIjGmZ5L/Y72Wf35OP1o7Df0ynFUS3y/Id3WttuQ5oUhtE1WNjiz9x7WkO9gaWhIW8IysMAPi+wo6R/WaT7cLNPITrMedwpwkOhH8bo2ZYSbgwEMtUgIhmrpQmVFC/HnO8/O7K3Ja/bX+zdGv+Gq7fpqp2xF+WlOUsCq1cNIdxJcKmUi2565fUXxMgUuF2RTaQ7Isaw4L/VC5XooKKu98L4WWtbWxkOAPAq1S1WcBi/7FWlqtV+xaCaVV84nTjvOzhmlcQY7Ws2GmGW4S1MKMv29iAWK5nBwH/Ln8ODBf3xKNBXBOXAa0szWkZ9sYE2Tonyx8yeobeaF7LgefLNxkggOQEFUKdiHad7NFAuGmFJTEzQUgmPazBmNYXlMGsPZcRaojQsJ/SCLVURonaKPNFb31gHLivin9KCfpiZvnTyNWUrFbySKIKciGwYQGSxwWSDG47zcgKmPnb860x6850PPk1cUUyfej52n5Qug5mN2Vv5e1qN7thCEtqTnx5SLQV6RIJlvRyY9K79aR3SNyWqngak4zhGLgFXOnQqxq6bwUrMB6A8dnpyj0GYGU0zsbSyzCBuLllxRhTXnnJYqM0xxjWe0TIvEiqU5RskVL88m+lm7kAdMHiqbbGCGuSD1awX83jikXBO+Z3HHch2pE5Qqs5IKB5HZpgLc2Vaw1dHcSYqYULNMIm8BpyiTZWWmOm0f8ypXsV/B9hBb899FbZEbln2jWm+JdHUaFbNKZpfb8i3aZdWm8nZwKzaWSxqsFspYmwWRDcE0N1BWiRpz9v/HqTxmivx4qvfpAjvauA/e7OtysdCMtPh28h3/rUgB8/LkB4Sl9DKpACteG8Bo00RuEWqG0ebUZ++snFVL03x2znzSJBGiXFTklrJWHw4p0UnGzRrvXp1VlJ0NqCp1C4Nrt1k62ZawnmtRKDhtgvHZ0eT/A82pLtDYPF5ImumysiwRwEFazgsmhIux1Ttp0k1FbHm35NlgrMopRKtW74wmtySiazv/X+RueZtE1OtXJSUrpDKoDGVlL2B+bWTWKm5lsKJKHUOWoMcFKD3VuqvKtr1yXgfU17L5TbgPsVXBcyFBLh2LQqhNcEifFhOyWdh/ZqtsaugWr8/9mPqKkMwgc4SF6hec4WQGfbisGLTt5g+8CPM3bE+ISCJM6Cqf7+Gwwi1K2iaBg00WugYICCtEpI+CpTvwUixKkvhAzleyYetYxqSJyAyyicz/HQY2YtnTLYqDyOchZcVk6QHTrSw+dcVcvhalXdIgC7g6wWZE341Ysqs1DQHWsI0YsoV3Ira3LEoTERnfrywlZNcsEKFDZUWf/MOLGdC0zbB5PrqysnWYmPxwTc7ObLxIZlmGpJC5DYSK3GvFcicfvYgnvBJuR9HX6jxb6X8bti2b96DFlFpy7bp8YLmkt6oT5xWJbgUiIKfPp84gZ+o8nw2/4yL6VkjRni+HPwlOhZbDNw7J5HEy5ilzfMYceyDFjDn5MtYr6noJDdkh26Lk4cKEm2ua/s9QBBxV9OB6Abbh/U+AFfj7S3ZlxsaYIFCfLIeuPtgSkJUBm+EuH366HOWZBSr0Olg+wqqKpSuoniHPU+YZ22SFtiuXTTyVScz/WZXBknPnFp4Pd6Sw5Z/nZL1Jz/Ff/Mb++L0yLyVrJbu8gY/qZHJeMV0aZ0uW2rk0a1RYaeYjDBLdZnK+HBlyOYvsOGYZvqKhKkv95CdrkQNdKGR66ws3EkiPSF7GDC8QW3OFRIkTuySSfFhSpdxm8GCLC35qHOaKy3QxYR4uf5GWbAWm5qmtuZ0rkWYvl1LSyqvzZ6tXbFKi0jpNU+TNqKy7nI7K1DpzQsli3pT/JGTF9GQZ+Y8+bOAu1uGMg6Ip4ioIHHS2XtJJgqVmAjMrlJSqPrgGIF9wj7kWt5fAquIiDr2vxAUY8SIjvajuZoGHoZq8TrKZM91DqkGju4s2d8sss6NAN06KRqkcge7SKjXNbYtmebr60UvhVgkO6Y5Id0S64wDpjqZZrIP0x9Y8ItIMu0wzNGnpIWiH5vpr0RBNRTdFSzQ2/xRpikgpVFMKTYpiRTFEUiCSApEUiKRAJAUiKRBJgUgKRFIgkgKRFIikQCQFdoUUqAzx9iMJmqJFJA0iaRBJg0gaPC5pUNwjm95bMqFyS/i95G/hr+6wBY3bFcgeRPbgHuxB9UyPbEJkE7bOJlSqXjfZhdVNRbbh3mxDavMQT2b3qqYhKNVa5bg3RjgrICwnTEwsNLMvBMVSsw9DVDxFvem1sG0FiQRGJDAigXHwBEb1bDccIqO9p0RCY38IjWqtPTyxUdeOBgmO6iraITpquoOERyQ8qnFXtcIg8RGJj0h8ROIjEh+R+IjERyQ+IvERiY9IfETiIxIfe0x8LHiiJgiQ6ugRiZBIhEQiJBIhkQi5BxFSs92BhEgkRNYmRBZXAEiMRGLkgYmRBRXsA0HS1GQkSjZHlEwhEy1jsiCIOgw46jJ/oIvgm3UY0sffkWT2dFqEScUAdJgnqWxta/TIU1WO9u9sjQPqn1xYAroxTIzzWFurHyZN3bdaV30qVAN5lsizRJ7lEHmW+kmyP9dk98LlInOz08xNvR0chLBpqr4eT1NfcmP0TEPjT/y27LJnwvuwd+Vy6rXL+nrsshim5Y/wPmxkgCIDFBmgyABFBigyQJEBigxQZIAiAxQZoMgA7TgDVBEg7kn81IeayPdEvifyPZHviXxPO76nYW8EaZ5I89yH5qma5pHdiezO9tmdCs3rKKmzqqXI5dyfywlreVhluhEfXXcBwwsMTsWo1+DmfU+ST0/LgNyqY9YBMzZzPe8uVbPQzLY4mqenB70Spk5QSJVEqiRSJQdIlVTNTn1OQWnr+ZC42GXiokorD8FYVNdbi6qoKrIpjqKyuZgyEmmGqYaoFARTRCJBEAmCSBBEgiASBJEgiARBJAgiQRAJgkgQRIJgrwiCudBuP2agKjpESiBSApESiJTA41ICc9PNI/dWzF8Kz9UdTqByvwHJgEgG3IMMmJ/SkQWILMDWWYA5lesm/U/fROT97c37g0DxBUaVx2awYyQPcw2C1zvqkQCvfpv51VMi+5V6313Cn6KpbZH+TlMneidUk8CQAIgEQCQADpAAqJux+kwC3MULIhGwy0RAnXYeggyor7sWIVBXbFOkQG2zkRiIxMBUS3RKguRAJAciORDJgUgORHIgkgORHIjkQCQHIjkQyYFIDuwVObAU3u1HENRFiUgSRJIgkgSRJIh5A604gtrtCOQJIk9wD55geXZHriByBVvnCpbUrpt8QXMzkTO4N2cQ/IcL3mPrC6miloa7AZ6YkNhJMgdF37vPG8wa2jZr8JS0oWcC1QsL+YLIF0S+4ID5gvl5aghswWr/h1zBPnAF85p5SKZgseZGeIL5QptmCRaajBxB5AgWYcu8iiBDEBmCyBBEhiAyBJEhiAxBZAgiQxAZgsgQRIYgMgR7yRAUwV09fmA+QkR2ILIDkR2I7EBkB+7EDixsPyA3ELmBNbiB6byOzEBkBh6MGSiUrtu8QFUjkRXYACtQ+EeJEyjGuAYHDDa+bwBQjqkH/JHTe06KFqgagO5yA9WtbYsgeLLK0UfRVogN+YLIF0S+4AD5goYJrM+kwR3dITIHu8wcNOjoIeiDxuprcQgNJTdFJDQ1HtmEyCZMFcWgJ0gpREohUgqRUoiUQqQUIqUQKYVIKURKIVIKkVKIlMJeUQpVEd5+vEJDrIjkQiQXIrkQyYUdvZ/YtC3QHcqhqZXIO0Te4R68Q+Xkj+RDJB+2Tj5UaV43GYiVLUUa4t40RHBS1DOKwXVTZs9UyTba9hP4SCnRJNhcArRT8KLUmayjMJPhJ+J9vSELugoLZ2Ti3mzfPavAIBhsVIk/bLEO/rwhUM0hKfxp+aMCsWHbZ2r2MY1s36erTbqku8xvSn1PQhoDzdJt5Nyjt7MnMl8HLBL/xYu+jApxsftCRwgazIfoSj1yVkXnCwZRua4f+jSSKw829L88RP9W/qi55pXLlhbwKg6P9PXk/fbvgqCulH2bFMaVanb+A81bckwxlRtYHtxY9K/O4NJ1VtUOJyyvYG2W/bGlAWVfwY85CbY7swqWjoWIykMprFk1otTWxNt8+1MNRSpftAEV1W8mXvw1Vr8AYzmFH+qvJVFOS6KuhCqZvFfeS9gvYRfd7y10QStl00vDFu+WbAvzoq2MBYp7tTdNMCcqhtdeqXa/NNbHd2wj82YQX1HdrEPQmrfmJdL5Pev96B6KzOALjtPE69WKH1d44VvZGTXTFGecfwwIbKjCkuHJAfwDNm1lwGcDO1TrWGy80s4yLMlQIv3Wf4amQIQIUB4t4Q/nthQYoel8WS5G/jta860YzExeTBqTnLOcuGrl0KtzKiKTCRjVVNKySh3e7SzAS+Qn5GCKzgwYaoyulKP+Pgz8kHxiT8B2KwS0n0Gpv1h5Vs6EZ/ubU/brEt4dKYxNbSg1z1X0YCxtH7wh8TpIdh31esXLXtCyhJ3OT5y8eKqMou7YH3SK4tLEOUo3R7Hxcb95gU8XjNR5uWSxILMk7s68tbUQ9XegqwClU12bwt+a4oFKz3nhjNBWbNTEC168jWYxuQ59adCmu73Mal4t/TCZij5Oth+p9j5rTdRMARo8pZd5sbvIC2OPoVP7HHhRPqw9crHzOU72+zgHNwtN4Fs6jS8aTkyuDQpJM9/BYUEz3/y/nZ9DIGlOnZ9/un17Vy4ipRloi5n7swTKGjtQYEWJtZSpqCh43hPPew7TBag8fgdPOg7R6wz2gKKsS4c4kZivr9YRRLko7RGp3Y4c5lrX9zOG+UN223/RiswF6nhuovFs7qGtZiZlelA+lMUf3v80ZJnGfkKHIfcXmfLApKzlVickU/c9hR96XmlGQ03/sD3E0cKZQTNWsKVrFwJ6TaxRWFKMq8Z6XCVq3QNSZO260uGCXbZoesWuYBTHVDVrBIm3JLme/5MwysTpYQBy748LBeRb0hIicJrCbn+J7qWDWnOd7kUPfhJ50SZlS2nL09KdFRo9+Yn+IHPBtLJoRgSHUOmQLKDQv9LYlgpsrm0KbUKwS8Swp6ZrtBhRC0Qtho1aKCy6P+AFesbGPeNgIRWFgA6BrCirrQWwKEpsCGdRtRXhFnXjM9djhbmUHIzVW0p/gLBNt2AbhdFYozeZEk2zv/Q4TkmHpqVP9C8rVWmq/LR/8JA58ESUqC2UiK473K0fnOZCpxo4grSOPm38SDMQx4WStI1qCVU6eW3AMKpTYVR9/a/WbYSdEHYaNuxkntoQgULXOWgwyqz+h8ClqlpQC6IyF94QWlXRAwSuELhC4MoAXJntBzGsw2JY1mEuwlltwVnJVgRuEdrSiKcWrrG5W76GXE7RepaI9fUpYlyKYTg2wqVsUmv41knrQVeFWCUghGgQohk6RKP3zF29yW1P6x8wzqCX4WFQBlP9NTEGfdGNIQyG1p80voARfDcieL1+Wt6x1uWA2GpdjOFwe+HwBm7wmKUiSAeZRcMK2TQWAxUWNKceExeK61JsXGraQWLkk9WPrgvVVmAYO2PsfEqxs9qD9yuGtvYKJxJLq2V6+Jha144GY2t1Fa3E2JreYKyNsXanYm21ng4s5q5cZ2PsfbDYO12xaIPwgrDqBFtUVj8sw8ebdRjSx9+RZPZ0gjG4YhSOHHorW9RWxH3SStA+bzgOqDNiN2EIxlKsrdUPk51ItvXUpEIFMHTH0H3gobve8ffnWEJX3MtwwQC9lhwEAzBVXy/015fcVMRvaDuS9tWNL9szsuk7hg/otdqaSl+W8rT8UQ+p7VaxBIIJrYEJMF4BFYAbcQm4CxABQAgKyTQXNPLLd04eOuDD0CnsIG3SYcCDU9ODrgqxSkAY22Nsf1Kxfc4zd347fjfrP5XIOyfDI4TehfqbjL1zRbcTfOdbj9vsGEZ3K4zO6Wf/t9ft1sUYCR8uEub3eJZDYS6bOtcjkuTT0zIg7I7TE7z+Uu7+ka/BzDelreswT1PeXROaTiAY22JsO/DrJxUet+sxraWVD/eaR4XMDnLdo7Leetc+Kops6vpHVWsxVsVY9cixqkovex+jVqxjMTZt7cpFkrgvMPJuDEMPaiaLokZo8s7zg090knz764ywYT+9cLQ0BMcNSRXNaSksPWHZd1F4JsFgiIoh6rBDVJ0X7nqYuoPFDzZU1cnuEOGqvu5aIauu2IbCVm2rMXTF0PXIoatON3sfvlqsdzGEbSuEXdDBd2FJR5cSYvipypVE0kA4c/2wjBIyP91AVgxAN8LYrDEtB7EnJ/XuCU4vFAxfMXw9jfA173v7ErxW2vrgQ9e83A4ZuBZrbiRszRfacNBaaDGGrBiydiRkzWvmYAJW7doWw9X2w1WPD74UrApx1Aha0iVLG9HKYWPOtLbjBpvbVrQUZfZfYB0aesWwYoCIAWI/DEbj+Loe6VWbKdgjiSI6CMIu3Hi9WgUs3LvULPJp/EBV/PJzbiUphVzJyFnQlV4CCvjZJFF2oiYV0W7gwJcvmsZJ66zF+UU6ABdcp1/EP2n7qWqvqQAfqN3ToHa+Duhkv6BLR/rUxW/FMHI0cV2wY9f9/cL55nvOPV/DfaZe7sskLeCS/XOUjfrlLO0a/+L+XNlifQhg35eZF7LQinYHVCTti7kn52d7rYL3W49+1vbQ3ubHO5Rh7wrgvy/qj3WWMdWbjGpBfDK4SsE9HgJQKVVZE+4oloc4hzGKNdwCnY9y45X3El5KzlH7opU7Mc/jVe9YPDiyxA0QwLECcDqlNMLWC6ZunZSNKUKLKtZf/CRbk0yzIK9G+P3GCx9JtFzHOoEMfWu/MADHRVtKjWkJdDlZqbefA5gatDf3Eq9G5l/uy1nza5ciFKheMQCD1CxCyLlmKQ/Ei0jkJsuvJKw9NCDrmoWs1/687tgm64eaRUhbBdqS4iSyaoyXENfQp+piGvJnel+FgCYCmsNmvKiXJP1Jg49TIE6BOAXuOgUOFrBUu7ND4Ja6mmsRwdSFNkQE07QYL2hQNz6dabbXMhgeTvXe7lluplYPw9xg9WB6i5zNs7Kft2wyjKDVo+Cz7XpGPbPVg5L/tSyYe1m8T6NbdD+1/7FGbVN7nKZ/jA17rqzoaaQD3IoLuGn6h/5RMMQp/NA/IkxwOqva7ZTtbyr/w9RSEMCU/9I/BtY3hR+GjlC7m8IP/SOSxU2NXMHiwmaa/tG/K00qYUtkbba16zBPh95lEEhMXUZBGjXg6NtkGZEbMltHMV2o/sixltPbilAOw3E3JDRNamlb4sT14BDIDBtSbVWQqTme8Jomj1wH3NXD3yZFoewSAdfVoSr9QEAYAeFhA8KmiaFPsHD3nc9gQTiTCh0CijPXXwuQMxXdECxnbD2Cczpwjk+RCPF0CuIx6fIOQA97bSp+9w9KsAw1EFBoC1CIQQB04IQEUp4/1VOlaGpElTd0KYnggmoUjostqFvUErRw2krQURFWiAcDewzshx3YG5xy14+97mb6g42rDRI8RFhtrL5WVG0ouaGg2tR2PBGIcfKR42SDevY+/ZHdahiD37aC34iOvzL2VQmmRtRD1ytxEq1nyXU4x012NutUDslxg2KL5rUUIaOuHHAvbE5WyVMNzntrOrOLPmB8jvH5sONz28miP5vwXXE8gwUEbFXmEOiAfVtqQQW21TSEG1j3Cjfm1Y1nPgC35bsFN9hqtfUWPZPylP3s3/b8HsEIohVtoRWzVBiuF85d/cZ9pdC2Y1AVmkIAko1nEmwu2akeh4YMXmw+mSvWQnTt4zwtX1RLUklOk3+wPErmZz6+vXE/fbj5z3c/fPiUz/xJdfYmU1n3vdTedG50b0XeyjtqO7940Ze8e8yFX3uOCe3oV7I99QwHiyY///z+Te/6X+rfWfFsV96s7JXhzLAolsdOs+7OhlRdoDzM1Sv3wuiXi2x4iIW/tSiw7FLzTllzsk4+iGaI50RoNpEeV/twJtYp+6n2wFRiU/p/9ZdUGFP6/6oMoaO82q3oMKZ51XZ1NSPleEMhk5w2swIV9UJ+Xo/dmddSxWfKES6PEAxdtSd4f/f25vru/YefxqYB9YIXbxOzHu3dTJCzuT3PMKuRX1d+RMcoNy/Td1VpYqu7eP3Dp+v/utX2bRbQtZDj/kxn3OD1ExyAi2+p8OKFT+LLvMi+JyGJ/FlmpvwdujoCuAlsFbIr5+rJ9UCoAZV3/pliFhEL7KRQgGhCUcXSpn3+/GVc+OoaFmvsO31n8sCfy4FB+Gl4J9990Bu6ugp9uji7VCZgsUI41INYnY5lr9zIbQ1muSarAS3o7ZVyFCdlPaMepfSZ5t00h8E0HUDdc6Jd8KD4U/Mk9Ik+Bb90WPSMm5rKn5RiirI4U88eT9YwYm5a2pnuDLlihMaGh41nyfOjoX6GgTbbwaiMHrYDE2fOx9JgaFvnDFPTa6xBvRw6pkFZy8ozzFZygGdHNF7ToDFZoo2pEF9+vC5Huuz4WUcu0yKqk9WnT5qy577zgpic1VSxw6hWOrb1lUqe1oqTUn4ReKVeSVrNY214e6vW7TdJaN1nvk6qufkPajnd0hgBVWAH4643pZliD+WaRzqE5SXky1Vzlx0YNf+zfQe/VHrTgsPKPM9VdZJtlT5MmMREc/QA2C6jrB6hkvJMd3IwVplQ0tGYWkxgOVWoHPXdyAms7ANcEbU7n4T9PvIdXbsYqmgvnwi/NM4i6Z6g2t9TBYpAzayDlSkz5/4sgbLGME992WWTtl3t2Moc2SDIBjmaNau8cX9IGSfkQFq8/KrpNeEuFAhZ7xqiOchFIpVB03jmj20STpYzhSLtoQu0B1nLrakNIPUp/BjXzUTZXCiopTJoVsTWfs2KtmBFXThsMKokRtgEpJUjsk9QmpuXhkXPYKmSUouqEbrdRZu7ZcbhEFNlJ2NuZUt7FINr2t9WTN59wQ5DKvqxxtgYY+Ojx8Ymr9n5S7bbNOSBxqQmeTcUo5qqwDP8GF0eO7o06aflIf7W40PL1RnGiweNF41zyLDixyTauAmbmATLf0vxUo5CY5FI4dRmD0LNQot7G3KW+nGY0LPLAh+WlKrHHkNSDEk7FpKqveuAQ1N7Az+JEFUt/1ZCVXVVGLJiyNqtkFWtp90MXStXdxjCHjGE1cw1Aw9l0wxB2pi2MCx1Qh2qqz8sw8ebdRjSx9+RZPbUzZBW0dA+RbLK5rcWwHZdqu2zE+OAOgA38Z8JjXvg2FXcVP6og8pdK02MhDESPn4krHfK/eEx99NTDDW21mtUUyG1vgYkLGsaXzYRZCR3LADXa7U1Qbks5Wn5o6Mxku3WtBitHzZaN0xaAwvSQU0C2lU34n11F9BZCM0VY1DnLCpJPj0tA8IOJHfz8LDcwj4dIs63u7XDxJ0VYL+lUB5bjIExBj7+4V2FNxzU7q+twQ71kKxCvk0dllUUjbu5GEwe/XirQi+7sntbsbrC+O+wB1RVc8PADqqSxH2BProxdBKsRO50jUDhnecHn+hy7u2vM8JUrJPRXqmVPYr4FG1vK+rrtjD7Lw31GGMEiBHg0SNAnYccVBS4i/EONBLUybmhaFBXPEaEGBEeOyLU6WZXokKL1RdGhgeNDLXzxbCiwwXtpgsrMpekHaVWU+p8A4HF9cMySsi80zGiaGMPI8Ss5W3Hh10UY98loRpfjAwxMuxMZJj3i4OMC6vNduBRYV7GDceE+cIxIsSIsCsRYV4zuxYPaldbGA0eJRoszBJDjQU93k0pEhQdrxFA3NC1T/V90h0IBlUN7VFEqG5+W2Fh56U6CJloRxqjRIwSjx4lGhzmoELFHa14oPGiQdoNBY2GGjByxMjx2JGjQT27Ej7arcowhjxoDGmaPoYVSMJdrFRdRFfddL04Va5ht/0E/ecXObOLdp3tFcEFY7BQmcuKO4un6rt8yzqk0JZRvsnx7InM10HBwMrlF1I3vDyRsGphM6eLS3Z4Of1ju57KvoIfcxIkXnm5Iy913FvRTLhT/BcvUo4ov8o27RBfovDPvNUqgLUubR41pnF6ibwXf43HrCtT+FG+3Dqt9ar+PdT5JuywJuTLruvt6+/nimUR64v+fnbDkusu8sLYY6YoVl3qZa9miaZ8OE2hNSmkyvqSLZPuoL23yfrhi92V3e2rm8KIdpCS9Nbk/fZvwyIePtbdFp5XFrjnPveB5i2mA/Rh9lt3DzkdSPoICeN1RNwnL2ZD8i/alkvJDtTvSn3M30NedPZCxtk8I7Szi1cEl7W/V9c5py8+JO63v3rB6sn764QNtrt6+NsEjOz9vD/3NdcRxqneuNqQBhSlawfNdVLkeK9vV7TMBkOSgxVnt3XKl5ECjPz5fzn+8yqiLuyZRhNXDl3Bzb5yiDMkPl31R85qGft8JBwvelzDc86LFzvebEYntTChotsoSn6kq34auzqPNx9fO0IjmZFMdu14SD9MVbo8CPI3U+XNvg3UJwEXFvXhPceNgGp4K3GXgLVe3zjsbuNc22mJBUBvaCR0R/+AXXH4/b+pHMAoLy2fnYTLl8uR80cZvYOQoWDAmqGVXxnrg7MynMQ0s1CAalDScdxprua+8vtoNftRvK51U24Oi3ObCRCVQJ+i6gfiRSRyk+VXEhrqZosE0X6Vn3XVflBt93ZTuGStVWshtb3mmzWRfZy5fUWx5zcmsoJsKpWH17bivEgKlctfnikWFNfzeQq/wUa2Hy6W0TOL8QHbFHvErPmTs4o+qw3usiyLJ+LBhvbk7vr2P93b1/94++bnH96ONea6dTETP17y1l2O+Lhtv+O2eXExUsDA1FFc5ppKXX6yXsEOgdKpwZqSWgHrU3GXgK03K/ccy20w3aZeuS8gWeS0YPzqFzKXLndb/aisH9OiLlntfArgUxo3vJPcuSXJ9fyfhHbyG+kqZCS38YSQoy6Lpv3Q3ku7XjO+96IHP4m8aJPuTWnLg7SZ8YS3ffIotlJAvgr9m/xEf5C52NeyaEZEvsESwFtAoX8VGWq1TaFNCI6CZ+V0bgCwlkJ0/UG30AQQbOs+2KZQjUNgbspqa0FvihIbQuBUbR0GEJe5KCs0ruSIrN5S+g0E9A4H6CnU1xrXyxRkmv2lR/hK+jEtfaJ/WakmU+WnCBwicIjAIQKHCBw2CBya4QrED7uFH9Kgyt0u3qa5wL/ObY7bUKgPyKKmuScEMvZEYAi2DBNv1KnfAKBHs29BFBINA1HI5lBIs7UdApCsakG9u0aNhTd13ai5B4hYImLZE8TSrMkIXiJ4ieAlgpcIXiJ4KcBLaxgEccyO3XW8FZxbxDQ1Qq2Flm3uljS6ov5zPUtEmNVdcFPR2JOCNnsgrI6OdNUoDgKf05tHV3OZIcx0dJhJrzSHAZlM9deEmPRFNwYwGVrfI3gJAZz2ARy9puybeQ3xEMRDEA9BPATxEBs8xCp2QjSka2jIhnYeJMsFl8qYgSEKiTYWXRdS1/UDEik0+mShkY4Lr2cQSXE0BweVqM0GIROETCwgC7XyHB460bWjQQhFXUUrUIqmNwipIKSigVTUGoPQCkIrCK0gtILQyqGglcrYCyGWjkMsafp+LdZSEHGdsJ2qwA/L8PFmHYb08XckmT11FmpRtPWUEJYeiKr9s0NxQA2bL984eTnW1uqHyXFOoKkENQTMRm9//Tl71hX9QTioOThIr5cHQYFM1dcDf/QlN4X5GNo+jMNZZXvHU1MHRIj0+mV9ZKoswWn5IzzChLgS4kqIKyGu1CSuZBVxIpzUMTgJxBBQsbkRl5u7AMEBiKSQZ3OAxKfIpzN+T8Aj3tjTRY+6Kazu83KUozg8bCdnHsjDQeDFBvnIKc0RkJdC/U1CL7mi28Fe8q1Hng2iKDoUJacpyK9BHARxEMRBEAc5GA6ii50QCOk6EPLCJFdGQrhEa0TX35Pk09MyILcJnYu6CoHkGnlC0EenhdN5yCM/egOAOlRmgBAHQhxKiEGlLIeANtT11oI0VEU2BGUoW4sQBkIYGYSh0hCELhC6QOgCoQuELtqDLipiH4QsugVZPJKE+ncqLzcGgcH8KQuwRhD8zvMDmMze/jojzEq7ilKUGnpCSEXnhdR5tKI8ggNALHQmgagFohZK9ECnMIdALvR110IvdMU2hGBoW40oBqIYGYqh0xJEMhDJQCQDkQxEMtpDMixiI0QzuoVmLKjI3BcqM5ekQqMaURJkAwHz9cMySsi865iGaOYJIhodFVBv8Ix0/AaEZuSNAbEMxDKMeEJeXQ6JZBRrbgTHyBfaMIpRaDFiGIhhlDCMvI4ggoEIBiIYiGAggtE+gqGNhRC/6Cp+4XGRSeiFEGKN0PgTbfIioNNYR0GLtH0nhFZ0VSSdhymygRsAPlHQewQmEJhQwgMFPTkEIlGqshYUUSitIQyi2EYEHxB8yMCHgnIg6oCoA6IOiDog6tAe6qCPaRBu6Bbc8CIkRaWfCq1GLPvGCx9JtFzHurm1GyhDoZknBDZ0XEDtX8WRuocaF3BwH8CaX7uUeEU7QGoWE5NgUbMIIb2apcjOtPbQgKxrFrJe+/O6Y5usH2oWIc1f5hWkRWPowt019Km6mHaguKJbGQAip54j+nPnEDo6dHTo6BCvPi5erfaih4CtdTXXQq/VhTYEYmtaPIwrsWR8iV+EZXg41VK7Z/nUYvUwTCBWD6aXoNo8W0SxLJoMA2j1KDh2u55R9231oOSkLQvmrhhvMDvcjoXaE1hfXpahYekfY+2jovJppINAiiu4afqH/lEwsin80D8izGs60y3alWid/A9TS0FwU/5L/xhY1hR+GDpCbWoKP/SPyEil9LepTG5O0/QPvEQO959w/wn3n3D/qcH9p0qYG7ehurUNNU8F5i6YxKgyFGRYY9PjNllG5IbM1lFMY+EfSRx7j51NmK5s7AntUPVCWIeAb1nHtVXBPQPxhNc0eeS6w8RSHLqj7AeohTiAXQGTdfZpb6DzyoUYbGMYrElnD4HEmuuvhceaim4IlTW2fijYLOsUInyHQ/hMWrUDzsdem4rfiCQhkoRIEiJJiCQ1iCRZhqOIJ3ULT4pBbFQeQm5uusSZqkPTGnjFDTWKvmBLqraeELTUB1F1/tS1chAHgOwYbANPYyOyokQ2DDpzCGDFWH0tXMVQckOwiqnteHobkZIMKTEoCp7kRvwD8Q/EPxD/aA//sIuZEP7oFvwRUakp0Q+VOGtE1HTNTz3mepZch/NesWwqG35CsEjvhNg+QWJOVslTjdNw7UAv1YIaAA5ja5n9YdscUZkQ62kM67HVy0MAP/ZtqYUC2VbTECRk3athsG6YW0DOzeGQJFv9subfMAlO2U/k3iD2hNgTYk+IPTWIPe0RmCIQ1S0gapaK0PXCuatn5VSKejsG1P6c+0+Rz2d0UJ57Z+aFzOzBYzleuBEtjWlTnXv3Vqj8Pe2mVMwqIt8g8vCcF1aas6ATvzNfgk17zv275XISkcXl6J6WOHeSaANf5EpIbWni/GP5QguLxs4LHWePFkoHlLZl+bItnX6SPi8VARMivETVZDtYogWfiPf1hixIRHWTNh6aJ715D0fs0xZSOcMcTp0EFCZUiBbh8oHSjsDyG1V/FkM5sbcgyYYHaqzpMWtDfqCV3XcuF7AITKBBo638ZwGdbJxCC67yygywBjXB0KfGeqnM91S2GW+1CvwZ87emFEG6GfB6+/r7+Zdy8cxdFUt9TUfEewjI590CZDVIkT6fJtw0PUw/JxHtzuSt+CMNvbO4CWL/+DZZP3yxQiNA4arGLFvZpX9sm1Ze9OmxEJt8UDstuDSoqXqyZ+bBJx/6KvuteYbZ4NQhYbym7unJi1nn/kVLvYSvpmyhrHlXTqcylXtcdNpCWsxFgSYJPauB27IS28BmczZvVuEmsHj2+4Tw9r7LrX3ENPSeSc0McpXpD+f+LIGy6BqJFngUPJ8rwqEw++Nqh8rY+wPhD0khXzkfwmDj3PNl6X3MVrf3yVbk9KP4abmmYcP9fbrEo2vMseMpyrpPE4jfZy/FK+8lpC9M2t2OyOnz2Nl14+J0di5kkzvE7kS+vlo7EHJRDe0y5Fo3jJ0E8E5WufzKSRhx16HtXQdZ36x3FkCiU/gxrpvkb3RWaS+S/7B1txrDEbjDJUcA06POJPrmz0ScelmZElBuT0UWvYgs5McnbvaxZiwmmqW3NXLI6hZT4rSwLaKucjQRMB5uBeFWEG4F4VbQwLeCUui5qT0gg8fu8T5Pr/ZwWAKodEFTJ7MbSa7n/yS0k9/IAGBLuTunlJ9vGFJsHzPy0lGqCRx50YOfRF60cfdO26ZQ1clP9AeZ2+Vx4479G6wWvAUU+lc3JlQM+s032oTgOJkHZfU8LWhVIeX+IKxoLQj4IuDbUMLHsgIfJM+jqtp66R3LJTaV1VHR1mGAwZkjtUKES+7S8gIbhXdDUPmA6SPL6muNLWcKMs3+0oOdJf2Ylj4x3cSiUJOp8lMEr6vBa3PkhRg2YtiIYSOGjRh25zDsaseNUPZhoGwaXrvbBfI0hxbVwESleHNgILemZyeEdw9PtgjmDRP61mnqaaHgZo+FgDjaEALipwaIm33CIbDxqhbUgsnNhTeEmFf0AMFzBM97Ap6bNRlx9KHj6NYRHULqCKkjpI6QOkLqnYPUd/LhiK4fBl2XImi3iLRrBFYLmN3cLbO8QWJNMgjIXdGvkwLchyXXzt/opR7wU0ON9UY3rOu/EPw8OfBTr9qHgT5N9dcEPvVFNwZ7GlqPF5UhrCjBinpN2femspNG6ayWgYjRIUaHGB1idIjRdRCjs/bgiNAdCqHb0I6526zcQn4MoFNIqzEYp5C9eHAwXaF/JwvXDUfOPYPtigN/yvCd2hgRxkMYbzAwnlrFDw/n6drRIKynrqIVeE/TG4T5EObTwHxqjUG4rybcV7mMRNgPYT+E/RD2Q9iv47CflSdH+O9I8F96u5gWByyIrw5ORMX7wzJ8vFmHIX38HUlmT0OAARXdOiX0b1hSbf9YbxxQd8FXevzETqyt1Q+T45wjV8n0xPBEvVX35wR5V1QNocpTgyr11nMQhNJUfT1gUl9yU3ikoe3DOGJd9kp49vmA6KVev6wPPpclOC1/hAeRLTBPq8UzQp0IdSLUiVAnQp3dgzqtHTginAdCOGGIAyoSN+IycRcgFMA1FbJqDvji65Hh4Zm89tMFNHsv1+7TGJUDftJwY87okLaIWOBwsMCcah8BDCzU3yQamCu6HTgw33qkJSKwpwP2cpqCdMS60JxuGYjYHGJziM0hNofYXNexOZMHR3DuWOAcDwPL6ByXVg0Y53uSfHpaBuQWJvoBwHK5/pwQHDcUOXYehssP9GnBbyrjQtgNYbcew24qlT4E3KautxbMpiqyIXhN2VqE1RBWy2A1lYYgnLYznFaxjEMYDWE0hNEQRkMYrXMwmoXnRvjsMPDZI0mo06ay4PMtLFJk4dRAWd55fgAz1NtfZ4SZ3gAQs1KfTgg1G5I8O4+clQf7tNAznaEhgoYIWo8RNJ1aHwJF09ddC0nTFdsQmqZtNSJqiKhliJpOSxBV2xlVs1jmIbKGyBoia4isIbLWOWTN0nsjunYYdG1BxeG+UHm4JBUIVd2SkBpAZa4fllFC5gPC2ESPThBh678se4OvpUN9muha3sQQW0NsbQDYWl6pD4msFWtuBFfLF9owqlZoMWJqiKmVMLW8jiCitjeipl3WIZ6GeBriaYinIZ7WWTzN6LsRTTs0muZxcUhYmhBQDfTlk4jwBgChpV05IexsANLrPGiWjfFpoWUFa0KYDGGyHsNkBW0+BD5WqrIWMFYorSFErNhGhMIQCsugsIJyIAa2MwamX54h+IXgF4JfCH4h+NU58MvstBH1OgzqlYZUVE1TgdTASd544SOJlutYt3bpHdhV6NEJYV7DkWX791amDqXGbZXcxbLm1y4lXtEOkJrFxCRY1CxCSK9mKbL7rT00IOuahazX/rzu2Cbrh5pFSDOeebFp0RgIrwx9qi6mHUS46IFOCxhWzzz9ucsXfSL6RPSJuG2C2ybV2yZqX3+I3RNdzbU2UdSFNrSXomnxMK6alrE1fsG04eFUS+2e5ROg1cMwzVk9KPTa6tkigmfRZBhAq0dh+rHrGZ1krB6UphLLgvmEgTeDH27jTO0JrC8FzwDB9A/9zpCofBrp4J/iOnOa/mHYbaJGNoUf48rNs5kutFAClvI/TC0FwU35L/1jYFlT+GHatVs/TOGH/hEZrJX+rtoJpFWnf+Dl7NXboJWIHe6G4m4o7obibijuhnZuN9TKd+Om6GE2ReepMNwFkwbV2oJ8auyr3SbLiNyQ2TqK/W/kRxLH3uMQ7ntS9uuE9kuHJtdD7BCwMdJWBZevxRNe0+SRqxmTYHGUj7I7pZb3ae1RmWy+TztVnddD3BE4sR0Bk2UdYl/AXH+t3QFT0Q3tERhbP5SdAtYpxJsPhzebtGoH1Jm9NhW/EdesxjUtV9aIbiK6iegmopuIbnYO3dzBgyPGeRiMMwaR0LEWMnHT9eRUDWzUAMZuqKYPEO9UdeuE4M6BSbXz6VGU431aaKPB4jBtCqJ9PUb7DJp9CLDPWH0trM9QckNQn6ntmGYF0bsMvTMoCqZc2RmTs1v+ISSHkBxCcgjJISTXOUjO3oEjIncYRC6iElECcipR1UBu6MqDusH1LLkO50MlI1b28YSQuiHLu31y2Jyskqca59LbQQOrZXpa0KCtvfeHlHhEvUP48cTgR1vrOQQWad+WWsCkbTUNoZTWvRoGOZE5L6QmHg7ctNUva5oik+CU/USKYjUcuscaG7FRxEYRG0VsFLHRzmGje3pzBEoPA5TOUvG4NDB19UTGSjFuxwAwFR6V5kmSpfQ8hUgd5pMqZ57NU+kfWxCiPIWVMQIWyGcXyRDv6w1ZkIhqDZm4t9Dkq8LAwbTrQyy5jbxpZB4EzvkD1YnzbfjtgIOlkWlECiXEGxqnUtnPnHj96EUOtWDnfkXVKS2QBfvrMKDD6LyQi1IBL2kTQBeiZeAEy+VqTGVMB8yfPTkgeRDwBirfVldsRr5yWCYyL1dCDtI0ZFPTOlOsMCePhPqis4I/lxKZ6d13fqkys8AV0pTqdqtbY6qoSWEIpFZP+HC7MMiXI20pzN1mRW1FqVnSci0BBZ+ylZoiFrMeEPoxiahJTN6HfuJ7gf8vYjUkrLWZn0yCzaWiXWeKF032cqlM6zpxvdUq8GdseCHhlPiUTSJjJ6vvTONFZwFd2jipRebTSRCY+HzadddVV172z/nG7Lwovd6+/n7+pVw861Wx1NfUHXgPAfn8eSeozAz6FkxA+XCmHm/FHykIlwEoLMa7TdYPX6zA0wO4ZcWc3syqXrN1pHZJKs2lZeQ/0LzFdIA+zH5rnoGBpI+QMF7TSfbJi9mQ/Iu2xeQZ+LtyCsWpPE7FpYeQMZuOQP+EdtbY8mIltrKtVUObd9/FZL+Ps1OZawJYX+Pbkj2XUfs7QKH3TGqmsa7MwT73ZwmURWc7WqDNltI+ilEU+sH2Jo+pCSoj7s/2Y2+Vr9kdxLwCjXdZu4xOZ/9QVvFD7BHm66u3ESiX1dBmX655w9jQA3dglQe7nMAcN//a3vyT9c16gw8kOoUf47oJske4yYSbTLjJhJtMw95kcl2xqc761NhekyYM7vl+kgKKzdbslaOkbpAY/akkh2Fta7HMkumsXicRLUmu5/8ktJPfSP8xMLk3x4XC5Ja0gogNQ3DtYxNeOkg1AQovevCTyIs27t4ZYBXaOfmJ/iBzu5Sw3E9+g9WKt4BC/+rGhApMv+NDmxDsApXsobUajTwp1E4h2P6Ad2ggjRoIQopHyH9c1puDpD1WVVsz3XG5yKayHCsaOwy4MXNgVphjyU1ZXi+o8CoIWx4wnXJZfa3Ry0xBptlfehyzpB/T0ieme/IUajJVforwKMKjCI8iPIrwaIOZg42YyPBQ0mI0gmCpJn0xoTaRrRKnOaSiBgQnsVuHBaNqOnZcRFXTqFbA1cFJFmGkTsFI9XS5Wk9PCn01eysEYtGCEJM9PCZrtspDwLNVLaiH1JpLbwi0regC4reI3/YEvzVrMkK5COUilItQLkK5COVyKNcagRkeqmsIbRDgVQO8UrpRtwj2aoazFjq4uVtm+WJEbDcE1FfRrWNjvoomtYT4DkqmXRRI1WCfGGipN7au3k+3hxIg8nYM5E2vWofB3Uz110Xd9GU3hrkZmo+XxCGmJWFaek2xvCUOISKEiBAiQogIIaJ9ICKrkG2IAJFm/Y3wkA4e2tDxdrepgLc5YJVj2RiOUIhChoYRFYrrElZUaNoBMKPByLrLArId/BPGktRG2S9MyUo5EFs6NrakVrXDY0y6djSJNanraAVz0nQHsSfEnjTYk1pjEINCDAoxKMSgEIM6EAZVGQIOHYtSrNsRk7LEpNLwQgtOFQa3DnBBte+HZfh4sw5D+vg7ksyeBoBNKXp1ZEhK0aJ2kKhBCbT9o3ZxQB0FX4lyCn9c9/L0BkReIc7TgrT0ttyfA51d0DIEyY4AkumV9yDYmKn6mpCYvuimkDBD44dx3LHsFfAc4gFxM71+WR9CLEtwWv4IDwUi2oZoG6JtiLY1iLZZhbkDBNk0y33E1jTYGgg+oAPmRnzE3AUMGSBqipFsDnf5FMGd24ND0ni3OgWl8SYdAkvru0y7KJCqwT5lqCtnbJ1nbdkrAQJRRweicqp1BCSqUH+jUFSu7HawqHzzkY2FqJIOVcppCrKwEBdCXAhxIcSFDoUL6UK2wQND2/U3IkO2yNALG7MyNMTHsgaO8D1JPj0tA3Kb0Omv/5hQrjvHxYJyTWkFAxqI7LokAN3gnhTWozKirmM8FsJGbOfw2I5KlQ6B6ajrrYflqMpsCMNRNhexG8RuMuxGpSGI2SBmg5gNYjaI2bSG2VSEWMPDakrraMRo1BjNI0noVEJHyo1hqGCmloeuRlj/zvMDmDff/jojzCH0H5Ypdem40EypOa3AMwOSY9cEYRrkk4JqdIbVdbjGUvAI2RwestGp1CFgG33d9aAbXbkNwTfaZiOEgxBOBuHotARhHIRxEMZBGAdhnNZgHItQbHhQjnKNjXCOGs5Z0MFyX+ho0RhADBdVwNIQNgAHXD8so4TMhwPqiA51A9IRjWkV0Om9BLslBP0AnySUkzenvgA5RpEjjHM8GCevTocEcYo1NwPh5EttGMApNBnhG4RvSvBNXkcQvEHwBsEbBG8QvGkdvNGGXcOFbqRVNQI3VcCNxwdLgm3E8NUI+dNgo/9oTVrbcWGatBWt4DP9F1ZHhl0xpCcFxRRspesYjFm6CL4cHnwpKNAhUJdSlfXglkJxDeEsxUYiwIIASwawFJQDkRVEVhBZQWQFkZXWkBV9wDQ8SEVeJCOWosZSXsQYUR1Lh6tGOP7GCx9JtFzHugm8bxBKoUPHRVIKjWkFUBmMBNu/RSn1ZzXuTuJOizW/dinxinaA1CwmJsGiZhFCzjVLkb1/7aEBWdcsZL3253XHNlk/1CxCmnDNS16LxtBIwzX0qbqYBnyT3u+cFPionmX6c58cekL0hOgJd/GEiNAfHqFXe9lDAPW6muvh9epSG4LtNU0exk2HMqTG7zc0PJyqqd2zfO6xehhmGKsH03u3bZ4tAncWTYYBtHoUPL9dz6h/t3pQ8uKWBXNfjRdTHm6PRu0JrO+kzADA9I+x9lFR+TTSoSzFJd40/UP/KBjZFH7oHxHmNZ3pVvVKgFL+h6mlILgp/6V/DCxrCj8MHaE2NYUf+kdkcFb621QmN6dp+gfeDYo7brjjhjtuuOPW3I5bJaI+vI03RQiM+2/q/bd5OlTugo0V1bzC6NXYzLlNlhG5IbN1FNPA+0cSx97jAG58UHbruFtzyia1skE3MJkeApxmQ6StCm5eiSe8pskjl6e7evjbpDjIu4CAdfShStYntTVisvU+bZB0WwcRjj48HG3S7EOA0ub660HTprIbAqiNzR8KTM06hWDn4cBOk1btAHmy16biN4JqCKohqIagGoJqzYFqllHw8KA17aIeATY1wBbDgFEVECPmpouqqTq6roHM3FA7HB7YpurVcbE2VYtagdqGJdAOiqNiqE8K6DLYWdeTEdhrAOJMh8eZDIp1CJjJWH09lMlQdEMgk6nxmMgAcaMMNzIoCiY1QDQI0SBEgxANag0NsgvUhgcG6RbeiAWpsaCIjpcSClINZA3ggEYZ1EevZ8l1OB8oB6uyi8fFiCqb1wpgNGC5t8+RmZNV8lTjVGgr8t9FticFV9naf384Wl3QP8THDo+P2WryIcAy+7bUQ85s62kIRrPu1jB4W8yTIGvrcOibrX5ZM7iYBKfsJ7K3EK9DvA7xOsTrmsPr9oiThwfeWYUIiOSpkbxZOniuF85dPcercpC3Y7AN9QEmzA98OYFEMbeXTQB2pjmmQ6fbqzOFpnB7u1SmJpt4wYu3ibnxixoncCeOH7prOvjB5Ui5fNQ4Jlbkiiq0T5vEPJ6y5GC5XF2qJwxWeFZMmlZW8XD+k9GEjbaoR1LJKuRtK66XiDa6VXnBf6yWKANAv6OKe0uib/6MivB9SOcL8ok98ZrOrd5DQD7DTPUlX0YBdeBoEfykmsombnhnVNCT8oyXQ6l6P062D96QeB0ktiO6f7GycVq+bRDPiQ6/SaHrjO35+flHEsHCx/FC59xnr/FOnzvcSTleltR6Qh8/XceqUDhJxcZCVcZMUFP4caZZHbxKRebEKzLzF/5MzNjx1R5uiJWVb5YaE7fFw2/YCtqEhm9NhyuYzaN3kRfGHlv92BXdGCpftfXGfiu319pCzP+tWE0LMXe+1twKSXRYpHVtYipEHdxTB3utVPBf6D2TGpleKzMdz/1ZAuXQEIkWZihtLw0vanD1liOqdQObnbLH3XND00UrOqYVtbN7ab1z2fyupaySo5p1Ve1K5us623fTUS5GjYfvtquYa1YZE95/1/BAO4aiGrAlffJbbcbis9o7iurdxB12Eo+5i7jfDqJy91DWI6sdQpDYFH5UgMr6jLcl6PVTGrrepwHe/dihA+CcP3gRDW1hMKgjikjhvft8THjPHxw76zAgcey8kIuIbONicCrRsgjWQuw5dugDL0/+7MkBSBaQ1w1U59AFRwJT9cyJ149e5NDQW9UEKbq9z7u+V0U/nLaMFX8u2sYi6/NCI7b9L8LL6Wg491m4Pjkr7STlpsJUH68quQyS67VflGioCxWAQ2kjVgYfpJZUARAWQESpKgUooajRAEzkKs0VawAp9BvoHLRQRGY77XxY7RMVHQh1UWb/czmypQGQYCd1ypat70O69PAC/19kB4XKBj3T9CTYXPZvEM8OxxnYa9N+3/36A+zV771Pv88efa39+V325vXbprk1PszWH6NlsizrenGvOmKRrGyORjOp1P72do533sgu4L5nzW7oNrCZq9vIZYkO03XYHijeLUmu5/8ktEPfSJNgXnehX7nHp4QA5/vdIBB8OirUe8zJS+VUA3jyogc/ibxo4+6dkVVhgpOf6A8yt0vRGpFvMPd7Cyjwr25MqA7o7x6j1Qe28NeORqIxgrYh5d6BvwqBIwaM9tiEPQ4OlVYIo21wWlnl3hi1ojRdFLhLrmJFG/sLWGeGX4lal8y78g2lNSLo3TzorVBJK+w7E/40+0sdwpZkPy19MtYgXAoVmCo/PWlgva8QdyO4886Y82iiD/UQYN4VYO7pWCLOjDiz/gxYHmhWrd53wJs5uTaPN1dbTb9OOGnpwh3HnWno5m4XsdMc/rEHhighGqeHSGs6f0rgtHYIGsSpT1LHECIbOkS2v+lUmwYC2QVsy+yqEdNGg23YYAcHb5stqG2ku6r2vUFvc8EN4N8VLUcoHKHwI0LhZu1EVBxR8QGj4laBJQLkuwLk/R9WxMoRK7fFyiuigl1g89Rf5YDznawJMfRDYOjJViRuEU/XiGsv2HNzt8xyeAm/jHkb6sD1igE9LbBeOQCNQvWoswj/N6J0VUqF6T8OBJzrnebJw+b7KvoAwWG9lrQPDZvqrgEM64ttIoOHsdk9QIURg20Og9VrQiUCi9k0MJsGZtNQo7uVsQhiu7tju/0eVER2Edm1zLZhXM/XzL6xgxlhNo6DILobOgzu9mYFISsG6CpEVRsaK4TqCJE1BesWijpdeLc0EK3BvKjL/6e9b2tuHEfSfdevYLgeJM2q2Dt9zp4HbyhmPXXp8W5VV4ftijqzHgdNS7TNLllUkJTdmt7+75sJgBRIAiR4kaxLdkS7ZJkEgUQikd+XyQTRvZ0poamSEf37CvSv2rgSDdxyARw4HazWmu3Swro+dEQPq5vvnibWDIPo4qOli9UaQbQx0cZEG3dAG5diG6KP29HH+ytcopGJRm5EI2vwQKd0stGyIlr5NWjlxKpq+eXc3DXh5mBOPwXzh4vlfA6XfvTiySNRci3oZYU8j4pVVo6/SzKZFJY4ZFaaaAbW3In9J0+8zBlpn+TPY+O39pvpb4V+Ev28HfpZb3ypZscOLJnDY671Crdxwrrs0c15an2rndDTJZ3e39IWxWVFtSc2QGTrdceo8ERxlsbFr+j8QaK+ifo2pL4rkRgx3rUZ7/2WKRHdRHSbEt0lqKEtv228iIjW3gatjfKdwXw4IZ8Q5x5nBMlsxUS1pwQ5w3EkNaVVQz9ivjkRwOYI52PQLlKPqumnisnlxFHGEFHGb0OVPHS+NKMlWyZMc8/uijHNNNtFPeCyXlMi7/HynxlNoATe/ecTX62sbbV/SzxeSx5v74RKRB4RecYlbcsc2pbnwNVYR1TM9nXIPD5tRTaPz1UDwuUnL/72GMy8y9iNPUrta04OZgR5TKRgbuAdkoGkm0QtNlQynRJRbuhWCEqVMSRisqZCHxwhqdKKTROR6mc2JiBVzXWRq6nsJjGOR8Q4qjSAmEbKl6R8yUb5kiXYgQjWugTrvgqTiFUiVg0zJJX+eMvUSINlQzmRW6BRH7zYecGJcCKcCfS55JlpwEx9dP0Zuloffpt4TNOInWrOnBaEeUzsqWLwHTKopKfEorZUtjJlIjZ1K2yqzkASo9pAuQ+OVdVpx6aZVf1zG7Oruia7YFi13SWW9YhYVp0WENNKTCsxrY2Y1gqMQWxrXbZ1nwVKjCsxroaMq9Zfb8m6Gi4fYl63wLzew1w4uC+BqRSzAcpSmKEWzNbZXRDG3pR4rfb8qxDlMbKv6dA3wL2ShhLz2kDR9IpErOtWWdesWSTOtbZaHyzjmtWMbfGt+ae2ZluzDXbJtea6SkzrETKtWR0gnpV4VuJZW/GsSjxBLGtTlnX/xEkcK3GsNTnWnH/eEcNaunSIX90qv+ryuZDYVTE7DZirZAPvgLLSIfRa2L8enZncvDUeM4OI10/vkErczwl5ZfEqxFfNnL2xzudi/UXC4UZneuqB2zF/YHgB1y2ALwQxI2vg2549yjWxQNMKrUSR++BZ94h0rLkLvw9H6N1Hj8ESvsHl33ecabC8m3ngv4KZjSbQq6nj9HMNPruh78JVERoQ9znwp5Y7X1ncmwGPiLWOVuZ+5k/iiHcTLQYfST/Kd9AN4QaQZ5RDJNbVI+tU5M3uoRvrC3HDYijpGZ8Ilg/wyC8raBxsYJBrw59P/Qnm2TOCB3U0tWjYyF0AYxXfMKsJIgFZ5BrpJ9rdt9BPhF3IPgTl1xipHWIVayw3XFteGMLAha470XKxmDGSbzBUwklQ28G1zvWPhwiirRiV69qUdR7VI51vbspBw/1JPxl0n+trAtmg76C2S5isO1jDk0dvupzBhnsPvhRc1f89Tx4ObcfBdek4f/StZ9+1brlvdQ1W6sZOGhiwX4eppAeTZFj8D7cnPRWqbDOGiTtnzicMA1XBdAwnvV5db71XC0td1yD4a6zXm+KTdEo71mvzqFfKUh0Yw50zT5umtguPa8E959vafdK5ioStRRooKGwDpi2H+qKF+zIfSEapK3IkM2UmPIkppTQ8Lg7eTBN2ThHEGs0tUaMDxdgkd6wy+4Pz0/17nIKZBjDyvTt/8MJgGakEfaiHduQGfUzZTYWhd0hJHJUu7f1ZtAnB2vAEWr55MMm0akHoX/MmkJdocbtQmRYtyNRzK1HgPLZoYLn0p23kGC/vWtwu6V55RKiiE27sOSXjKG+ipanTmzI6biZHVqm3UDrkmwwrGVYyrIfIf6kt3qZpMN1TG2d4qhvs4KAkTU/391R5OROEnyWvuTDRwurr+EKpvBAtb+VFQl8rr8vnmFR0EYVUeRlaxOpRgN2rvEiybgYNchu2vpBSc7tKzVWvXiMaLk3aST6MNIEo1uQ4VLEtebdlnHxQX4YLZIw/1H8WS2M8UTm8ygQi+Rddz3BSxvwf9SW4Ksb4Q9NpWA9j/FGdnSR91rXFl8I4+TCiE8foxDHTE8dKiTpKG66bNry/4qS0YUobNj1lTIP6Wp4vZrR26GSxbQQUp8lUOCw9MQI9yc1Og5jQZRyE3oU3WYYRAPfPPIvmOKKMyqEfU6xRI4AOI45HqF0HQI+zWdI2jwccRjZv3X7gquQs7n608/NsSlc2VcMqNaOYUI5aLDN4FBnacdU/OL6+TBs3zdqXP7sxd1/WbAcMfmmv95nH5y/dEGvcOWtcpjGG3DG7ZSz+JRaTWExjFtPA+Scusy6Xue9CJUaTGE1TRrPUO27Ja9ZYR8RuboPdjHBCQNJiRpIX+kB1lFPVgIzCGomb5KKOrQatSp7HRJ+qx98he0oKS5RsJypXoVJUnHYr9GuJvaQKtc20/OBI0RId2TQnWvroxpRoSatdVK0t6zSVrj0iprNEEah+rXQB1a+l+rUmZCencKsRCDG4dRncPZcpEbhE4BpWsi3z41uWszVfRFTTdgvkLU6RkrtVzVMDJgzMLqzx5SQ+m0+POGO1UgzHRL8aCKNDLvbINXDvU/um3iJ+bPiWf+dqV0etKIs1xyiZGkHKaN0BtT84gtZU+zbN1pr3ozF1a/qIDjJbjUezv1mubCVSjmv3zK+p7hjlu7JZGrOflOtKua7Gua414QGxpnVZ00MSMFGoRKGa5sAa+9118mETa5ahVBuuMMqO3QbBOkkmx3HnU0efK1s5iXzMkxmsScv5GISw3E7XcoDpifIm4hy307uZx0zE+lJkL2A6wcY7zoCVerKqbs7Zf7zJxidCv+FnE1aOLRJKiWzOKLN/t3vq2syP4uvc87kJu+mEqSWd2DmOF48jalEbtbJg79SfxNgO2GZorIrSaqaAeQWjc+lEB4/0XDoyD1m2UN5Jdpt631NrVI9Rlafj+M7T0nSambaqKrbFssLlhzKJ3rD9wQ/sBxdjGmqs9KfrqmOW7NC7L88Y9Kf6LD5b4fs0YkP0dqDqHoML+YmRWCZ4LoFSfxop77ihc8Nanxt2qCoqeiTbOuODyeTdYIw/RpWXGhZSTse6E2tlfzgOVlApies0KTbnxWfTXz0Y0POxVDCURvyKID7bjS6x/PFM6ebcXTcRYAuf1w3v/Dh0w5XTuESaQlftn+GHN62umcY3tGcMIrj32OCfAVXC5OhPS4HHz2p53nV1WKOjxAoQK7CnxSGL63O3YTzZtY7sWs0ihMXxEr8gOp2qZCXJUFA8g5N/FHqyjxyF3qcjqoKoigPX1KSyWdGI1iYuUmMzTj9VUxgFuzMufFPdiNIUjZXfEkPSaY00D+Sb7jHjDPRogK4lH/X4uBPN4F+RRtH2qEtG5SjnnEDI64KQFppdrblEuRDlsp+US/kWROzLsRm+ekRMufYQJ0OcjDnSNfIKiZ4heuZ4lFb0sdzKEmlDpE0VaROvNcjJEzga7WqE61dXQfr2j/BS6S2INvyQQqCvyg4p+9MtN0Q6tEt8U4dKUDXJRKIQiUJLdHZtYv13iJhpZyHq8g16kRwf27BPMKlyVydkT8j+WFQ2xfV6a1YL1RMcrguHV07MtnpR0ELMG0PDijlpjWNy7gHhma4wca6pncHGhX5tDiOTbu0LVm6gFKaTTtiZsDMt2dl1nV1ijzC0meVog6XVIiJMvS8ApdQLIGxN2PrYVFeJsdVWjrD2NrF2su9rQXdukpoAJJjUT8H84WI5n8OlH7148ki4qAXmVsjzNaG2sjudImxSoB1/6SGagdlzYv/JEwlDUZsTRjpRrwr1IYhOEJ0W/+zaYFPZ7dcOdsP01AT7emFTlr7odHFe9zKNvtJ1ITaA2IAj0diEBNBbv9rZ80UrMS5+RdnrnVIIuABmMH9OyCfQuccZROJAMbHt4R73uo6kBIFq6LuD7ZP+bBDcH8Ns7950VU0HoWVCyweBazMWdbdDzjXWciv0mREJhZj3xjNX7ZQEJglMHovKqtFkxppRKHmrOPCFyb4IBPmcNDm4zYu/PQYz7zIGr4gifi0O9ZMF+ZqH+2X70ekhf6QrO4pKa0+6blIJhRIKpSU5uy6z6juNaU0sQc1D7RQiIAy7w2d96Xdpwq6EXQ9dVZPj6RRWi7DqJg+S82LnBSXuRChyPFJOnoIGcOOj68++gZ/24beJx2RNkKM5PC0I8xUhqqIvXcJU0ptdhqqNJr9scgmyEmSlpTm7rrL0Ow1bTa1CPeiqEwXB193FBBW7N0FYgrDHoK6idzoLRlB2g1D2HoTuoLsFG7UQO6hzYSpaQJOzuyCMvSkBk/aAVohyB+Bs2pNNgFnSmN2FsjUmXj+xBGMJxtKynF2X2/e9ALHl9qAZhM2KgQDs7iMC5Y5N8JXg6+Eraw68Zm0XQdetQFeXC10CrmIaGoCQ9+78wQuDZaSaskN9UTQ36FcEmIWedAkwj2puN1cjBZaoO3Vjt2FlFL5JsC63aoFrRosmEF21uF3MZYsW7jwAtaETB9+9eStR4Fy2aGC59Kdt5Bgv71rc7k+9JwahJ6sWZ/2yTBynZBzlTXRiifSWhhgPYjz2k5tQuwa7XcSLNijaoGiDakLBqVc7VZETnU4Mi8HB7dxMVl/HJ63yQjQFlRclRZerrpOXtUEXUUqVl+ESrR4FLMTKi6TlZtAgX1T7WMyvFI0SeUrk6eErq+ibetepXb0vsc7j5IPJofXsUeNQxXipb+AGe5x8qL4FTfcYf1RfKsQ2nqgceNV/siUfy7+YjAS1csz/qb4c7fsYfxgMGKz8GH9UXyrZ+rH02eQZ3PCPkw9UlbFLbn2arEiHkQgRmLncIm1Av17GQehdeJNlGPnP3mfOUhwHwa4c+ivS7Jr+dEm2H+Fsb5LRYOLTPgKr50Q2f4L9wCfZWdz9aOcnoBbCbKwlVVpAdCjRoftJh5YZ8l0nRXfdhNSjqspmggirlLDitm8P6RED/4FIEiJJjkVlRQ/LrF4DwoTdPhb/EoTuEkJHOFOg1mKqnMQUj9U+cQOEhdnzmwRYx/aalUqer4jR1d3pEqKTAu34W1dNVaBiigl+E/ymBTq7NjD8O/0SVg3zUA9blwiEXsfaXfxRvZ8TYibEfCQaKzpYYsro7awNwt8Q5K5Ev6oJaYBdYP+P4nA5ic/m0yMOLFeK4RUBrEHfukSzR64Rm4scTb1F/NjZMdidaEWdWSe0S2h3P3GpqXHf7cDzbpiPegDYVPIUaBadZpO8j2Hmml4DAWgC0MeovqK3pnaxdiia2Y8x+0lh6C5x+CSZMcedTx19ULpyZvmY/2MygxXOH9/jE3eP0oT1M5jMohFINcrv9eegOOjIsjcc2Y6e6L7zkd152suts9zfB9DosOT5mSWEvegZv3RZNAWIFSKbHeRxPi06OJJzY/R2LHurU24kI4Bvnvv9wrv3Qg/s4LXyW9u5nDx60+WMJd7VupHHbNLbTyVl+QaIZLlYBPjWIEgYYc6tbJGGtwxPSHfMA+s2EectrrP5bIUWfR75oM4u01r0llGD7+ALmHD8iK0DbunJSAEeBwuAdW6U/BqyUBRa5wDNaaLheDssHB/GIzWRPothi1tJJ27hWVNcCjAIaAtQxsSd92M8ssVypRbCRErYx2AZA/Z5BqTlRjBIgEFCButlBO6j/K4hTuep6mVrmOoSRCHAgQ29ye8y8ADp9c1i+2x1uD4YhIslmIon70MYBppdp//ZjyKcUrFFpS0nkBJExr+5/Xerr24CAfAqWIIJwoYYnmNiZmoBArMu2Pj+0i+zjmJgc/b+aLrdJ2831cBewxbCuBX6jKrkTdP+u7I6g5JYqNCouWB0+VWwO7hW0hG7cqDg9zz7E3ZirVhJfwVbfSm+tRFf84+w2agVIG1hGxqQPGwLKpCz6hkrVez/G+vqy/svg8c4XkSnP/zwAA9b3tmT4OkHrihvp97zD0/BPPgBxgjOxg//58cf/9/w1HKn09Sm4dpP7Bq3J+5iMUOCAvdlW/FM2GlAT1/4MN3Zi7uKcMWvokQVcHuVGuE8xwTMVowMzaOXiLjYuHQXvrFWBMyZF9qSZvjZUrA47m3V222RsOpsvxobbQAjxbDP71nfGbs19adoKqOFN/HvV0jasA3O4u+Jgyl9clfQT3BcLA+M7HKRagaTzFuA8owCydyneii6Nii+fgTb9wS2j6nFOCMwxqDWVsD7xHzyXos3HhMNHycfspdISmquoNtWzo0pZqVSVrxhaaR/as0zmMLE2ysh/wueoMQJs8GvJw5QziwDCHabMXScROSIXFuy/Rl3VnIguYwEXCu6uUrwDB+ll3RNCEz5IQ1YSqf+o8sfoeyD1LB9vv6s6k7TPhg9giGDeLmYaRz6UWHyCkxnGiShldP5yqmvvG2W0Gb1uIPuGD9NQsyxH8+8hgWQMNrV8FZ3+qsHqvjc5P5OF2XlwiuPVdJqrNjHtr5Km/ViB1bv3myKZEE4X5fwEbcJ93U7spBaO7kD//mE4YkIsxake24X4FgnlycMSDSylvOZhyje64femujAxR8GMic9C4IF8nMiJQKZZ8QTK5YcAZYrRosyAVjz4IYIavKPRuzJAEaGSXsjXfY16Qlr8kT0BdmN2Unuweuxyqx5Mmrr1ubQiD1KsbhrLuFEB1W2zGlLJve6D8D3ero4d5UJVrFwmV7nqDdZECzIVvWAulH4vFEbqU29ghGUZlsX/ss3Xh3pzN9RGZIv9r+rCL155816XNlNjYHWZwEw61xZtI/lLVVdlFpc9ZUVcWqDqa8Ti+5+TndXN7WTXhxS9uGlqlqxWJPwsrzCjWLITOPG7Kc63ovKNsYf6j+najZOP41KUiS8WX37amK+8qarllHdTa1vq/E7pO21NF1vocSIBoq1UDXf2nSb/NibbPf6ORyOrJPz+bM7w9zT8GH55M1jBlBt6z18hcGhBYzq9B/zE+sfmTtPLOutdWb1k/70OS0t0t+Q4YdWrL4oNwO9sDNOR/8vmib7YiSiPXT9dA3Kw+r/5aRUOfdmvTXWV5Pl1+vYQJca5xLDXGmUhxl/V+PvlOd/rvOeWoah5cww9R4laJsUofA+px2wOexxGOwZDJVNoDGx9FlUyRNyqEvzIMR1muekz5JbXENI5T3Fb4d2nkeulXMmQwzdFalu6i4oSQPLZzzWUo2U9jqf+5iF7//TM1SORKSpssaz1WDnRSWB1aSsbgME/FO4mHwWtytgsBwELGldyqXKGQBlgnV2PnRd49ac/ZJNrK6C3sO8fZHutuVK8er+5GWaTcZOGyh7SL6Oe9mDsiLOPUz+Y06yEtrX2VUj+6vgDmA6B0LGWFjYxh//dzA0SUgusBBrI/fgzdEAeutOxenF6lXG/4oK4LBNKVmoyUPSv+iWkMgv4Hdr6RR+0c9wzaCfKbYn9tbP/AWkvibDlYcNxn1uL/rqi+R6ynmAX76404Q4ebsvJidnDE42kp/XsV7R3uLdy7vUI0mfaDs8G1A2vsN8dsag4J6k92fHlvNVOJuKzsov+IpWUQcS88z7VmGQK6xprQkQ08qLlsu2QH1pqbSrtrlhIbmifepyy7RlVcoyz1vBfGT2oUEwG6NpmS2WpSXDRu1GRkm61p9G1mPwclrhfP8teFHmesrX/PLhwvn25eK/Pn768i2b95xmW59LPW0bxlePHIbz3VsfXMMs7dev5+93aZSVI1Fnd5tPqiqUJEtF48ekwio2JAuvXqgLZFqSEV4ltHyGvPLynKmUjZJBdrJ0ucJYoszH7GfR5IBIx/B/8Q8grTH8P6owSUpFyECQThRhWBAntJb1y1mLVb1aQ61tdatXmIqsSFHO1av1/OrDxdnV+ZefzSZA4FboTN0eoj6Ud+cJNwfvt4Ufgmwy+yXcOxjWHd3Zp29nf7/U5hHi7spGCP5Y+nlwHwb/hB31Klx6fM/kWc66ldhTratTc66mUW0DhU+yvy9Dv356Y5uXslsmnGy0/ka7NIM2xTdIQTeTRfg6JT/apNm0TLVpm26zqbVQM1ePFsCGE/cO0ITTQtQsxDfW1/9v+U+LEHYgjEGeWpNHb/KdxwDnns9eolkEkc87tY5VvriR5U7wFaN5DKJf5Vp9gJFh7tvDxS/v0jM7WXyzDnU8hy8TPRQ0shRCkP8yVucCtHyYxFmbPEzL53WW19ZN6l1p+K7rtLb2qW0NisxU5LMZ5rQ5aiJSGxZhb0jnXsmtU1HmVBs55G+nXoGY+aup9ycfflug/Zg/WPfBMowflYuUvzBeGcIfWQ/Q6f7vQutVkhjajiDq/+ifKNLUzFPVjNPVzFPW9NGMdL6qSgapp65RnkezWQR/JpzuwCSaVHrpGSyoxnlnRrlnBvlnxjloJoHrbnLRWuej7Y4677oqG6lxtf3IBmpLUsjKBd86d6z+JEQeeEraWRCOnTwZ4G72MZfKdFaqxlR3hioXwoEkuvKew2QwfMM9+KwXdzZfjRAh3/R6el10qtIS+BNAMDuaACPDeNspPaLYLG+h/YC3NpyeNpUpu40UR/KnZJzatL3dr5uVD0T33pT8ZyVlXkDRsRrS1F1goVWr7J4eoFosNXO3YjfZv0ZSAZgnWHBoBnlNCFYBdjLBd6VE/VUmC7TieP3bZ3iWa0ODF97Me3a59Uwaw6JcYSj9gYs1sns9HuhIjhYT12NnznAAYKuTicY6HzMvDuZJGks4PK18p9VBXXHuwTxOcIfB6juaQNn9EpRrzTEklfU+sq/Xl/GnnGLiUCFi9vLogz+PMZzsqpuymP7Cm09xvxmrS/jhd0UtvubduhkpElufvGAZj/9thArEN7GopzcGb6x3jK8A4/ji9Z95nZOpxcoQwRzOggcsoOWGc+6Y8GIofphrg5XSenQj2BC9uZXKlGm8z3KreXGWcDnHhuy8XZ558wGKY2iNx9a/Fo0TdOMB5lr0Q22f7k/eYS9YpWO2lPq/8w9/9JVdW6WlXLDW14myzZO/fr2yvn2wzi4+WJdX558+Wd/Ozq/Of/6Jl+mLQdlxOcSebf09WLJaTckCX8DWid6FpuGkzJWd9uiWLYBkMtZ9Y51f9xssDia3a5oFIfZj0CwLBO3hqnTDFbM+6Jkw/cKORwFKJp1RLJ4z956xxtlksgztk151Im1i3bLlU8AgfZct6c/BC7QMvWZWIl4i0WXdMkW/ZUPkeoyDYj158Sw2AqmJR/cZzQkMCOx86EM3p5b328RbrCvKPHhxxFVkqn6Z8+cvVx9OeZmaF6aGzO+DRtcNCZEL1WEXwHOevaw5DpYPj+nUsIlxZ1gebqVRfIwhR/BBauQpCHH78NwwXU65pybCwN4+rsTLsOCpZN4ujSds/nCNRi/QmeCF/7paj2ktC25ZuKx7afDccfw5WEFngGXlJHvFqsw5v0brqmDrknRj8dd17UbpusHQykce3DgO38LD/Lk3vVk/2l3CgEP/n3APezhyscYMH97srFuI7LP0800hCyDf3dyTNeM0Goi0naAODDICHPVy5fdOawRI1jf/GgXzxGuSdxcUGPy2Hq64Zp0ShXfaGBKNBnIjkqPDkjTgBvEXVvmtz77sy1dxBqn/GLxg6fPkajnbaN3GNbvsRs7TZX9XJWoliTORSI1QvpHE+6h6xUjMr2j3IQjAC3BYpfu75T0bPe7vT25siyqhV8F/RnI+THZxRMsFKrDNfPb0fQibTayYpqHOYxR9xZGCgK4rOPP1uOX0tFGtuxRpMjeFmmHtRKN7bSQrqEwGlMhMUnAABkogC0NJTiqevM5yUjxaN3nDwuplGb4bWb48dzgfZUA/hVWdZSGqUe6vZyj4tCjtTQ1boAgIJ9nLTGanOpUQtXYTdcixJez0BkXMInQn2N9o4SpWFce+DPnfn/yeODu5rPU/Bv3cn3zw1oYnioJ58BDe2okYEiIxCQ+cqKr54ekRcBPbGe+CZywTCPuml0AVjsiQA0AK6HIS+gtFjcQFu9bhtQf9CUvGKj4MMIw3G+uldAX/ep/wIvvd18urL58/XOQQaNHrZRMeetFyJt4TSEGCmFWlD1h72bOmh1Uou7EmbEAblBphvbVYAM16FyxW1drRoYaYa0knmqLRFm75M8qicQXkqzRRDG5jUZJIAeoDDQbK9osbRt57fxKXl1qXO3XNX87t35RXVOcOnPwqjDMoKcKue0ewLHDW591ino/cwxLpg9yzYxFN3JTG/JAT5hcyDAz2XPMItrXzK3s76v1t0f0rdd5y+3puRy8qithZRnvm56k8tXpeWksPra53lsxMWm07ETxbBmPQ/fQ9IMHyWYldTc7COq2oDtnAh5NPEc6V6T+1Mm/FPfBOOYu7H5M35EbSpDAOvOSWTIRFLsRVdQNPQJKIxd1yzKT9VszHXjtlu+0FcwFz/scL+dve1i0M6GkR4KkFiDFuD9EpvlvBOmHBXen1r7vYef6zO1s8un925qCGv0Zs4WTFofY/vvvz6biiHdW+kLMtVU0Iw6L3gYxeol3rlFSOXV0Fu+w1Yo0i6hvgTHTmvc7xmsAu/K2koSD47q87wH8tyT9ZLJykdnt6k/xlya3L+HFc7nKyfIP16RY23qItZ1PY8OS77Djgzq/D1LOkhEWJf5rstDi39Tou3Wnef3zHXdFA7a5rvma6xSu0Y2SOW6ir4DLG16l0brrYPMfiX7Mbh6rLsl59SsxrAnnXKLKbtUHJ/jXfGjdBwsnPBQg19HXucYzZUWaTxuHqdF9wd5ya12PB14qZT0Mia2kMKqIH5XhWGZAos6gaNFPQff0l6z11ZFhHR46rvXhpjO820fPoEROGbuXAHgZHWRRY09gkCENvEs9W6zgli9gJMWNwVARbWVyOR6w1bWHxrXTcdhnwVs1o2UmYeS3Qxe25AAaK5nPJOCxal7/7XdJ3lpdW1MX12CIvFs0PsL8KNkOaps+g72vp3io6d2vdeROXx7D9SNEWP9aKO0S3GFS+jdcvA/EjruBB785+xqfC6LzJUsGWvLGe4Jk+zKYV+fjRnXvBMpqtbFX0oGKO1EtVMANsSZVlexgscf3C6WfTjfojU4qJhXoVinCqENVn9zvCa6yInGg1ixrfSqkDQioiLRFkJp1rtm5JCnfj+Sth8DJn1d947FsoNPwJB7UM5ywWrWgmE6q3vmO2lBuyU5ShiWAZTjxsYgYCYUbB50fqqJTAf3jEU95Q35YslShczlnuSXAPDvFTEK5Y3kIQRt6IPwhBpqKl+zB4guH5LHUzUWGeeYKTz9P8Q7Hr2CXriX9SOHCKGVPm0Sma2pf9XCgAI2yR9uW+1HFx53VA5QW7w16LysyqmNoI/170yf6bG7EE3IHgxTUjaKxWG1KtnHrxqISZdnWsYfW0rDNNK9G2OkEWhgsqCEgjLcyquq2PZJQ7fjX1dRmxcES/NAl/UHUasfbvYu89uwtC2HD0l+EW4fD+lEvINKZVS85CCKPKe7JPDxcT0Wc22Ze8+xUnDQ/bh8CEd1yYz1CQ0P292tVYlw0sj3ZnYHEWQ35eXolCfkkXpMJxxdMyT77OPfb2iTdN9iLm1QgqvZC2wtZFm4jHBTvXdhsRD3ZLjYCHuD4f7+iK+jWhfPmhv6Nel1RvQvGy4fUNDtrUM7uNGd3WTK4hg9uAuS1hbGsztQ0YWoVRrWZkmzKx9RjYobY6WW2mtRbDWsGsdseqbopRLbCpmyHwahF3WsKuhKjTEXT5dzk6IOS6IOJKCbgGxFtXhFt9ss2UaEtEv5zP/O8ek1kJTTZC8b//gvfkWnFw4hz2ypg5TcdIuVxD4tB6wcdN2LsOjItbM2/8kih3Y46PAwwG2nLnsVdiXdiKsDn+Ms+LeGkLS5Xkq5cEwdS6h6HcuUktFGSYsJZJ8VWcEeslElf5Zpg+wB3hU8riJOPmpxOLIaxVGf6ueDUpr4JNOMUmfKIxl5jyiDrXIP/KY4aMUlGH3dCGHVCGndCF3VCFrWjCCoowNyMFarCKFtwI+6RlnYaFN6PrIvcy1F6G2LmGl4F1M6DeDUivC9BbgnPjUyp6vTZgvAqvZuBV13CVNV5Eq5cw6cnb/fuRpif3uAZ2zd62Ryl7cscpcY8S9yhxr17inrx+KH2P0vcofY/S9yh9j9L3KH2P0vcofW+30/cMfDdK4qMkPkrioyQ+SuKjJD5K4us8iU/egSmVj1L5XimVT8Xedx0hyRDthUCJdLZOVzGT4nE9FDjpMHCimTGKoVAM5RBiKBJBsJ1AimY9UUyFYioUU6GYCsVUKKZCMRWKqVBMZbdjKvXcOAqvUHiFwisUXqHwCoVXKLzSeXhFsxlTpIUiLQccadEx84qgy+oqeJccElRgKnegtgJXbTtZWLb3tIhX7J4P+EkKsFRceXjlFJSTR+UVarC/VF6h5Fcqr0DlFboj96i8QhPSjsorZBqh8gr1eEmJkzRzFajcApVbOIxyC0qNp/ILpd92U36hAod1j3UVE12FdD/8xtECId49Rry5SSTkS8iXkC8hX0K+hHwJ+RLyVSHfapeBEDAh4ENEwDnNJyR86Eg4N+EKRAze6qdg/gBtz6ELH7148rgfZfVVPS++cHd86FghFgLFBIoJFBMoJlBMoJhAMYFiAYrNPAXCwoSFDwQLKxSeIPABQmDFPFciX15af6eK828gBrzLhWRU80FlZKiMDJXir1lBRrWQqH5MU6rIgDJqTB21oJBKqCRzSqkttdSMYqroOtWPofoxVD+G6sdQ/RiqH3O89WNqOHFUPYaqx+zD9k7VY6h6DFWP6VDTSrQtFTlVj2ldPUa1FVPtGKNJNJxaqh2za0ETQb8XoiY/efG3x2DmoWp4+5EomOlyjZL84lGHlyKYEQjlBlJuIOUGUm4g5QZSbiDlBlJuIEd7VS4CJQVSUuBhJAVmNJ2yAbeQDViHauoC2WZmuIhoP7r+7BsYnA+JZaE6MPsBYwsTR1CWoCxBWYKyBGUJyhKUJSgrvEkDN4HgLMHZw4CzBW0nSHt4L7gVJlmPasX0E6bdL0wrpo0QLSFaQrSEaAnREqIlREuINoto9U4C4VnCs4eFZ4WuE5o9XDQr5jbBsv8xmUH/OTDKgdtvwg9ez9FkFtUs1iKaKMDaBihVC4GThyQnfL4OXk1Qw2YQazJGgqoEVbuBqruJPt9Yn/z5d2u54N60wi1iL6SgmyNkkMIoP5ZaSRwHvNqfC9/BevYBCaSyg0sGw1u4BMxDCrSkNmDiF+4Dvu12m8Ul4PJzXxocpodH5tLYv0Z23jLaa58Uhp5+3jzUTqAvPnUW2c4aCzv2gxdLWiy2rvQGGfXVR+68kXboPWmDEDwh+NdC8Hnxpxa9FMMnF+01iudC3iKKZwZqcyC+xG8i9E7o/TDQe6LkBNs7hu110qrzKLRr/J60XwxCv3fnDx6sfj6AaKeKq2pvyXW6xZEiO1xsNTdIKrNKZVapzGq9Mqu5JUQFVpvyZAZ8WWPerAV/VsKjmfNpbXm1ZvxaRdepwCoVWKUCq1RglQqsUoHVoy2waua+UWlVKq26Dxs7lVal0qpUWrVDTSvRtlTkVFq1bWnV3CZMRVWNps9wUqmo6qunNuZp9kKE5DIGsHkBLncY+c/eZy+K3AdvP+Ikyq7XKK+quT+fKbnDQRTlCCiUQqEUCqXUC6UoFxIFVCigQgEVCqhQQIUCKhRQoYAKBVR2O6BSx4mjsAqFVSisQmEVCqtQWIXCKp2HVZRbMQVXKLiy2eBKM6q/65iLmpUvRF6wqmGXgZftnWen6nmNuIv69tcsULHJgoqq0VKpihpMMZWqKPmVqipSVcXuiECqydCE4KOqiplGqKpiPQ4z5S8NPQUqzkDFGQ6jOINK4alQQ+m3Gz4ArwyadQ2TVc8qomTAV+DeLSfx2Xzaea7i1Xpf3gZurhxLDRBt0NYeJTJWjoaSGimp8RCSGiUksJ3MxsqVRVmOlOVIWY6U5UhZjpTlSFmOlOVIWY67neXY1KGjjEfKeKSMR8p4pIxHynikjMfOMx4rt2XKfqTsx1fKfjSOFXQd4qmm9WGaer03Jf9ZFwkwZV6X5WLEAMP+ZTf13lhfI+jL3So5gcb65rnf1035CO+evDnMEziizOlzJ+AxJkYdAOCUUeLQEuLjt8/wSNeGzoBJFqkPk5kPDUR2r8fOCUtMROZBUoxjkJ67IF9wrfzWdi5hh5kuZ94NTHkuIsbQcxH4w84Uhv7Uu9HEw/4khcagAfduVqCS3onvr681JuaJz5otZu9mlGvgDN1cbOFm/TCX2z2HdxZ/XmfWoA1r0BYX2cJI3hSiaorbKzuXtsEsYxqeA42UAmzw22n+YeB+yY+VnecCaWZsjOVOjJL283X0hSVIkH4yUYPC5VkwX/l0tkxBh6wF/uZ4RayfTBP7k+R/FufochXF3lPhWPecQGTH1WaN8h3i6/z7HBCfaosQE4g2VurmH/9unej2i5MrkQG1jJYgqhVHcWzdu7BWvAV8NQe5wVeJbJKnjKyXR3/ymKD7aLlYsAHhvWlxnn/MtY+2Ti49jyHWmf/kx5GFKUyn1mMcL6LTH35Im5h6z/jLA/jr6EK+fVjCGo3439/yW384qczx4QZeiBZn154unxYKP+F3dYoR36L7pyYKI9bPVfDen5QEmDIKg1EJ4bqYZjL8oUlmFJr9Vxe0NmUKQHNT2uA0n6/iRz5sM4hzB+lFo4zdUSWtGItUL9ZNiXYtBhhJpWj1ftMfvfLrqhKVWqtd6o11KZ2k0YZ6lt9NI7HTttpRebRV2ltMM1Ay1bKs/6mXrFJ+fe6AUeXF8D0LbtofxIdiGowQT34U6Pw578HNvYIPeHgq/vvfwVxCsyC6p0UQg0OzqgpaSV2S7rLP15931yVo6wH01KYsibYYa0/OyMVu9D11JB68GANDxUUl3M9LEQa6gps0JjBJTCiNAnHgE3r3aQjdSb8ambx1wRdSLnVjkAot0cZx8qHrjRKldj7t1F5hkzb+ANVuY7M4G3U2nSZSQEbKn/PO4CYZB8wfARECSIpdW7ZO7BuVJUJtjuyfAMd/FleB0mQHMyje9chzr+2rs8v/ci7f/e3D+6+fPqynx/ajgPdrMJRfKZH8aC6PgoKCH+aFg6HtxEwThRYNR0IxhgPVCy1ZdZEMyFj6nL0oEck4+aDspZk6FVWphRoJwWR14o+efv/iafD1d6/GW1bmPcOKLWjXd7cNbiXpnwK210Wlu4y4Zo27mK7NAncaDeRG5N2i0921kCECm1FfurgPlibp5aluueVhY3bGhPBt6Yai0XRnvhuNxYOuMz24YQf09tkVfcXW8d1bld4If1fd9hi8aHKgyqV39unb2d8vlTeC7MpH8OKuov7I+ujOIm+of0ewvAO/fLhwzq8+XJxdnX/5uUk/wNKew7pgm0e/pBvK7IT8a4m9nGFxHt35dOatVeJ+OZ/EQTCLbAD3se/mkigLG4Cwa4UdIPvcTJ6gGCwb3Qn/yxX+4WRYc4cY5ncAOcQ/KSSAJjTNODP0kZJfQRszrnLHksFa/2L1BdHSNziwnDcu/5K9TLZU44w3WrK/8ASMLe4vtBPQTkA7wSHsBKg5CSTQq83Lozdf60t+tSHPABDyacHTHpLfclwYtsGCV/8JS0QEsNIBp124ue7jhf0b5fG1so1PSSFdWQcVTjVCySmCNaRTeFi4KI4BjkShxEbop8aeYbhvbMYHEHtPhQ9gNOTajgL5AGsfQEq8JEeAHAFyBMgRIEeAHIEtOgLCtJMr8Op0QDIT2/MDiEUml4FchiNzGUSCr9JtWF/V1mWo7S70avsKJX5CqY+wSf/AaJvsdBfpvbFW7uL+1PLmuDX2/hcodvlH/Q0ZAA=="); } importPys(); diff --git a/tests/reboot/greeter_rbt.golden.py b/tests/reboot/greeter_rbt.golden.py index f761c7d5..31f6afd4 100755 --- a/tests/reboot/greeter_rbt.golden.py +++ b/tests/reboot/greeter_rbt.golden.py @@ -17,7 +17,7 @@ # may be invalid (broken) if the generated code is mismatched with the installed # libraries. import reboot.versioning as IMPORT_reboot_versioning -IMPORT_reboot_versioning.check_generated_code_compatible("0.45.2") +IMPORT_reboot_versioning.check_generated_code_compatible("0.46.0") # ATTENTION: no types in this file should be imported with their unqualified # name (e.g. `from typing import Any`). That would cause clashes @@ -1131,7 +1131,8 @@ def convert_authorizer_rule_if_necessary( # realize where they are missing authorization). if authorizer_or_rule is None: return IMPORT_reboot_aio_auth_authorizers.DefaultAuthorizer( - 'Greeter' + 'Greeter', + is_user_type=False, ) if isinstance(authorizer_or_rule, IMPORT_reboot_aio_auth_authorizers.AuthorizerRule): @@ -2411,6 +2412,14 @@ async def run_Create( method='Create', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'Greet' == task.method_name: @@ -2477,6 +2486,14 @@ async def run_Greet( method='Greet', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'SetAdjective' == task.method_name: @@ -2546,6 +2563,14 @@ async def run_SetAdjective( method='SetAdjective', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TransactionSetAdjective' == task.method_name: @@ -2615,6 +2640,14 @@ async def run_TransactionSetAdjective( method='TransactionSetAdjective', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TryToConstructContext' == task.method_name: @@ -2681,6 +2714,14 @@ async def run_TryToConstructContext( method='TryToConstructContext', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TryToConstructExternalContext' == task.method_name: @@ -2747,6 +2788,14 @@ async def run_TryToConstructExternalContext( method='TryToConstructExternalContext', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TestLongRunningFetch' == task.method_name: @@ -2813,6 +2862,14 @@ async def run_TestLongRunningFetch( method='TestLongRunningFetch', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'TestLongRunningWriter' == task.method_name: @@ -2882,6 +2939,14 @@ async def run_TestLongRunningWriter( method='TestLongRunningWriter', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'GetWholeState' == task.method_name: @@ -2948,6 +3013,14 @@ async def run_GetWholeState( method='GetWholeState', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'FailWithException' == task.method_name: @@ -3014,6 +3087,14 @@ async def run_FailWithException( method='FailWithException', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'FailWithAborted' == task.method_name: @@ -3080,6 +3161,14 @@ async def run_FailWithAborted( method='FailWithAborted', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'Workflow' == task.method_name: @@ -3134,60 +3223,54 @@ async def run_Workflow( raise @IMPORT_reboot_aio_internals_middleware.maybe_run_function_twice_to_validate_effects - async def run_Workflow_reactively( + async def run_Workflow_workflow( validating_effects: bool, context: IMPORT_reboot_aio_contexts.WorkflowContext, ): - async with self._state_manager.reactively( - context, - self._servicer.__state_type__, - # Already authorized when we created the task. - authorize=None, - ): - try: - # When we're validating effects we - # periodically timeout so that we can log - # that a workflow might be hung, i.e., the - # user has a bug. - task = IMPORT_asyncio.create_task( - run_Workflow( - context, - validating_effects=validating_effects, - ) + try: + # When we're validating effects we + # periodically timeout so that we can log + # that a workflow might be hung, i.e., the + # user has a bug. + task = IMPORT_asyncio.create_task( + run_Workflow( + context, + validating_effects=validating_effects, + ) + ) + timeout = None if not validating_effects else 5 # seconds + while True: + done, pending = await IMPORT_asyncio.wait( + [task], + timeout=timeout, ) - timeout = None if not validating_effects else 5 # seconds - while True: - done, pending = await IMPORT_asyncio.wait( - [task], - timeout=timeout, + # Check if we've timed out, which + # should only occur if we're + # validating effects. + if len(done) == 0: + assert validating_effects and timeout is not None + logger.warning( + f'Still waiting for method Greeter.Workflow ' + 'to complete after re-running to validate effects.' ) - # Check if we've timed out, which - # should only occur if we're - # validating effects. - if len(done) == 0: - assert validating_effects and timeout is not None - logger.warning( - f'Still waiting for method Greeter.Workflow ' - 'to complete after re-running to validate effects.' - ) - timeout += 5 # seconds - continue - return task.result() - finally: - if not task.done(): - task.cancel() - # Need to actually await the task so if - # there is an exception we don't get a - # warning logged that the exception was - # never retrieved, but we don't care about - # the exception because we're done with - # the task. - try: - await task - except: - pass - - return await run_Workflow_reactively( + timeout += 5 # seconds + continue + return task.result() + finally: + if not task.done(): + task.cancel() + # Need to actually await the task so if + # there is an exception we don't get a + # warning logged that the exception was + # never retrieved, but we don't care about + # the exception because we're done with + # the task. + try: + await task + except: + pass + + return await run_Workflow_workflow( self.create_context( headers=IMPORT_reboot_aio_headers.Headers( application_id=self.application_id, @@ -3198,6 +3281,14 @@ async def run_Workflow_reactively( method='Workflow', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'DangerousFields' == task.method_name: @@ -3267,6 +3358,14 @@ async def run_DangerousFields( method='DangerousFields', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'StoreRecursiveMessage' == task.method_name: @@ -3336,6 +3435,14 @@ async def run_StoreRecursiveMessage( method='StoreRecursiveMessage', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'ReadRecursiveMessage' == task.method_name: @@ -3402,6 +3509,14 @@ async def run_ReadRecursiveMessage( method='ReadRecursiveMessage', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) elif 'ConstructAndStoreRecursiveMessage' == task.method_name: @@ -3471,6 +3586,14 @@ async def run_ConstructAndStoreRecursiveMessage( method='ConstructAndStoreRecursiveMessage', context_type=IMPORT_reboot_aio_contexts.WorkflowContext, task=task, + # Propagate state manager and state type so that + # `until()` calls (via `WorkflowContext.retry_reactively_until()`) + # can enter `reactively()` scoped to each `until()` + # invocation. + reactively_state_manager=self._state_manager, + reactively_state_type=( + self._servicer.__state_type__ + ), ) ) @@ -3522,9 +3645,6 @@ async def __Create( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -3986,9 +4106,6 @@ async def __Greet( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -4430,9 +4547,6 @@ async def __SetAdjective( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -4894,9 +5008,6 @@ async def __TransactionSetAdjective( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -5061,6 +5172,25 @@ async def _TransactionSetAdjective( self._servicer.__state_type_name__, context._state_ref ) assert transaction is not None + # Re-check for an idempotent mutation now that we hold the + # transaction semaphore. This is a fix for a potential race: + # https://github.com/reboot-dev/mono/issues/5361 + # + # If a previous call's commit ran (updating the bloom filter + # and, for constructors, making the state visible in memory + # via `self._states`) after our initial idempotent mutation + # check but before we acquired the semaphore, then without + # this re-check, constructors could raise + # `StateAlreadyConstructed` and non-constructors could + # re-execute the mutation, potentially corrupting state. + idempotent_mutation = await self._state_manager.check_for_idempotent_mutation( + context + ) + if idempotent_mutation is not None: + await self._state_manager.transaction_participant_abort(transaction) + response = tests.reboot.greeter_pb2.SetAdjectiveResponse() + response.ParseFromString(idempotent_mutation.response) + return response async with self._state_manager.transaction( context, self._servicer.__state_type__, @@ -5359,9 +5489,6 @@ async def __TryToConstructContext( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -5798,9 +5925,6 @@ async def __TryToConstructExternalContext( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -6237,9 +6361,6 @@ async def __TestLongRunningFetch( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -6681,9 +6802,6 @@ async def __TestLongRunningWriter( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -7145,9 +7263,6 @@ async def __GetWholeState( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -7584,9 +7699,6 @@ async def __FailWithException( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8023,9 +8135,6 @@ async def __FailWithAborted( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8462,9 +8571,6 @@ async def __Workflow( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -8849,9 +8955,6 @@ async def __DangerousFields( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -9318,9 +9421,6 @@ async def __StoreRecursiveMessage( tasks=context._tasks, _colocated_upserts=context._colocated_upserts, ) - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -9782,9 +9882,6 @@ async def __ReadRecursiveMessage( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -10221,9 +10318,6 @@ async def __ConstructAndStoreRecursiveMessage( context=context, ) return response - except IMPORT_reboot_aio_contexts.RetryReactively: - # Retrying reactively, just let this propagate. - raise except IMPORT_reboot_aio_contexts.EffectValidationRetry: # Doing effect validation, just let this propagate. raise @@ -10388,6 +10482,25 @@ async def _ConstructAndStoreRecursiveMessage( self._servicer.__state_type_name__, context._state_ref ) assert transaction is not None + # Re-check for an idempotent mutation now that we hold the + # transaction semaphore. This is a fix for a potential race: + # https://github.com/reboot-dev/mono/issues/5361 + # + # If a previous call's commit ran (updating the bloom filter + # and, for constructors, making the state visible in memory + # via `self._states`) after our initial idempotent mutation + # check but before we acquired the semaphore, then without + # this re-check, constructors could raise + # `StateAlreadyConstructed` and non-constructors could + # re-execute the mutation, potentially corrupting state. + idempotent_mutation = await self._state_manager.check_for_idempotent_mutation( + context + ) + if idempotent_mutation is not None: + await self._state_manager.transaction_participant_abort(transaction) + response = tests.reboot.greeter_pb2.ConstructAndStoreRecursiveMessageResponse() + response.ParseFromString(idempotent_mutation.response) + return response async with self._state_manager.transaction( context, self._servicer.__state_type__, @@ -10756,10 +10869,16 @@ async def _maybe_verify_token( method=method, context_type=IMPORT_reboot_aio_contexts.ReaderContext, ) as context: - return await self._token_verifier.verify_token( + result = await self._token_verifier.verify_token( context=context, token=headers.bearer_token, ) + if isinstance(result, IMPORT_rbt_v1alpha1.errors_pb2.Unauthenticated): + raise IMPORT_reboot_aio_aborted.SystemAborted( + result, + message=result.message or None, + ) + return result return None diff --git a/tests/reboot/greeter_rbt_react.golden.js b/tests/reboot/greeter_rbt_react.golden.js index 9db7e402..d317f266 100755 --- a/tests/reboot/greeter_rbt_react.golden.js +++ b/tests/reboot/greeter_rbt_react.golden.js @@ -18,8 +18,9 @@ import { Empty } from "@bufbuild/protobuf"; import * as reboot_react from "@reboot-dev/reboot-react"; import * as reboot_web from "@reboot-dev/reboot-web"; import * as reboot_api from "@reboot-dev/reboot-api"; -import React, { useEffect, useMemo, useState, } from "react"; +import React, { useEffect, useMemo, useRef, useState, } from "react"; import { v4 as uuidv4 } from "uuid"; +import { useRefreshMCPBearerToken } from "@reboot-dev/reboot-react/internal"; // NOTE NOTE NOTE // // If you are reading this comment because you are trying to debug @@ -3840,6 +3841,7 @@ export const useGreeter = ({ id }) => { const rebootClient = reboot_react.useRebootClient(); const url = rebootClient.url; const bearerToken = rebootClient.bearerToken; + const refreshMCPBearerToken = useRefreshMCPBearerToken(); const [instance, setInstance] = useState(() => { return GreeterInstance.use(id, stateRef, url); }); @@ -3943,6 +3945,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterGreetResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterGreetAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.Greet' aborted with ${aborted.message}`); setAborted(aborted); @@ -3980,13 +3989,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -4049,9 +4073,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/Greet`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterGreetResponseFromProtobufShape((greeter_pb.GreetResponse.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterGreetAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterGreetAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterGreetAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/Greet`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterGreetResponseFromProtobufShape((greeter_pb.GreetResponse.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterGreetAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.Greet' aborted with ${aborted.message}`); return { aborted }; @@ -4185,6 +4280,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterTryToConstructContextResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterTryToConstructContextAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TryToConstructContext' aborted with ${aborted.message}`); setAborted(aborted); @@ -4222,13 +4324,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -4291,9 +4408,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TryToConstructContext`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTryToConstructContextResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterTryToConstructContextAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterTryToConstructContextAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterTryToConstructContextAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TryToConstructContext`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTryToConstructContextResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterTryToConstructContextAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TryToConstructContext' aborted with ${aborted.message}`); return { aborted }; @@ -4361,6 +4549,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterTryToConstructExternalContextResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterTryToConstructExternalContextAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TryToConstructExternalContext' aborted with ${aborted.message}`); setAborted(aborted); @@ -4398,13 +4593,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -4467,9 +4677,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TryToConstructExternalContext`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTryToConstructExternalContextResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterTryToConstructExternalContextAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterTryToConstructExternalContextAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterTryToConstructExternalContextAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TryToConstructExternalContext`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTryToConstructExternalContextResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterTryToConstructExternalContextAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TryToConstructExternalContext' aborted with ${aborted.message}`); return { aborted }; @@ -4537,6 +4818,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterTestLongRunningFetchResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterTestLongRunningFetchAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TestLongRunningFetch' aborted with ${aborted.message}`); setAborted(aborted); @@ -4574,13 +4862,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -4643,9 +4946,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TestLongRunningFetch`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTestLongRunningFetchResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterTestLongRunningFetchAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterTestLongRunningFetchAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterTestLongRunningFetchAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/TestLongRunningFetch`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterTestLongRunningFetchResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterTestLongRunningFetchAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.TestLongRunningFetch' aborted with ${aborted.message}`); return { aborted }; @@ -4746,6 +5120,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterGetWholeStateResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterGetWholeStateAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.GetWholeState' aborted with ${aborted.message}`); setAborted(aborted); @@ -4783,13 +5164,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -4852,9 +5248,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/GetWholeState`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterGetWholeStateResponseFromProtobufShape((GreeterProto.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterGetWholeStateAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterGetWholeStateAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterGetWholeStateAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/GetWholeState`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterGetWholeStateResponseFromProtobufShape((GreeterProto.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterGetWholeStateAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.GetWholeState' aborted with ${aborted.message}`); return { aborted }; @@ -4922,6 +5389,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterFailWithExceptionResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterFailWithExceptionAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.FailWithException' aborted with ${aborted.message}`); setAborted(aborted); @@ -4959,13 +5433,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -5028,9 +5517,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/FailWithException`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterFailWithExceptionResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterFailWithExceptionAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterFailWithExceptionAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterFailWithExceptionAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/FailWithException`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterFailWithExceptionResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterFailWithExceptionAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.FailWithException' aborted with ${aborted.message}`); return { aborted }; @@ -5098,6 +5658,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterFailWithAbortedResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterFailWithAbortedAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.FailWithAborted' aborted with ${aborted.message}`); setAborted(aborted); @@ -5135,13 +5702,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -5204,9 +5786,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/FailWithAborted`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterFailWithAbortedResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterFailWithAbortedAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterFailWithAbortedAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterFailWithAbortedAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/FailWithAborted`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterFailWithAbortedResponseFromProtobufShape((Empty.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterFailWithAbortedAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.FailWithAborted' aborted with ${aborted.message}`); return { aborted }; @@ -5340,6 +5993,13 @@ export const useGreeter = ({ id }) => { setAborted(undefined); setResponse(GreeterReadRecursiveMessageResponseFromProtobufShape(response)); }, setIsLoading, (status) => { + // If the server rejected us due to an expired + // token, refresh via the MCP host. The token + // change triggers a re-render and reconnect. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + refreshMCPBearerToken(); + } const aborted = GreeterReadRecursiveMessageAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.ReadRecursiveMessage' aborted with ${aborted.message}`); setAborted(aborted); @@ -5377,13 +6037,28 @@ export const useGreeter = ({ id }) => { // in order to set internal state on it, but on subsequent // renders it will recognize the same promise and return // without suspending. + // + // We need to store the suspense promise in a `useRef` so + // that we can continually return it even if the reader is + // changing due to things like the `bearerToken` changing, + // however, we don't want to flicker the suspense fallback + // when `bearerToken` changes after we've already received + // a stable `response` (or `aborted`). + const suspensePromiseRef = useRef(undefined); const suspensePromise = useMemo(() => { - if (!options.suspense || reader.event.isSet()) { - return Promise.resolve(); + if (suspensePromiseRef.current === undefined || (response === undefined && aborted === undefined)) { + if (!options.suspense || reader.event.isSet()) { + suspensePromiseRef.current = Promise.resolve(); + } + else { + reboot_api.assert(reader.promise !== undefined); + reboot_api.assert(response === undefined); + reboot_api.assert(aborted === undefined); + suspensePromiseRef.current = reader.promise.then(() => { }); + } } - reboot_api.assert(reader.promise !== undefined); - return reader.promise.then(() => { }); - }, [reader, options.suspense]); + return suspensePromiseRef.current; + }, [options.suspense, reader, response, aborted]); if (options.suspense) { if (!("use" in React)) { // Raise if it doesn't look like we are using React>=19. @@ -5446,9 +6121,80 @@ export const useGreeter = ({ id }) => { if (aborted) { return { aborted }; } + else if (response.status === 401 && refreshMCPBearerToken) { + // Token expired — refresh via MCP host and retry once. + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/ReadRecursiveMessage`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterReadRecursiveMessageResponseFromProtobufShape((greeter_pb.ReadRecursiveMessageResponse.fromJson(await retryResponse.json()))) + }; + } + // Fall through to generic error handling on retry failure. + return { + aborted: new GreeterReadRecursiveMessageAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unknown error with HTTP status ${retryResponse.status} after token refresh` + }) + }; + } + catch (e) { + return { + aborted: new GreeterReadRecursiveMessageAborted(new reboot_api.errors_pb.Aborted(), { + message: e instanceof Error + ? `${e}` + : `Unknown error: ${JSON.stringify(e)}` + }) + }; + } + } + // Refresh failed — fall through to generic error. + return { + aborted: new GreeterReadRecursiveMessageAborted(new reboot_api.errors_pb.Unknown(), { + message: `Unauthorized (HTTP 401) and token refresh failed` + }) + }; + } else if (!response.ok) { if (response.headers.get("content-type") === "application/json") { const status = reboot_api.Status.fromJson(await response.json()); + // If the server rejected us due to an expired + // token, refresh via the MCP host and retry once. + if (status.code === reboot_api.StatusCode.UNAUTHENTICATED && + refreshMCPBearerToken) { + const newToken = await refreshMCPBearerToken(); + if (newToken) { + const retryHeaders = new Headers(); + retryHeaders.set("Content-Type", "application/json"); + retryHeaders.append("Connection", "keep-alive"); + retryHeaders.append("Authorization", `Bearer ${newToken}`); + try { + const retryResponse = await reboot_web.guardedFetch(new Request(`${rebootClient.url}/__/reboot/rpc/${stateRef}/tests.reboot.GreeterMethods/ReadRecursiveMessage`, { + method: "POST", + headers: retryHeaders, + body: request.toJsonString() + }), options); + if (retryResponse.ok) { + return { + response: GreeterReadRecursiveMessageResponseFromProtobufShape((greeter_pb.ReadRecursiveMessageResponse.fromJson(await retryResponse.json()))) + }; + } + } + catch (_a) { + // Fall through to return the original + // aborted error. + } + } + } const aborted = GreeterReadRecursiveMessageAborted.fromStatus(status); console.warn(`[Reboot] 'Greeter.ReadRecursiveMessage' aborted with ${aborted.message}`); return { aborted }; diff --git a/tests/reboot/idempotency_tests.py b/tests/reboot/idempotency_tests.py index 37ee2731..e72e8292 100644 --- a/tests/reboot/idempotency_tests.py +++ b/tests/reboot/idempotency_tests.py @@ -1,6 +1,7 @@ import unittest import uuid from datetime import timedelta +from rbt.v1alpha1.errors_pb2 import StateAlreadyConstructed from reboot.aio.applications import Application from reboot.aio.call import Options from reboot.aio.idempotency import ( @@ -314,6 +315,76 @@ async def test_idempotency_failed_mutation_violation(self) -> None: str(error.exception), ) + async def test_constructors_are_idempotent(self) -> None: + """ + Tests that calling a constructor idempotently twice with + the same alias and parameters succeeds both times, rather + than raising `StateAlreadyConstructed` on the second call. + """ + await self.rbt.up(Application(servicers=[AccountServicer])) + + context = self.rbt.create_external_context(name=self.id()) + + # First call: construct the account idempotently. + state_id = 'idempotent-constructor-test' + account1, _ = await Account.idempotently( + 'open account', + ).Open(context, state_id) + + # Second call: exact same alias, context, and state ID. This + # must succeed (not raise `StateAlreadyConstructed`). + account2, _ = await Account.idempotently( + 'open account', + ).Open(context, state_id) + + # Both calls should return the same state. + self.assertEqual(account1.state_id, state_id) + self.assertEqual(account2.state_id, state_id) + + # Verify that calling the constructor *without* idempotency on + # the same state ID does raise `StateAlreadyConstructed`. + with self.assertRaises(Account.OpenAborted) as aborted: + await Account.Open(context, state_id) + + self.assertEqual( + type(aborted.exception.error), StateAlreadyConstructed + ) + + async def test_non_constructor_transactions_are_idempotent( + self, + ) -> None: + """ + Tests that calling a non-constructor transaction idempotently + twice with the same alias will elide the second execution, + rather than re-executing the mutation on the second call. + """ + await self.rbt.up( + Application(servicers=[AccountServicer, BankServicer]) + ) + + context = self.rbt.create_external_context(name=self.id()) + + bank, _ = await Bank.Create(context, SINGLETON_BANK_ID) + + # First call: sign up the account idempotently. + await bank.idempotently('sign up jonathan').SignUp( + context, + account_id='jonathan', + ) + + # Second call: exact same alias, context, and arguments. This + # must succeed by returning the cached response, not by + # re-executing the mutation (which would fail because 'jonathan' + # is already in `bank.state.account_ids`). + await bank.idempotently('sign up jonathan').SignUp( + context, + account_id='jonathan', + ) + + # Sanity-check: a non-idempotent re-sign-up must still fail. + with self.assertRaises(Bank.SignUpAborted): + await bank.SignUp(context, account_id='jonathan') + async def test_idempotently_generate_id(self) -> None: created_id: Optional[str] = None diff --git a/tests/reboot/nodejs/auth_integration_test/package.json b/tests/reboot/nodejs/auth_integration_test/package.json index 4e575288..f507e61e 100644 --- a/tests/reboot/nodejs/auth_integration_test/package.json +++ b/tests/reboot/nodejs/auth_integration_test/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "5.4.5", "@types/node": "20.11.5" } diff --git a/tests/reboot/nodejs/input_error_integration_test/package.json b/tests/reboot/nodejs/input_error_integration_test/package.json index 4e575288..f507e61e 100644 --- a/tests/reboot/nodejs/input_error_integration_test/package.json +++ b/tests/reboot/nodejs/input_error_integration_test/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@reboot-dev/reboot": "0.45.2", + "@reboot-dev/reboot": "0.46.0", "typescript": "5.4.5", "@types/node": "20.11.5" } diff --git a/tests/reboot/nodejs/yarn_zod_test/backend/package.json b/tests/reboot/nodejs/yarn_zod_test/backend/package.json index baeebebf..6b0f5817 100644 --- a/tests/reboot/nodejs/yarn_zod_test/backend/package.json +++ b/tests/reboot/nodejs/yarn_zod_test/backend/package.json @@ -8,9 +8,9 @@ }, "dependencies": { "@monorepo/api": "workspace:*", - "@reboot-dev/reboot": "0.45.2", - "@reboot-dev/reboot-api": "0.45.2", - "@reboot-dev/reboot-std": "0.45.2", + "@reboot-dev/reboot": "0.46.0", + "@reboot-dev/reboot-api": "0.46.0", + "@reboot-dev/reboot-std": "0.46.0", "tsx": "^4.20.3" } } diff --git a/tests/reboot/nodejs/yarn_zod_test/yarn.lock b/tests/reboot/nodejs/yarn_zod_test/yarn.lock index fbce92b8..7ea99763 100644 --- a/tests/reboot/nodejs/yarn_zod_test/yarn.lock +++ b/tests/reboot/nodejs/yarn_zod_test/yarn.lock @@ -431,9 +431,9 @@ __metadata: resolution: "@monorepo/backend@workspace:backend" dependencies: "@monorepo/api": "workspace:*" - "@reboot-dev/reboot": "npm:0.45.2" - "@reboot-dev/reboot-api": "npm:0.45.2" - "@reboot-dev/reboot-std": "npm:0.45.2" + "@reboot-dev/reboot": "npm:0.46.0" + "@reboot-dev/reboot-api": "npm:0.46.0" + "@reboot-dev/reboot-std": "npm:0.46.0" tsx: "npm:^4.20.3" languageName: unknown linkType: soft @@ -467,46 +467,46 @@ __metadata: languageName: node linkType: hard -"@reboot-dev/reboot-api@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-api@npm:0.45.2" +"@reboot-dev/reboot-api@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-api@npm:0.46.0" dependencies: "@scarf/scarf": "npm:1.4.0" typescript: "npm:5.4.5" zod: "npm:^3.25.51" peerDependencies: "@bufbuild/protobuf": 1.10.1 - checksum: 10c0/7f27d7b729dd96d847a82d10cc91bfda03fce6ad824789d42e3e35c33265a9c1669ad983d348e4acaf79c41b2f0dd85531768d76d771ecbd6dfc29cdf6c2fe3b + checksum: 10c0/988d942f13c68029429e5c763a65e952f4b1fa029ba3445d2c5481a986f96e424bfbcb07f1a957198763bec6779cccf64d13900c0334999f598b23c6168217e3 languageName: node linkType: hard -"@reboot-dev/reboot-std-api@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-std-api@npm:0.45.2" +"@reboot-dev/reboot-std-api@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-std-api@npm:0.46.0" dependencies: "@scarf/scarf": "npm:1.4.0" - checksum: 10c0/a565be630fd4366a10ef671a490adc9a5495aef4010976e08e267696242e4d65f03c2665a4824a7b312167907e50874491f8c970babe931288e2dcebea098d93 + checksum: 10c0/7c30d03d068d088e455bd654ae0b4dbe8d61c01d1d5c6045adf78800ff5f5f95c2cfbc25256c9ad386a5c90e8090739eccdc2aec1f74cfb198919dd7743b04b7 languageName: node linkType: hard -"@reboot-dev/reboot-std@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot-std@npm:0.45.2" +"@reboot-dev/reboot-std@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot-std@npm:0.46.0" dependencies: - "@reboot-dev/reboot": "npm:0.45.2" - "@reboot-dev/reboot-std-api": "npm:0.45.2" + "@reboot-dev/reboot": "npm:0.46.0" + "@reboot-dev/reboot-std-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" - checksum: 10c0/28d1bd150485944e17bb4b073fb0f6e000d8f2d3849e8cf7a66609294b9ebea225f0a5504396ebbe1999134c1756672cab83fc2edb31513ce4f012728cc30606 + checksum: 10c0/fdfa211549c9c1f5ea77e8a231f2b108f865c64d6ae6b4fc499e7669304495c5b1a8ca9ed0af722a4e8cfee0178802254bb31a5c0d726f05554bab253a524e11 languageName: node linkType: hard -"@reboot-dev/reboot@npm:0.45.2": - version: 0.45.2 - resolution: "@reboot-dev/reboot@npm:0.45.2" +"@reboot-dev/reboot@npm:0.46.0": + version: 0.46.0 + resolution: "@reboot-dev/reboot@npm:0.46.0" dependencies: "@bufbuild/protoc-gen-es": "npm:1.10.1" "@bufbuild/protoplugin": "npm:1.10.1" - "@reboot-dev/reboot-api": "npm:0.45.2" + "@reboot-dev/reboot-api": "npm:0.46.0" "@scarf/scarf": "npm:1.4.0" "@standard-schema/spec": "npm:1.0.0" chalk: "npm:^4.1.2" @@ -527,7 +527,7 @@ __metadata: rbt: rbt.js rbt-esbuild: rbt-esbuild.js zod-to-proto: zod-to-proto.js - checksum: 10c0/2d233aa036c63e05baeae34f62a9f7f2d593668f58e9f6269169f01899aa9ca776ca42e340a186246ab5118e2229a00e5a7f4e3bc68ce6cca3d92dfa39ee4a4b + checksum: 10c0/66d746c93408bc2fcf4c56b0ab508d4ebdd9fad480b5bffaa86de35b2ea90123cf2e4bba783970109555957f6b08e6985a0331eb904a477b4d8d48a1546dcbb9 languageName: node linkType: hard diff --git a/tests/reboot/ping/BUILD.bazel b/tests/reboot/ping/BUILD.bazel index 813b0137..4664449c 100644 --- a/tests/reboot/ping/BUILD.bazel +++ b/tests/reboot/ping/BUILD.bazel @@ -16,6 +16,8 @@ py_test( "//reboot/aio:applications_py", "//reboot/ping:ping_py", "//reboot/aio:tests_py", + "//tests/reboot:test_token_verifier_py", requirement("httpx"), + requirement("PyJWT"), ], ) diff --git a/tests/reboot/ping/ping_test.py b/tests/reboot/ping/ping_test.py index c0ad6001..0210c527 100644 --- a/tests/reboot/ping/ping_test.py +++ b/tests/reboot/ping/ping_test.py @@ -1,17 +1,22 @@ import httpx import json +import os +import time import unittest from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client +from rbt.v1alpha1.errors_pb2 import PermissionDenied, Unauthenticated from reboot.aio.applications import Application from reboot.aio.tests import Reboot from reboot.ping.ping import ( CounterServicer, PingServicer, PongServicer, - SessionServicer, + UserServicer, ) -from reboot.ping.ping_api_rbt import Counter, Ping, Pong +from reboot.ping.ping_api_rbt import Counter, Ping, Pong, User, UserAuthorizer +from reboot.settings import ENVVAR_REBOOT_OAUTH_SIGNING_SECRET +from unittest import mock class PingTest(unittest.IsolatedAsyncioTestCase): @@ -25,14 +30,7 @@ async def asyncTearDown(self): async def test_ping_periodically(self): await self.rbt.up( - Application( - servicers=[ - PingServicer, - PongServicer, - SessionServicer, - CounterServicer, - ], - ), + Application(servicers=[PingServicer, PongServicer]), ) context = self.rbt.create_external_context(name=f"test-{self.id()}") @@ -56,20 +54,11 @@ async def test_ping_periodically(self): self.assertEqual(pong_response.num_pongs, num_pings) async def test_counter(self): - await self.rbt.up( - Application( - servicers=[ - PingServicer, - PongServicer, - SessionServicer, - CounterServicer, - ], - ), - ) + await self.rbt.up(Application(servicers=[CounterServicer])) context = self.rbt.create_external_context(name=f"test-{self.id()}") - counter, _ = await Counter.create(context) + counter, _ = await Counter.create(context, description="test counter") response = await counter.increment(context) self.assertEqual(response.value, 1) @@ -83,116 +72,216 @@ async def test_counter(self): async def test_counter_over_mcp(self): await self.rbt.up( Application( - servicers=[ - PingServicer, - PongServicer, - SessionServicer, - CounterServicer, - ], + servicers=[UserServicer, CounterServicer], ), ) mcp_url = self.rbt.http_localhost_url("/mcp") + access_token = self.rbt.make_valid_oauth_access_token() - async with streamable_http_client(mcp_url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {access_token}", + }, + follow_redirects=True, + ) as http_client: + async with streamable_http_client( + mcp_url, + http_client=http_client, + ) as ( read_stream, write_stream, - ) as session: - await session.initialize() - - # Verify tools are listed. - tools = await session.list_tools() - tool_names = [t.name for t in tools.tools] - self.assertIn("create_counter", tool_names) - self.assertIn("counter_increment", tool_names) - self.assertIn("counter_value", tool_names) - - # Create a counter via the Session tool. - result = await session.call_tool("create_counter", {}) - data = json.loads(result.content[0].text) - counter_id = data["counter_id"] - - # Increment twice, passing the counter ID. - await session.call_tool( - "counter_increment", - {"counter_id": counter_id}, - ) - await session.call_tool( - "counter_increment", - {"counter_id": counter_id}, - ) - - # Read value via tool and verify count. - result = await session.call_tool( - "counter_value", - {"counter_id": counter_id}, - ) - data = json.loads(result.content[0].text) - self.assertEqual(data["value"], 2) + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + + # Verify tools are listed. + tools = await session.list_tools() + tool_names = [t.name for t in tools.tools] + self.assertIn("create_counter", tool_names) + self.assertIn("whoami", tool_names) + self.assertIn("counter_increment", tool_names) + self.assertIn("counter_value", tool_names) + + # Create a counter via the User tool. + result = await session.call_tool( + "create_counter", + {"request": { + "description": "test counter" + }}, + ) + data = json.loads(result.content[0].text) + counter_id = data["counter_id"] + + # Increment twice, passing the counter + # ID. + await session.call_tool( + "counter_increment", + {"counter_id": counter_id}, + ) + await session.call_tool( + "counter_increment", + {"counter_id": counter_id}, + ) + + # Read value via tool and verify count. + result = await session.call_tool( + "counter_value", + {"counter_id": counter_id}, + ) + data = json.loads(result.content[0].text) + self.assertEqual(data["value"], 2) + + # Verify list_counters returns the + # counter we created. + result = await session.call_tool("list_counters", {}) + data = json.loads(result.content[0].text) + self.assertEqual(len(data["counters"]), 1) + counter0 = data["counters"][0] + self.assertEqual( + counter0["counter_id"], + counter_id, + ) + self.assertEqual( + counter0["description"], + "test counter", + ) + + async def test_whoami_with_anonymous_auth(self): + """Verify that Anonymous OAuth populates user_id, + and that unauthenticated requests get a 401.""" + await self.rbt.up( + Application( + servicers=[UserServicer, CounterServicer], + ), + ) + + mcp_url = self.rbt.http_localhost_url("/mcp") + + # Step 0: connecting without a bearer token should + # get HTTP 401, telling the client to authenticate. + async with httpx.AsyncClient( + follow_redirects=True, + ) as client: + response = await client.post( + mcp_url, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + }, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + ) + self.assertEqual(response.status_code, 401) + + # With a valid bearer token, whoami should return + # the user_id from the JWT. + access_token = self.rbt.make_valid_oauth_access_token() + + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {access_token}", + }, + follow_redirects=True, + ) as http_client: + async with streamable_http_client( + mcp_url, + http_client=http_client, + ) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + + result = await session.call_tool("whoami", {}) + data = json.loads(result.content[0].text) + self.assertIn("user_id", data) + # The default test user from + # `make_bearer_token`. + self.assertEqual(data["user_id"], "test-user") async def test_ui_tool_ids_mapping(self): await self.rbt.up( Application( servicers=[ - PingServicer, - PongServicer, - SessionServicer, - CounterServicer, + UserServicer, CounterServicer, PingServicer, PongServicer ], ), ) mcp_url = self.rbt.http_localhost_url("/mcp") + access_token = self.rbt.make_valid_oauth_access_token() - async with streamable_http_client(mcp_url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {access_token}", + }, + follow_redirects=True, + ) as http_client: + async with streamable_http_client( + mcp_url, + http_client=http_client, + ) as ( read_stream, write_stream, - ) as session: - await session.initialize() - - tools = await session.list_tools() - tool_names = [t.name for t in tools.tools] - self.assertIn("ping_show_pinger", tool_names) - self.assertIn("counter_show_clicker", tool_names) - - # The `show_pinger` UI tool should return an `ids` - # mapping that includes both the Ping ID (passed - # explicitly) and the Session ID - # (auto-constructed). - result = await session.call_tool( - "ping_show_pinger", {"ping_id": "my-ping"} - ) - data = json.loads(result.content[0].text) - ids = data["ids"] - self.assertEqual(ids["reboot.ping.Ping"], "my-ping") - session_id = ids["reboot.ping.Session"] - self.assertIsInstance(session_id, str) - self.assertGreater(len(session_id), 0) - - # Create a counter so we can show its clicker. - result = await session.call_tool("create_counter", {}) - data = json.loads(result.content[0].text) - counter_id = data["counter_id"] - - # The `counter_show_clicker` UI tool should - # return an `ids` mapping with the Counter ID. - result = await session.call_tool( - "counter_show_clicker", - {"counter_id": counter_id}, - ) - data = json.loads(result.content[0].text) - ids = data["ids"] - self.assertEqual(ids["reboot.ping.Counter"], counter_id) + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + + tools = await session.list_tools() + tool_names = [t.name for t in tools.tools] + self.assertIn("ping_show_pinger", tool_names) + self.assertIn("counter_show_clicker", tool_names) + + # The `show_pinger` UI tool should return an + # `ids` mapping that includes the Ping ID + # (passed explicitly) and all auto-construct + # IDs (Session and User). + result = await session.call_tool( + "ping_show_pinger", + {"ping_id": "my-ping"}, + ) + data = json.loads(result.content[0].text) + ids = data["ids"] + self.assertEqual( + ids["reboot.ping.Ping"], + "my-ping", + ) + user_id = ids["reboot.ping.User"] + self.assertIsInstance(user_id, str) + self.assertGreater(len(user_id), 0) + + # Create a counter so we can show its clicker. + result = await session.call_tool( + "create_counter", + {"request": { + "description": "test counter" + }}, + ) + data = json.loads(result.content[0].text) + counter_id = data["counter_id"] + + # The `counter_show_clicker` UI tool should + # return an `ids` mapping with the Counter ID. + result = await session.call_tool( + "counter_show_clicker", + {"counter_id": counter_id}, + ) + data = json.loads(result.content[0].text) + ids = data["ids"] + self.assertEqual(ids["reboot.ping.Counter"], counter_id) async def test_ui_resource_metadata(self): """Verify UI resources include CSP metadata. @@ -207,65 +296,93 @@ async def test_ui_resource_metadata(self): servicers=[ PingServicer, PongServicer, - SessionServicer, + UserServicer, CounterServicer, ], ), ) mcp_url = self.rbt.http_localhost_url("/mcp") + access_token = self.rbt.make_valid_oauth_access_token() - async with streamable_http_client(mcp_url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {access_token}", + }, + follow_redirects=True, + ) as http_client: + async with streamable_http_client( + mcp_url, + http_client=http_client, + ) as ( read_stream, write_stream, - ) as session: - await session.initialize() - - # UI resources use URI templates (e.g., - # `ui://counter/{ui}`), so they appear in - # `list_resource_templates`, not - # `list_resources`. - templates = (await session.list_resource_templates()) - template_uris = [ - str(t.uriTemplate) for t in templates.resourceTemplates - ] - self.assertTrue( - any("ui://" in uri for uri in template_uris), - f"Expected ui:// resource templates, " - f"got: {template_uris}", - ) - - # Read the clicker UI resource. - result = await session.read_resource( - "ui://counter/show_clicker" - ) - self.assertEqual(len(result.contents), 1) - - content = result.contents[0] - - # Should be HTML. - self.assertIn( - "text/html", - content.mimeType or "", - ) - - # Verify CSP metadata was injected. - self.assertIsNotNone( - content.meta, - "Expected `_meta` with CSP metadata on resource content", - ) - ui_meta = content.meta.get("ui", {}) - csp = ui_meta.get("csp", {}) - self.assertIn("connectDomains", csp) - self.assertIn("frameDomains", csp) - # Domains should contain the Reboot server URL and - # Reboot websocket URL. - self.assertEqual(len(csp["connectDomains"]), 2) + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + + # UI resources use URI templates (e.g., + # `ui://counter/{ui}`), so they appear in + # `list_resource_templates`, not + # `list_resources`. + templates = (await session.list_resource_templates()) + template_uris = [ + str(t.uriTemplate) for t in templates.resourceTemplates + ] + self.assertTrue( + any("ui://" in uri for uri in template_uris), + f"Expected ui:// resource templates, " + f"got: {template_uris}", + ) + + # Read the clicker UI resource. + result = await session.read_resource( + "ui://counter/show_clicker" + ) + self.assertEqual(len(result.contents), 1) + + content = result.contents[0] + + # Should be HTML. + self.assertIn("text/html", content.mimeType or "") + + # Verify CSP metadata was injected. + self.assertIsNotNone( + content.meta, + "Expected `_meta` with CSP metadata on resource content", + ) + ui_meta = content.meta.get("ui", {}) + csp = ui_meta.get("csp", {}) + self.assertIn("connectDomains", csp) + self.assertIn("frameDomains", csp) + # Domains should contain the Reboot server URL and + # Reboot websocket URL. + self.assertEqual(len(csp["connectDomains"]), 2) + + async def test_user_without_oauth_raises(self): + """ + Registering a `User` servicer without `Application(oauth=...)` + should raise at `Application` startup when not in `rbt dev` or a + unit test. + """ + # Temporarily unset the signing secret so that the `Application` + # constructor believes that we're NOT a unit test; otherwise it + # would silently default to `oauth=Anonymous()`. + with mock.patch.dict( + os.environ, + {ENVVAR_REBOOT_OAUTH_SIGNING_SECRET: ""}, + clear=False, + ): + with self.assertRaises(ValueError) as context: + Application(servicers=[UserServicer, CounterServicer]) + self.assertIn( + "requires OAuth to identify the user", + str(context.exception), + ) async def test_mcp_no_tools_returns_501(self): # An application with only PongServicer (which has @@ -298,6 +415,363 @@ async def test_mcp_no_tools_returns_501(self): body = response.json() self.assertEqual(body["error"], "no_mcp_tools") + async def test_mcp_expired_token_returns_401(self): + await self.rbt.up( + Application( + servicers=[UserServicer, CounterServicer], + ), + ) + + mcp_url = self.rbt.http_localhost_url("/mcp") + valid_token = self.rbt.make_valid_oauth_access_token() + + mcp_init_body = { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1.0", + }, + }, + } + + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + + # A valid (non-expired) token should be accepted. + valid_token = self.rbt.make_valid_oauth_access_token() + + async with httpx.AsyncClient( + follow_redirects=True, + ) as client: + response = await client.post( + mcp_url, + json=mcp_init_body, + headers={ + **mcp_headers, + "Authorization": f"Bearer {valid_token}", + }, + ) + self.assertEqual(response.status_code, 200) + + # An expired token should get HTTP 401. + expired_token = self.rbt.make_jwt( + type="access", + sub="test-user", + aud="reboot-mcp", + exp=int(time.time()) - 10, + ) + + async with httpx.AsyncClient( + follow_redirects=True, + ) as client: + response = await client.post( + mcp_url, + json=mcp_init_body, + headers={ + **mcp_headers, + "Authorization": f"Bearer {expired_token}", + }, + ) + self.assertEqual(response.status_code, 401) + # `invalid_token` is the standard error code for expired or + # invalid bearer tokens per RFC 6750 Section 3.1. + self.assertIn( + "invalid_token", + response.headers.get("www-authenticate", ""), + ) + + async def test_user_auto_constructed(self): + """Verify User state is auto-constructed from the JWT sub claim.""" + await self.rbt.up( + Application( + servicers=[UserServicer, CounterServicer], + ), + ) + + mcp_url = self.rbt.http_localhost_url("/mcp") + user_id = "test-user-for-auto-construct" + access_token = self.rbt.make_valid_oauth_access_token(user_id=user_id) + + # Connect twice with the same user ID (different MCP sessions). + # The first session should auto-construct `User`. The second + # should silently skip creation and still work. + for i in range(2): + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {access_token}", + }, + follow_redirects=True, + ) as http_client: + async with streamable_http_client( + mcp_url, + http_client=http_client, + ) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + + # The `whoami` tool is on the auto-constructed + # `User` state type. Being able to call it + # proves that a `User` exists. + result = await session.call_tool("whoami", {}) + data = json.loads(result.content[0].text) + self.assertEqual(data["user_id"], user_id) + + async def test_refresh_token_produces_valid_access_token(self): + await self.rbt.up( + Application( + servicers=[UserServicer, CounterServicer], + ), + ) + + token_url = self.rbt.http_localhost_url("/__/oauth/token") + mcp_url = self.rbt.http_localhost_url("/mcp") + + # Mint an expired access token and a valid refresh + # token, as if the client had previously + # authenticated and the access token has since + # expired. + client_id = self.rbt.make_jwt( + type="client", + redirect_uris=["http://localhost/callback"], + exp=int(time.time()) + 3600, + ) + + expired_access_token = self.rbt.make_jwt( + type="access", + sub="test-user", + aud="reboot-mcp", + exp=int(time.time()) - 10, + ) + + refresh_token = self.rbt.make_jwt( + type="refresh", + sub="test-user", + client_id=client_id, + exp=int(time.time()) + 30 * 24 * 3600, + ) + + async with httpx.AsyncClient( + follow_redirects=True, + ) as client: + # The expired access token should be rejected. + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + response = await client.post( + mcp_url, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1.0", + }, + }, + }, + headers={ + **mcp_headers, + "Authorization": + f"Bearer {expired_access_token}", + }, + ) + self.assertEqual(response.status_code, 401) + + # Use the refresh token to get new tokens. + response = await client.post( + token_url, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + }, + ) + self.assertEqual(response.status_code, 200) + token_data = response.json() + + new_access_token = token_data["access_token"] + new_refresh_token = token_data["refresh_token"] + + # The response should include both tokens. + self.assertIsInstance(new_access_token, str) + self.assertIsInstance(new_refresh_token, str) + self.assertEqual(token_data["token_type"], "bearer") + self.assertIn("expires_in", token_data) + + # The new refresh token should differ from the + # old one (token rotation). + self.assertNotEqual(new_refresh_token, refresh_token) + + # The new access token should be accepted by + # the MCP endpoint. + response = await client.post( + mcp_url, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1.0", + }, + }, + }, + headers={ + **mcp_headers, + "Authorization": + f"Bearer {new_access_token}", + }, + ) + self.assertEqual(response.status_code, 200) + + async def test_user_default_auth(self): + """ + Verify User default auth: owner and app-internal are allowed; + wrong user gets PermissionDenied; unauthenticated gets + Unauthenticated. + """ + await self.rbt.up( + Application( + servicers=[UserServicer, CounterServicer], + ), + ) + + owner_id = "owner-user" + other_id = "other-user" + + owner_token = self.rbt.make_valid_oauth_access_token(user_id=owner_id) + other_token = self.rbt.make_valid_oauth_access_token(user_id=other_id) + + # App-internal context can call User methods (no + # bearer token needed for app-internal). + internal_context = self.rbt.create_external_context( + name=f"test-{self.id()}-internal", + app_internal=True, + ) + + # Create the User state (app-internal call). + await User.create(internal_context, owner_id) + + # Owner can call their own User's methods. + owner_context = self.rbt.create_external_context( + name=f"test-{self.id()}-owner", + bearer_token=owner_token, + ) + response = await User.ref(owner_id).whoami(owner_context) + self.assertEqual(response.user_id, owner_id) + + # A different user gets PermissionDenied. + other_context = self.rbt.create_external_context( + name=f"test-{self.id()}-other", + bearer_token=other_token, + ) + with self.assertRaises(User.WhoamiAborted) as aborted: + await User.ref(owner_id).whoami(other_context) + self.assertIsInstance(aborted.exception.error, PermissionDenied) + + # No token at all gets Unauthenticated. + noauth_context = self.rbt.create_external_context( + name=f"test-{self.id()}-noauth", + ) + with self.assertRaises(User.WhoamiAborted) as aborted: + await User.ref(owner_id).whoami(noauth_context) + self.assertIsInstance(aborted.exception.error, Unauthenticated) + + async def test_user_generated_authorizer_default(self): + """Test the generated `UserAuthorizer` default rule. + + There are _two_ kinds of default authorizers: the + `DefaultAuthorizer` that's used when a developer specifies no + authorizer at all, and the default implementation of the methods + on the generated `YourTypeAuthorizer` base class that are active + when the developer chooses a custom authorizer but doesn't + implement all the methods. This test tests the second case, for + `User`. + + The generated `UserAuthorizer` defaults to + `allow_if(any=[state_id_is_user_id, is_app_internal])`. This + test verifies that rule by using a servicer that explicitly + returns `UserAuthorizer()`. + """ + + # A servicer that uses the generated authorizer with its default + # rule (rather than relying on `DefaultAuthorizer`). + class UserWithGeneratedAuth(UserServicer): + + def authorizer(self): + return UserAuthorizer() + + await self.rbt.up( + Application( + servicers=[UserWithGeneratedAuth, CounterServicer], + ), + ) + + owner_id = "gen-auth-owner" + other_id = "gen-auth-other" + + owner_token = self.rbt.make_valid_oauth_access_token(user_id=owner_id) + other_token = self.rbt.make_valid_oauth_access_token(user_id=other_id) + + # Create User via app-internal context. + internal_context = self.rbt.create_external_context( + name=f"test-{self.id()}-internal", + app_internal=True, + ) + await User.create(internal_context, owner_id) + + # Owner can call their own User. + owner_context = self.rbt.create_external_context( + name=f"test-{self.id()}-owner", + bearer_token=owner_token, + ) + response = await User.ref(owner_id).whoami(owner_context) + self.assertEqual(response.user_id, owner_id) + + # Different user gets PermissionDenied. + other_context = self.rbt.create_external_context( + name=f"test-{self.id()}-other", + bearer_token=other_token, + ) + with self.assertRaises(User.WhoamiAborted) as aborted: + await User.ref(owner_id).whoami(other_context) + self.assertIsInstance(aborted.exception.error, PermissionDenied) + + # No token: `state_id_is_user_id` returns Unauthenticated, + # `is_app_internal` returns PermissionDenied. With `any=`, the + # presence of at least one PermissionDenied means the final + # result is PermissionDenied (not Unauthenticated). + noauth_context = self.rbt.create_external_context( + name=f"test-{self.id()}-noauth", + ) + with self.assertRaises(User.WhoamiAborted) as aborted: + await User.ref(owner_id).whoami(noauth_context) + self.assertIsInstance(aborted.exception.error, PermissionDenied) + if __name__ == '__main__': - unittest.main() + unittest.main(verbosity=2) diff --git a/tests/reboot/protoc/helpers.bzl b/tests/reboot/protoc/helpers.bzl index 17f1e492..88613df3 100644 --- a/tests/reboot/protoc/helpers.bzl +++ b/tests/reboot/protoc/helpers.bzl @@ -177,7 +177,6 @@ def success_echo_test( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = name + "_react_test_generated_main.py", py_test_tags = [ "requires-docker", diff --git a/tests/reboot/pydantic/schema_validation_errors/test.py b/tests/reboot/pydantic/schema_validation_errors/test.py index 627ac978..d74c4d54 100644 --- a/tests/reboot/pydantic/schema_validation_errors/test.py +++ b/tests/reboot/pydantic/schema_validation_errors/test.py @@ -349,58 +349,57 @@ def test_non_type_data_type(self): ) -class SessionStateValidationErrorsTest(unittest.TestCase): - """Test that Session state must be default-constructible.""" +class UserStateValidationErrorsTest(unittest.TestCase): + """Test that User state must be default-constructible.""" - def test_session_state_without_defaults_raises_error(self): - """Session state fields must all have defaults.""" + def test_user_state_without_defaults_raises_error(self): + """User state fields must all have defaults.""" - class BadSessionState(Model): + class BadUserState(Model): value: int = Field(tag=1) # No default! with self.assertRaises(UserPydanticError) as error: API( - Session=Type( - state=BadSessionState, + User=Type( + state=BadUserState, methods=Methods(), ), ) self.assertEqual( str(error.exception), - "Field `value` in Session state model " - "`BadSessionState` must have a default " - "value, or be optional. Session instances " + "Field `value` in User state model " + "`BadUserState` must have a default " + "value, or be optional. User instances " "are auto-constructed, in their default " - "(empty) state, for every new AI session " - "connecting to the application, and such " - "a fresh state must be valid.", + "(empty) state, and such a fresh state " + "must be valid.", ) - def test_session_state_with_defaults_is_valid(self): - """Session state with all defaults should be accepted.""" + def test_user_state_with_defaults_is_valid(self): + """User state with all defaults should be accepted.""" - class GoodSessionState(Model): + class GoodUserState(Model): value: int = Field(tag=1, default=0) # Should not raise. API( - Session=Type( - state=GoodSessionState, + User=Type( + state=GoodUserState, methods=Methods(), ), ) - def test_session_state_with_optional_field_is_valid(self): - """Session state with Optional fields is accepted.""" + def test_user_state_with_optional_field_is_valid(self): + """User state with Optional fields is accepted.""" - class GoodSessionState(Model): + class GoodUserState(Model): value: Optional[str] = Field(tag=1) # Should not raise: Optional fields default to None. API( - Session=Type( - state=GoodSessionState, + User=Type( + state=GoodUserState, methods=Methods(), ), ) diff --git a/tests/reboot/pydantic_web/default_values/BUILD.bazel b/tests/reboot/pydantic_web/default_values/BUILD.bazel index 025e8ab9..ccfd4909 100644 --- a/tests/reboot/pydantic_web/default_values/BUILD.bazel +++ b/tests/reboot/pydantic_web/default_values/BUILD.bazel @@ -87,7 +87,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", py_test_tags = [ "macos_not_supported", diff --git a/tests/reboot/pydantic_web/errors/BUILD.bazel b/tests/reboot/pydantic_web/errors/BUILD.bazel index a9d1731b..077f4377 100644 --- a/tests/reboot/pydantic_web/errors/BUILD.bazel +++ b/tests/reboot/pydantic_web/errors/BUILD.bazel @@ -88,7 +88,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", py_test_tags = [ "macos_not_supported", diff --git a/tests/reboot/pydantic_web/nested_types/BUILD.bazel b/tests/reboot/pydantic_web/nested_types/BUILD.bazel index 8f7f2211..5dc3ef09 100644 --- a/tests/reboot/pydantic_web/nested_types/BUILD.bazel +++ b/tests/reboot/pydantic_web/nested_types/BUILD.bazel @@ -108,7 +108,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", py_test_tags = [ "macos_not_supported", diff --git a/tests/reboot/pydantic_web/optional_fields/BUILD.bazel b/tests/reboot/pydantic_web/optional_fields/BUILD.bazel index 025e8ab9..ccfd4909 100644 --- a/tests/reboot/pydantic_web/optional_fields/BUILD.bazel +++ b/tests/reboot/pydantic_web/optional_fields/BUILD.bazel @@ -87,7 +87,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", py_test_tags = [ "macos_not_supported", diff --git a/tests/reboot/react/std/presence/BUILD.bazel b/tests/reboot/react/std/presence/BUILD.bazel index 684acbfe..4aeeb6c8 100644 --- a/tests/reboot/react/std/presence/BUILD.bazel +++ b/tests/reboot/react/std/presence/BUILD.bazel @@ -49,7 +49,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_200_websockets/BUILD.bazel b/tests/reboot/react/test_200_websockets/BUILD.bazel index bb9fe4ea..3f985178 100644 --- a/tests/reboot/react/test_200_websockets/BUILD.bazel +++ b/tests/reboot/react/test_200_websockets/BUILD.bazel @@ -50,7 +50,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_auth/BUILD.bazel b/tests/reboot/react/test_auth/BUILD.bazel index c27cc662..4e9da75d 100644 --- a/tests/reboot/react/test_auth/BUILD.bazel +++ b/tests/reboot/react/test_auth/BUILD.bazel @@ -49,7 +49,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_errors/BUILD.bazel b/tests/reboot/react/test_errors/BUILD.bazel index dd84012d..260fe58e 100644 --- a/tests/reboot/react/test_errors/BUILD.bazel +++ b/tests/reboot/react/test_errors/BUILD.bazel @@ -51,7 +51,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_http_fallback/BUILD.bazel b/tests/reboot/react/test_http_fallback/BUILD.bazel index 5ee0375d..de610afc 100644 --- a/tests/reboot/react/test_http_fallback/BUILD.bazel +++ b/tests/reboot/react/test_http_fallback/BUILD.bazel @@ -53,7 +53,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_idempotent_writer/BUILD.bazel b/tests/reboot/react/test_idempotent_writer/BUILD.bazel index 8b41138a..113fb003 100644 --- a/tests/reboot/react/test_idempotent_writer/BUILD.bazel +++ b/tests/reboot/react/test_idempotent_writer/BUILD.bazel @@ -51,7 +51,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_long_running_fetches/BUILD.bazel b/tests/reboot/react/test_long_running_fetches/BUILD.bazel index 9dad5a26..b5c2ee46 100644 --- a/tests/reboot/react/test_long_running_fetches/BUILD.bazel +++ b/tests/reboot/react/test_long_running_fetches/BUILD.bazel @@ -53,7 +53,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_mutations/BUILD.bazel b/tests/reboot/react/test_mutations/BUILD.bazel index 9c2c6c5f..95d8fabf 100644 --- a/tests/reboot/react/test_mutations/BUILD.bazel +++ b/tests/reboot/react/test_mutations/BUILD.bazel @@ -102,7 +102,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_reader/BUILD.bazel b/tests/reboot/react/test_reader/BUILD.bazel index dd84012d..260fe58e 100644 --- a/tests/reboot/react/test_reader/BUILD.bazel +++ b/tests/reboot/react/test_reader/BUILD.bazel @@ -51,7 +51,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_suspense/BUILD.bazel b/tests/reboot/react/test_suspense/BUILD.bazel index 79899572..9157b431 100644 --- a/tests/reboot/react/test_suspense/BUILD.bazel +++ b/tests/reboot/react/test_suspense/BUILD.bazel @@ -51,7 +51,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_undeclared_errors/BUILD.bazel b/tests/reboot/react/test_undeclared_errors/BUILD.bazel index c662c10e..c2d74f8c 100644 --- a/tests/reboot/react/test_undeclared_errors/BUILD.bazel +++ b/tests/reboot/react/test_undeclared_errors/BUILD.bazel @@ -51,7 +51,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_websockets_connection/BUILD.bazel b/tests/reboot/react/test_websockets_connection/BUILD.bazel index 9abd9997..2cb4389d 100644 --- a/tests/reboot/react/test_websockets_connection/BUILD.bazel +++ b/tests/reboot/react/test_websockets_connection/BUILD.bazel @@ -50,7 +50,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/react/test_writer_abort_with_retrying_reactive_reader/BUILD.bazel b/tests/reboot/react/test_writer_abort_with_retrying_reactive_reader/BUILD.bazel index d66c35fc..11f36a21 100644 --- a/tests/reboot/react/test_writer_abort_with_retrying_reactive_reader/BUILD.bazel +++ b/tests/reboot/react/test_writer_abort_with_retrying_reactive_reader/BUILD.bazel @@ -125,7 +125,6 @@ py_web_test_suite_env( browsers = [ "@io_bazel_rules_webtesting//browsers:chromium-local", ], - local = True, main = "test_against_local_envoy.py", # This test is not supported on macOS; see: # https://github.com/reboot-dev/mono/issues/4067. diff --git a/tests/reboot/server/database_tests.cc b/tests/reboot/server/database_tests.cc index 8e1fa78e..dc051a10 100644 --- a/tests/reboot/server/database_tests.cc +++ b/tests/reboot/server/database_tests.cc @@ -2,8 +2,10 @@ #include #include +#include #include #include +#include #include "gmock/gmock-matchers.h" #include "google/protobuf/any.pb.h" @@ -2779,6 +2781,10 @@ TEST_F(TwoShardDatabaseTest, ExportFiltersByShardIds) { v1alpha1::ExportResponse response; grpc::ClientContext context; + // This uses `Export` instead of `ExportStreamed` since the code + // path is pretty much the same. + // TODO: Make these tests use `ExportStreamed` once we deprecate + // `Export`. grpc::Status status = stub->Export(&context, request, &response); ASSERT_TRUE(status.ok()) << status.error_message(); @@ -2807,6 +2813,10 @@ TEST_F(TwoShardDatabaseTest, ExportFiltersByShardIds) { v1alpha1::ExportResponse response; grpc::ClientContext context; + // This uses `Export` instead of `ExportStreamed` since the code + // path is pretty much the same. + // TODO: Make these tests use `ExportStreamed` once we deprecate + // `Export`. grpc::Status status = stub->Export(&context, request, &response); ASSERT_TRUE(status.ok()) << status.error_message(); @@ -2836,6 +2846,10 @@ TEST_F(TwoShardDatabaseTest, ExportFiltersByShardIds) { v1alpha1::ExportResponse response; grpc::ClientContext context; + // This uses `Export` instead of `ExportStreamed` since the code + // path is pretty much the same. + // TODO: Make these tests use `ExportStreamed` once we deprecate + // `Export`. grpc::Status status = stub->Export(&context, request, &response); ASSERT_TRUE(status.ok()) << status.error_message(); @@ -2866,6 +2880,10 @@ TEST_F(TwoShardDatabaseTest, ExportFiltersByShardIds) { v1alpha1::ExportResponse response; grpc::ClientContext context; + // This uses `Export` instead of `ExportStreamed` since the code + // path is pretty much the same. + // TODO: Make these tests use `ExportStreamed` once we deprecate + // `Export`. grpc::Status status = stub->Export(&context, request, &response); EXPECT_FALSE(status.ok()); @@ -2876,6 +2894,222 @@ TEST_F(TwoShardDatabaseTest, ExportFiltersByShardIds) { } } +TEST_F(TwoShardDatabaseTest, ExportStreamedCancelled) { + const std::string state_type = "TestType"; + for (const auto& state_ref : find_shard_actors(0, 5)) { + v1alpha1::Actor actor; + actor.set_state_type(state_type); + actor.set_state_ref(state_ref); + actor.set_state("data"); + store({actor}, {}); + } + + std::promise snapshot_taken; + std::promise cancelled; + SetTestOnlyHookForLongRunningRPC( + server->TestOnly_GetService(), + [&](TestOnlyLongRunningRPCHookSite site) { + ASSERT_EQ( + site, + TestOnlyLongRunningRPCHookSite:: + EXPORT_RIGHT_AFTER_IMPLICIT_SNAPSHOT); + snapshot_taken.set_value(); + cancelled.get_future().wait(); + }); + + v1alpha1::ExportRequest request; + request.set_state_type(state_type); + request.add_shard_ids("s000000000"); + + grpc::ClientContext context; + std::unique_ptr> reader( + stub->ExportStreamed(&context, request)); + + v1alpha1::ExportResponse response; + std::thread read_thread([&]() { + while (reader->Read(&response)) {} + }); + + snapshot_taken.get_future().wait(); + context.TryCancel(); + cancelled.set_value(); + read_thread.join(); + + grpc::Status status = reader->Finish(); + EXPECT_EQ(status.error_code(), grpc::StatusCode::CANCELLED); + EXPECT_EQ(response.ByteSizeLong(), 0u); +} + +TEST_F(TwoShardDatabaseTest, ExportStreamedDoesNotBlockConcurrentStore) { + // Verify that `ExportStreamed` does not hold the database mutex during + // iteration. + const std::string state_type = "TestType"; + + std::vector shard0_refs = find_shard_actors(0, 5); + for (const auto& state_ref : shard0_refs) { + v1alpha1::Actor actor; + actor.set_state_type(state_type); + actor.set_state_ref(state_ref); + actor.set_state("data"); + store({actor}, {}); + } + + // `snapshot_taken` fires from the server thread once the snapshot is + // taken. `store_done` unblocks the server thread to finish export + // after the concurrent `Store` completes. + std::promise snapshot_taken; + std::promise store_done; + SetTestOnlyHookForLongRunningRPC( + server->TestOnly_GetService(), + [&](TestOnlyLongRunningRPCHookSite site) { + ASSERT_EQ( + site, + TestOnlyLongRunningRPCHookSite:: + EXPORT_RIGHT_AFTER_IMPLICIT_SNAPSHOT); + snapshot_taken.set_value(); + store_done.get_future().wait(); + }); + + v1alpha1::ExportRequest request; + request.set_state_type(state_type); + request.add_shard_ids("s000000000"); + + grpc::ClientContext export_context; + std::unique_ptr> reader( + stub->ExportStreamed(&export_context, request)); + + std::set exported_refs; + int exported_export_items_count = 0; + std::thread export_thread([&]() { + v1alpha1::ExportResponse response; + while (reader->Read(&response)) { + for (const auto& item : response.items()) { + EXPECT_TRUE(item.has_actor()); + EXPECT_EQ(state_type, item.actor().state_type()); + EXPECT_EQ("data", item.actor().state()); + exported_refs.insert(item.actor().state_ref()); + exported_export_items_count++; + } + } + }); + + // Wait until the server has taken the iterator snapshot, then + // perform a concurrent `Store` into the same shard being exported. + snapshot_taken.get_future().wait(); + + // Use the same shard which we use in the export so we know for sure + // the new actor would be included if the snapshot were taken after + // the `Store`. + std::string new_ref = find_shard_actors(0, 6)[5]; + v1alpha1::Actor new_actor; + new_actor.set_state_type(state_type); + new_actor.set_state_ref(new_ref); + new_actor.set_state("concurrent_data"); + store({new_actor}, {}); + + // Unblock the server to finish the `ExportStreamed`. + store_done.set_value(); + export_thread.join(); + + // The snapshot was taken before the concurrent `Store`, so only the + // original actors must appear. + EXPECT_EQ(shard0_refs.size(), exported_export_items_count); + + // The new actor must not appear, it was stored after the snapshot + // was taken. + EXPECT_EQ(exported_refs.count(new_ref), 0); +} + +TEST_F(TwoShardDatabaseTest, RecoverCancelled) { + // Store a task so RecoverTasks has something to iterate over. + v1alpha1::Actor actor; + actor.set_state_type("Greeter"); + actor.set_state_ref(make_state_ref("test_1234")); + actor.set_state("data"); + store({actor}, {}); + + std::promise mutex_acquired; + std::promise cancelled; + SetTestOnlyHookForLongRunningRPC( + server->TestOnly_GetService(), + [&](TestOnlyLongRunningRPCHookSite site) { + ASSERT_EQ( + site, + TestOnlyLongRunningRPCHookSite::RECOVER_RIGHT_AFTER_MUTEX_ACQUIRE); + mutex_acquired.set_value(); + cancelled.get_future().wait(); + }); + + v1alpha1::RecoverRequest request; + request.add_shard_ids("s000000000"); + request.add_shard_ids("s000000001"); + + grpc::ClientContext context; + std::unique_ptr> reader( + stub->Recover(&context, request)); + + v1alpha1::RecoverResponse response; + std::thread read_thread([&]() { + while (reader->Read(&response)) {} + }); + + mutex_acquired.get_future().wait(); + context.TryCancel(); + cancelled.set_value(); + read_thread.join(); + + grpc::Status status = reader->Finish(); + EXPECT_EQ(status.error_code(), grpc::StatusCode::CANCELLED); + EXPECT_EQ(response.ByteSizeLong(), 0u); +} + +TEST_F(TwoShardDatabaseTest, RecoverIdempotentMutationsCancelled) { + const std::string state_type = "Greeter"; + const std::string state_ref = make_state_ref("test_1234"); + + v1alpha1::IdempotentMutation idempotent_mutation; + idempotent_mutation.set_state_type(state_type); + idempotent_mutation.set_state_ref(state_ref); + idempotent_mutation.set_key(UUID::random().toBytes()); + idempotent_mutation.set_response({}); + store({}, {}, std::nullopt, std::move(idempotent_mutation)); + + std::promise mutex_acquired; + std::promise cancelled; + SetTestOnlyHookForLongRunningRPC( + server->TestOnly_GetService(), + [&](TestOnlyLongRunningRPCHookSite site) { + ASSERT_EQ( + site, + TestOnlyLongRunningRPCHookSite:: + RECOVER_IDEMPOTENT_MUTATIONS_RIGHT_AFTER_MUTEX_ACQUIRE); + mutex_acquired.set_value(); + cancelled.get_future().wait(); + }); + + v1alpha1::RecoverIdempotentMutationsRequest request; + request.set_state_type(state_type); + request.set_state_ref(state_ref); + + grpc::ClientContext context; + std::unique_ptr< + grpc::ClientReader> + reader(stub->RecoverIdempotentMutations(&context, request)); + + v1alpha1::RecoverIdempotentMutationsResponse response; + std::thread read_thread([&]() { + while (reader->Read(&response)) {} + }); + + mutex_acquired.get_future().wait(); + context.TryCancel(); + cancelled.set_value(); + read_thread.join(); + + grpc::Status status = reader->Finish(); + EXPECT_EQ(status.error_code(), grpc::StatusCode::CANCELLED); + EXPECT_EQ(response.ByteSizeLong(), 0u); +} //////////////////////////////////////////////////////////////////////// diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/BUILD.bazel b/tests/reboot/server/service_descriptor_validator_pydantic/BUILD.bazel index 34768fe2..fd0a325d 100644 --- a/tests/reboot/server/service_descriptor_validator_pydantic/BUILD.bazel +++ b/tests/reboot/server/service_descriptor_validator_pydantic/BUILD.bazel @@ -5,14 +5,14 @@ load("@rules_python//python:defs.bzl", "py_test") load("//reboot:pydantic_to_proto.bzl", "pydantic_to_proto") pydantic_to_proto( - name = "pydantic_original_proto_file", + name = "pydantic_state_original_proto_file", py_deps = ["//reboot:api_py"], - pydantic = ":pydantic_original_api.py", + pydantic = ":pydantic_state_original_api.py", ) proto_library( - name = "pydantic_original_proto", - srcs = [":pydantic_original_proto_file"], + name = "pydantic_state_original_proto", + srcs = [":pydantic_state_original_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -22,20 +22,20 @@ proto_library( ) py_proto_library( - name = "pydantic_original_py_proto", + name = "pydantic_state_original_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":pydantic_original_proto"], + deps = [":pydantic_state_original_proto"], ) pydantic_to_proto( - name = "pydantic_field_deleted_proto_file", + name = "pydantic_state_field_deleted_proto_file", py_deps = ["//reboot:api_py"], - pydantic = ":pydantic_field_deleted_api.py", + pydantic = ":pydantic_state_field_deleted_api.py", ) proto_library( - name = "pydantic_field_deleted_proto", - srcs = [":pydantic_field_deleted_proto_file"], + name = "pydantic_state_field_deleted_proto", + srcs = [":pydantic_state_field_deleted_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -45,9 +45,9 @@ proto_library( ) py_proto_library( - name = "pydantic_field_deleted_py_proto", + name = "pydantic_state_field_deleted_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":pydantic_field_deleted_proto"], + deps = [":pydantic_state_field_deleted_proto"], ) pydantic_to_proto( @@ -120,14 +120,14 @@ py_proto_library( ) pydantic_to_proto( - name = "pydantic_field_type_changed_proto_file", + name = "pydantic_state_field_type_changed_proto_file", py_deps = ["//reboot:api_py"], - pydantic = ":pydantic_field_type_changed_api.py", + pydantic = ":pydantic_state_field_type_changed_api.py", ) proto_library( - name = "pydantic_field_type_changed_proto", - srcs = [":pydantic_field_type_changed_proto_file"], + name = "pydantic_state_field_type_changed_proto", + srcs = [":pydantic_state_field_type_changed_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -137,9 +137,9 @@ proto_library( ) py_proto_library( - name = "pydantic_field_type_changed_py_proto", + name = "pydantic_state_field_type_changed_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":pydantic_field_type_changed_proto"], + deps = [":pydantic_state_field_type_changed_proto"], ) pydantic_to_proto( @@ -211,21 +211,117 @@ py_proto_library( deps = [":pydantic_request_field_type_changed_proto"], ) +pydantic_to_proto( + name = "pydantic_state_non_required_field_proto_file", + py_deps = ["//reboot:api_py"], + pydantic = ":pydantic_state_non_required_field_api.py", +) + +proto_library( + name = "pydantic_state_non_required_field_proto", + srcs = [":pydantic_state_non_required_field_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "pydantic_state_non_required_field_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":pydantic_state_non_required_field_proto"], +) + +pydantic_to_proto( + name = "pydantic_state_required_field_added_proto_file", + py_deps = ["//reboot:api_py"], + pydantic = ":pydantic_state_required_field_added_api.py", +) + +proto_library( + name = "pydantic_state_required_field_added_proto", + srcs = [":pydantic_state_required_field_added_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "pydantic_state_required_field_added_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":pydantic_state_required_field_added_proto"], +) + +pydantic_to_proto( + name = "pydantic_request_non_required_field_proto_file", + py_deps = ["//reboot:api_py"], + pydantic = ":pydantic_request_non_required_field_api.py", +) + +proto_library( + name = "pydantic_request_non_required_field_proto", + srcs = [":pydantic_request_non_required_field_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "pydantic_request_non_required_field_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":pydantic_request_non_required_field_proto"], +) + +pydantic_to_proto( + name = "pydantic_request_required_field_added_proto_file", + py_deps = ["//reboot:api_py"], + pydantic = ":pydantic_request_required_field_added_api.py", +) + +proto_library( + name = "pydantic_request_required_field_added_proto", + srcs = [":pydantic_request_required_field_added_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "pydantic_request_required_field_added_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":pydantic_request_required_field_added_proto"], +) + py_test( name = "service_descriptor_validator_pydantic_test_py", srcs = ["test.py"], main = "test.py", deps = [ requirement("protobuf"), - ":pydantic_field_deleted_py_proto", - ":pydantic_field_type_changed_py_proto", ":pydantic_method_deleted_py_proto", ":pydantic_method_type_changed_py_proto", - ":pydantic_original_py_proto", ":pydantic_request_field_deleted_py_proto", ":pydantic_request_field_type_changed_py_proto", + ":pydantic_request_non_required_field_py_proto", ":pydantic_request_original_py_proto", + ":pydantic_request_required_field_added_py_proto", + ":pydantic_state_field_deleted_py_proto", + ":pydantic_state_field_type_changed_py_proto", + ":pydantic_state_non_required_field_py_proto", + ":pydantic_state_original_py_proto", ":pydantic_state_renamed_py_proto", + ":pydantic_state_required_field_added_py_proto", "//reboot/server:service_descriptor_validator_py", "//tests/reboot:test_helpers_py", ], diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_method_deleted_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_method_deleted_api.py index ac7fc035..92d1db48 100644 --- a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_method_deleted_api.py +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_method_deleted_api.py @@ -1,4 +1,4 @@ -"""Variant of `pydantic_original_api.py` with `do_something` removed.""" +"""Variant of `pydantic_state_original_api.py` with `do_something` removed.""" from reboot.api import API, Field, Methods, Model, Type diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_non_required_field_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_non_required_field_api.py new file mode 100644 index 00000000..ad8dba61 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_non_required_field_api.py @@ -0,0 +1,22 @@ +"""Variant of `pydantic_request_original_api.py` with an optional field.""" +from reboot.api import API, Field, Methods, Model, Type, Writer + + +class EchoPydanticState(Model): + pass + + +class DoSomethingRequest(Model): + my_request_field: int = Field(tag=1, default=0) + + +EchoPydanticMethods = Methods( + do_something=Writer(request=DoSomethingRequest, response=None), +) + +api = API( + EchoPydantic=Type( + state=EchoPydanticState, + methods=EchoPydanticMethods, + ), +) diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_required_field_added_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_required_field_added_api.py new file mode 100644 index 00000000..99c46a69 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_request_required_field_added_api.py @@ -0,0 +1,23 @@ +"""Variant of `pydantic_request_original_api.py` with a new required field.""" +from reboot.api import API, Field, Methods, Model, Type, Writer + + +class EchoPydanticState(Model): + pass + + +class DoSomethingRequest(Model): + my_request_field: int = Field(tag=1) + my_new_request_field: int = Field(tag=2) + + +EchoPydanticMethods = Methods( + do_something=Writer(request=DoSomethingRequest, response=None), +) + +api = API( + EchoPydantic=Type( + state=EchoPydanticState, + methods=EchoPydanticMethods, + ), +) diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_field_deleted_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_field_deleted_api.py similarity index 80% rename from tests/reboot/server/service_descriptor_validator_pydantic/pydantic_field_deleted_api.py rename to tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_field_deleted_api.py index 8c095b7f..d9389ecf 100644 --- a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_field_deleted_api.py +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_field_deleted_api.py @@ -1,4 +1,4 @@ -"""Variant of `pydantic_original_api.py` with `my_field` removed.""" +"""Variant of `pydantic_state_original_api.py` with `my_field` removed.""" from reboot.api import API, Methods, Model, Type, Writer diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_field_type_changed_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_field_type_changed_api.py similarity index 100% rename from tests/reboot/server/service_descriptor_validator_pydantic/pydantic_field_type_changed_api.py rename to tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_field_type_changed_api.py diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_non_required_field_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_non_required_field_api.py new file mode 100644 index 00000000..2a3f066b --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_non_required_field_api.py @@ -0,0 +1,18 @@ +"""Variant of `pydantic_state_original_api.py` with `my_field` having a default.""" +from reboot.api import API, Field, Methods, Model, Type, Writer + + +class EchoPydanticState(Model): + my_field: int = Field(tag=1, default=0) + + +EchoPydanticMethods = Methods( + do_something=Writer(request=None, response=None), +) + +api = API( + EchoPydantic=Type( + state=EchoPydanticState, + methods=EchoPydanticMethods, + ), +) diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_original_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_original_api.py similarity index 100% rename from tests/reboot/server/service_descriptor_validator_pydantic/pydantic_original_api.py rename to tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_original_api.py diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_required_field_added_api.py b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_required_field_added_api.py new file mode 100644 index 00000000..ad037465 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_pydantic/pydantic_state_required_field_added_api.py @@ -0,0 +1,19 @@ +"""Variant of `pydantic_original_api.py` with a new required field added.""" +from reboot.api import API, Field, Methods, Model, Type, Writer + + +class EchoPydanticState(Model): + my_field: int = Field(tag=1) + my_new_field: int = Field(tag=2) + + +EchoPydanticMethods = Methods( + do_something=Writer(request=None, response=None), +) + +api = API( + EchoPydantic=Type( + state=EchoPydanticState, + methods=EchoPydanticMethods, + ), +) diff --git a/tests/reboot/server/service_descriptor_validator_pydantic/test.py b/tests/reboot/server/service_descriptor_validator_pydantic/test.py index eb973a17..305e77c7 100644 --- a/tests/reboot/server/service_descriptor_validator_pydantic/test.py +++ b/tests/reboot/server/service_descriptor_validator_pydantic/test.py @@ -28,19 +28,23 @@ class ServiceDescriptorValidatorPydanticTestCase(unittest.TestCase): field_type_changed: FileDescriptorSet method_deleted: FileDescriptorSet method_type_changed: FileDescriptorSet + state_non_required_field: FileDescriptorSet + required_field_added: FileDescriptorSet state_renamed: FileDescriptorSet + request_non_required_field: FileDescriptorSet request_original: FileDescriptorSet request_field_deleted: FileDescriptorSet request_field_type_changed: FileDescriptorSet + request_required_field_added: FileDescriptorSet @classmethod def setUpClass(cls) -> None: - cls.original = get_descriptor_set('pydantic_original_api_pb2') + cls.original = get_descriptor_set('pydantic_state_original_api_pb2') cls.field_deleted = get_descriptor_set( - 'pydantic_field_deleted_api_pb2' + 'pydantic_state_field_deleted_api_pb2' ) cls.field_type_changed = get_descriptor_set( - 'pydantic_field_type_changed_api_pb2' + 'pydantic_state_field_type_changed_api_pb2' ) cls.method_deleted = get_descriptor_set( 'pydantic_method_deleted_api_pb2' @@ -48,6 +52,12 @@ def setUpClass(cls) -> None: cls.method_type_changed = get_descriptor_set( 'pydantic_method_type_changed_api_pb2' ) + cls.state_non_required_field = get_descriptor_set( + 'pydantic_state_non_required_field_api_pb2' + ) + cls.required_field_added = get_descriptor_set( + 'pydantic_state_required_field_added_api_pb2' + ) cls.state_renamed = get_descriptor_set( 'pydantic_state_renamed_api_pb2' ) @@ -60,6 +70,12 @@ def setUpClass(cls) -> None: cls.request_field_type_changed = get_descriptor_set( 'pydantic_request_field_type_changed_api_pb2' ) + cls.request_non_required_field = get_descriptor_set( + 'pydantic_request_non_required_field_api_pb2' + ) + cls.request_required_field_added = get_descriptor_set( + 'pydantic_request_required_field_added_api_pb2' + ) def test_pydantic_state_deleted(self): """Pydantic schema deletion uses 'servicer type' terminology.""" @@ -76,7 +92,7 @@ def test_pydantic_state_deleted(self): self.assertEqual( 'servicer type `tests.reboot.server' '.service_descriptor_validator_pydantic' - '.pydantic_original_api.EchoPydantic` ' + '.pydantic_state_original_api.EchoPydantic` ' 'was deleted', errors[0], ) @@ -98,7 +114,7 @@ def test_pydantic_method_deleted(self): 'Method `do_something` was deleted from servicer type ' '`tests.reboot.server' '.service_descriptor_validator_pydantic' - '.pydantic_original_api.EchoPydantic`', + '.pydantic_state_original_api.EchoPydantic`', errors[0], ) @@ -118,7 +134,7 @@ def test_pydantic_method_type_changed(self): self.assertEqual( 'Reboot options for method `do_something` of servicer type ' '`tests.reboot.server.service_descriptor_validator_pydantic' - '.pydantic_original_api.EchoPydantic` updated from...\n' + '.pydantic_state_original_api.EchoPydantic` updated from...\n' '```\n' 'writer {\n' '}\n' @@ -148,7 +164,7 @@ def test_pydantic_field_deleted(self): 'Field `my_field` was removed from the state of servicer type ' '`tests.reboot.server' '.service_descriptor_validator_pydantic' - '.pydantic_original_api.EchoPydantic`. ' + '.pydantic_state_original_api.EchoPydantic`. ' 'Removing fields is a backwards-incompatible change. ' 'To continue, restore the field or use `expunge` to ' 'clear all existing state data.', @@ -172,7 +188,7 @@ def test_pydantic_field_type_changed(self): 'Field `my_field` in the state of servicer type ' '`tests.reboot.server' '.service_descriptor_validator_pydantic' - '.pydantic_field_type_changed_api.EchoPydantic` ' + '.pydantic_state_field_type_changed_api.EchoPydantic` ' 'has switched type from `double` to `string`', errors[0], ) @@ -225,6 +241,150 @@ def test_pydantic_request_field_type_changed(self): errors[0], ) + def test_pydantic_required_field_added(self): + """Adding a required field raises an error with Pydantic terminology.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.original, + self.required_field_added, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_new_field` was added to the state of servicer ' + 'type `tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_state_required_field_added_api.EchoPydantic` as a ' + 'required field. Adding required fields is a ' + 'backwards-incompatible change. To continue, add the field ' + 'with a default value or use `expunge` to clear all ' + 'existing state data.', + errors[0], + ) + + def test_pydantic_optional_to_required(self): + """Changing a field from optional to required raises an error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.state_non_required_field, + self.original, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_field` in the state of servicer type ' + '`tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_state_original_api.EchoPydantic` became required. ' + 'This is a backwards-incompatible change. To continue, ' + 'revert the change or use `expunge` to clear all existing ' + 'state data.', + errors[0], + ) + + def test_pydantic_required_to_optional(self): + """Changing a field from required to optional raises an error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.original, + self.state_non_required_field, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_field` in the state of servicer type ' + '`tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_state_non_required_field_api.EchoPydantic` is not ' + 'required anymore. This is a backwards-incompatible change. ' + 'To continue, revert the change or use `expunge` to clear ' + 'all existing state data.', + errors[0], + ) + + def test_pydantic_request_required_field_added(self): + """Adding a required request field raises an error with 'Pydantic + model' terminology.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_original, + self.request_required_field_added, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_new_request_field` was added to Pydantic model ' + '`tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_request_required_field_added_api' + '.EchoPydanticDoSomethingRequest` as a required field. ' + 'Adding required fields is a backwards-incompatible change. ' + 'To continue, add the field with a default value or use ' + '`expunge` to clear all existing state data.', + errors[0], + ) + + def test_pydantic_request_optional_to_required(self): + """Changing a request field from optional to required raises an + error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_non_required_field, + self.request_original, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_request_field` in Pydantic model ' + '`tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_request_original_api' + '.EchoPydanticDoSomethingRequest` became required. This is ' + 'a backwards-incompatible change. To continue, revert the ' + 'change or use `expunge` to clear all existing state data.', + errors[0], + ) + + def test_pydantic_request_required_to_optional(self): + """Changing a request field from required to optional raises an + error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_original, + self.request_non_required_field, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `my_request_field` in Pydantic model ' + '`tests.reboot.server' + '.service_descriptor_validator_pydantic' + '.pydantic_request_non_required_field_api' + '.EchoPydanticDoSomethingRequest` is not required anymore. ' + 'This is a backwards-incompatible change. To continue, ' + 'revert the change or use `expunge` to clear all existing ' + 'state data.', + errors[0], + ) + if __name__ == '__main__': # `get_descriptor_set` might import multiple pb2 modules that define diff --git a/tests/reboot/server/service_descriptor_validator_zod/BUILD.bazel b/tests/reboot/server/service_descriptor_validator_zod/BUILD.bazel index 053b9196..f9343fe6 100644 --- a/tests/reboot/server/service_descriptor_validator_zod/BUILD.bazel +++ b/tests/reboot/server/service_descriptor_validator_zod/BUILD.bazel @@ -5,13 +5,13 @@ load("@rules_python//python:defs.bzl", "py_test") load("//tests:zod_to_proto.bzl", "zod_to_proto") zod_to_proto( - name = "zod_original_proto_file", - zod = "zod_original_api.ts", + name = "zod_state_original_proto_file", + zod = "zod_state_original_api.ts", ) proto_library( - name = "zod_original_proto", - srcs = [":zod_original_proto_file"], + name = "zod_state_original_proto", + srcs = [":zod_state_original_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -21,19 +21,19 @@ proto_library( ) py_proto_library( - name = "zod_original_py_proto", + name = "zod_state_original_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":zod_original_proto"], + deps = [":zod_state_original_proto"], ) zod_to_proto( - name = "zod_field_deleted_proto_file", - zod = "zod_field_deleted_api.ts", + name = "zod_state_field_deleted_proto_file", + zod = "zod_state_field_deleted_api.ts", ) proto_library( - name = "zod_field_deleted_proto", - srcs = [":zod_field_deleted_proto_file"], + name = "zod_state_field_deleted_proto", + srcs = [":zod_state_field_deleted_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -43,9 +43,9 @@ proto_library( ) py_proto_library( - name = "zod_field_deleted_py_proto", + name = "zod_state_field_deleted_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":zod_field_deleted_proto"], + deps = [":zod_state_field_deleted_proto"], ) zod_to_proto( @@ -115,13 +115,13 @@ py_proto_library( ) zod_to_proto( - name = "zod_field_type_changed_proto_file", - zod = "zod_field_type_changed_api.ts", + name = "zod_state_field_type_changed_proto_file", + zod = "zod_state_field_type_changed_api.ts", ) proto_library( - name = "zod_field_type_changed_proto", - srcs = [":zod_field_type_changed_proto_file"], + name = "zod_state_field_type_changed_proto", + srcs = [":zod_state_field_type_changed_proto_file"], deps = [ "//rbt/v1alpha1:options_proto", "//rbt/v1alpha1:tasks_proto", @@ -131,9 +131,9 @@ proto_library( ) py_proto_library( - name = "zod_field_type_changed_py_proto", + name = "zod_state_field_type_changed_py_proto", visibility = ["//tests/reboot:__subpackages__"], - deps = [":zod_field_type_changed_proto"], + deps = [":zod_state_field_type_changed_proto"], ) zod_to_proto( @@ -202,21 +202,113 @@ py_proto_library( deps = [":zod_request_field_type_changed_proto"], ) +zod_to_proto( + name = "zod_state_non_required_field_proto_file", + zod = "zod_state_non_required_field_api.ts", +) + +proto_library( + name = "zod_state_non_required_field_proto", + srcs = [":zod_state_non_required_field_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "zod_state_non_required_field_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":zod_state_non_required_field_proto"], +) + +zod_to_proto( + name = "zod_state_required_field_added_proto_file", + zod = "zod_state_required_field_added_api.ts", +) + +proto_library( + name = "zod_state_required_field_added_proto", + srcs = [":zod_state_required_field_added_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "zod_state_required_field_added_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":zod_state_required_field_added_proto"], +) + +zod_to_proto( + name = "zod_request_non_required_field_proto_file", + zod = "zod_request_non_required_field_api.ts", +) + +proto_library( + name = "zod_request_non_required_field_proto", + srcs = [":zod_request_non_required_field_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "zod_request_non_required_field_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":zod_request_non_required_field_proto"], +) + +zod_to_proto( + name = "zod_request_required_field_added_proto_file", + zod = "zod_request_required_field_added_api.ts", +) + +proto_library( + name = "zod_request_required_field_added_proto", + srcs = [":zod_request_required_field_added_proto_file"], + deps = [ + "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:struct_proto", + ], +) + +py_proto_library( + name = "zod_request_required_field_added_py_proto", + visibility = ["//tests/reboot:__subpackages__"], + deps = [":zod_request_required_field_added_proto"], +) + py_test( name = "service_descriptor_validator_zod_test_py", srcs = ["test.py"], main = "test.py", deps = [ requirement("protobuf"), - ":zod_field_deleted_py_proto", - ":zod_field_type_changed_py_proto", ":zod_method_deleted_py_proto", ":zod_method_type_changed_py_proto", - ":zod_original_py_proto", ":zod_request_field_deleted_py_proto", ":zod_request_field_type_changed_py_proto", + ":zod_request_non_required_field_py_proto", ":zod_request_original_py_proto", + ":zod_request_required_field_added_py_proto", + ":zod_state_field_deleted_py_proto", + ":zod_state_field_type_changed_py_proto", + ":zod_state_non_required_field_py_proto", + ":zod_state_original_py_proto", ":zod_state_renamed_py_proto", + ":zod_state_required_field_added_py_proto", "//reboot/server:service_descriptor_validator_py", "//tests/reboot:test_helpers_py", ], diff --git a/tests/reboot/server/service_descriptor_validator_zod/test.py b/tests/reboot/server/service_descriptor_validator_zod/test.py index c98ea1de..79266505 100644 --- a/tests/reboot/server/service_descriptor_validator_zod/test.py +++ b/tests/reboot/server/service_descriptor_validator_zod/test.py @@ -28,22 +28,34 @@ class ServiceDescriptorValidatorZodTestCase(unittest.TestCase): field_type_changed: FileDescriptorSet method_deleted: FileDescriptorSet method_type_changed: FileDescriptorSet + state_non_required_field: FileDescriptorSet + required_field_added: FileDescriptorSet state_renamed: FileDescriptorSet + request_non_required_field: FileDescriptorSet request_original: FileDescriptorSet request_field_deleted: FileDescriptorSet request_field_type_changed: FileDescriptorSet + request_required_field_added: FileDescriptorSet @classmethod def setUpClass(cls) -> None: - cls.original = get_descriptor_set('zod_original_api_pb2') - cls.field_deleted = get_descriptor_set('zod_field_deleted_api_pb2') + cls.original = get_descriptor_set('zod_state_original_api_pb2') + cls.field_deleted = get_descriptor_set( + 'zod_state_field_deleted_api_pb2' + ) cls.field_type_changed = get_descriptor_set( - 'zod_field_type_changed_api_pb2' + 'zod_state_field_type_changed_api_pb2' ) cls.method_deleted = get_descriptor_set('zod_method_deleted_api_pb2') cls.method_type_changed = get_descriptor_set( 'zod_method_type_changed_api_pb2' ) + cls.state_non_required_field = get_descriptor_set( + 'zod_state_non_required_field_api_pb2' + ) + cls.required_field_added = get_descriptor_set( + 'zod_state_required_field_added_api_pb2' + ) cls.state_renamed = get_descriptor_set('zod_state_renamed_api_pb2') cls.request_original = get_descriptor_set( 'zod_request_original_api_pb2' @@ -54,6 +66,12 @@ def setUpClass(cls) -> None: cls.request_field_type_changed = get_descriptor_set( 'zod_request_field_type_changed_api_pb2' ) + cls.request_non_required_field = get_descriptor_set( + 'zod_request_non_required_field_api_pb2' + ) + cls.request_required_field_added = get_descriptor_set( + 'zod_request_required_field_added_api_pb2' + ) def test_zod_state_deleted(self): """Zod schema deletion uses 'servicer type' terminology.""" @@ -70,7 +88,7 @@ def test_zod_state_deleted(self): self.assertEqual( 'servicer type `EchoZod` (from `tests/reboot/server' '/service_descriptor_validator_zod' - '/zod_original_api.ts`) was deleted', + '/zod_state_original_api.ts`) was deleted', errors[0], ) @@ -91,7 +109,7 @@ def test_zod_method_deleted(self): 'Method `doSomething` was deleted from servicer type ' '`EchoZod` (from `tests/reboot/server' '/service_descriptor_validator_zod' - '/zod_original_api.ts`)', + '/zod_state_original_api.ts`)', errors[0], ) @@ -112,7 +130,7 @@ def test_zod_method_type_changed(self): 'Reboot options for method `doSomething` of servicer type ' '`EchoZod` (from `tests/reboot/server' '/service_descriptor_validator_zod' - '/zod_original_api.ts`) updated from...\n' + '/zod_state_original_api.ts`) updated from...\n' '```\n' 'writer {\n' '}\n' @@ -142,7 +160,7 @@ def test_zod_field_deleted(self): 'Field `myField` was removed from the state of servicer type ' '`EchoZod` (from `tests/reboot/server' '/service_descriptor_validator_zod' - '/zod_original_api.ts`). ' + '/zod_state_original_api.ts`). ' 'Removing fields is a backwards-incompatible change. ' 'To continue, restore the field or use `expunge` to ' 'clear all existing state data.', @@ -166,7 +184,7 @@ def test_zod_field_type_changed(self): 'Field `myField` in the state of servicer type ' '`EchoZod` (from `tests/reboot/server' '/service_descriptor_validator_zod' - '/zod_field_type_changed_api.ts`) ' + '/zod_state_field_type_changed_api.ts`) ' 'has switched type from `string` to `bool`', errors[0], ) @@ -217,6 +235,145 @@ def test_zod_request_field_type_changed(self): errors[0], ) + def test_zod_required_field_added(self): + """Adding a required field raises an error with Zod terminology.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.original, + self.required_field_added, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myNewField` was added to the state of servicer type ' + '`EchoZod` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_state_required_field_added_api.ts`) as a required field. ' + 'Adding required fields is a backwards-incompatible change. ' + 'To continue, add the field with a default value or use ' + '`expunge` to clear all existing state data.', + errors[0], + ) + + def test_zod_optional_to_required(self): + """Changing a field from optional to required raises an error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.state_non_required_field, + self.original, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myField` in the state of servicer type ' + '`EchoZod` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_state_original_api.ts`) became required. This is a ' + 'backwards-incompatible change. To continue, revert the ' + 'change or use `expunge` to clear all existing state data.', + errors[0], + ) + + def test_zod_required_to_optional(self): + """Changing a field from required to optional raises an error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.original, + self.state_non_required_field, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myField` in the state of servicer type ' + '`EchoZod` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_state_non_required_field_api.ts`) is not required ' + 'anymore. This is a backwards-incompatible change. To ' + 'continue, revert the change or use `expunge` to clear all ' + 'existing state data.', + errors[0], + ) + + def test_zod_request_required_field_added(self): + """Adding a required request field raises an error with 'Zod + schema' terminology.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_original, + self.request_required_field_added, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myNewRequestField` was added to Zod schema ' + '`EchoZodDoSomethingRequest` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_request_required_field_added_api.ts`) as a required ' + 'field. Adding required fields is a backwards-incompatible ' + 'change. To continue, add the field with a default value ' + 'or use `expunge` to clear all existing state data.', + errors[0], + ) + + def test_zod_request_optional_to_required(self): + """Changing a request field from optional to required raises an + error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_non_required_field, + self.request_original, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myRequestField` in Zod schema ' + '`EchoZodDoSomethingRequest` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_request_original_api.ts`) became required. This is a ' + 'backwards-incompatible change. To continue, revert the ' + 'change or use `expunge` to clear all existing state data.', + errors[0], + ) + + def test_zod_request_required_to_optional(self): + """Changing a request field from required to optional raises an + error.""" + with self.assertRaises(ProtoValidationError) as e: + validate_descriptor_sets_are_backwards_compatible( + self.request_original, + self.request_non_required_field, + ) + + self.assertIsNotNone(e.exception.validation_errors) + assert e.exception.validation_errors is not None # for mypy + errors = e.exception.validation_errors + self.assertEqual(len(errors), 1) + self.assertEqual( + 'Field `myRequestField` in Zod schema ' + '`EchoZodDoSomethingRequest` (from `tests/reboot/server' + '/service_descriptor_validator_zod' + '/zod_request_non_required_field_api.ts`) is not required ' + 'anymore. This is a backwards-incompatible change. To ' + 'continue, revert the change or use `expunge` to clear all ' + 'existing state data.', + errors[0], + ) + if __name__ == '__main__': # `get_descriptor_set` might import multiple pb2 modules that define diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_request_non_required_field_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_request_non_required_field_api.ts new file mode 100644 index 00000000..803bd32d --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_zod/zod_request_non_required_field_api.ts @@ -0,0 +1,17 @@ +import { writer } from "@reboot-dev/reboot-api"; +import { z } from "zod/v4"; + +// Variant of `zod_request_original_api.ts` with `myRequestField` optional. +const EchoZod = { + state: {}, + methods: { + doSomething: writer({ + request: { + myRequestField: z.string().optional().meta({ tag: 1 }), + }, + response: z.void(), + }), + }, +}; + +export const api = { EchoZod }; diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_request_required_field_added_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_request_required_field_added_api.ts new file mode 100644 index 00000000..fa1148e4 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_zod/zod_request_required_field_added_api.ts @@ -0,0 +1,18 @@ +import { writer } from "@reboot-dev/reboot-api"; +import { z } from "zod/v4"; + +// Variant of `zod_request_original_api.ts` with a new required field added. +const EchoZod = { + state: {}, + methods: { + doSomething: writer({ + request: { + myRequestField: z.string().meta({ tag: 1 }), + myNewRequestField: z.string().meta({ tag: 2 }), + }, + response: z.void(), + }), + }, +}; + +export const api = { EchoZod }; diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_field_deleted_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_state_field_deleted_api.ts similarity index 67% rename from tests/reboot/server/service_descriptor_validator_zod/zod_field_deleted_api.ts rename to tests/reboot/server/service_descriptor_validator_zod/zod_state_field_deleted_api.ts index 231440d4..23368a87 100644 --- a/tests/reboot/server/service_descriptor_validator_zod/zod_field_deleted_api.ts +++ b/tests/reboot/server/service_descriptor_validator_zod/zod_state_field_deleted_api.ts @@ -1,7 +1,7 @@ /** - * Variant of `zod_original_api.ts` with `myField` removed. + * Variant of `zod_state_original_api.ts` with `myField` removed. * - * When compared against `zod_original_api.ts` as the updated version, + * When compared against `zod_state_original_api.ts` as the updated version, * the `myField` field appears deleted from `EchoZod`. */ import { writer } from "@reboot-dev/reboot-api"; diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_field_type_changed_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_state_field_type_changed_api.ts similarity index 100% rename from tests/reboot/server/service_descriptor_validator_zod/zod_field_type_changed_api.ts rename to tests/reboot/server/service_descriptor_validator_zod/zod_state_field_type_changed_api.ts diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_state_non_required_field_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_state_non_required_field_api.ts new file mode 100644 index 00000000..c92dea24 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_zod/zod_state_non_required_field_api.ts @@ -0,0 +1,14 @@ +import { writer } from "@reboot-dev/reboot-api"; +import { z } from "zod/v4"; + +// Variant of `zod_state_original_api.ts` with `myField` being optional. +const EchoZod = { + state: { + myField: z.string().optional().meta({ tag: 1 }), + }, + methods: { + doSomething: writer({ request: {}, response: z.void() }), + }, +}; + +export const api = { EchoZod }; diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_original_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_state_original_api.ts similarity index 100% rename from tests/reboot/server/service_descriptor_validator_zod/zod_original_api.ts rename to tests/reboot/server/service_descriptor_validator_zod/zod_state_original_api.ts diff --git a/tests/reboot/server/service_descriptor_validator_zod/zod_state_required_field_added_api.ts b/tests/reboot/server/service_descriptor_validator_zod/zod_state_required_field_added_api.ts new file mode 100644 index 00000000..c4c81e95 --- /dev/null +++ b/tests/reboot/server/service_descriptor_validator_zod/zod_state_required_field_added_api.ts @@ -0,0 +1,15 @@ +import { writer } from "@reboot-dev/reboot-api"; +import { z } from "zod/v4"; + +// Variant of `zod_state_original_api.ts` with a new required field added. +const EchoZod = { + state: { + myField: z.string().meta({ tag: 1 }), + myNewField: z.string().meta({ tag: 2 }), + }, + methods: { + doSomething: writer({ request: {}, response: z.void() }), + }, +}; + +export const api = { EchoZod }; diff --git a/tests/reboot/state_manager_tests.py b/tests/reboot/state_manager_tests.py index 04432166..7a194c28 100644 --- a/tests/reboot/state_manager_tests.py +++ b/tests/reboot/state_manager_tests.py @@ -141,7 +141,7 @@ def create_test_context( servicer_type.__state_type_name__, state_id ) _servicing.set(Servicing.INITIALIZING) - context = context_type( + kwargs: dict = dict( channel_manager=self.channel_manager, headers=Headers( application_id=ApplicationId('unused'), @@ -151,6 +151,10 @@ def create_test_context( method="unused", effect_validation=EffectValidation.ENABLED, ) + if context_type == WorkflowContext: + kwargs['reactively_state_manager'] = self.state_manager + kwargs['reactively_state_type'] = servicer_type.__state_type__ + context = context_type(**kwargs) _servicing.set(Servicing.NO) return context diff --git a/tests/reboot/test_token_verifier.py b/tests/reboot/test_token_verifier.py index 8c11a2bd..250af68c 100644 --- a/tests/reboot/test_token_verifier.py +++ b/tests/reboot/test_token_verifier.py @@ -1,7 +1,7 @@ import jwt from log.log import get_logger from reboot.aio.auth import Auth -from reboot.aio.auth.token_verifiers import TokenVerifier +from reboot.aio.auth.token_verifiers import TokenVerifier, VerifyTokenResult from reboot.aio.contexts import ReaderContext from typing import Any, Optional @@ -22,7 +22,7 @@ async def verify_token( self, context: ReaderContext, token: Optional[str], - ) -> Optional[Auth]: + ) -> VerifyTokenResult: """Decode self-signed JWT and return Auth. The token must contain the `sub` claim. This claim is assumed to hold