mobster-cli 0.4.3__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.
- mobile_agent/__init__.py +4 -0
- mobile_agent/__main__.py +598 -0
- mobile_agent/accounts/__init__.py +1 -0
- mobile_agent/accounts/context.py +80 -0
- mobile_agent/accounts/mcp.py +55 -0
- mobile_agent/accounts/pacing.py +318 -0
- mobile_agent/accounts/register.py +78 -0
- mobile_agent/accounts/routes.py +212 -0
- mobile_agent/accounts/routing.py +69 -0
- mobile_agent/accounts/screen.py +273 -0
- mobile_agent/accounts/stats.py +54 -0
- mobile_agent/accounts/store.py +394 -0
- mobile_agent/agent_hooks.py +123 -0
- mobile_agent/ai_plans/__init__.py +7 -0
- mobile_agent/ai_plans/agents.py +96 -0
- mobile_agent/ai_plans/claude_code.py +208 -0
- mobile_agent/ai_plans/cli.py +400 -0
- mobile_agent/ai_plans/codex.py +258 -0
- mobile_agent/ai_plans/compat_chat.py +307 -0
- mobile_agent/ai_plans/device_login.py +164 -0
- mobile_agent/ai_plans/draft_client.py +68 -0
- mobile_agent/ai_plans/gemini.py +137 -0
- mobile_agent/ai_plans/instructions.py +65 -0
- mobile_agent/ai_plans/mcp_http.py +160 -0
- mobile_agent/ai_plans/phone_tools.py +892 -0
- mobile_agent/ai_plans/providers.py +235 -0
- mobile_agent/ai_plans/routes.py +160 -0
- mobile_agent/ai_plans/runner.py +400 -0
- mobile_agent/ai_plans/vault.py +114 -0
- mobile_agent/alerts.py +337 -0
- mobile_agent/android/__init__.py +18 -0
- mobile_agent/android/adb.py +268 -0
- mobile_agent/android/conditions.py +128 -0
- mobile_agent/android/doctor.py +54 -0
- mobile_agent/android/driver.py +137 -0
- mobile_agent/android/emulator.py +216 -0
- mobile_agent/android/logs.py +85 -0
- mobile_agent/android/manager.py +225 -0
- mobile_agent/android/records.py +31 -0
- mobile_agent/android/tree.py +96 -0
- mobile_agent/answer_shapes.py +230 -0
- mobile_agent/api_devices.py +333 -0
- mobile_agent/api_errors.py +17 -0
- mobile_agent/api_get.py +249 -0
- mobile_agent/api_http.py +467 -0
- mobile_agent/api_post.py +453 -0
- mobile_agent/api_routes.py +173 -0
- mobile_agent/app_api/__init__.py +4 -0
- mobile_agent/app_api/cli.py +151 -0
- mobile_agent/app_api/endpoints.py +193 -0
- mobile_agent/app_api/mcp.py +54 -0
- mobile_agent/app_api/openapi.py +146 -0
- mobile_agent/app_api/reading.py +172 -0
- mobile_agent/app_api/register.py +10 -0
- mobile_agent/app_api/screen.py +216 -0
- mobile_agent/app_api/service.py +382 -0
- mobile_agent/app_api/sessions.py +404 -0
- mobile_agent/app_api/tokens.py +167 -0
- mobile_agent/app_choice.py +131 -0
- mobile_agent/app_icons.py +183 -0
- mobile_agent/app_killer.py +69 -0
- mobile_agent/artifacts.py +606 -0
- mobile_agent/attach/__init__.py +13 -0
- mobile_agent/attach/cli.py +46 -0
- mobile_agent/attach/flutter.py +28 -0
- mobile_agent/attach/score.py +119 -0
- mobile_agent/attach/web.py +13 -0
- mobile_agent/attachments/__init__.py +1 -0
- mobile_agent/attachments/agent.py +418 -0
- mobile_agent/attachments/extract.py +217 -0
- mobile_agent/attachments/mcp.py +104 -0
- mobile_agent/attachments/register.py +73 -0
- mobile_agent/attachments/routes.py +326 -0
- mobile_agent/attachments/service.py +170 -0
- mobile_agent/attachments/sniff.py +152 -0
- mobile_agent/attachments/store.py +441 -0
- mobile_agent/audit_verify.py +436 -0
- mobile_agent/bench/README.md +410 -0
- mobile_agent/bench/__init__.py +0 -0
- mobile_agent/bench/__main__.py +394 -0
- mobile_agent/bench/ab.py +168 -0
- mobile_agent/bench/actuation.py +368 -0
- mobile_agent/bench/agents/__init__.py +9 -0
- mobile_agent/bench/agents/base.py +105 -0
- mobile_agent/bench/agents/plan_clients.py +245 -0
- mobile_agent/bench/agents/smart.py +533 -0
- mobile_agent/bench/checks.py +358 -0
- mobile_agent/bench/decide_gate.py +214 -0
- mobile_agent/bench/diagnose.py +181 -0
- mobile_agent/bench/evals/__init__.py +14 -0
- mobile_agent/bench/evals/build.py +565 -0
- mobile_agent/bench/evals/checkpoint.py +214 -0
- mobile_agent/bench/evals/irt.py +219 -0
- mobile_agent/bench/evals/l0.py +647 -0
- mobile_agent/bench/evals/l1.py +866 -0
- mobile_agent/bench/evals/models.py +124 -0
- mobile_agent/bench/evals/pairwise.py +189 -0
- mobile_agent/bench/evals/proxy.py +90 -0
- mobile_agent/bench/evals/sets.py +149 -0
- mobile_agent/bench/evals/traces.py +446 -0
- mobile_agent/bench/fixtures.py +176 -0
- mobile_agent/bench/guard_replay.py +129 -0
- mobile_agent/bench/h2-manifest.json +20 -0
- mobile_agent/bench/h3-manifest.json +20 -0
- mobile_agent/bench/heldout-manifest.json +32 -0
- mobile_agent/bench/heldout.py +375 -0
- mobile_agent/bench/iosworld.py +901 -0
- mobile_agent/bench/iosworld_stats.py +510 -0
- mobile_agent/bench/llm_units.py +116 -0
- mobile_agent/bench/manifest.json +93 -0
- mobile_agent/bench/metrics.py +66 -0
- mobile_agent/bench/phone.py +44 -0
- mobile_agent/bench/replay.py +172 -0
- mobile_agent/bench/report.py +207 -0
- mobile_agent/bench/runner.py +456 -0
- mobile_agent/bench/safety.py +310 -0
- mobile_agent/bench/stream_timing.py +213 -0
- mobile_agent/bench/suite.py +444 -0
- mobile_agent/bench/sweep.py +176 -0
- mobile_agent/bench/tape.py +798 -0
- mobile_agent/bench/tape_replay.py +88 -0
- mobile_agent/bench/trace_evals.py +128 -0
- mobile_agent/bench/trace_view.py +81 -0
- mobile_agent/bench/truth/iphone15pro.json +138 -0
- mobile_agent/bench/truth.py +340 -0
- mobile_agent/bench/tuning.py +543 -0
- mobile_agent/bench/tuning_analysis.py +656 -0
- mobile_agent/bench/vague-heldout-manifest.json +12 -0
- mobile_agent/bench/vague_rubric.py +135 -0
- mobile_agent/bench/vague_run.py +243 -0
- mobile_agent/brand.py +454 -0
- mobile_agent/brand_fonts.py +756 -0
- mobile_agent/build_identity.py +21 -0
- mobile_agent/cable_bt.py +623 -0
- mobile_agent/cable_bt_runtime.py +128 -0
- mobile_agent/catalog.py +69 -0
- mobile_agent/chart_svg.py +415 -0
- mobile_agent/chat_here.py +189 -0
- mobile_agent/cli_constants.py +5 -0
- mobile_agent/cli_parser.py +611 -0
- mobile_agent/cli_run.py +115 -0
- mobile_agent/compiled_read/__init__.py +21 -0
- mobile_agent/compiled_read/delta.py +89 -0
- mobile_agent/compiled_read/extractors.py +202 -0
- mobile_agent/compiled_read/routes.py +56 -0
- mobile_agent/completion.py +267 -0
- mobile_agent/compose.py +48 -0
- mobile_agent/config.py +114 -0
- mobile_agent/connectors/__init__.py +1 -0
- mobile_agent/connectors/api_session.py +76 -0
- mobile_agent/connectors/callback_page.py +146 -0
- mobile_agent/connectors/client.py +582 -0
- mobile_agent/connectors/github_app.py +55 -0
- mobile_agent/connectors/graph_api.py +396 -0
- mobile_agent/connectors/imports.py +170 -0
- mobile_agent/connectors/issue.py +66 -0
- mobile_agent/connectors/keystore.py +85 -0
- mobile_agent/connectors/marks.py +222 -0
- mobile_agent/connectors/oauth.py +476 -0
- mobile_agent/connectors/picker.py +228 -0
- mobile_agent/connectors/presets.py +591 -0
- mobile_agent/connectors/register.py +48 -0
- mobile_agent/connectors/routes.py +153 -0
- mobile_agent/connectors/service.py +966 -0
- mobile_agent/connectors/shared.py +136 -0
- mobile_agent/connectors/skill.py +446 -0
- mobile_agent/connectors/slack_api.py +269 -0
- mobile_agent/connectors/steps.py +92 -0
- mobile_agent/connectors/store.py +144 -0
- mobile_agent/connectors/vault.py +72 -0
- mobile_agent/console.py +178 -0
- mobile_agent/content_queue/__init__.py +1 -0
- mobile_agent/content_queue/client.py +263 -0
- mobile_agent/content_queue/importing.py +153 -0
- mobile_agent/content_queue/media_check.py +224 -0
- mobile_agent/content_queue/prompt.py +64 -0
- mobile_agent/content_queue/register.py +91 -0
- mobile_agent/content_queue/routes.py +370 -0
- mobile_agent/content_queue/runner.py +475 -0
- mobile_agent/content_queue/store.py +521 -0
- mobile_agent/contract.py +996 -0
- mobile_agent/contract_asks.py +248 -0
- mobile_agent/contract_prompt.py +45 -0
- mobile_agent/contract_screen.py +981 -0
- mobile_agent/contract_view.py +84 -0
- mobile_agent/corpus/__init__.py +26 -0
- mobile_agent/corpus/cli.py +63 -0
- mobile_agent/corpus/consent.py +55 -0
- mobile_agent/corpus/listener.py +76 -0
- mobile_agent/corpus/register.py +9 -0
- mobile_agent/corpus/skeleton.py +72 -0
- mobile_agent/corpus/store.py +143 -0
- mobile_agent/costs.py +161 -0
- mobile_agent/decider_scope.py +40 -0
- mobile_agent/decision_provider.py +189 -0
- mobile_agent/deeplinks.py +352 -0
- mobile_agent/device_info.py +116 -0
- mobile_agent/device_manager.py +978 -0
- mobile_agent/device_status.py +195 -0
- mobile_agent/device_targets.py +217 -0
- mobile_agent/devices.py +676 -0
- mobile_agent/devkit/__init__.py +64 -0
- mobile_agent/devkit/account_cli.py +90 -0
- mobile_agent/devkit/asc.py +562 -0
- mobile_agent/devkit/asc_cli.py +214 -0
- mobile_agent/devkit/ci.py +737 -0
- mobile_agent/devkit/ci_cli.py +196 -0
- mobile_agent/devkit/ci_hosts.py +194 -0
- mobile_agent/devkit/es256.py +197 -0
- mobile_agent/devkit/expo.py +440 -0
- mobile_agent/devkit/expo_cli.py +405 -0
- mobile_agent/devkit/expo_dev.py +203 -0
- mobile_agent/devkit/expo_routes.py +90 -0
- mobile_agent/devkit/fixtures_cli.py +133 -0
- mobile_agent/devkit/fixtures_routes.py +82 -0
- mobile_agent/devkit/links.py +375 -0
- mobile_agent/devkit/links_cli.py +123 -0
- mobile_agent/devkit/links_routes.py +83 -0
- mobile_agent/devkit/nightly.py +152 -0
- mobile_agent/devkit/shots.py +203 -0
- mobile_agent/devkit/testflight.py +155 -0
- mobile_agent/devkit/testflight_cli.py +127 -0
- mobile_agent/devkit/testflight_routes.py +111 -0
- mobile_agent/devkit/xcode.py +236 -0
- mobile_agent/devkit/xcode_cli.py +101 -0
- mobile_agent/devtools.py +419 -0
- mobile_agent/doctor.py +509 -0
- mobile_agent/doctor_fix.py +467 -0
- mobile_agent/downloads.py +55 -0
- mobile_agent/drivers.py +793 -0
- mobile_agent/dry_run.py +102 -0
- mobile_agent/effect_ledger.py +83 -0
- mobile_agent/engine_health.py +378 -0
- mobile_agent/engine_hooks.py +96 -0
- mobile_agent/engines.py +408 -0
- mobile_agent/env_file.py +63 -0
- mobile_agent/env_secrets.py +149 -0
- mobile_agent/errors.py +15 -0
- mobile_agent/evals/__init__.py +3 -0
- mobile_agent/evals/app_survey.py +149 -0
- mobile_agent/evals/development/__init__.py +1 -0
- mobile_agent/evals/development/score.py +276 -0
- mobile_agent/evals/latency_report.py +312 -0
- mobile_agent/evals/offline_report.py +374 -0
- mobile_agent/evals/oracles.py +298 -0
- mobile_agent/evals/redteam/__init__.py +15 -0
- mobile_agent/evals/redteam/__main__.py +72 -0
- mobile_agent/evals/redteam/cases.py +730 -0
- mobile_agent/evals/redteam/harness.py +604 -0
- mobile_agent/evals/redteam/live.py +139 -0
- mobile_agent/evals/redteam/plain.py +85 -0
- mobile_agent/evals/vision/__init__.py +0 -0
- mobile_agent/evals/vision/images/dog_001.jpg +0 -0
- mobile_agent/evals/vision/images/dog_002.jpg +0 -0
- mobile_agent/evals/vision/images/dog_003.jpg +0 -0
- mobile_agent/evals/vision/images/dog_004.jpg +0 -0
- mobile_agent/evals/vision/images/dog_005.jpg +0 -0
- mobile_agent/evals/vision/images/dog_006.jpg +0 -0
- mobile_agent/evals/vision/images/dog_007.jpg +0 -0
- mobile_agent/evals/vision/images/dog_010.jpg +0 -0
- mobile_agent/evals/vision/images/dog_011.jpg +0 -0
- mobile_agent/evals/vision/images/dog_013.jpg +0 -0
- mobile_agent/evals/vision/images/dog_014.jpg +0 -0
- mobile_agent/evals/vision/images/dog_015.jpg +0 -0
- mobile_agent/evals/vision/images/dog_016.jpg +0 -0
- mobile_agent/evals/vision/images/dog_017.jpg +0 -0
- mobile_agent/evals/vision/images/dog_019.jpg +0 -0
- mobile_agent/evals/vision/images/dog_020.jpg +0 -0
- mobile_agent/evals/vision/images/dog_021.jpg +0 -0
- mobile_agent/evals/vision/images/dog_022.jpg +0 -0
- mobile_agent/evals/vision/images/dog_023.jpg +0 -0
- mobile_agent/evals/vision/images/dog_024.jpg +0 -0
- mobile_agent/evals/vision/images/dog_026.jpg +0 -0
- mobile_agent/evals/vision/images/dog_027.jpg +0 -0
- mobile_agent/evals/vision/images/dog_034.jpg +0 -0
- mobile_agent/evals/vision/images/dog_035.jpg +0 -0
- mobile_agent/evals/vision/images/dog_038.jpg +0 -0
- mobile_agent/evals/vision/images/dog_042.jpg +0 -0
- mobile_agent/evals/vision/images/dog_047.jpg +0 -0
- mobile_agent/evals/vision/images/dog_052.jpg +0 -0
- mobile_agent/evals/vision/images/dog_058.jpg +0 -0
- mobile_agent/evals/vision/images/dog_062.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
- mobile_agent/evals/vision/manifest.json +2355 -0
- mobile_agent/eventbus.py +110 -0
- mobile_agent/explain.py +34 -0
- mobile_agent/failure_line.py +197 -0
- mobile_agent/fanout.py +663 -0
- mobile_agent/feedback/__init__.py +2 -0
- mobile_agent/feedback/bundle.py +204 -0
- mobile_agent/feedback/outbox.py +196 -0
- mobile_agent/feedback/register.py +9 -0
- mobile_agent/feedback/routes.py +177 -0
- mobile_agent/fleet.py +841 -0
- mobile_agent/fleet_health.py +729 -0
- mobile_agent/fleet_phone.py +186 -0
- mobile_agent/frontier/__init__.py +79 -0
- mobile_agent/frontier/act.py +380 -0
- mobile_agent/frontier/actions.py +70 -0
- mobile_agent/frontier/agent.py +256 -0
- mobile_agent/frontier/approvals.py +366 -0
- mobile_agent/frontier/hooks.py +199 -0
- mobile_agent/frontier/interrupts.py +125 -0
- mobile_agent/frontier/observe.py +331 -0
- mobile_agent/frontier/phases.py +381 -0
- mobile_agent/frontier/pipeline.py +61 -0
- mobile_agent/frontier/prompt.py +384 -0
- mobile_agent/frontier/protocol.py +91 -0
- mobile_agent/frontier/safety.py +467 -0
- mobile_agent/frontier/saved.py +375 -0
- mobile_agent/frontier/screen.py +419 -0
- mobile_agent/frontier/skills.py +147 -0
- mobile_agent/frontier/stages/__init__.py +1 -0
- mobile_agent/frontier/stages/after.py +192 -0
- mobile_agent/frontier/stages/approve.py +138 -0
- mobile_agent/frontier/stages/controls.py +115 -0
- mobile_agent/frontier/stages/ends.py +102 -0
- mobile_agent/frontier/stages/gate.py +92 -0
- mobile_agent/frontier/stages/guards.py +132 -0
- mobile_agent/frontier/stages/points.py +155 -0
- mobile_agent/frontier/stages/send.py +177 -0
- mobile_agent/frontier/turn.py +151 -0
- mobile_agent/full_disk_access.py +83 -0
- mobile_agent/gemini.py +69 -0
- mobile_agent/genuine.py +120 -0
- mobile_agent/graph/__init__.py +4 -0
- mobile_agent/graph/catalog.py +789 -0
- mobile_agent/graph/chain.py +306 -0
- mobile_agent/graph/dispatch.py +98 -0
- mobile_agent/graph/draft.py +373 -0
- mobile_agent/graph/edit.py +76 -0
- mobile_agent/graph/estimate.py +189 -0
- mobile_agent/graph/expr.py +562 -0
- mobile_agent/graph/functions.py +271 -0
- mobile_agent/graph/infer.py +144 -0
- mobile_agent/graph/nodes.py +936 -0
- mobile_agent/graph/phone_steps.py +178 -0
- mobile_agent/graph/rerun.py +327 -0
- mobile_agent/graph/run.py +899 -0
- mobile_agent/graph/schema.py +842 -0
- mobile_agent/graph/state.py +161 -0
- mobile_agent/graph/values.py +198 -0
- mobile_agent/harness/__init__.py +2 -0
- mobile_agent/harness/checkpoints.py +468 -0
- mobile_agent/harness/control.py +101 -0
- mobile_agent/harness/prewarm.py +213 -0
- mobile_agent/harness/register.py +43 -0
- mobile_agent/harness/routes.py +114 -0
- mobile_agent/harness/settings.py +66 -0
- mobile_agent/harness/tools/__init__.py +67 -0
- mobile_agent/harness/tools/ask_user.py +102 -0
- mobile_agent/harness/tools/watch.py +120 -0
- mobile_agent/harness_api.py +815 -0
- mobile_agent/hedge.py +203 -0
- mobile_agent/help_topics.py +305 -0
- mobile_agent/hints.py +248 -0
- mobile_agent/history.py +69 -0
- mobile_agent/home.py +214 -0
- mobile_agent/http_pool.py +175 -0
- mobile_agent/image_policy.py +139 -0
- mobile_agent/inbox.py +148 -0
- mobile_agent/installed_apps.py +344 -0
- mobile_agent/integrations/__init__.py +1 -0
- mobile_agent/integrations/cli.py +472 -0
- mobile_agent/integrations/client_files.py +65 -0
- mobile_agent/integrations/clients.py +998 -0
- mobile_agent/integrations/doctor.py +185 -0
- mobile_agent/integrations/jsonc.py +383 -0
- mobile_agent/integrations/routes.py +135 -0
- mobile_agent/integrations/rules.py +112 -0
- mobile_agent/integrations/server_options.py +46 -0
- mobile_agent/integrations/skill.py +152 -0
- mobile_agent/integrations/skill_files.py +10 -0
- mobile_agent/integrations/textedit.py +309 -0
- mobile_agent/intents/__init__.py +17 -0
- mobile_agent/intents/atlas.py +216 -0
- mobile_agent/intents/callback.py +44 -0
- mobile_agent/intents/cli.py +55 -0
- mobile_agent/intents/harvest.py +91 -0
- mobile_agent/intents/plan.py +93 -0
- mobile_agent/ios/__init__.py +1 -0
- mobile_agent/ios/guide.py +16 -0
- mobile_agent/ios/kb.py +212 -0
- mobile_agent/ios/kb_data.py +1588 -0
- mobile_agent/ios/mcp.py +44 -0
- mobile_agent/ios/register.py +8 -0
- mobile_agent/ios/system.py +369 -0
- mobile_agent/journal.py +531 -0
- mobile_agent/keychain_acl.py +374 -0
- mobile_agent/keys.py +395 -0
- mobile_agent/latency_overlap.py +36 -0
- mobile_agent/latency_trace.py +170 -0
- mobile_agent/learned/__init__.py +15 -0
- mobile_agent/learned/app_path.py +160 -0
- mobile_agent/learned/code_read.py +38 -0
- mobile_agent/learned/parts.py +546 -0
- mobile_agent/learned/programs.py +691 -0
- mobile_agent/learned/register.py +171 -0
- mobile_agent/learned/routes.py +61 -0
- mobile_agent/learned/taught.py +329 -0
- mobile_agent/learned/teach.py +538 -0
- mobile_agent/learned/workflows.py +647 -0
- mobile_agent/learned/zero.py +41 -0
- mobile_agent/links.py +37 -0
- mobile_agent/listen.py +607 -0
- mobile_agent/lock_spike.py +278 -0
- mobile_agent/lockscreen.py +526 -0
- mobile_agent/login.py +354 -0
- mobile_agent/longform/__init__.py +15 -0
- mobile_agent/longform/ads.py +191 -0
- mobile_agent/longform/blocks.py +117 -0
- mobile_agent/longform/budget.py +265 -0
- mobile_agent/longform/context.py +285 -0
- mobile_agent/longform/costs.py +93 -0
- mobile_agent/longform/digest.py +145 -0
- mobile_agent/longform/extract.py +508 -0
- mobile_agent/longform/feed.py +505 -0
- mobile_agent/longform/followup.py +168 -0
- mobile_agent/longform/frames.py +82 -0
- mobile_agent/longform/identity.py +87 -0
- mobile_agent/longform/inbox.py +221 -0
- mobile_agent/longform/items.py +425 -0
- mobile_agent/longform/models.py +115 -0
- mobile_agent/longform/notebook.py +329 -0
- mobile_agent/longform/plan.py +614 -0
- mobile_agent/longform/recall.py +195 -0
- mobile_agent/longform/receipts.py +81 -0
- mobile_agent/longform/register.py +124 -0
- mobile_agent/longform/report.py +343 -0
- mobile_agent/longform/routes.py +102 -0
- mobile_agent/longform/session.py +644 -0
- mobile_agent/longform/store.py +146 -0
- mobile_agent/longform/synthesis.py +347 -0
- mobile_agent/mac_tools.py +223 -0
- mobile_agent/manual_control.py +143 -0
- mobile_agent/maps.py +149 -0
- mobile_agent/marks.py +92 -0
- mobile_agent/mcp_server/__init__.py +3 -0
- mobile_agent/mcp_server/__main__.py +29 -0
- mobile_agent/mcp_server/action_journal.py +24 -0
- mobile_agent/mcp_server/action_tools.py +638 -0
- mobile_agent/mcp_server/approvals.py +219 -0
- mobile_agent/mcp_server/catalog.py +92 -0
- mobile_agent/mcp_server/cli.py +194 -0
- mobile_agent/mcp_server/codes.py +147 -0
- mobile_agent/mcp_server/device_tools.py +306 -0
- mobile_agent/mcp_server/direct.py +272 -0
- mobile_agent/mcp_server/expo_tools.py +105 -0
- mobile_agent/mcp_server/phone_tools.py +510 -0
- mobile_agent/mcp_server/protocol.py +364 -0
- mobile_agent/mcp_server/registry.py +55 -0
- mobile_agent/mcp_server/run_tools.py +531 -0
- mobile_agent/mcp_server/schemas.py +425 -0
- mobile_agent/mcp_server/screen_tools.py +168 -0
- mobile_agent/mcp_server/session.py +469 -0
- mobile_agent/mcp_server/support.py +145 -0
- mobile_agent/mcp_server/tools.py +493 -0
- mobile_agent/memory/__init__.py +9 -0
- mobile_agent/memory/cli.py +207 -0
- mobile_agent/memory/extract.py +191 -0
- mobile_agent/memory/listener.py +100 -0
- mobile_agent/memory/model.py +144 -0
- mobile_agent/memory/project.py +96 -0
- mobile_agent/memory/provider.py +127 -0
- mobile_agent/memory/register.py +21 -0
- mobile_agent/memory/retrieval.py +172 -0
- mobile_agent/memory/routes.py +216 -0
- mobile_agent/memory/runs.py +124 -0
- mobile_agent/memory/store.py +806 -0
- mobile_agent/memory/writer.py +278 -0
- mobile_agent/mirroring.py +585 -0
- mobile_agent/mirroring_runtime.py +221 -0
- mobile_agent/model_clients.py +604 -0
- mobile_agent/narrate.py +813 -0
- mobile_agent/native_helpers.py +84 -0
- mobile_agent/netxray/__init__.py +1 -0
- mobile_agent/netxray/analyze.py +322 -0
- mobile_agent/netxray/attribution.py +62 -0
- mobile_agent/netxray/capture.py +88 -0
- mobile_agent/netxray/cli.py +142 -0
- mobile_agent/netxray/job.py +219 -0
- mobile_agent/netxray/mobster-pcap.c +560 -0
- mobile_agent/netxray/packets.py +410 -0
- mobile_agent/netxray/pcapng.py +223 -0
- mobile_agent/netxray/register.py +93 -0
- mobile_agent/netxray/report.py +214 -0
- mobile_agent/netxray/store.py +48 -0
- mobile_agent/netxray/summary.py +157 -0
- mobile_agent/netxray/target.py +67 -0
- mobile_agent/netxray/tool.py +66 -0
- mobile_agent/netxray/trackers.py +194 -0
- mobile_agent/notify_triggers/__init__.py +1 -0
- mobile_agent/notify_triggers/rules.py +39 -0
- mobile_agent/notify_triggers/sources.py +315 -0
- mobile_agent/notify_triggers/watch.py +135 -0
- mobile_agent/offers.py +86 -0
- mobile_agent/orchestrate.py +215 -0
- mobile_agent/outlets/__init__.py +1 -0
- mobile_agent/outlets/connect.py +249 -0
- mobile_agent/outlets/folder.py +180 -0
- mobile_agent/outlets/hooks.py +453 -0
- mobile_agent/outlets/inbound.py +472 -0
- mobile_agent/outlets/inbound_reply.py +57 -0
- mobile_agent/outlets/inbound_routes.py +81 -0
- mobile_agent/outlets/kinds.py +308 -0
- mobile_agent/outlets/mail.py +110 -0
- mobile_agent/outlets/net.py +86 -0
- mobile_agent/outlets/notion.py +244 -0
- mobile_agent/outlets/register.py +56 -0
- mobile_agent/outlets/routes.py +135 -0
- mobile_agent/outlets/service.py +580 -0
- mobile_agent/outlets/sheets.py +169 -0
- mobile_agent/outlets/slack.py +177 -0
- mobile_agent/outlets/store.py +194 -0
- mobile_agent/outlets/vault.py +80 -0
- mobile_agent/outlets/webhook.py +316 -0
- mobile_agent/output_contract.py +141 -0
- mobile_agent/owner_presence.py +211 -0
- mobile_agent/ownership.py +219 -0
- mobile_agent/packs/__init__.py +1 -0
- mobile_agent/packs/calendar.py +243 -0
- mobile_agent/packs/context.py +68 -0
- mobile_agent/packs/core.py +757 -0
- mobile_agent/packs/front_door.py +316 -0
- mobile_agent/packs/heal.py +214 -0
- mobile_agent/packs/health.py +122 -0
- mobile_agent/packs/insights.py +76 -0
- mobile_agent/packs/instagram.py +640 -0
- mobile_agent/packs/linkedin.py +106 -0
- mobile_agent/packs/maps.py +196 -0
- mobile_agent/packs/mcp.py +149 -0
- mobile_agent/packs/messages.py +270 -0
- mobile_agent/packs/notes.py +226 -0
- mobile_agent/packs/photos.py +166 -0
- mobile_agent/packs/pins.py +47 -0
- mobile_agent/packs/reddit.py +110 -0
- mobile_agent/packs/register.py +276 -0
- mobile_agent/packs/rows.py +108 -0
- mobile_agent/packs/safari.py +143 -0
- mobile_agent/packs/settings.py +253 -0
- mobile_agent/packs/social.py +300 -0
- mobile_agent/packs/threads.py +97 -0
- mobile_agent/packs/tiktok.py +455 -0
- mobile_agent/packs/tools.py +290 -0
- mobile_agent/packs/x.py +127 -0
- mobile_agent/packs/youtube.py +199 -0
- mobile_agent/parity.py +270 -0
- mobile_agent/paths.py +99 -0
- mobile_agent/perception.py +470 -0
- mobile_agent/permissions/__init__.py +32 -0
- mobile_agent/permissions/consequence.py +535 -0
- mobile_agent/permissions/exceptions.py +99 -0
- mobile_agent/permissions/gate.py +316 -0
- mobile_agent/permissions/outside.py +316 -0
- mobile_agent/permissions/policy.py +288 -0
- mobile_agent/permissions/project_policy.py +411 -0
- mobile_agent/permissions/register.py +148 -0
- mobile_agent/permissions/rules.py +291 -0
- mobile_agent/phone_io/__init__.py +146 -0
- mobile_agent/phone_io/cli.py +427 -0
- mobile_agent/phone_io/clipboard.py +78 -0
- mobile_agent/phone_io/controls.py +78 -0
- mobile_agent/phone_io/files.py +289 -0
- mobile_agent/phone_io/handoff.py +123 -0
- mobile_agent/phone_io/icloud.py +118 -0
- mobile_agent/phone_io/install.py +188 -0
- mobile_agent/phone_io/network_capture.py +188 -0
- mobile_agent/phone_io/network_cli.py +138 -0
- mobile_agent/phone_io/network_profiles.py +138 -0
- mobile_agent/phone_io/photos.py +512 -0
- mobile_agent/phone_io/recording.py +115 -0
- mobile_agent/phone_io/secrets.py +146 -0
- mobile_agent/phone_io/transfer.py +895 -0
- mobile_agent/phone_io/tunnel.py +129 -0
- mobile_agent/phone_ready.py +91 -0
- mobile_agent/pinning.py +158 -0
- mobile_agent/plan_gate.py +807 -0
- mobile_agent/plugins/__init__.py +3 -0
- mobile_agent/plugins/install.py +163 -0
- mobile_agent/plugins/manifest.py +242 -0
- mobile_agent/plugins/register.py +15 -0
- mobile_agent/plugins/routes.py +82 -0
- mobile_agent/plugins/service.py +322 -0
- mobile_agent/plugins/skills.py +76 -0
- mobile_agent/plugins/store.py +79 -0
- mobile_agent/pool.py +285 -0
- mobile_agent/power_watch.py +275 -0
- mobile_agent/premium_model.py +22 -0
- mobile_agent/project_env.py +291 -0
- mobile_agent/project_env_cli.py +74 -0
- mobile_agent/project_env_routes.py +55 -0
- mobile_agent/projects/__init__.py +27 -0
- mobile_agent/projects/ci.py +332 -0
- mobile_agent/projects/clone.py +339 -0
- mobile_agent/projects/drafts.py +158 -0
- mobile_agent/projects/folder.py +436 -0
- mobile_agent/projects/github.py +131 -0
- mobile_agent/projects/register.py +31 -0
- mobile_agent/projects/revisions.py +253 -0
- mobile_agent/projects/routes.py +234 -0
- mobile_agent/projects/runs.py +297 -0
- mobile_agent/projects/service.py +695 -0
- mobile_agent/projects/setup.py +84 -0
- mobile_agent/projects/store.py +379 -0
- mobile_agent/projects/sync.py +380 -0
- mobile_agent/projects/workflow_files.py +270 -0
- mobile_agent/replay_core/__init__.py +26 -0
- mobile_agent/replay_core/checks.py +594 -0
- mobile_agent/replay_core/engine.py +596 -0
- mobile_agent/replay_core/heal.py +364 -0
- mobile_agent/replay_core/path.py +240 -0
- mobile_agent/replay_core/recorder.py +206 -0
- mobile_agent/replay_core/store.py +171 -0
- mobile_agent/result_blocks.py +941 -0
- mobile_agent/run.py +642 -0
- mobile_agent/run_delivery.py +123 -0
- mobile_agent/run_executor.py +200 -0
- mobile_agent/run_export.py +284 -0
- mobile_agent/run_gif.py +891 -0
- mobile_agent/runner_hints.py +86 -0
- mobile_agent/runner_renewal.py +205 -0
- mobile_agent/runtime_create.py +549 -0
- mobile_agent/runtime_driver.py +458 -0
- mobile_agent/runtime_limits.py +319 -0
- mobile_agent/runtime_queue.py +394 -0
- mobile_agent/runtime_runs.py +383 -0
- mobile_agent/runtime_status.py +181 -0
- mobile_agent/runtime_teach.py +295 -0
- mobile_agent/runtime_work.py +599 -0
- mobile_agent/screen_text.py +101 -0
- mobile_agent/secret_filter.py +113 -0
- mobile_agent/serve.py +160 -0
- mobile_agent/server.py +363 -0
- mobile_agent/service_support.py +42 -0
- mobile_agent/settle/__init__.py +1 -0
- mobile_agent/settle/predict.py +102 -0
- mobile_agent/settle/replay.py +92 -0
- mobile_agent/settle/store.py +93 -0
- mobile_agent/settle/stream.py +43 -0
- mobile_agent/setup_cli.py +225 -0
- mobile_agent/setup_errors.py +87 -0
- mobile_agent/setup_service.py +527 -0
- mobile_agent/share.py +104 -0
- mobile_agent/signing.py +184 -0
- mobile_agent/sim/__init__.py +6 -0
- mobile_agent/sim/__main__.py +26 -0
- mobile_agent/sim/api.py +952 -0
- mobile_agent/sim/apps.py +172 -0
- mobile_agent/sim/biometrics.py +84 -0
- mobile_agent/sim/cli.py +271 -0
- mobile_agent/sim/doctor.py +293 -0
- mobile_agent/sim/governor.py +90 -0
- mobile_agent/sim/holders.py +88 -0
- mobile_agent/sim/models.py +62 -0
- mobile_agent/sim/registry.py +161 -0
- mobile_agent/sim/simctl.py +269 -0
- mobile_agent/sim/wda.py +618 -0
- mobile_agent/sim/xcode.py +74 -0
- mobile_agent/skills/__init__.py +12 -0
- mobile_agent/skills/codes.py +597 -0
- mobile_agent/skills/notifications.py +281 -0
- mobile_agent/skills/open_url.py +127 -0
- mobile_agent/smart_events.py +588 -0
- mobile_agent/smart_result.py +490 -0
- mobile_agent/smart_run.py +413 -0
- mobile_agent/state.py +662 -0
- mobile_agent/states.py +217 -0
- mobile_agent/steering.py +70 -0
- mobile_agent/style.py +178 -0
- mobile_agent/supervised.py +234 -0
- mobile_agent/swift/helper_bt_hid.swift +534 -0
- mobile_agent/swift/helper_doc_text.swift +184 -0
- mobile_agent/swift/helper_draft_reply.swift +170 -0
- mobile_agent/swift/helper_map.swift +110 -0
- mobile_agent/swift/helper_mirroring.swift +1099 -0
- mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
- mobile_agent/swift/helper_pdf.swift +100 -0
- mobile_agent/swift/helper_phone_audio.swift +993 -0
- mobile_agent/swift/helper_phone_screen.swift +416 -0
- mobile_agent/swift/helper_screen_text.swift +24 -0
- mobile_agent/swift/helper_transfer.swift +246 -0
- mobile_agent/swift/ocr.swift +22 -0
- mobile_agent/task_policy.py +337 -0
- mobile_agent/team_signing/__init__.py +34 -0
- mobile_agent/team_signing/apple_ca.py +37 -0
- mobile_agent/team_signing/bundle.py +136 -0
- mobile_agent/team_signing/cli.py +208 -0
- mobile_agent/team_signing/identity.py +248 -0
- mobile_agent/team_signing/ledger.py +105 -0
- mobile_agent/team_signing/prebuilt.py +61 -0
- mobile_agent/team_signing/resign.py +256 -0
- mobile_agent/team_signing/service.py +382 -0
- mobile_agent/team_signing/tooling.py +243 -0
- mobile_agent/team_signing/writes.py +268 -0
- mobile_agent/terminal_image.py +81 -0
- mobile_agent/testkit/__init__.py +1 -0
- mobile_agent/testkit/a11y_baseline.py +132 -0
- mobile_agent/testkit/api.py +403 -0
- mobile_agent/testkit/capsule.py +400 -0
- mobile_agent/testkit/capsule_cli.py +57 -0
- mobile_agent/testkit/cli.py +948 -0
- mobile_agent/testkit/cli_arguments.py +193 -0
- mobile_agent/testkit/comparison.py +288 -0
- mobile_agent/testkit/comparison_cli.py +41 -0
- mobile_agent/testkit/discover.py +257 -0
- mobile_agent/testkit/editor.py +243 -0
- mobile_agent/testkit/execute.py +515 -0
- mobile_agent/testkit/github_checks.py +248 -0
- mobile_agent/testkit/gitref.py +66 -0
- mobile_agent/testkit/heal_review.py +176 -0
- mobile_agent/testkit/health.py +480 -0
- mobile_agent/testkit/history.py +255 -0
- mobile_agent/testkit/hosted_results.py +321 -0
- mobile_agent/testkit/input_files.py +173 -0
- mobile_agent/testkit/jobs.py +285 -0
- mobile_agent/testkit/junit_verdict.py +33 -0
- mobile_agent/testkit/maestro.py +802 -0
- mobile_agent/testkit/mcp.py +562 -0
- mobile_agent/testkit/mcp_comparison.py +88 -0
- mobile_agent/testkit/minimize.py +471 -0
- mobile_agent/testkit/minimizer_cli.py +48 -0
- mobile_agent/testkit/notify.py +212 -0
- mobile_agent/testkit/phone_video.py +191 -0
- mobile_agent/testkit/pr.py +491 -0
- mobile_agent/testkit/pr_report.py +562 -0
- mobile_agent/testkit/prbuild.py +148 -0
- mobile_agent/testkit/proof_report.py +55 -0
- mobile_agent/testkit/provenance.py +119 -0
- mobile_agent/testkit/prrun.py +244 -0
- mobile_agent/testkit/recent.py +166 -0
- mobile_agent/testkit/recipes.py +146 -0
- mobile_agent/testkit/record.py +204 -0
- mobile_agent/testkit/register.py +288 -0
- mobile_agent/testkit/reports.py +998 -0
- mobile_agent/testkit/repro.py +432 -0
- mobile_agent/testkit/scenario.py +440 -0
- mobile_agent/testkit/scenario_cli.py +37 -0
- mobile_agent/testkit/snapshots_cli.py +60 -0
- mobile_agent/testkit/suite.py +670 -0
- mobile_agent/testkit/targets.py +497 -0
- mobile_agent/testkit/xctest.py +378 -0
- mobile_agent/threads/__init__.py +1 -0
- mobile_agent/threads/chat_once.py +264 -0
- mobile_agent/threads/cli.py +740 -0
- mobile_agent/threads/client.py +326 -0
- mobile_agent/threads/context.py +246 -0
- mobile_agent/threads/mcp.py +739 -0
- mobile_agent/threads/recent.py +34 -0
- mobile_agent/threads/register.py +54 -0
- mobile_agent/threads/routes.py +188 -0
- mobile_agent/threads/service.py +975 -0
- mobile_agent/threads/store.py +361 -0
- mobile_agent/threads/summary.py +117 -0
- mobile_agent/threads/term.py +101 -0
- mobile_agent/threads/workflow_cli.py +104 -0
- mobile_agent/tls.py +36 -0
- mobile_agent/traces.py +271 -0
- mobile_agent/tracks.py +235 -0
- mobile_agent/transport.py +271 -0
- mobile_agent/triggers/__init__.py +1 -0
- mobile_agent/triggers/actions.py +204 -0
- mobile_agent/triggers/model.py +146 -0
- mobile_agent/triggers/register.py +29 -0
- mobile_agent/triggers/routes.py +101 -0
- mobile_agent/triggers/service.py +387 -0
- mobile_agent/triggers/store.py +142 -0
- mobile_agent/tui/__init__.py +89 -0
- mobile_agent/tui/app.py +582 -0
- mobile_agent/tui/chrome.py +321 -0
- mobile_agent/tui/commands.py +375 -0
- mobile_agent/tui/demo_phone.py +369 -0
- mobile_agent/tui/images.py +29 -0
- mobile_agent/tui/keys.py +64 -0
- mobile_agent/tui/prompt.py +195 -0
- mobile_agent/tui/render.py +395 -0
- mobile_agent/tui/screens.py +180 -0
- mobile_agent/tui/session.py +308 -0
- mobile_agent/tui/tasks.py +287 -0
- mobile_agent/tui/theme.py +118 -0
- mobile_agent/tui/widgets.py +272 -0
- mobile_agent/update_check.py +213 -0
- mobile_agent/usage.py +184 -0
- mobile_agent/verify/__init__.py +0 -0
- mobile_agent/verify/__main__.py +41 -0
- mobile_agent/verify/a11y.py +189 -0
- mobile_agent/verify/actions.py +290 -0
- mobile_agent/verify/assertions.py +570 -0
- mobile_agent/verify/audit.py +312 -0
- mobile_agent/verify/business.py +221 -0
- mobile_agent/verify/business_run.py +131 -0
- mobile_agent/verify/business_state.py +168 -0
- mobile_agent/verify/check_schema.py +398 -0
- mobile_agent/verify/checks.py +614 -0
- mobile_agent/verify/cli.py +423 -0
- mobile_agent/verify/conditions.py +483 -0
- mobile_agent/verify/crashes.py +321 -0
- mobile_agent/verify/device_audit.py +246 -0
- mobile_agent/verify/dialogs.py +287 -0
- mobile_agent/verify/evidence.py +705 -0
- mobile_agent/verify/fixtures.py +305 -0
- mobile_agent/verify/flow.py +213 -0
- mobile_agent/verify/l10n.py +141 -0
- mobile_agent/verify/netcerts.py +67 -0
- mobile_agent/verify/netproxy.py +356 -0
- mobile_agent/verify/netrun.py +81 -0
- mobile_agent/verify/netshim.m +28 -0
- mobile_agent/verify/netshim.py +48 -0
- mobile_agent/verify/netstubs.py +132 -0
- mobile_agent/verify/overlay.py +174 -0
- mobile_agent/verify/perf.py +239 -0
- mobile_agent/verify/proof.py +192 -0
- mobile_agent/verify/replay_admission.py +48 -0
- mobile_agent/verify/report.py +952 -0
- mobile_agent/verify/run_cleanup.py +67 -0
- mobile_agent/verify/run_links.py +48 -0
- mobile_agent/verify/run_perf.py +44 -0
- mobile_agent/verify/run_secrets.py +39 -0
- mobile_agent/verify/runner.py +976 -0
- mobile_agent/verify/runs.py +57 -0
- mobile_agent/verify/screen_coverage.py +181 -0
- mobile_agent/verify/smart.py +106 -0
- mobile_agent/verify/snapshot_run.py +114 -0
- mobile_agent/verify/snapshots.py +306 -0
- mobile_agent/verify/sound.py +335 -0
- mobile_agent/verify/source_observation.py +130 -0
- mobile_agent/verify/system_surfaces/__init__.py +7 -0
- mobile_agent/verify/system_surfaces/play.py +106 -0
- mobile_agent/verify/system_surfaces/spec.py +139 -0
- mobile_agent/verify/system_surfaces/stage.py +138 -0
- mobile_agent/verify/system_surfaces/surfaces.py +121 -0
- mobile_agent/verify/tree.py +223 -0
- mobile_agent/verify/verdict.py +377 -0
- mobile_agent/verify/wire.py +392 -0
- mobile_agent/verify/wirestore.py +176 -0
- mobile_agent/waiting/__init__.py +1 -0
- mobile_agent/waiting/contacts.py +73 -0
- mobile_agent/waiting/drafts.py +136 -0
- mobile_agent/waiting/job.py +145 -0
- mobile_agent/waiting/messages.py +174 -0
- mobile_agent/waiting/rank.py +115 -0
- mobile_agent/waiting/register.py +26 -0
- mobile_agent/watch.py +502 -0
- mobile_agent/wda/__init__.py +2 -0
- mobile_agent/wda/frame_clock.py +646 -0
- mobile_agent/wda/frame_source.py +325 -0
- mobile_agent/wda/frame_store.py +115 -0
- mobile_agent/wda/gate.py +163 -0
- mobile_agent/wda/gestures.py +291 -0
- mobile_agent/wda/guards.py +94 -0
- mobile_agent/wda/identity.py +55 -0
- mobile_agent/wda/observe.py +884 -0
- mobile_agent/wda/patches.py +856 -0
- mobile_agent/wda/point_gestures.py +150 -0
- mobile_agent/wda/source.py +32 -0
- mobile_agent/wda/system_dialogs.py +571 -0
- mobile_agent/wda/text.py +694 -0
- mobile_agent/wda/timeouts.py +77 -0
- mobile_agent/wda/video.py +505 -0
- mobile_agent/wda/watchdog.py +243 -0
- mobile_agent/wireless/__init__.py +15 -0
- mobile_agent/wireless/api.py +354 -0
- mobile_agent/wireless/cli.py +287 -0
- mobile_agent/wireless/devicectl.py +229 -0
- mobile_agent/wireless/exposure.py +33 -0
- mobile_agent/wireless/mobster-wifi-pair.c +122 -0
- mobile_agent/wireless/monitor.py +332 -0
- mobile_agent/wireless/network.py +334 -0
- mobile_agent/wireless/onboarding.py +293 -0
- mobile_agent/wireless/pair.py +124 -0
- mobile_agent/wireless/probe.py +322 -0
- mobile_agent/wireless/register.py +8 -0
- mobile_agent/wireless/relay.py +179 -0
- mobile_agent/wireless/result.py +87 -0
- mobile_agent/wireless/store.py +85 -0
- mobile_agent/wireless/switch.py +77 -0
- mobile_agent/wireless/transport.py +118 -0
- mobile_agent/wireless/words.py +81 -0
- mobile_agent/wireless/xctestrun.py +83 -0
- mobile_agent/workflow_tools.py +59 -0
- mobile_agent/workflows/__init__.py +2 -0
- mobile_agent/workflows/brief.py +201 -0
- mobile_agent/workflows/cron.py +110 -0
- mobile_agent/workflows/health.py +257 -0
- mobile_agent/workflows/history.py +126 -0
- mobile_agent/workflows/records.py +442 -0
- mobile_agent/workflows/runs.py +1000 -0
- mobile_agent/workflows/scheduler.py +134 -0
- mobile_agent/workflows/service.py +973 -0
- mobile_agent/workflows/starters.py +285 -0
- mobile_agent/workflows/triggers.py +248 -0
- mobile_agent/xray/__init__.py +1 -0
- mobile_agent/xray/app_cli.py +116 -0
- mobile_agent/xray/cli.py +21 -0
- mobile_agent/xray/crawl.py +754 -0
- mobile_agent/xray/diff.py +171 -0
- mobile_agent/xray/diff_cli.py +43 -0
- mobile_agent/xray/job.py +184 -0
- mobile_agent/xray/paywall.py +188 -0
- mobile_agent/xray/policy.py +305 -0
- mobile_agent/xray/register.py +43 -0
- mobile_agent/xray/report.py +283 -0
- mobile_agent/xray/screens.py +117 -0
- mobster_cli-0.4.3.dist-info/METADATA +93 -0
- mobster_cli-0.4.3.dist-info/RECORD +1009 -0
- mobster_cli-0.4.3.dist-info/WHEEL +5 -0
- mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
- mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
- mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
"""Starting and running workflows: each dispatch is claimed before it acts, and an unknown outcome never runs again."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .. import engines, harness_api, secret_filter
|
|
11
|
+
from ..api_errors import APIError, nothing_started
|
|
12
|
+
from ..graph import chain as graph_chain
|
|
13
|
+
from ..graph import dispatch as graph_dispatch
|
|
14
|
+
from ..graph import nodes as graph_nodes
|
|
15
|
+
from ..graph import rerun as graph_rerun
|
|
16
|
+
from ..graph import run as graph_run
|
|
17
|
+
from ..graph import schema as graph_schema
|
|
18
|
+
from ..graph.run import GraphRun
|
|
19
|
+
from ..journal import JournalError
|
|
20
|
+
from . import health, starters
|
|
21
|
+
from . import records as batch
|
|
22
|
+
from .cron import MIN_INTERVAL_MS, next_occurrences
|
|
23
|
+
|
|
24
|
+
THREAD_ID = re.compile(r"[a-f0-9]{12}")
|
|
25
|
+
MAX_DISPATCHES = 10000
|
|
26
|
+
LATE_TOLERANCE_MS = 60_000
|
|
27
|
+
DAY_MS = 86_400_000
|
|
28
|
+
# Finished dispatch records protect replays for a week. A slot's claim advances its schedule; a started task's
|
|
29
|
+
# run-journal request record keeps refusing a replay after that, while old dispatches can be pruned.
|
|
30
|
+
DISPATCH_RETENTION_MS = 7 * DAY_MS
|
|
31
|
+
# A scheduled dispatch's key (tick): workflow:<id>:scheduled:<slot>. Only these alert.
|
|
32
|
+
SCHEDULED = re.compile(r"workflow:([0-9a-f]{1,32}):scheduled:\d+")
|
|
33
|
+
# A scheduled run waiting on an approval this long gets an alert (alerts.APPROVAL_WAIT_S).
|
|
34
|
+
APPROVAL_ALERT_MS = 60_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Dispatch:
|
|
39
|
+
key: str
|
|
40
|
+
workflow: dict
|
|
41
|
+
text: str | None = None # the filled steps (None: the defaults)
|
|
42
|
+
thread: str | None = None # run in this conversation ("Run in this conversation")
|
|
43
|
+
new_conversation: bool = False # a new conversation even when the workflow has a home one
|
|
44
|
+
origin: str | None = None # the request's origin (X-Mobster-Origin); None for a schedule
|
|
45
|
+
values: dict | None = None # the blanks' values a run gave (None: the defaults)
|
|
46
|
+
rows: list | None = None # an each repeat's rows a run gave (None: the workflow's own)
|
|
47
|
+
remote_grant: str | None = None # the Mobster Remote app that started it (origin "remote" only)
|
|
48
|
+
test: bool = False # a test run: the draft, with its pins (graph/rerun.py)
|
|
49
|
+
rerun: dict | None = None # {"from" | "only" | "resume": ...}: a re-run of an earlier run
|
|
50
|
+
body: dict | None = None # a webhook's JSON body: the webhook trigger's body, data (masked, 16 KB)
|
|
51
|
+
trigger: str | None = None # shortcut, notification or app: what started it (its trigger's entry)
|
|
52
|
+
input: str | None = None # what the Shortcut sent: the trigger's input, data
|
|
53
|
+
event: dict | None = None # a notification, or an app trigger's {row, rows}: data (triggers.py)
|
|
54
|
+
# the Settings trigger that started it, {id, name}: its tasks' run field trigger
|
|
55
|
+
started_by: dict | None = None
|
|
56
|
+
project_secrets: bool = True # trusted local dispatch or private app session, never request JSON
|
|
57
|
+
prove: bool = False # a test run that, when it completes, pins its steps' outputs (prove)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Admission:
|
|
62
|
+
"""What a dispatch resolves before it creates a task"""
|
|
63
|
+
thread: dict | None
|
|
64
|
+
made: bool
|
|
65
|
+
home: bool
|
|
66
|
+
extras: dict
|
|
67
|
+
engine: str | None
|
|
68
|
+
device: str | None
|
|
69
|
+
origin: str
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def thread_id(self):
|
|
73
|
+
return self.thread and self.thread["id"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WorkflowDispatchUncertain(JournalError):
|
|
77
|
+
"""Canonical run creation succeeded, but saving its workflow linkage failed."""
|
|
78
|
+
def __init__(self, run_id):
|
|
79
|
+
super().__init__("The task may already have started. Check its run status before making another request.")
|
|
80
|
+
self.run_id = run_id
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WorkflowRuns:
|
|
84
|
+
@staticmethod
|
|
85
|
+
def live(workflow):
|
|
86
|
+
"""The graph that runs: the published one (``graph`` is the draft the canvas edits)."""
|
|
87
|
+
published = workflow.get("published")
|
|
88
|
+
return published["graph"] if isinstance(published, dict) and graph_schema.check_graph(
|
|
89
|
+
published.get("graph")) else workflow["graph"]
|
|
90
|
+
|
|
91
|
+
def _claim(self, workflow, key, scheduled_at, outcome="claimed"):
|
|
92
|
+
with self.journal.lock:
|
|
93
|
+
previous = self.journal.connection.execute("SELECT outcome,run_id FROM workflow_dispatches WHERE key=?",
|
|
94
|
+
(key,)).fetchone()
|
|
95
|
+
if previous:
|
|
96
|
+
return previous
|
|
97
|
+
now = self.now()
|
|
98
|
+
with self.journal.transaction() as connection:
|
|
99
|
+
self._prune(connection, now)
|
|
100
|
+
if connection.execute("SELECT count(*) FROM workflow_dispatches").fetchone()[0] >= MAX_DISPATCHES:
|
|
101
|
+
raise JournalError("Workflow dispatch retention limit reached; no new dispatch authorized")
|
|
102
|
+
connection.execute("INSERT INTO workflow_dispatches VALUES (?,?,?,?,?,NULL)",
|
|
103
|
+
(key, workflow["id"], scheduled_at, now, outcome))
|
|
104
|
+
workflow["lastOutcome"] = outcome
|
|
105
|
+
if scheduled_at is not None:
|
|
106
|
+
workflow["nextAt"] = next_occurrences(workflow["cron"], workflow["timezone"],
|
|
107
|
+
max(now, scheduled_at))[0]
|
|
108
|
+
self._save(connection, workflow)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _prune(connection, now):
|
|
113
|
+
"""Drop finished dispatch records past DISPATCH_RETENTION_MS."""
|
|
114
|
+
finished = "outcome NOT IN ('claimed','started')"
|
|
115
|
+
connection.execute(f"DELETE FROM workflow_dispatches WHERE claimed_at<? AND {finished}",
|
|
116
|
+
(now - DISPATCH_RETENTION_MS,))
|
|
117
|
+
excess = connection.execute("SELECT count(*) FROM workflow_dispatches").fetchone()[0] - MAX_DISPATCHES + 1
|
|
118
|
+
if excess > 0:
|
|
119
|
+
connection.execute(f"""DELETE FROM workflow_dispatches WHERE rowid IN (SELECT rowid FROM workflow_dispatches
|
|
120
|
+
WHERE {finished} AND (claimed_at<? OR outcome LIKE 'skipped_%') ORDER BY claimed_at LIMIT ?)""",
|
|
121
|
+
(now - DAY_MS, excess))
|
|
122
|
+
|
|
123
|
+
def _outcome(self, key, outcome, run_id=None, thread_id=None, home=False):
|
|
124
|
+
with self.journal.transaction() as connection:
|
|
125
|
+
self._save_outcome(connection, key, outcome, run_id, thread_id, home)
|
|
126
|
+
|
|
127
|
+
def _save_outcome(self, connection, key, outcome, run_id=None, thread_id=None, home=False):
|
|
128
|
+
row = connection.execute("SELECT workflow_id FROM workflow_dispatches WHERE key=?", (key,)).fetchone()
|
|
129
|
+
if row is None:
|
|
130
|
+
raise JournalError("Workflow dispatch record is missing")
|
|
131
|
+
workflow = self._get(row[0])
|
|
132
|
+
success = self._success(workflow, outcome, run_id)
|
|
133
|
+
latest = connection.execute(
|
|
134
|
+
"SELECT key FROM workflow_dispatches WHERE workflow_id=? ORDER BY rowid DESC LIMIT 1",
|
|
135
|
+
(row[0],)).fetchone()[0]
|
|
136
|
+
connection.execute("UPDATE workflow_dispatches SET outcome=?,run_id=? WHERE key=?", (outcome, run_id, key))
|
|
137
|
+
changed = False
|
|
138
|
+
if home and thread_id and workflow.get("homeThreadId") != thread_id:
|
|
139
|
+
workflow["homeThreadId"], changed = thread_id, True
|
|
140
|
+
if success is not None and (success["at"] or 0) > ((workflow.get("lastSuccess") or {}).get("at") or 0):
|
|
141
|
+
# Its newest verified success, kept with the workflow: the dispatch records go after a week.
|
|
142
|
+
workflow["lastSuccess"], changed = success, True
|
|
143
|
+
if latest == key:
|
|
144
|
+
workflow.update(lastOutcome=outcome)
|
|
145
|
+
if run_id is not None and not str(run_id).startswith("batch:"):
|
|
146
|
+
workflow["lastRunId"] = run_id
|
|
147
|
+
if thread_id is not None:
|
|
148
|
+
workflow["lastThreadId"] = thread_id
|
|
149
|
+
changed = True
|
|
150
|
+
if changed:
|
|
151
|
+
self._save(connection, workflow)
|
|
152
|
+
|
|
153
|
+
def _success(self, workflow, outcome, run_id):
|
|
154
|
+
"""The success an ended attempt records ({at, runId, line}), or None"""
|
|
155
|
+
if outcome in health.GOING or not isinstance(run_id, str) or run_id.startswith("batch:"):
|
|
156
|
+
return None
|
|
157
|
+
run = (getattr(self.runtime, "runs", None) or {}).get(run_id)
|
|
158
|
+
if run is None or getattr(run, "finished_at", None) is None:
|
|
159
|
+
return None
|
|
160
|
+
done = health.verified(run) if health.is_plain(workflow) else outcome == "completed"
|
|
161
|
+
return health.success_of(run) if done else None
|
|
162
|
+
|
|
163
|
+
def attempts(self, identifier, limit=20):
|
|
164
|
+
"""The workflow's newest dispatches, newest first: (outcome, run id, claimed at in ms)."""
|
|
165
|
+
with self.journal.lock:
|
|
166
|
+
return self.journal.connection.execute(
|
|
167
|
+
"SELECT outcome,run_id,claimed_at FROM workflow_dispatches WHERE workflow_id=? ORDER BY rowid DESC "
|
|
168
|
+
"LIMIT ?",
|
|
169
|
+
(identifier, limit)).fetchall()
|
|
170
|
+
|
|
171
|
+
def health(self, workflow):
|
|
172
|
+
"""What the workflow's row says about it (health.py): its last verified success and what needs you."""
|
|
173
|
+
with self.runtime.lock:
|
|
174
|
+
runs = dict(self.runtime.runs)
|
|
175
|
+
filled = (self._account_values(workflow) or {}) if workflow.get("enabled") else {}
|
|
176
|
+
return health.workflow_health(workflow, self.attempts(workflow["id"]), runs, filled)
|
|
177
|
+
|
|
178
|
+
def list_with_health(self):
|
|
179
|
+
"""GET /api/workflows: every workflow with its ``health``."""
|
|
180
|
+
return [{**workflow, "health": self.health(workflow)} for workflow in self.list()]
|
|
181
|
+
|
|
182
|
+
def _record_started(self, key, run_id, thread_id=None, home=False):
|
|
183
|
+
try:
|
|
184
|
+
self._outcome(key, "started", run_id, thread_id, home)
|
|
185
|
+
except JournalError:
|
|
186
|
+
raise WorkflowDispatchUncertain(run_id) from None
|
|
187
|
+
|
|
188
|
+
def _target(self, service, thread_id):
|
|
189
|
+
"""The conversation a request names, checked before anything is claimed"""
|
|
190
|
+
if not isinstance(thread_id, str) or not THREAD_ID.fullmatch(thread_id):
|
|
191
|
+
raise ValueError("threadId is a conversation's 12-character id")
|
|
192
|
+
if service is None:
|
|
193
|
+
raise APIError("This Mobster can't run a workflow in a conversation. Update Mobster, then try again.",
|
|
194
|
+
409, "conversations_unavailable")
|
|
195
|
+
thread = service.store.get(thread_id)
|
|
196
|
+
if thread is None:
|
|
197
|
+
raise APIError("That conversation was deleted.", 404, "thread_not_found")
|
|
198
|
+
if thread.get("archived"):
|
|
199
|
+
raise APIError("That conversation is archived. Restore it to continue.", 409, "thread_archived")
|
|
200
|
+
return thread
|
|
201
|
+
|
|
202
|
+
def _conversation(self, service, workflow, dispatch):
|
|
203
|
+
"""(thread, made, home): the run's conversation, whether it was made for it, whether it is now the home."""
|
|
204
|
+
if dispatch.thread:
|
|
205
|
+
thread = service.store.get(dispatch.thread)
|
|
206
|
+
if thread is None or thread.get("archived"):
|
|
207
|
+
raise APIError("That conversation is no longer open.", 409, "thread_unavailable")
|
|
208
|
+
return thread, False, False
|
|
209
|
+
same = bool(workflow.get("sameConversation")) and not dispatch.new_conversation
|
|
210
|
+
home = workflow.get("homeThreadId") if same else None
|
|
211
|
+
thread = service.store.get(home) if home else None
|
|
212
|
+
if thread is not None and not thread.get("archived"):
|
|
213
|
+
return thread, False, False
|
|
214
|
+
# A new conversation, named after the workflow (a title is at most 80 characters, cut at a word).
|
|
215
|
+
title = workflow["name"] if len(workflow["name"]) <= 80 else workflow["name"][:80].rsplit(" ", 1)[0]
|
|
216
|
+
return service.create_thread(title), True, same
|
|
217
|
+
|
|
218
|
+
def _drop(self, service, thread_id):
|
|
219
|
+
"""A conversation made for a run that never started goes again."""
|
|
220
|
+
try:
|
|
221
|
+
service.store.delete_thread(thread_id)
|
|
222
|
+
service.published_thread({"id": thread_id}, "thread_deleted")
|
|
223
|
+
except Exception: # noqa: BLE001 -- an empty conversation left behind is harmless
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def _admit(self, service, dispatch):
|
|
227
|
+
"""The conversation, extras, engine, device and origin a run starts with (plain or batch)."""
|
|
228
|
+
workflow = dispatch.workflow
|
|
229
|
+
# The engine and the device ride along only when one was saved.
|
|
230
|
+
engine, device = engines.read_engine(workflow.get("engine")), workflow.get("device")
|
|
231
|
+
account = workflow.get("account")
|
|
232
|
+
extras = {"account": account} if account else {}
|
|
233
|
+
if dispatch.remote_grant:
|
|
234
|
+
extras["remoteGrant"] = dispatch.remote_grant
|
|
235
|
+
if dispatch.started_by and "trigger" in harness_api.run_fields():
|
|
236
|
+
extras["trigger"] = dispatch.started_by # "Started by trigger: <name>" (the triggers track's run field)
|
|
237
|
+
file = workflow.get("file")
|
|
238
|
+
if isinstance(file, dict) and file.get("projectId") and "project" in harness_api.run_fields():
|
|
239
|
+
project = harness_api.clean_extras({"project": {"id": file["projectId"]}}, self.runtime)["project"]
|
|
240
|
+
extras["project"] = project
|
|
241
|
+
if "projectEnv" in harness_api.run_fields():
|
|
242
|
+
extras["projectEnv"] = project["path"]
|
|
243
|
+
thread, made, home = None, False, False
|
|
244
|
+
if service is not None:
|
|
245
|
+
thread, made, home = self._conversation(service, workflow, dispatch)
|
|
246
|
+
extras["threadId"] = thread["id"]
|
|
247
|
+
from ..threads.service import ORIGIN_VIA # threads is optional: imported where it is present
|
|
248
|
+
if ORIGIN_VIA.get(dispatch.origin):
|
|
249
|
+
extras["via"] = ORIGIN_VIA[dispatch.origin]
|
|
250
|
+
if dispatch.origin in harness_api.INTERACTIVE and "askUser" in harness_api.run_fields():
|
|
251
|
+
extras["askUser"] = True # someone is watching this conversation and can answer
|
|
252
|
+
# Run here: the conversation's own phone, when the workflow names none. A workflow for an account
|
|
253
|
+
# runs on the account's phone (accounts/routing.py), never on the conversation's.
|
|
254
|
+
if not device and not account and thread.get("device") and self._devices_here():
|
|
255
|
+
device = thread["device"]
|
|
256
|
+
origin = (dispatch.origin if dispatch.origin in harness_api.UNATTENDED_ORIGINS
|
|
257
|
+
else "api" if not dispatch.project_secrets and extras.get("projectEnv")
|
|
258
|
+
else "schedule" if graph_dispatch.unattended(dispatch.key) else "workflow")
|
|
259
|
+
return Admission(thread, made, home, extras, engine, device, origin)
|
|
260
|
+
|
|
261
|
+
def _dispatch(self, dispatch):
|
|
262
|
+
workflow, key = dispatch.workflow, dispatch.key
|
|
263
|
+
refused = _unsupported(workflow, draft=dispatch.test)
|
|
264
|
+
if refused:
|
|
265
|
+
self._outcome(key, "dispatch_failed")
|
|
266
|
+
raise refused
|
|
267
|
+
if dispatch.test or dispatch.rerun or not graph_chain.is_plain(self.live(workflow)):
|
|
268
|
+
return self._dispatch_batch(dispatch)
|
|
269
|
+
service, admission = self._threads(), None
|
|
270
|
+
try:
|
|
271
|
+
text = dispatch.text if dispatch.text is not None else starters.fill(workflow["goal"],
|
|
272
|
+
self._account_values(workflow))
|
|
273
|
+
admission = self._admit(service, dispatch)
|
|
274
|
+
extras, engine, device = admission.extras, admission.engine, admission.device
|
|
275
|
+
send_to = self._send_extras(workflow, dispatch.values)
|
|
276
|
+
if send_to is not None:
|
|
277
|
+
extras["sendTo"] = send_to
|
|
278
|
+
if dispatch.input is not None or dispatch.event is not None:
|
|
279
|
+
# What a Shortcut sent, or the notification: the task reads it as data, fenced, never as its words.
|
|
280
|
+
extras["workflowInput"] = graph_nodes.as_text(dispatch.event if dispatch.event is not None
|
|
281
|
+
else dispatch.input)
|
|
282
|
+
# With no key for Mobster's agent, the person's ready coding agent does the task, as the composer sends a
|
|
283
|
+
# typed one (DR-56). A replay or a no-model task keeps its engine.
|
|
284
|
+
if not engine and extras.get("replay") != "only" and not harness_api.calls_no_model(extras):
|
|
285
|
+
engine = getattr(self.runtime, "plan_agent", lambda: None)()
|
|
286
|
+
# A saved task with no device runs on the primary phone, never on whichever phone was used last: a
|
|
287
|
+
# schedule fires unattended, and "text my team" must not go out from another phone.
|
|
288
|
+
run, replayed = self.runtime.create(workflow.get("appId"), text, "live", key,
|
|
289
|
+
output_schema=workflow["outputSchema"],
|
|
290
|
+
output_format=workflow["outputFormat"],
|
|
291
|
+
**({"engine": engine} if engine else {}),
|
|
292
|
+
**({"device": device} if device else
|
|
293
|
+
{"default_device": "primary"} if self._devices_here() else {}),
|
|
294
|
+
**({"extras": extras} if extras else {}),
|
|
295
|
+
origin=admission.origin, _workflow_id=workflow["id"])
|
|
296
|
+
except APIError as exc:
|
|
297
|
+
request = self.journal.lookup(key)
|
|
298
|
+
if request:
|
|
299
|
+
self._record_started(key, request[0], admission and admission.thread_id, admission and admission.home)
|
|
300
|
+
raise
|
|
301
|
+
if admission and admission.made:
|
|
302
|
+
self._drop(service, admission.thread_id)
|
|
303
|
+
outcome = ("skipped_busy" if exc.code in {"run_active", "device_busy"} else
|
|
304
|
+
"skipped_offline" if exc.code in {"device_unavailable", "shutting_down", "device_not_found"}
|
|
305
|
+
else "skipped_monthly_limit" if exc.code == "monthly_limit_reached"
|
|
306
|
+
else "dispatch_failed")
|
|
307
|
+
self._outcome(key, outcome)
|
|
308
|
+
raise nothing_started(exc) from None # no task under this request's key: none was recorded or sent
|
|
309
|
+
except (ValueError, JournalError):
|
|
310
|
+
request = self.journal.lookup(key)
|
|
311
|
+
if request: # the run was created: keep it, and never create it again
|
|
312
|
+
self._record_started(key, request[0], admission and admission.thread_id, admission and admission.home)
|
|
313
|
+
raise
|
|
314
|
+
if admission and admission.made:
|
|
315
|
+
self._drop(service, admission.thread_id)
|
|
316
|
+
self._outcome(key, "dispatch_failed")
|
|
317
|
+
raise
|
|
318
|
+
# If this commit fails, the durable claim and canonical run idempotency
|
|
319
|
+
# record remain. Recovery links them without ever invoking create again.
|
|
320
|
+
self._record_started(key, run.id, admission.thread_id, admission.home)
|
|
321
|
+
return run, replayed
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _request_key(identifier, request_key, values=None, thread_id=None, new_conversation=False, rows=None,
|
|
325
|
+
debug=None):
|
|
326
|
+
asked = values is not None or thread_id is not None or new_conversation
|
|
327
|
+
try:
|
|
328
|
+
fingerprint = starters.values_fingerprint(values, thread_id, new_conversation) if asked else None
|
|
329
|
+
except ValueError: # typed values (a graph's list or object inputs) aren't a starter's short text
|
|
330
|
+
fingerprint = hashlib.sha256(json.dumps({"values": values, "thread": thread_id, "new": new_conversation},
|
|
331
|
+
sort_keys=True, ensure_ascii=False,
|
|
332
|
+
default=str).encode()).hexdigest()
|
|
333
|
+
digest = request_key if not asked else request_key + "\n" + fingerprint
|
|
334
|
+
if rows is not None:
|
|
335
|
+
digest += "\n" + hashlib.sha256(json.dumps(rows, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
336
|
+
if debug:
|
|
337
|
+
digest += "\n" + hashlib.sha256(json.dumps(debug, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
338
|
+
return f"workflow:{identifier}:manual:{hashlib.sha256(digest.encode()).hexdigest()}"
|
|
339
|
+
|
|
340
|
+
def _check_values(self, workflow, values, rows, draft=False):
|
|
341
|
+
"""The words a run sends (a one-task workflow's) after checking every blank has a value"""
|
|
342
|
+
graph = workflow["graph"] if draft else self.live(workflow) # a test run runs the draft
|
|
343
|
+
given = self._account_values(workflow, values, typed=not graph_chain.is_plain(graph))
|
|
344
|
+
if graph_chain.is_plain(graph):
|
|
345
|
+
return starters.fill(graph_chain.words(graph) if draft else workflow["goal"], given)
|
|
346
|
+
if draft or graph_chain.as_chain(graph) is None:
|
|
347
|
+
graph_dispatch.check_values(graph, given, rows)
|
|
348
|
+
return None
|
|
349
|
+
repeat = workflow.get("repeat") or {}
|
|
350
|
+
if repeat.get("kind") == "each":
|
|
351
|
+
rows = rows if rows is not None else repeat.get("rows")
|
|
352
|
+
if not rows:
|
|
353
|
+
raise ValueError("Give the rows to repeat over, as a list or a CSV")
|
|
354
|
+
elif rows is not None:
|
|
355
|
+
raise ValueError("Rows are for a workflow that repeats for each row")
|
|
356
|
+
names = {starters._key(blank["name"]) for blank in starters.blanks(workflow["goal"])}
|
|
357
|
+
base = starters.clean_values(given)
|
|
358
|
+
for row in rows or [{}]:
|
|
359
|
+
merged = {**base, **starters.clean_values(row)}
|
|
360
|
+
starters.fill(workflow["goal"], {name: value for name, value in merged.items() if name in names})
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
def _outside(self, identifier, origin, remote_grant, values, thread_id, rows):
|
|
364
|
+
"""The values a run from Mobster Remote may send, after the rules that fail closed"""
|
|
365
|
+
if origin != "remote":
|
|
366
|
+
return values
|
|
367
|
+
if not remote_grant:
|
|
368
|
+
raise ValueError("A run through Mobster Remote names its app")
|
|
369
|
+
if thread_id is not None or rows is not None:
|
|
370
|
+
raise APIError("A run through Mobster Remote takes values only.", 403, "remote_unsupported")
|
|
371
|
+
if not self.has_webhook(identifier):
|
|
372
|
+
raise APIError("This workflow has no webhook URL, so apps connected through Mobster Remote can't start it. "
|
|
373
|
+
"To allow it, open the workflow in Mobster, choose Edit, More, "
|
|
374
|
+
"then Start from another tool.",
|
|
375
|
+
403, "remote_unsupported")
|
|
376
|
+
if not isinstance(values, dict):
|
|
377
|
+
return values
|
|
378
|
+
return {name: secret_filter.redact(" ".join(str(value).split())[:300])
|
|
379
|
+
if isinstance(value, (str, int, float)) and not isinstance(value, bool) else value
|
|
380
|
+
for name, value in values.items()}
|
|
381
|
+
|
|
382
|
+
def has_webhook(self, identifier):
|
|
383
|
+
from ..outlets import hooks
|
|
384
|
+
service = hooks.service_for(self.runtime)
|
|
385
|
+
try:
|
|
386
|
+
return service is not None and bool(service.store.all(identifier))
|
|
387
|
+
except JournalError:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def run(self, identifier, request_key, values=None, thread_id=None, new_conversation=False, origin=None, rows=None,
|
|
391
|
+
remote_grant=None, test=False, rerun=None, body=None, trigger=None, input=None, prove=False,
|
|
392
|
+
project_secrets=True):
|
|
393
|
+
"""Start a workflow now."""
|
|
394
|
+
return self._run(identifier, request_key, values, thread_id, new_conversation, origin, rows, remote_grant,
|
|
395
|
+
test, rerun, body, trigger, input, prove, project_secrets)[:2]
|
|
396
|
+
|
|
397
|
+
def _run(self, identifier, request_key, values=None, thread_id=None, new_conversation=False, origin=None, rows=None,
|
|
398
|
+
remote_grant=None, test=False, rerun=None, body=None, trigger=None, input=None, prove=False,
|
|
399
|
+
project_secrets=True):
|
|
400
|
+
"""run(), also returning the dispatch key: (run, replayed, key)."""
|
|
401
|
+
if not isinstance(request_key, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", request_key):
|
|
402
|
+
raise ValueError("Supply one valid Idempotency-Key to run a saved task")
|
|
403
|
+
if type(new_conversation) is not bool:
|
|
404
|
+
raise ValueError("newConversation must be true or false")
|
|
405
|
+
if thread_id is not None and new_conversation:
|
|
406
|
+
raise ValueError("Give threadId or newConversation, not both")
|
|
407
|
+
if rows is not None:
|
|
408
|
+
rows = batch.validate_rows(rows)
|
|
409
|
+
if type(test) is not bool or rerun is not None and not isinstance(rerun, dict):
|
|
410
|
+
raise ValueError("test is true or false, and a re-run is from, only or resume")
|
|
411
|
+
if (test or rerun) and origin in harness_api.UNATTENDED_ORIGINS:
|
|
412
|
+
raise APIError("A test run or a re-run starts from Mobster's editor.", 403, "test_from_editor")
|
|
413
|
+
if not test and rerun is None and origin in DRAFT_TESTERS and self._never_published(identifier):
|
|
414
|
+
# Run on a workflow that was never published tests the draft (docs/workflows.mdx › Test it): marked as a
|
|
415
|
+
# test, with its pins, and never a run of the published version. Only a person at the app, the terminal
|
|
416
|
+
# or the CLI: a schedule, a webhook, MCP or Remote still meet "Publish it, then run it".
|
|
417
|
+
test = True
|
|
418
|
+
if trigger not in (None, "shortcut") or input is not None and (
|
|
419
|
+
trigger is None or not isinstance(input, str) or len(input) > graph_nodes.INPUT_CHARS):
|
|
420
|
+
raise ValueError(f"trigger is shortcut, and its input is text of at most {graph_nodes.INPUT_CHARS:,} "
|
|
421
|
+
"characters")
|
|
422
|
+
if trigger and origin in harness_api.UNATTENDED_ORIGINS:
|
|
423
|
+
# A Shortcut runs on this Mac (Run Mobster Workflow in the Mac app, or a script with its token); MCP,
|
|
424
|
+
# Remote and webhooks start a workflow at its manual or webhook trigger.
|
|
425
|
+
raise APIError("A Shortcut starts this run on your Mac.", 403, "shortcut_only")
|
|
426
|
+
if prove and (not test or rerun):
|
|
427
|
+
raise ValueError("A prove is a test run of the draft")
|
|
428
|
+
debug = {key: value for key, value in (("test", test), ("rerun", rerun), ("trigger", trigger),
|
|
429
|
+
("input", input), ("prove", prove)) if value} or None
|
|
430
|
+
if body is not None:
|
|
431
|
+
from ..output_contract import bounded_json
|
|
432
|
+
try:
|
|
433
|
+
body = bounded_json(secret_filter.redact_deep(body), max_bytes=16_000)
|
|
434
|
+
except ValueError:
|
|
435
|
+
body = {"cut": True}
|
|
436
|
+
with self.lock:
|
|
437
|
+
values = self._outside(identifier, origin, remote_grant, values, thread_id, rows)
|
|
438
|
+
key = self._request_key(identifier, request_key, values, thread_id, new_conversation, rows, debug)
|
|
439
|
+
if getattr(self.runtime, "closing", False):
|
|
440
|
+
# "Nothing started" only for a key never claimed or run here: a retry of a start must not hear it.
|
|
441
|
+
with self.journal.lock:
|
|
442
|
+
seen = self.journal.connection.execute("SELECT 1 FROM workflow_dispatches WHERE key=?",
|
|
443
|
+
(key,)).fetchone() or self.journal.lookup(key)
|
|
444
|
+
refusal = APIError("The service is shutting down", 503, "shutting_down")
|
|
445
|
+
raise refusal if seen else nothing_started(refusal)
|
|
446
|
+
with self.journal.lock:
|
|
447
|
+
workflow = self._get(identifier)
|
|
448
|
+
refused = _unsupported(workflow, draft=test)
|
|
449
|
+
if refused:
|
|
450
|
+
raise refused
|
|
451
|
+
text = None if rerun else self._check_values(workflow, values, rows, draft=test)
|
|
452
|
+
if thread_id is not None:
|
|
453
|
+
self._target(self._threads(), thread_id)
|
|
454
|
+
prior = self._claim(workflow, key, None)
|
|
455
|
+
if prior:
|
|
456
|
+
record = self.batches.public(self.batches.by_key(key))
|
|
457
|
+
if record is not None or isinstance(prior[1], str) and prior[1].startswith("batch:"):
|
|
458
|
+
record = record or self.batches.public(self.batches.get(prior[1][6:]))
|
|
459
|
+
first = next((entry.get("runId") for entry in (record or {}).get("log", [])
|
|
460
|
+
if entry.get("runId")), None)
|
|
461
|
+
with self.runtime.lock:
|
|
462
|
+
return (self.runtime.runs.get(first) if first else None), True, key
|
|
463
|
+
with self.runtime.lock:
|
|
464
|
+
run = self.runtime.runs.get(prior[1])
|
|
465
|
+
if run is not None:
|
|
466
|
+
return run, True, key
|
|
467
|
+
if prior[1] is not None:
|
|
468
|
+
raise APIError("This task expired from history; it was not restarted", 410, "run_expired")
|
|
469
|
+
raise APIError(
|
|
470
|
+
"The previous dispatch was not repeated. Review saved task status before using a new request.",
|
|
471
|
+
409, "workflow_dispatch_not_repeated")
|
|
472
|
+
run, replayed = self._dispatch(Dispatch(key, workflow, text=text, thread=thread_id,
|
|
473
|
+
new_conversation=new_conversation, origin=origin, values=values,
|
|
474
|
+
rows=rows, remote_grant=remote_grant, test=test, rerun=rerun,
|
|
475
|
+
body=body, trigger=trigger, input=input, prove=prove,
|
|
476
|
+
project_secrets=project_secrets))
|
|
477
|
+
return run, replayed, key
|
|
478
|
+
|
|
479
|
+
def _never_published(self, identifier):
|
|
480
|
+
"""Whether the workflow is a graph that has never been published (so only its draft can run)."""
|
|
481
|
+
try:
|
|
482
|
+
with self.journal.lock:
|
|
483
|
+
workflow = self._get(identifier)
|
|
484
|
+
except APIError:
|
|
485
|
+
return False
|
|
486
|
+
return bool(graph_schema.check_graph(workflow.get("graph")) and not isinstance(workflow.get("published"), dict))
|
|
487
|
+
|
|
488
|
+
def start(self, identifier, request_key, values=None, thread_id=None, new_conversation=False, origin=None,
|
|
489
|
+
rows=None, remote_grant=None, test=False, rerun=None, body=None, trigger=None, input=None, prove=False,
|
|
490
|
+
project_secrets=True):
|
|
491
|
+
"""POST /api/workflows/{id}/run's answer"""
|
|
492
|
+
run, replayed, key = self._run(identifier, request_key, values, thread_id, new_conversation, origin, rows,
|
|
493
|
+
remote_grant, test, rerun, body, trigger=trigger, input=input, prove=prove,
|
|
494
|
+
project_secrets=project_secrets)
|
|
495
|
+
record = self.batches.public(self.batches.by_key(key))
|
|
496
|
+
extras = getattr(run, "extras", None)
|
|
497
|
+
kept = record is not None and (record["status"] == "running"
|
|
498
|
+
or any(r.get("runIds") for r in record.get("rounds", [])))
|
|
499
|
+
thread = extras.get("threadId") if isinstance(extras, dict) else record["threadId"] if kept else None
|
|
500
|
+
return {"run": run.public() if run is not None else None, "replayed": replayed, "threadId": thread,
|
|
501
|
+
"batch": self._batch_view(record)}
|
|
502
|
+
|
|
503
|
+
def _reconcile(self):
|
|
504
|
+
with self.journal.lock:
|
|
505
|
+
pending = self.journal.connection.execute(
|
|
506
|
+
"SELECT key,run_id FROM workflow_dispatches WHERE outcome='started'").fetchall()
|
|
507
|
+
batches = [(key, ref) for key, ref in pending if isinstance(ref, str) and ref.startswith("batch:")]
|
|
508
|
+
pending = [(key, ref) for key, ref in pending if (key, ref) not in batches]
|
|
509
|
+
live = set(self.running().values())
|
|
510
|
+
for key, ref in batches:
|
|
511
|
+
identifier = ref[6:]
|
|
512
|
+
if identifier in live:
|
|
513
|
+
continue
|
|
514
|
+
record = self.batches.get(identifier)
|
|
515
|
+
if record is not None and record["status"] == "running":
|
|
516
|
+
continue # recover() ends a batch that outlived its process
|
|
517
|
+
runs = [entry["runId"] for entry in (self.batches.public(record) or {}).get("log", [])
|
|
518
|
+
if entry.get("runId")]
|
|
519
|
+
self._outcome(key, record["status"] if record else "interrupted", runs[-1] if runs else None)
|
|
520
|
+
with self.runtime.lock:
|
|
521
|
+
completed = [(key, run_id, self.runtime.runs[run_id].status if run_id in self.runtime.runs
|
|
522
|
+
else "run_expired")
|
|
523
|
+
for key, run_id in pending if run_id not in self.runtime.runs
|
|
524
|
+
or self.runtime.runs[run_id].finished_at is not None]
|
|
525
|
+
for key, run_id, outcome in completed:
|
|
526
|
+
self._outcome(key, outcome, run_id)
|
|
527
|
+
self._alert_finished(key, run_id, outcome)
|
|
528
|
+
|
|
529
|
+
def recover(self):
|
|
530
|
+
"""Run once before the scheduler starts; no missed/uncertain dispatch is replayed."""
|
|
531
|
+
with self.lock:
|
|
532
|
+
live = set(self.running().values())
|
|
533
|
+
for record in self.batches.running():
|
|
534
|
+
if record["id"] in live:
|
|
535
|
+
continue
|
|
536
|
+
if record.get("graph") == 2:
|
|
537
|
+
graph_dispatch.recover(self, record)
|
|
538
|
+
continue
|
|
539
|
+
# Mobster quit while it ran: it ends here. A step claimed before the quit gets its task's id when the
|
|
540
|
+
# task started; nothing is run again.
|
|
541
|
+
for entry in record.get("log", []):
|
|
542
|
+
if entry.get("state") == "claimed" and entry.get("key"):
|
|
543
|
+
request = self.journal.lookup(entry["key"])
|
|
544
|
+
entry.update(state="interrupted", **({"runId": request[0]} if request else {}))
|
|
545
|
+
for item in record.get("rounds", []):
|
|
546
|
+
if item.get("status") == "running":
|
|
547
|
+
item["status"] = "interrupted"
|
|
548
|
+
record.update(status="interrupted", finishedAt=self.now(),
|
|
549
|
+
reason="Mobster quit while this workflow ran. It wasn't run again.")
|
|
550
|
+
self.batches.save(record)
|
|
551
|
+
with self.journal.lock:
|
|
552
|
+
claims = self.journal.connection.execute(
|
|
553
|
+
"SELECT key FROM workflow_dispatches WHERE outcome='claimed'").fetchall()
|
|
554
|
+
for (key,) in claims:
|
|
555
|
+
request = self.journal.lookup(key)
|
|
556
|
+
self._outcome(key, "started" if request else "interrupted", request[0] if request else None)
|
|
557
|
+
self._reconcile()
|
|
558
|
+
for workflow in self._records():
|
|
559
|
+
if workflow["enabled"] and workflow["nextAt"] <= self.now():
|
|
560
|
+
if schedule_rule(workflow, "missed") == "run_once":
|
|
561
|
+
continue # the first tick runs it once (tick)
|
|
562
|
+
key = f"workflow:{workflow['id']}:scheduled:{workflow['nextAt']}"
|
|
563
|
+
self._claim(workflow, key, workflow["nextAt"], "skipped_missed")
|
|
564
|
+
|
|
565
|
+
def _alert_finished(self, key, run_id, outcome):
|
|
566
|
+
"""A scheduled run ended without finishing: post its alert. Never raises."""
|
|
567
|
+
if self.alerts is None or SCHEDULED.fullmatch(key or "") is None or run_id is None:
|
|
568
|
+
return
|
|
569
|
+
try:
|
|
570
|
+
with self.journal.lock:
|
|
571
|
+
workflow = self._get(SCHEDULED.fullmatch(key).group(1))
|
|
572
|
+
with self.runtime.lock:
|
|
573
|
+
run = self.runtime.runs.get(run_id)
|
|
574
|
+
summary = getattr(run, "summary", None) or {}
|
|
575
|
+
code = summary.get("code") if isinstance(summary, dict) else None
|
|
576
|
+
self.alerts.run_finished(workflow.get("name"), outcome, run_id, code if isinstance(code, str) else None)
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
def _alert_waiting(self):
|
|
581
|
+
"""A scheduled run has waited on an approval for a minute or more: post one alert per approval."""
|
|
582
|
+
if self.alerts is None:
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
with self.journal.lock:
|
|
586
|
+
started = self.journal.connection.execute(
|
|
587
|
+
"SELECT key,run_id FROM workflow_dispatches WHERE outcome='started' AND scheduled_at IS NOT NULL"
|
|
588
|
+
).fetchall()
|
|
589
|
+
if not started:
|
|
590
|
+
self._alerted_approvals.clear()
|
|
591
|
+
return
|
|
592
|
+
now = self.now()
|
|
593
|
+
for key, run_id in started:
|
|
594
|
+
with self.runtime.lock:
|
|
595
|
+
run = self.runtime.runs.get(run_id)
|
|
596
|
+
approval = getattr(run, "approval", None)
|
|
597
|
+
if not isinstance(approval, dict) or approval.get("id") in self._alerted_approvals:
|
|
598
|
+
continue
|
|
599
|
+
asked = approval.get("requestedAt")
|
|
600
|
+
match = SCHEDULED.fullmatch(key)
|
|
601
|
+
if match and isinstance(asked, (int, float)) and now - asked >= APPROVAL_ALERT_MS:
|
|
602
|
+
self._alerted_approvals.add(approval.get("id"))
|
|
603
|
+
with self.journal.lock:
|
|
604
|
+
name = self._get(match.group(1)).get("name")
|
|
605
|
+
self.alerts.approval_waiting(name, run_id)
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
def _start_queued(self):
|
|
610
|
+
"""The one run that waited for the workflow's last run starts once that run ended"""
|
|
611
|
+
running = set(self.running())
|
|
612
|
+
for workflow in self._records():
|
|
613
|
+
queued = workflow.get("queuedDispatch")
|
|
614
|
+
if not queued or workflow["id"] in running:
|
|
615
|
+
continue
|
|
616
|
+
event = workflow.get("queuedEvent") if isinstance(workflow.get("queuedEvent"), dict) else {}
|
|
617
|
+
with self.journal.lock, self.journal.transaction() as connection:
|
|
618
|
+
workflow.pop("queuedDispatch", None)
|
|
619
|
+
workflow.pop("queuedEvent", None)
|
|
620
|
+
self._save(connection, workflow)
|
|
621
|
+
if not workflow.get("enabled"):
|
|
622
|
+
self._outcome(queued, "skipped_busy")
|
|
623
|
+
continue
|
|
624
|
+
try:
|
|
625
|
+
self._dispatch(Dispatch(queued, workflow, trigger=event.get("trigger"), event=event.get("event"),
|
|
626
|
+
started_by=event.get("startedBy")))
|
|
627
|
+
except (APIError, ValueError):
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
def tick(self):
|
|
631
|
+
with self.lock:
|
|
632
|
+
if getattr(self.runtime, "closing", False):
|
|
633
|
+
return
|
|
634
|
+
self._alert_waiting()
|
|
635
|
+
self._reconcile()
|
|
636
|
+
self._start_queued()
|
|
637
|
+
records = self._records()
|
|
638
|
+
self.triggers.tick(records)
|
|
639
|
+
for workflow in records:
|
|
640
|
+
now = self.now() # Earlier readiness checks may have blocked or the wall clock may have changed.
|
|
641
|
+
slot = workflow["nextAt"]
|
|
642
|
+
if not workflow["enabled"] or slot is None or slot > now:
|
|
643
|
+
continue
|
|
644
|
+
key = f"workflow:{workflow['id']}:scheduled:{slot}"
|
|
645
|
+
# A slot the Mac slept through is skipped, or run once when the schedule says so (missed: run_once):
|
|
646
|
+
# the claim moves nextAt past now, so many missed slots still make one run.
|
|
647
|
+
late = now - slot >= LATE_TOLERANCE_MS
|
|
648
|
+
outcome = "skipped_missed" if late and schedule_rule(workflow, "missed") != "run_once" else "claimed"
|
|
649
|
+
with self.journal.lock:
|
|
650
|
+
attempts = self.journal.connection.execute("""SELECT workflow_id,claimed_at FROM workflow_dispatches
|
|
651
|
+
WHERE scheduled_at IS NOT NULL AND claimed_at>? AND outcome NOT LIKE 'skipped_%'""",
|
|
652
|
+
(now - DAY_MS,)).fetchall()
|
|
653
|
+
if len(attempts) >= self.daily_limit:
|
|
654
|
+
outcome = "skipped_daily_limit"
|
|
655
|
+
elif any(identifier == workflow["id"] and now - claimed < MIN_INTERVAL_MS
|
|
656
|
+
for identifier, claimed in attempts):
|
|
657
|
+
outcome = "skipped_interval_limit"
|
|
658
|
+
if self._claim(workflow, key, slot, outcome) or outcome != "claimed":
|
|
659
|
+
continue
|
|
660
|
+
try:
|
|
661
|
+
self._dispatch(Dispatch(key, workflow))
|
|
662
|
+
except (APIError, ValueError):
|
|
663
|
+
# A skipped/failed occurrence is durable and never retried in a burst.
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
def _batch_view(self, record):
|
|
667
|
+
if record is None:
|
|
668
|
+
return None
|
|
669
|
+
with self._batches_lock:
|
|
670
|
+
going = next((item for item in self._batches.values() if item.record["id"] == record["id"]), None)
|
|
671
|
+
return going.public() if going is not None else self.batches.public(record)
|
|
672
|
+
|
|
673
|
+
def _dispatch_batch(self, dispatch):
|
|
674
|
+
workflow, key = dispatch.workflow, dispatch.key
|
|
675
|
+
with self._batches_lock:
|
|
676
|
+
busy = workflow["id"] in self._batches
|
|
677
|
+
if busy:
|
|
678
|
+
if ":scheduled:" in key and schedule_rule(workflow, "overlap") == "queue_one":
|
|
679
|
+
with self.journal.lock:
|
|
680
|
+
current = self._get(workflow["id"])
|
|
681
|
+
if not current.get("queuedDispatch"):
|
|
682
|
+
# One scheduled run waits for the one going (overlap: queue_one); tick starts it after.
|
|
683
|
+
with self.journal.lock, self.journal.transaction() as connection:
|
|
684
|
+
current["queuedDispatch"] = key
|
|
685
|
+
self._save(connection, current)
|
|
686
|
+
self._outcome(key, "queued")
|
|
687
|
+
raise APIError("This workflow is still running; this run starts when it ends.", 409, "queued")
|
|
688
|
+
self._outcome(key, "skipped_busy")
|
|
689
|
+
raise APIError("This workflow is already running. Stop it, or wait for it to finish.", 409, "run_active")
|
|
690
|
+
service, admission = self._threads(), None
|
|
691
|
+
graph = workflow["graph"] if dispatch.test else self.live(workflow)
|
|
692
|
+
try:
|
|
693
|
+
debug, entry, payload, values, source = graph_rerun.plan(self, workflow, graph, dispatch.rerun,
|
|
694
|
+
test=dispatch.test)
|
|
695
|
+
if entry is None:
|
|
696
|
+
entry = graph_dispatch.entry(graph, dispatch)
|
|
697
|
+
if values is None:
|
|
698
|
+
values = graph_dispatch.check_values(
|
|
699
|
+
graph, self._account_values(workflow, dispatch.values, typed=True), dispatch.rows)
|
|
700
|
+
else:
|
|
701
|
+
graph_dispatch.check_values(graph, values, payload.get("rows") if isinstance(payload, dict) else None)
|
|
702
|
+
admission = self._admit(service, dispatch)
|
|
703
|
+
if payload is None:
|
|
704
|
+
payload = graph_dispatch.payload(entry, dispatch, values)
|
|
705
|
+
record = batch.new_record(workflow, key, admission.thread_id, self.now())
|
|
706
|
+
record.update(graph=2, trigger=entry, test=dispatch.test, **({"from": source} if source else {}),
|
|
707
|
+
**({"prove": True} if dispatch.prove else {}),
|
|
708
|
+
revision=workflow.get("revision") if dispatch.test else
|
|
709
|
+
(workflow.get("published") or {}).get("revision", workflow.get("revision")))
|
|
710
|
+
self.batches.add(record)
|
|
711
|
+
except (APIError, ValueError, JournalError):
|
|
712
|
+
if admission and admission.made:
|
|
713
|
+
self._drop(service, admission.thread_id)
|
|
714
|
+
self._outcome(key, "dispatch_failed")
|
|
715
|
+
raise
|
|
716
|
+
base = {**({"engine": admission.engine} if admission.engine else {}), "origin": admission.origin}
|
|
717
|
+
default = ({"device": admission.device} if admission.device
|
|
718
|
+
else {"default_device": "primary"} if self._devices_here() else {})
|
|
719
|
+
|
|
720
|
+
def create(app_id, goal, run_key, *, output_schema=None, output_format="auto", cap_usd=None, device=None,
|
|
721
|
+
extras=None, engine=None, workflow_id=workflow["id"]):
|
|
722
|
+
merged = {**admission.extras, **(extras or {})}
|
|
723
|
+
# A step's own fallback engine (graph/nodes.phone: the ready coding agent when Mobster's agent has no key)
|
|
724
|
+
# never overrides the engine the workflow was started with.
|
|
725
|
+
return self.runtime.create(app_id, goal, "live", run_key, output_schema=output_schema,
|
|
726
|
+
output_format=output_format,
|
|
727
|
+
**{**({"engine": engine} if engine else {}), **base},
|
|
728
|
+
**({"device": device} if device else default),
|
|
729
|
+
**({"extras": merged} if merged else {}),
|
|
730
|
+
**({"cap_usd": cap_usd} if cap_usd is not None else {}),
|
|
731
|
+
_workflow_id=workflow_id)
|
|
732
|
+
going = GraphRun(self, record, workflow, graph, entry=entry, payload=payload, values=values, create=create,
|
|
733
|
+
origin=admission.origin, clock=self.clock, debug=debug)
|
|
734
|
+
going.admitted_device = admission.device
|
|
735
|
+
going.made_thread = admission.thread_id if admission.made else None
|
|
736
|
+
try:
|
|
737
|
+
# The admission lock owns these writes through cleanup; the worker starts only after durable linkage.
|
|
738
|
+
self._record_started(key, f"batch:{record['id']}", admission.thread_id, admission.home)
|
|
739
|
+
with self._batches_lock:
|
|
740
|
+
self._batches[workflow["id"]] = going
|
|
741
|
+
going.start()
|
|
742
|
+
except Exception:
|
|
743
|
+
with self._batches_lock:
|
|
744
|
+
if self._batches.get(workflow["id"]) is going:
|
|
745
|
+
del self._batches[workflow["id"]]
|
|
746
|
+
record.update(status="interrupted", finishedAt=self.now(), reason="This run never started.")
|
|
747
|
+
try:
|
|
748
|
+
self.batches.save(record)
|
|
749
|
+
except JournalError:
|
|
750
|
+
pass # recover() ends a record left running at the next launch
|
|
751
|
+
if admission.made:
|
|
752
|
+
self._drop(service, admission.thread_id)
|
|
753
|
+
raise
|
|
754
|
+
going.first.wait(graph_run.FIRST_WAIT_S)
|
|
755
|
+
return going.first_run, False
|
|
756
|
+
|
|
757
|
+
def batch_finished(self, going, status, reason):
|
|
758
|
+
"""Commit the run's end and the workflow's result together, then publish while close() still owns the worker."""
|
|
759
|
+
key = going.record["key"]
|
|
760
|
+
last = going.last or going.first_run
|
|
761
|
+
with self._batches_lock:
|
|
762
|
+
if self._batches.get(going.workflow["id"]) is going:
|
|
763
|
+
del self._batches[going.workflow["id"]]
|
|
764
|
+
self._ending.add(going) # still writing to the journal: close() waits for it
|
|
765
|
+
try:
|
|
766
|
+
if going.first_run is None and getattr(going, "made_thread", None):
|
|
767
|
+
self._drop(self._threads(), going.made_thread)
|
|
768
|
+
try:
|
|
769
|
+
with going.cond, self.journal.transaction() as connection:
|
|
770
|
+
self.batches.write(connection, going.record)
|
|
771
|
+
# Deleting a workflow removes its dispatch; the run still records how its worker ended.
|
|
772
|
+
if connection.execute("SELECT 1 FROM workflow_dispatches WHERE key=?", (key,)).fetchone():
|
|
773
|
+
self._save_outcome(connection, key, status, last.id if last is not None else None)
|
|
774
|
+
except JournalError:
|
|
775
|
+
batch.log.exception("batch %s: saving its end failed", going.record["id"])
|
|
776
|
+
return
|
|
777
|
+
self._alert_finished(key, last.id if last is not None else None, status)
|
|
778
|
+
if going.record.get("prove") and status == "completed":
|
|
779
|
+
try:
|
|
780
|
+
self._proved(going)
|
|
781
|
+
except Exception: # noqa: BLE001 -- the run's outcome stands; the draft just isn't marked proven
|
|
782
|
+
graph_run.log.exception("pinning a prove's outputs failed")
|
|
783
|
+
going.publish_end(status, reason)
|
|
784
|
+
finally:
|
|
785
|
+
with self._batches_lock:
|
|
786
|
+
self._ending.discard(going)
|
|
787
|
+
|
|
788
|
+
def stop_batch(self, identifier):
|
|
789
|
+
"""Stop a workflow that is running its steps"""
|
|
790
|
+
with self._batches_lock:
|
|
791
|
+
going = self._batches.get(identifier)
|
|
792
|
+
if going is None:
|
|
793
|
+
raise APIError("This workflow isn't running.", 409, "not_running")
|
|
794
|
+
going.stop_all()
|
|
795
|
+
return going.public()
|
|
796
|
+
|
|
797
|
+
def batch_runs(self, identifier):
|
|
798
|
+
"""The workflow's recent batches, newest first, each with its rounds and its log."""
|
|
799
|
+
with self.journal.lock:
|
|
800
|
+
self._get(identifier)
|
|
801
|
+
return [self._batch_view(record) for record in self.batches.recent(identifier)]
|
|
802
|
+
|
|
803
|
+
def follow(self, identifier, started, seconds):
|
|
804
|
+
"""A run ``start`` began, once it ends or as it goes when ``seconds`` run out"""
|
|
805
|
+
from ..threads.mcp import graph_state
|
|
806
|
+
batch_id = (started.get("batch") or {}).get("id")
|
|
807
|
+
if batch_id:
|
|
808
|
+
going = self._going(identifier, batch_id)
|
|
809
|
+
if going is not None:
|
|
810
|
+
going.thread.join(seconds)
|
|
811
|
+
record = next((r for r in self.batch_runs(identifier) if r["id"] == batch_id), None) or started["batch"]
|
|
812
|
+
state = graph_state(record)
|
|
813
|
+
return {"status": state["status"], "reason": state["reason"], "result": state["result"], "answer": None,
|
|
814
|
+
"runId": (started.get("run") or {}).get("id"), "batchId": batch_id, "steps": state["steps"]}
|
|
815
|
+
run_id = (started.get("run") or {}).get("id")
|
|
816
|
+
with self.runtime.lock:
|
|
817
|
+
run = self.runtime.runs.get(run_id) if run_id else None
|
|
818
|
+
if run is None:
|
|
819
|
+
return {"status": "running", "reason": None, "result": None, "answer": None, "runId": run_id,
|
|
820
|
+
"batchId": None, "steps": []}
|
|
821
|
+
deadline = time.monotonic() + seconds
|
|
822
|
+
with run.condition:
|
|
823
|
+
while getattr(run, "finished_at", None) is None and time.monotonic() < deadline:
|
|
824
|
+
run.condition.wait(min(0.5, max(0.0, deadline - time.monotonic())))
|
|
825
|
+
summary = run.summary if isinstance(run.summary, dict) else {}
|
|
826
|
+
return {"status": run.status, "reason": summary.get("reason"), "result": summary.get("data"),
|
|
827
|
+
"answer": batch.answer_text(summary) or None, "runId": run_id, "batchId": None, "steps": []}
|
|
828
|
+
|
|
829
|
+
def estimate(self, identifier, draft=False, _depth=0):
|
|
830
|
+
"""GET /api/workflows/{id}/estimate[?draft=1]"""
|
|
831
|
+
from ..graph import estimate as graph_estimate
|
|
832
|
+
with self.journal.lock:
|
|
833
|
+
workflow = self._get(identifier)
|
|
834
|
+
return graph_estimate.of_workflow(self, workflow, workflow["graph"] if draft else self.live(workflow), _depth)
|
|
835
|
+
|
|
836
|
+
def node_history(self, identifier, batch_id, instance):
|
|
837
|
+
"""GET /api/workflows/{id}/runs/{batchId}/nodes/{instance}"""
|
|
838
|
+
with self.journal.lock:
|
|
839
|
+
self._get(identifier)
|
|
840
|
+
record = self.batches.get(batch_id)
|
|
841
|
+
found = self.batches.node(batch_id, instance) if record is not None and record["workflowId"] == identifier \
|
|
842
|
+
else None
|
|
843
|
+
if found is None:
|
|
844
|
+
raise APIError("That step isn't kept any more.", 404, "not_found")
|
|
845
|
+
return found
|
|
846
|
+
|
|
847
|
+
def node_frames(self, identifier, batch_id, instance):
|
|
848
|
+
"""GET /api/workflows/{id}/runs/{batchId}/nodes/{instance}/frames"""
|
|
849
|
+
run_id = self.node_history(identifier, batch_id, instance).get("runId")
|
|
850
|
+
return {"runId": run_id, "frames": self._frames(run_id)}
|
|
851
|
+
|
|
852
|
+
def _frames(self, run_id):
|
|
853
|
+
"""[{at, frameId, event}] of the screens task ``run_id`` kept, in order; none when it isn't kept."""
|
|
854
|
+
with self.runtime.lock:
|
|
855
|
+
run = self.runtime.runs.get(run_id) if run_id else None
|
|
856
|
+
if run is None:
|
|
857
|
+
return []
|
|
858
|
+
with run.condition:
|
|
859
|
+
events = list(getattr(run, "events", None) or ())
|
|
860
|
+
return [{"at": e.get("timestamp"), "frameId": e["frameId"], "event": e.get("event")}
|
|
861
|
+
for e in events if isinstance(e, dict) and isinstance(e.get("frameId"), str)]
|
|
862
|
+
|
|
863
|
+
def timeline(self, identifier, batch_id):
|
|
864
|
+
"""GET /api/workflows/{id}/runs/{batchId}/timeline: a kept run across its steps in time, for scrubbing it."""
|
|
865
|
+
with self.journal.lock:
|
|
866
|
+
self._get(identifier)
|
|
867
|
+
record = self.batches.get(batch_id)
|
|
868
|
+
if record is None or record["workflowId"] != identifier:
|
|
869
|
+
raise APIError("That run isn't kept any more.", 404, "not_found")
|
|
870
|
+
rows = [row for row in self.batches.nodes(batch_id) if not str(row.get("type")).startswith("trigger.")]
|
|
871
|
+
keys = ("instance", "node", "type", "state", "status", "deviceId", "deviceName", "runId", "readyAt",
|
|
872
|
+
"startedAt", "finishedAt", "queuedReason", "reused", "pinned")
|
|
873
|
+
steps = sorted(({key: row[key] for key in keys if key in row} for row in rows),
|
|
874
|
+
key=lambda step: (step.get("startedAt") or step.get("readyAt") or 0))
|
|
875
|
+
frames = sorted(({**frame, "instance": row["instance"], "runId": row["runId"]}
|
|
876
|
+
for row in rows if row.get("runId") and not row.get("reused")
|
|
877
|
+
for frame in self._frames(row["runId"])), key=lambda frame: frame.get("at") or 0)
|
|
878
|
+
return {"batchId": batch_id, "status": record.get("status"), "startedAt": record.get("createdAt"),
|
|
879
|
+
"finishedAt": record.get("finishedAt"), "steps": steps, "frames": frames}
|
|
880
|
+
|
|
881
|
+
def _going(self, identifier, batch_id):
|
|
882
|
+
"""The run ``batch_id`` of workflow ``identifier`` while it goes"""
|
|
883
|
+
with self._batches_lock:
|
|
884
|
+
going = self._batches.get(identifier)
|
|
885
|
+
pending = [going] if going is not None else []
|
|
886
|
+
while pending:
|
|
887
|
+
run = pending.pop()
|
|
888
|
+
if run.record["id"] == batch_id:
|
|
889
|
+
return run
|
|
890
|
+
pending.extend(run.children.values())
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
def answer_approval(self, identifier, batch_id, instance, body):
|
|
894
|
+
"""POST /api/workflows/{id}/runs/{batchId}/approvals/{instance} {decision, note?, value?}"""
|
|
895
|
+
if not isinstance(body, dict) or set(body) - {"decision", "note", "value"}:
|
|
896
|
+
raise ValueError("An answer has a decision, and may have a note and an edited value")
|
|
897
|
+
if body.get("decision") not in ("approve", "approve_all", "decline"):
|
|
898
|
+
raise ValueError("decision is approve, approve_all or decline")
|
|
899
|
+
note = body.get("note")
|
|
900
|
+
if note is not None and (not isinstance(note, str) or len(note) > 300):
|
|
901
|
+
raise ValueError("A note is at most 300 characters")
|
|
902
|
+
going = self._going(identifier, batch_id)
|
|
903
|
+
if going is None:
|
|
904
|
+
raise APIError("That run isn't going any more.", 409, "not_waiting")
|
|
905
|
+
return going.answer(instance, body["decision"], value=body.get("value"), has_value="value" in body,
|
|
906
|
+
note=" ".join(note.split()) if note else None, by="you")
|
|
907
|
+
|
|
908
|
+
def pending_approvals(self):
|
|
909
|
+
"""GET /api/workflows/approvals: every approval step waiting for you, oldest first."""
|
|
910
|
+
with self._batches_lock:
|
|
911
|
+
pending = list(self._batches.values())
|
|
912
|
+
out = []
|
|
913
|
+
while pending:
|
|
914
|
+
run = pending.pop()
|
|
915
|
+
pending.extend(run.children.values())
|
|
916
|
+
with run.cond:
|
|
917
|
+
for instance, (request, _answered, _box) in run.approvals.items():
|
|
918
|
+
out.append({"workflowId": run.workflow["id"], "workflowName": run.workflow["name"],
|
|
919
|
+
"batchId": run.record["id"], "instance": instance, **request})
|
|
920
|
+
return sorted(out, key=lambda item: item["expiresAt"])
|
|
921
|
+
|
|
922
|
+
def resume_all(self, identifier, batch_ids, request_key, origin=None):
|
|
923
|
+
"""POST /api/workflows/{id}/resume {batchIds}: resume each of those runs in order (graph/rerun.resume_all)."""
|
|
924
|
+
return graph_rerun.resume_all(self, identifier, batch_ids, request_key, origin)
|
|
925
|
+
|
|
926
|
+
def resume_preview(self, identifier, batch_id):
|
|
927
|
+
"""GET /api/workflows/{id}/runs/{batchId}/resume: what resuming that run would reuse, run again and leave."""
|
|
928
|
+
with self.journal.lock:
|
|
929
|
+
workflow = self._get(identifier)
|
|
930
|
+
return graph_rerun.preview(self, workflow, self.live(workflow), batch_id)
|
|
931
|
+
|
|
932
|
+
def recorded_scope(self, identifier, batch_id, node):
|
|
933
|
+
"""The values an expression at ``node`` read in a past run (POST /api/workflows/evaluate)."""
|
|
934
|
+
with self.journal.lock:
|
|
935
|
+
workflow = self._get(identifier)
|
|
936
|
+
return graph_rerun.recorded_scope(self, workflow, batch_id, node)
|
|
937
|
+
|
|
938
|
+
def running(self):
|
|
939
|
+
"""{workflow id: batch id} of the batches going now."""
|
|
940
|
+
with self._batches_lock:
|
|
941
|
+
return {identifier: going.record["id"] for identifier, going in self._batches.items()}
|
|
942
|
+
|
|
943
|
+
def close(self, timeout=5.0):
|
|
944
|
+
"""Mobster is quitting: every batch stops before its next step; False while one still owns journal writes."""
|
|
945
|
+
self._closing.set() # a bulk resume starts nothing more
|
|
946
|
+
links = getattr(self, "_approval_links", None)
|
|
947
|
+
if links is not None:
|
|
948
|
+
links.close() # asking by link ends with the run that asked
|
|
949
|
+
deadline = time.monotonic() + timeout
|
|
950
|
+
with self._batches_lock:
|
|
951
|
+
resuming = list(self._resuming.values())
|
|
952
|
+
for thread in resuming: # each sees the flag within a second; one starting a run registers it first
|
|
953
|
+
if thread is not threading.current_thread():
|
|
954
|
+
thread.join(max(0.0, min(2.0, deadline - time.monotonic())))
|
|
955
|
+
if not self.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
|
|
956
|
+
return False # Admission still owns journal writes, even before a run exists.
|
|
957
|
+
try:
|
|
958
|
+
with self._batches_lock:
|
|
959
|
+
going = list(self._batches.values())
|
|
960
|
+
ending = list(self._ending)
|
|
961
|
+
for item in going:
|
|
962
|
+
item.stop.set()
|
|
963
|
+
finally:
|
|
964
|
+
self.lock.release()
|
|
965
|
+
going += ending
|
|
966
|
+
# A batch ends on its own thread and records its outcome in the journal. Wait for those threads (bounded) so the
|
|
967
|
+
# journal isn't closed under a write: a write to a closed SQLite connection from another thread can
|
|
968
|
+
# crash the process.
|
|
969
|
+
ended = not any(thread.is_alive() for thread in resuming)
|
|
970
|
+
for item in going:
|
|
971
|
+
thread = getattr(item, "thread", None)
|
|
972
|
+
if thread is None or thread is threading.current_thread():
|
|
973
|
+
continue
|
|
974
|
+
if thread.ident is None:
|
|
975
|
+
ended = False # registered but not started yet: it will write after this returns
|
|
976
|
+
continue
|
|
977
|
+
thread.join(max(0.0, deadline - time.monotonic()))
|
|
978
|
+
ended = ended and not thread.is_alive()
|
|
979
|
+
return ended # False: a batch is still running, so the journal must stay open
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def schedule_rule(workflow, name):
|
|
983
|
+
"""The published schedule trigger's ``missed`` or ``overlap`` rule ("skip" when it says none)."""
|
|
984
|
+
graph = WorkflowRuns.live(workflow) if graph_schema.check_graph(workflow.get("graph")) else {}
|
|
985
|
+
trigger = next((n for n in graph.get("nodes") or [] if n.get("type") == "trigger.schedule"), None)
|
|
986
|
+
return ((trigger or {}).get("config") or {}).get(name) or "skip"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# The origins of a person starting a run themselves: Run on a draft is a test run for them.
|
|
990
|
+
DRAFT_TESTERS = frozenset({"app", "tui", "cli"})
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _unsupported(workflow, draft=False):
|
|
994
|
+
"""The refusal for a workflow that can't run"""
|
|
995
|
+
if (not draft and graph_schema.check_graph(workflow.get("graph"))
|
|
996
|
+
and not isinstance(workflow.get("published"), dict)):
|
|
997
|
+
return APIError("This workflow is a draft. Publish it, then run it.", 409, "not_published")
|
|
998
|
+
graph = workflow["graph"] if draft else WorkflowRuns.live(workflow)
|
|
999
|
+
why = graph_run.unsupported(graph) if graph_schema.check_graph(graph) else None
|
|
1000
|
+
return APIError(why, 409, "graph_unsupported") if why else None
|