mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
MUL-3321: Add runtime delete CLI command
Adds a command-line runtime delete flow with strict default behavior and explicit cascade support.\n\nFixes #3909.
This commit is contained in:
@@ -2,8 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -43,11 +47,22 @@ var runtimeUpdateCmd = &cobra.Command{
|
||||
RunE: runRuntimeUpdate,
|
||||
}
|
||||
|
||||
var runtimeDeleteCmd = &cobra.Command{
|
||||
Use: "delete <runtime-id>",
|
||||
Short: "Delete a runtime from the workspace",
|
||||
Long: "Delete a runtime registration from the workspace.\n\n" +
|
||||
"By default this refuses when active agents are still bound to the runtime. " +
|
||||
"Pass --cascade to archive those agents, cancel their queued/running tasks, and delete the runtime.",
|
||||
Args: exactArgs(1),
|
||||
RunE: runRuntimeDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtimeCmd.AddCommand(runtimeListCmd)
|
||||
runtimeCmd.AddCommand(runtimeUsageCmd)
|
||||
runtimeCmd.AddCommand(runtimeActivityCmd)
|
||||
runtimeCmd.AddCommand(runtimeUpdateCmd)
|
||||
runtimeCmd.AddCommand(runtimeDeleteCmd)
|
||||
|
||||
// runtime list
|
||||
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
@@ -63,6 +78,10 @@ func init() {
|
||||
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
|
||||
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
|
||||
|
||||
// runtime delete
|
||||
runtimeDeleteCmd.Flags().Bool("cascade", false, "Archive active agents bound to the runtime, cancel their tasks, then delete the runtime")
|
||||
runtimeDeleteCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,6 +196,49 @@ func runRuntimeActivity(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRuntimeDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtimeID := args[0]
|
||||
ctx, cancel := cli.APIContext(context.Background())
|
||||
defer cancel()
|
||||
|
||||
err = client.DeleteJSON(ctx, "/api/runtimes/"+runtimeID)
|
||||
if err == nil {
|
||||
return printRuntimeDeleteResult(cmd, map[string]any{
|
||||
"id": runtimeID,
|
||||
"deleted": true,
|
||||
})
|
||||
}
|
||||
|
||||
conflict, ok := runtimeDeleteConflict(err)
|
||||
if !ok {
|
||||
return fmt.Errorf("delete runtime: %w", err)
|
||||
}
|
||||
|
||||
cascade, _ := cmd.Flags().GetBool("cascade")
|
||||
if !cascade {
|
||||
return fmt.Errorf(
|
||||
"delete runtime: runtime has active agents bound to it (%s); archive or reassign them first, or rerun with --cascade to archive them and delete the runtime",
|
||||
strings.Join(conflict.AgentDisplays(), ", "),
|
||||
)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"expected_active_agent_ids": conflict.AgentIDs(),
|
||||
}
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/runtimes/"+runtimeID+"/archive-agents-and-delete", body, &result); err != nil {
|
||||
return fmt.Errorf("cascade delete runtime: %w", err)
|
||||
}
|
||||
result["id"] = runtimeID
|
||||
result["deleted"] = true
|
||||
return printRuntimeDeleteResult(cmd, result)
|
||||
}
|
||||
|
||||
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -238,3 +300,66 @@ func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeDeleteConflictPayload struct {
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
ActiveAgents []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"active_agents"`
|
||||
}
|
||||
|
||||
func runtimeDeleteConflict(err error) (runtimeDeleteConflictPayload, bool) {
|
||||
var httpErr *cli.HTTPError
|
||||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict {
|
||||
return runtimeDeleteConflictPayload{}, false
|
||||
}
|
||||
var payload runtimeDeleteConflictPayload
|
||||
if json.Unmarshal([]byte(httpErr.Body), &payload) != nil {
|
||||
return runtimeDeleteConflictPayload{}, false
|
||||
}
|
||||
if payload.Code != "runtime_has_active_agents" || len(payload.ActiveAgents) == 0 {
|
||||
return runtimeDeleteConflictPayload{}, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (p runtimeDeleteConflictPayload) AgentIDs() []string {
|
||||
ids := make([]string, 0, len(p.ActiveAgents))
|
||||
for _, agent := range p.ActiveAgents {
|
||||
if agent.ID != "" {
|
||||
ids = append(ids, agent.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (p runtimeDeleteConflictPayload) AgentDisplays() []string {
|
||||
displays := make([]string, 0, len(p.ActiveAgents))
|
||||
for _, agent := range p.ActiveAgents {
|
||||
switch {
|
||||
case agent.Name != "" && agent.ID != "":
|
||||
displays = append(displays, fmt.Sprintf("%s (%s)", agent.Name, agent.ID))
|
||||
case agent.Name != "":
|
||||
displays = append(displays, agent.Name)
|
||||
case agent.ID != "":
|
||||
displays = append(displays, agent.ID)
|
||||
}
|
||||
}
|
||||
return displays
|
||||
}
|
||||
|
||||
func printRuntimeDeleteResult(cmd *cobra.Command, result map[string]any) error {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
if agentsArchived, ok := result["agents_archived"]; ok {
|
||||
fmt.Fprintf(os.Stderr, "Runtime %s deleted; archived %v agent(s).\n", strVal(result, "id"), agentsArchived)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Runtime %s deleted.\n", strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
181
server/cmd/multica/cmd_runtime_test.go
Normal file
181
server/cmd/multica/cmd_runtime_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRuntimeDeleteTestCmd(serverURL string) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "delete"}
|
||||
cmd.Flags().String("server-url", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
cmd.Flags().Bool("cascade", false, "")
|
||||
cmd.Flags().String("output", "table", "")
|
||||
_ = cmd.Flags().Set("server-url", serverURL)
|
||||
_ = cmd.Flags().Set("workspace-id", "ws-1")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func captureRuntimeStdout(t *testing.T, fn func() error) (string, error) {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe stdout: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = old }()
|
||||
|
||||
runErr := fn()
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close stdout writer: %v", err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read stdout: %v", err)
|
||||
}
|
||||
return string(out), runErr
|
||||
}
|
||||
|
||||
func TestRunRuntimeDeleteStrictSuccessPrintsJSON(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
|
||||
var deleteCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Fatalf("method = %s, want DELETE", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/runtimes/rt-1" {
|
||||
t.Fatalf("path = %q, want /api/runtimes/rt-1", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Workspace-ID") != "ws-1" {
|
||||
t.Fatalf("X-Workspace-ID = %q, want ws-1", r.Header.Get("X-Workspace-ID"))
|
||||
}
|
||||
deleteCount++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cmd := newRuntimeDeleteTestCmd(srv.URL)
|
||||
_ = cmd.Flags().Set("output", "json")
|
||||
|
||||
out, err := captureRuntimeStdout(t, func() error {
|
||||
return runRuntimeDelete(cmd, []string{"rt-1"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runRuntimeDelete: %v", err)
|
||||
}
|
||||
if deleteCount != 1 {
|
||||
t.Fatalf("deleteCount = %d, want 1", deleteCount)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode stdout JSON %q: %v", out, err)
|
||||
}
|
||||
if got["id"] != "rt-1" || got["deleted"] != true {
|
||||
t.Fatalf("stdout = %#v, want deleted result for rt-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRuntimeDeleteConflictSuggestsCascade(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/api/runtimes/rt-1" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "runtime_has_active_agents",
|
||||
"error": "cannot delete runtime: it has active agents bound to it.",
|
||||
"active_agents": []map[string]any{
|
||||
{"id": "agent-1", "name": "Codex"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := runRuntimeDelete(newRuntimeDeleteTestCmd(srv.URL), []string{"rt-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected active-agent conflict")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "Codex (agent-1)") || !strings.Contains(msg, "--cascade") {
|
||||
t.Fatalf("error = %q, want agent name and --cascade guidance", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRuntimeDeleteCascadeConfirmsActiveAgentSnapshot(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
|
||||
var gotExpectedIDs []string
|
||||
var deleteCount, cascadeCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/runtimes/rt-1":
|
||||
deleteCount++
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "runtime_has_active_agents",
|
||||
"error": "cannot delete runtime: it has active agents bound to it.",
|
||||
"active_agents": []map[string]any{
|
||||
{"id": "agent-1", "name": "Codex"},
|
||||
{"id": "agent-2", "name": "Claude"},
|
||||
},
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/runtimes/rt-1/archive-agents-and-delete":
|
||||
cascadeCount++
|
||||
var body struct {
|
||||
ExpectedActiveAgentIDs []string `json:"expected_active_agent_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode cascade body: %v", err)
|
||||
}
|
||||
gotExpectedIDs = body.ExpectedActiveAgentIDs
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"agents_archived": 2,
|
||||
"tasks_cancelled": 1,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cmd := newRuntimeDeleteTestCmd(srv.URL)
|
||||
_ = cmd.Flags().Set("cascade", "true")
|
||||
_ = cmd.Flags().Set("output", "json")
|
||||
|
||||
out, err := captureRuntimeStdout(t, func() error {
|
||||
return runRuntimeDelete(cmd, []string{"rt-1"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runRuntimeDelete: %v", err)
|
||||
}
|
||||
if deleteCount != 1 || cascadeCount != 1 {
|
||||
t.Fatalf("deleteCount/cascadeCount = %d/%d, want 1/1", deleteCount, cascadeCount)
|
||||
}
|
||||
if strings.Join(gotExpectedIDs, ",") != "agent-1,agent-2" {
|
||||
t.Fatalf("expected_active_agent_ids = %#v", gotExpectedIDs)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode stdout JSON %q: %v", out, err)
|
||||
}
|
||||
if got["id"] != "rt-1" || got["deleted"] != true || got["agents_archived"] != float64(2) {
|
||||
t.Fatalf("stdout = %#v, want cascade result", got)
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,12 @@ multica runtime list --output json
|
||||
multica runtime usage <runtime-id> --output json
|
||||
multica runtime activity <runtime-id> --output json
|
||||
multica runtime update <runtime-id> --target-version <version> --output json
|
||||
multica runtime delete <runtime-id>
|
||||
multica repo checkout <url>
|
||||
multica repo checkout <url> --ref <branch-or-sha>
|
||||
```
|
||||
|
||||
`runtime update` is a write. `repo checkout` creates a git worktree in the task working directory.
|
||||
`runtime update` and `runtime delete` are writes. `runtime delete` removes a runtime registration; if active agents are still bound, it refuses unless the user explicitly passes `--cascade`, which archives those agents and cancels their queued/running tasks before deleting the runtime. `repo checkout` creates a git worktree in the task working directory.
|
||||
|
||||
`repo checkout` requires `MULTICA_DAEMON_PORT`; it is intended to run inside a daemon task. If absent, you are not in the normal agent checkout path.
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Runtimes and repos source map
|
||||
|
||||
- `server/cmd/multica/cmd_runtime.go` registers `runtime list`, `usage`, `activity`, and `update`.
|
||||
- `server/cmd/multica/cmd_runtime.go` registers `runtime list`, `usage`, `activity`, `update`, and `delete`.
|
||||
- `runtime list` reads `/api/runtimes` and prints `id`, `name`, `runtime_mode`, `provider`, `status`, and `last_seen_at`.
|
||||
- `runtime update` posts to `/api/runtimes/{runtime-id}/update`; with `--wait` it polls update status.
|
||||
- `runtime delete` deletes `/api/runtimes/{runtime-id}`; with `--cascade`, it first reads the `runtime_has_active_agents` conflict payload and posts those ids to `/api/runtimes/{runtime-id}/archive-agents-and-delete`.
|
||||
- `server/cmd/multica/cmd_repo.go` registers `repo checkout <url> [--ref]`.
|
||||
- `repo checkout` requires `MULTICA_DAEMON_PORT`, sends `workspace_id`, `workdir`, `ref`, `agent_name`, and `task_id` to local daemon `/repo/checkout`, then prints the checked-out path.
|
||||
- `server/cmd/server/router.go` registers daemon APIs under `/api/daemon`, including workspace repos and task claim.
|
||||
|
||||
Reference in New Issue
Block a user