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.
- {fruxon-2.1.0 → fruxon-2.2.0}/HISTORY.md +31 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/PKG-INFO +6 -2
- {fruxon-2.1.0 → fruxon-2.2.0}/README.md +5 -1
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/__init__.py +24 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/_version.py +2 -2
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/ledger.py +97 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_shared.py +86 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/auth.py +82 -14
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_notices.py +4 -17
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers.py +14 -1
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_ledger.py +244 -6
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_shape.py +207 -4
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/exceptions.py +15 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/fruxon.py +287 -20
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/models.py +406 -5
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-debug-trace/SKILL.md +10 -5
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/ui.py +45 -2
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_checks_ledger.py +97 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli.py +148 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_client.py +71 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers.py +64 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger.py +498 -2
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_shape.py +190 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_ui.py +175 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/.gitignore +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/LICENSE +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/pyproject.toml +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/__main__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/_ssl.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/agent.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/checks/references.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_crash.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_schema.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/_stream.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_approvals.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_budget.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_channels.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_draft.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_evaluations.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_executions.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_improvements.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_lifecycle.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_memory.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_reply_style.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_revisions.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_sandbox.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_sandbox_test.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_slots.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_system_blocks.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_tests.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_topics.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/agents_voice_calls.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/applications.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/applications_entry_points.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/assets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/capabilities.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/completion.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/config.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/consult_pins.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/conversation_imports.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/dash.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/describe.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/doctor.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/environments.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/escalations.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/examples.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/guides.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/integrations.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/keys.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_bases.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_documents.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/knowledge_sources.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/llm_providers.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/messages.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/metrics.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/participants.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/pipelines.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/secrets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/skills.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/storage.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/tools.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_budget.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_check.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_ledger_stage_tests.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/triggers_questions.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/cli/workspaces.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/credentials.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/device_auth.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/doctor.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/flow_validation.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/output.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/params.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/sandbox_scenario.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-agent-mode/SKILL.md +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-build-agent/SKILL.md +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-create-integration/SKILL.md +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-meet/SKILL.md +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/skills/fruxon-use-integrations/SKILL.md +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/telemetry.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/app.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/demo.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/links.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/agent.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/base.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/home.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/ledger.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/loader.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/pickers.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/screens/trace.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/source.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/theme.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/views.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/tui/widgets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/update_check.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/src/fruxon/validation.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/.test_durations.json +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/__init__.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/conftest.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_actor.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_lifecycle.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_revisions_list.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agent_slots.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_check_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_revisions_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_agents_sandbox.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_applications.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_approvals.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_assets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_budgets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_capabilities.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_channels.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_checks_agent.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_base_url_routing.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_crash.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_cli_root_flags.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_connect_nudge.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_consult.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_conversation_imports.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_credentials.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_app.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_client.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_links.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_dash_source.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_describe.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_device_auth.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_docs_commands_exist.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_doctor.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_bootstrap.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_evaluate_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_pull_shape.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_run_replay.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_draft_validate_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_drafts.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_entry_points.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_environments.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_escalations.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_evaluations.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_execution_records.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_flow_validation.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_fruxon.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_guides.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_improvements.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integration_configs_write_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integration_triggers_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integrations_authorize_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_integrations_connect_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_kb_trial_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_bases.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_documents.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_notices.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_knowledge_sources.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_llm_provider_configs_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_llm_providers_connect_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_managed_by.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_memory.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_messages.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_metrics.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_llm_config_refs.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_prefetch_refs.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_online_secret_refs.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_output.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_params.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_participants.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_participants_write.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_pipelines.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_reply_style.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_sandbox_scenario.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_schema.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_schema_fetch.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_secrets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_skills.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_ssl.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_storage.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_system_blocks.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_system_blocks_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_telemetry.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_test_chats.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_topics.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_trigger_budgets.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_check_cli.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger_batch_views.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_ledger_stage_tests.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_questions.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_triggers_write.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_tui_boundary.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_update_check.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_validation.py +0 -0
- {fruxon-2.1.0 → fruxon-2.2.0}/tests/test_voice_calls.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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
|
|
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
|
|
593
|
-
failure — missing creds, network error,
|
|
594
|
-
token in scope), or anything else. Whoami's
|
|
595
|
-
"am I signed in?" with local state; this just
|
|
596
|
-
answer with what the key can do when we can reach the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'} ({
|
|
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 =
|
|
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]{
|
|
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
|