Files
kandev/apps/backend/internal/agentctl/server/process/manager_command.go
T
2026-08-24 21:05:02 +00:00

127 lines
3.4 KiB
Go

package process
import (
"bytes"
"context"
"errors"
"io"
"time"
"github.com/kandev/kandev/internal/agentctl/types"
tools "github.com/kandev/kandev/internal/tools/installer"
)
const managedCommandOutputLimit = 1 << 20
// CommandEnvironment returns the task environment overrides applied to every
// command owned by this manager.
func (m *Manager) CommandEnvironment() (map[string]string, error) {
return mergeAgentEnvIntoShellConfigWithError(m.agentEnvSnapshot(), nil)
}
// Output runs a direct command as an instance-owned process and returns bounded
// stdout after its complete process tree is reaped. Stderr is drained but is
// not joined to stdout, so callers that parse stdout cannot mistake a warning
// path for command output.
func (m *Manager) Output(parent context.Context, spec tools.CommandSpec) ([]byte, error) {
stdout, _, err := m.runManagedCommand(parent, spec)
return stdout, err
}
// CombinedOutput runs a direct command as an instance-owned process and
// returns bounded stdout/stderr after its complete process tree is reaped.
func (m *Manager) CombinedOutput(parent context.Context, spec tools.CommandSpec) ([]byte, error) {
stdout, stderr, err := m.runManagedCommand(parent, spec)
return append(stdout, stderr...), err
}
func (m *Manager) runManagedCommand(parent context.Context, spec tools.CommandSpec) ([]byte, []byte, error) {
ctx, release, err := m.BeginOwnedOperation(parent)
if err != nil {
return nil, nil, err
}
defer release()
sessionID := m.cfg.SessionID
if sessionID == "" {
sessionID = m.cfg.InstanceID
}
if sessionID == "" {
sessionID = "managed-command"
}
if err := ctx.Err(); err != nil {
return nil, nil, err
}
proc, err := m.StartPipedProcess(PipedStartRequest{
SessionID: sessionID,
Kind: types.ProcessKindCustom,
ScriptName: "managed-command",
Command: spec.Path,
Args: spec.Args,
WorkingDir: spec.Dir,
Env: spec.Env,
PipeStderr: true,
})
if err != nil {
return nil, nil, err
}
_ = proc.Stdin.Close()
stdout := collectManagedCommandOutput(proc.Stdout)
stderr := collectManagedCommandOutput(proc.Stderr)
var stopErr error
canceled := false
finished := true
select {
case <-proc.Done:
case <-ctx.Done():
canceled = true
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
stopErr = m.StopProcess(stopCtx, StopProcessRequest{ProcessID: proc.ID})
select {
case <-proc.Done:
case <-stopCtx.Done():
finished = false
stopErr = errors.Join(stopErr, stopCtx.Err())
}
cancel()
}
if !finished {
return nil, nil, errors.Join(ctx.Err(), stopErr)
}
stdoutOutput := <-stdout
stderrOutput := <-stderr
waitErr := proc.Wait()
if canceled {
return stdoutOutput, stderrOutput, errors.Join(ctx.Err(), stopErr, waitErr)
}
return stdoutOutput, stderrOutput, waitErr
}
func collectManagedCommandOutput(reader io.ReadCloser) <-chan []byte {
result := make(chan []byte, 1)
go func() {
defer func() { _ = reader.Close() }()
var output bytes.Buffer
_, _ = io.Copy(&boundedOutputWriter{buffer: &output, remaining: managedCommandOutputLimit}, reader)
result <- output.Bytes()
}()
return result
}
type boundedOutputWriter struct {
buffer *bytes.Buffer
remaining int
}
func (w *boundedOutputWriter) Write(data []byte) (int, error) {
written := len(data)
if w.remaining > 0 {
keep := min(len(data), w.remaining)
_, _ = w.buffer.Write(data[:keep])
w.remaining -= keep
}
return written, nil
}