gatewaysdk 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gatewaysdk/__init__.py +540 -0
- gatewaysdk/adapter/__init__.py +15 -0
- gatewaysdk/adapter/base.py +94 -0
- gatewaysdk/adapter/messages.py +270 -0
- gatewaysdk/adapter/triplet.py +1026 -0
- gatewaysdk/algorithm/__init__.py +39 -0
- gatewaysdk/algorithm/apo/__init__.py +5 -0
- gatewaysdk/algorithm/apo/apo.py +898 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
- gatewaysdk/algorithm/base.py +262 -0
- gatewaysdk/algorithm/decorator.py +264 -0
- gatewaysdk/algorithm/evals/__init__.py +7 -0
- gatewaysdk/algorithm/evals/evals.py +217 -0
- gatewaysdk/algorithm/fast.py +250 -0
- gatewaysdk/algorithm/gepa/__init__.py +61 -0
- gatewaysdk/algorithm/gepa/adapter.py +495 -0
- gatewaysdk/algorithm/gepa/gepa.py +570 -0
- gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
- gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
- gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
- gatewaysdk/algorithm/gepa/lib/api.py +382 -0
- gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
- gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
- gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
- gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
- gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
- gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
- gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
- gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
- gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
- gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
- gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
- gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
- gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
- gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
- gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
- gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
- gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
- gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
- gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
- gatewaysdk/algorithm/gepa/tracing.py +105 -0
- gatewaysdk/algorithm/utils.py +177 -0
- gatewaysdk/algorithm/verl/__init__.py +5 -0
- gatewaysdk/algorithm/verl/interface.py +202 -0
- gatewaysdk/automations.py +111 -0
- gatewaysdk/benchmark_hub/README.md +88 -0
- gatewaysdk/benchmark_hub/__init__.py +119 -0
- gatewaysdk/benchmark_hub/_archive.py +189 -0
- gatewaysdk/benchmark_hub/_tar.py +6 -0
- gatewaysdk/benchmark_hub/client.py +992 -0
- gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
- gatewaysdk/benchmark_hub/eval_config.py +20 -0
- gatewaysdk/benchmark_hub/evals.py +351 -0
- gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
- gatewaysdk/benchmark_hub/path_utils.py +81 -0
- gatewaysdk/benchmark_hub/save_utils.py +101 -0
- gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
- gatewaysdk/benchmark_hub/versioning.py +72 -0
- gatewaysdk/build.py +515 -0
- gatewaysdk/cli/__init__.py +58 -0
- gatewaysdk/cli/agent_runner.py +132 -0
- gatewaysdk/cli/http_client.py +115 -0
- gatewaysdk/cli/platform.py +4086 -0
- gatewaysdk/cli/prometheus.py +115 -0
- gatewaysdk/cli/release_gate.py +215 -0
- gatewaysdk/cli/store.py +131 -0
- gatewaysdk/cli/vllm.py +29 -0
- gatewaysdk/client.py +406 -0
- gatewaysdk/config.py +348 -0
- gatewaysdk/connectors/__init__.py +25 -0
- gatewaysdk/connectors/client.py +203 -0
- gatewaysdk/connectors/skill.py +25 -0
- gatewaysdk/connectors/template.py +106 -0
- gatewaysdk/context.py +606 -0
- gatewaysdk/emitter/__init__.py +43 -0
- gatewaysdk/emitter/annotation.py +370 -0
- gatewaysdk/emitter/exception.py +54 -0
- gatewaysdk/emitter/message.py +61 -0
- gatewaysdk/emitter/object.py +117 -0
- gatewaysdk/emitter/reward.py +320 -0
- gatewaysdk/env_var.py +156 -0
- gatewaysdk/environment/__init__.py +108 -0
- gatewaysdk/environment/_bundle.py +281 -0
- gatewaysdk/environment/_harbor.py +276 -0
- gatewaysdk/environment/_materialize.py +97 -0
- gatewaysdk/environment/_tar.py +33 -0
- gatewaysdk/environment/_world.py +346 -0
- gatewaysdk/environment/core.py +527 -0
- gatewaysdk/environment/errors.py +58 -0
- gatewaysdk/environment/runtime.py +150 -0
- gatewaysdk/environment/schema/__init__.py +35 -0
- gatewaysdk/environment/schema/__main__.py +225 -0
- gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
- gatewaysdk/environment/schema/_slack_scenario.py +183 -0
- gatewaysdk/environment/schema/api.py +1628 -0
- gatewaysdk/environment/schema/batch.py +755 -0
- gatewaysdk/environment/schema/compiler.py +615 -0
- gatewaysdk/environment/schema/conform.py +428 -0
- gatewaysdk/environment/schema/connector.py +777 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
- gatewaysdk/environment/schema/connectors/base.json +28 -0
- gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
- gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
- gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
- gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
- gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
- gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
- gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
- gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
- gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
- gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
- gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
- gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
- gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
- gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
- gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
- gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
- gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
- gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
- gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
- gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
- gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
- gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
- gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
- gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
- gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
- gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
- gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
- gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
- gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
- gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
- gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
- gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
- gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
- gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
- gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
- gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
- gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
- gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
- gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
- gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
- gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
- gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
- gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
- gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
- gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
- gatewaysdk/environment/schema/data.py +593 -0
- gatewaysdk/environment/schema/extract.py +263 -0
- gatewaysdk/environment/schema/host.py +2437 -0
- gatewaysdk/environment/schema/host_surfaces.py +275 -0
- gatewaysdk/environment/schema/platform.py +1272 -0
- gatewaysdk/environment/schema/scaffold.py +250 -0
- gatewaysdk/environment/schema/skill.py +118 -0
- gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
- gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
- gatewaysdk/environment/schema/slack.py +757 -0
- gatewaysdk/environment/schema/snapshot.py +479 -0
- gatewaysdk/environment/schema/store.py +904 -0
- gatewaysdk/environment/schema/tasks.py +525 -0
- gatewaysdk/environment/schema/tools_world.py +322 -0
- gatewaysdk/environment/schema/validation.py +577 -0
- gatewaysdk/environment/schema/worker.py +78 -0
- gatewaysdk/environment/schema/workers.py +500 -0
- gatewaysdk/environment/schema/world_tests.py +336 -0
- gatewaysdk/environments/__init__.py +24 -0
- gatewaysdk/environments/client.py +486 -0
- gatewaysdk/environments/types.py +318 -0
- gatewaysdk/execution/__init__.py +15 -0
- gatewaysdk/execution/base.py +64 -0
- gatewaysdk/execution/client_server.py +443 -0
- gatewaysdk/execution/events.py +69 -0
- gatewaysdk/execution/inter_process.py +16 -0
- gatewaysdk/execution/shared_memory.py +282 -0
- gatewaysdk/experiments/__init__.py +90 -0
- gatewaysdk/experiments/assignment.py +177 -0
- gatewaysdk/experiments/client.py +877 -0
- gatewaysdk/experiments/exposure.py +222 -0
- gatewaysdk/experiments/types.py +81 -0
- gatewaysdk/importers/__init__.py +49 -0
- gatewaysdk/importers/_normalize.py +91 -0
- gatewaysdk/importers/builder.py +173 -0
- gatewaysdk/importers/client.py +95 -0
- gatewaysdk/importers/langsmith.py +196 -0
- gatewaysdk/importers/recipes.py +249 -0
- gatewaysdk/instrumentation/__init__.py +300 -0
- gatewaysdk/instrumentation/agentops.py +314 -0
- gatewaysdk/instrumentation/agentops_langchain.py +45 -0
- gatewaysdk/instrumentation/base.py +119 -0
- gatewaysdk/instrumentation/litellm.py +83 -0
- gatewaysdk/instrumentation/registry.py +273 -0
- gatewaysdk/instrumentation/vllm.py +81 -0
- gatewaysdk/instrumentation/weave.py +500 -0
- gatewaysdk/integrations/__init__.py +15 -0
- gatewaysdk/integrations/gateway/__init__.py +11 -0
- gatewaysdk/integrations/gateway/client.py +171 -0
- gatewaysdk/integrations/tool_access.py +549 -0
- gatewaysdk/litagent/__init__.py +11 -0
- gatewaysdk/litagent/decorator.py +536 -0
- gatewaysdk/litagent/litagent.py +252 -0
- gatewaysdk/llm_proxy.py +1742 -0
- gatewaysdk/logging.py +370 -0
- gatewaysdk/memory.py +278 -0
- gatewaysdk/personas.py +186 -0
- gatewaysdk/platform/__init__.py +17 -0
- gatewaysdk/platform/builder.py +221 -0
- gatewaysdk/platform/compatibility.py +67 -0
- gatewaysdk/platform/manifest.py +185 -0
- gatewaysdk/platform/orchestrator.py +901 -0
- gatewaysdk/platform/registry.py +122 -0
- gatewaysdk/platform/worker.py +864 -0
- gatewaysdk/replay/__init__.py +1032 -0
- gatewaysdk/replay/pytest.py +56 -0
- gatewaysdk/reward.py +7 -0
- gatewaysdk/run.py +1781 -0
- gatewaysdk/runner/__init__.py +11 -0
- gatewaysdk/runner/agent.py +878 -0
- gatewaysdk/runner/base.py +182 -0
- gatewaysdk/runner/legacy.py +309 -0
- gatewaysdk/security.py +700 -0
- gatewaysdk/semconv.py +170 -0
- gatewaysdk/server.py +399 -0
- gatewaysdk/sessions.py +282 -0
- gatewaysdk/store/__init__.py +45 -0
- gatewaysdk/store/base.py +908 -0
- gatewaysdk/store/client_server.py +2093 -0
- gatewaysdk/store/collection/__init__.py +30 -0
- gatewaysdk/store/collection/base.py +587 -0
- gatewaysdk/store/collection/memory.py +970 -0
- gatewaysdk/store/collection/mongo.py +1412 -0
- gatewaysdk/store/collection_based.py +1823 -0
- gatewaysdk/store/gateway.py +983 -0
- gatewaysdk/store/gateway_listener.py +465 -0
- gatewaysdk/store/listener.py +58 -0
- gatewaysdk/store/memory.py +396 -0
- gatewaysdk/store/mongo.py +165 -0
- gatewaysdk/store/redis_stream.py +517 -0
- gatewaysdk/store/sqlite.py +3 -0
- gatewaysdk/store/threading.py +370 -0
- gatewaysdk/store/utils.py +142 -0
- gatewaysdk/tracer/__init__.py +14 -0
- gatewaysdk/tracer/base.py +286 -0
- gatewaysdk/tracer/dummy.py +106 -0
- gatewaysdk/tracer/otel.py +559 -0
- gatewaysdk/tracing/__init__.py +110 -0
- gatewaysdk/tracing/api.py +808 -0
- gatewaysdk/tracing/attributes.py +9 -0
- gatewaysdk/tracing/context.py +272 -0
- gatewaysdk/tracing/exporters/__init__.py +10 -0
- gatewaysdk/tracing/exporters/gateway.py +228 -0
- gatewaysdk/tracing/identity.py +288 -0
- gatewaysdk/tracing/init.py +620 -0
- gatewaysdk/tracing/instrumentors/__init__.py +15 -0
- gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
- gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
- gatewaysdk/tracing/instrumentors/registry.py +352 -0
- gatewaysdk/tracing/mapping.py +729 -0
- gatewaysdk/tracing/processors.py +393 -0
- gatewaysdk/tracing/push.py +617 -0
- gatewaysdk/tracing/push_models.py +247 -0
- gatewaysdk/tracing/semconv.py +294 -0
- gatewaysdk/tracing/span_builder.py +356 -0
- gatewaysdk/trainer/__init__.py +6 -0
- gatewaysdk/trainer/init_utils.py +263 -0
- gatewaysdk/trainer/legacy.py +359 -0
- gatewaysdk/trainer/registry.py +12 -0
- gatewaysdk/trainer/trainer.py +638 -0
- gatewaysdk/types/__init__.py +63 -0
- gatewaysdk/types/core.py +556 -0
- gatewaysdk/types/resources.py +204 -0
- gatewaysdk/types/tracer.py +515 -0
- gatewaysdk/types/tracing.py +162 -0
- gatewaysdk/users.py +251 -0
- gatewaysdk/utils/__init__.py +1 -0
- gatewaysdk/utils/id.py +18 -0
- gatewaysdk/utils/metrics.py +1025 -0
- gatewaysdk/utils/otel.py +550 -0
- gatewaysdk/utils/otlp.py +556 -0
- gatewaysdk/utils/redact.py +22 -0
- gatewaysdk/utils/server_launcher.py +1045 -0
- gatewaysdk/utils/system_snapshot.py +90 -0
- gatewaysdk/verl/__init__.py +8 -0
- gatewaysdk/verl/__main__.py +6 -0
- gatewaysdk/verl/async_server.py +46 -0
- gatewaysdk/verl/config.yaml +27 -0
- gatewaysdk/verl/daemon.py +1154 -0
- gatewaysdk/verl/dataset.py +44 -0
- gatewaysdk/verl/entrypoint.py +248 -0
- gatewaysdk/verl/trainer.py +549 -0
- gatewaysdk/world_browser.py +748 -0
- gatewaysdk/world_data.py +453 -0
- gatewaysdk/world_sessions.py +978 -0
- gatewaysdk/world_tasks.py +285 -0
- gatewaysdk/world_tools.py +115 -0
- gatewaysdk/worlds.py +639 -0
- gatewaysdk-0.3.2.dist-info/METADATA +236 -0
- gatewaysdk-0.3.2.dist-info/RECORD +376 -0
- gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
- gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
- gatewaysdk-0.3.2.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
"""Credential-free platform bridge for authored schema bundles.
|
|
2
|
+
|
|
3
|
+
Validation parses Python syntax without importing it. Authored code executes only
|
|
4
|
+
inside the hosted runner or an isolated workbench, never the publication server.
|
|
5
|
+
The stdio protocol deliberately contains bundle bytes and mapping context only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import gzip
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
import tarfile
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .._materialize import materialize_template
|
|
24
|
+
from . import ContractError, SQLiteStore, bundle_mode, check_bundle, compile_bundle
|
|
25
|
+
from . import snapshot as snapshots
|
|
26
|
+
from .validation import (
|
|
27
|
+
digest,
|
|
28
|
+
encoded,
|
|
29
|
+
fail,
|
|
30
|
+
journal_paths,
|
|
31
|
+
members,
|
|
32
|
+
nonempty,
|
|
33
|
+
read_json,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
37
|
+
MAX_FILES = 2000
|
|
38
|
+
COMMANDS = (
|
|
39
|
+
"validate",
|
|
40
|
+
"validate-archive",
|
|
41
|
+
"init-template",
|
|
42
|
+
"init-template-files",
|
|
43
|
+
"template-summary",
|
|
44
|
+
"describe",
|
|
45
|
+
"compile",
|
|
46
|
+
"apply-batch",
|
|
47
|
+
"build-slack",
|
|
48
|
+
"refresh-slack",
|
|
49
|
+
"capture",
|
|
50
|
+
"import-data",
|
|
51
|
+
"ingest",
|
|
52
|
+
"conform",
|
|
53
|
+
"counts",
|
|
54
|
+
"api-bundle",
|
|
55
|
+
"templates",
|
|
56
|
+
"validate-rows",
|
|
57
|
+
"snapshot-build",
|
|
58
|
+
"snapshot-default",
|
|
59
|
+
"run-tests",
|
|
60
|
+
)
|
|
61
|
+
PLATFORM_MAPPING = {"id": "platform-agent", "version": "1"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compile_world(root: Path) -> dict:
|
|
65
|
+
"""Authored files -> generated artifacts, on an arbitrary schema world.
|
|
66
|
+
|
|
67
|
+
The platform stores compiled trees, so a world whose authored inputs changed
|
|
68
|
+
(``schema/world.json``, ``gateway-env.toml``, handlers) is recompiled here
|
|
69
|
+
rather than rejected with ``lock_mismatch``. Only a journal that holds no
|
|
70
|
+
state (``mutations == []``) of the same domain ``$id`` follows the contract to
|
|
71
|
+
its new identity; a journal with rows is refused, never migrated silently —
|
|
72
|
+
state migration stays manual (re-import the rows against the new schema).
|
|
73
|
+
Journals from a different domain are refused outright. The snapshots are then
|
|
74
|
+
rebuilt loudly: data the contract refuses is a rejection, not a dropped snapshot.
|
|
75
|
+
"""
|
|
76
|
+
from .snapshot import build_all
|
|
77
|
+
|
|
78
|
+
if bundle_mode(root) is None:
|
|
79
|
+
fail(
|
|
80
|
+
"mode",
|
|
81
|
+
"Expected a schema-backed mock-tools world (gateway-env.toml with [schema] language)",
|
|
82
|
+
)
|
|
83
|
+
added = []
|
|
84
|
+
base = root / "schema/base.json"
|
|
85
|
+
if not base.exists():
|
|
86
|
+
from .scaffold import TEMPLATES
|
|
87
|
+
|
|
88
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
base.write_bytes((TEMPLATES / "base.json").read_bytes())
|
|
90
|
+
added.append("schema/base.json")
|
|
91
|
+
contract = compile_bundle(root, snapshots=False)
|
|
92
|
+
initial = root / "data/initial.json"
|
|
93
|
+
if not initial.exists():
|
|
94
|
+
initial.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
initial.write_bytes(
|
|
96
|
+
encoded(
|
|
97
|
+
{
|
|
98
|
+
"id": f"{contract.metadata['package']['id']}.initial.v1",
|
|
99
|
+
"target": contract.target,
|
|
100
|
+
"mapping": {"id": "empty", "version": "1"},
|
|
101
|
+
"coverage": "partial",
|
|
102
|
+
"mutations": [],
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
added.append("data/initial.json")
|
|
107
|
+
retargeted = []
|
|
108
|
+
for path in journal_paths(root):
|
|
109
|
+
journal = read_json(path)
|
|
110
|
+
location = str(path.relative_to(root))
|
|
111
|
+
if not isinstance(journal, dict) or not isinstance(journal.get("target"), dict):
|
|
112
|
+
fail("batch", "Journal must be a batch object with a target", location)
|
|
113
|
+
if journal["target"] == contract.target:
|
|
114
|
+
continue
|
|
115
|
+
if journal["target"].get("id") != contract.target["id"]:
|
|
116
|
+
fail(
|
|
117
|
+
"batch_target",
|
|
118
|
+
"Journal belongs to a different domain; remove it or restore the schema's $id",
|
|
119
|
+
location,
|
|
120
|
+
)
|
|
121
|
+
if journal.get("mutations") != []:
|
|
122
|
+
fail(
|
|
123
|
+
"batch_target",
|
|
124
|
+
"Batch targets a different domain identity/version/hash; a journal that holds "
|
|
125
|
+
"state is not migrated to an edited schema — re-import its rows against the new "
|
|
126
|
+
"contract (or restore the schema it was written against)",
|
|
127
|
+
location,
|
|
128
|
+
)
|
|
129
|
+
journal["target"] = contract.target
|
|
130
|
+
path.write_bytes(encoded(journal))
|
|
131
|
+
retargeted.append(location)
|
|
132
|
+
build_all(root, contract)
|
|
133
|
+
return {"added": added, "retargeted": retargeted}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def append_batch(root: Path, batch: dict, *, source: str | None = None) -> dict:
|
|
137
|
+
"""Validate then append one platform batch as an immutable refresh journal.
|
|
138
|
+
|
|
139
|
+
``target`` is filled from the compiled contract (a caller-supplied one must
|
|
140
|
+
match); ``mapping`` and ``coverage`` default to the platform mapping and
|
|
141
|
+
``partial``. Mutations without ``source`` take ``{"record": "<source>#<n>"}``
|
|
142
|
+
when a source prefix is given, otherwise provenance is required per mutation.
|
|
143
|
+
Replaying every prior journal first makes a repeated batch id idempotent and
|
|
144
|
+
a reused id with different content a conflict, exactly as in the store.
|
|
145
|
+
"""
|
|
146
|
+
contract = check_bundle(root)
|
|
147
|
+
members(
|
|
148
|
+
batch,
|
|
149
|
+
{"id", "target", "mapping", "coverage", "mutations"},
|
|
150
|
+
{"id", "mutations"},
|
|
151
|
+
"/batch",
|
|
152
|
+
)
|
|
153
|
+
nonempty(batch["id"], "/batch/id")
|
|
154
|
+
if "target" in batch and batch["target"] != contract.target:
|
|
155
|
+
fail(
|
|
156
|
+
"batch_target",
|
|
157
|
+
"Batch targets a different domain identity/version/hash",
|
|
158
|
+
"/batch/target",
|
|
159
|
+
)
|
|
160
|
+
if not isinstance(batch["mutations"], list) or not batch["mutations"]:
|
|
161
|
+
fail("batch", "mutations must be a non-empty array", "/batch/mutations")
|
|
162
|
+
mutations = []
|
|
163
|
+
for index, mutation in enumerate(batch["mutations"]):
|
|
164
|
+
if not isinstance(mutation, dict):
|
|
165
|
+
fail(
|
|
166
|
+
"batch", "Each mutation must be an object", f"/batch/mutations/{index}"
|
|
167
|
+
)
|
|
168
|
+
if "source" not in mutation:
|
|
169
|
+
if source is None:
|
|
170
|
+
fail(
|
|
171
|
+
"provenance",
|
|
172
|
+
"Mutation has no source; pass a source prefix or a source.record per mutation",
|
|
173
|
+
f"/batch/mutations/{index}/source",
|
|
174
|
+
)
|
|
175
|
+
mutation = {**mutation, "source": {"record": f"{source}#{index}"}}
|
|
176
|
+
mutations.append(mutation)
|
|
177
|
+
resolved = {
|
|
178
|
+
"id": batch["id"],
|
|
179
|
+
"target": contract.target,
|
|
180
|
+
"mapping": batch.get("mapping", PLATFORM_MAPPING),
|
|
181
|
+
"coverage": batch.get("coverage", "partial"),
|
|
182
|
+
"mutations": mutations,
|
|
183
|
+
}
|
|
184
|
+
journals = journal_paths(root)
|
|
185
|
+
with (
|
|
186
|
+
tempfile.TemporaryDirectory(prefix="gateway-apply-batch-") as temporary,
|
|
187
|
+
SQLiteStore.create(Path(temporary) / "state.sqlite", contract) as store,
|
|
188
|
+
):
|
|
189
|
+
for path in journals:
|
|
190
|
+
store.apply_batch(read_json(path))
|
|
191
|
+
result = store.apply_batch(resolved)
|
|
192
|
+
store.audit()
|
|
193
|
+
if result["status"] == "already_applied":
|
|
194
|
+
return result
|
|
195
|
+
batches = root / "data/batches"
|
|
196
|
+
batches.mkdir(exist_ok=True)
|
|
197
|
+
existing = len(journals) - 1
|
|
198
|
+
target = batches / f"{existing + 1:06d}-{digest(batch['id'].encode())[:16]}.json"
|
|
199
|
+
target.write_bytes(encoded(resolved))
|
|
200
|
+
if snapshots.default_snapshot(root).is_file():
|
|
201
|
+
# The journals grew; a snapshot built before this batch would be served
|
|
202
|
+
# over them. Rebuild so the tree stays one thing (default + scenarios).
|
|
203
|
+
from .compiler import build
|
|
204
|
+
from .snapshot import build_all
|
|
205
|
+
|
|
206
|
+
build_all(root, build(root))
|
|
207
|
+
return {**result, "journal": str(target.relative_to(root))}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _pinned_state(bundle: Path, contract) -> dict[str, int] | None:
|
|
211
|
+
"""The entity counts of a data-only version whose hosted snapshot is not in the
|
|
212
|
+
tree: ``db/snapshot.json`` alone. The platform audited that snapshot through
|
|
213
|
+
this contract when it built it, so the pin stands in for the state check —
|
|
214
|
+
provided it names this contract; a snapshot built through another contract
|
|
215
|
+
is refused, since the tree's data files cannot reproduce it either. A tree
|
|
216
|
+
that carries its snapshot, or no pin at all, materializes as usual."""
|
|
217
|
+
from .snapshot import contract_hash, current_default, pinned_default
|
|
218
|
+
|
|
219
|
+
pin = pinned_default(bundle)
|
|
220
|
+
if pin is None or current_default(bundle, contract) is not None:
|
|
221
|
+
return None
|
|
222
|
+
if pin.get("contractHash") != contract_hash(contract):
|
|
223
|
+
fail(
|
|
224
|
+
"snapshot_pinned",
|
|
225
|
+
"db/snapshot.json pins a hosted snapshot built through a different contract; "
|
|
226
|
+
"re-import its rows against the current schema (import_world_data, "
|
|
227
|
+
"gateway worlds data import) or restore the schema it was built against",
|
|
228
|
+
"/db/snapshot.json",
|
|
229
|
+
)
|
|
230
|
+
counts = pin.get("entityCounts")
|
|
231
|
+
if not isinstance(counts, dict) or any(
|
|
232
|
+
not isinstance(value, int) or isinstance(value, bool)
|
|
233
|
+
for value in counts.values()
|
|
234
|
+
):
|
|
235
|
+
fail(
|
|
236
|
+
"snapshot_pin",
|
|
237
|
+
"db/snapshot.json must carry the hosted snapshot's entityCounts",
|
|
238
|
+
"/db/snapshot.json",
|
|
239
|
+
)
|
|
240
|
+
return {entity: int(counts.get(entity, 0)) for entity in contract.entities}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def validate_bundle(bundle: str | Path) -> dict:
|
|
244
|
+
bundle = Path(bundle)
|
|
245
|
+
if bundle_mode(bundle) is None:
|
|
246
|
+
fail("mode", "Expected a schema-backed mock-tools world")
|
|
247
|
+
contract = check_bundle(bundle)
|
|
248
|
+
declared = set(contract.tools)
|
|
249
|
+
found: dict[str, str] = {}
|
|
250
|
+
for path in sorted((bundle / "tools").glob("*.py")):
|
|
251
|
+
if path.is_symlink():
|
|
252
|
+
fail("path", "Tool handlers must be regular files")
|
|
253
|
+
try:
|
|
254
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=path.name)
|
|
255
|
+
except (SyntaxError, UnicodeError):
|
|
256
|
+
fail("handler", f"Invalid Python handler syntax: {path.name}")
|
|
257
|
+
for node in tree.body:
|
|
258
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
259
|
+
continue
|
|
260
|
+
names = []
|
|
261
|
+
for decorator in node.decorator_list:
|
|
262
|
+
target = (
|
|
263
|
+
decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
264
|
+
)
|
|
265
|
+
is_tool = (isinstance(target, ast.Name) and target.id == "tool") or (
|
|
266
|
+
isinstance(target, ast.Attribute) and target.attr == "tool"
|
|
267
|
+
)
|
|
268
|
+
if not is_tool:
|
|
269
|
+
continue
|
|
270
|
+
name = node.name
|
|
271
|
+
if isinstance(decorator, ast.Call):
|
|
272
|
+
for keyword in decorator.keywords:
|
|
273
|
+
if keyword.arg == "name":
|
|
274
|
+
if not isinstance(
|
|
275
|
+
keyword.value, ast.Constant
|
|
276
|
+
) or not isinstance(keyword.value.value, str):
|
|
277
|
+
fail(
|
|
278
|
+
"handler", "Publication requires literal tool names"
|
|
279
|
+
)
|
|
280
|
+
name = keyword.value.value
|
|
281
|
+
names.append(name)
|
|
282
|
+
if not names and node.name in declared:
|
|
283
|
+
names.append(node.name)
|
|
284
|
+
if not names and node.name == "run" and path.stem in declared:
|
|
285
|
+
names.append(path.stem)
|
|
286
|
+
for name in names:
|
|
287
|
+
if name in found:
|
|
288
|
+
fail("handler", f"Duplicate handler for {name}")
|
|
289
|
+
found[name] = path.name
|
|
290
|
+
if set(found) != declared:
|
|
291
|
+
fail("handler", "Declared tools and Python handlers must match exactly")
|
|
292
|
+
pinned = _pinned_state(bundle, contract)
|
|
293
|
+
if pinned is not None:
|
|
294
|
+
counts, initial_state = pinned, "pinned"
|
|
295
|
+
else:
|
|
296
|
+
initial_state = "validated"
|
|
297
|
+
with tempfile.TemporaryDirectory(prefix="gateway-state-check-") as staging:
|
|
298
|
+
database = Path(staging) / "world.db"
|
|
299
|
+
materialize_template(bundle, database)
|
|
300
|
+
with SQLiteStore.open(database, contract) as store:
|
|
301
|
+
store.audit()
|
|
302
|
+
counts = {
|
|
303
|
+
entity: len(store.list(entity)) for entity in contract.entities
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
"status": "ok",
|
|
307
|
+
"domain": contract.target,
|
|
308
|
+
"package": contract.metadata["package"],
|
|
309
|
+
"initial_state": initial_state,
|
|
310
|
+
"handler_execution": "skipped",
|
|
311
|
+
"entity_counts": counts,
|
|
312
|
+
"tools": sorted(declared),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
API_BUNDLE = "gateway-api-world.toml"
|
|
317
|
+
BUNDLED_WORLD = "server/world"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def world_root(root: Path) -> Path:
|
|
321
|
+
"""The world inside the tree the platform holds. ``bench push`` uploads a schema
|
|
322
|
+
world as its api bundle, so a version's tree is ``gateway-api-world.toml`` +
|
|
323
|
+
``seed/`` + ``scenarios/`` + ``server/world/<the authoring world>``: that nested
|
|
324
|
+
world is what data commands operate on. A plain tree is its own root."""
|
|
325
|
+
nested = root / BUNDLED_WORLD
|
|
326
|
+
if (root / API_BUNDLE).is_file() and (
|
|
327
|
+
(nested / "connector.toml").is_file()
|
|
328
|
+
or (nested / "schema/world.json").is_file()
|
|
329
|
+
):
|
|
330
|
+
return nested
|
|
331
|
+
return root
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _write_files(root: Path, entries: list) -> None:
|
|
335
|
+
if not isinstance(entries, list) or len(entries) > MAX_FILES:
|
|
336
|
+
fail("size", "Invalid bundle file count")
|
|
337
|
+
seen = set()
|
|
338
|
+
size = 0
|
|
339
|
+
for entry in entries:
|
|
340
|
+
if not isinstance(entry, dict) or set(entry) != {"path", "content"}:
|
|
341
|
+
fail("input", "Expected path and base64 content")
|
|
342
|
+
name = entry["path"]
|
|
343
|
+
if not isinstance(name, str) or not name or "\\" in name or "\x00" in name:
|
|
344
|
+
fail("path", "Invalid bundle path")
|
|
345
|
+
path = PurePosixPath(name)
|
|
346
|
+
if (
|
|
347
|
+
path.is_absolute()
|
|
348
|
+
or any(p in ("", ".", "..") for p in name.split("/"))
|
|
349
|
+
or name in seen
|
|
350
|
+
):
|
|
351
|
+
fail("path", "Bundle paths must be unique relative file paths")
|
|
352
|
+
seen.add(name)
|
|
353
|
+
try:
|
|
354
|
+
data = base64.b64decode(entry["content"], validate=True)
|
|
355
|
+
except (ValueError, TypeError):
|
|
356
|
+
fail("input", "Invalid base64 bundle content")
|
|
357
|
+
size += len(data)
|
|
358
|
+
if size > MAX_BYTES:
|
|
359
|
+
fail("size", "Bundle exceeds 64 MiB")
|
|
360
|
+
target = root.joinpath(*path.parts)
|
|
361
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
target.write_bytes(data)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _archive_files(encoded: str) -> list:
|
|
366
|
+
try:
|
|
367
|
+
archive = base64.b64decode(encoded, validate=True)
|
|
368
|
+
if len(archive) > MAX_BYTES:
|
|
369
|
+
fail("size", "Schema archive exceeds 64 MiB")
|
|
370
|
+
with gzip.GzipFile(fileobj=io.BytesIO(archive)) as stream:
|
|
371
|
+
data = stream.read(MAX_BYTES + 1)
|
|
372
|
+
if len(data) > MAX_BYTES:
|
|
373
|
+
fail("size", "Expanded schema archive exceeds 64 MiB")
|
|
374
|
+
files = {}
|
|
375
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:") as tar:
|
|
376
|
+
if tar.pax_headers:
|
|
377
|
+
fail("archive", "Global archive metadata is not supported")
|
|
378
|
+
for member in tar:
|
|
379
|
+
if len(files) > MAX_FILES:
|
|
380
|
+
fail("size", "Too many archive files")
|
|
381
|
+
if (
|
|
382
|
+
member.issym()
|
|
383
|
+
or member.islnk()
|
|
384
|
+
or not (member.isfile() or member.isdir())
|
|
385
|
+
):
|
|
386
|
+
fail(
|
|
387
|
+
"archive",
|
|
388
|
+
"Archive may contain only ordinary files and directories",
|
|
389
|
+
)
|
|
390
|
+
if any(
|
|
391
|
+
key
|
|
392
|
+
not in (
|
|
393
|
+
"path",
|
|
394
|
+
"mtime",
|
|
395
|
+
"atime",
|
|
396
|
+
"ctime",
|
|
397
|
+
"uid",
|
|
398
|
+
"gid",
|
|
399
|
+
"uname",
|
|
400
|
+
"gname",
|
|
401
|
+
)
|
|
402
|
+
for key in member.pax_headers
|
|
403
|
+
):
|
|
404
|
+
fail("archive", "Unsupported archive metadata")
|
|
405
|
+
name = member.name
|
|
406
|
+
while name.startswith("./"):
|
|
407
|
+
name = name[2:]
|
|
408
|
+
path = PurePosixPath(name)
|
|
409
|
+
if (
|
|
410
|
+
path.is_absolute()
|
|
411
|
+
or ".." in path.parts
|
|
412
|
+
or not name
|
|
413
|
+
or chr(92) in name
|
|
414
|
+
):
|
|
415
|
+
fail("path", "Archive path escapes the bundle")
|
|
416
|
+
if member.isdir():
|
|
417
|
+
continue
|
|
418
|
+
stream = tar.extractfile(member)
|
|
419
|
+
if stream is None:
|
|
420
|
+
fail("archive", "Unreadable archive member")
|
|
421
|
+
content = stream.read(MAX_BYTES + 1)
|
|
422
|
+
if len(content) != member.size:
|
|
423
|
+
fail("archive", "Truncated archive member")
|
|
424
|
+
files[str(path)] = base64.b64encode(content).decode("ascii")
|
|
425
|
+
return [{"path": path, "content": content} for path, content in files.items()]
|
|
426
|
+
except (tarfile.TarError, EOFError, OSError, ValueError, TypeError):
|
|
427
|
+
fail("archive", "Invalid world archive")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _capture(request: dict) -> dict:
|
|
431
|
+
"""``capture``: run one connection operation for the platform, from the pod whose
|
|
432
|
+
egress the vendor allow-listed. The spec arrives as text, the credential as the
|
|
433
|
+
environment variable it names (set by the caller, never in the request), and every
|
|
434
|
+
recorded page comes back redacted the way the CLI would have written it."""
|
|
435
|
+
from .connector import CAPTURES, MANIFEST, capture, parse_spec
|
|
436
|
+
|
|
437
|
+
spec_text = request.get("spec")
|
|
438
|
+
op = request.get("op")
|
|
439
|
+
args = request.get("args") or {}
|
|
440
|
+
params = request.get("params") or {}
|
|
441
|
+
max_pages = request.get("max_pages")
|
|
442
|
+
if not isinstance(spec_text, str) or not isinstance(op, str):
|
|
443
|
+
fail("input", "Expected the connector.toml text and an operation name")
|
|
444
|
+
if not all(
|
|
445
|
+
isinstance(m, dict)
|
|
446
|
+
and all(isinstance(k, str) and isinstance(v, str) for k, v in m.items())
|
|
447
|
+
for m in (args, params)
|
|
448
|
+
):
|
|
449
|
+
fail("input", "args and params map names to strings")
|
|
450
|
+
if max_pages is not None and (
|
|
451
|
+
isinstance(max_pages, bool) or not isinstance(max_pages, int) or max_pages < 1
|
|
452
|
+
):
|
|
453
|
+
fail("input", "max_pages must be a positive integer")
|
|
454
|
+
parse_spec(spec_text)
|
|
455
|
+
with tempfile.TemporaryDirectory(prefix="gateway-capture-") as directory:
|
|
456
|
+
root = Path(directory) / "world"
|
|
457
|
+
root.mkdir()
|
|
458
|
+
(root / "connector.toml").write_text(spec_text, encoding="utf-8")
|
|
459
|
+
written = capture(root, op, args, params, max_pages=max_pages)
|
|
460
|
+
manifest = json.loads((root / CAPTURES / MANIFEST).read_text(encoding="utf-8"))
|
|
461
|
+
return {
|
|
462
|
+
"status": "ok",
|
|
463
|
+
"files": [
|
|
464
|
+
{
|
|
465
|
+
"path": path.relative_to(root).as_posix(),
|
|
466
|
+
"content": base64.b64encode(path.read_bytes()).decode("ascii"),
|
|
467
|
+
}
|
|
468
|
+
for path in written
|
|
469
|
+
],
|
|
470
|
+
"manifest": manifest,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _template_files(request: dict) -> dict[str, bytes]:
|
|
475
|
+
listed = request.get("files")
|
|
476
|
+
if not isinstance(listed, list) or not listed:
|
|
477
|
+
fail("input", "Expected the template's files as [{path, content}]")
|
|
478
|
+
files: dict[str, bytes] = {}
|
|
479
|
+
for entry in listed:
|
|
480
|
+
if (
|
|
481
|
+
not isinstance(entry, dict)
|
|
482
|
+
or not isinstance(entry.get("path"), str)
|
|
483
|
+
or not isinstance(entry.get("content"), str)
|
|
484
|
+
):
|
|
485
|
+
fail("input", "Each template file is {path, content} with base64 content")
|
|
486
|
+
try:
|
|
487
|
+
files[entry["path"]] = base64.b64decode(entry["content"], validate=True)
|
|
488
|
+
except (ValueError, binascii.Error):
|
|
489
|
+
fail("input", "Template file content must be base64")
|
|
490
|
+
return files
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _template_summary(files: dict[str, bytes]) -> dict:
|
|
494
|
+
"""What a workspace connector is, read from its files: the vendor connection
|
|
495
|
+
(connector.toml) and the data model (world.json), plus the digest the platform
|
|
496
|
+
pins worlds to. Never runs the template's Python."""
|
|
497
|
+
from .connector import parse_spec
|
|
498
|
+
from .scaffold import template_digest
|
|
499
|
+
|
|
500
|
+
# A template keeps world.json at its root; an initialized world keeps it under schema/.
|
|
501
|
+
world_path = next(
|
|
502
|
+
(path for path in ("world.json", "schema/world.json") if path in files), None
|
|
503
|
+
)
|
|
504
|
+
if world_path is None:
|
|
505
|
+
fail(
|
|
506
|
+
"template",
|
|
507
|
+
"No world.json (template) or schema/world.json (world) among the files",
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
world = json.loads(files[world_path].decode("utf-8"))
|
|
511
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
512
|
+
fail("template", f"{world_path} is not valid JSON: {error}")
|
|
513
|
+
gateway = world.get("x-gateway") if isinstance(world, dict) else None
|
|
514
|
+
if not isinstance(gateway, dict):
|
|
515
|
+
fail("template", f"{world_path} is not a gateway-world document")
|
|
516
|
+
entities = []
|
|
517
|
+
for name, entity in sorted((gateway.get("entities") or {}).items()):
|
|
518
|
+
fields: list = []
|
|
519
|
+
schema = entity.get("schema") if isinstance(entity, dict) else None
|
|
520
|
+
ref = schema.get("$ref") if isinstance(schema, dict) else None
|
|
521
|
+
definition = None
|
|
522
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
523
|
+
definition = (world.get("$defs") or {}).get(ref[len("#/$defs/") :])
|
|
524
|
+
properties = (
|
|
525
|
+
(definition or {}).get("properties") or {}
|
|
526
|
+
if isinstance(definition, dict)
|
|
527
|
+
else {}
|
|
528
|
+
)
|
|
529
|
+
required = (
|
|
530
|
+
set((definition or {}).get("required") or [])
|
|
531
|
+
if isinstance(definition, dict)
|
|
532
|
+
else set()
|
|
533
|
+
)
|
|
534
|
+
for field_name, field_schema in properties.items():
|
|
535
|
+
kind = field_schema.get("type") if isinstance(field_schema, dict) else None
|
|
536
|
+
if isinstance(kind, list):
|
|
537
|
+
# A nullable field is `["string", "null"]` in JSON Schema; the page
|
|
538
|
+
# shows it as one readable type, not the list's concatenation.
|
|
539
|
+
kind = " | ".join(str(part) for part in kind)
|
|
540
|
+
if (
|
|
541
|
+
kind is None
|
|
542
|
+
and isinstance(field_schema, dict)
|
|
543
|
+
and isinstance(field_schema.get("$ref"), str)
|
|
544
|
+
):
|
|
545
|
+
kind = field_schema["$ref"].rsplit("/", 1)[-1]
|
|
546
|
+
fields.append(
|
|
547
|
+
{"name": field_name, "type": kind, "required": field_name in required}
|
|
548
|
+
)
|
|
549
|
+
entities.append(
|
|
550
|
+
{
|
|
551
|
+
"name": name,
|
|
552
|
+
"primaryKey": list(entity.get("primary_key") or [])
|
|
553
|
+
if isinstance(entity, dict)
|
|
554
|
+
else [],
|
|
555
|
+
"unique": list(entity.get("unique") or [])
|
|
556
|
+
if isinstance(entity, dict)
|
|
557
|
+
else [],
|
|
558
|
+
"relationships": list(entity.get("relationships") or [])
|
|
559
|
+
if isinstance(entity, dict)
|
|
560
|
+
else [],
|
|
561
|
+
"fields": fields,
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
connection = None
|
|
565
|
+
if "connector.toml" in files:
|
|
566
|
+
spec = parse_spec(files["connector.toml"].decode("utf-8"))
|
|
567
|
+
connection = {
|
|
568
|
+
"slug": spec.slug,
|
|
569
|
+
"baseUrl": spec.base_url,
|
|
570
|
+
"auth": spec.scheme,
|
|
571
|
+
"operations": [
|
|
572
|
+
{
|
|
573
|
+
"name": op.name,
|
|
574
|
+
"method": op.method,
|
|
575
|
+
"path": op.path,
|
|
576
|
+
"params": list(op.params),
|
|
577
|
+
"entity": op.entity,
|
|
578
|
+
"results": op.results,
|
|
579
|
+
}
|
|
580
|
+
for op in spec.operations.values()
|
|
581
|
+
],
|
|
582
|
+
}
|
|
583
|
+
package = gateway.get("package") or {}
|
|
584
|
+
return {
|
|
585
|
+
"status": "ok",
|
|
586
|
+
"summary": {
|
|
587
|
+
"packageVersion": package.get("version"),
|
|
588
|
+
"domainVersion": gateway.get("domain_version"),
|
|
589
|
+
"domainId": world.get("$id"),
|
|
590
|
+
"digest": template_digest(files),
|
|
591
|
+
"entities": entities,
|
|
592
|
+
"connection": connection,
|
|
593
|
+
"tools": sorted((gateway.get("tools") or {}).keys()),
|
|
594
|
+
"files": sorted(files),
|
|
595
|
+
},
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
COMPILED_MARKERS = ("schema/world.json", "tool-schemas.json")
|
|
600
|
+
# The compiled world's files a description is read from; the rest of a bundle
|
|
601
|
+
# (seed data, verifiers, tool sources) says nothing about the contract.
|
|
602
|
+
DESCRIBE_PATHS = (
|
|
603
|
+
"schema/world.json",
|
|
604
|
+
"tool-schemas.json",
|
|
605
|
+
"connector.toml",
|
|
606
|
+
"api/handlers.py",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _describe(request: dict) -> dict:
|
|
611
|
+
"""One description for the connector page and a world's Schema tab: the data
|
|
612
|
+
model, the tools with their schemas, and — when the connector declares a vendor
|
|
613
|
+
connection — the OpenAPI document the served world answers to.
|
|
614
|
+
|
|
615
|
+
The connector is compiled exactly as a world build compiles it (an installed
|
|
616
|
+
template by slug, or a template's files), so the page shows what a world gets;
|
|
617
|
+
a compiled world's own files (``schema/world.json`` + ``tool-schemas.json``) pass
|
|
618
|
+
straight through. Never runs the connector's Python."""
|
|
619
|
+
from .connector import parse_spec
|
|
620
|
+
from .scaffold import (
|
|
621
|
+
TEMPLATES,
|
|
622
|
+
init_bundle,
|
|
623
|
+
init_bundle_from_files,
|
|
624
|
+
template_digest,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
connector = request.get("connector")
|
|
628
|
+
with tempfile.TemporaryDirectory(prefix="gateway-schema-") as directory:
|
|
629
|
+
root = Path(directory) / "bundle"
|
|
630
|
+
if connector is not None:
|
|
631
|
+
if not isinstance(connector, str):
|
|
632
|
+
fail("input", "Expected an installed connector template slug")
|
|
633
|
+
init_bundle(root, connector=connector)
|
|
634
|
+
template_dir = TEMPLATES / connector
|
|
635
|
+
authored = {
|
|
636
|
+
str(path.relative_to(template_dir)): path.read_bytes()
|
|
637
|
+
for path in sorted(template_dir.rglob("*"))
|
|
638
|
+
if path.is_file() and "__pycache__" not in path.parts
|
|
639
|
+
}
|
|
640
|
+
else:
|
|
641
|
+
authored = _template_files(request)
|
|
642
|
+
if all(marker in authored for marker in COMPILED_MARKERS):
|
|
643
|
+
root.mkdir()
|
|
644
|
+
for path, data in authored.items():
|
|
645
|
+
if path in DESCRIBE_PATHS:
|
|
646
|
+
target = root / path
|
|
647
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
648
|
+
target.write_bytes(data)
|
|
649
|
+
else:
|
|
650
|
+
slug = (
|
|
651
|
+
request.get("slug")
|
|
652
|
+
if isinstance(request.get("slug"), str)
|
|
653
|
+
else None
|
|
654
|
+
)
|
|
655
|
+
if slug is None and "connector.toml" in authored:
|
|
656
|
+
slug = parse_spec(authored["connector.toml"].decode("utf-8")).slug
|
|
657
|
+
init_bundle_from_files(root, authored, connector=slug or "custom")
|
|
658
|
+
summary = _template_summary(
|
|
659
|
+
{"world.json": (root / "schema/world.json").read_bytes()}
|
|
660
|
+
)["summary"]
|
|
661
|
+
summary["digest"] = template_digest(authored)
|
|
662
|
+
summary["files"] = sorted(authored)
|
|
663
|
+
tool_schemas = {}
|
|
664
|
+
if (root / "tool-schemas.json").is_file():
|
|
665
|
+
tool_schemas = (
|
|
666
|
+
json.loads(
|
|
667
|
+
(root / "tool-schemas.json").read_text(encoding="utf-8")
|
|
668
|
+
).get("tools")
|
|
669
|
+
or {}
|
|
670
|
+
)
|
|
671
|
+
summary["tools"] = [
|
|
672
|
+
{
|
|
673
|
+
"name": name,
|
|
674
|
+
"description": str(entry.get("description") or ""),
|
|
675
|
+
"inputSchema": entry.get("input_schema"),
|
|
676
|
+
"outputSchema": entry.get("output_schema"),
|
|
677
|
+
}
|
|
678
|
+
for name, entry in sorted(tool_schemas.items())
|
|
679
|
+
]
|
|
680
|
+
summary["connection"] = None
|
|
681
|
+
summary["openapi"] = None
|
|
682
|
+
if (root / "connector.toml").is_file():
|
|
683
|
+
from .api import load_api_settings, openapi_document
|
|
684
|
+
|
|
685
|
+
spec = parse_spec((root / "connector.toml").read_text(encoding="utf-8"))
|
|
686
|
+
try:
|
|
687
|
+
settings = load_api_settings(root, spec)
|
|
688
|
+
except ContractError:
|
|
689
|
+
# A template without its handlers still has a shape worth showing;
|
|
690
|
+
# the routes render with the default action and status.
|
|
691
|
+
settings = None
|
|
692
|
+
summary["connection"] = {
|
|
693
|
+
"slug": spec.slug,
|
|
694
|
+
"baseUrl": spec.base_url,
|
|
695
|
+
"auth": spec.scheme,
|
|
696
|
+
"operations": [
|
|
697
|
+
{
|
|
698
|
+
"name": op.name,
|
|
699
|
+
"method": op.method,
|
|
700
|
+
"path": op.path,
|
|
701
|
+
"params": list(op.params),
|
|
702
|
+
"entity": op.entity,
|
|
703
|
+
"results": op.results,
|
|
704
|
+
"action": settings.for_operation(op.name).action
|
|
705
|
+
if settings
|
|
706
|
+
else None,
|
|
707
|
+
"status": settings.for_operation(op.name).status
|
|
708
|
+
if settings
|
|
709
|
+
else None,
|
|
710
|
+
}
|
|
711
|
+
for op in spec.operations.values()
|
|
712
|
+
],
|
|
713
|
+
}
|
|
714
|
+
summary["openapi"] = openapi_document(
|
|
715
|
+
spec, summary.get("domainId") or spec.slug, settings
|
|
716
|
+
)
|
|
717
|
+
return {"status": "ok", "description": summary}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _import_data(request: dict) -> dict:
|
|
721
|
+
"""``import-data``: rows into a world the platform holds as files. The tree arrives
|
|
722
|
+
as ``[{path, content}]``, the rows as ``{entity: [row, …]}`` with a mode; the world is
|
|
723
|
+
rebuilt in a temporary directory, the rows go in through :func:`data.import_rows`
|
|
724
|
+
(the same gate the CLI uses, scratch copy first), and every file of the updated
|
|
725
|
+
tree comes back the way ``init-template`` returns one, so the platform commits a
|
|
726
|
+
version from it without knowing which file changed."""
|
|
727
|
+
from .data import KIND_SCHEMA, MODES, import_rows
|
|
728
|
+
|
|
729
|
+
rows = request.get("rows")
|
|
730
|
+
mode = request.get("mode", "append")
|
|
731
|
+
if not isinstance(rows, dict) or not all(
|
|
732
|
+
isinstance(v, list) for v in rows.values()
|
|
733
|
+
):
|
|
734
|
+
fail("input", "Expected rows as {entity: [row, …]}")
|
|
735
|
+
if mode not in MODES:
|
|
736
|
+
fail("input", f"mode must be one of {', '.join(MODES)}")
|
|
737
|
+
with tempfile.TemporaryDirectory(prefix="gateway-import-") as directory:
|
|
738
|
+
root = Path(directory) / "bundle"
|
|
739
|
+
root.mkdir()
|
|
740
|
+
_write_files(root, request.get("files"))
|
|
741
|
+
world = world_root(root)
|
|
742
|
+
result = import_rows(world, rows, mode=mode)
|
|
743
|
+
if result["kind"] == KIND_SCHEMA and snapshots.default_snapshot(world).is_file():
|
|
744
|
+
# The journals grew; a snapshot built before this import would be
|
|
745
|
+
# served over them. Rebuild so the tree that travels back is one thing.
|
|
746
|
+
from .compiler import build
|
|
747
|
+
from .snapshot import build_all
|
|
748
|
+
|
|
749
|
+
build_all(world, build(world)) # build(): the contract, no snapshot audit
|
|
750
|
+
if world != root and result["kind"] == KIND_SCHEMA:
|
|
751
|
+
# The bundle's base seed is derived from the world's data; older runners post it.
|
|
752
|
+
from .api import write_base_seed
|
|
753
|
+
|
|
754
|
+
write_base_seed(world, root)
|
|
755
|
+
return {
|
|
756
|
+
"status": "ok",
|
|
757
|
+
"kind": result["kind"],
|
|
758
|
+
"entity_counts": result["entity_counts"],
|
|
759
|
+
"changed": result["changed"],
|
|
760
|
+
"files": _tree(root),
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
# ---------------------------------------------------------------- batches
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _workdir(request: dict) -> Path:
|
|
768
|
+
"""The absolute directory the caller prepared; every path in the request is under it."""
|
|
769
|
+
workdir = request.get("workdir")
|
|
770
|
+
if (
|
|
771
|
+
not isinstance(workdir, str)
|
|
772
|
+
or not workdir
|
|
773
|
+
or not Path(workdir).is_absolute()
|
|
774
|
+
or not Path(workdir).is_dir()
|
|
775
|
+
):
|
|
776
|
+
fail(
|
|
777
|
+
"input",
|
|
778
|
+
"Expected workdir: an absolute path to a directory the caller prepared",
|
|
779
|
+
)
|
|
780
|
+
return Path(workdir).resolve()
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _under(workdir: Path, name: Any, what: str) -> Path:
|
|
784
|
+
if not isinstance(name, str) or not name or "\x00" in name:
|
|
785
|
+
fail("path", f"{what} must be a path under workdir")
|
|
786
|
+
candidate = (
|
|
787
|
+
(workdir / name).resolve()
|
|
788
|
+
if not Path(name).is_absolute()
|
|
789
|
+
else Path(name).resolve()
|
|
790
|
+
)
|
|
791
|
+
if not candidate.is_relative_to(workdir):
|
|
792
|
+
fail("path", f"{what} must stay under workdir")
|
|
793
|
+
return candidate
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _chunk_specs(workdir: Path, request: dict) -> list:
|
|
797
|
+
from .batch import ChunkSpec
|
|
798
|
+
|
|
799
|
+
listed = request.get("chunks")
|
|
800
|
+
if not isinstance(listed, list) or not listed:
|
|
801
|
+
fail("input", "Expected chunks as [{ordinal, path, entity?}]")
|
|
802
|
+
specs = []
|
|
803
|
+
seen = set()
|
|
804
|
+
for entry in listed:
|
|
805
|
+
if (
|
|
806
|
+
not isinstance(entry, dict)
|
|
807
|
+
or not isinstance(entry.get("ordinal"), int)
|
|
808
|
+
or isinstance(entry.get("ordinal"), bool)
|
|
809
|
+
):
|
|
810
|
+
fail("input", "Each chunk is {ordinal, path, entity?}")
|
|
811
|
+
entity = entry.get("entity")
|
|
812
|
+
if entity is not None and not isinstance(entity, str):
|
|
813
|
+
fail("input", "chunk entity must be a string")
|
|
814
|
+
if entry["ordinal"] in seen:
|
|
815
|
+
fail("input", f"chunk ordinal {entry['ordinal']} is listed twice")
|
|
816
|
+
seen.add(entry["ordinal"])
|
|
817
|
+
path = _under(workdir, entry.get("path"), "chunk path")
|
|
818
|
+
if not path.is_file():
|
|
819
|
+
fail("input", f"chunk {entry['ordinal']} is missing under workdir")
|
|
820
|
+
specs.append(ChunkSpec(entry["ordinal"], path, entity))
|
|
821
|
+
return sorted(specs, key=lambda spec: spec.ordinal)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _tree(root: Path) -> list:
|
|
825
|
+
"""Every file under ``root`` as ``[{path, content}]``, the encoding ``init-template``
|
|
826
|
+
returns, so the platform commits a version without knowing which file changed."""
|
|
827
|
+
return [
|
|
828
|
+
{
|
|
829
|
+
"path": path.relative_to(root).as_posix(),
|
|
830
|
+
"content": base64.b64encode(path.read_bytes()).decode("ascii"),
|
|
831
|
+
}
|
|
832
|
+
for path in sorted(root.rglob("*"))
|
|
833
|
+
if path.is_file() and "__pycache__" not in path.parts
|
|
834
|
+
]
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _write_tree(workdir: Path, request: dict) -> Path:
|
|
838
|
+
"""The world tree from ``files``, written under ``workdir/tree`` and compiled (the
|
|
839
|
+
contract alone is enough: generated artifacts are regenerated, snapshots are not)."""
|
|
840
|
+
from .compiler import compile_bundle
|
|
841
|
+
|
|
842
|
+
root = workdir / "tree"
|
|
843
|
+
if root.exists():
|
|
844
|
+
shutil.rmtree(root)
|
|
845
|
+
root.mkdir()
|
|
846
|
+
_write_files(root, request.get("files"))
|
|
847
|
+
world = world_root(root)
|
|
848
|
+
compile_bundle(world, snapshots=False)
|
|
849
|
+
return world
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _flag(request: dict, name: str, default: bool) -> bool:
|
|
853
|
+
value = request.get(name, default)
|
|
854
|
+
if not isinstance(value, bool):
|
|
855
|
+
fail("input", f"{name} must be true or false")
|
|
856
|
+
return value
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _validate_rows(request: dict) -> dict:
|
|
860
|
+
"""``validate-rows``: shape-only validation of every row in the chunks (type, bounds,
|
|
861
|
+
required, unknown property, primary key present); no reference checks."""
|
|
862
|
+
from .batch import validate_chunks
|
|
863
|
+
from .compiler import check_bundle
|
|
864
|
+
from .data import MODES
|
|
865
|
+
|
|
866
|
+
workdir = _workdir(request)
|
|
867
|
+
if request.get("mode", "append") not in MODES:
|
|
868
|
+
fail("input", f"mode must be one of {', '.join(MODES)}")
|
|
869
|
+
root = _write_tree(workdir, request)
|
|
870
|
+
report = validate_chunks(check_bundle(root), _chunk_specs(workdir, request))
|
|
871
|
+
return {"status": "ok", **report}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _snapshot_build(request: dict) -> dict:
|
|
875
|
+
"""``snapshot-build``: the parent snapshot (or the tree's default) with the chunks
|
|
876
|
+
applied in dependency order, one transaction (atomic) or one per chunk; the new
|
|
877
|
+
snapshot saved under workdir with its sidecar, or nothing on dryRun or an atomic
|
|
878
|
+
refusal."""
|
|
879
|
+
from .batch import build_batch
|
|
880
|
+
from .compiler import check_bundle
|
|
881
|
+
from .data import MERGES, MODES
|
|
882
|
+
from .snapshot import EMPTY_DIGEST, build_default, default_snapshot
|
|
883
|
+
|
|
884
|
+
workdir = _workdir(request)
|
|
885
|
+
mode = request.get("mode", "append")
|
|
886
|
+
merge = request.get("merge", "upsert")
|
|
887
|
+
if mode not in MODES:
|
|
888
|
+
fail("input", f"mode must be one of {', '.join(MODES)}")
|
|
889
|
+
if merge not in MERGES:
|
|
890
|
+
fail("input", f"merge must be one of {', '.join(MERGES)}")
|
|
891
|
+
batch_id = request.get("batchId")
|
|
892
|
+
parent_digest = request.get("parentBatchDigest", EMPTY_DIGEST)
|
|
893
|
+
if (
|
|
894
|
+
not isinstance(batch_id, str)
|
|
895
|
+
or not batch_id
|
|
896
|
+
or "/" in batch_id
|
|
897
|
+
or batch_id in (".", "..")
|
|
898
|
+
):
|
|
899
|
+
fail("input", "batchId must be a non-empty string without slashes")
|
|
900
|
+
if not isinstance(parent_digest, str) or len(parent_digest) != 64:
|
|
901
|
+
fail("input", "parentBatchDigest must be a 64-hex batch digest")
|
|
902
|
+
atomic = _flag(request, "atomic", True)
|
|
903
|
+
dry_run = _flag(request, "dryRun", False)
|
|
904
|
+
root = _write_tree(workdir, request)
|
|
905
|
+
contract = check_bundle(root)
|
|
906
|
+
chunks = _chunk_specs(workdir, request)
|
|
907
|
+
# One directory per batch, so a parent built by an earlier call in the same
|
|
908
|
+
# workdir is never the file being written.
|
|
909
|
+
out_dir = workdir / "snapshot" / batch_id
|
|
910
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
911
|
+
out = out_dir / "state.sqlite"
|
|
912
|
+
out.unlink(missing_ok=True)
|
|
913
|
+
out.with_name("state.json").unlink(missing_ok=True)
|
|
914
|
+
if request.get("parentSnapshot") is not None:
|
|
915
|
+
parent = _under(workdir, request["parentSnapshot"], "parentSnapshot")
|
|
916
|
+
if not parent.is_file():
|
|
917
|
+
fail("input", "parentSnapshot is missing under workdir")
|
|
918
|
+
shutil.copyfile(parent, out)
|
|
919
|
+
else:
|
|
920
|
+
build_default(root, contract)
|
|
921
|
+
shutil.copyfile(default_snapshot(root), out)
|
|
922
|
+
result = build_batch(
|
|
923
|
+
contract,
|
|
924
|
+
out,
|
|
925
|
+
chunks,
|
|
926
|
+
mode=mode,
|
|
927
|
+
merge=merge,
|
|
928
|
+
atomic=atomic,
|
|
929
|
+
batch_id=batch_id,
|
|
930
|
+
parent_digest=parent_digest,
|
|
931
|
+
dry_run=dry_run,
|
|
932
|
+
)
|
|
933
|
+
if "snapshot" in result:
|
|
934
|
+
# Absolute, under workdir: the caller opens it as it is.
|
|
935
|
+
result["snapshot"] = str(result["snapshot"])
|
|
936
|
+
return {"status": "ok", **result}
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _snapshot_default(request: dict) -> dict:
|
|
940
|
+
"""``snapshot-default``: the tree's default snapshot and every scenario snapshot,
|
|
941
|
+
built under ``workdir/tree``; the sidecars come back with absolute paths."""
|
|
942
|
+
from .compiler import check_bundle
|
|
943
|
+
from .snapshot import build_all, default_snapshot, scenario_snapshot
|
|
944
|
+
|
|
945
|
+
workdir = _workdir(request)
|
|
946
|
+
root = _write_tree(workdir, request)
|
|
947
|
+
built = build_all(root, check_bundle(root))
|
|
948
|
+
# Paths are absolute, under workdir; the sidecar sits both at the top level and
|
|
949
|
+
# keyed by its tree path, so a reader may take either.
|
|
950
|
+
return {
|
|
951
|
+
"status": "ok",
|
|
952
|
+
"snapshot": str(default_snapshot(root)),
|
|
953
|
+
"sidecar": built["default"],
|
|
954
|
+
"sidecars": {"db/state.json": built["default"]},
|
|
955
|
+
"scenarios": {
|
|
956
|
+
name: {"snapshot": str(scenario_snapshot(root, name)), "sidecar": sidecar}
|
|
957
|
+
for name, sidecar in built["scenarios"].items()
|
|
958
|
+
},
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _api_bundle(request: dict) -> dict:
|
|
963
|
+
"""The api-kind bundle that hosts a world's API, built from the world's files:
|
|
964
|
+
what `gateway worlds schema api-bundle` writes locally, for a CLI with no Python."""
|
|
965
|
+
from .api import build_api_bundle
|
|
966
|
+
|
|
967
|
+
files = _template_files(request)
|
|
968
|
+
with tempfile.TemporaryDirectory(prefix="gateway-schema-") as directory:
|
|
969
|
+
root = Path(directory) / "world"
|
|
970
|
+
root.mkdir()
|
|
971
|
+
for path, data in files.items():
|
|
972
|
+
target = root / path
|
|
973
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
974
|
+
target.write_bytes(data)
|
|
975
|
+
out = Path(directory) / "api"
|
|
976
|
+
build_api_bundle(root, out)
|
|
977
|
+
return {"status": "ok", "files": _tree(out)}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _ingest(request: dict) -> dict:
|
|
981
|
+
"""``ingest``: the world's authored tree, its ``captures/`` and manifest included,
|
|
982
|
+
seeded from those captures through the contract exactly as the CLI does, and the
|
|
983
|
+
whole updated tree handed back. Runs no Python of the world's own."""
|
|
984
|
+
from .connector import ingest
|
|
985
|
+
|
|
986
|
+
batch_id = request.get("batch_id")
|
|
987
|
+
if batch_id is not None and not isinstance(batch_id, str):
|
|
988
|
+
fail("input", "batch_id must be a string")
|
|
989
|
+
with tempfile.TemporaryDirectory(prefix="gateway-ingest-") as directory:
|
|
990
|
+
root = Path(directory) / "world"
|
|
991
|
+
root.mkdir()
|
|
992
|
+
_write_files(root, request.get("files"))
|
|
993
|
+
result = ingest(world_root(root), batch_id=batch_id)
|
|
994
|
+
return {"status": "ok", "result": result, "files": _tree(root)}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _conform(request: dict) -> dict:
|
|
998
|
+
"""``conform``: serve the world in the bridge and check every operation's answer
|
|
999
|
+
against the vendor's OpenAPI documents. The report is the one ``connector conform
|
|
1000
|
+
--out`` writes; ``world`` names the bundle, never the bridge's scratch path."""
|
|
1001
|
+
from .conform import conform
|
|
1002
|
+
|
|
1003
|
+
documents = request.get("openapi")
|
|
1004
|
+
if not isinstance(documents, list) or not documents:
|
|
1005
|
+
fail("input", "Expected one or more OpenAPI documents as [{path, content}]")
|
|
1006
|
+
max_operations = request.get("max_operations")
|
|
1007
|
+
if max_operations is not None and (
|
|
1008
|
+
isinstance(max_operations, bool)
|
|
1009
|
+
or not isinstance(max_operations, int)
|
|
1010
|
+
or max_operations < 1
|
|
1011
|
+
):
|
|
1012
|
+
fail("input", "max_operations must be a positive integer")
|
|
1013
|
+
with tempfile.TemporaryDirectory(prefix="gateway-conform-") as directory:
|
|
1014
|
+
root = Path(directory) / "world"
|
|
1015
|
+
root.mkdir()
|
|
1016
|
+
_write_files(root, request.get("files"))
|
|
1017
|
+
openapi = Path(directory) / "openapi"
|
|
1018
|
+
openapi.mkdir()
|
|
1019
|
+
_write_files(openapi, documents)
|
|
1020
|
+
paths = [
|
|
1021
|
+
openapi.joinpath(*PurePosixPath(entry["path"]).parts) for entry in documents
|
|
1022
|
+
]
|
|
1023
|
+
report = conform(
|
|
1024
|
+
world_root(root), paths, max_operations=max_operations
|
|
1025
|
+
).to_json()
|
|
1026
|
+
report["world"] = root.name
|
|
1027
|
+
return {"status": "ok", "report": report}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _run_tests(request: dict) -> dict:
|
|
1031
|
+
"""``run-tests``: serve the world at these files in the bridge and run the tests it
|
|
1032
|
+
ships under ``tests/`` (see :mod:`world_tests`). ``scenario`` starts the world in one
|
|
1033
|
+
of its scenarios; ``snapshot`` ({url, sha256, digest}) lays a pinned data snapshot
|
|
1034
|
+
into the tree first, so a data-only version is tested with its rows."""
|
|
1035
|
+
from .snapshot import fetch as fetch_snapshot
|
|
1036
|
+
from .world_tests import run
|
|
1037
|
+
|
|
1038
|
+
scenario = request.get("scenario")
|
|
1039
|
+
if scenario is not None and not isinstance(scenario, str):
|
|
1040
|
+
fail("input", "scenario must be a string")
|
|
1041
|
+
snapshot = request.get("snapshot")
|
|
1042
|
+
if snapshot is not None and not (
|
|
1043
|
+
isinstance(snapshot, dict) and all(isinstance(snapshot.get(k), str) for k in ("url", "sha256", "digest"))
|
|
1044
|
+
):
|
|
1045
|
+
fail("input", "snapshot must be {url, sha256, digest}")
|
|
1046
|
+
with tempfile.TemporaryDirectory(prefix="gateway-tests-") as directory:
|
|
1047
|
+
root = Path(directory) / "world"
|
|
1048
|
+
root.mkdir()
|
|
1049
|
+
_write_files(root, request.get("files"))
|
|
1050
|
+
root = world_root(root)
|
|
1051
|
+
if snapshot is not None:
|
|
1052
|
+
fetch_snapshot(root, check_bundle(root), snapshot["url"], snapshot["sha256"], snapshot["digest"])
|
|
1053
|
+
report = run(root, scenario=scenario).to_json()
|
|
1054
|
+
report["world"] = root.name
|
|
1055
|
+
return {"status": "ok", "report": report}
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _counts(request: dict) -> dict:
|
|
1059
|
+
"""``counts``: what the world at these files seeds with. A kind whose seed lives
|
|
1060
|
+
elsewhere (a harbor tree, an unknown layout) answers with the reason instead of
|
|
1061
|
+
a rejection, so the caller can show it."""
|
|
1062
|
+
from .data import entity_counts, entity_rows, world_kind
|
|
1063
|
+
|
|
1064
|
+
entity = request.get("entity")
|
|
1065
|
+
if entity is not None and not isinstance(entity, str):
|
|
1066
|
+
fail("input", "entity must be a string")
|
|
1067
|
+
with tempfile.TemporaryDirectory(prefix="gateway-counts-") as directory:
|
|
1068
|
+
root = Path(directory) / "world"
|
|
1069
|
+
root.mkdir()
|
|
1070
|
+
_write_files(root, request.get("files"))
|
|
1071
|
+
root = world_root(root)
|
|
1072
|
+
kind = world_kind(root)
|
|
1073
|
+
try:
|
|
1074
|
+
counts = entity_counts(root)
|
|
1075
|
+
except ContractError as error:
|
|
1076
|
+
if error.detail.get("code") != "kind":
|
|
1077
|
+
raise
|
|
1078
|
+
return {
|
|
1079
|
+
"status": "ok",
|
|
1080
|
+
"kind": kind,
|
|
1081
|
+
"entity_counts": None,
|
|
1082
|
+
"reason": error.detail["message"],
|
|
1083
|
+
}
|
|
1084
|
+
report = {"status": "ok", "kind": kind, "entity_counts": counts}
|
|
1085
|
+
if entity is not None:
|
|
1086
|
+
report["rows"] = entity_rows(root, entity)
|
|
1087
|
+
return report
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _templates(request: dict) -> dict:
|
|
1091
|
+
"""``templates``: every installed connector template, read from its ``world.json``
|
|
1092
|
+
(name and description when the document carries them) and whether it declares a
|
|
1093
|
+
vendor connection. Never runs a template's Python."""
|
|
1094
|
+
from .scaffold import TEMPLATES, list_templates
|
|
1095
|
+
|
|
1096
|
+
templates = []
|
|
1097
|
+
for slug in list_templates():
|
|
1098
|
+
world = json.loads(
|
|
1099
|
+
(TEMPLATES / slug / "world.json").read_text(encoding="utf-8")
|
|
1100
|
+
)
|
|
1101
|
+
gateway = world.get("x-gateway") if isinstance(world, dict) else None
|
|
1102
|
+
if not isinstance(gateway, dict):
|
|
1103
|
+
fail("template", f"{slug}/world.json is not a gateway-world document")
|
|
1104
|
+
templates.append(
|
|
1105
|
+
{
|
|
1106
|
+
"slug": slug,
|
|
1107
|
+
"name": world.get("title")
|
|
1108
|
+
if isinstance(world.get("title"), str)
|
|
1109
|
+
else None,
|
|
1110
|
+
"description": world.get("description")
|
|
1111
|
+
if isinstance(world.get("description"), str)
|
|
1112
|
+
else None,
|
|
1113
|
+
"entities": sorted(gateway.get("entities") or {}),
|
|
1114
|
+
"tools": sorted(gateway.get("tools") or {}),
|
|
1115
|
+
"hasConnection": (TEMPLATES / slug / "connector.toml").is_file(),
|
|
1116
|
+
}
|
|
1117
|
+
)
|
|
1118
|
+
return {"status": "ok", "templates": templates}
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def execute(request: dict) -> dict:
|
|
1122
|
+
if not isinstance(request, dict) or request.get("command") not in COMMANDS:
|
|
1123
|
+
fail(
|
|
1124
|
+
"command",
|
|
1125
|
+
"Expected one of " + ", ".join(COMMANDS[:-1]) + " or " + COMMANDS[-1],
|
|
1126
|
+
)
|
|
1127
|
+
if request["command"] == "validate-rows":
|
|
1128
|
+
return _validate_rows(request)
|
|
1129
|
+
if request["command"] == "snapshot-build":
|
|
1130
|
+
return _snapshot_build(request)
|
|
1131
|
+
if request["command"] == "snapshot-default":
|
|
1132
|
+
return _snapshot_default(request)
|
|
1133
|
+
if request["command"] == "capture":
|
|
1134
|
+
return _capture(request)
|
|
1135
|
+
if request["command"] == "import-data":
|
|
1136
|
+
return _import_data(request)
|
|
1137
|
+
if request["command"] == "ingest":
|
|
1138
|
+
return _ingest(request)
|
|
1139
|
+
if request["command"] == "conform":
|
|
1140
|
+
return _conform(request)
|
|
1141
|
+
if request["command"] == "run-tests":
|
|
1142
|
+
return _run_tests(request)
|
|
1143
|
+
if request["command"] == "counts":
|
|
1144
|
+
return _counts(request)
|
|
1145
|
+
if request["command"] == "templates":
|
|
1146
|
+
return _templates(request)
|
|
1147
|
+
if request["command"] == "api-bundle":
|
|
1148
|
+
return _api_bundle(request)
|
|
1149
|
+
if request["command"] == "template-summary":
|
|
1150
|
+
return _template_summary(_template_files(request))
|
|
1151
|
+
if request["command"] == "describe":
|
|
1152
|
+
return _describe(request)
|
|
1153
|
+
extra: dict = {}
|
|
1154
|
+
with tempfile.TemporaryDirectory(prefix="gateway-schema-") as directory:
|
|
1155
|
+
root = Path(directory) / "bundle"
|
|
1156
|
+
root.mkdir()
|
|
1157
|
+
if request["command"] == "validate-archive":
|
|
1158
|
+
_write_files(root, _archive_files(request.get("archive")))
|
|
1159
|
+
# A stored version may be the api bundle of a schema world: the
|
|
1160
|
+
# world lives under server/world, and that is what gets validated.
|
|
1161
|
+
root = world_root(root)
|
|
1162
|
+
if bundle_mode(root) is None:
|
|
1163
|
+
return {"status": "ok", "mode": "legacy"}
|
|
1164
|
+
if request["command"] in (
|
|
1165
|
+
"validate",
|
|
1166
|
+
"compile",
|
|
1167
|
+
"apply-batch",
|
|
1168
|
+
"refresh-slack",
|
|
1169
|
+
):
|
|
1170
|
+
_write_files(root, request.get("files"))
|
|
1171
|
+
# The tree the platform sent may be a schema world's api bundle; the
|
|
1172
|
+
# world itself sits under server/world and is what compiles, takes a
|
|
1173
|
+
# batch and validates. The whole tree still travels back.
|
|
1174
|
+
world = world_root(root)
|
|
1175
|
+
if request["command"] == "compile":
|
|
1176
|
+
# Arbitrary custom worlds: the agent authors schema/world.json (+ manifest,
|
|
1177
|
+
# handlers, journals); the trusted compiler owns every generated artifact.
|
|
1178
|
+
extra["compile"] = compile_world(world)
|
|
1179
|
+
if world != root and (world / "data/initial.json").is_file():
|
|
1180
|
+
from .api import write_base_seed
|
|
1181
|
+
|
|
1182
|
+
write_base_seed(world, root)
|
|
1183
|
+
if request["command"] == "apply-batch":
|
|
1184
|
+
batch, source = request.get("batch"), request.get("source")
|
|
1185
|
+
if not isinstance(batch, dict) or (
|
|
1186
|
+
source is not None and not isinstance(source, str)
|
|
1187
|
+
):
|
|
1188
|
+
fail(
|
|
1189
|
+
"input",
|
|
1190
|
+
"Expected a batch object and an optional source prefix string",
|
|
1191
|
+
)
|
|
1192
|
+
extra["batch"] = append_batch(world, batch, source=source)
|
|
1193
|
+
if request["command"] == "init-template":
|
|
1194
|
+
# The platform's "create world" for every connector template: the same
|
|
1195
|
+
# init + compile the CLI runs, on a fresh empty tree, with nothing seeded.
|
|
1196
|
+
from .scaffold import init_bundle
|
|
1197
|
+
|
|
1198
|
+
connector = request.get("connector")
|
|
1199
|
+
name = request.get("name")
|
|
1200
|
+
if not isinstance(connector, str) or (
|
|
1201
|
+
name is not None and not isinstance(name, str)
|
|
1202
|
+
):
|
|
1203
|
+
fail("input", "Expected a connector template slug and an optional name")
|
|
1204
|
+
init_bundle(root, connector=connector, name=name)
|
|
1205
|
+
if request["command"] == "init-template-files":
|
|
1206
|
+
# A workspace connector: the template arrives as files, never as a path
|
|
1207
|
+
# the request chose, and the world is built exactly as from an installed one.
|
|
1208
|
+
from .scaffold import init_bundle_from_files
|
|
1209
|
+
|
|
1210
|
+
connector = request.get("connector")
|
|
1211
|
+
name = request.get("name")
|
|
1212
|
+
if not isinstance(connector, str) or (
|
|
1213
|
+
name is not None and not isinstance(name, str)
|
|
1214
|
+
):
|
|
1215
|
+
fail("input", "Expected the connector slug and an optional name")
|
|
1216
|
+
init_bundle_from_files(
|
|
1217
|
+
root, _template_files(request), connector=connector, name=name
|
|
1218
|
+
)
|
|
1219
|
+
if request["command"] == "build-slack":
|
|
1220
|
+
from .slack import build_slack_bundle
|
|
1221
|
+
|
|
1222
|
+
build_slack_bundle(
|
|
1223
|
+
root, evidence=request.get("evidence"), name=request.get("name")
|
|
1224
|
+
)
|
|
1225
|
+
if request["command"] == "refresh-slack":
|
|
1226
|
+
from .slack import refresh_slack_bundle
|
|
1227
|
+
|
|
1228
|
+
refresh_slack_bundle(
|
|
1229
|
+
root, request.get("evidence"), batch_id=request.get("batch_id")
|
|
1230
|
+
)
|
|
1231
|
+
report = {**validate_bundle(world_root(root)), **extra}
|
|
1232
|
+
if request["command"] not in ("validate", "validate-archive"):
|
|
1233
|
+
report["files"] = [
|
|
1234
|
+
{
|
|
1235
|
+
"path": str(path.relative_to(root)),
|
|
1236
|
+
"content": base64.b64encode(path.read_bytes()).decode("ascii"),
|
|
1237
|
+
}
|
|
1238
|
+
for path in sorted(root.rglob("*"))
|
|
1239
|
+
if path.is_file()
|
|
1240
|
+
]
|
|
1241
|
+
return report
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def main():
|
|
1245
|
+
try:
|
|
1246
|
+
raw = sys.stdin.buffer.read(2 * MAX_BYTES + 1)
|
|
1247
|
+
if len(raw) > 2 * MAX_BYTES:
|
|
1248
|
+
fail("size", "Bridge request is too large")
|
|
1249
|
+
result = execute(json.loads(raw))
|
|
1250
|
+
print(json.dumps(result, sort_keys=True))
|
|
1251
|
+
except ContractError as error:
|
|
1252
|
+
print(json.dumps({"status": "rejected", "error": error.detail}, sort_keys=True))
|
|
1253
|
+
return 1
|
|
1254
|
+
except (OSError, ValueError, TypeError):
|
|
1255
|
+
# Never dump request content or provider records in process diagnostics.
|
|
1256
|
+
print(
|
|
1257
|
+
json.dumps(
|
|
1258
|
+
{
|
|
1259
|
+
"status": "rejected",
|
|
1260
|
+
"error": {
|
|
1261
|
+
"code": "input",
|
|
1262
|
+
"message": "Invalid schema bridge input",
|
|
1263
|
+
},
|
|
1264
|
+
}
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
return 1
|
|
1268
|
+
return 0
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
if __name__ == "__main__":
|
|
1272
|
+
raise SystemExit(main())
|