feat: add descriptive messages to cloud action responses (#4592)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-04-05 16:17:22 -07:00
committed by GitHub
parent 5abda9f224
commit dd5cd3ccac
13 changed files with 79 additions and 54 deletions
+9 -4
View File
@@ -367,21 +367,26 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
return err
}
func (c *Client) UpdateContainer(ctx context.Context, containerID string, progressCh chan<- container.UpdateProgress) error {
func (c *Client) UpdateContainer(ctx context.Context, containerID string, progressCh chan<- container.UpdateProgress) (bool, error) {
defer close(progressCh)
stream, err := c.client.UpdateContainer(ctx, &pb.UpdateContainerRequest{ContainerId: containerID})
if err != nil {
return err
return false, err
}
updated := false
for {
progress, err := stream.Recv()
if err == io.EOF {
return nil
return updated, nil
}
if err != nil {
return err
return false, err
}
if progress.Status == "done" {
updated = true
}
progressCh <- container.UpdateProgress{
+2 -2
View File
@@ -100,9 +100,9 @@ func (m *MockedClientService) Exec(ctx context.Context, c container.Container, c
return args.Error(0)
}
func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) error {
func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
args := m.Called(ctx, c, progressCh)
return args.Error(0)
return args.Bool(0), args.Error(1)
}
var wantedContainer = container.Container{}
+3 -2
View File
@@ -39,7 +39,7 @@ type ClientService interface {
ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error)
Host(ctx context.Context) (container.Host, error)
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) error
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) (bool, error)
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error)
SubscribeStats(context.Context, chan<- container.ContainerStat)
@@ -324,7 +324,8 @@ func (s *server) UpdateContainer(req *pb.UpdateContainerRequest, out pb.AgentSer
errCh := make(chan error, 1)
go func() {
errCh <- s.service.UpdateContainer(out.Context(), c, progressCh)
_, err := s.service.UpdateContainer(out.Context(), c, progressCh)
errCh <- err
}()
for progress := range progressCh {
+32 -24
View File
@@ -41,12 +41,15 @@ func executeContainerAction(ctx context.Context, name string, argsJSON string, h
return nil, fmt.Errorf("action failed: %w", err)
}
message := fmt.Sprintf("Successfully %s container %s.", pastTense(action), args.ContainerID)
return &pb.CallToolResponse{
Success: true,
Result: &pb.CallToolResponse_Action{Action: &pb.ActionResult{
Success: true,
ContainerId: args.ContainerID,
Action: string(action),
Message: message,
}},
}, nil
}
@@ -69,33 +72,24 @@ func executeUpdateContainer(ctx context.Context, argsJSON string, hostService To
return nil, fmt.Errorf("container not found: %w", err)
}
progressCh := make(chan container.UpdateProgress, 100)
errCh := make(chan error, 1)
progressCh := make(chan container.UpdateProgress)
var updated bool
var updateErr error
done := make(chan struct{})
go func() {
errCh <- cs.Update(ctx, progressCh)
updated, updateErr = cs.Update(ctx, progressCh)
close(done)
}()
// Drain progress channel and capture final status
var lastStatus string
var lastError string
for progress := range progressCh {
lastStatus = progress.Status
if progress.Error != "" {
lastError = progress.Error
}
for range progressCh {
}
<-done
if updateErr != nil {
return nil, fmt.Errorf("update failed: %w", updateErr)
}
if err := <-errCh; err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
action := "update"
if lastStatus == "up-to-date" {
action = "update (already up-to-date)"
}
if lastError != "" {
return nil, fmt.Errorf("update failed: %s", lastError)
message := fmt.Sprintf("Successfully updated container %s by pulling the latest image and recreating it.", args.ContainerID)
if !updated {
message = fmt.Sprintf("Container %s is already running the latest image. No update was needed.", args.ContainerID)
}
return &pb.CallToolResponse{
@@ -103,11 +97,25 @@ func executeUpdateContainer(ctx context.Context, argsJSON string, hostService To
Result: &pb.CallToolResponse_Action{Action: &pb.ActionResult{
Success: true,
ContainerId: args.ContainerID,
Action: action,
Action: "update",
Message: message,
}},
}, nil
}
func pastTense(action container.ContainerAction) string {
switch action {
case container.Start:
return "started"
case container.Stop:
return "stopped"
case container.Restart:
return "restarted"
default:
return string(action) + "ed"
}
}
func resolveAction(name string) (container.ContainerAction, error) {
switch name {
case "start_container":
+2 -2
View File
@@ -126,9 +126,9 @@ func (m *MockClientService) Exec(_ context.Context, _ container.Container, _ []s
return nil
}
func (m *MockClientService) UpdateContainer(_ context.Context, _ container.Container, progressCh chan<- container.UpdateProgress) error {
func (m *MockClientService) UpdateContainer(_ context.Context, _ container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
close(progressCh)
return nil
return false, nil
}
func TestExecuteTool_ListRunningContainers(t *testing.T) {
+1 -1
View File
@@ -76,7 +76,7 @@ func (a *agentService) ContainerAction(ctx context.Context, container container.
return a.client.ContainerAction(ctx, container.ID, action)
}
func (a *agentService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) error {
func (a *agentService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
return a.client.UpdateContainer(ctx, c.ID, progressCh)
}
+1 -1
View File
@@ -15,7 +15,7 @@ type ClientService interface {
ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error)
Host(ctx context.Context) (container.Host, error)
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) error
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) (bool, error)
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
RawLogs(context.Context, container.Container, time.Time, time.Time, container.StdType) (io.ReadCloser, error)
@@ -36,7 +36,7 @@ func (c *ContainerService) Action(ctx context.Context, action container.Containe
return c.clientService.ContainerAction(ctx, c.Container, action)
}
func (c *ContainerService) Update(ctx context.Context, progressCh chan<- container.UpdateProgress) error {
func (c *ContainerService) Update(ctx context.Context, progressCh chan<- container.UpdateProgress) (bool, error) {
return c.clientService.UpdateContainer(ctx, c.Container, progressCh)
}
+12 -12
View File
@@ -114,14 +114,14 @@ type pullEvent struct {
ID string `json:"id"`
}
func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) error {
func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
defer close(progressCh)
// 1. Inspect container to get full config
inspectResp, err := d.client.ContainerInspect(ctx, c.ID)
if err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("inspect failed: %v", err)}
return err
return false, err
}
imageName := inspectResp.Config.Image
@@ -130,7 +130,7 @@ func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.C
reader, err := d.client.ImagePull(ctx, imageName)
if err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("pull failed: %v", err)}
return err
return false, err
}
defer reader.Close()
@@ -142,7 +142,7 @@ func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.C
break
} else if err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("pull decode failed: %v", err)}
return err
return false, err
}
progressCh <- container.UpdateProgress{
@@ -160,7 +160,7 @@ func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.C
// 3. If no new layers, report up-to-date
if !updated {
progressCh <- container.UpdateProgress{Status: "up-to-date"}
return nil
return false, nil
}
// 4. Check if this is a swarm service
@@ -170,10 +170,10 @@ func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.C
serviceID := c.Labels["com.docker.swarm.service.id"]
if err := d.client.ServiceUpdate(ctx, serviceID, imageName); err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("service update failed: %v", err)}
return err
return false, err
}
progressCh <- container.UpdateProgress{Status: "done"}
return nil
return true, nil
}
// 5. Standalone container: stop -> remove -> create -> start
@@ -185,31 +185,31 @@ func (d *DockerClientService) UpdateContainer(ctx context.Context, c container.C
if c.State == "running" {
if err := d.client.ContainerActions(ctx, container.Stop, c.ID); err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("stop failed: %v", err)}
return err
return false, err
}
}
// Remove
if err := d.client.ContainerRemove(ctx, c.ID); err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("remove failed: %v", err)}
return err
return false, err
}
// Create with same config
newID, err := d.client.ContainerCreate(ctx, inspectResp, containerName)
if err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("create failed: %v", err)}
return err
return false, err
}
// Start
if err := d.client.ContainerActions(ctx, container.Start, newID); err != nil {
progressCh <- container.UpdateProgress{Status: "error", Error: fmt.Sprintf("start failed: %v", err)}
return err
return false, err
}
progressCh <- container.UpdateProgress{Status: "done"}
return nil
return true, nil
}
func (d *DockerClientService) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
+2 -2
View File
@@ -93,9 +93,9 @@ func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, conta
k.store.SubscribeNewContainers(ctx, containers)
}
func (k *K8sClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) error {
func (k *K8sClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
defer close(progressCh)
return fmt.Errorf("update container is not supported in Kubernetes mode")
return false, fmt.Errorf("update container is not supported in Kubernetes mode")
}
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
+2 -1
View File
@@ -83,7 +83,8 @@ func (h *handler) containerUpdate(w http.ResponseWriter, r *http.Request) {
errCh := make(chan error, 1)
go func() {
errCh <- containerService.Update(r.Context(), progressCh)
_, err := containerService.Update(r.Context(), progressCh)
errCh <- err
}()
for progress := range progressCh {
+11 -2
View File
@@ -1338,6 +1338,7 @@ type ActionResult struct {
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
ContainerId string `protobuf:"bytes,2,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"`
Action string `protobuf:"bytes,3,opt,name=action,proto3" json:"action,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1393,6 +1394,13 @@ func (x *ActionResult) GetAction() string {
return ""
}
func (x *ActionResult) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
var File_cloud_proto protoreflect.FileDescriptor
const file_cloud_proto_rawDesc = "" +
@@ -1511,11 +1519,12 @@ const file_cloud_proto_rawDesc = "" +
"\fnetwork_mode\x18\x12 \x01(\tR\vnetworkMode\x1a9\n" +
"\vLabelsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x0e\x10\x0fR\x03env\"c\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x0e\x10\x0fR\x03env\"}\n" +
"\fActionResult\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12!\n" +
"\fcontainer_id\x18\x02 \x01(\tR\vcontainerId\x12\x16\n" +
"\x06action\x18\x03 \x01(\tR\x06action2M\n" +
"\x06action\x18\x03 \x01(\tR\x06action\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage2M\n" +
"\x10CloudToolService\x129\n" +
"\n" +
"ToolStream\x12\x13.cloud.ToolResponse\x1a\x12.cloud.ToolRequest(\x010\x01B&Z$github.com/amir20/dozzle/proto/cloudb\x06proto3"
+1
View File
@@ -150,4 +150,5 @@ message ActionResult {
bool success = 1;
string container_id = 2;
string action = 3;
string message = 4;
}