fruxon 2.1.0__tar.gz → 2.2.0__tar.gz

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 (216) hide show
  1. {fruxon-2.1.0 → fruxon-2.2.0}/HISTORY.md +31 -0
  2. {fruxon-2.1.0 → fruxon-2.2.0}/PKG-INFO +6 -2
  3. {fruxon-2.1.0 → fruxon-2.2.0}/README.md +5 -1
  4. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/__init__.py +24 -0
  5. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/_version.py +2 -2
  6. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/ledger.py +97 -0
  7. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_shared.py +86 -0
  8. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/auth.py +82 -14
  9. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_notices.py +4 -17
  10. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers.py +14 -1
  11. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_ledger.py +244 -6
  12. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_shape.py +207 -4
  13. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/exceptions.py +15 -0
  14. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/fruxon.py +287 -20
  15. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/models.py +406 -5
  16. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-debug-trace/SKILL.md +10 -5
  17. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/ui.py +45 -2
  18. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_checks_ledger.py +97 -0
  19. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli.py +148 -0
  20. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_client.py +71 -0
  21. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers.py +64 -0
  22. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger.py +498 -2
  23. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_shape.py +190 -0
  24. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_ui.py +175 -0
  25. {fruxon-2.1.0 → fruxon-2.2.0}/.gitignore +0 -0
  26. {fruxon-2.1.0 → fruxon-2.2.0}/LICENSE +0 -0
  27. {fruxon-2.1.0 → fruxon-2.2.0}/pyproject.toml +0 -0
  28. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/__main__.py +0 -0
  29. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/_ssl.py +0 -0
  30. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/__init__.py +0 -0
  31. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/agent.py +0 -0
  32. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/references.py +0 -0
  33. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/__init__.py +0 -0
  34. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_crash.py +0 -0
  35. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_schema.py +0 -0
  36. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_stream.py +0 -0
  37. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents.py +0 -0
  38. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_approvals.py +0 -0
  39. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_budget.py +0 -0
  40. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_channels.py +0 -0
  41. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_draft.py +0 -0
  42. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_evaluations.py +0 -0
  43. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_executions.py +0 -0
  44. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_improvements.py +0 -0
  45. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_lifecycle.py +0 -0
  46. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_memory.py +0 -0
  47. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_reply_style.py +0 -0
  48. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_revisions.py +0 -0
  49. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_sandbox.py +0 -0
  50. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_sandbox_test.py +0 -0
  51. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_slots.py +0 -0
  52. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_system_blocks.py +0 -0
  53. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_tests.py +0 -0
  54. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_topics.py +0 -0
  55. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_voice_calls.py +0 -0
  56. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/applications.py +0 -0
  57. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/applications_entry_points.py +0 -0
  58. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/assets.py +0 -0
  59. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/capabilities.py +0 -0
  60. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/completion.py +0 -0
  61. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/config.py +0 -0
  62. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/consult_pins.py +0 -0
  63. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/conversation_imports.py +0 -0
  64. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/dash.py +0 -0
  65. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/describe.py +0 -0
  66. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/doctor.py +0 -0
  67. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/environments.py +0 -0
  68. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/escalations.py +0 -0
  69. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/examples.py +0 -0
  70. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/guides.py +0 -0
  71. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/integrations.py +0 -0
  72. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/keys.py +0 -0
  73. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_bases.py +0 -0
  74. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_documents.py +0 -0
  75. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_sources.py +0 -0
  76. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/llm_providers.py +0 -0
  77. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/messages.py +0 -0
  78. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/metrics.py +0 -0
  79. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/participants.py +0 -0
  80. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/pipelines.py +0 -0
  81. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/secrets.py +0 -0
  82. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/skills.py +0 -0
  83. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/storage.py +0 -0
  84. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/tools.py +0 -0
  85. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_budget.py +0 -0
  86. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_check.py +0 -0
  87. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_ledger_stage_tests.py +0 -0
  88. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_questions.py +0 -0
  89. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/workspaces.py +0 -0
  90. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/credentials.py +0 -0
  91. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/device_auth.py +0 -0
  92. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/doctor.py +0 -0
  93. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/flow_validation.py +0 -0
  94. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/output.py +0 -0
  95. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/params.py +0 -0
  96. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/sandbox_scenario.py +0 -0
  97. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/__init__.py +0 -0
  98. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-agent-mode/SKILL.md +0 -0
  99. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-build-agent/SKILL.md +0 -0
  100. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-create-integration/SKILL.md +0 -0
  101. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-meet/SKILL.md +0 -0
  102. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-use-integrations/SKILL.md +0 -0
  103. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/telemetry.py +0 -0
  104. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/__init__.py +0 -0
  105. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/app.py +0 -0
  106. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/demo.py +0 -0
  107. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/links.py +0 -0
  108. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/__init__.py +0 -0
  109. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/agent.py +0 -0
  110. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/base.py +0 -0
  111. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/home.py +0 -0
  112. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/ledger.py +0 -0
  113. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/loader.py +0 -0
  114. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/pickers.py +0 -0
  115. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/trace.py +0 -0
  116. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/source.py +0 -0
  117. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/theme.py +0 -0
  118. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/views.py +0 -0
  119. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/widgets.py +0 -0
  120. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/update_check.py +0 -0
  121. {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/validation.py +0 -0
  122. {fruxon-2.1.0 → fruxon-2.2.0}/tests/.test_durations.json +0 -0
  123. {fruxon-2.1.0 → fruxon-2.2.0}/tests/__init__.py +0 -0
  124. {fruxon-2.1.0 → fruxon-2.2.0}/tests/conftest.py +0 -0
  125. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_actor.py +0 -0
  126. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_lifecycle.py +0 -0
  127. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_revisions_list.py +0 -0
  128. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_slots.py +0 -0
  129. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_check_cli.py +0 -0
  130. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_revisions_cli.py +0 -0
  131. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_sandbox.py +0 -0
  132. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_applications.py +0 -0
  133. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_approvals.py +0 -0
  134. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_assets.py +0 -0
  135. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_budgets.py +0 -0
  136. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_capabilities.py +0 -0
  137. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_channels.py +0 -0
  138. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_checks_agent.py +0 -0
  139. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_base_url_routing.py +0 -0
  140. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_crash.py +0 -0
  141. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_root_flags.py +0 -0
  142. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_connect_nudge.py +0 -0
  143. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_consult.py +0 -0
  144. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_conversation_imports.py +0 -0
  145. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_credentials.py +0 -0
  146. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_app.py +0 -0
  147. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_cli.py +0 -0
  148. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_client.py +0 -0
  149. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_links.py +0 -0
  150. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_source.py +0 -0
  151. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_describe.py +0 -0
  152. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_device_auth.py +0 -0
  153. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_docs_commands_exist.py +0 -0
  154. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_doctor.py +0 -0
  155. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_bootstrap.py +0 -0
  156. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_evaluate_cli.py +0 -0
  157. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_pull_shape.py +0 -0
  158. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_run_replay.py +0 -0
  159. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_validate_cli.py +0 -0
  160. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_drafts.py +0 -0
  161. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_entry_points.py +0 -0
  162. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_environments.py +0 -0
  163. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_escalations.py +0 -0
  164. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_evaluations.py +0 -0
  165. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_execution_records.py +0 -0
  166. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_flow_validation.py +0 -0
  167. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_fruxon.py +0 -0
  168. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_guides.py +0 -0
  169. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_improvements.py +0 -0
  170. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integration_configs_write_cli.py +0 -0
  171. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integration_triggers_cli.py +0 -0
  172. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integrations_authorize_cli.py +0 -0
  173. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integrations_connect_cli.py +0 -0
  174. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_kb_trial_cli.py +0 -0
  175. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_bases.py +0 -0
  176. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_documents.py +0 -0
  177. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_notices.py +0 -0
  178. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_sources.py +0 -0
  179. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_llm_provider_configs_cli.py +0 -0
  180. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_llm_providers_connect_cli.py +0 -0
  181. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_managed_by.py +0 -0
  182. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_memory.py +0 -0
  183. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_messages.py +0 -0
  184. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_metrics.py +0 -0
  185. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_llm_config_refs.py +0 -0
  186. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_prefetch_refs.py +0 -0
  187. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_secret_refs.py +0 -0
  188. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_output.py +0 -0
  189. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_params.py +0 -0
  190. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_participants.py +0 -0
  191. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_participants_write.py +0 -0
  192. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_pipelines.py +0 -0
  193. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_reply_style.py +0 -0
  194. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_sandbox_scenario.py +0 -0
  195. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_schema.py +0 -0
  196. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_schema_fetch.py +0 -0
  197. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_secrets.py +0 -0
  198. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_skills.py +0 -0
  199. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_ssl.py +0 -0
  200. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_storage.py +0 -0
  201. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_system_blocks.py +0 -0
  202. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_system_blocks_cli.py +0 -0
  203. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_telemetry.py +0 -0
  204. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_test_chats.py +0 -0
  205. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_topics.py +0 -0
  206. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_trigger_budgets.py +0 -0
  207. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_check_cli.py +0 -0
  208. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger_batch_views.py +0 -0
  209. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger_stage_tests.py +0 -0
  210. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_questions.py +0 -0
  211. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_write.py +0 -0
  212. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_tui_boundary.py +0 -0
  213. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_update_check.py +0 -0
  214. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_validation.py +0 -0
  215. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_voice_calls.py +0 -0
  216. {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_workspaces_cli.py +0 -0
@@ -1,5 +1,36 @@
1
1
  # History
2
2
 
3
+ ## Unreleased — whoami and the 403 hint read the role cap off the key
4
+
5
+ - On a server with fruxon-backend #1571, `fruxon whoami` and the 403
6
+ `insufficient_scope` hint take your role and the scopes in effect from
7
+ `/tokens/current` (`ownerRole`, `effectiveScopes`) instead of also reading
8
+ your membership, which needs `workspace:read`. A key without that scope now
9
+ gets its role named instead of "couldn't be read". Older servers keep the
10
+ membership read.
11
+ - **`fruxon whoami`** lists a service-account key's grant (`N scopes from its
12
+ grant`, and `effectiveScopes` under `current_key` in `-o json`) instead of
13
+ calling it "dead weight" for carrying no scopes of its own.
14
+
15
+ ## Unreleased — say when your workspace role, not the key, refuses a call
16
+
17
+ - A 403 `insufficient_scope` for a scope the key already carries no longer
18
+ says to re-run `fruxon login --preset …`. Since fruxon-backend `f5aaf1250`
19
+ the server caps a personal key to its owner's current workspace role, so a
20
+ Viewer's key minted with `agents:write` is refused it, and a new key would be
21
+ refused the same way. The CLI now asks the refused key what it carries
22
+ (`/tokens/current`, then the owner's membership) and, when the key has the
23
+ scope, says the role is the limit and to ask a workspace Admin to raise it or
24
+ make the change. A key that lacks the scope, a service-account key, or a
25
+ lookup that fails keeps the preset hint.
26
+ - **`fruxon whoami`** labels its scope list as the key's own and adds a line
27
+ with your role in the workspace and how many of those scopes it leaves in
28
+ effect. `-o json` adds `ownerType`, `ownerRole` and `effectiveScopes` under
29
+ `current_key` (`null` when the role couldn't be read).
30
+ - `InsufficientScopeError.client` is the client whose request was refused, and
31
+ `FruxonClient.get_user(user)` reads one workspace member, including `role`
32
+ and `effectiveScopes`.
33
+
3
34
  ## Unreleased — read voice calls back
4
35
 
5
36
  - **`fruxon agents voice-calls list <agent>`** lists the agent's Talk calls
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fruxon
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
5
5
  Project-URL: bugs, https://github.com/fruxon-ai/fruxon-sdk/issues
6
6
  Project-URL: changelog, https://github.com/fruxon-ai/fruxon-sdk/blob/main/HISTORY.md
@@ -172,6 +172,7 @@ Integrations + tools:
172
172
  | `fruxon triggers check <trigger>` | Audit whether a work-shape automation can move its items, part by part in the order an item meets them: on and taking items in, the last pass read its source, each PROCESS stage's agent exists with a deployed revision and working credentials and models (a rejected credential's row says how many items failed there since), each ASK stage's contact is declared and bound on the asking agent and its oldest question has not waited past a fifth of its answer window, the outcome note's contact reaches a person, this week's spend against the weekly limit, and how many items died in the last 7 days. `{trigger, overall, checks[]}` like `agents check`; exits 12 only on a hard fail, warnings stay 0. Read-only. |
173
173
  | `fruxon triggers budget list/get/set/delete` | Spend limits on an automation **as a whole** (`--period DAILY/WEEKLY/MONTHLY`, UTC calendar windows), counting every production run it caused whichever agent made it. Once one is enforced they govern its runs, which then meet only these limits and not the agents' own; with none enforced, each stage stops at its agent's own limit. None by default and every one removable. An enforced limit refuses new work at the limit (stage runs, passes, a question's opening, the report) and holds the stages until the window resets, while turns of conversations it already opened still run (up to 110%); `--no-enforce` only alerts the Application's admins. Needs Editor. |
174
174
  | `fruxon triggers ledger redrive/ignore/settle/cancel-batch` | The operator verbs; all confirm and refuse without `--yes` in agent mode. `redrive` returns a DEAD or FAILED item to the queue with its attempts reset — settle any `UNKNOWN` effect first or the next attempt hits the same refusal. `ignore` excludes an item for a **required** reason, which is the only thing separating IGNORED from DEAD later. `settle` answers an effect the platform could not confirm (`--landed` / `--did-not-land` + `--reason`); wrong in the second direction duplicates the write. `cancel-batch` calls off a question — the items go back to READY and a later pass re-batches them. |
175
+ | `fruxon triggers ledger wake` | End an item's wait at a WAIT stage now, as if its due time had come: an event the stage waits for that already arrived takes `onEvent` with what it carries, otherwise the `onDue` exit applies (on to the next or named stage, or FAIL / DEAD); the history records `OPERATOR_WAKE`. 409 for an item waiting on a question (answer it or `cancel-batch`). `ignore` stops a wait without continuing. `items --waiting-for TIME --order-by wakeAt` lists what waits on time, soonest first; the Detail column says when each wakes and the footer flags any 15 minutes overdue. `items --admitted-via API\|AGENT` lists what was started by hand (`admit`, or an agent's `start_work_item`). |
175
176
  | `fruxon triggers ledger admit` | Start one item by hand on an automation whose work shape declares `onDemand`. `--set path=value` / `path:=<json>` (dotted paths nest) or `--data-file`; the server checks the declared inputs and refuses credential-named keys. The key — `--key`, or composed from the shape's `keyPaths` — is the whole idempotency: the same key again returns the existing item **unchanged** (`created: false`), whatever its status. Needs `triggers:fire`. |
176
177
  | `fruxon triggers ledger set-data` | Correct a parked (DEAD / FAILED / IGNORED) item's `data` before re-driving it — the value a stage recorded wrong, such as an ASK answer written as `SKIP`, which a plain re-drive would route on again. `--set path=value` (or `path:=<json>`, repeatable) on a top-level key the item carries or a path the shape declares (`produces`, `answeredWhenPath`); a misspelled key is refused with the settable list rather than added. `--reason` is required and lands on the item's history beside every old → new value. `--redrive` re-drives in the same transaction; `redrive` and `redrive-many` take `--set` too. |
177
178
  | `fruxon triggers ledger redrive-many/ignore-many` | The same two verbs over a **selection** — `--id` (repeatable), `--stage`, `--status`, `--before`/`--after` (Unix ms). The CLI resolves the selection to a count under the verb's own eligibility, shows it, and the server acts on exactly that count (409 if the set changed). The count is the server's own whenever the endpoint's filters say exactly what the selection says (a `--status` or an `--ignore-reason`, with no `--id` and no `--before`/`--after`) — one request instead of walking every page of a 4,000-item ledger, and one fewer place for the two sides to disagree. `ignore-many --stage X` is how you drain a stage. When the selection is `--id` values, the result names every id it did **not** act on and why — not a work item, another automation's, or a status the verb cannot touch (usually a pass holding the claim). |
@@ -182,7 +183,7 @@ Integrations + tools:
182
183
  | `fruxon triggers ledger walk/walk-steps` | `walk --item <id> --yes` chains stage tests: the item's stage (or `--from`), then each stage it advances into, on the data it advanced with, until a real pass would stop too. Each ASK stage it reaches takes `--reply <stage>=<text>` or `--answer <stage>=<value>`; `--agent-revision <agent>=<N>` runs every stage of that agent on an undeployed revision. `stopped.code` is `COMPLETED`, `REACHED_UNTIL` (`--until`), `NOT_ADVANCED` (a `WAIT`, too, and an ADVANCE a pass would undo: a duplicate, or fields the next stage requires), `REPLY_NEEDED` (an ASK stage nobody answered), `STAGE_NOT_TESTABLE`, `MAX_STEPS` (default 10), `TIMEOUT` or `CANCELLED`. Every step is a paid run, so it confirms first and needs `--yes` in agent mode; it exits 0 whenever it ran. `walk-steps <trigger> <walk>` reads a walk back in step order. |
183
184
  | `fruxon triggers ledger reset-cursor/purge` | `reset-cursor` moves where the next scan starts, as a compare-and-swap on the version it just read (`--value` omitted = the door's own horizon); it never re-runs admitted work. It is the *after* to `workShape.door.startFrom`'s *before* — a horizon set at creation seeds the cursor before anything sweeps, and is ignored once a pass has read the source. `purge` starts the ledger over — cancels open questions, deletes items and effects, bumps the cursor version — and confirms the exact item count first. The items' transitions stay, as the ledger's history. Destructive; the effects' external targets are not touched. |
184
185
  | `fruxon triggers questions list/answer` | The human half of an ASK stage, tenant-wide. `ledger batches <trigger>` answers *what is this automation waiting on*; this answers *what is waiting on anyone* — soonest deadline first, which is how an undelivered question gets found at all. Read **`delivery`** first: `UNDELIVERABLE` (nobody reachable in the slot) and `FAILED` (a target resolved, the send did not land) both mean nobody was asked and nobody will answer unless you do — `QUEUE_ONLY` is healthy. `--undelivered-only` narrows to that pair; `--trigger` filters client-side (the endpoint has no such filter). `answer` settles one on the assignee's behalf and is `--yes`-gated: the reply interpreter **fails closed**, so an answer it cannot place advances nothing and the question stays open, and each item advances only once the stage's `answeredWhenPath` is filled — answering two of three advances two. |
185
- | `fruxon triggers set-stages <id> --file` | Replace an automation's stages without rewriting the rest of its work shape (a PATCH replaces the whole shape, so this reads it, splices, and posts). Takes a bare stages array or any document carrying one. Pre-flights the two refusals the server only reports after the write: live items at a stage your list drops, and a `door` that will re-expand `source` on save. `--dry-run` prints the merged shape and the warnings; `preview-shape <id> --file` takes the same file and renders what each stage would hand its agent. `--help` documents every stage field the server reads but the CLI passes through untouched — `routes`, `ignoreReasons`, a FORM stage's `answerField`, `formClosing` and `formJoinNotice`. |
186
+ | `fruxon triggers set-stages <id> --file` | Replace an automation's stages without rewriting the rest of its work shape (a PATCH replaces the whole shape, so this reads it, splices, and posts). Takes a bare stages array or any document carrying one. Pre-flights the two refusals the server only reports after the write: live items at a stage your list drops, and a `door` that will re-expand `source` on save. `--dry-run` prints the merged shape and the warnings; `preview-shape <id> --file` takes the same file and renders what each stage would hand its agent. `--help` documents every stage field the server reads but the CLI passes through untouched — `routes`, `ignoreReasons`, a FORM stage's `answerField`, `formClosing` and `formJoinNotice`, and a WAIT stage's `wait` (`minutes` or `untilPath`, then `onDue`; an `event` with `match`, `capture` and `onEvent` to end it early — the invoice example and the save rules are there). It also warns before a save that changes a stage's kind, or a WAIT stage's event correlation, under CLAIMED or WAITING items, which the server refuses, and prints the save's own warnings — such as a WAIT matching its event on a value nothing keeps unique. |
186
187
  | `fruxon triggers revisions list/get/restore` | A trigger's audit history — every mutation writes a snapshot, and `changeReason` separates a config edit from a binding reconcile. `get -o json | jq .workShape` is the shape as it stood, which is how you undo an edit by hand. `restore` replays one and is **not** a universal undo: it runs the same validation a save does, so a snapshot that drops a stage still holding live items is refused. |
187
188
  | `fruxon secrets list/get/grants` | Discover tenant secrets an agent can reference — the ids for `allowedSecretIds` and the `{{secret.KEY}}` names, with publish-state and per-agent grants. Metadata only; values are never returned. |
188
189
  | `fruxon participants list/get/create/update/delete/enable/disable` | Manage agent-network participants (people / groups / agents the network routes to). `list` takes `--query` (matches names **and channel addresses and account ids**, so a phone number or handle resolves to an id, and searches both directory tiers by default), `--kind`, `--tier`, `--agent`, `--environment-id`, and pages up to `--limit` / `--all`. `create`/`update` take `--file` (+`--schema`); `enable`/`disable` toggle routing; `delete` confirms + refuses without `--yes` in agent mode. |
@@ -343,6 +344,9 @@ spend = client.get_ledger_spend(trigger_id) # this calendar week, by agent and
343
344
  print(spend.cost, [(a.agent_id, a.cost) for a in spend.agents])
344
345
  for b in client.list_ledger_batches(trigger_id, open_only=True):
345
346
  print(b.delivery, b.question) # UNDELIVERABLE ⇒ nobody was asked
347
+ for i in client.list_ledger_items(trigger_id, waiting_for="TIME", order_by="wakeAt"):
348
+ print(i.item_key, i.wake_at) # parked at a WAIT stage, soonest first
349
+ client.wake_ledger_item(trigger_id, item_id, reason="indexed early") # its onDue exit applies now
346
350
  ```
347
351
 
348
352
  The client picks up `FRUXON_TOKEN`, `FRUXON_WORKSPACE`, and `FRUXON_BASE_URL` only if you read them yourself — the constructor takes explicit values. The CLI resolves them automatically (command flags → root flags → env → stored config).
@@ -137,6 +137,7 @@ Integrations + tools:
137
137
  | `fruxon triggers check <trigger>` | Audit whether a work-shape automation can move its items, part by part in the order an item meets them: on and taking items in, the last pass read its source, each PROCESS stage's agent exists with a deployed revision and working credentials and models (a rejected credential's row says how many items failed there since), each ASK stage's contact is declared and bound on the asking agent and its oldest question has not waited past a fifth of its answer window, the outcome note's contact reaches a person, this week's spend against the weekly limit, and how many items died in the last 7 days. `{trigger, overall, checks[]}` like `agents check`; exits 12 only on a hard fail, warnings stay 0. Read-only. |
138
138
  | `fruxon triggers budget list/get/set/delete` | Spend limits on an automation **as a whole** (`--period DAILY/WEEKLY/MONTHLY`, UTC calendar windows), counting every production run it caused whichever agent made it. Once one is enforced they govern its runs, which then meet only these limits and not the agents' own; with none enforced, each stage stops at its agent's own limit. None by default and every one removable. An enforced limit refuses new work at the limit (stage runs, passes, a question's opening, the report) and holds the stages until the window resets, while turns of conversations it already opened still run (up to 110%); `--no-enforce` only alerts the Application's admins. Needs Editor. |
139
139
  | `fruxon triggers ledger redrive/ignore/settle/cancel-batch` | The operator verbs; all confirm and refuse without `--yes` in agent mode. `redrive` returns a DEAD or FAILED item to the queue with its attempts reset — settle any `UNKNOWN` effect first or the next attempt hits the same refusal. `ignore` excludes an item for a **required** reason, which is the only thing separating IGNORED from DEAD later. `settle` answers an effect the platform could not confirm (`--landed` / `--did-not-land` + `--reason`); wrong in the second direction duplicates the write. `cancel-batch` calls off a question — the items go back to READY and a later pass re-batches them. |
140
+ | `fruxon triggers ledger wake` | End an item's wait at a WAIT stage now, as if its due time had come: an event the stage waits for that already arrived takes `onEvent` with what it carries, otherwise the `onDue` exit applies (on to the next or named stage, or FAIL / DEAD); the history records `OPERATOR_WAKE`. 409 for an item waiting on a question (answer it or `cancel-batch`). `ignore` stops a wait without continuing. `items --waiting-for TIME --order-by wakeAt` lists what waits on time, soonest first; the Detail column says when each wakes and the footer flags any 15 minutes overdue. `items --admitted-via API\|AGENT` lists what was started by hand (`admit`, or an agent's `start_work_item`). |
140
141
  | `fruxon triggers ledger admit` | Start one item by hand on an automation whose work shape declares `onDemand`. `--set path=value` / `path:=<json>` (dotted paths nest) or `--data-file`; the server checks the declared inputs and refuses credential-named keys. The key — `--key`, or composed from the shape's `keyPaths` — is the whole idempotency: the same key again returns the existing item **unchanged** (`created: false`), whatever its status. Needs `triggers:fire`. |
141
142
  | `fruxon triggers ledger set-data` | Correct a parked (DEAD / FAILED / IGNORED) item's `data` before re-driving it — the value a stage recorded wrong, such as an ASK answer written as `SKIP`, which a plain re-drive would route on again. `--set path=value` (or `path:=<json>`, repeatable) on a top-level key the item carries or a path the shape declares (`produces`, `answeredWhenPath`); a misspelled key is refused with the settable list rather than added. `--reason` is required and lands on the item's history beside every old → new value. `--redrive` re-drives in the same transaction; `redrive` and `redrive-many` take `--set` too. |
142
143
  | `fruxon triggers ledger redrive-many/ignore-many` | The same two verbs over a **selection** — `--id` (repeatable), `--stage`, `--status`, `--before`/`--after` (Unix ms). The CLI resolves the selection to a count under the verb's own eligibility, shows it, and the server acts on exactly that count (409 if the set changed). The count is the server's own whenever the endpoint's filters say exactly what the selection says (a `--status` or an `--ignore-reason`, with no `--id` and no `--before`/`--after`) — one request instead of walking every page of a 4,000-item ledger, and one fewer place for the two sides to disagree. `ignore-many --stage X` is how you drain a stage. When the selection is `--id` values, the result names every id it did **not** act on and why — not a work item, another automation's, or a status the verb cannot touch (usually a pass holding the claim). |
@@ -147,7 +148,7 @@ Integrations + tools:
147
148
  | `fruxon triggers ledger walk/walk-steps` | `walk --item <id> --yes` chains stage tests: the item's stage (or `--from`), then each stage it advances into, on the data it advanced with, until a real pass would stop too. Each ASK stage it reaches takes `--reply <stage>=<text>` or `--answer <stage>=<value>`; `--agent-revision <agent>=<N>` runs every stage of that agent on an undeployed revision. `stopped.code` is `COMPLETED`, `REACHED_UNTIL` (`--until`), `NOT_ADVANCED` (a `WAIT`, too, and an ADVANCE a pass would undo: a duplicate, or fields the next stage requires), `REPLY_NEEDED` (an ASK stage nobody answered), `STAGE_NOT_TESTABLE`, `MAX_STEPS` (default 10), `TIMEOUT` or `CANCELLED`. Every step is a paid run, so it confirms first and needs `--yes` in agent mode; it exits 0 whenever it ran. `walk-steps <trigger> <walk>` reads a walk back in step order. |
148
149
  | `fruxon triggers ledger reset-cursor/purge` | `reset-cursor` moves where the next scan starts, as a compare-and-swap on the version it just read (`--value` omitted = the door's own horizon); it never re-runs admitted work. It is the *after* to `workShape.door.startFrom`'s *before* — a horizon set at creation seeds the cursor before anything sweeps, and is ignored once a pass has read the source. `purge` starts the ledger over — cancels open questions, deletes items and effects, bumps the cursor version — and confirms the exact item count first. The items' transitions stay, as the ledger's history. Destructive; the effects' external targets are not touched. |
149
150
  | `fruxon triggers questions list/answer` | The human half of an ASK stage, tenant-wide. `ledger batches <trigger>` answers *what is this automation waiting on*; this answers *what is waiting on anyone* — soonest deadline first, which is how an undelivered question gets found at all. Read **`delivery`** first: `UNDELIVERABLE` (nobody reachable in the slot) and `FAILED` (a target resolved, the send did not land) both mean nobody was asked and nobody will answer unless you do — `QUEUE_ONLY` is healthy. `--undelivered-only` narrows to that pair; `--trigger` filters client-side (the endpoint has no such filter). `answer` settles one on the assignee's behalf and is `--yes`-gated: the reply interpreter **fails closed**, so an answer it cannot place advances nothing and the question stays open, and each item advances only once the stage's `answeredWhenPath` is filled — answering two of three advances two. |
150
- | `fruxon triggers set-stages <id> --file` | Replace an automation's stages without rewriting the rest of its work shape (a PATCH replaces the whole shape, so this reads it, splices, and posts). Takes a bare stages array or any document carrying one. Pre-flights the two refusals the server only reports after the write: live items at a stage your list drops, and a `door` that will re-expand `source` on save. `--dry-run` prints the merged shape and the warnings; `preview-shape <id> --file` takes the same file and renders what each stage would hand its agent. `--help` documents every stage field the server reads but the CLI passes through untouched — `routes`, `ignoreReasons`, a FORM stage's `answerField`, `formClosing` and `formJoinNotice`. |
151
+ | `fruxon triggers set-stages <id> --file` | Replace an automation's stages without rewriting the rest of its work shape (a PATCH replaces the whole shape, so this reads it, splices, and posts). Takes a bare stages array or any document carrying one. Pre-flights the two refusals the server only reports after the write: live items at a stage your list drops, and a `door` that will re-expand `source` on save. `--dry-run` prints the merged shape and the warnings; `preview-shape <id> --file` takes the same file and renders what each stage would hand its agent. `--help` documents every stage field the server reads but the CLI passes through untouched — `routes`, `ignoreReasons`, a FORM stage's `answerField`, `formClosing` and `formJoinNotice`, and a WAIT stage's `wait` (`minutes` or `untilPath`, then `onDue`; an `event` with `match`, `capture` and `onEvent` to end it early — the invoice example and the save rules are there). It also warns before a save that changes a stage's kind, or a WAIT stage's event correlation, under CLAIMED or WAITING items, which the server refuses, and prints the save's own warnings — such as a WAIT matching its event on a value nothing keeps unique. |
151
152
  | `fruxon triggers revisions list/get/restore` | A trigger's audit history — every mutation writes a snapshot, and `changeReason` separates a config edit from a binding reconcile. `get -o json | jq .workShape` is the shape as it stood, which is how you undo an edit by hand. `restore` replays one and is **not** a universal undo: it runs the same validation a save does, so a snapshot that drops a stage still holding live items is refused. |
152
153
  | `fruxon secrets list/get/grants` | Discover tenant secrets an agent can reference — the ids for `allowedSecretIds` and the `{{secret.KEY}}` names, with publish-state and per-agent grants. Metadata only; values are never returned. |
153
154
  | `fruxon participants list/get/create/update/delete/enable/disable` | Manage agent-network participants (people / groups / agents the network routes to). `list` takes `--query` (matches names **and channel addresses and account ids**, so a phone number or handle resolves to an id, and searches both directory tiers by default), `--kind`, `--tier`, `--agent`, `--environment-id`, and pages up to `--limit` / `--all`. `create`/`update` take `--file` (+`--schema`); `enable`/`disable` toggle routing; `delete` confirms + refuses without `--yes` in agent mode. |
@@ -308,6 +309,9 @@ spend = client.get_ledger_spend(trigger_id) # this calendar week, by agent and
308
309
  print(spend.cost, [(a.agent_id, a.cost) for a in spend.agents])
309
310
  for b in client.list_ledger_batches(trigger_id, open_only=True):
310
311
  print(b.delivery, b.question) # UNDELIVERABLE ⇒ nobody was asked
312
+ for i in client.list_ledger_items(trigger_id, waiting_for="TIME", order_by="wakeAt"):
313
+ print(i.item_key, i.wake_at) # parked at a WAIT stage, soonest first
314
+ client.wake_ledger_item(trigger_id, item_id, reason="indexed early") # its onDue exit applies now
311
315
  ```
312
316
 
313
317
  The client picks up `FRUXON_TOKEN`, `FRUXON_WORKSPACE`, and `FRUXON_BASE_URL` only if you read them yourself — the constructor takes explicit values. The CLI resolves them automatically (command flags → root flags → env → stored config).
@@ -81,7 +81,14 @@ from fruxon.models import (
81
81
  LedgerGatheringGroup,
82
82
  LedgerItem,
83
83
  LedgerItemAdmission,
84
+ LedgerItemAttempt,
85
+ LedgerItemAttemptAsk,
86
+ LedgerItemAttemptBatch,
87
+ LedgerItemAttemptOutcome,
88
+ LedgerItemAttemptWait,
84
89
  LedgerItemPoint,
90
+ LedgerItemRuns,
91
+ LedgerItemRunTotals,
85
92
  LedgerItemTimeseries,
86
93
  LedgerItemTransition,
87
94
  LedgerPass,
@@ -98,6 +105,11 @@ from fruxon.models import (
98
105
  LedgerStageTestSimulatedCall,
99
106
  LedgerStageTestSink,
100
107
  LedgerStageTestVerdict,
108
+ LedgerWaitCapture,
109
+ LedgerWaitConfig,
110
+ LedgerWaitEventConfig,
111
+ LedgerWaitExit,
112
+ LedgerWaitMatch,
101
113
  ManagedBy,
102
114
  ManagedResourceLink,
103
115
  Memory,
@@ -216,6 +228,13 @@ __all__ = [
216
228
  "LedgerGatheringGroup",
217
229
  "LedgerItem",
218
230
  "LedgerItemAdmission",
231
+ "LedgerItemAttempt",
232
+ "LedgerItemAttemptAsk",
233
+ "LedgerItemAttemptBatch",
234
+ "LedgerItemAttemptOutcome",
235
+ "LedgerItemAttemptWait",
236
+ "LedgerItemRuns",
237
+ "LedgerItemRunTotals",
219
238
  "LedgerItemTransition",
220
239
  "LedgerPass",
221
240
  "LedgerPurgeResult",
@@ -225,6 +244,11 @@ __all__ = [
225
244
  "LedgerStageTestSimulatedCall",
226
245
  "LedgerStageTestSink",
227
246
  "LedgerStageTestVerdict",
247
+ "LedgerWaitCapture",
248
+ "LedgerWaitConfig",
249
+ "LedgerWaitEventConfig",
250
+ "LedgerWaitExit",
251
+ "LedgerWaitMatch",
228
252
  "LedgerSkippedItem",
229
253
  "ManagedBy",
230
254
  "Memory",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -21,6 +21,7 @@ from datetime import datetime
21
21
 
22
22
  from fruxon.checks import CheckReport, CheckResult, shape_get
23
23
  from fruxon.exceptions import FruxonError, NotFoundError
24
+ from fruxon.models import LedgerWaitConfig, LedgerWaitEventConfig, LedgerWaitExit
24
25
 
25
26
  DAY_MS = 86_400_000
26
27
  _HOUR_MS = 3_600_000
@@ -137,6 +138,7 @@ def check_ledger(client, trigger: str, *, detail=None, now_ms: int | None = None
137
138
  cells: dict[str, dict[str, int]] = {}
138
139
  for cell in funnel or []:
139
140
  cells.setdefault(cell.stage_id, {})[cell.status.upper()] = cell.count
141
+ waiting_cells = {c.stage_id: c for c in funnel or [] if c.status.upper() == "WAITING"}
140
142
  today = int(datetime.fromtimestamp(now / 1000).replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
141
143
  series, _ = _try(
142
144
  client.get_ledger_item_timeseries, trigger, interval="DAY", since=today * 1000 - 6 * DAY_MS, until=now
@@ -207,6 +209,9 @@ def check_ledger(client, trigger: str, *, detail=None, now_ms: int | None = None
207
209
  if kind == "ASK":
208
210
  rows.append(_ask_row(trigger, stage, stage_id, asker, slots_of, batches, batches_ok, now))
209
211
  continue
212
+ if kind == "WAIT":
213
+ rows.append(_wait_row(trigger, stage, stage_id, waiting_cells.get(stage_id), now))
214
+ continue
210
215
  agent = str(shape_get(stage, "agentId") or "")
211
216
  if not agent:
212
217
  add(stage_id, "skip", "no agent — items pass through", explain="", fix="trigger-edit")
@@ -432,10 +437,102 @@ def _ask_row(
432
437
  )
433
438
 
434
439
 
440
+ def _wait_row(trigger: str, stage: dict, stage_id: str, cell, now: int) -> CheckResult:
441
+ """A WAIT stage: nothing runs there, so the one thing that can be wrong is a
442
+ wait left well past its due time — the sweeper that ends them is behind."""
443
+ wait = LedgerWaitConfig.from_dict(shape_get(stage, "wait")) or LedgerWaitConfig()
444
+ what = f"waits {describe_wait(wait)}"
445
+ overdue = getattr(cell, "overdue", 0) if cell is not None else 0
446
+ if overdue:
447
+ return CheckResult(
448
+ stage_id,
449
+ "warn",
450
+ f"{what} · {overdue} overdue",
451
+ hint=f"fruxon triggers ledger items {trigger} --waiting-for TIME --order-by wakeAt",
452
+ explain="A wait ends within a minute or two of its due time. Items more than 15 minutes past it mean "
453
+ "the wait sweeper is down or behind; `fruxon triggers ledger wake` ends one by hand.",
454
+ fix=f"work:WAITING:{stage_id}",
455
+ )
456
+ waiting = cell.count if cell is not None else 0
457
+ next_wake = getattr(cell, "next_wake_at", None) if cell is not None else None
458
+ if waiting:
459
+ what += f" · {waiting:,} waiting" + (f", next wakes {_clock(next_wake, now)}" if next_wake else "")
460
+ return CheckResult(
461
+ stage_id,
462
+ "ok",
463
+ what,
464
+ explain="Nothing runs here: each item waits until its due time"
465
+ + (", or until its event arrives," if wait.event is not None else "")
466
+ + " then moves on.",
467
+ fix=f"work:WAITING:{stage_id}",
468
+ )
469
+
470
+
471
+ def _span(minutes: int) -> str:
472
+ days, rest = divmod(minutes, 1_440)
473
+ hours, mins = divmod(rest, 60)
474
+ return " ".join(f"{n}{unit}" for n, unit in ((days, "d"), (hours, "h"), (mins, "m")) if n) or "0m"
475
+
476
+
477
+ def _exit_text(exit_: LedgerWaitExit) -> str:
478
+ if exit_.action == "ADVANCE_TO":
479
+ return exit_.stage_id or "?"
480
+ if exit_.action in {"FAIL", "DEAD"}:
481
+ return exit_.action
482
+ return "next"
483
+
484
+
485
+ def describe_wait(wait: LedgerWaitConfig) -> str:
486
+ """A WAIT stage in a few words: ``3d``, ``until due_at, then DEAD``, ``1h 30m → paid``, or with an
487
+ event ``until due_at or stripe.payment_intent.succeeded: event → receipt, due → next``."""
488
+ span = _span(wait.minutes) if wait.minutes is not None else f"until {wait.until_path or '?'}"
489
+ if wait.event is not None:
490
+ return (
491
+ f"{span} or {wait.event.event_type or '?'}: event → {_exit_text(wait.effective_on_event)}, "
492
+ f"due → {_exit_text(wait.effective_on_due)}"
493
+ )
494
+ exit_ = wait.effective_on_due
495
+ if exit_.action == "ADVANCE_TO":
496
+ return f"{span} → {exit_.stage_id or '?'}"
497
+ if exit_.action in {"FAIL", "DEAD"}:
498
+ return f"{span}, then {exit_.action}"
499
+ return span
500
+
501
+
502
+ def describe_wait_event(event: LedgerWaitEventConfig) -> list[tuple[str, str]]:
503
+ """``(label, text)`` lines for what a WAIT stage's event is matched on and carries into the item."""
504
+ lines: list[tuple[str, str]] = []
505
+ if event.integration_config_id:
506
+ lines.append(("connection", event.integration_config_id))
507
+ lines.extend(("match", f"{m.event_path} = item {m.item_path}") for m in event.match)
508
+ lines.extend(("capture", f"{c.event_path} → {c.item_path} ({c.type})") for c in event.capture)
509
+ if event.filter:
510
+ lines.append(("only when", _filter_text(event.filter)))
511
+ return lines
512
+
513
+
514
+ def _filter_text(flt: dict) -> str:
515
+ """An event filter as one line: its conditions, joined the way its ``match`` combines them."""
516
+ conditions = shape_get(flt, "conditions")
517
+ parts = []
518
+ for c in conditions if isinstance(conditions, list) else []:
519
+ if not isinstance(c, dict):
520
+ continue
521
+ values = shape_get(c, "values")
522
+ value = f"[{', '.join(str(v) for v in values)}]" if isinstance(values, list) else shape_get(c, "value")
523
+ parts.append(" ".join(str(p) for p in (shape_get(c, "path"), shape_get(c, "operator"), value) if p is not None))
524
+ joiner = " or " if str(shape_get(flt, "match") or "ALL").upper() == "ANY" else " and "
525
+ return joiner.join(parts) or "—"
526
+
527
+
435
528
  def describe_stage(stage: dict) -> tuple[str, str, str, str]:
436
529
  """``(name, kind, who, what it does)`` for the console's shape table."""
437
530
  stage_id = str(shape_get(stage, "id") or "?")
438
531
  kind = str(shape_get(stage, "kind") or "").upper()
532
+ if kind == "WAIT":
533
+ wait = LedgerWaitConfig.from_dict(shape_get(stage, "wait")) or LedgerWaitConfig()
534
+ who = wait.event.event_type if wait.event is not None and wait.event.event_type else "clock"
535
+ return stage_id, "wait", who, describe_wait(wait)
439
536
  if kind == "ASK":
440
537
  ask = shape_get(stage, "ask")
441
538
  ask = ask if isinstance(ask, dict) else {}
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import sys
15
+ from dataclasses import dataclass
15
16
  from datetime import datetime, timezone
16
17
 
17
18
  import typer
@@ -69,6 +70,24 @@ def format_ts(ms: int | None) -> str:
69
70
  return f"{ms} (raw)"
70
71
 
71
72
 
73
+ def format_relative(ms: int, now: int) -> str:
74
+ """How far ``ms`` is from ``now``, both epoch-ms: ``in 2d 3h`` ahead, ``40m ago`` behind.
75
+
76
+ Two units at most and never less than a minute — a due time is read as
77
+ "soon" or "a while", not to the second.
78
+ """
79
+ delta = abs(ms - now) // 60_000
80
+ days, rest = divmod(delta, 1_440)
81
+ hours, minutes = divmod(rest, 60)
82
+ if days:
83
+ span = f"{days}d {hours}h" if hours else f"{days}d"
84
+ elif hours:
85
+ span = f"{hours}h {minutes}m" if minutes else f"{hours}h"
86
+ else:
87
+ span = f"{max(minutes, 1)}m"
88
+ return f"in {span}" if ms >= now else f"{span} ago"
89
+
90
+
72
91
  def build_dict(required: dict, *, truthy: dict | None = None, keep_zero: dict | None = None) -> dict:
73
92
  """Assemble a wire dict from three buckets, so each call site only
74
93
  has to say which bucket a field belongs in rather than re-deriving
@@ -305,6 +324,73 @@ def build_client(workspace: str | None, base_url: str | None) -> FruxonClient:
305
324
  return FruxonClient(token=token, workspace=workspace, base_url=resolved_base)
306
325
 
307
326
 
327
+ @dataclass(frozen=True)
328
+ class KeyAccess:
329
+ """What the calling key carries, and what the server lets it do in this workspace.
330
+
331
+ ``scopes`` is the key's own list, as minted (``/tokens/current``). A
332
+ personal key acts as its owner, and the server cuts it down on every
333
+ request to what the owner's current workspace role allows
334
+ (fruxon-backend ``RoleScopeMap.CapToRole``), so ``scopes`` can name a
335
+ scope the key is refused. ``role`` and ``effective_scopes`` are that
336
+ cut. A service-account key carries no scopes of its own and no role
337
+ caps it: its ``effective_scopes`` is the account's grant and ``role``
338
+ is ``None``. Both are ``None`` when the server didn't say: the owner's
339
+ membership could not be read, or a service-account key on a server
340
+ older than fruxon-backend #1571.
341
+ """
342
+
343
+ key: dict
344
+ scopes: list[str]
345
+ personal: bool
346
+ role: str | None
347
+ effective_scopes: list[str] | None
348
+
349
+
350
+ def read_key_access(client: FruxonClient) -> KeyAccess | None:
351
+ """Ask the server what ``client``'s key carries and what it may do in this workspace.
352
+
353
+ A server with fruxon-backend #1571 says it all on ``/tokens/current``
354
+ (``effectiveScopes``, and ``ownerRole`` for a personal key). On an
355
+ older one a personal key's cut is worked out from its owner's
356
+ membership (``GET /users/{ownerId}``), a read that needs
357
+ ``workspace:read``.
358
+
359
+ Best-effort: ``None`` when ``/tokens/current`` can't answer (signed in
360
+ by federation, network down, …), and a :class:`KeyAccess` without a
361
+ role when only the membership read fails.
362
+ """
363
+ try:
364
+ key = client.get_current_token()
365
+ except Exception:
366
+ return None
367
+ if not key:
368
+ return None
369
+ scopes = [str(s) for s in key.get("scopes") or []]
370
+ personal = key.get("ownerType") == "USER"
371
+ role = effective = None
372
+ owner = key.get("ownerId")
373
+ reported, owner_role = key.get("effectiveScopes"), key.get("ownerRole")
374
+ if isinstance(reported, list) and (owner_role or not personal):
375
+ # The server's own answer: what this request was authorised with
376
+ # (CapToRole's cut, or a service account's grant). One call, with
377
+ # no workspace:read, which a narrow key may not carry.
378
+ effective = [str(s) for s in reported]
379
+ role = str(owner_role) if owner_role else None
380
+ elif personal and owner:
381
+ try:
382
+ member = client.get_user(str(owner))
383
+ except Exception:
384
+ member = {}
385
+ ceiling = member.get("effectiveScopes")
386
+ if member.get("role") and isinstance(ceiling, list):
387
+ role = str(member["role"])
388
+ allowed = {str(s) for s in ceiling}
389
+ # CapToRole's order: the key's own, keeping what the role carries.
390
+ effective = [s for s in dict.fromkeys(scopes) if s in allowed]
391
+ return KeyAccess(key=key, scopes=scopes, personal=personal, role=role, effective_scopes=effective)
392
+
393
+
308
394
  def load_json_body(file: str, *, what: str) -> dict:
309
395
  """Read and parse a ``--file`` JSON request body into a dict.
310
396
 
@@ -18,6 +18,7 @@ import typer
18
18
 
19
19
  from fruxon import credentials, device_auth
20
20
  from fruxon.cli import app
21
+ from fruxon.cli._shared import KeyAccess, read_key_access
21
22
  from fruxon.fruxon import FruxonClient
22
23
  from fruxon.ui import (
23
24
  EXIT_INTERACTIVE_REQUIRED,
@@ -586,14 +587,15 @@ def _storage_hint(path) -> str:
586
587
  return str(path)
587
588
 
588
589
 
589
- def _fetch_current_key_safely(token: str | None, workspace: str | None, base_url: str | None) -> dict | None:
590
- """Best-effort call to ``/tokens/current`` for whoami enrichment.
590
+ def _fetch_key_access_safely(token: str | None, workspace: str | None, base_url: str | None) -> KeyAccess | None:
591
+ """Best-effort call to ``/tokens/current`` (and the owner's role) for whoami enrichment.
591
592
 
592
- Returns the key's metadata dict on success, ``None`` on any
593
- failure — missing creds, network error, federation/JWT auth (no
594
- token in scope), or anything else. Whoami's job is to answer
595
- "am I signed in?" with local state; this just decorates the
596
- answer with what the key can do when we can reach the server.
593
+ Returns what the key carries and what its owner's role lets it use
594
+ on success, ``None`` on any failure — missing creds, network error,
595
+ federation/JWT auth (no token in scope), or anything else. Whoami's
596
+ job is to answer "am I signed in?" with local state; this just
597
+ decorates the answer with what the key can do when we can reach the
598
+ server.
597
599
  """
598
600
  if not token or not workspace:
599
601
  return None
@@ -603,9 +605,9 @@ def _fetch_current_key_safely(token: str | None, workspace: str | None, base_url
603
605
  workspace=workspace,
604
606
  base_url=base_url or FruxonClient.DEFAULT_BASE_URL,
605
607
  )
606
- return client.get_current_token()
607
608
  except Exception:
608
609
  return None
610
+ return read_key_access(client)
609
611
 
610
612
 
611
613
  @app.command()
@@ -668,7 +670,8 @@ def whoami(
668
670
  # core whoami data: a network failure here shouldn't break the
669
671
  # ability to ask "am I signed in?". The CLI's primary identity
670
672
  # check stays local-only; this just enriches it.
671
- current_key = _fetch_current_key_safely(token, workspace, base_url)
673
+ access = _fetch_key_access_safely(token, workspace, base_url)
674
+ current_key = access.key if access else None
672
675
 
673
676
  if output == "json":
674
677
  # Structured shape an agent can branch on without parsing text.
@@ -690,12 +693,21 @@ def whoami(
690
693
  {
691
694
  "name": current_key.get("name"),
692
695
  "keyPrefix": current_key.get("keyPrefix"),
693
- "scopes": current_key.get("scopes") or [],
696
+ # The key's own scopes, as minted. A personal key may use
697
+ # only those its owner's role also carries: effectiveScopes,
698
+ # null when the role couldn't be read. A service-account
699
+ # key carries none of its own and no role caps it; its
700
+ # effectiveScopes is the account's grant (null on a server
701
+ # older than fruxon-backend #1571).
702
+ "scopes": access.scopes,
703
+ "ownerType": current_key.get("ownerType"),
704
+ "ownerRole": access.role,
705
+ "effectiveScopes": access.effective_scopes,
694
706
  "revokedAt": current_key.get("revokedAt"),
695
707
  "expiresAt": current_key.get("expiresAt"),
696
708
  "lastUsedAt": current_key.get("lastUsedAt"),
697
709
  }
698
- if current_key
710
+ if access and current_key
699
711
  else None
700
712
  ),
701
713
  }
@@ -749,14 +761,70 @@ def whoami(
749
761
  # scope count. Full scope list is one extra line so it doesn't
750
762
  # bury the credential info above. Omitting on failure keeps
751
763
  # whoami useful when the network is unreachable.
752
- if current_key:
764
+ if access and current_key:
753
765
  name = current_key.get("name") or "(unnamed)"
754
- scopes = current_key.get("scopes") or []
766
+ if current_key.get("ownerType") == "SERVICE_ACCOUNT":
767
+ _say_service_account_grant(name, access)
768
+ return
769
+ scopes = access.scopes
755
770
  scope_count = len(scopes)
756
771
  say_ok(
757
- f"Key [bold]{name}[/bold] · {scope_count} scope{'s' if scope_count != 1 else ''}",
772
+ f"Key [bold]{name}[/bold] · {scope_count} scope{'s' if scope_count != 1 else ''} on the key",
758
773
  hint=", ".join(scopes) if scopes else "no scopes (key is dead weight)",
759
774
  )
775
+ if access.personal:
776
+ _say_role_cap(access)
777
+
778
+
779
+ def _say_service_account_grant(name: str, access: KeyAccess) -> None:
780
+ """Name a service-account key and the account's grant it acts with.
781
+
782
+ The key carries no scopes of its own, so the personal key's "0 scopes
783
+ on the key · dead weight" would call a working key dead. What it may
784
+ do is the account's grant, which a server with fruxon-backend #1571
785
+ reports as ``effectiveScopes``. No role caps it, so there is no role
786
+ line.
787
+ """
788
+ grant = access.effective_scopes
789
+ if grant is None:
790
+ say_ok(
791
+ f"Key [bold]{name}[/bold] · service account",
792
+ hint="It acts with the account's grant, which this server doesn't report.",
793
+ )
794
+ return
795
+ count = len(grant)
796
+ say_ok(
797
+ f"Key [bold]{name}[/bold] · service account · {count} scope{'s' if count != 1 else ''} from its grant",
798
+ hint=", ".join(grant) if grant else "no scopes (the account's grant is empty)",
799
+ )
800
+
801
+
802
+ def _say_role_cap(access: KeyAccess) -> None:
803
+ """Say how much of a personal key its owner's workspace role lets it use.
804
+
805
+ The list above is the key's own; the server cuts a personal key down
806
+ to its owner's current role on every request. Without this line a
807
+ Viewer reads agents:write there, gets a 403 for it, and is told to
808
+ sign in again — which mints a key cut down the same way.
809
+ """
810
+ if access.role is None or access.effective_scopes is None:
811
+ say_info(
812
+ "Those are the key's own scopes. The server lets a personal key use "
813
+ "only those your workspace role also carries; your role couldn't be read here.",
814
+ )
815
+ return
816
+ role = access.role.replace("_", " ").title()
817
+ effective = access.effective_scopes
818
+ if set(effective) == set(access.scopes):
819
+ say_ok(f"Role [bold]{role}[/bold] · every scope on the key is in effect")
820
+ return
821
+ say_warn(
822
+ f"Role [bold]{role}[/bold] · this key may use {len(effective)} of its {len(set(access.scopes))} scopes here",
823
+ hint=(
824
+ f"{', '.join(effective) if effective else 'none'}\n"
825
+ " Signing in again won't widen it; a workspace Admin can raise your role."
826
+ ),
827
+ )
760
828
 
761
829
 
762
830
  def _source_label(env_name: str, active: str | None, from_file: str | None) -> str:
@@ -33,7 +33,7 @@ from typing import Annotated
33
33
 
34
34
  import typer
35
35
 
36
- from fruxon.cli._shared import build_client, confirm_or_die
36
+ from fruxon.cli._shared import build_client, confirm_or_die, format_relative
37
37
  from fruxon.cli.knowledge_bases import _esc, _say_kv, knowledge_bases_app
38
38
  from fruxon.exceptions import FruxonError
39
39
  from fruxon.fruxon import KnowledgeNotice
@@ -643,20 +643,7 @@ def _when(ms: int, zone: str | None, now: int) -> str:
643
643
  """``2026-10-14 18:00 Asia/Jerusalem (in 4h 10m)``: in the zone agents tell it in, and how far off."""
644
644
  tz = _tzinfo(zone)
645
645
  at = datetime.fromtimestamp(ms / 1000, tz=tz or timezone.utc).strftime("%Y-%m-%d %H:%M")
646
- return f"{at} {zone if tz else 'UTC'} ({_relative(ms, now)})"
647
-
648
-
649
- def _relative(ms: int, now: int) -> str:
650
- delta = abs(ms - now) // 60_000
651
- days, rest = divmod(delta, 1_440)
652
- hours, minutes = divmod(rest, 60)
653
- if days:
654
- span = f"{days}d {hours}h" if hours else f"{days}d"
655
- elif hours:
656
- span = f"{hours}h {minutes}m" if minutes else f"{hours}h"
657
- else:
658
- span = f"{max(minutes, 1)}m"
659
- return f"in {span}" if ms >= now else f"{span} ago"
646
+ return f"{at} {zone if tz else 'UTC'} ({format_relative(ms, now)})"
660
647
 
661
648
 
662
649
  def _state(n: KnowledgeNotice) -> str:
@@ -677,11 +664,11 @@ def _notices_table(notices: list[KnowledgeNotice], now: int):
677
664
  table.add_column("Ends")
678
665
  for n in notices:
679
666
  if n.active:
680
- ends = _relative(n.valid_until, now)
667
+ ends = format_relative(n.valid_until, now)
681
668
  state = "[green]active[/green]"
682
669
  else:
683
670
  note = f": {_esc(n.withdraw_note)}" if n.withdraw_note else ""
684
- ends = f"[dim]{_relative(n.ended_at, now)}{note}[/dim]"
671
+ ends = f"[dim]{format_relative(n.ended_at, now)}{note}[/dim]"
685
672
  state = f"[dim]{_state(n)}[/dim]"
686
673
  table.add_row(_esc(n.id), _esc(n.title), state, ends)
687
674
  return table