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,973 @@
|
|
|
1
|
+
"""Durable saved workflows and at-most-once dispatch, sharing the canonical run journal."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from .. import engines, harness_api
|
|
10
|
+
from ..api_errors import APIError
|
|
11
|
+
from ..catalog import APPS, pack_apps
|
|
12
|
+
from ..graph import catalog as graph_catalog
|
|
13
|
+
from ..graph import chain as graph_chain
|
|
14
|
+
from ..graph import draft as graph_draft
|
|
15
|
+
from ..graph import edit as graph_edit
|
|
16
|
+
from ..graph import nodes as graph_nodes
|
|
17
|
+
from ..graph import schema as graph_schema
|
|
18
|
+
from ..journal import encode
|
|
19
|
+
from ..output_contract import validate_output
|
|
20
|
+
from ..workflow_tools import TOOL_WAIT_DEFAULT, TOOL_WAIT_MAX, _as_tool, tools
|
|
21
|
+
from . import brief, starters
|
|
22
|
+
from . import records as batch
|
|
23
|
+
from .cron import DAILY_LIMIT, local_timezone, next_occurrences, timezone, validate_cron
|
|
24
|
+
from .history import WorkflowHistory
|
|
25
|
+
from .runs import WorkflowRuns
|
|
26
|
+
from .triggers import WorkflowTriggers
|
|
27
|
+
|
|
28
|
+
brief.register() # the brief step (brief.py): one result from a workflow's tasks
|
|
29
|
+
graph_nodes.register() # the input a graph's phone step reads, as data (graph/nodes.py)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
MAX_WORKFLOWS = 200
|
|
33
|
+
# The start app a Smart task names for the Home Screen (server.ANY_APP); a saved task stores null.
|
|
34
|
+
ANY_APP_ID = "any"
|
|
35
|
+
CREATE_FIELDS = {"name", "appId", "appName", "goal", "outputSchema", "outputFormat", "engine", "cron", "timezone",
|
|
36
|
+
"enabled",
|
|
37
|
+
"device", "sameConversation", "homeThreadId", "account", "sendTo", "steps", "repeat", "limits",
|
|
38
|
+
"produces", "graph", "publish", "asTool"}
|
|
39
|
+
UPDATE_FIELDS = {"name", "goal", "appId", "appName", "cron", "timezone", "enabled", "revision", "engine", "device",
|
|
40
|
+
"sameConversation", "homeThreadId", "account", "sendTo", "steps", "repeat", "limits", "produces",
|
|
41
|
+
"graph",
|
|
42
|
+
"publish", "asTool"}
|
|
43
|
+
# What a body can't send with a graph: the graph says it (its steps, its words, its app, its schedule trigger).
|
|
44
|
+
NOT_WITH_GRAPH = {"steps", "repeat", "limits", "goal", "appId", "cron", "timezone"}
|
|
45
|
+
# The record's version: 2 is a conversation starter (blanks, sameConversation, homeThreadId); 3 adds its step cards
|
|
46
|
+
# (steps, repeat, limits); 4 makes it a graph (graph). Older records are upgraded when Mobster opens them (_migrate):
|
|
47
|
+
# version 1 text keeps its meaning, braces and all, a starter becomes one task card with the same words and app, and
|
|
48
|
+
# cards become a linear graph.
|
|
49
|
+
VERSION = 4
|
|
50
|
+
THREAD_ID = re.compile(r"[a-f0-9]{12}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def public_file(file):
|
|
54
|
+
"""A workflow's file as the API shows it"""
|
|
55
|
+
if not isinstance(file, dict):
|
|
56
|
+
return None
|
|
57
|
+
return {key: file.get(key) for key in ("projectId", "projectName", "path", "state", "problem", "revision",
|
|
58
|
+
"syncedAt")}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_produces(value):
|
|
62
|
+
"""The result blocks a workflow says it hands back (result_blocks.TYPES, docs/results.mdx)"""
|
|
63
|
+
from ..result_blocks import TYPES
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
if (not isinstance(value, list) or not 1 <= len(value) <= 3 or len(set(value)) != len(value)
|
|
67
|
+
or any(kind not in TYPES for kind in value)):
|
|
68
|
+
raise ValueError("produces is 1 to 3 result block kinds: " + ", ".join(TYPES))
|
|
69
|
+
return list(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_goal(value):
|
|
73
|
+
"""A saved task's instruction: 1–4000 characters, stored trimmed."""
|
|
74
|
+
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 4000:
|
|
75
|
+
raise ValueError("Supply a task of 1–4000 characters")
|
|
76
|
+
return starters.check_steps(value.strip())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_engine(value):
|
|
80
|
+
"""A saved engine is "smart" or None (Mobster's agent either way). Quick mode's retired "fast" reads as "smart"."""
|
|
81
|
+
value = engines.read_engine(value)
|
|
82
|
+
if value is None or value == engines.SMART:
|
|
83
|
+
return value
|
|
84
|
+
raise ValueError('engine must be "smart"')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Workflows(WorkflowRuns):
|
|
88
|
+
def __init__(self, runtime, clock=time.time, daily_limit=DAILY_LIMIT, alerts=None):
|
|
89
|
+
self.runtime = runtime
|
|
90
|
+
self.journal = runtime.journal
|
|
91
|
+
self.clock = clock
|
|
92
|
+
self.daily_limit = daily_limit
|
|
93
|
+
# Webhook alerts for scheduled runs (alerts.py): opt-in by address, never blocking, never failing a run.
|
|
94
|
+
self.alerts = alerts if alerts is not None else _default_alerts()
|
|
95
|
+
self._alerted_approvals = set()
|
|
96
|
+
# Updates and dispatch initiation serialize, so a completed pause cannot
|
|
97
|
+
# race a not-yet-started dispatch. No journal transaction spans device I/O.
|
|
98
|
+
self.lock = threading.RLock()
|
|
99
|
+
self.scheduler_error = None
|
|
100
|
+
# Batches going now, by workflow id (one at a time per workflow), under their own lock: a batch ends on its
|
|
101
|
+
# own thread and must never wait for a dispatch that holds ``lock``.
|
|
102
|
+
self._batches = {}
|
|
103
|
+
self._batches_lock = threading.Lock()
|
|
104
|
+
self._ending = set() # batches past their last step that are still recording how they ended
|
|
105
|
+
self._resuming = {} # workflow id -> the thread resuming a list of its runs (resume_all)
|
|
106
|
+
self._closing = threading.Event()
|
|
107
|
+
self.deleted = [] # callables told each deleted record (projects/sync.py)
|
|
108
|
+
with self.journal.transaction() as connection:
|
|
109
|
+
connection.execute("CREATE TABLE IF NOT EXISTS workflows (id TEXT PRIMARY KEY, data TEXT NOT NULL)")
|
|
110
|
+
connection.execute("""CREATE TABLE IF NOT EXISTS workflow_dispatches (
|
|
111
|
+
key TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflows(id),
|
|
112
|
+
scheduled_at INTEGER, claimed_at INTEGER NOT NULL,
|
|
113
|
+
outcome TEXT NOT NULL, run_id TEXT)
|
|
114
|
+
""")
|
|
115
|
+
connection.execute("CREATE INDEX IF NOT EXISTS workflow_dispatch_times ON workflow_dispatches(claimed_at)")
|
|
116
|
+
self._migrate(connection)
|
|
117
|
+
self.batches = batch.BatchStore(self.journal)
|
|
118
|
+
self.history = WorkflowHistory(self)
|
|
119
|
+
self.triggers = WorkflowTriggers(self)
|
|
120
|
+
self._approval_links = None
|
|
121
|
+
self._links_lock = threading.Lock()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def approval_links(self):
|
|
125
|
+
"""The approval links of this Mac (teams/approval_link.py), made the first time an approval step asks."""
|
|
126
|
+
with self._links_lock:
|
|
127
|
+
if self._approval_links is None:
|
|
128
|
+
from .. import plan_gate
|
|
129
|
+
try:
|
|
130
|
+
from ..teams import approval_link
|
|
131
|
+
except ModuleNotFoundError as error:
|
|
132
|
+
if error.name != "mobile_agent.teams":
|
|
133
|
+
raise
|
|
134
|
+
raise APIError("Approval links need the Mobster app's team extension. Approve this step locally.",
|
|
135
|
+
503, "extension_unavailable") from None
|
|
136
|
+
|
|
137
|
+
def person():
|
|
138
|
+
stored = plan_gate.stored_account(plan_gate.app_account_file()) or {}
|
|
139
|
+
user = stored.get("user") if isinstance(stored.get("user"), dict) else {}
|
|
140
|
+
return str(user.get("name") or user.get("email") or "Mobster")[:60]
|
|
141
|
+
self._approval_links = approval_link.ApprovalLinks(self, approval_link.Service(approval_link.mac_token),
|
|
142
|
+
person=person)
|
|
143
|
+
return self._approval_links
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def upgrade(workflow):
|
|
147
|
+
"""A saved task as today's record, or None when it is one already."""
|
|
148
|
+
upgraded = dict(workflow)
|
|
149
|
+
if workflow.get("version", 1) < 2:
|
|
150
|
+
if starters.has_braces(upgraded.get("goal")):
|
|
151
|
+
upgraded["goal"] = starters.escape(upgraded["goal"])
|
|
152
|
+
upgraded.setdefault("sameConversation", False)
|
|
153
|
+
upgraded.setdefault("homeThreadId", None)
|
|
154
|
+
upgraded.setdefault("lastThreadId", None)
|
|
155
|
+
if workflow.get("version", 1) < VERSION:
|
|
156
|
+
upgraded["version"] = VERSION
|
|
157
|
+
if not isinstance(upgraded.get("steps"), list) and isinstance(upgraded.get("goal"), str):
|
|
158
|
+
# A starter is a one-task workflow: its words are its one card, starting where it started.
|
|
159
|
+
upgraded["steps"] = batch.one_task(upgraded["goal"], upgraded.get("appId"))
|
|
160
|
+
upgraded.setdefault("repeat", None)
|
|
161
|
+
upgraded.setdefault("limits", dict(batch.DEFAULT_LIMITS))
|
|
162
|
+
if upgraded.get("engine") is not None:
|
|
163
|
+
upgraded["engine"] = engines.read_engine(upgraded["engine"])
|
|
164
|
+
upgraded.pop("helperModel", None)
|
|
165
|
+
if not graph_schema.check_graph(upgraded.get("graph")) and isinstance(upgraded.get("steps"), list):
|
|
166
|
+
upgraded["graph"] = graph_chain.from_chain(upgraded["steps"], upgraded.get("repeat"),
|
|
167
|
+
upgraded.get("limits"),
|
|
168
|
+
upgraded.get("cron"), upgraded.get("timezone"))
|
|
169
|
+
upgraded["published"] = {"revision": upgraded.get("revision", 1), "graph": upgraded["graph"]}
|
|
170
|
+
Workflows.derive(upgraded)
|
|
171
|
+
return upgraded if upgraded != workflow else None
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def derive(workflow):
|
|
175
|
+
"""``workflow`` with what its published graph says written on the record"""
|
|
176
|
+
graph = Workflows.live(workflow)
|
|
177
|
+
schedule = next((node for node in graph["nodes"] if node["type"] == "trigger.schedule"), None)
|
|
178
|
+
workflow["cron"] = schedule["config"]["cron"] if schedule else None
|
|
179
|
+
if schedule:
|
|
180
|
+
workflow["timezone"] = schedule["config"]["timezone"]
|
|
181
|
+
cards = graph_chain.as_chain(graph)
|
|
182
|
+
workflow["steps"], workflow["repeat"] = (cards[0], cards[1]) if cards else (None, None)
|
|
183
|
+
workflow["limits"] = dict(graph.get("limits") or graph_schema.DEFAULT_LIMITS)
|
|
184
|
+
workflow["goal"] = graph_chain.words(graph) or workflow.get("goal") or workflow["name"]
|
|
185
|
+
if graph_chain.is_plain(graph):
|
|
186
|
+
workflow["appId"] = cards[0][0].get("appId")
|
|
187
|
+
return workflow
|
|
188
|
+
|
|
189
|
+
def _migrate(self, connection):
|
|
190
|
+
for identifier, data in connection.execute("SELECT id, data FROM workflows").fetchall():
|
|
191
|
+
try:
|
|
192
|
+
upgraded = self.upgrade(json.loads(data))
|
|
193
|
+
except ValueError:
|
|
194
|
+
continue
|
|
195
|
+
if upgraded is not None:
|
|
196
|
+
connection.execute("UPDATE workflows SET data=? WHERE id=?", (encode(upgraded), identifier))
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def public(workflow):
|
|
200
|
+
"""A workflow as the API shows it"""
|
|
201
|
+
graph = workflow.get("graph")
|
|
202
|
+
if not graph_schema.check_graph(graph):
|
|
203
|
+
steps = workflow.get("steps") if isinstance(workflow.get("steps"), list) else batch.one_task(
|
|
204
|
+
workflow.get("goal"), workflow.get("appId"))
|
|
205
|
+
graph = graph_chain.from_chain(steps, workflow.get("repeat"), workflow.get("limits"), workflow.get("cron"),
|
|
206
|
+
workflow.get("timezone"))
|
|
207
|
+
workflow = {**workflow, "graph": graph}
|
|
208
|
+
live = Workflows.live(workflow)
|
|
209
|
+
published = workflow.get("published") if isinstance(workflow.get("published"), dict) else None
|
|
210
|
+
cards = graph_chain.as_chain(live)
|
|
211
|
+
blanks = [{"name": item["name"], "default": None if item.get("default") is None else str(item["default"]),
|
|
212
|
+
"type": item["type"]} for item in live.get("inputs") or []]
|
|
213
|
+
declared = {starters._key(item["name"]) for item in blanks}
|
|
214
|
+
blanks += [blank for blank in starters.blanks(workflow.get("goal"))
|
|
215
|
+
if starters._key(blank["name"]) not in declared]
|
|
216
|
+
# A notification waiting for its run stays on this Mac's record only: no client reads what it said.
|
|
217
|
+
workflow = {key: value for key, value in workflow.items() if key != "queuedEvent"}
|
|
218
|
+
return {**workflow, "file": public_file(workflow.get("file")),
|
|
219
|
+
"sameConversation": bool(workflow.get("sameConversation")),
|
|
220
|
+
"homeThreadId": workflow.get("homeThreadId"), "lastThreadId": workflow.get("lastThreadId"),
|
|
221
|
+
"blanks": blanks, "graph": graph, "steps": cards[0] if cards else None,
|
|
222
|
+
"repeat": cards[1] if cards else None,
|
|
223
|
+
"published": {"revision": published["revision"]} if published else None,
|
|
224
|
+
"draft": published is None or published.get("graph") != graph,
|
|
225
|
+
"startsItself": _starts_itself(workflow), "listensFor": _listens_for(workflow),
|
|
226
|
+
"limits": dict(live.get("limits") or graph_schema.DEFAULT_LIMITS)}
|
|
227
|
+
|
|
228
|
+
def now(self):
|
|
229
|
+
return round(self.clock() * 1000)
|
|
230
|
+
|
|
231
|
+
def _get(self, identifier):
|
|
232
|
+
row = self.journal.connection.execute("SELECT data FROM workflows WHERE id=?", (identifier,)).fetchone()
|
|
233
|
+
if row is None:
|
|
234
|
+
raise APIError("Saved task not found", 404, "not_found")
|
|
235
|
+
return json.loads(row[0])
|
|
236
|
+
|
|
237
|
+
def _save(self, connection, workflow):
|
|
238
|
+
connection.execute("UPDATE workflows SET data=? WHERE id=?", (encode(workflow), workflow["id"]))
|
|
239
|
+
|
|
240
|
+
def _records(self):
|
|
241
|
+
"""Every stored record, newest first (not the public view: these are saved back)."""
|
|
242
|
+
with self.journal.lock:
|
|
243
|
+
return [json.loads(row[0])
|
|
244
|
+
for row in self.journal.connection.execute("SELECT data FROM workflows ORDER BY rowid DESC")]
|
|
245
|
+
|
|
246
|
+
def list(self):
|
|
247
|
+
with self.lock:
|
|
248
|
+
self._reconcile()
|
|
249
|
+
with self.journal.lock:
|
|
250
|
+
return [self.public(json.loads(row[0]))
|
|
251
|
+
for row in self.journal.connection.execute("SELECT data FROM workflows ORDER BY rowid DESC")]
|
|
252
|
+
|
|
253
|
+
def tools(self):
|
|
254
|
+
"""GET /api/workflows/tools: each workflow made a tool, as MCP, REST and Shortcuts see it (``tools``)."""
|
|
255
|
+
with self.lock:
|
|
256
|
+
return tools([(self.public(record), self.live(record)) for record in reversed(self._records())])
|
|
257
|
+
|
|
258
|
+
def tool(self, slug):
|
|
259
|
+
"""The tool ``slug`` names (GET /v1/workflows/<slug>); 404 when no workflow is that tool any more."""
|
|
260
|
+
found = next((tool for tool in self.tools() if tool["slug"] == slug), None)
|
|
261
|
+
if found is None:
|
|
262
|
+
raise APIError(f"No workflow is the tool {slug}. GET /api/workflows/tools lists them.", 404, "not_found")
|
|
263
|
+
return found
|
|
264
|
+
|
|
265
|
+
def run_tool(self, slug, body, request_key, origin=None, project_secrets=False):
|
|
266
|
+
"""POST /v1/workflows/<slug>: run the published workflow the tool names with the values its schema takes"""
|
|
267
|
+
tool = self.tool(slug)
|
|
268
|
+
schema = tool["inputSchema"]
|
|
269
|
+
if not isinstance(body, dict) or set(body) - set(schema["properties"]):
|
|
270
|
+
raise ValueError(f"{tool['workflowName']} takes " + ", ".join(schema["properties"]))
|
|
271
|
+
missing = [name for name in schema["required"] if name not in body]
|
|
272
|
+
if missing:
|
|
273
|
+
raise ValueError(f"Give {', '.join(missing)}: {tool['workflowName']} has no default for it")
|
|
274
|
+
wait = body.get("wait_seconds", TOOL_WAIT_DEFAULT)
|
|
275
|
+
if type(wait) is not int or not 0 <= wait <= TOOL_WAIT_MAX:
|
|
276
|
+
raise ValueError(f"wait_seconds is a whole number from 0 to {TOOL_WAIT_MAX}")
|
|
277
|
+
values = {name: value for name, value in body.items() if name != "wait_seconds"}
|
|
278
|
+
started = self.start(tool["workflowId"], request_key, values=values or None, origin=origin,
|
|
279
|
+
project_secrets=project_secrets)
|
|
280
|
+
answer = {"workflowId": tool["workflowId"], "threadId": started.get("threadId"),
|
|
281
|
+
**self.follow(tool["workflowId"], started, wait)}
|
|
282
|
+
return answer, answer["status"] not in ("running", "queued")
|
|
283
|
+
|
|
284
|
+
def prove(self, identifier, body, request_key, origin=None):
|
|
285
|
+
"""POST /api/workflows/{id}/prove {revision, graph?}"""
|
|
286
|
+
if not isinstance(body, dict) or set(body) - {"revision", "graph"} or type(body.get("revision")) is not int:
|
|
287
|
+
raise ValueError("A prove takes the draft's revision, and its graph when you save it now")
|
|
288
|
+
if "graph" in body:
|
|
289
|
+
saved = self.update(identifier, {"revision": body["revision"], "graph": body["graph"]})
|
|
290
|
+
else:
|
|
291
|
+
saved = self.get(identifier)
|
|
292
|
+
if saved["revision"] != body["revision"]:
|
|
293
|
+
raise APIError("The saved task changed. Reload before editing it.", 409, "revision_conflict")
|
|
294
|
+
return {"workflow": saved, **self.start(identifier, request_key, origin=origin, test=True, prove=True)}
|
|
295
|
+
|
|
296
|
+
def _proved(self, going):
|
|
297
|
+
"""A prove's test run completed: pin each step's real output on the draft it ran"""
|
|
298
|
+
identifier, ran = going.workflow["id"], going.record.get("revision")
|
|
299
|
+
with self.lock, self.journal.lock:
|
|
300
|
+
workflow = self._get(identifier)
|
|
301
|
+
if workflow.get("revision") != ran:
|
|
302
|
+
return
|
|
303
|
+
graph = json.loads(json.dumps(workflow["graph"]))
|
|
304
|
+
nodes = {node["id"]: node for node in graph["nodes"]}
|
|
305
|
+
pinned, total = [], 0
|
|
306
|
+
for inst in sorted(going.instances.values(), key=lambda i: i.seq):
|
|
307
|
+
if "@" in inst.id or inst.state != "done" or inst.reused or inst.kind not in PROVE_PINS:
|
|
308
|
+
continue
|
|
309
|
+
pin = {port: value for port, value in inst.ports.items() if port != "error"}
|
|
310
|
+
size = len(json.dumps(pin, ensure_ascii=False, default=str).encode())
|
|
311
|
+
if not pin or size > graph_schema.MAX_PIN_BYTES or total + size > graph_schema.MAX_PINS_BYTES:
|
|
312
|
+
continue
|
|
313
|
+
nodes[inst.node["id"]]["pin"], total = pin, total + size
|
|
314
|
+
pinned.append(inst.node["id"])
|
|
315
|
+
if pinned:
|
|
316
|
+
try:
|
|
317
|
+
ran = self.update(identifier, {"revision": ran, "graph": graph})["revision"]
|
|
318
|
+
except (APIError, ValueError):
|
|
319
|
+
pinned = [] # the outputs don't fit the pins (or it changed): proven, unpinned
|
|
320
|
+
with self.lock, self.journal.transaction() as connection:
|
|
321
|
+
workflow = self._get(identifier)
|
|
322
|
+
if workflow.get("revision") != ran:
|
|
323
|
+
return
|
|
324
|
+
workflow["proven"] = {"revision": ran, "batchId": going.record["id"], "at": self.now(), "pinned": pinned}
|
|
325
|
+
self._save(connection, workflow)
|
|
326
|
+
|
|
327
|
+
def get(self, identifier):
|
|
328
|
+
with self.lock, self.journal.lock:
|
|
329
|
+
return self.public(self._get(identifier))
|
|
330
|
+
|
|
331
|
+
def find(self, wanted):
|
|
332
|
+
"""A workflow by its id, else by its name (case and spacing ignored)."""
|
|
333
|
+
if not isinstance(wanted, str) or not wanted.strip():
|
|
334
|
+
raise ValueError("Name the workflow, or give its id")
|
|
335
|
+
wanted = wanted.strip()
|
|
336
|
+
listed = self.list()
|
|
337
|
+
found = [w for w in listed if w["id"] == wanted.lower()] or [
|
|
338
|
+
w for w in listed if " ".join(w["name"].split()).casefold() == " ".join(wanted.split()).casefold()]
|
|
339
|
+
if not found:
|
|
340
|
+
raise APIError(f"No workflow is called {wanted[:120]}", 404, "not_found")
|
|
341
|
+
if len(found) > 1:
|
|
342
|
+
raise APIError(f"Several workflows are called {wanted[:120]}. Use its id: "
|
|
343
|
+
+ ", ".join(w["id"] for w in found), 409, "ambiguous")
|
|
344
|
+
return found[0]
|
|
345
|
+
|
|
346
|
+
def app_ids(self, wanted):
|
|
347
|
+
"""The ids a saved task may name: the catalog's, the apps found on the phone, and, until the phone lists its
|
|
348
|
+
apps, the app packs' apps (ICP-10). A phone that lists its apps without a pack's app refuses it."""
|
|
349
|
+
ids = {app["id"] for app in APPS}
|
|
350
|
+
if wanted in ids:
|
|
351
|
+
return ids
|
|
352
|
+
apps = getattr(self.runtime, "apps", None)
|
|
353
|
+
listed = apps() if callable(apps) else []
|
|
354
|
+
ids |= {app["id"] for app in listed}
|
|
355
|
+
if all(app.get("installed") is None for app in listed): # no inventory: the catalog claims nothing
|
|
356
|
+
ids |= {bundle for _key, bundle, _name in pack_apps()}
|
|
357
|
+
return ids
|
|
358
|
+
|
|
359
|
+
def _devices_here(self):
|
|
360
|
+
"""Whether this runtime runs tasks on several devices (a fleet, and Runtime.create(default_device=...))."""
|
|
361
|
+
return getattr(self.runtime, "fleet", None) is not None
|
|
362
|
+
|
|
363
|
+
def _device(self, value):
|
|
364
|
+
"""A saved task's device: None (the default device when it runs)"""
|
|
365
|
+
if value is None:
|
|
366
|
+
return None
|
|
367
|
+
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 128:
|
|
368
|
+
raise ValueError("device must be a device's id, UDID or name")
|
|
369
|
+
fleet = getattr(self.runtime, "fleet", None)
|
|
370
|
+
if fleet is None:
|
|
371
|
+
raise ValueError("Mobster can't run a task on a chosen device here. Save the task without a device.")
|
|
372
|
+
try:
|
|
373
|
+
return fleet.find(value.strip()).id
|
|
374
|
+
except LookupError as error:
|
|
375
|
+
raise ValueError(str(error)) from None
|
|
376
|
+
|
|
377
|
+
def _account(self, value):
|
|
378
|
+
"""A saved task's account (track accounts): None, or an account this Mobster knows, saved by its id."""
|
|
379
|
+
if value is None:
|
|
380
|
+
return None
|
|
381
|
+
from .. import harness_api
|
|
382
|
+
field = harness_api.run_fields().get("account")
|
|
383
|
+
if field is None:
|
|
384
|
+
raise ValueError("Profiles aren't available right now. Save the task without a profile.")
|
|
385
|
+
return field[0](value, self.runtime)["id"]
|
|
386
|
+
|
|
387
|
+
def _send_to(self, value):
|
|
388
|
+
"""A saved task's destination (track outlets): None, or a destination this Mobster has, saved by its id."""
|
|
389
|
+
if value is None:
|
|
390
|
+
return None
|
|
391
|
+
field = harness_api.run_fields().get("sendTo")
|
|
392
|
+
if field is None:
|
|
393
|
+
raise ValueError("Sending results isn't available in this Mobster. Save the workflow without sendTo.")
|
|
394
|
+
return field[0](value, self.runtime)["destination"]
|
|
395
|
+
|
|
396
|
+
def _send_extras(self, workflow, values=None):
|
|
397
|
+
"""The run field that sends the result to the workflow's destination; None never stops the run."""
|
|
398
|
+
destination = workflow.get("sendTo")
|
|
399
|
+
field = harness_api.run_fields().get("sendTo")
|
|
400
|
+
if not destination or field is None:
|
|
401
|
+
return None
|
|
402
|
+
try:
|
|
403
|
+
given = starters.clean_values(self._account_values(workflow, values))
|
|
404
|
+
except ValueError:
|
|
405
|
+
given = {}
|
|
406
|
+
blanks = {}
|
|
407
|
+
for blank in starters.blanks(workflow.get("goal")):
|
|
408
|
+
value = given.get(starters._key(blank["name"])) or blank["default"]
|
|
409
|
+
if value:
|
|
410
|
+
blanks[blank["name"]] = value
|
|
411
|
+
value = {"destination": destination, "workflow": {"id": workflow["id"], "name": workflow["name"],
|
|
412
|
+
"blanks": blanks}}
|
|
413
|
+
try:
|
|
414
|
+
return field[0](value, self.runtime)
|
|
415
|
+
except ValueError:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
def _app_name(self, app_id, given=None):
|
|
419
|
+
"""The name shown for a saved task's app, kept so it still has one while the phone is away."""
|
|
420
|
+
if app_id is None:
|
|
421
|
+
return None
|
|
422
|
+
if given is not None:
|
|
423
|
+
if not isinstance(given, str) or not 1 <= len(given.strip()) <= 120:
|
|
424
|
+
raise ValueError("Name the app using 1–120 characters")
|
|
425
|
+
return given.strip()
|
|
426
|
+
names = {app["id"]: app["name"] for app in APPS}
|
|
427
|
+
apps = getattr(self.runtime, "apps", None)
|
|
428
|
+
if app_id not in names and callable(apps):
|
|
429
|
+
try:
|
|
430
|
+
names.update({app["id"]: app["name"] for app in apps()})
|
|
431
|
+
except Exception: # The phone's inventory is optional here; the list falls back to a plain label.
|
|
432
|
+
pass
|
|
433
|
+
return names.get(app_id)
|
|
434
|
+
|
|
435
|
+
def _check_task(self, workflow):
|
|
436
|
+
"""The app and engine a saved task starts with. Without an app it starts on the Home Screen."""
|
|
437
|
+
validate_engine(workflow.get("engine"))
|
|
438
|
+
app_id = workflow.get("appId")
|
|
439
|
+
if app_id is None:
|
|
440
|
+
return
|
|
441
|
+
if not isinstance(app_id, str) or app_id not in self.app_ids(app_id):
|
|
442
|
+
raise ValueError(graph_catalog.app_missing(app_id))
|
|
443
|
+
|
|
444
|
+
def _schedule(self, workflow):
|
|
445
|
+
"""Validates cron, timezone and enabled in place and returns the next occurrence (None while paused)."""
|
|
446
|
+
if type(workflow["enabled"]) is not bool:
|
|
447
|
+
raise ValueError("enabled must be true or false")
|
|
448
|
+
timezone(workflow["timezone"])
|
|
449
|
+
if workflow["cron"] is not None:
|
|
450
|
+
workflow["cron"] = validate_cron(workflow["cron"], workflow["timezone"], self.daily_limit)
|
|
451
|
+
# Also validate sparse expressions even while paused.
|
|
452
|
+
next_at = next_occurrences(workflow["cron"], workflow["timezone"], self.now())[0]
|
|
453
|
+
else:
|
|
454
|
+
next_at = None
|
|
455
|
+
if workflow["enabled"] and next_at is None and not _listens(workflow):
|
|
456
|
+
raise ValueError(
|
|
457
|
+
"Configure a cron schedule, or a trigger for notifications or an app, before turning it on")
|
|
458
|
+
repeat = workflow.get("repeat") or {}
|
|
459
|
+
if workflow["enabled"] and repeat.get("kind") == "each" and not repeat.get("rows"):
|
|
460
|
+
raise ValueError("A schedule repeats over the rows saved with the workflow. Save its rows first.")
|
|
461
|
+
filled = (self._account_values(workflow) or {}) if workflow["enabled"] else {}
|
|
462
|
+
if workflow["enabled"] and repeat.get("rows"):
|
|
463
|
+
# A blank every saved row fills has a value at every scheduled run.
|
|
464
|
+
keys = [set(starters.clean_values(row)) for row in repeat["rows"]]
|
|
465
|
+
filled = {**{key: "row" for key in set.intersection(*keys)}, **filled}
|
|
466
|
+
missing = [name for name in starters.unfilled(workflow.get("goal")) if starters._key(name) not in filled] \
|
|
467
|
+
if workflow["enabled"] else []
|
|
468
|
+
if missing:
|
|
469
|
+
raise ValueError("A schedule fills every blank with its default. Give "
|
|
470
|
+
+ ", ".join(missing) + " a value, like {" + missing[0] + ": ...}")
|
|
471
|
+
return next_at
|
|
472
|
+
|
|
473
|
+
def _account_values(self, workflow, values=None, *, typed=False):
|
|
474
|
+
"""A run's values, with the workflow's account (track accounts) filling the blanks it has a value for"""
|
|
475
|
+
account = workflow.get("account")
|
|
476
|
+
if not account:
|
|
477
|
+
return values
|
|
478
|
+
try:
|
|
479
|
+
from ..accounts.context import values as account_values
|
|
480
|
+
from ..accounts.store import store_for
|
|
481
|
+
known = account_values(store_for(self.runtime).get(account))
|
|
482
|
+
except Exception: # noqa: BLE001 -- no accounts track, or the account is gone: the blanks stay unfilled
|
|
483
|
+
return values
|
|
484
|
+
if typed and values is not None and not isinstance(values, dict):
|
|
485
|
+
raise ValueError("Graph values are an object of named inputs")
|
|
486
|
+
out = {}
|
|
487
|
+
for blank in starters.blanks(workflow.get("goal")):
|
|
488
|
+
key = starters._key(blank["name"])
|
|
489
|
+
value = known.get(key, known.get(key.replace(" ", "_")))
|
|
490
|
+
if value:
|
|
491
|
+
out[key] = value
|
|
492
|
+
# Graph inputs keep their JSON types until graph.dispatch checks and coerces them.
|
|
493
|
+
# Plain starter blanks still use their text-only normalization.
|
|
494
|
+
out.update((values or {}) if typed else starters.clean_values(values))
|
|
495
|
+
return out
|
|
496
|
+
|
|
497
|
+
def owns_run_request(self, workflow_id, key):
|
|
498
|
+
"""Only a saved workflow dispatch or a journaled graph node may attach its identity to a task."""
|
|
499
|
+
if not isinstance(key, str) or not key.startswith(f"workflow:{workflow_id}:"):
|
|
500
|
+
return False
|
|
501
|
+
with self.journal.lock:
|
|
502
|
+
row = self.journal.connection.execute(
|
|
503
|
+
"SELECT workflow_id FROM workflow_dispatches WHERE key=?", (key,)).fetchone()
|
|
504
|
+
if row is not None:
|
|
505
|
+
return row[0] == workflow_id
|
|
506
|
+
prefix, marker, suffix = key.rpartition(":g")
|
|
507
|
+
batch_id, colon, _ = suffix.partition(":")
|
|
508
|
+
if not marker or not colon or not re.fullmatch(r"[0-9a-f]{12}", batch_id):
|
|
509
|
+
return False
|
|
510
|
+
record = self.batches.get(batch_id)
|
|
511
|
+
return bool(record and record["workflowId"] == workflow_id and record["key"] == prefix
|
|
512
|
+
and any(node.get("key") == key for node in self.batches.nodes(batch_id)))
|
|
513
|
+
|
|
514
|
+
def run_hook_event(self, identifier, request_key, values, body, occurred_at):
|
|
515
|
+
"""A claimed webhook event skips an overlapping workflow under the same lock as dispatch."""
|
|
516
|
+
with self.lock:
|
|
517
|
+
self._reconcile()
|
|
518
|
+
with self.journal.lock:
|
|
519
|
+
busy = self.journal.connection.execute(
|
|
520
|
+
"SELECT 1 FROM workflow_dispatches WHERE workflow_id=? AND outcome IN ('claimed','started') "
|
|
521
|
+
"UNION ALL SELECT 1 FROM workflow_batches WHERE workflow_id=? AND status='running' LIMIT 1",
|
|
522
|
+
(identifier, identifier)).fetchone()
|
|
523
|
+
if busy:
|
|
524
|
+
raise APIError("This workflow is already running. This event was skipped.", 409,
|
|
525
|
+
"workflow_overlap")
|
|
526
|
+
if abs(self.now() - occurred_at) > 300_000:
|
|
527
|
+
raise APIError("This event expired before it could start.", 410, "event_expired")
|
|
528
|
+
return self.run(identifier, request_key, values, origin="hook", body=body)
|
|
529
|
+
|
|
530
|
+
def _conversation_fields(self, workflow):
|
|
531
|
+
"""Validates sameConversation and homeThreadId in place: a home conversation is one this Mobster has."""
|
|
532
|
+
if type(workflow.get("sameConversation", False)) is not bool:
|
|
533
|
+
raise ValueError("sameConversation must be true or false")
|
|
534
|
+
home = workflow.get("homeThreadId")
|
|
535
|
+
if home is None:
|
|
536
|
+
return
|
|
537
|
+
if not isinstance(home, str) or not THREAD_ID.fullmatch(home):
|
|
538
|
+
raise ValueError("homeThreadId is a conversation's 12-character id")
|
|
539
|
+
service = self._threads()
|
|
540
|
+
if service is not None and service.store.get(home) is None:
|
|
541
|
+
raise ValueError("That conversation was deleted. Save the workflow without it.")
|
|
542
|
+
|
|
543
|
+
def _threads(self):
|
|
544
|
+
"""The conversations service (threads.service), or None in a Mobster without conversations."""
|
|
545
|
+
try:
|
|
546
|
+
from ..threads.service import service_for
|
|
547
|
+
except ImportError:
|
|
548
|
+
return None
|
|
549
|
+
try:
|
|
550
|
+
return service_for(self.runtime)
|
|
551
|
+
except Exception: # noqa: BLE001 -- a runtime that isn't a Runtime (a test double)
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
def _destination_exists(self):
|
|
555
|
+
"""callable(id) -> whether a saved destination has that id, or None in a Mobster without outlets."""
|
|
556
|
+
try:
|
|
557
|
+
from ..outlets.service import service_for
|
|
558
|
+
except ImportError:
|
|
559
|
+
return None
|
|
560
|
+
service = service_for(self.runtime)
|
|
561
|
+
if service is None:
|
|
562
|
+
return None
|
|
563
|
+
return lambda identifier: service.store.destination(identifier) is not None
|
|
564
|
+
|
|
565
|
+
def _check_cards(self, steps, repeat, limits, workflow_id=None):
|
|
566
|
+
"""(steps, repeat, limits) checked (batch.validate), each task's app one this Mobster knows."""
|
|
567
|
+
records = {record["id"]: record for record in self._records()}
|
|
568
|
+
steps = [{**step, "appId": None} if isinstance(step, dict) and step.get("appId") == ANY_APP_ID else step
|
|
569
|
+
for step in steps] if isinstance(steps, list) else steps
|
|
570
|
+
steps, repeat, limits = batch.validate(steps, repeat, limits, workflow_id=workflow_id, workflows=records,
|
|
571
|
+
destinations=self._destination_exists(), runtime=self.runtime)
|
|
572
|
+
for step in steps:
|
|
573
|
+
app = step.get("appId") if step["kind"] == "task" else None
|
|
574
|
+
if app is not None and app not in self.app_ids(app):
|
|
575
|
+
raise ValueError(graph_catalog.app_missing(app))
|
|
576
|
+
return steps, repeat, limits
|
|
577
|
+
|
|
578
|
+
def _graph_context(self, workflow_id=None):
|
|
579
|
+
"""What graph validation asks this Mobster (graph/schema.Context)."""
|
|
580
|
+
def connector(config):
|
|
581
|
+
if "connector" not in batch.step_kinds():
|
|
582
|
+
raise ValueError("Connectors aren't available in this Mobster")
|
|
583
|
+
made = batch._kinds["connector"][0]({"id": "check", "kind": "connector", **config}, self.runtime)
|
|
584
|
+
return made
|
|
585
|
+
|
|
586
|
+
def cron(value, zone):
|
|
587
|
+
return validate_cron(value, zone or local_timezone(), self.daily_limit)
|
|
588
|
+
|
|
589
|
+
def zone(value):
|
|
590
|
+
timezone(value)
|
|
591
|
+
return value
|
|
592
|
+
|
|
593
|
+
return graph_schema.Context(
|
|
594
|
+
workflow_id=workflow_id, workflows={record["id"]: record for record in self._records()},
|
|
595
|
+
app_ids=self.app_ids, device=self._device, account=self._account, destination=self._destination_exists(),
|
|
596
|
+
connector=connector, packs=_packs(), cron=cron, timezone=zone, deliveries=tuple(batch.delivery_kinds()),
|
|
597
|
+
plain=graph_chain.record_is_plain)
|
|
598
|
+
|
|
599
|
+
def check_graph(self, graph, workflow_id=None):
|
|
600
|
+
"""``graph`` as it would be saved, or graph_schema.GraphError listing every
|
|
601
|
+
problem (POST /api/workflows/validate)."""
|
|
602
|
+
return graph_schema.validate(graph, self._graph_context(workflow_id))
|
|
603
|
+
|
|
604
|
+
def _chain_graph(self, steps, repeat, limits, cron, zone, workflow_id=None, before=None):
|
|
605
|
+
"""The graph a body's cards make, keeping where the canvas put each node and what it called it."""
|
|
606
|
+
graph = graph_chain.from_chain(steps, repeat, limits, cron, zone)
|
|
607
|
+
if before is not None:
|
|
608
|
+
kept = {node["id"]: node for node in before.get("nodes") or []}
|
|
609
|
+
for node in graph["nodes"]:
|
|
610
|
+
old = kept.get(node["id"])
|
|
611
|
+
if old is not None and old["type"] == node["type"]:
|
|
612
|
+
node["position"] = old.get("position", node["position"])
|
|
613
|
+
for key in ("title", "notes"):
|
|
614
|
+
if old.get(key):
|
|
615
|
+
node[key] = old[key]
|
|
616
|
+
return self.check_graph(graph, workflow_id)
|
|
617
|
+
|
|
618
|
+
def create(self, body):
|
|
619
|
+
if not isinstance(body, dict) or set(body) - CREATE_FIELDS:
|
|
620
|
+
raise ValueError("Unsupported saved task field")
|
|
621
|
+
name = body.get("name")
|
|
622
|
+
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 120:
|
|
623
|
+
raise ValueError("Name the saved task using 1–120 characters")
|
|
624
|
+
app_id = body.get("appId")
|
|
625
|
+
if app_id == ANY_APP_ID:
|
|
626
|
+
app_id = None # a run started on the Home Screen, saved from the run view
|
|
627
|
+
zone = body.get("timezone", local_timezone())
|
|
628
|
+
if "graph" in body:
|
|
629
|
+
if set(body) & NOT_WITH_GRAPH:
|
|
630
|
+
raise ValueError("Send the graph or the steps, not both: the graph holds the steps, their words, "
|
|
631
|
+
"the app and the schedule")
|
|
632
|
+
graph = self.check_graph(body["graph"])
|
|
633
|
+
else:
|
|
634
|
+
if "steps" in body:
|
|
635
|
+
steps, repeat, limits = self._check_cards(body["steps"], body.get("repeat"), body.get("limits"))
|
|
636
|
+
else:
|
|
637
|
+
goal = validate_goal(body.get("goal"))
|
|
638
|
+
steps, repeat, limits = self._check_cards(batch.one_task(goal, app_id), body.get("repeat"),
|
|
639
|
+
body.get("limits"))
|
|
640
|
+
graph = self._chain_graph(steps, repeat, limits, body.get("cron"), zone)
|
|
641
|
+
engine = validate_engine(body.get("engine"))
|
|
642
|
+
output_format = body.get("outputFormat", "auto")
|
|
643
|
+
schema = validate_output(output_format, body.get("outputSchema"))
|
|
644
|
+
now = self.now()
|
|
645
|
+
# A new schedule reads in the Mac's own time unless the caller names a zone (existing ones keep theirs).
|
|
646
|
+
device = self._device(body.get("device"))
|
|
647
|
+
account = self._account(body.get("account"))
|
|
648
|
+
send_to = self._send_to(body.get("sendTo"))
|
|
649
|
+
workflow = {"id": uuid.uuid4().hex[:12], "name": name.strip(), "appId": app_id, "appName": None, "goal": None,
|
|
650
|
+
"outputSchema": schema, "outputFormat": output_format,
|
|
651
|
+
"engine": engine, "device": device, **({"account": account} if account else {}),
|
|
652
|
+
**({"sendTo": send_to} if send_to else {}), "cron": None, "timezone": zone,
|
|
653
|
+
"enabled": body.get("enabled", False), "revision": 1, "createdAt": now, "updatedAt": now,
|
|
654
|
+
"nextAt": None, "lastRunId": None, "lastOutcome": None, "version": VERSION,
|
|
655
|
+
"sameConversation": body.get("sameConversation", False), "homeThreadId": body.get("homeThreadId"),
|
|
656
|
+
"lastThreadId": None, "graph": graph,
|
|
657
|
+
**({"produces": validate_produces(body["produces"])} if body.get("produces") is not None else {})}
|
|
658
|
+
if body.get("publish", True) is not False:
|
|
659
|
+
workflow["published"] = {"revision": 1, "graph": graph}
|
|
660
|
+
if body.get("asTool") is not None:
|
|
661
|
+
workflow["asTool"] = _as_tool(body["asTool"])
|
|
662
|
+
self.derive(workflow)
|
|
663
|
+
self._check_task({"appId": workflow["appId"], "engine": engine})
|
|
664
|
+
workflow["appName"] = self._app_name(workflow["appId"], body.get("appName"))
|
|
665
|
+
self._conversation_fields(workflow)
|
|
666
|
+
next_at = self._schedule(workflow)
|
|
667
|
+
workflow["nextAt"] = next_at if workflow["enabled"] else None
|
|
668
|
+
with self.lock, self.journal.lock:
|
|
669
|
+
if self.journal.connection.execute("SELECT count(*) FROM workflows").fetchone()[0] >= MAX_WORKFLOWS:
|
|
670
|
+
raise APIError("Saved task limit reached", 409, "workflow_limit")
|
|
671
|
+
with self.journal.transaction() as connection:
|
|
672
|
+
connection.execute("INSERT INTO workflows VALUES (?,?)", (workflow["id"], encode(workflow)))
|
|
673
|
+
self.history.keep(connection, workflow)
|
|
674
|
+
self.triggers.changed()
|
|
675
|
+
return self.public(workflow)
|
|
676
|
+
|
|
677
|
+
def _graph_change(self, workflow, body, identifier):
|
|
678
|
+
"""(draft, published): the graphs an update leaves, ``published`` None when it doesn't publish."""
|
|
679
|
+
publish = body.get("publish")
|
|
680
|
+
if publish is not None and type(publish) is not bool:
|
|
681
|
+
raise ValueError("publish is true or false")
|
|
682
|
+
if "graph" in body:
|
|
683
|
+
if set(body) & NOT_WITH_GRAPH:
|
|
684
|
+
raise ValueError("Send the graph or the steps, not both: the graph holds the steps, their words, "
|
|
685
|
+
"the app and the schedule")
|
|
686
|
+
graph = self.check_graph(body["graph"], identifier)
|
|
687
|
+
return graph, graph if publish else None
|
|
688
|
+
if not set(body) & NOT_WITH_GRAPH:
|
|
689
|
+
return workflow["graph"], workflow["graph"] if publish else None
|
|
690
|
+
live = self.live(workflow)
|
|
691
|
+
cron = body.get("cron", workflow.get("cron"))
|
|
692
|
+
zone = body.get("timezone", workflow.get("timezone"))
|
|
693
|
+
cards = graph_chain.as_chain(live)
|
|
694
|
+
if cards is None:
|
|
695
|
+
if set(body) & {"steps", "repeat", "goal", "appId"}:
|
|
696
|
+
raise ValueError("This workflow is a graph with more than steps in a row. Change it in the canvas, "
|
|
697
|
+
"or send its graph.")
|
|
698
|
+
|
|
699
|
+
def changed(graph):
|
|
700
|
+
graph = graph_edit.with_schedule(graph, cron, zone)
|
|
701
|
+
return self.check_graph({**graph, "limits": body["limits"]} if "limits" in body else graph, identifier)
|
|
702
|
+
graph = changed(live)
|
|
703
|
+
draft = graph if workflow["graph"] == live else changed(workflow["graph"])
|
|
704
|
+
return draft, None if publish is False else graph
|
|
705
|
+
steps, repeat, limits = cards[0], cards[1], cards[2]
|
|
706
|
+
if "steps" in body:
|
|
707
|
+
steps, repeat, limits = self._check_cards(body["steps"], body.get("repeat", repeat),
|
|
708
|
+
body.get("limits", limits), identifier)
|
|
709
|
+
elif "goal" in body or "appId" in body or "repeat" in body or "limits" in body:
|
|
710
|
+
if "goal" in body or "appId" in body:
|
|
711
|
+
if not batch.is_plain(steps):
|
|
712
|
+
raise ValueError("This workflow has several steps. Change them in its steps.")
|
|
713
|
+
text = validate_goal(body["goal"]) if "goal" in body else steps[0]["text"]
|
|
714
|
+
app = body["appId"] if "appId" in body else steps[0].get("appId")
|
|
715
|
+
steps = [{**steps[0], "text": text, "appId": None if app == ANY_APP_ID else app}]
|
|
716
|
+
steps, repeat, limits = self._check_cards(steps, body.get("repeat", repeat), body.get("limits", limits),
|
|
717
|
+
identifier)
|
|
718
|
+
graph = self._chain_graph(steps, repeat, limits, cron, zone, identifier, before=workflow["graph"])
|
|
719
|
+
return graph, None if publish is False else graph
|
|
720
|
+
|
|
721
|
+
def update(self, identifier, body):
|
|
722
|
+
if (not isinstance(body, dict) or set(body) - UPDATE_FIELDS
|
|
723
|
+
or type(body.get("revision")) is not int):
|
|
724
|
+
raise ValueError("Supply the current revision and supported schedule fields")
|
|
725
|
+
with self.lock, self.journal.lock:
|
|
726
|
+
workflow = self._get(identifier)
|
|
727
|
+
if body["revision"] != workflow["revision"]:
|
|
728
|
+
raise APIError("The saved task changed. Reload before editing it.", 409, "revision_conflict")
|
|
729
|
+
if not graph_schema.check_graph(workflow.get("graph")):
|
|
730
|
+
workflow = {**workflow, **(self.upgrade(workflow) or {})}
|
|
731
|
+
updated = {**workflow, **{key: value for key, value in body.items()
|
|
732
|
+
if key not in NOT_WITH_GRAPH | {"graph", "publish"}}}
|
|
733
|
+
if "produces" in body:
|
|
734
|
+
updated["produces"] = validate_produces(body["produces"])
|
|
735
|
+
if updated["produces"] is None:
|
|
736
|
+
updated.pop("produces")
|
|
737
|
+
if "asTool" in body:
|
|
738
|
+
updated["asTool"] = _as_tool(body["asTool"])
|
|
739
|
+
updated["graph"], published = self._graph_change(workflow, body, identifier)
|
|
740
|
+
if published is not None:
|
|
741
|
+
updated["published"] = {"revision": workflow["revision"] + 1, "graph": published}
|
|
742
|
+
if "appId" in body and not graph_chain.is_plain(updated["graph"]):
|
|
743
|
+
updated["appId"] = None if body["appId"] == ANY_APP_ID else body["appId"]
|
|
744
|
+
if "timezone" in body:
|
|
745
|
+
updated["timezone"] = body["timezone"]
|
|
746
|
+
self.derive(updated)
|
|
747
|
+
if "device" in body:
|
|
748
|
+
updated["device"] = self._device(body["device"])
|
|
749
|
+
if "account" in body:
|
|
750
|
+
updated["account"] = self._account(body["account"])
|
|
751
|
+
if updated["account"] is None:
|
|
752
|
+
updated.pop("account")
|
|
753
|
+
if "sendTo" in body:
|
|
754
|
+
updated["sendTo"] = self._send_to(body["sendTo"])
|
|
755
|
+
if updated["sendTo"] is None:
|
|
756
|
+
updated.pop("sendTo")
|
|
757
|
+
if "engine" in body:
|
|
758
|
+
updated["engine"] = validate_engine(body["engine"])
|
|
759
|
+
app_changed = updated.get("appId") != workflow.get("appId")
|
|
760
|
+
if "engine" in body or "appId" in body or app_changed:
|
|
761
|
+
self._check_task(updated)
|
|
762
|
+
if "appId" in body or "appName" in body or app_changed:
|
|
763
|
+
# A name sent with the change wins. Without one, the same app keeps its name and a new app is looked up.
|
|
764
|
+
if "appName" in body:
|
|
765
|
+
given = body["appName"]
|
|
766
|
+
elif updated.get("appId") == workflow.get("appId"):
|
|
767
|
+
given = workflow.get("appName")
|
|
768
|
+
else:
|
|
769
|
+
given = None
|
|
770
|
+
updated["appName"] = self._app_name(updated.get("appId"), given)
|
|
771
|
+
if not isinstance(updated["name"], str) or not 1 <= len(updated["name"].strip()) <= 120:
|
|
772
|
+
raise ValueError("Name the saved task using 1–120 characters")
|
|
773
|
+
updated["name"] = updated["name"].strip()
|
|
774
|
+
if "sameConversation" in body or "homeThreadId" in body:
|
|
775
|
+
self._conversation_fields(updated)
|
|
776
|
+
return self._commit(workflow, updated)
|
|
777
|
+
|
|
778
|
+
def _commit(self, workflow, updated):
|
|
779
|
+
"""Save an edit (or a rollback) as the next revision, and keep it as a version. Under self.lock."""
|
|
780
|
+
next_at = self._schedule(updated)
|
|
781
|
+
updated.update(revision=workflow["revision"] + 1, updatedAt=self.now())
|
|
782
|
+
schedule_changed = any(updated.get(key) != workflow.get(key) for key in ("cron", "timezone", "enabled"))
|
|
783
|
+
if schedule_changed:
|
|
784
|
+
updated["nextAt"] = next_at if updated["enabled"] else None
|
|
785
|
+
with self.journal.transaction() as connection:
|
|
786
|
+
self._save(connection, updated)
|
|
787
|
+
self.history.keep(connection, updated)
|
|
788
|
+
self.triggers.changed()
|
|
789
|
+
return self.public(updated)
|
|
790
|
+
|
|
791
|
+
def file_records(self):
|
|
792
|
+
"""Every stored record with a ``file``; only rows that mention one are decoded, as the sync reads it often."""
|
|
793
|
+
with self.lock, self.journal.lock:
|
|
794
|
+
rows = self.journal.connection.execute(
|
|
795
|
+
"""SELECT data FROM workflows WHERE instr(data, '"file":{') > 0""").fetchall()
|
|
796
|
+
return [record for record in (json.loads(row[0]) for row in rows) if isinstance(record.get("file"), dict)]
|
|
797
|
+
|
|
798
|
+
def set_file(self, identifier, file):
|
|
799
|
+
"""Keep where the workflow's file is and how its sync stands, or forget it (None)."""
|
|
800
|
+
with self.lock, self.journal.lock:
|
|
801
|
+
try:
|
|
802
|
+
workflow = self._get(identifier)
|
|
803
|
+
except APIError:
|
|
804
|
+
return None
|
|
805
|
+
if file is None:
|
|
806
|
+
workflow.pop("file", None)
|
|
807
|
+
else:
|
|
808
|
+
workflow["file"] = file
|
|
809
|
+
with self.journal.transaction() as connection:
|
|
810
|
+
self._save(connection, workflow)
|
|
811
|
+
return self.public(workflow)
|
|
812
|
+
|
|
813
|
+
def delete(self, identifier):
|
|
814
|
+
"""Removes a saved task and its dispatch records, under self.lock so never between a claim and its outcome."""
|
|
815
|
+
with self.lock, self.journal.lock:
|
|
816
|
+
gone = self._get(identifier)
|
|
817
|
+
with self._batches_lock:
|
|
818
|
+
going = self._batches.get(identifier)
|
|
819
|
+
if going is not None:
|
|
820
|
+
going.stop.set() # its task stops at the next safe point; nothing new starts
|
|
821
|
+
with self.journal.transaction() as connection:
|
|
822
|
+
connection.execute("DELETE FROM workflow_dispatches WHERE workflow_id=?", (identifier,))
|
|
823
|
+
self.history.forget(connection, identifier)
|
|
824
|
+
self.triggers.forget(connection, identifier)
|
|
825
|
+
connection.execute("DELETE FROM workflows WHERE id=?", (identifier,))
|
|
826
|
+
self.batches.forget(identifier)
|
|
827
|
+
self.triggers.changed()
|
|
828
|
+
for listener in list(self.deleted):
|
|
829
|
+
try:
|
|
830
|
+
listener(gone)
|
|
831
|
+
except Exception: # noqa: BLE001 -- the workflow is deleted whatever a listener does
|
|
832
|
+
harness_api.log.exception("a workflow delete listener failed")
|
|
833
|
+
return identifier
|
|
834
|
+
|
|
835
|
+
def _answer(self, run_id):
|
|
836
|
+
"""A kept run's answer, or None."""
|
|
837
|
+
with self.runtime.lock:
|
|
838
|
+
run = self.runtime.runs.get(run_id) if run_id else None
|
|
839
|
+
summary = getattr(run, "summary", None)
|
|
840
|
+
answer = summary.get("answer") if isinstance(summary, dict) else None
|
|
841
|
+
return answer if isinstance(answer, str) and answer.strip() else None
|
|
842
|
+
|
|
843
|
+
def draft(self, body, client_factory=None):
|
|
844
|
+
"""{"draft": {...}, "drafter"} for POST /api/workflows/draft {threadId?, runId?}"""
|
|
845
|
+
if isinstance(body, dict) and "words" in body:
|
|
846
|
+
# Words to a graph (graph/draft.py): the drafter proposes, Mobster checks, nothing is saved.
|
|
847
|
+
if set(body) - {"words", "graph"}:
|
|
848
|
+
raise ValueError("A draft from words takes words, and the graph it changes")
|
|
849
|
+
current = body.get("graph")
|
|
850
|
+
if current is not None and not graph_schema.check_graph(current):
|
|
851
|
+
raise ValueError("graph is a workflow graph of version 2")
|
|
852
|
+
client = (client_factory or starters.draft_client)()
|
|
853
|
+
return graph_draft.draft(body["words"], self._graph_context(), client=client, current=current,
|
|
854
|
+
zone=local_timezone())
|
|
855
|
+
if (not isinstance(body, dict) or set(body) - {"threadId", "runId"}
|
|
856
|
+
or not (body.get("threadId") or body.get("runId"))):
|
|
857
|
+
raise ValueError("Give the words of a workflow, the threadId of a conversation, or the runId of a task")
|
|
858
|
+
thread_id, run_id = body.get("threadId"), body.get("runId")
|
|
859
|
+
for name, value in (("threadId", thread_id), ("runId", run_id)):
|
|
860
|
+
if value is not None and (not isinstance(value, str) or not THREAD_ID.fullmatch(value)):
|
|
861
|
+
raise ValueError(f"{name} is a 12-character id")
|
|
862
|
+
turns, run_ids = [], []
|
|
863
|
+
service = self._threads()
|
|
864
|
+
if thread_id and service is not None:
|
|
865
|
+
thread = service.store.get(thread_id)
|
|
866
|
+
if thread is None:
|
|
867
|
+
raise APIError("That conversation was deleted.", 404, "thread_not_found")
|
|
868
|
+
for item in service.store.items(thread_id):
|
|
869
|
+
if item.get("kind") == "user" and item.get("routed") in ("new_run", "steer", "answer", "unread"):
|
|
870
|
+
turns.append(("You", item.get("text")))
|
|
871
|
+
elif item.get("kind") == "run":
|
|
872
|
+
run_ids.append(item.get("runId"))
|
|
873
|
+
answer = (item.get("result") or {}).get("answer") or self._answer(item.get("runId"))
|
|
874
|
+
if answer:
|
|
875
|
+
turns.append(("Mobster", answer))
|
|
876
|
+
else:
|
|
877
|
+
thread_id = None
|
|
878
|
+
with self.runtime.lock:
|
|
879
|
+
run = self.runtime.runs.get(run_id) if run_id else None
|
|
880
|
+
if run is None:
|
|
881
|
+
raise APIError("That task is no longer kept.", 404, "not_found")
|
|
882
|
+
run_ids.append(run.id)
|
|
883
|
+
turns.append(("You", run.goal))
|
|
884
|
+
answer = self._answer(run.id)
|
|
885
|
+
if answer:
|
|
886
|
+
turns.append(("Mobster", answer))
|
|
887
|
+
made, drafter = starters.draft(turns, client_factory or starters.draft_client)
|
|
888
|
+
if made is None:
|
|
889
|
+
raise APIError("There's nothing in that conversation to save yet.", 409, "nothing_to_save")
|
|
890
|
+
with self.runtime.lock:
|
|
891
|
+
runs = [self.runtime.runs[i] for i in run_ids if i in self.runtime.runs]
|
|
892
|
+
newest = runs[-1] if runs else None
|
|
893
|
+
apps = {(getattr(r, "app", None) or {}).get("id") for r in runs}
|
|
894
|
+
engine = engines.read_engine(getattr(newest, "engine", None)) if newest is not None else None
|
|
895
|
+
app_id = apps.pop() if len(apps) == 1 else None
|
|
896
|
+
if app_id == ANY_APP_ID or app_id is None:
|
|
897
|
+
app_id, engine = None, "smart"
|
|
898
|
+
out = {"name": made["name"], "goal": made["steps"], "appId": app_id, "engine": engine,
|
|
899
|
+
"homeThreadId": thread_id, "blanks": starters.blanks(made["steps"]),
|
|
900
|
+
"graph": graph_chain.from_chain(batch.one_task(made["steps"], app_id))}
|
|
901
|
+
if app_id:
|
|
902
|
+
out["appName"] = self._app_name(app_id)
|
|
903
|
+
kinds = []
|
|
904
|
+
for done in runs:
|
|
905
|
+
for block in ((getattr(done, "summary", None) or {}).get("blocks") or {}).get("blocks") or ():
|
|
906
|
+
if isinstance(block, dict) and block.get("type") and block["type"] not in kinds:
|
|
907
|
+
kinds.append(block["type"])
|
|
908
|
+
if kinds:
|
|
909
|
+
out["produces"] = kinds[:3] # what the tasks it came from handed back, so the Apps page can show it
|
|
910
|
+
if newest is not None and len(runs) == 1 and getattr(newest, "output_schema", None):
|
|
911
|
+
out["outputSchema"] = newest.output_schema
|
|
912
|
+
out["outputFormat"] = getattr(newest, "output_format", None) or "json"
|
|
913
|
+
return {"draft": out, "drafter": drafter}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# The steps a prove pins: what reads, costs or acts. Approvals (a person's answer) and flow nodes always run again.
|
|
917
|
+
PROVE_PINS = frozenset({"phone.task", "phone.taught", "phone.check", "app.read", "connector.call", "ai.ask", "brief",
|
|
918
|
+
"send", "workflow.call"})
|
|
919
|
+
# The triggers in a graph that start it from outside, while it is turned on (workflows/triggers.py).
|
|
920
|
+
LISTENING = frozenset({"trigger.notification", "trigger.app"})
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _listens(workflow):
|
|
924
|
+
"""Whether the workflow's draft or published graph has a trigger that starts it from outside"""
|
|
925
|
+
graph = workflow.get("graph") if graph_schema.check_graph(workflow.get("graph")) else {}
|
|
926
|
+
published = (workflow.get("published") or {}).get("graph") if isinstance(workflow.get("published"), dict) else None
|
|
927
|
+
return any(n.get("type") in LISTENING for g in (graph, published or {}) for n in g.get("nodes") or ())
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _published_graph(workflow):
|
|
931
|
+
"""The published graph, or None while nothing is published (nothing fires from a draft)."""
|
|
932
|
+
published = workflow.get("published")
|
|
933
|
+
graph = published.get("graph") if isinstance(published, dict) else None
|
|
934
|
+
return graph if graph_schema.check_graph(graph) else None
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _listens_for(workflow):
|
|
938
|
+
"""The trigger in the published graph that starts it from outside, "notification" or "app" (a notification first, as
|
|
939
|
+
the Mac app names one), or None: what the workflow's row says, read from the graph that fires, not the draft."""
|
|
940
|
+
types = {n.get("type") for n in (_published_graph(workflow) or {}).get("nodes") or ()}
|
|
941
|
+
return "notification" if "trigger.notification" in types else "app" if "trigger.app" in types else None
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _starts_itself(workflow):
|
|
945
|
+
"""Whether turning the workflow on makes it start by itself: published, with a schedule (``cron``, which ``derive``
|
|
946
|
+
reads from the published graph and the tick fires at ``nextAt``) or a notification or app trigger in the published
|
|
947
|
+
graph (``WorkflowTriggers`` reads only that one). ``enabled`` doesn't change it, so a paused workflow says True and
|
|
948
|
+
can be resumed. The draft never counts: until it is published, nothing fires from it."""
|
|
949
|
+
if _published_graph(workflow) is None:
|
|
950
|
+
return False
|
|
951
|
+
return workflow.get("cron") is not None or _listens_for(workflow) is not None
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _packs():
|
|
955
|
+
"""{key: {"name", "readers": {what: [columns]}}} of the app packs here, or None without them."""
|
|
956
|
+
try:
|
|
957
|
+
from ..packs.register import all_packs
|
|
958
|
+
packs = all_packs()
|
|
959
|
+
except Exception: # noqa: BLE001 -- no packs track: app.read can't be saved, said by the graph's check
|
|
960
|
+
return None
|
|
961
|
+
return {pack.key: {"name": pack.name, "readers": {
|
|
962
|
+
what: [{"key": key, "label": label, "type": kind}
|
|
963
|
+
for key, label, kind in (getattr(pack, "read_columns", None) or {}).get(what, ())]
|
|
964
|
+
for what in pack.readers}} for pack in packs}
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _default_alerts():
|
|
968
|
+
"""alerts.Alerts, or None in a build without it: scheduling never depends on alerts."""
|
|
969
|
+
try:
|
|
970
|
+
from ..alerts import Alerts
|
|
971
|
+
except ImportError:
|
|
972
|
+
return None
|
|
973
|
+
return Alerts()
|