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