diff --git a/global_test.go b/global_test.go index 975d682..6d00eb1 100644 --- a/global_test.go +++ b/global_test.go @@ -11,7 +11,6 @@ import ( ) func TestGlobal_DefaultNotInitialized(t *testing.T) { - // Reset global state for this test old := defaultService.Swap(nil) defer func() { if old != nil { @@ -21,13 +20,13 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { assert.Nil(t, Default()) - _, err := Start(context.Background(), "echo", "test") - assert.ErrorIs(t, err, ErrServiceNotInitialized) + r := Start(context.Background(), "echo", "test") + assert.False(t, r.OK) - _, err = Run(context.Background(), "echo", "test") - assert.ErrorIs(t, err, ErrServiceNotInitialized) + r = Run(context.Background(), "echo", "test") + assert.False(t, r.OK) - _, err = Get("proc-1") + _, err := Get("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) assert.Nil(t, List()) @@ -36,11 +35,11 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { err = Kill("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) - _, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"}) - assert.ErrorIs(t, err, ErrServiceNotInitialized) + r = StartWithOptions(context.Background(), RunOptions{Command: "echo"}) + assert.False(t, r.OK) - _, err = RunWithOptions(context.Background(), RunOptions{Command: "echo"}) - assert.ErrorIs(t, err, ErrServiceNotInitialized) + r = RunWithOptions(context.Background(), RunOptions{Command: "echo"}) + assert.False(t, r.OK) } func newGlobalTestService(t *testing.T) *Service { @@ -62,7 +61,6 @@ func TestGlobal_SetDefault(t *testing.T) { }() svc := newGlobalTestService(t) - err := SetDefault(svc) require.NoError(t, err) assert.Equal(t, svc, Default()) @@ -83,7 +81,6 @@ func TestGlobal_ConcurrentDefault(t *testing.T) { }() svc := newGlobalTestService(t) - err := SetDefault(svc) require.NoError(t, err) @@ -146,7 +143,6 @@ func TestGlobal_ConcurrentOperations(t *testing.T) { }() svc := newGlobalTestService(t) - err := SetDefault(svc) require.NoError(t, err) @@ -158,10 +154,10 @@ func TestGlobal_ConcurrentOperations(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - proc, err := Start(context.Background(), "echo", "concurrent") - if err == nil { + r := Start(context.Background(), "echo", "concurrent") + if r.OK { procMu.Lock() - processes = append(processes, proc) + processes = append(processes, r.Value.(*Process)) procMu.Unlock() } }() @@ -201,7 +197,6 @@ func TestGlobal_ConcurrentOperations(t *testing.T) { func TestGlobal_StartWithOptions(t *testing.T) { svc, _ := newTestService(t) - old := defaultService.Swap(svc) defer func() { if old != nil { @@ -209,21 +204,20 @@ func TestGlobal_StartWithOptions(t *testing.T) { } }() - proc, err := StartWithOptions(context.Background(), RunOptions{ + r := StartWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"with", "options"}, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) <-proc.Done() - assert.Equal(t, 0, proc.ExitCode) assert.Contains(t, proc.Output(), "with options") } func TestGlobal_RunWithOptions(t *testing.T) { svc, _ := newTestService(t) - old := defaultService.Swap(svc) defer func() { if old != nil { @@ -231,17 +225,16 @@ func TestGlobal_RunWithOptions(t *testing.T) { } }() - output, err := RunWithOptions(context.Background(), RunOptions{ + r := RunWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"run", "options"}, }) - require.NoError(t, err) - assert.Contains(t, output, "run options") + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "run options") } func TestGlobal_Running(t *testing.T) { svc, _ := newTestService(t) - old := defaultService.Swap(svc) defer func() { if old != nil { @@ -252,8 +245,9 @@ func TestGlobal_Running(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, err := Start(ctx, "sleep", "60") - require.NoError(t, err) + r := Start(ctx, "sleep", "60") + require.True(t, r.OK) + proc := r.Value.(*Process) running := Running() assert.Len(t, running, 1) diff --git a/go.mod b/go.mod index 766e200..75dfafb 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,21 @@ module dappco.re/go/core/process go 1.26.0 require ( - dappco.re/go/core v0.4.7 - dappco.re/go/core/io v0.1.7 - dappco.re/go/core/log v0.0.4 - dappco.re/go/core/ws v0.2.4 + dappco.re/go/core v0.5.0 + dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 + dappco.re/go/core/ws v0.3.0 forge.lthn.ai/core/api v0.1.5 github.com/gin-gonic/gin v1.12.0 github.com/stretchr/testify v1.11.1 ) require ( + dappco.re/go/core/api v0.2.0 + dappco.re/go/core/i18n v0.2.0 + dappco.re/go/core/process v0.3.0 + dappco.re/go/core/scm v0.4.0 + dappco.re/go/core/store v0.2.0 forge.lthn.ai/core/go-io v0.1.5 // indirect forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/99designs/gqlgen v0.17.88 // indirect @@ -108,10 +113,3 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace ( - dappco.re/go/core => ../go - dappco.re/go/core/io => ../go-io - dappco.re/go/core/log => ../go-log - dappco.re/go/core/ws => ../go-ws -) diff --git a/process_global.go b/process_global.go index 041fe4d..dc30b2e 100644 --- a/process_global.go +++ b/process_global.go @@ -50,19 +50,19 @@ func Init(c *core.Core) error { // --- Global convenience functions --- // Start spawns a new process using the default service. -func Start(ctx context.Context, command string, args ...string) (*Process, error) { +func Start(ctx context.Context, command string, args ...string) core.Result { svc := Default() if svc == nil { - return nil, ErrServiceNotInitialized + return core.Result{OK: false} } return svc.Start(ctx, command, args...) } // Run executes a command and waits for completion using the default service. -func Run(ctx context.Context, command string, args ...string) (string, error) { +func Run(ctx context.Context, command string, args ...string) core.Result { svc := Default() if svc == nil { - return "", ErrServiceNotInitialized + return core.Result{Value: "", OK: false} } return svc.Run(ctx, command, args...) } @@ -95,19 +95,19 @@ func Kill(id string) error { } // StartWithOptions spawns a process with full configuration using the default service. -func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { +func StartWithOptions(ctx context.Context, opts RunOptions) core.Result { svc := Default() if svc == nil { - return nil, ErrServiceNotInitialized + return core.Result{OK: false} } return svc.StartWithOptions(ctx, opts) } // RunWithOptions executes a command with options and waits using the default service. -func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { +func RunWithOptions(ctx context.Context, opts RunOptions) core.Result { svc := Default() if svc == nil { - return "", ErrServiceNotInitialized + return core.Result{Value: "", OK: false} } return svc.RunWithOptions(ctx, opts) } diff --git a/process_test.go b/process_test.go index 9ef4016..1c95f83 100644 --- a/process_test.go +++ b/process_test.go @@ -13,8 +13,7 @@ import ( func TestProcess_Info(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.Start(context.Background(), "echo", "hello") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "echo", "hello") <-proc.Done() @@ -30,24 +29,15 @@ func TestProcess_Info(t *testing.T) { func TestProcess_Output(t *testing.T) { t.Run("captures stdout", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "hello world") - require.NoError(t, err) - + proc := startProc(t, svc, context.Background(), "echo", "hello world") <-proc.Done() - - output := proc.Output() - assert.Contains(t, output, "hello world") + assert.Contains(t, proc.Output(), "hello world") }) t.Run("OutputBytes returns copy", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) - + proc := startProc(t, svc, context.Background(), "echo", "test") <-proc.Done() - bytes := proc.OutputBytes() assert.NotNil(t, bytes) assert.Contains(t, string(bytes), "test") @@ -57,29 +47,21 @@ func TestProcess_Output(t *testing.T) { func TestProcess_IsRunning(t *testing.T) { t.Run("true while running", func(t *testing.T) { svc, _ := newTestService(t) - ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, err := svc.Start(ctx, "sleep", "10") - require.NoError(t, err) - + proc := startProc(t, svc, ctx, "sleep", "10") assert.True(t, proc.IsRunning()) cancel() <-proc.Done() - assert.False(t, proc.IsRunning()) }) t.Run("false after completion", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - + proc := startProc(t, svc, context.Background(), "echo", "done") <-proc.Done() - assert.False(t, proc.IsRunning()) }) } @@ -87,21 +69,15 @@ func TestProcess_IsRunning(t *testing.T) { func TestProcess_Wait(t *testing.T) { t.Run("returns nil on success", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "ok") - require.NoError(t, err) - - err = proc.Wait() + proc := startProc(t, svc, context.Background(), "echo", "ok") + err := proc.Wait() assert.NoError(t, err) }) t.Run("returns error on failure", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1") - require.NoError(t, err) - - err = proc.Wait() + proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1") + err := proc.Wait() assert.Error(t, err) }) } @@ -109,13 +85,10 @@ func TestProcess_Wait(t *testing.T) { func TestProcess_Done(t *testing.T) { t.Run("channel closes on completion", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "echo", "test") select { case <-proc.Done(): - // Success - channel closed case <-time.After(5 * time.Second): t.Fatal("Done channel should have closed") } @@ -125,21 +98,17 @@ func TestProcess_Done(t *testing.T) { func TestProcess_Kill(t *testing.T) { t.Run("terminates running process", func(t *testing.T) { svc, _ := newTestService(t) - ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) - + proc := startProc(t, svc, ctx, "sleep", "60") assert.True(t, proc.IsRunning()) - err = proc.Kill() + err := proc.Kill() assert.NoError(t, err) select { case <-proc.Done(): - // Good - process terminated case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } @@ -147,13 +116,9 @@ func TestProcess_Kill(t *testing.T) { t.Run("noop on completed process", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - + proc := startProc(t, svc, context.Background(), "echo", "done") <-proc.Done() - - err = proc.Kill() + err := proc.Kill() assert.NoError(t, err) }) } @@ -161,31 +126,21 @@ func TestProcess_Kill(t *testing.T) { func TestProcess_SendInput(t *testing.T) { t.Run("writes to stdin", func(t *testing.T) { svc, _ := newTestService(t) + proc := startProc(t, svc, context.Background(), "cat") - // Use cat to echo back stdin - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - err = proc.SendInput("hello\n") + err := proc.SendInput("hello\n") assert.NoError(t, err) - err = proc.CloseStdin() assert.NoError(t, err) - <-proc.Done() - assert.Contains(t, proc.Output(), "hello") }) t.Run("error on completed process", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - + proc := startProc(t, svc, context.Background(), "echo", "done") <-proc.Done() - - err = proc.SendInput("test") + err := proc.SendInput("test") assert.ErrorIs(t, err, ErrProcessNotRunning) }) } @@ -193,19 +148,15 @@ func TestProcess_SendInput(t *testing.T) { func TestProcess_Signal(t *testing.T) { t.Run("sends signal to running process", func(t *testing.T) { svc, _ := newTestService(t) - ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) - - err = proc.Signal(os.Interrupt) + proc := startProc(t, svc, ctx, "sleep", "60") + err := proc.Signal(os.Interrupt) assert.NoError(t, err) select { case <-proc.Done(): - // Process terminated by signal case <-time.After(2 * time.Second): t.Fatal("process should have been terminated by signal") } @@ -213,12 +164,9 @@ func TestProcess_Signal(t *testing.T) { t.Run("error on completed process", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "echo", "done") <-proc.Done() - - err = proc.Signal(os.Interrupt) + err := proc.Signal(os.Interrupt) assert.ErrorIs(t, err, ErrProcessNotRunning) }) } @@ -226,17 +174,12 @@ func TestProcess_Signal(t *testing.T) { func TestProcess_CloseStdin(t *testing.T) { t.Run("closes stdin pipe", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - err = proc.CloseStdin() + proc := startProc(t, svc, context.Background(), "cat") + err := proc.CloseStdin() assert.NoError(t, err) - // Process should exit now that stdin is closed select { case <-proc.Done(): - // Good case <-time.After(2 * time.Second): t.Fatal("cat should exit when stdin is closed") } @@ -244,17 +187,10 @@ func TestProcess_CloseStdin(t *testing.T) { t.Run("double close is safe", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - // First close - err = proc.CloseStdin() + proc := startProc(t, svc, context.Background(), "cat") + err := proc.CloseStdin() assert.NoError(t, err) - <-proc.Done() - - // Second close should be safe (stdin already nil) err = proc.CloseStdin() assert.NoError(t, err) }) @@ -263,34 +199,31 @@ func TestProcess_CloseStdin(t *testing.T) { func TestProcess_Timeout(t *testing.T) { t.Run("kills process after timeout", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, Timeout: 200 * time.Millisecond, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) select { case <-proc.Done(): - // Good — process was killed by timeout case <-time.After(5 * time.Second): t.Fatal("process should have been killed by timeout") } - assert.False(t, proc.IsRunning()) }) t.Run("no timeout when zero", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"fast"}, Timeout: 0, }) - require.NoError(t, err) - + require.True(t, r.OK) + proc := r.Value.(*Process) <-proc.Done() assert.Equal(t, 0, proc.ExitCode) }) @@ -299,23 +232,20 @@ func TestProcess_Timeout(t *testing.T) { func TestProcess_Shutdown(t *testing.T) { t.Run("graceful with grace period", func(t *testing.T) { svc, _ := newTestService(t) - - // Use a process that traps SIGTERM - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, GracePeriod: 100 * time.Millisecond, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) assert.True(t, proc.IsRunning()) - - err = proc.Shutdown() + err := proc.Shutdown() assert.NoError(t, err) select { case <-proc.Done(): - // Good case <-time.After(5 * time.Second): t.Fatal("shutdown should have completed") } @@ -323,19 +253,18 @@ func TestProcess_Shutdown(t *testing.T) { t.Run("immediate kill without grace period", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) - err = proc.Shutdown() + err := proc.Shutdown() assert.NoError(t, err) select { case <-proc.Done(): - // Good case <-time.After(2 * time.Second): t.Fatal("kill should be immediate") } @@ -345,25 +274,21 @@ func TestProcess_Shutdown(t *testing.T) { func TestProcess_KillGroup(t *testing.T) { t.Run("kills child processes", func(t *testing.T) { svc, _ := newTestService(t) - - // Spawn a parent that spawns a child — KillGroup should kill both - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "sleep 60 & wait"}, Detach: true, KillGroup: true, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) - // Give child time to spawn time.Sleep(100 * time.Millisecond) - - err = proc.Kill() + err := proc.Kill() assert.NoError(t, err) select { case <-proc.Done(): - // Good — whole group killed case <-time.After(5 * time.Second): t.Fatal("process group should have been killed") } @@ -373,18 +298,17 @@ func TestProcess_KillGroup(t *testing.T) { func TestProcess_TimeoutWithGrace(t *testing.T) { t.Run("timeout triggers graceful shutdown", func(t *testing.T) { svc, _ := newTestService(t) - - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, Timeout: 200 * time.Millisecond, GracePeriod: 100 * time.Millisecond, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) select { case <-proc.Done(): - // Good — timeout + grace triggered case <-time.After(5 * time.Second): t.Fatal("process should have been killed by timeout") } diff --git a/runner.go b/runner.go index 017ec38..80fec17 100644 --- a/runner.go +++ b/runner.go @@ -193,21 +193,21 @@ func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { start := time.Now() - proc, err := r.service.StartWithOptions(ctx, RunOptions{ + sr := r.service.StartWithOptions(ctx, RunOptions{ Command: spec.Command, Args: spec.Args, Dir: spec.Dir, Env: spec.Env, }) - if err != nil { + if !sr.OK { return RunResult{ Name: spec.Name, Spec: spec, Duration: time.Since(start), - Error: err, } } + proc := sr.Value.(*Process) <-proc.Done() return RunResult{ diff --git a/service.go b/service.go index 5fd1339..bc97ba3 100644 --- a/service.go +++ b/service.go @@ -3,8 +3,6 @@ package process import ( "bufio" "context" - "errors" - "fmt" "io" "os/exec" "sync" @@ -43,7 +41,22 @@ type Options struct { BufferSize int } +// Register is the WithService factory for go-process. +// Registers the process service with Core — OnStartup registers named Actions +// (process.run, process.start, process.kill, process.list, process.get). +// +// core.New(core.WithService(process.Register)) +func Register(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, Options{BufferSize: DefaultBufferSize}), + processes: make(map[string]*Process), + bufSize: DefaultBufferSize, + } + return core.Result{Value: svc, OK: true} +} + // NewService creates a process service factory for Core registration. +// Deprecated: Use Register with core.WithService(process.Register) instead. // // core, _ := core.New( // core.WithName("process", process.NewService(process.Options{})), @@ -62,14 +75,23 @@ func NewService(opts Options) func(*core.Core) (any, error) { } } -// OnStartup implements core.Startable. -func (s *Service) OnStartup(ctx context.Context) error { - return nil +// OnStartup implements core.Startable — registers named Actions. +// +// c.Process().Run(ctx, "git", "log") // → calls process.run Action +func (s *Service) OnStartup(ctx context.Context) core.Result { + c := s.Core() + c.Action("process.run", s.handleRun) + c.Action("process.start", s.handleStart) + c.Action("process.kill", s.handleKill) + c.Action("process.list", s.handleList) + c.Action("process.get", s.handleGet) + return core.Result{OK: true} } -// OnShutdown implements core.Stoppable. -// Gracefully shuts down all running processes (SIGTERM → SIGKILL). -func (s *Service) OnShutdown(ctx context.Context) error { +// OnShutdown implements core.Stoppable — kills all managed processes. +// +// c.ServiceShutdown(ctx) // calls OnShutdown on all Stoppable services +func (s *Service) OnShutdown(ctx context.Context) core.Result { s.mu.RLock() procs := make([]*Process, 0, len(s.processes)) for _, p := range s.processes { @@ -83,11 +105,14 @@ func (s *Service) OnShutdown(ctx context.Context) error { _ = p.Shutdown() } - return nil + return core.Result{OK: true} } // Start spawns a new process with the given command and args. -func (s *Service) Start(ctx context.Context, command string, args ...string) (*Process, error) { +// +// r := svc.Start(ctx, "echo", "hello") +// if r.OK { proc := r.Value.(*Process) } +func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result { return s.StartWithOptions(ctx, RunOptions{ Command: command, Args: args, @@ -95,8 +120,11 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P } // StartWithOptions spawns a process with full configuration. -func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { - id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) +// +// r := svc.StartWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test", "./..."}}) +// if r.OK { proc := r.Value.(*Process) } +func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result { + id := core.ID() // Detached processes use Background context so they survive parent death parentCtx := ctx @@ -122,19 +150,19 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce stdout, err := cmd.StdoutPipe() if err != nil { cancel() - return nil, coreerr.E("Service.StartWithOptions", "failed to create stdout pipe", err) + return core.Result{OK: false} } stderr, err := cmd.StderrPipe() if err != nil { cancel() - return nil, coreerr.E("Service.StartWithOptions", "failed to create stderr pipe", err) + return core.Result{OK: false} } stdin, err := cmd.StdinPipe() if err != nil { cancel() - return nil, coreerr.E("Service.StartWithOptions", "failed to create stdin pipe", err) + return core.Result{OK: false} } // Create output buffer (enabled by default) @@ -164,7 +192,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Start the process if err := cmd.Start(); err != nil { cancel() - return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) + return core.Result{OK: false} } // Store process @@ -177,7 +205,6 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce go func() { select { case <-proc.done: - // Process exited before timeout case <-time.After(opts.Timeout): proc.Shutdown() } @@ -185,7 +212,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce } // Broadcast start - _ = s.Core().ACTION(ActionProcessStarted{ + s.Core().ACTION(ActionProcessStarted{ ID: id, Command: opts.Command, Args: opts.Args, @@ -207,10 +234,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Wait for process completion go func() { - // Wait for output streaming to complete wg.Wait() - - // Wait for process exit err := cmd.Wait() duration := time.Since(proc.StartedAt) @@ -219,7 +243,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce proc.Duration = duration if err != nil { var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if core.As(err, &exitErr) { proc.ExitCode = exitErr.ExitCode() proc.Status = StatusExited } else { @@ -235,20 +259,15 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce close(proc.done) - // Broadcast exit - var exitErr error - if status == StatusFailed { - exitErr = err - } - _ = s.Core().ACTION(ActionProcessExited{ + s.Core().ACTION(ActionProcessExited{ ID: id, ExitCode: exitCode, Duration: duration, - Error: exitErr, }) + _ = status }() - return proc, nil + return core.Result{Value: proc, OK: true} } // streamOutput reads from a pipe and broadcasts lines via ACTION. @@ -371,34 +390,146 @@ func (s *Service) Output(id string) (string, error) { } // Run executes a command and waits for completion. -// Returns the combined output and any error. -func (s *Service) Run(ctx context.Context, command string, args ...string) (string, error) { - proc, err := s.Start(ctx, command, args...) - if err != nil { - return "", err +// Value is always the output string. OK is true if exit code is 0. +// +// r := svc.Run(ctx, "go", "test", "./...") +// output := r.Value.(string) +func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result { + r := s.Start(ctx, command, args...) + if !r.OK { + return core.Result{Value: "", OK: false} } + proc := r.Value.(*Process) <-proc.Done() - output := proc.Output() - if proc.ExitCode != 0 { - return output, coreerr.E("Service.Run", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) - } - return output, nil + return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0} } // RunWithOptions executes a command with options and waits for completion. -func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { - proc, err := s.StartWithOptions(ctx, opts) - if err != nil { - return "", err +// Value is always the output string. OK is true if exit code is 0. +// +// r := svc.RunWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test"}}) +// output := r.Value.(string) +func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result { + r := s.StartWithOptions(ctx, opts) + if !r.OK { + return core.Result{Value: "", OK: false} } + proc := r.Value.(*Process) <-proc.Done() - output := proc.Output() - if proc.ExitCode != 0 { - return output, coreerr.E("Service.RunWithOptions", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) - } - return output, nil + return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0} +} + +// --- Named Action Handlers --- +// These are registered during OnStartup and called via c.Process() sugar. +// c.Process().Run(ctx, "git", "log") → c.Action("process.run").Run(ctx, opts) + +// handleRun executes a command synchronously and returns the output. +// +// r := c.Action("process.run").Run(ctx, core.NewOptions( +// core.Option{Key: "command", Value: "git"}, +// core.Option{Key: "args", Value: []string{"log"}}, +// core.Option{Key: "dir", Value: "/repo"}, +// )) +func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result { + command := opts.String("command") + if command == "" { + return core.Result{Value: coreerr.E("process.run", "command is required", nil), OK: false} + } + + runOpts := RunOptions{ + Command: command, + Dir: opts.String("dir"), + } + if r := opts.Get("args"); r.OK { + if args, ok := r.Value.([]string); ok { + runOpts.Args = args + } + } + if r := opts.Get("env"); r.OK { + if env, ok := r.Value.([]string); ok { + runOpts.Env = env + } + } + + return s.RunWithOptions(ctx, runOpts) +} + +// handleStart spawns a detached/background process and returns the process ID. +// +// r := c.Action("process.start").Run(ctx, core.NewOptions( +// core.Option{Key: "command", Value: "docker"}, +// core.Option{Key: "args", Value: []string{"run", "nginx"}}, +// )) +// id := r.Value.(string) +func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result { + command := opts.String("command") + if command == "" { + return core.Result{Value: coreerr.E("process.start", "command is required", nil), OK: false} + } + + runOpts := RunOptions{ + Command: command, + Dir: opts.String("dir"), + } + if r := opts.Get("args"); r.OK { + if args, ok := r.Value.([]string); ok { + runOpts.Args = args + } + } + + r := s.StartWithOptions(ctx, runOpts) + if !r.OK { + return r + } + return core.Result{Value: r.Value.(*Process).ID, OK: true} +} + +// handleKill terminates a process by ID. +// +// r := c.Action("process.kill").Run(ctx, core.NewOptions( +// core.Option{Key: "id", Value: "id-42-a3f2b1"}, +// )) +func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result { + id := opts.String("id") + if id != "" { + if err := s.Kill(id); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + } + return core.Result{Value: coreerr.E("process.kill", "id is required", nil), OK: false} +} + +// handleList returns the IDs of all managed processes. +// +// r := c.Action("process.list").Run(ctx, core.NewOptions()) +// ids := r.Value.([]string) +func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result { + s.mu.RLock() + defer s.mu.RUnlock() + + ids := make([]string, 0, len(s.processes)) + for id := range s.processes { + ids = append(ids, id) + } + return core.Result{Value: ids, OK: true} +} + +// handleGet returns process info by ID. +// +// r := c.Action("process.get").Run(ctx, core.NewOptions( +// core.Option{Key: "id", Value: "id-42-a3f2b1"}, +// )) +// info := r.Value.(process.Info) +func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result { + id := opts.String("id") + proc, err := s.Get(id) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: proc.Info(), OK: true} } diff --git a/service_test.go b/service_test.go index 868b7a3..cc9b49d 100644 --- a/service_test.go +++ b/service_test.go @@ -24,19 +24,23 @@ func newTestService(t *testing.T) (*Service, *framework.Core) { return svc, c } +func startProc(t *testing.T, svc *Service, ctx context.Context, command string, args ...string) *Process { + t.Helper() + r := svc.Start(ctx, command, args...) + require.True(t, r.OK) + return r.Value.(*Process) +} + func TestService_Start(t *testing.T) { t.Run("echo command", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.Start(context.Background(), "echo", "hello") - require.NoError(t, err) - require.NotNil(t, proc) + proc := startProc(t, svc, context.Background(), "echo", "hello") assert.NotEmpty(t, proc.ID) assert.Equal(t, "echo", proc.Command) assert.Equal(t, []string{"hello"}, proc.Args) - // Wait for completion <-proc.Done() assert.Equal(t, StatusExited, proc.Status) @@ -47,8 +51,7 @@ func TestService_Start(t *testing.T) { t.Run("failing command", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.Start(context.Background(), "sh", "-c", "exit 42") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 42") <-proc.Done() @@ -59,22 +62,22 @@ func TestService_Start(t *testing.T) { t.Run("non-existent command", func(t *testing.T) { svc, _ := newTestService(t) - _, err := svc.Start(context.Background(), "nonexistent_command_xyz") - assert.Error(t, err) + r := svc.Start(context.Background(), "nonexistent_command_xyz") + assert.False(t, r.OK) }) t.Run("with working directory", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "pwd", Dir: "/tmp", }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) <-proc.Done() - // On macOS /tmp is a symlink to /private/tmp output := strings.TrimSpace(proc.Output()) assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output) }) @@ -83,15 +86,12 @@ func TestService_Start(t *testing.T) { svc, _ := newTestService(t) ctx, cancel := context.WithCancel(context.Background()) - proc, err := svc.Start(ctx, "sleep", "10") - require.NoError(t, err) + proc := startProc(t, svc, ctx, "sleep", "10") - // Cancel immediately cancel() select { case <-proc.Done(): - // Good - process was killed case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } @@ -100,12 +100,13 @@ func TestService_Start(t *testing.T) { t.Run("disable capture", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"no-capture"}, DisableCapture: true, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) <-proc.Done() assert.Equal(t, StatusExited, proc.Status) @@ -115,12 +116,13 @@ func TestService_Start(t *testing.T) { t.Run("with environment variables", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + r := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "echo $MY_TEST_VAR"}, Env: []string{"MY_TEST_VAR=hello_env"}, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) <-proc.Done() assert.Contains(t, proc.Output(), "hello_env") @@ -131,17 +133,16 @@ func TestService_Start(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - proc, err := svc.StartWithOptions(ctx, RunOptions{ + r := svc.StartWithOptions(ctx, RunOptions{ Command: "echo", Args: []string{"detached"}, Detach: true, }) - require.NoError(t, err) + require.True(t, r.OK) + proc := r.Value.(*Process) - // Cancel the parent context cancel() - // Detached process should still complete normally select { case <-proc.Done(): assert.Equal(t, StatusExited, proc.Status) @@ -156,17 +157,16 @@ func TestService_Run(t *testing.T) { t.Run("returns output", func(t *testing.T) { svc, _ := newTestService(t) - output, err := svc.Run(context.Background(), "echo", "hello world") - require.NoError(t, err) - assert.Contains(t, output, "hello world") + r := svc.Run(context.Background(), "echo", "hello world") + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "hello world") }) - t.Run("returns error on failure", func(t *testing.T) { + t.Run("returns !OK on failure", func(t *testing.T) { svc, _ := newTestService(t) - _, err := svc.Run(context.Background(), "sh", "-c", "exit 1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "exited with code 1") + r := svc.Run(context.Background(), "sh", "-c", "exit 1") + assert.False(t, r.OK) }) } @@ -174,7 +174,6 @@ func TestService_Actions(t *testing.T) { t.Run("broadcasts events", func(t *testing.T) { c := framework.New() - // Register process service on Core factory := NewService(Options{}) raw, err := factory(c) require.NoError(t, err) @@ -198,12 +197,10 @@ func TestService_Actions(t *testing.T) { } return framework.Result{OK: true} }) - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "echo", "test") <-proc.Done() - // Give time for events to propagate time.Sleep(10 * time.Millisecond) mu.Lock() @@ -232,8 +229,8 @@ func TestService_List(t *testing.T) { t.Run("tracks processes", func(t *testing.T) { svc, _ := newTestService(t) - proc1, _ := svc.Start(context.Background(), "echo", "1") - proc2, _ := svc.Start(context.Background(), "echo", "2") + proc1 := startProc(t, svc, context.Background(), "echo", "1") + proc2 := startProc(t, svc, context.Background(), "echo", "2") <-proc1.Done() <-proc2.Done() @@ -245,7 +242,7 @@ func TestService_List(t *testing.T) { t.Run("get by id", func(t *testing.T) { svc, _ := newTestService(t) - proc, _ := svc.Start(context.Background(), "echo", "test") + proc := startProc(t, svc, context.Background(), "echo", "test") <-proc.Done() got, err := svc.Get(proc.ID) @@ -265,7 +262,7 @@ func TestService_Remove(t *testing.T) { t.Run("removes completed process", func(t *testing.T) { svc, _ := newTestService(t) - proc, _ := svc.Start(context.Background(), "echo", "test") + proc := startProc(t, svc, context.Background(), "echo", "test") <-proc.Done() err := svc.Remove(proc.ID) @@ -281,7 +278,7 @@ func TestService_Remove(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, _ := svc.Start(ctx, "sleep", "10") + proc := startProc(t, svc, ctx, "sleep", "10") err := svc.Remove(proc.ID) assert.Error(t, err) @@ -295,8 +292,8 @@ func TestService_Clear(t *testing.T) { t.Run("clears completed processes", func(t *testing.T) { svc, _ := newTestService(t) - proc1, _ := svc.Start(context.Background(), "echo", "1") - proc2, _ := svc.Start(context.Background(), "echo", "2") + proc1 := startProc(t, svc, context.Background(), "echo", "1") + proc2 := startProc(t, svc, context.Background(), "echo", "2") <-proc1.Done() <-proc2.Done() @@ -316,15 +313,13 @@ func TestService_Kill(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) + proc := startProc(t, svc, ctx, "sleep", "60") - err = svc.Kill(proc.ID) + err := svc.Kill(proc.ID) assert.NoError(t, err) select { case <-proc.Done(): - // Process killed successfully case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } @@ -342,8 +337,7 @@ func TestService_Output(t *testing.T) { t.Run("returns captured output", func(t *testing.T) { svc, _ := newTestService(t) - proc, err := svc.Start(context.Background(), "echo", "captured") - require.NoError(t, err) + proc := startProc(t, svc, context.Background(), "echo", "captured") <-proc.Done() output, err := svc.Output(proc.ID) @@ -366,16 +360,14 @@ func TestService_OnShutdown(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc1, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) - proc2, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) + proc1 := startProc(t, svc, ctx, "sleep", "60") + proc2 := startProc(t, svc, ctx, "sleep", "60") assert.True(t, proc1.IsRunning()) assert.True(t, proc2.IsRunning()) - err = svc.OnShutdown(context.Background()) - assert.NoError(t, err) + r := svc.OnShutdown(context.Background()) + assert.True(t, r.OK) select { case <-proc1.Done(): @@ -391,10 +383,16 @@ func TestService_OnShutdown(t *testing.T) { } func TestService_OnStartup(t *testing.T) { - t.Run("returns nil", func(t *testing.T) { - svc, _ := newTestService(t) - err := svc.OnStartup(context.Background()) - assert.NoError(t, err) + t.Run("registers named actions", func(t *testing.T) { + svc, c := newTestService(t) + r := svc.OnStartup(context.Background()) + assert.True(t, r.OK) + + assert.True(t, c.Action("process.run").Exists()) + assert.True(t, c.Action("process.start").Exists()) + assert.True(t, c.Action("process.kill").Exists()) + assert.True(t, c.Action("process.list").Exists()) + assert.True(t, c.Action("process.get").Exists()) }) } @@ -402,23 +400,22 @@ func TestService_RunWithOptions(t *testing.T) { t.Run("returns output on success", func(t *testing.T) { svc, _ := newTestService(t) - output, err := svc.RunWithOptions(context.Background(), RunOptions{ + r := svc.RunWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"opts-test"}, }) - require.NoError(t, err) - assert.Contains(t, output, "opts-test") + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "opts-test") }) - t.Run("returns error on failure", func(t *testing.T) { + t.Run("returns !OK on failure", func(t *testing.T) { svc, _ := newTestService(t) - _, err := svc.RunWithOptions(context.Background(), RunOptions{ + r := svc.RunWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "exit 2"}, }) - assert.Error(t, err) - assert.Contains(t, err.Error(), "exited with code 2") + assert.False(t, r.OK) }) } @@ -429,11 +426,8 @@ func TestService_Running(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc1, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) - - proc2, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) + proc1 := startProc(t, svc, ctx, "sleep", "60") + proc2 := startProc(t, svc, context.Background(), "echo", "done") <-proc2.Done() running := svc.Running()