feat: implement control history and mutation APIs

This commit is contained in:
2026-09-06 10:18:37 +00:00
parent ef43e9592c
commit 8df56263fb
11 changed files with 1515 additions and 3 deletions
+251 -1
View File
@@ -16,6 +16,7 @@ import (
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/agentproto"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
@@ -55,6 +56,16 @@ func run(args []string, output, diagnostics io.Writer) error {
return stat(ctx, client, args[1:], output)
case "run":
return runCommand(ctx, client, args[1:], output, diagnostics)
case "append":
return appendStdin(ctx, client, args[1:], output)
case "close-stdin":
return closeStdin(ctx, client, args[1:], output)
case "kill":
return killCommand(ctx, client, args[1:], output)
case "storage":
return storage(ctx, client, args[1:], output)
case "client":
return clientCommand(ctx, client, args[1:], output)
default:
return fmt.Errorf("unknown command %q", args[0])
}
@@ -224,15 +235,254 @@ func runCommand(ctx context.Context, client rvboxv1.ControlClient, args []string
return printRunResponse(ctx, client, request, output, *background)
}
func printRunResponse(ctx context.Context, client rvboxv1.ControlClient, request *rvboxv1.RunCommandRequest, output io.Writer, _ bool) error {
func printRunResponse(ctx context.Context, client rvboxv1.ControlClient, request *rvboxv1.RunCommandRequest, output io.Writer, background bool) error {
response, err := client.RunCommand(ctx, request)
if err != nil {
return err
}
fmt.Fprintf(output, "%s %s\n", response.GetIssueUuid(), response.GetLifecycle())
if background {
return nil
}
follow, err := client.FollowCommand(ctx, &rvboxv1.FollowCommandRequest{ClientId: request.GetTargetClientId(), IssueUuid: response.GetIssueUuid(), IncludeExisting: true})
if err != nil {
return err
}
for {
item, err := follow.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
event := item.GetEvent()
if event == nil {
continue
}
switch payload := event.Payload.(type) {
case *rvboxv1.CommandEvent_Output:
data, decodeErr := agentproto.DecodeOutputChunk(payload.Output, 64<<10)
if decodeErr != nil {
return decodeErr
}
if _, writeErr := output.Write(data); writeErr != nil {
return writeErr
}
case *rvboxv1.CommandEvent_Lifecycle:
fmt.Fprintf(output, "\n[%s] %s\n", payload.Lifecycle.GetLifecycle(), payload.Lifecycle.GetDetail())
}
}
}
func appendStdin(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
flags := flag.NewFlagSet("append", flag.ContinueOnError)
flags.SetOutput(io.Discard)
filePath := flags.String("file", "", "read stdin data from a file")
raw := flags.Bool("raw", false, "do not append a newline")
requestID := flags.String("request-id", "", "canonical UUIDv7")
if err := flags.Parse(args); err != nil {
return err
}
positionals := flags.Args()
if len(positionals) < 2 || len(positionals) > 3 || (*filePath != "" && len(positionals) == 3) {
return errors.New("append requires CLIENT UUID TEXT, or --file PATH CLIENT UUID")
}
data := []byte{}
var err error
if *filePath != "" {
data, err = os.ReadFile(*filePath)
} else {
data = []byte(positionals[2])
}
if err != nil {
return err
}
if *requestID == "" {
*requestID, err = generatedRequestID()
if err != nil {
return err
}
}
issue := positionals[1]
if _, err := domain.ParseUUIDv7(issue); err != nil {
return err
}
response, err := client.AppendStdin(ctx, &rvboxv1.AppendStdinRequest{ClientId: positionals[0], IssueUuid: issue, Data: data, AppendNewline: !*raw, RequestId: *requestID})
if err != nil {
return err
}
fmt.Fprintf(output, "%d\n", response.GetWriteSeq())
return nil
}
func closeStdin(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
flags := flag.NewFlagSet("close-stdin", flag.ContinueOnError)
flags.SetOutput(io.Discard)
requestID := flags.String("request-id", "", "canonical UUIDv7")
if err := flags.Parse(args); err != nil {
return err
}
if len(flags.Args()) != 2 {
return errors.New("close-stdin requires CLIENT UUID")
}
if _, err := domain.ParseUUIDv7(flags.Args()[1]); err != nil {
return err
}
if *requestID == "" {
var err error
*requestID, err = generatedRequestID()
if err != nil {
return err
}
}
response, err := client.CloseStdin(ctx, &rvboxv1.CloseStdinRequest{ClientId: flags.Args()[0], IssueUuid: flags.Args()[1], RequestId: *requestID})
if err != nil {
return err
}
fmt.Fprintf(output, "%d\n", response.GetWriteSeq())
return nil
}
func killCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
flags := flag.NewFlagSet("kill", flag.ContinueOnError)
flags.SetOutput(io.Discard)
requestID := flags.String("request-id", "", "canonical UUIDv7")
if err := flags.Parse(args); err != nil {
return err
}
positionals := flags.Args()
signal := rvboxv1.SignalKind_SIGNAL_TERM
if len(positionals) == 3 {
parsed, err := parseSignal(positionals[0])
if err != nil {
return err
}
signal = parsed
positionals = positionals[1:]
}
if len(positionals) != 2 {
return errors.New("kill requires [SIGNAL] CLIENT UUID")
}
if _, err := domain.ParseUUIDv7(positionals[1]); err != nil {
return err
}
if *requestID == "" {
var err error
*requestID, err = generatedRequestID()
if err != nil {
return err
}
}
response, err := client.SignalCommand(ctx, &rvboxv1.ControlSignalCommandRequest{ClientId: positionals[0], IssueUuid: positionals[1], Signal: signal, RequestId: *requestID})
if err != nil {
return err
}
fmt.Fprintf(output, "%d\n", response.GetCommandRevision())
return nil
}
func storage(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
if len(args) == 0 {
return errors.New("storage requires incidents, repair, or acknowledge")
}
switch args[0] {
case "incidents":
response, err := client.ListStorageIncidents(ctx, &rvboxv1.ListStorageIncidentsRequest{IncludeResolved: contains(args[1:], "--all")})
if err != nil {
return err
}
for _, incident := range response.GetIncidents() {
fmt.Fprintf(output, "%s state=%s scope=%s summary=%s\n", incident.GetIncidentId(), incident.GetState(), incident.GetScope(), incident.GetSummary())
}
return nil
case "repair", "acknowledge":
if len(args) < 2 {
return errors.New("storage mutation requires INCIDENT_ID")
}
requestID, err := generatedRequestID()
if err != nil {
return err
}
if args[0] == "repair" {
response, callErr := client.RepairStorageIncident(ctx, &rvboxv1.RepairStorageIncidentRequest{IncidentId: args[1], RequestId: requestID})
if callErr != nil {
return callErr
}
fmt.Fprintf(output, "%s\n", response.GetIncident().GetState())
return nil
}
note := strings.Join(args[2:], " ")
if note == "" {
return errors.New("storage acknowledge requires a note")
}
response, callErr := client.AcknowledgeStorageIncident(ctx, &rvboxv1.AcknowledgeStorageIncidentRequest{IncidentId: args[1], RequestId: requestID, Note: note})
if callErr != nil {
return callErr
}
fmt.Fprintf(output, "%s\n", response.GetIncident().GetState())
return nil
default:
return fmt.Errorf("unknown storage command %q", args[0])
}
}
func clientCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
if len(args) != 3 || args[0] != "takeover" {
return errors.New("client takeover requires CLIENT INSTANCE_ID")
}
if _, err := domain.ParseUUIDv7(args[2]); err != nil {
return err
}
requestID, err := generatedRequestID()
if err != nil {
return err
}
response, err := client.AuthorizeClientTakeover(ctx, &rvboxv1.AuthorizeClientTakeoverRequest{ClientId: args[1], ClientInstanceId: args[2], RequestId: requestID})
if err != nil {
return err
}
fmt.Fprintln(output, response.GetExpiresAt().AsTime().UTC().Format(time.RFC3339Nano))
return nil
}
func generatedRequestID() (string, error) {
value, err := domain.NewUUIDv7()
if err != nil {
return "", err
}
return value.String(), nil
}
func parseSignal(value string) (rvboxv1.SignalKind, error) {
value = strings.ToUpper(strings.TrimPrefix(value, "SIG"))
switch value {
case "HUP":
return rvboxv1.SignalKind_SIGNAL_HUP, nil
case "INT":
return rvboxv1.SignalKind_SIGNAL_INT, nil
case "TERM":
return rvboxv1.SignalKind_SIGNAL_TERM, nil
case "KILL":
return rvboxv1.SignalKind_SIGNAL_KILL, nil
case "USR1":
return rvboxv1.SignalKind_SIGNAL_USR1, nil
case "USR2":
return rvboxv1.SignalKind_SIGNAL_USR2, nil
default:
return rvboxv1.SignalKind_SIGNAL_KIND_UNSPECIFIED, fmt.Errorf("unsupported signal %q", value)
}
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func queueDuration(value time.Duration) *durationpb.Duration {
if value < 0 {
return nil
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"path/filepath"
"testing"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
)
func TestGlobalSocketAndCLIValueParsing_HP_CTL_11(t *testing.T) {
socket, args, err := globalSocket([]string{"--socket", filepath.Join(t.TempDir(), "rvbox.sock"), "run", "client", "echo"})
if err != nil || args[0] != "run" || socket == "" {
t.Fatalf("global socket = %q %#v, %v", socket, args, err)
}
if _, _, err := globalSocket([]string{"--socket", "relative.sock", "stat"}); err == nil {
t.Fatal("relative control socket accepted")
}
for input, want := range map[string]rvboxv1.ShellType{"sh": rvboxv1.ShellType_SHELL_SH, "bash": rvboxv1.ShellType_SHELL_BASH, "cmd": rvboxv1.ShellType_SHELL_CMD, "pwsh": rvboxv1.ShellType_SHELL_POWERSHELL, "": rvboxv1.ShellType_SHELL_TYPE_UNSPECIFIED} {
got, err := parseShell(input)
if err != nil || got != want {
t.Errorf("parseShell(%q) = %v, %v", input, got, err)
}
}
if _, err := parseProfile("unknown"); err == nil {
t.Fatal("unknown profile accepted")
}
if got := queueDuration(-time.Nanosecond); got != nil {
t.Fatal("negative queue duration was encoded")
}
if got := queueDuration(0); got == nil || got.AsDuration() != 0 {
t.Fatalf("zero queue duration = %v", got)
}
}