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/.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..1ddcace0a34 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/AGENTS.md @@ -0,0 +1,146 @@ +# 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. + +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. +- 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`. + +## 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 a small number of remaining +divergences kept for compatibility with the currently deployed Foundry service: + +| Concern | Spec | Live service | Client choice | +|---|---|---|---| +| `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/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/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..e66e93f16e4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint.go @@ -0,0 +1,226 @@ +// 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", +} + +// 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", + ) + } + + // Reject explicit ports: Foundry hosts are reached on the default HTTPS + // 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, + "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( + 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 +} + +// 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 +// 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 (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 + } + + 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. + 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/endpoint_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go new file mode 100644 index 00000000000..a47ed3a6a38 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/endpoint_test.go @@ -0,0 +1,205 @@ +// 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" +) + +// 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 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 + }{ + {"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) { + t.Parallel() + assert.Equal(t, tt.want, isFoundryHost(tt.host)) + }) + } +} + +// ─── validateProjectEndpoint ───────────────────────────────────────────────── + +func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + got, err := validateProjectEndpoint(tt.raw) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidateProjectEndpoint_Rejections(t *testing.T) { + t.Parallel() + 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"}, + {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) { + t.Parallel() + _, err := validateProjectEndpoint(tt.raw) + assert.Error(t, err) + }) + } +} + +// ─── resolveProjectEndpoint cascade ────────────────────────────────────────── + +func TestResolveProjectEndpoint_FlagWins(t *testing.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) + 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) + 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) + 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) + 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) + 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/root.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go index 4b8c6131a22..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,9 +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(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 } 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..28026ef740c --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -0,0 +1,313 @@ +// 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 + timeZone string + at string + cronExpression 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: 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.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", "", + "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 + // 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 + } + + 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 timer or --trigger recurring, 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.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 + } + + // 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. +// +// 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 or --trigger recurring; github-issue is deferred to a future release", + ) + } + + 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: timer, recurring", + ) + } + + t := routines.RoutineTrigger{ + Type: wireType, + TimeZone: flags.timeZone, + } + + switch flags.trigger { + 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 + 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, 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 ", + ) + } + 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.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 ", + ) + } + if agentName != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name is not applicable to agent-invoke action", + "use --agent-endpoint-id for agent-invoke, or omit --agent-name", + ) + } + 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 + } + + return a, nil +} 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..30ba4e73bb6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -0,0 +1,143 @@ +// 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) { + 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() + 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", + 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) { + 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) +} + +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) { + t.Parallel() + 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-name", got.AgentName) + assert.Empty(t, got.AgentEndpointID) + assert.Equal(t, "conv-1", got.ConversationID) +} + +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.Equal(t, "ep-id-123", got.AgentEndpointID) +} + +func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { + t.Parallel() + _, 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) { + 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) + assert.Equal(t, "ep-id-456", got.AgentEndpointID) + assert.Equal(t, "sess-1", got.SessionID) +} + +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) +} + +func TestBuildAction_AgentResponseRejectsSessionID(t *testing.T) { + t.Parallel() + _, err := buildAction("agent-response", "my-agent-name", "", "", "sess-1") + assert.Error(t, err, "--session-id must be rejected for agent-response action") +} + +func TestBuildAction_AgentInvokeRejectsAgentName(t *testing.T) { + t.Parallel() + _, 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) { + 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 new file mode 100644 index 00000000000..185c1299514 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go @@ -0,0 +1,97 @@ +// 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, extCtx.NoPrompt, 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, 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 { + 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..8f50277613c --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -0,0 +1,123 @@ +// 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 output string + + cmd := &cobra.Command{ + Use: "dispatch ", + Short: "Manually trigger a routine.", + Long: `Manually trigger a Foundry routine. + +The service runs the routine asynchronously. By default, the command prints +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, output) + }, + } + + cmd.Flags().BoolVar(&asyncMode, "async", false, + "Suppress descriptive output; useful for scripting") + cmd.Flags().StringVar(&input, "input", "", + "Plain-text user-message payload for the routine dispatch") + + 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, output string, +) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + // 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 + 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.OpDispatchRoutine) + } + 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{ + Payload: &routines.RoutineDispatchPayload{ + Type: routine.Action.Type, + Input: input, + }, + } + } + + 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 { + if resp.DispatchID != "" { + fmt.Println(resp.DispatchID) + } + return nil + } + + 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.TaskID != "" { + fmt.Printf("Task ID: %s\n", resp.TaskID) + } + 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..bafdfb667bb --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_helpers.go @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "slices" + "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 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 "unknown" + } + if *b { + return "true" + } + return "false" +} + +// 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)) + // 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) + } + if t.TimeZone != "" { + fmt.Fprintf(tw, " TimeZone:\t%s\n", t.TimeZone) + } + } + 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.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 new file mode 100644 index 00000000000..25508b5968a --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go @@ -0,0 +1,86 @@ +// 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 := "" + // 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 r.Action != nil { + actionType = r.Action.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..122e80c04ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -0,0 +1,267 @@ +// 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) { + // #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("unable to read routine manifest file %s: %v", path, err), + "check file permissions and ensure the path points to a regular 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 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 + } + 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 file.Action != nil && body.Action == nil { + body.Action = file.Action + } +} + +// 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. +func applyUpdateFlags( + existing *routines.Routine, + description, timeZone, at, cronExpression, agentName, agentEndpointID, conversationID, sessionID string, + descChanged, tzChanged, atChanged, cronChanged, agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged bool, +) (int, error) { + changed := 0 + + if descChanged { + existing.Description = description + changed++ + } + + // Trigger field updates + trigger := getTrigger(existing) + if tzChanged { + if trigger == nil { + 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, 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++ + } + 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) + } + existing.Triggers[routines.DefaultTriggerKey] = *trigger + } + + // Action field updates + action := getAction(existing) + 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-name / --agent-endpoint-id", + ) + } + if agentNameChanged && action.Type == routines.ActionCLIToWire["agent-invoke"] { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-name is not applicable to agent-invoke actions", + "use --agent-endpoint-id for agent-invoke, or recreate the routine with agent-response", + ) + } + // 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, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --conversation-id: routine has no action", + "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++ + } + if sessIDChanged { + if action == nil { + 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", + ) + } + 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++ + } + if action != nil { + existing.Action = 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 routine action, or nil. +func getAction(r *routines.Routine) *routines.RoutineAction { + if r.Action == 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 new file mode 100644 index 00000000000..4c6ce8b7afb --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -0,0 +1,336 @@ +// 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) { + t.Parallel() + r := &routines.Routine{ + Name: "test-routine", + Description: "a test routine", + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", CronExpression: "0 8 * * 1-5"}, + }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "my-agent-name"}, + } + 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"].CronExpression) + require.NotNil(t, got.Action) + assert.Equal(t, "my-agent-name", got.Action.AgentName) +} + +func TestReadRoutineManifest_YAML(t *testing.T) { + t.Parallel() + yaml := `name: yaml-routine +description: yaml desc +triggers: + default: + type: timer + at: "2026-01-01T00:00:00Z" +action: + type: invoke_agent_responses_api + agent_name: yaml-agent-name +` + 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) + require.NotNil(t, got.Action) + assert.Equal(t, "yaml-agent-name", got.Action.AgentName) +} + +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)) + + _, err := readRoutineManifest(path) + assert.Error(t, err) +} + +// ─── mergeRoutineFromFile ───────────────────────────────────────────────────── + +func TestMergeRoutineFromFile_FileFieldsMergedWhenBodyEmpty(t *testing.T) { + t.Parallel() + body := &routines.Routine{Name: "from-cli"} + file := &routines.Routine{ + Description: "from file", + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule", CronExpression: "* * * * *"}}, + Action: &routines.RoutineAction{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) + require.NotNil(t, body.Action) + assert.Equal(t, "a", body.Action.AgentName) +} + +func TestMergeRoutineFromFile_BodyFieldsWinOverFile(t *testing.T) { + t.Parallel() + body := &routines.Routine{ + Name: "from-cli", + Description: "cli 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", AgentName: "cli-agent"}, + } + file := &routines.Routine{ + Description: "file description", + Triggers: map[string]routines.RoutineTrigger{ + "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") + require.NotNil(t, body.Action) + assert.Equal(t, "cli-agent", body.Action.AgentName, "body action must win") +} + +// ─── 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", AgentName: "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{ + Name: "my-routine", + Description: "old desc", + Triggers: map[string]routines.RoutineTrigger{ + "default": {Type: "schedule", CronExpression: "0 8 * * *", TimeZone: "UTC"}, + }, + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "old-agent-name"}, + } +} + +func TestApplyUpdateFlags_Description(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + 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_TimeZone(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + n, err := applyUpdateFlags(r, + "", "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_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-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-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", AgentName: "old-agent-name"}, + 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) + require.NotNil(t, r.Action) + assert.Equal(t, "new-ep", r.Action.AgentEndpointID) + 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-name", "new-ep", "", "", + false, false, false, false, true, true, false, false, + ) + assert.Error(t, err) +} + +func TestApplyUpdateFlags_NoChangesReturnsZero(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + n, err := applyUpdateFlags(r, + "", "", "", "", "", "", "", "", + false, false, false, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 0, n) +} + +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-name", "", "", "", + false, false, false, false, true, false, false, false, + ) + assert.Error(t, err, "--agent-name 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, 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, false, true, + ) + assert.Error(t, err, "--session-id must be rejected for agent-response actions") +} + +// ─── 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", CronExpression: "0 9 * * *"}, + }, + } + trig := getTrigger(r) + require.NotNil(t, trig) + assert.Equal(t, "schedule", trig.Type) + trig.CronExpression = "changed" + assert.Equal(t, "0 9 * * *", r.Triggers["default"].CronExpression) +} + +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{ + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentName: "orig-agent-name"}, + } + act := getAction(r) + require.NotNil(t, act) + act.AgentName = "changed" + assert.Equal(t, "orig-agent-name", r.Action.AgentName) +} 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..2e4cff73ab6 --- /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\tPHASE\tSTARTED\tENDED") + fmt.Fprintln(tw, "--\t------\t-----\t-------\t-----") + for _, run := range items { + 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_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..e83bbe97d68 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -0,0 +1,166 @@ +// 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 + timeZone string + at string + cronExpression 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.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.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") + 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, overwrite routine fields with the manifest (manifest wins). + var changed int + if flags.file != "" { + manifest, err := readRoutineManifest(flags.file) + if err != nil { + return err + } + changed += overwriteRoutineFromFile(existing, manifest) + // Preserve the positional name argument. + existing.Name = flags.name + } + + // Apply named flag changes (flag presence, not just non-empty value). + descChanged := cmd.Flags().Changed("description") + tzChanged := cmd.Flags().Changed("time-zone") + atChanged := cmd.Flags().Changed("at") + 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.cronExpression, + flags.agentName, flags.agentEndpointID, flags.conversationID, flags.sessionID, + descChanged, tzChanged, atChanged, cronChanged, + agentNameChanged, agentEpChanged, convIDChanged, sessIDChanged, + ) + if err != nil { + return err + } + changed += flagChanged + + 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..3c127838fce --- /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..6ff4344d040 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go @@ -0,0 +1,164 @@ +// 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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// 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 +// ([context.Canceled] or gRPC [codes.Canceled]). +func IsCancellation(err error) bool { + 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. +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/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) +} 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..cb8b86564a0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package routines + +import ( + "bytes" + "context" + "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" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const ( + routinesAPIVersion = "v1" + routinesPreviewHeader = "Foundry-Features" + routinesPreviewValue = "Routines=V1Preview" +) + +// Client is the data-plane client for Foundry Routines API operations. +type Client struct { + endpoint string + 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). 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, + 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(), + Logging: policy.LogOptions{ + AllowedHeaders: []string{azsdk.MsCorrelationIdHeader}, + }, + Retry: policy.RetryOptions{ + MaxRetries: 1, + TryTimeout: 30 * time.Second, + }, + 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 route +// (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) +} + +// 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 +} + +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 + } + + var page PagedRoutine + if err := c.getPage(ctx, nextURL, &page); err != nil { + return nil, err + } + + all = append(all, page.Value...) + nextURL = page.NextLink + } + + return all, nil +} + +// getPage performs a paginated GET and decodes the body into out. +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)) + 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 enables a routine. +func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.postRoutineAction(ctx, name, "enable") +} + +// DisableRoutine disables a routine. +func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.postRoutineAction(ctx, name, "disable") +} + +// 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, 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 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, "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 + + // 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 != "" { + baseQuery = append(baseQuery, "filter="+url.QueryEscape(opts.Filter)) + } + + 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 + } + + var page PagedRoutineRun + if err := c.getPage(ctx, nextURL, &page); err != nil { + return nil, err + } + + all = append(all, page.Value...) + + if opts.Top > 0 && len(all) >= opts.Top { + all = all[:opts.Top] + break + } + + if page.NextPageToken != "" { + pageQuery := append(slices.Clone(baseQuery), + "pageToken="+url.QueryEscape(page.NextPageToken)) + nextURL = c.routineRunsURL(routineName, pageQuery...) + } 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 +} + +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 +} + +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..8a4fe008b44 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -0,0 +1,121 @@ +// 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. +// +// 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. +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"` +} + +// RoutineTrigger is the discriminated union for routine triggers. +// 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:"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"` + + // 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"` +} + +// 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" 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. +type PagedRoutine struct { + 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"` + 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"` + 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. +type PagedRoutineRun struct { + Value []RoutineRun `json:"value"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +// 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_async route. +type DispatchRoutineRequest struct { + Payload *RoutineDispatchPayload `json:"payload,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"` + TaskID string `json:"task_id,omitempty"` +} + +// TriggerCLIToWire maps CLI --trigger aliases to wire type values. +// +// 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", + "github-issue": "github_issue", +} + +// 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", +} + +// DefaultTriggerKey is the map key used for the single trigger in create/update. +const DefaultTriggerKey = "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 new file mode 100644 index 00000000000..596c990c02b --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models_test.go @@ -0,0 +1,61 @@ +// 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + assert.Equal(t, "default", DefaultTriggerKey) +} + +func TestTriggerCLIToWire_NoUnknownEntries(t *testing.T) { + t.Parallel() + // 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) { + t.Parallel() + for k := range ActionCLIToWire { + switch k { + case "agent-response", "agent-invoke": + // OK + default: + t.Errorf("unexpected key %q in ActionCLIToWire", k) + } + } +}