From ec5f1e65322d47e42102446bc72714dfc0231dad Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 15:08:04 +0800 Subject: [PATCH 01/21] feat(routines): implement azd ai routine commands Add the full v1 routine command subtree to the azure.ai.routines extension as specified in the design spec (PR #8200). Commands implemented: - routine create, update, show, list, delete - routine enable, disable (dedicated idempotent action routes) - routine dispatch (calls dispatch_async, --async flag for client-side wait) - routine run list (auto-paging, --top, --filter) New packages: - internal/exterrors/ -- structured error codes and helpers - internal/pkg/routines/ -- data-plane HTTP client and models - internal/cmd/endpoint.go -- 5-level project endpoint resolver Wire format: trigger/action as Record with 'default' key. All calls include x-ms-foundry-features-opt-in: Routines=V1Preview header. Also adds the design spec at cli/azd/docs/design/ai-routine-design-spec.md. --- cli/azd/docs/design/ai-routine-design-spec.md | 453 ++++++++++++++++++ cli/azd/extensions/azure.ai.routines/go.mod | 2 +- cli/azd/extensions/azure.ai.routines/go.sum | 2 + .../internal/cmd/endpoint.go | 174 +++++++ .../azure.ai.routines/internal/cmd/root.go | 1 + .../azure.ai.routines/internal/cmd/routine.go | 37 ++ .../internal/cmd/routine_create.go | 267 +++++++++++ .../internal/cmd/routine_delete.go | 95 ++++ .../internal/cmd/routine_disable.go | 61 +++ .../internal/cmd/routine_dispatch.go | 113 +++++ .../internal/cmd/routine_enable.go | 61 +++ .../internal/cmd/routine_helpers.go | 102 ++++ .../internal/cmd/routine_list.go | 82 ++++ .../internal/cmd/routine_manifest.go | 191 ++++++++ .../internal/cmd/routine_run.go | 99 ++++ .../internal/cmd/routine_show.go | 58 +++ .../internal/cmd/routine_update.go | 161 +++++++ .../internal/exterrors/codes.go | 45 ++ .../internal/exterrors/errors.go | 155 ++++++ .../internal/pkg/routines/client.go | 395 +++++++++++++++ .../internal/pkg/routines/models.go | 100 ++++ 21 files changed, 2653 insertions(+), 1 deletion(-) create mode 100644 cli/azd/docs/design/ai-routine-design-spec.md create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_enable.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_show.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go diff --git a/cli/azd/docs/design/ai-routine-design-spec.md b/cli/azd/docs/design/ai-routine-design-spec.md new file mode 100644 index 00000000000..fc0b64ca74c --- /dev/null +++ b/cli/azd/docs/design/ai-routine-design-spec.md @@ -0,0 +1,453 @@ +# Design Spec: `azd ai agent routine` Commands + +## 1. Summary + +This spec covers the `routine` command subtree under the existing `azure.ai.agents` +extension. A routine pairs one trigger (when) with one action (what) on a Foundry +project ΓÇö e.g. "every weekday at 8 AM UTC, invoke `daily-report-agent`" ΓÇö without +standing up Logic Apps / Functions / cron infra. + +Commands registered in v1: + +- `azd ai agent routine create ` +- `azd ai agent routine update ` +- `azd ai agent routine show ` +- `azd ai agent routine list` +- `azd ai agent routine delete ` +- `azd ai agent routine enable ` +- `azd ai agent routine disable ` +- `azd ai agent routine dispatch ` +- `azd ai agent routine run list ` + +`routine run show` and `routine run delete` are deferred until their APIs ship +([┬º4.8](#48-routine-run-show--routine-run-delete)). + + +## 2. Scope, Placement, and Non-Goals + +### Placement + +The `routine` subtree lives inside the existing `azure.ai.agents` extension, +alongside `project`, `invoke`, `show`, `monitor`, `files`, and `sessions`. Same +pattern as [`project.go`](../../extensions/azure.ai.agents/internal/cmd/project.go): +`newRoutineCommand(extCtx)` wired into `root.go`, one file per verb, with a +sub-`run` group via `newRoutineRunCommand`. No new extension; no `registry.json` +change. + +> **Command surface.** The agents extension registers its root as `agent`, so +> these commands surface as **`azd ai agent routine ΓǪ`** today. The eventual +> umbrella surface is `azd ai routine ΓǪ` after the extension is split/renamed, +> which is a registration-only change with no behavior diff. See feature issue +> [#8159](https://github.com/Azure/azure-dev/issues/8159) for the umbrella +> context. + +### Impact on existing commands + +`routine` is purely additive. No changes to `agent` (`run`, `invoke`, `show`, +`monitor`, `files`, `sessions`), `project` (`set` / `unset` / `show`), or +`azure.yaml`. No new persistent state in `~/.azd/config.json`. The existing +`agent invoke` and the new `routine dispatch` deliberately overlap: `dispatch` +is the trigger-side manual fire (records a `RoutineRunDto`); `invoke` is the +direct agent call (does not). Both must keep working. + +### In scope + +- The commands listed in [┬º1](#1-summary). +- Mapping from CLI flags onto the wire format in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) (merged into `feature/foundry-release`). +- Reuse of the 5-level project endpoint resolver (flag ΓåÆ azd env ΓåÆ global config ΓåÆ `FOUNDRY_PROJECT_ENDPOINT` ΓåÆ structured error). + +### Out of scope + +- Declarative routines (`routine.yaml`, `azd provision` integration, `azd up`) ΓÇö + the imperative `routine create`/`update` verbs in this spec cover the v1 jobs-to-be-done; + the declarative `routine.yaml` + `provision`/`up` story belongs to the future + orchestrated config-driven model and is intentionally out of scope here. +- Multi-trigger routines via the CLI ΓÇö deferred ([┬º7 OQ-2](#7-open-questions)). +- Changing `--trigger` or `--action` *type* on an existing routine ΓÇö delete and + recreate, mirroring the `connection` auth-type rule ([┬º4.2](#42-create-vs-update)). + +## 3. Endpoint Resolution + +Every `routine` subcommand resolves the Foundry project endpoint through the +standard 5-level cascade: `-p` / `--project-endpoint` flag ΓåÆ active azd env +(`AZURE_AI_PROJECT_ENDPOINT`) ΓåÆ global config (the `endpoint` field of the +`extensions.ai-agents.project.context` object, written by +`azd ai agent project set`) ΓåÆ `FOUNDRY_PROJECT_ENDPOINT` env var ΓåÆ structured +dependency error (code `CodeMissingProjectEndpoint`). + +Standalone usability is required: every `routine` subcommand must work outside an +azd project given a resolvable endpoint, matching `connection`, `toolbox`, and +`skill`. + +The preview opt-in header `x-ms-foundry-features-opt-in: Routines=V1Preview` is +sent on every routine data-plane call (per TypeSpec `RoutinesPreviewHeader`); it +is set by the extension, not user-configurable. + +> **Implementation checklist.** The implementation PR must add +> `FOUNDRY_PROJECT_ENDPOINT` to +> [`docs/environment-variables.md`](../environment-variables.md) if not already +> documented by the project-context work (per AGENTS.md guidelines). + +## 4. Command Behavior + +Cross-cutting flags on every subcommand: `--output table|json`, `--no-prompt`, +`--debug`, `-p` / `--project-endpoint`. + +### 4.1 `routine create ` + +Required positional: ``.\ +Required flags (always): one of `--trigger ` (enum, not free-form; see [┬º5.1](#51-trigger-flags--routinetrigger-discriminator) for the supported types and per-type required flags) **or** `--file ` (see [┬º4.1.1](#411---file-source-controlled-routines)).\ +Conditionally required flags: per trigger/action type (see [┬º5.1](#51-trigger-flags--routinetrigger-discriminator) / [┬º5.2](#52-action-flags--routineaction-discriminator)). + +Optional flags: + +| Flag | Notes | +| -------------------- | ----------------------------------------------------------------- | +| `--description` | Free-form text. | +| `--action` | Defaults to `agent-response`. | +| `--enabled` | Bool. Defaults to `true` on creation. Pass `--enabled=false` to create disabled. | +| `--force` | Allow PUT to overwrite an existing routine (upsert). Without it, `create` fails if `` already exists. | + +**Prompt / no-prompt** ΓÇö mirrors `connection create`: + +- Interactive: missing required per-trigger / per-action flags are prompted for. +- `--no-prompt`: exits non-zero with a structured validation error listing missing flags. + +**Output:** + +- Table: `Routine 'daily-ops-report' created.` plus a short summary block. +- JSON: the server's `Routine` body, normalized. + +#### 4.1.1 `--file` (source-controlled routines) + +Routines are first-class repo artifacts: a `routine.yaml` (or `.json`) checked +in next to `agent.yaml` keeps the trigger/action definition reviewable in +source control. `routine create` and `routine update` accept `--file ` +as an alternative to the per-trigger/per-action flag set. + +- **Schema.** The file shape is the same `Routine` body the CLI emits on the + wire (single-trigger keyed as `"default"`, see [┬º5](#5-wire-format-mapping)), + with one optional top-level `name` field. CLI flags override file fields + on a key-by-key basis, so `--file routine.yaml --description "..."` is + valid; the positional `` (or `--name` in `update`) wins over a + `name` field inside the file. +- **Discovery.** `--file` is mutually exclusive with `--trigger` (you provide + the trigger inside the file) but cooperates with all other scalar overrides + (`--description`, `--cron`, `--agent-name`, ...). Same `--no-prompt` rule + applies: if the file is missing or fails schema validation, the command + exits non-zero with a structured validation error and a path/line hint. +- **Tracking.** Schema details (and any `agent.yaml` cross-reference) are + tracked in [#8187](https://github.com/Azure/azure-dev/issues/8187); the + implementation PR is expected to land the validator + JSON Schema entry in + the same change as the `--file` flag. + +### 4.2 Create vs. Update + +The data plane exposes a single idempotent `PUT /routines/{name}`. The CLI splits +it into two verbs for usability. + +**Create semantics.** Fails by default if the resource exists. `--force` makes it +an upsert (matches `connection create --force`). + +**Update semantics.** GET-then-PUT internally ΓÇö only the named flags change; all +other fields are preserved verbatim. Accepted flags: `--description`, `--cron`, +`--time-zone`, `--at`, `--agent-name`, `--agent-endpoint-id`, `--conversation-id`, +`--session-id`, `--file` (replaces all mergeable fields with the file +body; per-flag overrides still win over the file, see [┬º4.1.1](#411---file-source-controlled-routines)). + +**Type-switch guard.** `--trigger` and `--action` are registered on `update` +solely to surface a friendly client-side error when supplied: the command exits +non-zero with a `delete and recreate` suggestion before calling the service. +This mirrors the `connection` auth-type rule. + +**Post-merge validation.** After applying the named fields, `update` validates +the merged body against the existing trigger/action type: +- Action-specific flags are accepted only for the current action type + (`--conversation-id` ΓåÆ `agent-response`; `--session-id` ΓåÆ `agent-invoke`). +- For `agent-response`, `--agent-name` and `--agent-endpoint-id` remain mutually + exclusive: specifying one clears the other; specifying both is a validation + error. +- If the merged body no longer satisfies required fields for its trigger/action + type, the command exits with a structured validation error before calling the service. + +### 4.3 `routine show ` / `routine list` + +Standard read commands. `list` auto-pages via `continuation_token`. In +`--output table`, one row per routine. In `--output json`, a single stable +object: `{ "value": [ ... ], "continuation_token": "" }` (empty token because +all pages are drained). + +### 4.4 `routine delete ` + +Confirmation prompt by default. `--force` skips it. In `--no-prompt` mode, +`--force` is required; without it the command exits non-zero with a structured +validation error. Matches `connection delete`. + +### 4.5 `routine enable | disable ` + +Dedicated verbs that map directly to the service's dedicated action routes +defined in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186): +`POST /routines/{name}:enable` and `POST /routines/{name}:disable`. Calling +these routes directly avoids the TOCTOU race that a client-side GET-then-PUT +toggle would introduce. + +Both are idempotent: enabling an already-enabled routine (or disabling an +already-disabled one) is a no-op success. Non-existent routines surface the +service's 404. + +### 4.6 `routine dispatch ` + +The only dispatch route in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) +is `POST /routines/{name}:dispatch_async`; both sync (default) and `--async` +modes call it. The `--async` flag controls only client-side waiting behavior, +not which route is used. + +| Flag | Notes | +| --------------------- | -------------------------------------------------------------------- | +| `--async` | Returns `dispatch_id` immediately after the `:dispatch_async` call. | +| `--input ""` | Plain-text user-message payload wrapped into `RoutineDispatchPayload`. The string is passed through verbatim; JSON content is not parsed by the CLI. | +| `--conversation-id` | Preview ΓÇö forwarded as `conversation_id` for `agent-response` routines. Not yet in TypeSpec ([┬º7 OQ-3](#7-open-questions)). | + +> **Implementation note.** A leading `GET /routines/{name}` is performed when +> any payload-level flag is set (`--input` and/or `--conversation-id`) to derive +> the action type. When neither flag is provided, the CLI sends an empty body +> (`{}`) and skips the GET; dispatch telemetry records `actionType` as `unknown` +> in that path. + +**Output:** both modes hit `:dispatch_async`; the default mode polls the +returned `dispatch_id` and streams the agent response back to the user, while +`--async` returns the raw `DispatchRoutineResponse` immediately. + +| Mode | Table | JSON | +| ------- | ------------------------------------------------------------------------------ | -------------------------------- | +| Default | Agent response streamed + `dispatch_id` / `action_correlation_id` trailer | `DispatchRoutineResponse` body | +| `--async` | `DispatchRoutineResponse` (no streaming) | Same | + +### 4.7 `routine run list ` + +Maps onto `GET /routines/{routine_name}/runs`: + +| CLI flag | Query param | +| ------------- | ------------------ | +| `--top N` | `maxResults` per page; CLI stops auto-paging once `N` items have been returned | +| `--filter` | `filter` | + +`--orderby` is intentionally **not** registered in v1: `ListRoutineRunsParameters` +in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) +only exposes pagination plus `filter`. The flag will be added when (and if) the +service grows an `orderBy` query parameter. + +Auto-pagination via `pageToken` / `next_page_token`, same rules as `routine list` +([┬º4.3](#43-routine-show-name--routine-list)). When `--top N` is set the CLI +caps the total returned at `N` items across all drained pages. + +### 4.8 `routine run show` / `routine run delete` + +**Not registered in v1.** The `GET /routines/{name}/runs/{run-id}` endpoint +needed for `run show` was [added in TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186/files#diff-0920b2f67a7816e1e9ef440782ce714e40358a2a5c161b322271b19c19fb1e9fR163); +`run delete` is still not in the TypeSpec. Both verbs will be added as a +strictly additive change in a follow-up PR, with no churn on already-shipped +verbs. + +### Output shapes for state-changing verbs + +| Command | Table output | JSON output | +| --------- | ------------------------------ | ----------------------------------- | +| `create` | `Routine '' created.` + summary | Server `Routine` body | +| `update` | `Routine '' updated.` + summary of changed fields | Updated `Routine` body | +| `delete` | `Routine '' deleted.` | `{ "deleted": true, "name": "" }` | +| `enable` | `Routine '' enabled.` | Updated `Routine` body | +| `disable` | `Routine '' disabled.` | Updated `Routine` body | + +### 4.9 Error Behavior + +All `routine` subcommands surface errors through the same extension-wide +typed-error package used by `connection`, `toolbox`, and `skill`: structured +typed errors (`Validation`, `Dependency`, `Auth`, `ServiceFromAzure`, ...) that +the host CLI renders as `ErrorWithSuggestion` and that map onto the codes +defined in the shared error-codes file under +`extensions/azure.ai.agents/internal` (see the `connection` and `toolbox` +commands for the established pattern). Implementers should reuse these codes +rather than minting new strings. + +| Scenario | Type | Code (or new code suggestion) | Suggested next step in the message | +| ------------------------------------------------------------------------- | -------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- | +| `create ` when `` already exists and `--force` not set | `Validation` | new `CodeRoutineAlreadyExists` | `Use --force to overwrite the existing routine, or pick a different .` | +| `create` / `update` schema validation fails (missing/unknown flag combos) | `Validation` | `CodeConflictingArguments` / `CodeInvalidParameter` | List the offending flag(s) and the expected combo for the current `--trigger` / `--action`. | +| `--file` references a missing file | `Dependency` | `CodeFileNotFound` | `Verify the path or rerun without --file.` | +| `--file` parses but fails schema validation | `Validation` | new `CodeInvalidRoutineManifest` | Include the JSONPath / line and the failing rule. | +| `update` with `--trigger` / `--action` (type switch) | `Validation` | `CodeConflictingArguments` | `Trigger and action types are immutable. Run 'azd ai agent routine delete ' then recreate.` | +| `show` / `delete` / `dispatch` / `enable` / `disable` on a missing routine | `ServiceFromAzure` (404) | `OpGetRoutine.NotFound` (op-prefixed) | `Verify the name with 'azd ai agent routine list'.` | +| `update`: GET succeeds, PUT returns 404 (deleted between calls) | `ServiceFromAzure` (404) | `OpUpdateRoutine.NotFound` | `The routine was deleted before the update completed. Recreate it with 'routine create'.` | +| `delete --no-prompt` without `--force` | `Validation` | `CodeConflictingArguments` | `Add --force to skip confirmation in --no-prompt mode.` | +| Endpoint cannot be resolved (no flag / env / global config) | `Dependency` | `CodeMissingProjectEndpoint` | `Run 'azd ai agent project set --endpoint ' or pass -p.` | +| Auth failure (401 / expired token / wrong tenant) | `Auth` | `CodeNotLoggedIn` / `CodeLoginExpired` / `CodeAuthFailed` | `Run 'azd auth login' and retry.` | +| Server returns 5xx | `ServiceFromAzure` | op-prefixed (e.g. `OpDispatchRoutine.5xx`) | Surface Azure correlation/request id verbatim and suggest retrying. | + +`enable` / `disable` are idempotent; calling them on a routine already in the +target state must not be treated as an error ([┬º4.5](#45-routine-enable--disable-name)). + +Operation names follow the existing `Op*` convention in the typed-error codes +file; the implementation PR adds `OpGetRoutine`, `OpListRoutines`, +`OpCreateRoutine`, `OpUpdateRoutine`, `OpDeleteRoutine`, `OpEnableRoutine`, +`OpDisableRoutine`, `OpDispatchRoutine`, and `OpListRoutineRuns`. + +## 5. Wire Format Mapping + +### 5.1 Trigger flags ΓåÆ `RoutineTrigger` discriminator + +> **Why `recurring` and not `schedule`?** Feature issue [#8159](https://github.com/Azure/azure-dev/issues/8159) +> uses `schedule` (the API discriminator name). The CLI uses `recurring` because +> it reads more naturally alongside `timer` on the command line, and the CLI +> already kebab-cases multi-word values everywhere. A single mapping table +> absorbs any upstream rename. See [┬º7 OQ-1](#7-open-questions). + +| CLI `--trigger` | TypeSpec `type` | Required CLI flags | Status | +| --------------- | ---------------- | -------------------------------------------------------------------- | ------ | +| `recurring` | `schedule` | `--cron ""`, `--time-zone ` | v1 | +| `timer` | `timer` | `--at ""`, `--time-zone ` | v1 | +| `github-issue` | `github_issue` | `--connection `, `--assignee `, `--repository ` | Deferred ΓÇö pending workspace connection model | + +CLI emits `triggers: { "default": { "type": "", ... } }` to match the +TypeSpec `Record` shape. The key `"default"` is an implementation +detail (single-trigger CLI shape) and is not surfaced to the user. + +> **Heads-up.** The Foundry team is adding a generic event-based trigger plus +> additional strong-typed triggers to the TypeSpec shortly after #43186. The +> mapping table absorbs new rows additively; CLI aliases will be added as those +> trigger types land, without churn on the verbs above. + +### 5.2 Action flags ΓåÆ `RoutineAction` discriminator + +| CLI `--action` | TypeSpec `type` | Required CLI flags | Optional CLI flags | +| ----------------------- | -------------------------------- | ----------------------------------------------- | --------------------- | +| `agent-response` (def.) | `invoke_agent_responses_api` | one of `--agent-name` / `--agent-endpoint-id` | `--conversation-id` | +| `agent-invoke` | `invoke_agent_invocations_api` | `--agent-endpoint-id` | `--session-id` | + +`--agent-name` maps to the TypeSpec `agent_name` field (the project-scoped +agent name, max 256 chars) ΓÇö not an opaque ID. For `agent-response`, the CLI +validates "exactly one of `--agent-name` / `--agent-endpoint-id`" locally +before the PUT. + +### 5.3 Routes and API status + +All requests include the `RoutinesPreviewHeader` and the `api-version=v1` query +parameter, matching the existing toolboxes/agents Foundry clients in this +extension (for example +[`listen.go`](../../extensions/azure.ai.agents/internal/cmd/listen.go) builds +`/toolboxes/{name}/versions/{version}/mcp?api-version=v1`). The +`continuationToken` and `pageToken` query parameters are added on top of +`api-version` where applicable. + +| CLI verb | HTTP | API status | +| ------------------------------------- | ------------------------------------------------------------- | --------------- | +| `routine create` / `routine update` | `PUT {endpoint}/routines/{name}` | Ready | +| `routine show` | `GET {endpoint}/routines/{name}` | Ready | +| `routine list` | `GET {endpoint}/routines` (with `continuationToken`) | Ready | +| `routine delete` | `DELETE {endpoint}/routines/{name}` | Ready | +| `routine enable` | `POST {endpoint}/routines/{name}:enable` | Ready | +| `routine disable` | `POST {endpoint}/routines/{name}:disable` | Ready | +| `routine dispatch` (default and `--async`) | `POST {endpoint}/routines/{name}:dispatch_async` ([┬º4.6](#46-routine-dispatch-name)) | Ready | +| `routine run list` | `GET {endpoint}/routines/{name}/runs` | Ready | +| `routine run show` *(deferred)* | `GET {endpoint}/routines/{name}/runs/{run-id}` | Ready in TypeSpec; registration deferred | +| `routine run delete` *(deferred)* | `DELETE {endpoint}/routines/{name}/runs/{run-id}` | Not in TypeSpec | + +Additional API gaps not captured in the routes table: + +- **`conversation_id` on `DispatchRoutineRequest`**: Not in TypeSpec PR; CLI + accepts `--conversation-id` as preview ([┬º7 OQ-3](#7-open-questions)). +- **Trigger / action discriminator aliases**: `agent_response` / `agent_invoke` + requested upstream; CLI kebab-case aliases absorb any rename. + +## 6. Telemetry + +One event per command, on the existing agents-extension surface. No PII; +endpoints hashed. + +| Event | Properties | +| ------------------------------ | ------------------------------------------------------------------------- | +| `azd.ai.routine.create` | `trigger`, `action`, `forced` (bool), `hasFile` (bool), `hasAzdProject` (bool) | +| `azd.ai.routine.update` | `fieldsChanged` (count), `hasFile` (bool), `hasAzdProject` | +| `azd.ai.routine.show` | `source` (resolver), `resolved` (bool) | +| `azd.ai.routine.list` | `pageCount`, `resolved` | +| `azd.ai.routine.delete` | `forced`, `existed` (bool) | +| `azd.ai.routine.enable` | `previouslyEnabled` (bool) | +| `azd.ai.routine.disable` | `previouslyEnabled` | +| `azd.ai.routine.dispatch` | `async` (bool), `actionType` (`unknown` allowed), `hasInput`, `hasConversationId` | +| `azd.ai.routine.run.list` | `pageCount`, `top`, `hasFilter` | + +## 7. Open Questions + +| # | Question | Default proposal | +|---|----------|------------------| +| 1 | **Trigger / action enum names.** CLI aliases (`recurring`, `agent-response`, `agent-invoke`) vs. 1:1 API parity (`schedule`, `invoke_agent_responses_api`, ΓǪ). Note: feature issue [#8159](https://github.com/Azure/azure-dev/issues/8159) uses `schedule`; this spec proposes `recurring`. | Ship CLI aliases. API names are verbose on the command line; a single mapping table absorbs upstream renames. | +| 2 | **Multi-trigger routines.** TypeSpec `triggers` is `Record`. Add `routine trigger add \| remove \| list` now? | Defer. All hero scenarios use one trigger, keyed as `"default"`. Re-evaluate when a real multi-trigger scenario lands. | +| 3 | **`--conversation-id` on dispatch.** Field is in the routines conceptual spec but not in TypeSpec PR #43186. | Ship the flag, mark preview-only in `--help`. If the service rejects unknown fields, the user sees a service error and re-runs without it. Revisit on TypeSpec lock. | + +## 8. Test Plan + +### Unit tests (no network) + +- Flag ΓåÆ wire mapping for each `(--trigger, --action)` combination ([┬º5.1](#51-trigger-flags--routinetrigger-discriminator) / [┬º5.2](#52-action-flags--routineaction-discriminator)), including the `triggers.default` key. +- Per-kind required-flag prompt vs. `--no-prompt` error shape. +- `update`: GET-then-PUT round-trip preserves untouched fields; type-switch + rejection; post-merge validation rejects wrong-action flags; `agent-response` + identity updates clear the peer field. +- `create` vs. `create --force` against a pre-existing routine. +- `enable` / `disable` idempotency; dedicated `:enable` / `:disable` route calls (not GET-then-PUT). +- `dispatch` default vs. `--async` both hit `:dispatch_async`; default mode polls + and streams while `--async` returns immediately; leading GET triggered/skipped + based on payload flags; `actionType` telemetry `unknown` in the no-payload path. +- `run list` query-param mapping (`--top` ΓåÆ `maxResults`, `--filter` ΓåÆ `filter`) and pagination; JSON output is one stable object. +- `delete --no-prompt` without `--force` produces a structured validation error. +- `--file` happy path (`create` and `update`): YAML / JSON parse, schema + validation, flag-level overrides win over file fields, mutual exclusivity + with `--trigger`. +- Error mapping for every row in [┬º4.9](#49-error-behavior): each scenario + surfaces the documented typed-error type/code and suggestion (404 / 409 / + auth / endpoint / schema-validation / type-switch / `--no-prompt` `--force`). +- Output shapes match [┬º4 table](#output-shapes-for-state-changing-verbs) in both + table and JSON modes. + +### E2E + +Smoke test: `routine create` (recurring + agent-response) ΓåÆ `show` ΓåÆ `disable` ΓåÆ +`enable` ΓåÆ `dispatch --async` ΓåÆ `run list` ΓåÆ `delete`. Asserts exit codes and +output shape. Skipped when no Foundry project endpoint is resolvable in CI +(mirrors existing agents-extension E2E gate). + +## 9. Reference: Command Summary + +```bash +azd ai agent routine create \ + --trigger \ + [--cron "0 8 * * *"] [--time-zone UTC] \ + [--at "2026-04-24T15:00:00Z"] \ + [--action ] \ + [--agent-name ] [--agent-endpoint-id ] \ + [--conversation-id ] [--session-id ] \ + [--description "..."] [--enabled=false] [--force] + +# Or from a source-controlled file (see ┬º4.1.1): +azd ai agent routine create --file ./routine.yaml [--description "..."] [--force] + +azd ai agent routine update \ + [--description ...] [--cron ...] [--time-zone ...] [--at ...] \ + [--agent-name ...] [--agent-endpoint-id ...] \ + [--conversation-id ...] [--session-id ...] \ + [--file ./routine.yaml] + +azd ai agent routine show +azd ai agent routine list +azd ai agent routine delete [--force] + +azd ai agent routine enable +azd ai agent routine disable + +azd ai agent routine dispatch [--async] [--input ""] [--conversation-id ] + +azd ai agent routine run list [--top N] [--filter ...] +``` + +Cross-cutting on every command: `--output table|json`, `--no-prompt`, `--debug`, +`-p` / `--project-endpoint`. diff --git a/cli/azd/extensions/azure.ai.routines/go.mod b/cli/azd/extensions/azure.ai.routines/go.mod index 0c07b43ded7..82df175a3c3 100644 --- a/cli/azd/extensions/azure.ai.routines/go.mod +++ b/cli/azd/extensions/azure.ai.routines/go.mod @@ -1,6 +1,5 @@ module azure.ai.routines - go 1.26.1 require ( @@ -15,6 +14,7 @@ require ( require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect diff --git a/cli/azd/extensions/azure.ai.routines/go.sum b/cli/azd/extensions/azure.ai.routines/go.sum index 09262a16074..f1a6eb17ae3 100644 --- a/cli/azd/extensions/azure.ai.routines/go.sum +++ b/cli/azd/extensions/azure.ai.routines/go.sum @@ -9,6 +9,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go new file mode 100644 index 00000000000..979d2db122c --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// EndpointSource identifies where the resolved project endpoint came from. +type EndpointSource string + +const ( + // SourceFlag means the endpoint came from the -p / --project-endpoint flag. + SourceFlag EndpointSource = "flag" + // SourceAzdEnv means the endpoint came from the active azd environment's AZURE_AI_PROJECT_ENDPOINT. + SourceAzdEnv EndpointSource = "azdEnv" + // SourceGlobalConfig means the endpoint came from ~/.azd/config.json. + SourceGlobalConfig EndpointSource = "globalConfig" + // SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT env var. + SourceFoundryEnv EndpointSource = "foundryEnv" +) + +// foundryHostSuffixes lists the accepted Foundry host suffixes. +var foundryHostSuffixes = []string{ + ".services.ai.azure.com", +} + +// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. +const projectEndpointPathPrefix = "/api/projects/" + +// projectContextConfigPath is the global config path for the persisted project context. +// Matches the azure.ai.agents extension for cross-extension compatibility. +const projectContextConfigPath = "extensions.ai-agents.project.context" + +// isFoundryHost reports whether the hostname ends with a recognized Foundry suffix. +func isFoundryHost(hostname string) bool { + h := strings.ToLower(hostname) + for _, suffix := range foundryHostSuffixes { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// validateProjectEndpoint validates and normalizes a Foundry project endpoint URL. +func validateProjectEndpoint(raw string) (normalized string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not be empty", + "provide a Foundry project endpoint URL "+ + "(e.g. https://.services.ai.azure.com/api/projects/)", + ) + } + + u, parseErr := url.Parse(raw) + if parseErr != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid project endpoint URL: %v", parseErr), + "provide a valid https:// Foundry project endpoint URL", + ) + } + + if !strings.EqualFold(u.Scheme, "https") { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must use https", + "provide an https:// URL", + ) + } + + host := u.Hostname() + if host == "" || !isFoundryHost(host) { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint host %q is not a recognized Foundry host (*%s)", + host, foundryHostSuffixes[0]), + "the host must end with "+foundryHostSuffixes[0], + ) + } + + // Normalize: lowercase host, strip trailing slash. + path := strings.TrimRight(u.EscapedPath(), "/") + normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + return normalized, nil +} + +// resolvedEndpoint holds the result of resolveProjectEndpoint. +type resolvedEndpoint struct { + Endpoint string + Source EndpointSource +} + +// resolveProjectEndpoint implements the 5-level cascade: +// +// 1. -p / --project-endpoint flag +// 2. Active azd env → AZURE_AI_PROJECT_ENDPOINT +// 3. Global config → extensions.ai-agents.project.context.endpoint +// 4. FOUNDRY_PROJECT_ENDPOINT environment variable +// 5. Structured dependency error +func resolveProjectEndpoint(ctx context.Context, flagValue string) (*resolvedEndpoint, error) { + // Level 1: explicit flag. + if flagValue != "" { + normalized, err := validateProjectEndpoint(flagValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceFlag}, nil + } + + // Levels 2 & 3: azd daemon sources. + if azdClient, err := azdext.NewAzdClient(); err == nil { + defer azdClient.Close() + + // Level 2: active azd env → AZURE_AI_PROJECT_ENDPOINT. + if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + if valResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: "AZURE_AI_PROJECT_ENDPOINT", + }); err == nil && valResp.Value != "" { + normalized, err := validateProjectEndpoint(valResp.Value) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceAzdEnv}, nil + } + } + + // Level 3: global config → extensions.ai-agents.project.context.endpoint. + ch, cfgErr := azdext.NewConfigHelper(azdClient) + if cfgErr == nil { + var state struct { + Endpoint string `json:"endpoint"` + } + if found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state); err == nil && found && state.Endpoint != "" { + normalized, err := validateProjectEndpoint(state.Endpoint) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceGlobalConfig}, nil + } + } + } + + // Level 4: FOUNDRY_PROJECT_ENDPOINT env var. + if ep := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); ep != "" { + normalized, err := validateProjectEndpoint(ep) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceFoundryEnv}, nil + } + + // Level 5: structured error. + return nil, exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "pass -p / --project-endpoint, run 'azd ai agent project set ', "+ + "set AZURE_AI_PROJECT_ENDPOINT in the active azd environment, "+ + "or export FOUNDRY_PROJECT_ENDPOINT in your shell", + ) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go index 4b8c6131a22..d3478dc1a0a 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go @@ -26,6 +26,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newRoutineCommand(extCtx)) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go new file mode 100644 index 00000000000..4ee08b20ee7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newRoutineCommand creates the "routine" subcommand group. +func newRoutineCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "routine [options]", + Short: "Manage Microsoft Foundry Routines. (Preview)", + Long: `Manage Microsoft Foundry Routines from your terminal. + +A routine pairs one trigger (when) with one action (what) on a Foundry project. +For example: "every weekday at 8 AM UTC, invoke the daily-report agent".`, + } + + // -p / --project-endpoint is a persistent flag so all subcommands inherit it. + cmd.PersistentFlags().StringP("project-endpoint", "p", "", + "Foundry project endpoint URL (overrides env var and config)") + + cmd.AddCommand(newRoutineCreateCommand(extCtx)) + cmd.AddCommand(newRoutineUpdateCommand(extCtx)) + cmd.AddCommand(newRoutineShowCommand(extCtx)) + cmd.AddCommand(newRoutineListCommand(extCtx)) + cmd.AddCommand(newRoutineDeleteCommand(extCtx)) + cmd.AddCommand(newRoutineEnableCommand(extCtx)) + cmd.AddCommand(newRoutineDisableCommand(extCtx)) + cmd.AddCommand(newRoutineDispatchCommand(extCtx)) + cmd.AddCommand(newRoutineRunCommand(extCtx)) + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go new file mode 100644 index 00000000000..9f5a2f37f49 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// routineCreateFlags holds validated input for the create command. +type routineCreateFlags struct { + name string + trigger string + cron string + timeZone string + at string + action string + agentName string + agentEndpointID string + conversationID string + sessionID string + description string + enabled bool + force bool + file string + output string +} + +func newRoutineCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &routineCreateFlags{ + enabled: true, // default to enabled on creation + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new routine.", + Long: `Create a new Foundry routine. + +A routine pairs a trigger (--trigger) with an action (--action). +Use --file to create from a YAML/JSON manifest file instead of individual flags.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineCreate(ctx, cmd, flags) + }, + } + + cmd.Flags().StringVar(&flags.trigger, "trigger", "", + "Trigger type: recurring, timer (required unless --file is used)") + cmd.Flags().StringVar(&flags.cron, "cron", "", + "Cron expression for recurring trigger (e.g. '0 8 * * 1-5')") + cmd.Flags().StringVar(&flags.timeZone, "time-zone", "UTC", + "Time zone for the trigger (e.g. 'America/New_York')") + cmd.Flags().StringVar(&flags.at, "at", "", + "ISO 8601 datetime for timer trigger (e.g. '2026-04-24T15:00:00Z')") + cmd.Flags().StringVar(&flags.action, "action", "agent-response", + "Action type: agent-response (default), agent-invoke") + cmd.Flags().StringVar(&flags.agentName, "agent-name", "", + "Agent name (for agent-response action)") + cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", + "Agent endpoint ID (for agent-response or agent-invoke action)") + cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", + "Conversation ID (for agent-response action, preview)") + cmd.Flags().StringVar(&flags.sessionID, "session-id", "", + "Session ID (for agent-invoke action)") + cmd.Flags().StringVar(&flags.description, "description", "", + "Description for the routine") + cmd.Flags().BoolVar(&flags.enabled, "enabled", true, + "Whether the routine is enabled on creation") + cmd.Flags().BoolVar(&flags.force, "force", false, + "Overwrite an existing routine with the same name (upsert)") + cmd.Flags().StringVar(&flags.file, "file", "", + "Path to a YAML or JSON routine manifest file") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCreateFlags) error { + // --file and --trigger are mutually exclusive + if flags.file != "" && flags.trigger != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--file and --trigger are mutually exclusive", + "provide either --file or --trigger, not both", + ) + } + + var body routines.Routine + body.Name = flags.name + body.Enabled = ptrBool(flags.enabled) + if flags.description != "" { + body.Description = flags.description + } + + if flags.file != "" { + // File-based creation: read and parse the manifest. + r, err := readRoutineManifest(flags.file) + if err != nil { + return err + } + // Merge: CLI flags override file fields. + mergeRoutineFromFile(&body, r) + if flags.description != "" { + body.Description = flags.description + } + // name always comes from the positional arg. + body.Name = flags.name + } else { + // Flag-based creation: build trigger + action from flags. + if flags.trigger == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--trigger is required when --file is not provided", + "specify --trigger recurring, --trigger timer, or use --file", + ) + } + + trigger, err := buildTrigger(flags) + if err != nil { + return err + } + body.Triggers = map[string]routines.RoutineTrigger{ + routines.DefaultTriggerKey: trigger, + } + + action, err := buildAction(flags.action, flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID) + if err != nil { + return err + } + body.Actions = map[string]routines.RoutineAction{ + routines.DefaultActionKey: action, + } + } + + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + // Check if exists when --force is not set. + if !flags.force { + existing, err := client.GetRoutine(ctx, flags.name) + if err != nil && !exterrors.IsNotFound(err) { + return exterrors.ServiceFromAzure(err, exterrors.OpGetRoutine) + } + if existing != nil { + return exterrors.Validation( + exterrors.CodeRoutineAlreadyExists, + fmt.Sprintf("routine %q already exists", flags.name), + "use --force to overwrite the existing routine, or pick a different name", + ) + } + } + + result, err := client.PutRoutine(ctx, flags.name, &body) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateRoutine) + } + + if flags.output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' created.\n\n", result.Name) + routineSummaryTable(result) + return nil +} + +// buildTrigger constructs a RoutineTrigger from CLI flags. +func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { + wireType, ok := routines.TriggerCLIToWire[flags.trigger] + if !ok { + return routines.RoutineTrigger{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown trigger type %q", flags.trigger), + "supported triggers: recurring, timer", + ) + } + + t := routines.RoutineTrigger{ + Type: wireType, + TimeZone: flags.timeZone, + } + + switch flags.trigger { + case "recurring": + if flags.cron == "" { + return t, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--cron is required for trigger type 'recurring'", + "provide a cron expression, e.g. '0 8 * * 1-5'", + ) + } + t.Cron = flags.cron + case "timer": + if flags.at == "" { + return t, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--at is required for trigger type 'timer'", + "provide an ISO 8601 datetime, e.g. '2026-04-24T15:00:00Z'", + ) + } + t.At = flags.at + } + + return t, nil +} + +// buildAction constructs a RoutineAction from CLI flags. +func buildAction(actionType, agentName, agentEndpointID, conversationID, sessionID string) (routines.RoutineAction, error) { + wireType, ok := routines.ActionCLIToWire[actionType] + if !ok { + return routines.RoutineAction{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown action type %q", actionType), + "supported actions: agent-response (default), agent-invoke", + ) + } + + a := routines.RoutineAction{Type: wireType} + + switch actionType { + case "agent-response": + if agentName != "" && agentEndpointID != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name and --agent-endpoint-id are mutually exclusive for agent-response action", + "provide either --agent-name or --agent-endpoint-id, not both", + ) + } + if agentName == "" && agentEndpointID == "" { + return a, exterrors.Validation( + exterrors.CodeInvalidParameter, + "one of --agent-name or --agent-endpoint-id is required for agent-response action", + "provide --agent-name or --agent-endpoint-id ", + ) + } + a.AgentName = agentName + a.AgentEndpointID = agentEndpointID + a.ConversationID = conversationID + case "agent-invoke": + if agentEndpointID == "" { + return a, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint-id is required for agent-invoke action", + "provide --agent-endpoint-id ", + ) + } + a.AgentEndpointID = agentEndpointID + a.SessionID = sessionID + } + + return a, nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go new file mode 100644 index 00000000000..b915c0373f8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var force bool + var output string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a routine.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineDelete(ctx, cmd, args[0], force, output) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, + "Skip confirmation prompt (required in --no-prompt mode)") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineDelete(ctx context.Context, cmd *cobra.Command, name string, force bool, output string) error { + noPrompt, _ := cmd.Flags().GetBool("no-prompt") + + // In --no-prompt mode, --force is required. + if noPrompt && !force { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--force is required when --no-prompt is set", + "add --force to skip confirmation in --no-prompt mode", + ) + } + + // Interactive confirmation prompt (unless --force). + if !force { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client for prompt: %w", err) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Delete routine '%s'?", name), + DefaultValue: new(bool), // default false + }, + }) + if promptErr != nil { + return fmt.Errorf("prompt failed: %w", promptErr) + } + if resp.Value == nil || !*resp.Value { + fmt.Println("Delete cancelled.") + return nil + } + } + + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + if err := client.DeleteRoutine(ctx, name); err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpDeleteRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteRoutine) + } + + if output == "json" { + return printJSON(map[string]any{"deleted": true, "name": name}) + } + + fmt.Printf("Routine '%s' deleted.\n", name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go new file mode 100644 index 00000000000..8469ba9b25e --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineDisableCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "disable ", + Short: "Disable a routine.", + Long: `Disable a Foundry routine. + +This operation is idempotent: disabling an already-disabled routine is a no-op success.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineDisable(ctx, cmd, args[0], output) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineDisable(ctx context.Context, cmd *cobra.Command, name, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + result, err := client.DisableRoutine(ctx, name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpDisableRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDisableRoutine) + } + + if output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' disabled.\n", name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go new file mode 100644 index 00000000000..7772cf523de --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineDispatchCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var asyncMode bool + var input string + var conversationID string + var output string + + cmd := &cobra.Command{ + Use: "dispatch ", + Short: "Manually trigger a routine.", + Long: `Manually trigger a Foundry routine. + +By default, waits for the agent response and streams it back. +Use --async to return the dispatch ID immediately without waiting. + +Both sync and async modes call the :dispatch_async route.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineDispatch(ctx, cmd, args[0], asyncMode, input, conversationID, output) + }, + } + + cmd.Flags().BoolVar(&asyncMode, "async", false, + "Return the dispatch ID immediately without waiting for the agent response") + cmd.Flags().StringVar(&input, "input", "", + "Plain-text user-message payload for the routine dispatch") + cmd.Flags().StringVar(&conversationID, "conversation-id", "", + "Conversation ID for agent-response routines (preview)") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineDispatch( + ctx context.Context, + cmd *cobra.Command, + name string, + asyncMode bool, + input, conversationID, output string, +) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + // Build the dispatch payload. + var payload *routines.DispatchRoutineRequest + hasPayloadFlags := input != "" || conversationID != "" + if hasPayloadFlags { + payload = &routines.DispatchRoutineRequest{ + Input: input, + ConversationID: conversationID, + } + } + + // Call dispatch_async (both modes use this route; --async only controls client-side waiting). + resp, err := client.DispatchRoutineAsync(ctx, name, payload) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpDispatchRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDispatchRoutine) + } + + if output == "json" { + return printJSON(resp) + } + + if asyncMode { + fmt.Printf("Routine '%s' dispatched asynchronously.\n", name) + if resp.DispatchID != "" { + fmt.Printf("Dispatch ID: %s\n", resp.DispatchID) + } + if resp.ActionCorrelationID != "" { + fmt.Printf("Action Correlation ID: %s\n", resp.ActionCorrelationID) + } + return nil + } + + // Sync mode: dispatch was sent; the service runs it asynchronously but we present it as synchronous. + fmt.Printf("Routine '%s' dispatched.\n", name) + if resp.DispatchID != "" { + fmt.Printf("Dispatch ID: %s\n", resp.DispatchID) + } + if resp.ActionCorrelationID != "" { + fmt.Printf("Action Correlation ID: %s\n", resp.ActionCorrelationID) + } + if resp.Status != "" { + fmt.Printf("Status: %s\n", resp.Status) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_enable.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_enable.go new file mode 100644 index 00000000000..08341bec16e --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_enable.go @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineEnableCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "enable ", + Short: "Enable a routine.", + Long: `Enable a Foundry routine. + +This operation is idempotent: enabling an already-enabled routine is a no-op success.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineEnable(ctx, cmd, args[0], output) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineEnable(ctx context.Context, cmd *cobra.Command, name, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + result, err := client.EnableRoutine(ctx, name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpEnableRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpEnableRoutine) + } + + if output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' enabled.\n", name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go new file mode 100644 index 00000000000..f118daf66bd --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/spf13/cobra" +) + +// newRoutineClient resolves the project endpoint and creates an authenticated routine client. +func newRoutineClient(ctx context.Context, cmd *cobra.Command) (*routines.Client, string, error) { + flagEndpoint, _ := cmd.Flags().GetString("project-endpoint") + + resolved, err := resolveProjectEndpoint(ctx, flagEndpoint) + if err != nil { + return nil, "", err + } + + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, "", exterrors.Auth( + exterrors.CodeAuthFailed, + fmt.Sprintf("failed to create Azure credential: %v", err), + "run `azd auth login` to authenticate", + ) + } + + return routines.NewClient(resolved.Endpoint, cred), resolved.Endpoint, nil +} + +// printJSON marshals v to indented JSON and writes to stdout. +func printJSON(v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + fmt.Println(string(data)) + return nil +} + +// newTabWriter creates a tabwriter that flushes to stdout. +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) +} + +// boolStr returns "true"/"false" for a *bool pointer. +func boolStr(b *bool) string { + if b == nil { + return "true" + } + if *b { + return "true" + } + return "false" +} + +// ptrBool returns a pointer to b. +func ptrBool(b bool) *bool { return &b } + +// routineSummaryTable prints a short summary of a routine in table format. +func routineSummaryTable(r *routines.Routine) { + tw := newTabWriter() + defer tw.Flush() + fmt.Fprintf(tw, "Name:\t%s\n", r.Name) + if r.Description != "" { + fmt.Fprintf(tw, "Description:\t%s\n", r.Description) + } + fmt.Fprintf(tw, "Enabled:\t%s\n", boolStr(r.Enabled)) + if t, ok := r.Triggers[routines.DefaultTriggerKey]; ok { + fmt.Fprintf(tw, "Trigger:\t%s\n", t.Type) + if t.Cron != "" { + fmt.Fprintf(tw, " Cron:\t%s\n", t.Cron) + } + if t.At != "" { + fmt.Fprintf(tw, " At:\t%s\n", t.At) + } + if t.TimeZone != "" { + fmt.Fprintf(tw, " TimeZone:\t%s\n", t.TimeZone) + } + } + if a, ok := r.Actions[routines.DefaultActionKey]; ok { + fmt.Fprintf(tw, "Action:\t%s\n", a.Type) + if a.AgentName != "" { + fmt.Fprintf(tw, " AgentName:\t%s\n", a.AgentName) + } + if a.AgentEndpointID != "" { + fmt.Fprintf(tw, " AgentEndpointID:\t%s\n", a.AgentEndpointID) + } + } +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go new file mode 100644 index 00000000000..a26bd2d8f20 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "list", + Short: "List all routines in the Foundry project.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineList(ctx, cmd, output) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineList(ctx context.Context, cmd *cobra.Command, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + items, err := client.ListRoutines(ctx) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListRoutines) + } + + if output == "json" { + return printJSON(map[string]any{ + "value": items, + "continuation_token": "", + }) + } + + if len(items) == 0 { + fmt.Println("No routines found.") + return nil + } + + tw := newTabWriter() + defer tw.Flush() + fmt.Fprintln(tw, "NAME\tENABLED\tTRIGGER\tACTION") + fmt.Fprintln(tw, "----\t-------\t-------\t------") + for _, r := range items { + triggerType := "" + if t, ok := r.Triggers[routines.DefaultTriggerKey]; ok { + triggerType = t.Type + } + actionType := "" + if a, ok := r.Actions[routines.DefaultActionKey]; ok { + actionType = a.Type + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + r.Name, + boolStr(r.Enabled), + triggerType, + actionType, + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go new file mode 100644 index 00000000000..ed40e2b71b7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "gopkg.in/yaml.v3" +) + +// readRoutineManifest reads and parses a routine manifest from a YAML or JSON file. +func readRoutineManifest(path string) (*routines.Routine, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeFileNotFound, + fmt.Sprintf("routine manifest file not found: %s", path), + "verify the path or rerun without --file", + ) + } + + var r routines.Routine + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("failed to parse routine manifest %s: %v", path, err), + "ensure the file is valid YAML and matches the routine schema", + ) + } + case ".json", "": + if err := json.Unmarshal(data, &r); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("failed to parse routine manifest %s: %v", path, err), + "ensure the file is valid JSON and matches the routine schema", + ) + } + default: + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("unsupported manifest file extension %q", ext), + "use a .yaml, .yml, or .json file", + ) + } + + return &r, nil +} + +// mergeRoutineFromFile copies fields from the manifest into body. +// The caller's positional argument wins over any name in the file. +// Individual flag overrides are applied by the caller after this function returns. +func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { + if file.Description != "" && body.Description == "" { + body.Description = file.Description + } + if file.Enabled != nil && body.Enabled == nil { + body.Enabled = file.Enabled + } + if len(file.Triggers) > 0 && len(body.Triggers) == 0 { + body.Triggers = file.Triggers + } + if len(file.Actions) > 0 && len(body.Actions) == 0 { + body.Actions = file.Actions + } +} + +// applyUpdateFlags applies named CLI update flags onto an existing routine body. +// It returns the count of fields changed. +func applyUpdateFlags( + existing *routines.Routine, + description, cron, timeZone, at, agentName, agentEndpointID, conversationID, sessionID string, + descChanged, cronChanged, tzChanged, atChanged, agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged bool, +) (int, error) { + changed := 0 + + if descChanged { + existing.Description = description + changed++ + } + + // Trigger field updates + trigger := getTrigger(existing) + if cronChanged { + if trigger == nil { + return 0, fmt.Errorf("cannot set --cron: routine has no default trigger") + } + trigger.Cron = cron + changed++ + } + if tzChanged { + if trigger == nil { + return 0, fmt.Errorf("cannot set --time-zone: routine has no default trigger") + } + trigger.TimeZone = timeZone + changed++ + } + if atChanged { + if trigger == nil { + return 0, fmt.Errorf("cannot set --at: routine has no default trigger") + } + trigger.At = at + changed++ + } + if trigger != nil { + if existing.Triggers == nil { + existing.Triggers = make(map[string]routines.RoutineTrigger) + } + existing.Triggers[routines.DefaultTriggerKey] = *trigger + } + + // Action field updates + action := getAction(existing) + if agentNameChanged || agentEpChanged { + if action == nil { + return 0, fmt.Errorf("cannot update agent fields: routine has no default action") + } + // agent-name and agent-endpoint-id are mutually exclusive; specifying one clears the other. + if agentNameChanged && agentEpChanged && agentName != "" && agentEndpointID != "" { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name and --agent-endpoint-id are mutually exclusive", + "provide either --agent-name or --agent-endpoint-id, not both", + ) + } + if agentNameChanged { + action.AgentName = agentName + if agentName != "" { + action.AgentEndpointID = "" // specifying agent-name clears agent-endpoint-id + } + changed++ + } + if agentEpChanged { + action.AgentEndpointID = agentEndpointID + if agentEndpointID != "" { + action.AgentName = "" // specifying agent-endpoint-id clears agent-name + } + changed++ + } + } + if convIDChanged { + if action == nil { + return 0, fmt.Errorf("cannot set --conversation-id: routine has no default action") + } + action.ConversationID = conversationID + changed++ + } + if sessIDChanged { + if action == nil { + return 0, fmt.Errorf("cannot set --session-id: routine has no default action") + } + action.SessionID = sessionID + changed++ + } + if action != nil { + if existing.Actions == nil { + existing.Actions = make(map[string]routines.RoutineAction) + } + existing.Actions[routines.DefaultActionKey] = *action + } + + return changed, nil +} + +// getTrigger returns a copy of the default trigger, or nil. +func getTrigger(r *routines.Routine) *routines.RoutineTrigger { + if t, ok := r.Triggers[routines.DefaultTriggerKey]; ok { + cp := t + return &cp + } + return nil +} + +// getAction returns a copy of the default action, or nil. +func getAction(r *routines.Routine) *routines.RoutineAction { + if a, ok := r.Actions[routines.DefaultActionKey]; ok { + cp := a + return &cp + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go new file mode 100644 index 00000000000..e1f2acaa3f3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newRoutineRunCommand creates the "run" subcommand group. +func newRoutineRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "run [options]", + Short: "Manage routine run history.", + } + + cmd.AddCommand(newRoutineRunListCommand(extCtx)) + + return cmd +} + +func newRoutineRunListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var top int + var filter string + var output string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List runs for a routine.", + Long: `List execution history for a Foundry routine. + +Auto-paginates via page tokens. Use --top to cap the total number of results.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineRunList(ctx, cmd, args[0], top, filter, output) + }, + } + + cmd.Flags().IntVar(&top, "top", 0, + "Maximum total number of runs to return (0 = no cap)") + cmd.Flags().StringVar(&filter, "filter", "", + "OData filter expression") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineRunList(ctx context.Context, cmd *cobra.Command, routineName string, top int, filter, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + items, err := client.ListRoutineRuns(ctx, routineName, routines.ListRoutineRunsOptions{ + Top: top, + Filter: filter, + }) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpListRoutineRuns, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", routineName)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpListRoutineRuns) + } + + if output == "json" { + return printJSON(map[string]any{ + "value": items, + "next_page_token": "", + }) + } + + if len(items) == 0 { + fmt.Printf("No runs found for routine '%s'.\n", routineName) + return nil + } + + tw := newTabWriter() + defer tw.Flush() + fmt.Fprintln(tw, "ID\tSTATUS\tSTARTED\tENDED") + fmt.Fprintln(tw, "--\t------\t-------\t-----") + for _, run := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + run.ID, run.Status, run.StartedAt, run.EndedAt) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_show.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_show.go new file mode 100644 index 00000000000..165fbd285f7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_show.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details of a routine.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineShow(ctx, cmd, args[0], output) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineShow(ctx context.Context, cmd *cobra.Command, name, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + routine, err := client.GetRoutine(ctx, name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpGetRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpGetRoutine) + } + + if output == "json" { + return printJSON(routine) + } + + routineSummaryTable(routine) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go new file mode 100644 index 00000000000..7a23d42a4b4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// routineUpdateFlags holds validated input for the update command. +type routineUpdateFlags struct { + name string + trigger string // type-switch guard only + action string // type-switch guard only + description string + cron string + timeZone string + at string + agentName string + agentEndpointID string + conversationID string + sessionID string + file string + output string +} + +func newRoutineUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &routineUpdateFlags{} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing routine.", + Long: `Update fields on an existing Foundry routine. + +Only the named flags change; all other fields are preserved verbatim. +To change the trigger or action type, delete and recreate the routine.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineUpdate(ctx, cmd, flags) + }, + } + + // Type-switch guards — registered to surface a friendly error, never used for actual update. + cmd.Flags().StringVar(&flags.trigger, "trigger", "", + "Not allowed on update: trigger types are immutable. Delete and recreate to change.") + cmd.Flags().StringVar(&flags.action, "action", "", + "Not allowed on update: action types are immutable. Delete and recreate to change.") + _ = cmd.Flags().MarkHidden("trigger") + _ = cmd.Flags().MarkHidden("action") + + cmd.Flags().StringVar(&flags.description, "description", "", "New description for the routine") + cmd.Flags().StringVar(&flags.cron, "cron", "", "New cron expression for recurring trigger") + cmd.Flags().StringVar(&flags.timeZone, "time-zone", "", "New time zone for the trigger") + cmd.Flags().StringVar(&flags.at, "at", "", "New ISO 8601 datetime for timer trigger") + cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "New agent name") + cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "New agent endpoint ID") + cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", "New conversation ID (preview)") + cmd.Flags().StringVar(&flags.sessionID, "session-id", "", "New session ID") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to a YAML/JSON manifest; merged fields win unless overridden by flags") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpdateFlags) error { + // Type-switch guard: --trigger and --action are not allowed on update. + if flags.trigger != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--trigger cannot be changed on an existing routine", + fmt.Sprintf("trigger types are immutable. Run 'routine delete %s' then recreate.", flags.name), + ) + } + if flags.action != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--action cannot be changed on an existing routine", + fmt.Sprintf("action types are immutable. Run 'routine delete %s' then recreate.", flags.name), + ) + } + + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + // GET the existing routine. + existing, err := client.GetRoutine(ctx, flags.name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpGetRoutine, + fmt.Sprintf("routine %q not found", flags.name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpGetRoutine) + } + + // If --file is provided, merge the manifest first. + if flags.file != "" { + manifest, err := readRoutineManifest(flags.file) + if err != nil { + return err + } + mergeRoutineFromFile(existing, manifest) + } + + // Apply named flag changes (flag presence, not just non-empty value). + descChanged := cmd.Flags().Changed("description") + cronChanged := cmd.Flags().Changed("cron") + tzChanged := cmd.Flags().Changed("time-zone") + atChanged := cmd.Flags().Changed("at") + agentNameChanged := cmd.Flags().Changed("agent-name") + agentEpChanged := cmd.Flags().Changed("agent-endpoint-id") + convIDChanged := cmd.Flags().Changed("conversation-id") + sessIDChanged := cmd.Flags().Changed("session-id") + + changed, err := applyUpdateFlags( + existing, + flags.description, flags.cron, flags.timeZone, flags.at, + flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID, + descChanged, cronChanged, tzChanged, atChanged, + agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged, + ) + if err != nil { + return err + } + + if changed == 0 && flags.file == "" { + fmt.Printf("No changes specified for routine '%s'.\n", flags.name) + return nil + } + + // PUT the updated body. + result, err := client.PutRoutine(ctx, flags.name, existing) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpUpdateRoutine, + fmt.Sprintf("routine %q was deleted before the update completed", flags.name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateRoutine) + } + + if flags.output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' updated (%d field(s) changed).\n\n", result.Name, changed) + routineSummaryTable(result) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go new file mode 100644 index 00000000000..64c2be07c16 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Error codes for user cancellation. +const ( + CodeCancelled = "cancelled" +) + +// Error codes for validation errors. +const ( + CodeInvalidParameter = "invalid_parameter" + CodeConflictingArguments = "conflicting_arguments" + CodeInvalidRoutineManifest = "invalid_routine_manifest" + CodeRoutineAlreadyExists = "routine_already_exists" +) + +// Error codes for dependency errors. +const ( + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeFileNotFound = "file_not_found" +) + +// Error codes for auth errors. +const ( + //nolint:gosec // error code identifier, not a credential + CodeNotLoggedIn = "not_logged_in" + CodeLoginExpired = "login_expired" + CodeAuthFailed = "auth_failed" +) + +// Operation names for ServiceFromAzure errors. +// These are prefixed to the Azure error code (e.g., "get_routine.NotFound"). +const ( + OpGetRoutine = "get_routine" + OpListRoutines = "list_routines" + OpCreateRoutine = "create_routine" + OpUpdateRoutine = "update_routine" + OpDeleteRoutine = "delete_routine" + OpEnableRoutine = "enable_routine" + OpDisableRoutine = "disable_routine" + OpDispatchRoutine = "dispatch_routine" + OpListRoutineRuns = "list_routine_runs" +) diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go new file mode 100644 index 00000000000..573c8a9b7cb --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error helpers for the azure.ai.routines extension. +package exterrors + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation LocalError for user-input errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency LocalError for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth LocalError for authentication/authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// Internal returns an internal LocalError for unexpected failures. +func Internal(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryInternal, + } +} + +// User returns a user-action LocalError (e.g. cancellation). +func User(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryUser, + } +} + +// Cancelled returns a user cancellation error. +func Cancelled(message string) error { + return User(CodeCancelled, message) +} + +// ServiceFromAzure wraps an azcore.ResponseError into an azdext.ServiceError. +// If the error is not an azcore.ResponseError, it returns a generic internal error. +func ServiceFromAzure(err error, operation string) error { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + serviceName := "" + if respErr.RawResponse != nil && respErr.RawResponse.Request != nil { + serviceName = respErr.RawResponse.Request.Host + } + code := respErr.ErrorCode + if code == "" { + code = fmt.Sprintf("%d", respErr.StatusCode) + } + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, respErr.Error()), + ErrorCode: fmt.Sprintf("%s.%s", operation, code), + StatusCode: respErr.StatusCode, + ServiceName: serviceName, + } + } + if IsCancellation(err) { + return Cancelled(fmt.Sprintf("%s was cancelled", operation)) + } + return Internal(operation, fmt.Sprintf("%s: %s", operation, err.Error())) +} + +// ServiceFromStatus returns a ServiceFromAzure-style error for a raw HTTP status code. +func ServiceFromStatus(statusCode int, operation, message string) error { + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, message), + ErrorCode: fmt.Sprintf("%s.%d", operation, statusCode), + StatusCode: statusCode, + } +} + +// IsNotFound returns true if the error represents an HTTP 404. +func IsNotFound(err error) bool { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + return respErr.StatusCode == http.StatusNotFound + } + var svcErr *azdext.ServiceError + if errors.As(err, &svcErr) { + return svcErr.StatusCode == http.StatusNotFound + } + return false +} + +// IsConflict returns true if the error represents an HTTP 409. +func IsConflict(err error) bool { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + return respErr.StatusCode == http.StatusConflict + } + var svcErr *azdext.ServiceError + if errors.As(err, &svcErr) { + return svcErr.StatusCode == http.StatusConflict + } + return false +} + +// IsCancellation checks if an error represents user cancellation. +func IsCancellation(err error) bool { + return errors.Is(err, context.Canceled) +} + +// authFromMessage creates an Auth error from an HTTP response message. +func authFromMessage(msg string) error { + if strings.Contains(msg, "not logged in") { + return Auth(CodeNotLoggedIn, msg, "run `azd auth login` to authenticate") + } + if strings.Contains(msg, "expired") { + return Auth(CodeLoginExpired, msg, "run `azd auth login` to acquire a new token") + } + return Auth(CodeAuthFailed, msg, "run `azd auth login` to authenticate") +} + +// WrapAuthError wraps a 401 error as an Auth error. +func WrapAuthError(err error, operation string) error { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == http.StatusUnauthorized { + return authFromMessage(respErr.Error()) + } + return ServiceFromAzure(err, operation) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go new file mode 100644 index 00000000000..92114fe982a --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -0,0 +1,395 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package routines + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const ( + routinesAPIVersion = "v1" + routinesPreviewHeader = "x-ms-foundry-features-opt-in" + routinesPreviewValue = "Routines=V1Preview" +) + +// Client is the data-plane client for Foundry Routines API operations. +type Client struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewClient creates a new Routines data-plane client. +func NewClient(endpoint string, cred azcore.TokenCredential) *Client { + clientOptions := &policy.ClientOptions{ + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy( + cred, + []string{"https://ai.azure.com/.default"}, + nil, + ), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy("azd-ext-azure-ai-routines/0.1.0"), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-routines", + "v0.1.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &Client{endpoint: strings.TrimRight(endpoint, "/"), pipeline: pipeline} +} + +// routineURL returns the URL for a named routine. +func (c *Client) routineURL(name string) string { + return fmt.Sprintf("%s/routines/%s?api-version=%s", c.endpoint, url.PathEscape(name), routinesAPIVersion) +} + +// routinesURL returns the base routines collection URL with optional query parameters. +func (c *Client) routinesURL(extraQuery ...string) string { + base := fmt.Sprintf("%s/routines?api-version=%s", c.endpoint, routinesAPIVersion) + if len(extraQuery) > 0 { + return base + "&" + strings.Join(extraQuery, "&") + } + return base +} + +// routineActionURL returns the URL for a named routine action (enable/disable/dispatch_async). +func (c *Client) routineActionURL(name, action string) string { + return fmt.Sprintf("%s/routines/%s:%s?api-version=%s", c.endpoint, url.PathEscape(name), action, routinesAPIVersion) +} + +// routineRunsURL returns the URL for listing routine runs. +func (c *Client) routineRunsURL(routineName string, extraQuery ...string) string { + base := fmt.Sprintf("%s/routines/%s/runs?api-version=%s", c.endpoint, url.PathEscape(routineName), routinesAPIVersion) + if len(extraQuery) > 0 { + return base + "&" + strings.Join(extraQuery, "&") + } + return base +} + +// addPreviewHeader adds the required Routines preview opt-in header to a request. +func addPreviewHeader(req *policy.Request) { + req.Raw().Header.Set(routinesPreviewHeader, routinesPreviewValue) +} + +// GetRoutine retrieves a routine by name. +func (c *Client) GetRoutine(ctx context.Context, name string) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodGet, c.routineURL(name)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var routine Routine + if err := decodeJSON(resp.Body, &routine); err != nil { + return nil, err + } + return &routine, nil +} + +// ListRoutines retrieves all routines, draining all pages. +func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { + var all []Routine + nextURL := c.routinesURL() + + for nextURL != "" { + if err := c.validateSameOrigin(nextURL); err != nil { + return nil, err + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, nextURL) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var page PagedRoutine + if err := decodeJSON(resp.Body, &page); err != nil { + return nil, err + } + + all = append(all, page.Value...) + if page.ContinuationToken != "" { + nextURL = c.routinesURL("continuationToken=" + url.QueryEscape(page.ContinuationToken)) + } else { + nextURL = "" + } + } + + return all, nil +} + +// PutRoutine creates or replaces a routine (upsert via PUT). +func (c *Client) PutRoutine(ctx context.Context, name string, body *Routine) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodPut, c.routineURL(name)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + if err := setJSONBody(req, body); err != nil { + return nil, err + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + var result Routine + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteRoutine deletes a routine by name. +func (c *Client) DeleteRoutine(ctx context.Context, name string) error { + req, err := runtime.NewRequest(ctx, http.MethodDelete, c.routineURL(name)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + + return nil +} + +// EnableRoutine calls the :enable action route for a routine. +func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.postAction(ctx, name, "enable") +} + +// DisableRoutine calls the :disable action route for a routine. +func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.postAction(ctx, name, "disable") +} + +// postAction performs a POST to a named action route and returns the resulting routine. +func (c *Client) postAction(ctx context.Context, name, action string) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, action)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var result Routine + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DispatchRoutineAsync calls the :dispatch_async action route. +func (c *Client) DispatchRoutineAsync( + ctx context.Context, + name string, + payload *DispatchRoutineRequest, +) (*DispatchRoutineResponse, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatch_async")) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + if payload != nil { + if err := setJSONBody(req, payload); err != nil { + return nil, err + } + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted) { + return nil, runtime.NewResponseError(resp) + } + + var result DispatchRoutineResponse + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ListRoutineRunsOptions controls optional parameters for listing routine runs. +type ListRoutineRunsOptions struct { + // Top caps the total number of items returned across all pages (0 = no cap). + Top int + Filter string +} + +// ListRoutineRuns retrieves runs for a routine, respecting Top and Filter options. +func (c *Client) ListRoutineRuns(ctx context.Context, routineName string, opts ListRoutineRunsOptions) ([]RoutineRun, error) { + var all []RoutineRun + + var extraQuery []string + if opts.Top > 0 { + extraQuery = append(extraQuery, fmt.Sprintf("maxResults=%d", opts.Top)) + } + if opts.Filter != "" { + extraQuery = append(extraQuery, "filter="+url.QueryEscape(opts.Filter)) + } + + nextURL := c.routineRunsURL(routineName, extraQuery...) + + for nextURL != "" { + if err := c.validateSameOrigin(nextURL); err != nil { + return nil, err + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, nextURL) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var page PagedRoutineRun + if err := decodeJSON(resp.Body, &page); err != nil { + return nil, err + } + + all = append(all, page.Value...) + + // Respect Top cap across pages. + if opts.Top > 0 && len(all) >= opts.Top { + all = all[:opts.Top] + break + } + + if page.NextPageToken != "" { + nextURL = c.routineRunsURL(routineName, "pageToken="+url.QueryEscape(page.NextPageToken)) + } else { + nextURL = "" + } + } + + return all, nil +} + +// validateSameOrigin ensures a pagination URL has the same origin as the configured endpoint. +func (c *Client) validateSameOrigin(targetURL string) error { + endpointURL, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + linkURL, err := url.Parse(targetURL) + if err != nil { + return fmt.Errorf("invalid pagination URL: %w", err) + } + + if linkURL.Scheme == "" { + return fmt.Errorf("pagination URL must have an explicit scheme, got %q", targetURL) + } + + if !strings.EqualFold(linkURL.Scheme, endpointURL.Scheme) || + !strings.EqualFold(linkURL.Host, endpointURL.Host) { + return fmt.Errorf( + "pagination URL origin mismatch: expected %s://%s, got %s://%s", + endpointURL.Scheme, endpointURL.Host, linkURL.Scheme, linkURL.Host, + ) + } + + return nil +} + +// decodeJSON reads and unmarshals a JSON response body. +func decodeJSON(body io.Reader, v any) error { + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + return nil +} + +// setJSONBody marshals v as JSON and sets it as the request body. +func setJSONBody(req *policy.Request, v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + req.Raw().Header.Set("Content-Type", "application/json") + req.Raw().ContentLength = int64(len(data)) + req.Raw().Body = io.NopCloser(bytes.NewReader(data)) + req.Raw().GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go new file mode 100644 index 00000000000..f8c65f25e6d --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package routines provides the data-plane client and models for Microsoft Foundry Routines. +package routines + +// Routine represents a Foundry routine resource. +type Routine struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Triggers map[string]RoutineTrigger `json:"triggers,omitempty"` + Actions map[string]RoutineAction `json:"actions,omitempty"` +} + +// RoutineTrigger is the discriminated union for routine triggers. +// The "type" field selects the variant: +// - "schedule" (CLI alias: "recurring"): cron-based recurring trigger +// - "timer": one-shot timer trigger +// - "github_issue": GitHub issue event trigger (deferred) +type RoutineTrigger struct { + Type string `json:"type"` + + // schedule / timer fields + Cron string `json:"cron,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + + // timer-only fields + At string `json:"at,omitempty"` + + // github_issue fields (deferred in v1) + Connection string `json:"connection,omitempty"` + Assignee string `json:"assignee,omitempty"` + Repository string `json:"repository,omitempty"` +} + +// RoutineAction is the discriminated union for routine actions. +// The "type" field selects the variant: +// - "invoke_agent_responses_api" (CLI alias: "agent-response") +// - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") +type RoutineAction struct { + Type string `json:"type"` + AgentName string `json:"agent_name,omitempty"` + AgentEndpointID string `json:"agent_endpoint_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// PagedRoutine represents a page of routine resources. +type PagedRoutine struct { + Value []Routine `json:"value"` + ContinuationToken string `json:"continuation_token,omitempty"` +} + +// RoutineRun represents a single routine execution record. +type RoutineRun struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + Error string `json:"error,omitempty"` +} + +// PagedRoutineRun represents a page of routine run records. +type PagedRoutineRun struct { + Value []RoutineRun `json:"value"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +// DispatchRoutineRequest is the request body for the dispatch_async route. +type DispatchRoutineRequest struct { + Input string `json:"input,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` +} + +// DispatchRoutineResponse is the response from the dispatch_async route. +type DispatchRoutineResponse struct { + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` + Status string `json:"status,omitempty"` +} + +// TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. +var TriggerCLIToWire = map[string]string{ + "recurring": "schedule", + "timer": "timer", + "github-issue": "github_issue", +} + +// ActionCLIToWire maps CLI --action aliases to TypeSpec wire type values. +var ActionCLIToWire = map[string]string{ + "agent-response": "invoke_agent_responses_api", + "agent-invoke": "invoke_agent_invocations_api", +} + +// DefaultTriggerKey is the map key used for the single trigger in create/update. +const DefaultTriggerKey = "default" + +// DefaultActionKey is the map key used for the single action in create/update. +const DefaultActionKey = "default" From 520ab3260ac4f9f6959c0d40dbd49cebf8f3d9c8 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 15:18:48 +0800 Subject: [PATCH 02/21] test(routines): add unit tests for endpoint, create, manifest, and models - Add readAzdProjectSourcesFunc seam to endpoint.go for daemon isolation - endpoint_test.go: isFoundryHost, validateProjectEndpoint, full cascade tests - routine_create_test.go: buildTrigger and buildAction table tests - routine_manifest_test.go: readRoutineManifest (JSON/YAML), mergeRoutineFromFile, applyUpdateFlags, getTrigger/getAction - models_test.go: TriggerCLIToWire and ActionCLIToWire completeness - Add yaml struct tags to models.go for YAML manifest support --- .../internal/cmd/endpoint.go | 102 +++++-- .../internal/cmd/endpoint_test.go | 187 +++++++++++++ .../internal/cmd/routine_create_test.go | 104 +++++++ .../internal/cmd/routine_manifest_test.go | 257 ++++++++++++++++++ .../internal/pkg/routines/models.go | 34 +-- .../internal/pkg/routines/models_test.go | 57 ++++ 6 files changed, 695 insertions(+), 46 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go index 979d2db122c..258a1f79a7f 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -103,6 +103,62 @@ type resolvedEndpoint struct { Source EndpointSource } +// azdProjectSources holds the values read from azd-managed sources (levels 2 and 3). +type azdProjectSources struct { + // EnvValue is the AZURE_AI_PROJECT_ENDPOINT from the active azd env, or "". + EnvValue string + // EnvName is the active azd env name. Only meaningful when EnvValue != "". + EnvName string + // CfgEndpoint is the endpoint persisted in global config, or "". + CfgEndpoint string + // CfgFound is true when the global config path was found and had a non-empty endpoint. + CfgFound bool +} + +// readAzdProjectSourcesFunc is a package-level seam so tests can stub the +// daemon-backed lookup without spinning up a real azd gRPC server. +var readAzdProjectSourcesFunc = readAzdProjectSources + +// readAzdProjectSources dials the azd daemon (if reachable) and reads the +// active env's AZURE_AI_PROJECT_ENDPOINT and the global-config project +// endpoint in a single client lifetime. Errors talking to the daemon are +// silently ignored (treated as "no daemon"); the caller falls through to +// the FOUNDRY_PROJECT_ENDPOINT host env var. +func readAzdProjectSources(ctx context.Context) (azdProjectSources, error) { + var out azdProjectSources + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return out, nil + } + defer azdClient.Close() + + // Level 2: active azd env → AZURE_AI_PROJECT_ENDPOINT. + if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + if valResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: "AZURE_AI_PROJECT_ENDPOINT", + }); err == nil && valResp.Value != "" { + out.EnvValue = valResp.Value + out.EnvName = envResp.Environment.Name + } + } + + // Level 3: global config → extensions.ai-agents.project.context.endpoint. + ch, cfgErr := azdext.NewConfigHelper(azdClient) + if cfgErr == nil { + var state struct { + Endpoint string `json:"endpoint"` + } + if found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state); err == nil && found && state.Endpoint != "" { + out.CfgEndpoint = state.Endpoint + out.CfgFound = true + } + } + + return out, nil +} + // resolveProjectEndpoint implements the 5-level cascade: // // 1. -p / --project-endpoint flag @@ -120,38 +176,26 @@ func resolveProjectEndpoint(ctx context.Context, flagValue string) (*resolvedEnd return &resolvedEndpoint{Endpoint: normalized, Source: SourceFlag}, nil } - // Levels 2 & 3: azd daemon sources. - if azdClient, err := azdext.NewAzdClient(); err == nil { - defer azdClient.Close() - - // Level 2: active azd env → AZURE_AI_PROJECT_ENDPOINT. - if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { - if valResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: envResp.Environment.Name, - Key: "AZURE_AI_PROJECT_ENDPOINT", - }); err == nil && valResp.Value != "" { - normalized, err := validateProjectEndpoint(valResp.Value) - if err != nil { - return nil, err - } - return &resolvedEndpoint{Endpoint: normalized, Source: SourceAzdEnv}, nil - } + // Levels 2 & 3: azd daemon sources (replaceable seam for testing). + sources, err := readAzdProjectSourcesFunc(ctx) + if err != nil { + return nil, err + } + + if sources.EnvValue != "" { + normalized, err := validateProjectEndpoint(sources.EnvValue) + if err != nil { + return nil, err } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceAzdEnv}, nil + } - // Level 3: global config → extensions.ai-agents.project.context.endpoint. - ch, cfgErr := azdext.NewConfigHelper(azdClient) - if cfgErr == nil { - var state struct { - Endpoint string `json:"endpoint"` - } - if found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state); err == nil && found && state.Endpoint != "" { - normalized, err := validateProjectEndpoint(state.Endpoint) - if err != nil { - return nil, err - } - return &resolvedEndpoint{Endpoint: normalized, Source: SourceGlobalConfig}, nil - } + if sources.CfgFound && sources.CfgEndpoint != "" { + normalized, err := validateProjectEndpoint(sources.CfgEndpoint) + if err != nil { + return nil, err } + return &resolvedEndpoint{Endpoint: normalized, Source: SourceGlobalConfig}, nil } // Level 4: FOUNDRY_PROJECT_ENDPOINT env var. diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go new file mode 100644 index 00000000000..7df113561d9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isolateFromAzdDaemon replaces the readAzdProjectSourcesFunc seam with a +// no-op stub so tests never dial a live azd gRPC server, and restores the +// original on cleanup. +func isolateFromAzdDaemon(t *testing.T) { + t.Helper() + orig := readAzdProjectSourcesFunc + readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { + return azdProjectSources{}, nil + } + t.Cleanup(func() { readAzdProjectSourcesFunc = orig }) +} + +// ─── isFoundryHost ──────────────────────────────────────────────────────────── + +func TestIsFoundryHost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {"myaccount.services.ai.azure.com", true}, + {"myaccount.SERVICES.AI.AZURE.COM", true}, // case-insensitive + {"sub.myaccount.services.ai.azure.com", true}, + {"evil.example.com", false}, + {"services.ai.azure.com.evil.com", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + assert.Equal(t, tt.want, isFoundryHost(tt.host)) + }) + } +} + +// ─── validateProjectEndpoint ───────────────────────────────────────────────── + +func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "basic endpoint", + raw: "https://myaccount.services.ai.azure.com/api/projects/myproj", + want: "https://myaccount.services.ai.azure.com/api/projects/myproj", + }, + { + name: "uppercase scheme", + raw: "HTTPS://MyAccount.SERVICES.AI.AZURE.COM/api/projects/myproj", + want: "https://myaccount.services.ai.azure.com/api/projects/myproj", + }, + { + name: "trailing slash stripped", + raw: "https://myaccount.services.ai.azure.com/api/projects/myproj/", + want: "https://myaccount.services.ai.azure.com/api/projects/myproj", + }, + { + name: "host only (no path)", + raw: "https://myaccount.services.ai.azure.com", + want: "https://myaccount.services.ai.azure.com", + }, + { + name: "leading/trailing whitespace trimmed", + raw: " https://myaccount.services.ai.azure.com/api/projects/x ", + want: "https://myaccount.services.ai.azure.com/api/projects/x", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateProjectEndpoint(tt.raw) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidateProjectEndpoint_Rejections(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "empty string", raw: ""}, + {name: "http scheme", raw: "http://myaccount.services.ai.azure.com/api/projects/x"}, + {name: "non-foundry host", raw: "https://management.azure.com/api/projects/x"}, + {name: "no scheme", raw: "myaccount.services.ai.azure.com/api/projects/x"}, + {name: "localhost", raw: "https://localhost/api/projects/x"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validateProjectEndpoint(tt.raw) + assert.Error(t, err) + }) + } +} + +// ─── resolveProjectEndpoint cascade ────────────────────────────────────────── + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://other.services.ai.azure.com/api/projects/other") + + got, err := resolveProjectEndpoint(t.Context(), "https://myaccount.services.ai.azure.com/api/projects/p") + require.NoError(t, err) + assert.Equal(t, SourceFlag, got.Source) + assert.Equal(t, "https://myaccount.services.ai.azure.com/api/projects/p", got.Endpoint) +} + +func TestResolveProjectEndpoint_AzdEnv(t *testing.T) { + isolateFromAzdDaemon(t) + + readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { + return azdProjectSources{ + EnvValue: "https://envaccount.services.ai.azure.com/api/projects/env", + EnvName: "my-env", + }, nil + } + t.Cleanup(func() { isolateFromAzdDaemon(t) }) // not strictly needed; isolate cleans up + + got, err := resolveProjectEndpoint(t.Context(), "") + require.NoError(t, err) + assert.Equal(t, SourceAzdEnv, got.Source) + assert.Equal(t, "https://envaccount.services.ai.azure.com/api/projects/env", got.Endpoint) +} + +func TestResolveProjectEndpoint_GlobalConfig(t *testing.T) { + isolateFromAzdDaemon(t) + + readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { + return azdProjectSources{ + CfgEndpoint: "https://cfgaccount.services.ai.azure.com/api/projects/cfg", + CfgFound: true, + }, nil + } + + got, err := resolveProjectEndpoint(t.Context(), "") + require.NoError(t, err) + assert.Equal(t, SourceGlobalConfig, got.Source) + assert.Equal(t, "https://cfgaccount.services.ai.azure.com/api/projects/cfg", got.Endpoint) +} + +func TestResolveProjectEndpoint_FoundryEnv(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://fenv.services.ai.azure.com/api/projects/fe") + + got, err := resolveProjectEndpoint(t.Context(), "") + require.NoError(t, err) + assert.Equal(t, SourceFoundryEnv, got.Source) + assert.Equal(t, "https://fenv.services.ai.azure.com/api/projects/fe", got.Endpoint) +} + +func TestResolveProjectEndpoint_NoSourceReturnsError(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + + _, err := resolveProjectEndpoint(t.Context(), "") + assert.Error(t, err) +} + +func TestResolveProjectEndpoint_FlagNormalizesURL(t *testing.T) { + isolateFromAzdDaemon(t) + + got, err := resolveProjectEndpoint(t.Context(), + "HTTPS://MyAccount.SERVICES.AI.AZURE.COM/api/projects/p/") + require.NoError(t, err) + assert.Equal(t, SourceFlag, got.Source) + assert.Equal(t, "https://myaccount.services.ai.azure.com/api/projects/p", got.Endpoint) +} + +func TestResolveProjectEndpoint_InvalidFlagReturnsError(t *testing.T) { + isolateFromAzdDaemon(t) + + _, err := resolveProjectEndpoint(t.Context(), "http://myaccount.services.ai.azure.com/api/projects/p") + assert.Error(t, err, "http:// scheme should be rejected") +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go new file mode 100644 index 00000000000..73979c59920 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azure.ai.routines/internal/pkg/routines" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── buildTrigger ───────────────────────────────────────────────────────────── + +func TestBuildTrigger_Recurring(t *testing.T) { + flags := &routineCreateFlags{ + trigger: "recurring", + cron: "0 8 * * 1-5", + timeZone: "America/New_York", + } + got, err := buildTrigger(flags) + require.NoError(t, err) + assert.Equal(t, "schedule", got.Type) + assert.Equal(t, "0 8 * * 1-5", got.Cron) + assert.Equal(t, "America/New_York", got.TimeZone) +} + +func TestBuildTrigger_RecurringMissingCron(t *testing.T) { + flags := &routineCreateFlags{trigger: "recurring"} + _, err := buildTrigger(flags) + assert.Error(t, err) +} + +func TestBuildTrigger_Timer(t *testing.T) { + flags := &routineCreateFlags{ + trigger: "timer", + at: "2026-04-24T15:00:00Z", + timeZone: "UTC", + } + got, err := buildTrigger(flags) + require.NoError(t, err) + assert.Equal(t, "timer", got.Type) + assert.Equal(t, "2026-04-24T15:00:00Z", got.At) +} + +func TestBuildTrigger_TimerMissingAt(t *testing.T) { + flags := &routineCreateFlags{trigger: "timer"} + _, err := buildTrigger(flags) + assert.Error(t, err) +} + +func TestBuildTrigger_UnknownType(t *testing.T) { + flags := &routineCreateFlags{trigger: "unknown-trigger"} + _, err := buildTrigger(flags) + assert.Error(t, err) +} + +// ─── buildAction ────────────────────────────────────────────────────────────── + +func TestBuildAction_AgentResponseByName(t *testing.T) { + got, err := buildAction("agent-response", "my-agent", "", "conv-1", "") + require.NoError(t, err) + assert.Equal(t, routines.ActionCLIToWire["agent-response"], got.Type) + assert.Equal(t, "my-agent", got.AgentName) + assert.Empty(t, got.AgentEndpointID) + assert.Equal(t, "conv-1", got.ConversationID) +} + +func TestBuildAction_AgentResponseByEndpointID(t *testing.T) { + got, err := buildAction("agent-response", "", "ep-id-123", "", "") + require.NoError(t, err) + assert.Empty(t, got.AgentName) + assert.Equal(t, "ep-id-123", got.AgentEndpointID) +} + +func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { + _, err := buildAction("agent-response", "my-agent", "ep-id-123", "", "") + assert.Error(t, err, "agent-name and agent-endpoint-id must be mutually exclusive") +} + +func TestBuildAction_AgentResponseMissingBoth(t *testing.T) { + _, err := buildAction("agent-response", "", "", "", "") + assert.Error(t, err) +} + +func TestBuildAction_AgentInvoke(t *testing.T) { + got, err := buildAction("agent-invoke", "", "ep-id-456", "", "sess-1") + require.NoError(t, err) + assert.Equal(t, routines.ActionCLIToWire["agent-invoke"], got.Type) + assert.Equal(t, "ep-id-456", got.AgentEndpointID) + assert.Equal(t, "sess-1", got.SessionID) +} + +func TestBuildAction_AgentInvokeMissingEndpointID(t *testing.T) { + _, err := buildAction("agent-invoke", "", "", "", "") + assert.Error(t, err) +} + +func TestBuildAction_UnknownType(t *testing.T) { + _, err := buildAction("no-such-action", "", "ep", "", "") + assert.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go new file mode 100644 index 00000000000..15079aadccc --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "azure.ai.routines/internal/pkg/routines" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── readRoutineManifest ────────────────────────────────────────────────────── + +func TestReadRoutineManifest_JSON(t *testing.T) { + r := &routines.Routine{ + Name: "test-routine", + Description: "a test routine", + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", Cron: "0 8 * * 1-5"}, + }, + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentName: "my-agent"}, + }, + } + data, err := json.Marshal(r) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "routine.json") + require.NoError(t, os.WriteFile(path, data, 0600)) + + got, err := readRoutineManifest(path) + require.NoError(t, err) + assert.Equal(t, "test-routine", got.Name) + assert.Equal(t, "a test routine", got.Description) + assert.Equal(t, "schedule", got.Triggers["default"].Type) + assert.Equal(t, "0 8 * * 1-5", got.Triggers["default"].Cron) + assert.Equal(t, "my-agent", got.Actions["default"].AgentName) +} + +func TestReadRoutineManifest_YAML(t *testing.T) { + yaml := `name: yaml-routine +description: yaml desc +triggers: + default: + type: timer + at: "2026-01-01T00:00:00Z" +actions: + default: + type: invoke_agent_responses_api + agent_name: yaml-agent +` + path := filepath.Join(t.TempDir(), "routine.yaml") + require.NoError(t, os.WriteFile(path, []byte(yaml), 0600)) + + got, err := readRoutineManifest(path) + require.NoError(t, err) + assert.Equal(t, "yaml-routine", got.Name) + assert.Equal(t, "timer", got.Triggers["default"].Type) + assert.Equal(t, "yaml-agent", got.Actions["default"].AgentName) +} + +func TestReadRoutineManifest_FileNotFound(t *testing.T) { + _, err := readRoutineManifest("/nonexistent/path/routine.yaml") + assert.Error(t, err) +} + +func TestReadRoutineManifest_UnsupportedExtension(t *testing.T) { + path := filepath.Join(t.TempDir(), "routine.toml") + require.NoError(t, os.WriteFile(path, []byte("name = 'x'"), 0600)) + + _, err := readRoutineManifest(path) + assert.Error(t, err) +} + +// ─── mergeRoutineFromFile ───────────────────────────────────────────────────── + +func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { + body := &routines.Routine{Name: "from-cli"} + file := &routines.Routine{ + Description: "from file", + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", Cron: "* * * * *"}}, + Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_responses_api", AgentName: "a"}}, + } + mergeRoutineFromFile(body, file) + + assert.Equal(t, "from-cli", body.Name, "name must not be overwritten by file") + assert.Equal(t, "from file", body.Description) + assert.Equal(t, "schedule", body.Triggers["default"].Type) + assert.Equal(t, "a", body.Actions["default"].AgentName) +} + +func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { + enabled := true + body := &routines.Routine{ + Name: "from-cli", + Description: "cli description", + Enabled: &enabled, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer", At: "2026-01-01T00:00:00Z"}}, + Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_responses_api", AgentName: "cli-agent"}}, + } + file := &routines.Routine{ + Description: "file description", + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", Cron: "* * * * *"}}, + Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}}, + } + mergeRoutineFromFile(body, file) + + assert.Equal(t, "cli description", body.Description, "body description must win") + assert.Equal(t, "timer", body.Triggers["default"].Type, "body trigger must win") + assert.Equal(t, "cli-agent", body.Actions["default"].AgentName, "body action must win") +} + +// ─── applyUpdateFlags ───────────────────────────────────────────────────────── + +func routine_with_schedule_and_agentresp() *routines.Routine { + return &routines.Routine{ + Name: "my-routine", + Description: "old desc", + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", Cron: "0 8 * * *", TimeZone: "UTC"}, + }, + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentName: "old-agent"}, + }, + } +} + +func TestApplyUpdateFlags_Description(t *testing.T) { + r := routine_with_schedule_and_agentresp() + n, err := applyUpdateFlags(r, + "new desc", "", "", "", "", "", "", "", + true, false, false, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "new desc", r.Description) +} + +func TestApplyUpdateFlags_Cron(t *testing.T) { + r := routine_with_schedule_and_agentresp() + n, err := applyUpdateFlags(r, + "", "0 9 * * 1-5", "", "", "", "", "", "", + false, true, false, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "0 9 * * 1-5", r.Triggers["default"].Cron) +} + +func TestApplyUpdateFlags_TimeZone(t *testing.T) { + r := routine_with_schedule_and_agentresp() + n, err := applyUpdateFlags(r, + "", "", "America/New_York", "", "", "", "", "", + false, false, true, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "America/New_York", r.Triggers["default"].TimeZone) +} + +func TestApplyUpdateFlags_AgentNameClearsEndpointID(t *testing.T) { + r := &routines.Routine{ + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, + }, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, + } + n, err := applyUpdateFlags(r, + "", "", "", "", "new-agent", "", "", "", + false, false, false, false, true, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "new-agent", r.Actions["default"].AgentName) + assert.Empty(t, r.Actions["default"].AgentEndpointID, "setting agent-name should clear agent-endpoint-id") +} + +func TestApplyUpdateFlags_AgentEndpointIDClearsName(t *testing.T) { + r := &routines.Routine{ + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentName: "old-agent"}, + }, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, + } + n, err := applyUpdateFlags(r, + "", "", "", "", "", "new-ep", "", "", + false, false, false, false, false, true, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "new-ep", r.Actions["default"].AgentEndpointID) + assert.Empty(t, r.Actions["default"].AgentName, "setting agent-endpoint-id should clear agent-name") +} + +func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { + r := routine_with_schedule_and_agentresp() + _, err := applyUpdateFlags(r, + "", "", "", "", "new-agent", "new-ep", "", "", + false, false, false, false, true, true, false, false, + ) + assert.Error(t, err) +} + +func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { + r := routine_with_schedule_and_agentresp() + n, err := applyUpdateFlags(r, + "", "", "", "", "", "", "", "", + false, false, false, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 0, n) +} + +// ─── getTrigger / getAction ─────────────────────────────────────────────────── + +func TestGetTrigger_NilWhenEmpty(t *testing.T) { + r := &routines.Routine{} + assert.Nil(t, getTrigger(r)) +} + +func TestGetTrigger_ReturnsCopy(t *testing.T) { + r := &routines.Routine{ + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", Cron: "0 9 * * *"}, + }, + } + trig := getTrigger(r) + require.NotNil(t, trig) + assert.Equal(t, "schedule", trig.Type) + // Modifying copy must not affect original. + trig.Cron = "changed" + assert.Equal(t, "0 9 * * *", r.Triggers["default"].Cron) +} + +func TestGetAction_NilWhenEmpty(t *testing.T) { + r := &routines.Routine{} + assert.Nil(t, getAction(r)) +} + +func TestGetAction_ReturnsCopy(t *testing.T) { + r := &routines.Routine{ + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentName: "orig-agent"}, + }, + } + act := getAction(r) + require.NotNil(t, act) + // Modifying copy must not affect original. + act.AgentName = "changed" + assert.Equal(t, "orig-agent", r.Actions["default"].AgentName) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index f8c65f25e6d..cf0c0a7c97f 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -6,11 +6,11 @@ package routines // Routine represents a Foundry routine resource. type Routine struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Triggers map[string]RoutineTrigger `json:"triggers,omitempty"` - Actions map[string]RoutineAction `json:"actions,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Triggers map[string]RoutineTrigger `json:"triggers,omitempty" yaml:"triggers,omitempty"` + Actions map[string]RoutineAction `json:"actions,omitempty" yaml:"actions,omitempty"` } // RoutineTrigger is the discriminated union for routine triggers. @@ -19,19 +19,19 @@ type Routine struct { // - "timer": one-shot timer trigger // - "github_issue": GitHub issue event trigger (deferred) type RoutineTrigger struct { - Type string `json:"type"` + Type string `json:"type" yaml:"type"` // schedule / timer fields - Cron string `json:"cron,omitempty"` - TimeZone string `json:"time_zone,omitempty"` + Cron string `json:"cron,omitempty" yaml:"cron,omitempty"` + TimeZone string `json:"time_zone,omitempty" yaml:"time_zone,omitempty"` // timer-only fields - At string `json:"at,omitempty"` + At string `json:"at,omitempty" yaml:"at,omitempty"` // github_issue fields (deferred in v1) - Connection string `json:"connection,omitempty"` - Assignee string `json:"assignee,omitempty"` - Repository string `json:"repository,omitempty"` + Connection string `json:"connection,omitempty" yaml:"connection,omitempty"` + Assignee string `json:"assignee,omitempty" yaml:"assignee,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` } // RoutineAction is the discriminated union for routine actions. @@ -39,11 +39,11 @@ type RoutineTrigger struct { // - "invoke_agent_responses_api" (CLI alias: "agent-response") // - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") type RoutineAction struct { - Type string `json:"type"` - AgentName string `json:"agent_name,omitempty"` - AgentEndpointID string `json:"agent_endpoint_id,omitempty"` - ConversationID string `json:"conversation_id,omitempty"` - SessionID string `json:"session_id,omitempty"` + Type string `json:"type" yaml:"type"` + AgentName string `json:"agent_name,omitempty" yaml:"agent_name,omitempty"` + AgentEndpointID string `json:"agent_endpoint_id,omitempty" yaml:"agent_endpoint_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty" yaml:"conversation_id,omitempty"` + SessionID string `json:"session_id,omitempty" yaml:"session_id,omitempty"` } // PagedRoutine represents a page of routine resources. diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go new file mode 100644 index 00000000000..0cd89da7436 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package routines + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTriggerCLIToWire_AllEntriesPresent(t *testing.T) { + expected := map[string]string{ + "recurring": "schedule", + "timer": "timer", + "github-issue": "github_issue", + } + assert.Equal(t, expected, TriggerCLIToWire, + "TriggerCLIToWire must contain all documented CLI→wire mappings") +} + +func TestActionCLIToWire_AllEntriesPresent(t *testing.T) { + expected := map[string]string{ + "agent-response": "invoke_agent_responses_api", + "agent-invoke": "invoke_agent_invocations_api", + } + assert.Equal(t, expected, ActionCLIToWire, + "ActionCLIToWire must contain all documented CLI→wire mappings") +} + +func TestDefaultKeys(t *testing.T) { + assert.Equal(t, "default", DefaultTriggerKey) + assert.Equal(t, "default", DefaultActionKey) +} + +func TestTriggerCLIToWire_NoUnknownEntries(t *testing.T) { + // Ensure no extra/typo entries sneak in. + for k := range TriggerCLIToWire { + switch k { + case "recurring", "timer", "github-issue": + // OK + default: + t.Errorf("unexpected key %q in TriggerCLIToWire", k) + } + } +} + +func TestActionCLIToWire_NoUnknownEntries(t *testing.T) { + for k := range ActionCLIToWire { + switch k { + case "agent-response", "agent-invoke": + // OK + default: + t.Errorf("unexpected key %q in ActionCLIToWire", k) + } + } +} From e092aa5f5e6eec1b4c78cc3779fbd8338a26a2c4 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 15:25:47 +0800 Subject: [PATCH 03/21] test(routines): align test patterns with azure.ai.agents extension - Extract stubAzdProjectSources() helper (mirrors stubAzdHostedSources in agents) - isolateFromAzdDaemon now also clears AZD_SERVER env var - Add t.Parallel() to all pure-function tests (isFoundryHost, validateProjectEndpoint, buildTrigger, buildAction, mergeRoutineFromFile, applyUpdateFlags, getTrigger/getAction, TriggerCLIToWire/ActionCLIToWire map checks) --- .../internal/cmd/endpoint_test.go | 66 ++++++++++++------- .../internal/cmd/routine_create_test.go | 12 ++++ .../internal/cmd/routine_manifest_test.go | 17 +++++ .../internal/pkg/routines/models_test.go | 5 ++ 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go index 7df113561d9..afcf94d99e0 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go @@ -11,21 +11,35 @@ import ( "github.com/stretchr/testify/require" ) -// isolateFromAzdDaemon replaces the readAzdProjectSourcesFunc seam with a -// no-op stub so tests never dial a live azd gRPC server, and restores the -// original on cleanup. -func isolateFromAzdDaemon(t *testing.T) { +// stubAzdProjectSources replaces readAzdProjectSourcesFunc for the duration of +// the test with a function that returns the given sources/err. +func stubAzdProjectSources(t *testing.T, sources azdProjectSources, err error) { t.Helper() orig := readAzdProjectSourcesFunc - readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { - return azdProjectSources{}, nil + readAzdProjectSourcesFunc = func(context.Context) (azdProjectSources, error) { + return sources, err } t.Cleanup(func() { readAzdProjectSourcesFunc = orig }) } +// isolateFromAzdDaemon makes the test independent of any azd daemon that +// might be reachable on the developer machine via AZD_SERVER. It does two +// things: +// - Clears AZD_SERVER so azdext.NewAzdClient() cannot connect. +// - Stubs readAzdProjectSourcesFunc to return no project sources. +// +// Together this ensures the resolver under test only sees the flag and the +// FOUNDRY_PROJECT_ENDPOINT host env var. +func isolateFromAzdDaemon(t *testing.T) { + t.Helper() + t.Setenv("AZD_SERVER", "") + stubAzdProjectSources(t, azdProjectSources{}, nil) +} + // ─── isFoundryHost ──────────────────────────────────────────────────────────── func TestIsFoundryHost(t *testing.T) { + t.Parallel() tests := []struct { host string want bool @@ -39,6 +53,7 @@ func TestIsFoundryHost(t *testing.T) { } for _, tt := range tests { t.Run(tt.host, func(t *testing.T) { + t.Parallel() assert.Equal(t, tt.want, isFoundryHost(tt.host)) }) } @@ -47,10 +62,11 @@ func TestIsFoundryHost(t *testing.T) { // ─── validateProjectEndpoint ───────────────────────────────────────────────── func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { + t.Parallel() tests := []struct { - name string - raw string - want string + name string + raw string + want string }{ { name: "basic endpoint", @@ -80,6 +96,7 @@ func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got, err := validateProjectEndpoint(tt.raw) require.NoError(t, err) assert.Equal(t, tt.want, got) @@ -88,6 +105,7 @@ func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { } func TestValidateProjectEndpoint_Rejections(t *testing.T) { + t.Parallel() tests := []struct { name string raw string @@ -100,6 +118,7 @@ func TestValidateProjectEndpoint_Rejections(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() _, err := validateProjectEndpoint(tt.raw) assert.Error(t, err) }) @@ -109,8 +128,12 @@ func TestValidateProjectEndpoint_Rejections(t *testing.T) { // ─── resolveProjectEndpoint cascade ────────────────────────────────────────── func TestResolveProjectEndpoint_FlagWins(t *testing.T) { - isolateFromAzdDaemon(t) + // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag should win. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://other.services.ai.azure.com/api/projects/other") + stubAzdProjectSources(t, azdProjectSources{ + EnvValue: "https://envaccount.services.ai.azure.com/api/projects/env", + EnvName: "my-env", + }, nil) got, err := resolveProjectEndpoint(t.Context(), "https://myaccount.services.ai.azure.com/api/projects/p") require.NoError(t, err) @@ -120,14 +143,10 @@ func TestResolveProjectEndpoint_FlagWins(t *testing.T) { func TestResolveProjectEndpoint_AzdEnv(t *testing.T) { isolateFromAzdDaemon(t) - - readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { - return azdProjectSources{ - EnvValue: "https://envaccount.services.ai.azure.com/api/projects/env", - EnvName: "my-env", - }, nil - } - t.Cleanup(func() { isolateFromAzdDaemon(t) }) // not strictly needed; isolate cleans up + stubAzdProjectSources(t, azdProjectSources{ + EnvValue: "https://envaccount.services.ai.azure.com/api/projects/env", + EnvName: "my-env", + }, nil) got, err := resolveProjectEndpoint(t.Context(), "") require.NoError(t, err) @@ -137,13 +156,10 @@ func TestResolveProjectEndpoint_AzdEnv(t *testing.T) { func TestResolveProjectEndpoint_GlobalConfig(t *testing.T) { isolateFromAzdDaemon(t) - - readAzdProjectSourcesFunc = func(_ context.Context) (azdProjectSources, error) { - return azdProjectSources{ - CfgEndpoint: "https://cfgaccount.services.ai.azure.com/api/projects/cfg", - CfgFound: true, - }, nil - } + stubAzdProjectSources(t, azdProjectSources{ + CfgEndpoint: "https://cfgaccount.services.ai.azure.com/api/projects/cfg", + CfgFound: true, + }, nil) got, err := resolveProjectEndpoint(t.Context(), "") require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index 73979c59920..22fc8fce998 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -15,6 +15,7 @@ import ( // ─── buildTrigger ───────────────────────────────────────────────────────────── func TestBuildTrigger_Recurring(t *testing.T) { + t.Parallel() flags := &routineCreateFlags{ trigger: "recurring", cron: "0 8 * * 1-5", @@ -28,12 +29,14 @@ func TestBuildTrigger_Recurring(t *testing.T) { } func TestBuildTrigger_RecurringMissingCron(t *testing.T) { + t.Parallel() flags := &routineCreateFlags{trigger: "recurring"} _, err := buildTrigger(flags) assert.Error(t, err) } func TestBuildTrigger_Timer(t *testing.T) { + t.Parallel() flags := &routineCreateFlags{ trigger: "timer", at: "2026-04-24T15:00:00Z", @@ -46,12 +49,14 @@ func TestBuildTrigger_Timer(t *testing.T) { } func TestBuildTrigger_TimerMissingAt(t *testing.T) { + t.Parallel() flags := &routineCreateFlags{trigger: "timer"} _, err := buildTrigger(flags) assert.Error(t, err) } func TestBuildTrigger_UnknownType(t *testing.T) { + t.Parallel() flags := &routineCreateFlags{trigger: "unknown-trigger"} _, err := buildTrigger(flags) assert.Error(t, err) @@ -60,6 +65,7 @@ func TestBuildTrigger_UnknownType(t *testing.T) { // ─── buildAction ────────────────────────────────────────────────────────────── func TestBuildAction_AgentResponseByName(t *testing.T) { + t.Parallel() got, err := buildAction("agent-response", "my-agent", "", "conv-1", "") require.NoError(t, err) assert.Equal(t, routines.ActionCLIToWire["agent-response"], got.Type) @@ -69,6 +75,7 @@ func TestBuildAction_AgentResponseByName(t *testing.T) { } func TestBuildAction_AgentResponseByEndpointID(t *testing.T) { + t.Parallel() got, err := buildAction("agent-response", "", "ep-id-123", "", "") require.NoError(t, err) assert.Empty(t, got.AgentName) @@ -76,16 +83,19 @@ func TestBuildAction_AgentResponseByEndpointID(t *testing.T) { } func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { + t.Parallel() _, err := buildAction("agent-response", "my-agent", "ep-id-123", "", "") assert.Error(t, err, "agent-name and agent-endpoint-id must be mutually exclusive") } func TestBuildAction_AgentResponseMissingBoth(t *testing.T) { + t.Parallel() _, err := buildAction("agent-response", "", "", "", "") assert.Error(t, err) } func TestBuildAction_AgentInvoke(t *testing.T) { + t.Parallel() got, err := buildAction("agent-invoke", "", "ep-id-456", "", "sess-1") require.NoError(t, err) assert.Equal(t, routines.ActionCLIToWire["agent-invoke"], got.Type) @@ -94,11 +104,13 @@ func TestBuildAction_AgentInvoke(t *testing.T) { } func TestBuildAction_AgentInvokeMissingEndpointID(t *testing.T) { + t.Parallel() _, err := buildAction("agent-invoke", "", "", "", "") assert.Error(t, err) } func TestBuildAction_UnknownType(t *testing.T) { + t.Parallel() _, err := buildAction("no-such-action", "", "ep", "", "") assert.Error(t, err) } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index 15079aadccc..1eebd8102c6 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -18,6 +18,7 @@ import ( // ─── readRoutineManifest ────────────────────────────────────────────────────── func TestReadRoutineManifest_JSON(t *testing.T) { + t.Parallel() r := &routines.Routine{ Name: "test-routine", Description: "a test routine", @@ -44,6 +45,7 @@ func TestReadRoutineManifest_JSON(t *testing.T) { } func TestReadRoutineManifest_YAML(t *testing.T) { + t.Parallel() yaml := `name: yaml-routine description: yaml desc triggers: @@ -66,11 +68,13 @@ actions: } func TestReadRoutineManifest_FileNotFound(t *testing.T) { + t.Parallel() _, err := readRoutineManifest("/nonexistent/path/routine.yaml") assert.Error(t, err) } func TestReadRoutineManifest_UnsupportedExtension(t *testing.T) { + t.Parallel() path := filepath.Join(t.TempDir(), "routine.toml") require.NoError(t, os.WriteFile(path, []byte("name = 'x'"), 0600)) @@ -81,6 +85,7 @@ func TestReadRoutineManifest_UnsupportedExtension(t *testing.T) { // ─── mergeRoutineFromFile ───────────────────────────────────────────────────── func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { + t.Parallel() body := &routines.Routine{Name: "from-cli"} file := &routines.Routine{ Description: "from file", @@ -96,6 +101,7 @@ func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { } func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { + t.Parallel() enabled := true body := &routines.Routine{ Name: "from-cli", @@ -132,6 +138,7 @@ func routine_with_schedule_and_agentresp() *routines.Routine { } func TestApplyUpdateFlags_Description(t *testing.T) { + t.Parallel() r := routine_with_schedule_and_agentresp() n, err := applyUpdateFlags(r, "new desc", "", "", "", "", "", "", "", @@ -143,6 +150,7 @@ func TestApplyUpdateFlags_Description(t *testing.T) { } func TestApplyUpdateFlags_Cron(t *testing.T) { + t.Parallel() r := routine_with_schedule_and_agentresp() n, err := applyUpdateFlags(r, "", "0 9 * * 1-5", "", "", "", "", "", "", @@ -154,6 +162,7 @@ func TestApplyUpdateFlags_Cron(t *testing.T) { } func TestApplyUpdateFlags_TimeZone(t *testing.T) { + t.Parallel() r := routine_with_schedule_and_agentresp() n, err := applyUpdateFlags(r, "", "", "America/New_York", "", "", "", "", "", @@ -165,6 +174,7 @@ func TestApplyUpdateFlags_TimeZone(t *testing.T) { } func TestApplyUpdateFlags_AgentNameClearsEndpointID(t *testing.T) { + t.Parallel() r := &routines.Routine{ Actions: map[string]routines.RoutineAction{ "default": {Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, @@ -182,6 +192,7 @@ func TestApplyUpdateFlags_AgentNameClearsEndpointID(t *testing.T) { } func TestApplyUpdateFlags_AgentEndpointIDClearsName(t *testing.T) { + t.Parallel() r := &routines.Routine{ Actions: map[string]routines.RoutineAction{ "default": {Type: "invoke_agent_responses_api", AgentName: "old-agent"}, @@ -199,6 +210,7 @@ func TestApplyUpdateFlags_AgentEndpointIDClearsName(t *testing.T) { } func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { + t.Parallel() r := routine_with_schedule_and_agentresp() _, err := applyUpdateFlags(r, "", "", "", "", "new-agent", "new-ep", "", "", @@ -208,6 +220,7 @@ func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { } func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { + t.Parallel() r := routine_with_schedule_and_agentresp() n, err := applyUpdateFlags(r, "", "", "", "", "", "", "", "", @@ -220,11 +233,13 @@ func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { // ─── getTrigger / getAction ─────────────────────────────────────────────────── func TestGetTrigger_NilWhenEmpty(t *testing.T) { + t.Parallel() r := &routines.Routine{} assert.Nil(t, getTrigger(r)) } func TestGetTrigger_ReturnsCopy(t *testing.T) { + t.Parallel() r := &routines.Routine{ Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "schedule", Cron: "0 9 * * *"}, @@ -239,11 +254,13 @@ func TestGetTrigger_ReturnsCopy(t *testing.T) { } func TestGetAction_NilWhenEmpty(t *testing.T) { + t.Parallel() r := &routines.Routine{} assert.Nil(t, getAction(r)) } func TestGetAction_ReturnsCopy(t *testing.T) { + t.Parallel() r := &routines.Routine{ Actions: map[string]routines.RoutineAction{ "default": {Type: "invoke_agent_responses_api", AgentName: "orig-agent"}, diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go index 0cd89da7436..8aabec2239d 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go @@ -10,6 +10,7 @@ import ( ) func TestTriggerCLIToWire_AllEntriesPresent(t *testing.T) { + t.Parallel() expected := map[string]string{ "recurring": "schedule", "timer": "timer", @@ -20,6 +21,7 @@ func TestTriggerCLIToWire_AllEntriesPresent(t *testing.T) { } func TestActionCLIToWire_AllEntriesPresent(t *testing.T) { + t.Parallel() expected := map[string]string{ "agent-response": "invoke_agent_responses_api", "agent-invoke": "invoke_agent_invocations_api", @@ -29,11 +31,13 @@ func TestActionCLIToWire_AllEntriesPresent(t *testing.T) { } func TestDefaultKeys(t *testing.T) { + t.Parallel() assert.Equal(t, "default", DefaultTriggerKey) assert.Equal(t, "default", DefaultActionKey) } func TestTriggerCLIToWire_NoUnknownEntries(t *testing.T) { + t.Parallel() // Ensure no extra/typo entries sneak in. for k := range TriggerCLIToWire { switch k { @@ -46,6 +50,7 @@ func TestTriggerCLIToWire_NoUnknownEntries(t *testing.T) { } func TestActionCLIToWire_NoUnknownEntries(t *testing.T) { + t.Parallel() for k := range ActionCLIToWire { switch k { case "agent-response", "agent-invoke": From b9122b657c469e577f80da233f37211b4a89433f Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 15:52:34 +0800 Subject: [PATCH 04/21] fix(routines): address CI lint and spell-check failures - cspell.yaml: add exterrors, sess, routineName, azdProjectSources to word list - endpoint.go: remove unused projectEndpointPathPrefix constant - routine_create.go: wrap long buildAction() call (line >125 chars) - routine_update.go: wrap long --file flag help text - routine_manifest_test.go: expand inline map literals to multi-line - client.go: wrap ListRoutineRuns signature to fit 125-char limit - Run gofmt -w and go fix on all files (codes.go, client.go, models.go formatting) --- cli/azd/extensions/azure.ai.routines/cspell.yaml | 6 +++++- .../azure.ai.routines/internal/cmd/endpoint.go | 3 --- .../internal/cmd/routine_create.go | 7 +++++-- .../internal/cmd/routine_helpers.go | 4 +++- .../internal/cmd/routine_manifest_test.go | 16 ++++++++++++---- .../internal/cmd/routine_update.go | 3 ++- .../internal/exterrors/codes.go | 6 +++--- .../internal/pkg/routines/client.go | 10 ++++++---- .../internal/pkg/routines/models.go | 6 +++--- 9 files changed, 39 insertions(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/cspell.yaml b/cli/azd/extensions/azure.ai.routines/cspell.yaml index 258d305a22d..e95524b9e1d 100644 --- a/cli/azd/extensions/azure.ai.routines/cspell.yaml +++ b/cli/azd/extensions/azure.ai.routines/cspell.yaml @@ -1,2 +1,6 @@ import: ../../.vscode/cspell.yaml -words: [] +words: + - exterrors + - sess + - routineName + - azdProjectSources diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go index 258a1f79a7f..e168b0a2bb2 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -34,9 +34,6 @@ var foundryHostSuffixes = []string{ ".services.ai.azure.com", } -// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. -const projectEndpointPathPrefix = "/api/projects/" - // projectContextConfigPath is the global config path for the persisted project context. // Matches the azure.ai.agents extension for cross-extension compatibility. const projectContextConfigPath = "extensions.ai-agents.project.context" diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index 9f5a2f37f49..8d28ade7bde 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -100,7 +100,7 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre var body routines.Routine body.Name = flags.name - body.Enabled = ptrBool(flags.enabled) + body.Enabled = new(flags.enabled) if flags.description != "" { body.Description = flags.description } @@ -136,7 +136,10 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre routines.DefaultTriggerKey: trigger, } - action, err := buildAction(flags.action, flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID) + action, err := buildAction( + flags.action, flags.agentName, flags.agentEndpointID, + flags.conversationID, flags.sessionID, + ) if err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go index f118daf66bd..c32ca836d1c 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -67,7 +67,9 @@ func boolStr(b *bool) string { } // ptrBool returns a pointer to b. -func ptrBool(b bool) *bool { return &b } +// +//go:fix inline +func ptrBool(b bool) *bool { return new(b) } // routineSummaryTable prints a short summary of a routine in table format. func routineSummaryTable(r *routines.Routine) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index 1eebd8102c6..fe714d96a78 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -107,13 +107,21 @@ func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { Name: "from-cli", Description: "cli description", Enabled: &enabled, - Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer", At: "2026-01-01T00:00:00Z"}}, - Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_responses_api", AgentName: "cli-agent"}}, + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "timer", At: "2026-01-01T00:00:00Z"}, + }, + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_responses_api", AgentName: "cli-agent"}, + }, } file := &routines.Routine{ Description: "file description", - Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", Cron: "* * * * *"}}, - Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}}, + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", Cron: "* * * * *"}, + }, + Actions: map[string]routines.RoutineAction{ + "default": {Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, + }, } mergeRoutineFromFile(body, file) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go index 7a23d42a4b4..8050e882b5a 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -65,7 +65,8 @@ To change the trigger or action type, delete and recreate the routine.`, cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "New agent endpoint ID") cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", "New conversation ID (preview)") cmd.Flags().StringVar(&flags.sessionID, "session-id", "", "New session ID") - cmd.Flags().StringVar(&flags.file, "file", "", "Path to a YAML/JSON manifest; merged fields win unless overridden by flags") + cmd.Flags().StringVar(&flags.file, "file", "", + "Path to a YAML/JSON manifest; merged fields win unless overridden by flags") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go index 64c2be07c16..3c127838fce 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go @@ -10,10 +10,10 @@ const ( // Error codes for validation errors. const ( - CodeInvalidParameter = "invalid_parameter" - CodeConflictingArguments = "conflicting_arguments" + CodeInvalidParameter = "invalid_parameter" + CodeConflictingArguments = "conflicting_arguments" CodeInvalidRoutineManifest = "invalid_routine_manifest" - CodeRoutineAlreadyExists = "routine_already_exists" + CodeRoutineAlreadyExists = "routine_already_exists" ) // Error codes for dependency errors. diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 92114fe982a..0d8e0ed93aa 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -20,9 +20,9 @@ import ( ) const ( - routinesAPIVersion = "v1" - routinesPreviewHeader = "x-ms-foundry-features-opt-in" - routinesPreviewValue = "Routines=V1Preview" + routinesAPIVersion = "v1" + routinesPreviewHeader = "x-ms-foundry-features-opt-in" + routinesPreviewValue = "Routines=V1Preview" ) // Client is the data-plane client for Foundry Routines API operations. @@ -283,7 +283,9 @@ type ListRoutineRunsOptions struct { } // ListRoutineRuns retrieves runs for a routine, respecting Top and Filter options. -func (c *Client) ListRoutineRuns(ctx context.Context, routineName string, opts ListRoutineRunsOptions) ([]RoutineRun, error) { +func (c *Client) ListRoutineRuns( + ctx context.Context, routineName string, opts ListRoutineRunsOptions, +) ([]RoutineRun, error) { var all []RoutineRun var extraQuery []string diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index cf0c0a7c97f..c567602c35b 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -75,9 +75,9 @@ type DispatchRoutineRequest struct { // DispatchRoutineResponse is the response from the dispatch_async route. type DispatchRoutineResponse struct { - DispatchID string `json:"dispatch_id,omitempty"` - ActionCorrelationID string `json:"action_correlation_id,omitempty"` - Status string `json:"status,omitempty"` + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` + Status string `json:"status,omitempty"` } // TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. From 32dcd78ff4702519d7c3a75de2fada143d0bc411 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 16:15:15 +0800 Subject: [PATCH 05/21] fix(routines): remove unused ptrBool helper golangci-lint flagged ptrBool as unused. The function had no call sites; the //go:fix inline directive does not exempt it from the unused linter. --- .../azure.ai.routines/internal/cmd/routine_helpers.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go index c32ca836d1c..f1a781178cf 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -66,11 +66,6 @@ func boolStr(b *bool) string { return "false" } -// ptrBool returns a pointer to b. -// -//go:fix inline -func ptrBool(b bool) *bool { return new(b) } - // routineSummaryTable prints a short summary of a routine in table format. func routineSummaryTable(r *routines.Routine) { tw := newTabWriter() From f7d128494eca8ef0756b81e3e51fdbc65cc6827b Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 16:31:00 +0800 Subject: [PATCH 06/21] docs(routines): remove design spec from PR The design spec is tracked separately in PR #8200; this PR focuses on the implementation only. --- cli/azd/docs/design/ai-routine-design-spec.md | 453 ------------------ 1 file changed, 453 deletions(-) delete mode 100644 cli/azd/docs/design/ai-routine-design-spec.md diff --git a/cli/azd/docs/design/ai-routine-design-spec.md b/cli/azd/docs/design/ai-routine-design-spec.md deleted file mode 100644 index fc0b64ca74c..00000000000 --- a/cli/azd/docs/design/ai-routine-design-spec.md +++ /dev/null @@ -1,453 +0,0 @@ -# Design Spec: `azd ai agent routine` Commands - -## 1. Summary - -This spec covers the `routine` command subtree under the existing `azure.ai.agents` -extension. A routine pairs one trigger (when) with one action (what) on a Foundry -project ΓÇö e.g. "every weekday at 8 AM UTC, invoke `daily-report-agent`" ΓÇö without -standing up Logic Apps / Functions / cron infra. - -Commands registered in v1: - -- `azd ai agent routine create ` -- `azd ai agent routine update ` -- `azd ai agent routine show ` -- `azd ai agent routine list` -- `azd ai agent routine delete ` -- `azd ai agent routine enable ` -- `azd ai agent routine disable ` -- `azd ai agent routine dispatch ` -- `azd ai agent routine run list ` - -`routine run show` and `routine run delete` are deferred until their APIs ship -([┬º4.8](#48-routine-run-show--routine-run-delete)). - - -## 2. Scope, Placement, and Non-Goals - -### Placement - -The `routine` subtree lives inside the existing `azure.ai.agents` extension, -alongside `project`, `invoke`, `show`, `monitor`, `files`, and `sessions`. Same -pattern as [`project.go`](../../extensions/azure.ai.agents/internal/cmd/project.go): -`newRoutineCommand(extCtx)` wired into `root.go`, one file per verb, with a -sub-`run` group via `newRoutineRunCommand`. No new extension; no `registry.json` -change. - -> **Command surface.** The agents extension registers its root as `agent`, so -> these commands surface as **`azd ai agent routine ΓǪ`** today. The eventual -> umbrella surface is `azd ai routine ΓǪ` after the extension is split/renamed, -> which is a registration-only change with no behavior diff. See feature issue -> [#8159](https://github.com/Azure/azure-dev/issues/8159) for the umbrella -> context. - -### Impact on existing commands - -`routine` is purely additive. No changes to `agent` (`run`, `invoke`, `show`, -`monitor`, `files`, `sessions`), `project` (`set` / `unset` / `show`), or -`azure.yaml`. No new persistent state in `~/.azd/config.json`. The existing -`agent invoke` and the new `routine dispatch` deliberately overlap: `dispatch` -is the trigger-side manual fire (records a `RoutineRunDto`); `invoke` is the -direct agent call (does not). Both must keep working. - -### In scope - -- The commands listed in [┬º1](#1-summary). -- Mapping from CLI flags onto the wire format in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) (merged into `feature/foundry-release`). -- Reuse of the 5-level project endpoint resolver (flag ΓåÆ azd env ΓåÆ global config ΓåÆ `FOUNDRY_PROJECT_ENDPOINT` ΓåÆ structured error). - -### Out of scope - -- Declarative routines (`routine.yaml`, `azd provision` integration, `azd up`) ΓÇö - the imperative `routine create`/`update` verbs in this spec cover the v1 jobs-to-be-done; - the declarative `routine.yaml` + `provision`/`up` story belongs to the future - orchestrated config-driven model and is intentionally out of scope here. -- Multi-trigger routines via the CLI ΓÇö deferred ([┬º7 OQ-2](#7-open-questions)). -- Changing `--trigger` or `--action` *type* on an existing routine ΓÇö delete and - recreate, mirroring the `connection` auth-type rule ([┬º4.2](#42-create-vs-update)). - -## 3. Endpoint Resolution - -Every `routine` subcommand resolves the Foundry project endpoint through the -standard 5-level cascade: `-p` / `--project-endpoint` flag ΓåÆ active azd env -(`AZURE_AI_PROJECT_ENDPOINT`) ΓåÆ global config (the `endpoint` field of the -`extensions.ai-agents.project.context` object, written by -`azd ai agent project set`) ΓåÆ `FOUNDRY_PROJECT_ENDPOINT` env var ΓåÆ structured -dependency error (code `CodeMissingProjectEndpoint`). - -Standalone usability is required: every `routine` subcommand must work outside an -azd project given a resolvable endpoint, matching `connection`, `toolbox`, and -`skill`. - -The preview opt-in header `x-ms-foundry-features-opt-in: Routines=V1Preview` is -sent on every routine data-plane call (per TypeSpec `RoutinesPreviewHeader`); it -is set by the extension, not user-configurable. - -> **Implementation checklist.** The implementation PR must add -> `FOUNDRY_PROJECT_ENDPOINT` to -> [`docs/environment-variables.md`](../environment-variables.md) if not already -> documented by the project-context work (per AGENTS.md guidelines). - -## 4. Command Behavior - -Cross-cutting flags on every subcommand: `--output table|json`, `--no-prompt`, -`--debug`, `-p` / `--project-endpoint`. - -### 4.1 `routine create ` - -Required positional: ``.\ -Required flags (always): one of `--trigger ` (enum, not free-form; see [┬º5.1](#51-trigger-flags--routinetrigger-discriminator) for the supported types and per-type required flags) **or** `--file ` (see [┬º4.1.1](#411---file-source-controlled-routines)).\ -Conditionally required flags: per trigger/action type (see [┬º5.1](#51-trigger-flags--routinetrigger-discriminator) / [┬º5.2](#52-action-flags--routineaction-discriminator)). - -Optional flags: - -| Flag | Notes | -| -------------------- | ----------------------------------------------------------------- | -| `--description` | Free-form text. | -| `--action` | Defaults to `agent-response`. | -| `--enabled` | Bool. Defaults to `true` on creation. Pass `--enabled=false` to create disabled. | -| `--force` | Allow PUT to overwrite an existing routine (upsert). Without it, `create` fails if `` already exists. | - -**Prompt / no-prompt** ΓÇö mirrors `connection create`: - -- Interactive: missing required per-trigger / per-action flags are prompted for. -- `--no-prompt`: exits non-zero with a structured validation error listing missing flags. - -**Output:** - -- Table: `Routine 'daily-ops-report' created.` plus a short summary block. -- JSON: the server's `Routine` body, normalized. - -#### 4.1.1 `--file` (source-controlled routines) - -Routines are first-class repo artifacts: a `routine.yaml` (or `.json`) checked -in next to `agent.yaml` keeps the trigger/action definition reviewable in -source control. `routine create` and `routine update` accept `--file ` -as an alternative to the per-trigger/per-action flag set. - -- **Schema.** The file shape is the same `Routine` body the CLI emits on the - wire (single-trigger keyed as `"default"`, see [┬º5](#5-wire-format-mapping)), - with one optional top-level `name` field. CLI flags override file fields - on a key-by-key basis, so `--file routine.yaml --description "..."` is - valid; the positional `` (or `--name` in `update`) wins over a - `name` field inside the file. -- **Discovery.** `--file` is mutually exclusive with `--trigger` (you provide - the trigger inside the file) but cooperates with all other scalar overrides - (`--description`, `--cron`, `--agent-name`, ...). Same `--no-prompt` rule - applies: if the file is missing or fails schema validation, the command - exits non-zero with a structured validation error and a path/line hint. -- **Tracking.** Schema details (and any `agent.yaml` cross-reference) are - tracked in [#8187](https://github.com/Azure/azure-dev/issues/8187); the - implementation PR is expected to land the validator + JSON Schema entry in - the same change as the `--file` flag. - -### 4.2 Create vs. Update - -The data plane exposes a single idempotent `PUT /routines/{name}`. The CLI splits -it into two verbs for usability. - -**Create semantics.** Fails by default if the resource exists. `--force` makes it -an upsert (matches `connection create --force`). - -**Update semantics.** GET-then-PUT internally ΓÇö only the named flags change; all -other fields are preserved verbatim. Accepted flags: `--description`, `--cron`, -`--time-zone`, `--at`, `--agent-name`, `--agent-endpoint-id`, `--conversation-id`, -`--session-id`, `--file` (replaces all mergeable fields with the file -body; per-flag overrides still win over the file, see [┬º4.1.1](#411---file-source-controlled-routines)). - -**Type-switch guard.** `--trigger` and `--action` are registered on `update` -solely to surface a friendly client-side error when supplied: the command exits -non-zero with a `delete and recreate` suggestion before calling the service. -This mirrors the `connection` auth-type rule. - -**Post-merge validation.** After applying the named fields, `update` validates -the merged body against the existing trigger/action type: -- Action-specific flags are accepted only for the current action type - (`--conversation-id` ΓåÆ `agent-response`; `--session-id` ΓåÆ `agent-invoke`). -- For `agent-response`, `--agent-name` and `--agent-endpoint-id` remain mutually - exclusive: specifying one clears the other; specifying both is a validation - error. -- If the merged body no longer satisfies required fields for its trigger/action - type, the command exits with a structured validation error before calling the service. - -### 4.3 `routine show ` / `routine list` - -Standard read commands. `list` auto-pages via `continuation_token`. In -`--output table`, one row per routine. In `--output json`, a single stable -object: `{ "value": [ ... ], "continuation_token": "" }` (empty token because -all pages are drained). - -### 4.4 `routine delete ` - -Confirmation prompt by default. `--force` skips it. In `--no-prompt` mode, -`--force` is required; without it the command exits non-zero with a structured -validation error. Matches `connection delete`. - -### 4.5 `routine enable | disable ` - -Dedicated verbs that map directly to the service's dedicated action routes -defined in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186): -`POST /routines/{name}:enable` and `POST /routines/{name}:disable`. Calling -these routes directly avoids the TOCTOU race that a client-side GET-then-PUT -toggle would introduce. - -Both are idempotent: enabling an already-enabled routine (or disabling an -already-disabled one) is a no-op success. Non-existent routines surface the -service's 404. - -### 4.6 `routine dispatch ` - -The only dispatch route in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) -is `POST /routines/{name}:dispatch_async`; both sync (default) and `--async` -modes call it. The `--async` flag controls only client-side waiting behavior, -not which route is used. - -| Flag | Notes | -| --------------------- | -------------------------------------------------------------------- | -| `--async` | Returns `dispatch_id` immediately after the `:dispatch_async` call. | -| `--input ""` | Plain-text user-message payload wrapped into `RoutineDispatchPayload`. The string is passed through verbatim; JSON content is not parsed by the CLI. | -| `--conversation-id` | Preview ΓÇö forwarded as `conversation_id` for `agent-response` routines. Not yet in TypeSpec ([┬º7 OQ-3](#7-open-questions)). | - -> **Implementation note.** A leading `GET /routines/{name}` is performed when -> any payload-level flag is set (`--input` and/or `--conversation-id`) to derive -> the action type. When neither flag is provided, the CLI sends an empty body -> (`{}`) and skips the GET; dispatch telemetry records `actionType` as `unknown` -> in that path. - -**Output:** both modes hit `:dispatch_async`; the default mode polls the -returned `dispatch_id` and streams the agent response back to the user, while -`--async` returns the raw `DispatchRoutineResponse` immediately. - -| Mode | Table | JSON | -| ------- | ------------------------------------------------------------------------------ | -------------------------------- | -| Default | Agent response streamed + `dispatch_id` / `action_correlation_id` trailer | `DispatchRoutineResponse` body | -| `--async` | `DispatchRoutineResponse` (no streaming) | Same | - -### 4.7 `routine run list ` - -Maps onto `GET /routines/{routine_name}/runs`: - -| CLI flag | Query param | -| ------------- | ------------------ | -| `--top N` | `maxResults` per page; CLI stops auto-paging once `N` items have been returned | -| `--filter` | `filter` | - -`--orderby` is intentionally **not** registered in v1: `ListRoutineRunsParameters` -in [TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) -only exposes pagination plus `filter`. The flag will be added when (and if) the -service grows an `orderBy` query parameter. - -Auto-pagination via `pageToken` / `next_page_token`, same rules as `routine list` -([┬º4.3](#43-routine-show-name--routine-list)). When `--top N` is set the CLI -caps the total returned at `N` items across all drained pages. - -### 4.8 `routine run show` / `routine run delete` - -**Not registered in v1.** The `GET /routines/{name}/runs/{run-id}` endpoint -needed for `run show` was [added in TypeSpec PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186/files#diff-0920b2f67a7816e1e9ef440782ce714e40358a2a5c161b322271b19c19fb1e9fR163); -`run delete` is still not in the TypeSpec. Both verbs will be added as a -strictly additive change in a follow-up PR, with no churn on already-shipped -verbs. - -### Output shapes for state-changing verbs - -| Command | Table output | JSON output | -| --------- | ------------------------------ | ----------------------------------- | -| `create` | `Routine '' created.` + summary | Server `Routine` body | -| `update` | `Routine '' updated.` + summary of changed fields | Updated `Routine` body | -| `delete` | `Routine '' deleted.` | `{ "deleted": true, "name": "" }` | -| `enable` | `Routine '' enabled.` | Updated `Routine` body | -| `disable` | `Routine '' disabled.` | Updated `Routine` body | - -### 4.9 Error Behavior - -All `routine` subcommands surface errors through the same extension-wide -typed-error package used by `connection`, `toolbox`, and `skill`: structured -typed errors (`Validation`, `Dependency`, `Auth`, `ServiceFromAzure`, ...) that -the host CLI renders as `ErrorWithSuggestion` and that map onto the codes -defined in the shared error-codes file under -`extensions/azure.ai.agents/internal` (see the `connection` and `toolbox` -commands for the established pattern). Implementers should reuse these codes -rather than minting new strings. - -| Scenario | Type | Code (or new code suggestion) | Suggested next step in the message | -| ------------------------------------------------------------------------- | -------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- | -| `create ` when `` already exists and `--force` not set | `Validation` | new `CodeRoutineAlreadyExists` | `Use --force to overwrite the existing routine, or pick a different .` | -| `create` / `update` schema validation fails (missing/unknown flag combos) | `Validation` | `CodeConflictingArguments` / `CodeInvalidParameter` | List the offending flag(s) and the expected combo for the current `--trigger` / `--action`. | -| `--file` references a missing file | `Dependency` | `CodeFileNotFound` | `Verify the path or rerun without --file.` | -| `--file` parses but fails schema validation | `Validation` | new `CodeInvalidRoutineManifest` | Include the JSONPath / line and the failing rule. | -| `update` with `--trigger` / `--action` (type switch) | `Validation` | `CodeConflictingArguments` | `Trigger and action types are immutable. Run 'azd ai agent routine delete ' then recreate.` | -| `show` / `delete` / `dispatch` / `enable` / `disable` on a missing routine | `ServiceFromAzure` (404) | `OpGetRoutine.NotFound` (op-prefixed) | `Verify the name with 'azd ai agent routine list'.` | -| `update`: GET succeeds, PUT returns 404 (deleted between calls) | `ServiceFromAzure` (404) | `OpUpdateRoutine.NotFound` | `The routine was deleted before the update completed. Recreate it with 'routine create'.` | -| `delete --no-prompt` without `--force` | `Validation` | `CodeConflictingArguments` | `Add --force to skip confirmation in --no-prompt mode.` | -| Endpoint cannot be resolved (no flag / env / global config) | `Dependency` | `CodeMissingProjectEndpoint` | `Run 'azd ai agent project set --endpoint ' or pass -p.` | -| Auth failure (401 / expired token / wrong tenant) | `Auth` | `CodeNotLoggedIn` / `CodeLoginExpired` / `CodeAuthFailed` | `Run 'azd auth login' and retry.` | -| Server returns 5xx | `ServiceFromAzure` | op-prefixed (e.g. `OpDispatchRoutine.5xx`) | Surface Azure correlation/request id verbatim and suggest retrying. | - -`enable` / `disable` are idempotent; calling them on a routine already in the -target state must not be treated as an error ([┬º4.5](#45-routine-enable--disable-name)). - -Operation names follow the existing `Op*` convention in the typed-error codes -file; the implementation PR adds `OpGetRoutine`, `OpListRoutines`, -`OpCreateRoutine`, `OpUpdateRoutine`, `OpDeleteRoutine`, `OpEnableRoutine`, -`OpDisableRoutine`, `OpDispatchRoutine`, and `OpListRoutineRuns`. - -## 5. Wire Format Mapping - -### 5.1 Trigger flags ΓåÆ `RoutineTrigger` discriminator - -> **Why `recurring` and not `schedule`?** Feature issue [#8159](https://github.com/Azure/azure-dev/issues/8159) -> uses `schedule` (the API discriminator name). The CLI uses `recurring` because -> it reads more naturally alongside `timer` on the command line, and the CLI -> already kebab-cases multi-word values everywhere. A single mapping table -> absorbs any upstream rename. See [┬º7 OQ-1](#7-open-questions). - -| CLI `--trigger` | TypeSpec `type` | Required CLI flags | Status | -| --------------- | ---------------- | -------------------------------------------------------------------- | ------ | -| `recurring` | `schedule` | `--cron ""`, `--time-zone ` | v1 | -| `timer` | `timer` | `--at ""`, `--time-zone ` | v1 | -| `github-issue` | `github_issue` | `--connection `, `--assignee `, `--repository ` | Deferred ΓÇö pending workspace connection model | - -CLI emits `triggers: { "default": { "type": "", ... } }` to match the -TypeSpec `Record` shape. The key `"default"` is an implementation -detail (single-trigger CLI shape) and is not surfaced to the user. - -> **Heads-up.** The Foundry team is adding a generic event-based trigger plus -> additional strong-typed triggers to the TypeSpec shortly after #43186. The -> mapping table absorbs new rows additively; CLI aliases will be added as those -> trigger types land, without churn on the verbs above. - -### 5.2 Action flags ΓåÆ `RoutineAction` discriminator - -| CLI `--action` | TypeSpec `type` | Required CLI flags | Optional CLI flags | -| ----------------------- | -------------------------------- | ----------------------------------------------- | --------------------- | -| `agent-response` (def.) | `invoke_agent_responses_api` | one of `--agent-name` / `--agent-endpoint-id` | `--conversation-id` | -| `agent-invoke` | `invoke_agent_invocations_api` | `--agent-endpoint-id` | `--session-id` | - -`--agent-name` maps to the TypeSpec `agent_name` field (the project-scoped -agent name, max 256 chars) ΓÇö not an opaque ID. For `agent-response`, the CLI -validates "exactly one of `--agent-name` / `--agent-endpoint-id`" locally -before the PUT. - -### 5.3 Routes and API status - -All requests include the `RoutinesPreviewHeader` and the `api-version=v1` query -parameter, matching the existing toolboxes/agents Foundry clients in this -extension (for example -[`listen.go`](../../extensions/azure.ai.agents/internal/cmd/listen.go) builds -`/toolboxes/{name}/versions/{version}/mcp?api-version=v1`). The -`continuationToken` and `pageToken` query parameters are added on top of -`api-version` where applicable. - -| CLI verb | HTTP | API status | -| ------------------------------------- | ------------------------------------------------------------- | --------------- | -| `routine create` / `routine update` | `PUT {endpoint}/routines/{name}` | Ready | -| `routine show` | `GET {endpoint}/routines/{name}` | Ready | -| `routine list` | `GET {endpoint}/routines` (with `continuationToken`) | Ready | -| `routine delete` | `DELETE {endpoint}/routines/{name}` | Ready | -| `routine enable` | `POST {endpoint}/routines/{name}:enable` | Ready | -| `routine disable` | `POST {endpoint}/routines/{name}:disable` | Ready | -| `routine dispatch` (default and `--async`) | `POST {endpoint}/routines/{name}:dispatch_async` ([┬º4.6](#46-routine-dispatch-name)) | Ready | -| `routine run list` | `GET {endpoint}/routines/{name}/runs` | Ready | -| `routine run show` *(deferred)* | `GET {endpoint}/routines/{name}/runs/{run-id}` | Ready in TypeSpec; registration deferred | -| `routine run delete` *(deferred)* | `DELETE {endpoint}/routines/{name}/runs/{run-id}` | Not in TypeSpec | - -Additional API gaps not captured in the routes table: - -- **`conversation_id` on `DispatchRoutineRequest`**: Not in TypeSpec PR; CLI - accepts `--conversation-id` as preview ([┬º7 OQ-3](#7-open-questions)). -- **Trigger / action discriminator aliases**: `agent_response` / `agent_invoke` - requested upstream; CLI kebab-case aliases absorb any rename. - -## 6. Telemetry - -One event per command, on the existing agents-extension surface. No PII; -endpoints hashed. - -| Event | Properties | -| ------------------------------ | ------------------------------------------------------------------------- | -| `azd.ai.routine.create` | `trigger`, `action`, `forced` (bool), `hasFile` (bool), `hasAzdProject` (bool) | -| `azd.ai.routine.update` | `fieldsChanged` (count), `hasFile` (bool), `hasAzdProject` | -| `azd.ai.routine.show` | `source` (resolver), `resolved` (bool) | -| `azd.ai.routine.list` | `pageCount`, `resolved` | -| `azd.ai.routine.delete` | `forced`, `existed` (bool) | -| `azd.ai.routine.enable` | `previouslyEnabled` (bool) | -| `azd.ai.routine.disable` | `previouslyEnabled` | -| `azd.ai.routine.dispatch` | `async` (bool), `actionType` (`unknown` allowed), `hasInput`, `hasConversationId` | -| `azd.ai.routine.run.list` | `pageCount`, `top`, `hasFilter` | - -## 7. Open Questions - -| # | Question | Default proposal | -|---|----------|------------------| -| 1 | **Trigger / action enum names.** CLI aliases (`recurring`, `agent-response`, `agent-invoke`) vs. 1:1 API parity (`schedule`, `invoke_agent_responses_api`, ΓǪ). Note: feature issue [#8159](https://github.com/Azure/azure-dev/issues/8159) uses `schedule`; this spec proposes `recurring`. | Ship CLI aliases. API names are verbose on the command line; a single mapping table absorbs upstream renames. | -| 2 | **Multi-trigger routines.** TypeSpec `triggers` is `Record`. Add `routine trigger add \| remove \| list` now? | Defer. All hero scenarios use one trigger, keyed as `"default"`. Re-evaluate when a real multi-trigger scenario lands. | -| 3 | **`--conversation-id` on dispatch.** Field is in the routines conceptual spec but not in TypeSpec PR #43186. | Ship the flag, mark preview-only in `--help`. If the service rejects unknown fields, the user sees a service error and re-runs without it. Revisit on TypeSpec lock. | - -## 8. Test Plan - -### Unit tests (no network) - -- Flag ΓåÆ wire mapping for each `(--trigger, --action)` combination ([┬º5.1](#51-trigger-flags--routinetrigger-discriminator) / [┬º5.2](#52-action-flags--routineaction-discriminator)), including the `triggers.default` key. -- Per-kind required-flag prompt vs. `--no-prompt` error shape. -- `update`: GET-then-PUT round-trip preserves untouched fields; type-switch - rejection; post-merge validation rejects wrong-action flags; `agent-response` - identity updates clear the peer field. -- `create` vs. `create --force` against a pre-existing routine. -- `enable` / `disable` idempotency; dedicated `:enable` / `:disable` route calls (not GET-then-PUT). -- `dispatch` default vs. `--async` both hit `:dispatch_async`; default mode polls - and streams while `--async` returns immediately; leading GET triggered/skipped - based on payload flags; `actionType` telemetry `unknown` in the no-payload path. -- `run list` query-param mapping (`--top` ΓåÆ `maxResults`, `--filter` ΓåÆ `filter`) and pagination; JSON output is one stable object. -- `delete --no-prompt` without `--force` produces a structured validation error. -- `--file` happy path (`create` and `update`): YAML / JSON parse, schema - validation, flag-level overrides win over file fields, mutual exclusivity - with `--trigger`. -- Error mapping for every row in [┬º4.9](#49-error-behavior): each scenario - surfaces the documented typed-error type/code and suggestion (404 / 409 / - auth / endpoint / schema-validation / type-switch / `--no-prompt` `--force`). -- Output shapes match [┬º4 table](#output-shapes-for-state-changing-verbs) in both - table and JSON modes. - -### E2E - -Smoke test: `routine create` (recurring + agent-response) ΓåÆ `show` ΓåÆ `disable` ΓåÆ -`enable` ΓåÆ `dispatch --async` ΓåÆ `run list` ΓåÆ `delete`. Asserts exit codes and -output shape. Skipped when no Foundry project endpoint is resolvable in CI -(mirrors existing agents-extension E2E gate). - -## 9. Reference: Command Summary - -```bash -azd ai agent routine create \ - --trigger \ - [--cron "0 8 * * *"] [--time-zone UTC] \ - [--at "2026-04-24T15:00:00Z"] \ - [--action ] \ - [--agent-name ] [--agent-endpoint-id ] \ - [--conversation-id ] [--session-id ] \ - [--description "..."] [--enabled=false] [--force] - -# Or from a source-controlled file (see ┬º4.1.1): -azd ai agent routine create --file ./routine.yaml [--description "..."] [--force] - -azd ai agent routine update \ - [--description ...] [--cron ...] [--time-zone ...] [--at ...] \ - [--agent-name ...] [--agent-endpoint-id ...] \ - [--conversation-id ...] [--session-id ...] \ - [--file ./routine.yaml] - -azd ai agent routine show -azd ai agent routine list -azd ai agent routine delete [--force] - -azd ai agent routine enable -azd ai agent routine disable - -azd ai agent routine dispatch [--async] [--input ""] [--conversation-id ] - -azd ai agent routine run list [--top N] [--filter ...] -``` - -Cross-cutting on every command: `--output table|json`, `--no-prompt`, `--debug`, -`-p` / `--project-endpoint`. From 8e0d72344f65cf4774043c5b8d3baf98e8551413 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 17:00:26 +0800 Subject: [PATCH 07/21] fix(routines): close response bodies per page and preserve filter on pagination - Extract getPage helper so resp.Body.Close runs per iteration in ListRoutines and ListRoutineRuns (defer-in-loop leaked FDs). - Preserve the filter query param across pages in ListRoutineRuns; previously page 2+ only carried pageToken and dropped the filter. - Correct dispatch command help text: the service always runs routines asynchronously, so the old 'waits and streams' wording was wrong. --- .../internal/cmd/routine_dispatch.go | 10 +-- .../internal/pkg/routines/client.go | 80 +++++++++---------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go index 7772cf523de..70138122f7c 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -25,10 +25,10 @@ func newRoutineDispatchCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Manually trigger a routine.", Long: `Manually trigger a Foundry routine. -By default, waits for the agent response and streams it back. -Use --async to return the dispatch ID immediately without waiting. - -Both sync and async modes call the :dispatch_async route.`, +The service runs the routine asynchronously. By default, the command prints +the dispatch ID, action correlation ID, and initial status. Use --async to +suppress the status field for scripting; use 'routine run list ' to +inspect execution results.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { output = extCtx.OutputFormat @@ -38,7 +38,7 @@ Both sync and async modes call the :dispatch_async route.`, } cmd.Flags().BoolVar(&asyncMode, "async", false, - "Return the dispatch ID immediately without waiting for the agent response") + "Suppress the status field; useful for scripting") cmd.Flags().StringVar(&input, "input", "", "Plain-text user-message payload for the routine dispatch") cmd.Flags().StringVar(&conversationID, "conversation-id", "", diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 0d8e0ed93aa..532bac8dca5 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "slices" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -123,24 +124,8 @@ func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { return nil, err } - req, err := runtime.NewRequest(ctx, http.MethodGet, nextURL) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - addPreviewHeader(req) - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK) { - return nil, runtime.NewResponseError(resp) - } - var page PagedRoutine - if err := decodeJSON(resp.Body, &page); err != nil { + if err := c.getPage(ctx, nextURL, &page); err != nil { return nil, err } @@ -155,6 +140,29 @@ func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { return all, nil } +// getPage performs a paginated GET and decodes the body into out. +// It scopes resp.Body.Close() to a single iteration to avoid file-descriptor +// accumulation when callers loop across many pages. +func (c *Client) getPage(ctx context.Context, pageURL string, out any) error { + req, err := runtime.NewRequest(ctx, http.MethodGet, pageURL) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return runtime.NewResponseError(resp) + } + + return decodeJSON(resp.Body, out) +} + // PutRoutine creates or replaces a routine (upsert via PUT). func (c *Client) PutRoutine(ctx context.Context, name string, body *Routine) (*Routine, error) { req, err := runtime.NewRequest(ctx, http.MethodPut, c.routineURL(name)) @@ -288,39 +296,27 @@ func (c *Client) ListRoutineRuns( ) ([]RoutineRun, error) { var all []RoutineRun - var extraQuery []string - if opts.Top > 0 { - extraQuery = append(extraQuery, fmt.Sprintf("maxResults=%d", opts.Top)) - } + // baseQuery holds the original filter, preserved across pages. maxResults is + // only sent on the first page (we cap totals client-side via opts.Top). + var baseQuery []string if opts.Filter != "" { - extraQuery = append(extraQuery, "filter="+url.QueryEscape(opts.Filter)) + baseQuery = append(baseQuery, "filter="+url.QueryEscape(opts.Filter)) } - nextURL := c.routineRunsURL(routineName, extraQuery...) + firstPageQuery := slices.Clone(baseQuery) + if opts.Top > 0 { + firstPageQuery = append(firstPageQuery, fmt.Sprintf("maxResults=%d", opts.Top)) + } + + nextURL := c.routineRunsURL(routineName, firstPageQuery...) for nextURL != "" { if err := c.validateSameOrigin(nextURL); err != nil { return nil, err } - req, err := runtime.NewRequest(ctx, http.MethodGet, nextURL) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - addPreviewHeader(req) - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK) { - return nil, runtime.NewResponseError(resp) - } - var page PagedRoutineRun - if err := decodeJSON(resp.Body, &page); err != nil { + if err := c.getPage(ctx, nextURL, &page); err != nil { return nil, err } @@ -333,7 +329,9 @@ func (c *Client) ListRoutineRuns( } if page.NextPageToken != "" { - nextURL = c.routineRunsURL(routineName, "pageToken="+url.QueryEscape(page.NextPageToken)) + pageQuery := append(slices.Clone(baseQuery), + "pageToken="+url.QueryEscape(page.NextPageToken)) + nextURL = c.routineRunsURL(routineName, pageQuery...) } else { nextURL = "" } From b7d375b8e12c096abd5f297e891a91ea5a260c29 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 17:27:39 +0800 Subject: [PATCH 08/21] fix(routines): flatten command tree to remove duplicate 'routine routine' The extension namespace 'ai.routine' already mounts the extension under 'azd ai routine'. Adding a 'routine' subcommand group on top of that produced the redundant 'azd ai routine routine ' path. Move --project-endpoint persistent flag and all subcommands directly onto rootCmd so the correct usage is 'azd ai routine '. --- .../azure.ai.routines/internal/cmd/root.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go index d3478dc1a0a..21a9c7b4911 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go @@ -23,10 +23,22 @@ func NewRootCommand() *cobra.Command { rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + // -p / --project-endpoint is a persistent flag so all subcommands inherit it. + rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", + "Foundry project endpoint URL (overrides env var and config)") + rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) - rootCmd.AddCommand(newRoutineCommand(extCtx)) + rootCmd.AddCommand(newRoutineCreateCommand(extCtx)) + rootCmd.AddCommand(newRoutineUpdateCommand(extCtx)) + rootCmd.AddCommand(newRoutineShowCommand(extCtx)) + rootCmd.AddCommand(newRoutineListCommand(extCtx)) + rootCmd.AddCommand(newRoutineDeleteCommand(extCtx)) + rootCmd.AddCommand(newRoutineEnableCommand(extCtx)) + rootCmd.AddCommand(newRoutineDisableCommand(extCtx)) + rootCmd.AddCommand(newRoutineDispatchCommand(extCtx)) + rootCmd.AddCommand(newRoutineRunCommand(extCtx)) return rootCmd } From 02fb138c8c6a23f29e481d0019cd5d7f17b33acb Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 17:58:49 +0800 Subject: [PATCH 09/21] fix(routines): align data-plane client with Foundry Routines TypeSpec The first cut of the Routines client used header/route/field shapes that did not match the TypeSpec being merged in azure-rest-api-specs#42779. Realign the extension with the spec so requests round-trip cleanly: * Preview header renamed from 'x-ms-foundry-features-opt-in' to 'Foundry-Features' (the value 'Routines=V1Preview' was already correct). * Async dispatch route renamed ':dispatch_async' -> ':dispatchAsync'; the action segment is case-sensitive per spec. * Dropped the non-existent ':enable' / ':disable' action routes; enable/disable now read the routine and PUT it back with 'enabled' flipped (idempotent: no-op if already at the target value). * DispatchRoutineRequest wraps a discriminated 'payload' object whose 'type' must match the routine's action type; --conversation-id was removed from dispatch (the spec does not expose it). * Routine.Action is now a single discriminated object (not an 'actions' map keyed by name). * RoutineAction.AgentName -> AgentID; the CLI flag is renamed to --agent-id accordingly. * RoutineTrigger.Cron -> CronExpression to match the TypeSpec field. * PagedRoutine pagination follows the absolute 'nextLink' URL from Azure.Core.Page instead of re-deriving a continuation query. * RoutineRun gains the additional fields documented in the spec (phase, trigger_type, attempt_source, action_type, triggered_at, dispatch_id, action_correlation_id, response_id, error_type, error_message); 'run list' now prints phase alongside status. * EventRoutineTrigger fields aligned to the spec: connection_id, owner, repository, actions[]; removed 'assignee'. * DispatchRoutineResponse drops the unused 'status' field. Tests, mock manifests, and the E2E driver were updated to the new contract (--agent-id, agent_id, cron_expression, single 'action'). Note: the live Foundry Routines preview endpoint still returns HTTP 500 on /routines?api-version=v1 even with the correct request shape; that is an upstream service bug tracked separately. --- .../azure.ai.routines/internal/cmd/routine.go | 37 ------ .../internal/cmd/routine_create.go | 30 +++-- .../internal/cmd/routine_create_test.go | 14 +-- .../internal/cmd/routine_dispatch.go | 55 +++++---- .../internal/cmd/routine_helpers.go | 32 ++++-- .../internal/cmd/routine_list.go | 8 +- .../internal/cmd/routine_manifest.go | 51 ++++----- .../internal/cmd/routine_manifest_test.go | 106 ++++++++---------- .../internal/cmd/routine_run.go | 8 +- .../internal/cmd/routine_update.go | 10 +- .../internal/pkg/routines/client.go | 61 +++++----- .../internal/pkg/routines/models.go | 76 ++++++++----- .../internal/pkg/routines/models_test.go | 1 - 13 files changed, 240 insertions(+), 249 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go deleted file mode 100644 index 4ee08b20ee7..00000000000 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -// newRoutineCommand creates the "routine" subcommand group. -func newRoutineCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - cmd := &cobra.Command{ - Use: "routine [options]", - Short: "Manage Microsoft Foundry Routines. (Preview)", - Long: `Manage Microsoft Foundry Routines from your terminal. - -A routine pairs one trigger (when) with one action (what) on a Foundry project. -For example: "every weekday at 8 AM UTC, invoke the daily-report agent".`, - } - - // -p / --project-endpoint is a persistent flag so all subcommands inherit it. - cmd.PersistentFlags().StringP("project-endpoint", "p", "", - "Foundry project endpoint URL (overrides env var and config)") - - cmd.AddCommand(newRoutineCreateCommand(extCtx)) - cmd.AddCommand(newRoutineUpdateCommand(extCtx)) - cmd.AddCommand(newRoutineShowCommand(extCtx)) - cmd.AddCommand(newRoutineListCommand(extCtx)) - cmd.AddCommand(newRoutineDeleteCommand(extCtx)) - cmd.AddCommand(newRoutineEnableCommand(extCtx)) - cmd.AddCommand(newRoutineDisableCommand(extCtx)) - cmd.AddCommand(newRoutineDispatchCommand(extCtx)) - cmd.AddCommand(newRoutineRunCommand(extCtx)) - - return cmd -} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index 8d28ade7bde..c80a0e53a78 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -22,7 +22,7 @@ type routineCreateFlags struct { timeZone string at string action string - agentName string + agentID string agentEndpointID string conversationID string sessionID string @@ -64,8 +64,8 @@ Use --file to create from a YAML/JSON manifest file instead of individual flags. "ISO 8601 datetime for timer trigger (e.g. '2026-04-24T15:00:00Z')") cmd.Flags().StringVar(&flags.action, "action", "agent-response", "Action type: agent-response (default), agent-invoke") - cmd.Flags().StringVar(&flags.agentName, "agent-name", "", - "Agent name (for agent-response action)") + cmd.Flags().StringVar(&flags.agentID, "agent-id", "", + "Project-scoped agent ID (for agent-response action)") cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "Agent endpoint ID (for agent-response or agent-invoke action)") cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", @@ -137,15 +137,13 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre } action, err := buildAction( - flags.action, flags.agentName, flags.agentEndpointID, + flags.action, flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, ) if err != nil { return err } - body.Actions = map[string]routines.RoutineAction{ - routines.DefaultActionKey: action, - } + body.Action = &action } client, _, err := newRoutineClient(ctx, cmd) @@ -207,7 +205,7 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { "provide a cron expression, e.g. '0 8 * * 1-5'", ) } - t.Cron = flags.cron + t.CronExpression = flags.cron case "timer": if flags.at == "" { return t, exterrors.Validation( @@ -223,7 +221,7 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { } // buildAction constructs a RoutineAction from CLI flags. -func buildAction(actionType, agentName, agentEndpointID, conversationID, sessionID string) (routines.RoutineAction, error) { +func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID string) (routines.RoutineAction, error) { wireType, ok := routines.ActionCLIToWire[actionType] if !ok { return routines.RoutineAction{}, exterrors.Validation( @@ -237,21 +235,21 @@ func buildAction(actionType, agentName, agentEndpointID, conversationID, session switch actionType { case "agent-response": - if agentName != "" && agentEndpointID != "" { + if agentID != "" && agentEndpointID != "" { return a, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-name and --agent-endpoint-id are mutually exclusive for agent-response action", - "provide either --agent-name or --agent-endpoint-id, not both", + "--agent-id and --agent-endpoint-id are mutually exclusive for agent-response action", + "provide either --agent-id or --agent-endpoint-id, not both", ) } - if agentName == "" && agentEndpointID == "" { + if agentID == "" && agentEndpointID == "" { return a, exterrors.Validation( exterrors.CodeInvalidParameter, - "one of --agent-name or --agent-endpoint-id is required for agent-response action", - "provide --agent-name or --agent-endpoint-id ", + "one of --agent-id or --agent-endpoint-id is required for agent-response action", + "provide --agent-id or --agent-endpoint-id ", ) } - a.AgentName = agentName + a.AgentID = agentID a.AgentEndpointID = agentEndpointID a.ConversationID = conversationID case "agent-invoke": diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index 22fc8fce998..4cc1e2617fd 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -24,7 +24,7 @@ func TestBuildTrigger_Recurring(t *testing.T) { got, err := buildTrigger(flags) require.NoError(t, err) assert.Equal(t, "schedule", got.Type) - assert.Equal(t, "0 8 * * 1-5", got.Cron) + assert.Equal(t, "0 8 * * 1-5", got.CronExpression) assert.Equal(t, "America/New_York", got.TimeZone) } @@ -64,12 +64,12 @@ func TestBuildTrigger_UnknownType(t *testing.T) { // ─── buildAction ────────────────────────────────────────────────────────────── -func TestBuildAction_AgentResponseByName(t *testing.T) { +func TestBuildAction_AgentResponseByID(t *testing.T) { t.Parallel() - got, err := buildAction("agent-response", "my-agent", "", "conv-1", "") + got, err := buildAction("agent-response", "my-agent-id", "", "conv-1", "") require.NoError(t, err) assert.Equal(t, routines.ActionCLIToWire["agent-response"], got.Type) - assert.Equal(t, "my-agent", got.AgentName) + assert.Equal(t, "my-agent-id", got.AgentID) assert.Empty(t, got.AgentEndpointID) assert.Equal(t, "conv-1", got.ConversationID) } @@ -78,14 +78,14 @@ func TestBuildAction_AgentResponseByEndpointID(t *testing.T) { t.Parallel() got, err := buildAction("agent-response", "", "ep-id-123", "", "") require.NoError(t, err) - assert.Empty(t, got.AgentName) + assert.Empty(t, got.AgentID) assert.Equal(t, "ep-id-123", got.AgentEndpointID) } func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { t.Parallel() - _, err := buildAction("agent-response", "my-agent", "ep-id-123", "", "") - assert.Error(t, err, "agent-name and agent-endpoint-id must be mutually exclusive") + _, err := buildAction("agent-response", "my-agent-id", "ep-id-123", "", "") + assert.Error(t, err, "agent-id and agent-endpoint-id must be mutually exclusive") } func TestBuildAction_AgentResponseMissingBoth(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go index 70138122f7c..0cbdc741f2b 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -17,7 +17,6 @@ import ( func newRoutineDispatchCommand(extCtx *azdext.ExtensionContext) *cobra.Command { var asyncMode bool var input string - var conversationID string var output string cmd := &cobra.Command{ @@ -26,23 +25,21 @@ func newRoutineDispatchCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Manually trigger a Foundry routine. The service runs the routine asynchronously. By default, the command prints -the dispatch ID, action correlation ID, and initial status. Use --async to -suppress the status field for scripting; use 'routine run list ' to -inspect execution results.`, +the dispatch ID and action correlation ID. Use --async to suppress extra +output for scripting; use 'routine run list ' to inspect execution +results.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { output = extCtx.OutputFormat ctx := azdext.WithAccessToken(cmd.Context()) - return runRoutineDispatch(ctx, cmd, args[0], asyncMode, input, conversationID, output) + return runRoutineDispatch(ctx, cmd, args[0], asyncMode, input, output) }, } cmd.Flags().BoolVar(&asyncMode, "async", false, - "Suppress the status field; useful for scripting") + "Suppress descriptive output; useful for scripting") cmd.Flags().StringVar(&input, "input", "", "Plain-text user-message payload for the routine dispatch") - cmd.Flags().StringVar(&conversationID, "conversation-id", "", - "Conversation ID for agent-response routines (preview)") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", @@ -56,24 +53,42 @@ func runRoutineDispatch( cmd *cobra.Command, name string, asyncMode bool, - input, conversationID, output string, + input, output string, ) error { client, _, err := newRoutineClient(ctx, cmd) if err != nil { return err } - // Build the dispatch payload. + // Build the dispatch payload. The payload wrapper carries a discriminated + // inner type that must match the routine's action type, so we fetch the + // routine first to read its action type. We skip the GET when no override + // is provided (the service uses the action's default input in that case). var payload *routines.DispatchRoutineRequest - hasPayloadFlags := input != "" || conversationID != "" - if hasPayloadFlags { + if input != "" { + routine, getErr := client.GetRoutine(ctx, name) + if getErr != nil { + if exterrors.IsNotFound(getErr) { + return exterrors.ServiceFromStatus(404, exterrors.OpDispatchRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(getErr, exterrors.OpGetRoutine) + } + if routine.Action == nil || routine.Action.Type == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("routine %q has no action configured; cannot dispatch with --input", name), + "update the routine to add an action before dispatching", + ) + } payload = &routines.DispatchRoutineRequest{ - Input: input, - ConversationID: conversationID, + Payload: &routines.RoutineDispatchPayload{ + Type: routine.Action.Type, + Input: input, + }, } } - // Call dispatch_async (both modes use this route; --async only controls client-side waiting). resp, err := client.DispatchRoutineAsync(ctx, name, payload) if err != nil { if exterrors.IsNotFound(err) { @@ -88,17 +103,12 @@ func runRoutineDispatch( } if asyncMode { - fmt.Printf("Routine '%s' dispatched asynchronously.\n", name) if resp.DispatchID != "" { - fmt.Printf("Dispatch ID: %s\n", resp.DispatchID) - } - if resp.ActionCorrelationID != "" { - fmt.Printf("Action Correlation ID: %s\n", resp.ActionCorrelationID) + fmt.Println(resp.DispatchID) } return nil } - // Sync mode: dispatch was sent; the service runs it asynchronously but we present it as synchronous. fmt.Printf("Routine '%s' dispatched.\n", name) if resp.DispatchID != "" { fmt.Printf("Dispatch ID: %s\n", resp.DispatchID) @@ -106,8 +116,5 @@ func runRoutineDispatch( if resp.ActionCorrelationID != "" { fmt.Printf("Action Correlation ID: %s\n", resp.ActionCorrelationID) } - if resp.Status != "" { - fmt.Printf("Status: %s\n", resp.Status) - } return nil } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go index f1a781178cf..76b7de4b356 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "text/tabwriter" "azure.ai.routines/internal/exterrors" @@ -75,10 +76,13 @@ func routineSummaryTable(r *routines.Routine) { fmt.Fprintf(tw, "Description:\t%s\n", r.Description) } fmt.Fprintf(tw, "Enabled:\t%s\n", boolStr(r.Enabled)) - if t, ok := r.Triggers[routines.DefaultTriggerKey]; ok { - fmt.Fprintf(tw, "Trigger:\t%s\n", t.Type) - if t.Cron != "" { - fmt.Fprintf(tw, " Cron:\t%s\n", t.Cron) + // Routine.triggers is a map keyed by user-defined identifiers; iterate + // in deterministic key order so multiple triggers render consistently. + for _, key := range sortedKeys(r.Triggers) { + t := r.Triggers[key] + fmt.Fprintf(tw, "Trigger (%s):\t%s\n", key, t.Type) + if t.CronExpression != "" { + fmt.Fprintf(tw, " Cron:\t%s\n", t.CronExpression) } if t.At != "" { fmt.Fprintf(tw, " At:\t%s\n", t.At) @@ -87,13 +91,27 @@ func routineSummaryTable(r *routines.Routine) { fmt.Fprintf(tw, " TimeZone:\t%s\n", t.TimeZone) } } - if a, ok := r.Actions[routines.DefaultActionKey]; ok { + if r.Action != nil { + a := r.Action fmt.Fprintf(tw, "Action:\t%s\n", a.Type) - if a.AgentName != "" { - fmt.Fprintf(tw, " AgentName:\t%s\n", a.AgentName) + if a.AgentID != "" { + fmt.Fprintf(tw, " AgentID:\t%s\n", a.AgentID) } if a.AgentEndpointID != "" { fmt.Fprintf(tw, " AgentEndpointID:\t%s\n", a.AgentEndpointID) } } } + +// sortedKeys returns the keys of a string-keyed map in lexicographic order. +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go index a26bd2d8f20..25508b5968a 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go @@ -64,12 +64,16 @@ func runRoutineList(ctx context.Context, cmd *cobra.Command, output string) erro fmt.Fprintln(tw, "----\t-------\t-------\t------") for _, r := range items { triggerType := "" + // Pick a representative trigger type for the table summary; use the + // "default" key if present, else fall back to the first sorted key. if t, ok := r.Triggers[routines.DefaultTriggerKey]; ok { triggerType = t.Type + } else if keys := sortedKeys(r.Triggers); len(keys) > 0 { + triggerType = r.Triggers[keys[0]].Type } actionType := "" - if a, ok := r.Actions[routines.DefaultActionKey]; ok { - actionType = a.Type + if r.Action != nil { + actionType = r.Action.Type } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.Name, diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index ed40e2b71b7..6c16c7f59e2 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -70,8 +70,8 @@ func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { if len(file.Triggers) > 0 && len(body.Triggers) == 0 { body.Triggers = file.Triggers } - if len(file.Actions) > 0 && len(body.Actions) == 0 { - body.Actions = file.Actions + if file.Action != nil && body.Action == nil { + body.Action = file.Action } } @@ -79,8 +79,8 @@ func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { // It returns the count of fields changed. func applyUpdateFlags( existing *routines.Routine, - description, cron, timeZone, at, agentName, agentEndpointID, conversationID, sessionID string, - descChanged, cronChanged, tzChanged, atChanged, agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged bool, + description, cron, timeZone, at, agentID, agentEndpointID, conversationID, sessionID string, + descChanged, cronChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged bool, ) (int, error) { changed := 0 @@ -95,7 +95,7 @@ func applyUpdateFlags( if trigger == nil { return 0, fmt.Errorf("cannot set --cron: routine has no default trigger") } - trigger.Cron = cron + trigger.CronExpression = cron changed++ } if tzChanged { @@ -121,52 +121,49 @@ func applyUpdateFlags( // Action field updates action := getAction(existing) - if agentNameChanged || agentEpChanged { + if agentIDChanged || agentEpChanged { if action == nil { - return 0, fmt.Errorf("cannot update agent fields: routine has no default action") + return 0, fmt.Errorf("cannot update agent fields: routine has no action") } - // agent-name and agent-endpoint-id are mutually exclusive; specifying one clears the other. - if agentNameChanged && agentEpChanged && agentName != "" && agentEndpointID != "" { + // agent-id and agent-endpoint-id are mutually exclusive; specifying one clears the other. + if agentIDChanged && agentEpChanged && agentID != "" && agentEndpointID != "" { return 0, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-name and --agent-endpoint-id are mutually exclusive", - "provide either --agent-name or --agent-endpoint-id, not both", + "--agent-id and --agent-endpoint-id are mutually exclusive", + "provide either --agent-id or --agent-endpoint-id, not both", ) } - if agentNameChanged { - action.AgentName = agentName - if agentName != "" { - action.AgentEndpointID = "" // specifying agent-name clears agent-endpoint-id + if agentIDChanged { + action.AgentID = agentID + if agentID != "" { + action.AgentEndpointID = "" // specifying agent-id clears agent-endpoint-id } changed++ } if agentEpChanged { action.AgentEndpointID = agentEndpointID if agentEndpointID != "" { - action.AgentName = "" // specifying agent-endpoint-id clears agent-name + action.AgentID = "" // specifying agent-endpoint-id clears agent-id } changed++ } } if convIDChanged { if action == nil { - return 0, fmt.Errorf("cannot set --conversation-id: routine has no default action") + return 0, fmt.Errorf("cannot set --conversation-id: routine has no action") } action.ConversationID = conversationID changed++ } if sessIDChanged { if action == nil { - return 0, fmt.Errorf("cannot set --session-id: routine has no default action") + return 0, fmt.Errorf("cannot set --session-id: routine has no action") } action.SessionID = sessionID changed++ } if action != nil { - if existing.Actions == nil { - existing.Actions = make(map[string]routines.RoutineAction) - } - existing.Actions[routines.DefaultActionKey] = *action + existing.Action = action } return changed, nil @@ -181,11 +178,11 @@ func getTrigger(r *routines.Routine) *routines.RoutineTrigger { return nil } -// getAction returns a copy of the default action, or nil. +// getAction returns a copy of the routine action, or nil. func getAction(r *routines.Routine) *routines.RoutineAction { - if a, ok := r.Actions[routines.DefaultActionKey]; ok { - cp := a - return &cp + if r.Action == nil { + return nil } - return nil + cp := *r.Action + return &cp } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index fe714d96a78..3ce5a14a123 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -23,11 +23,9 @@ func TestReadRoutineManifest_JSON(t *testing.T) { Name: "test-routine", Description: "a test routine", Triggers: map[string]routines.RoutineTrigger{ - "default": {Type: "schedule", Cron: "0 8 * * 1-5"}, - }, - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentName: "my-agent"}, + "default": {Type: "schedule", CronExpression: "0 8 * * 1-5"}, }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "my-agent-id"}, } data, err := json.Marshal(r) require.NoError(t, err) @@ -40,8 +38,9 @@ func TestReadRoutineManifest_JSON(t *testing.T) { assert.Equal(t, "test-routine", got.Name) assert.Equal(t, "a test routine", got.Description) assert.Equal(t, "schedule", got.Triggers["default"].Type) - assert.Equal(t, "0 8 * * 1-5", got.Triggers["default"].Cron) - assert.Equal(t, "my-agent", got.Actions["default"].AgentName) + assert.Equal(t, "0 8 * * 1-5", got.Triggers["default"].CronExpression) + require.NotNil(t, got.Action) + assert.Equal(t, "my-agent-id", got.Action.AgentID) } func TestReadRoutineManifest_YAML(t *testing.T) { @@ -52,10 +51,9 @@ triggers: default: type: timer at: "2026-01-01T00:00:00Z" -actions: - default: - type: invoke_agent_responses_api - agent_name: yaml-agent +action: + type: invoke_agent_responses_api + agent_id: yaml-agent-id ` path := filepath.Join(t.TempDir(), "routine.yaml") require.NoError(t, os.WriteFile(path, []byte(yaml), 0600)) @@ -64,7 +62,8 @@ actions: require.NoError(t, err) assert.Equal(t, "yaml-routine", got.Name) assert.Equal(t, "timer", got.Triggers["default"].Type) - assert.Equal(t, "yaml-agent", got.Actions["default"].AgentName) + require.NotNil(t, got.Action) + assert.Equal(t, "yaml-agent-id", got.Action.AgentID) } func TestReadRoutineManifest_FileNotFound(t *testing.T) { @@ -89,65 +88,60 @@ func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { body := &routines.Routine{Name: "from-cli"} file := &routines.Routine{ Description: "from file", - Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", Cron: "* * * * *"}}, - Actions: map[string]routines.RoutineAction{"default": {Type: "invoke_agent_responses_api", AgentName: "a"}}, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", CronExpression: "* * * * *"}}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "a"}, } mergeRoutineFromFile(body, file) assert.Equal(t, "from-cli", body.Name, "name must not be overwritten by file") assert.Equal(t, "from file", body.Description) assert.Equal(t, "schedule", body.Triggers["default"].Type) - assert.Equal(t, "a", body.Actions["default"].AgentName) + require.NotNil(t, body.Action) + assert.Equal(t, "a", body.Action.AgentID) } func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { t.Parallel() - enabled := true body := &routines.Routine{ Name: "from-cli", Description: "cli description", - Enabled: &enabled, + Enabled: new(true), Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "timer", At: "2026-01-01T00:00:00Z"}, }, - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentName: "cli-agent"}, - }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "cli-agent"}, } file := &routines.Routine{ Description: "file description", Triggers: map[string]routines.RoutineTrigger{ - "default": {Type: "schedule", Cron: "* * * * *"}, - }, - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, + "default": {Type: "schedule", CronExpression: "* * * * *"}, }, + Action: &routines.RoutineAction{Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, } mergeRoutineFromFile(body, file) assert.Equal(t, "cli description", body.Description, "body description must win") assert.Equal(t, "timer", body.Triggers["default"].Type, "body trigger must win") - assert.Equal(t, "cli-agent", body.Actions["default"].AgentName, "body action must win") + require.NotNil(t, body.Action) + assert.Equal(t, "cli-agent", body.Action.AgentID, "body action must win") } // ─── applyUpdateFlags ───────────────────────────────────────────────────────── -func routine_with_schedule_and_agentresp() *routines.Routine { +func routineWithScheduleAndAgentResp() *routines.Routine { return &routines.Routine{ Name: "my-routine", Description: "old desc", Triggers: map[string]routines.RoutineTrigger{ - "default": {Type: "schedule", Cron: "0 8 * * *", TimeZone: "UTC"}, - }, - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentName: "old-agent"}, + "default": {Type: "schedule", CronExpression: "0 8 * * *", TimeZone: "UTC"}, }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent-id"}, } } func TestApplyUpdateFlags_Description(t *testing.T) { t.Parallel() - r := routine_with_schedule_and_agentresp() + r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, "new desc", "", "", "", "", "", "", "", true, false, false, false, false, false, false, false, @@ -159,19 +153,19 @@ func TestApplyUpdateFlags_Description(t *testing.T) { func TestApplyUpdateFlags_Cron(t *testing.T) { t.Parallel() - r := routine_with_schedule_and_agentresp() + r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, "", "0 9 * * 1-5", "", "", "", "", "", "", false, true, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) - assert.Equal(t, "0 9 * * 1-5", r.Triggers["default"].Cron) + assert.Equal(t, "0 9 * * 1-5", r.Triggers["default"].CronExpression) } func TestApplyUpdateFlags_TimeZone(t *testing.T) { t.Parallel() - r := routine_with_schedule_and_agentresp() + r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, "", "", "America/New_York", "", "", "", "", "", false, false, true, false, false, false, false, false, @@ -181,30 +175,27 @@ func TestApplyUpdateFlags_TimeZone(t *testing.T) { assert.Equal(t, "America/New_York", r.Triggers["default"].TimeZone) } -func TestApplyUpdateFlags_AgentNameClearsEndpointID(t *testing.T) { +func TestApplyUpdateFlags_AgentIDClearsEndpointID(t *testing.T) { t.Parallel() r := &routines.Routine{ - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, - }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, - "", "", "", "", "new-agent", "", "", "", + "", "", "", "", "new-agent-id", "", "", "", false, false, false, false, true, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) - assert.Equal(t, "new-agent", r.Actions["default"].AgentName) - assert.Empty(t, r.Actions["default"].AgentEndpointID, "setting agent-name should clear agent-endpoint-id") + require.NotNil(t, r.Action) + assert.Equal(t, "new-agent-id", r.Action.AgentID) + assert.Empty(t, r.Action.AgentEndpointID, "setting agent-id should clear agent-endpoint-id") } -func TestApplyUpdateFlags_AgentEndpointIDClearsName(t *testing.T) { +func TestApplyUpdateFlags_AgentEndpointIDClearsID(t *testing.T) { t.Parallel() r := &routines.Routine{ - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentName: "old-agent"}, - }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent-id"}, Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, @@ -213,15 +204,16 @@ func TestApplyUpdateFlags_AgentEndpointIDClearsName(t *testing.T) { ) require.NoError(t, err) assert.Equal(t, 1, n) - assert.Equal(t, "new-ep", r.Actions["default"].AgentEndpointID) - assert.Empty(t, r.Actions["default"].AgentName, "setting agent-endpoint-id should clear agent-name") + require.NotNil(t, r.Action) + assert.Equal(t, "new-ep", r.Action.AgentEndpointID) + assert.Empty(t, r.Action.AgentID, "setting agent-endpoint-id should clear agent-id") } func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { t.Parallel() - r := routine_with_schedule_and_agentresp() + r := routineWithScheduleAndAgentResp() _, err := applyUpdateFlags(r, - "", "", "", "", "new-agent", "new-ep", "", "", + "", "", "", "", "new-agent-id", "new-ep", "", "", false, false, false, false, true, true, false, false, ) assert.Error(t, err) @@ -229,7 +221,7 @@ func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { t.Parallel() - r := routine_with_schedule_and_agentresp() + r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, "", "", "", "", "", "", "", "", false, false, false, false, false, false, false, false, @@ -250,15 +242,14 @@ func TestGetTrigger_ReturnsCopy(t *testing.T) { t.Parallel() r := &routines.Routine{ Triggers: map[string]routines.RoutineTrigger{ - "default": {Type: "schedule", Cron: "0 9 * * *"}, + "default": {Type: "schedule", CronExpression: "0 9 * * *"}, }, } trig := getTrigger(r) require.NotNil(t, trig) assert.Equal(t, "schedule", trig.Type) - // Modifying copy must not affect original. - trig.Cron = "changed" - assert.Equal(t, "0 9 * * *", r.Triggers["default"].Cron) + trig.CronExpression = "changed" + assert.Equal(t, "0 9 * * *", r.Triggers["default"].CronExpression) } func TestGetAction_NilWhenEmpty(t *testing.T) { @@ -270,13 +261,10 @@ func TestGetAction_NilWhenEmpty(t *testing.T) { func TestGetAction_ReturnsCopy(t *testing.T) { t.Parallel() r := &routines.Routine{ - Actions: map[string]routines.RoutineAction{ - "default": {Type: "invoke_agent_responses_api", AgentName: "orig-agent"}, - }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "orig-agent-id"}, } act := getAction(r) require.NotNil(t, act) - // Modifying copy must not affect original. - act.AgentName = "changed" - assert.Equal(t, "orig-agent", r.Actions["default"].AgentName) + act.AgentID = "changed" + assert.Equal(t, "orig-agent-id", r.Action.AgentID) } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go index e1f2acaa3f3..2e4cff73ab6 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go @@ -89,11 +89,11 @@ func runRoutineRunList(ctx context.Context, cmd *cobra.Command, routineName stri tw := newTabWriter() defer tw.Flush() - fmt.Fprintln(tw, "ID\tSTATUS\tSTARTED\tENDED") - fmt.Fprintln(tw, "--\t------\t-------\t-----") + fmt.Fprintln(tw, "ID\tSTATUS\tPHASE\tSTARTED\tENDED") + fmt.Fprintln(tw, "--\t------\t-----\t-------\t-----") for _, run := range items { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", - run.ID, run.Status, run.StartedAt, run.EndedAt) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + run.ID, run.Status, run.Phase, run.StartedAt, run.EndedAt) } return nil } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go index 8050e882b5a..13772926daf 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -22,7 +22,7 @@ type routineUpdateFlags struct { cron string timeZone string at string - agentName string + agentID string agentEndpointID string conversationID string sessionID string @@ -61,7 +61,7 @@ To change the trigger or action type, delete and recreate the routine.`, cmd.Flags().StringVar(&flags.cron, "cron", "", "New cron expression for recurring trigger") cmd.Flags().StringVar(&flags.timeZone, "time-zone", "", "New time zone for the trigger") cmd.Flags().StringVar(&flags.at, "at", "", "New ISO 8601 datetime for timer trigger") - cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "New agent name") + cmd.Flags().StringVar(&flags.agentID, "agent-id", "", "New project-scoped agent ID") cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "New agent endpoint ID") cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", "New conversation ID (preview)") cmd.Flags().StringVar(&flags.sessionID, "session-id", "", "New session ID") @@ -121,7 +121,7 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd cronChanged := cmd.Flags().Changed("cron") tzChanged := cmd.Flags().Changed("time-zone") atChanged := cmd.Flags().Changed("at") - agentNameChanged := cmd.Flags().Changed("agent-name") + agentIDChanged := cmd.Flags().Changed("agent-id") agentEpChanged := cmd.Flags().Changed("agent-endpoint-id") convIDChanged := cmd.Flags().Changed("conversation-id") sessIDChanged := cmd.Flags().Changed("session-id") @@ -129,9 +129,9 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd changed, err := applyUpdateFlags( existing, flags.description, flags.cron, flags.timeZone, flags.at, - flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID, + flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, descChanged, cronChanged, tzChanged, atChanged, - agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged, + agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged, ) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 532bac8dca5..319b1b7a2aa 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -22,7 +22,7 @@ import ( const ( routinesAPIVersion = "v1" - routinesPreviewHeader = "x-ms-foundry-features-opt-in" + routinesPreviewHeader = "Foundry-Features" routinesPreviewValue = "Routines=V1Preview" ) @@ -70,7 +70,9 @@ func (c *Client) routinesURL(extraQuery ...string) string { return base } -// routineActionURL returns the URL for a named routine action (enable/disable/dispatch_async). +// routineActionURL returns the URL for a named routine action route +// (e.g. :dispatch, :dispatchAsync). The action segment is case-sensitive +// and must match the TypeSpec route exactly. func (c *Client) routineActionURL(name, action string) string { return fmt.Sprintf("%s/routines/%s:%s?api-version=%s", c.endpoint, url.PathEscape(name), action, routinesAPIVersion) } @@ -130,11 +132,10 @@ func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { } all = append(all, page.Value...) - if page.ContinuationToken != "" { - nextURL = c.routinesURL("continuationToken=" + url.QueryEscape(page.ContinuationToken)) - } else { - nextURL = "" - } + // The service returns an absolute nextLink URL when more pages exist + // (Azure.Core.Page). We follow it verbatim after a same-origin + // check rather than re-deriving the continuation query string. + nextURL = page.NextLink } return all, nil @@ -213,48 +214,42 @@ func (c *Client) DeleteRoutine(ctx context.Context, name string) error { return nil } -// EnableRoutine calls the :enable action route for a routine. +// EnableRoutine flips `enabled` to true via PUT. +// The Foundry Routines API does not expose a dedicated :enable route; the +// client mutates the routine resource directly. func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { - return c.postAction(ctx, name, "enable") + return c.setEnabled(ctx, name, true) } -// DisableRoutine calls the :disable action route for a routine. +// DisableRoutine flips `enabled` to false via PUT. func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { - return c.postAction(ctx, name, "disable") + return c.setEnabled(ctx, name, false) } -// postAction performs a POST to a named action route and returns the resulting routine. -func (c *Client) postAction(ctx context.Context, name, action string) (*Routine, error) { - req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, action)) +// setEnabled performs a GET + PUT to mutate the `enabled` field. It returns +// the current routine without an extra round-trip if the field is already +// at the desired value (idempotent enable/disable). +func (c *Client) setEnabled(ctx context.Context, name string, enabled bool) (*Routine, error) { + existing, err := c.GetRoutine(ctx, name) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - addPreviewHeader(req) - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK) { - return nil, runtime.NewResponseError(resp) - } - - var result Routine - if err := decodeJSON(resp.Body, &result); err != nil { return nil, err } - return &result, nil + if existing.Enabled != nil && *existing.Enabled == enabled { + return existing, nil + } + existing.Enabled = &enabled + return c.PutRoutine(ctx, name, existing) } -// DispatchRoutineAsync calls the :dispatch_async action route. +// DispatchRoutineAsync calls the :dispatchAsync action route. +// The action segment is camelCase per TypeSpec; do not change it to +// snake_case without first updating the Foundry Routines spec. func (c *Client) DispatchRoutineAsync( ctx context.Context, name string, payload *DispatchRoutineRequest, ) (*DispatchRoutineResponse, error) { - req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatch_async")) + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatchAsync")) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index c567602c35b..da5d91691ae 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -5,12 +5,17 @@ package routines // Routine represents a Foundry routine resource. +// Field shapes track the Routines TypeSpec (azure-rest-api-specs PR #42779): +// - `triggers` is a map keyed by user-defined identifiers. +// - `action` is a single discriminated object, not a map. type Routine struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` Triggers map[string]RoutineTrigger `json:"triggers,omitempty" yaml:"triggers,omitempty"` - Actions map[string]RoutineAction `json:"actions,omitempty" yaml:"actions,omitempty"` + Action *RoutineAction `json:"action,omitempty" yaml:"action,omitempty"` + CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` } // RoutineTrigger is the discriminated union for routine triggers. @@ -19,19 +24,22 @@ type Routine struct { // - "timer": one-shot timer trigger // - "github_issue": GitHub issue event trigger (deferred) type RoutineTrigger struct { - Type string `json:"type" yaml:"type"` + Type string `json:"type" yaml:"type"` - // schedule / timer fields - Cron string `json:"cron,omitempty" yaml:"cron,omitempty"` - TimeZone string `json:"time_zone,omitempty" yaml:"time_zone,omitempty"` + // schedule fields + CronExpression string `json:"cron_expression,omitempty" yaml:"cron_expression,omitempty"` + + // schedule / timer shared + TimeZone string `json:"time_zone,omitempty" yaml:"time_zone,omitempty"` // timer-only fields - At string `json:"at,omitempty" yaml:"at,omitempty"` + At string `json:"at,omitempty" yaml:"at,omitempty"` // github_issue fields (deferred in v1) - Connection string `json:"connection,omitempty" yaml:"connection,omitempty"` - Assignee string `json:"assignee,omitempty" yaml:"assignee,omitempty"` - Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` + ConnectionID string `json:"connection_id,omitempty" yaml:"connection_id,omitempty"` + Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` + Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` } // RoutineAction is the discriminated union for routine actions. @@ -39,26 +47,36 @@ type RoutineTrigger struct { // - "invoke_agent_responses_api" (CLI alias: "agent-response") // - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") type RoutineAction struct { - Type string `json:"type" yaml:"type"` - AgentName string `json:"agent_name,omitempty" yaml:"agent_name,omitempty"` + Type string `json:"type" yaml:"type"` + AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` AgentEndpointID string `json:"agent_endpoint_id,omitempty" yaml:"agent_endpoint_id,omitempty"` ConversationID string `json:"conversation_id,omitempty" yaml:"conversation_id,omitempty"` SessionID string `json:"session_id,omitempty" yaml:"session_id,omitempty"` } -// PagedRoutine represents a page of routine resources. +// PagedRoutine represents a page of routine resources. The service returns an +// `nextLink` absolute URL when more pages exist (Azure.Core.Page). type PagedRoutine struct { - Value []Routine `json:"value"` - ContinuationToken string `json:"continuation_token,omitempty"` + Value []Routine `json:"value"` + NextLink string `json:"nextLink,omitempty"` } // RoutineRun represents a single routine execution record. type RoutineRun struct { - ID string `json:"id,omitempty"` - Status string `json:"status,omitempty"` - StartedAt string `json:"started_at,omitempty"` - EndedAt string `json:"ended_at,omitempty"` - Error string `json:"error,omitempty"` + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Phase string `json:"phase,omitempty"` + TriggerType string `json:"trigger_type,omitempty"` + AttemptSource string `json:"attempt_source,omitempty"` + ActionType string `json:"action_type,omitempty"` + TriggeredAt string `json:"triggered_at,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` + ResponseID string `json:"response_id,omitempty"` + ErrorType string `json:"error_type,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` } // PagedRoutineRun represents a page of routine run records. @@ -67,17 +85,24 @@ type PagedRoutineRun struct { NextPageToken string `json:"next_page_token,omitempty"` } -// DispatchRoutineRequest is the request body for the dispatch_async route. +// RoutineDispatchPayload is the discriminated dispatch payload. The "type" +// field matches the routine action type (invoke_agent_responses_api or +// invoke_agent_invocations_api). +type RoutineDispatchPayload struct { + Type string `json:"type"` + Input string `json:"input,omitempty"` +} + +// DispatchRoutineRequest is the request body for the :dispatch / :dispatchAsync +// routes. The payload wrapper is required for :dispatchAsync. type DispatchRoutineRequest struct { - Input string `json:"input,omitempty"` - ConversationID string `json:"conversation_id,omitempty"` + Payload *RoutineDispatchPayload `json:"payload,omitempty"` } -// DispatchRoutineResponse is the response from the dispatch_async route. +// DispatchRoutineResponse is the response from the :dispatch / :dispatchAsync routes. type DispatchRoutineResponse struct { DispatchID string `json:"dispatch_id,omitempty"` ActionCorrelationID string `json:"action_correlation_id,omitempty"` - Status string `json:"status,omitempty"` } // TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. @@ -95,6 +120,3 @@ var ActionCLIToWire = map[string]string{ // DefaultTriggerKey is the map key used for the single trigger in create/update. const DefaultTriggerKey = "default" - -// DefaultActionKey is the map key used for the single action in create/update. -const DefaultActionKey = "default" diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go index 8aabec2239d..596c990c02b 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go @@ -33,7 +33,6 @@ func TestActionCLIToWire_AllEntriesPresent(t *testing.T) { func TestDefaultKeys(t *testing.T) { t.Parallel() assert.Equal(t, "default", DefaultTriggerKey) - assert.Equal(t, "default", DefaultActionKey) } func TestTriggerCLIToWire_NoUnknownEntries(t *testing.T) { From 4a9cbe37870c85b403523b6dff9935ee4ca3dba3 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 21:28:34 +0800 Subject: [PATCH 10/21] fix(routines): clarify comment for github_issue fields in RoutineTrigger --- .../azure.ai.routines/internal/pkg/routines/models.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index da5d91691ae..3da38ca1f03 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -35,7 +35,7 @@ type RoutineTrigger struct { // timer-only fields At string `json:"at,omitempty" yaml:"at,omitempty"` - // github_issue fields (deferred in v1) + // github_issue fields ConnectionID string `json:"connection_id,omitempty" yaml:"connection_id,omitempty"` Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` From 1ff548766f305cfcddb1c906064c0a55231f4fa9 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 20 May 2026 14:09:25 +0800 Subject: [PATCH 11/21] fix(routines): address PR review feedback - endpoint: reject project endpoints with an explicit port so the normalized URL cannot silently strip a non-default port - routine create: only set Enabled from --enabled when the user explicitly passes the flag, so a manifest's enabled value is honored; default to enabled=true if neither source provides one - routine create: explicitly reject --trigger github-issue (deferred for v1) instead of producing an incomplete github_issue trigger - routine_helpers: boolStr now returns "unknown" for a nil pointer to avoid displaying "true" when the field is absent from the service response - routine_manifest: surface applyUpdateFlags user-input errors as exterrors.Validation (CodeInvalidParameter) for consistent CLI error shapes --- .../internal/cmd/endpoint.go | 11 ++++++ .../internal/cmd/endpoint_test.go | 2 ++ .../internal/cmd/routine_create.go | 27 +++++++++++++- .../internal/cmd/routine_create_test.go | 9 +++++ .../internal/cmd/routine_helpers.go | 6 ++-- .../internal/cmd/routine_manifest.go | 36 +++++++++++++++---- 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go index e168b0a2bb2..24fa80b92e6 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -78,6 +78,17 @@ func validateProjectEndpoint(raw string) (normalized string, err error) { ) } + // Reject explicit ports: Foundry hosts are reached on the default HTTPS + // port (443) and accepting other ports would silently misroute traffic + // (the normalized URL strips the port). + if u.Port() != "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not contain an explicit port", + "remove the port from the URL (Foundry uses the default HTTPS port 443)", + ) + } + host := u.Hostname() if host == "" || !isFoundryHost(host) { return "", exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go index afcf94d99e0..a47ed3a6a38 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go @@ -115,6 +115,8 @@ func TestValidateProjectEndpoint_Rejections(t *testing.T) { {name: "non-foundry host", raw: "https://management.azure.com/api/projects/x"}, {name: "no scheme", raw: "myaccount.services.ai.azure.com/api/projects/x"}, {name: "localhost", raw: "https://localhost/api/projects/x"}, + {name: "explicit port", raw: "https://myaccount.services.ai.azure.com:444/api/projects/x"}, + {name: "default port still rejected", raw: "https://myaccount.services.ai.azure.com:443/api/projects/x"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index c80a0e53a78..baa0a3f3230 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -100,7 +100,12 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre var body routines.Routine body.Name = flags.name - body.Enabled = new(flags.enabled) + // Only set Enabled from the flag when the user explicitly passed it. + // Otherwise let the manifest fill it in (file mode), and the post-merge + // fallback below defaults to enabled=true. + if cmd.Flags().Changed("enabled") { + body.Enabled = new(flags.enabled) + } if flags.description != "" { body.Description = flags.description } @@ -146,6 +151,13 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre body.Action = &action } + // Default Enabled to true when neither the flag nor the manifest provided + // a value. This matches the documented "enabled by default on creation" + // behavior while still letting a manifest's explicit `enabled: false` win. + if body.Enabled == nil { + body.Enabled = new(true) + } + client, _, err := newRoutineClient(ctx, cmd) if err != nil { return err @@ -182,6 +194,19 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre // buildTrigger constructs a RoutineTrigger from CLI flags. func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { + // "github-issue" is present in TriggerCLIToWire (it maps to the wire type + // for the deferred GitHub issue trigger), but the CLI does not yet support + // supplying the fields it needs (connection, owner, repository, actions). + // Reject it explicitly so users get a clear "deferred" message instead of + // a silently incomplete request. + if flags.trigger == "github-issue" { + return routines.RoutineTrigger{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + "trigger type 'github-issue' is not yet supported by the CLI", + "use --trigger recurring or --trigger timer; github-issue is deferred to a future release", + ) + } + wireType, ok := routines.TriggerCLIToWire[flags.trigger] if !ok { return routines.RoutineTrigger{}, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index 4cc1e2617fd..b4986afcd77 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -62,6 +62,15 @@ func TestBuildTrigger_UnknownType(t *testing.T) { assert.Error(t, err) } +func TestBuildTrigger_GithubIssueRejected(t *testing.T) { + t.Parallel() + // "github-issue" is in TriggerCLIToWire but is deferred for v1; buildTrigger + // must reject it explicitly rather than producing an incomplete trigger. + flags := &routineCreateFlags{trigger: "github-issue"} + _, err := buildTrigger(flags) + assert.Error(t, err) +} + // ─── buildAction ────────────────────────────────────────────────────────────── func TestBuildAction_AgentResponseByID(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go index 76b7de4b356..5b76f711b38 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -56,10 +56,12 @@ func newTabWriter() *tabwriter.Writer { return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) } -// boolStr returns "true"/"false" for a *bool pointer. +// boolStr returns a human-readable string for a *bool field. +// Returns "unknown" when the pointer is nil so callers don't silently +// display a default that wasn't actually returned by the service. func boolStr(b *bool) string { if b == nil { - return "true" + return "unknown" } if *b { return "true" diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index 6c16c7f59e2..160bcf27cbf 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -93,21 +93,33 @@ func applyUpdateFlags( trigger := getTrigger(existing) if cronChanged { if trigger == nil { - return 0, fmt.Errorf("cannot set --cron: routine has no default trigger") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --cron: routine has no default trigger", + "add a trigger by recreating the routine, or omit --cron", + ) } trigger.CronExpression = cron changed++ } if tzChanged { if trigger == nil { - return 0, fmt.Errorf("cannot set --time-zone: routine has no default trigger") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --time-zone: routine has no default trigger", + "add a trigger by recreating the routine, or omit --time-zone", + ) } trigger.TimeZone = timeZone changed++ } if atChanged { if trigger == nil { - return 0, fmt.Errorf("cannot set --at: routine has no default trigger") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --at: routine has no default trigger", + "add a trigger by recreating the routine, or omit --at", + ) } trigger.At = at changed++ @@ -123,7 +135,11 @@ func applyUpdateFlags( action := getAction(existing) if agentIDChanged || agentEpChanged { if action == nil { - return 0, fmt.Errorf("cannot update agent fields: routine has no action") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot update agent fields: routine has no action", + "add an action by recreating the routine, or omit --agent-id / --agent-endpoint-id", + ) } // agent-id and agent-endpoint-id are mutually exclusive; specifying one clears the other. if agentIDChanged && agentEpChanged && agentID != "" && agentEndpointID != "" { @@ -150,14 +166,22 @@ func applyUpdateFlags( } if convIDChanged { if action == nil { - return 0, fmt.Errorf("cannot set --conversation-id: routine has no action") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --conversation-id: routine has no action", + "add an action by recreating the routine, or omit --conversation-id", + ) } action.ConversationID = conversationID changed++ } if sessIDChanged { if action == nil { - return 0, fmt.Errorf("cannot set --session-id: routine has no action") + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --session-id: routine has no action", + "add an action by recreating the routine, or omit --session-id", + ) } action.SessionID = sessionID changed++ From 8ce5954b58e8506d58b5b89fdc85eee6bbf803b8 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 16:36:21 +0800 Subject: [PATCH 12/21] chore(routines): add .golangci.yaml and AGENTS.md to align with sibling extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other AI extensions (projects, agents, toolboxes, inspector) ship a .golangci.yaml lint config and an AGENTS.md contributor guide. Add both to azure.ai.routines so it follows the same convention, and register the project-specific xterrors word in cspell.yaml. --- .../azure.ai.routines/.golangci.yaml | 17 +++ .../extensions/azure.ai.routines/AGENTS.md | 132 ++++++++++++++++++ .../internal/cmd/routine_manifest.go | 1 + 3 files changed, 150 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.routines/.golangci.yaml create mode 100644 cli/azd/extensions/azure.ai.routines/AGENTS.md diff --git a/cli/azd/extensions/azure.ai.routines/.golangci.yaml b/cli/azd/extensions/azure.ai.routines/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.routines/AGENTS.md b/cli/azd/extensions/azure.ai.routines/AGENTS.md new file mode 100644 index 00000000000..5a2bfe6f5e6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/AGENTS.md @@ -0,0 +1,132 @@ +# Azure AI Routines Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd +instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.routines` is a first-party azd extension under +`cli/azd/extensions/azure.ai.routines/`. It runs as a separate Go binary and talks +to the azd host over gRPC. + +The user-facing surface is `azd ai routine ` — CRUD over Microsoft Foundry +Routines attached to a Foundry project endpoint. + +Useful places to start: + +- `internal/cmd/`: Cobra commands and verb implementations +- Project-endpoint resolution comes from the sibling `azure.ai.projects` + extension (and the shared cascade); do not re-implement it here. + +## Build and test + +From `cli/azd/extensions/azure.ai.routines`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build + +# Run unit tests +go test ./... -count=1 +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd + dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the +core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of +the version in `go.mod`. Do not merge the extension with that `replace` still +present. + +## Error handling + +Return plain Go errors by default, and wrap lower-level failures with +`fmt.Errorf("context: %w", err)` where useful. + +If this extension grows enough to need stable telemetry categories, error codes, +or user-facing suggestions, introduce an `internal/exterrors` package modeled on +the one in `azure.ai.agents` / `azure.ai.toolboxes`: + +- Create a structured error once, as close as possible to the place where you + know the final category, code, and suggestion. +- If `err` is already a structured error, return it unchanged. Do **not** wrap + it with `fmt.Errorf("context: %w", err)` — during gRPC serialization, azd + preserves the structured error's own message/code/category, not the outer + wrapper text. +- Prefer the dedicated helpers at the Azure/gRPC boundary: + - `exterrors.ServiceFromAzure(err, operation)` for `azcore.ResponseError` + (data-plane and ARM calls). + - `exterrors.FromPrompt(err, contextMessage)` for `azdClient.Prompt().*` + failures. + +## Release preparation + +A new extension release ships in two PRs: + +### PR 1 — Version bump + +Bumps the extension to the new version. Touches only: + +- `version.txt` — new semver string +- `extension.yaml` — `version:` field +- `CHANGELOG.md` — new release section at the top + +Once merged, the team triggers the CI release pipeline, which builds, signs, and +publishes the extension binaries as a GitHub release. + +### PR 2 — Registry update + +After the GitHub release is live, a follow-up PR updates +`cli/azd/extensions/registry.json` so azd users can install the new version. +The contents of that file are produced by running `azd x publish` against the +published release artifacts (which computes the artifact URLs and checksums). +The resulting PR should contain only the regenerated `registry.json` entry for +the extension, and in some cases updated test snapshots as well. + +## Output: `log` vs `fmt` + +Extensions write directly to stdout/stderr — there is no `Console` abstraction +from azd core. + +- **`fmt.Print*`** — user-facing output (stdout). Pair with `output.With*Format` + helpers for styled text. +- **`log.Print*`** — developer diagnostics (stderr). Hidden unless `--debug` + is set. Never use `log` for anything the user needs to see. +- Do not use `log.Fatal` or `log.Panic` for expected failures — return an error + instead. + +```go +// ✅ log — internal detail the user doesn't need to see +log.Printf("routine show: pending-routine read failed for %q: %v", name, err) + +// ✅ fmt — user-facing status and results +fmt.Printf("Created routine %s at version %s.\n", name, version) + +// ❌ fmt used for debug noise — user sees internal details they can't act on +fmt.Printf("Parsed endpoint: host=%s, path=%s\n", host, path) // use log.Printf + +// ❌ log used for user-facing info — user never sees it without --debug +log.Printf("No routines found on project") // use fmt.Print* +``` + +## Other extension conventions + +- Use modern Go 1.26 patterns where they help readability. +- Reserved azd globals (`--output`, `--no-prompt`) are inherited from `extCtx`, + not registered as flags on each verb. +- Lowercase-normalize `--output` when reading it from `extCtx` so downstream + branches can compare with `== "json"`. +- When using `PromptSubscription()`, create credentials with + `Subscription.UserTenantId`, not `Subscription.TenantId`. diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index 160bcf27cbf..2a1b683bc9a 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -18,6 +18,7 @@ import ( // readRoutineManifest reads and parses a routine manifest from a YAML or JSON file. func readRoutineManifest(path string) (*routines.Routine, error) { + // #nosec G304 - path is provided by the user via --file and is intentional data, err := os.ReadFile(path) if err != nil { return nil, exterrors.Dependency( From 4aa3627c187e60f0fbb1e331e39ec38b29454ded Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 19:25:14 +0800 Subject: [PATCH 13/21] fix(routines): use camelCase JSON tags to match Foundry service wire The deployed Foundry Routines data plane applies a camelCase property naming policy on the wire (e.g. `cronExpression`, `timeZone`, `agentId`), even though the upstream TypeSpec / OpenAPI document still emits snake_case. With snake_case JSON tags, `routine create` and `update` always failed with errors like: triggers['default'].cronExpression must be provided for schedule routines exactly one of action.agentId or action.agentEndpointId must be provided and routines read back from `show` / `list` would have empty trigger/action fields because the camelCase wire payload did not deserialize into snake_case-tagged Go fields. Switch the JSON tags on `Routine`, `RoutineTrigger`, `RoutineAction`, `RoutineRun`, `PagedRoutineRun`, and `DispatchRoutineResponse` to camelCase so requests/responses round-trip cleanly against the deployed service. YAML tags stay snake_case so user-facing `--file` manifests keep the documented convention. Verified against a live project endpoint: create/list/show now reach the service correctly (residual `InternalServerError` from the backend is unrelated and reproduces from raw curl with the same body). --- .../internal/pkg/routines/models.go | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index 3da38ca1f03..3d08e3a26bd 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -8,14 +8,19 @@ package routines // Field shapes track the Routines TypeSpec (azure-rest-api-specs PR #42779): // - `triggers` is a map keyed by user-defined identifiers. // - `action` is a single discriminated object, not a map. +// +// JSON tags use camelCase to match the deployed Foundry service wire format, +// which applies a camelCase property-naming policy regardless of the +// snake_case casing in the OpenAPI document. YAML tags stay snake_case to +// match the user-facing manifest convention used in --file documentation. type Routine struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` Triggers map[string]RoutineTrigger `json:"triggers,omitempty" yaml:"triggers,omitempty"` Action *RoutineAction `json:"action,omitempty" yaml:"action,omitempty"` - CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` + CreatedAt string `json:"createdAt,omitempty" yaml:"created_at,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty" yaml:"updated_at,omitempty"` } // RoutineTrigger is the discriminated union for routine triggers. @@ -27,16 +32,16 @@ type RoutineTrigger struct { Type string `json:"type" yaml:"type"` // schedule fields - CronExpression string `json:"cron_expression,omitempty" yaml:"cron_expression,omitempty"` + CronExpression string `json:"cronExpression,omitempty" yaml:"cron_expression,omitempty"` // schedule / timer shared - TimeZone string `json:"time_zone,omitempty" yaml:"time_zone,omitempty"` + TimeZone string `json:"timeZone,omitempty" yaml:"time_zone,omitempty"` // timer-only fields At string `json:"at,omitempty" yaml:"at,omitempty"` // github_issue fields - ConnectionID string `json:"connection_id,omitempty" yaml:"connection_id,omitempty"` + ConnectionID string `json:"connectionId,omitempty" yaml:"connection_id,omitempty"` Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` @@ -48,10 +53,10 @@ type RoutineTrigger struct { // - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") type RoutineAction struct { Type string `json:"type" yaml:"type"` - AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` - AgentEndpointID string `json:"agent_endpoint_id,omitempty" yaml:"agent_endpoint_id,omitempty"` - ConversationID string `json:"conversation_id,omitempty" yaml:"conversation_id,omitempty"` - SessionID string `json:"session_id,omitempty" yaml:"session_id,omitempty"` + AgentID string `json:"agentId,omitempty" yaml:"agent_id,omitempty"` + AgentEndpointID string `json:"agentEndpointId,omitempty" yaml:"agent_endpoint_id,omitempty"` + ConversationID string `json:"conversationId,omitempty" yaml:"conversation_id,omitempty"` + SessionID string `json:"sessionId,omitempty" yaml:"session_id,omitempty"` } // PagedRoutine represents a page of routine resources. The service returns an @@ -66,23 +71,23 @@ type RoutineRun struct { ID string `json:"id,omitempty"` Status string `json:"status,omitempty"` Phase string `json:"phase,omitempty"` - TriggerType string `json:"trigger_type,omitempty"` - AttemptSource string `json:"attempt_source,omitempty"` - ActionType string `json:"action_type,omitempty"` - TriggeredAt string `json:"triggered_at,omitempty"` - StartedAt string `json:"started_at,omitempty"` - EndedAt string `json:"ended_at,omitempty"` - DispatchID string `json:"dispatch_id,omitempty"` - ActionCorrelationID string `json:"action_correlation_id,omitempty"` - ResponseID string `json:"response_id,omitempty"` - ErrorType string `json:"error_type,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` + TriggerType string `json:"triggerType,omitempty"` + AttemptSource string `json:"attemptSource,omitempty"` + ActionType string `json:"actionType,omitempty"` + TriggeredAt string `json:"triggeredAt,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + EndedAt string `json:"endedAt,omitempty"` + DispatchID string `json:"dispatchId,omitempty"` + ActionCorrelationID string `json:"actionCorrelationId,omitempty"` + ResponseID string `json:"responseId,omitempty"` + ErrorType string `json:"errorType,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` } // PagedRoutineRun represents a page of routine run records. type PagedRoutineRun struct { Value []RoutineRun `json:"value"` - NextPageToken string `json:"next_page_token,omitempty"` + NextPageToken string `json:"nextPageToken,omitempty"` } // RoutineDispatchPayload is the discriminated dispatch payload. The "type" @@ -101,8 +106,8 @@ type DispatchRoutineRequest struct { // DispatchRoutineResponse is the response from the :dispatch / :dispatchAsync routes. type DispatchRoutineResponse struct { - DispatchID string `json:"dispatch_id,omitempty"` - ActionCorrelationID string `json:"action_correlation_id,omitempty"` + DispatchID string `json:"dispatchId,omitempty"` + ActionCorrelationID string `json:"actionCorrelationId,omitempty"` } // TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. From 795d0ab1d58c81d773a2ed9d2521d165c8d4a242 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 20:22:23 +0800 Subject: [PATCH 14/21] feat(routines): align with spec PR #43186 and fix HTTP/2 hang Use azure-rest-api-specs PR #43186 (Foundry Routines TypeSpec) as the single source of truth for the routines extension, applying every spec change that does not break the currently deployed service, and documenting each deliberate divergence inline and in AGENTS.md. ## Spec alignment * `RoutineRun` and `DispatchRoutineResponse` gain the new `TaskID` field (wire `taskId`); the service already emits it. `dispatch` now prints `Task ID` after `Action Correlation ID`, and JSON output exposes the new field too. * `RoutineTrigger` is restructured to match the spec's `GitHubIssueOpenedRoutineTrigger` shape: dropped `Owner` / `Actions[]`, added `Assignee`. The github trigger is still deferred at the CLI surface, so this is safe. * Inline comments and a new AGENTS.md table call out each divergence the client deliberately keeps to stay compatible with the live service: camelCase wire naming (spec is snake_case), `agentId` field (spec renamed to `agent_name`), `:dispatchAsync` action segment (spec uses `:dispatch_async`), GET+PUT enable/disable fallback (spec adds dedicated routes which still 404), `value`/`nextLink` / `value`/`nextPageToken` paged shapes (spec uses `AgentsPagedResult`), and `github_issue` wire value (spec renamed to `github_issue_opened`). ## CLI bug fix: HTTP/2 stream-reset hang The pipeline now uses a custom `http.Client` with an explicit `ResponseHeaderTimeout` (60s) and `TryTimeout` (30s), and azcore retries are capped at 1. When the Foundry service returns an HTTP/2 RST_STREAM (for example, the schedule-create InternalServerError), the CLI now surfaces a `context deadline exceeded` error within ~40 seconds instead of the previous ~6 minute hang. ## Verified end-to-end against a live Foundry project * timer create / show / list / update / disable / enable / dispatch (with `taskId` round-tripping) / run list / delete all succeed. * schedule create still fails (service-side ISE) but now in under a minute instead of six. --- .../extensions/azure.ai.routines/AGENTS.md | 22 +++++ .../internal/cmd/routine_dispatch.go | 3 + .../internal/pkg/routines/client.go | 58 +++++++++++-- .../internal/pkg/routines/models.go | 82 ++++++++++++++----- 4 files changed, 137 insertions(+), 28 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/AGENTS.md b/cli/azd/extensions/azure.ai.routines/AGENTS.md index 5a2bfe6f5e6..128d1a57bb1 100644 --- a/cli/azd/extensions/azure.ai.routines/AGENTS.md +++ b/cli/azd/extensions/azure.ai.routines/AGENTS.md @@ -130,3 +130,25 @@ log.Printf("No routines found on project") // use fmt.Print* branches can compare with `== "json"`. - When using `PromptSubscription()`, create credentials with `Subscription.UserTenantId`, not `Subscription.TenantId`. + +## API spec alignment + +The authoritative TypeSpec is in +[`azure-rest-api-specs` PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) +(`specification/ai-foundry/data-plane/Foundry/src/routines/`). The client in +`internal/pkg/routines/` tracks that spec, with the following deliberate +divergences that exist purely to stay compatible with the currently deployed +Foundry service. Each divergence is also noted inline in the code. + +| Concern | Spec (PR #43186) | Live service | Client choice | +|---|---|---|---| +| Wire field naming | `snake_case` | `camelCase` | camelCase JSON tags | +| `InvokeAgentResponsesApiRoutineAction` agent field | `agent_name` | `agentId` | `AgentID` / `agentId` | +| `:dispatch_async` action segment | snake_case | `:dispatchAsync` only | camelCase URL | +| `POST :enable` / `POST :disable` | dedicated routes | 404 | GET+PUT fallback | +| `:github_issue_opened` trigger | renamed in spec | accepts old `github_issue` | CLI keeps `github_issue` wire value (trigger feature is deferred at the CLI anyway) | +| `AgentsPagedResult` envelope | `data` + `last_id` + `has_more` | `value` + `nextLink` (routines) / `value` + `nextPageToken` (runs) | matches service | +| `task_id` on `DispatchRoutineResponse` / `RoutineRun` | new in spec | already emitted by service | added (`TaskID` / `taskId`) | + +When the service catches up to the spec, revisit these one at a time. + diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go index 0cbdc741f2b..fd397aa4f9d 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -116,5 +116,8 @@ func runRoutineDispatch( if resp.ActionCorrelationID != "" { fmt.Printf("Action Correlation ID: %s\n", resp.ActionCorrelationID) } + if resp.TaskID != "" { + fmt.Printf("Task ID: %s\n", resp.TaskID) + } return nil } diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 319b1b7a2aa..052ef9cd2f9 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -9,10 +9,12 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "slices" "strings" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -32,9 +34,43 @@ type Client struct { pipeline runtime.Pipeline } +// newHTTPClient returns the *http.Client used by the data-plane pipeline. +// +// The default azcore transport relies on Go's HTTP/2 client, which can wait +// minutes before surfacing a server-side stream reset (RST_STREAM). The +// Foundry Routines data plane returns 500s for certain inputs via stream +// resets, so callers were observing ~6 minute hangs on what curl reports as +// a sub-second failure. We use a transport with explicit response-header and +// connection-level timeouts so failures surface within tens of seconds. +func newHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + } + return &http.Client{Transport: transport} +} + // NewClient creates a new Routines data-plane client. func NewClient(endpoint string, cred azcore.TokenCredential) *Client { clientOptions := &policy.ClientOptions{ + Transport: newHTTPClient(), + // Limit retries so HTTP/2 stream-reset failures surface quickly to the + // user. The azcore default is 3 retries which, combined with a 60s + // response-header timeout, can hide a fast server-side failure behind + // a 4-minute wait. CLI callers can simply re-run the command. + Retry: policy.RetryOptions{ + MaxRetries: 1, + TryTimeout: 30 * time.Second, + }, PerCallPolicies: []policy.Policy{ runtime.NewBearerTokenPolicy( cred, @@ -214,14 +250,20 @@ func (c *Client) DeleteRoutine(ctx context.Context, name string) error { return nil } -// EnableRoutine flips `enabled` to true via PUT. -// The Foundry Routines API does not expose a dedicated :enable route; the -// client mutates the routine resource directly. +// EnableRoutine flips `enabled` to true on the routine. +// +// Spec PR #43186 added a dedicated `POST /routines/{name}:enable` route, +// but the live service still returns 404 for it. We fall back to a GET+PUT +// on the routine resource; revisit when the service exposes the route. func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { return c.setEnabled(ctx, name, true) } -// DisableRoutine flips `enabled` to false via PUT. +// DisableRoutine flips `enabled` to false on the routine. +// +// Spec PR #43186 added a dedicated `POST /routines/{name}:disable` route, +// but the live service still returns 404 for it. We fall back to a GET+PUT +// on the routine resource; revisit when the service exposes the route. func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { return c.setEnabled(ctx, name, false) } @@ -241,9 +283,11 @@ func (c *Client) setEnabled(ctx context.Context, name string, enabled bool) (*Ro return c.PutRoutine(ctx, name, existing) } -// DispatchRoutineAsync calls the :dispatchAsync action route. -// The action segment is camelCase per TypeSpec; do not change it to -// snake_case without first updating the Foundry Routines spec. +// DispatchRoutineAsync calls the routine async-dispatch action route. +// +// Spec PR #43186 names this route `:dispatch_async` (snake_case). The live +// service only exposes the camelCase form `:dispatchAsync`, so we use that +// here. Revisit when the service catches up to the spec. func (c *Client) DispatchRoutineAsync( ctx context.Context, name string, diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index 3d08e3a26bd..b73addd1c62 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -5,14 +5,20 @@ package routines // Routine represents a Foundry routine resource. -// Field shapes track the Routines TypeSpec (azure-rest-api-specs PR #42779): -// - `triggers` is a map keyed by user-defined identifiers. -// - `action` is a single discriminated object, not a map. // -// JSON tags use camelCase to match the deployed Foundry service wire format, -// which applies a camelCase property-naming policy regardless of the -// snake_case casing in the OpenAPI document. YAML tags stay snake_case to +// Field shapes follow the Routines TypeSpec +// (azure-rest-api-specs PR #43186, src/routines/models.tsp), with a deliberate +// wire-naming divergence noted below. +// +// JSON tags use camelCase to match the deployed Foundry service, which applies +// a camelCase property-naming policy on the wire regardless of the snake_case +// casing in the TypeSpec / OpenAPI document. YAML tags stay snake_case to // match the user-facing manifest convention used in --file documentation. +// +// Spec divergences kept for service compatibility: +// - Wire field naming uses camelCase, not snake_case as in the spec. +// - `AgentID` keeps the wire name `agentId`; the spec renames this to +// `agent_name`, but the live service still expects `agentId`. type Routine struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -27,7 +33,13 @@ type Routine struct { // The "type" field selects the variant: // - "schedule" (CLI alias: "recurring"): cron-based recurring trigger // - "timer": one-shot timer trigger -// - "github_issue": GitHub issue event trigger (deferred) +// - "github_issue_opened": GitHub issue-opened trigger (deferred in CLI) +// +// The spec previously used `github_issue` with `owner`/`actions[]` fields; +// PR #43186 renamed it to `github_issue_opened` with an `assignee` field. +// The CLI surface for this trigger is deferred, so the struct tracks the new +// spec shape (assignee), while `TriggerCLIToWire` still maps the CLI alias +// `github-issue` to `github_issue` for live-service compatibility. type RoutineTrigger struct { Type string `json:"type" yaml:"type"` @@ -40,17 +52,21 @@ type RoutineTrigger struct { // timer-only fields At string `json:"at,omitempty" yaml:"at,omitempty"` - // github_issue fields - ConnectionID string `json:"connectionId,omitempty" yaml:"connection_id,omitempty"` - Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` - Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` - Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` + // github_issue_opened fields (per spec PR #43186) + ConnectionID string `json:"connectionId,omitempty" yaml:"connection_id,omitempty"` + Assignee string `json:"assignee,omitempty" yaml:"assignee,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` } // RoutineAction is the discriminated union for routine actions. // The "type" field selects the variant: // - "invoke_agent_responses_api" (CLI alias: "agent-response") // - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") +// +// Spec PR #43186 renamed `agent_id` to `agent_name` in +// `InvokeAgentResponsesApiRoutineAction`. The live service still expects +// `agentId`, so we keep `AgentID` with the `agentId` JSON tag and revisit +// when the service catches up. type RoutineAction struct { Type string `json:"type" yaml:"type"` AgentID string `json:"agentId,omitempty" yaml:"agent_id,omitempty"` @@ -59,8 +75,13 @@ type RoutineAction struct { SessionID string `json:"sessionId,omitempty" yaml:"session_id,omitempty"` } -// PagedRoutine represents a page of routine resources. The service returns an -// `nextLink` absolute URL when more pages exist (Azure.Core.Page). +// PagedRoutine represents a page of routine resources. +// +// Spec PR #43186 defines the paginated envelope as `AgentsPagedResult` +// with fields `data`, `first_id`, `last_id`, `has_more` (where `last_id` +// is the continuation cursor passed back as `after=`). The deployed service +// still returns the legacy `value` + `nextLink` shape, so the client tracks +// that shape for now and revisits when the service catches up. type PagedRoutine struct { Value []Routine `json:"value"` NextLink string `json:"nextLink,omitempty"` @@ -80,11 +101,18 @@ type RoutineRun struct { DispatchID string `json:"dispatchId,omitempty"` ActionCorrelationID string `json:"actionCorrelationId,omitempty"` ResponseID string `json:"responseId,omitempty"` - ErrorType string `json:"errorType,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` + // TaskID is the workspace task identifier linked to the routine attempt + // (added in spec PR #43186; the service already emits it). + TaskID string `json:"taskId,omitempty"` + ErrorType string `json:"errorType,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` } // PagedRoutineRun represents a page of routine run records. +// +// Spec PR #43186 also models this with `AgentsPagedResult`. The +// deployed service still uses `value` + `nextPageToken`, so the client tracks +// that shape for now. type PagedRoutineRun struct { Value []RoutineRun `json:"value"` NextPageToken string `json:"nextPageToken,omitempty"` @@ -98,26 +126,38 @@ type RoutineDispatchPayload struct { Input string `json:"input,omitempty"` } -// DispatchRoutineRequest is the request body for the :dispatch / :dispatchAsync -// routes. The payload wrapper is required for :dispatchAsync. +// DispatchRoutineRequest is the request body for the :dispatchAsync route. +// +// The spec route is `:dispatch_async` (snake_case); the live service exposes +// the camelCase form `:dispatchAsync` only. The client URL is camelCase to +// match the service. type DispatchRoutineRequest struct { Payload *RoutineDispatchPayload `json:"payload,omitempty"` } -// DispatchRoutineResponse is the response from the :dispatch / :dispatchAsync routes. +// DispatchRoutineResponse is the response from the :dispatchAsync route. +// +// `TaskID` was added in spec PR #43186 and is already emitted by the service. type DispatchRoutineResponse struct { DispatchID string `json:"dispatchId,omitempty"` ActionCorrelationID string `json:"actionCorrelationId,omitempty"` + TaskID string `json:"taskId,omitempty"` } -// TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. +// TriggerCLIToWire maps CLI --trigger aliases to wire type values. +// +// Note: spec PR #43186 renamed the github trigger wire value from +// `github_issue` to `github_issue_opened`. The live service still expects +// `github_issue`, so the CLI alias `github-issue` keeps that value until the +// service catches up. The CLI does not expose the github trigger yet — see +// `buildTrigger` in `routine_create.go` for the deferred-feature gate. var TriggerCLIToWire = map[string]string{ "recurring": "schedule", "timer": "timer", "github-issue": "github_issue", } -// ActionCLIToWire maps CLI --action aliases to TypeSpec wire type values. +// ActionCLIToWire maps CLI --action aliases to wire type values. var ActionCLIToWire = map[string]string{ "agent-response": "invoke_agent_responses_api", "agent-invoke": "invoke_agent_invocations_api", From 3eedf275e2aac84a9b7e6b44272fe60b1659545c Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 20:42:21 +0800 Subject: [PATCH 15/21] feat(routines): defer recurring/schedule trigger until service is ready The Foundry data plane currently returns `InternalServerError` for any `PUT /routines/{name}` request whose trigger is `schedule` (the wire value behind the CLI's `--trigger recurring`). The CLI side is fully implemented and verified correct via raw curl, so this is a service-side issue, but it leaves the `recurring` trigger non-functional end-to-end. Take the recurring trigger off the public CLI surface so users do not hit the service hang: * Drop the `--cron` flag from `routine create` and `routine update`. * `--trigger recurring` is now rejected with the same "deferred" shape as `--trigger github-issue`: a clear error pointing the user at `--trigger timer` and explaining that recurring is gated on the Foundry service. * `--trigger` help text and validation messages list only `timer`. The underlying wire model still carries `cron_expression` / `time_zone` and the `schedule` discriminator so re-enabling the trigger when the service is ready is just a CLI flag-wiring change. Unit tests around buildTrigger and applyUpdateFlags are updated accordingly. --- .../internal/cmd/routine_create.go | 38 +++++++++---------- .../internal/cmd/routine_create_test.go | 19 ++-------- .../internal/cmd/routine_manifest.go | 19 +++------- .../internal/cmd/routine_manifest_test.go | 36 ++++++------------ .../internal/cmd/routine_update.go | 7 +--- 5 files changed, 41 insertions(+), 78 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index baa0a3f3230..4d95eb29c27 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -18,7 +18,6 @@ import ( type routineCreateFlags struct { name string trigger string - cron string timeZone string at string action string @@ -55,9 +54,7 @@ Use --file to create from a YAML/JSON manifest file instead of individual flags. } cmd.Flags().StringVar(&flags.trigger, "trigger", "", - "Trigger type: recurring, timer (required unless --file is used)") - cmd.Flags().StringVar(&flags.cron, "cron", "", - "Cron expression for recurring trigger (e.g. '0 8 * * 1-5')") + "Trigger type: timer (required unless --file is used)") cmd.Flags().StringVar(&flags.timeZone, "time-zone", "UTC", "Time zone for the trigger (e.g. 'America/New_York')") cmd.Flags().StringVar(&flags.at, "at", "", @@ -129,7 +126,7 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre return exterrors.Validation( exterrors.CodeInvalidParameter, "--trigger is required when --file is not provided", - "specify --trigger recurring, --trigger timer, or use --file", + "specify --trigger timer, or use --file", ) } @@ -193,17 +190,25 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre } // buildTrigger constructs a RoutineTrigger from CLI flags. +// +// Only the `timer` trigger is exposed at the CLI surface in this PR. The +// `recurring` (schedule) and `github-issue` triggers are deferred until the +// Foundry service is ready (see PR #8241 description and AGENTS.md). The +// underlying model and wire support for both triggers are retained so +// re-enabling them is just a CLI flag wiring change. func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { - // "github-issue" is present in TriggerCLIToWire (it maps to the wire type - // for the deferred GitHub issue trigger), but the CLI does not yet support - // supplying the fields it needs (connection, owner, repository, actions). - // Reject it explicitly so users get a clear "deferred" message instead of - // a silently incomplete request. if flags.trigger == "github-issue" { return routines.RoutineTrigger{}, exterrors.Validation( exterrors.CodeInvalidParameter, "trigger type 'github-issue' is not yet supported by the CLI", - "use --trigger recurring or --trigger timer; github-issue is deferred to a future release", + "use --trigger timer; github-issue is deferred to a future release", + ) + } + if flags.trigger == "recurring" { + return routines.RoutineTrigger{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + "trigger type 'recurring' is not yet supported by the CLI", + "use --trigger timer; recurring/schedule is deferred until the Foundry service is ready", ) } @@ -212,7 +217,7 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { return routines.RoutineTrigger{}, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("unknown trigger type %q", flags.trigger), - "supported triggers: recurring, timer", + "supported triggers: timer", ) } @@ -222,15 +227,6 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { } switch flags.trigger { - case "recurring": - if flags.cron == "" { - return t, exterrors.Validation( - exterrors.CodeInvalidParameter, - "--cron is required for trigger type 'recurring'", - "provide a cron expression, e.g. '0 8 * * 1-5'", - ) - } - t.CronExpression = flags.cron case "timer": if flags.at == "" { return t, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index b4986afcd77..f6fb6053e0f 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -14,22 +14,11 @@ import ( // ─── buildTrigger ───────────────────────────────────────────────────────────── -func TestBuildTrigger_Recurring(t *testing.T) { - t.Parallel() - flags := &routineCreateFlags{ - trigger: "recurring", - cron: "0 8 * * 1-5", - timeZone: "America/New_York", - } - got, err := buildTrigger(flags) - require.NoError(t, err) - assert.Equal(t, "schedule", got.Type) - assert.Equal(t, "0 8 * * 1-5", got.CronExpression) - assert.Equal(t, "America/New_York", got.TimeZone) -} - -func TestBuildTrigger_RecurringMissingCron(t *testing.T) { +func TestBuildTrigger_RecurringDeferred(t *testing.T) { t.Parallel() + // `recurring` is in TriggerCLIToWire but is deferred at the CLI surface + // (service-side schedule create is not yet ready); buildTrigger must + // reject it explicitly with a "deferred" message. flags := &routineCreateFlags{trigger: "recurring"} _, err := buildTrigger(flags) assert.Error(t, err) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index 2a1b683bc9a..d8f58d845da 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -78,10 +78,14 @@ func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { // applyUpdateFlags applies named CLI update flags onto an existing routine body. // It returns the count of fields changed. +// +// Note: --cron is intentionally not handled here. The recurring/schedule +// trigger is deferred at the CLI surface (see buildTrigger in +// routine_create.go) until the Foundry service is ready. func applyUpdateFlags( existing *routines.Routine, - description, cron, timeZone, at, agentID, agentEndpointID, conversationID, sessionID string, - descChanged, cronChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged bool, + description, timeZone, at, agentID, agentEndpointID, conversationID, sessionID string, + descChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged bool, ) (int, error) { changed := 0 @@ -92,17 +96,6 @@ func applyUpdateFlags( // Trigger field updates trigger := getTrigger(existing) - if cronChanged { - if trigger == nil { - return 0, exterrors.Validation( - exterrors.CodeInvalidParameter, - "cannot set --cron: routine has no default trigger", - "add a trigger by recreating the routine, or omit --cron", - ) - } - trigger.CronExpression = cron - changed++ - } if tzChanged { if trigger == nil { return 0, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index 3ce5a14a123..feff918dd36 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -143,32 +143,20 @@ func TestApplyUpdateFlags_Description(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "new desc", "", "", "", "", "", "", "", - true, false, false, false, false, false, false, false, + "new desc", "", "", "", "", "", "", + true, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) assert.Equal(t, "new desc", r.Description) } -func TestApplyUpdateFlags_Cron(t *testing.T) { - t.Parallel() - r := routineWithScheduleAndAgentResp() - n, err := applyUpdateFlags(r, - "", "0 9 * * 1-5", "", "", "", "", "", "", - false, true, false, false, false, false, false, false, - ) - require.NoError(t, err) - assert.Equal(t, 1, n) - assert.Equal(t, "0 9 * * 1-5", r.Triggers["default"].CronExpression) -} - func TestApplyUpdateFlags_TimeZone(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "", "", "America/New_York", "", "", "", "", "", - false, false, true, false, false, false, false, false, + "", "America/New_York", "", "", "", "", "", + false, true, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) @@ -182,8 +170,8 @@ func TestApplyUpdateFlags_AgentIDClearsEndpointID(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, - "", "", "", "", "new-agent-id", "", "", "", - false, false, false, false, true, false, false, false, + "", "", "", "new-agent-id", "", "", "", + false, false, false, true, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) @@ -199,8 +187,8 @@ func TestApplyUpdateFlags_AgentEndpointIDClearsID(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, - "", "", "", "", "", "new-ep", "", "", - false, false, false, false, false, true, false, false, + "", "", "", "", "new-ep", "", "", + false, false, false, false, true, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) @@ -213,8 +201,8 @@ func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() _, err := applyUpdateFlags(r, - "", "", "", "", "new-agent-id", "new-ep", "", "", - false, false, false, false, true, true, false, false, + "", "", "", "new-agent-id", "new-ep", "", "", + false, false, false, true, true, false, false, ) assert.Error(t, err) } @@ -223,8 +211,8 @@ func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "", "", "", "", "", "", "", "", - false, false, false, false, false, false, false, false, + "", "", "", "", "", "", "", + false, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 0, n) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go index 13772926daf..a83fa443c78 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -19,7 +19,6 @@ type routineUpdateFlags struct { trigger string // type-switch guard only action string // type-switch guard only description string - cron string timeZone string at string agentID string @@ -58,7 +57,6 @@ To change the trigger or action type, delete and recreate the routine.`, _ = cmd.Flags().MarkHidden("action") cmd.Flags().StringVar(&flags.description, "description", "", "New description for the routine") - cmd.Flags().StringVar(&flags.cron, "cron", "", "New cron expression for recurring trigger") cmd.Flags().StringVar(&flags.timeZone, "time-zone", "", "New time zone for the trigger") cmd.Flags().StringVar(&flags.at, "at", "", "New ISO 8601 datetime for timer trigger") cmd.Flags().StringVar(&flags.agentID, "agent-id", "", "New project-scoped agent ID") @@ -118,7 +116,6 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd // Apply named flag changes (flag presence, not just non-empty value). descChanged := cmd.Flags().Changed("description") - cronChanged := cmd.Flags().Changed("cron") tzChanged := cmd.Flags().Changed("time-zone") atChanged := cmd.Flags().Changed("at") agentIDChanged := cmd.Flags().Changed("agent-id") @@ -128,9 +125,9 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd changed, err := applyUpdateFlags( existing, - flags.description, flags.cron, flags.timeZone, flags.at, + flags.description, flags.timeZone, flags.at, flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, - descChanged, cronChanged, tzChanged, atChanged, + descChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged, ) if err != nil { From 96595121a9604910e1cbdcbc49e5a1d7b552bdaf Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 21:33:44 +0800 Subject: [PATCH 16/21] fix(routines): enforce update-mode manifest merge, env-backed no-prompt in delete, and action-type flag validation --- .../internal/cmd/routine_create.go | 21 +++++ .../internal/cmd/routine_create_test.go | 18 ++++ .../internal/cmd/routine_delete.go | 8 +- .../internal/cmd/routine_manifest.go | 53 +++++++++++- .../internal/cmd/routine_manifest_test.go | 82 ++++++++++++++++++- .../internal/cmd/routine_update.go | 10 ++- 6 files changed, 182 insertions(+), 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index 4d95eb29c27..894b2d41441 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -270,6 +270,13 @@ func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID "provide --agent-id or --agent-endpoint-id ", ) } + if sessionID != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--session-id is not applicable to agent-response action", + "use --session-id with --action agent-invoke, or omit --session-id", + ) + } a.AgentID = agentID a.AgentEndpointID = agentEndpointID a.ConversationID = conversationID @@ -281,6 +288,20 @@ func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID "provide --agent-endpoint-id ", ) } + if agentID != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-id is not applicable to agent-invoke action", + "use --agent-endpoint-id for agent-invoke, or omit --agent-id", + ) + } + if conversationID != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--conversation-id is not applicable to agent-invoke action", + "use --session-id for agent-invoke, or omit --conversation-id", + ) + } a.AgentEndpointID = agentEndpointID a.SessionID = sessionID } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index f6fb6053e0f..0587341f8ac 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -112,3 +112,21 @@ func TestBuildAction_UnknownType(t *testing.T) { _, err := buildAction("no-such-action", "", "ep", "", "") assert.Error(t, err) } + +func TestBuildAction_AgentResponseRejectsSessionID(t *testing.T) { + t.Parallel() + _, err := buildAction("agent-response", "my-agent-id", "", "", "sess-1") + assert.Error(t, err, "--session-id must be rejected for agent-response action") +} + +func TestBuildAction_AgentInvokeRejectsAgentID(t *testing.T) { + t.Parallel() + _, err := buildAction("agent-invoke", "my-agent-id", "ep-id", "", "") + assert.Error(t, err, "--agent-id must be rejected for agent-invoke action") +} + +func TestBuildAction_AgentInvokeRejectsConversationID(t *testing.T) { + t.Parallel() + _, err := buildAction("agent-invoke", "", "ep-id", "conv-1", "") + assert.Error(t, err, "--conversation-id must be rejected for agent-invoke action") +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go index b915c0373f8..185c1299514 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go @@ -24,7 +24,7 @@ func newRoutineDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { output = extCtx.OutputFormat ctx := azdext.WithAccessToken(cmd.Context()) - return runRoutineDelete(ctx, cmd, args[0], force, output) + return runRoutineDelete(ctx, cmd, args[0], force, extCtx.NoPrompt, output) }, } @@ -38,8 +38,10 @@ func newRoutineDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { return cmd } -func runRoutineDelete(ctx context.Context, cmd *cobra.Command, name string, force bool, output string) error { - noPrompt, _ := cmd.Flags().GetBool("no-prompt") +func runRoutineDelete(ctx context.Context, cmd *cobra.Command, name string, force bool, noPromptEnv bool, output string) error { + // Combine env-backed no-prompt (AZD_NO_PROMPT) with the explicit CLI flag. + flagNoPrompt, _ := cmd.Flags().GetBool("no-prompt") + noPrompt := noPromptEnv || flagNoPrompt // In --no-prompt mode, --force is required. if noPrompt && !force { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index d8f58d845da..ca90a805cc9 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -58,9 +58,10 @@ func readRoutineManifest(path string) (*routines.Routine, error) { return &r, nil } -// mergeRoutineFromFile copies fields from the manifest into body. -// The caller's positional argument wins over any name in the file. -// Individual flag overrides are applied by the caller after this function returns. +// mergeRoutineFromFile copies non-zero fields from file into body only when the +// corresponding body field is unset (create-mode: body wins). The positional +// and any explicit flag overrides take precedence and are applied by +// the caller. func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { if file.Description != "" && body.Description == "" { body.Description = file.Description @@ -76,6 +77,31 @@ func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { } } +// overwriteRoutineFromFile copies non-zero fields from file onto existing, +// overwriting whatever the fetched routine had (update-mode: manifest wins). +// Name is not touched; the caller preserves the positional argument. +// Returns the count of fields overwritten. +func overwriteRoutineFromFile(existing *routines.Routine, file *routines.Routine) int { + changed := 0 + if file.Description != "" { + existing.Description = file.Description + changed++ + } + if file.Enabled != nil { + existing.Enabled = file.Enabled + changed++ + } + if len(file.Triggers) > 0 { + existing.Triggers = file.Triggers + changed++ + } + if file.Action != nil { + existing.Action = file.Action + changed++ + } + return changed +} + // applyUpdateFlags applies named CLI update flags onto an existing routine body. // It returns the count of fields changed. // @@ -135,6 +161,13 @@ func applyUpdateFlags( "add an action by recreating the routine, or omit --agent-id / --agent-endpoint-id", ) } + if agentIDChanged && action.Type == routines.ActionCLIToWire["agent-invoke"] { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-id is not applicable to agent-invoke actions", + "use --agent-endpoint-id for agent-invoke, or recreate the routine with agent-response", + ) + } // agent-id and agent-endpoint-id are mutually exclusive; specifying one clears the other. if agentIDChanged && agentEpChanged && agentID != "" && agentEndpointID != "" { return 0, exterrors.Validation( @@ -166,6 +199,13 @@ func applyUpdateFlags( "add an action by recreating the routine, or omit --conversation-id", ) } + if action.Type == routines.ActionCLIToWire["agent-invoke"] { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--conversation-id is not applicable to agent-invoke actions", + "use --session-id for agent-invoke, or recreate the routine with agent-response", + ) + } action.ConversationID = conversationID changed++ } @@ -177,6 +217,13 @@ func applyUpdateFlags( "add an action by recreating the routine, or omit --session-id", ) } + if action.Type == routines.ActionCLIToWire["agent-response"] { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--session-id is not applicable to agent-response actions", + "use --conversation-id for agent-response, or recreate the routine with agent-invoke", + ) + } action.SessionID = sessionID changed++ } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index feff918dd36..f8ad634960f 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -126,7 +126,51 @@ func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { assert.Equal(t, "cli-agent", body.Action.AgentID, "body action must win") } -// ─── applyUpdateFlags ───────────────────────────────────────────────────────── +// ─── overwriteRoutineFromFile ────────────────────────────────────────────────── + +func TestOverwriteRoutineFromFile_ManifestWins(t *testing.T) { + t.Parallel() + existing := &routines.Routine{ + Name: "fetched-name", + Description: "old description", + Enabled: new(true), + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "timer", At: "2026-01-01T00:00:00Z"}, + }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent"}, + } + file := &routines.Routine{ + Description: "new description from manifest", + Enabled: new(false), + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", CronExpression: "0 9 * * *"}, + }, + Action: &routines.RoutineAction{Type: "invoke_agent_invocations_api", AgentEndpointID: "new-ep"}, + } + n := overwriteRoutineFromFile(existing, file) + + assert.Equal(t, 4, n, "all four fields should be counted as changed") + assert.Equal(t, "fetched-name", existing.Name, "name must not be overwritten by file") + assert.Equal(t, "new description from manifest", existing.Description) + require.NotNil(t, existing.Enabled) + assert.False(t, *existing.Enabled) + assert.Equal(t, "schedule", existing.Triggers["default"].Type) + require.NotNil(t, existing.Action) + assert.Equal(t, "invoke_agent_invocations_api", existing.Action.Type) +} + +func TestOverwriteRoutineFromFile_EmptyManifestChangesNothing(t *testing.T) { + t.Parallel() + existing := &routines.Routine{ + Name: "my-routine", + Description: "keep this", + } + n := overwriteRoutineFromFile(existing, &routines.Routine{}) + assert.Equal(t, 0, n) + assert.Equal(t, "keep this", existing.Description) +} + + func routineWithScheduleAndAgentResp() *routines.Routine { return &routines.Routine{ @@ -218,6 +262,42 @@ func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { assert.Equal(t, 0, n) } +func TestApplyUpdateFlags_AgentIDRejectedForAgentInvoke(t *testing.T) { + t.Parallel() + r := &routines.Routine{ + Action: &routines.RoutineAction{Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer"}}, + } + _, err := applyUpdateFlags(r, + "", "", "", "new-agent-id", "", "", "", + false, false, false, true, false, false, false, + ) + assert.Error(t, err, "--agent-id must be rejected for agent-invoke actions") +} + +func TestApplyUpdateFlags_ConvIDRejectedForAgentInvoke(t *testing.T) { + t.Parallel() + r := &routines.Routine{ + Action: &routines.RoutineAction{Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer"}}, + } + _, err := applyUpdateFlags(r, + "", "", "", "", "", "conv-1", "", + false, false, false, false, false, true, false, + ) + assert.Error(t, err, "--conversation-id must be rejected for agent-invoke actions") +} + +func TestApplyUpdateFlags_SessIDRejectedForAgentResponse(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + _, err := applyUpdateFlags(r, + "", "", "", "", "", "", "sess-1", + false, false, false, false, false, false, true, + ) + assert.Error(t, err, "--session-id must be rejected for agent-response actions") +} + // ─── getTrigger / getAction ─────────────────────────────────────────────────── func TestGetTrigger_NilWhenEmpty(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go index a83fa443c78..41c425b0db5 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -105,13 +105,16 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd return exterrors.ServiceFromAzure(err, exterrors.OpGetRoutine) } - // If --file is provided, merge the manifest first. + // If --file is provided, overwrite routine fields with the manifest (manifest wins). + var changed int if flags.file != "" { manifest, err := readRoutineManifest(flags.file) if err != nil { return err } - mergeRoutineFromFile(existing, manifest) + changed += overwriteRoutineFromFile(existing, manifest) + // Preserve the positional name argument. + existing.Name = flags.name } // Apply named flag changes (flag presence, not just non-empty value). @@ -123,7 +126,7 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd convIDChanged := cmd.Flags().Changed("conversation-id") sessIDChanged := cmd.Flags().Changed("session-id") - changed, err := applyUpdateFlags( + flagChanged, err := applyUpdateFlags( existing, flags.description, flags.timeZone, flags.at, flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, @@ -133,6 +136,7 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd if err != nil { return err } + changed += flagChanged if changed == 0 && flags.file == "" { fmt.Printf("No changes specified for routine '%s'.\n", flags.name) From 53114fa6f1bd3663d041c9f9d33c4dca6b5e8cf6 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 21:48:32 +0800 Subject: [PATCH 17/21] fix(routines): replace unknown word 'misroute' in endpoint comment --- cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go index 24fa80b92e6..e66e93f16e4 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -79,8 +79,8 @@ func validateProjectEndpoint(raw string) (normalized string, err error) { } // Reject explicit ports: Foundry hosts are reached on the default HTTPS - // port (443) and accepting other ports would silently misroute traffic - // (the normalized URL strips the port). + // port (443); accepting other ports would silently route traffic to the + // wrong destination (the normalized URL strips the port). if u.Port() != "" { return "", exterrors.Validation( exterrors.CodeInvalidParameter, From 57eb23c8b36a60dcf012adcc5f3e8413f489ce7b Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 21:56:49 +0800 Subject: [PATCH 18/21] feat(routines): add exterrors unit tests and .gitignore for bin/ --- .../extensions/azure.ai.routines/.gitignore | 1 + .../internal/exterrors/errors_test.go | 172 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.routines/.gitignore create mode 100644 cli/azd/extensions/azure.ai.routines/internal/exterrors/errors_test.go diff --git a/cli/azd/extensions/azure.ai.routines/.gitignore b/cli/azd/extensions/azure.ai.routines/.gitignore new file mode 100644 index 00000000000..e660fd93d31 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors_test.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors_test.go new file mode 100644 index 00000000000..15828f2ba61 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors_test.go @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +import ( + "context" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── constructor helpers ────────────────────────────────────────────────────── + +func TestValidation_Category(t *testing.T) { + t.Parallel() + err := Validation(CodeInvalidParameter, "bad input", "fix it") + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryValidation, le.Category) + assert.Equal(t, CodeInvalidParameter, le.Code) + assert.Equal(t, "bad input", le.Message) + assert.Equal(t, "fix it", le.Suggestion) +} + +func TestDependency_Category(t *testing.T) { + t.Parallel() + err := Dependency(CodeFileNotFound, "file missing", "check path") + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryDependency, le.Category) + assert.Equal(t, CodeFileNotFound, le.Code) +} + +func TestAuth_Category(t *testing.T) { + t.Parallel() + err := Auth(CodeAuthFailed, "not authenticated", "run azd auth login") + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryAuth, le.Category) +} + +func TestInternal_Category(t *testing.T) { + t.Parallel() + err := Internal("some_op", "unexpected failure") + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryInternal, le.Category) +} + +func TestCancelled_Category(t *testing.T) { + t.Parallel() + err := Cancelled("operation cancelled by user") + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryUser, le.Category) + assert.Equal(t, CodeCancelled, le.Code) +} + +// ─── ServiceFromAzure ───────────────────────────────────────────────────────── + +func TestServiceFromAzure_ResponseError(t *testing.T) { + t.Parallel() + azErr := &azcore.ResponseError{StatusCode: http.StatusNotFound, ErrorCode: "RoutineNotFound"} + err := ServiceFromAzure(azErr, OpGetRoutine) + var svcErr *azdext.ServiceError + require.ErrorAs(t, err, &svcErr) + assert.Equal(t, http.StatusNotFound, svcErr.StatusCode) + assert.Contains(t, svcErr.ErrorCode, OpGetRoutine) + assert.Contains(t, svcErr.ErrorCode, "RoutineNotFound") +} + +func TestServiceFromAzure_ResponseError_EmptyCode(t *testing.T) { + t.Parallel() + // When ErrorCode is empty the status code is used as the code suffix. + azErr := &azcore.ResponseError{StatusCode: http.StatusInternalServerError} + err := ServiceFromAzure(azErr, OpListRoutines) + var svcErr *azdext.ServiceError + require.ErrorAs(t, err, &svcErr) + assert.Contains(t, svcErr.ErrorCode, "500") +} + +func TestServiceFromAzure_Cancellation(t *testing.T) { + t.Parallel() + err := ServiceFromAzure(context.Canceled, OpDeleteRoutine) + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryUser, le.Category) + assert.Equal(t, CodeCancelled, le.Code) +} + +func TestServiceFromAzure_GenericError(t *testing.T) { + t.Parallel() + err := ServiceFromAzure(assert.AnError, OpCreateRoutine) + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryInternal, le.Category) +} + +// ─── ServiceFromStatus ──────────────────────────────────────────────────────── + +func TestServiceFromStatus(t *testing.T) { + t.Parallel() + err := ServiceFromStatus(http.StatusNotFound, OpGetRoutine, "routine not found") + var svcErr *azdext.ServiceError + require.ErrorAs(t, err, &svcErr) + assert.Equal(t, http.StatusNotFound, svcErr.StatusCode) + assert.Contains(t, svcErr.ErrorCode, OpGetRoutine) + assert.Contains(t, svcErr.Message, "routine not found") +} + +// ─── IsNotFound / IsConflict ────────────────────────────────────────────────── + +func TestIsNotFound_ResponseError(t *testing.T) { + t.Parallel() + assert.True(t, IsNotFound(&azcore.ResponseError{StatusCode: http.StatusNotFound})) + assert.False(t, IsNotFound(&azcore.ResponseError{StatusCode: http.StatusOK})) +} + +func TestIsNotFound_ServiceError(t *testing.T) { + t.Parallel() + assert.True(t, IsNotFound(&azdext.ServiceError{StatusCode: http.StatusNotFound})) + assert.False(t, IsNotFound(&azdext.ServiceError{StatusCode: http.StatusConflict})) +} + +func TestIsConflict_ResponseError(t *testing.T) { + t.Parallel() + assert.True(t, IsConflict(&azcore.ResponseError{StatusCode: http.StatusConflict})) + assert.False(t, IsConflict(&azcore.ResponseError{StatusCode: http.StatusNotFound})) +} + +// ─── IsCancellation ─────────────────────────────────────────────────────────── + +func TestIsCancellation(t *testing.T) { + t.Parallel() + assert.True(t, IsCancellation(context.Canceled)) + assert.False(t, IsCancellation(assert.AnError)) +} + +// ─── WrapAuthError ──────────────────────────────────────────────────────────── + +func TestWrapAuthError_401_NotLoggedIn(t *testing.T) { + t.Parallel() + azErr := &azcore.ResponseError{StatusCode: http.StatusUnauthorized, ErrorCode: "not logged in, run `azd auth login` to login"} + err := WrapAuthError(azErr, OpGetRoutine) + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, azdext.LocalErrorCategoryAuth, le.Category) + assert.Equal(t, CodeNotLoggedIn, le.Code) +} + +func TestWrapAuthError_401_LoginExpired(t *testing.T) { + t.Parallel() + azErr := &azcore.ResponseError{StatusCode: http.StatusUnauthorized, ErrorCode: "AADSTS70043: token expired"} + err := WrapAuthError(azErr, OpGetRoutine) + var le *azdext.LocalError + require.ErrorAs(t, err, &le) + assert.Equal(t, CodeLoginExpired, le.Code) +} + +func TestWrapAuthError_NonAuth_DelegatesToServiceFromAzure(t *testing.T) { + t.Parallel() + azErr := &azcore.ResponseError{StatusCode: http.StatusForbidden} + err := WrapAuthError(azErr, OpGetRoutine) + var svcErr *azdext.ServiceError + require.ErrorAs(t, err, &svcErr) + assert.Equal(t, http.StatusForbidden, svcErr.StatusCode) +} From e76c6896a733e48820e671b866312f680c8db922 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 22 May 2026 21:59:15 +0800 Subject: [PATCH 19/21] style(routines): fix gofmt formatting in test files --- .../azure.ai.routines/internal/cmd/routine_manifest_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index f8ad634960f..1967a3c9cf1 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -170,8 +170,6 @@ func TestOverwriteRoutineFromFile_EmptyManifestChangesNothing(t *testing.T) { assert.Equal(t, "keep this", existing.Description) } - - func routineWithScheduleAndAgentResp() *routines.Routine { return &routines.Routine{ Name: "my-routine", From 4762d7343493166837b5956917ddff025ec2d7d0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 26 May 2026 12:21:56 +0800 Subject: [PATCH 20/21] feat(routines): align client with spec PR #43186 routes and fields The Foundry data plane now honors the routine spec from azure-rest-api-specs#43186. Switch the client off the workarounds that the first cut needed and onto the spec-shaped routes and wire format. Tested by probing the live data plane on a Foundry project endpoint: * Wire field naming: switch from camelCase to snake_case across Routine, RoutineTrigger, RoutineAction, RoutineRun, and DispatchRoutineResponse. Confirmed: service now rejects `agentId` / `agentName` (camel) with a 400 `exactly one of agent_name or agent_endpoint_id must be provided` and only accepts `agent_name`. * Enable / disable: switch from GET+PUT-with-enabled-flipped to the spec routes `POST /routines/{name}:enable` and `POST /routines/{name}:disable`. Confirmed: both routes return `UserError` / `NotFoundError` for missing routines (route exists; resource doesn't), instead of the empty 404 the routes used to return. * Async dispatch: switch from `:dispatchAsync` (camelCase) to the spec route `:dispatch_async` (snake). Confirmed: the snake route is live; the camel form now returns an empty 404 (route gone). * Schedule trigger: re-enable `--trigger recurring` / `--cron`. The original deferral was because every `schedule` PUT 500'd; with the spec wire format the schedule trigger passes service-side validation just like `timer` does. Re-add the `--cron` flag on `create` and `update`. Kept divergent because the service has not caught up yet: * `github_issue_opened` trigger value -- service still rejects it with `unrecognized type discriminator id`; CLI does not expose the github trigger yet, so the wire mapping keeps `github_issue`. * `AgentsPagedResult` envelope -- service still returns `value` + `nextLink` (routines) / `value` + `nextPageToken` (runs) rather than the spec's `data` / `last_id` / `has_more`. Also: * CLI flag `--agent-id` renamed to `--agent-name` to match the spec field name. Go field `RoutineAction.AgentID` renamed to `AgentName`. * Drop now-stale `spec divergence` comments from the client, models, and AGENTS.md alignment table. --- .../extensions/azure.ai.routines/AGENTS.md | 18 +-- .../internal/cmd/routine_create.go | 65 +++++----- .../internal/cmd/routine_create_test.go | 37 ++++-- .../internal/cmd/routine_helpers.go | 4 +- .../internal/cmd/routine_manifest.go | 45 ++++--- .../internal/cmd/routine_manifest_test.go | 76 ++++++------ .../internal/cmd/routine_update.go | 17 +-- .../internal/pkg/routines/client.go | 79 +++++------- .../internal/pkg/routines/models.go | 114 ++++++------------ 9 files changed, 204 insertions(+), 251 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/AGENTS.md b/cli/azd/extensions/azure.ai.routines/AGENTS.md index 128d1a57bb1..97f7ef702eb 100644 --- a/cli/azd/extensions/azure.ai.routines/AGENTS.md +++ b/cli/azd/extensions/azure.ai.routines/AGENTS.md @@ -136,19 +136,11 @@ log.Printf("No routines found on project") // use fmt.Print* The authoritative TypeSpec is in [`azure-rest-api-specs` PR #43186](https://github.com/Azure/azure-rest-api-specs/pull/43186) (`specification/ai-foundry/data-plane/Foundry/src/routines/`). The client in -`internal/pkg/routines/` tracks that spec, with the following deliberate -divergences that exist purely to stay compatible with the currently deployed -Foundry service. Each divergence is also noted inline in the code. +`internal/pkg/routines/` tracks that spec, with a small number of remaining +divergences kept for compatibility with the currently deployed Foundry service: -| Concern | Spec (PR #43186) | Live service | Client choice | +| Concern | Spec | Live service | Client choice | |---|---|---|---| -| Wire field naming | `snake_case` | `camelCase` | camelCase JSON tags | -| `InvokeAgentResponsesApiRoutineAction` agent field | `agent_name` | `agentId` | `AgentID` / `agentId` | -| `:dispatch_async` action segment | snake_case | `:dispatchAsync` only | camelCase URL | -| `POST :enable` / `POST :disable` | dedicated routes | 404 | GET+PUT fallback | -| `:github_issue_opened` trigger | renamed in spec | accepts old `github_issue` | CLI keeps `github_issue` wire value (trigger feature is deferred at the CLI anyway) | -| `AgentsPagedResult` envelope | `data` + `last_id` + `has_more` | `value` + `nextLink` (routines) / `value` + `nextPageToken` (runs) | matches service | -| `task_id` on `DispatchRoutineResponse` / `RoutineRun` | new in spec | already emitted by service | added (`TaskID` / `taskId`) | - -When the service catches up to the spec, revisit these one at a time. +| `github_issue_opened` trigger | renamed in spec | still accepts only `github_issue` | keep `github_issue` wire value (CLI surface is deferred) | +| `AgentsPagedResult` envelope | `data` + `last_id` + `has_more` | `value` + `nextLink` (routines) / `value` + `nextPageToken` (runs) | match service shape | diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go index 894b2d41441..28026ef740c 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -20,8 +20,9 @@ type routineCreateFlags struct { trigger string timeZone string at string + cronExpression string action string - agentID string + agentName string agentEndpointID string conversationID string sessionID string @@ -54,15 +55,17 @@ Use --file to create from a YAML/JSON manifest file instead of individual flags. } cmd.Flags().StringVar(&flags.trigger, "trigger", "", - "Trigger type: timer (required unless --file is used)") + "Trigger type: timer or recurring (required unless --file is used)") cmd.Flags().StringVar(&flags.timeZone, "time-zone", "UTC", "Time zone for the trigger (e.g. 'America/New_York')") cmd.Flags().StringVar(&flags.at, "at", "", "ISO 8601 datetime for timer trigger (e.g. '2026-04-24T15:00:00Z')") + cmd.Flags().StringVar(&flags.cronExpression, "cron", "", + "5-field cron expression for recurring trigger (minimum interval 5 minutes)") cmd.Flags().StringVar(&flags.action, "action", "agent-response", "Action type: agent-response (default), agent-invoke") - cmd.Flags().StringVar(&flags.agentID, "agent-id", "", - "Project-scoped agent ID (for agent-response action)") + cmd.Flags().StringVar(&flags.agentName, "agent-name", "", + "Project-scoped agent name (for agent-response action)") cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "Agent endpoint ID (for agent-response or agent-invoke action)") cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", @@ -126,7 +129,7 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre return exterrors.Validation( exterrors.CodeInvalidParameter, "--trigger is required when --file is not provided", - "specify --trigger timer, or use --file", + "specify --trigger timer or --trigger recurring, or use --file", ) } @@ -139,7 +142,7 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre } action, err := buildAction( - flags.action, flags.agentID, flags.agentEndpointID, + flags.action, flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID, ) if err != nil { @@ -191,24 +194,15 @@ func runRoutineCreate(ctx context.Context, cmd *cobra.Command, flags *routineCre // buildTrigger constructs a RoutineTrigger from CLI flags. // -// Only the `timer` trigger is exposed at the CLI surface in this PR. The -// `recurring` (schedule) and `github-issue` triggers are deferred until the -// Foundry service is ready (see PR #8241 description and AGENTS.md). The -// underlying model and wire support for both triggers are retained so -// re-enabling them is just a CLI flag wiring change. +// Supported triggers: timer (one-shot, --at) and recurring (cron, --cron). +// The github-issue trigger is deferred until the Foundry service accepts the +// renamed github_issue_opened discriminator. func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { if flags.trigger == "github-issue" { return routines.RoutineTrigger{}, exterrors.Validation( exterrors.CodeInvalidParameter, "trigger type 'github-issue' is not yet supported by the CLI", - "use --trigger timer; github-issue is deferred to a future release", - ) - } - if flags.trigger == "recurring" { - return routines.RoutineTrigger{}, exterrors.Validation( - exterrors.CodeInvalidParameter, - "trigger type 'recurring' is not yet supported by the CLI", - "use --trigger timer; recurring/schedule is deferred until the Foundry service is ready", + "use --trigger timer or --trigger recurring; github-issue is deferred to a future release", ) } @@ -217,7 +211,7 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { return routines.RoutineTrigger{}, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("unknown trigger type %q", flags.trigger), - "supported triggers: timer", + "supported triggers: timer, recurring", ) } @@ -236,13 +230,22 @@ func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { ) } t.At = flags.at + case "recurring": + if flags.cronExpression == "" { + return t, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--cron is required for trigger type 'recurring'", + "provide a 5-field cron expression, e.g. '0 8 * * *' (minimum interval 5 minutes)", + ) + } + t.CronExpression = flags.cronExpression } return t, nil } // buildAction constructs a RoutineAction from CLI flags. -func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID string) (routines.RoutineAction, error) { +func buildAction(actionType, agentName, agentEndpointID, conversationID, sessionID string) (routines.RoutineAction, error) { wireType, ok := routines.ActionCLIToWire[actionType] if !ok { return routines.RoutineAction{}, exterrors.Validation( @@ -256,18 +259,18 @@ func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID switch actionType { case "agent-response": - if agentID != "" && agentEndpointID != "" { + if agentName != "" && agentEndpointID != "" { return a, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-id and --agent-endpoint-id are mutually exclusive for agent-response action", - "provide either --agent-id or --agent-endpoint-id, not both", + "--agent-name and --agent-endpoint-id are mutually exclusive for agent-response action", + "provide either --agent-name or --agent-endpoint-id, not both", ) } - if agentID == "" && agentEndpointID == "" { + if agentName == "" && agentEndpointID == "" { return a, exterrors.Validation( exterrors.CodeInvalidParameter, - "one of --agent-id or --agent-endpoint-id is required for agent-response action", - "provide --agent-id or --agent-endpoint-id ", + "one of --agent-name or --agent-endpoint-id is required for agent-response action", + "provide --agent-name or --agent-endpoint-id ", ) } if sessionID != "" { @@ -277,7 +280,7 @@ func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID "use --session-id with --action agent-invoke, or omit --session-id", ) } - a.AgentID = agentID + a.AgentName = agentName a.AgentEndpointID = agentEndpointID a.ConversationID = conversationID case "agent-invoke": @@ -288,11 +291,11 @@ func buildAction(actionType, agentID, agentEndpointID, conversationID, sessionID "provide --agent-endpoint-id ", ) } - if agentID != "" { + if agentName != "" { return a, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-id is not applicable to agent-invoke action", - "use --agent-endpoint-id for agent-invoke, or omit --agent-id", + "--agent-name is not applicable to agent-invoke action", + "use --agent-endpoint-id for agent-invoke, or omit --agent-name", ) } if conversationID != "" { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go index 0587341f8ac..30ba4e73bb6 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -14,11 +14,22 @@ import ( // ─── buildTrigger ───────────────────────────────────────────────────────────── -func TestBuildTrigger_RecurringDeferred(t *testing.T) { +func TestBuildTrigger_Recurring(t *testing.T) { + t.Parallel() + flags := &routineCreateFlags{ + trigger: "recurring", + cronExpression: "0 8 * * *", + timeZone: "UTC", + } + got, err := buildTrigger(flags) + require.NoError(t, err) + assert.Equal(t, "schedule", got.Type) + assert.Equal(t, "0 8 * * *", got.CronExpression) + assert.Equal(t, "UTC", got.TimeZone) +} + +func TestBuildTrigger_RecurringMissingCron(t *testing.T) { t.Parallel() - // `recurring` is in TriggerCLIToWire but is deferred at the CLI surface - // (service-side schedule create is not yet ready); buildTrigger must - // reject it explicitly with a "deferred" message. flags := &routineCreateFlags{trigger: "recurring"} _, err := buildTrigger(flags) assert.Error(t, err) @@ -64,10 +75,10 @@ func TestBuildTrigger_GithubIssueRejected(t *testing.T) { func TestBuildAction_AgentResponseByID(t *testing.T) { t.Parallel() - got, err := buildAction("agent-response", "my-agent-id", "", "conv-1", "") + got, err := buildAction("agent-response", "my-agent-name", "", "conv-1", "") require.NoError(t, err) assert.Equal(t, routines.ActionCLIToWire["agent-response"], got.Type) - assert.Equal(t, "my-agent-id", got.AgentID) + assert.Equal(t, "my-agent-name", got.AgentName) assert.Empty(t, got.AgentEndpointID) assert.Equal(t, "conv-1", got.ConversationID) } @@ -76,14 +87,14 @@ func TestBuildAction_AgentResponseByEndpointID(t *testing.T) { t.Parallel() got, err := buildAction("agent-response", "", "ep-id-123", "", "") require.NoError(t, err) - assert.Empty(t, got.AgentID) + assert.Empty(t, got.AgentName) assert.Equal(t, "ep-id-123", got.AgentEndpointID) } func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { t.Parallel() - _, err := buildAction("agent-response", "my-agent-id", "ep-id-123", "", "") - assert.Error(t, err, "agent-id and agent-endpoint-id must be mutually exclusive") + _, err := buildAction("agent-response", "my-agent-name", "ep-id-123", "", "") + assert.Error(t, err, "agent-name and agent-endpoint-id must be mutually exclusive") } func TestBuildAction_AgentResponseMissingBoth(t *testing.T) { @@ -115,14 +126,14 @@ func TestBuildAction_UnknownType(t *testing.T) { func TestBuildAction_AgentResponseRejectsSessionID(t *testing.T) { t.Parallel() - _, err := buildAction("agent-response", "my-agent-id", "", "", "sess-1") + _, err := buildAction("agent-response", "my-agent-name", "", "", "sess-1") assert.Error(t, err, "--session-id must be rejected for agent-response action") } -func TestBuildAction_AgentInvokeRejectsAgentID(t *testing.T) { +func TestBuildAction_AgentInvokeRejectsAgentName(t *testing.T) { t.Parallel() - _, err := buildAction("agent-invoke", "my-agent-id", "ep-id", "", "") - assert.Error(t, err, "--agent-id must be rejected for agent-invoke action") + _, err := buildAction("agent-invoke", "my-agent-name", "ep-id", "", "") + assert.Error(t, err, "--agent-name must be rejected for agent-invoke action") } func TestBuildAction_AgentInvokeRejectsConversationID(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go index 5b76f711b38..bafdfb667bb 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -96,8 +96,8 @@ func routineSummaryTable(r *routines.Routine) { if r.Action != nil { a := r.Action fmt.Fprintf(tw, "Action:\t%s\n", a.Type) - if a.AgentID != "" { - fmt.Fprintf(tw, " AgentID:\t%s\n", a.AgentID) + if a.AgentName != "" { + fmt.Fprintf(tw, " AgentName:\t%s\n", a.AgentName) } if a.AgentEndpointID != "" { fmt.Fprintf(tw, " AgentEndpointID:\t%s\n", a.AgentEndpointID) diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index ca90a805cc9..ea174c4f908 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -104,14 +104,10 @@ func overwriteRoutineFromFile(existing *routines.Routine, file *routines.Routine // applyUpdateFlags applies named CLI update flags onto an existing routine body. // It returns the count of fields changed. -// -// Note: --cron is intentionally not handled here. The recurring/schedule -// trigger is deferred at the CLI surface (see buildTrigger in -// routine_create.go) until the Foundry service is ready. func applyUpdateFlags( existing *routines.Routine, - description, timeZone, at, agentID, agentEndpointID, conversationID, sessionID string, - descChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged bool, + description, timeZone, at, cronExpression, agentName, agentEndpointID, conversationID, sessionID string, + descChanged, tzChanged, atChanged, cronChanged, agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged bool, ) (int, error) { changed := 0 @@ -144,6 +140,17 @@ func applyUpdateFlags( trigger.At = at changed++ } + if cronChanged { + if trigger == nil { + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --cron: routine has no default trigger", + "add a trigger by recreating the routine, or omit --cron", + ) + } + trigger.CronExpression = cronExpression + changed++ + } if trigger != nil { if existing.Triggers == nil { existing.Triggers = make(map[string]routines.RoutineTrigger) @@ -153,40 +160,40 @@ func applyUpdateFlags( // Action field updates action := getAction(existing) - if agentIDChanged || agentEpChanged { + if agentNameChanged || agentEpChanged { if action == nil { return 0, exterrors.Validation( exterrors.CodeInvalidParameter, "cannot update agent fields: routine has no action", - "add an action by recreating the routine, or omit --agent-id / --agent-endpoint-id", + "add an action by recreating the routine, or omit --agent-name / --agent-endpoint-id", ) } - if agentIDChanged && action.Type == routines.ActionCLIToWire["agent-invoke"] { + if agentNameChanged && action.Type == routines.ActionCLIToWire["agent-invoke"] { return 0, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-id is not applicable to agent-invoke actions", + "--agent-name is not applicable to agent-invoke actions", "use --agent-endpoint-id for agent-invoke, or recreate the routine with agent-response", ) } - // agent-id and agent-endpoint-id are mutually exclusive; specifying one clears the other. - if agentIDChanged && agentEpChanged && agentID != "" && agentEndpointID != "" { + // agent-name and agent-endpoint-id are mutually exclusive; specifying one clears the other. + if agentNameChanged && agentEpChanged && agentName != "" && agentEndpointID != "" { return 0, exterrors.Validation( exterrors.CodeConflictingArguments, - "--agent-id and --agent-endpoint-id are mutually exclusive", - "provide either --agent-id or --agent-endpoint-id, not both", + "--agent-name and --agent-endpoint-id are mutually exclusive", + "provide either --agent-name or --agent-endpoint-id, not both", ) } - if agentIDChanged { - action.AgentID = agentID - if agentID != "" { - action.AgentEndpointID = "" // specifying agent-id clears agent-endpoint-id + if agentNameChanged { + action.AgentName = agentName + if agentName != "" { + action.AgentEndpointID = "" // specifying agent-name clears agent-endpoint-id } changed++ } if agentEpChanged { action.AgentEndpointID = agentEndpointID if agentEndpointID != "" { - action.AgentID = "" // specifying agent-endpoint-id clears agent-id + action.AgentName = "" // specifying agent-endpoint-id clears agent-name } changed++ } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go index 1967a3c9cf1..4c6ce8b7afb 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -25,7 +25,7 @@ func TestReadRoutineManifest_JSON(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "schedule", CronExpression: "0 8 * * 1-5"}, }, - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "my-agent-id"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "my-agent-name"}, } data, err := json.Marshal(r) require.NoError(t, err) @@ -40,7 +40,7 @@ func TestReadRoutineManifest_JSON(t *testing.T) { assert.Equal(t, "schedule", got.Triggers["default"].Type) assert.Equal(t, "0 8 * * 1-5", got.Triggers["default"].CronExpression) require.NotNil(t, got.Action) - assert.Equal(t, "my-agent-id", got.Action.AgentID) + assert.Equal(t, "my-agent-name", got.Action.AgentName) } func TestReadRoutineManifest_YAML(t *testing.T) { @@ -53,7 +53,7 @@ triggers: at: "2026-01-01T00:00:00Z" action: type: invoke_agent_responses_api - agent_id: yaml-agent-id + agent_name: yaml-agent-name ` path := filepath.Join(t.TempDir(), "routine.yaml") require.NoError(t, os.WriteFile(path, []byte(yaml), 0600)) @@ -63,7 +63,7 @@ action: assert.Equal(t, "yaml-routine", got.Name) assert.Equal(t, "timer", got.Triggers["default"].Type) require.NotNil(t, got.Action) - assert.Equal(t, "yaml-agent-id", got.Action.AgentID) + assert.Equal(t, "yaml-agent-name", got.Action.AgentName) } func TestReadRoutineManifest_FileNotFound(t *testing.T) { @@ -89,7 +89,7 @@ func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { file := &routines.Routine{ Description: "from file", Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", CronExpression: "* * * * *"}}, - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "a"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "a"}, } mergeRoutineFromFile(body, file) @@ -97,7 +97,7 @@ func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { assert.Equal(t, "from file", body.Description) assert.Equal(t, "schedule", body.Triggers["default"].Type) require.NotNil(t, body.Action) - assert.Equal(t, "a", body.Action.AgentID) + assert.Equal(t, "a", body.Action.AgentName) } func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { @@ -109,7 +109,7 @@ func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "timer", At: "2026-01-01T00:00:00Z"}, }, - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "cli-agent"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "cli-agent"}, } file := &routines.Routine{ Description: "file description", @@ -123,7 +123,7 @@ func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { assert.Equal(t, "cli description", body.Description, "body description must win") assert.Equal(t, "timer", body.Triggers["default"].Type, "body trigger must win") require.NotNil(t, body.Action) - assert.Equal(t, "cli-agent", body.Action.AgentID, "body action must win") + assert.Equal(t, "cli-agent", body.Action.AgentName, "body action must win") } // ─── overwriteRoutineFromFile ────────────────────────────────────────────────── @@ -137,7 +137,7 @@ func TestOverwriteRoutineFromFile_ManifestWins(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "timer", At: "2026-01-01T00:00:00Z"}, }, - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "old-agent"}, } file := &routines.Routine{ Description: "new description from manifest", @@ -177,7 +177,7 @@ func routineWithScheduleAndAgentResp() *routines.Routine { Triggers: map[string]routines.RoutineTrigger{ "default": {Type: "schedule", CronExpression: "0 8 * * *", TimeZone: "UTC"}, }, - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent-id"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "old-agent-name"}, } } @@ -185,8 +185,8 @@ func TestApplyUpdateFlags_Description(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "new desc", "", "", "", "", "", "", - true, false, false, false, false, false, false, + "new desc", "", "", "", "", "", "", "", + true, false, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) @@ -197,54 +197,54 @@ func TestApplyUpdateFlags_TimeZone(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "", "America/New_York", "", "", "", "", "", - false, true, false, false, false, false, false, + "", "America/New_York", "", "", "", "", "", "", + false, true, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) assert.Equal(t, "America/New_York", r.Triggers["default"].TimeZone) } -func TestApplyUpdateFlags_AgentIDClearsEndpointID(t *testing.T) { +func TestApplyUpdateFlags_AgentNameClearsEndpointID(t *testing.T) { t.Parallel() r := &routines.Routine{ Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, - "", "", "", "new-agent-id", "", "", "", - false, false, false, true, false, false, false, + "", "", "", "", "new-agent-name", "", "", "", + false, false, false, false, true, false, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) require.NotNil(t, r.Action) - assert.Equal(t, "new-agent-id", r.Action.AgentID) - assert.Empty(t, r.Action.AgentEndpointID, "setting agent-id should clear agent-endpoint-id") + assert.Equal(t, "new-agent-name", r.Action.AgentName) + assert.Empty(t, r.Action.AgentEndpointID, "setting agent-name should clear agent-endpoint-id") } func TestApplyUpdateFlags_AgentEndpointIDClearsID(t *testing.T) { t.Parallel() r := &routines.Routine{ - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent-id"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "old-agent-name"}, Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, } n, err := applyUpdateFlags(r, - "", "", "", "", "new-ep", "", "", - false, false, false, false, true, false, false, + "", "", "", "", "", "new-ep", "", "", + false, false, false, false, false, true, false, false, ) require.NoError(t, err) assert.Equal(t, 1, n) require.NotNil(t, r.Action) assert.Equal(t, "new-ep", r.Action.AgentEndpointID) - assert.Empty(t, r.Action.AgentID, "setting agent-endpoint-id should clear agent-id") + assert.Empty(t, r.Action.AgentName, "setting agent-endpoint-id should clear agent-name") } func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() _, err := applyUpdateFlags(r, - "", "", "", "new-agent-id", "new-ep", "", "", - false, false, false, true, true, false, false, + "", "", "", "", "new-agent-name", "new-ep", "", "", + false, false, false, false, true, true, false, false, ) assert.Error(t, err) } @@ -253,24 +253,24 @@ func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() n, err := applyUpdateFlags(r, - "", "", "", "", "", "", "", - false, false, false, false, false, false, false, + "", "", "", "", "", "", "", "", + false, false, false, false, false, false, false, false, ) require.NoError(t, err) assert.Equal(t, 0, n) } -func TestApplyUpdateFlags_AgentIDRejectedForAgentInvoke(t *testing.T) { +func TestApplyUpdateFlags_AgentNameRejectedForAgentInvoke(t *testing.T) { t.Parallel() r := &routines.Routine{ Action: &routines.RoutineAction{Type: "invoke_agent_invocations_api", AgentEndpointID: "ep"}, Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer"}}, } _, err := applyUpdateFlags(r, - "", "", "", "new-agent-id", "", "", "", - false, false, false, true, false, false, false, + "", "", "", "", "new-agent-name", "", "", "", + false, false, false, false, true, false, false, false, ) - assert.Error(t, err, "--agent-id must be rejected for agent-invoke actions") + assert.Error(t, err, "--agent-name must be rejected for agent-invoke actions") } func TestApplyUpdateFlags_ConvIDRejectedForAgentInvoke(t *testing.T) { @@ -280,8 +280,8 @@ func TestApplyUpdateFlags_ConvIDRejectedForAgentInvoke(t *testing.T) { Triggers: map[string]routines.RoutineTrigger{"default": {Type: "timer"}}, } _, err := applyUpdateFlags(r, - "", "", "", "", "", "conv-1", "", - false, false, false, false, false, true, false, + "", "", "", "", "", "", "conv-1", "", + false, false, false, false, false, false, true, false, ) assert.Error(t, err, "--conversation-id must be rejected for agent-invoke actions") } @@ -290,8 +290,8 @@ func TestApplyUpdateFlags_SessIDRejectedForAgentResponse(t *testing.T) { t.Parallel() r := routineWithScheduleAndAgentResp() _, err := applyUpdateFlags(r, - "", "", "", "", "", "", "sess-1", - false, false, false, false, false, false, true, + "", "", "", "", "", "", "", "sess-1", + false, false, false, false, false, false, false, true, ) assert.Error(t, err, "--session-id must be rejected for agent-response actions") } @@ -327,10 +327,10 @@ func TestGetAction_NilWhenEmpty(t *testing.T) { func TestGetAction_ReturnsCopy(t *testing.T) { t.Parallel() r := &routines.Routine{ - Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "orig-agent-id"}, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "orig-agent-name"}, } act := getAction(r) require.NotNil(t, act) - act.AgentID = "changed" - assert.Equal(t, "orig-agent-id", r.Action.AgentID) + act.AgentName = "changed" + assert.Equal(t, "orig-agent-name", r.Action.AgentName) } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go index 41c425b0db5..e83bbe97d68 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -21,7 +21,8 @@ type routineUpdateFlags struct { description string timeZone string at string - agentID string + cronExpression string + agentName string agentEndpointID string conversationID string sessionID string @@ -59,7 +60,8 @@ To change the trigger or action type, delete and recreate the routine.`, cmd.Flags().StringVar(&flags.description, "description", "", "New description for the routine") cmd.Flags().StringVar(&flags.timeZone, "time-zone", "", "New time zone for the trigger") cmd.Flags().StringVar(&flags.at, "at", "", "New ISO 8601 datetime for timer trigger") - cmd.Flags().StringVar(&flags.agentID, "agent-id", "", "New project-scoped agent ID") + cmd.Flags().StringVar(&flags.cronExpression, "cron", "", "New cron expression for recurring trigger") + cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "New project-scoped agent name") cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "New agent endpoint ID") cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", "New conversation ID (preview)") cmd.Flags().StringVar(&flags.sessionID, "session-id", "", "New session ID") @@ -121,17 +123,18 @@ func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpd descChanged := cmd.Flags().Changed("description") tzChanged := cmd.Flags().Changed("time-zone") atChanged := cmd.Flags().Changed("at") - agentIDChanged := cmd.Flags().Changed("agent-id") + cronChanged := cmd.Flags().Changed("cron") + agentNameChanged := cmd.Flags().Changed("agent-name") agentEpChanged := cmd.Flags().Changed("agent-endpoint-id") convIDChanged := cmd.Flags().Changed("conversation-id") sessIDChanged := cmd.Flags().Changed("session-id") flagChanged, err := applyUpdateFlags( existing, - flags.description, flags.timeZone, flags.at, - flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, - descChanged, tzChanged, atChanged, - agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged, + flags.description, flags.timeZone, flags.at, flags.cronExpression, + flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID, + descChanged, tzChanged, atChanged, cronChanged, + agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged, ) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 052ef9cd2f9..5977ce8c3ad 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -37,11 +37,9 @@ type Client struct { // newHTTPClient returns the *http.Client used by the data-plane pipeline. // // The default azcore transport relies on Go's HTTP/2 client, which can wait -// minutes before surfacing a server-side stream reset (RST_STREAM). The -// Foundry Routines data plane returns 500s for certain inputs via stream -// resets, so callers were observing ~6 minute hangs on what curl reports as -// a sub-second failure. We use a transport with explicit response-header and -// connection-level timeouts so failures surface within tens of seconds. +// minutes before surfacing a server-side stream reset (RST_STREAM). We set +// explicit response-header and connection-level timeouts so failures surface +// within tens of seconds. func newHTTPClient() *http.Client { transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, @@ -63,10 +61,6 @@ func newHTTPClient() *http.Client { func NewClient(endpoint string, cred azcore.TokenCredential) *Client { clientOptions := &policy.ClientOptions{ Transport: newHTTPClient(), - // Limit retries so HTTP/2 stream-reset failures surface quickly to the - // user. The azcore default is 3 retries which, combined with a 60s - // response-header timeout, can hide a fast server-side failure behind - // a 4-minute wait. CLI callers can simply re-run the command. Retry: policy.RetryOptions{ MaxRetries: 1, TryTimeout: 30 * time.Second, @@ -107,8 +101,7 @@ func (c *Client) routinesURL(extraQuery ...string) string { } // routineActionURL returns the URL for a named routine action route -// (e.g. :dispatch, :dispatchAsync). The action segment is case-sensitive -// and must match the TypeSpec route exactly. +// (e.g. :enable, :disable, :dispatch_async). func (c *Client) routineActionURL(name, action string) string { return fmt.Sprintf("%s/routines/%s:%s?api-version=%s", c.endpoint, url.PathEscape(name), action, routinesAPIVersion) } @@ -122,7 +115,6 @@ func (c *Client) routineRunsURL(routineName string, extraQuery ...string) string return base } -// addPreviewHeader adds the required Routines preview opt-in header to a request. func addPreviewHeader(req *policy.Request) { req.Raw().Header.Set(routinesPreviewHeader, routinesPreviewValue) } @@ -168,9 +160,6 @@ func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { } all = append(all, page.Value...) - // The service returns an absolute nextLink URL when more pages exist - // (Azure.Core.Page). We follow it verbatim after a same-origin - // check rather than re-deriving the continuation query string. nextURL = page.NextLink } @@ -178,8 +167,6 @@ func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { } // getPage performs a paginated GET and decodes the body into out. -// It scopes resp.Body.Close() to a single iteration to avoid file-descriptor -// accumulation when callers loop across many pages. func (c *Client) getPage(ctx context.Context, pageURL string, out any) error { req, err := runtime.NewRequest(ctx, http.MethodGet, pageURL) if err != nil { @@ -250,50 +237,49 @@ func (c *Client) DeleteRoutine(ctx context.Context, name string) error { return nil } -// EnableRoutine flips `enabled` to true on the routine. -// -// Spec PR #43186 added a dedicated `POST /routines/{name}:enable` route, -// but the live service still returns 404 for it. We fall back to a GET+PUT -// on the routine resource; revisit when the service exposes the route. +// EnableRoutine enables a routine. func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { - return c.setEnabled(ctx, name, true) + return c.postRoutineAction(ctx, name, "enable") } -// DisableRoutine flips `enabled` to false on the routine. -// -// Spec PR #43186 added a dedicated `POST /routines/{name}:disable` route, -// but the live service still returns 404 for it. We fall back to a GET+PUT -// on the routine resource; revisit when the service exposes the route. +// DisableRoutine disables a routine. func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { - return c.setEnabled(ctx, name, false) + return c.postRoutineAction(ctx, name, "disable") } -// setEnabled performs a GET + PUT to mutate the `enabled` field. It returns -// the current routine without an extra round-trip if the field is already -// at the desired value (idempotent enable/disable). -func (c *Client) setEnabled(ctx context.Context, name string, enabled bool) (*Routine, error) { - existing, err := c.GetRoutine(ctx, name) +// postRoutineAction calls a POST : route on a routine and returns the +// updated resource. +func (c *Client) postRoutineAction(ctx context.Context, name, action string) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, action)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create request: %w", err) } - if existing.Enabled != nil && *existing.Enabled == enabled { - return existing, nil + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) } - existing.Enabled = &enabled - return c.PutRoutine(ctx, name, existing) + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var result Routine + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil } -// DispatchRoutineAsync calls the routine async-dispatch action route. -// -// Spec PR #43186 names this route `:dispatch_async` (snake_case). The live -// service only exposes the camelCase form `:dispatchAsync`, so we use that -// here. Revisit when the service catches up to the spec. +// DispatchRoutineAsync calls the routine async-dispatch route. func (c *Client) DispatchRoutineAsync( ctx context.Context, name string, payload *DispatchRoutineRequest, ) (*DispatchRoutineResponse, error) { - req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatchAsync")) + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatch_async")) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -361,7 +347,6 @@ func (c *Client) ListRoutineRuns( all = append(all, page.Value...) - // Respect Top cap across pages. if opts.Top > 0 && len(all) >= opts.Top { all = all[:opts.Top] break @@ -406,7 +391,6 @@ func (c *Client) validateSameOrigin(targetURL string) error { return nil } -// decodeJSON reads and unmarshals a JSON response body. func decodeJSON(body io.Reader, v any) error { data, err := io.ReadAll(body) if err != nil { @@ -418,7 +402,6 @@ func decodeJSON(body io.Reader, v any) error { return nil } -// setJSONBody marshals v as JSON and sets it as the request body. func setJSONBody(req *policy.Request, v any) error { data, err := json.Marshal(v) if err != nil { diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go index b73addd1c62..8a4fe008b44 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -2,58 +2,38 @@ // Licensed under the MIT License. // Package routines provides the data-plane client and models for Microsoft Foundry Routines. +// +// Wire shapes follow the Foundry Routines TypeSpec +// (azure-rest-api-specs PR #43186, src/routines/{models,routes}.tsp). package routines // Routine represents a Foundry routine resource. -// -// Field shapes follow the Routines TypeSpec -// (azure-rest-api-specs PR #43186, src/routines/models.tsp), with a deliberate -// wire-naming divergence noted below. -// -// JSON tags use camelCase to match the deployed Foundry service, which applies -// a camelCase property-naming policy on the wire regardless of the snake_case -// casing in the TypeSpec / OpenAPI document. YAML tags stay snake_case to -// match the user-facing manifest convention used in --file documentation. -// -// Spec divergences kept for service compatibility: -// - Wire field naming uses camelCase, not snake_case as in the spec. -// - `AgentID` keeps the wire name `agentId`; the spec renames this to -// `agent_name`, but the live service still expects `agentId`. type Routine struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` Triggers map[string]RoutineTrigger `json:"triggers,omitempty" yaml:"triggers,omitempty"` Action *RoutineAction `json:"action,omitempty" yaml:"action,omitempty"` - CreatedAt string `json:"createdAt,omitempty" yaml:"created_at,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty" yaml:"updated_at,omitempty"` + CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` } // RoutineTrigger is the discriminated union for routine triggers. -// The "type" field selects the variant: -// - "schedule" (CLI alias: "recurring"): cron-based recurring trigger -// - "timer": one-shot timer trigger -// - "github_issue_opened": GitHub issue-opened trigger (deferred in CLI) -// -// The spec previously used `github_issue` with `owner`/`actions[]` fields; -// PR #43186 renamed it to `github_issue_opened` with an `assignee` field. -// The CLI surface for this trigger is deferred, so the struct tracks the new -// spec shape (assignee), while `TriggerCLIToWire` still maps the CLI alias -// `github-issue` to `github_issue` for live-service compatibility. +// The "type" field selects the variant: "schedule", "timer", or "github_issue". type RoutineTrigger struct { Type string `json:"type" yaml:"type"` // schedule fields - CronExpression string `json:"cronExpression,omitempty" yaml:"cron_expression,omitempty"` + CronExpression string `json:"cron_expression,omitempty" yaml:"cron_expression,omitempty"` // schedule / timer shared - TimeZone string `json:"timeZone,omitempty" yaml:"time_zone,omitempty"` + TimeZone string `json:"time_zone,omitempty" yaml:"time_zone,omitempty"` // timer-only fields At string `json:"at,omitempty" yaml:"at,omitempty"` - // github_issue_opened fields (per spec PR #43186) - ConnectionID string `json:"connectionId,omitempty" yaml:"connection_id,omitempty"` + // github_issue fields + ConnectionID string `json:"connection_id,omitempty" yaml:"connection_id,omitempty"` Assignee string `json:"assignee,omitempty" yaml:"assignee,omitempty"` Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` } @@ -62,26 +42,15 @@ type RoutineTrigger struct { // The "type" field selects the variant: // - "invoke_agent_responses_api" (CLI alias: "agent-response") // - "invoke_agent_invocations_api" (CLI alias: "agent-invoke") -// -// Spec PR #43186 renamed `agent_id` to `agent_name` in -// `InvokeAgentResponsesApiRoutineAction`. The live service still expects -// `agentId`, so we keep `AgentID` with the `agentId` JSON tag and revisit -// when the service catches up. type RoutineAction struct { - Type string `json:"type" yaml:"type"` - AgentID string `json:"agentId,omitempty" yaml:"agent_id,omitempty"` - AgentEndpointID string `json:"agentEndpointId,omitempty" yaml:"agent_endpoint_id,omitempty"` - ConversationID string `json:"conversationId,omitempty" yaml:"conversation_id,omitempty"` - SessionID string `json:"sessionId,omitempty" yaml:"session_id,omitempty"` + Type string `json:"type" yaml:"type"` + AgentName string `json:"agent_name,omitempty" yaml:"agent_name,omitempty"` + AgentEndpointID string `json:"agent_endpoint_id,omitempty" yaml:"agent_endpoint_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty" yaml:"conversation_id,omitempty"` + SessionID string `json:"session_id,omitempty" yaml:"session_id,omitempty"` } // PagedRoutine represents a page of routine resources. -// -// Spec PR #43186 defines the paginated envelope as `AgentsPagedResult` -// with fields `data`, `first_id`, `last_id`, `has_more` (where `last_id` -// is the continuation cursor passed back as `after=`). The deployed service -// still returns the legacy `value` + `nextLink` shape, so the client tracks -// that shape for now and revisits when the service catches up. type PagedRoutine struct { Value []Routine `json:"value"` NextLink string `json:"nextLink,omitempty"` @@ -92,27 +61,21 @@ type RoutineRun struct { ID string `json:"id,omitempty"` Status string `json:"status,omitempty"` Phase string `json:"phase,omitempty"` - TriggerType string `json:"triggerType,omitempty"` - AttemptSource string `json:"attemptSource,omitempty"` - ActionType string `json:"actionType,omitempty"` - TriggeredAt string `json:"triggeredAt,omitempty"` - StartedAt string `json:"startedAt,omitempty"` - EndedAt string `json:"endedAt,omitempty"` - DispatchID string `json:"dispatchId,omitempty"` - ActionCorrelationID string `json:"actionCorrelationId,omitempty"` - ResponseID string `json:"responseId,omitempty"` - // TaskID is the workspace task identifier linked to the routine attempt - // (added in spec PR #43186; the service already emits it). - TaskID string `json:"taskId,omitempty"` - ErrorType string `json:"errorType,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` + TriggerType string `json:"trigger_type,omitempty"` + AttemptSource string `json:"attempt_source,omitempty"` + ActionType string `json:"action_type,omitempty"` + TriggeredAt string `json:"triggered_at,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` + ResponseID string `json:"response_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + ErrorType string `json:"error_type,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` } // PagedRoutineRun represents a page of routine run records. -// -// Spec PR #43186 also models this with `AgentsPagedResult`. The -// deployed service still uses `value` + `nextPageToken`, so the client tracks -// that shape for now. type PagedRoutineRun struct { Value []RoutineRun `json:"value"` NextPageToken string `json:"nextPageToken,omitempty"` @@ -126,31 +89,22 @@ type RoutineDispatchPayload struct { Input string `json:"input,omitempty"` } -// DispatchRoutineRequest is the request body for the :dispatchAsync route. -// -// The spec route is `:dispatch_async` (snake_case); the live service exposes -// the camelCase form `:dispatchAsync` only. The client URL is camelCase to -// match the service. +// DispatchRoutineRequest is the request body for the :dispatch_async route. type DispatchRoutineRequest struct { Payload *RoutineDispatchPayload `json:"payload,omitempty"` } -// DispatchRoutineResponse is the response from the :dispatchAsync route. -// -// `TaskID` was added in spec PR #43186 and is already emitted by the service. +// DispatchRoutineResponse is the response from the :dispatch_async route. type DispatchRoutineResponse struct { - DispatchID string `json:"dispatchId,omitempty"` - ActionCorrelationID string `json:"actionCorrelationId,omitempty"` - TaskID string `json:"taskId,omitempty"` + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` + TaskID string `json:"task_id,omitempty"` } // TriggerCLIToWire maps CLI --trigger aliases to wire type values. // -// Note: spec PR #43186 renamed the github trigger wire value from -// `github_issue` to `github_issue_opened`. The live service still expects -// `github_issue`, so the CLI alias `github-issue` keeps that value until the -// service catches up. The CLI does not expose the github trigger yet — see -// `buildTrigger` in `routine_create.go` for the deferred-feature gate. +// The github_issue value here matches the deployed service. The TypeSpec uses +// github_issue_opened; the CLI does not expose the github trigger yet. var TriggerCLIToWire = map[string]string{ "recurring": "schedule", "timer": "timer", From b1d23b909c1ed413a161e6cab6630d55124d3e9d Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 26 May 2026 17:19:20 +0800 Subject: [PATCH 21/21] =?UTF-8?q?fix(routines):=20address=20review=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20op=20code,=20gRPC=20cancel,=20manifest=20erro?= =?UTF-8?q?rs,=20logging,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routine_dispatch.go: use OpDispatchRoutine (not OpGetRoutine) when the inner GetRoutine call fails during dispatch validation - exterrors/errors.go: IsCancellation now checks gRPC codes.Canceled in addition to context.Canceled, matching the agents implementation - routine_manifest.go: distinguish os.IsNotExist from other os.ReadFile errors so permission-denied / is-a-directory get accurate messages - client.go: add Logging.AllowedHeaders for MsCorrelationIdHeader to match agents observability parity - AGENTS.md: rewrite exterrors section in present tense (package exists) --- cli/azd/extensions/azure.ai.routines/AGENTS.md | 6 +++--- .../internal/cmd/routine_dispatch.go | 2 +- .../internal/cmd/routine_manifest.go | 11 +++++++++-- .../azure.ai.routines/internal/exterrors/errors.go | 13 +++++++++++-- .../internal/pkg/routines/client.go | 3 +++ 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.routines/AGENTS.md b/cli/azd/extensions/azure.ai.routines/AGENTS.md index 97f7ef702eb..1ddcace0a34 100644 --- a/cli/azd/extensions/azure.ai.routines/AGENTS.md +++ b/cli/azd/extensions/azure.ai.routines/AGENTS.md @@ -55,9 +55,9 @@ present. Return plain Go errors by default, and wrap lower-level failures with `fmt.Errorf("context: %w", err)` where useful. -If this extension grows enough to need stable telemetry categories, error codes, -or user-facing suggestions, introduce an `internal/exterrors` package modeled on -the one in `azure.ai.agents` / `azure.ai.toolboxes`: +This extension uses an `internal/exterrors` package (modeled on `azure.ai.agents` / +`azure.ai.toolboxes`) for stable telemetry categories, error codes, and +user-facing suggestions: - Create a structured error once, as close as possible to the place where you know the final category, code, and suggestion. diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go index fd397aa4f9d..8f50277613c 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -72,7 +72,7 @@ func runRoutineDispatch( return exterrors.ServiceFromStatus(404, exterrors.OpDispatchRoutine, fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) } - return exterrors.ServiceFromAzure(getErr, exterrors.OpGetRoutine) + return exterrors.ServiceFromAzure(getErr, exterrors.OpDispatchRoutine) } if routine.Action == nil || routine.Action.Type == "" { return exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go index ea174c4f908..122e80c04ea 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -21,10 +21,17 @@ func readRoutineManifest(path string) (*routines.Routine, error) { // #nosec G304 - path is provided by the user via --file and is intentional data, err := os.ReadFile(path) if err != nil { + if os.IsNotExist(err) { + return nil, exterrors.Dependency( + exterrors.CodeFileNotFound, + fmt.Sprintf("routine manifest file not found: %s", path), + "verify the path or rerun without --file", + ) + } return nil, exterrors.Dependency( exterrors.CodeFileNotFound, - fmt.Sprintf("routine manifest file not found: %s", path), - "verify the path or rerun without --file", + fmt.Sprintf("unable to read routine manifest file %s: %v", path, err), + "check file permissions and ensure the path points to a regular file", ) } diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go index 573c8a9b7cb..6ff4344d040 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go @@ -13,6 +13,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Validation returns a validation LocalError for user-input errors. @@ -129,9 +131,16 @@ func IsConflict(err error) bool { return false } -// IsCancellation checks if an error represents user cancellation. +// IsCancellation checks if an error represents user cancellation +// ([context.Canceled] or gRPC [codes.Canceled]). func IsCancellation(err error) bool { - return errors.Is(err, context.Canceled) + if errors.Is(err, context.Canceled) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.Canceled { + return true + } + return false } // authFromMessage creates an Auth error from an HTTP response message. diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go index 5977ce8c3ad..cb8b86564a0 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -61,6 +61,9 @@ func newHTTPClient() *http.Client { func NewClient(endpoint string, cred azcore.TokenCredential) *Client { clientOptions := &policy.ClientOptions{ Transport: newHTTPClient(), + Logging: policy.LogOptions{ + AllowedHeaders: []string{azsdk.MsCorrelationIdHeader}, + }, Retry: policy.RetryOptions{ MaxRetries: 1, TryTimeout: 30 * time.Second,