From ba62212c60cbfa60d3946951a4745d4bb30ebe2b Mon Sep 17 00:00:00 2001 From: johnnyzwu Date: Tue, 21 Jul 2026 17:58:41 +0800 Subject: [PATCH 1/2] feat: add wait support for instance and tool --- cmd/agr/characterization_test.go | 26 ++ internal/commands/instance/create/command.go | 30 ++- .../commands/instance/create/command_test.go | 45 +++- internal/commands/instance/delete/command.go | 2 +- .../commands/instance/delete/command_test.go | 5 +- internal/commands/instance/get/command.go | 28 ++- .../commands/instance/get/command_test.go | 58 +++++ internal/commands/instance/pause/command.go | 19 +- .../commands/instance/pause/command_test.go | 39 ++- internal/commands/instance/resume/command.go | 19 +- .../commands/instance/resume/command_test.go | 39 ++- internal/commands/instance/update/command.go | 21 +- .../commands/instance/update/command_test.go | 52 +++- .../commands/internal/resourcewait/wait.go | 222 ++++++++++++++++++ .../internal/resourcewait/wait_test.go | 195 +++++++++++++++ internal/commands/registry_generated_test.go | 54 ++++- internal/commands/tool/create/command.go | 32 ++- internal/commands/tool/create/command_test.go | 44 +++- internal/commands/tool/delete/command_test.go | 9 + internal/commands/tool/fork/command.go | 22 +- internal/commands/tool/fork/command_test.go | 36 +++ internal/commands/tool/get/command.go | 28 ++- internal/commands/tool/get/command_test.go | 55 +++++ internal/commands/tool/update/command.go | 25 +- internal/commands/tool/update/command_test.go | 56 ++++- 25 files changed, 1117 insertions(+), 44 deletions(-) create mode 100644 internal/commands/internal/resourcewait/wait.go create mode 100644 internal/commands/internal/resourcewait/wait_test.go diff --git a/cmd/agr/characterization_test.go b/cmd/agr/characterization_test.go index 77d6974..75af020 100644 --- a/cmd/agr/characterization_test.go +++ b/cmd/agr/characterization_test.go @@ -671,6 +671,32 @@ func schemaForCommand(t *testing.T, command string) commandSchemaSnapshot { return snapshot } +func TestCharacterization_WaitFlagSchemaScope(t *testing.T) { + for _, commandID := range []string{ + "instance.create", + "instance.get", + "instance.pause", + "instance.resume", + "instance.update", + "tool.create", + "tool.fork", + "tool.get", + "tool.update", + } { + schema := schemaForCommand(t, commandID) + flag, ok := schema.Flags["wait"] + if !ok || flag.Type != "bool" { + t.Errorf("schema %s wait flag = %#v, present = %v", commandID, flag, ok) + } + } + for _, commandID := range []string{"instance.delete", "instance.list", "tool.delete", "tool.list"} { + schema := schemaForCommand(t, commandID) + if flag, ok := schema.Flags["wait"]; ok { + t.Errorf("schema %s unexpectedly includes wait flag: %#v", commandID, flag) + } + } +} + func schemaSnapshotForCommand(t *testing.T, command string) commandSchemaSnapshot { t.Helper() output, err := runAGR(t, "schema", command, "-o", "json") diff --git a/internal/commands/instance/create/command.go b/internal/commands/instance/create/command.go index dcf76a1..6ea8adb 100644 --- a/internal/commands/instance/create/command.go +++ b/internal/commands/instance/create/command.go @@ -9,7 +9,9 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/cli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + instanceget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/get" instanceview "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/internal/instanceview" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" "github.com/TencentCloudAgentRuntime/ags-cli/internal/progress" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" @@ -19,6 +21,7 @@ import ( func Module() command.Module { api := APIDescriptor() spec := api.CommandSpec() + spec.Flags = append(spec.Flags, resourcewait.Flag()) spec.Output = command.OutputSpec{ DataType: "InstanceCreateData", Description: "Instance create result with normalized instance data.", @@ -77,12 +80,37 @@ func Module() command.Module { }) } sp.Stop("✓", "Instance created") - return instanceCreateResult(response, result), nil + mutationResult := instanceCreateResult(response, result) + if !resourcewait.Requested(req) { + return mutationResult, nil + } + getter, ok := deps.ControlPlane.(resourcewait.InstanceGetter) + if !ok { + return nil, fmt.Errorf("instance.create --wait requires GetInstance support") + } + instanceID := instanceview.DerefString(response.Instance.InstanceId) + if instanceID == "" { + return nil, missingInstanceIDError() + } + finalInstance, err := resourcewait.WaitForInstance(ctx, instanceID, getter.GetInstance, resourcewait.OptionsFromDeps(deps)) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(instanceget.Result(finalInstance), mutationResult), nil })}, nil }, } } +func missingInstanceIDError() error { + return output.NewCLIError(&output.Failure{ + Code: "INTERNAL_ERROR", + Kind: output.KindGenericError, + Message: "cannot wait because the create response did not include an instance id", + Hint: "Rerun with --debug. If the issue persists, inspect the control-plane response.", + }) +} + func validateToolSelection(req command.Request) error { toolName := stringFlag(req, "tool-name") toolID := stringFlag(req, "tool-id") diff --git a/internal/commands/instance/create/command_test.go b/internal/commands/instance/create/command_test.go index e577f2f..88621b1 100644 --- a/internal/commands/instance/create/command_test.go +++ b/internal/commands/instance/create/command_test.go @@ -6,8 +6,10 @@ import ( "errors" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/iostreams" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" @@ -16,20 +18,29 @@ import ( // fakeMixedControlPlane implements apicli.ControlPlane for testing the full // create flow (parameter validation → request build → executor → response render). type fakeMixedControlPlane struct { - action string - request map[string]any - resp *ags.StartSandboxInstanceResponseParams + action string + request map[string]any + resp *ags.StartSandboxInstanceResponseParams + calls int + getCalls int + finalInstance *ags.SandboxInstance } func (f *fakeMixedControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ if f.resp != nil { return f.resp, nil } return map[string]any{"ok": true}, nil } +func (f *fakeMixedControlPlane) GetInstance(_ context.Context, _ string) (*ags.SandboxInstance, error) { + f.getCalls++ + return f.finalInstance, nil +} + // allInstanceCreateFlags returns a complete flag set mimicking what Cobra registers // for `agr instance create`. Unset optional flags have Changed=false. func allInstanceCreateFlags() map[string]command.FlagValue { @@ -160,6 +171,34 @@ func TestModuleCreatesInstanceAndRendersText(t *testing.T) { } } +func TestModuleWaitsAfterCreatingExactlyOnce(t *testing.T) { + id := "ins-created" + starting := "STARTING" + running := "RUNNING" + cp := &fakeMixedControlPlane{ + resp: &ags.StartSandboxInstanceResponseParams{Instance: &ags.SandboxInstance{InstanceId: &id, Status: &starting}}, + finalInstance: &ags.SandboxInstance{InstanceId: &id, Status: &running}, + } + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: time.Millisecond, Timeout: 50 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + flags := withChanged(allInstanceCreateFlags(), "tool-name", "tool-name") + flags["wait"] = command.FlagValue{Name: "wait", Type: command.FlagBool, Bool: true} + result, err := runtime.Handler.Run(context.Background(), command.Request{Flags: flags}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetInstance = %d", cp.calls, cp.getCalls) + } + if result.Data.(map[string]any)["Status"] != running || len(result.Effects) != 1 { + t.Fatalf("result = %#v", result) + } +} + func TestModuleCreatesInstanceWithToolID(t *testing.T) { id := "ins-byid" toolID := "sdt-abc123" diff --git a/internal/commands/instance/delete/command.go b/internal/commands/instance/delete/command.go index 6d43b3e..82ebc3d 100644 --- a/internal/commands/instance/delete/command.go +++ b/internal/commands/instance/delete/command.go @@ -158,9 +158,9 @@ func Module() command.Module { warnings = append(warnings, fmt.Sprintf("Failed to delete %s: %v", instanceID, err)) continue } + summary.AlreadyAbsent = append(summary.AlreadyAbsent, item.AlreadyAbsent...) summary.Deleted += item.Deleted summary.DeletedIDs = append(summary.DeletedIDs, item.DeletedIDs...) - summary.AlreadyAbsent = append(summary.AlreadyAbsent, item.AlreadyAbsent...) } return resultFromSummary(summary, warnings, deps.IO.ErrOut), nil }), diff --git a/internal/commands/instance/delete/command_test.go b/internal/commands/instance/delete/command_test.go index a885446..1e92f9d 100644 --- a/internal/commands/instance/delete/command_test.go +++ b/internal/commands/instance/delete/command_test.go @@ -10,7 +10,7 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ) -func TestModuleKeepsGeneratedAPIDescriptorAndWorkflowFlag(t *testing.T) { +func TestModuleKeepsGeneratedAPIDescriptorWithoutWaitFlag(t *testing.T) { module := Module() if module.Descriptor.Generated == nil { t.Fatalf("mixed module missing generated descriptor snapshot") @@ -37,6 +37,9 @@ func TestModuleKeepsGeneratedAPIDescriptorAndWorkflowFlag(t *testing.T) { if !hasFlag(module.Descriptor.Spec.Flags, "ignore-not-found") { t.Fatalf("final spec missing --ignore-not-found") } + if hasFlag(module.Descriptor.Spec.Flags, "wait") { + t.Fatalf("instance.delete must not expose --wait") + } } func TestModuleSupportsMultiDeleteWorkflow(t *testing.T) { diff --git a/internal/commands/instance/get/command.go b/internal/commands/instance/get/command.go index 032f96b..8f4c53c 100644 --- a/internal/commands/instance/get/command.go +++ b/internal/commands/instance/get/command.go @@ -8,6 +8,7 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" instanceview "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/internal/instanceview" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -26,6 +27,7 @@ func Module() command.Module { Short: "Get instance details", Long: "Get detailed information about a specific instance.", Args: []command.ArgSpec{{Name: "instance-id", Required: true}}, + Flags: []command.FlagSpec{resourcewait.Flag()}, SupportsJSON: true, Output: command.OutputSpec{DataType: "Instance"}, } @@ -54,21 +56,33 @@ func Module() command.Module { if strings.TrimSpace(instanceID) == "" { return nil, output.NewUsageError("MISSING_REQUIRED_ARG", "missing instance id", "Provide .") } - instance, err := cp.GetInstance(ctx, instanceID) + var instance *ags.SandboxInstance + var err error + if resourcewait.Requested(req) { + instance, err = resourcewait.WaitForInstance(ctx, instanceID, cp.GetInstance, resourcewait.OptionsFromDeps(deps)) + } else { + instance, err = cp.GetInstance(ctx, instanceID) + } if err != nil { return nil, err } - return &command.Result{ - Data: instanceview.CanonicalData(instance), - Text: func(w io.Writer) { - renderInstanceDetails(w, instance) - }, - }, nil + return Result(instance), nil })}, nil }, } } +// Result returns the canonical command result for an Instance. Lifecycle +// mutation commands reuse it after --wait reaches the expected state. +func Result(instance *ags.SandboxInstance) *command.Result { + return &command.Result{ + Data: instanceview.CanonicalData(instance), + Text: func(w io.Writer) { + renderInstanceDetails(w, instance) + }, + } +} + func renderInstanceDetails(w io.Writer, instance *ags.SandboxInstance) { kvs := []instanceview.KeyValue{ {Key: "ID", Value: instanceview.DerefString(instance.InstanceId)}, diff --git a/internal/commands/instance/get/command_test.go b/internal/commands/instance/get/command_test.go index c3942b0..5be7113 100644 --- a/internal/commands/instance/get/command_test.go +++ b/internal/commands/instance/get/command_test.go @@ -5,9 +5,11 @@ import ( "context" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" instanceview "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/internal/instanceview" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -20,6 +22,9 @@ func TestModuleDescriptor(t *testing.T) { if !spec.SupportsJSON { t.Fatalf("get should support JSON") } + if !hasFlag(spec.Flags, "wait") { + t.Fatalf("flags = %#v, want --wait", spec.Flags) + } } func TestModuleGetsInstance(t *testing.T) { @@ -106,13 +111,66 @@ func TestModuleRejectsMissingInstanceID(t *testing.T) { type fakeControlPlane struct { instanceID string instance *ags.SandboxInstance + instances []*ags.SandboxInstance + calls int } func (f *fakeControlPlane) GetInstance(_ context.Context, instanceID string) (*ags.SandboxInstance, error) { f.instanceID = instanceID + f.calls++ + if len(f.instances) > 0 { + index := f.calls - 1 + if index >= len(f.instances) { + index = len(f.instances) - 1 + } + return f.instances[index], nil + } return f.instance, nil } +func TestModuleWaitsForInstanceTerminalState(t *testing.T) { + starting := "STARTING" + running := "RUNNING" + cp := &fakeControlPlane{instances: []*ags.SandboxInstance{ + {Status: &starting}, + {Status: &running}, + }} + runtime, err := Module().Build(command.Deps{ + ControlPlane: cp, + Values: map[string]any{resourcewait.OptionsKey: resourcewait.Options{ + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"ins-unit"}, + Flags: map[string]command.FlagValue{ + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if cp.calls != 2 { + t.Fatalf("GetInstance calls = %d, want 2", cp.calls) + } + if result.Data.(map[string]any)["Status"] != running { + t.Fatalf("data = %#v", result.Data) + } +} + +func hasFlag(flags []command.FlagSpec, name string) bool { + for _, flag := range flags { + if flag.Name == name { + return true + } + } + return false +} + func TestFormatTimeout(t *testing.T) { for _, tc := range []struct { seconds uint64 diff --git a/internal/commands/instance/pause/command.go b/internal/commands/instance/pause/command.go index a4ecca5..4290e2a 100644 --- a/internal/commands/instance/pause/command.go +++ b/internal/commands/instance/pause/command.go @@ -7,17 +7,21 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + instanceget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/get" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ) // Module returns this package's command module. func Module() command.Module { api := APIDescriptor() - spec := api.CommandSpec() + generatedSpec := api.CommandSpec() + spec := generatedSpec + spec.Flags = append(spec.Flags, resourcewait.Flag()) return command.Module{ Descriptor: command.Descriptor{ Spec: spec, Generated: &command.Descriptor{ - Spec: spec, + Spec: generatedSpec, Groups: api.Groups, API: api, Source: "apicli", @@ -42,6 +46,17 @@ func Module() command.Module { result.Text = func(w io.Writer) { fmt.Fprintf(w, "Instance paused: %s\n", instanceID) } + if resourcewait.Requested(req) { + getter, ok := deps.ControlPlane.(resourcewait.InstanceGetter) + if !ok { + return nil, fmt.Errorf("instance.pause --wait requires GetInstance support") + } + instance, err := resourcewait.WaitForInstance(ctx, instanceID, getter.GetInstance, resourcewait.OptionsFromDeps(deps)) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(instanceget.Result(instance), result), nil + } return result, nil })}, nil }, diff --git a/internal/commands/instance/pause/command_test.go b/internal/commands/instance/pause/command_test.go index 23eb39f..16bfd41 100644 --- a/internal/commands/instance/pause/command_test.go +++ b/internal/commands/instance/pause/command_test.go @@ -5,22 +5,33 @@ import ( "context" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) type fakeMixedControlPlane struct { - action string - request map[string]any + action string + request map[string]any + calls int + getCalls int + status string } func (f *fakeMixedControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ return &ags.PauseSandboxInstanceResponseParams{}, nil } +func (f *fakeMixedControlPlane) GetInstance(_ context.Context, instanceID string) (*ags.SandboxInstance, error) { + f.getCalls++ + return &ags.SandboxInstance{InstanceId: &instanceID, Status: &f.status}, nil +} + func TestModulePausesInstanceAndRendersText(t *testing.T) { cp := &fakeMixedControlPlane{} runtime, err := Module().Build(command.Deps{ControlPlane: cp}) @@ -43,3 +54,27 @@ func TestModulePausesInstanceAndRendersText(t *testing.T) { t.Fatalf("text = %q", text.String()) } } + +func TestModuleWaitsAfterPausingExactlyOnce(t *testing.T) { + cp := &fakeMixedControlPlane{status: "PAUSED"} + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: time.Millisecond, Timeout: 50 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"ins-unit"}, + ArgValues: map[string]string{"instance-id": "ins-unit"}, + Flags: map[string]command.FlagValue{"wait": {Name: "wait", Type: command.FlagBool, Bool: true}}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetInstance = %d", cp.calls, cp.getCalls) + } + if result.Data.(map[string]any)["Status"] != "PAUSED" { + t.Fatalf("result = %#v", result.Data) + } +} diff --git a/internal/commands/instance/resume/command.go b/internal/commands/instance/resume/command.go index e88d83c..5d22e46 100644 --- a/internal/commands/instance/resume/command.go +++ b/internal/commands/instance/resume/command.go @@ -7,17 +7,21 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + instanceget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/get" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ) // Module returns this package's command module. func Module() command.Module { api := APIDescriptor() - spec := api.CommandSpec() + generatedSpec := api.CommandSpec() + spec := generatedSpec + spec.Flags = append(spec.Flags, resourcewait.Flag()) return command.Module{ Descriptor: command.Descriptor{ Spec: spec, Generated: &command.Descriptor{ - Spec: spec, + Spec: generatedSpec, Groups: api.Groups, API: api, Source: "apicli", @@ -42,6 +46,17 @@ func Module() command.Module { result.Text = func(w io.Writer) { fmt.Fprintf(w, "Instance resumed: %s\n", instanceID) } + if resourcewait.Requested(req) { + getter, ok := deps.ControlPlane.(resourcewait.InstanceGetter) + if !ok { + return nil, fmt.Errorf("instance.resume --wait requires GetInstance support") + } + instance, err := resourcewait.WaitForInstance(ctx, instanceID, getter.GetInstance, resourcewait.OptionsFromDeps(deps)) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(instanceget.Result(instance), result), nil + } return result, nil })}, nil }, diff --git a/internal/commands/instance/resume/command_test.go b/internal/commands/instance/resume/command_test.go index 6c79d4d..2d9df7f 100644 --- a/internal/commands/instance/resume/command_test.go +++ b/internal/commands/instance/resume/command_test.go @@ -5,22 +5,33 @@ import ( "context" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) type fakeMixedControlPlane struct { - action string - request map[string]any + action string + request map[string]any + calls int + getCalls int + status string } func (f *fakeMixedControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ return &ags.ResumeSandboxInstanceResponseParams{}, nil } +func (f *fakeMixedControlPlane) GetInstance(_ context.Context, instanceID string) (*ags.SandboxInstance, error) { + f.getCalls++ + return &ags.SandboxInstance{InstanceId: &instanceID, Status: &f.status}, nil +} + func TestModuleResumesInstanceAndRendersText(t *testing.T) { cp := &fakeMixedControlPlane{} runtime, err := Module().Build(command.Deps{ControlPlane: cp}) @@ -43,3 +54,27 @@ func TestModuleResumesInstanceAndRendersText(t *testing.T) { t.Fatalf("text = %q", text.String()) } } + +func TestModuleWaitsAfterResumingExactlyOnce(t *testing.T) { + cp := &fakeMixedControlPlane{status: "RUNNING"} + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: time.Millisecond, Timeout: 50 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"ins-unit"}, + ArgValues: map[string]string{"instance-id": "ins-unit"}, + Flags: map[string]command.FlagValue{"wait": {Name: "wait", Type: command.FlagBool, Bool: true}}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetInstance = %d", cp.calls, cp.getCalls) + } + if result.Data.(map[string]any)["Status"] != "RUNNING" { + t.Fatalf("result = %#v", result.Data) + } +} diff --git a/internal/commands/instance/update/command.go b/internal/commands/instance/update/command.go index 1be91bd..4f0567f 100644 --- a/internal/commands/instance/update/command.go +++ b/internal/commands/instance/update/command.go @@ -7,17 +7,21 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + instanceget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/instance/get" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ) // Module returns this package's command module. func Module() command.Module { api := APIDescriptor() - spec := api.CommandSpec() + generatedSpec := api.CommandSpec() + spec := generatedSpec + spec.Flags = append(spec.Flags, resourcewait.Flag()) return command.Module{ Descriptor: command.Descriptor{ Spec: spec, Generated: &command.Descriptor{ - Spec: spec, + Spec: generatedSpec, Groups: api.Groups, API: api, Source: "apicli", @@ -42,6 +46,19 @@ func Module() command.Module { result.Text = func(w io.Writer) { fmt.Fprintf(w, "Instance updated: %s\n", instanceID) } + if resourcewait.Requested(req) { + getter, ok := deps.ControlPlane.(resourcewait.InstanceGetter) + if !ok { + return nil, fmt.Errorf("instance.update --wait requires GetInstance support") + } + options := resourcewait.OptionsFromDeps(deps) + options.DelayBeforeFirstPoll = true + instance, err := resourcewait.WaitForInstance(ctx, instanceID, getter.GetInstance, options) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(instanceget.Result(instance), result), nil + } return result, nil })}, nil }, diff --git a/internal/commands/instance/update/command_test.go b/internal/commands/instance/update/command_test.go index ef24132..b48b353 100644 --- a/internal/commands/instance/update/command_test.go +++ b/internal/commands/instance/update/command_test.go @@ -5,22 +5,39 @@ import ( "context" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) type fakeMixedControlPlane struct { - action string - request map[string]any + action string + request map[string]any + calls int + getCalls int + status string + updateReturned time.Time + firstGet time.Time } func (f *fakeMixedControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ + f.updateReturned = time.Now() return &ags.UpdateSandboxInstanceResponseParams{}, nil } +func (f *fakeMixedControlPlane) GetInstance(_ context.Context, instanceID string) (*ags.SandboxInstance, error) { + f.getCalls++ + if f.firstGet.IsZero() { + f.firstGet = time.Now() + } + return &ags.SandboxInstance{InstanceId: &instanceID, Status: &f.status}, nil +} + func TestModuleUpdatesInstanceAndRendersText(t *testing.T) { cp := &fakeMixedControlPlane{} runtime, err := Module().Build(command.Deps{ControlPlane: cp}) @@ -46,3 +63,34 @@ func TestModuleUpdatesInstanceAndRendersText(t *testing.T) { t.Fatalf("text = %q", text.String()) } } + +func TestModuleWaitsAfterUpdatingExactlyOnce(t *testing.T) { + const interval = 20 * time.Millisecond + cp := &fakeMixedControlPlane{status: "RUNNING"} + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: interval, Timeout: 100 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"ins-unit"}, + ArgValues: map[string]string{"instance-id": "ins-unit"}, + Flags: map[string]command.FlagValue{ + "timeout": {Name: "timeout", Type: command.FlagString, String: "10m", Changed: true}, + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetInstance = %d", cp.calls, cp.getCalls) + } + if elapsed := cp.firstGet.Sub(cp.updateReturned); elapsed < interval { + t.Fatalf("first GetInstance started %s after update returned, want at least %s", elapsed, interval) + } + if result.Data.(map[string]any)["Status"] != "RUNNING" { + t.Fatalf("result = %#v", result.Data) + } +} diff --git a/internal/commands/internal/resourcewait/wait.go b/internal/commands/internal/resourcewait/wait.go new file mode 100644 index 0000000..9ac6ab0 --- /dev/null +++ b/internal/commands/internal/resourcewait/wait.go @@ -0,0 +1,222 @@ +// Package resourcewait provides polling for Instance and Tool lifecycle states. +package resourcewait + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" + ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" +) + +const ( + DefaultInterval = 5 * time.Second + DefaultTimeout = 10 * time.Minute + + // OptionsKey allows focused tests to replace the default timing through + // command.Deps.Values without exposing timing flags to CLI users. + OptionsKey = "resourcewait.options" +) + +var ( + instanceTransitionalStatuses = statusSet("STARTING", "PAUSING", "STOPPING") + instanceStableStatuses = statusSet( + "RUNNING", "PAUSED", "STOPPED", "FAILED", "STARTING_FAILED", + "STOPPING_FAILED", "PAUSE_FAILED", "RESUME_FAILED", + ) + toolTransitionalStatuses = statusSet("CREATING", "DELETING") + toolStableStatuses = statusSet("ACTIVE", "FAILED", "ISOLATED") +) + +// Options controls one wait operation. +type Options struct { + Interval time.Duration + Timeout time.Duration + DelayBeforeFirstPoll bool +} + +// InstanceGetter is the control-plane capability needed by Instance waiters. +type InstanceGetter interface { + GetInstance(context.Context, string) (*ags.SandboxInstance, error) +} + +// ToolGetter is the control-plane capability needed by Tool waiters. +type ToolGetter interface { + GetTool(context.Context, string) (*ags.SandboxTool, error) +} + +// Flag returns the shared workflow flag used by supported commands. +func Flag() command.FlagSpec { + return command.FlagSpec{ + Name: "wait", + Usage: "Wait until the resource leaves a transitional state", + Type: command.FlagBool, + Workflow: true, + } +} + +// Requested reports whether --wait was enabled for the request. +func Requested(req command.Request) bool { + value, ok := req.Flags["wait"] + return ok && value.Bool +} + +// PreserveMutationMetadata carries side effects and warnings from the one-time +// mutation onto the final resource result returned after waiting. +func PreserveMutationMetadata(finalResult, mutationResult *command.Result) *command.Result { + if finalResult == nil || mutationResult == nil { + return finalResult + } + finalResult.Warnings = append([]string(nil), mutationResult.Warnings...) + finalResult.Effects = append([]output.Effect(nil), mutationResult.Effects...) + if mutationResult.MetaExtra != nil { + finalResult.MetaExtra = make(map[string]any, len(mutationResult.MetaExtra)) + for key, value := range mutationResult.MetaExtra { + finalResult.MetaExtra[key] = value + } + } + return finalResult +} + +// OptionsFromDeps returns production timing defaults, with a test-only +// dependency override when supplied by a command module test. +func OptionsFromDeps(deps command.Deps) Options { + options := Options{Interval: DefaultInterval, Timeout: DefaultTimeout} + if configured, ok := deps.Values[OptionsKey].(Options); ok { + if configured.Interval > 0 { + options.Interval = configured.Interval + } + if configured.Timeout > 0 { + options.Timeout = configured.Timeout + } + } + return options +} + +// WaitForInstance polls GetInstance until the instance leaves a transitional +// state or the operation times out. +func WaitForInstance( + ctx context.Context, + instanceID string, + get func(context.Context, string) (*ags.SandboxInstance, error), + options Options, +) (*ags.SandboxInstance, error) { + return waitFor(ctx, "instance", instanceID, get, func(instance *ags.SandboxInstance) string { + if instance == nil || instance.Status == nil { + return "" + } + return *instance.Status + }, instanceTransitionalStatuses, instanceStableStatuses, options) +} + +// WaitForTool polls GetTool until the tool leaves a transitional state or the +// operation times out. +func WaitForTool( + ctx context.Context, + toolID string, + get func(context.Context, string) (*ags.SandboxTool, error), + options Options, +) (*ags.SandboxTool, error) { + return waitFor(ctx, "tool", toolID, get, func(tool *ags.SandboxTool) string { + if tool == nil || tool.Status == nil { + return "" + } + return *tool.Status + }, toolTransitionalStatuses, toolStableStatuses, options) +} + +func waitFor[T any]( + ctx context.Context, + resourceType string, + resourceID string, + get func(context.Context, string) (T, error), + statusOf func(T) string, + transitionalStatuses map[string]struct{}, + stableStatuses map[string]struct{}, + options Options, +) (T, error) { + var zero T + if options.Interval <= 0 { + options.Interval = DefaultInterval + } + if options.Timeout <= 0 { + options.Timeout = DefaultTimeout + } + + waitCtx, cancel := context.WithTimeout(ctx, options.Timeout) + defer cancel() + lastStatus := "" + if options.DelayBeforeFirstPoll { + if err := waitForNextPoll(waitCtx, options.Interval); err != nil { + return zero, waitContextError(ctx, resourceType, resourceID, lastStatus) + } + } + for { + resource, err := get(waitCtx, resourceID) + if err != nil { + if waitCtx.Err() != nil { + return zero, waitContextError(ctx, resourceType, resourceID, lastStatus) + } + return zero, err + } + + lastStatus = strings.TrimSpace(statusOf(resource)) + normalizedStatus := strings.ToUpper(lastStatus) + if _, ok := stableStatuses[normalizedStatus]; ok { + return resource, nil + } + if _, ok := transitionalStatuses[normalizedStatus]; !ok { + return zero, unknownStatusError(resourceType, resourceID, lastStatus) + } + + if err := waitForNextPoll(waitCtx, options.Interval); err != nil { + return zero, waitContextError(ctx, resourceType, resourceID, lastStatus) + } + } +} + +func statusSet(statuses ...string) map[string]struct{} { + set := make(map[string]struct{}, len(statuses)) + for _, status := range statuses { + set[status] = struct{}{} + } + return set +} + +func unknownStatusError(resourceType, resourceID, status string) error { + return output.NewCLIError(&output.Failure{ + Code: "WAIT_UNKNOWN_STATUS", + Kind: output.KindGenericError, + Message: fmt.Sprintf("%s %s has unknown status %q", resourceType, resourceID, status), + Hint: "Update agr if the service introduced a new resource status.", + Details: map[string]any{"ResourceType": resourceType, "ResourceId": resourceID, "LastStatus": status}, + }) +} + +func waitForNextPoll(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func waitContextError(parent context.Context, resourceType, resourceID, lastStatus string) error { + if err := parent.Err(); err != nil { + return err + } + return output.NewCLIError(&output.Failure{ + Code: "WAIT_TIMEOUT", + Kind: output.KindTimeout, + Message: fmt.Sprintf("timed out waiting for %s %s", resourceType, resourceID), + Hint: fmt.Sprintf("Run 'agr %s get %s --wait' to continue waiting.", resourceType, resourceID), + Retryable: true, + Details: map[string]any{"ResourceType": resourceType, "ResourceId": resourceID, "LastStatus": lastStatus}, + }) +} diff --git a/internal/commands/internal/resourcewait/wait_test.go b/internal/commands/internal/resourcewait/wait_test.go new file mode 100644 index 0000000..439ed1b --- /dev/null +++ b/internal/commands/internal/resourcewait/wait_test.go @@ -0,0 +1,195 @@ +package resourcewait + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" + ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" +) + +func TestDefaults(t *testing.T) { + if DefaultInterval != 5*time.Second { + t.Fatalf("DefaultInterval = %s, want 5s", DefaultInterval) + } + if DefaultTimeout != 10*time.Minute { + t.Fatalf("DefaultTimeout = %s, want 10m", DefaultTimeout) + } +} + +func TestWaitFlagAndRequested(t *testing.T) { + flag := Flag() + if flag.Name != "wait" || flag.Type != command.FlagBool || !flag.Workflow { + t.Fatalf("Flag() = %#v", flag) + } + if flag.Usage != "Wait until the resource leaves a transitional state" { + t.Fatalf("Flag().Usage = %q", flag.Usage) + } + if !Requested(command.Request{Flags: map[string]command.FlagValue{ + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }}) { + t.Fatal("Requested should report true for --wait") + } +} + +func TestWaitForInstancePollsUntilStableState(t *testing.T) { + statuses := []string{"STARTING", "RUNNING"} + calls := 0 + got, err := WaitForInstance(context.Background(), "ins-1", func(context.Context, string) (*ags.SandboxInstance, error) { + status := statuses[calls] + calls++ + return &ags.SandboxInstance{Status: &status}, nil + }, testOptions()) + if err != nil { + t.Fatalf("WaitForInstance returned error: %v", err) + } + if calls != 2 || got == nil || got.Status == nil || *got.Status != "RUNNING" { + t.Fatalf("calls = %d, instance = %#v", calls, got) + } +} + +func TestWaitForToolPollsUntilStableState(t *testing.T) { + statuses := []string{"CREATING", "ACTIVE"} + calls := 0 + got, err := WaitForTool(context.Background(), "tool-1", func(context.Context, string) (*ags.SandboxTool, error) { + status := statuses[calls] + calls++ + return &ags.SandboxTool{Status: &status}, nil + }, testOptions()) + if err != nil { + t.Fatalf("WaitForTool returned error: %v", err) + } + if calls != 2 || got == nil || got.Status == nil || *got.Status != "ACTIVE" { + t.Fatalf("calls = %d, tool = %#v", calls, got) + } +} + +func TestWaitForToolCanDelayFirstPoll(t *testing.T) { + const interval = 20 * time.Millisecond + startedAt := time.Now() + status := "ACTIVE" + _, err := WaitForTool(context.Background(), "tool-1", func(context.Context, string) (*ags.SandboxTool, error) { + if elapsed := time.Since(startedAt); elapsed < interval { + t.Fatalf("first poll started after %s, want at least %s", elapsed, interval) + } + return &ags.SandboxTool{Status: &status}, nil + }, Options{ + Interval: interval, + Timeout: 100 * time.Millisecond, + DelayBeforeFirstPoll: true, + }) + if err != nil { + t.Fatalf("WaitForTool returned error: %v", err) + } +} + +func TestWaitForInstanceReturnsEveryKnownStableState(t *testing.T) { + statuses := []string{ + "RUNNING", "PAUSED", "STOPPED", "FAILED", "STARTING_FAILED", + "STOPPING_FAILED", "PAUSE_FAILED", "RESUME_FAILED", + } + for _, status := range statuses { + t.Run(status, func(t *testing.T) { + calls := 0 + got, err := WaitForInstance(context.Background(), "ins-1", func(context.Context, string) (*ags.SandboxInstance, error) { + calls++ + return &ags.SandboxInstance{Status: &status}, nil + }, testOptions()) + if err != nil { + t.Fatalf("WaitForInstance returned error: %v", err) + } + if calls != 1 || got == nil || got.Status == nil || *got.Status != status { + t.Fatalf("calls = %d, instance = %#v", calls, got) + } + }) + } +} + +func TestWaitForToolReturnsEveryKnownStableState(t *testing.T) { + statuses := []string{"ACTIVE", "FAILED", "ISOLATED"} + for _, status := range statuses { + t.Run(status, func(t *testing.T) { + calls := 0 + got, err := WaitForTool(context.Background(), "tool-1", func(context.Context, string) (*ags.SandboxTool, error) { + calls++ + return &ags.SandboxTool{Status: &status}, nil + }, testOptions()) + if err != nil { + t.Fatalf("WaitForTool returned error: %v", err) + } + if calls != 1 || got == nil || got.Status == nil || *got.Status != status { + t.Fatalf("calls = %d, tool = %#v", calls, got) + } + }) + } +} + +func TestWaitForInstanceRecognizesEveryTransitionalState(t *testing.T) { + for _, transitional := range []string{"STARTING", "PAUSING", "STOPPING"} { + t.Run(transitional, func(t *testing.T) { + statuses := []string{transitional, "RUNNING"} + calls := 0 + _, err := WaitForInstance(context.Background(), "ins-1", func(context.Context, string) (*ags.SandboxInstance, error) { + status := statuses[calls] + calls++ + return &ags.SandboxInstance{Status: &status}, nil + }, testOptions()) + if err != nil || calls != 2 { + t.Fatalf("calls = %d, error = %v", calls, err) + } + }) + } +} + +func TestWaitForToolRecognizesEveryTransitionalState(t *testing.T) { + for _, transitional := range []string{"CREATING", "DELETING"} { + t.Run(transitional, func(t *testing.T) { + statuses := []string{transitional, "ACTIVE"} + calls := 0 + _, err := WaitForTool(context.Background(), "tool-1", func(context.Context, string) (*ags.SandboxTool, error) { + status := statuses[calls] + calls++ + return &ags.SandboxTool{Status: &status}, nil + }, testOptions()) + if err != nil || calls != 2 { + t.Fatalf("calls = %d, error = %v", calls, err) + } + }) + } +} + +func TestWaitForInstanceRejectsUnknownState(t *testing.T) { + status := "NEW_SERVER_STATE" + _, err := WaitForInstance(context.Background(), "ins-1", func(context.Context, string) (*ags.SandboxInstance, error) { + return &ags.SandboxInstance{Status: &status}, nil + }, testOptions()) + var cliErr *output.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("error = %T %v, want *output.CLIError", err, err) + } + if cliErr.Failure.Code != "WAIT_UNKNOWN_STATUS" || cliErr.Failure.Kind != output.KindGenericError || cliErr.Failure.Details["LastStatus"] != status { + t.Fatalf("failure = %#v", cliErr.Failure) + } +} + +func TestWaitStopsWhenParentContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + status := "STARTING" + _, err := WaitForInstance(ctx, "ins-1", func(context.Context, string) (*ags.SandboxInstance, error) { + cancel() + return &ags.SandboxInstance{Status: &status}, nil + }, testOptions()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func testOptions() Options { + return Options{ + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + } +} diff --git a/internal/commands/registry_generated_test.go b/internal/commands/registry_generated_test.go index d63be88..3152a93 100644 --- a/internal/commands/registry_generated_test.go +++ b/internal/commands/registry_generated_test.go @@ -1,6 +1,10 @@ package commands -import "testing" +import ( + "testing" + + "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" +) func TestRegistryIncludesAllKnownCommandModules(t *testing.T) { registry, err := Registry() @@ -50,3 +54,51 @@ func TestRegistryIncludesAllKnownCommandModules(t *testing.T) { t.Fatalf("module count = %d, want %d", got, len(want)) } } + +func TestWaitFlagScope(t *testing.T) { + registry, err := Registry() + if err != nil { + t.Fatalf("Registry returned error: %v", err) + } + wantWait := []string{ + "instance.create", + "instance.get", + "instance.pause", + "instance.resume", + "instance.update", + "tool.create", + "tool.fork", + "tool.get", + "tool.update", + } + for _, id := range wantWait { + module, ok := registry.Lookup(id) + if !ok { + t.Fatalf("registry missing %s", id) + } + flag, ok := findFlag(module.Descriptor.Spec.Flags, "wait") + if !ok || flag.Type != command.FlagBool || !flag.Workflow { + t.Errorf("%s --wait = %#v, present = %v", id, flag, ok) + } + if module.Descriptor.Generated != nil { + if flag, ok := findFlag(module.Descriptor.Generated.Spec.Flags, "wait"); ok { + t.Errorf("%s generated API snapshot unexpectedly includes workflow --wait: %#v", id, flag) + } + } + } + for _, id := range []string{"instance.delete", "instance.list", "tool.delete", "tool.list"} { + module, _ := registry.Lookup(id) + if flag, ok := findFlag(module.Descriptor.Spec.Flags, "wait"); ok { + t.Errorf("%s unexpectedly exposes --wait: %#v", id, flag) + } + } +} + +func findFlag(flags []command.FlagSpec, name string) (command.FlagSpec, bool) { + for _, flag := range flags { + if flag.Name == name { + return flag, true + } + } + return command.FlagSpec{}, false +} diff --git a/internal/commands/tool/create/command.go b/internal/commands/tool/create/command.go index 771c2f3..ab216de 100644 --- a/internal/commands/tool/create/command.go +++ b/internal/commands/tool/create/command.go @@ -9,6 +9,8 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/cli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" + toolget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/tool/get" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" "github.com/TencentCloudAgentRuntime/ags-cli/internal/progress" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" @@ -17,12 +19,14 @@ import ( // Module returns this package's command module. func Module() command.Module { api := APIDescriptor() - spec := api.CommandSpec() + generatedSpec := api.CommandSpec() + spec := generatedSpec + spec.Flags = append(spec.Flags, resourcewait.Flag()) return command.Module{ Descriptor: command.Descriptor{ Spec: spec, Generated: &command.Descriptor{ - Spec: spec, + Spec: generatedSpec, Groups: api.Groups, API: api, Source: "apicli", @@ -63,6 +67,21 @@ func Module() command.Module { // Response type mismatch or missing ToolId — cannot confirm success. sp.Cleanup() } + if resourcewait.Requested(req) { + response, ok := result.Data.(*ags.CreateSandboxToolResponseParams) + if !ok || derefString(response.ToolId) == "" { + return nil, missingToolIDError() + } + getter, ok := deps.ControlPlane.(resourcewait.ToolGetter) + if !ok { + return nil, fmt.Errorf("tool.create --wait requires GetTool support") + } + tool, err := resourcewait.WaitForTool(ctx, derefString(response.ToolId), getter.GetTool, resourcewait.OptionsFromDeps(deps)) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(toolget.Result(tool), result), nil + } return result, nil }), }, nil @@ -70,6 +89,15 @@ func Module() command.Module { } } +func missingToolIDError() error { + return output.NewCLIError(&output.Failure{ + Code: "INTERNAL_ERROR", + Kind: output.KindGenericError, + Message: "cannot wait because the create response did not include a tool id", + Hint: "Rerun with --debug. If the issue persists, inspect the control-plane response.", + }) +} + // applyCreateResultText enriches the command result with text rendering and // effects when the response is a valid CreateSandboxToolResponseParams with a // non-empty ToolId. Returns true if the response was confirmed valid. diff --git a/internal/commands/tool/create/command_test.go b/internal/commands/tool/create/command_test.go index 6e824c7..4e61121 100644 --- a/internal/commands/tool/create/command_test.go +++ b/internal/commands/tool/create/command_test.go @@ -6,8 +6,10 @@ import ( "errors" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/iostreams" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" @@ -16,20 +18,29 @@ import ( // fakeMixedControlPlane implements apicli.ControlPlane for testing the full // tool create flow (validation → request build → executor → response render). type fakeMixedControlPlane struct { - action string - request map[string]any - resp *ags.CreateSandboxToolResponseParams + action string + request map[string]any + resp *ags.CreateSandboxToolResponseParams + calls int + getCalls int + finalTool *ags.SandboxTool } func (f *fakeMixedControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ if f.resp != nil { return f.resp, nil } return map[string]any{"ok": true}, nil } +func (f *fakeMixedControlPlane) GetTool(_ context.Context, _ string) (*ags.SandboxTool, error) { + f.getCalls++ + return f.finalTool, nil +} + // allToolCreateFlags returns a complete flag set mimicking what Cobra registers // for `agr tool create`. Unset optional flags have Changed=false. func allToolCreateFlags() map[string]command.FlagValue { @@ -227,6 +238,33 @@ func TestModuleCreatesToolAndRendersText(t *testing.T) { } } +func TestModuleWaitsAfterCreatingExactlyOnce(t *testing.T) { + toolID := "sdt-new123" + active := "ACTIVE" + cp := &fakeMixedControlPlane{ + resp: &ags.CreateSandboxToolResponseParams{ToolId: &toolID}, + finalTool: &ags.SandboxTool{ToolId: &toolID, Status: &active}, + } + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: time.Millisecond, Timeout: 50 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + flags := minRequiredFlags() + flags["wait"] = command.FlagValue{Name: "wait", Type: command.FlagBool, Bool: true} + result, err := runtime.Handler.Run(context.Background(), command.Request{Flags: flags}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetTool = %d", cp.calls, cp.getCalls) + } + if result.Data.(map[string]any)["Status"] != active || len(result.Effects) != 1 { + t.Fatalf("result = %#v", result) + } +} + func TestModuleCreatesToolWithAllOptionalFlags(t *testing.T) { toolID := "sdt-full" cp := &fakeMixedControlPlane{resp: &ags.CreateSandboxToolResponseParams{ diff --git a/internal/commands/tool/delete/command_test.go b/internal/commands/tool/delete/command_test.go index d4d2f95..6afd43f 100644 --- a/internal/commands/tool/delete/command_test.go +++ b/internal/commands/tool/delete/command_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "slices" "testing" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" @@ -15,6 +16,14 @@ type fakeControlPlane struct { fail map[string]error } +func TestModuleDoesNotExposeWaitFlag(t *testing.T) { + if slices.ContainsFunc(Module().Descriptor.Spec.Flags, func(flag command.FlagSpec) bool { + return flag.Name == "wait" + }) { + t.Fatalf("tool.delete must not expose --wait") + } +} + func (f *fakeControlPlane) DeleteTool(_ context.Context, toolID string) error { if err := f.fail[toolID]; err != nil { return err diff --git a/internal/commands/tool/fork/command.go b/internal/commands/tool/fork/command.go index 18a5a6c..8850f8e 100644 --- a/internal/commands/tool/fork/command.go +++ b/internal/commands/tool/fork/command.go @@ -8,7 +8,9 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/tooltags" + toolget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/tool/get" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -23,6 +25,7 @@ type ControlPlane interface { func Module() command.Module { api := forkAPIDescriptor() spec := api.CommandSpec() + spec.Flags = append(spec.Flags, resourcewait.Flag()) for i := range spec.Flags { if spec.Flags[i].Name == "tool-name" { spec.Flags[i].Required = true @@ -70,7 +73,24 @@ func Module() command.Module { if err != nil { return nil, err } - return createResult(result, req), nil + mutationResult := createResult(result, req) + if !resourcewait.Requested(req) { + return mutationResult, nil + } + toolID := createdToolID(result) + if toolID == "" { + return nil, output.NewCLIError(&output.Failure{ + Code: "INTERNAL_ERROR", + Kind: output.KindGenericError, + Message: "cannot wait because the create response did not include a tool id", + Hint: "Rerun with --debug. If the issue persists, inspect the control-plane response.", + }) + } + tool, err := resourcewait.WaitForTool(ctx, toolID, cp.GetTool, resourcewait.OptionsFromDeps(deps)) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(toolget.Result(tool), mutationResult), nil }), }, nil }, diff --git a/internal/commands/tool/fork/command_test.go b/internal/commands/tool/fork/command_test.go index 908a9cd..4e1594c 100644 --- a/internal/commands/tool/fork/command_test.go +++ b/internal/commands/tool/fork/command_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -17,12 +19,19 @@ type fakeControlPlane struct { callErr error action string request map[string]any + getIDs []string + callCount int } func (f *fakeControlPlane) GetTool(_ context.Context, toolID string) (*ags.SandboxTool, error) { + f.getIDs = append(f.getIDs, toolID) if f.getErr != nil { return nil, f.getErr } + if toolID == "sdt-new" { + status := "ACTIVE" + return &ags.SandboxTool{ToolId: &toolID, Status: &status}, nil + } if f.sourceTool != nil { return f.sourceTool, nil } @@ -32,12 +41,39 @@ func (f *fakeControlPlane) GetTool(_ context.Context, toolID string) (*ags.Sandb func (f *fakeControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.callCount++ if f.callErr != nil { return nil, f.callErr } return map[string]any{"ToolId": "sdt-new"}, nil } +func TestModuleWaitsForForkedToolWithoutRepeatingCreate(t *testing.T) { + cp := &fakeControlPlane{} + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: time.Millisecond, Timeout: 50 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"sdt-source"}, + Flags: map[string]command.FlagValue{ + "tool-name": {Name: "tool-name", Type: command.FlagString, String: "copy", Changed: true}, + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.callCount != 1 || len(cp.getIDs) != 2 || cp.getIDs[0] != "sdt-source" || cp.getIDs[1] != "sdt-new" { + t.Fatalf("Call = %d, GetTool ids = %#v", cp.callCount, cp.getIDs) + } + if result.Data.(map[string]any)["Status"] != "ACTIVE" { + t.Fatalf("result = %#v", result.Data) + } +} + func TestModuleCopiesCreateCapableFields(t *testing.T) { cp := &fakeControlPlane{} runFork(t, cp, command.Request{ diff --git a/internal/commands/tool/get/command.go b/internal/commands/tool/get/command.go index e708597..5ab29d2 100644 --- a/internal/commands/tool/get/command.go +++ b/internal/commands/tool/get/command.go @@ -9,6 +9,7 @@ import ( "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -29,6 +30,7 @@ func Module() command.Module { Args: []command.ArgSpec{ {Name: "tool-id", Required: true, Description: "Sandbox tool ID."}, }, + Flags: []command.FlagSpec{resourcewait.Flag()}, Output: command.OutputSpec{ DataType: "SandboxTool", Description: "Sandbox tool details.", @@ -63,22 +65,34 @@ func Module() command.Module { if strings.TrimSpace(toolID) == "" { return nil, output.NewUsageError("MISSING_REQUIRED_ARG", "missing tool id", "Provide .") } - tool, err := cp.GetTool(ctx, toolID) + var tool *ags.SandboxTool + var err error + if resourcewait.Requested(req) { + tool, err = resourcewait.WaitForTool(ctx, toolID, cp.GetTool, resourcewait.OptionsFromDeps(deps)) + } else { + tool, err = cp.GetTool(ctx, toolID) + } if err != nil { return nil, err } - return &command.Result{ - Data: canonicalToolData(tool), - Text: func(w io.Writer) { - renderToolDetails(w, tool) - }, - }, nil + return Result(tool), nil }), }, nil }, } } +// Result returns the canonical command result for a Tool. Lifecycle mutation +// commands reuse it after --wait reaches ACTIVE. +func Result(tool *ags.SandboxTool) *command.Result { + return &command.Result{ + Data: canonicalToolData(tool), + Text: func(w io.Writer) { + renderToolDetails(w, tool) + }, + } +} + func renderToolDetails(w io.Writer, tool *ags.SandboxTool) { tagsStr := strings.Join(sortedTagStrings(tool.Tags), ", ") if tagsStr == "" { diff --git a/internal/commands/tool/get/command_test.go b/internal/commands/tool/get/command_test.go index 8866a14..eb754a6 100644 --- a/internal/commands/tool/get/command_test.go +++ b/internal/commands/tool/get/command_test.go @@ -6,25 +6,80 @@ import ( "errors" "strings" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) type fakeControlPlane struct { toolID string err error + tools []*ags.SandboxTool + calls int } func (f *fakeControlPlane) GetTool(_ context.Context, toolID string) (*ags.SandboxTool, error) { f.toolID = toolID + f.calls++ if f.err != nil { return nil, f.err } + if len(f.tools) > 0 { + index := f.calls - 1 + if index >= len(f.tools) { + index = len(f.tools) - 1 + } + return f.tools[index], nil + } id := toolID return &ags.SandboxTool{ToolId: &id}, nil } +func TestModuleDescriptorIncludesWait(t *testing.T) { + for _, flag := range Module().Descriptor.Spec.Flags { + if flag.Name == "wait" && flag.Type == command.FlagBool && flag.Workflow { + return + } + } + t.Fatal("tool get descriptor does not include workflow --wait") +} + +func TestModuleWaitsForToolTerminalState(t *testing.T) { + creating := "CREATING" + active := "ACTIVE" + cp := &fakeControlPlane{tools: []*ags.SandboxTool{ + {Status: &creating}, + {Status: &active}, + }} + runtime, err := Module().Build(command.Deps{ + ControlPlane: cp, + Values: map[string]any{resourcewait.OptionsKey: resourcewait.Options{ + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"sdt-unit"}, + Flags: map[string]command.FlagValue{ + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if cp.calls != 2 { + t.Fatalf("GetTool calls = %d, want 2", cp.calls) + } + if result.Data.(map[string]any)["Status"] != active { + t.Fatalf("data = %#v", result.Data) + } +} + func TestModuleExecutesToolGetter(t *testing.T) { cp := &fakeControlPlane{} runtime, err := Module().Build(command.Deps{ControlPlane: cp}) diff --git a/internal/commands/tool/update/command.go b/internal/commands/tool/update/command.go index 9bd0b29..45ff815 100644 --- a/internal/commands/tool/update/command.go +++ b/internal/commands/tool/update/command.go @@ -7,6 +7,8 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" + toolget "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/tool/get" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) @@ -14,12 +16,14 @@ import ( // Module returns this package's command module. func Module() command.Module { api := APIDescriptor() - spec := api.CommandSpec() + generatedSpec := api.CommandSpec() + spec := generatedSpec + spec.Flags = append(spec.Flags, resourcewait.Flag()) return command.Module{ Descriptor: command.Descriptor{ Spec: spec, Generated: &command.Descriptor{ - Spec: spec, + Spec: generatedSpec, Groups: api.Groups, API: api, Source: "apicli", @@ -45,6 +49,23 @@ func Module() command.Module { return nil, err } applyUpdateResultText(result, req) + if resourcewait.Requested(req) { + getter, ok := deps.ControlPlane.(resourcewait.ToolGetter) + if !ok { + return nil, fmt.Errorf("tool.update --wait requires GetTool support") + } + toolID, err := ToolID(req) + if err != nil { + return nil, err + } + options := resourcewait.OptionsFromDeps(deps) + options.DelayBeforeFirstPoll = true + tool, err := resourcewait.WaitForTool(ctx, toolID, getter.GetTool, options) + if err != nil { + return nil, err + } + return resourcewait.PreserveMutationMetadata(toolget.Result(tool), result), nil + } return result, nil }), }, nil diff --git a/internal/commands/tool/update/command_test.go b/internal/commands/tool/update/command_test.go index 0241723..5f4971e 100644 --- a/internal/commands/tool/update/command_test.go +++ b/internal/commands/tool/update/command_test.go @@ -4,27 +4,77 @@ import ( "context" "errors" "testing" + "time" "github.com/TencentCloudAgentRuntime/ags-cli/internal/apicli" "github.com/TencentCloudAgentRuntime/ags-cli/internal/command" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/commands/internal/resourcewait" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" + ags "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ags/v20250920" ) type fakeControlPlane struct { - action string - request map[string]any - err error + action string + request map[string]any + err error + calls int + getCalls int + updateReturned time.Time + firstGet time.Time } func (f *fakeControlPlane) Call(_ context.Context, action string, request map[string]any) (any, error) { f.action = action f.request = request + f.calls++ if f.err != nil { return nil, f.err } + f.updateReturned = time.Now() return map[string]any{"ok": true}, nil } +func (f *fakeControlPlane) GetTool(_ context.Context, toolID string) (*ags.SandboxTool, error) { + f.getCalls++ + if f.firstGet.IsZero() { + f.firstGet = time.Now() + } + status := "ACTIVE" + return &ags.SandboxTool{ToolId: &toolID, Status: &status}, nil +} + +func TestModuleWaitsAfterUpdatingExactlyOnce(t *testing.T) { + const interval = 20 * time.Millisecond + cp := &fakeControlPlane{} + runtime, err := Module().Build(command.Deps{ControlPlane: cp, Values: map[string]any{ + resourcewait.OptionsKey: resourcewait.Options{Interval: interval, Timeout: 100 * time.Millisecond}, + }}) + if err != nil { + t.Fatalf("Build: %v", err) + } + result, err := runtime.Handler.Run(context.Background(), command.Request{ + Args: []string{"sdt-unit"}, + ArgValues: map[string]string{"tool-id": "sdt-unit"}, + Flags: map[string]command.FlagValue{ + "description": {Name: "description", Type: command.FlagString, String: "updated", Changed: true}, + "request": {Name: "request", Type: command.FlagString}, + "wait": {Name: "wait", Type: command.FlagBool, Bool: true}, + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if cp.calls != 1 || cp.getCalls != 1 { + t.Fatalf("Call = %d, GetTool = %d", cp.calls, cp.getCalls) + } + if elapsed := cp.firstGet.Sub(cp.updateReturned); elapsed < interval { + t.Fatalf("first GetTool started %s after update returned, want at least %s", elapsed, interval) + } + if result.Data.(map[string]any)["Status"] != "ACTIVE" { + t.Fatalf("result = %#v", result.Data) + } +} + func TestModuleBuildsUpdateRequest(t *testing.T) { cp := &fakeControlPlane{} runtime, err := Module().Build(command.Deps{ControlPlane: cp}) From 4253848ef6092ad0df3353bfd1b27eda84a9aaac Mon Sep 17 00:00:00 2001 From: johnnyzwu Date: Tue, 21 Jul 2026 18:19:57 +0800 Subject: [PATCH 2/2] fix: show tool status in wait output --- internal/commands/tool/get/command.go | 4 ++++ internal/commands/tool/get/command_test.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/internal/commands/tool/get/command.go b/internal/commands/tool/get/command.go index 5ab29d2..3319f4a 100644 --- a/internal/commands/tool/get/command.go +++ b/internal/commands/tool/get/command.go @@ -106,11 +106,15 @@ func renderToolDetails(w io.Writer, tool *ags.SandboxTool) { {key: "ID", value: derefString(tool.ToolId)}, {key: "Name", value: derefString(tool.ToolName)}, {key: "Type", value: derefString(tool.ToolType)}, + {key: "Status", value: derefString(tool.Status)}, {key: "NetworkMode", value: networkMode}, {key: "Description", value: derefString(tool.Description)}, {key: "Tags", value: tagsStr}, {key: "Created", value: formatShortTime(derefString(tool.CreateTime))}, } + if tool.StatusReason != nil && *tool.StatusReason != "" { + kvs = append(kvs, keyValue{key: "StatusReason", value: *tool.StatusReason}) + } if tool.RoleArn != nil && *tool.RoleArn != "" { kvs = append(kvs, keyValue{key: "RoleArn", value: *tool.RoleArn}) } diff --git a/internal/commands/tool/get/command_test.go b/internal/commands/tool/get/command_test.go index eb754a6..35be840 100644 --- a/internal/commands/tool/get/command_test.go +++ b/internal/commands/tool/get/command_test.go @@ -206,3 +206,21 @@ func TestRenderToolDetailsIncludesOptionalFields(t *testing.T) { t.Fatalf("data = %#v", data) } } + +func TestRenderToolDetailsIncludesFailedStatusReason(t *testing.T) { + status := "FAILED" + statusReason := "provider image pull failed" + tool := &ags.SandboxTool{ + Status: &status, + StatusReason: &statusReason, + } + + var text bytes.Buffer + renderToolDetails(&text, tool) + got := text.String() + for _, want := range []string{"Status:", status, "StatusReason:", statusReason} { + if !strings.Contains(got, want) { + t.Fatalf("text missing %q: %s", want, got) + } + } +}