agent-learning-kit 0.1.0__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.
- agent_learning_kit-0.1.0.dist-info/METADATA +381 -0
- agent_learning_kit-0.1.0.dist-info/RECORD +642 -0
- agent_learning_kit-0.1.0.dist-info/WHEEL +4 -0
- agent_learning_kit-0.1.0.dist-info/entry_points.txt +5 -0
- agent_learning_kit-0.1.0.dist-info/licenses/LICENSE +173 -0
- agent_learning_kit-0.1.0.dist-info/licenses/NOTICE +7 -0
- fi/__init__.py +5 -0
- fi/alk/__init__.py +57 -0
- fi/alk/_facade.py +31 -0
- fi/alk/_module_alias.py +68 -0
- fi/alk/_paths.py +14 -0
- fi/alk/_schema.py +522 -0
- fi/alk/actions.py +727 -0
- fi/alk/bench/__init__.py +517 -0
- fi/alk/bench/_codeexec.py +213 -0
- fi/alk/bench/_coding.py +215 -0
- fi/alk/bench/_docker.py +237 -0
- fi/alk/bench/_grader.py +286 -0
- fi/alk/bench/_pull.py +212 -0
- fi/alk/bench/_voice.py +147 -0
- fi/alk/capabilities.py +627 -0
- fi/alk/cli.py +6396 -0
- fi/alk/config.py +130 -0
- fi/alk/cua_loop.py +562 -0
- fi/alk/evals.py +2351 -0
- fi/alk/extensions.py +163 -0
- fi/alk/harness/ARCHITECTURE.md +231 -0
- fi/alk/harness/DESIGN.md +246 -0
- fi/alk/harness/ENVIRONMENT_CONFORMANCE.md +127 -0
- fi/alk/harness/HOW-IT-WORKS.md +297 -0
- fi/alk/harness/IMPLEMENTATION_AND_VALIDATION_STATUS.md +229 -0
- fi/alk/harness/README.md +417 -0
- fi/alk/harness/__init__.py +77 -0
- fi/alk/harness/__main__.py +3 -0
- fi/alk/harness/amend.py +312 -0
- fi/alk/harness/artifacts.py +319 -0
- fi/alk/harness/authoring_entrypoint.py +189 -0
- fi/alk/harness/authoring_runtime_validation.py +267 -0
- fi/alk/harness/backends/README.md +43 -0
- fi/alk/harness/backends/__init__.py +122 -0
- fi/alk/harness/backends/base.py +241 -0
- fi/alk/harness/backends/claude.py +211 -0
- fi/alk/harness/backends/files.py +182 -0
- fi/alk/harness/backends/vertex_gemini.py +457 -0
- fi/alk/harness/background_noise.py +95 -0
- fi/alk/harness/build.py +385 -0
- fi/alk/harness/bundle.py +593 -0
- fi/alk/harness/bundle_author_v2.py +1831 -0
- fi/alk/harness/bundle_v2.py +719 -0
- fi/alk/harness/call_runner.py +1440 -0
- fi/alk/harness/callback_http_adapter.py +111 -0
- fi/alk/harness/catalogue.py +287 -0
- fi/alk/harness/chat.py +428 -0
- fi/alk/harness/chat_call_runner.py +506 -0
- fi/alk/harness/checks.py +136 -0
- fi/alk/harness/cli.py +1354 -0
- fi/alk/harness/config.py +338 -0
- fi/alk/harness/contract.py +718 -0
- fi/alk/harness/credentials.py +674 -0
- fi/alk/harness/data/persona_vocabulary.json +111 -0
- fi/alk/harness/environment.py +99 -0
- fi/alk/harness/environment_plan.py +168 -0
- fi/alk/harness/events.py +125 -0
- fi/alk/harness/executor.py +304 -0
- fi/alk/harness/folder.py +234 -0
- fi/alk/harness/generated_runtime.py +815 -0
- fi/alk/harness/github.py +72 -0
- fi/alk/harness/hosted_authoring_entrypoint.py +183 -0
- fi/alk/harness/hosted_entrypoint.py +2402 -0
- fi/alk/harness/hosted_scheduler.py +2218 -0
- fi/alk/harness/job.py +426 -0
- fi/alk/harness/judge.py +184 -0
- fi/alk/harness/livekit_source.py +50 -0
- fi/alk/harness/livekit_tool_trace_bootstrap.py +71 -0
- fi/alk/harness/observability.py +208 -0
- fi/alk/harness/outbound.py +3252 -0
- fi/alk/harness/packaging.py +515 -0
- fi/alk/harness/persona_guides.py +157 -0
- fi/alk/harness/platform.py +692 -0
- fi/alk/harness/process_preflight.py +764 -0
- fi/alk/harness/process_runtime.py +5670 -0
- fi/alk/harness/prove.py +425 -0
- fi/alk/harness/provider_import.py +703 -0
- fi/alk/harness/provider_lifecycle.py +392 -0
- fi/alk/harness/provision.py +2896 -0
- fi/alk/harness/reception.py +147 -0
- fi/alk/harness/retell_chat_call_runner.py +373 -0
- fi/alk/harness/run/__init__.py +296 -0
- fi/alk/harness/run/alk.py +184 -0
- fi/alk/harness/run/call.py +162 -0
- fi/alk/harness/run/conversation.py +264 -0
- fi/alk/harness/run/data/voices_by_language_and_gender.json +693 -0
- fi/alk/harness/run/evidence.py +195 -0
- fi/alk/harness/run/grade.py +598 -0
- fi/alk/harness/run/live.py +297 -0
- fi/alk/harness/run/models.py +56 -0
- fi/alk/harness/run/platform_evals.py +227 -0
- fi/alk/harness/run/sdk_voice.py +130 -0
- fi/alk/harness/run/simulation.py +1209 -0
- fi/alk/harness/run/stage.py +91 -0
- fi/alk/harness/run/targets.py +508 -0
- fi/alk/harness/run/tools.py +601 -0
- fi/alk/harness/run/voice.py +340 -0
- fi/alk/harness/runtime.py +172 -0
- fi/alk/harness/sandbox_server.py +2011 -0
- fi/alk/harness/sandbox_worker.py +44 -0
- fi/alk/harness/scenario.py +1048 -0
- fi/alk/harness/scenario_source.py +879 -0
- fi/alk/harness/scenario_tools.py +1143 -0
- fi/alk/harness/scenarios.py +915 -0
- fi/alk/harness/secrets.py +168 -0
- fi/alk/harness/service_catalog.py +97 -0
- fi/alk/harness/session.py +391 -0
- fi/alk/harness/sessions.py +372 -0
- fi/alk/harness/simulator.py +76 -0
- fi/alk/harness/simulator_voice.py +928 -0
- fi/alk/harness/skills/build-environment/SKILL.md +538 -0
- fi/alk/harness/skills/harness.md +131 -0
- fi/alk/harness/skills/kinds/chat.md +48 -0
- fi/alk/harness/skills/kinds/voice-voicemail.md +63 -0
- fi/alk/harness/skills/kinds/voice.md +59 -0
- fi/alk/harness/skills/plan-suite/SKILL.md +103 -0
- fi/alk/harness/skills/provision-environment/SKILL.md +136 -0
- fi/alk/harness/skills/run-scenarios/SKILL.md +112 -0
- fi/alk/harness/skills/understand-agent/SKILL.md +251 -0
- fi/alk/harness/skills/write-scenarios/SKILL.md +606 -0
- fi/alk/harness/skills/write-scenarios/references/refusals.md +28 -0
- fi/alk/harness/skills/write-scenarios/references/world-api.md +92 -0
- fi/alk/harness/source_data_invariants.py +444 -0
- fi/alk/harness/source_tool_evidence.py +79 -0
- fi/alk/harness/sources.py +253 -0
- fi/alk/harness/spend.py +140 -0
- fi/alk/harness/tool_trace_proxy.py +104 -0
- fi/alk/harness/tools.py +1018 -0
- fi/alk/harness/understand.py +169 -0
- fi/alk/harness/voicemail_audio.py +74 -0
- fi/alk/harness/world/__init__.py +33 -0
- fi/alk/harness/world/errors.py +68 -0
- fi/alk/harness/world/expectations.py +91 -0
- fi/alk/harness/world/handle.py +538 -0
- fi/alk/harness/world/kinds.py +196 -0
- fi/alk/harness/world/mutate.py +186 -0
- fi/alk/harness/world/probe.py +413 -0
- fi/alk/harness/world/provision.py +511 -0
- fi/alk/harness/world/provisioned.py +191 -0
- fi/alk/harness/world/runtime.py +616 -0
- fi/alk/harness/world/snapshot.py +288 -0
- fi/alk/harness/world/stores/__init__.py +305 -0
- fi/alk/harness/world/stores/container.py +215 -0
- fi/alk/harness/world/stores/inprocess.py +346 -0
- fi/alk/harness/world/stores/postgres.py +481 -0
- fi/alk/harness/world/stores/prove.py +202 -0
- fi/alk/harness/world/stores/sqlite.py +245 -0
- fi/alk/harness/world/stores/written.py +182 -0
- fi/alk/harness/world/tools.py +1516 -0
- fi/alk/harness/world/workspace.py +144 -0
- fi/alk/image_loop.py +453 -0
- fi/alk/image_perturb.py +241 -0
- fi/alk/improve.py +274 -0
- fi/alk/live/__init__.py +154 -0
- fi/alk/live/_attribution.py +184 -0
- fi/alk/live/_capture.py +264 -0
- fi/alk/live/_codec.py +391 -0
- fi/alk/live/_contract.py +134 -0
- fi/alk/live/_loopback.py +316 -0
- fi/alk/live/_perturb.py +449 -0
- fi/alk/live/_runner.py +386 -0
- fi/alk/live/_stats.py +561 -0
- fi/alk/live/_transcript.py +240 -0
- fi/alk/live/_workers/__init__.py +9 -0
- fi/alk/live/_workers/a2a_worker.py +316 -0
- fi/alk/live/_workers/langgraph_worker.py +217 -0
- fi/alk/live/_workers/livekit_worker.py +207 -0
- fi/alk/live/_workers/mcp_loopback_server.py +46 -0
- fi/alk/live/_workers/mcp_worker.py +158 -0
- fi/alk/live/_workers/pipecat_worker.py +189 -0
- fi/alk/live/a2a_lane.py +138 -0
- fi/alk/live/langgraph_lane.py +339 -0
- fi/alk/live/livekit_lane.py +376 -0
- fi/alk/live/mcp_lane.py +172 -0
- fi/alk/live/pipecat_lane.py +341 -0
- fi/alk/live/voice_redteam.py +494 -0
- fi/alk/loss.py +306 -0
- fi/alk/optimize.py +36260 -0
- fi/alk/practice/__init__.py +51 -0
- fi/alk/practice/_assess.py +103 -0
- fi/alk/practice/_budget.py +81 -0
- fi/alk/practice/_calibrate.py +69 -0
- fi/alk/practice/_capstone.py +86 -0
- fi/alk/practice/_contract.py +91 -0
- fi/alk/practice/_diagnose.py +79 -0
- fi/alk/practice/_drill.py +196 -0
- fi/alk/practice/_experiment.py +720 -0
- fi/alk/practice/_schedule.py +102 -0
- fi/alk/practice/_store.py +194 -0
- fi/alk/practice/_trainer.py +245 -0
- fi/alk/practice/_update.py +125 -0
- fi/alk/redteam.py +2621 -0
- fi/alk/rewardhack.py +237 -0
- fi/alk/simulate.py +10351 -0
- fi/alk/studio/__init__.py +82 -0
- fi/alk/studio/_bias.py +314 -0
- fi/alk/studio/_calibration.py +522 -0
- fi/alk/studio/_coverage.py +262 -0
- fi/alk/studio/_download.py +665 -0
- fi/alk/studio/_fidelity_attack.py +114 -0
- fi/alk/studio/_generate.py +652 -0
- fi/alk/studio/_library.py +370 -0
- fi/alk/studio/_scan.py +134 -0
- fi/alk/studio/_upgrade.py +42 -0
- fi/alk/studio/_vendor.py +172 -0
- fi/alk/suite.py +4200 -0
- fi/alk/tasks.py +828 -0
- fi/alk/telemetry/__init__.py +149 -0
- fi/alk/telemetry/_contract.py +141 -0
- fi/alk/telemetry/_emit.py +182 -0
- fi/alk/telemetry/_ledger.py +296 -0
- fi/alk/telemetry/_queue.py +127 -0
- fi/alk/telemetry/_row.py +294 -0
- fi/alk/telemetry/_run.py +233 -0
- fi/alk/telemetry/_sync.py +193 -0
- fi/alk/telemetry/_url.py +119 -0
- fi/alk/trinity.py +49397 -0
- fi/alk/voice_loop.py +174 -0
- fi/api/__init__.py +1 -0
- fi/api/auth.py +137 -0
- fi/api/types.py +29 -0
- fi/cli/__init__.py +9 -0
- fi/cli/assertions/__init__.py +25 -0
- fi/cli/assertions/conditions.py +76 -0
- fi/cli/assertions/evaluator.py +286 -0
- fi/cli/assertions/exit_codes.py +20 -0
- fi/cli/assertions/parser.py +131 -0
- fi/cli/assertions/reporter.py +194 -0
- fi/cli/commands/__init__.py +9 -0
- fi/cli/commands/config.py +165 -0
- fi/cli/commands/export.py +208 -0
- fi/cli/commands/init.py +112 -0
- fi/cli/commands/list_cmd.py +213 -0
- fi/cli/commands/run.py +486 -0
- fi/cli/commands/validate.py +173 -0
- fi/cli/commands/view.py +424 -0
- fi/cli/config/__init__.py +6 -0
- fi/cli/config/defaults.py +206 -0
- fi/cli/config/loader.py +155 -0
- fi/cli/config/schema.py +174 -0
- fi/cli/main.py +78 -0
- fi/cli/output/__init__.py +6 -0
- fi/cli/output/formatters.py +106 -0
- fi/cli/output/reporters.py +46 -0
- fi/cli/storage/__init__.py +5 -0
- fi/cli/storage/run_history.py +249 -0
- fi/cli/utils/__init__.py +5 -0
- fi/cli/utils/console.py +44 -0
- fi/evals/__init__.py +131 -0
- fi/evals/autoeval/__init__.py +137 -0
- fi/evals/autoeval/analyzer.py +211 -0
- fi/evals/autoeval/config.py +244 -0
- fi/evals/autoeval/export.py +213 -0
- fi/evals/autoeval/interactive.py +283 -0
- fi/evals/autoeval/pipeline.py +625 -0
- fi/evals/autoeval/prompts.py +139 -0
- fi/evals/autoeval/recommender.py +242 -0
- fi/evals/autoeval/rules.py +589 -0
- fi/evals/autoeval/templates.py +299 -0
- fi/evals/autoeval/types.py +232 -0
- fi/evals/core/__init__.py +16 -0
- fi/evals/core/cloud_registry.py +184 -0
- fi/evals/core/engines.py +368 -0
- fi/evals/core/evaluate.py +319 -0
- fi/evals/core/judge_prompt.py +90 -0
- fi/evals/core/prompt_generator.py +83 -0
- fi/evals/core/registry.py +57 -0
- fi/evals/core/result.py +55 -0
- fi/evals/evaluator.py +721 -0
- fi/evals/execution.py +168 -0
- fi/evals/feedback/__init__.py +32 -0
- fi/evals/feedback/calibrator.py +160 -0
- fi/evals/feedback/collector.py +214 -0
- fi/evals/feedback/hooks.py +81 -0
- fi/evals/feedback/retriever.py +128 -0
- fi/evals/feedback/store.py +272 -0
- fi/evals/feedback/types.py +99 -0
- fi/evals/framework/README.md +79 -0
- fi/evals/framework/__init__.py +267 -0
- fi/evals/framework/backends/Dockerfile.eval-runner +33 -0
- fi/evals/framework/backends/__init__.py +99 -0
- fi/evals/framework/backends/_container.py +141 -0
- fi/evals/framework/backends/_utils.py +145 -0
- fi/evals/framework/backends/base.py +223 -0
- fi/evals/framework/backends/celery_backend.py +417 -0
- fi/evals/framework/backends/celery_worker.py +78 -0
- fi/evals/framework/backends/kubernetes_backend.py +665 -0
- fi/evals/framework/backends/ray_backend.py +521 -0
- fi/evals/framework/backends/temporal.py +350 -0
- fi/evals/framework/backends/temporal_worker.py +126 -0
- fi/evals/framework/backends/thread_pool.py +286 -0
- fi/evals/framework/context.py +258 -0
- fi/evals/framework/enrichment.py +306 -0
- fi/evals/framework/evals/__init__.py +68 -0
- fi/evals/framework/evals/agentic.py +399 -0
- fi/evals/framework/evals/builder.py +609 -0
- fi/evals/framework/evals/semantic.py +142 -0
- fi/evals/framework/evaluator.py +647 -0
- fi/evals/framework/evaluators/__init__.py +22 -0
- fi/evals/framework/evaluators/blocking.py +347 -0
- fi/evals/framework/evaluators/non_blocking.py +577 -0
- fi/evals/framework/propagation.py +421 -0
- fi/evals/framework/protocols.py +385 -0
- fi/evals/framework/registry.py +370 -0
- fi/evals/framework/resilience/__init__.py +150 -0
- fi/evals/framework/resilience/circuit_breaker.py +309 -0
- fi/evals/framework/resilience/degradation.py +355 -0
- fi/evals/framework/resilience/health.py +505 -0
- fi/evals/framework/resilience/rate_limiter.py +228 -0
- fi/evals/framework/resilience/retry.py +274 -0
- fi/evals/framework/resilience/types.py +288 -0
- fi/evals/framework/resilience/wrapper.py +433 -0
- fi/evals/framework/types.py +218 -0
- fi/evals/guardrails/README.md +915 -0
- fi/evals/guardrails/__init__.py +96 -0
- fi/evals/guardrails/backends/__init__.py +43 -0
- fi/evals/guardrails/backends/azure.py +361 -0
- fi/evals/guardrails/backends/base.py +88 -0
- fi/evals/guardrails/backends/generic_llm.py +163 -0
- fi/evals/guardrails/backends/granite.py +216 -0
- fi/evals/guardrails/backends/llamaguard.py +221 -0
- fi/evals/guardrails/backends/local_base.py +479 -0
- fi/evals/guardrails/backends/openai.py +365 -0
- fi/evals/guardrails/backends/qwen.py +170 -0
- fi/evals/guardrails/backends/shieldgemma.py +154 -0
- fi/evals/guardrails/backends/turing.py +235 -0
- fi/evals/guardrails/backends/vllm_client.py +321 -0
- fi/evals/guardrails/backends/wildguard.py +188 -0
- fi/evals/guardrails/base.py +888 -0
- fi/evals/guardrails/config.py +221 -0
- fi/evals/guardrails/discovery.py +243 -0
- fi/evals/guardrails/gateway.py +437 -0
- fi/evals/guardrails/registry.py +231 -0
- fi/evals/guardrails/scanners/__init__.py +127 -0
- fi/evals/guardrails/scanners/base.py +191 -0
- fi/evals/guardrails/scanners/code_injection.py +243 -0
- fi/evals/guardrails/scanners/eval_delegate.py +574 -0
- fi/evals/guardrails/scanners/invisible_chars.py +351 -0
- fi/evals/guardrails/scanners/jailbreak.py +412 -0
- fi/evals/guardrails/scanners/language.py +288 -0
- fi/evals/guardrails/scanners/pipeline.py +260 -0
- fi/evals/guardrails/scanners/regex.py +311 -0
- fi/evals/guardrails/scanners/secrets.py +274 -0
- fi/evals/guardrails/scanners/topics.py +649 -0
- fi/evals/guardrails/scanners/urls.py +341 -0
- fi/evals/guardrails/types.py +96 -0
- fi/evals/llm/__init__.py +3 -0
- fi/evals/llm/base_llm_provider.py +35 -0
- fi/evals/llm/providers/litellm.py +70 -0
- fi/evals/local/__init__.py +90 -0
- fi/evals/local/evaluator.py +690 -0
- fi/evals/local/execution_mode.py +121 -0
- fi/evals/local/llm.py +489 -0
- fi/evals/local/metrics/__init__.py +19 -0
- fi/evals/local/registry.py +360 -0
- fi/evals/manager.py +1018 -0
- fi/evals/manager_types.py +362 -0
- fi/evals/metrics/__init__.py +185 -0
- fi/evals/metrics/agents/__init__.py +74 -0
- fi/evals/metrics/agents/metrics.py +693 -0
- fi/evals/metrics/agents/report.py +36463 -0
- fi/evals/metrics/agents/types.py +160 -0
- fi/evals/metrics/base_llm_metric.py +111 -0
- fi/evals/metrics/base_metric.py +138 -0
- fi/evals/metrics/code_security/__init__.py +305 -0
- fi/evals/metrics/code_security/analyzer.py +985 -0
- fi/evals/metrics/code_security/benchmarks/__init__.py +73 -0
- fi/evals/metrics/code_security/benchmarks/builtin.py +750 -0
- fi/evals/metrics/code_security/benchmarks/loader.py +580 -0
- fi/evals/metrics/code_security/benchmarks/types.py +308 -0
- fi/evals/metrics/code_security/detectors/__init__.py +186 -0
- fi/evals/metrics/code_security/detectors/base.py +394 -0
- fi/evals/metrics/code_security/detectors/cryptography.py +345 -0
- fi/evals/metrics/code_security/detectors/injection.py +744 -0
- fi/evals/metrics/code_security/detectors/secrets.py +287 -0
- fi/evals/metrics/code_security/detectors/serialization.py +192 -0
- fi/evals/metrics/code_security/joint_metrics.py +588 -0
- fi/evals/metrics/code_security/judges/__init__.py +83 -0
- fi/evals/metrics/code_security/judges/base.py +238 -0
- fi/evals/metrics/code_security/judges/dual_judge.py +534 -0
- fi/evals/metrics/code_security/judges/llm_judge.py +301 -0
- fi/evals/metrics/code_security/judges/pattern_judge.py +515 -0
- fi/evals/metrics/code_security/metrics.py +388 -0
- fi/evals/metrics/code_security/modes/__init__.py +63 -0
- fi/evals/metrics/code_security/modes/adversarial.py +284 -0
- fi/evals/metrics/code_security/modes/autocomplete.py +198 -0
- fi/evals/metrics/code_security/modes/base.py +283 -0
- fi/evals/metrics/code_security/modes/instruct.py +253 -0
- fi/evals/metrics/code_security/modes/repair.py +230 -0
- fi/evals/metrics/code_security/reports/__init__.py +57 -0
- fi/evals/metrics/code_security/reports/generator.py +404 -0
- fi/evals/metrics/code_security/reports/leaderboard.py +509 -0
- fi/evals/metrics/code_security/types.py +534 -0
- fi/evals/metrics/function_calling/__init__.py +34 -0
- fi/evals/metrics/function_calling/metrics.py +573 -0
- fi/evals/metrics/function_calling/types.py +87 -0
- fi/evals/metrics/hallucination/__init__.py +54 -0
- fi/evals/metrics/hallucination/detector.py +149 -0
- fi/evals/metrics/hallucination/metrics.py +390 -0
- fi/evals/metrics/hallucination/nli.py +253 -0
- fi/evals/metrics/hallucination/sentinel.py +106 -0
- fi/evals/metrics/hallucination/types.py +132 -0
- fi/evals/metrics/heuristics/aggregation_metrics.py +85 -0
- fi/evals/metrics/heuristics/json_metrics.py +87 -0
- fi/evals/metrics/heuristics/similarity_metrics.py +375 -0
- fi/evals/metrics/heuristics/string_metrics.py +391 -0
- fi/evals/metrics/llm_as_judges/__init__.py +17 -0
- fi/evals/metrics/llm_as_judges/custom_judge/metric.py +112 -0
- fi/evals/metrics/llm_as_judges/custom_judge/prompts.py +26 -0
- fi/evals/metrics/llm_as_judges/types.py +48 -0
- fi/evals/metrics/rag/__init__.py +111 -0
- fi/evals/metrics/rag/advanced/__init__.py +14 -0
- fi/evals/metrics/rag/advanced/multi_hop.py +283 -0
- fi/evals/metrics/rag/advanced/source_attribution.py +344 -0
- fi/evals/metrics/rag/generation/__init__.py +17 -0
- fi/evals/metrics/rag/generation/answer_relevancy.py +176 -0
- fi/evals/metrics/rag/generation/context_utilization.py +245 -0
- fi/evals/metrics/rag/generation/faithfulness.py +241 -0
- fi/evals/metrics/rag/generation/groundedness.py +131 -0
- fi/evals/metrics/rag/rag_score.py +277 -0
- fi/evals/metrics/rag/retrieval/__init__.py +20 -0
- fi/evals/metrics/rag/retrieval/context_entity_recall.py +124 -0
- fi/evals/metrics/rag/retrieval/context_precision.py +158 -0
- fi/evals/metrics/rag/retrieval/context_recall.py +106 -0
- fi/evals/metrics/rag/retrieval/noise_sensitivity.py +163 -0
- fi/evals/metrics/rag/retrieval/ranking.py +261 -0
- fi/evals/metrics/rag/types.py +100 -0
- fi/evals/metrics/rag/utils/__init__.py +62 -0
- fi/evals/metrics/rag/utils/claims.py +189 -0
- fi/evals/metrics/rag/utils/entities.py +244 -0
- fi/evals/metrics/rag/utils/nli.py +92 -0
- fi/evals/metrics/rag/utils/similarity.py +345 -0
- fi/evals/metrics/structured/__init__.py +114 -0
- fi/evals/metrics/structured/field_completeness.py +313 -0
- fi/evals/metrics/structured/hierarchy_score.py +366 -0
- fi/evals/metrics/structured/json_validation.py +190 -0
- fi/evals/metrics/structured/schema_compliance.py +280 -0
- fi/evals/metrics/structured/structured_output_score.py +298 -0
- fi/evals/metrics/structured/types.py +108 -0
- fi/evals/metrics/structured/validators/__init__.py +30 -0
- fi/evals/metrics/structured/validators/base.py +189 -0
- fi/evals/metrics/structured/validators/json_validator.py +196 -0
- fi/evals/metrics/structured/validators/pydantic_validator.py +178 -0
- fi/evals/metrics/structured/validators/yaml_validator.py +248 -0
- fi/evals/otel/__init__.py +266 -0
- fi/evals/otel/config.py +400 -0
- fi/evals/otel/conventions.py +463 -0
- fi/evals/otel/enrichment.py +371 -0
- fi/evals/otel/instrumentors/__init__.py +140 -0
- fi/evals/otel/instrumentors/anthropic.py +517 -0
- fi/evals/otel/instrumentors/base.py +382 -0
- fi/evals/otel/instrumentors/openai.py +673 -0
- fi/evals/otel/processors/__init__.py +36 -0
- fi/evals/otel/processors/base.py +473 -0
- fi/evals/otel/processors/cost.py +445 -0
- fi/evals/otel/processors/evaluation.py +559 -0
- fi/evals/otel/processors/llm.py +462 -0
- fi/evals/otel/tracer.py +506 -0
- fi/evals/otel/types.py +232 -0
- fi/evals/otel_utils.py +23 -0
- fi/evals/protect.py +671 -0
- fi/evals/protect_input_adapter.py +154 -0
- fi/evals/streaming/__init__.py +88 -0
- fi/evals/streaming/buffer.py +213 -0
- fi/evals/streaming/evaluator.py +551 -0
- fi/evals/streaming/policy.py +307 -0
- fi/evals/streaming/scorers.py +368 -0
- fi/evals/streaming/types.py +238 -0
- fi/evals/templates.py +472 -0
- fi/evals/types.py +156 -0
- fi/opt/__init__.py +221 -0
- fi/opt/_objective_scoring.py +85 -0
- fi/opt/base/__init__.py +11 -0
- fi/opt/base/base_generator.py +33 -0
- fi/opt/base/base_mapper.py +26 -0
- fi/opt/base/base_optimizer.py +45 -0
- fi/opt/base/evaluator.py +211 -0
- fi/opt/components.py +3095 -0
- fi/opt/datamappers/__init__.py +3 -0
- fi/opt/datamappers/basic_mapper.py +40 -0
- fi/opt/deployment.py +1021 -0
- fi/opt/evidence.py +4332 -0
- fi/opt/generators/__init__.py +3 -0
- fi/opt/generators/litellm.py +66 -0
- fi/opt/integrations/__init__.py +23 -0
- fi/opt/integrations/generative_suite.py +410 -0
- fi/opt/integrations/simulate.py +1313 -0
- fi/opt/mutations.py +771 -0
- fi/opt/observability.py +4639 -0
- fi/opt/optimizer_trace.py +889 -0
- fi/opt/optimizers/__init__.py +80 -0
- fi/opt/optimizers/agent.py +331 -0
- fi/opt/optimizers/agent_bandit.py +392 -0
- fi/opt/optimizers/agent_curriculum.py +635 -0
- fi/opt/optimizers/agent_evolution.py +894 -0
- fi/opt/optimizers/agent_feedback.py +1863 -0
- fi/opt/optimizers/agent_pareto.py +547 -0
- fi/opt/optimizers/agent_social_memory.py +1113 -0
- fi/opt/optimizers/agent_tpe.py +321 -0
- fi/opt/optimizers/bayesian_search.py +449 -0
- fi/opt/optimizers/council.py +2075 -0
- fi/opt/optimizers/futureagi_replay.py +799 -0
- fi/opt/optimizers/gepa.py +322 -0
- fi/opt/optimizers/metaprompt.py +243 -0
- fi/opt/optimizers/promptwizard.py +417 -0
- fi/opt/optimizers/protegi.py +329 -0
- fi/opt/optimizers/random_search.py +224 -0
- fi/opt/research.py +518 -0
- fi/opt/simulation.py +260 -0
- fi/opt/targets.py +232 -0
- fi/opt/types.py +66 -0
- fi/opt/utils/__init__.py +4 -0
- fi/opt/utils/early_stopping.py +266 -0
- fi/opt/utils/setup_logging.py +82 -0
- fi/simulate/__init__.py +540 -0
- fi/simulate/_hashing.py +35 -0
- fi/simulate/_logging.py +10 -0
- fi/simulate/adapters.py +87 -0
- fi/simulate/agent/__init__.py +120 -0
- fi/simulate/agent/browser.py +658 -0
- fi/simulate/agent/definition.py +587 -0
- fi/simulate/agent/frameworks.py +3528 -0
- fi/simulate/agent/generic.py +8286 -0
- fi/simulate/agent/import_probe.py +227 -0
- fi/simulate/agent/memory.py +905 -0
- fi/simulate/agent/mocks.py +101 -0
- fi/simulate/agent/multi_agent.py +361 -0
- fi/simulate/agent/orchestration.py +903 -0
- fi/simulate/agent/realtime.py +665 -0
- fi/simulate/agent/wrapper.py +99 -0
- fi/simulate/agent/wrappers/__init__.py +18 -0
- fi/simulate/agent/wrappers/anthropic.py +62 -0
- fi/simulate/agent/wrappers/gemini.py +65 -0
- fi/simulate/agent/wrappers/http.py +404 -0
- fi/simulate/agent/wrappers/langchain.py +80 -0
- fi/simulate/agent/wrappers/openai.py +75 -0
- fi/simulate/agent/wrappers/websocket.py +326 -0
- fi/simulate/artifacts/__init__.py +11 -0
- fi/simulate/artifacts/manifest.py +62 -0
- fi/simulate/cli.py +20560 -0
- fi/simulate/endpoints/__init__.py +45 -0
- fi/simulate/endpoints/_http_actor.py +73 -0
- fi/simulate/endpoints/actor_sources.py +243 -0
- fi/simulate/endpoints/base.py +107 -0
- fi/simulate/endpoints/builtins.py +10 -0
- fi/simulate/endpoints/callable.py +95 -0
- fi/simulate/endpoints/http.py +76 -0
- fi/simulate/endpoints/livekit.py +138 -0
- fi/simulate/endpoints/originators.py +132 -0
- fi/simulate/endpoints/profiles.py +348 -0
- fi/simulate/endpoints/retell.py +633 -0
- fi/simulate/endpoints/vapi.py +205 -0
- fi/simulate/endpoints/websocket.py +76 -0
- fi/simulate/environment.py +33026 -0
- fi/simulate/environments/__init__.py +11 -0
- fi/simulate/environments/base.py +73 -0
- fi/simulate/environments/chat.py +697 -0
- fi/simulate/environments/voice.py +212 -0
- fi/simulate/evaluation/__init__.py +4 -0
- fi/simulate/evaluation/ai_eval.py +227 -0
- fi/simulate/evidence/__init__.py +35 -0
- fi/simulate/evidence/base.py +59 -0
- fi/simulate/evidence/caller_observed.py +50 -0
- fi/simulate/evidence/livekit_instrumentation.py +51 -0
- fi/simulate/evidence/livekit_room.py +50 -0
- fi/simulate/evidence/otel.py +49 -0
- fi/simulate/evidence/providers/__init__.py +24 -0
- fi/simulate/evidence/providers/base.py +61 -0
- fi/simulate/evidence/providers/retell.py +376 -0
- fi/simulate/evidence/providers/vapi.py +426 -0
- fi/simulate/hosted/__init__.py +32 -0
- fi/simulate/hosted/child_entrypoint.py +306 -0
- fi/simulate/hosted/job.py +150 -0
- fi/simulate/hosted/targets.py +53 -0
- fi/simulate/instrumentation/__init__.py +5 -0
- fi/simulate/instrumentation/livekit/__init__.py +122 -0
- fi/simulate/manifest.py +1033 -0
- fi/simulate/matrix_cli.py +165 -0
- fi/simulate/realtime/__init__.py +40 -0
- fi/simulate/realtime/events.py +107 -0
- fi/simulate/realtime/media.py +61 -0
- fi/simulate/realtime/session.py +91 -0
- fi/simulate/recording/__init__.py +5 -0
- fi/simulate/recording/room_recorder.py +326 -0
- fi/simulate/registry.py +185 -0
- fi/simulate/results/__init__.py +9 -0
- fi/simulate/results/base.py +18 -0
- fi/simulate/results/filesystem.py +71 -0
- fi/simulate/results/futureagi.py +1340 -0
- fi/simulate/runtime/__init__.py +85 -0
- fi/simulate/runtime/capabilities.py +40 -0
- fi/simulate/runtime/events.py +63 -0
- fi/simulate/runtime/failures.py +25 -0
- fi/simulate/runtime/ids.py +34 -0
- fi/simulate/runtime/plan.py +70 -0
- fi/simulate/runtime/planner.py +102 -0
- fi/simulate/runtime/report.py +174 -0
- fi/simulate/runtime/run.py +75 -0
- fi/simulate/runtime/runner.py +333 -0
- fi/simulate/runtime/spec.py +186 -0
- fi/simulate/simulation/__init__.py +30 -0
- fi/simulate/simulation/behavior_policy.py +425 -0
- fi/simulate/simulation/bridge/__init__.py +9 -0
- fi/simulate/simulation/bridge/audio.py +29 -0
- fi/simulate/simulation/bridge/connector.py +46 -0
- fi/simulate/simulation/bridge/livekit.py +252 -0
- fi/simulate/simulation/bridge/retell.py +188 -0
- fi/simulate/simulation/bridge/vapi.py +177 -0
- fi/simulate/simulation/contract.py +419 -0
- fi/simulate/simulation/engines/__init__.py +12 -0
- fi/simulate/simulation/engines/base.py +21 -0
- fi/simulate/simulation/engines/cloud.py +517 -0
- fi/simulate/simulation/engines/livekit.py +4167 -0
- fi/simulate/simulation/engines/local_text.py +89 -0
- fi/simulate/simulation/fidelity.py +374 -0
- fi/simulate/simulation/gemini_tts_stream.py +110 -0
- fi/simulate/simulation/generator.py +91 -0
- fi/simulate/simulation/goal_machine.py +185 -0
- fi/simulate/simulation/livekit_models.py +467 -0
- fi/simulate/simulation/matrix.py +170 -0
- fi/simulate/simulation/models.py +279 -0
- fi/simulate/simulation/runner.py +153 -0
- fi/simulate/simulation/synthetic.py +880 -0
- fi/simulate/simulation/voice_prompt.py +502 -0
- fi/simulate/simulator/__init__.py +55 -0
- fi/simulate/simulator/builtins.py +53 -0
- fi/simulate/suite.py +1288 -0
- fi/simulate/utils/routes.py +164 -0
- fi/simulate/voice.py +225 -0
- fi/simulate/voice_cli.py +182 -0
- fi/utils/__init__.py +1 -0
- fi/utils/constants.py +14 -0
- fi/utils/errors.py +200 -0
- fi/utils/executor.py +26 -0
- fi/utils/routes.py +119 -0
- fi/utils/utils.py +17 -0
|
@@ -0,0 +1,2896 @@
|
|
|
1
|
+
"""Provision the runtime an agent repository already describes.
|
|
2
|
+
|
|
3
|
+
The source repository is the authority for infrastructure. If it ships a Compose file, the
|
|
4
|
+
harness starts that file under an isolated project name, substitutes free host ports, waits for
|
|
5
|
+
its health checks, and records the URLs that the agent must receive. It does not ask a model to
|
|
6
|
+
rewrite Dockerfiles, migrations, seed data, or service implementations.
|
|
7
|
+
|
|
8
|
+
This module deliberately has no Docker SDK dependency. Compose itself is the parser and the
|
|
9
|
+
lifecycle manager, so every Compose feature the user's project supports keeps working here too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
from urllib import error as urllib_error
|
|
27
|
+
from urllib import request as urllib_request
|
|
28
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from .packaging import PackagingKind, inspect_packaging
|
|
33
|
+
from .generated_runtime import (
|
|
34
|
+
GENERATED_DOCKERFILE,
|
|
35
|
+
GeneratedRuntimeError,
|
|
36
|
+
GeneratedRuntimePlan,
|
|
37
|
+
can_generate_runtime,
|
|
38
|
+
prepare_generated_runtime,
|
|
39
|
+
)
|
|
40
|
+
from .livekit_source import infer_livekit_agent_name_from_source
|
|
41
|
+
from .service_catalog import address, profile_for
|
|
42
|
+
from .secrets import runtime_configuration_value
|
|
43
|
+
|
|
44
|
+
MANIFEST = "environment.json"
|
|
45
|
+
COMPOSE_FILES = (
|
|
46
|
+
"compose.yaml",
|
|
47
|
+
"compose.yml",
|
|
48
|
+
"docker-compose.yaml",
|
|
49
|
+
"docker-compose.yml",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ${TOOLS_PORT:-18090}:8080 is the common way a repository makes a published port movable.
|
|
53
|
+
_PORT_VARIABLE = re.compile(
|
|
54
|
+
r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-?(?P<default>\d+))?\}"
|
|
55
|
+
r"\s*:\s*(?P<target>\d+)"
|
|
56
|
+
)
|
|
57
|
+
# Covers os.environ.get/getenv and the equivalent bracket lookup. The default URL is useful:
|
|
58
|
+
# it identifies which published service this setting points to without guessing from its name.
|
|
59
|
+
_URL_SETTING = re.compile(
|
|
60
|
+
r"(?:os\.(?:environ\.get|getenv)\(\s*|os\.environ\[\s*)"
|
|
61
|
+
r"[\"'](?P<name>[A-Za-z_][A-Za-z0-9_]*)[\"']"
|
|
62
|
+
r"(?:\s*,\s*[\"'](?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://[^\"']+)[\"'])?"
|
|
63
|
+
)
|
|
64
|
+
_JS_URL_SETTING = re.compile(
|
|
65
|
+
r"process\.env\.(?P<name>[A-Z][A-Z0-9_]{2,})\s*(?:\?\?|\|\|)\s*"
|
|
66
|
+
r"[\"'](?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://[^\"']+)[\"']"
|
|
67
|
+
)
|
|
68
|
+
_DOTENV_URL_SETTING = re.compile(
|
|
69
|
+
r"(?m)^\s*(?P<name>[A-Z][A-Z0-9_]{2,})\s*=\s*"
|
|
70
|
+
r"(?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://\S+)\s*$"
|
|
71
|
+
)
|
|
72
|
+
_ENV_NAME = re.compile(
|
|
73
|
+
r"(?:os\.(?:environ\.get|getenv)\(\s*|os\.environ\[\s*|process\.env\.)"
|
|
74
|
+
r"[\"']?(?P<name>[A-Z][A-Z0-9_]{2,})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProvisionError(RuntimeError):
|
|
79
|
+
"""The source environment could not be discovered, started, or inspected."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ProvisionedEnvironment:
|
|
84
|
+
source: str
|
|
85
|
+
compose_file: str
|
|
86
|
+
project: str
|
|
87
|
+
compose_override_file: str = ""
|
|
88
|
+
services: list[str] = field(default_factory=list)
|
|
89
|
+
port_variables: dict[str, str] = field(default_factory=dict)
|
|
90
|
+
overrides: dict[str, str] = field(default_factory=dict)
|
|
91
|
+
internal_overrides: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
service_endpoints: list[dict[str, Any]] = field(default_factory=list)
|
|
93
|
+
runtime_services: list[str] = field(default_factory=list)
|
|
94
|
+
runtime_container: str = ""
|
|
95
|
+
runtime_trace_volume: str = ""
|
|
96
|
+
runtime_trace_path: str = ""
|
|
97
|
+
# Container-port -> runner-reachable address for conversational ingress. Values are
|
|
98
|
+
# ephemeral localhost bindings locally or private container-network addresses in a hosted
|
|
99
|
+
# runner. They contain no credentials and disappear when the runtime is stopped.
|
|
100
|
+
runtime_endpoints: dict[str, str] = field(default_factory=dict)
|
|
101
|
+
runner_network: str = ""
|
|
102
|
+
running: bool = False
|
|
103
|
+
source_fingerprint: str = ""
|
|
104
|
+
provision_seconds: float = 0.0
|
|
105
|
+
managed: bool = False
|
|
106
|
+
generated_runtime_plan: str = ""
|
|
107
|
+
runtime_fingerprint: str = ""
|
|
108
|
+
# Names only. Values are resolved from the job secret environment immediately before the
|
|
109
|
+
# ephemeral worker starts and are never serialized into environment.json or a bundle.
|
|
110
|
+
runtime_configuration_names: list[str] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
def save(self, destination: Path) -> Path:
|
|
113
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
target = destination / MANIFEST
|
|
115
|
+
target.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
|
116
|
+
return target
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def load(cls, destination: Path) -> ProvisionedEnvironment | None:
|
|
120
|
+
target = Path(destination) / MANIFEST
|
|
121
|
+
if not target.exists():
|
|
122
|
+
return None
|
|
123
|
+
return cls(**json.loads(target.read_text(encoding="utf-8")))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compose_file(source: str | Path) -> Path | None:
|
|
127
|
+
root = Path(source).expanduser().resolve()
|
|
128
|
+
for name in COMPOSE_FILES:
|
|
129
|
+
candidate = root / name
|
|
130
|
+
if candidate.is_file():
|
|
131
|
+
return candidate
|
|
132
|
+
packaging = inspect_packaging(root)
|
|
133
|
+
if packaging.ready and packaging.selected_kind is PackagingKind.COMPOSE:
|
|
134
|
+
assert packaging.selected_path is not None
|
|
135
|
+
return root / packaging.selected_path
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_FINGERPRINT_IGNORED = {
|
|
140
|
+
".git",
|
|
141
|
+
".mypy_cache",
|
|
142
|
+
".pytest_cache",
|
|
143
|
+
".ruff_cache",
|
|
144
|
+
".venv",
|
|
145
|
+
"__pycache__",
|
|
146
|
+
"artifacts",
|
|
147
|
+
"build",
|
|
148
|
+
"dist",
|
|
149
|
+
"node_modules",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def source_fingerprint(source: str | Path) -> str:
|
|
154
|
+
"""Identify the exact submitted source used to build an environment.
|
|
155
|
+
|
|
156
|
+
A path is not a version. Reusing a healthy Compose project after files at that path changed
|
|
157
|
+
silently runs yesterday's image against today's contract and scenarios. Hash the build
|
|
158
|
+
context while excluding local caches and generated artifacts so reuse is both safe and fast.
|
|
159
|
+
"""
|
|
160
|
+
root = Path(source).expanduser().resolve()
|
|
161
|
+
digest = hashlib.sha256()
|
|
162
|
+
for path in sorted(root.rglob("*")):
|
|
163
|
+
if any(part in _FINGERPRINT_IGNORED for part in path.relative_to(root).parts):
|
|
164
|
+
continue
|
|
165
|
+
if path.is_symlink():
|
|
166
|
+
# Never follow a submitted link while fingerprinting. A malicious
|
|
167
|
+
# repository could otherwise make preflight read an arbitrary host file.
|
|
168
|
+
relative = path.relative_to(root).as_posix().encode()
|
|
169
|
+
target = os.readlink(path).encode(errors="surrogateescape")
|
|
170
|
+
digest.update(len(relative).to_bytes(8, "big"))
|
|
171
|
+
digest.update(relative)
|
|
172
|
+
digest.update(b"\0symlink\0")
|
|
173
|
+
digest.update(target)
|
|
174
|
+
continue
|
|
175
|
+
if not path.is_file():
|
|
176
|
+
continue
|
|
177
|
+
relative = path.relative_to(root).as_posix().encode()
|
|
178
|
+
digest.update(len(relative).to_bytes(8, "big"))
|
|
179
|
+
digest.update(relative)
|
|
180
|
+
try:
|
|
181
|
+
with path.open("rb") as source_file:
|
|
182
|
+
while chunk := source_file.read(1024 * 1024):
|
|
183
|
+
digest.update(chunk)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise ProvisionError(f"could not fingerprint {path}: {exc}") from exc
|
|
186
|
+
return digest.hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _free_port() -> int:
|
|
190
|
+
with socket.socket() as held:
|
|
191
|
+
held.bind(("127.0.0.1", 0))
|
|
192
|
+
return int(held.getsockname()[1])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def port_variables(path: Path) -> dict[str, str]:
|
|
196
|
+
"""Ask Docker to allocate every interpolated host port atomically.
|
|
197
|
+
|
|
198
|
+
Binding a temporary socket to discover a free port and releasing it before
|
|
199
|
+
Compose starts is inherently racy: another concurrent world can claim the
|
|
200
|
+
port in that gap. A published port of zero delegates allocation to Docker at
|
|
201
|
+
container creation, and ``_published_port`` resolves the assigned value once
|
|
202
|
+
the service is running.
|
|
203
|
+
"""
|
|
204
|
+
text = path.read_text(encoding="utf-8")
|
|
205
|
+
assigned: dict[str, str] = {}
|
|
206
|
+
for match in _PORT_VARIABLE.finditer(text):
|
|
207
|
+
name = match.group("name")
|
|
208
|
+
if name in assigned:
|
|
209
|
+
continue
|
|
210
|
+
assigned[name] = "0"
|
|
211
|
+
return assigned
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _declared_port_targets(path: Path) -> dict[int, int]:
|
|
215
|
+
"""Map the repository's default host ports to their container ports."""
|
|
216
|
+
targets: dict[int, int] = {}
|
|
217
|
+
for match in _PORT_VARIABLE.finditer(path.read_text(encoding="utf-8")):
|
|
218
|
+
if match.group("default"):
|
|
219
|
+
targets[int(match.group("default"))] = int(match.group("target"))
|
|
220
|
+
return targets
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _run(
|
|
224
|
+
environment: ProvisionedEnvironment,
|
|
225
|
+
*arguments: str,
|
|
226
|
+
check: bool = True,
|
|
227
|
+
timeout: int = 900,
|
|
228
|
+
process_overrides: dict[str, str] | None = None,
|
|
229
|
+
) -> str:
|
|
230
|
+
files = ["--file", environment.compose_file]
|
|
231
|
+
if environment.compose_override_file:
|
|
232
|
+
files.extend(("--file", environment.compose_override_file))
|
|
233
|
+
command = [
|
|
234
|
+
"docker",
|
|
235
|
+
"compose",
|
|
236
|
+
*files,
|
|
237
|
+
"--project-name",
|
|
238
|
+
environment.project,
|
|
239
|
+
*arguments,
|
|
240
|
+
]
|
|
241
|
+
# Runtime credentials are inherited by Compose and selected with ``--env NAME``. They must
|
|
242
|
+
# never be serialized into argv, where error messages and host process listings expose them.
|
|
243
|
+
process_env = {
|
|
244
|
+
**os.environ,
|
|
245
|
+
**environment.port_variables,
|
|
246
|
+
}
|
|
247
|
+
# Uploaded values are namespaced inside the harness worker. Expose them under their submitted
|
|
248
|
+
# names only to Compose, where interpolation and explicitly inherited service variables need
|
|
249
|
+
# them. This never falls back to runner/controller credentials.
|
|
250
|
+
for name in environment.runtime_configuration_names:
|
|
251
|
+
value = runtime_configuration_value(name).strip()
|
|
252
|
+
if value:
|
|
253
|
+
process_env[name] = value
|
|
254
|
+
# Explicit ALK endpoint/credential-path substitutions are the final authority. Applying
|
|
255
|
+
# uploaded values after these substitutions silently restores development hostnames such as
|
|
256
|
+
# ``harness`` or ``localhost`` at the exact point Compose starts the submitted runtime.
|
|
257
|
+
process_env.update(process_overrides or {})
|
|
258
|
+
|
|
259
|
+
def execute() -> subprocess.CompletedProcess[str]:
|
|
260
|
+
return subprocess.run(
|
|
261
|
+
command,
|
|
262
|
+
cwd=environment.source,
|
|
263
|
+
env=process_env,
|
|
264
|
+
capture_output=True,
|
|
265
|
+
text=True,
|
|
266
|
+
check=False,
|
|
267
|
+
timeout=timeout,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
completed = execute()
|
|
272
|
+
except FileNotFoundError as exc:
|
|
273
|
+
raise ProvisionError("Docker is not installed or is not on PATH") from exc
|
|
274
|
+
except subprocess.TimeoutExpired as exc:
|
|
275
|
+
raise ProvisionError(f"environment command timed out after {timeout}s") from exc
|
|
276
|
+
output = ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
277
|
+
if (
|
|
278
|
+
completed.returncode
|
|
279
|
+
and "all predefined address pools have been fully subnetted" in output
|
|
280
|
+
):
|
|
281
|
+
# Interrupted local runs can leave empty Compose networks behind even after their
|
|
282
|
+
# containers and volumes are gone. After enough runs Docker cannot allocate another
|
|
283
|
+
# subnet and every otherwise valid environment becomes unrunnable. Reclaim only empty,
|
|
284
|
+
# ALK-owned networks, never customer/platform networks or a network with a container.
|
|
285
|
+
if _remove_unused_harness_networks(exclude_project=environment.project):
|
|
286
|
+
completed = execute()
|
|
287
|
+
output = ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
288
|
+
if check and completed.returncode:
|
|
289
|
+
shown = _command_failure_output(output)
|
|
290
|
+
raise ProvisionError(
|
|
291
|
+
f"{' '.join(command)} failed ({completed.returncode}): {shown}"
|
|
292
|
+
)
|
|
293
|
+
return output
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _start_managed_services(
|
|
297
|
+
environment: ProvisionedEnvironment, services: list[str], *, build: bool = True
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Start dependencies, retrying one clean boot for harness-owned stacks only."""
|
|
300
|
+
attempts = 2 if environment.managed else 1
|
|
301
|
+
for attempt in range(attempts):
|
|
302
|
+
try:
|
|
303
|
+
# Managed dependencies are independent. Starting heavyweight brokers and object
|
|
304
|
+
# stores in one burst can exhaust a local/hosted sandbox's process budget even when
|
|
305
|
+
# its steady-state capacity is sufficient. Admit each service to readiness before
|
|
306
|
+
# starting the next; submitted Compose retains its own native dependency graph.
|
|
307
|
+
groups = (
|
|
308
|
+
([service] for service in services)
|
|
309
|
+
if environment.managed
|
|
310
|
+
else [services]
|
|
311
|
+
)
|
|
312
|
+
for group in groups:
|
|
313
|
+
_run(
|
|
314
|
+
environment,
|
|
315
|
+
"up",
|
|
316
|
+
"--detach",
|
|
317
|
+
"--build" if build else "--no-build",
|
|
318
|
+
"--wait",
|
|
319
|
+
*group,
|
|
320
|
+
)
|
|
321
|
+
return
|
|
322
|
+
except ProvisionError as exc:
|
|
323
|
+
detail = str(exc).lower()
|
|
324
|
+
transient_boot = " exited (" in detail or "unhealthy" in detail
|
|
325
|
+
if attempt + 1 >= attempts or not transient_boot:
|
|
326
|
+
raise
|
|
327
|
+
# A retry starts from a genuinely clean state. Reusing a partially initialized
|
|
328
|
+
# broker/database volume makes the second attempt neither isolated nor diagnostic.
|
|
329
|
+
_run(
|
|
330
|
+
environment,
|
|
331
|
+
"down",
|
|
332
|
+
"--volumes",
|
|
333
|
+
"--remove-orphans",
|
|
334
|
+
check=False,
|
|
335
|
+
timeout=120,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
_TRANSIENT_BUILD_FAILURES = (
|
|
340
|
+
"unexpected eof",
|
|
341
|
+
"tls handshake timeout",
|
|
342
|
+
"i/o timeout",
|
|
343
|
+
"connection reset by peer",
|
|
344
|
+
"temporary failure in name resolution",
|
|
345
|
+
"no such host",
|
|
346
|
+
"502 bad gateway",
|
|
347
|
+
"503 service unavailable",
|
|
348
|
+
"429 too many requests",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _build_runtime(environment: ProvisionedEnvironment) -> None:
|
|
353
|
+
"""Build the submitted image with bounded retries for registry transport failures.
|
|
354
|
+
|
|
355
|
+
Repository and dependency-resolution failures remain deterministic and surface immediately.
|
|
356
|
+
A public registry can fail before BuildKit receives a base-image manifest; replaying that
|
|
357
|
+
side-effect-free build is safe and should not force a complete platform-job resubmission.
|
|
358
|
+
"""
|
|
359
|
+
attempts = 3
|
|
360
|
+
for attempt in range(1, attempts + 1):
|
|
361
|
+
try:
|
|
362
|
+
_run(environment, "build", *environment.runtime_services)
|
|
363
|
+
return
|
|
364
|
+
except ProvisionError as exc:
|
|
365
|
+
transient = any(
|
|
366
|
+
marker in str(exc).lower() for marker in _TRANSIENT_BUILD_FAILURES
|
|
367
|
+
)
|
|
368
|
+
if not transient or attempt == attempts:
|
|
369
|
+
raise
|
|
370
|
+
time.sleep(2 ** (attempt - 1))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _command_failure_output(output: str, *, limit: int = 16_000) -> str:
|
|
374
|
+
"""Keep actionable build context without putting megabytes of logs into job state/UI."""
|
|
375
|
+
if not output:
|
|
376
|
+
return "no output"
|
|
377
|
+
if len(output) <= limit:
|
|
378
|
+
return output
|
|
379
|
+
head_size = min(2000, max(1, limit // 4))
|
|
380
|
+
head = output[:head_size]
|
|
381
|
+
tail = output[-(limit - len(head)) :]
|
|
382
|
+
omitted = len(output) - len(head) - len(tail)
|
|
383
|
+
return f"{head}\n... {omitted} characters omitted ...\n{tail}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _config(environment: ProvisionedEnvironment) -> dict[str, Any]:
|
|
387
|
+
# Include opt-in profiles while inspecting. They are not started as infrastructure, but the
|
|
388
|
+
# harness must still discover which service is the submitted agent runtime.
|
|
389
|
+
rendered = _run(
|
|
390
|
+
environment,
|
|
391
|
+
"--profile",
|
|
392
|
+
"*",
|
|
393
|
+
"config",
|
|
394
|
+
"--format",
|
|
395
|
+
"json",
|
|
396
|
+
timeout=60,
|
|
397
|
+
)
|
|
398
|
+
try:
|
|
399
|
+
value = json.loads(rendered)
|
|
400
|
+
except json.JSONDecodeError as exc:
|
|
401
|
+
raise ProvisionError(
|
|
402
|
+
f"Docker Compose returned invalid configuration: {exc}"
|
|
403
|
+
) from exc
|
|
404
|
+
if not isinstance(value, dict) or not isinstance(value.get("services"), dict):
|
|
405
|
+
raise ProvisionError("Docker Compose configuration has no services")
|
|
406
|
+
return value
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _started_services(
|
|
410
|
+
config: dict[str, Any], runtime_services: list[str] | None = None
|
|
411
|
+
) -> list[str]:
|
|
412
|
+
"""Services Compose starts by default; opt-in profile services stay opt-in."""
|
|
413
|
+
runtimes = set(runtime_services or [])
|
|
414
|
+
return sorted(
|
|
415
|
+
name
|
|
416
|
+
for name, service in config["services"].items()
|
|
417
|
+
if _service_starts_by_default(service) and name not in runtimes
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _service_starts_by_default(service: dict[str, Any]) -> bool:
|
|
422
|
+
profiles = [str(value) for value in (service.get("profiles") or [])]
|
|
423
|
+
# Compose treats an explicit empty-string profile as active by default. Dify uses this to
|
|
424
|
+
# make one database/vector backend the default while retaining named alternatives.
|
|
425
|
+
return not profiles or "" in profiles
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _runtime_interface_port(contract: Any | None) -> int | None:
|
|
429
|
+
runtime = getattr(contract, "runtime", None)
|
|
430
|
+
interface = getattr(runtime, "interface", None)
|
|
431
|
+
port = getattr(interface, "port", None)
|
|
432
|
+
return int(port) if port else None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _service_container_ports(service: dict[str, Any]) -> set[int]:
|
|
436
|
+
ports: set[int] = set()
|
|
437
|
+
for entry in service.get("ports") or []:
|
|
438
|
+
if isinstance(entry, dict) and entry.get("target"):
|
|
439
|
+
ports.add(int(entry["target"]))
|
|
440
|
+
elif isinstance(entry, (str, int)):
|
|
441
|
+
rendered = str(entry).split("/")[0]
|
|
442
|
+
target = rendered.rsplit(":", 1)[-1]
|
|
443
|
+
if target.isdigit():
|
|
444
|
+
ports.add(int(target))
|
|
445
|
+
for entry in service.get("expose") or []:
|
|
446
|
+
rendered = str(entry).split("/")[0]
|
|
447
|
+
if rendered.isdigit():
|
|
448
|
+
ports.add(int(rendered))
|
|
449
|
+
return ports
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _runtime_services(config: dict[str, Any], contract: Any | None = None) -> list[str]:
|
|
453
|
+
"""Opt-in services that identify the submitted agent/worker runtime.
|
|
454
|
+
|
|
455
|
+
Compose profiles are also commonly used for pgAdmin, dashboards and debugging tools. Treating
|
|
456
|
+
every profiled service as an agent causes the harness to launch infrastructure UIs as if they
|
|
457
|
+
were voice workers. A generated harness runtime is explicit; repository runtimes must carry a
|
|
458
|
+
recognizable agent/worker/bot name.
|
|
459
|
+
"""
|
|
460
|
+
profiled = [
|
|
461
|
+
name
|
|
462
|
+
for name, service in config["services"].items()
|
|
463
|
+
if not _service_starts_by_default(service)
|
|
464
|
+
]
|
|
465
|
+
explicit = [
|
|
466
|
+
name
|
|
467
|
+
for name in profiled
|
|
468
|
+
if "harness-runtime" in (config["services"][name].get("profiles") or [])
|
|
469
|
+
]
|
|
470
|
+
if explicit:
|
|
471
|
+
return sorted(explicit)
|
|
472
|
+
preferred = [
|
|
473
|
+
name
|
|
474
|
+
for name in profiled
|
|
475
|
+
if any(word in name.lower() for word in ("agent", "worker", "bot"))
|
|
476
|
+
]
|
|
477
|
+
if preferred:
|
|
478
|
+
return sorted(preferred)
|
|
479
|
+
interface_port = _runtime_interface_port(contract)
|
|
480
|
+
if interface_port is None:
|
|
481
|
+
return []
|
|
482
|
+
candidates = sorted(
|
|
483
|
+
name
|
|
484
|
+
for name, service in config["services"].items()
|
|
485
|
+
if interface_port in _service_container_ports(service)
|
|
486
|
+
)
|
|
487
|
+
if len(candidates) > 1:
|
|
488
|
+
raise ProvisionError(
|
|
489
|
+
f"chat runtime port {interface_port} is exposed by several Compose services: "
|
|
490
|
+
+ ", ".join(candidates)
|
|
491
|
+
+ "; select one runtime component explicitly"
|
|
492
|
+
)
|
|
493
|
+
return candidates
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _validate_compose_security(config: dict[str, Any]) -> None:
|
|
497
|
+
"""Reject host-escape primitives before any submitted container is created."""
|
|
498
|
+
violations: list[str] = []
|
|
499
|
+
for name, service in config["services"].items():
|
|
500
|
+
if service.get("privileged"):
|
|
501
|
+
violations.append(f"{name}: privileged")
|
|
502
|
+
for key in ("network_mode", "pid", "ipc"):
|
|
503
|
+
if str(service.get(key) or "").lower() == "host":
|
|
504
|
+
violations.append(f"{name}: {key}=host")
|
|
505
|
+
if service.get("devices"):
|
|
506
|
+
violations.append(f"{name}: host devices")
|
|
507
|
+
for volume in service.get("volumes") or []:
|
|
508
|
+
source = (
|
|
509
|
+
str(volume.get("source") or "")
|
|
510
|
+
if isinstance(volume, dict)
|
|
511
|
+
else str(volume)
|
|
512
|
+
)
|
|
513
|
+
if (
|
|
514
|
+
source in {"/var/run/docker.sock", "/run/docker.sock"}
|
|
515
|
+
or "docker.sock:" in source
|
|
516
|
+
):
|
|
517
|
+
violations.append(f"{name}: Docker socket mount")
|
|
518
|
+
if violations:
|
|
519
|
+
raise ProvisionError(
|
|
520
|
+
"submitted Compose requests forbidden host access: " + ", ".join(violations)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _write_port_override(
|
|
525
|
+
destination: Path,
|
|
526
|
+
environment: ProvisionedEnvironment,
|
|
527
|
+
config: dict[str, Any],
|
|
528
|
+
) -> None:
|
|
529
|
+
"""Replace every fixed published port with a job-owned port.
|
|
530
|
+
|
|
531
|
+
Environment variables cover only repositories that deliberately parameterise their Compose
|
|
532
|
+
ports. Most repositories publish constants, which collide as soon as two jobs overlap. A
|
|
533
|
+
generated Compose override makes isolation universal while leaving container ports intact.
|
|
534
|
+
"""
|
|
535
|
+
services: list[tuple[str, list[dict[str, Any]], bool]] = []
|
|
536
|
+
dynamic_targets = set(
|
|
537
|
+
_declared_port_targets(Path(environment.compose_file)).values()
|
|
538
|
+
)
|
|
539
|
+
bind_host = os.environ.get("ALK_DOCKER_BIND_HOST", "").strip() or (
|
|
540
|
+
"127.0.0.1" if _published_host() == "127.0.0.1" else "0.0.0.0"
|
|
541
|
+
)
|
|
542
|
+
for name, service in config["services"].items():
|
|
543
|
+
# Compose excludes profiled services unless that profile is explicitly enabled. Do not
|
|
544
|
+
# allocate ports for dormant TURN/admin/debug services; doing so can exhaust a runner's
|
|
545
|
+
# ephemeral port range before the selected environment even starts.
|
|
546
|
+
if not _service_starts_by_default(service):
|
|
547
|
+
continue
|
|
548
|
+
ports: list[dict[str, Any]] = []
|
|
549
|
+
for item in service.get("ports") or []:
|
|
550
|
+
target = int(item.get("target") or 0)
|
|
551
|
+
if not target or target in dynamic_targets:
|
|
552
|
+
continue
|
|
553
|
+
ports.append(
|
|
554
|
+
{
|
|
555
|
+
"target": target,
|
|
556
|
+
# Let Docker reserve an available port in the same atomic
|
|
557
|
+
# operation that creates the container. Preselecting a
|
|
558
|
+
# supposedly free port races concurrent worlds.
|
|
559
|
+
"published": "0",
|
|
560
|
+
"host_ip": bind_host,
|
|
561
|
+
"protocol": str(item.get("protocol") or "tcp"),
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
fixed_container_name = bool(str(service.get("container_name") or "").strip())
|
|
565
|
+
if ports or fixed_container_name:
|
|
566
|
+
services.append((name, ports, fixed_container_name))
|
|
567
|
+
reset_env_files = _missing_env_file_services(Path(environment.compose_file))
|
|
568
|
+
if not services and not reset_env_files:
|
|
569
|
+
return
|
|
570
|
+
lines = ["services:"]
|
|
571
|
+
for name in reset_env_files:
|
|
572
|
+
lines.extend((f" {json.dumps(name)}:", " env_file: !reset []"))
|
|
573
|
+
for name, ports, fixed_container_name in services:
|
|
574
|
+
lines.append(f" {json.dumps(name)}:")
|
|
575
|
+
if fixed_container_name:
|
|
576
|
+
# A submitted container_name bypasses Compose project scoping and collides across
|
|
577
|
+
# concurrent jobs. Compose's reset tag restores its normal project-owned name.
|
|
578
|
+
lines.append(" container_name: !reset null")
|
|
579
|
+
if ports:
|
|
580
|
+
lines.append(" ports: !override")
|
|
581
|
+
for item in ports:
|
|
582
|
+
lines.extend(
|
|
583
|
+
(
|
|
584
|
+
f" - target: {item['target']}",
|
|
585
|
+
f" published: {json.dumps(item['published'])}",
|
|
586
|
+
f" host_ip: {json.dumps(item['host_ip'])}",
|
|
587
|
+
f" protocol: {item['protocol']}",
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
591
|
+
target = destination / "compose.harness.override.yaml"
|
|
592
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
593
|
+
environment.compose_override_file = str(target.resolve())
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _missing_env_file_services(compose: Path) -> list[str]:
|
|
597
|
+
"""Find services whose repository-local dotenv file is absent.
|
|
598
|
+
|
|
599
|
+
Hosted uploads intentionally exclude secret dotenv files. Compose otherwise refuses even to
|
|
600
|
+
render a service whose ``env_file`` is absent. The generated override removes only that file
|
|
601
|
+
reference; job-scoped uploaded values are injected separately at the process/container
|
|
602
|
+
boundary and are never written into the checkout or artifacts.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
document = yaml.safe_load(compose.read_text(encoding="utf-8")) or {}
|
|
606
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
607
|
+
return []
|
|
608
|
+
services = document.get("services") if isinstance(document, dict) else None
|
|
609
|
+
if not isinstance(services, dict):
|
|
610
|
+
return []
|
|
611
|
+
missing: list[str] = []
|
|
612
|
+
for name, service in services.items():
|
|
613
|
+
if not isinstance(service, dict) or not service.get("env_file"):
|
|
614
|
+
continue
|
|
615
|
+
entries = service["env_file"]
|
|
616
|
+
if not isinstance(entries, list):
|
|
617
|
+
entries = [entries]
|
|
618
|
+
paths = [
|
|
619
|
+
str(item.get("path") if isinstance(item, dict) else item)
|
|
620
|
+
for item in entries
|
|
621
|
+
]
|
|
622
|
+
if any(path and not (compose.parent / path).is_file() for path in paths):
|
|
623
|
+
missing.append(str(name))
|
|
624
|
+
return sorted(missing)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _write_initial_env_file_override(
|
|
628
|
+
destination: Path, environment: ProvisionedEnvironment
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Make a submitted Compose model renderable before port inspection."""
|
|
631
|
+
services = _missing_env_file_services(Path(environment.compose_file))
|
|
632
|
+
if not services:
|
|
633
|
+
return
|
|
634
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
target = destination / "compose.harness.override.yaml"
|
|
636
|
+
lines = ["services:"]
|
|
637
|
+
for name in services:
|
|
638
|
+
lines.extend((f" {json.dumps(name)}:", " env_file: !reset []"))
|
|
639
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
640
|
+
environment.compose_override_file = str(target.resolve())
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _refresh_compose_ports(
|
|
644
|
+
destination: Path, environment: ProvisionedEnvironment
|
|
645
|
+
) -> dict[str, Any]:
|
|
646
|
+
"""Render the submitted stack with a newly allocated host-port set."""
|
|
647
|
+
environment.port_variables = port_variables(Path(environment.compose_file))
|
|
648
|
+
environment.compose_override_file = None
|
|
649
|
+
_write_initial_env_file_override(destination, environment)
|
|
650
|
+
config = _config(environment)
|
|
651
|
+
_validate_compose_security(config)
|
|
652
|
+
_write_port_override(destination, environment, config)
|
|
653
|
+
if environment.compose_override_file:
|
|
654
|
+
config = _config(environment)
|
|
655
|
+
_validate_compose_security(config)
|
|
656
|
+
return config
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _published_port(
|
|
660
|
+
environment: ProvisionedEnvironment, service: str, target: int
|
|
661
|
+
) -> int:
|
|
662
|
+
shown = _run(environment, "port", service, str(target), timeout=30)
|
|
663
|
+
line = next((line for line in shown.splitlines() if line.strip()), "")
|
|
664
|
+
try:
|
|
665
|
+
return int(line.rsplit(":", 1)[1])
|
|
666
|
+
except (IndexError, ValueError) as exc:
|
|
667
|
+
raise ProvisionError(
|
|
668
|
+
f"could not determine the host port for {service}:{target}: {shown or 'no mapping'}"
|
|
669
|
+
) from exc
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _url_settings(source: Path) -> list[tuple[str, str]]:
|
|
673
|
+
found: list[tuple[str, str]] = []
|
|
674
|
+
ignored = {".git", ".venv", "node_modules", "dist", "build", "__pycache__"}
|
|
675
|
+
for path in source.rglob("*"):
|
|
676
|
+
if (
|
|
677
|
+
path.suffix.lower() not in {".py", ".js", ".jsx", ".ts", ".tsx"}
|
|
678
|
+
and path.name != ".env.example"
|
|
679
|
+
):
|
|
680
|
+
continue
|
|
681
|
+
if any(part in ignored for part in path.parts):
|
|
682
|
+
continue
|
|
683
|
+
try:
|
|
684
|
+
text = path.read_text(encoding="utf-8")
|
|
685
|
+
except (OSError, UnicodeDecodeError):
|
|
686
|
+
continue
|
|
687
|
+
for match in _URL_SETTING.finditer(text):
|
|
688
|
+
url = match.group("url") or ""
|
|
689
|
+
if url:
|
|
690
|
+
found.append((match.group("name"), url))
|
|
691
|
+
for pattern in (_JS_URL_SETTING, _DOTENV_URL_SETTING):
|
|
692
|
+
found.extend(
|
|
693
|
+
(match.group("name"), match.group("url"))
|
|
694
|
+
for match in pattern.finditer(text)
|
|
695
|
+
)
|
|
696
|
+
return found
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _declared_configuration_names(source: Path) -> set[str]:
|
|
700
|
+
"""Configuration variables the submitted source actually reads or documents."""
|
|
701
|
+
found = {name for name, _ in _url_settings(source)}
|
|
702
|
+
ignored = {".git", ".venv", "node_modules", "dist", "build", "__pycache__"}
|
|
703
|
+
for path in source.rglob("*"):
|
|
704
|
+
if (
|
|
705
|
+
path.suffix.lower() not in {".py", ".js", ".jsx", ".ts", ".tsx"}
|
|
706
|
+
and path.name != ".env.example"
|
|
707
|
+
):
|
|
708
|
+
continue
|
|
709
|
+
if not path.is_file() or any(part in ignored for part in path.parts):
|
|
710
|
+
continue
|
|
711
|
+
try:
|
|
712
|
+
text = path.read_text(encoding="utf-8")
|
|
713
|
+
except (OSError, UnicodeDecodeError):
|
|
714
|
+
continue
|
|
715
|
+
found.update(match.group("name") for match in _ENV_NAME.finditer(text))
|
|
716
|
+
if path.name == ".env.example":
|
|
717
|
+
found.update(
|
|
718
|
+
match.group(1)
|
|
719
|
+
for match in re.finditer(r"(?m)^\s*([A-Z][A-Z0-9_]{2,})\s*=", text)
|
|
720
|
+
)
|
|
721
|
+
return found
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _replace_endpoint(value: str, host: str, port: int) -> str:
|
|
725
|
+
parsed = urlsplit(value)
|
|
726
|
+
userinfo = ""
|
|
727
|
+
if parsed.username is not None:
|
|
728
|
+
userinfo = quote(parsed.username, safe="")
|
|
729
|
+
if parsed.password is not None:
|
|
730
|
+
userinfo += ":" + quote(parsed.password, safe="")
|
|
731
|
+
userinfo += "@"
|
|
732
|
+
return urlunsplit(
|
|
733
|
+
(
|
|
734
|
+
parsed.scheme,
|
|
735
|
+
f"{userinfo}{host}:{port}",
|
|
736
|
+
parsed.path,
|
|
737
|
+
parsed.query,
|
|
738
|
+
parsed.fragment,
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _service_endpoints(
|
|
744
|
+
environment: ProvisionedEnvironment, config: dict[str, Any]
|
|
745
|
+
) -> list[dict[str, Any]]:
|
|
746
|
+
"""Describe every published dependency; unknown images remain usable TCP services."""
|
|
747
|
+
records: list[dict[str, Any]] = []
|
|
748
|
+
for service_name in environment.services:
|
|
749
|
+
service = config["services"][service_name]
|
|
750
|
+
image = str(service.get("image") or "")
|
|
751
|
+
for port in service.get("ports") or []:
|
|
752
|
+
target = int(port.get("target") or 0)
|
|
753
|
+
if not target:
|
|
754
|
+
continue
|
|
755
|
+
configured_host_port = int(port.get("published") or 0)
|
|
756
|
+
try:
|
|
757
|
+
host_port = _published_port(environment, service_name, target)
|
|
758
|
+
except ProvisionError:
|
|
759
|
+
# Primarily useful for provider adapters and dry-run validation. A real started
|
|
760
|
+
# Compose project normally resolves through ``compose port``.
|
|
761
|
+
if not configured_host_port:
|
|
762
|
+
raise
|
|
763
|
+
host_port = configured_host_port
|
|
764
|
+
profile = profile_for(service_name, image, target)
|
|
765
|
+
records.append(
|
|
766
|
+
{
|
|
767
|
+
"service": service_name,
|
|
768
|
+
"kind": profile.kind,
|
|
769
|
+
"protocol": profile.protocol,
|
|
770
|
+
"container_port": target,
|
|
771
|
+
"host_port": host_port,
|
|
772
|
+
"configured_host_port": configured_host_port,
|
|
773
|
+
"external_address": address(
|
|
774
|
+
profile.protocol, _published_host(), host_port
|
|
775
|
+
),
|
|
776
|
+
"internal_address": address(profile.protocol, service_name, target),
|
|
777
|
+
"configuration_names": list(profile.configuration_names),
|
|
778
|
+
"readiness_path": profile.readiness_path,
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
return records
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _endpoint_ready(endpoint: dict[str, Any], host: str) -> bool:
|
|
785
|
+
"""Probe the service protocol when known, falling back to a TCP connection."""
|
|
786
|
+
port = int(endpoint["host_port"])
|
|
787
|
+
protocol = str(endpoint.get("protocol") or "tcp")
|
|
788
|
+
try:
|
|
789
|
+
with socket.create_connection((host, port), timeout=0.75) as connection:
|
|
790
|
+
if protocol == "redis":
|
|
791
|
+
connection.sendall(b"*1\r\n$4\r\nPING\r\n")
|
|
792
|
+
response = connection.recv(128)
|
|
793
|
+
# An authentication challenge is a semantic Redis response and proves the
|
|
794
|
+
# server is ready. Credentials are validated by the submitted agent itself.
|
|
795
|
+
return response.startswith((b"+PONG", b"-NOAUTH"))
|
|
796
|
+
if protocol == "amqp":
|
|
797
|
+
# A listening RabbitMQ socket can appear before the AMQP application is ready.
|
|
798
|
+
# Require the server to answer the protocol header, not merely accept TCP.
|
|
799
|
+
connection.sendall(b"AMQP\x00\x00\x09\x01")
|
|
800
|
+
return bool(connection.recv(128))
|
|
801
|
+
if protocol == "nats":
|
|
802
|
+
# NATS begins every client session with an INFO line once the server can route
|
|
803
|
+
# traffic. This also distinguishes it from an unrelated process on the port.
|
|
804
|
+
return connection.recv(256).startswith(b"INFO ")
|
|
805
|
+
except OSError:
|
|
806
|
+
return False
|
|
807
|
+
readiness_path = str(endpoint.get("readiness_path") or "")
|
|
808
|
+
if readiness_path and protocol in {"clickhouse", "http", "mcp", "s3"}:
|
|
809
|
+
url = f"http://{host}:{port}/{readiness_path.lstrip('/')}"
|
|
810
|
+
try:
|
|
811
|
+
with urllib_request.build_opener(urllib_request.ProxyHandler({})).open(
|
|
812
|
+
url, timeout=1.0
|
|
813
|
+
) as response:
|
|
814
|
+
response.read(256)
|
|
815
|
+
return 200 <= response.status < 400
|
|
816
|
+
except (OSError, urllib_error.URLError, urllib_error.HTTPError):
|
|
817
|
+
return False
|
|
818
|
+
return True
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _wait_for_endpoints(
|
|
822
|
+
endpoints: list[dict[str, Any]],
|
|
823
|
+
timeout: float = 60.0,
|
|
824
|
+
stability_seconds: float = 2.0,
|
|
825
|
+
*,
|
|
826
|
+
use_internal: bool = False,
|
|
827
|
+
) -> None:
|
|
828
|
+
"""Require stable protocol readiness, not merely a briefly-open container port."""
|
|
829
|
+
indexed: dict[tuple[str, int], dict[str, Any]] = {}
|
|
830
|
+
for item in endpoints:
|
|
831
|
+
probe = dict(item)
|
|
832
|
+
if use_internal:
|
|
833
|
+
probe["host_port"] = int(item["container_port"])
|
|
834
|
+
probe["probe_host"] = str(item["service"])
|
|
835
|
+
indexed[(str(probe["service"]), int(probe["host_port"]))] = probe
|
|
836
|
+
pending = set(indexed)
|
|
837
|
+
ready_since: dict[tuple[str, int], float] = {}
|
|
838
|
+
deadline = time.monotonic() + timeout
|
|
839
|
+
while pending and time.monotonic() < deadline:
|
|
840
|
+
for endpoint in list(pending):
|
|
841
|
+
probe = indexed[endpoint]
|
|
842
|
+
host = str(probe.get("probe_host") or _published_host())
|
|
843
|
+
if _endpoint_ready(probe, host):
|
|
844
|
+
first_ready = ready_since.setdefault(endpoint, time.monotonic())
|
|
845
|
+
# Protocol services can briefly answer while an init script is about to restart
|
|
846
|
+
# them. A short stability window prevents the first real tool call racing that
|
|
847
|
+
# transition. Unknown TCP services retain the fast generic behavior.
|
|
848
|
+
needs_stability = bool(indexed[endpoint].get("readiness_path")) or str(
|
|
849
|
+
indexed[endpoint].get("protocol") or ""
|
|
850
|
+
) in {"amqp", "nats", "redis"}
|
|
851
|
+
if (
|
|
852
|
+
not needs_stability
|
|
853
|
+
or time.monotonic() - first_ready >= stability_seconds
|
|
854
|
+
):
|
|
855
|
+
pending.remove(endpoint)
|
|
856
|
+
else:
|
|
857
|
+
ready_since.pop(endpoint, None)
|
|
858
|
+
if pending:
|
|
859
|
+
time.sleep(0.2)
|
|
860
|
+
if pending:
|
|
861
|
+
rendered = ", ".join(f"{service}:{port}" for service, port in sorted(pending))
|
|
862
|
+
raise ProvisionError(f"environment endpoints did not become ready: {rendered}")
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _wait_for_environment_endpoints(
|
|
866
|
+
environment: ProvisionedEnvironment,
|
|
867
|
+
timeout: float = 60.0,
|
|
868
|
+
stability_seconds: float = 2.0,
|
|
869
|
+
) -> None:
|
|
870
|
+
"""Probe over the runner's private network when the runner is containerized."""
|
|
871
|
+
internal = bool(os.environ.get("ALK_RUNNER_CONTAINER", "").strip())
|
|
872
|
+
if internal and environment.services:
|
|
873
|
+
_attach_runner_network(environment)
|
|
874
|
+
_wait_for_endpoints(
|
|
875
|
+
environment.service_endpoints,
|
|
876
|
+
timeout,
|
|
877
|
+
stability_seconds,
|
|
878
|
+
use_internal=True,
|
|
879
|
+
)
|
|
880
|
+
return
|
|
881
|
+
_wait_for_endpoints(environment.service_endpoints, timeout, stability_seconds)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _overrides(
|
|
885
|
+
environment: ProvisionedEnvironment, config: dict[str, Any]
|
|
886
|
+
) -> dict[str, str]:
|
|
887
|
+
"""Map declared settings to the equivalent endpoint in this isolated project."""
|
|
888
|
+
endpoints = environment.service_endpoints or _service_endpoints(environment, config)
|
|
889
|
+
source = Path(environment.source)
|
|
890
|
+
names = _declared_configuration_names(source)
|
|
891
|
+
answers: dict[str, str] = {}
|
|
892
|
+
declared = _declared_port_targets(Path(environment.compose_file))
|
|
893
|
+
for variable, default in _url_settings(Path(environment.source)):
|
|
894
|
+
parsed = urlsplit(default)
|
|
895
|
+
try:
|
|
896
|
+
default_port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
897
|
+
except ValueError:
|
|
898
|
+
# Cluster DSNs are commonly comma-separated URL lists. They require a topology-aware
|
|
899
|
+
# adapter; one malformed/specialized declaration must not abort environment creation.
|
|
900
|
+
continue
|
|
901
|
+
expected_target = declared.get(default_port)
|
|
902
|
+
match = next(
|
|
903
|
+
(
|
|
904
|
+
item
|
|
905
|
+
for item in endpoints
|
|
906
|
+
if item["host_port"] == default_port
|
|
907
|
+
or item["configured_host_port"] == default_port
|
|
908
|
+
or item["container_port"] == default_port
|
|
909
|
+
or (
|
|
910
|
+
expected_target is not None
|
|
911
|
+
and item["container_port"] == expected_target
|
|
912
|
+
)
|
|
913
|
+
),
|
|
914
|
+
None,
|
|
915
|
+
)
|
|
916
|
+
if match is None:
|
|
917
|
+
continue
|
|
918
|
+
answers[variable] = _replace_endpoint(
|
|
919
|
+
default, _published_host(), int(match["host_port"])
|
|
920
|
+
)
|
|
921
|
+
for endpoint in endpoints:
|
|
922
|
+
kind = str(endpoint["kind"]).upper().replace("-", "_")
|
|
923
|
+
candidates = set(endpoint["configuration_names"]) | {
|
|
924
|
+
f"{kind}_HOST",
|
|
925
|
+
f"{kind}_PORT",
|
|
926
|
+
}
|
|
927
|
+
for variable in sorted(names & candidates):
|
|
928
|
+
if variable in answers:
|
|
929
|
+
continue
|
|
930
|
+
if variable.endswith("_HOST"):
|
|
931
|
+
value = _published_host()
|
|
932
|
+
elif variable.endswith("_PORT"):
|
|
933
|
+
value = str(endpoint["host_port"])
|
|
934
|
+
elif variable.endswith("BOOTSTRAP_SERVERS"):
|
|
935
|
+
value = f"{_published_host()}:{endpoint['host_port']}"
|
|
936
|
+
else:
|
|
937
|
+
value = str(endpoint["external_address"])
|
|
938
|
+
answers[variable] = value
|
|
939
|
+
return answers
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _internal_overrides(
|
|
943
|
+
environment: ProvisionedEnvironment, config: dict[str, Any]
|
|
944
|
+
) -> dict[str, str]:
|
|
945
|
+
"""The same inferred endpoints, addressed from another Compose container."""
|
|
946
|
+
endpoints = environment.service_endpoints or _service_endpoints(environment, config)
|
|
947
|
+
declared = _declared_port_targets(Path(environment.compose_file))
|
|
948
|
+
answers: dict[str, str] = {}
|
|
949
|
+
for variable, default in _url_settings(Path(environment.source)):
|
|
950
|
+
parsed = urlsplit(default)
|
|
951
|
+
try:
|
|
952
|
+
default_port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
953
|
+
except ValueError:
|
|
954
|
+
continue
|
|
955
|
+
expected_target = declared.get(default_port)
|
|
956
|
+
match = next(
|
|
957
|
+
(
|
|
958
|
+
item
|
|
959
|
+
for item in endpoints
|
|
960
|
+
if item["host_port"] == default_port
|
|
961
|
+
or item["configured_host_port"] == default_port
|
|
962
|
+
or item["container_port"] == default_port
|
|
963
|
+
or (
|
|
964
|
+
expected_target is not None
|
|
965
|
+
and item["container_port"] == expected_target
|
|
966
|
+
)
|
|
967
|
+
),
|
|
968
|
+
None,
|
|
969
|
+
)
|
|
970
|
+
if match is not None:
|
|
971
|
+
answers[variable] = _replace_endpoint(
|
|
972
|
+
default, str(match["service"]), int(match["container_port"])
|
|
973
|
+
)
|
|
974
|
+
external = _overrides(environment, config)
|
|
975
|
+
by_name: dict[str, dict[str, Any]] = {}
|
|
976
|
+
for endpoint in endpoints:
|
|
977
|
+
for name in endpoint["configuration_names"]:
|
|
978
|
+
by_name[name] = endpoint
|
|
979
|
+
kind = str(endpoint["kind"]).upper().replace("-", "_")
|
|
980
|
+
by_name[f"{kind}_HOST"] = endpoint
|
|
981
|
+
by_name[f"{kind}_PORT"] = endpoint
|
|
982
|
+
for variable in external:
|
|
983
|
+
if variable in answers or variable not in by_name:
|
|
984
|
+
continue
|
|
985
|
+
endpoint = by_name[variable]
|
|
986
|
+
if variable.endswith("_HOST"):
|
|
987
|
+
value = str(endpoint["service"])
|
|
988
|
+
elif variable.endswith("_PORT"):
|
|
989
|
+
value = str(endpoint["container_port"])
|
|
990
|
+
elif variable.endswith("BOOTSTRAP_SERVERS"):
|
|
991
|
+
value = f"{endpoint['service']}:{endpoint['container_port']}"
|
|
992
|
+
else:
|
|
993
|
+
value = str(endpoint["internal_address"])
|
|
994
|
+
answers[variable] = value
|
|
995
|
+
if environment.managed and len(environment.runtime_services) == 1:
|
|
996
|
+
# The harness-generated Compose adapter owns these values. Its runtime service carries
|
|
997
|
+
# the complete connector assembled by ``_managed_service`` (database, user and any
|
|
998
|
+
# run-local credentials), while endpoint inference above can only reconstruct a generic
|
|
999
|
+
# host/port URL. Prefer the generated connector so starting the submitted runtime cannot
|
|
1000
|
+
# accidentally turn e.g. ``mysql://harness:.../voice`` into ``mysql://mysql:3306``.
|
|
1001
|
+
runtime = config.get("services", {}).get(environment.runtime_services[0]) or {}
|
|
1002
|
+
generated = _environment_values(runtime)
|
|
1003
|
+
endpoint_names = {
|
|
1004
|
+
str(name)
|
|
1005
|
+
for endpoint in endpoints
|
|
1006
|
+
for name in endpoint.get("configuration_names", [])
|
|
1007
|
+
}
|
|
1008
|
+
for name in endpoint_names:
|
|
1009
|
+
value = generated.get(name, "").strip()
|
|
1010
|
+
if value:
|
|
1011
|
+
answers[name] = value
|
|
1012
|
+
return answers
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _environment_values(service: dict[str, Any]) -> dict[str, str]:
|
|
1016
|
+
raw = service.get("environment") or {}
|
|
1017
|
+
if isinstance(raw, list):
|
|
1018
|
+
return dict(
|
|
1019
|
+
entry.split("=", 1) if "=" in entry else (entry, "") for entry in raw
|
|
1020
|
+
)
|
|
1021
|
+
return {str(key): str(value) for key, value in raw.items()}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def postgres_dsn(destination: str | Path) -> str:
|
|
1025
|
+
"""Resolve the submitted Compose project's Postgres endpoint without persisting its secret."""
|
|
1026
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
1027
|
+
if environment is None or not environment.running:
|
|
1028
|
+
raise ProvisionError(f"no running environment recorded at {destination}")
|
|
1029
|
+
config = _config(environment)
|
|
1030
|
+
candidates: list[tuple[str, dict[str, Any]]] = []
|
|
1031
|
+
for name in environment.services:
|
|
1032
|
+
service = config["services"].get(name) or {}
|
|
1033
|
+
image = str(service.get("image") or "").lower()
|
|
1034
|
+
targets = {int(port.get("target") or 0) for port in service.get("ports") or []}
|
|
1035
|
+
if image.startswith("postgres:") or 5432 in targets:
|
|
1036
|
+
candidates.append((name, service))
|
|
1037
|
+
if len(candidates) != 1:
|
|
1038
|
+
raise ProvisionError(
|
|
1039
|
+
"expected exactly one Postgres service in the submitted environment, found "
|
|
1040
|
+
+ (", ".join(name for name, _ in candidates) or "none")
|
|
1041
|
+
)
|
|
1042
|
+
name, service = candidates[0]
|
|
1043
|
+
values = _environment_values(service)
|
|
1044
|
+
user = values.get("POSTGRES_USER") or "postgres"
|
|
1045
|
+
password = values.get("POSTGRES_PASSWORD") or ""
|
|
1046
|
+
database = values.get("POSTGRES_DB") or user
|
|
1047
|
+
port = _published_port(environment, name, 5432)
|
|
1048
|
+
return (
|
|
1049
|
+
f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}"
|
|
1050
|
+
f"@{_published_host()}:{port}/{quote(database, safe='')}"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _published_host() -> str:
|
|
1055
|
+
"""Host through which this process reaches ports published by Docker.
|
|
1056
|
+
|
|
1057
|
+
A host CLI reaches published ports on loopback. A sandbox talking to a
|
|
1058
|
+
remote daemon reaches the same ports through that daemon's gateway.
|
|
1059
|
+
Deployment owns the address; provisioning only applies it consistently to
|
|
1060
|
+
HTTP services and attached stores.
|
|
1061
|
+
"""
|
|
1062
|
+
return os.environ.get("ALK_DOCKER_PUBLISHED_HOST", "").strip() or "127.0.0.1"
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def uses_private_runner_network(environment: ProvisionedEnvironment) -> bool:
|
|
1066
|
+
"""Whether this process shares the provisioned environment's Compose network."""
|
|
1067
|
+
return bool(
|
|
1068
|
+
os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
|
|
1069
|
+
and environment.runner_network
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def reachable_overrides(environment: ProvisionedEnvironment) -> dict[str, str]:
|
|
1074
|
+
"""Return service addresses reachable from the process executing the harness.
|
|
1075
|
+
|
|
1076
|
+
A local CLI reaches Compose services through published host ports. A hosted worker is
|
|
1077
|
+
attached to the run's private Compose network and must use service DNS/container ports;
|
|
1078
|
+
Docker Desktop does not guarantee that a container can hairpin through its own published
|
|
1079
|
+
``host.docker.internal`` port.
|
|
1080
|
+
"""
|
|
1081
|
+
if uses_private_runner_network(environment) and environment.internal_overrides:
|
|
1082
|
+
return dict(environment.internal_overrides)
|
|
1083
|
+
return dict(environment.overrides)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _runner_service_address(
|
|
1087
|
+
environment: ProvisionedEnvironment, address: str, service: str
|
|
1088
|
+
) -> str:
|
|
1089
|
+
"""Use a run-unique DNS name when the runner shares many Compose networks.
|
|
1090
|
+
|
|
1091
|
+
A hosted runner is attached to the platform network and to one private network per active
|
|
1092
|
+
harness environment. Generic Compose aliases such as ``postgres`` can consequently resolve
|
|
1093
|
+
to the platform database instead of this run's database. Submitted containers stay on one
|
|
1094
|
+
network and may use service aliases; the multi-network runner must use the unique container
|
|
1095
|
+
name Docker registers on the same private network.
|
|
1096
|
+
"""
|
|
1097
|
+
container_id = _run(environment, "ps", "--quiet", service, timeout=30).strip()
|
|
1098
|
+
if not container_id:
|
|
1099
|
+
raise ProvisionError(f"service {service!r} has no running container")
|
|
1100
|
+
container_name = (
|
|
1101
|
+
_docker("inspect", "--format", "{{.Name}}", container_id, timeout=30)
|
|
1102
|
+
.strip()
|
|
1103
|
+
.lstrip("/")
|
|
1104
|
+
)
|
|
1105
|
+
if not container_name:
|
|
1106
|
+
raise ProvisionError(f"service {service!r} has no resolvable container name")
|
|
1107
|
+
parsed = urlsplit(address)
|
|
1108
|
+
userinfo, separator, _host = parsed.netloc.rpartition("@")
|
|
1109
|
+
authority = f"{userinfo}{separator}" if separator else ""
|
|
1110
|
+
authority += container_name
|
|
1111
|
+
if parsed.port is not None:
|
|
1112
|
+
authority += f":{parsed.port}"
|
|
1113
|
+
return urlunsplit(parsed._replace(netloc=authority))
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def attached_postgres_store(destination: str | Path):
|
|
1117
|
+
"""The standard world-store interface over the repository's running Postgres."""
|
|
1118
|
+
from .world.stores.postgres import AttachedPostgresStore
|
|
1119
|
+
|
|
1120
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
1121
|
+
if environment is None or not environment.running:
|
|
1122
|
+
raise ProvisionError(f"no running environment recorded at {destination}")
|
|
1123
|
+
if uses_private_runner_network(environment):
|
|
1124
|
+
candidates = {
|
|
1125
|
+
value
|
|
1126
|
+
for value in environment.internal_overrides.values()
|
|
1127
|
+
if urlsplit(value).scheme in {"postgres", "postgresql"}
|
|
1128
|
+
}
|
|
1129
|
+
if len(candidates) == 1:
|
|
1130
|
+
address = next(iter(candidates))
|
|
1131
|
+
services = {
|
|
1132
|
+
str(endpoint.get("service") or "")
|
|
1133
|
+
for endpoint in environment.service_endpoints
|
|
1134
|
+
if str(endpoint.get("kind") or "") == "postgres"
|
|
1135
|
+
}
|
|
1136
|
+
services.discard("")
|
|
1137
|
+
if len(services) == 1:
|
|
1138
|
+
address = _runner_service_address(
|
|
1139
|
+
environment, address, next(iter(services))
|
|
1140
|
+
)
|
|
1141
|
+
return AttachedPostgresStore(address)
|
|
1142
|
+
return AttachedPostgresStore(postgres_dsn(destination))
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _configuration_name(value: str) -> str:
|
|
1146
|
+
"""Extract an environment variable name from a contract's prose or config key."""
|
|
1147
|
+
candidates = _configuration_names(value)
|
|
1148
|
+
return candidates[0] if candidates else ""
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _configuration_names(value: str) -> list[str]:
|
|
1152
|
+
"""Extract every env name from fields that may contain comma/slash-separated prose."""
|
|
1153
|
+
raw = (value or "").strip()
|
|
1154
|
+
candidates = re.findall(r"\b[A-Z][A-Z0-9_]{2,}\b", raw)
|
|
1155
|
+
names = [candidate for candidate in candidates if "_" in candidate]
|
|
1156
|
+
if not names and re.fullmatch(r"[A-Z][A-Z0-9_]{2,}", raw):
|
|
1157
|
+
names = [raw]
|
|
1158
|
+
return list(dict.fromkeys(names))
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _contract_runtime_configuration_names(contract: Any | None) -> list[str]:
|
|
1162
|
+
names: set[str] = set()
|
|
1163
|
+
store = getattr(contract, "data_store", None)
|
|
1164
|
+
if store is not None:
|
|
1165
|
+
for field_name in ("configured_by", "config_key", "password_from"):
|
|
1166
|
+
names.update(
|
|
1167
|
+
_configuration_names(str(getattr(store, field_name, "") or ""))
|
|
1168
|
+
)
|
|
1169
|
+
dependencies = [
|
|
1170
|
+
*list(getattr(contract, "dependencies", None) or []),
|
|
1171
|
+
*list(getattr(contract, "runtime_dependencies", None) or []),
|
|
1172
|
+
]
|
|
1173
|
+
for dependency in dependencies:
|
|
1174
|
+
reached = getattr(dependency, "reached", None)
|
|
1175
|
+
if reached is None:
|
|
1176
|
+
continue
|
|
1177
|
+
for field_name in ("dsn_env", "config_key", "user", "password_from"):
|
|
1178
|
+
names.update(
|
|
1179
|
+
_configuration_names(str(getattr(reached, field_name, "") or ""))
|
|
1180
|
+
)
|
|
1181
|
+
forbidden = {
|
|
1182
|
+
"FI_API_KEY",
|
|
1183
|
+
"FI_SECRET_KEY",
|
|
1184
|
+
"HARNESS_PLATFORM_API_KEY",
|
|
1185
|
+
"HARNESS_PLATFORM_SECRET_KEY",
|
|
1186
|
+
}
|
|
1187
|
+
supplied = os.environ.get("ALK_RUNTIME_CONFIGURATION_NAMES", "")
|
|
1188
|
+
names.update(
|
|
1189
|
+
name
|
|
1190
|
+
for name in supplied.split(",")
|
|
1191
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)
|
|
1192
|
+
)
|
|
1193
|
+
return sorted(
|
|
1194
|
+
name for name in names if name not in forbidden and not name.startswith("ALK_")
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _saved_runtime_configuration_names(destination: Path) -> list[str]:
|
|
1199
|
+
"""Recover authorized configuration *names* without reading or persisting their values."""
|
|
1200
|
+
names: set[str] = set()
|
|
1201
|
+
job_path = destination / "job.json"
|
|
1202
|
+
if job_path.is_file():
|
|
1203
|
+
try:
|
|
1204
|
+
job = json.loads(job_path.read_text(encoding="utf-8"))
|
|
1205
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
1206
|
+
job = {}
|
|
1207
|
+
agent = job.get("agent") if isinstance(job, dict) else None
|
|
1208
|
+
references = agent.get("secret_refs") if isinstance(agent, dict) else None
|
|
1209
|
+
if isinstance(references, dict):
|
|
1210
|
+
names.update(str(name) for name in references)
|
|
1211
|
+
metadata = job.get("metadata") if isinstance(job, dict) else None
|
|
1212
|
+
declared = (
|
|
1213
|
+
metadata.get("environment_value_names")
|
|
1214
|
+
if isinstance(metadata, dict)
|
|
1215
|
+
else None
|
|
1216
|
+
)
|
|
1217
|
+
if isinstance(declared, list):
|
|
1218
|
+
names.update(str(name) for name in declared)
|
|
1219
|
+
|
|
1220
|
+
previous = ProvisionedEnvironment.load(destination)
|
|
1221
|
+
if previous is not None:
|
|
1222
|
+
names.update(previous.runtime_configuration_names)
|
|
1223
|
+
|
|
1224
|
+
forbidden = {
|
|
1225
|
+
"FI_API_KEY",
|
|
1226
|
+
"FI_SECRET_KEY",
|
|
1227
|
+
"HARNESS_PLATFORM_API_KEY",
|
|
1228
|
+
"HARNESS_PLATFORM_SECRET_KEY",
|
|
1229
|
+
}
|
|
1230
|
+
return sorted(
|
|
1231
|
+
name
|
|
1232
|
+
for name in names
|
|
1233
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)
|
|
1234
|
+
and name not in forbidden
|
|
1235
|
+
and not name.startswith("ALK_")
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _managed_service(
|
|
1240
|
+
engine: str,
|
|
1241
|
+
*,
|
|
1242
|
+
version: str,
|
|
1243
|
+
database: str,
|
|
1244
|
+
user: str,
|
|
1245
|
+
init_mounts: list[str],
|
|
1246
|
+
) -> tuple[dict[str, Any], str, str]:
|
|
1247
|
+
"""A real infrastructure service, its internal connector and default config name."""
|
|
1248
|
+
if engine == "clickhouse":
|
|
1249
|
+
return (
|
|
1250
|
+
{
|
|
1251
|
+
"image": f"clickhouse/clickhouse-server:{version or '24.8'}",
|
|
1252
|
+
"environment": {
|
|
1253
|
+
"CLICKHOUSE_DB": database,
|
|
1254
|
+
"CLICKHOUSE_USER": user,
|
|
1255
|
+
"CLICKHOUSE_PASSWORD": "",
|
|
1256
|
+
"CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
|
|
1257
|
+
},
|
|
1258
|
+
"ports": [
|
|
1259
|
+
"${HARNESS_CLICKHOUSE_HTTP_PORT:-58123}:8123",
|
|
1260
|
+
"${HARNESS_CLICKHOUSE_NATIVE_PORT:-59000}:9000",
|
|
1261
|
+
],
|
|
1262
|
+
"healthcheck": {
|
|
1263
|
+
"test": [
|
|
1264
|
+
"CMD",
|
|
1265
|
+
"wget",
|
|
1266
|
+
"--spider",
|
|
1267
|
+
"-q",
|
|
1268
|
+
"http://127.0.0.1:8123/ping",
|
|
1269
|
+
],
|
|
1270
|
+
"interval": "1s",
|
|
1271
|
+
"timeout": "3s",
|
|
1272
|
+
"retries": 60,
|
|
1273
|
+
},
|
|
1274
|
+
"volumes": init_mounts,
|
|
1275
|
+
},
|
|
1276
|
+
f"http://{quote(user, safe='')}@clickhouse:8123/{quote(database, safe='')}",
|
|
1277
|
+
"CLICKHOUSE_URL",
|
|
1278
|
+
)
|
|
1279
|
+
if engine == "postgres":
|
|
1280
|
+
return (
|
|
1281
|
+
{
|
|
1282
|
+
"image": f"postgres:{version or '16'}",
|
|
1283
|
+
"environment": {
|
|
1284
|
+
"POSTGRES_DB": database,
|
|
1285
|
+
"POSTGRES_USER": user,
|
|
1286
|
+
"POSTGRES_HOST_AUTH_METHOD": "trust",
|
|
1287
|
+
},
|
|
1288
|
+
"ports": ["${HARNESS_POSTGRES_PORT:-55432}:5432"],
|
|
1289
|
+
"healthcheck": {
|
|
1290
|
+
"test": ["CMD-SHELL", f"pg_isready -U {user} -d {database}"],
|
|
1291
|
+
"interval": "1s",
|
|
1292
|
+
"timeout": "3s",
|
|
1293
|
+
"retries": 30,
|
|
1294
|
+
},
|
|
1295
|
+
"volumes": init_mounts,
|
|
1296
|
+
},
|
|
1297
|
+
f"postgresql://{user}@postgres:5432/{database}",
|
|
1298
|
+
"DATABASE_URL",
|
|
1299
|
+
)
|
|
1300
|
+
if engine == "mysql":
|
|
1301
|
+
return (
|
|
1302
|
+
{
|
|
1303
|
+
"image": f"mysql:{version or '8.4'}",
|
|
1304
|
+
"environment": {
|
|
1305
|
+
"MYSQL_DATABASE": database,
|
|
1306
|
+
"MYSQL_USER": user,
|
|
1307
|
+
"MYSQL_PASSWORD": "harness-local",
|
|
1308
|
+
"MYSQL_ROOT_PASSWORD": "harness-root-local",
|
|
1309
|
+
},
|
|
1310
|
+
"ports": ["${HARNESS_MYSQL_PORT:-53306}:3306"],
|
|
1311
|
+
"healthcheck": {
|
|
1312
|
+
"test": [
|
|
1313
|
+
"CMD-SHELL",
|
|
1314
|
+
"mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER "
|
|
1315
|
+
"-p$$MYSQL_PASSWORD --silent",
|
|
1316
|
+
],
|
|
1317
|
+
"interval": "1s",
|
|
1318
|
+
"timeout": "5s",
|
|
1319
|
+
"retries": 60,
|
|
1320
|
+
},
|
|
1321
|
+
"volumes": init_mounts,
|
|
1322
|
+
},
|
|
1323
|
+
f"mysql://{quote(user, safe='')}:harness-local@mysql:3306/"
|
|
1324
|
+
f"{quote(database, safe='')}",
|
|
1325
|
+
"DATABASE_URL",
|
|
1326
|
+
)
|
|
1327
|
+
if engine == "redis":
|
|
1328
|
+
return (
|
|
1329
|
+
{
|
|
1330
|
+
"image": f"redis:{version or '7-alpine'}",
|
|
1331
|
+
"ports": ["${HARNESS_REDIS_PORT:-56379}:6379"],
|
|
1332
|
+
"healthcheck": {
|
|
1333
|
+
"test": ["CMD", "redis-cli", "ping"],
|
|
1334
|
+
"interval": "1s",
|
|
1335
|
+
"timeout": "3s",
|
|
1336
|
+
"retries": 30,
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
"redis://redis:6379",
|
|
1340
|
+
"REDIS_URL",
|
|
1341
|
+
)
|
|
1342
|
+
if engine == "mongodb":
|
|
1343
|
+
return (
|
|
1344
|
+
{
|
|
1345
|
+
"image": f"mongo:{version or '7'}",
|
|
1346
|
+
"ports": ["${HARNESS_MONGODB_PORT:-57017}:27017"],
|
|
1347
|
+
"healthcheck": {
|
|
1348
|
+
"test": [
|
|
1349
|
+
"CMD",
|
|
1350
|
+
"mongosh",
|
|
1351
|
+
"--quiet",
|
|
1352
|
+
"--eval",
|
|
1353
|
+
"db.adminCommand('ping').ok",
|
|
1354
|
+
],
|
|
1355
|
+
"interval": "1s",
|
|
1356
|
+
"timeout": "5s",
|
|
1357
|
+
"retries": 60,
|
|
1358
|
+
},
|
|
1359
|
+
},
|
|
1360
|
+
f"mongodb://mongodb:27017/{quote(database, safe='')}",
|
|
1361
|
+
"MONGODB_URL",
|
|
1362
|
+
)
|
|
1363
|
+
if engine == "qdrant":
|
|
1364
|
+
return (
|
|
1365
|
+
{
|
|
1366
|
+
"image": f"qdrant/qdrant:{version or 'v1.13.6'}",
|
|
1367
|
+
"ports": ["${HARNESS_QDRANT_HTTP_PORT:-56333}:6333"],
|
|
1368
|
+
},
|
|
1369
|
+
"http://qdrant:6333",
|
|
1370
|
+
"QDRANT_URL",
|
|
1371
|
+
)
|
|
1372
|
+
if engine == "rabbitmq":
|
|
1373
|
+
return (
|
|
1374
|
+
{
|
|
1375
|
+
# The management plugin is not needed for AMQP workloads and materially raises
|
|
1376
|
+
# memory/boot pressure when several isolated jobs start together. Customers can
|
|
1377
|
+
# still request a management tag explicitly in their contract.
|
|
1378
|
+
"image": f"rabbitmq:{version or '3.13-alpine'}",
|
|
1379
|
+
# The image declares /var/lib/rabbitmq as an anonymous volume. On Docker Desktop
|
|
1380
|
+
# the initial cookie can be materialized as uid 0 even though the broker runs as
|
|
1381
|
+
# uid 100, and the stock entrypoint does not repair that particular file. Seed
|
|
1382
|
+
# the run-local cookie with the correct ownership before delegating back to the
|
|
1383
|
+
# image entrypoint. ``$$`` is Compose escaping, not host interpolation.
|
|
1384
|
+
"entrypoint": [
|
|
1385
|
+
"sh",
|
|
1386
|
+
"-c",
|
|
1387
|
+
'printf %s "$$RABBITMQ_ERLANG_COOKIE" > '
|
|
1388
|
+
"/var/lib/rabbitmq/.erlang.cookie && "
|
|
1389
|
+
"chown rabbitmq:rabbitmq /var/lib/rabbitmq/.erlang.cookie && "
|
|
1390
|
+
"chmod 400 /var/lib/rabbitmq/.erlang.cookie && "
|
|
1391
|
+
'exec docker-entrypoint.sh "$$@"',
|
|
1392
|
+
"--",
|
|
1393
|
+
],
|
|
1394
|
+
# Compose does not reliably retain an image's CMD once an exec-form entrypoint is
|
|
1395
|
+
# supplied (notably with JSON Compose input on Docker Desktop). Pass the stock
|
|
1396
|
+
# broker command explicitly so ``docker-entrypoint.sh`` always receives ``$1``.
|
|
1397
|
+
"command": ["rabbitmq-server"],
|
|
1398
|
+
"environment": {
|
|
1399
|
+
"RABBITMQ_DEFAULT_USER": "harness",
|
|
1400
|
+
"RABBITMQ_DEFAULT_PASS": "harness-local",
|
|
1401
|
+
# RabbitMQ 3.13 on Docker Desktop can create the image-declared anonymous
|
|
1402
|
+
# data volume's cookie as root before the broker drops to uid 100. Erlang
|
|
1403
|
+
# then aborts with EACCES. Supplying an environment-local cookie makes the
|
|
1404
|
+
# broker use the injected value and avoids depending on that image-volume
|
|
1405
|
+
# ownership race. Each Compose project has a private network, so this is an
|
|
1406
|
+
# internal bootstrap credential, never a customer or platform secret.
|
|
1407
|
+
"RABBITMQ_ERLANG_COOKIE": "harness-local-cookie",
|
|
1408
|
+
# Erlang otherwise sizes scheduler/dirty-scheduler pools from the host CPU
|
|
1409
|
+
# count, not the job's practical sandbox share. Concurrent environments can
|
|
1410
|
+
# then exhaust Docker's thread budget during boot even though each broker is
|
|
1411
|
+
# small. Keep the harness-owned broker deterministic and resource-bounded.
|
|
1412
|
+
"RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS": "+S 2:2 +SDcpu 1 +SDio 1",
|
|
1413
|
+
},
|
|
1414
|
+
"ports": ["${HARNESS_RABBITMQ_PORT:-55672}:5672"],
|
|
1415
|
+
"healthcheck": {
|
|
1416
|
+
"test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"],
|
|
1417
|
+
"interval": "1s",
|
|
1418
|
+
"timeout": "5s",
|
|
1419
|
+
"retries": 90,
|
|
1420
|
+
},
|
|
1421
|
+
},
|
|
1422
|
+
"amqp://harness:harness-local@rabbitmq:5672/%2F",
|
|
1423
|
+
"AMQP_URL",
|
|
1424
|
+
)
|
|
1425
|
+
if engine == "nats":
|
|
1426
|
+
return (
|
|
1427
|
+
{
|
|
1428
|
+
"image": f"nats:{version or '2.10-alpine'}",
|
|
1429
|
+
"command": ["-js", "-m", "8222"],
|
|
1430
|
+
"ports": [
|
|
1431
|
+
"${HARNESS_NATS_PORT:-54222}:4222",
|
|
1432
|
+
"${HARNESS_NATS_MONITORING_PORT:-58222}:8222",
|
|
1433
|
+
],
|
|
1434
|
+
"healthcheck": {
|
|
1435
|
+
"test": [
|
|
1436
|
+
"CMD",
|
|
1437
|
+
"wget",
|
|
1438
|
+
"--spider",
|
|
1439
|
+
"-q",
|
|
1440
|
+
"http://127.0.0.1:8222/healthz",
|
|
1441
|
+
],
|
|
1442
|
+
"interval": "1s",
|
|
1443
|
+
"timeout": "5s",
|
|
1444
|
+
"retries": 60,
|
|
1445
|
+
},
|
|
1446
|
+
},
|
|
1447
|
+
"nats://nats:4222",
|
|
1448
|
+
"NATS_URL",
|
|
1449
|
+
)
|
|
1450
|
+
if engine == "minio":
|
|
1451
|
+
return (
|
|
1452
|
+
{
|
|
1453
|
+
"image": f"minio/minio:{version or 'RELEASE.2025-04-22T22-12-26Z'}",
|
|
1454
|
+
"command": ["server", "/data", "--console-address", ":9001"],
|
|
1455
|
+
"environment": {
|
|
1456
|
+
"MINIO_ROOT_USER": "harness",
|
|
1457
|
+
"MINIO_ROOT_PASSWORD": "harness-local-secret",
|
|
1458
|
+
},
|
|
1459
|
+
"ports": [
|
|
1460
|
+
"${HARNESS_MINIO_PORT:-59010}:9000",
|
|
1461
|
+
"${HARNESS_MINIO_CONSOLE_PORT:-59011}:9001",
|
|
1462
|
+
],
|
|
1463
|
+
"healthcheck": {
|
|
1464
|
+
"test": [
|
|
1465
|
+
"CMD",
|
|
1466
|
+
"curl",
|
|
1467
|
+
"-f",
|
|
1468
|
+
"http://127.0.0.1:9000/minio/health/ready",
|
|
1469
|
+
],
|
|
1470
|
+
"interval": "1s",
|
|
1471
|
+
"timeout": "5s",
|
|
1472
|
+
"retries": 60,
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
"http://minio:9000",
|
|
1476
|
+
"S3_ENDPOINT_URL",
|
|
1477
|
+
)
|
|
1478
|
+
raise ProvisionError(f"managed_dependency_unsupported: {engine}")
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _managed_compose(
|
|
1482
|
+
source: Path,
|
|
1483
|
+
destination: Path,
|
|
1484
|
+
contract: Any,
|
|
1485
|
+
generated_runtime: GeneratedRuntimePlan | None = None,
|
|
1486
|
+
) -> Path | None:
|
|
1487
|
+
"""Write a harness-owned adapter for a supported source-declared data store.
|
|
1488
|
+
|
|
1489
|
+
This is deliberately registry-like and explicit. A recognized engine gets its real service;
|
|
1490
|
+
an unknown engine returns no adapter and provisioning fails clearly rather than substituting
|
|
1491
|
+
Postgres or an in-memory fake with different behavior.
|
|
1492
|
+
"""
|
|
1493
|
+
store = getattr(contract, "data_store", None)
|
|
1494
|
+
kind = str(getattr(store, "kind", "") or "").lower()
|
|
1495
|
+
dependencies = list(getattr(contract, "dependencies", None) or [])
|
|
1496
|
+
supported = (
|
|
1497
|
+
"clickhouse",
|
|
1498
|
+
"postgres",
|
|
1499
|
+
"mysql",
|
|
1500
|
+
"redis",
|
|
1501
|
+
"mongodb",
|
|
1502
|
+
"qdrant",
|
|
1503
|
+
"rabbitmq",
|
|
1504
|
+
"nats",
|
|
1505
|
+
"minio",
|
|
1506
|
+
)
|
|
1507
|
+
primary_engine = next((one for one in supported if one in kind), "")
|
|
1508
|
+
requested: list[tuple[str, Any | None]] = []
|
|
1509
|
+
embedded_store = any(
|
|
1510
|
+
marker in kind.replace("-", "_").replace(" ", "_")
|
|
1511
|
+
for marker in (
|
|
1512
|
+
"in_process",
|
|
1513
|
+
"in_memory",
|
|
1514
|
+
"memory",
|
|
1515
|
+
"sqlite",
|
|
1516
|
+
"filesystem",
|
|
1517
|
+
"file_store",
|
|
1518
|
+
"local_state",
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
unsupported_declared = bool(kind) and not primary_engine and not embedded_store
|
|
1522
|
+
dependency_manifest = ""
|
|
1523
|
+
if generated_runtime is not None:
|
|
1524
|
+
manifest = (
|
|
1525
|
+
source / generated_runtime.component / generated_runtime.dependency_file
|
|
1526
|
+
)
|
|
1527
|
+
if manifest.is_file():
|
|
1528
|
+
dependency_manifest = (
|
|
1529
|
+
manifest.read_text(encoding="utf-8", errors="replace")
|
|
1530
|
+
.lower()
|
|
1531
|
+
.replace("_", "-")
|
|
1532
|
+
)
|
|
1533
|
+
if primary_engine:
|
|
1534
|
+
requested.append((primary_engine, None))
|
|
1535
|
+
for dependency in dependencies:
|
|
1536
|
+
description = (
|
|
1537
|
+
f"{getattr(dependency, 'name', '')} {getattr(dependency, 'engine', '')} "
|
|
1538
|
+
f"{getattr(dependency, 'kind', '')} {getattr(dependency, 'what', '')}"
|
|
1539
|
+
).lower()
|
|
1540
|
+
engine = next((one for one in supported if one in description), "")
|
|
1541
|
+
declared_engine = str(getattr(dependency, "engine", "") or "").strip()
|
|
1542
|
+
reached = getattr(dependency, "reached", None)
|
|
1543
|
+
dsn_env = str(getattr(reached, "dsn_env", "") or "").strip()
|
|
1544
|
+
config_key = str(getattr(reached, "config_key", "") or "").strip()
|
|
1545
|
+
password_from = str(getattr(reached, "password_from", "") or "").strip()
|
|
1546
|
+
normalized_description = description.replace("-", "_").replace(" ", "_")
|
|
1547
|
+
reached_host = str(getattr(reached, "host", "") or "").strip()
|
|
1548
|
+
normalized_reached_host = reached_host.replace("-", "_").replace(" ", "_")
|
|
1549
|
+
embedded_engine = any(
|
|
1550
|
+
marker in normalized_description
|
|
1551
|
+
for marker in ("sqlite", "in_process", "in_memory", "filesystem")
|
|
1552
|
+
)
|
|
1553
|
+
embedded_dependency = bool(
|
|
1554
|
+
reached
|
|
1555
|
+
and (
|
|
1556
|
+
str(getattr(reached, "loader_module", "") or "").strip()
|
|
1557
|
+
or str(getattr(reached, "loader_function", "") or "").strip()
|
|
1558
|
+
)
|
|
1559
|
+
and not dsn_env
|
|
1560
|
+
and not str(getattr(reached, "config_key", "") or "").strip()
|
|
1561
|
+
and not str(getattr(reached, "password_from", "") or "").strip()
|
|
1562
|
+
# SQLite is process-local even when source understanding describes the host in
|
|
1563
|
+
# prose (for example ``in-memory :memory:``) instead of using the literal sentinel.
|
|
1564
|
+
# It is embedded state, not an external service ALK must supply.
|
|
1565
|
+
and (embedded_engine or reached_host in {"", ":memory:"})
|
|
1566
|
+
and not getattr(reached, "port", None)
|
|
1567
|
+
and not str(getattr(reached, "database", "") or "").strip()
|
|
1568
|
+
)
|
|
1569
|
+
package_name = re.split(r"[<>=!~\s\[]", declared_engine, maxsplit=1)[0]
|
|
1570
|
+
packaged_dependency = bool(
|
|
1571
|
+
generated_runtime is not None
|
|
1572
|
+
and package_name
|
|
1573
|
+
and package_name.lower().replace("_", "-") in dependency_manifest
|
|
1574
|
+
and not dsn_env
|
|
1575
|
+
and not config_key
|
|
1576
|
+
and not password_from
|
|
1577
|
+
and not str(getattr(reached, "database", "") or "").strip()
|
|
1578
|
+
and not (reached_host not in {"", ":memory:"})
|
|
1579
|
+
and not getattr(reached, "port", None)
|
|
1580
|
+
)
|
|
1581
|
+
external_provider = bool(
|
|
1582
|
+
reached
|
|
1583
|
+
and (password_from or dsn_env or config_key)
|
|
1584
|
+
and not str(getattr(reached, "database", "") or "").strip()
|
|
1585
|
+
)
|
|
1586
|
+
# Hosted model, speech and media providers are reached with customer credentials; they
|
|
1587
|
+
# are not infrastructure ALK can or should synthesize. Source understanding occasionally
|
|
1588
|
+
# records the provider accurately but omits its well-known credential variable. Keep that
|
|
1589
|
+
# contract omission in preflight/credential UX instead of misclassifying Gemini or
|
|
1590
|
+
# Deepgram as an unsupported Docker service.
|
|
1591
|
+
hosted_provider = any(
|
|
1592
|
+
marker in normalized_description
|
|
1593
|
+
for marker in (
|
|
1594
|
+
"openai",
|
|
1595
|
+
"anthropic",
|
|
1596
|
+
"gemini",
|
|
1597
|
+
"vertex_ai",
|
|
1598
|
+
"deepgram",
|
|
1599
|
+
"cartesia",
|
|
1600
|
+
"elevenlabs",
|
|
1601
|
+
"livekit",
|
|
1602
|
+
"retell",
|
|
1603
|
+
"vapi",
|
|
1604
|
+
"daily",
|
|
1605
|
+
)
|
|
1606
|
+
)
|
|
1607
|
+
# A submitted image can own process-local runtime dependencies such as an ONNX VAD
|
|
1608
|
+
# model downloaded by its Dockerfile. They are not infrastructure ALK must synthesize.
|
|
1609
|
+
# Keep this deliberately structural and narrow: an unknown customer service with no
|
|
1610
|
+
# explicit local-runtime reachability remains unsupported, and any network/database or
|
|
1611
|
+
# credential seam prevents this classification.
|
|
1612
|
+
local_runtime_dependency = bool(
|
|
1613
|
+
reached
|
|
1614
|
+
and any(
|
|
1615
|
+
marker in normalized_reached_host
|
|
1616
|
+
for marker in (
|
|
1617
|
+
"in_process",
|
|
1618
|
+
"in_memory",
|
|
1619
|
+
"local_model",
|
|
1620
|
+
"model_file",
|
|
1621
|
+
"downloaded_at_build",
|
|
1622
|
+
"on_device",
|
|
1623
|
+
"embedded_runtime",
|
|
1624
|
+
)
|
|
1625
|
+
)
|
|
1626
|
+
and not dsn_env
|
|
1627
|
+
and not config_key
|
|
1628
|
+
and not password_from
|
|
1629
|
+
and not getattr(reached, "port", None)
|
|
1630
|
+
and not str(getattr(reached, "database", "") or "").strip()
|
|
1631
|
+
)
|
|
1632
|
+
if (
|
|
1633
|
+
not engine
|
|
1634
|
+
and declared_engine
|
|
1635
|
+
and not external_provider
|
|
1636
|
+
and not hosted_provider
|
|
1637
|
+
and not embedded_dependency
|
|
1638
|
+
and not packaged_dependency
|
|
1639
|
+
and not local_runtime_dependency
|
|
1640
|
+
):
|
|
1641
|
+
unsupported_declared = True
|
|
1642
|
+
if engine and engine not in {name for name, _ in requested}:
|
|
1643
|
+
requested.append((engine, dependency))
|
|
1644
|
+
# A standalone Dockerfile needs no service adapter. A Dockerfile whose contract names a
|
|
1645
|
+
# service we cannot supply is different: do not silently omit that dependency and pretend
|
|
1646
|
+
# the environment is complete.
|
|
1647
|
+
if unsupported_declared:
|
|
1648
|
+
return None
|
|
1649
|
+
runtime = getattr(contract, "runtime", None)
|
|
1650
|
+
dockerfile_value = str(getattr(runtime, "dockerfile", "") or "Dockerfile")
|
|
1651
|
+
dockerfile = source / dockerfile_value
|
|
1652
|
+
if generated_runtime is None and not dockerfile.is_file():
|
|
1653
|
+
if not requested:
|
|
1654
|
+
return None
|
|
1655
|
+
raise ProvisionError(
|
|
1656
|
+
"the agent requires "
|
|
1657
|
+
+ ", ".join(engine for engine, _ in requested)
|
|
1658
|
+
+ " but ships neither Compose nor a Dockerfile; "
|
|
1659
|
+
"the harness can provision dependencies only when it can run the submitted code"
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
init_mounts: list[str] = []
|
|
1663
|
+
schema_value = str(getattr(store, "schema_from", "") or "").strip()
|
|
1664
|
+
if schema_value:
|
|
1665
|
+
# ``schema_from`` is evidence extracted from source, not guaranteed to be a path. Models
|
|
1666
|
+
# may accurately describe an inline DDL constant here (including table names), and feeding
|
|
1667
|
+
# that prose to ``stat`` can raise ENAMETOOLONG before a submitted Dockerfile is built.
|
|
1668
|
+
# Only a real path contained by the submitted checkout is eligible for an init mount.
|
|
1669
|
+
try:
|
|
1670
|
+
schema = (source / schema_value).resolve()
|
|
1671
|
+
source_root = source.resolve()
|
|
1672
|
+
schema_is_source_path = (
|
|
1673
|
+
schema.is_relative_to(source_root) and schema.exists()
|
|
1674
|
+
)
|
|
1675
|
+
except (OSError, RuntimeError, ValueError):
|
|
1676
|
+
schema_is_source_path = False
|
|
1677
|
+
if schema_is_source_path and (
|
|
1678
|
+
schema.is_dir() or schema.suffix.lower() == ".sql"
|
|
1679
|
+
):
|
|
1680
|
+
target = (
|
|
1681
|
+
"/docker-entrypoint-initdb.d/source"
|
|
1682
|
+
if schema.is_dir()
|
|
1683
|
+
else "/docker-entrypoint-initdb.d/001-source.sql"
|
|
1684
|
+
)
|
|
1685
|
+
init_mounts.append(f"{schema}:{target}:ro")
|
|
1686
|
+
|
|
1687
|
+
database = str(getattr(store, "database", "") or "harness")
|
|
1688
|
+
user = str(getattr(store, "user", "") or "harness")
|
|
1689
|
+
services: dict[str, Any] = {}
|
|
1690
|
+
runtime_environment: dict[str, str] = {}
|
|
1691
|
+
depends_on: dict[str, Any] = {}
|
|
1692
|
+
for engine, declared_dependency in requested:
|
|
1693
|
+
version = str(
|
|
1694
|
+
getattr(declared_dependency, "version", "")
|
|
1695
|
+
or (getattr(store, "version", "") if engine == primary_engine else "")
|
|
1696
|
+
or ""
|
|
1697
|
+
)
|
|
1698
|
+
service, internal_dsn, default_variable = _managed_service(
|
|
1699
|
+
engine,
|
|
1700
|
+
version=version,
|
|
1701
|
+
database=database,
|
|
1702
|
+
user=user,
|
|
1703
|
+
init_mounts=init_mounts if engine == primary_engine else [],
|
|
1704
|
+
)
|
|
1705
|
+
variable = ""
|
|
1706
|
+
if engine == primary_engine:
|
|
1707
|
+
variable = _configuration_name(
|
|
1708
|
+
str(getattr(store, "config_key", "") or "")
|
|
1709
|
+
or str(getattr(store, "configured_by", "") or "")
|
|
1710
|
+
)
|
|
1711
|
+
if declared_dependency is not None:
|
|
1712
|
+
reached = getattr(declared_dependency, "reached", None)
|
|
1713
|
+
variable = variable or _configuration_name(
|
|
1714
|
+
str(getattr(reached, "dsn_env", "") or "")
|
|
1715
|
+
or str(getattr(reached, "config_key", "") or "")
|
|
1716
|
+
)
|
|
1717
|
+
runtime_environment[variable or default_variable] = internal_dsn
|
|
1718
|
+
if engine == "minio":
|
|
1719
|
+
# These are harness-owned, run-local credentials for the isolated MinIO service,
|
|
1720
|
+
# not customer credentials. They never enter HarnessJob or SecretRef payloads.
|
|
1721
|
+
runtime_environment.update(
|
|
1722
|
+
{
|
|
1723
|
+
"AWS_ACCESS_KEY_ID": "harness",
|
|
1724
|
+
"AWS_SECRET_ACCESS_KEY": "harness-local-secret",
|
|
1725
|
+
"AWS_DEFAULT_REGION": "us-east-1",
|
|
1726
|
+
}
|
|
1727
|
+
)
|
|
1728
|
+
services[engine] = service
|
|
1729
|
+
depends_on[engine] = {"condition": "service_healthy"}
|
|
1730
|
+
platform_value = str(getattr(runtime, "platform", "") or "")
|
|
1731
|
+
if not platform_value:
|
|
1732
|
+
declared_platforms: set[str] = set()
|
|
1733
|
+
for name in COMPOSE_FILES:
|
|
1734
|
+
compose = source / name
|
|
1735
|
+
if not compose.is_file():
|
|
1736
|
+
continue
|
|
1737
|
+
declared_platforms.update(
|
|
1738
|
+
match.group(1)
|
|
1739
|
+
for match in re.finditer(
|
|
1740
|
+
r"(?m)^\s*platform\s*:\s*['\"]?([^'\"\s#]+)",
|
|
1741
|
+
compose.read_text(encoding="utf-8", errors="replace"),
|
|
1742
|
+
)
|
|
1743
|
+
)
|
|
1744
|
+
if len(declared_platforms) == 1:
|
|
1745
|
+
platform_value = declared_platforms.pop()
|
|
1746
|
+
build_context = str(source)
|
|
1747
|
+
if generated_runtime is not None:
|
|
1748
|
+
build_context = generated_runtime.context_directory
|
|
1749
|
+
dockerfile_value = GENERATED_DOCKERFILE
|
|
1750
|
+
runtime_service: dict[str, Any] = {
|
|
1751
|
+
"build": {"context": build_context, "dockerfile": dockerfile_value},
|
|
1752
|
+
"profiles": ["harness-runtime"],
|
|
1753
|
+
"environment": runtime_environment,
|
|
1754
|
+
"depends_on": depends_on,
|
|
1755
|
+
}
|
|
1756
|
+
if generated_runtime is not None:
|
|
1757
|
+
runtime_service["command"] = list(generated_runtime.command)
|
|
1758
|
+
if platform_value:
|
|
1759
|
+
runtime_service["platform"] = platform_value
|
|
1760
|
+
services["agent-runtime"] = runtime_service
|
|
1761
|
+
document = {"services": services}
|
|
1762
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
1763
|
+
target = destination / "managed-compose.json"
|
|
1764
|
+
target.write_text(json.dumps(document, indent=2), encoding="utf-8")
|
|
1765
|
+
return target
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def provision(
|
|
1769
|
+
source: str | Path,
|
|
1770
|
+
destination: str | Path,
|
|
1771
|
+
contract: Any | None = None,
|
|
1772
|
+
) -> ProvisionedEnvironment:
|
|
1773
|
+
"""Start and record the environment described by one source repository."""
|
|
1774
|
+
source_root = Path(source).expanduser().resolve()
|
|
1775
|
+
destination = Path(destination)
|
|
1776
|
+
packaging = inspect_packaging(source_root)
|
|
1777
|
+
generated_runtime: GeneratedRuntimePlan | None = None
|
|
1778
|
+
explicit_compose = str(
|
|
1779
|
+
getattr(getattr(contract, "runtime", None), "compose_file", "") or ""
|
|
1780
|
+
)
|
|
1781
|
+
explicit_dockerfile = str(
|
|
1782
|
+
getattr(getattr(contract, "runtime", None), "dockerfile", "") or ""
|
|
1783
|
+
)
|
|
1784
|
+
# A Compose service commonly builds from the repository's Dockerfile. When understanding
|
|
1785
|
+
# records both, Compose remains the authoritative full-stack runtime; the Dockerfile is not a
|
|
1786
|
+
# competing selection. Ambiguous repositories are rejected earlier by packaging admission.
|
|
1787
|
+
if explicit_compose:
|
|
1788
|
+
selected_compose = (source_root / explicit_compose).resolve()
|
|
1789
|
+
try:
|
|
1790
|
+
selected_compose.relative_to(source_root)
|
|
1791
|
+
except ValueError as exc:
|
|
1792
|
+
raise ProvisionError(
|
|
1793
|
+
"runtime Compose file escapes the submitted repository"
|
|
1794
|
+
) from exc
|
|
1795
|
+
if not selected_compose.is_file():
|
|
1796
|
+
raise ProvisionError(
|
|
1797
|
+
f"runtime Compose file does not exist: {explicit_compose}"
|
|
1798
|
+
)
|
|
1799
|
+
explicit_candidate = next(
|
|
1800
|
+
(
|
|
1801
|
+
item
|
|
1802
|
+
for item in packaging.candidates
|
|
1803
|
+
if item.kind is PackagingKind.COMPOSE
|
|
1804
|
+
and item.path == Path(explicit_compose).as_posix()
|
|
1805
|
+
),
|
|
1806
|
+
None,
|
|
1807
|
+
)
|
|
1808
|
+
if explicit_candidate is None:
|
|
1809
|
+
raise ProvisionError(
|
|
1810
|
+
"runtime Compose file is not a supported submitted Compose file: "
|
|
1811
|
+
f"{explicit_compose}"
|
|
1812
|
+
)
|
|
1813
|
+
blocking = [
|
|
1814
|
+
finding.message
|
|
1815
|
+
for finding in explicit_candidate.findings
|
|
1816
|
+
if finding.blocking
|
|
1817
|
+
and not (
|
|
1818
|
+
finding.code == "compose_env_file_missing"
|
|
1819
|
+
# The explicit runtime came from the saved contract. ALK renders a private
|
|
1820
|
+
# override below that removes the missing env_file, while start_runtime injects
|
|
1821
|
+
# the resolved job-scoped values. Requiring a worker-only process variable here
|
|
1822
|
+
# made the same bundle resumable from hosted execution but not from the CLI.
|
|
1823
|
+
and contract is not None
|
|
1824
|
+
)
|
|
1825
|
+
]
|
|
1826
|
+
if blocking:
|
|
1827
|
+
raise ProvisionError("packaging preflight failed: " + "; ".join(blocking))
|
|
1828
|
+
compose = selected_compose
|
|
1829
|
+
elif explicit_dockerfile:
|
|
1830
|
+
selected_dockerfile = (source_root / explicit_dockerfile).resolve()
|
|
1831
|
+
try:
|
|
1832
|
+
selected_dockerfile.relative_to(source_root)
|
|
1833
|
+
except ValueError as exc:
|
|
1834
|
+
raise ProvisionError(
|
|
1835
|
+
"runtime Dockerfile escapes the submitted repository"
|
|
1836
|
+
) from exc
|
|
1837
|
+
if not selected_dockerfile.is_file():
|
|
1838
|
+
raise ProvisionError(
|
|
1839
|
+
f"runtime Dockerfile does not exist: {explicit_dockerfile}"
|
|
1840
|
+
)
|
|
1841
|
+
explicit_candidate = next(
|
|
1842
|
+
(
|
|
1843
|
+
item
|
|
1844
|
+
for item in packaging.candidates
|
|
1845
|
+
if item.kind is PackagingKind.DOCKERFILE
|
|
1846
|
+
and item.path == Path(explicit_dockerfile).as_posix()
|
|
1847
|
+
),
|
|
1848
|
+
None,
|
|
1849
|
+
)
|
|
1850
|
+
blocking = [
|
|
1851
|
+
finding.message
|
|
1852
|
+
for finding in (explicit_candidate.findings if explicit_candidate else [])
|
|
1853
|
+
if finding.blocking
|
|
1854
|
+
]
|
|
1855
|
+
if blocking:
|
|
1856
|
+
raise ProvisionError("packaging preflight failed: " + "; ".join(blocking))
|
|
1857
|
+
compose = None
|
|
1858
|
+
elif packaging.ready and packaging.selected_kind is PackagingKind.COMPOSE:
|
|
1859
|
+
assert packaging.selected_path is not None
|
|
1860
|
+
compose = source_root / packaging.selected_path
|
|
1861
|
+
elif packaging.ready and packaging.selected_kind is PackagingKind.DOCKERFILE:
|
|
1862
|
+
compose = None
|
|
1863
|
+
elif packaging.candidates:
|
|
1864
|
+
details = list(packaging.notes)
|
|
1865
|
+
details.extend(
|
|
1866
|
+
finding.message
|
|
1867
|
+
for candidate in packaging.candidates
|
|
1868
|
+
for finding in candidate.findings
|
|
1869
|
+
if finding.blocking
|
|
1870
|
+
)
|
|
1871
|
+
raise ProvisionError("packaging preflight failed: " + "; ".join(details))
|
|
1872
|
+
else:
|
|
1873
|
+
compose = None
|
|
1874
|
+
managed = False
|
|
1875
|
+
if compose is None and contract is not None:
|
|
1876
|
+
if not packaging.candidates and not (source_root / "Dockerfile").is_file():
|
|
1877
|
+
try:
|
|
1878
|
+
generated_runtime = prepare_generated_runtime(
|
|
1879
|
+
source_root,
|
|
1880
|
+
destination,
|
|
1881
|
+
getattr(contract, "runtime", None),
|
|
1882
|
+
)
|
|
1883
|
+
except GeneratedRuntimeError as exc:
|
|
1884
|
+
dependencies = list(getattr(contract, "dependencies", None) or [])
|
|
1885
|
+
required = [
|
|
1886
|
+
str(getattr(item, "engine", "") or getattr(item, "name", ""))
|
|
1887
|
+
for item in dependencies
|
|
1888
|
+
]
|
|
1889
|
+
prefix = (
|
|
1890
|
+
"the agent requires "
|
|
1891
|
+
+ ", ".join(item for item in required if item)
|
|
1892
|
+
+ " but ships neither Compose nor a Dockerfile; "
|
|
1893
|
+
if required
|
|
1894
|
+
else ""
|
|
1895
|
+
)
|
|
1896
|
+
raise ProvisionError(prefix + str(exc)) from exc
|
|
1897
|
+
compose = _managed_compose(
|
|
1898
|
+
source_root, destination, contract, generated_runtime=generated_runtime
|
|
1899
|
+
)
|
|
1900
|
+
managed = compose is not None
|
|
1901
|
+
if compose is None:
|
|
1902
|
+
raise ProvisionError(
|
|
1903
|
+
f"{source_root} does not ship a Compose file; a non-Compose runtime adapter is required"
|
|
1904
|
+
)
|
|
1905
|
+
fingerprint = source_fingerprint(source_root)
|
|
1906
|
+
runtime_fingerprint = generated_runtime.fingerprint if generated_runtime else ""
|
|
1907
|
+
runtime_configuration_names = sorted(
|
|
1908
|
+
{
|
|
1909
|
+
*_contract_runtime_configuration_names(contract),
|
|
1910
|
+
*_saved_runtime_configuration_names(destination),
|
|
1911
|
+
}
|
|
1912
|
+
)
|
|
1913
|
+
existing = ProvisionedEnvironment.load(destination)
|
|
1914
|
+
if (
|
|
1915
|
+
existing
|
|
1916
|
+
and Path(existing.source) == source_root
|
|
1917
|
+
and existing.source_fingerprint == fingerprint
|
|
1918
|
+
and existing.runtime_fingerprint == runtime_fingerprint
|
|
1919
|
+
and existing.running
|
|
1920
|
+
):
|
|
1921
|
+
# Verify rather than trusting a stale file left by a killed process.
|
|
1922
|
+
if not existing.services or _run(
|
|
1923
|
+
existing, "ps", "--status", "running", "--quiet", check=False, timeout=30
|
|
1924
|
+
):
|
|
1925
|
+
config = _config(existing)
|
|
1926
|
+
_validate_compose_security(config)
|
|
1927
|
+
existing.runtime_services = _runtime_services(config, contract)
|
|
1928
|
+
existing.services = _started_services(config, existing.runtime_services)
|
|
1929
|
+
existing.service_endpoints = _service_endpoints(existing, config)
|
|
1930
|
+
_wait_for_environment_endpoints(existing)
|
|
1931
|
+
existing.overrides = _overrides(existing, config)
|
|
1932
|
+
existing.internal_overrides = _internal_overrides(existing, config)
|
|
1933
|
+
existing.runtime_configuration_names = runtime_configuration_names
|
|
1934
|
+
existing.save(destination)
|
|
1935
|
+
return existing
|
|
1936
|
+
|
|
1937
|
+
# A recorded project belongs only to this session. If its source changed or its process died,
|
|
1938
|
+
# remove its test volumes before replacing it so stale rows and orphan containers cannot be
|
|
1939
|
+
# mistaken for the newly submitted environment.
|
|
1940
|
+
if existing is not None and existing.running:
|
|
1941
|
+
_detach_runner_network(existing)
|
|
1942
|
+
_run(
|
|
1943
|
+
existing,
|
|
1944
|
+
"down",
|
|
1945
|
+
"--volumes",
|
|
1946
|
+
"--remove-orphans",
|
|
1947
|
+
check=False,
|
|
1948
|
+
timeout=120,
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
project = "fagi-harness-" + secrets.token_hex(4)
|
|
1952
|
+
environment = ProvisionedEnvironment(
|
|
1953
|
+
source=str(source_root),
|
|
1954
|
+
compose_file=str(compose),
|
|
1955
|
+
project=project,
|
|
1956
|
+
port_variables=port_variables(compose),
|
|
1957
|
+
source_fingerprint=fingerprint,
|
|
1958
|
+
managed=managed,
|
|
1959
|
+
generated_runtime_plan=(
|
|
1960
|
+
str(destination / "generated-runtime.json") if generated_runtime else ""
|
|
1961
|
+
),
|
|
1962
|
+
runtime_fingerprint=runtime_fingerprint,
|
|
1963
|
+
runtime_configuration_names=runtime_configuration_names,
|
|
1964
|
+
)
|
|
1965
|
+
_write_initial_env_file_override(destination, environment)
|
|
1966
|
+
config = _config(environment)
|
|
1967
|
+
_validate_compose_security(config)
|
|
1968
|
+
_write_port_override(destination, environment, config)
|
|
1969
|
+
if environment.compose_override_file:
|
|
1970
|
+
config = _config(environment)
|
|
1971
|
+
_validate_compose_security(config)
|
|
1972
|
+
environment.runtime_services = _runtime_services(config, contract)
|
|
1973
|
+
environment.services = _started_services(config, environment.runtime_services)
|
|
1974
|
+
if not environment.services and len(environment.runtime_services) != 1:
|
|
1975
|
+
raise ProvisionError(
|
|
1976
|
+
"the Compose file has neither default infrastructure services nor exactly one "
|
|
1977
|
+
"opt-in agent runtime"
|
|
1978
|
+
)
|
|
1979
|
+
try:
|
|
1980
|
+
started = time.monotonic()
|
|
1981
|
+
# Build the submitted runtime during provisioning so packaging failures surface before a
|
|
1982
|
+
# simulation is accepted. A Dockerfile-only agent may legitimately have no infrastructure
|
|
1983
|
+
# containers to start; its environment is the isolated, validated, built runtime itself.
|
|
1984
|
+
if environment.runtime_services:
|
|
1985
|
+
_build_runtime(environment)
|
|
1986
|
+
if environment.services:
|
|
1987
|
+
_start_managed_services(environment, environment.services)
|
|
1988
|
+
environment.service_endpoints = _service_endpoints(environment, config)
|
|
1989
|
+
try:
|
|
1990
|
+
_wait_for_environment_endpoints(environment)
|
|
1991
|
+
except ProvisionError as exc:
|
|
1992
|
+
# A submitted stack can be healthy internally while Docker Desktop/the hosted
|
|
1993
|
+
# provider has not made its newly published ports reachable from the runner yet.
|
|
1994
|
+
# Recreate this run's services and volumes once. This is infrastructure recovery,
|
|
1995
|
+
# before any scenario executes; it neither retries nor hides an agent outcome.
|
|
1996
|
+
if not environment.services or not str(exc).startswith(
|
|
1997
|
+
"environment endpoints did not become ready:"
|
|
1998
|
+
):
|
|
1999
|
+
raise
|
|
2000
|
+
_detach_runner_network(environment)
|
|
2001
|
+
_run(
|
|
2002
|
+
environment,
|
|
2003
|
+
"down",
|
|
2004
|
+
"--volumes",
|
|
2005
|
+
"--remove-orphans",
|
|
2006
|
+
check=False,
|
|
2007
|
+
timeout=120,
|
|
2008
|
+
)
|
|
2009
|
+
# Reusing the same published port cannot recover a Docker/host forwarding failure.
|
|
2010
|
+
# Re-render both interpolated and fixed port mappings before the second boot.
|
|
2011
|
+
config = _refresh_compose_ports(destination, environment)
|
|
2012
|
+
environment.runtime_services = _runtime_services(config, contract)
|
|
2013
|
+
environment.services = _started_services(
|
|
2014
|
+
config, environment.runtime_services
|
|
2015
|
+
)
|
|
2016
|
+
_start_managed_services(environment, environment.services, build=False)
|
|
2017
|
+
config = _config(environment)
|
|
2018
|
+
environment.runtime_services = _runtime_services(config, contract)
|
|
2019
|
+
environment.services = _started_services(
|
|
2020
|
+
config, environment.runtime_services
|
|
2021
|
+
)
|
|
2022
|
+
environment.service_endpoints = _service_endpoints(environment, config)
|
|
2023
|
+
_wait_for_environment_endpoints(environment)
|
|
2024
|
+
environment.overrides = _overrides(environment, config)
|
|
2025
|
+
environment.internal_overrides = _internal_overrides(environment, config)
|
|
2026
|
+
environment.running = True
|
|
2027
|
+
environment.provision_seconds = round(time.monotonic() - started, 3)
|
|
2028
|
+
environment.save(destination)
|
|
2029
|
+
return environment
|
|
2030
|
+
except Exception:
|
|
2031
|
+
_detach_runner_network(environment)
|
|
2032
|
+
_run(
|
|
2033
|
+
environment,
|
|
2034
|
+
"down",
|
|
2035
|
+
"--volumes",
|
|
2036
|
+
"--remove-orphans",
|
|
2037
|
+
check=False,
|
|
2038
|
+
timeout=120,
|
|
2039
|
+
)
|
|
2040
|
+
raise
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def provision_if_present(
|
|
2044
|
+
source: str | Path, destination: str | Path, contract: Any | None = None
|
|
2045
|
+
) -> ProvisionedEnvironment | None:
|
|
2046
|
+
"""Provision a repository's Compose environment, or do nothing for in-process agents."""
|
|
2047
|
+
if compose_file(source) is None and contract is None:
|
|
2048
|
+
return None
|
|
2049
|
+
if compose_file(source) is None:
|
|
2050
|
+
store = getattr(contract, "data_store", None)
|
|
2051
|
+
dependencies = list(getattr(contract, "dependencies", None) or [])
|
|
2052
|
+
runtime = getattr(contract, "runtime", None)
|
|
2053
|
+
compose_value = str(getattr(runtime, "compose_file", "") or "")
|
|
2054
|
+
dockerfile_value = str(getattr(runtime, "dockerfile", "") or "Dockerfile")
|
|
2055
|
+
source_root = Path(source).expanduser().resolve()
|
|
2056
|
+
has_runtime = bool(compose_value) or (source_root / dockerfile_value).is_file()
|
|
2057
|
+
generated = can_generate_runtime(source, runtime)
|
|
2058
|
+
if store is None and not dependencies and not has_runtime and not generated:
|
|
2059
|
+
return None
|
|
2060
|
+
return provision(source, destination, contract)
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def reset(destination: str | Path) -> ProvisionedEnvironment:
|
|
2064
|
+
"""Return a provisioned environment to the repository's declared seed state.
|
|
2065
|
+
|
|
2066
|
+
This generic reset is deliberately lifecycle-based: removing only this Compose project's
|
|
2067
|
+
volumes and recreating its already-built default services works for databases, queues and
|
|
2068
|
+
filesystems without guessing their internal reset protocol. Engine-specific snapshot
|
|
2069
|
+
adapters may optimise this later, but they must preserve the same observable result.
|
|
2070
|
+
"""
|
|
2071
|
+
destination = Path(destination)
|
|
2072
|
+
environment = ProvisionedEnvironment.load(destination)
|
|
2073
|
+
if environment is None:
|
|
2074
|
+
raise ProvisionError(f"no environment recorded at {destination}")
|
|
2075
|
+
if environment.runtime_container:
|
|
2076
|
+
stop_runtime(destination)
|
|
2077
|
+
environment = ProvisionedEnvironment.load(destination) or environment
|
|
2078
|
+
_detach_runner_network(environment)
|
|
2079
|
+
_run(
|
|
2080
|
+
environment,
|
|
2081
|
+
"down",
|
|
2082
|
+
"--volumes",
|
|
2083
|
+
"--remove-orphans",
|
|
2084
|
+
check=False,
|
|
2085
|
+
timeout=120,
|
|
2086
|
+
)
|
|
2087
|
+
started = time.monotonic()
|
|
2088
|
+
if environment.services:
|
|
2089
|
+
_start_managed_services(environment, environment.services, build=False)
|
|
2090
|
+
config = _config(environment)
|
|
2091
|
+
_validate_compose_security(config)
|
|
2092
|
+
recorded_runtime = [
|
|
2093
|
+
name for name in environment.runtime_services if name in config["services"]
|
|
2094
|
+
]
|
|
2095
|
+
environment.runtime_services = recorded_runtime or _runtime_services(config)
|
|
2096
|
+
environment.services = _started_services(config, environment.runtime_services)
|
|
2097
|
+
environment.service_endpoints = _service_endpoints(environment, config)
|
|
2098
|
+
_wait_for_environment_endpoints(environment)
|
|
2099
|
+
environment.overrides = _overrides(environment, config)
|
|
2100
|
+
environment.internal_overrides = _internal_overrides(environment, config)
|
|
2101
|
+
environment.running = True
|
|
2102
|
+
environment.provision_seconds = round(time.monotonic() - started, 3)
|
|
2103
|
+
environment.save(destination)
|
|
2104
|
+
return environment
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def healthy(destination: str | Path) -> bool:
|
|
2108
|
+
"""Whether the recorded project still has all of its expected services running.
|
|
2109
|
+
|
|
2110
|
+
Runtime providers use this public probe rather than reaching into Compose mechanics. A
|
|
2111
|
+
stale manifest therefore becomes an unhealthy runtime instead of being trusted as ready.
|
|
2112
|
+
"""
|
|
2113
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
2114
|
+
if environment is None or not environment.running:
|
|
2115
|
+
return False
|
|
2116
|
+
if not environment.services:
|
|
2117
|
+
if not environment.runtime_services:
|
|
2118
|
+
return False
|
|
2119
|
+
if not environment.runtime_container:
|
|
2120
|
+
return True
|
|
2121
|
+
return bool(
|
|
2122
|
+
_docker(
|
|
2123
|
+
"inspect",
|
|
2124
|
+
"--format",
|
|
2125
|
+
"{{.State.Running}}",
|
|
2126
|
+
environment.runtime_container,
|
|
2127
|
+
check=False,
|
|
2128
|
+
timeout=30,
|
|
2129
|
+
).strip()
|
|
2130
|
+
== "true"
|
|
2131
|
+
)
|
|
2132
|
+
running = _run(
|
|
2133
|
+
environment,
|
|
2134
|
+
"ps",
|
|
2135
|
+
"--status",
|
|
2136
|
+
"running",
|
|
2137
|
+
"--services",
|
|
2138
|
+
check=False,
|
|
2139
|
+
timeout=30,
|
|
2140
|
+
)
|
|
2141
|
+
found = {line.strip() for line in running.splitlines() if line.strip()}
|
|
2142
|
+
if not set(environment.services).issubset(found):
|
|
2143
|
+
return False
|
|
2144
|
+
try:
|
|
2145
|
+
# Provision/reset require a stability window; this is a point-in-time liveness probe.
|
|
2146
|
+
_wait_for_environment_endpoints(environment, 1.0, 0.0)
|
|
2147
|
+
except ProvisionError:
|
|
2148
|
+
return False
|
|
2149
|
+
return True
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
def _docker(*arguments: str, check: bool = True, timeout: int = 120) -> str:
|
|
2153
|
+
try:
|
|
2154
|
+
completed = subprocess.run(
|
|
2155
|
+
["docker", *arguments],
|
|
2156
|
+
capture_output=True,
|
|
2157
|
+
text=True,
|
|
2158
|
+
check=False,
|
|
2159
|
+
timeout=timeout,
|
|
2160
|
+
)
|
|
2161
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
2162
|
+
raise ProvisionError(
|
|
2163
|
+
f"docker {' '.join(arguments)} could not run: {exc}"
|
|
2164
|
+
) from exc
|
|
2165
|
+
output = ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
2166
|
+
if check and completed.returncode:
|
|
2167
|
+
raise ProvisionError(
|
|
2168
|
+
f"docker {' '.join(arguments)} failed ({completed.returncode}): "
|
|
2169
|
+
f"{output or 'no output'}"
|
|
2170
|
+
)
|
|
2171
|
+
return output
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def _remove_unused_harness_networks(*, exclude_project: str = "") -> int:
|
|
2175
|
+
"""Remove empty networks owned by terminated ALK Compose projects."""
|
|
2176
|
+
listing = _docker(
|
|
2177
|
+
"network",
|
|
2178
|
+
"ls",
|
|
2179
|
+
"--filter",
|
|
2180
|
+
"label=com.docker.compose.project",
|
|
2181
|
+
"--format",
|
|
2182
|
+
"{{.Name}}",
|
|
2183
|
+
check=False,
|
|
2184
|
+
timeout=30,
|
|
2185
|
+
)
|
|
2186
|
+
removed = 0
|
|
2187
|
+
for name in sorted(set(listing.splitlines())):
|
|
2188
|
+
if not name.startswith("fagi-harness-") or name == f"{exclude_project}_default":
|
|
2189
|
+
continue
|
|
2190
|
+
containers = _docker(
|
|
2191
|
+
"network",
|
|
2192
|
+
"inspect",
|
|
2193
|
+
"--format",
|
|
2194
|
+
"{{json .Containers}}",
|
|
2195
|
+
name,
|
|
2196
|
+
check=False,
|
|
2197
|
+
timeout=30,
|
|
2198
|
+
).strip()
|
|
2199
|
+
try:
|
|
2200
|
+
attached = json.loads(containers or "null")
|
|
2201
|
+
except json.JSONDecodeError:
|
|
2202
|
+
continue
|
|
2203
|
+
if attached != {}:
|
|
2204
|
+
continue
|
|
2205
|
+
result = _docker("network", "rm", name, check=False, timeout=30)
|
|
2206
|
+
if "error" not in result.lower():
|
|
2207
|
+
removed += 1
|
|
2208
|
+
return removed
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
def _remove_empty_project_default_network(project: str) -> bool:
|
|
2212
|
+
"""Remove only this run's empty Compose default network after teardown.
|
|
2213
|
+
|
|
2214
|
+
Docker Compose can return successfully from ``down`` while leaving its default
|
|
2215
|
+
network behind when the containerized runner was detached immediately before
|
|
2216
|
+
teardown. Repeated hosted runs then exhaust Docker's address pools. The network
|
|
2217
|
+
name is deterministic and ALK-owned; still require zero attachments before
|
|
2218
|
+
removing it, so cleanup can never disconnect another process.
|
|
2219
|
+
"""
|
|
2220
|
+
if not project.startswith("fagi-harness-"):
|
|
2221
|
+
return False
|
|
2222
|
+
name = f"{project}_default"
|
|
2223
|
+
containers = _docker(
|
|
2224
|
+
"network",
|
|
2225
|
+
"inspect",
|
|
2226
|
+
"--format",
|
|
2227
|
+
"{{json .Containers}}",
|
|
2228
|
+
name,
|
|
2229
|
+
check=False,
|
|
2230
|
+
timeout=30,
|
|
2231
|
+
).strip()
|
|
2232
|
+
try:
|
|
2233
|
+
attached = json.loads(containers or "null")
|
|
2234
|
+
except json.JSONDecodeError:
|
|
2235
|
+
return False
|
|
2236
|
+
if attached != {}:
|
|
2237
|
+
return False
|
|
2238
|
+
result = _docker("network", "rm", name, check=False, timeout=30)
|
|
2239
|
+
return "error" not in result.lower()
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
def _valid_google_credentials(path: Path) -> bool:
|
|
2243
|
+
"""Google's SDK accepts a path long before it discovers malformed JSON.
|
|
2244
|
+
|
|
2245
|
+
A Compose placeholder such as ``/dev/null`` therefore looks configured during discovery but
|
|
2246
|
+
fails only after a paid voice call has started. Validate the actual host file before mounting
|
|
2247
|
+
it into a submitted worker.
|
|
2248
|
+
"""
|
|
2249
|
+
if not path.is_file():
|
|
2250
|
+
return False
|
|
2251
|
+
try:
|
|
2252
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
2253
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
2254
|
+
return False
|
|
2255
|
+
return isinstance(value, dict) and bool(value)
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _runtime_credential_mounts(
|
|
2259
|
+
service_environment: dict[str, str], service_config: dict[str, Any]
|
|
2260
|
+
) -> list[tuple[str, Path, str]]:
|
|
2261
|
+
"""Resolve credential variables to real host files and their container targets.
|
|
2262
|
+
|
|
2263
|
+
Repository Compose files commonly expose a stable in-container credential path backed by a
|
|
2264
|
+
harmless placeholder when run without platform secrets. The harness replaces that placeholder
|
|
2265
|
+
with its platform-owned credential; repository users should not have to copy secrets into the
|
|
2266
|
+
submitted source tree.
|
|
2267
|
+
"""
|
|
2268
|
+
declared_volumes = {
|
|
2269
|
+
str(volume.get("target", "")): Path(str(volume.get("source", ""))).expanduser()
|
|
2270
|
+
for volume in service_config.get("volumes", [])
|
|
2271
|
+
if isinstance(volume, dict) and volume.get("target") and volume.get("source")
|
|
2272
|
+
}
|
|
2273
|
+
mounts: list[tuple[str, Path, str]] = []
|
|
2274
|
+
hosted_execution = os.environ.get("ALK_HOSTED_EXECUTION", "").strip() == "1"
|
|
2275
|
+
approved_secret_files = {
|
|
2276
|
+
name.strip()
|
|
2277
|
+
for name in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
|
|
2278
|
+
if name.strip()
|
|
2279
|
+
}
|
|
2280
|
+
for name, value in service_environment.items():
|
|
2281
|
+
if not any(marker in name.upper() for marker in ("CREDENTIAL", "KEY_FILE")):
|
|
2282
|
+
continue
|
|
2283
|
+
configured = Path(value).expanduser()
|
|
2284
|
+
target = f"/run/harness-secrets/{configured.name}"
|
|
2285
|
+
source = (
|
|
2286
|
+
configured if configured.is_absolute() and configured.is_file() else None
|
|
2287
|
+
)
|
|
2288
|
+
if hosted_execution and name not in approved_secret_files:
|
|
2289
|
+
# A raw path supplied by an untrusted hosted job must never become a host bind mount.
|
|
2290
|
+
# Only the provider's opaque secret-file upload flow may authorize file mounts.
|
|
2291
|
+
source = None
|
|
2292
|
+
if configured.is_absolute() and value in declared_volumes:
|
|
2293
|
+
target = value
|
|
2294
|
+
declared = declared_volumes[value]
|
|
2295
|
+
source = declared if declared.is_file() else None
|
|
2296
|
+
if name.upper() == "GOOGLE_APPLICATION_CREDENTIALS":
|
|
2297
|
+
if source is None or not _valid_google_credentials(source):
|
|
2298
|
+
runtime_value = runtime_configuration_value(name).strip()
|
|
2299
|
+
runtime = Path(runtime_value).expanduser() if runtime_value else None
|
|
2300
|
+
if (
|
|
2301
|
+
runtime is not None
|
|
2302
|
+
and _valid_google_credentials(runtime)
|
|
2303
|
+
and (not hosted_execution or name in approved_secret_files)
|
|
2304
|
+
):
|
|
2305
|
+
source = runtime
|
|
2306
|
+
# Do not reuse a repository-owned destination when its declared mount is a
|
|
2307
|
+
# placeholder (commonly /dev/null). Compose may retain that service mount
|
|
2308
|
+
# even when ``docker compose run --volume`` supplies a replacement, causing
|
|
2309
|
+
# the worker to see the placeholder. Platform credentials always get an
|
|
2310
|
+
# ALK-owned destination that cannot collide with submitted mounts.
|
|
2311
|
+
target = f"/run/harness-secrets/{runtime.name}"
|
|
2312
|
+
if source is None or not _valid_google_credentials(source):
|
|
2313
|
+
raise ProvisionError(
|
|
2314
|
+
"the submitted runtime needs GOOGLE_APPLICATION_CREDENTIALS, but no valid "
|
|
2315
|
+
"JSON credential file was mounted for this job; upload the service-account JSON "
|
|
2316
|
+
"through the credential-file control"
|
|
2317
|
+
)
|
|
2318
|
+
if source is not None:
|
|
2319
|
+
mounts.append((name, source.resolve(), target))
|
|
2320
|
+
return mounts
|
|
2321
|
+
|
|
2322
|
+
|
|
2323
|
+
def start_runtime(
|
|
2324
|
+
destination: str | Path,
|
|
2325
|
+
*,
|
|
2326
|
+
overrides: dict[str, str] | None = None,
|
|
2327
|
+
trace_path: str | Path | None = None,
|
|
2328
|
+
publish_ports: list[int] | None = None,
|
|
2329
|
+
stable_seconds: float | None = None,
|
|
2330
|
+
) -> ProvisionedEnvironment:
|
|
2331
|
+
"""Start the submitted agent/worker service with only test endpoint substitutions."""
|
|
2332
|
+
destination = Path(destination)
|
|
2333
|
+
environment = ProvisionedEnvironment.load(destination)
|
|
2334
|
+
if environment is None or not environment.running:
|
|
2335
|
+
raise ProvisionError(f"no running environment recorded at {destination}")
|
|
2336
|
+
if len(environment.runtime_services) != 1:
|
|
2337
|
+
raise ProvisionError(
|
|
2338
|
+
"expected exactly one opt-in agent runtime service, found "
|
|
2339
|
+
+ (", ".join(environment.runtime_services) or "none")
|
|
2340
|
+
)
|
|
2341
|
+
service = environment.runtime_services[0]
|
|
2342
|
+
container = f"{environment.project}-runtime"
|
|
2343
|
+
# The manifest write happens after readiness. A killed harness can therefore leave the
|
|
2344
|
+
# session-owned container behind without recording it; always reconcile the deterministic
|
|
2345
|
+
# name before starting instead of trusting bookkeeping from a process that may have died.
|
|
2346
|
+
_docker("rm", "--force", container, check=False)
|
|
2347
|
+
config = _config(environment)
|
|
2348
|
+
service_config = config["services"][service]
|
|
2349
|
+
service_environment = _environment_values(service_config)
|
|
2350
|
+
endpoint_overrides = environment.internal_overrides
|
|
2351
|
+
# Endpoints generated for this isolated environment are authoritative. A submitted image may
|
|
2352
|
+
# declare a development default such as ``http://harness:8787`` or ``localhost``; retaining
|
|
2353
|
+
# it because the variable is present in the service environment disconnects the unchanged
|
|
2354
|
+
# agent from the services ALK just provisioned. ``internal_overrides`` already preserves
|
|
2355
|
+
# complete managed DSNs (including generated credentials), so it is both safer and more
|
|
2356
|
+
# accurate than any submitted default.
|
|
2357
|
+
injected = {
|
|
2358
|
+
**endpoint_overrides,
|
|
2359
|
+
"HARNESS_MODE": "1",
|
|
2360
|
+
# The SDK recorder is also a remote LiveKit participant. Agents that
|
|
2361
|
+
# wait for an arbitrary participant can otherwise bind their audio
|
|
2362
|
+
# input to the silent recorder and stall after one turn. This stable
|
|
2363
|
+
# prefix identifies the actual simulated caller across scenarios.
|
|
2364
|
+
"HARNESS_CALLER_IDENTITY_PREFIX": "fagi-simulator",
|
|
2365
|
+
**(overrides or {}),
|
|
2366
|
+
}
|
|
2367
|
+
for name in environment.runtime_configuration_names:
|
|
2368
|
+
value = runtime_configuration_value(name).strip()
|
|
2369
|
+
if value:
|
|
2370
|
+
if name == "LIVEKIT_URL":
|
|
2371
|
+
parsed = urlsplit(value)
|
|
2372
|
+
if (parsed.hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}:
|
|
2373
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
2374
|
+
value = urlunsplit(
|
|
2375
|
+
(
|
|
2376
|
+
parsed.scheme,
|
|
2377
|
+
f"host.docker.internal{port}",
|
|
2378
|
+
parsed.path,
|
|
2379
|
+
parsed.query,
|
|
2380
|
+
parsed.fragment,
|
|
2381
|
+
)
|
|
2382
|
+
)
|
|
2383
|
+
injected.setdefault(name, value)
|
|
2384
|
+
# LiveKit's production worker defaults to INFO, while its framework-owned tool lifecycle
|
|
2385
|
+
# events are DEBUG. Those events are the only generic evidence available when a submitted
|
|
2386
|
+
# agent executes tools entirely in-process and does not implement the optional trace seam.
|
|
2387
|
+
# This changes logging only, never agent behavior, and raw logs are not retained.
|
|
2388
|
+
injected.setdefault(
|
|
2389
|
+
"LIVEKIT_LOG_LEVEL",
|
|
2390
|
+
os.environ.get("HARNESS_RUNTIME_LOG_LEVEL", "DEBUG").strip() or "DEBUG",
|
|
2391
|
+
)
|
|
2392
|
+
arguments = [
|
|
2393
|
+
"run",
|
|
2394
|
+
"--detach",
|
|
2395
|
+
"--no-deps",
|
|
2396
|
+
"--name",
|
|
2397
|
+
container,
|
|
2398
|
+
]
|
|
2399
|
+
requested_ports = sorted({int(port) for port in publish_ports or []})
|
|
2400
|
+
runner_is_containerized = bool(os.environ.get("ALK_RUNNER_CONTAINER", "").strip())
|
|
2401
|
+
for port in requested_ports:
|
|
2402
|
+
if port < 1 or port > 65535:
|
|
2403
|
+
raise ProvisionError(f"runtime_publish_port_invalid: {port}")
|
|
2404
|
+
# Bind only loopback and let Docker allocate the host port. Fixed submitted ports must
|
|
2405
|
+
# not collide when two jobs run together. A containerized hosted runner joins the
|
|
2406
|
+
# private project network instead and never publishes the ingress on the Docker host.
|
|
2407
|
+
if not runner_is_containerized:
|
|
2408
|
+
arguments.extend(("--publish", f"127.0.0.1::{port}"))
|
|
2409
|
+
trace_volume = ""
|
|
2410
|
+
trace_destination = ""
|
|
2411
|
+
if trace_path is not None:
|
|
2412
|
+
trace = Path(trace_path).expanduser().resolve()
|
|
2413
|
+
trace.parent.mkdir(parents=True, exist_ok=True)
|
|
2414
|
+
# The submitted runtime commonly runs as a non-root UID that is unrelated to the
|
|
2415
|
+
# sandbox runner's UID. Mounting a root-owned result directory and asking that worker to
|
|
2416
|
+
# create the trace file makes otherwise-successful tools fail with PermissionError in
|
|
2417
|
+
# agents that trace synchronously. Pre-create only the job-owned file and make that file
|
|
2418
|
+
# writable across the container boundary; the surrounding artifact tree stays private.
|
|
2419
|
+
trace.touch(exist_ok=True)
|
|
2420
|
+
trace.chmod(0o666)
|
|
2421
|
+
# Mount only this scenario's result folder. Agents that support the generic harness trace
|
|
2422
|
+
# seam write semantic/model-facing tool events here; agents that do not simply ignore the
|
|
2423
|
+
# variable and the backend proxy remains the fallback evidence source.
|
|
2424
|
+
trace_arguments, trace_volume = _runtime_trace_mount(trace)
|
|
2425
|
+
arguments.extend(trace_arguments)
|
|
2426
|
+
trace_destination = str(trace)
|
|
2427
|
+
container_trace = f"/run/harness-trace/{trace.name}"
|
|
2428
|
+
# HARNESS_AGENT_TOOL_TRACE is the runtime-level contract. Keep the shorter historical
|
|
2429
|
+
# name as a compatibility alias for submitted agents that already adopted it; both point
|
|
2430
|
+
# at the same mounted file, so evidence is still collected exactly once.
|
|
2431
|
+
injected["HARNESS_AGENT_TOOL_TRACE"] = container_trace
|
|
2432
|
+
injected["HARNESS_TOOL_TRACE"] = container_trace
|
|
2433
|
+
# Credential paths in a repository env file name host files. Mount them into a stable,
|
|
2434
|
+
# read-only container location and replace only the path value; never copy or persist the
|
|
2435
|
+
# credential contents in harness artifacts.
|
|
2436
|
+
mounted_credentials: set[str] = set()
|
|
2437
|
+
for name, source, target in _runtime_credential_mounts(
|
|
2438
|
+
service_environment, service_config
|
|
2439
|
+
):
|
|
2440
|
+
arguments.extend(("--volume", f"{source}:{target}:ro"))
|
|
2441
|
+
injected[name] = target
|
|
2442
|
+
mounted_credentials.add(name)
|
|
2443
|
+
# Dockerfile/generated runtimes may not declare credential variables in their image or
|
|
2444
|
+
# generated Compose model. Provider-authorized file references are still mounted explicitly;
|
|
2445
|
+
# discovery controls the env name while the provider controls the source path.
|
|
2446
|
+
for name in sorted(
|
|
2447
|
+
{
|
|
2448
|
+
item.strip()
|
|
2449
|
+
for item in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
|
|
2450
|
+
if item.strip()
|
|
2451
|
+
}
|
|
2452
|
+
- mounted_credentials
|
|
2453
|
+
):
|
|
2454
|
+
runtime_path = runtime_configuration_value(name).strip()
|
|
2455
|
+
source = Path(runtime_path).expanduser() if runtime_path else None
|
|
2456
|
+
valid = source is not None and source.is_file()
|
|
2457
|
+
if name == "GOOGLE_APPLICATION_CREDENTIALS":
|
|
2458
|
+
valid = bool(source is not None and _valid_google_credentials(source))
|
|
2459
|
+
if not valid or source is None:
|
|
2460
|
+
raise ProvisionError(f"job-scoped credential file unavailable: {name}")
|
|
2461
|
+
target = f"/run/harness-secrets/{name.lower()}"
|
|
2462
|
+
arguments.extend(("--volume", f"{source.resolve()}:{target}:ro"))
|
|
2463
|
+
injected[name] = target
|
|
2464
|
+
mounted_credentials.add(name)
|
|
2465
|
+
google_path = injected.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip()
|
|
2466
|
+
if google_path and "GOOGLE_APPLICATION_CREDENTIALS" not in mounted_credentials:
|
|
2467
|
+
if os.environ.get(
|
|
2468
|
+
"ALK_HOSTED_EXECUTION", ""
|
|
2469
|
+
).strip() == "1" and "GOOGLE_APPLICATION_CREDENTIALS" not in {
|
|
2470
|
+
name.strip()
|
|
2471
|
+
for name in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
|
|
2472
|
+
if name.strip()
|
|
2473
|
+
}:
|
|
2474
|
+
raise ProvisionError(
|
|
2475
|
+
"hosted credential file path rejected; upload "
|
|
2476
|
+
"GOOGLE_APPLICATION_CREDENTIALS through the credential-file control"
|
|
2477
|
+
)
|
|
2478
|
+
google_source = Path(google_path).expanduser()
|
|
2479
|
+
if not _valid_google_credentials(google_source):
|
|
2480
|
+
raise ProvisionError(
|
|
2481
|
+
"GOOGLE_APPLICATION_CREDENTIALS for the generated runtime is not a valid "
|
|
2482
|
+
"readable JSON credential file"
|
|
2483
|
+
)
|
|
2484
|
+
google_target = f"/run/harness-secrets/{google_source.name}"
|
|
2485
|
+
arguments.extend(("--volume", f"{google_source.resolve()}:{google_target}:ro"))
|
|
2486
|
+
injected["GOOGLE_APPLICATION_CREDENTIALS"] = google_target
|
|
2487
|
+
# Compose does not consistently replace a service-declared environment value when ``run -e
|
|
2488
|
+
# NAME`` only inherits it from the client process. Pin ALK-owned, non-secret routing values
|
|
2489
|
+
# explicitly; credentials and potentially credential-bearing DSNs remain env-only so they
|
|
2490
|
+
# never enter argv, logs, or exception text.
|
|
2491
|
+
explicit_non_secret = {
|
|
2492
|
+
"DEMO_CALLER_ANI",
|
|
2493
|
+
"HARNESS_AGENT_TOOL_TRACE",
|
|
2494
|
+
"HARNESS_CALLER_IDENTITY_PREFIX",
|
|
2495
|
+
"HARNESS_MODE",
|
|
2496
|
+
"HARNESS_TOOL_TRACE",
|
|
2497
|
+
"LIVEKIT_AGENT_NAME",
|
|
2498
|
+
"LIVEKIT_LOG_LEVEL",
|
|
2499
|
+
"TOOLS_API_URL",
|
|
2500
|
+
}
|
|
2501
|
+
for name in sorted(injected):
|
|
2502
|
+
value = f"{name}={injected[name]}" if name in explicit_non_secret else name
|
|
2503
|
+
arguments.extend(("--env", value))
|
|
2504
|
+
arguments.append(service)
|
|
2505
|
+
_run(environment, *arguments, timeout=900, process_overrides=injected)
|
|
2506
|
+
deadline = time.monotonic() + 60
|
|
2507
|
+
# LiveKit workers commonly stay alive while warming VAD/STT/TTS processes and only register
|
|
2508
|
+
# for dispatch afterwards. Starting a room after five seconds races that registration: the
|
|
2509
|
+
# container looks healthy, but LiveKit has nobody to dispatch to. Fifteen seconds covers the
|
|
2510
|
+
# observed plugin warm-up while remaining configurable for unusually small or large workers.
|
|
2511
|
+
stable_seconds = max(
|
|
2512
|
+
0.25,
|
|
2513
|
+
float(
|
|
2514
|
+
stable_seconds
|
|
2515
|
+
if stable_seconds is not None
|
|
2516
|
+
else os.environ.get("HARNESS_RUNTIME_STABLE_SECONDS", "15")
|
|
2517
|
+
),
|
|
2518
|
+
)
|
|
2519
|
+
stable_since: float | None = None
|
|
2520
|
+
while time.monotonic() < deadline:
|
|
2521
|
+
status = _docker(
|
|
2522
|
+
"inspect",
|
|
2523
|
+
"--format",
|
|
2524
|
+
"{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}",
|
|
2525
|
+
container,
|
|
2526
|
+
check=False,
|
|
2527
|
+
timeout=10,
|
|
2528
|
+
).strip()
|
|
2529
|
+
if status.startswith("running healthy"):
|
|
2530
|
+
break
|
|
2531
|
+
if status.startswith("running"):
|
|
2532
|
+
stable_since = stable_since or time.monotonic()
|
|
2533
|
+
# A worker often has no healthcheck. Remaining alive through startup is the strongest
|
|
2534
|
+
# generic signal available; an immediate import/configuration crash is still caught.
|
|
2535
|
+
# This window must include worker registration, not only process creation.
|
|
2536
|
+
if time.monotonic() - stable_since >= stable_seconds:
|
|
2537
|
+
break
|
|
2538
|
+
else:
|
|
2539
|
+
stable_since = None
|
|
2540
|
+
if status.startswith(("exited", "dead")):
|
|
2541
|
+
logs = _docker(
|
|
2542
|
+
"logs", "--tail", "40", container, check=False, timeout=10
|
|
2543
|
+
)
|
|
2544
|
+
raise ProvisionError(
|
|
2545
|
+
f"submitted runtime {service!r} exited during startup:\n{logs}"
|
|
2546
|
+
)
|
|
2547
|
+
time.sleep(0.25)
|
|
2548
|
+
else:
|
|
2549
|
+
raise ProvisionError(
|
|
2550
|
+
f"submitted runtime {service!r} did not become ready within 60s"
|
|
2551
|
+
)
|
|
2552
|
+
environment.runtime_container = container
|
|
2553
|
+
environment.runtime_trace_volume = trace_volume
|
|
2554
|
+
environment.runtime_trace_path = trace_destination
|
|
2555
|
+
environment.runtime_endpoints = {
|
|
2556
|
+
str(port): _runtime_address(container, port) for port in requested_ports
|
|
2557
|
+
}
|
|
2558
|
+
environment.save(destination)
|
|
2559
|
+
return environment
|
|
2560
|
+
|
|
2561
|
+
|
|
2562
|
+
def _runtime_address(container: str, port: int) -> str:
|
|
2563
|
+
"""Return one submitted-runtime port as an address this runner can reach."""
|
|
2564
|
+
if os.environ.get("ALK_RUNNER_CONTAINER", "").strip():
|
|
2565
|
+
# The runner joins the project network before using this address. Container names are
|
|
2566
|
+
# stable, job-scoped and avoid publishing a customer service on the Docker host.
|
|
2567
|
+
return f"{container}:{port}"
|
|
2568
|
+
rendered = _docker("port", container, f"{port}/tcp", timeout=30).strip()
|
|
2569
|
+
first = next((line.strip() for line in rendered.splitlines() if line.strip()), "")
|
|
2570
|
+
if not first:
|
|
2571
|
+
raise ProvisionError(f"runtime port {port} was not published by {container}")
|
|
2572
|
+
# Docker may render 0.0.0.0 or :: even though the requested binding is loopback. Callers
|
|
2573
|
+
# always use local loopback; returning a wildcard host is not a usable endpoint.
|
|
2574
|
+
_, separator, host_port = first.rpartition(":")
|
|
2575
|
+
if not separator or not host_port.isdigit():
|
|
2576
|
+
raise ProvisionError(f"could not parse published runtime address: {first}")
|
|
2577
|
+
return f"127.0.0.1:{host_port}"
|
|
2578
|
+
|
|
2579
|
+
|
|
2580
|
+
def runtime_endpoint(
|
|
2581
|
+
destination: str | Path, port: int, *, scheme: str = "http"
|
|
2582
|
+
) -> str:
|
|
2583
|
+
"""Resolve a declared container port after ``start_runtime`` published it."""
|
|
2584
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
2585
|
+
if environment is None or not environment.runtime_container:
|
|
2586
|
+
raise ProvisionError(f"no running submitted runtime recorded at {destination}")
|
|
2587
|
+
address = environment.runtime_endpoints.get(str(port), "")
|
|
2588
|
+
if not address:
|
|
2589
|
+
raise ProvisionError(f"submitted runtime did not publish container port {port}")
|
|
2590
|
+
return f"{scheme}://{address}"
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def runtime_logs(destination: str | Path, *, tail: int = 4000) -> str:
|
|
2594
|
+
"""Read the active submitted runtime's bounded stdout/stderr.
|
|
2595
|
+
|
|
2596
|
+
This is an evidence fallback for frameworks whose tools execute entirely inside the
|
|
2597
|
+
customer process and therefore never cross the harness webhook. The caller must normalize
|
|
2598
|
+
framework-specific structured events immediately; raw customer logs are deliberately not
|
|
2599
|
+
persisted as artifacts because they can contain credentials or unrelated PII.
|
|
2600
|
+
"""
|
|
2601
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
2602
|
+
if environment is None or not environment.runtime_container:
|
|
2603
|
+
return ""
|
|
2604
|
+
bounded_tail = max(1, min(int(tail), 20_000))
|
|
2605
|
+
return _docker(
|
|
2606
|
+
"logs",
|
|
2607
|
+
"--tail",
|
|
2608
|
+
str(bounded_tail),
|
|
2609
|
+
environment.runtime_container,
|
|
2610
|
+
check=False,
|
|
2611
|
+
timeout=30,
|
|
2612
|
+
)
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def _runtime_trace_mount(trace: Path) -> tuple[list[str], str]:
|
|
2616
|
+
"""Give a sibling runtime one writable trace target without exposing other jobs.
|
|
2617
|
+
|
|
2618
|
+
Docker resolves bind sources on the daemon host, not inside the runner container. A path
|
|
2619
|
+
such as ``/var/lib/alk-sandbox`` can therefore name a Docker volume in the runner while
|
|
2620
|
+
accidentally naming an unrelated host directory for ``docker run --volume``. Resolve bind
|
|
2621
|
+
mounts to their host source; for named-volume runners, use a one-call exchange volume that is
|
|
2622
|
+
copied back on cleanup so a submitted runtime cannot inspect artifacts from another job.
|
|
2623
|
+
"""
|
|
2624
|
+
configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
|
|
2625
|
+
if not configured:
|
|
2626
|
+
return ["--volume", f"{trace.parent}:/run/harness-trace"], ""
|
|
2627
|
+
runner = socket.gethostname() if configured == "self" else configured
|
|
2628
|
+
mounts = json.loads(
|
|
2629
|
+
_docker(
|
|
2630
|
+
"inspect",
|
|
2631
|
+
"--format",
|
|
2632
|
+
"{{json .Mounts}}",
|
|
2633
|
+
runner,
|
|
2634
|
+
timeout=30,
|
|
2635
|
+
)
|
|
2636
|
+
or "[]"
|
|
2637
|
+
)
|
|
2638
|
+
for mount in mounts:
|
|
2639
|
+
destination = Path(str(mount.get("Destination") or ""))
|
|
2640
|
+
if not destination.is_absolute():
|
|
2641
|
+
continue
|
|
2642
|
+
try:
|
|
2643
|
+
relative = trace.parent.relative_to(destination)
|
|
2644
|
+
except ValueError:
|
|
2645
|
+
continue
|
|
2646
|
+
kind = str(mount.get("Type") or "")
|
|
2647
|
+
if kind == "volume" and mount.get("Name"):
|
|
2648
|
+
# ``docker compose run`` does not accept ``--mount`` and ``--volume`` cannot select
|
|
2649
|
+
# a subdirectory of another volume. Use a one-call exchange volume instead: the
|
|
2650
|
+
# runtime writes there, then stop_runtime copies the one file back into the runner's
|
|
2651
|
+
# job volume before deleting the exchange volume.
|
|
2652
|
+
exchange = f"alk-trace-{uuid.uuid4().hex[:12]}"
|
|
2653
|
+
_docker("volume", "create", exchange, timeout=30)
|
|
2654
|
+
_docker(
|
|
2655
|
+
"run",
|
|
2656
|
+
"--rm",
|
|
2657
|
+
"--volume",
|
|
2658
|
+
f"{exchange}:/trace",
|
|
2659
|
+
"--entrypoint",
|
|
2660
|
+
"touch",
|
|
2661
|
+
"docker:27-cli",
|
|
2662
|
+
f"/trace/{trace.name}",
|
|
2663
|
+
timeout=30,
|
|
2664
|
+
)
|
|
2665
|
+
_docker(
|
|
2666
|
+
"run",
|
|
2667
|
+
"--rm",
|
|
2668
|
+
"--volume",
|
|
2669
|
+
f"{exchange}:/trace",
|
|
2670
|
+
"--entrypoint",
|
|
2671
|
+
"chmod",
|
|
2672
|
+
"docker:27-cli",
|
|
2673
|
+
"0666",
|
|
2674
|
+
f"/trace/{trace.name}",
|
|
2675
|
+
timeout=30,
|
|
2676
|
+
)
|
|
2677
|
+
return ["--volume", f"{exchange}:/run/harness-trace"], exchange
|
|
2678
|
+
if kind == "bind" and mount.get("Source"):
|
|
2679
|
+
source = Path(str(mount["Source"])) / relative
|
|
2680
|
+
return ["--volume", f"{source}:/run/harness-trace"], ""
|
|
2681
|
+
# Local Docker execution has no outer runner mount to translate.
|
|
2682
|
+
return ["--volume", f"{trace.parent}:/run/harness-trace"], ""
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
def _attach_runner_network(
|
|
2686
|
+
environment: ProvisionedEnvironment, *, alias: str = "alk-harness-runner"
|
|
2687
|
+
) -> str:
|
|
2688
|
+
"""Join a containerized runner to an in-memory environment during provisioning."""
|
|
2689
|
+
configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
|
|
2690
|
+
if not configured:
|
|
2691
|
+
return ""
|
|
2692
|
+
runner = socket.gethostname() if configured == "self" else configured
|
|
2693
|
+
service_container = ""
|
|
2694
|
+
if environment.services:
|
|
2695
|
+
service_container = _run(
|
|
2696
|
+
environment,
|
|
2697
|
+
"ps",
|
|
2698
|
+
"--quiet",
|
|
2699
|
+
environment.services[0],
|
|
2700
|
+
timeout=30,
|
|
2701
|
+
).strip()
|
|
2702
|
+
elif environment.runtime_container:
|
|
2703
|
+
service_container = environment.runtime_container
|
|
2704
|
+
else:
|
|
2705
|
+
# A runtime-only Compose project creates its network when ``compose run`` starts the
|
|
2706
|
+
# worker. Return the stable alias now; the caller invokes this again immediately after
|
|
2707
|
+
# startup to make that alias resolvable from the submitted container.
|
|
2708
|
+
return alias
|
|
2709
|
+
if not service_container:
|
|
2710
|
+
raise ProvisionError("could not identify a container in the source environment")
|
|
2711
|
+
networks = json.loads(
|
|
2712
|
+
_docker(
|
|
2713
|
+
"inspect",
|
|
2714
|
+
"--format",
|
|
2715
|
+
"{{json .NetworkSettings.Networks}}",
|
|
2716
|
+
service_container,
|
|
2717
|
+
timeout=30,
|
|
2718
|
+
)
|
|
2719
|
+
or "{}"
|
|
2720
|
+
)
|
|
2721
|
+
if len(networks) != 1:
|
|
2722
|
+
raise ProvisionError(
|
|
2723
|
+
"expected the source environment on one private network, found "
|
|
2724
|
+
+ (", ".join(networks) or "none")
|
|
2725
|
+
)
|
|
2726
|
+
network = next(iter(networks))
|
|
2727
|
+
runner_networks = json.loads(
|
|
2728
|
+
_docker(
|
|
2729
|
+
"inspect",
|
|
2730
|
+
"--format",
|
|
2731
|
+
"{{json .NetworkSettings.Networks}}",
|
|
2732
|
+
runner,
|
|
2733
|
+
timeout=30,
|
|
2734
|
+
)
|
|
2735
|
+
or "{}"
|
|
2736
|
+
)
|
|
2737
|
+
if network not in runner_networks:
|
|
2738
|
+
_docker("network", "connect", "--alias", alias, network, runner, timeout=30)
|
|
2739
|
+
environment.runner_network = network
|
|
2740
|
+
return alias
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _detach_runner_network(environment: ProvisionedEnvironment) -> None:
|
|
2744
|
+
configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
|
|
2745
|
+
if not configured or not environment.runner_network:
|
|
2746
|
+
return
|
|
2747
|
+
runner = socket.gethostname() if configured == "self" else configured
|
|
2748
|
+
_docker(
|
|
2749
|
+
"network",
|
|
2750
|
+
"disconnect",
|
|
2751
|
+
environment.runner_network,
|
|
2752
|
+
runner,
|
|
2753
|
+
check=False,
|
|
2754
|
+
timeout=30,
|
|
2755
|
+
)
|
|
2756
|
+
environment.runner_network = ""
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
def connect_runner_network(
|
|
2760
|
+
destination: str | Path, *, alias: str = "alk-harness-runner"
|
|
2761
|
+
) -> str:
|
|
2762
|
+
"""Join this runner to the submitted environment's private network.
|
|
2763
|
+
|
|
2764
|
+
A per-call webhook lives in the runner process. The submitted agent runs in
|
|
2765
|
+
the repository's Compose network, so a hosted/containerized runner must
|
|
2766
|
+
join that network explicitly; host loopback and host-published ports are
|
|
2767
|
+
neither private nor reliably reachable from sibling containers.
|
|
2768
|
+
"""
|
|
2769
|
+
destination = Path(destination)
|
|
2770
|
+
environment = ProvisionedEnvironment.load(destination)
|
|
2771
|
+
if environment is None or not environment.running:
|
|
2772
|
+
raise ProvisionError(f"no running environment recorded at {destination}")
|
|
2773
|
+
connected = _attach_runner_network(environment, alias=alias)
|
|
2774
|
+
environment.save(destination)
|
|
2775
|
+
return connected
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def runtime_environment(destination: str | Path) -> dict[str, str]:
|
|
2779
|
+
"""Return the submitted worker's rendered environment without writing it to artifacts."""
|
|
2780
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
2781
|
+
if environment is None or not environment.running:
|
|
2782
|
+
raise ProvisionError(f"no running environment recorded at {destination}")
|
|
2783
|
+
if len(environment.runtime_services) != 1:
|
|
2784
|
+
raise ProvisionError(
|
|
2785
|
+
"expected exactly one opt-in agent runtime service, found "
|
|
2786
|
+
+ (", ".join(environment.runtime_services) or "none")
|
|
2787
|
+
)
|
|
2788
|
+
config = _config(environment)
|
|
2789
|
+
values = _environment_values(config["services"][environment.runtime_services[0]])
|
|
2790
|
+
for name in environment.runtime_configuration_names:
|
|
2791
|
+
value = runtime_configuration_value(name).strip()
|
|
2792
|
+
if value:
|
|
2793
|
+
values[name] = value
|
|
2794
|
+
return values
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def infer_livekit_agent_name(destination: str | Path) -> str:
|
|
2798
|
+
"""Read the worker's configured or source-default LiveKit dispatch name."""
|
|
2799
|
+
configured = runtime_environment(destination).get("LIVEKIT_AGENT_NAME", "").strip()
|
|
2800
|
+
if configured:
|
|
2801
|
+
return configured
|
|
2802
|
+
environment = ProvisionedEnvironment.load(Path(destination))
|
|
2803
|
+
if environment is None:
|
|
2804
|
+
return ""
|
|
2805
|
+
return infer_livekit_agent_name_from_source(
|
|
2806
|
+
Path(environment.source), ignored_parts=_FINGERPRINT_IGNORED
|
|
2807
|
+
)
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def activate_voice_environment(
|
|
2811
|
+
destination: str | Path, *, system_prompt: str = ""
|
|
2812
|
+
) -> dict[str, str]:
|
|
2813
|
+
"""Make source-owned voice credentials available to the in-process simulator.
|
|
2814
|
+
|
|
2815
|
+
Compose has already resolved the repository's env files. Values are copied only into this
|
|
2816
|
+
process for the duration of the harness process and are never added to environment.json or
|
|
2817
|
+
run artifacts. Existing platform-wide simulator settings always win.
|
|
2818
|
+
"""
|
|
2819
|
+
values = runtime_environment(destination)
|
|
2820
|
+
allowed = {
|
|
2821
|
+
"LIVEKIT_URL",
|
|
2822
|
+
"LIVEKIT_API_KEY",
|
|
2823
|
+
"LIVEKIT_API_SECRET",
|
|
2824
|
+
"DEEPGRAM_API_KEY",
|
|
2825
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
2826
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
2827
|
+
"GOOGLE_CLOUD_LOCATION",
|
|
2828
|
+
"GEMINI_API_KEY",
|
|
2829
|
+
"GOOGLE_API_KEY",
|
|
2830
|
+
"OPENAI_API_KEY",
|
|
2831
|
+
"CARTESIA_API_KEY",
|
|
2832
|
+
"ELEVEN_API_KEY",
|
|
2833
|
+
"ELEVENLABS_API_KEY",
|
|
2834
|
+
}
|
|
2835
|
+
activated: dict[str, str] = {}
|
|
2836
|
+
for name in allowed:
|
|
2837
|
+
value = values.get(name, "").strip()
|
|
2838
|
+
if value and not os.environ.get(name, "").strip():
|
|
2839
|
+
os.environ[name] = value
|
|
2840
|
+
activated[name] = value
|
|
2841
|
+
agent_name = infer_livekit_agent_name(destination)
|
|
2842
|
+
if agent_name and not os.environ.get("LIVEKIT_TARGET_AGENT_NAME", "").strip():
|
|
2843
|
+
os.environ["LIVEKIT_TARGET_AGENT_NAME"] = agent_name
|
|
2844
|
+
activated["LIVEKIT_TARGET_AGENT_NAME"] = agent_name
|
|
2845
|
+
if system_prompt and not os.environ.get("LIVEKIT_TARGET_SYSTEM_PROMPT", "").strip():
|
|
2846
|
+
os.environ["LIVEKIT_TARGET_SYSTEM_PROMPT"] = system_prompt
|
|
2847
|
+
activated["LIVEKIT_TARGET_SYSTEM_PROMPT"] = system_prompt
|
|
2848
|
+
return activated
|
|
2849
|
+
|
|
2850
|
+
|
|
2851
|
+
def stop_runtime(destination: str | Path) -> bool:
|
|
2852
|
+
"""Stop only the submitted worker, leaving its scenario infrastructure running."""
|
|
2853
|
+
destination = Path(destination)
|
|
2854
|
+
environment = ProvisionedEnvironment.load(destination)
|
|
2855
|
+
if environment is None or not environment.runtime_container:
|
|
2856
|
+
return False
|
|
2857
|
+
if environment.runtime_trace_volume and environment.runtime_trace_path:
|
|
2858
|
+
trace = Path(environment.runtime_trace_path)
|
|
2859
|
+
trace.parent.mkdir(parents=True, exist_ok=True)
|
|
2860
|
+
_docker(
|
|
2861
|
+
"cp",
|
|
2862
|
+
f"{environment.runtime_container}:/run/harness-trace/{trace.name}",
|
|
2863
|
+
str(trace),
|
|
2864
|
+
check=False,
|
|
2865
|
+
)
|
|
2866
|
+
_docker("rm", "--force", environment.runtime_container, check=False)
|
|
2867
|
+
if environment.runtime_trace_volume:
|
|
2868
|
+
_docker(
|
|
2869
|
+
"volume", "rm", environment.runtime_trace_volume, check=False, timeout=30
|
|
2870
|
+
)
|
|
2871
|
+
environment.runtime_container = ""
|
|
2872
|
+
environment.runtime_trace_volume = ""
|
|
2873
|
+
environment.runtime_trace_path = ""
|
|
2874
|
+
environment.runtime_endpoints = {}
|
|
2875
|
+
# Keep the runner attached while the scenario is graded against the real datastore. The
|
|
2876
|
+
# worker is gone, but post-call state checks still need the run's private network. ``reset``
|
|
2877
|
+
# and ``stop`` own the eventual network detach together with infrastructure teardown.
|
|
2878
|
+
environment.save(destination)
|
|
2879
|
+
return True
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def stop(destination: str | Path) -> bool:
|
|
2883
|
+
"""Tear down the exact project recorded for a session, including its test data."""
|
|
2884
|
+
destination = Path(destination)
|
|
2885
|
+
environment = ProvisionedEnvironment.load(destination)
|
|
2886
|
+
if environment is None:
|
|
2887
|
+
return False
|
|
2888
|
+
if environment.runtime_container:
|
|
2889
|
+
stop_runtime(destination)
|
|
2890
|
+
environment = ProvisionedEnvironment.load(destination) or environment
|
|
2891
|
+
_detach_runner_network(environment)
|
|
2892
|
+
_run(environment, "down", "--volumes", "--remove-orphans", check=False, timeout=120)
|
|
2893
|
+
_remove_empty_project_default_network(environment.project)
|
|
2894
|
+
environment.running = False
|
|
2895
|
+
environment.save(destination)
|
|
2896
|
+
return True
|