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..24fa80b92e6 --- /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) and accepting other ports would silently misroute traffic + // (the normalized URL strips the port). + if u.Port() != "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not contain an explicit port", + "remove the port from the URL (Foundry uses the default HTTPS port 443)", + ) + } + + host := u.Hostname() + if host == "" || !isFoundryHost(host) { + return "", exterrors.Validation( + 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..baa0a3f3230 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create.go @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// routineCreateFlags holds validated input for the create command. +type routineCreateFlags struct { + name string + trigger string + cron string + timeZone string + at string + action string + agentID string + agentEndpointID string + conversationID string + sessionID string + description string + enabled bool + force bool + file string + output string +} + +func newRoutineCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &routineCreateFlags{ + enabled: true, // default to enabled on creation + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new routine.", + Long: `Create a new Foundry routine. + +A routine pairs a trigger (--trigger) with an action (--action). +Use --file to create from a YAML/JSON manifest file instead of individual flags.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineCreate(ctx, cmd, flags) + }, + } + + cmd.Flags().StringVar(&flags.trigger, "trigger", "", + "Trigger type: recurring, timer (required unless --file is used)") + cmd.Flags().StringVar(&flags.cron, "cron", "", + "Cron expression for recurring trigger (e.g. '0 8 * * 1-5')") + cmd.Flags().StringVar(&flags.timeZone, "time-zone", "UTC", + "Time zone for the trigger (e.g. 'America/New_York')") + cmd.Flags().StringVar(&flags.at, "at", "", + "ISO 8601 datetime for timer trigger (e.g. '2026-04-24T15:00:00Z')") + cmd.Flags().StringVar(&flags.action, "action", "agent-response", + "Action type: agent-response (default), agent-invoke") + cmd.Flags().StringVar(&flags.agentID, "agent-id", "", + "Project-scoped agent ID (for agent-response action)") + cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", + "Agent endpoint ID (for agent-response or agent-invoke action)") + cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", + "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 recurring, --trigger timer, or use --file", + ) + } + + trigger, err := buildTrigger(flags) + if err != nil { + return err + } + body.Triggers = map[string]routines.RoutineTrigger{ + routines.DefaultTriggerKey: trigger, + } + + action, err := buildAction( + flags.action, flags.agentID, 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. +func buildTrigger(flags *routineCreateFlags) (routines.RoutineTrigger, error) { + // "github-issue" is present in TriggerCLIToWire (it maps to the wire type + // for the deferred GitHub issue trigger), but the CLI does not yet support + // supplying the fields it needs (connection, owner, repository, actions). + // Reject it explicitly so users get a clear "deferred" message instead of + // a silently incomplete request. + if flags.trigger == "github-issue" { + return routines.RoutineTrigger{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + "trigger type 'github-issue' is not yet supported by the CLI", + "use --trigger recurring or --trigger timer; github-issue is deferred to a future release", + ) + } + + wireType, ok := routines.TriggerCLIToWire[flags.trigger] + if !ok { + return routines.RoutineTrigger{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown trigger type %q", flags.trigger), + "supported triggers: recurring, timer", + ) + } + + t := routines.RoutineTrigger{ + Type: wireType, + TimeZone: flags.timeZone, + } + + switch flags.trigger { + case "recurring": + if flags.cron == "" { + return t, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--cron is required for trigger type 'recurring'", + "provide a cron expression, e.g. '0 8 * * 1-5'", + ) + } + t.CronExpression = flags.cron + case "timer": + if flags.at == "" { + return t, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--at is required for trigger type 'timer'", + "provide an ISO 8601 datetime, e.g. '2026-04-24T15:00:00Z'", + ) + } + t.At = flags.at + } + + return t, nil +} + +// buildAction constructs a RoutineAction from CLI flags. +func buildAction(actionType, agentID, 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 agentID != "" && agentEndpointID != "" { + return a, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-id and --agent-endpoint-id are mutually exclusive for agent-response action", + "provide either --agent-id or --agent-endpoint-id, not both", + ) + } + if agentID == "" && agentEndpointID == "" { + return a, exterrors.Validation( + exterrors.CodeInvalidParameter, + "one of --agent-id or --agent-endpoint-id is required for agent-response action", + "provide --agent-id or --agent-endpoint-id ", + ) + } + a.AgentID = agentID + a.AgentEndpointID = agentEndpointID + a.ConversationID = conversationID + case "agent-invoke": + if agentEndpointID == "" { + return a, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint-id is required for agent-invoke action", + "provide --agent-endpoint-id ", + ) + } + a.AgentEndpointID = agentEndpointID + a.SessionID = sessionID + } + + return a, nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go new file mode 100644 index 00000000000..b4986afcd77 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_create_test.go @@ -0,0 +1,125 @@ +// 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", + cron: "0 8 * * 1-5", + timeZone: "America/New_York", + } + got, err := buildTrigger(flags) + require.NoError(t, err) + assert.Equal(t, "schedule", got.Type) + assert.Equal(t, "0 8 * * 1-5", got.CronExpression) + assert.Equal(t, "America/New_York", got.TimeZone) +} + +func TestBuildTrigger_RecurringMissingCron(t *testing.T) { + 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-id", "", "conv-1", "") + require.NoError(t, err) + assert.Equal(t, routines.ActionCLIToWire["agent-response"], got.Type) + assert.Equal(t, "my-agent-id", got.AgentID) + 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.AgentID) + assert.Equal(t, "ep-id-123", got.AgentEndpointID) +} + +func TestBuildAction_AgentResponseMutuallyExclusive(t *testing.T) { + t.Parallel() + _, err := buildAction("agent-response", "my-agent-id", "ep-id-123", "", "") + assert.Error(t, err, "agent-id and agent-endpoint-id must be mutually exclusive") +} + +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) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go new file mode 100644 index 00000000000..b915c0373f8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_delete.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var force bool + var output string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a routine.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineDelete(ctx, cmd, args[0], force, output) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, + "Skip confirmation prompt (required in --no-prompt mode)") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineDelete(ctx context.Context, cmd *cobra.Command, name string, force bool, output string) error { + noPrompt, _ := cmd.Flags().GetBool("no-prompt") + + // In --no-prompt mode, --force is required. + if noPrompt && !force { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--force is required when --no-prompt is set", + "add --force to skip confirmation in --no-prompt mode", + ) + } + + // Interactive confirmation prompt (unless --force). + if !force { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client for prompt: %w", err) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Delete routine '%s'?", name), + DefaultValue: new(bool), // default false + }, + }) + if promptErr != nil { + return fmt.Errorf("prompt failed: %w", promptErr) + } + if resp.Value == nil || !*resp.Value { + fmt.Println("Delete cancelled.") + return nil + } + } + + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + if err := client.DeleteRoutine(ctx, name); err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpDeleteRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteRoutine) + } + + if output == "json" { + return printJSON(map[string]any{"deleted": true, "name": name}) + } + + fmt.Printf("Routine '%s' deleted.\n", name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go new file mode 100644 index 00000000000..8469ba9b25e --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_disable.go @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newRoutineDisableCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "disable ", + Short: "Disable a routine.", + Long: `Disable a Foundry routine. + +This operation is idempotent: disabling an already-disabled routine is a no-op success.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineDisable(ctx, cmd, args[0], output) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineDisable(ctx context.Context, cmd *cobra.Command, name, output string) error { + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + result, err := client.DisableRoutine(ctx, name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpDisableRoutine, + fmt.Sprintf("routine %q not found. Verify the name with 'routine list'.", name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDisableRoutine) + } + + if output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' disabled.\n", name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go new file mode 100644 index 00000000000..0cbdc741f2b --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_dispatch.go @@ -0,0 +1,120 @@ +// 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.OpGetRoutine) + } + if routine.Action == nil || routine.Action.Type == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("routine %q has no action configured; cannot dispatch with --input", name), + "update the routine to add an action before dispatching", + ) + } + payload = &routines.DispatchRoutineRequest{ + 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) + } + 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..5b76f711b38 --- /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.AgentID != "" { + fmt.Fprintf(tw, " AgentID:\t%s\n", a.AgentID) + } + if a.AgentEndpointID != "" { + fmt.Fprintf(tw, " AgentEndpointID:\t%s\n", a.AgentEndpointID) + } + } +} + +// sortedKeys returns the keys of a string-keyed map in lexicographic order. +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_list.go 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..160bcf27cbf --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest.go @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "azure.ai.routines/internal/exterrors" + "azure.ai.routines/internal/pkg/routines" + + "gopkg.in/yaml.v3" +) + +// readRoutineManifest reads and parses a routine manifest from a YAML or JSON file. +func readRoutineManifest(path string) (*routines.Routine, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeFileNotFound, + fmt.Sprintf("routine manifest file not found: %s", path), + "verify the path or rerun without --file", + ) + } + + var r routines.Routine + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("failed to parse routine manifest %s: %v", path, err), + "ensure the file is valid YAML and matches the routine schema", + ) + } + case ".json", "": + if err := json.Unmarshal(data, &r); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("failed to parse routine manifest %s: %v", path, err), + "ensure the file is valid JSON and matches the routine schema", + ) + } + default: + return nil, exterrors.Validation( + exterrors.CodeInvalidRoutineManifest, + fmt.Sprintf("unsupported manifest file extension %q", ext), + "use a .yaml, .yml, or .json file", + ) + } + + return &r, nil +} + +// mergeRoutineFromFile copies fields from the manifest into body. +// The caller's positional argument wins over any name in the file. +// Individual flag overrides are applied by the caller after this function returns. +func mergeRoutineFromFile(body *routines.Routine, file *routines.Routine) { + if file.Description != "" && body.Description == "" { + body.Description = file.Description + } + if file.Enabled != nil && body.Enabled == nil { + body.Enabled = file.Enabled + } + if len(file.Triggers) > 0 && len(body.Triggers) == 0 { + body.Triggers = file.Triggers + } + if file.Action != nil && body.Action == nil { + body.Action = file.Action + } +} + +// applyUpdateFlags applies named CLI update flags onto an existing routine body. +// It returns the count of fields changed. +func applyUpdateFlags( + existing *routines.Routine, + description, cron, timeZone, at, agentID, agentEndpointID, conversationID, sessionID string, + descChanged, cronChanged, tzChanged, atChanged, agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged bool, +) (int, error) { + changed := 0 + + if descChanged { + existing.Description = description + changed++ + } + + // Trigger field updates + trigger := getTrigger(existing) + if cronChanged { + if trigger == nil { + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot set --cron: routine has no default trigger", + "add a trigger by recreating the routine, or omit --cron", + ) + } + trigger.CronExpression = cron + changed++ + } + if tzChanged { + if trigger == nil { + return 0, exterrors.Validation( + 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 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 agentIDChanged || agentEpChanged { + if action == nil { + return 0, exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot update agent fields: routine has no action", + "add an action by recreating the routine, or omit --agent-id / --agent-endpoint-id", + ) + } + // agent-id and agent-endpoint-id are mutually exclusive; specifying one clears the other. + if agentIDChanged && agentEpChanged && agentID != "" && agentEndpointID != "" { + return 0, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--agent-id and --agent-endpoint-id are mutually exclusive", + "provide either --agent-id or --agent-endpoint-id, not both", + ) + } + if agentIDChanged { + action.AgentID = agentID + if agentID != "" { + action.AgentEndpointID = "" // specifying agent-id clears agent-endpoint-id + } + changed++ + } + if agentEpChanged { + action.AgentEndpointID = agentEndpointID + if agentEndpointID != "" { + action.AgentID = "" // specifying agent-endpoint-id clears agent-id + } + 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", + ) + } + 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", + ) + } + 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..3ce5a14a123 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_manifest_test.go @@ -0,0 +1,270 @@ +// 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", AgentID: "my-agent-id"}, + } + 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-id", got.Action.AgentID) +} + +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_id: yaml-agent-id +` + 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-id", got.Action.AgentID) +} + +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", AgentID: "a"}, + } + mergeRoutineFromFile(body, file) + + assert.Equal(t, "from-cli", body.Name, "name must not be overwritten by file") + assert.Equal(t, "from file", body.Description) + assert.Equal(t, "schedule", body.Triggers["default"].Type) + require.NotNil(t, body.Action) + assert.Equal(t, "a", body.Action.AgentID) +} + +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", AgentID: "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.AgentID, "body action must win") +} + +// ─── applyUpdateFlags ───────────────────────────────────────────────────────── + +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", AgentID: "old-agent-id"}, + } +} + +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_Cron(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + n, err := applyUpdateFlags(r, + "", "0 9 * * 1-5", "", "", "", "", "", "", + false, true, false, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "0 9 * * 1-5", r.Triggers["default"].CronExpression) +} + +func TestApplyUpdateFlags_TimeZone(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + n, err := applyUpdateFlags(r, + "", "", "America/New_York", "", "", "", "", "", + false, false, true, false, false, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + assert.Equal(t, "America/New_York", r.Triggers["default"].TimeZone) +} + +func TestApplyUpdateFlags_AgentIDClearsEndpointID(t *testing.T) { + t.Parallel() + r := &routines.Routine{ + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentEndpointID: "old-ep"}, + Triggers: map[string]routines.RoutineTrigger{"default": {Type: "schedule"}}, + } + n, err := applyUpdateFlags(r, + "", "", "", "", "new-agent-id", "", "", "", + false, false, false, false, true, false, false, false, + ) + require.NoError(t, err) + assert.Equal(t, 1, n) + require.NotNil(t, r.Action) + assert.Equal(t, "new-agent-id", r.Action.AgentID) + assert.Empty(t, r.Action.AgentEndpointID, "setting agent-id should clear agent-endpoint-id") +} + +func TestApplyUpdateFlags_AgentEndpointIDClearsID(t *testing.T) { + t.Parallel() + r := &routines.Routine{ + Action: &routines.RoutineAction{Type: "invoke_agent_responses_api", AgentID: "old-agent-id"}, + 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.AgentID, "setting agent-endpoint-id should clear agent-id") +} + +func TestApplyUpdateFlags_MutuallyExclusiveAgentFields(t *testing.T) { + t.Parallel() + r := routineWithScheduleAndAgentResp() + _, err := applyUpdateFlags(r, + "", "", "", "", "new-agent-id", "new-ep", "", "", + false, false, false, false, true, true, false, false, + ) + 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) +} + +// ─── 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", AgentID: "orig-agent-id"}, + } + act := getAction(r) + require.NotNil(t, act) + act.AgentID = "changed" + assert.Equal(t, "orig-agent-id", r.Action.AgentID) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_run.go 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..13772926daf --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/routine_update.go @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.routines/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// routineUpdateFlags holds validated input for the update command. +type routineUpdateFlags struct { + name string + trigger string // type-switch guard only + action string // type-switch guard only + description string + cron string + timeZone string + at string + agentID string + agentEndpointID string + conversationID string + sessionID string + file string + output string +} + +func newRoutineUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &routineUpdateFlags{} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing routine.", + Long: `Update fields on an existing Foundry routine. + +Only the named flags change; all other fields are preserved verbatim. +To change the trigger or action type, delete and recreate the routine.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) + return runRoutineUpdate(ctx, cmd, flags) + }, + } + + // Type-switch guards — registered to surface a friendly error, never used for actual update. + cmd.Flags().StringVar(&flags.trigger, "trigger", "", + "Not allowed on update: trigger types are immutable. Delete and recreate to change.") + cmd.Flags().StringVar(&flags.action, "action", "", + "Not allowed on update: action types are immutable. Delete and recreate to change.") + _ = cmd.Flags().MarkHidden("trigger") + _ = cmd.Flags().MarkHidden("action") + + cmd.Flags().StringVar(&flags.description, "description", "", "New description for the routine") + cmd.Flags().StringVar(&flags.cron, "cron", "", "New cron expression for recurring trigger") + cmd.Flags().StringVar(&flags.timeZone, "time-zone", "", "New time zone for the trigger") + cmd.Flags().StringVar(&flags.at, "at", "", "New ISO 8601 datetime for timer trigger") + cmd.Flags().StringVar(&flags.agentID, "agent-id", "", "New project-scoped agent ID") + cmd.Flags().StringVar(&flags.agentEndpointID, "agent-endpoint-id", "", "New agent endpoint ID") + cmd.Flags().StringVar(&flags.conversationID, "conversation-id", "", "New conversation ID (preview)") + cmd.Flags().StringVar(&flags.sessionID, "session-id", "", "New session ID") + cmd.Flags().StringVar(&flags.file, "file", "", + "Path to a YAML/JSON manifest; merged fields win unless overridden by flags") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + + return cmd +} + +func runRoutineUpdate(ctx context.Context, cmd *cobra.Command, flags *routineUpdateFlags) error { + // Type-switch guard: --trigger and --action are not allowed on update. + if flags.trigger != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--trigger cannot be changed on an existing routine", + fmt.Sprintf("trigger types are immutable. Run 'routine delete %s' then recreate.", flags.name), + ) + } + if flags.action != "" { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--action cannot be changed on an existing routine", + fmt.Sprintf("action types are immutable. Run 'routine delete %s' then recreate.", flags.name), + ) + } + + client, _, err := newRoutineClient(ctx, cmd) + if err != nil { + return err + } + + // GET the existing routine. + existing, err := client.GetRoutine(ctx, flags.name) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpGetRoutine, + fmt.Sprintf("routine %q not found", flags.name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpGetRoutine) + } + + // If --file is provided, merge the manifest first. + if flags.file != "" { + manifest, err := readRoutineManifest(flags.file) + if err != nil { + return err + } + mergeRoutineFromFile(existing, manifest) + } + + // Apply named flag changes (flag presence, not just non-empty value). + descChanged := cmd.Flags().Changed("description") + cronChanged := cmd.Flags().Changed("cron") + tzChanged := cmd.Flags().Changed("time-zone") + atChanged := cmd.Flags().Changed("at") + agentIDChanged := cmd.Flags().Changed("agent-id") + agentEpChanged := cmd.Flags().Changed("agent-endpoint-id") + convIDChanged := cmd.Flags().Changed("conversation-id") + sessIDChanged := cmd.Flags().Changed("session-id") + + changed, err := applyUpdateFlags( + existing, + flags.description, flags.cron, flags.timeZone, flags.at, + flags.agentID, flags.agentEndpointID, flags.conversationID, flags.sessionID, + descChanged, cronChanged, tzChanged, atChanged, + agentIDChanged, agentEpChanged, convIDChanged, sessIDChanged, + ) + if err != nil { + return err + } + + if changed == 0 && flags.file == "" { + fmt.Printf("No changes specified for routine '%s'.\n", flags.name) + return nil + } + + // PUT the updated body. + result, err := client.PutRoutine(ctx, flags.name, existing) + if err != nil { + if exterrors.IsNotFound(err) { + return exterrors.ServiceFromStatus(404, exterrors.OpUpdateRoutine, + fmt.Sprintf("routine %q was deleted before the update completed", flags.name)) + } + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateRoutine) + } + + if flags.output == "json" { + return printJSON(result) + } + + fmt.Printf("Routine '%s' updated (%d field(s) changed).\n\n", result.Name, changed) + routineSummaryTable(result) + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.routines/internal/exterrors/codes.go new file mode 100644 index 00000000000..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..573c8a9b7cb --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/exterrors/errors.go @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error helpers for the azure.ai.routines extension. +package exterrors + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation LocalError for user-input errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency LocalError for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth LocalError for authentication/authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// Internal returns an internal LocalError for unexpected failures. +func Internal(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryInternal, + } +} + +// User returns a user-action LocalError (e.g. cancellation). +func User(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryUser, + } +} + +// Cancelled returns a user cancellation error. +func Cancelled(message string) error { + return User(CodeCancelled, message) +} + +// ServiceFromAzure wraps an azcore.ResponseError into an azdext.ServiceError. +// If the error is not an azcore.ResponseError, it returns a generic internal error. +func ServiceFromAzure(err error, operation string) error { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + serviceName := "" + if respErr.RawResponse != nil && respErr.RawResponse.Request != nil { + serviceName = respErr.RawResponse.Request.Host + } + code := respErr.ErrorCode + if code == "" { + code = fmt.Sprintf("%d", respErr.StatusCode) + } + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, respErr.Error()), + ErrorCode: fmt.Sprintf("%s.%s", operation, code), + StatusCode: respErr.StatusCode, + ServiceName: serviceName, + } + } + if IsCancellation(err) { + return Cancelled(fmt.Sprintf("%s was cancelled", operation)) + } + return Internal(operation, fmt.Sprintf("%s: %s", operation, err.Error())) +} + +// ServiceFromStatus returns a ServiceFromAzure-style error for a raw HTTP status code. +func ServiceFromStatus(statusCode int, operation, message string) error { + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, message), + ErrorCode: fmt.Sprintf("%s.%d", operation, statusCode), + StatusCode: statusCode, + } +} + +// IsNotFound returns true if the error represents an HTTP 404. +func IsNotFound(err error) bool { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + return respErr.StatusCode == http.StatusNotFound + } + var svcErr *azdext.ServiceError + if errors.As(err, &svcErr) { + return svcErr.StatusCode == http.StatusNotFound + } + return false +} + +// IsConflict returns true if the error represents an HTTP 409. +func IsConflict(err error) bool { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + return respErr.StatusCode == http.StatusConflict + } + var svcErr *azdext.ServiceError + if errors.As(err, &svcErr) { + return svcErr.StatusCode == http.StatusConflict + } + return false +} + +// IsCancellation checks if an error represents user cancellation. +func IsCancellation(err error) bool { + return errors.Is(err, context.Canceled) +} + +// authFromMessage creates an Auth error from an HTTP response message. +func authFromMessage(msg string) error { + if strings.Contains(msg, "not logged in") { + return Auth(CodeNotLoggedIn, msg, "run `azd auth login` to authenticate") + } + if strings.Contains(msg, "expired") { + return Auth(CodeLoginExpired, msg, "run `azd auth login` to acquire a new token") + } + return Auth(CodeAuthFailed, msg, "run `azd auth login` to authenticate") +} + +// WrapAuthError wraps a 401 error as an Auth error. +func WrapAuthError(err error, operation string) error { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == http.StatusUnauthorized { + return authFromMessage(respErr.Error()) + } + return ServiceFromAzure(err, operation) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go new file mode 100644 index 00000000000..319b1b7a2aa --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/client.go @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package routines + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const ( + routinesAPIVersion = "v1" + routinesPreviewHeader = "Foundry-Features" + routinesPreviewValue = "Routines=V1Preview" +) + +// Client is the data-plane client for Foundry Routines API operations. +type Client struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewClient creates a new Routines data-plane client. +func NewClient(endpoint string, cred azcore.TokenCredential) *Client { + clientOptions := &policy.ClientOptions{ + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy( + cred, + []string{"https://ai.azure.com/.default"}, + nil, + ), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy("azd-ext-azure-ai-routines/0.1.0"), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-routines", + "v0.1.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &Client{endpoint: strings.TrimRight(endpoint, "/"), pipeline: pipeline} +} + +// routineURL returns the URL for a named routine. +func (c *Client) routineURL(name string) string { + return fmt.Sprintf("%s/routines/%s?api-version=%s", c.endpoint, url.PathEscape(name), routinesAPIVersion) +} + +// routinesURL returns the base routines collection URL with optional query parameters. +func (c *Client) routinesURL(extraQuery ...string) string { + base := fmt.Sprintf("%s/routines?api-version=%s", c.endpoint, routinesAPIVersion) + if len(extraQuery) > 0 { + return base + "&" + strings.Join(extraQuery, "&") + } + return base +} + +// routineActionURL returns the URL for a named routine action route +// (e.g. :dispatch, :dispatchAsync). The action segment is case-sensitive +// and must match the TypeSpec route exactly. +func (c *Client) routineActionURL(name, action string) string { + return fmt.Sprintf("%s/routines/%s:%s?api-version=%s", c.endpoint, url.PathEscape(name), action, routinesAPIVersion) +} + +// routineRunsURL returns the URL for listing routine runs. +func (c *Client) routineRunsURL(routineName string, extraQuery ...string) string { + base := fmt.Sprintf("%s/routines/%s/runs?api-version=%s", c.endpoint, url.PathEscape(routineName), routinesAPIVersion) + if len(extraQuery) > 0 { + return base + "&" + strings.Join(extraQuery, "&") + } + return base +} + +// addPreviewHeader adds the required Routines preview opt-in header to a request. +func addPreviewHeader(req *policy.Request) { + req.Raw().Header.Set(routinesPreviewHeader, routinesPreviewValue) +} + +// GetRoutine retrieves a routine by name. +func (c *Client) GetRoutine(ctx context.Context, name string) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodGet, c.routineURL(name)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var routine Routine + if err := decodeJSON(resp.Body, &routine); err != nil { + return nil, err + } + return &routine, nil +} + +// ListRoutines retrieves all routines, draining all pages. +func (c *Client) ListRoutines(ctx context.Context) ([]Routine, error) { + var all []Routine + nextURL := c.routinesURL() + + for nextURL != "" { + if err := c.validateSameOrigin(nextURL); err != nil { + return nil, err + } + + var page PagedRoutine + if err := c.getPage(ctx, nextURL, &page); err != nil { + return nil, err + } + + all = append(all, page.Value...) + // The service returns an absolute nextLink URL when more pages exist + // (Azure.Core.Page). We follow it verbatim after a same-origin + // check rather than re-deriving the continuation query string. + nextURL = page.NextLink + } + + return all, nil +} + +// getPage performs a paginated GET and decodes the body into out. +// It scopes resp.Body.Close() to a single iteration to avoid file-descriptor +// accumulation when callers loop across many pages. +func (c *Client) getPage(ctx context.Context, pageURL string, out any) error { + req, err := runtime.NewRequest(ctx, http.MethodGet, pageURL) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + addPreviewHeader(req) + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return runtime.NewResponseError(resp) + } + + return decodeJSON(resp.Body, out) +} + +// PutRoutine creates or replaces a routine (upsert via PUT). +func (c *Client) PutRoutine(ctx context.Context, name string, body *Routine) (*Routine, error) { + req, err := runtime.NewRequest(ctx, http.MethodPut, c.routineURL(name)) + 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 flips `enabled` to true via PUT. +// The Foundry Routines API does not expose a dedicated :enable route; the +// client mutates the routine resource directly. +func (c *Client) EnableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.setEnabled(ctx, name, true) +} + +// DisableRoutine flips `enabled` to false via PUT. +func (c *Client) DisableRoutine(ctx context.Context, name string) (*Routine, error) { + return c.setEnabled(ctx, name, false) +} + +// setEnabled performs a GET + PUT to mutate the `enabled` field. It returns +// the current routine without an extra round-trip if the field is already +// at the desired value (idempotent enable/disable). +func (c *Client) setEnabled(ctx context.Context, name string, enabled bool) (*Routine, error) { + existing, err := c.GetRoutine(ctx, name) + if err != nil { + return nil, err + } + if existing.Enabled != nil && *existing.Enabled == enabled { + return existing, nil + } + existing.Enabled = &enabled + return c.PutRoutine(ctx, name, existing) +} + +// DispatchRoutineAsync calls the :dispatchAsync action route. +// The action segment is camelCase per TypeSpec; do not change it to +// snake_case without first updating the Foundry Routines spec. +func (c *Client) DispatchRoutineAsync( + ctx context.Context, + name string, + payload *DispatchRoutineRequest, +) (*DispatchRoutineResponse, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.routineActionURL(name, "dispatchAsync")) + 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...) + + // Respect Top cap across pages. + 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 +} + +// decodeJSON reads and unmarshals a JSON response body. +func decodeJSON(body io.Reader, v any) error { + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + return nil +} + +// setJSONBody marshals v as JSON and sets it as the request body. +func setJSONBody(req *policy.Request, v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + req.Raw().Header.Set("Content-Type", "application/json") + req.Raw().ContentLength = int64(len(data)) + req.Raw().Body = io.NopCloser(bytes.NewReader(data)) + req.Raw().GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go new file mode 100644 index 00000000000..3da38ca1f03 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/pkg/routines/models.go @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package routines provides the data-plane client and models for Microsoft Foundry Routines. +package routines + +// Routine represents a Foundry routine resource. +// Field shapes track the Routines TypeSpec (azure-rest-api-specs PR #42779): +// - `triggers` is a map keyed by user-defined identifiers. +// - `action` is a single discriminated object, not a map. +type Routine struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Triggers map[string]RoutineTrigger `json:"triggers,omitempty" yaml:"triggers,omitempty"` + 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" (CLI alias: "recurring"): cron-based recurring trigger +// - "timer": one-shot timer trigger +// - "github_issue": GitHub issue event trigger (deferred) +type RoutineTrigger struct { + Type string `json:"type" 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"` + Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` + Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` +} + +// RoutineAction is the discriminated union for routine actions. +// 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"` + AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` + AgentEndpointID string `json:"agent_endpoint_id,omitempty" yaml:"agent_endpoint_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty" yaml:"conversation_id,omitempty"` + SessionID string `json:"session_id,omitempty" yaml:"session_id,omitempty"` +} + +// PagedRoutine represents a page of routine resources. The service returns an +// `nextLink` absolute URL when more pages exist (Azure.Core.Page). +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"` + 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:"next_page_token,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 / :dispatchAsync +// routes. The payload wrapper is required for :dispatchAsync. +type DispatchRoutineRequest struct { + Payload *RoutineDispatchPayload `json:"payload,omitempty"` +} + +// DispatchRoutineResponse is the response from the :dispatch / :dispatchAsync routes. +type DispatchRoutineResponse struct { + DispatchID string `json:"dispatch_id,omitempty"` + ActionCorrelationID string `json:"action_correlation_id,omitempty"` +} + +// TriggerCLIToWire maps CLI --trigger aliases to TypeSpec wire type values. +var TriggerCLIToWire = map[string]string{ + "recurring": "schedule", + "timer": "timer", + "github-issue": "github_issue", +} + +// ActionCLIToWire maps CLI --action aliases to TypeSpec wire type values. +var ActionCLIToWire = map[string]string{ + "agent-response": "invoke_agent_responses_api", + "agent-invoke": "invoke_agent_invocations_api", +} + +// DefaultTriggerKey is the map key used for the single trigger in create/update. +const DefaultTriggerKey = "default" 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) + } + } +}