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