Add guest.session.send and vm.workspace.export RPCs
guest.session.send — write to a pipe-mode session's stdin without holding the exclusive attach. The daemon dials a fresh SSH connection, uploads the payload to a temp file, and cats it into the session's named FIFO. Linux atomicity for writes ≤ PIPE_BUF covers all pi RPC JSONL lines. Attach exclusivity is unchanged. vm.workspace.export — pull changes from guest back to host. Runs `git add -A && git diff --cached HEAD --binary` inside the guest via a new RunScriptOutput helper on guest.Client (stdout-only capture, distinct from RunScript which merges stderr). Returns a binary-safe patch and a list of changed files. CLI writes the patch to stdout for `| git apply` or to a file via --output. RunScriptOutput is implemented as a direct SSH session (same pattern as runSession) rather than going through StartCommand/StreamSession to avoid closing the underlying Client, which is required since ExportVMWorkspace calls it twice on the same connection. New files: internal/daemon/workspace_test.go
This commit is contained in:
parent
797a9de1ce
commit
94c353f317
9 changed files with 1074 additions and 1 deletions
|
|
@ -89,6 +89,9 @@ var (
|
|||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return rpc.Call[api.VMWorkspacePrepareResult](ctx, socketPath, "vm.workspace.prepare", params)
|
||||
}
|
||||
vmWorkspaceExportFunc = func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
return rpc.Call[api.WorkspaceExportResult](ctx, socketPath, "vm.workspace.export", params)
|
||||
}
|
||||
guestSessionStartFunc = func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
|
||||
}
|
||||
|
|
@ -110,6 +113,9 @@ var (
|
|||
guestSessionAttachBeginFunc = func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
|
||||
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
|
||||
}
|
||||
guestSessionSendFunc = func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
return rpc.Call[api.GuestSessionSendResult](ctx, socketPath, "guest.session.send", params)
|
||||
}
|
||||
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
|
||||
}
|
||||
|
|
@ -869,7 +875,10 @@ func newVMWorkspaceCommand() *cobra.Command {
|
|||
Short: "Manage repository workspaces inside a running VM",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(newVMWorkspacePrepareCommand())
|
||||
cmd.AddCommand(
|
||||
newVMWorkspacePrepareCommand(),
|
||||
newVMWorkspaceExportCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -929,6 +938,52 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newVMWorkspaceExportCommand() *cobra.Command {
|
||||
var guestPath string
|
||||
var outputPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "export <id-or-name>",
|
||||
Short: "Pull changes from a guest workspace back to the host as a patch",
|
||||
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff against HEAD. With no --output flag the patch is written to stdout so it can be piped directly to git apply.",
|
||||
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm workspace export devbox | git apply
|
||||
banger vm workspace export devbox --output worker.diff
|
||||
banger vm workspace export devbox --guest-path /root/project --output changes.diff
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
|
||||
IDOrName: args[0],
|
||||
GuestPath: guestPath,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.HasChanges {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "no changes")
|
||||
return nil
|
||||
}
|
||||
if outputPath != "" {
|
||||
if err := os.WriteFile(outputPath, result.Patch, 0o644); err != nil {
|
||||
return fmt.Errorf("write patch: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "patch written to %s (%d bytes, %d files)\n",
|
||||
outputPath, len(result.Patch), len(result.ChangedFiles))
|
||||
return err
|
||||
}
|
||||
_, err = cmd.OutOrStdout().Write(result.Patch)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
|
||||
cmd.Flags().StringVar(&outputPath, "output", "", "write patch to this file instead of stdout")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
|
|
@ -944,6 +999,7 @@ func newVMSessionCommand() *cobra.Command {
|
|||
newVMSessionStopCommand(),
|
||||
newVMSessionKillCommand(),
|
||||
newVMSessionAttachCommand(),
|
||||
newVMSessionSendCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -1134,6 +1190,51 @@ func newVMSessionAttachCommand() *cobra.Command {
|
|||
}
|
||||
}
|
||||
|
||||
func newVMSessionSendCommand() *cobra.Command {
|
||||
var message string
|
||||
cmd := &cobra.Command{
|
||||
Use: "send <id-or-name> <session>",
|
||||
Short: "Write bytes to a running guest session's stdin pipe",
|
||||
Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm session send devbox planner --message '{"type":"abort"}'
|
||||
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
|
||||
echo '{"type":"prompt","prompt":"Summarize."}' | banger vm session send devbox planner
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var payload []byte
|
||||
if message != "" {
|
||||
payload = []byte(message)
|
||||
if len(payload) > 0 && payload[len(payload)-1] != '\n' {
|
||||
payload = append(payload, '\n')
|
||||
}
|
||||
} else {
|
||||
payload, err = io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
}
|
||||
result, err := guestSessionSendFunc(cmd.Context(), layout.SocketPath, api.GuestSessionSendParams{
|
||||
VMIDOrName: args[0],
|
||||
SessionIDOrName: args[1],
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "sent %d bytes to session %s\n", result.BytesWritten, result.Session.Name)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&message, "message", "", "JSONL message to send; a trailing newline is appended if absent")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -1936,3 +1936,285 @@ func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir s
|
|||
c.streamCommand = remoteCommand
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVMSessionSendCommandExists(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("find vm: %v", err)
|
||||
}
|
||||
session, _, err := vm.Find([]string{"session"})
|
||||
if err != nil {
|
||||
t.Fatalf("find session: %v", err)
|
||||
}
|
||||
if _, _, err := session.Find([]string{"send"}); err != nil {
|
||||
t.Fatalf("find session send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendRejectsWrongArgCount(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "only-one-arg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "usage: banger vm session send") {
|
||||
t.Fatalf("Execute() error = %v, want send usage error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stubEnsureDaemonForSend(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "state"))
|
||||
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "run"))
|
||||
origPing := daemonPingFunc
|
||||
t.Cleanup(func() { daemonPingFunc = origPing })
|
||||
daemonPingFunc = func(context.Context, string) (api.PingResult, error) {
|
||||
return api.PingResult{Status: "ok", PID: os.Getpid()}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendWithMessageFlag(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
original := guestSessionSendFunc
|
||||
t.Cleanup(func() { guestSessionSendFunc = original })
|
||||
|
||||
var capturedParams api.GuestSessionSendParams
|
||||
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedParams = params
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{ID: "sess-id", Name: "planner"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner", "--message", `{"type":"abort"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
wantPayload := []byte(`{"type":"abort"}` + "\n")
|
||||
if string(capturedParams.Payload) != string(wantPayload) {
|
||||
t.Fatalf("payload = %q, want %q", capturedParams.Payload, wantPayload)
|
||||
}
|
||||
if capturedParams.VMIDOrName != "devbox" {
|
||||
t.Fatalf("VMIDOrName = %q, want %q", capturedParams.VMIDOrName, "devbox")
|
||||
}
|
||||
if capturedParams.SessionIDOrName != "planner" {
|
||||
t.Fatalf("SessionIDOrName = %q, want %q", capturedParams.SessionIDOrName, "planner")
|
||||
}
|
||||
if !strings.Contains(out.String(), "17") {
|
||||
t.Fatalf("output = %q, want bytes_written in output", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendMessageAlreadyHasNewline(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
original := guestSessionSendFunc
|
||||
t.Cleanup(func() { guestSessionSendFunc = original })
|
||||
|
||||
var capturedPayload []byte
|
||||
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedPayload = params.Payload
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{Name: "s"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "s", "--message", "{\"type\":\"abort\"}\n"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// Must not double-append newline.
|
||||
if capturedPayload[len(capturedPayload)-1] != '\n' {
|
||||
t.Fatalf("payload missing trailing newline: %q", capturedPayload)
|
||||
}
|
||||
if len(capturedPayload) > 0 && capturedPayload[len(capturedPayload)-2] == '\n' {
|
||||
t.Fatalf("payload has double trailing newline: %q", capturedPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendFromStdin(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
original := guestSessionSendFunc
|
||||
t.Cleanup(func() { guestSessionSendFunc = original })
|
||||
|
||||
var capturedPayload []byte
|
||||
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedPayload = params.Payload
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{Name: "planner"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
stdinPayload := `{"type":"steer","message":"Focus on src/"}` + "\n"
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetIn(strings.NewReader(stdinPayload))
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
if string(capturedPayload) != stdinPayload {
|
||||
t.Fatalf("payload = %q, want %q", capturedPayload, stdinPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportCommandExists(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("find vm: %v", err)
|
||||
}
|
||||
workspace, _, err := vm.Find([]string{"workspace"})
|
||||
if err != nil {
|
||||
t.Fatalf("find workspace: %v", err)
|
||||
}
|
||||
if _, _, err := workspace.Find([]string{"export"}); err != nil {
|
||||
t.Fatalf("find workspace export: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportRejectsMissingArg(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "usage: banger vm workspace export") {
|
||||
t.Fatalf("Execute() error = %v, want usage error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportWritesToStdout(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
origExport := vmWorkspaceExportFunc
|
||||
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
|
||||
|
||||
patch := []byte("diff --git a/main.go b/main.go\nindex 0000000..1111111 100644\n")
|
||||
vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
return api.WorkspaceExportResult{
|
||||
GuestPath: params.GuestPath,
|
||||
Patch: patch,
|
||||
ChangedFiles: []string{"main.go"},
|
||||
HasChanges: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out.Bytes(), patch) {
|
||||
t.Fatalf("stdout = %q, want %q", out.Bytes(), patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportWritesToFile(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
origExport := vmWorkspaceExportFunc
|
||||
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
|
||||
|
||||
patch := []byte("diff --git a/main.go b/main.go\n")
|
||||
vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
return api.WorkspaceExportResult{
|
||||
GuestPath: "/root/repo",
|
||||
Patch: patch,
|
||||
ChangedFiles: []string{"main.go"},
|
||||
HasChanges: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "worker.diff")
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--output", outFile})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, patch) {
|
||||
t.Fatalf("file content = %q, want %q", got, patch)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "worker.diff") {
|
||||
t.Fatalf("stderr = %q, want output path mentioned", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportNoChanges(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
origExport := vmWorkspaceExportFunc
|
||||
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
|
||||
|
||||
vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
return api.WorkspaceExportResult{
|
||||
GuestPath: "/root/repo",
|
||||
HasChanges: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when no changes", out.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no changes") {
|
||||
t.Fatalf("stderr = %q, want 'no changes'", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportGuestPathFlag(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
origExport := vmWorkspaceExportFunc
|
||||
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
|
||||
|
||||
var capturedParams api.WorkspaceExportParams
|
||||
vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
capturedParams = params
|
||||
return api.WorkspaceExportResult{HasChanges: false}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--guest-path", "/root/project"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if capturedParams.GuestPath != "/root/project" {
|
||||
t.Fatalf("GuestPath = %q, want /root/project", capturedParams.GuestPath)
|
||||
}
|
||||
if capturedParams.IDOrName != "devbox" {
|
||||
t.Fatalf("IDOrName = %q, want devbox", capturedParams.IDOrName)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue