diff --git a/README.md b/README.md index 6f304cf..719842b 100644 --- a/README.md +++ b/README.md @@ -495,21 +495,33 @@ The `user list` table shows **Name** and **User UUID**. You can copy a UUID dire ## Workflows -Browse the workflows configured in your organization. This surface is -**read-only** — workflows are created and edited in the Dailybot web app. The -feature is plan-gated; an org on a plan without workflows gets a clear upgrade -message. +Browse the workflows configured in your organization, and **trigger** ones whose +trigger type is `api_trigger` ("When triggered via API or button" in the +automations builder). Creating and editing workflow definitions remains in the +Dailybot web app. The feature is plan-gated; an org on a plan without workflows +gets a clear upgrade message. ```bash # List workflows visible to you (paginated + searchable) dailybot workflow list dailybot workflow list --search "release" --all --json +# Find triggerable workflows +dailybot workflow list --filter api_trigger --all # Get a single workflow by UUID dailybot workflow get dailybot workflow get --json + +# Queue an api_trigger run (async — returns 202; no run output) +dailybot workflow trigger +dailybot workflow trigger \ + --payload '{"env":"production","requested_by":"release-bot"}' ``` +The optional `--payload` must be a JSON object ≤ 8 KiB; workflow steps read it +as `{{trigger.body.*}}`. The same `api_trigger` workflows can also be fired from +a chat button via `callback_workflow` (see Chat below). + `dailybot workflow list` accepts the shared list flags — see [Listing, search, and pagination](#listing-search-and-pagination). @@ -869,8 +881,9 @@ Replies to agent emails land as messages retrievable via `dailybot agent message | Command | Description | |---------|-------------| -| `dailybot workflow list` | List workflows visible to you (read-only; search + pagination) | +| `dailybot workflow list` | List workflows visible to you (search + pagination; `--filter api_trigger`) | | `dailybot workflow get ` | Show a single workflow by UUID | +| `dailybot workflow trigger ` | Queue an `api_trigger` workflow run (async 202; optional `--payload`) | ### Chat (send bot messages to Slack/Teams/Discord/Google Chat) @@ -907,6 +920,22 @@ dailybot chat send -c C0123 -m "Build #421 ✅" \ --bot-name "Release Bot" --bot-icon-emoji ":rocket:" \ --link-button "Open report::https://app.company.com/report" +# Approval flow — callback_url buttons (+ optional bearer auth on the POST) +dailybot chat send -u -m "Deploy to prod?" \ + --approve-button "Yes=approve" --reject-button "No=deny" \ + --callback-url https://hooks.example.com/req42 --callback-bearer "$TOKEN" + +# Fire an api_trigger workflow from a button click +dailybot chat send -c C0123 -m "Ready to release?" \ + --workflow-button "Run release=" + +# Full button contract (modals, response trees, callback_prompt, …) via JSON +dailybot chat send -u -m "Need details" --buttons '[ + {"label":"Open","button_type":"interactive","value":"open", + "callback_url":"https://hooks.example.com/x", + "modal_body":{"title":"Notes","blocks":[ + {"type":"input","name":"notes","label":"Notes","multiline":true}]}}]' + # Send with another user's identity, or as yourself (Slack only, admin-only) dailybot chat send -c C0123 -m "Posting for the team" --send-as-user dailybot chat send -c C0123 -m "This one is from me" --send-as-me diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 7e5d91d..e3db0e1 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -1443,7 +1443,7 @@ def list_workflows( limit: int | None = None, meta: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - """GET /v1/workflows/ — list workflows (read-only; plan-gated feature).""" + """GET /v1/workflows/ — list workflows (plan-gated feature).""" params: dict[str, Any] = {} _merge_list_query(params, search=search, start_date=start_date, end_date=end_date) result: PaginatedResult = self._paginated_get( @@ -1464,6 +1464,30 @@ def get_workflow(self, workflow_uuid: str) -> dict[str, Any]: ) return self._handle_response(response) + def trigger_workflow( + self, + workflow_uuid: str, + *, + payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """POST /v1/workflows//trigger/ — queue an ``api_trigger`` workflow. + + Only workflows whose trigger type is ``api_trigger`` ("When triggered via + API or button") can be fired this way. The run is asynchronous — success + is ``202 {queued: true, workflow_uuid, detail}`` with no run output. + Optional *payload* (a JSON object ≤ 8 KiB) is exposed to workflow steps + as ``{{trigger.body.*}}`` variables. + """ + body: dict[str, Any] = {} + if payload is not None: + body["payload"] = payload + response: httpx.Response = self._request( + "POST", + f"{self.api_url}/v1/workflows/{workflow_uuid}/trigger/", + json=body, + ) + return self._handle_response(response) + def list_kudos_organization( self, *, diff --git a/dailybot_cli/commands/chat.py b/dailybot_cli/commands/chat.py index 8861439..aedfefb 100644 --- a/dailybot_cli/commands/chat.py +++ b/dailybot_cli/commands/chat.py @@ -28,8 +28,11 @@ from dailybot_cli.api_client import APIError, DailyBotClient from dailybot_cli.commands.agent import _merge_repo_metadata, _resolve_agent_context from dailybot_cli.commands.public_api_helpers import ( + EXIT_NOT_AUTHENTICATED, + EXIT_PERMISSION_DENIED, UUID_PATTERN, emit_json, + exit_for_api_error, get_current_user_uuid, ) from dailybot_cli.display import ( @@ -46,8 +49,32 @@ "direct_message", ) BUTTON_SEPARATOR: str = "::" +ERGONOMIC_BUTTON_SEPARATOR: str = "=" MAX_BOT_USERNAME_CHARS: int = 80 MAX_THREAD_RESPONSES: int = 10 +MAX_BUTTONS_PER_MESSAGE: int = 25 +BUTTON_CALLBACK_KEYS: tuple[str, ...] = ( + "callback_url", + "callback_form", + "callback_command", + "callback_prompt", + "callback_workflow", +) +BUTTON_ERROR_CODES: frozenset[str] = frozenset( + { + "button_link_and_callback_conflict", + "button_callback_conflict", + "button_callback_url_invalid", + "button_modal_body_invalid", + "button_callback_form_not_found", + "button_callback_command_invalid", + "button_callback_prompt_invalid", + "button_callback_workflow_not_found", + "button_response_invalid", + "button_callback_auth_invalid", + "buttons_count_out_of_range", + } +) class ChatPayloadError(ValueError): @@ -68,6 +95,48 @@ def _parse_button(raw: str, *, kind: str) -> tuple[str, str]: return label, value +def _parse_ergonomic_button(raw: str, *, flag: str) -> tuple[str, str]: + """Split a ``"Label=value"`` ergonomic button spec into ``(label, value)``.""" + label, sep, value = raw.partition(ERGONOMIC_BUTTON_SEPARATOR) + label = label.strip() + value = value.strip() + if not sep or not label or not value: + raise ChatPayloadError( + f"Invalid {flag} '{raw}'. Expected 'Label{ERGONOMIC_BUTTON_SEPARATOR}value' " + f"(e.g. 'Yes{ERGONOMIC_BUTTON_SEPARATOR}approve')." + ) + return label, value + + +def validate_buttons(buttons: list[Any], *, flag_hint: str = "--buttons") -> None: + """Light client-side checks; never strips unknown keys (API owns the contract). + + Enforces: ≤25 buttons, each entry is an object with a non-empty ``label``, + and at most one of the five callback fields per button. Richer rules + (modal size, URL shape, recursive ``response`` trees) stay server-side. + """ + if len(buttons) > MAX_BUTTONS_PER_MESSAGE: + raise ChatPayloadError( + f"At most {MAX_BUTTONS_PER_MESSAGE} buttons are allowed per message ({flag_hint})." + ) + for index, button in enumerate(buttons, start=1): + if not isinstance(button, dict): + raise ChatPayloadError(f"Button #{index} must be a JSON object ({flag_hint}).") + label_raw: Any = button.get("label") + label: str = str(label_raw).strip() if label_raw is not None else "" + if not label: + raise ChatPayloadError(f"Button #{index} is missing a required 'label' ({flag_hint}).") + present: list[str] = [ + key for key in BUTTON_CALLBACK_KEYS if button.get(key) not in (None, "") + ] + if len(present) > 1: + raise ChatPayloadError( + f"Button '{label}' sets more than one callback ({', '.join(present)}). " + "At most one of callback_url, callback_form, callback_command, " + "callback_prompt, or callback_workflow is allowed." + ) + + def build_chat_payload( *, text: str | None = None, @@ -77,6 +146,7 @@ def build_chat_payload( image_url: str | None = None, link_buttons: list[tuple[str, str]] | None = None, action_buttons: list[tuple[str, str]] | None = None, + extra_buttons: list[dict[str, Any]] | None = None, thread: str | None = None, channel_type: str | None = None, bot_name: str | None = None, @@ -95,6 +165,9 @@ def build_chat_payload( :class:`ChatPayloadError` on invalid combinations the API would reject — surfacing a friendly message before the network call. + *extra_buttons* is a list of raw button objects (from ``--buttons`` JSON or + ergonomic approval/workflow flags). Keys are forwarded untouched — the CLI + stays forward-compatible with every current and future button field. *thread_responses* posts follow-up messages inside the parent's thread in the same call (each reply inherits the parent's recipients). The API mints one id per reply in the response, so each is independently editable. @@ -136,7 +209,10 @@ def build_chat_payload( buttons.append({"label": label, "button_type": "link", "url": url}) for label, value in action_buttons or []: buttons.append({"label": label, "button_type": "interactive", "value": value}) + if extra_buttons: + buttons.extend(extra_buttons) if buttons: + validate_buttons(buttons) payload["buttons"] = buttons if users: @@ -212,7 +288,9 @@ def _send( client: DailyBotClient, payload: dict[str, Any], *, updated: bool, json_mode: bool ) -> None: """Run a single send/update call and render the result (used by `update`).""" - result: dict[str, Any] = _execute_send(client, payload, status="Sending message...") + result: dict[str, Any] = _execute_send( + client, payload, status="Sending message...", json_mode=json_mode + ) if json_mode: emit_json(result) return @@ -220,9 +298,13 @@ def _send( def _execute_send( - client: DailyBotClient, payload: dict[str, Any], *, status: str + client: DailyBotClient, + payload: dict[str, Any], + *, + status: str, + json_mode: bool = False, ) -> dict[str, Any]: - """Call the API with friendly error translation; return the result or exit 1.""" + """Call the API with friendly error translation; return the result or exit.""" if payload.get("platform_settings", {}).get("is_ephemeral") and not payload.get("target_users"): # The API silently skips an ephemeral message with no resolvable user. print_warning( @@ -233,31 +315,50 @@ def _execute_send( with console.status(status): return client.send_chat_message(payload) except APIError as e: - if e.code == "org_admin_required": - print_error( + overrides: dict[str, str] = { + "org_admin_required": ( "Sending as another user (--send-as-user / --send-as-me) requires organization " "admin privileges. Run it with an admin account or an org admin's API key." - ) - elif e.code == "cli_send_message_target_not_allowed": - print_error( + ), + "cli_send_message_target_not_allowed": ( f"{e.detail}\n Your role can only reach teammates, public channels, and teams " "you belong to. Use an allowed target, or an org API key for org-wide reach." - ) - elif e.code == "invalid_thread_responses": - print_error( + ), + "invalid_thread_responses": ( f"{e.detail}\n Thread replies allow at most 10 items, one level deep, with no " "targeting of their own (they inherit the parent's recipients)." - ) - elif e.status_code in (401, 403): - print_error( + ), + } + # Button contract errors: surface the server detail verbatim (richest signal). + if e.code and e.code in BUTTON_ERROR_CODES and e.detail: + overrides[e.code] = e.detail + # Auth hint when the server didn't send a more specific code. + if e.status_code in (401, 403) and e.code not in overrides: + auth_hint: str = ( f"{e.detail}\n Authenticate first: run 'dailybot login' (sends as you) or set an " "org API key with 'dailybot config key='." ) - elif e.status_code == 429: - print_error("Rate limit exceeded for chat sends. Wait a bit and retry.") - else: - print_error(e.detail) - raise SystemExit(1) + if e.code: + overrides[e.code] = auth_hint + else: + if json_mode: + emit_json({"error": auth_hint, "status": e.status_code, "detail": e.detail}) + else: + print_error(auth_hint) + # Match exit_for_api_error's status→exit-code contract so headless + # consumers see a stable code whether or not the server sent `code`. + exit_code: int = ( + EXIT_NOT_AUTHENTICATED if e.status_code == 401 else EXIT_PERMISSION_DENIED + ) + raise SystemExit(exit_code) + if e.status_code == 429 and e.code is None: + rate_msg: str = "Rate limit exceeded for chat sends. Wait a bit and retry." + if json_mode: + emit_json({"error": rate_msg, "status": 429, "detail": e.detail}) + else: + print_error(rate_msg) + raise SystemExit(1) + exit_for_api_error(e, json_mode, code_overrides=overrides) # --- chat group --- @@ -313,6 +414,49 @@ def _target_options(fn: Any) -> Any: multiple=True, help="Interactive button 'Label::value' (repeatable).", ), + click.option( + "--buttons", + "buttons_json", + default=None, + help=( + "Raw buttons JSON array — full API contract (callbacks, modals, response, " + "callback_auth). Keys forwarded untouched; max 25." + ), + ), + click.option( + "--approve-button", + "approve_button_raw", + default=None, + help="Approval button 'Label=value' (needs --callback-url).", + ), + click.option( + "--reject-button", + "reject_button_raw", + default=None, + help="Reject button 'Label=value' (needs --callback-url).", + ), + click.option( + "--callback-url", + "callback_url", + default=None, + help="HTTPS callback URL for --approve-button / --reject-button.", + ), + click.option( + "--callback-bearer", + "callback_bearer", + default=None, + help=( + "Optional bearer token for callback_auth on approval buttons. Prefer " + 'passing via an env var (e.g. --callback-bearer "$TOKEN") — a raw ' + "token on the command line lands in shell history and process lists." + ), + ), + click.option( + "--workflow-button", + "workflow_buttons_raw", + multiple=True, + help="Workflow button 'Label=' (callback_workflow; repeatable).", + ), click.option("--thread", default=None, help="Thread id to reply inside (channels)."), click.option( "--channel-type", @@ -353,6 +497,75 @@ def _target_options(fn: Any) -> Any: return fn +def _build_extra_buttons( + *, + buttons_json: str | None, + approve_button_raw: str | None, + reject_button_raw: str | None, + callback_url: str | None, + callback_bearer: str | None, + workflow_buttons_raw: tuple[str, ...], +) -> list[dict[str, Any]]: + """Assemble raw button objects from --buttons JSON and ergonomic flags.""" + extra: list[dict[str, Any]] = [] + + if buttons_json is not None: + try: + parsed: Any = json.loads(buttons_json) + except json.JSONDecodeError: + raise ChatPayloadError("Invalid JSON in --buttons.") from None + if not isinstance(parsed, list): + raise ChatPayloadError("--buttons must be a JSON array of button objects.") + for item in parsed: + if not isinstance(item, dict): + raise ChatPayloadError("--buttons entries must be JSON objects.") + extra.append(dict(item)) # shallow copy; keys untouched + + if callback_bearer and not callback_url: + raise ChatPayloadError("--callback-bearer requires --callback-url.") + if (approve_button_raw or reject_button_raw) and not callback_url: + raise ChatPayloadError("--approve-button / --reject-button require --callback-url.") + if callback_url and not (approve_button_raw or reject_button_raw): + raise ChatPayloadError( + "--callback-url needs at least one of --approve-button / --reject-button " + "(or put callback_url inside --buttons JSON)." + ) + + callback_auth: dict[str, Any] | None = None + if callback_bearer: + callback_auth = {"type": "bearer", "token": callback_bearer} + + for raw, flag in ( + (approve_button_raw, "--approve-button"), + (reject_button_raw, "--reject-button"), + ): + if not raw: + continue + label, value = _parse_ergonomic_button(raw, flag=flag) + button: dict[str, Any] = { + "label": label, + "button_type": "interactive", + "value": value, + "callback_url": callback_url, + } + if callback_auth is not None: + button["callback_auth"] = dict(callback_auth) + extra.append(button) + + for raw in workflow_buttons_raw: + label, workflow_uuid = _parse_ergonomic_button(raw, flag="--workflow-button") + extra.append( + { + "label": label, + "button_type": "interactive", + "value": workflow_uuid, + "callback_workflow": workflow_uuid, + } + ) + + return extra + + def _assemble_payload( *, payload_json: str | None, @@ -391,6 +604,11 @@ def _assemble_payload( raw["bot_message_id"] = bot_message_id try: _validate_targets(raw) + raw_buttons: Any = raw.get("buttons") + if raw_buttons is not None: + if not isinstance(raw_buttons, list): + raise ChatPayloadError("--payload-json 'buttons' must be a JSON array.") + validate_buttons(raw_buttons, flag_hint="--payload-json buttons") except ChatPayloadError as exc: print_error(str(exc)) raise SystemExit(1) @@ -400,14 +618,29 @@ def _assemble_payload( action_buttons: list[tuple[str, str]] = [] thread_messages: tuple[str, ...] = build_kwargs.pop("thread_messages_raw", ()) thread_responses: list[dict[str, Any]] = [{"message": t} for t in thread_messages if t] + buttons_json: str | None = build_kwargs.pop("buttons_json", None) + approve_button_raw: str | None = build_kwargs.pop("approve_button_raw", None) + reject_button_raw: str | None = build_kwargs.pop("reject_button_raw", None) + callback_url: str | None = build_kwargs.pop("callback_url", None) + callback_bearer: str | None = build_kwargs.pop("callback_bearer", None) + workflow_buttons_raw: tuple[str, ...] = build_kwargs.pop("workflow_buttons_raw", ()) try: for raw_btn in build_kwargs.pop("link_buttons_raw", ()): link_buttons.append(_parse_button(raw_btn, kind="link")) for raw_btn in build_kwargs.pop("action_buttons_raw", ()): action_buttons.append(_parse_button(raw_btn, kind="interactive")) + extra_buttons: list[dict[str, Any]] = _build_extra_buttons( + buttons_json=buttons_json, + approve_button_raw=approve_button_raw, + reject_button_raw=reject_button_raw, + callback_url=callback_url, + callback_bearer=callback_bearer, + workflow_buttons_raw=workflow_buttons_raw, + ) return build_chat_payload( link_buttons=link_buttons, action_buttons=action_buttons, + extra_buttons=extra_buttons or None, metadata=metadata_dict, bot_message_id=bot_message_id, thread_responses=thread_responses or None, @@ -452,6 +685,12 @@ def chat_send( image_url: str | None, link_buttons_raw: tuple[str, ...], action_buttons_raw: tuple[str, ...], + buttons_json: str | None, + approve_button_raw: str | None, + reject_button_raw: str | None, + callback_url: str | None, + callback_bearer: str | None, + workflow_buttons_raw: tuple[str, ...], thread: str | None, channel_type: str | None, bot_name: str | None, @@ -478,6 +717,25 @@ def chat_send( dailybot chat send -u ana@co.com -m "Heads up" --ephemeral dailybot chat send --payload-json '{"target_channels":["C0"],"messages":[...]}' --json + \b + Approval flow (callback_url buttons + optional bearer auth): + dailybot chat send -u -m "Deploy?" \\ + --approve-button "Yes=approve" --reject-button "No=deny" \\ + --callback-url https://hooks.example.com/req42 --callback-bearer "$TOKEN" + + \b + Workflow button (fires an api_trigger workflow on click): + dailybot chat send -c C0123 -m "Ready?" \\ + --workflow-button "Run release=" + + \b + Full button contract via --buttons JSON (modals, response trees, …): + dailybot chat send -u -m "Details?" --buttons '[ + {"label":"Open","button_type":"interactive","value":"open", + "callback_url":"https://hooks.example.com/x", + "modal_body":{"title":"Notes","blocks":[ + {"type":"input","name":"notes","label":"Notes","multiline":true}]}}]' + \b Report style — a short headline plus the detail inside its thread: dailybot chat send -c C0123 -m "🚀 Release v2.4 shipped" \\ @@ -511,6 +769,12 @@ def chat_send( image_url=image_url, link_buttons_raw=link_buttons_raw, action_buttons_raw=action_buttons_raw, + buttons_json=buttons_json, + approve_button_raw=approve_button_raw, + reject_button_raw=reject_button_raw, + callback_url=callback_url, + callback_bearer=callback_bearer, + workflow_buttons_raw=workflow_buttons_raw, thread_messages_raw=thread_messages_raw, thread=thread, channel_type=channel_type, @@ -538,6 +802,12 @@ def chat_update( image_url: str | None, link_buttons_raw: tuple[str, ...], action_buttons_raw: tuple[str, ...], + buttons_json: str | None, + approve_button_raw: str | None, + reject_button_raw: str | None, + callback_url: str | None, + callback_bearer: str | None, + workflow_buttons_raw: tuple[str, ...], thread: str | None, channel_type: str | None, bot_name: str | None, @@ -554,8 +824,9 @@ def chat_update( \b dailybot chat update -c C0123 -m "Status: DONE ✅" - Note: the chat platform keeps the message's original bot name/avatar on an - edit, so identity flags are ignored when updating. + Buttons round-trip on update the same way as send. Note: the chat platform + keeps the message's original bot name/avatar on an edit, so identity flags + are ignored when updating. Re-send within 72h with the same bot_message_id. """ profile_flag: str | None = ctx.obj.get("profile") client, repo_default_metadata = _resolved_client(profile_flag) @@ -572,6 +843,12 @@ def chat_update( image_url=image_url, link_buttons_raw=link_buttons_raw, action_buttons_raw=action_buttons_raw, + buttons_json=buttons_json, + approve_button_raw=approve_button_raw, + reject_button_raw=reject_button_raw, + callback_url=callback_url, + callback_bearer=callback_bearer, + workflow_buttons_raw=workflow_buttons_raw, thread=thread, channel_type=channel_type, bot_name=bot_name, diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 18a80a1..04551f0 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -275,6 +275,51 @@ ), "send_as_user_invalid_uuid": "Invalid UUID for --send-as-user.", "send_as_user_not_found": "User not found in your organization (must be active).", + # Interactive buttons (POST /v1/send-message/) — prefer server detail when + # present; these are fallbacks for older backends that omit detail. + "button_link_and_callback_conflict": ( + "A link button can't carry a callback. Use button_type=interactive for callbacks." + ), + "button_callback_conflict": ( + "An interactive button can set at most one of callback_url, callback_form, " + "callback_command, callback_prompt, or callback_workflow." + ), + "button_callback_url_invalid": "Invalid callback_url (must be https, ≤2048 chars).", + "button_modal_body_invalid": ( + "Invalid modal_body. Check block types, unique input names, size limits, " + "and that input modals include callback_url or callback_workflow." + ), + "button_callback_form_not_found": ( + "callback_form did not resolve to a form in your organization." + ), + "button_callback_command_invalid": ( + "Invalid callback_command (≤200 chars; the legacy 'prompt:' prefix is rejected " + "— use callback_prompt)." + ), + "button_callback_prompt_invalid": ("Invalid callback_prompt (required, ≤2000 chars)."), + "button_callback_workflow_not_found": ( + "callback_workflow did not resolve to an active api_trigger workflow in your org." + ), + "button_response_invalid": ( + "Invalid button response (message ≤2000; nested buttons depth ≤3, ≤25/level)." + ), + "button_callback_auth_invalid": ( + "Invalid callback_auth (bearer / basic / custom_header; only with callback_url)." + ), + "buttons_count_out_of_range": "A message can carry at most 25 buttons.", + # Workflow trigger (POST /v1/workflows//trigger/) + "workflow_not_triggerable": ( + "This workflow's trigger type isn't 'api_trigger' (set it in the automations " + 'builder to "When triggered via API or button") or the workflow is inactive.' + ), + "workflow_trigger_payload_invalid": ( + "--payload must be a JSON object and serialize to at most 8 KiB." + ), + "workflow_execute_not_allowed": ("You don't have permission to execute this workflow."), + "workflow_frozen": ( + "This workflow is frozen on your organization's plan. Upgrade or unfreeze it " + "in the automations builder." + ), } diff --git a/dailybot_cli/commands/workflow.py b/dailybot_cli/commands/workflow.py index 2bedb9f..e1cb697 100644 --- a/dailybot_cli/commands/workflow.py +++ b/dailybot_cli/commands/workflow.py @@ -1,5 +1,11 @@ -"""Workflow read commands (list / get). Read-only — workflow writes are API-side.""" +"""Workflow commands (list / get / trigger). +Browse workflows and fire ``api_trigger`` ones via +``POST /v1/workflows/{uuid}/trigger/``. Creating and editing workflow +definitions remains in the Dailybot web app (automations builder). +""" + +import json from typing import Any import click @@ -16,7 +22,9 @@ console, print_detail_panel, print_error, + print_info, print_pagination_footer, + print_success, print_workflows_table, ) @@ -30,19 +38,35 @@ ("Last run", "last_run_at"), ] +# Server rejects non-object / oversized payloads with +# ``workflow_trigger_payload_invalid``; catch the common cases client-side. +MAX_TRIGGER_PAYLOAD_BYTES: int = 8 * 1024 +API_TRIGGER_TYPE: str = "api_trigger" + @click.group() def workflow() -> None: - """Browse your organization's workflows (read-only). + """Browse and trigger your organization's workflows. \b Acts as you — visibility matches the webapp. Workflows are a plan-gated - feature; creating/editing them is done in the Dailybot web app. + feature; creating/editing them is done in the Dailybot automations builder. + + \b + Only workflows with trigger type 'api_trigger' ("When triggered via API or + button") can be fired with 'workflow trigger' or via a chat button's + callback_workflow. The optional --payload reaches steps as {{trigger.body.*}}. """ @workflow.command("list") @query_options +@click.option( + "--filter", + "trigger_filter", + default=None, + help=f"Client-side filter on trigger type (e.g. {API_TRIGGER_TYPE}).", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def workflow_list( page: int | None, @@ -55,6 +79,7 @@ def workflow_list( on_date: str | None, last_week: bool, today: bool, + trigger_filter: str | None, json_mode: bool, ) -> None: """List workflows in your organization. @@ -63,6 +88,7 @@ def workflow_list( Examples: dailybot workflow list dailybot workflow list --search deploy --json + dailybot workflow list --filter api_trigger --all """ enforce_plan_access("workflow_list", json_mode=json_mode) try: @@ -98,6 +124,16 @@ def workflow_list( ) except APIError as exc: exit_for_api_error(exc, json_mode) + if trigger_filter: + needle: str = trigger_filter.strip().lower() + # Compare against the same canonical value used by chat buttons / + # ``workflow trigger`` (API_TRIGGER_TYPE) and any other server type. + workflows = [wf for wf in workflows if str(wf.get("trigger_type", "")).lower() == needle] + if needle == API_TRIGGER_TYPE and not workflows and not json_mode: + print_info( + f"No workflows with trigger_type={API_TRIGGER_TYPE!r}. " + "Only that type can be fired via 'workflow trigger' or a chat button." + ) if json_mode: emit_json(workflows) return @@ -127,3 +163,65 @@ def workflow_get(workflow_uuid: str, json_mode: bool) -> None: emit_json(data) return print_detail_panel("Workflow", data, _WORKFLOW_FIELDS) + + +@workflow.command("trigger") +@click.argument("workflow_uuid") +@click.option( + "--payload", + "payload_raw", + default=None, + help="JSON object (≤8 KiB) exposed to steps as {{trigger.body.*}}.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def workflow_trigger(workflow_uuid: str, payload_raw: str | None, json_mode: bool) -> None: + """Queue an api_trigger workflow run (async — returns 202). + + \b + Only workflows with trigger type 'api_trigger' ("When triggered via API or + button" in the automations builder) are triggerable. The run is queued — + there is no run output to show. The same workflows can also be fired from + a chat button via callback_workflow (see 'dailybot chat send --help'). + + \b + Examples: + dailybot workflow trigger + dailybot workflow trigger \\ + --payload '{"env":"production","requested_by":"release-bot"}' + dailybot workflow trigger --json + """ + enforce_plan_access("workflow_trigger", json_mode=json_mode) + payload: dict[str, Any] | None = None + if payload_raw is not None: + try: + parsed: Any = json.loads(payload_raw) + except json.JSONDecodeError: + print_error("Invalid JSON in --payload.") + raise SystemExit(1) + if not isinstance(parsed, dict): + print_error("--payload must be a JSON object.") + raise SystemExit(1) + # Match httpx's json= serialization (default separators + ensure_ascii=False) + # so the local size guard measures the same bytes that hit the wire. + encoded: bytes = json.dumps(parsed, ensure_ascii=False).encode("utf-8") + if len(encoded) > MAX_TRIGGER_PAYLOAD_BYTES: + print_error( + f"--payload must serialize to at most {MAX_TRIGGER_PAYLOAD_BYTES} bytes " + f"(got {len(encoded)})." + ) + raise SystemExit(1) + payload = parsed + + client = require_auth() + try: + with console.status("Queuing workflow..."): + result: dict[str, Any] = client.trigger_workflow(workflow_uuid, payload=payload) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success("Workflow queued") + print_info(f"Workflow UUID: {result.get('workflow_uuid', workflow_uuid)}") + print_info("The run is asynchronous — there is no run output to show.") diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8bff231..67febda 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -350,18 +350,41 @@ Fetches a team via `GET /v1/teams//`. A name argument is resolved to UUID ### `dailybot workflow` (group) — user-scoped, Bearer or API key auth -Read-only browsing of the org's workflows. Workflow writes are done in the web -app; this group only lists and retrieves. The feature is **plan-gated** — an org -on a plan without workflows gets `403 plan_upgrade_required` (with `upgrade_url`). +Browse workflows and trigger `api_trigger` ones. Creating and editing workflow +definitions is done in the web app (automations builder). The feature is +**plan-gated** — an org on a plan without workflows gets +`403 plan_upgrade_required` (with `upgrade_url`). -#### `dailybot workflow list [--json]` +#### `dailybot workflow list [--filter TRIGGER] [--json]` -Lists workflows visible to the caller via `GET /v1/workflows/`. Accepts the [shared list query flags](#shared-list-query-flags) (pagination + `--search`/`--grep`). +Lists workflows visible to the caller via `GET /v1/workflows/`. Accepts the +[shared list query flags](#shared-list-query-flags) (pagination + `--search`/`--grep`). +`--filter api_trigger` is a client-side filter on `trigger_type` (helps find +workflows that can be fired with `workflow trigger` or a chat +`callback_workflow` button). #### `dailybot workflow get [--json]` Fetches a single workflow via `GET /v1/workflows//`. +#### `dailybot workflow trigger [--payload JSON] [--json]` + +Queues a run via `POST /v1/workflows//trigger/`. Only workflows whose +trigger type is **`api_trigger`** ("When triggered via API or button") are +triggerable; others return `400 workflow_not_triggerable`. Success is +**`202 {queued: true, workflow_uuid, detail}`** — the run is asynchronous and +there is no run output to show. + +`--payload` must be a JSON **object** ≤ 8 KiB (client-side check); it reaches +workflow steps as `{{trigger.body.*}}`. Error codes: +`workflow_not_triggerable`, `workflow_trigger_payload_invalid`, +`workflow_execute_not_allowed` (403), `workflow_frozen` (409 — plan/limit), +and 404 when the UUID is unknown or out of org. + +The same `api_trigger` workflows can also be fired from an interactive chat +button via `buttons[].callback_workflow` (optionally with a `modal_body` +whose submitted fields arrive as `{{trigger.fields.}}`). + --- ### User-scoped exit codes @@ -499,6 +522,12 @@ At least one target is required. Targets: Content & options: `--text/-m`, `--image-url/-i`, `--link-button "Label::url"` (repeatable), `--button "Label::value"` (interactive, repeatable), +`--buttons ''` (full button contract — callbacks, modals, response, +callback_auth; keys forwarded untouched; max 25), +`--approve-button "Label=value"` / `--reject-button "Label=value"` with +`--callback-url` and optional `--callback-bearer` (builds two `callback_url` +buttons), `--workflow-button "Label="` (repeatable; +`callback_workflow`), `--thread-message` (repeatable, max 10 — posts a reply in the parent's thread), `--thread` (reply into an existing platform thread id), `--channel-type` (`channel`/`private_channel`/`group_chat`/`direct_message`), @@ -511,6 +540,20 @@ Content & options: `--text/-m`, `--image-url/-i`, `--link-button "Label::url"` bypasses the building flags. Forward-compatible by design. - `--json` — emit the raw API response to stdout for headless/agent use: `{ "bot_message_id": "", "thread_responses": ["", …] }`. + On failure, emits `{ "error", "status", "code?", "detail?" }` and a structured + exit code. + +**Interactive buttons.** Up to 25 per message. Each interactive button may set +**at most one** of `callback_url`, `callback_form`, `callback_command`, +`callback_prompt`, or `callback_workflow`. Optional composers: `modal_body` +(with `callback_url` or `callback_workflow` when it has inputs), `response` +(auto-reply; recursive nested buttons), `callback_auth` (bearer / basic / +custom_header — only with `callback_url`). The CLI pre-validates mutual +exclusivity and the 25 cap, then forwards every key untouched (including +unknown future fields). Server codes: `button_callback_conflict`, +`button_modal_body_invalid`, `button_callback_workflow_not_found`, +`buttons_count_out_of_range`, and related `button_*` codes — `--json` passes +`{detail, code}` through. **Threads.** `--thread-message` builds the request's `thread_responses` array: a short parent message plus its replies, posted inside the parent's thread in @@ -675,6 +718,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `GET` | `/v1/kudos/wall-of-fame/` | `?limit` (optional) | `{ top_receiver, top_giver, dna_distribution, leaderboard: { count, next, previous, results }, leaderboard_summary }` | `kudos wall-of-fame` | | `GET` | `/v1/workflows/` | shared list params (all optional) | `{ count, next, previous, results }` | `workflow list`; plan-gated (403 `plan_upgrade_required`) | | `GET` | `/v1/workflows//` | — | `{ uuid, name, ... }` | `workflow get`; plan-gated | +| `POST` | `/v1/workflows//trigger/` | `{ payload? }` (object ≤8 KiB) | `202 { queued, workflow_uuid, detail }` | `workflow trigger`; `api_trigger` only; codes `workflow_not_triggerable`, `workflow_trigger_payload_invalid`, `workflow_execute_not_allowed`, `workflow_frozen` | ### Agent (X-API-KEY *or* Bearer) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 6f079a2..f3f2fb0 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -972,6 +972,34 @@ def test_send_chat_message_returns_update_id(self, client: DailyBotClient) -> No assert result["bot_message_id"] == "task-uuid" +class TestDailyBotClientWorkflowTrigger: + def test_trigger_workflow_posts_payload(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 202 + mock_response.json.return_value = { + "detail": "Workflow trigger accepted.", + "workflow_uuid": "w-1", + "queued": True, + } + + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.trigger_workflow("w-1", payload={"env": "prod"}) + + assert mock_post.call_args[0][0] == "http://test-api.example.com/v1/workflows/w-1/trigger/" + assert mock_post.call_args[1]["json"] == {"payload": {"env": "prod"}} + assert result["queued"] is True + + def test_trigger_workflow_empty_body_when_no_payload(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 202 + mock_response.json.return_value = {"queued": True, "workflow_uuid": "w-1"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.trigger_workflow("w-1") + + assert mock_post.call_args[1]["json"] == {} + + class TestAPIError: def test_api_error_raised(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) diff --git a/tests/api_error_codes_alignment_test.py b/tests/api_error_codes_alignment_test.py index 7949f51..6eba479 100644 --- a/tests/api_error_codes_alignment_test.py +++ b/tests/api_error_codes_alignment_test.py @@ -27,6 +27,19 @@ def test_invalid_kudos_filter_has_a_handler() -> None: assert "received" in ERROR_CODE_MESSAGES["invalid_kudos_filter"].lower() +def test_interactive_button_and_workflow_trigger_codes_are_mapped() -> None: + for code in ( + "button_callback_conflict", + "button_callback_workflow_not_found", + "buttons_count_out_of_range", + "workflow_not_triggerable", + "workflow_trigger_payload_invalid", + "workflow_execute_not_allowed", + "workflow_frozen", + ): + assert code in ERROR_CODE_MESSAGES + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_org_member_sees_generic_admin_message( diff --git a/tests/chat_commands_test.py b/tests/chat_commands_test.py index 09e664a..3805408 100644 --- a/tests/chat_commands_test.py +++ b/tests/chat_commands_test.py @@ -112,6 +112,59 @@ def test_too_many_thread_responses_raises(self) -> None: thread_responses=[{"message": str(i)} for i in range(11)], ) + def test_extra_buttons_passthrough_new_keys(self) -> None: + raw = [ + { + "label": "Yes", + "button_type": "interactive", + "value": "approve", + "callback_url": "https://hooks.example.com/x", + "label_after_click": "Approved", + "response": {"message": "Got it", "ephemeral": True}, + "callback_auth": {"type": "bearer", "token": "tok"}, + "future_field": {"kept": True}, + } + ] + payload = build_chat_payload(text="hi", channels=["C0"], extra_buttons=raw) + assert payload["buttons"][0]["callback_url"] == "https://hooks.example.com/x" + assert payload["buttons"][0]["future_field"] == {"kept": True} + assert payload["buttons"][0]["callback_auth"]["token"] == "tok" + + def test_callback_exclusivity_prevalidation(self) -> None: + with pytest.raises(ChatPayloadError, match="more than one callback"): + build_chat_payload( + text="hi", + channels=["C0"], + extra_buttons=[ + { + "label": "Go", + "button_type": "interactive", + "value": "go", + "callback_url": "https://x.example/a", + "callback_prompt": "Summarize", + } + ], + ) + + def test_buttons_cap_prevalidation(self) -> None: + with pytest.raises(ChatPayloadError, match="At most 25"): + build_chat_payload( + text="hi", + channels=["C0"], + extra_buttons=[ + {"label": f"B{i}", "button_type": "interactive", "value": str(i)} + for i in range(26) + ], + ) + + def test_missing_label_prevalidation(self) -> None: + with pytest.raises(ChatPayloadError, match="required 'label'"): + build_chat_payload( + text="hi", + channels=["C0"], + extra_buttons=[{"button_type": "interactive", "value": "x"}], + ) + # --- chat send / update commands --- @@ -212,7 +265,8 @@ def test_send_auth_error_hints_api_key( client = _mock_resolve(mock_resolve) client.send_chat_message.side_effect = APIError(status_code=403, detail="API Key Not Valid") result = runner.invoke(cli, ["chat", "send", "-c", "C0", "-m", "x"]) - assert result.exit_code == 1 + # Code-less 403 must match exit_for_api_error → EXIT_PERMISSION_DENIED. + assert result.exit_code == 4 # Rich may hard-wrap the hint; collapse whitespace before asserting. assert "dailybot config" in result.output.replace("\n", " ") assert "key=" in result.output @@ -272,7 +326,7 @@ def test_role_scope_error(self, mock_resolve: MagicMock, runner: CliRunner) -> N status_code=403, detail="Not allowed", code="cli_send_message_target_not_allowed" ) result = runner.invoke(cli, ["chat", "send", "-c", "C0", "-m", "x"]) - assert result.exit_code == 1 + assert result.exit_code == 4 assert "role can only reach" in result.output @patch("dailybot_cli.commands.chat._resolve_agent_context") @@ -283,6 +337,129 @@ def test_rate_limit_error(self, mock_resolve: MagicMock, runner: CliRunner) -> N assert result.exit_code == 1 assert "Rate limit" in result.output + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_approve_reject_callback_buttons( + self, mock_resolve: MagicMock, runner: CliRunner + ) -> None: + client = _mock_resolve(mock_resolve) + result = runner.invoke( + cli, + [ + "chat", + "send", + "-u", + "ana@co.com", + "-m", + "Deploy?", + "--approve-button", + "Yes=approve", + "--reject-button", + "No=deny", + "--callback-url", + "https://hooks.example.com/req42", + "--callback-bearer", + "secret-token", + ], + ) + assert result.exit_code == 0 + buttons = client.send_chat_message.call_args[0][0]["buttons"] + assert buttons[0]["callback_url"] == "https://hooks.example.com/req42" + assert buttons[0]["value"] == "approve" + assert buttons[0]["callback_auth"] == {"type": "bearer", "token": "secret-token"} + assert buttons[1]["value"] == "deny" + assert buttons[1]["callback_auth"]["type"] == "bearer" + + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_workflow_button(self, mock_resolve: MagicMock, runner: CliRunner) -> None: + client = _mock_resolve(mock_resolve) + wf = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + result = runner.invoke( + cli, + [ + "chat", + "send", + "-c", + "C0", + "-m", + "Ready?", + "--workflow-button", + f"Run release={wf}", + ], + ) + assert result.exit_code == 0 + button = client.send_chat_message.call_args[0][0]["buttons"][0] + assert button["label"] == "Run release" + assert button["callback_workflow"] == wf + assert button["value"] == wf + + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_buttons_json_passthrough(self, mock_resolve: MagicMock, runner: CliRunner) -> None: + client = _mock_resolve(mock_resolve) + buttons_json = ( + '[{"label":"Ask","button_type":"interactive","value":"ask",' + '"callback_prompt":"Summarize incidents","response":{"message":"On it"}}]' + ) + result = runner.invoke( + cli, ["chat", "send", "-c", "C0", "-m", "x", "--buttons", buttons_json] + ) + assert result.exit_code == 0 + button = client.send_chat_message.call_args[0][0]["buttons"][0] + assert button["callback_prompt"] == "Summarize incidents" + assert button["response"]["message"] == "On it" + + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_callback_exclusivity_fails_cli( + self, mock_resolve: MagicMock, runner: CliRunner + ) -> None: + _mock_resolve(mock_resolve) + buttons_json = ( + '[{"label":"X","button_type":"interactive","value":"x",' + '"callback_url":"https://a.example","callback_form":"form-uuid"}]' + ) + result = runner.invoke( + cli, ["chat", "send", "-c", "C0", "-m", "x", "--buttons", buttons_json] + ) + assert result.exit_code == 1 + assert "more than one callback" in result.output + + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_button_server_error_json_passthrough( + self, mock_resolve: MagicMock, runner: CliRunner + ) -> None: + client = _mock_resolve(mock_resolve) + client.send_chat_message.side_effect = APIError( + status_code=400, + detail="modal needs callback_url", + code="button_modal_body_invalid", + ) + result = runner.invoke(cli, ["chat", "send", "-c", "C0", "-m", "x", "--json"]) + assert result.exit_code == 2 + assert '"code": "button_modal_body_invalid"' in result.output + assert "modal needs callback_url" in result.output + + @patch("dailybot_cli.commands.chat._resolve_agent_context") + def test_update_with_buttons_json(self, mock_resolve: MagicMock, runner: CliRunner) -> None: + client = _mock_resolve(mock_resolve) + result = runner.invoke( + cli, + [ + "chat", + "update", + "m-123", + "-c", + "C0", + "-m", + "Updated", + "--buttons", + '[{"label":"Ok","button_type":"interactive","value":"ok",' + '"callback_command":"help"}]', + ], + ) + assert result.exit_code == 0 + sent = client.send_chat_message.call_args[0][0] + assert sent["bot_message_id"] == "m-123" + assert sent["buttons"][0]["callback_command"] == "help" + class TestChatUpdateCommand: @patch("dailybot_cli.commands.chat._resolve_agent_context") diff --git a/tests/workflow_commands_test.py b/tests/workflow_commands_test.py index 674c430..f89d078 100644 --- a/tests/workflow_commands_test.py +++ b/tests/workflow_commands_test.py @@ -1,4 +1,4 @@ -"""Tests for the workflow read commands (Task 9).""" +"""Tests for the workflow list / get / trigger commands.""" from typing import Any from unittest.mock import MagicMock @@ -29,6 +29,23 @@ def test_workflow_list_renders(monkeypatch: Any) -> None: assert "Showing" in result.output +def test_workflow_list_filter_api_trigger(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.list_workflows.return_value = [ + {"name": "Deploy", "uuid": "w-1", "trigger_type": "api_trigger", "active": True}, + { + "name": "Nightly", + "uuid": "w-2", + "trigger_type": "scheduled_task_execution", + "active": True, + }, + ] + result = CliRunner().invoke(cli, ["workflow", "list", "--filter", "api_trigger", "--json"]) + assert result.exit_code == 0 + assert "Deploy" in result.output + assert "Nightly" not in result.output + + def test_workflow_get_renders(monkeypatch: Any) -> None: client = _client(monkeypatch) client.get_workflow.return_value = {"name": "Deploy", "uuid": "w-1"} @@ -46,3 +63,85 @@ def test_workflow_list_plan_gated_403(monkeypatch: Any) -> None: result = CliRunner().invoke(cli, ["workflow", "list", "--json"]) assert result.exit_code == 4 assert "upgrade" in result.output.lower() + + +def test_workflow_trigger_happy_path(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.return_value = { + "detail": "Workflow trigger accepted.", + "workflow_uuid": "w-1", + "queued": True, + } + result = CliRunner().invoke( + cli, + ["workflow", "trigger", "w-1", "--payload", '{"env":"prod"}'], + ) + assert result.exit_code == 0 + assert "Workflow queued" in result.output + assert "w-1" in result.output + client.trigger_workflow.assert_called_once_with("w-1", payload={"env": "prod"}) + + +def test_workflow_trigger_json_mode(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.return_value = { + "queued": True, + "workflow_uuid": "w-1", + "detail": "ok", + } + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1", "--json"]) + assert result.exit_code == 0 + assert '"queued": true' in result.output + client.trigger_workflow.assert_called_once_with("w-1", payload=None) + + +def test_workflow_trigger_payload_must_be_object(monkeypatch: Any) -> None: + _client(monkeypatch) + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1", "--payload", "[1,2]"]) + assert result.exit_code == 1 + assert "JSON object" in result.output + + +def test_workflow_trigger_payload_size_guard(monkeypatch: Any) -> None: + _client(monkeypatch) + big = '{"blob":"' + ("x" * 9000) + '"}' + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1", "--payload", big]) + assert result.exit_code == 1 + assert "8192" in result.output + + +def test_workflow_trigger_not_triggerable(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.side_effect = APIError( + 400, "not triggerable", code="workflow_not_triggerable" + ) + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1"]) + assert result.exit_code == 2 + assert "api_trigger" in result.output + + +def test_workflow_trigger_execute_not_allowed(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.side_effect = APIError( + 403, "denied", code="workflow_execute_not_allowed" + ) + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1", "--json"]) + assert result.exit_code == 4 + assert '"code": "workflow_execute_not_allowed"' in result.output + + +def test_workflow_trigger_frozen(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.side_effect = APIError(409, "frozen", code="workflow_frozen") + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-1"]) + assert result.exit_code == 1 + assert "frozen" in result.output.lower() + assert "plan" in result.output.lower() + + +def test_workflow_trigger_not_found(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.trigger_workflow.side_effect = APIError(404, "missing") + result = CliRunner().invoke(cli, ["workflow", "trigger", "w-missing"]) + assert result.exit_code == 5 + assert "missing" in result.output.lower()