Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cmd/agr/characterization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
30 changes: 29 additions & 1 deletion internal/commands/instance/create/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.",
Expand Down Expand Up @@ -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")
Expand Down
45 changes: 42 additions & 3 deletions internal/commands/instance/create/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/instance/delete/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Expand Down
5 changes: 4 additions & 1 deletion internal/commands/instance/delete/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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) {
Expand Down
28 changes: 21 additions & 7 deletions internal/commands/instance/get/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"},
}
Expand Down Expand Up @@ -54,21 +56,33 @@ func Module() command.Module {
if strings.TrimSpace(instanceID) == "" {
return nil, output.NewUsageError("MISSING_REQUIRED_ARG", "missing instance id", "Provide <instance-id>.")
}
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)},
Expand Down
58 changes: 58 additions & 0 deletions internal/commands/instance/get/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions internal/commands/instance/pause/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
},
Expand Down
Loading