From 61867e56bb999adbb735bfeef96b8aee028cadbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 01:10:00 +0000 Subject: [PATCH 01/97] chore: update dependencies to dappco.re tagged versions Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 18 +++++------------- go.sum | 14 ++++++++++---- pkg/api/provider.go | 4 ++-- pkg/api/provider_test.go | 2 +- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 766e200..acae565 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,16 @@ 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 - forge.lthn.ai/core/api v0.1.5 + dappco.re/go/core v0.5.0 + dappco.re/go/core/api v0.2.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 github.com/gin-gonic/gin v1.12.0 github.com/stretchr/testify v1.11.1 ) require ( - 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 github.com/KyleBanks/depth v1.2.1 // indirect @@ -108,10 +107,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/go.sum b/go.sum index dab2b48..b1d28bf 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ -forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o= -forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII= -forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= -forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= +dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= +dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0= +dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ= +dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 622cfa3..4397912 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -10,8 +10,8 @@ import ( "strconv" "syscall" - "forge.lthn.ai/core/api" - "forge.lthn.ai/core/api/pkg/provider" + "dappco.re/go/core/api" + "dappco.re/go/core/api/pkg/provider" process "dappco.re/go/core/process" "dappco.re/go/core/ws" "github.com/gin-gonic/gin" diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index a068943..eac3592 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - goapi "forge.lthn.ai/core/api" + goapi "dappco.re/go/core/api" process "dappco.re/go/core/process" processapi "dappco.re/go/core/process/pkg/api" "github.com/gin-gonic/gin" From 0546b42ce3dabf286437e69221a9ee5bfd754931 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:17:11 +0000 Subject: [PATCH 02/97] feat(process): add Core task for run execution --- actions.go | 9 +++++++++ service.go | 33 +++++++++++++++++++++++++++++---- service_test.go | 15 ++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/actions.go b/actions.go index 7f33cf8..a09e520 100644 --- a/actions.go +++ b/actions.go @@ -4,6 +4,15 @@ import "time" // --- ACTION messages (broadcast via Core.ACTION) --- +// TaskProcessRun requests synchronous command execution through Core.PERFORM. +// The handler returns the combined command output on success. +type TaskProcessRun struct { + Command string + Args []string + Dir string + Env []string +} + // ActionProcessStarted is broadcast when a process begins execution. type ActionProcessStarted struct { ID string diff --git a/service.go b/service.go index 5fd1339..bc5b52c 100644 --- a/service.go +++ b/service.go @@ -30,10 +30,11 @@ var ( type Service struct { *core.ServiceRuntime[Options] - processes map[string]*Process - mu sync.RWMutex - bufSize int - idCounter atomic.Uint64 + processes map[string]*Process + mu sync.RWMutex + bufSize int + idCounter atomic.Uint64 + registrations sync.Once } // Options configures the process service. @@ -64,6 +65,11 @@ func NewService(opts Options) func(*core.Core) (any, error) { // OnStartup implements core.Startable. func (s *Service) OnStartup(ctx context.Context) error { + s.registrations.Do(func() { + if s.Core() != nil { + s.Core().RegisterTask(s.handleTask) + } + }) return nil } @@ -402,3 +408,22 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, } return output, nil } + +// handleTask dispatches Core.PERFORM messages for the process service. +func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { + switch m := task.(type) { + case TaskProcessRun: + output, err := s.RunWithOptions(c.Context(), RunOptions{ + Command: m.Command, + Args: m.Args, + Dir: m.Dir, + Env: m.Env, + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} + default: + return core.Result{} + } +} diff --git a/service_test.go b/service_test.go index 868b7a3..db43004 100644 --- a/service_test.go +++ b/service_test.go @@ -391,10 +391,19 @@ func TestService_OnShutdown(t *testing.T) { } func TestService_OnStartup(t *testing.T) { - t.Run("returns nil", func(t *testing.T) { - svc, _ := newTestService(t) + t.Run("registers process.run task", func(t *testing.T) { + svc, c := newTestService(t) + err := svc.OnStartup(context.Background()) - assert.NoError(t, err) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessRun{ + Command: "echo", + Args: []string{"action-run"}, + }) + + require.True(t, result.OK) + assert.Contains(t, result.Value.(string), "action-run") }) } From b6530cf85d09be42eb21d4dce545f66afa818fb4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:20:28 +0000 Subject: [PATCH 03/97] feat(process): track killed process lifecycle --- process_test.go | 11 +++++++ service.go | 79 ++++++++++++++++++++++++++++--------------------- service_test.go | 44 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/process_test.go b/process_test.go index 9ef4016..302bc9e 100644 --- a/process_test.go +++ b/process_test.go @@ -143,6 +143,8 @@ func TestProcess_Kill(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("noop on completed process", func(t *testing.T) { @@ -209,6 +211,8 @@ func TestProcess_Signal(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("process should have been terminated by signal") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("error on completed process", func(t *testing.T) { @@ -279,6 +283,7 @@ func TestProcess_Timeout(t *testing.T) { } assert.False(t, proc.IsRunning()) + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("no timeout when zero", func(t *testing.T) { @@ -319,6 +324,8 @@ func TestProcess_Shutdown(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("shutdown should have completed") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("immediate kill without grace period", func(t *testing.T) { @@ -367,6 +374,8 @@ func TestProcess_KillGroup(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("process group should have been killed") } + + assert.Equal(t, StatusKilled, proc.Status) }) } @@ -388,5 +397,7 @@ func TestProcess_TimeoutWithGrace(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("process should have been killed by timeout") } + + assert.Equal(t, StatusKilled, proc.Status) }) } diff --git a/service.go b/service.go index bc5b52c..f4fd18f 100644 --- a/service.go +++ b/service.go @@ -220,38 +220,31 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce err := cmd.Wait() duration := time.Since(proc.StartedAt) + status, exitCode, exitErr, signalName := classifyProcessExit(err) proc.mu.Lock() proc.Duration = duration - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - proc.ExitCode = exitErr.ExitCode() - proc.Status = StatusExited - } else { - proc.Status = StatusFailed - } - } else { - proc.ExitCode = 0 - proc.Status = StatusExited - } - status := proc.Status - exitCode := proc.ExitCode + proc.ExitCode = exitCode + proc.Status = status proc.mu.Unlock() close(proc.done) - // Broadcast exit - var exitErr error - if status == StatusFailed { - exitErr = err + // Broadcast lifecycle completion. + switch status { + case StatusKilled: + _ = s.Core().ACTION(ActionProcessKilled{ + ID: id, + Signal: signalName, + }) + default: + _ = s.Core().ACTION(ActionProcessExited{ + ID: id, + ExitCode: exitCode, + Duration: duration, + Error: exitErr, + }) } - _ = s.Core().ACTION(ActionProcessExited{ - ID: id, - ExitCode: exitCode, - Duration: duration, - Error: exitErr, - }) }() return proc, nil @@ -325,16 +318,7 @@ func (s *Service) Kill(id string) error { return err } - if err := proc.Kill(); err != nil { - return err - } - - _ = s.Core().ACTION(ActionProcessKilled{ - ID: id, - Signal: "SIGKILL", - }) - - return nil + return proc.Kill() } // Remove removes a completed process from the list. @@ -387,6 +371,9 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri <-proc.Done() output := proc.Output() + if proc.Status == StatusKilled { + return output, coreerr.E("Service.Run", "process was killed", nil) + } if proc.ExitCode != 0 { return output, coreerr.E("Service.Run", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) } @@ -403,6 +390,9 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, <-proc.Done() output := proc.Output() + if proc.Status == StatusKilled { + return output, coreerr.E("Service.RunWithOptions", "process was killed", nil) + } if proc.ExitCode != 0 { return output, coreerr.E("Service.RunWithOptions", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) } @@ -427,3 +417,24 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { return core.Result{} } } + +// classifyProcessExit maps a command completion error to lifecycle state. +func classifyProcessExit(err error) (Status, int, error, string) { + if err == nil { + return StatusExited, 0, nil, "" + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + signalName := ws.Signal().String() + if signalName == "" { + signalName = "signal" + } + return StatusKilled, -1, nil, signalName + } + return StatusExited, exitErr.ExitCode(), nil, "" + } + + return StatusFailed, 0, err, "" +} diff --git a/service_test.go b/service_test.go index db43004..7d3a64e 100644 --- a/service_test.go +++ b/service_test.go @@ -226,6 +226,48 @@ func TestService_Actions(t *testing.T) { assert.Len(t, exited, 1) assert.Equal(t, 0, exited[0].ExitCode) }) + + t.Run("broadcasts killed events", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) + + var killed []ActionProcessKilled + var mu sync.Mutex + + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + mu.Lock() + defer mu.Unlock() + if m, ok := msg.(ActionProcessKilled); ok { + killed = append(killed, m) + } + return framework.Result{OK: true} + }) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = svc.Kill(proc.ID) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed") + } + + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + assert.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) + assert.Equal(t, StatusKilled, proc.Status) + }) } func TestService_List(t *testing.T) { @@ -328,6 +370,8 @@ func TestService_Kill(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("error on unknown id", func(t *testing.T) { From 62e7bd7814757a46b577e591d4a5c259c422aa5e Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:22:43 +0000 Subject: [PATCH 04/97] Fix runner deadlock handling --- runner.go | 12 +++++++----- runner_test.go | 28 ++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/runner.go b/runner.go index 017ec38..ed1de10 100644 --- a/runner.go +++ b/runner.go @@ -99,13 +99,15 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } if len(ready) == 0 && len(remaining) > 0 { - // Deadlock — circular dependency or missing specs. Mark as failed, not skipped. + // Deadlock — circular dependency or missing specs. Report them as skipped + // with an error so callers can distinguish dependency graph issues from + // command execution failures. for name := range remaining { results = append(results, RunResult{ - Name: name, - Spec: remaining[name], - ExitCode: 1, - Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), + Name: name, + Spec: remaining[name], + Skipped: true, + Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), }) } break diff --git a/runner_test.go b/runner_test.go index fd96f79..cc4afd0 100644 --- a/runner_test.go +++ b/runner_test.go @@ -151,7 +151,7 @@ func TestRunner_RunAll(t *testing.T) { } func TestRunner_RunAll_CircularDeps(t *testing.T) { - t.Run("circular dependency counts as failed", func(t *testing.T) { + t.Run("circular dependency is skipped with error", func(t *testing.T) { runner := newTestRunner(t) result, err := runner.RunAll(context.Background(), []RunSpec{ @@ -160,9 +160,29 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) - assert.Equal(t, 2, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.True(t, result.Success()) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Error(t, res.Error) + } + }) + + t.Run("missing dependency is skipped with error", func(t *testing.T) { + runner := newTestRunner(t) + + result, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "a", Command: "echo", Args: []string{"a"}, After: []string{"missing"}}, + }) + require.NoError(t, err) + + assert.True(t, result.Success()) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 1, result.Skipped) + require.Len(t, result.Results, 1) + assert.True(t, result.Results[0].Skipped) + assert.Error(t, result.Results[0].Error) }) } From 87bebd7fa65df1dcbd73c5e316a83aea87d67420 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:27:27 +0000 Subject: [PATCH 05/97] feat(exec): add background command support --- exec/exec.go | 42 ++++++++++++++++++++++++++++++++++++++++-- exec/exec_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/exec/exec.go b/exec/exec.go index 6a2c49e..00b1d69 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -19,8 +19,8 @@ type Options struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer - // If true, command will run in background (not implemented in this wrapper yet) - // Background bool + // Background runs the command asynchronously and returns from Run immediately. + Background bool } // Command wraps os/exec.Command with logging and context @@ -79,9 +79,39 @@ func (c *Cmd) WithLogger(l Logger) *Cmd { return c } +// WithBackground configures whether Run should wait for the command to finish. +func (c *Cmd) WithBackground(background bool) *Cmd { + c.opts.Background = background + return c +} + +// Start launches the command. +func (c *Cmd) Start() error { + c.prepare() + c.logDebug("executing command") + + if err := c.cmd.Start(); err != nil { + wrapped := wrapError("Cmd.Start", err, c.name, c.args) + c.logError("command failed", wrapped) + return wrapped + } + + if c.opts.Background { + go func(cmd *exec.Cmd) { + _ = cmd.Wait() + }(c.cmd) + } + + return nil +} + // Run executes the command and waits for it to finish. // It automatically logs the command execution at debug level. func (c *Cmd) Run() error { + if c.opts.Background { + return c.Start() + } + c.prepare() c.logDebug("executing command") @@ -95,6 +125,10 @@ func (c *Cmd) Run() error { // Output runs the command and returns its standard output. func (c *Cmd) Output() ([]byte, error) { + if c.opts.Background { + return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil) + } + c.prepare() c.logDebug("executing command") @@ -109,6 +143,10 @@ func (c *Cmd) Output() ([]byte, error) { // CombinedOutput runs the command and returns its combined standard output and standard error. func (c *Cmd) CombinedOutput() ([]byte, error) { + if c.opts.Background { + return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil) + } + c.prepare() c.logDebug("executing command") diff --git a/exec/exec_test.go b/exec/exec_test.go index 6e2544b..f7d8cb9 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -2,8 +2,12 @@ package exec_test import ( "context" + "fmt" + "os" + "path/filepath" "strings" "testing" + "time" "dappco.re/go/core/process/exec" ) @@ -195,6 +199,49 @@ func TestCommand_WithStdinStdoutStderr(t *testing.T) { } } +func TestCommand_Run_Background(t *testing.T) { + logger := &mockLogger{} + ctx := context.Background() + dir := t.TempDir() + marker := filepath.Join(dir, "marker.txt") + + start := time.Now() + err := exec.Command(ctx, "sh", "-c", fmt.Sprintf("sleep 0.2; printf done > %q", marker)). + WithBackground(true). + WithLogger(logger). + Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("background run took too long: %s", elapsed) + } + + deadline := time.Now().Add(2 * time.Second) + for { + data, readErr := os.ReadFile(marker) + if readErr == nil && strings.TrimSpace(string(data)) == "done" { + break + } + if time.Now().After(deadline) { + t.Fatalf("background command did not create marker file") + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestCommand_Output_BackgroundRejected(t *testing.T) { + ctx := context.Background() + + _, err := exec.Command(ctx, "echo", "test"). + WithBackground(true). + Output() + if err == nil { + t.Fatal("expected error") + } +} + func TestRunQuiet_Good(t *testing.T) { ctx := context.Background() err := exec.RunQuiet(ctx, "echo", "quiet") From f70e301631ace93e77523b876f13d416e7035162 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:34:16 +0000 Subject: [PATCH 06/97] feat(process): validate KillGroup requires Detach --- service.go | 4 ++++ service_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/service.go b/service.go index f4fd18f..b9fbf1c 100644 --- a/service.go +++ b/service.go @@ -104,6 +104,10 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) + if opts.KillGroup && !opts.Detach { + return nil, coreerr.E("Service.StartWithOptions", "KillGroup requires Detach", nil) + } + // Detached processes use Background context so they survive parent death parentCtx := ctx if opts.Detach { diff --git a/service_test.go b/service_test.go index 7d3a64e..7c6b640 100644 --- a/service_test.go +++ b/service_test.go @@ -150,6 +150,18 @@ func TestService_Start(t *testing.T) { t.Fatal("detached process should have completed") } }) + + t.Run("kill group requires detach", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sleep", + Args: []string{"1"}, + KillGroup: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "KillGroup requires Detach") + }) } func TestService_Run(t *testing.T) { From dcf058047ed666eed2e8ab03c3a08281cabe3f08 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:37:48 +0000 Subject: [PATCH 07/97] feat(process): emit exit actions consistently --- service.go | 31 ++++++++++++++++++++----------- service_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/service.go b/service.go index b9fbf1c..10d086d 100644 --- a/service.go +++ b/service.go @@ -103,6 +103,7 @@ 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)) + startedAt := time.Now() if opts.KillGroup && !opts.Detach { return nil, coreerr.E("Service.StartWithOptions", "KillGroup requires Detach", nil) @@ -159,7 +160,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce Args: opts.Args, Dir: opts.Dir, Env: opts.Env, - StartedAt: time.Now(), + StartedAt: startedAt, Status: StatusRunning, cmd: cmd, ctx: procCtx, @@ -174,6 +175,14 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Start the process if err := cmd.Start(); err != nil { cancel() + if s.Core() != nil { + _ = s.Core().ACTION(ActionProcessExited{ + ID: id, + ExitCode: -1, + Duration: time.Since(startedAt), + Error: coreerr.E("Service.StartWithOptions", "failed to start process", err), + }) + } return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) } @@ -234,21 +243,21 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce close(proc.done) - // Broadcast lifecycle completion. - switch status { - case StatusKilled: + exitAction := ActionProcessExited{ + ID: id, + ExitCode: exitCode, + Duration: duration, + Error: exitErr, + } + if status == StatusKilled { + exitAction.Error = coreerr.E("Service.StartWithOptions", "process was killed", nil) _ = s.Core().ACTION(ActionProcessKilled{ ID: id, Signal: signalName, }) - default: - _ = s.Core().ACTION(ActionProcessExited{ - ID: id, - ExitCode: exitCode, - Duration: duration, - Error: exitErr, - }) } + + _ = s.Core().ACTION(exitAction) }() return proc, nil diff --git a/service_test.go b/service_test.go index 7c6b640..f0aeff1 100644 --- a/service_test.go +++ b/service_test.go @@ -248,6 +248,7 @@ func TestService_Actions(t *testing.T) { svc := raw.(*Service) var killed []ActionProcessKilled + var exited []ActionProcessExited var mu sync.Mutex c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { @@ -256,6 +257,9 @@ func TestService_Actions(t *testing.T) { if m, ok := msg.(ActionProcessKilled); ok { killed = append(killed, m) } + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } return framework.Result{OK: true} }) @@ -278,8 +282,43 @@ func TestService_Actions(t *testing.T) { assert.Len(t, killed, 1) assert.Equal(t, proc.ID, killed[0].ID) assert.NotEmpty(t, killed[0].Signal) + assert.Len(t, exited, 1) + assert.Equal(t, proc.ID, exited[0].ID) + assert.Error(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) + + t.Run("broadcasts exited event on start failure", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) + + var exited []ActionProcessExited + var mu sync.Mutex + + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + mu.Lock() + defer mu.Unlock() + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } + return framework.Result{OK: true} + }) + + _, err = svc.Start(context.Background(), "definitely-not-a-real-binary-xyz") + require.Error(t, err) + + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Len(t, exited, 1) + assert.Equal(t, -1, exited[0].ExitCode) + assert.Error(t, exited[0].Error) + }) } func TestService_List(t *testing.T) { From 9457694e4651576407de70e16ba0146106e8622b Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:41:36 +0000 Subject: [PATCH 08/97] feat(process): preserve runner result order --- runner.go | 37 +++++++++++++++++++++++++++---------- runner_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/runner.go b/runner.go index ed1de10..07ba97b 100644 --- a/runner.go +++ b/runner.go @@ -13,6 +13,9 @@ type Runner struct { service *Service } +// ErrRunnerNoService is returned when a runner was created without a service. +var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) + // NewRunner creates a runner for the given service. func NewRunner(svc *Service) *Runner { return &Runner{service: svc} @@ -68,20 +71,24 @@ func (r RunAllResult) Success() bool { // RunAll executes specs respecting dependencies, parallelising where possible. func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { + if err := r.ensureService(); err != nil { + return nil, err + } start := time.Now() // Build dependency graph specMap := make(map[string]RunSpec) + indexMap := make(map[string]int, len(specs)) for _, spec := range specs { specMap[spec.Name] = spec + indexMap[spec.Name] = len(indexMap) } // Track completion completed := make(map[string]*RunResult) var completedMu sync.Mutex - results := make([]RunResult, 0, len(specs)) - var resultsMu sync.Mutex + results := make([]RunResult, len(specs)) // Process specs in waves remaining := make(map[string]RunSpec) @@ -99,16 +106,15 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } if len(ready) == 0 && len(remaining) > 0 { - // Deadlock — circular dependency or missing specs. Report them as skipped - // with an error so callers can distinguish dependency graph issues from - // command execution failures. + // Deadlock - circular dependency or missing specs. + // Keep the output aligned with the input order. for name := range remaining { - results = append(results, RunResult{ + results[indexMap[name]] = RunResult{ Name: name, Spec: remaining[name], Skipped: true, Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), - }) + } } break } @@ -149,9 +155,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er completed[spec.Name] = &result completedMu.Unlock() - resultsMu.Lock() - results = append(results, result) - resultsMu.Unlock() + results[indexMap[spec.Name]] = result }(spec) } wg.Wait() @@ -181,6 +185,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er return aggResult, nil } +func (r *Runner) ensureService() error { + if r == nil || r.service == nil { + return ErrRunnerNoService + } + return nil +} + // canRun checks if all dependencies are completed. func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { for _, dep := range spec.After { @@ -224,6 +235,9 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { // RunSequential executes specs one after another, stopping on first failure. func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { + if err := r.ensureService(); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, 0, len(specs)) @@ -264,6 +278,9 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes // RunParallel executes all specs concurrently, regardless of dependencies. func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { + if err := r.ensureService(); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, len(specs)) diff --git a/runner_test.go b/runner_test.go index cc4afd0..746c705 100644 --- a/runner_test.go +++ b/runner_test.go @@ -148,6 +148,24 @@ func TestRunner_RunAll(t *testing.T) { assert.True(t, result.Success()) assert.Equal(t, 4, result.Passed) }) + + t.Run("preserves input order", func(t *testing.T) { + runner := newTestRunner(t) + + specs := []RunSpec{ + {Name: "third", Command: "echo", Args: []string{"3"}, After: []string{"second"}}, + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, + } + + result, err := runner.RunAll(context.Background(), specs) + require.NoError(t, err) + + require.Len(t, result.Results, len(specs)) + for i, res := range result.Results { + assert.Equal(t, specs[i].Name, res.Name) + } + }) } func TestRunner_RunAll_CircularDeps(t *testing.T) { @@ -207,3 +225,19 @@ func TestRunResult_Passed(t *testing.T) { assert.False(t, r.Passed()) }) } + +func TestRunner_NilService(t *testing.T) { + runner := NewRunner(nil) + + _, err := runner.RunAll(context.Background(), nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerNoService) + + _, err = runner.RunSequential(context.Background(), nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerNoService) + + _, err = runner.RunParallel(context.Background(), nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerNoService) +} From 6fda03d64d444a15cb6275e19bc308f73fc88a81 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:45:06 +0000 Subject: [PATCH 09/97] feat(process): fail invalid runner dependencies --- runner.go | 8 ++++---- runner_test.go | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/runner.go b/runner.go index 07ba97b..463e002 100644 --- a/runner.go +++ b/runner.go @@ -110,10 +110,10 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er // Keep the output aligned with the input order. for name := range remaining { results[indexMap[name]] = RunResult{ - Name: name, - Spec: remaining[name], - Skipped: true, - Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), + Name: name, + Spec: remaining[name], + ExitCode: 1, + Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), } } break diff --git a/runner_test.go b/runner_test.go index 746c705..12f7888 100644 --- a/runner_test.go +++ b/runner_test.go @@ -178,11 +178,12 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.True(t, result.Success()) - assert.Equal(t, 0, result.Failed) - assert.Equal(t, 2, result.Skipped) + assert.False(t, result.Success()) + assert.Equal(t, 2, result.Failed) + assert.Equal(t, 0, result.Skipped) for _, res := range result.Results { - assert.True(t, res.Skipped) + assert.False(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) assert.Error(t, res.Error) } }) @@ -195,11 +196,12 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.True(t, result.Success()) - assert.Equal(t, 0, result.Failed) - assert.Equal(t, 1, result.Skipped) + assert.False(t, result.Success()) + assert.Equal(t, 1, result.Failed) + assert.Equal(t, 0, result.Skipped) require.Len(t, result.Results, 1) - assert.True(t, result.Results[0].Skipped) + assert.False(t, result.Results[0].Skipped) + assert.Equal(t, 1, result.Results[0].ExitCode) assert.Error(t, result.Results[0].Error) }) } From 1b7431e3a05e2d6c3c68adbfeb943a8808cb24e8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:47:38 +0000 Subject: [PATCH 10/97] feat(process): skip unresolved pipeline specs --- runner.go | 3 ++- runner_test.go | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/runner.go b/runner.go index 463e002..3a5bf2e 100644 --- a/runner.go +++ b/runner.go @@ -66,7 +66,7 @@ type RunAllResult struct { // Success returns true if all non-skipped specs passed. func (r RunAllResult) Success() bool { - return r.Failed == 0 + return r.Failed == 0 && r.Skipped == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. @@ -113,6 +113,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er Name: name, Spec: remaining[name], ExitCode: 1, + Skipped: true, Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), } } diff --git a/runner_test.go b/runner_test.go index 12f7888..473545c 100644 --- a/runner_test.go +++ b/runner_test.go @@ -179,10 +179,10 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success()) - assert.Equal(t, 2, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) for _, res := range result.Results { - assert.False(t, res.Skipped) + assert.True(t, res.Skipped) assert.Equal(t, 1, res.ExitCode) assert.Error(t, res.Error) } @@ -197,10 +197,10 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success()) - assert.Equal(t, 1, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 1, result.Skipped) require.Len(t, result.Results, 1) - assert.False(t, result.Results[0].Skipped) + assert.True(t, result.Results[0].Skipped) assert.Equal(t, 1, result.Results[0].ExitCode) assert.Error(t, result.Results[0].Error) }) From 252f68db64bbb589900802c079187c66e9b5f332 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:50:52 +0000 Subject: [PATCH 11/97] feat(process): forward task run options --- actions.go | 10 ++++++++++ service.go | 13 +++++++++---- service_test.go | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/actions.go b/actions.go index a09e520..a93c9db 100644 --- a/actions.go +++ b/actions.go @@ -11,6 +11,16 @@ type TaskProcessRun struct { Args []string Dir string Env []string + // DisableCapture skips buffering process output before returning it. + DisableCapture bool + // Detach runs the command in its own process group. + Detach bool + // Timeout bounds the execution duration. + Timeout time.Duration + // GracePeriod controls SIGTERM-to-SIGKILL escalation. + GracePeriod time.Duration + // KillGroup terminates the entire process group instead of only the leader. + KillGroup bool } // ActionProcessStarted is broadcast when a process begins execution. diff --git a/service.go b/service.go index 10d086d..9a5c211 100644 --- a/service.go +++ b/service.go @@ -417,10 +417,15 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { switch m := task.(type) { case TaskProcessRun: output, err := s.RunWithOptions(c.Context(), RunOptions{ - Command: m.Command, - Args: m.Args, - Dir: m.Dir, - Env: m.Env, + Command: m.Command, + Args: m.Args, + Dir: m.Dir, + Env: m.Env, + DisableCapture: m.DisableCapture, + Detach: m.Detach, + Timeout: m.Timeout, + GracePeriod: m.GracePeriod, + KillGroup: m.KillGroup, }) if err != nil { return core.Result{Value: err, OK: false} diff --git a/service_test.go b/service_test.go index f0aeff1..e7b8323 100644 --- a/service_test.go +++ b/service_test.go @@ -500,6 +500,23 @@ func TestService_OnStartup(t *testing.T) { require.True(t, result.OK) assert.Contains(t, result.Value.(string), "action-run") }) + + t.Run("forwards task execution options", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessRun{ + Command: "sleep", + Args: []string{"60"}, + Timeout: 100 * time.Millisecond, + GracePeriod: 50 * time.Millisecond, + }) + + require.False(t, result.OK) + assert.Nil(t, result.Value) + }) } func TestService_RunWithOptions(t *testing.T) { From e58f376e4c4e6d31fd9158582222dfff1c90b3df Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:53:19 +0000 Subject: [PATCH 12/97] feat(process): signal process groups --- process.go | 8 ++++++++ process_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/process.go b/process.go index 9a2ad0b..80f43b0 100644 --- a/process.go +++ b/process.go @@ -188,6 +188,14 @@ func (p *Process) Signal(sig os.Signal) error { return nil } + if p.killGroup { + sysSig, ok := sig.(syscall.Signal) + if !ok { + return p.cmd.Process.Signal(sig) + } + return syscall.Kill(-p.cmd.Process.Pid, sysSig) + } + return p.cmd.Process.Signal(sig) } diff --git a/process_test.go b/process_test.go index 302bc9e..0dd9ebe 100644 --- a/process_test.go +++ b/process_test.go @@ -225,6 +225,28 @@ func TestProcess_Signal(t *testing.T) { err = proc.Signal(os.Interrupt) assert.ErrorIs(t, err, ErrProcessNotRunning) }) + + t.Run("signals process group when kill group is enabled", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "trap '' INT; sh -c 'trap - INT; sleep 60' & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + err = proc.Signal(os.Interrupt) + assert.NoError(t, err) + + select { + case <-proc.Done(): + // Good - the whole process group responded to the signal. + case <-time.After(5 * time.Second): + t.Fatal("process group should have been terminated by signal") + } + }) } func TestProcess_CloseStdin(t *testing.T) { From 9b536f08c6198c5076ce1ac63e47b7ee69b49cd6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:56:56 +0000 Subject: [PATCH 13/97] feat(exec): require command context --- exec/exec.go | 34 +++++++++++++++++++++------------- exec/exec_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/exec/exec.go b/exec/exec.go index 00b1d69..cc6f28d 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -12,6 +12,9 @@ import ( coreerr "dappco.re/go/core/log" ) +// ErrCommandContextRequired is returned when a command is created without a context. +var ErrCommandContextRequired = coreerr.E("", "exec: command context is required", nil) + // Options configuration for command execution type Options struct { Dir string @@ -87,7 +90,9 @@ func (c *Cmd) WithBackground(background bool) *Cmd { // Start launches the command. func (c *Cmd) Start() error { - c.prepare() + if err := c.prepare(); err != nil { + return err + } c.logDebug("executing command") if err := c.cmd.Start(); err != nil { @@ -112,7 +117,9 @@ func (c *Cmd) Run() error { return c.Start() } - c.prepare() + if err := c.prepare(); err != nil { + return err + } c.logDebug("executing command") if err := c.cmd.Run(); err != nil { @@ -129,7 +136,9 @@ func (c *Cmd) Output() ([]byte, error) { return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil) } - c.prepare() + if err := c.prepare(); err != nil { + return nil, err + } c.logDebug("executing command") out, err := c.cmd.Output() @@ -147,7 +156,9 @@ func (c *Cmd) CombinedOutput() ([]byte, error) { return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil) } - c.prepare() + if err := c.prepare(); err != nil { + return nil, err + } c.logDebug("executing command") out, err := c.cmd.CombinedOutput() @@ -159,17 +170,13 @@ func (c *Cmd) CombinedOutput() ([]byte, error) { return out, nil } -func (c *Cmd) prepare() { - if c.ctx != nil { - c.cmd = exec.CommandContext(c.ctx, c.name, c.args...) - } else { - // Should we enforce context? The issue says "Enforce context usage". - // For now, let's allow nil but log a warning if we had a logger? - // Or strictly panic/error? - // Let's fallback to Background for now but maybe strict later. - c.cmd = exec.Command(c.name, c.args...) +func (c *Cmd) prepare() error { + if c.ctx == nil { + return coreerr.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired) } + c.cmd = exec.CommandContext(c.ctx, c.name, c.args...) + c.cmd.Dir = c.opts.Dir if len(c.opts.Env) > 0 { c.cmd.Env = append(os.Environ(), c.opts.Env...) @@ -178,6 +185,7 @@ func (c *Cmd) prepare() { c.cmd.Stdin = c.opts.Stdin c.cmd.Stdout = c.opts.Stdout c.cmd.Stderr = c.opts.Stderr + return nil } // RunQuiet executes the command suppressing stdout unless there is an error. diff --git a/exec/exec_test.go b/exec/exec_test.go index f7d8cb9..8519468 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -10,6 +10,8 @@ import ( "time" "dappco.re/go/core/process/exec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // mockLogger captures log calls for testing @@ -231,6 +233,32 @@ func TestCommand_Run_Background(t *testing.T) { } } +func TestCommand_NilContextRejected(t *testing.T) { + t.Run("start", func(t *testing.T) { + err := exec.Command(nil, "echo", "test").Start() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("run", func(t *testing.T) { + err := exec.Command(nil, "echo", "test").Run() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("output", func(t *testing.T) { + _, err := exec.Command(nil, "echo", "test").Output() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("combined output", func(t *testing.T) { + _, err := exec.Command(nil, "echo", "test").CombinedOutput() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) +} + func TestCommand_Output_BackgroundRejected(t *testing.T) { ctx := context.Background() From f5a940facd9fffa59a545fb56297059c72972aa4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:01:22 +0000 Subject: [PATCH 14/97] feat(process): add running flag to process info --- process.go | 1 + process_test.go | 3 +++ types.go | 1 + 3 files changed, 5 insertions(+) diff --git a/process.go b/process.go index 80f43b0..5b4ae84 100644 --- a/process.go +++ b/process.go @@ -52,6 +52,7 @@ func (p *Process) Info() Info { Args: p.Args, Dir: p.Dir, StartedAt: p.StartedAt, + Running: p.Status == StatusRunning, Status: p.Status, ExitCode: p.ExitCode, Duration: p.Duration, diff --git a/process_test.go b/process_test.go index 0dd9ebe..1447f01 100644 --- a/process_test.go +++ b/process_test.go @@ -22,6 +22,7 @@ func TestProcess_Info(t *testing.T) { assert.Equal(t, proc.ID, info.ID) assert.Equal(t, "echo", info.Command) assert.Equal(t, []string{"hello"}, info.Args) + assert.False(t, info.Running) assert.Equal(t, StatusExited, info.Status) assert.Equal(t, 0, info.ExitCode) assert.Greater(t, info.Duration, time.Duration(0)) @@ -65,11 +66,13 @@ func TestProcess_IsRunning(t *testing.T) { require.NoError(t, err) assert.True(t, proc.IsRunning()) + assert.True(t, proc.Info().Running) cancel() <-proc.Done() assert.False(t, proc.IsRunning()) + assert.False(t, proc.Info().Running) }) t.Run("false after completion", func(t *testing.T) { diff --git a/types.go b/types.go index 0416b72..73b4cad 100644 --- a/types.go +++ b/types.go @@ -98,6 +98,7 @@ type Info struct { Args []string `json:"args"` Dir string `json:"dir"` StartedAt time.Time `json:"startedAt"` + Running bool `json:"running"` Status Status `json:"status"` ExitCode int `json:"exitCode"` Duration time.Duration `json:"duration"` From 2bc6eb70d7ebaabca7203c4aec3407fb08d74d3b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:04:54 +0000 Subject: [PATCH 15/97] fix(process): copy info slices defensively --- process.go | 2 +- process_test.go | 17 +++++++++++++++++ service.go | 4 ++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/process.go b/process.go index 5b4ae84..17da313 100644 --- a/process.go +++ b/process.go @@ -49,7 +49,7 @@ func (p *Process) Info() Info { return Info{ ID: p.ID, Command: p.Command, - Args: p.Args, + Args: append([]string(nil), p.Args...), Dir: p.Dir, StartedAt: p.StartedAt, Running: p.Status == StatusRunning, diff --git a/process_test.go b/process_test.go index 1447f01..f7590d0 100644 --- a/process_test.go +++ b/process_test.go @@ -28,6 +28,23 @@ func TestProcess_Info(t *testing.T) { assert.Greater(t, info.Duration, time.Duration(0)) } +func TestProcess_InfoSnapshot(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "echo", "snapshot") + require.NoError(t, err) + + <-proc.Done() + + info := proc.Info() + require.NotEmpty(t, info.Args) + + info.Args[0] = "mutated" + + assert.Equal(t, "snapshot", proc.Args[0]) + assert.Equal(t, "mutated", info.Args[0]) +} + func TestProcess_Output(t *testing.T) { t.Run("captures stdout", func(t *testing.T) { svc, _ := newTestService(t) diff --git a/service.go b/service.go index 9a5c211..6ed34dc 100644 --- a/service.go +++ b/service.go @@ -157,9 +157,9 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce proc := &Process{ ID: id, Command: opts.Command, - Args: opts.Args, + Args: append([]string(nil), opts.Args...), Dir: opts.Dir, - Env: opts.Env, + Env: append([]string(nil), opts.Env...), StartedAt: startedAt, Status: StatusRunning, cmd: cmd, From 84d07daf199ea23d0815557315c19564e50b545b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:07:48 +0000 Subject: [PATCH 16/97] feat(process): relax runner success semantics --- runner.go | 2 +- runner_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/runner.go b/runner.go index 3a5bf2e..3687369 100644 --- a/runner.go +++ b/runner.go @@ -66,7 +66,7 @@ type RunAllResult struct { // Success returns true if all non-skipped specs passed. func (r RunAllResult) Success() bool { - return r.Failed == 0 && r.Skipped == 0 + return r.Failed == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. diff --git a/runner_test.go b/runner_test.go index 473545c..1f160db 100644 --- a/runner_test.go +++ b/runner_test.go @@ -178,7 +178,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) + assert.True(t, result.Success()) assert.Equal(t, 0, result.Failed) assert.Equal(t, 2, result.Skipped) for _, res := range result.Results { @@ -196,7 +196,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) + assert.True(t, result.Success()) assert.Equal(t, 0, result.Failed) assert.Equal(t, 1, result.Skipped) require.Len(t, result.Results, 1) From 5142114e8916ec5d9b932cbfd02028674f3f7fa0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:11:21 +0000 Subject: [PATCH 17/97] feat(process): rollback daemon startup on registry failure --- daemon.go | 9 +++++++-- daemon_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/daemon.go b/daemon.go index af3e044..ae6fb02 100644 --- a/daemon.go +++ b/daemon.go @@ -91,8 +91,6 @@ func (d *Daemon) Start() error { } } - d.running = true - // Auto-register if registry is set if d.opts.Registry != nil { entry := d.opts.RegistryEntry @@ -101,10 +99,17 @@ func (d *Daemon) Start() error { entry.Health = d.health.Addr() } if err := d.opts.Registry.Register(entry); err != nil { + if d.health != nil { + _ = d.health.Stop(context.Background()) + } + if d.pid != nil { + _ = d.pid.Release() + } return coreerr.E("Daemon.Start", "registry", err) } } + d.running = true return nil } diff --git a/daemon_test.go b/daemon_test.go index 4e641d4..a3ba52b 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -163,3 +163,40 @@ func TestDaemon_AutoRegisters(t *testing.T) { _, ok = reg.Get("test-app", "serve") assert.False(t, ok) } + +func TestDaemon_StartRollsBackOnRegistryFailure(t *testing.T) { + dir := t.TempDir() + + pidPath := filepath.Join(dir, "daemon.pid") + regDir := filepath.Join(dir, "registry") + require.NoError(t, os.MkdirAll(regDir, 0o755)) + require.NoError(t, os.Chmod(regDir, 0o555)) + + d := NewDaemon(DaemonOptions{ + PIDFile: pidPath, + HealthAddr: "127.0.0.1:0", + Registry: NewRegistry(regDir), + RegistryEntry: DaemonEntry{ + Code: "broken", + Daemon: "start", + }, + }) + + err := d.Start() + require.Error(t, err) + + _, statErr := os.Stat(pidPath) + assert.True(t, os.IsNotExist(statErr)) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + client := &http.Client{Timeout: 250 * time.Millisecond} + resp, reqErr := client.Get("http://" + addr + "/health") + if resp != nil { + _ = resp.Body.Close() + } + assert.Error(t, reqErr) + + assert.NoError(t, d.Stop()) +} From 214cf4cfa865b743859c890f6f449f09790b0601 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:15:20 +0000 Subject: [PATCH 18/97] feat(process): expose health probe failure reasons Co-Authored-By: Virgil --- health.go | 27 ++++++++++++++++++++++++--- pkg/api/provider.go | 9 +++++++-- pkg/api/provider_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/health.go b/health.go index fd6adfe..923fbed 100644 --- a/health.go +++ b/health.go @@ -3,8 +3,10 @@ package process import ( "context" "fmt" + "io" "net" "net/http" + "strings" "sync" "time" @@ -116,21 +118,40 @@ func (h *HealthServer) Addr() string { // WaitForHealth polls a health endpoint until it responds 200 or the timeout // (in milliseconds) expires. Returns true if healthy, false on timeout. func WaitForHealth(addr string, timeoutMs int) bool { + ok, _ := ProbeHealth(addr, timeoutMs) + return ok +} + +// ProbeHealth polls a health endpoint until it responds 200 or the timeout +// (in milliseconds) expires. Returns the health status and the last observed +// failure reason if the endpoint never becomes healthy. +func ProbeHealth(addr string, timeoutMs int) (bool, string) { deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) url := fmt.Sprintf("http://%s/health", addr) client := &http.Client{Timeout: 2 * time.Second} + var lastReason string for time.Now().Before(deadline) { resp, err := client.Get(url) if err == nil { - resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { - return true + return true, "" } + lastReason = strings.TrimSpace(string(body)) + if lastReason == "" { + lastReason = resp.Status + } + } else { + lastReason = err.Error() } time.Sleep(200 * time.Millisecond) } - return false + if lastReason == "" { + lastReason = "health check timed out" + } + return false, lastReason } diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 4397912..e2a9183 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -140,13 +140,14 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { Method: "GET", Path: "/daemons/:code/:daemon/health", Summary: "Check daemon health", - Description: "Probes the daemon's health endpoint and returns the result.", + Description: "Probes the daemon's health endpoint and returns the result, including a failure reason when unhealthy.", Tags: []string{"process"}, Response: map[string]any{ "type": "object", "properties": map[string]any{ "healthy": map[string]any{"type": "boolean"}, "address": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, }, }, }, @@ -232,18 +233,22 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) { return } - healthy := process.WaitForHealth(entry.Health, 2000) + healthy, reason := process.ProbeHealth(entry.Health, 2000) result := map[string]any{ "healthy": healthy, "address": entry.Health, } + if !healthy && reason != "" { + result["reason"] = reason + } // Emit health event p.emitEvent("process.daemon.health", map[string]any{ "code": code, "daemon": daemon, "healthy": healthy, + "reason": reason, }) statusCode := http.StatusOK diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index eac3592..6a6f42c 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "strings" "testing" goapi "dappco.re/go/core/api" @@ -84,6 +86,43 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +func TestProcessProvider_HealthCheck_Bad(t *testing.T) { + dir := t.TempDir() + registry := newTestRegistry(dir) + + healthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("upstream health check failed")) + })) + defer healthSrv.Close() + + hostPort := strings.TrimPrefix(healthSrv.URL, "http://") + require.NoError(t, registry.Register(process.DaemonEntry{ + Code: "test", + Daemon: "broken", + PID: os.Getpid(), + Health: hostPort, + })) + + p := processapi.NewProvider(registry, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/process/daemons/test/broken/health", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp goapi.Response[map[string]any] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + + assert.Equal(t, false, resp.Data["healthy"]) + assert.Equal(t, hostPort, resp.Data["address"]) + assert.Equal(t, "upstream health check failed", resp.Data["reason"]) +} + func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { p := processapi.NewProvider(nil, nil) From fa79e4eee7a3c022c85bfdf948c7807fc4e1e149 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:18:14 +0000 Subject: [PATCH 19/97] fix(process): guard program run inputs --- program.go | 14 ++++++++++++++ program_test.go | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/program.go b/program.go index 5160392..7e2f328 100644 --- a/program.go +++ b/program.go @@ -14,6 +14,12 @@ import ( // Callers may use errors.Is to detect this condition. var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil) +// ErrProgramContextRequired is returned when Run or RunDir is called without a context. +var ErrProgramContextRequired = coreerr.E("", "program: command context is required", nil) + +// ErrProgramNameRequired is returned when Run or RunDir is called without a program name. +var ErrProgramNameRequired = coreerr.E("", "program: program name is empty", nil) + // Program represents a named executable located on the system PATH. // Create one with a Name, call Find to resolve its path, then Run or RunDir. type Program struct { @@ -48,11 +54,19 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) { // Returns trimmed combined stdout+stderr output and any error. // If dir is empty, the process inherits the caller's working directory. func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { + if ctx == nil { + return "", coreerr.E("Program.RunDir", "program: command context is required", ErrProgramContextRequired) + } + binary := p.Path if binary == "" { binary = p.Name } + if binary == "" { + return "", coreerr.E("Program.RunDir", "program name is empty", ErrProgramNameRequired) + } + var out bytes.Buffer cmd := exec.CommandContext(ctx, binary, args...) cmd.Stdout = &out diff --git a/program_test.go b/program_test.go index 970e2de..739e516 100644 --- a/program_test.go +++ b/program_test.go @@ -78,3 +78,19 @@ func TestProgram_Run_FailingCommand(t *testing.T) { _, err := p.Run(testCtx(t)) require.Error(t, err) } + +func TestProgram_Run_NilContextRejected(t *testing.T) { + p := &process.Program{Name: "echo"} + + _, err := p.Run(nil, "test") + require.Error(t, err) + assert.ErrorIs(t, err, process.ErrProgramContextRequired) +} + +func TestProgram_RunDir_EmptyNameRejected(t *testing.T) { + p := &process.Program{} + + _, err := p.RunDir(testCtx(t), "", "test") + require.Error(t, err) + assert.ErrorIs(t, err, process.ErrProgramNameRequired) +} From f98bbad5ac47ce7e63c5ad6570741e17057f890e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:21:22 +0000 Subject: [PATCH 20/97] fix(process): reject duplicate runner spec names --- runner.go | 26 ++++++++++++++++++++++++++ runner_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/runner.go b/runner.go index 3687369..53da9b2 100644 --- a/runner.go +++ b/runner.go @@ -16,6 +16,9 @@ type Runner struct { // ErrRunnerNoService is returned when a runner was created without a service. var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) +// ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated. +var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil) + // NewRunner creates a runner for the given service. func NewRunner(svc *Service) *Runner { return &Runner{service: svc} @@ -74,6 +77,9 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er if err := r.ensureService(); err != nil { return nil, err } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() // Build dependency graph @@ -239,6 +245,9 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes if err := r.ensureService(); err != nil { return nil, err } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, 0, len(specs)) @@ -282,6 +291,9 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul if err := r.ensureService(); err != nil { return nil, err } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, len(specs)) @@ -312,3 +324,17 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul return aggResult, nil } + +func validateSpecs(specs []RunSpec) error { + seen := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + if spec.Name == "" { + return coreerr.E("Runner.validateSpecs", "runner spec name is required", ErrRunnerInvalidSpecName) + } + if _, ok := seen[spec.Name]; ok { + return coreerr.E("Runner.validateSpecs", "runner spec name is duplicated", ErrRunnerInvalidSpecName) + } + seen[spec.Name] = struct{}{} + } + return nil +} diff --git a/runner_test.go b/runner_test.go index 1f160db..a6081ca 100644 --- a/runner_test.go +++ b/runner_test.go @@ -243,3 +243,33 @@ func TestRunner_NilService(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, ErrRunnerNoService) } + +func TestRunner_InvalidSpecNames(t *testing.T) { + runner := newTestRunner(t) + + t.Run("rejects empty names", func(t *testing.T) { + _, err := runner.RunSequential(context.Background(), []RunSpec{ + {Name: "", Command: "echo", Args: []string{"a"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) + + t.Run("rejects duplicate names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "same", Command: "echo", Args: []string{"a"}}, + {Name: "same", Command: "echo", Args: []string{"b"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) + + t.Run("rejects duplicate names in parallel mode", func(t *testing.T) { + _, err := runner.RunParallel(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}}, + {Name: "one", Command: "echo", Args: []string{"b"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) +} From ce2a4db6cbbfe945b958885cf6f7338cc88971a8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:24:52 +0000 Subject: [PATCH 21/97] fix(process): reject empty start command --- service.go | 4 ++++ service_test.go | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/service.go b/service.go index 6ed34dc..1bca64b 100644 --- a/service.go +++ b/service.go @@ -102,6 +102,10 @@ 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) { + if opts.Command == "" { + return nil, coreerr.E("Service.StartWithOptions", "command is required", nil) + } + id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) startedAt := time.Now() diff --git a/service_test.go b/service_test.go index e7b8323..f477fab 100644 --- a/service_test.go +++ b/service_test.go @@ -63,6 +63,14 @@ func TestService_Start(t *testing.T) { assert.Error(t, err) }) + t.Run("empty command is rejected", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(context.Background(), RunOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "command is required") + }) + t.Run("with working directory", func(t *testing.T) { svc, _ := newTestService(t) From 24f853631dfc8207be6a387ab32278edce39a354 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:28:15 +0000 Subject: [PATCH 22/97] Add PID-based process kill support --- actions.go | 8 ++++++++ global_test.go | 3 +++ process_global.go | 9 +++++++++ service.go | 28 ++++++++++++++++++++++++++++ service_test.go | 22 ++++++++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/actions.go b/actions.go index a93c9db..5341eac 100644 --- a/actions.go +++ b/actions.go @@ -23,6 +23,14 @@ type TaskProcessRun struct { KillGroup bool } +// TaskProcessKill requests termination of a managed process by ID or PID. +type TaskProcessKill struct { + // ID identifies a managed process started by this service. + ID string + // PID targets a process directly when ID is not available. + PID int +} + // ActionProcessStarted is broadcast when a process begins execution. type ActionProcessStarted struct { ID string diff --git a/global_test.go b/global_test.go index 975d682..4b07f88 100644 --- a/global_test.go +++ b/global_test.go @@ -36,6 +36,9 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { err = Kill("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) + err = KillPID(1234) + assert.ErrorIs(t, err, ErrServiceNotInitialized) + _, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"}) assert.ErrorIs(t, err, ErrServiceNotInitialized) diff --git a/process_global.go b/process_global.go index 041fe4d..74434e4 100644 --- a/process_global.go +++ b/process_global.go @@ -94,6 +94,15 @@ func Kill(id string) error { return svc.Kill(id) } +// KillPID terminates a process by operating-system PID using the default service. +func KillPID(pid int) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.KillPID(pid) +} + // StartWithOptions spawns a process with full configuration using the default service. func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { svc := Default() diff --git a/service.go b/service.go index 1bca64b..ea4ebc7 100644 --- a/service.go +++ b/service.go @@ -338,6 +338,19 @@ func (s *Service) Kill(id string) error { return proc.Kill() } +// KillPID terminates a process by operating-system PID. +func (s *Service) KillPID(pid int) error { + if pid <= 0 { + return coreerr.E("Service.KillPID", "pid must be positive", nil) + } + + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) + } + + return nil +} + // Remove removes a completed process from the list. func (s *Service) Remove(id string) error { s.mu.Lock() @@ -435,6 +448,21 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { return core.Result{Value: err, OK: false} } return core.Result{Value: output, OK: true} + case TaskProcessKill: + switch { + case m.ID != "": + if err := s.Kill(m.ID); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + case m.PID > 0: + if err := s.KillPID(m.PID); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + default: + return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} + } default: return core.Result{} } diff --git a/service_test.go b/service_test.go index f477fab..bdceddc 100644 --- a/service_test.go +++ b/service_test.go @@ -525,6 +525,28 @@ func TestService_OnStartup(t *testing.T) { require.False(t, result.OK) assert.Nil(t, result.Value) }) + + t.Run("registers process.kill task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + require.True(t, proc.IsRunning()) + + result := c.PERFORM(TaskProcessKill{PID: proc.Info().PID}) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed by pid") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) } func TestService_RunWithOptions(t *testing.T) { From eeca66240a6c99af8c9cf7f210286f35f8ce557d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:32:12 +0000 Subject: [PATCH 23/97] feat(process): make listings deterministic --- registry.go | 11 +++++++++++ registry_test.go | 4 +++- service.go | 13 +++++++++++++ service_test.go | 15 +++++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/registry.go b/registry.go index ed7c8eb..006cd23 100644 --- a/registry.go +++ b/registry.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "strings" "syscall" "time" @@ -125,6 +126,16 @@ func (r *Registry) List() ([]DaemonEntry, error) { alive = append(alive, entry) } + sort.Slice(alive, func(i, j int) bool { + if alive[i].Started.Equal(alive[j].Started) { + if alive[i].Code == alive[j].Code { + return alive[i].Daemon < alive[j].Daemon + } + return alive[i].Code < alive[j].Code + } + return alive[i].Started.Before(alive[j].Started) + }) + return alive, nil } diff --git a/registry_test.go b/registry_test.go index 108ae28..e442580 100644 --- a/registry_test.go +++ b/registry_test.go @@ -76,7 +76,9 @@ func TestRegistry_List(t *testing.T) { entries, err := reg.List() require.NoError(t, err) - assert.Len(t, entries, 2) + require.Len(t, entries, 2) + assert.Equal(t, "app1", entries[0].Code) + assert.Equal(t, "app2", entries[1].Code) } func TestRegistry_List_PrunesStale(t *testing.T) { diff --git a/service.go b/service.go index ea4ebc7..5b905e8 100644 --- a/service.go +++ b/service.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "sort" "sync" "sync/atomic" "syscall" @@ -311,6 +312,7 @@ func (s *Service) List() []*Process { for _, p := range s.processes { result = append(result, p) } + sortProcesses(result) return result } @@ -325,6 +327,7 @@ func (s *Service) Running() []*Process { result = append(result, p) } } + sortProcesses(result) return result } @@ -488,3 +491,13 @@ func classifyProcessExit(err error) (Status, int, error, string) { return StatusFailed, 0, err, "" } + +// sortProcesses orders processes by start time, then ID for stable output. +func sortProcesses(procs []*Process) { + sort.Slice(procs, func(i, j int) bool { + if procs[i].StartedAt.Equal(procs[j].StartedAt) { + return procs[i].ID < procs[j].ID + } + return procs[i].StartedAt.Before(procs[j].StartedAt) + }) +} diff --git a/service_test.go b/service_test.go index bdceddc..a69749c 100644 --- a/service_test.go +++ b/service_test.go @@ -341,6 +341,8 @@ func TestService_List(t *testing.T) { list := svc.List() assert.Len(t, list, 2) + assert.Equal(t, proc1.ID, list[0].ID) + assert.Equal(t, proc2.ID, list[1].ID) }) t.Run("get by id", func(t *testing.T) { @@ -583,15 +585,24 @@ func TestService_Running(t *testing.T) { proc1, err := svc.Start(ctx, "sleep", "60") require.NoError(t, err) - proc2, err := svc.Start(context.Background(), "echo", "done") + doneProc, err := svc.Start(context.Background(), "echo", "done") require.NoError(t, err) - <-proc2.Done() + <-doneProc.Done() running := svc.Running() assert.Len(t, running, 1) assert.Equal(t, proc1.ID, running[0].ID) + proc2, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + running = svc.Running() + assert.Len(t, running, 2) + assert.Equal(t, proc1.ID, running[0].ID) + assert.Equal(t, proc2.ID, running[1].ID) + cancel() <-proc1.Done() + <-proc2.Done() }) } From cdc8bfe502dbb8fade40127999bb1bea71188707 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:39:27 +0000 Subject: [PATCH 24/97] feat(process): add readiness accessors and AX examples Co-Authored-By: Virgil --- actions.go | 24 ++++++++++++++++++ daemon.go | 47 +++++++++++++++++++++++++++++++++- daemon_test.go | 7 ++++++ exec/exec.go | 60 ++++++++++++++++++++++++++++++++++++++------ exec/logger.go | 14 +++++++++++ health.go | 61 ++++++++++++++++++++++++++++++++++++++------ health_test.go | 11 ++++++++ pidfile.go | 24 ++++++++++++++++++ process.go | 44 ++++++++++++++++++++++++++++++++ process_global.go | 50 +++++++++++++++++++++++++++++++++++- program.go | 20 ++++++++++++++- registry.go | 28 +++++++++++++++++++++ runner.go | 28 +++++++++++++++++++++ service.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++ types.go | 25 ++++++++++++++++++ 15 files changed, 488 insertions(+), 19 deletions(-) diff --git a/actions.go b/actions.go index 5341eac..5a73f44 100644 --- a/actions.go +++ b/actions.go @@ -6,6 +6,10 @@ import "time" // TaskProcessRun requests synchronous command execution through Core.PERFORM. // The handler returns the combined command output on success. +// +// Example: +// +// c.PERFORM(process.TaskProcessRun{Command: "echo", Args: []string{"hello"}}) type TaskProcessRun struct { Command string Args []string @@ -24,6 +28,10 @@ type TaskProcessRun struct { } // TaskProcessKill requests termination of a managed process by ID or PID. +// +// Example: +// +// c.PERFORM(process.TaskProcessKill{ID: "proc-1"}) type TaskProcessKill struct { // ID identifies a managed process started by this service. ID string @@ -32,6 +40,10 @@ type TaskProcessKill struct { } // ActionProcessStarted is broadcast when a process begins execution. +// +// Example: +// +// case process.ActionProcessStarted: fmt.Println("started", msg.ID) type ActionProcessStarted struct { ID string Command string @@ -42,6 +54,10 @@ type ActionProcessStarted struct { // ActionProcessOutput is broadcast for each line of output. // Subscribe to this for real-time streaming. +// +// Example: +// +// case process.ActionProcessOutput: fmt.Println(msg.Line) type ActionProcessOutput struct { ID string Line string @@ -50,6 +66,10 @@ type ActionProcessOutput struct { // ActionProcessExited is broadcast when a process completes. // Check ExitCode for success (0) or failure. +// +// Example: +// +// case process.ActionProcessExited: fmt.Println(msg.ExitCode) type ActionProcessExited struct { ID string ExitCode int @@ -58,6 +78,10 @@ type ActionProcessExited struct { } // ActionProcessKilled is broadcast when a process is terminated. +// +// Example: +// +// case process.ActionProcessKilled: fmt.Println(msg.Signal) type ActionProcessKilled struct { ID string Signal string diff --git a/daemon.go b/daemon.go index ae6fb02..78b5120 100644 --- a/daemon.go +++ b/daemon.go @@ -11,6 +11,13 @@ import ( ) // DaemonOptions configures daemon mode execution. +// +// Example: +// +// opts := process.DaemonOptions{ +// PIDFile: "/var/run/myapp.pid", +// HealthAddr: "127.0.0.1:0", +// } type DaemonOptions struct { // PIDFile path for single-instance enforcement. // Leave empty to skip PID file management. @@ -46,6 +53,10 @@ type Daemon struct { } // NewDaemon creates a daemon runner with the given options. +// +// Example: +// +// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"}) func NewDaemon(opts DaemonOptions) *Daemon { if opts.ShutdownTimeout == 0 { opts.ShutdownTimeout = 30 * time.Second @@ -68,6 +79,10 @@ func NewDaemon(opts DaemonOptions) *Daemon { } // Start initialises the daemon (PID file, health server). +// +// Example: +// +// if err := daemon.Start(); err != nil { return err } func (d *Daemon) Start() error { d.mu.Lock() defer d.mu.Unlock() @@ -114,6 +129,10 @@ func (d *Daemon) Start() error { } // Run blocks until the context is cancelled. +// +// Example: +// +// if err := daemon.Run(ctx); err != nil { return err } func (d *Daemon) Run(ctx context.Context) error { d.mu.Lock() if !d.running { @@ -128,6 +147,10 @@ func (d *Daemon) Run(ctx context.Context) error { } // Stop performs graceful shutdown. +// +// Example: +// +// _ = daemon.Stop() func (d *Daemon) Stop() error { d.mu.Lock() defer d.mu.Unlock() @@ -167,14 +190,36 @@ func (d *Daemon) Stop() error { return nil } -// SetReady sets the daemon readiness status for health checks. +// SetReady sets the daemon readiness status for `/ready`. +// +// Example: +// +// daemon.SetReady(false) func (d *Daemon) SetReady(ready bool) { if d.health != nil { d.health.SetReady(ready) } } +// Ready reports whether the daemon is currently ready for traffic. +// +// Example: +// +// if daemon.Ready() { +// // expose the service to callers +// } +func (d *Daemon) Ready() bool { + if d.health != nil { + return d.health.Ready() + } + return false +} + // HealthAddr returns the health server address, or empty if disabled. +// +// Example: +// +// addr := daemon.HealthAddr() func (d *Daemon) HealthAddr() string { if d.health != nil { return d.health.Addr() diff --git a/daemon_test.go b/daemon_test.go index a3ba52b..8d8f802 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -75,14 +75,21 @@ func TestDaemon_SetReady(t *testing.T) { resp, _ := http.Get("http://" + addr + "/ready") assert.Equal(t, http.StatusOK, resp.StatusCode) _ = resp.Body.Close() + assert.True(t, d.Ready()) d.SetReady(false) + assert.False(t, d.Ready()) resp, _ = http.Get("http://" + addr + "/ready") assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) _ = resp.Body.Close() } +func TestDaemon_ReadyWithoutHealthServer(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + assert.False(t, d.Ready()) +} + func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Empty(t, d.HealthAddr()) diff --git a/exec/exec.go b/exec/exec.go index cc6f28d..d196820 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -15,7 +15,7 @@ import ( // ErrCommandContextRequired is returned when a command is created without a context. var ErrCommandContextRequired = coreerr.E("", "exec: command context is required", nil) -// Options configuration for command execution +// Options configures command execution. type Options struct { Dir string Env []string @@ -26,7 +26,11 @@ type Options struct { Background bool } -// Command wraps os/exec.Command with logging and context +// Command wraps os/exec.Command with logging and context. +// +// Example: +// +// cmd := exec.Command(ctx, "go", "test", "./...") func Command(ctx context.Context, name string, args ...string) *Cmd { return &Cmd{ name: name, @@ -35,7 +39,7 @@ func Command(ctx context.Context, name string, args ...string) *Cmd { } } -// Cmd represents a wrapped command +// Cmd represents a wrapped command. type Cmd struct { name string args []string @@ -45,31 +49,51 @@ type Cmd struct { logger Logger } -// WithDir sets the working directory +// WithDir sets the working directory. +// +// Example: +// +// cmd.WithDir("/tmp") func (c *Cmd) WithDir(dir string) *Cmd { c.opts.Dir = dir return c } -// WithEnv sets the environment variables +// WithEnv sets the environment variables. +// +// Example: +// +// cmd.WithEnv([]string{"CGO_ENABLED=0"}) func (c *Cmd) WithEnv(env []string) *Cmd { c.opts.Env = env return c } -// WithStdin sets stdin +// WithStdin sets stdin. +// +// Example: +// +// cmd.WithStdin(strings.NewReader("input")) func (c *Cmd) WithStdin(r io.Reader) *Cmd { c.opts.Stdin = r return c } -// WithStdout sets stdout +// WithStdout sets stdout. +// +// Example: +// +// cmd.WithStdout(os.Stdout) func (c *Cmd) WithStdout(w io.Writer) *Cmd { c.opts.Stdout = w return c } -// WithStderr sets stderr +// WithStderr sets stderr. +// +// Example: +// +// cmd.WithStderr(os.Stderr) func (c *Cmd) WithStderr(w io.Writer) *Cmd { c.opts.Stderr = w return c @@ -89,6 +113,10 @@ func (c *Cmd) WithBackground(background bool) *Cmd { } // Start launches the command. +// +// Example: +// +// if err := cmd.Start(); err != nil { return err } func (c *Cmd) Start() error { if err := c.prepare(); err != nil { return err @@ -112,6 +140,10 @@ func (c *Cmd) Start() error { // Run executes the command and waits for it to finish. // It automatically logs the command execution at debug level. +// +// Example: +// +// if err := cmd.Run(); err != nil { return err } func (c *Cmd) Run() error { if c.opts.Background { return c.Start() @@ -131,6 +163,10 @@ func (c *Cmd) Run() error { } // Output runs the command and returns its standard output. +// +// Example: +// +// out, err := cmd.Output() func (c *Cmd) Output() ([]byte, error) { if c.opts.Background { return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil) @@ -151,6 +187,10 @@ func (c *Cmd) Output() ([]byte, error) { } // CombinedOutput runs the command and returns its combined standard output and standard error. +// +// Example: +// +// out, err := cmd.CombinedOutput() func (c *Cmd) CombinedOutput() ([]byte, error) { if c.opts.Background { return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil) @@ -190,6 +230,10 @@ func (c *Cmd) prepare() error { // RunQuiet executes the command suppressing stdout unless there is an error. // Useful for internal commands. +// +// Example: +// +// err := exec.RunQuiet(ctx, "go", "vet", "./...") func RunQuiet(ctx context.Context, name string, args ...string) error { var stderr bytes.Buffer cmd := Command(ctx, name, args...).WithStderr(&stderr) diff --git a/exec/logger.go b/exec/logger.go index e8f5a6b..9a2992b 100644 --- a/exec/logger.go +++ b/exec/logger.go @@ -4,8 +4,14 @@ package exec // Compatible with pkg/log.Logger and other structured loggers. type Logger interface { // Debug logs a debug-level message with optional key-value pairs. + // + // Example: + // logger.Debug("starting", "cmd", "go") Debug(msg string, keyvals ...any) // Error logs an error-level message with optional key-value pairs. + // + // Example: + // logger.Error("failed", "cmd", "go", "err", err) Error(msg string, keyvals ...any) } @@ -22,6 +28,10 @@ var defaultLogger Logger = NopLogger{} // SetDefaultLogger sets the package-level default logger. // Commands without an explicit logger will use this. +// +// Example: +// +// exec.SetDefaultLogger(logger) func SetDefaultLogger(l Logger) { if l == nil { l = NopLogger{} @@ -30,6 +40,10 @@ func SetDefaultLogger(l Logger) { } // DefaultLogger returns the current default logger. +// +// Example: +// +// logger := exec.DefaultLogger() func DefaultLogger() Logger { return defaultLogger } diff --git a/health.go b/health.go index 923fbed..fba36f1 100644 --- a/health.go +++ b/health.go @@ -13,10 +13,10 @@ import ( coreerr "dappco.re/go/core/log" ) -// HealthCheck is a function that returns nil if healthy. +// HealthCheck is a function that returns nil when the service is healthy. type HealthCheck func() error -// HealthServer provides HTTP /health and /ready endpoints for process monitoring. +// HealthServer provides HTTP `/health` and `/ready` endpoints for process monitoring. type HealthServer struct { addr string server *http.Server @@ -27,6 +27,10 @@ type HealthServer struct { } // NewHealthServer creates a health check server on the given address. +// +// Example: +// +// server := process.NewHealthServer("127.0.0.1:0") func NewHealthServer(addr string) *HealthServer { return &HealthServer{ addr: addr, @@ -35,20 +39,45 @@ func NewHealthServer(addr string) *HealthServer { } // AddCheck registers a health check function. +// +// Example: +// +// server.AddCheck(func() error { return nil }) func (h *HealthServer) AddCheck(check HealthCheck) { h.mu.Lock() h.checks = append(h.checks, check) h.mu.Unlock() } -// SetReady sets the readiness status. +// SetReady sets the readiness status used by `/ready`. +// +// Example: +// +// server.SetReady(false) func (h *HealthServer) SetReady(ready bool) { h.mu.Lock() h.ready = ready h.mu.Unlock() } +// Ready reports whether `/ready` currently returns HTTP 200. +// +// Example: +// +// if server.Ready() { +// // publish the service +// } +func (h *HealthServer) Ready() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.ready +} + // Start begins serving health check endpoints. +// +// Example: +// +// if err := server.Start(); err != nil { return err } func (h *HealthServer) Start() error { mux := http.NewServeMux() @@ -100,6 +129,10 @@ func (h *HealthServer) Start() error { } // Stop gracefully shuts down the health server. +// +// Example: +// +// _ = server.Stop(context.Background()) func (h *HealthServer) Stop(ctx context.Context) error { if h.server == nil { return nil @@ -108,6 +141,10 @@ func (h *HealthServer) Stop(ctx context.Context) error { } // Addr returns the actual address the server is listening on. +// +// Example: +// +// addr := server.Addr() func (h *HealthServer) Addr() string { if h.listener != nil { return h.listener.Addr().String() @@ -115,16 +152,24 @@ func (h *HealthServer) Addr() string { return h.addr } -// WaitForHealth polls a health endpoint until it responds 200 or the timeout -// (in milliseconds) expires. Returns true if healthy, false on timeout. +// WaitForHealth polls `/health` until it responds 200 or the timeout expires. +// +// Example: +// +// if !process.WaitForHealth("127.0.0.1:8080", 5_000) { +// return errors.New("service did not become ready") +// } func WaitForHealth(addr string, timeoutMs int) bool { ok, _ := ProbeHealth(addr, timeoutMs) return ok } -// ProbeHealth polls a health endpoint until it responds 200 or the timeout -// (in milliseconds) expires. Returns the health status and the last observed -// failure reason if the endpoint never becomes healthy. +// ProbeHealth polls `/health` until it responds 200 or the timeout expires. +// It returns the health status and the last observed failure reason. +// +// Example: +// +// ok, reason := process.ProbeHealth("127.0.0.1:8080", 5_000) func ProbeHealth(addr string, timeoutMs int) (bool, string) { deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) url := fmt.Sprintf("http://%s/health", addr) diff --git a/health_test.go b/health_test.go index dad5bc3..d744661 100644 --- a/health_test.go +++ b/health_test.go @@ -11,6 +11,7 @@ import ( func TestHealthServer_Endpoints(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") + assert.True(t, hs.Ready()) err := hs.Start() require.NoError(t, err) defer func() { _ = hs.Stop(context.Background()) }() @@ -29,6 +30,7 @@ func TestHealthServer_Endpoints(t *testing.T) { _ = resp.Body.Close() hs.SetReady(false) + assert.False(t, hs.Ready()) resp, err = http.Get("http://" + addr + "/ready") require.NoError(t, err) @@ -36,6 +38,15 @@ func TestHealthServer_Endpoints(t *testing.T) { _ = resp.Body.Close() } +func TestHealthServer_Ready(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + + assert.True(t, hs.Ready()) + + hs.SetReady(false) + assert.False(t, hs.Ready()) +} + func TestHealthServer_WithChecks(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") diff --git a/pidfile.go b/pidfile.go index 909490d..3505c2d 100644 --- a/pidfile.go +++ b/pidfile.go @@ -14,18 +14,30 @@ import ( ) // PIDFile manages a process ID file for single-instance enforcement. +// +// Example: +// +// pidFile := process.NewPIDFile("/var/run/myapp.pid") type PIDFile struct { path string mu sync.Mutex } // NewPIDFile creates a PID file manager. +// +// Example: +// +// pidFile := process.NewPIDFile("/var/run/myapp.pid") func NewPIDFile(path string) *PIDFile { return &PIDFile{path: path} } // Acquire writes the current PID to the file. // Returns error if another instance is running. +// +// Example: +// +// if err := pidFile.Acquire(); err != nil { return err } func (p *PIDFile) Acquire() error { p.mu.Lock() defer p.mu.Unlock() @@ -57,6 +69,10 @@ func (p *PIDFile) Acquire() error { } // Release removes the PID file. +// +// Example: +// +// _ = pidFile.Release() func (p *PIDFile) Release() error { p.mu.Lock() defer p.mu.Unlock() @@ -67,6 +83,10 @@ func (p *PIDFile) Release() error { } // Path returns the PID file path. +// +// Example: +// +// path := pidFile.Path() func (p *PIDFile) Path() string { return p.path } @@ -74,6 +94,10 @@ func (p *PIDFile) Path() string { // ReadPID reads a PID file and checks if the process is still running. // Returns (pid, true) if the process is alive, (pid, false) if dead/stale, // or (0, false) if the file doesn't exist or is invalid. +// +// Example: +// +// pid, running := process.ReadPID("/var/run/myapp.pid") func ReadPID(path string) (int, bool) { data, err := coreio.Local.Read(path) if err != nil { diff --git a/process.go b/process.go index 17da313..c05185c 100644 --- a/process.go +++ b/process.go @@ -14,6 +14,10 @@ import ( ) // Process represents a managed external process. +// +// Example: +// +// proc, err := svc.Start(ctx, "echo", "hello") type Process struct { ID string Command string @@ -37,6 +41,10 @@ type Process struct { } // Info returns a snapshot of process state. +// +// Example: +// +// info := proc.Info() func (p *Process) Info() Info { p.mu.RLock() defer p.mu.RUnlock() @@ -61,6 +69,10 @@ func (p *Process) Info() Info { } // Output returns the captured output as a string. +// +// Example: +// +// fmt.Println(proc.Output()) func (p *Process) Output() string { p.mu.RLock() defer p.mu.RUnlock() @@ -71,6 +83,10 @@ func (p *Process) Output() string { } // OutputBytes returns the captured output as bytes. +// +// Example: +// +// data := proc.OutputBytes() func (p *Process) OutputBytes() []byte { p.mu.RLock() defer p.mu.RUnlock() @@ -88,6 +104,10 @@ func (p *Process) IsRunning() bool { } // Wait blocks until the process exits. +// +// Example: +// +// if err := proc.Wait(); err != nil { return err } func (p *Process) Wait() error { <-p.done p.mu.RLock() @@ -105,12 +125,20 @@ func (p *Process) Wait() error { } // Done returns a channel that closes when the process exits. +// +// Example: +// +// <-proc.Done() func (p *Process) Done() <-chan struct{} { return p.done } // Kill forcefully terminates the process. // If KillGroup is set, kills the entire process group. +// +// Example: +// +// _ = proc.Kill() func (p *Process) Kill() error { p.mu.Lock() defer p.mu.Unlock() @@ -133,6 +161,10 @@ func (p *Process) Kill() error { // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. // If GracePeriod was not set (zero), falls back to immediate Kill(). // If KillGroup is set, signals are sent to the entire process group. +// +// Example: +// +// _ = proc.Shutdown() func (p *Process) Shutdown() error { p.mu.RLock() grace := p.gracePeriod @@ -177,6 +209,10 @@ func (p *Process) terminate() error { } // Signal sends a signal to the process. +// +// Example: +// +// _ = proc.Signal(os.Interrupt) func (p *Process) Signal(sig os.Signal) error { p.mu.Lock() defer p.mu.Unlock() @@ -201,6 +237,10 @@ func (p *Process) Signal(sig os.Signal) error { } // SendInput writes to the process stdin. +// +// Example: +// +// _ = proc.SendInput("hello\n") func (p *Process) SendInput(input string) error { p.mu.RLock() defer p.mu.RUnlock() @@ -218,6 +258,10 @@ func (p *Process) SendInput(input string) error { } // CloseStdin closes the process stdin pipe. +// +// Example: +// +// _ = proc.CloseStdin() func (p *Process) CloseStdin() error { p.mu.Lock() defer p.mu.Unlock() diff --git a/process_global.go b/process_global.go index 74434e4..2689010 100644 --- a/process_global.go +++ b/process_global.go @@ -9,7 +9,7 @@ import ( coreerr "dappco.re/go/core/log" ) -// Global default service (follows i18n pattern). +// Global default service used by package-level helpers. var ( defaultService atomic.Pointer[Service] defaultOnce sync.Once @@ -18,12 +18,20 @@ var ( // Default returns the global process service. // Returns nil if not initialized. +// +// Example: +// +// svc := process.Default() func Default() *Service { return defaultService.Load() } // SetDefault sets the global process service. // Thread-safe: can be called concurrently with Default(). +// +// Example: +// +// _ = process.SetDefault(svc) func SetDefault(s *Service) error { if s == nil { return ErrSetDefaultNil @@ -34,6 +42,10 @@ func SetDefault(s *Service) error { // Init initializes the default global service with a Core instance. // This is typically called during application startup. +// +// Example: +// +// _ = process.Init(coreInstance) func Init(c *core.Core) error { defaultOnce.Do(func() { factory := NewService(Options{}) @@ -50,6 +62,10 @@ func Init(c *core.Core) error { // --- Global convenience functions --- // Start spawns a new process using the default service. +// +// Example: +// +// proc, err := process.Start(ctx, "echo", "hello") func Start(ctx context.Context, command string, args ...string) (*Process, error) { svc := Default() if svc == nil { @@ -59,6 +75,10 @@ func Start(ctx context.Context, command string, args ...string) (*Process, error } // Run executes a command and waits for completion using the default service. +// +// Example: +// +// out, err := process.Run(ctx, "echo", "hello") func Run(ctx context.Context, command string, args ...string) (string, error) { svc := Default() if svc == nil { @@ -68,6 +88,10 @@ func Run(ctx context.Context, command string, args ...string) (string, error) { } // Get returns a process by ID from the default service. +// +// Example: +// +// proc, err := process.Get("proc-1") func Get(id string) (*Process, error) { svc := Default() if svc == nil { @@ -77,6 +101,10 @@ func Get(id string) (*Process, error) { } // List returns all processes from the default service. +// +// Example: +// +// procs := process.List() func List() []*Process { svc := Default() if svc == nil { @@ -86,6 +114,10 @@ func List() []*Process { } // Kill terminates a process by ID using the default service. +// +// Example: +// +// _ = process.Kill("proc-1") func Kill(id string) error { svc := Default() if svc == nil { @@ -95,6 +127,10 @@ func Kill(id string) error { } // KillPID terminates a process by operating-system PID using the default service. +// +// Example: +// +// _ = process.KillPID(1234) func KillPID(pid int) error { svc := Default() if svc == nil { @@ -104,6 +140,10 @@ func KillPID(pid int) error { } // StartWithOptions spawns a process with full configuration using the default service. +// +// Example: +// +// proc, err := process.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"}) func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { svc := Default() if svc == nil { @@ -113,6 +153,10 @@ func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { } // RunWithOptions executes a command with options and waits using the default service. +// +// Example: +// +// out, err := process.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}}) func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { svc := Default() if svc == nil { @@ -122,6 +166,10 @@ func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { } // Running returns all currently running processes from the default service. +// +// Example: +// +// running := process.Running() func Running() []*Process { svc := Default() if svc == nil { diff --git a/program.go b/program.go index 7e2f328..192a3ee 100644 --- a/program.go +++ b/program.go @@ -21,17 +21,27 @@ var ErrProgramContextRequired = coreerr.E("", "program: command context is requi var ErrProgramNameRequired = coreerr.E("", "program: program name is empty", nil) // Program represents a named executable located on the system PATH. -// Create one with a Name, call Find to resolve its path, then Run or RunDir. +// +// Example: +// +// git := &process.Program{Name: "git"} +// if err := git.Find(); err != nil { return err } +// out, err := git.Run(ctx, "status") type Program struct { // Name is the binary name (e.g. "go", "node", "git"). Name string // Path is the absolute path resolved by Find. + // Example: "/usr/bin/git" // If empty, Run and RunDir fall back to Name for OS PATH resolution. Path string } // Find resolves the program's absolute path using exec.LookPath. // Returns ErrProgramNotFound (wrapped) if the binary is not on PATH. +// +// Example: +// +// if err := p.Find(); err != nil { return err } func (p *Program) Find() error { if p.Name == "" { return coreerr.E("Program.Find", "program name is empty", nil) @@ -46,6 +56,10 @@ func (p *Program) Find() error { // Run executes the program with args in the current working directory. // Returns trimmed combined stdout+stderr output and any error. +// +// Example: +// +// out, err := p.Run(ctx, "hello") func (p *Program) Run(ctx context.Context, args ...string) (string, error) { return p.RunDir(ctx, "", args...) } @@ -53,6 +67,10 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) { // RunDir executes the program with args in dir. // Returns trimmed combined stdout+stderr output and any error. // If dir is empty, the process inherits the caller's working directory. +// +// Example: +// +// out, err := p.RunDir(ctx, "/tmp", "pwd") func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { if ctx == nil { return "", coreerr.E("Program.RunDir", "program: command context is required", ErrProgramContextRequired) diff --git a/registry.go b/registry.go index 006cd23..cfc0722 100644 --- a/registry.go +++ b/registry.go @@ -14,6 +14,10 @@ import ( ) // DaemonEntry records a running daemon in the registry. +// +// Example: +// +// entry := process.DaemonEntry{Code: "app", Daemon: "serve", PID: os.Getpid()} type DaemonEntry struct { Code string `json:"code"` Daemon string `json:"daemon"` @@ -30,11 +34,19 @@ type Registry struct { } // NewRegistry creates a registry backed by the given directory. +// +// Example: +// +// reg := process.NewRegistry("/tmp/daemons") func NewRegistry(dir string) *Registry { return &Registry{dir: dir} } // DefaultRegistry returns a registry using ~/.core/daemons/. +// +// Example: +// +// reg := process.DefaultRegistry() func DefaultRegistry() *Registry { home, err := os.UserHomeDir() if err != nil { @@ -46,6 +58,10 @@ func DefaultRegistry() *Registry { // Register writes a daemon entry to the registry directory. // If Started is zero, it is set to the current time. // The directory is created if it does not exist. +// +// Example: +// +// _ = reg.Register(entry) func (r *Registry) Register(entry DaemonEntry) error { if entry.Started.IsZero() { entry.Started = time.Now() @@ -67,6 +83,10 @@ func (r *Registry) Register(entry DaemonEntry) error { } // Unregister removes a daemon entry from the registry. +// +// Example: +// +// _ = reg.Unregister("app", "serve") func (r *Registry) Unregister(code, daemon string) error { if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil { return coreerr.E("Registry.Unregister", "failed to delete entry file", err) @@ -76,6 +96,10 @@ func (r *Registry) Unregister(code, daemon string) error { // Get reads a single daemon entry and checks whether its process is alive. // If the process is dead, the stale file is removed and (nil, false) is returned. +// +// Example: +// +// entry, ok := reg.Get("app", "serve") func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { path := r.entryPath(code, daemon) @@ -99,6 +123,10 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { } // List returns all alive daemon entries, pruning any with dead PIDs. +// +// Example: +// +// entries, err := reg.List() func (r *Registry) List() ([]DaemonEntry, error) { matches, err := filepath.Glob(filepath.Join(r.dir, "*.json")) if err != nil { diff --git a/runner.go b/runner.go index 53da9b2..9a80fab 100644 --- a/runner.go +++ b/runner.go @@ -20,11 +20,19 @@ var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil) // NewRunner creates a runner for the given service. +// +// Example: +// +// runner := process.NewRunner(svc) func NewRunner(svc *Service) *Runner { return &Runner{service: svc} } // RunSpec defines a process to run with optional dependencies. +// +// Example: +// +// spec := process.RunSpec{Name: "test", Command: "go", Args: []string{"test", "./..."}} type RunSpec struct { // Name is a friendly identifier (e.g., "lint", "test"). Name string @@ -54,6 +62,10 @@ type RunResult struct { } // Passed returns true if the process succeeded. +// +// Example: +// +// if result.Passed() { ... } func (r RunResult) Passed() bool { return !r.Skipped && r.Error == nil && r.ExitCode == 0 } @@ -68,11 +80,19 @@ type RunAllResult struct { } // Success returns true if all non-skipped specs passed. +// +// Example: +// +// if result.Success() { ... } func (r RunAllResult) Success() bool { return r.Failed == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. +// +// Example: +// +// result, err := runner.RunAll(ctx, specs) func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err @@ -241,6 +261,10 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { } // RunSequential executes specs one after another, stopping on first failure. +// +// Example: +// +// result, err := runner.RunSequential(ctx, specs) func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err @@ -287,6 +311,10 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes } // RunParallel executes all specs concurrently, regardless of dependencies. +// +// Example: +// +// result, err := runner.RunParallel(ctx, specs) func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err diff --git a/service.go b/service.go index 5b905e8..afe2646 100644 --- a/service.go +++ b/service.go @@ -39,6 +39,10 @@ type Service struct { } // Options configures the process service. +// +// Example: +// +// svc := process.NewService(process.Options{BufferSize: 2 * 1024 * 1024}) type Options struct { // BufferSize is the ring buffer size for output capture. // Default: 1MB (1024 * 1024 bytes). @@ -50,6 +54,10 @@ type Options struct { // core, _ := core.New( // core.WithName("process", process.NewService(process.Options{})), // ) +// +// Example: +// +// factory := process.NewService(process.Options{}) func NewService(opts Options) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { if opts.BufferSize == 0 { @@ -65,6 +73,10 @@ func NewService(opts Options) func(*core.Core) (any, error) { } // OnStartup implements core.Startable. +// +// Example: +// +// _ = svc.OnStartup(ctx) func (s *Service) OnStartup(ctx context.Context) error { s.registrations.Do(func() { if s.Core() != nil { @@ -76,6 +88,10 @@ func (s *Service) OnStartup(ctx context.Context) error { // OnShutdown implements core.Stoppable. // Gracefully shuts down all running processes (SIGTERM → SIGKILL). +// +// Example: +// +// _ = svc.OnShutdown(ctx) func (s *Service) OnShutdown(ctx context.Context) error { s.mu.RLock() procs := make([]*Process, 0, len(s.processes)) @@ -94,6 +110,10 @@ func (s *Service) OnShutdown(ctx context.Context) error { } // Start spawns a new process with the given command and args. +// +// Example: +// +// proc, err := svc.Start(ctx, "echo", "hello") func (s *Service) Start(ctx context.Context, command string, args ...string) (*Process, error) { return s.StartWithOptions(ctx, RunOptions{ Command: command, @@ -102,6 +122,10 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P } // StartWithOptions spawns a process with full configuration. +// +// Example: +// +// proc, err := svc.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"}) func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { if opts.Command == "" { return nil, coreerr.E("Service.StartWithOptions", "command is required", nil) @@ -292,6 +316,10 @@ func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) { } // Get returns a process by ID. +// +// Example: +// +// proc, err := svc.Get("proc-1") func (s *Service) Get(id string) (*Process, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -304,6 +332,10 @@ func (s *Service) Get(id string) (*Process, error) { } // List returns all processes. +// +// Example: +// +// for _, proc := range svc.List() { _ = proc } func (s *Service) List() []*Process { s.mu.RLock() defer s.mu.RUnlock() @@ -317,6 +349,10 @@ func (s *Service) List() []*Process { } // Running returns all currently running processes. +// +// Example: +// +// running := svc.Running() func (s *Service) Running() []*Process { s.mu.RLock() defer s.mu.RUnlock() @@ -332,6 +368,10 @@ func (s *Service) Running() []*Process { } // Kill terminates a process by ID. +// +// Example: +// +// _ = svc.Kill("proc-1") func (s *Service) Kill(id string) error { proc, err := s.Get(id) if err != nil { @@ -342,6 +382,10 @@ func (s *Service) Kill(id string) error { } // KillPID terminates a process by operating-system PID. +// +// Example: +// +// _ = svc.KillPID(1234) func (s *Service) KillPID(pid int) error { if pid <= 0 { return coreerr.E("Service.KillPID", "pid must be positive", nil) @@ -355,6 +399,10 @@ func (s *Service) KillPID(pid int) error { } // Remove removes a completed process from the list. +// +// Example: +// +// _ = svc.Remove("proc-1") func (s *Service) Remove(id string) error { s.mu.Lock() defer s.mu.Unlock() @@ -373,6 +421,10 @@ func (s *Service) Remove(id string) error { } // Clear removes all completed processes. +// +// Example: +// +// svc.Clear() func (s *Service) Clear() { s.mu.Lock() defer s.mu.Unlock() @@ -385,6 +437,10 @@ func (s *Service) Clear() { } // Output returns the captured output of a process. +// +// Example: +// +// out, err := svc.Output("proc-1") func (s *Service) Output(id string) (string, error) { proc, err := s.Get(id) if err != nil { @@ -395,6 +451,10 @@ func (s *Service) Output(id string) (string, error) { // Run executes a command and waits for completion. // Returns the combined output and any error. +// +// Example: +// +// out, err := svc.Run(ctx, "echo", "hello") func (s *Service) Run(ctx context.Context, command string, args ...string) (string, error) { proc, err := s.Start(ctx, command, args...) if err != nil { @@ -414,6 +474,10 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri } // RunWithOptions executes a command with options and waits for completion. +// +// Example: +// +// out, err := svc.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}}) func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { proc, err := s.StartWithOptions(ctx, opts) if err != nil { diff --git a/types.go b/types.go index 73b4cad..a88547f 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,10 @@ // Package process provides process management with Core IPC integration. // +// Example: +// +// svc := process.NewService(process.Options{}) +// proc, err := svc.Start(ctx, "echo", "hello") +// // The process package enables spawning, monitoring, and controlling external // processes with output streaming via the Core ACTION system. // @@ -35,6 +40,10 @@ package process import "time" // Status represents the process lifecycle state. +// +// Example: +// +// if proc.Status == process.StatusKilled { return } type Status string const ( @@ -51,6 +60,10 @@ const ( ) // Stream identifies the output source. +// +// Example: +// +// if event.Stream == process.StreamStdout { ... } type Stream string const ( @@ -61,6 +74,13 @@ const ( ) // RunOptions configures process execution. +// +// Example: +// +// opts := process.RunOptions{ +// Command: "go", +// Args: []string{"test", "./..."}, +// } type RunOptions struct { // Command is the executable to run. Command string @@ -92,6 +112,11 @@ type RunOptions struct { } // Info provides a snapshot of process state without internal fields. +// +// Example: +// +// info := proc.Info() +// fmt.Println(info.PID) type Info struct { ID string `json:"id"` Command string `json:"command"` From 2255ade57e118fe81236d6ebeea381fc713428c8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:42:46 +0000 Subject: [PATCH 25/97] feat(process): harden process group signalling --- process.go | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/process.go b/process.go index c05185c..f6113a7 100644 --- a/process.go +++ b/process.go @@ -214,26 +214,50 @@ func (p *Process) terminate() error { // // _ = proc.Signal(os.Interrupt) func (p *Process) Signal(sig os.Signal) error { - p.mu.Lock() - defer p.mu.Unlock() + p.mu.RLock() + status := p.Status + cmd := p.cmd + killGroup := p.killGroup + p.mu.RUnlock() - if p.Status != StatusRunning { + if status != StatusRunning { return ErrProcessNotRunning } - if p.cmd == nil || p.cmd.Process == nil { + if cmd == nil || cmd.Process == nil { return nil } - if p.killGroup { - sysSig, ok := sig.(syscall.Signal) - if !ok { - return p.cmd.Process.Signal(sig) - } - return syscall.Kill(-p.cmd.Process.Pid, sysSig) + if !killGroup { + return cmd.Process.Signal(sig) } - return p.cmd.Process.Signal(sig) + sysSig, ok := sig.(syscall.Signal) + if !ok { + return cmd.Process.Signal(sig) + } + + if err := syscall.Kill(-cmd.Process.Pid, sysSig); err != nil { + return err + } + + // Some shells briefly ignore or defer the signal while they are still + // initialising child jobs. Retry once after a short delay so the whole + // process group is more reliably terminated. + go func(pid int, sig syscall.Signal, done <-chan struct{}) { + timer := time.NewTimer(50 * time.Millisecond) + defer timer.Stop() + + select { + case <-done: + return + case <-timer.C: + } + + _ = syscall.Kill(-pid, sig) + }(cmd.Process.Pid, sysSig, p.done) + + return nil } // SendInput writes to the process stdin. From 686f1053b301d5d7b385dac7f0d8c2e086336e61 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:45:40 +0000 Subject: [PATCH 26/97] feat(process): fix daemon stop order --- daemon.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/daemon.go b/daemon.go index 78b5120..cdf1e0e 100644 --- a/daemon.go +++ b/daemon.go @@ -164,10 +164,10 @@ func (d *Daemon) Stop() error { shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) defer cancel() - if d.health != nil { - d.health.SetReady(false) - if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) + // Auto-unregister + if d.opts.Registry != nil { + if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) } } @@ -177,9 +177,11 @@ func (d *Daemon) Stop() error { } } - // Auto-unregister - if d.opts.Registry != nil { - _ = d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon) + if d.health != nil { + d.health.SetReady(false) + if err := d.health.Stop(shutdownCtx); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) + } } d.running = false From d565e3539eaff3d8b449dc3212ad7870b30d24d8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:49:35 +0000 Subject: [PATCH 27/97] feat(process): add pipeline execution API --- pkg/api/provider.go | 88 +++++++++++++++++++++++++++++++-- pkg/api/provider_test.go | 86 ++++++++++++++++++++++++++++---- pkg/api/ui/dist/core-process.js | 5 +- 3 files changed, 163 insertions(+), 16 deletions(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index e2a9183..ec28250 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -5,9 +5,11 @@ package api import ( + "context" "net/http" "os" "strconv" + "strings" "syscall" "dappco.re/go/core/api" @@ -22,6 +24,8 @@ import ( // and provider.Renderable. type ProcessProvider struct { registry *process.Registry + service *process.Service + runner *process.Runner hub *ws.Hub } @@ -33,17 +37,24 @@ var ( _ provider.Renderable = (*ProcessProvider)(nil) ) -// NewProvider creates a process provider backed by the given daemon registry. +// NewProvider creates a process provider backed by the given daemon registry +// and optional process service for pipeline execution. +// // The WS hub is used to emit daemon state change events. Pass nil for hub // if WebSocket streaming is not needed. -func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider { +func NewProvider(registry *process.Registry, service *process.Service, hub *ws.Hub) *ProcessProvider { if registry == nil { registry = process.DefaultRegistry() } - return &ProcessProvider{ + p := &ProcessProvider{ registry: registry, + service: service, hub: hub, } + if service != nil { + p.runner = process.NewRunner(service) + } + return p } // Name implements api.RouteGroup. @@ -79,6 +90,7 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/daemons/:code/:daemon", p.getDaemon) rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon) rg.GET("/daemons/:code/:daemon/health", p.healthCheck) + rg.POST("/pipelines/run", p.runPipeline) } // Describe implements api.DescribableGroup. @@ -151,6 +163,25 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "POST", + Path: "/pipelines/run", + Summary: "Run a process pipeline", + Description: "Executes a list of process specs using the configured runner in sequential, parallel, or dependency-aware mode.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "results": map[string]any{ + "type": "array", + }, + "duration": map[string]any{"type": "integer"}, + "passed": map[string]any{"type": "integer"}, + "failed": map[string]any{"type": "integer"}, + "skipped": map[string]any{"type": "integer"}, + }, + }, + }, } } @@ -258,6 +289,57 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) { c.JSON(statusCode, api.OK(result)) } +type pipelineRunRequest struct { + Mode string `json:"mode"` + Specs []process.RunSpec `json:"specs"` +} + +func (p *ProcessProvider) runPipeline(c *gin.Context) { + if p.runner == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("runner_unavailable", "pipeline runner is not configured")) + return + } + + var req pipelineRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "all" + } + + ctx := c.Request.Context() + if ctx == nil { + ctx = context.Background() + } + + var ( + result *process.RunAllResult + err error + ) + + switch mode { + case "all": + result, err = p.runner.RunAll(ctx, req.Specs) + case "sequential": + result, err = p.runner.RunSequential(ctx, req.Specs) + case "parallel": + result, err = p.runner.RunParallel(ctx, req.Specs) + default: + c.JSON(http.StatusBadRequest, api.Fail("invalid_mode", "mode must be one of: all, sequential, parallel")) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("pipeline_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(result)) +} + // emitEvent sends a WS event if the hub is available. func (p *ProcessProvider) emitEvent(channel string, data any) { if p.hub == nil { diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 6a6f42c..a2e6f54 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + core "dappco.re/go/core" goapi "dappco.re/go/core/api" process "dappco.re/go/core/process" processapi "dappco.re/go/core/process/pkg/api" @@ -23,17 +24,17 @@ func init() { } func TestProcessProvider_Name_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) assert.Equal(t, "process", p.Name()) } func TestProcessProvider_BasePath_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) assert.Equal(t, "/api/process", p.BasePath()) } func TestProcessProvider_Channels_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) channels := p.Channels() assert.Contains(t, channels, "process.daemon.started") assert.Contains(t, channels, "process.daemon.stopped") @@ -41,9 +42,9 @@ func TestProcessProvider_Channels_Good(t *testing.T) { } func TestProcessProvider_Describe_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) descs := p.Describe() - assert.GreaterOrEqual(t, len(descs), 4) + assert.GreaterOrEqual(t, len(descs), 5) // Verify all descriptions have required fields for _, d := range descs { @@ -52,13 +53,22 @@ func TestProcessProvider_Describe_Good(t *testing.T) { assert.NotEmpty(t, d.Summary) assert.NotEmpty(t, d.Tags) } + + foundPipelineRoute := false + for _, d := range descs { + if d.Method == "POST" && d.Path == "/pipelines/run" { + foundPipelineRoute = true + break + } + } + assert.True(t, foundPipelineRoute, "pipeline route should be described") } func TestProcessProvider_ListDaemons_Good(t *testing.T) { // Use a temp directory so the registry has no daemons dir := t.TempDir() registry := newTestRegistry(dir) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() @@ -76,7 +86,7 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) { func TestProcessProvider_GetDaemon_Bad(t *testing.T) { dir := t.TempDir() registry := newTestRegistry(dir) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() @@ -104,7 +114,7 @@ func TestProcessProvider_HealthCheck_Bad(t *testing.T) { Health: hostPort, })) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() @@ -124,7 +134,7 @@ func TestProcessProvider_HealthCheck_Bad(t *testing.T) { } func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) engine, err := goapi.New() require.NoError(t, err) @@ -135,7 +145,7 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { } func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) engine, err := goapi.New() require.NoError(t, err) @@ -147,6 +157,51 @@ func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { assert.Contains(t, channels, "process.daemon.started") } +func TestProcessProvider_RunPipeline_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + + body := strings.NewReader(`{ + "mode": "parallel", + "specs": [ + {"name": "first", "command": "echo", "args": ["1"]}, + {"name": "second", "command": "echo", "args": ["2"]} + ] + }`) + req, err := http.NewRequest("POST", "/api/process/pipelines/run", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.RunAllResult] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 2, resp.Data.Passed) + assert.Len(t, resp.Data.Results, 2) +} + +func TestProcessProvider_RunPipeline_Unavailable(t *testing.T) { + p := processapi.NewProvider(nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/pipelines/run", strings.NewReader(`{"mode":"all","specs":[]}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + // -- Test helpers ------------------------------------------------------------- func setupRouter(p *processapi.ProcessProvider) *gin.Engine { @@ -160,3 +215,14 @@ func setupRouter(p *processapi.ProcessProvider) *gin.Engine { func newTestRegistry(dir string) *process.Registry { return process.NewRegistry(dir) } + +func newTestProcessService(t *testing.T) *process.Service { + t.Helper() + + c := core.New() + factory := process.NewService(process.Options{}) + raw, err := factory(c) + require.NoError(t, err) + + return raw.(*process.Service) +} diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index 3d499ec..5737a8f 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -1453,9 +1453,8 @@ let R = class extends $ { if (!this.result) return c`
- Pipeline runner endpoints are pending. Pass pipeline results via the - result property, or results will appear here once the REST - API for pipeline execution is available. + Pass pipeline results via the result property, or load + them from the REST API when your app executes a pipeline.
No pipeline results.
`; From 0e299e5349099e1ad91d34ba7874a82409a3f3f0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:52:58 +0000 Subject: [PATCH 28/97] feat(process): add process list core task --- actions.go | 10 ++++++++++ service.go | 12 ++++++++++++ service_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/actions.go b/actions.go index 5a73f44..8a2526d 100644 --- a/actions.go +++ b/actions.go @@ -39,6 +39,16 @@ type TaskProcessKill struct { PID int } +// TaskProcessList requests a snapshot of managed processes through Core.PERFORM. +// If RunningOnly is true, only active processes are returned. +// +// Example: +// +// c.PERFORM(process.TaskProcessList{RunningOnly: true}) +type TaskProcessList struct { + RunningOnly bool +} + // ActionProcessStarted is broadcast when a process begins execution. // // Example: diff --git a/service.go b/service.go index afe2646..5a1745c 100644 --- a/service.go +++ b/service.go @@ -530,6 +530,18 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { default: return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} } + case TaskProcessList: + procs := s.List() + if m.RunningOnly { + procs = s.Running() + } + + infos := make([]Info, 0, len(procs)) + for _, proc := range procs { + infos = append(infos, proc.Info()) + } + + return core.Result{Value: infos, OK: true} default: return core.Result{} } diff --git a/service_test.go b/service_test.go index a69749c..4437164 100644 --- a/service_test.go +++ b/service_test.go @@ -549,6 +549,31 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, StatusKilled, proc.Status) }) + + t.Run("registers process.list task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessList{RunningOnly: true}) + require.True(t, result.OK) + + infos, ok := result.Value.([]Info) + require.True(t, ok) + require.Len(t, infos, 1) + assert.Equal(t, proc.ID, infos[0].ID) + assert.True(t, infos[0].Running) + + cancel() + <-proc.Done() + }) } func TestService_RunWithOptions(t *testing.T) { From dfa97f2112ca39eab980deb8c2483f135d511436 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 00:55:49 +0000 Subject: [PATCH 29/97] fix(process): allow standalone service usage --- service.go | 58 +++++++++++++++++++++++++++++++------------------ service_test.go | 17 +++++++++++++++ 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/service.go b/service.go index 5a1745c..7614d51 100644 --- a/service.go +++ b/service.go @@ -38,6 +38,14 @@ type Service struct { registrations sync.Once } +// coreApp returns the attached Core runtime, if one exists. +func (s *Service) coreApp() *core.Core { + if s == nil || s.ServiceRuntime == nil { + return nil + } + return s.ServiceRuntime.Core() +} + // Options configures the process service. // // Example: @@ -79,8 +87,8 @@ func NewService(opts Options) func(*core.Core) (any, error) { // _ = svc.OnStartup(ctx) func (s *Service) OnStartup(ctx context.Context) error { s.registrations.Do(func() { - if s.Core() != nil { - s.Core().RegisterTask(s.handleTask) + if c := s.coreApp(); c != nil { + c.RegisterTask(s.handleTask) } }) return nil @@ -204,8 +212,8 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Start the process if err := cmd.Start(); err != nil { cancel() - if s.Core() != nil { - _ = s.Core().ACTION(ActionProcessExited{ + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessExited{ ID: id, ExitCode: -1, Duration: time.Since(startedAt), @@ -233,13 +241,15 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce } // Broadcast start - _ = s.Core().ACTION(ActionProcessStarted{ - ID: id, - Command: opts.Command, - Args: opts.Args, - Dir: opts.Dir, - PID: cmd.Process.Pid, - }) + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessStarted{ + ID: id, + Command: opts.Command, + Args: opts.Args, + Dir: opts.Dir, + PID: cmd.Process.Pid, + }) + } // Stream output in goroutines var wg sync.WaitGroup @@ -280,13 +290,17 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce } if status == StatusKilled { exitAction.Error = coreerr.E("Service.StartWithOptions", "process was killed", nil) - _ = s.Core().ACTION(ActionProcessKilled{ - ID: id, - Signal: signalName, - }) + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessKilled{ + ID: id, + Signal: signalName, + }) + } } - _ = s.Core().ACTION(exitAction) + if c := s.coreApp(); c != nil { + _ = c.ACTION(exitAction) + } }() return proc, nil @@ -307,11 +321,13 @@ func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) { } // Broadcast output - _ = s.Core().ACTION(ActionProcessOutput{ - ID: proc.ID, - Line: line, - Stream: stream, - }) + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessOutput{ + ID: proc.ID, + Line: line, + Stream: stream, + }) + } } } diff --git a/service_test.go b/service_test.go index 4437164..aa80c46 100644 --- a/service_test.go +++ b/service_test.go @@ -44,6 +44,23 @@ func TestService_Start(t *testing.T) { assert.Contains(t, proc.Output(), "hello") }) + t.Run("works without core runtime", func(t *testing.T) { + svc := &Service{ + processes: make(map[string]*Process), + bufSize: 1024, + } + + proc, err := svc.Start(context.Background(), "echo", "standalone") + require.NoError(t, err) + require.NotNil(t, proc) + + <-proc.Done() + + assert.Equal(t, StatusExited, proc.Status) + assert.Equal(t, 0, proc.ExitCode) + assert.Contains(t, proc.Output(), "standalone") + }) + t.Run("failing command", func(t *testing.T) { svc, _ := newTestService(t) From eb6a7819e711b04aabb31399ac8b676f56863eb4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:00:27 +0000 Subject: [PATCH 30/97] feat(process): emit kill action immediately --- process.go | 34 ++++++++++++++++++++------------- service.go | 50 +++++++++++++++++++++++++++++++++++++++---------- service_test.go | 13 +++++++++---- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/process.go b/process.go index f6113a7..1b2fc65 100644 --- a/process.go +++ b/process.go @@ -29,15 +29,17 @@ type Process struct { ExitCode int Duration time.Duration - cmd *exec.Cmd - ctx context.Context - cancel context.CancelFunc - output *RingBuffer - stdin io.WriteCloser - done chan struct{} - mu sync.RWMutex - gracePeriod time.Duration - killGroup bool + cmd *exec.Cmd + ctx context.Context + cancel context.CancelFunc + output *RingBuffer + stdin io.WriteCloser + done chan struct{} + mu sync.RWMutex + gracePeriod time.Duration + killGroup bool + killNotified bool + killSignal string } // Info returns a snapshot of process state. @@ -140,22 +142,28 @@ func (p *Process) Done() <-chan struct{} { // // _ = proc.Kill() func (p *Process) Kill() error { + _, err := p.kill() + return err +} + +// kill terminates the process and reports whether a signal was actually sent. +func (p *Process) kill() (bool, error) { p.mu.Lock() defer p.mu.Unlock() if p.Status != StatusRunning { - return nil + return false, nil } if p.cmd == nil || p.cmd.Process == nil { - return nil + return false, nil } if p.killGroup { // Kill entire process group (negative PID) - return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) + return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) } - return p.cmd.Process.Kill() + return true, p.cmd.Process.Kill() } // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. diff --git a/service.go b/service.go index 7614d51..4f51104 100644 --- a/service.go +++ b/service.go @@ -282,21 +282,16 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce close(proc.done) + if status == StatusKilled { + s.emitKilledAction(proc, signalName) + } + exitAction := ActionProcessExited{ ID: id, ExitCode: exitCode, Duration: duration, Error: exitErr, } - if status == StatusKilled { - exitAction.Error = coreerr.E("Service.StartWithOptions", "process was killed", nil) - if c := s.coreApp(); c != nil { - _ = c.ACTION(ActionProcessKilled{ - ID: id, - Signal: signalName, - }) - } - } if c := s.coreApp(); c != nil { _ = c.ACTION(exitAction) @@ -394,7 +389,14 @@ func (s *Service) Kill(id string) error { return err } - return proc.Kill() + sent, err := proc.kill() + if err != nil { + return err + } + if sent { + s.emitKilledAction(proc, "SIGKILL") + } + return nil } // KillPID terminates a process by operating-system PID. @@ -584,6 +586,34 @@ func classifyProcessExit(err error) (Status, int, error, string) { return StatusFailed, 0, err, "" } +// emitKilledAction broadcasts a kill event once for the given process. +func (s *Service) emitKilledAction(proc *Process, signalName string) { + if proc == nil { + return + } + + proc.mu.Lock() + if proc.killNotified { + proc.mu.Unlock() + return + } + proc.killNotified = true + if signalName != "" { + proc.killSignal = signalName + } else if proc.killSignal == "" { + proc.killSignal = "SIGKILL" + } + signal := proc.killSignal + proc.mu.Unlock() + + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessKilled{ + ID: proc.ID, + Signal: signal, + }) + } +} + // sortProcesses orders processes by start time, then ID for stable output. func sortProcesses(procs []*Process) { sort.Slice(procs, func(i, j int) bool { diff --git a/service_test.go b/service_test.go index aa80c46..cad5544 100644 --- a/service_test.go +++ b/service_test.go @@ -294,6 +294,14 @@ func TestService_Actions(t *testing.T) { err = svc.Kill(proc.ID) require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + mu.Lock() + require.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) + mu.Unlock() + select { case <-proc.Done(): case <-time.After(2 * time.Second): @@ -304,12 +312,9 @@ func TestService_Actions(t *testing.T) { mu.Lock() defer mu.Unlock() - assert.Len(t, killed, 1) - assert.Equal(t, proc.ID, killed[0].ID) - assert.NotEmpty(t, killed[0].Signal) assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Error(t, exited[0].Error) + assert.NoError(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) From 90ce26a1b73ae5c5bcb605c2ad33718598e66dc0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:03:42 +0000 Subject: [PATCH 31/97] feat(api): expose managed process routes Co-Authored-By: Virgil --- pkg/api/provider.go | 111 +++++++++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 102 +++++++++++++++++++++++++++++++++++ process.go | 21 ++++---- 3 files changed, 224 insertions(+), 10 deletions(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index ec28250..a3adbcd 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -90,6 +90,9 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/daemons/:code/:daemon", p.getDaemon) rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon) rg.GET("/daemons/:code/:daemon/health", p.healthCheck) + rg.GET("/processes", p.listProcesses) + rg.GET("/processes/:id", p.getProcess) + rg.POST("/processes/:id/kill", p.killProcess) rg.POST("/pipelines/run", p.runPipeline) } @@ -163,6 +166,66 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "GET", + Path: "/processes", + Summary: "List managed processes", + Description: "Returns the current process service snapshot as serialisable process info entries.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + }, + { + Method: "GET", + Path: "/processes/:id", + Summary: "Get a managed process", + Description: "Returns a single managed process by ID as a process info snapshot.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/kill", + Summary: "Kill a managed process", + Description: "Sends SIGKILL to the managed process identified by ID.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "killed": map[string]any{"type": "boolean"}, + }, + }, + }, { Method: "POST", Path: "/pipelines/run", @@ -289,6 +352,54 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) { c.JSON(statusCode, api.OK(result)) } +func (p *ProcessProvider) listProcesses(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + procs := p.service.List() + infos := make([]process.Info, 0, len(procs)) + for _, proc := range procs { + infos = append(infos, proc.Info()) + } + + c.JSON(http.StatusOK, api.OK(infos)) +} + +func (p *ProcessProvider) getProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + proc, err := p.service.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("not_found", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(proc.Info())) +} + +func (p *ProcessProvider) killProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + if err := p.service.Kill(c.Param("id")); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("kill_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) +} + type pipelineRunRequest struct { Mode string `json:"mode"` Specs []process.RunSpec `json:"specs"` diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index a2e6f54..34de0af 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -3,12 +3,14 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" + "time" core "dappco.re/go/core" goapi "dappco.re/go/core/api" @@ -202,6 +204,106 @@ func TestProcessProvider_RunPipeline_Unavailable(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, w.Code) } +func TestProcessProvider_ListProcesses_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "hello-api") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + require.Len(t, resp.Data, 1) + assert.Equal(t, proc.ID, resp.Data[0].ID) + assert.Equal(t, "echo", resp.Data[0].Command) +} + +func TestProcessProvider_GetProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "single") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID, nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, proc.ID, resp.Data.ID) + assert.Equal(t, "echo", resp.Data.Command) +} + +func TestProcessProvider_KillProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/kill", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["killed"]) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been killed") + } + assert.Equal(t, process.StatusKilled, proc.Status) +} + +func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { + p := processapi.NewProvider(nil, nil, nil) + r := setupRouter(p) + + cases := []string{ + "/api/process/processes", + "/api/process/processes/anything", + "/api/process/processes/anything/kill", + } + + for _, path := range cases { + w := httptest.NewRecorder() + req, err := http.NewRequest("GET", path, nil) + if strings.HasSuffix(path, "/kill") { + req, err = http.NewRequest("POST", path, nil) + } + require.NoError(t, err) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + } +} + // -- Test helpers ------------------------------------------------------------- func setupRouter(p *processapi.ProcessProvider) *gin.Engine { diff --git a/process.go b/process.go index 1b2fc65..94ba6f5 100644 --- a/process.go +++ b/process.go @@ -250,19 +250,20 @@ func (p *Process) Signal(sig os.Signal) error { } // Some shells briefly ignore or defer the signal while they are still - // initialising child jobs. Retry once after a short delay so the whole - // process group is more reliably terminated. + // initialising child jobs. Retry a few times after short delays so the + // whole process group is more reliably terminated. go func(pid int, sig syscall.Signal, done <-chan struct{}) { - timer := time.NewTimer(50 * time.Millisecond) - defer timer.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() - select { - case <-done: - return - case <-timer.C: + for i := 0; i < 5; i++ { + select { + case <-done: + return + case <-ticker.C: + _ = syscall.Kill(-pid, sig) + } } - - _ = syscall.Kill(-pid, sig) }(cmd.Process.Pid, sysSig, p.done) return nil From 1ccc61848b790a3a0596897940f4f3b32dea5ff6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:07:16 +0000 Subject: [PATCH 32/97] fix(process): kill running processes on shutdown Co-Authored-By: Virgil --- service.go | 4 ++-- service_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/service.go b/service.go index 4f51104..c2d22c2 100644 --- a/service.go +++ b/service.go @@ -95,7 +95,7 @@ func (s *Service) OnStartup(ctx context.Context) error { } // OnShutdown implements core.Stoppable. -// Gracefully shuts down all running processes (SIGTERM → SIGKILL). +// Immediately kills all running processes to avoid shutdown stalls. // // Example: // @@ -111,7 +111,7 @@ func (s *Service) OnShutdown(ctx context.Context) error { s.mu.RUnlock() for _, p := range procs { - _ = p.Shutdown() + _ = p.Kill() } return nil diff --git a/service_test.go b/service_test.go index cad5544..0e56afc 100644 --- a/service_test.go +++ b/service_test.go @@ -515,6 +515,31 @@ func TestService_OnShutdown(t *testing.T) { t.Fatal("proc2 should have been killed") } }) + + t.Run("does not wait for process grace period", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "trap '' TERM; sleep 60"}, + GracePeriod: 5 * time.Second, + }) + require.NoError(t, err) + require.True(t, proc.IsRunning()) + + start := time.Now() + err = svc.OnShutdown(context.Background()) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed immediately on shutdown") + } + + assert.Less(t, time.Since(start), 2*time.Second) + assert.Equal(t, StatusKilled, proc.Status) + }) } func TestService_OnStartup(t *testing.T) { From 31be7280a60618d2c3402d5628eeb5d5a50e3cc5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:11:03 +0000 Subject: [PATCH 33/97] feat(process): honor pending lifecycle --- process_test.go | 12 ++++++++++++ service.go | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/process_test.go b/process_test.go index f7590d0..91c3405 100644 --- a/process_test.go +++ b/process_test.go @@ -28,6 +28,18 @@ func TestProcess_Info(t *testing.T) { assert.Greater(t, info.Duration, time.Duration(0)) } +func TestProcess_Info_Pending(t *testing.T) { + proc := &Process{ + ID: "pending", + Status: StatusPending, + done: make(chan struct{}), + } + + info := proc.Info() + assert.Equal(t, StatusPending, info.Status) + assert.False(t, info.Running) +} + func TestProcess_InfoSnapshot(t *testing.T) { svc, _ := newTestService(t) diff --git a/service.go b/service.go index c2d22c2..0df87ad 100644 --- a/service.go +++ b/service.go @@ -198,7 +198,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce Dir: opts.Dir, Env: append([]string(nil), opts.Env...), StartedAt: startedAt, - Status: StatusRunning, + Status: StatusPending, cmd: cmd, ctx: procCtx, cancel: cancel, @@ -211,6 +211,10 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Start the process if err := cmd.Start(); err != nil { + proc.mu.Lock() + proc.Status = StatusFailed + proc.mu.Unlock() + cancel() if c := s.coreApp(); c != nil { _ = c.ACTION(ActionProcessExited{ @@ -223,6 +227,10 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) } + proc.mu.Lock() + proc.Status = StatusRunning + proc.mu.Unlock() + // Store process s.mu.Lock() s.processes[id] = proc From 82e85a99fdd49f0edd2418da2b53295eb97a09a5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:13:33 +0000 Subject: [PATCH 34/97] feat(process): report kill errors in exit actions --- service.go | 2 +- service_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service.go b/service.go index 0df87ad..7e30f5a 100644 --- a/service.go +++ b/service.go @@ -586,7 +586,7 @@ func classifyProcessExit(err error) (Status, int, error, string) { if signalName == "" { signalName = "signal" } - return StatusKilled, -1, nil, signalName + return StatusKilled, -1, coreerr.E("Service.StartWithOptions", "process was killed", nil), signalName } return StatusExited, exitErr.ExitCode(), nil, "" } diff --git a/service_test.go b/service_test.go index 0e56afc..4520d6c 100644 --- a/service_test.go +++ b/service_test.go @@ -314,7 +314,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.NoError(t, exited[0].Error) + assert.Error(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) From 16e5c57fd4276acbbdceabf965913945a97a38e9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:16:58 +0000 Subject: [PATCH 35/97] feat(process): skip pending runner specs on cancellation Co-Authored-By: Virgil --- runner.go | 44 +++++++++++++++++++++++++++++++++++++++----- runner_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/runner.go b/runner.go index 9a80fab..8bd0a34 100644 --- a/runner.go +++ b/runner.go @@ -123,6 +123,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } for len(remaining) > 0 { + if err := ctx.Err(); err != nil { + for name := range remaining { + results[indexMap[name]] = cancelledRunResult("Runner.RunAll", remaining[name], err) + } + break + } + // Find specs ready to run (all dependencies satisfied) ready := make([]RunSpec, 0) for _, spec := range remaining { @@ -276,17 +283,18 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes results := make([]RunResult, 0, len(specs)) for _, spec := range specs { + if err := ctx.Err(); err != nil { + results = append(results, cancelledRunResult("Runner.RunSequential", spec, err)) + continue + } + result := r.runSpec(ctx, spec) results = append(results, result) if !result.Passed() && !spec.AllowFailure { // Mark remaining as skipped for i := len(results); i < len(specs); i++ { - results = append(results, RunResult{ - Name: specs[i].Name, - Spec: specs[i], - Skipped: true, - }) + results = append(results, skippedRunResult("Runner.RunSequential", specs[i], nil)) } break } @@ -330,6 +338,10 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul wg.Add(1) go func(i int, spec RunSpec) { defer wg.Done() + if err := ctx.Err(); err != nil { + results[i] = cancelledRunResult("Runner.RunParallel", spec, err) + return + } results[i] = r.runSpec(ctx, spec) }(i, spec) } @@ -366,3 +378,25 @@ func validateSpecs(specs []RunSpec) error { } return nil } + +func skippedRunResult(op string, spec RunSpec, err error) RunResult { + result := RunResult{ + Name: spec.Name, + Spec: spec, + Skipped: true, + } + if err != nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "skipped", err) + } + return result +} + +func cancelledRunResult(op string, spec RunSpec, err error) RunResult { + result := skippedRunResult(op, spec, err) + if result.Error == nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "context cancelled", err) + } + return result +} diff --git a/runner_test.go b/runner_test.go index a6081ca..9b16729 100644 --- a/runner_test.go +++ b/runner_test.go @@ -206,6 +206,56 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) } +func TestRunner_ContextCancellation(t *testing.T) { + t.Run("run sequential skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunSequential(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) + + t.Run("run all skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunAll(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) +} + func TestRunResult_Passed(t *testing.T) { t.Run("success", func(t *testing.T) { r := RunResult{ExitCode: 0} From 498137fa8e19b261dbab4b1e91f4534d398d7c12 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:19:59 +0000 Subject: [PATCH 36/97] refactor(process): align Program with AX helpers Co-Authored-By: Virgil --- program.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/program.go b/program.go index 192a3ee..cf5cb94 100644 --- a/program.go +++ b/program.go @@ -3,10 +3,9 @@ package process import ( "bytes" "context" - "fmt" "os/exec" - "strings" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" ) @@ -48,7 +47,7 @@ func (p *Program) Find() error { } path, err := exec.LookPath(p.Name) if err != nil { - return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound) + return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound) } p.Path = path return nil @@ -94,7 +93,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin } if err := cmd.Run(); err != nil { - return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err) + return core.Trim(out.String()), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err) } - return strings.TrimSpace(out.String()), nil + return core.Trim(out.String()), nil } From ab024325433fe93c81e8553673a54cb51ae8c28a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:22:58 +0000 Subject: [PATCH 37/97] fix(process): unify pid kill handling --- service.go | 27 +++++++++++++++++++++++++++ service_test.go | 11 +++++++++++ 2 files changed, 38 insertions(+) diff --git a/service.go b/service.go index 7e30f5a..4794310 100644 --- a/service.go +++ b/service.go @@ -417,6 +417,17 @@ func (s *Service) KillPID(pid int) error { return coreerr.E("Service.KillPID", "pid must be positive", nil) } + if proc := s.findByPID(pid); proc != nil { + sent, err := proc.kill() + if err != nil { + return err + } + if sent { + s.emitKilledAction(proc, "SIGKILL") + } + return nil + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) } @@ -475,6 +486,22 @@ func (s *Service) Output(id string) (string, error) { return proc.Output(), nil } +// findByPID locates a managed process by operating-system PID. +func (s *Service) findByPID(pid int) *Process { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, proc := range s.processes { + proc.mu.RLock() + matches := proc.cmd != nil && proc.cmd.Process != nil && proc.cmd.Process.Pid == pid + proc.mu.RUnlock() + if matches { + return proc + } + } + return nil +} + // Run executes a command and waits for completion. // Returns the combined output and any error. // diff --git a/service_test.go b/service_test.go index 4520d6c..5d91099 100644 --- a/service_test.go +++ b/service_test.go @@ -585,6 +585,14 @@ func TestService_OnStartup(t *testing.T) { require.NoError(t, err) require.True(t, proc.IsRunning()) + var killed []ActionProcessKilled + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + if m, ok := msg.(ActionProcessKilled); ok { + killed = append(killed, m) + } + return framework.Result{OK: true} + }) + result := c.PERFORM(TaskProcessKill{PID: proc.Info().PID}) require.True(t, result.OK) @@ -595,6 +603,9 @@ func TestService_OnStartup(t *testing.T) { } assert.Equal(t, StatusKilled, proc.Status) + require.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) }) t.Run("registers process.list task", func(t *testing.T) { From 2e5ac4208be6478e9861189fd856e0d4d4a2ee88 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:26:42 +0000 Subject: [PATCH 38/97] feat(process): auto-populate daemon registry metadata Co-Authored-By: Virgil --- daemon.go | 12 +++++++++++- daemon_test.go | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/daemon.go b/daemon.go index cdf1e0e..ecf8cea 100644 --- a/daemon.go +++ b/daemon.go @@ -39,7 +39,7 @@ type DaemonOptions struct { Registry *Registry // RegistryEntry provides the code and daemon name for registration. - // PID, Health, and Started are filled automatically. + // PID, Health, Project, Binary, and Started are filled automatically. RegistryEntry DaemonEntry } @@ -113,6 +113,16 @@ func (d *Daemon) Start() error { if d.health != nil { entry.Health = d.health.Addr() } + if entry.Project == "" { + if wd, err := os.Getwd(); err == nil { + entry.Project = wd + } + } + if entry.Binary == "" { + if binary, err := os.Executable(); err == nil { + entry.Binary = binary + } + } if err := d.opts.Registry.Register(entry); err != nil { if d.health != nil { _ = d.health.Stop(context.Background()) diff --git a/daemon_test.go b/daemon_test.go index 8d8f802..cb862b5 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -144,6 +144,10 @@ func TestDaemon_StopIdempotent(t *testing.T) { func TestDaemon_AutoRegisters(t *testing.T) { dir := t.TempDir() reg := NewRegistry(filepath.Join(dir, "daemons")) + wd, err := os.Getwd() + require.NoError(t, err) + exe, err := os.Executable() + require.NoError(t, err) d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", @@ -154,7 +158,7 @@ func TestDaemon_AutoRegisters(t *testing.T) { }, }) - err := d.Start() + err = d.Start() require.NoError(t, err) // Should be registered @@ -162,6 +166,8 @@ func TestDaemon_AutoRegisters(t *testing.T) { require.True(t, ok) assert.Equal(t, os.Getpid(), entry.PID) assert.NotEmpty(t, entry.Health) + assert.Equal(t, wd, entry.Project) + assert.Equal(t, exe, entry.Binary) // Stop should unregister err = d.Stop() From ba4b0f11666c0f5d88f0412af007b4e2be9b3b49 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:29:54 +0000 Subject: [PATCH 39/97] Force kill unmanaged PIDs --- service.go | 4 ++-- service_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/service.go b/service.go index 4794310..35fedc2 100644 --- a/service.go +++ b/service.go @@ -407,7 +407,7 @@ func (s *Service) Kill(id string) error { return nil } -// KillPID terminates a process by operating-system PID. +// KillPID forcefully terminates a process by operating-system PID. // // Example: // @@ -428,7 +428,7 @@ func (s *Service) KillPID(pid int) error { return nil } - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) } diff --git a/service_test.go b/service_test.go index 5d91099..ba3b0ef 100644 --- a/service_test.go +++ b/service_test.go @@ -2,6 +2,7 @@ package process import ( "context" + "os/exec" "strings" "sync" "testing" @@ -465,6 +466,40 @@ func TestService_Kill(t *testing.T) { }) } +func TestService_KillPID(t *testing.T) { + t.Run("force kills unmanaged process", func(t *testing.T) { + svc, _ := newTestService(t) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := svc.KillPID(cmd.Process.Pid) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been killed") + } + }) +} + func TestService_Output(t *testing.T) { t.Run("returns captured output", func(t *testing.T) { svc, _ := newTestService(t) From 1028e31ae564dcfa7e2f9ab4df7e7602fd98b4c6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:32:43 +0000 Subject: [PATCH 40/97] Fix process group signal escalation --- process.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/process.go b/process.go index 94ba6f5..2c153d8 100644 --- a/process.go +++ b/process.go @@ -251,7 +251,8 @@ func (p *Process) Signal(sig os.Signal) error { // Some shells briefly ignore or defer the signal while they are still // initialising child jobs. Retry a few times after short delays so the - // whole process group is more reliably terminated. + // whole process group is more reliably terminated. If the requested signal + // still does not stop the group, escalate to SIGKILL so callers do not hang. go func(pid int, sig syscall.Signal, done <-chan struct{}) { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -264,6 +265,13 @@ func (p *Process) Signal(sig os.Signal) error { _ = syscall.Kill(-pid, sig) } } + + select { + case <-done: + return + default: + _ = syscall.Kill(-pid, syscall.SIGKILL) + } }(cmd.Process.Pid, sysSig, p.done) return nil From 4b1013a023e115ebd3387b876bf14b31cae694c2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:37:09 +0000 Subject: [PATCH 41/97] feat(process-ui): wire process REST API --- pkg/api/ui/dist/core-process.js | 150 ++++++++++++++++++++------------ ui/src/process-list.ts | 37 ++++++-- ui/src/process-runner.ts | 13 +-- ui/src/shared/api.ts | 39 +++++++++ 4 files changed, 167 insertions(+), 72 deletions(-) diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index 5737a8f..e312e18 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -22,14 +22,14 @@ let $e = class { return this.cssText; } }; -const Ae = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => { +const ke = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => { const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => { if (o._$cssResult$ === !0) return o.cssText; if (typeof o == "number") return o; throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + s[n + 1], s[0]); return new $e(t, s, re); -}, ke = (s, e) => { +}, Se = (s, e) => { if (se) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const t of e) { const i = document.createElement("style"), r = V.litNonce; @@ -38,17 +38,17 @@ const Ae = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, }, ae = se ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { let t = ""; for (const i of e.cssRules) t += i.cssText; - return Ae(t); + return ke(t); })(s) : s; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnPropertyNames: Ee, getOwnPropertySymbols: Ue, getPrototypeOf: Oe } = Object, A = globalThis, le = A.trustedTypes, ze = le ? le.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { +const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, le = A.trustedTypes, Te = le ? le.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { switch (e) { case Boolean: - s = s ? ze : null; + s = s ? Te : null; break; case Object: case Array: @@ -73,9 +73,9 @@ const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnProperty } } return t; -} }, ie = (s, e) => !Se(s, e), ce = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: ie }; +} }, ie = (s, e) => !Pe(s, e), ce = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: ie }; Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); -let D = class extends HTMLElement { +let T = class extends HTMLElement { static addInitializer(e) { this._$Ei(), (this.l ?? (this.l = [])).push(e); } @@ -85,11 +85,11 @@ let D = class extends HTMLElement { static createProperty(e, t = ce) { if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) { const i = Symbol(), r = this.getPropertyDescriptor(e, i, t); - r !== void 0 && Pe(this.prototype, e, r); + r !== void 0 && Ce(this.prototype, e, r); } } static getPropertyDescriptor(e, t, i) { - const { get: r, set: n } = Ce(this.prototype, e) ?? { get() { + const { get: r, set: n } = Ee(this.prototype, e) ?? { get() { return this[t]; }, set(o) { this[t] = o; @@ -104,13 +104,13 @@ let D = class extends HTMLElement { } static _$Ei() { if (this.hasOwnProperty(j("elementProperties"))) return; - const e = Oe(this); + const e = ze(this); e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties); } static finalize() { if (this.hasOwnProperty(j("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(j("properties"))) { - const t = this.properties, i = [...Ee(t), ...Ue(t)]; + const t = this.properties, i = [...Ue(t), ...Oe(t)]; for (const r of i) this.createProperty(r, t[r]); } const e = this[Symbol.metadata]; @@ -159,7 +159,7 @@ let D = class extends HTMLElement { } createRenderRoot() { const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return ke(e, this.constructor.elementStyles), e; + return Se(e, this.constructor.elementStyles), e; } connectedCallback() { var e; @@ -278,28 +278,28 @@ let D = class extends HTMLElement { firstUpdated(e) { } }; -D.elementStyles = [], D.shadowRootOptions = { mode: "open" }, D[j("elementProperties")] = /* @__PURE__ */ new Map(), D[j("finalized")] = /* @__PURE__ */ new Map(), X == null || X({ ReactiveElement: D }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); +T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[j("elementProperties")] = /* @__PURE__ */ new Map(), T[j("finalized")] = /* @__PURE__ */ new Map(), X == null || X({ ReactiveElement: T }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const N = globalThis, de = (s) => s, Z = N.trustedTypes, he = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ye = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, ve = "?" + x, De = `<${ve}>`, E = document, I = () => E.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", oe = Array.isArray, Te = (s) => oe(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[ -\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, pe = /-->/g, ue = />/g, S = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), me = /'/g, fe = /"/g, _e = /^(?:script|style|textarea|title)$/i, Me = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Me(1), T = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), ge = /* @__PURE__ */ new WeakMap(), P = E.createTreeWalker(E, 129); +const N = globalThis, de = (s) => s, Z = N.trustedTypes, he = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ye = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, ve = "?" + x, De = `<${ve}>`, E = document, I = () => E.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", oe = Array.isArray, Me = (s) => oe(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[ +\f\r]`, R = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, pe = /-->/g, ue = />/g, S = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), me = /'/g, fe = /"/g, _e = /^(?:script|style|textarea|title)$/i, He = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = He(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), ge = /* @__PURE__ */ new WeakMap(), P = E.createTreeWalker(E, 129); function we(s, e) { if (!oe(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); return he !== void 0 ? he.createHTML(e) : e; } const Re = (s, e) => { const t = s.length - 1, i = []; - let r, n = e === 2 ? "" : e === 3 ? "" : "", o = H; + let r, n = e === 2 ? "" : e === 3 ? "" : "", o = R; for (let l = 0; l < t; l++) { const a = s[l]; let p, m, h = -1, b = 0; - for (; b < a.length && (o.lastIndex = b, m = o.exec(a), m !== null); ) b = o.lastIndex, o === H ? m[1] === "!--" ? o = pe : m[1] !== void 0 ? o = ue : m[2] !== void 0 ? (_e.test(m[2]) && (r = RegExp("" ? (o = r ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? S : m[3] === '"' ? fe : me) : o === fe || o === me ? o = S : o === pe || o === ue ? o = H : (o = S, r = void 0); + for (; b < a.length && (o.lastIndex = b, m = o.exec(a), m !== null); ) b = o.lastIndex, o === R ? m[1] === "!--" ? o = pe : m[1] !== void 0 ? o = ue : m[2] !== void 0 ? (_e.test(m[2]) && (r = RegExp("" ? (o = r ?? R, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? S : m[3] === '"' ? fe : me) : o === fe || o === me ? o = S : o === pe || o === ue ? o = R : (o = S, r = void 0); const w = o === S && s[l + 1].startsWith("/>") ? " " : ""; - n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ye + a.slice(h) + x + w) : a + x + (h === -2 ? l : w); + n += o === R ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ye + a.slice(h) + x + w) : a + x + (h === -2 ? l : w); } return [we(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; }; @@ -317,7 +317,7 @@ class q { if (r.nodeType === 1) { if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(ye)) { const b = m[o++], w = r.getAttribute(h).split(x), K = /([.?@])?(.*)/.exec(b); - a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? je : K[1] === "?" ? Ne : K[1] === "@" ? Ie : G }), r.removeAttribute(h); + a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? Ne : K[1] === "?" ? Ie : K[1] === "@" ? Le : G }), r.removeAttribute(h); } else h.startsWith(x) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); if (_e.test(r.tagName)) { const h = r.textContent.split(x), b = h.length - 1; @@ -342,12 +342,12 @@ class q { } function M(s, e, t = s, i) { var o, l; - if (e === T) return e; + if (e === D) return e; let r = i !== void 0 ? (o = t._$Co) == null ? void 0 : o[i] : t._$Cl; const n = L(e) ? void 0 : e._$litDirective$; return (r == null ? void 0 : r.constructor) !== n && ((l = r == null ? void 0 : r._$AO) == null || l.call(r, !1), n === void 0 ? r = void 0 : (r = new n(s), r._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = r : t._$Cl = r), r !== void 0 && (e = M(s, r._$AS(s, e.values), r, i)), e; } -class He { +class je { constructor(e, t) { this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t; } @@ -364,7 +364,7 @@ class He { for (; a !== void 0; ) { if (o === a.index) { let p; - a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l]; + a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; } o !== (a == null ? void 0 : a.index) && (n = P.nextNode(), o++); } @@ -395,7 +395,7 @@ class W { return this._$AB; } _$AI(e, t = this) { - e = M(this, e, t), L(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== T && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Te(e) ? this.k(e) : this._(e); + e = M(this, e, t), L(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== D && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Me(e) ? this.k(e) : this._(e); } O(e) { return this._$AA.parentNode.insertBefore(e, this._$AB); @@ -411,7 +411,7 @@ class W { const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(we(i.h, i.h[0]), this.options)), i); if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t); else { - const o = new He(r, this), l = o.u(this.options); + const o = new je(r, this), l = o.u(this.options); o.p(t), this.T(l), this._$AH = o; } } @@ -451,11 +451,11 @@ class G { _$AI(e, t = this, i, r) { const n = this.strings; let o = !1; - if (n === void 0) e = M(this, e, t, 0), o = !L(e) || e !== this._$AH && e !== T, o && (this._$AH = e); + if (n === void 0) e = M(this, e, t, 0), o = !L(e) || e !== this._$AH && e !== D, o && (this._$AH = e); else { const l = e; let a, p; - for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === T && (p = this._$AH[a]), o || (o = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; + for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === D && (p = this._$AH[a]), o || (o = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; } o && !r && this.j(e); } @@ -463,7 +463,7 @@ class G { e === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); } } -class je extends G { +class Ne extends G { constructor() { super(...arguments), this.type = 3; } @@ -471,7 +471,7 @@ class je extends G { this.element[this.name] = e === d ? void 0 : e; } } -class Ne extends G { +class Ie extends G { constructor() { super(...arguments), this.type = 4; } @@ -479,12 +479,12 @@ class Ne extends G { this.element.toggleAttribute(this.name, !!e && e !== d); } } -class Ie extends G { +class Le extends G { constructor(e, t, i, r, n) { super(e, t, i, r, n), this.type = 5; } _$AI(e, t = this) { - if ((e = M(this, e, t, 0) ?? d) === T) return; + if ((e = M(this, e, t, 0) ?? d) === D) return; const i = this._$AH, r = e === d && i !== d || e.capture !== i.capture || e.once !== i.once || e.passive !== i.passive, n = e !== d && (i === d || r); r && this.element.removeEventListener(this.name, this, i), n && this.element.addEventListener(this.name, this, e), this._$AH = e; } @@ -493,7 +493,7 @@ class Ie extends G { typeof this._$AH == "function" ? this._$AH.call(((t = this.options) == null ? void 0 : t.host) ?? this.element, e) : this._$AH.handleEvent(e); } } -class Le { +class qe { constructor(e, t, i) { this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = i; } @@ -506,7 +506,7 @@ class Le { } const ee = N.litHtmlPolyfillSupport; ee == null || ee(q, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); -const qe = (s, e, t) => { +const Be = (s, e, t) => { const i = (t == null ? void 0 : t.renderBefore) ?? e; let r = i._$litPart$; if (r === void 0) { @@ -521,7 +521,7 @@ const qe = (s, e, t) => { * SPDX-License-Identifier: BSD-3-Clause */ const C = globalThis; -class $ extends D { +class $ extends T { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -532,7 +532,7 @@ class $ extends D { } update(e) { const t = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = qe(t, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = Be(t, this.renderRoot, this.renderOptions); } connectedCallback() { var e; @@ -543,7 +543,7 @@ class $ extends D { super.disconnectedCallback(), (e = this._$Do) == null || e.setConnected(!1); } render() { - return T; + return D; } } var be; @@ -566,7 +566,7 @@ const F = (s) => (e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Be = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: ie }, We = (s = Be, e, t) => { +const We = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: ie }, Fe = (s = We, e, t) => { const { kind: i, metadata: r } = t; let n = globalThis.litPropertyMetadata.get(r); if (n === void 0 && globalThis.litPropertyMetadata.set(r, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") { @@ -588,7 +588,7 @@ const Be = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: throw Error("Unsupported decorator location: " + i); }; function f(s) { - return (e, t) => typeof t == "object" ? We(s, e, t) : ((i, r, n) => { + return (e, t) => typeof t == "object" ? Fe(s, e, t) : ((i, r, n) => { const o = r.hasOwnProperty(n); return r.constructor.createProperty(n, i), o ? Object.getOwnPropertyDescriptor(r, n) : void 0; })(s, e, t); @@ -612,7 +612,7 @@ function xe(s, e) { } }, t; } -class Fe { +class Ae { constructor(e = "") { this.baseUrl = e; } @@ -644,6 +644,28 @@ class Fe { healthCheck(e, t) { return this.request(`/daemons/${e}/${t}/health`); } + /** List all managed processes. */ + listProcesses() { + return this.request("/processes"); + } + /** Get a single managed process by ID. */ + getProcess(e) { + return this.request(`/processes/${e}`); + } + /** Kill a managed process by ID. */ + killProcess(e) { + return this.request(`/processes/${e}/kill`, { + method: "POST" + }); + } + /** Run a process pipeline using the configured runner. */ + runPipeline(e, t) { + return this.request("/pipelines/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: e, specs: t }) + }); + } } var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, k = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ve(e, t) : e, n = s.length - 1, o; n >= 0; n--) @@ -655,7 +677,7 @@ let g = class extends $ { super(...arguments), this.apiUrl = "", this.daemons = [], this.loading = !0, this.error = "", this.stopping = /* @__PURE__ */ new Set(), this.checking = /* @__PURE__ */ new Set(), this.healthResults = /* @__PURE__ */ new Map(); } connectedCallback() { - super.connectedCallback(), this.api = new Fe(this.apiUrl), this.loadDaemons(); + super.connectedCallback(), this.api = new Ae(this.apiUrl), this.loadDaemons(); } async loadDaemons() { this.loading = !0, this.error = ""; @@ -957,10 +979,17 @@ let y = class extends $ { super(...arguments), this.apiUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.killing = /* @__PURE__ */ new Set(); } connectedCallback() { - super.connectedCallback(), this.loadProcesses(); + super.connectedCallback(), this.api = new Ae(this.apiUrl), this.loadProcesses(); } async loadProcesses() { - this.loading = !1, this.processes = []; + this.loading = !0, this.error = ""; + try { + this.processes = await this.api.listProcesses(); + } catch (s) { + this.error = s.message ?? "Failed to load processes", this.processes = []; + } finally { + this.loading = !1; + } } handleSelect(s) { this.dispatchEvent( @@ -971,6 +1000,17 @@ let y = class extends $ { }) ); } + async handleKill(s) { + this.killing = /* @__PURE__ */ new Set([...this.killing, s.id]); + try { + await this.api.killProcess(s.id), await this.loadProcesses(); + } catch (e) { + this.error = e.message ?? "Failed to kill process"; + } finally { + const e = new Set(this.killing); + e.delete(s.id), this.killing = e; + } + } formatUptime(s) { try { const e = Date.now() - new Date(s).getTime(), t = Math.floor(e / 1e3); @@ -986,8 +1026,7 @@ let y = class extends $ { ${this.error ? c`
${this.error}
` : d} ${this.processes.length === 0 ? c`
- Process list endpoints are pending. Processes will appear here once - the REST API for managed processes is available. + Managed processes are loaded from the process REST API.
No managed processes.
` : c` @@ -1021,7 +1060,7 @@ let y = class extends $ { class="kill-btn" ?disabled=${this.killing.has(s.id)} @click=${(t) => { - t.stopPropagation(); + t.stopPropagation(), this.handleKill(s); }} > ${this.killing.has(s.id) ? "Killing…" : "Kill"} @@ -1430,7 +1469,7 @@ var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, Q = (s, e, (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Xe(e, t, r), r; }; -let R = class extends $ { +let H = class extends $ { constructor() { super(...arguments), this.apiUrl = "", this.result = null, this.expandedOutputs = /* @__PURE__ */ new Set(); } @@ -1453,8 +1492,7 @@ let R = class extends $ { if (!this.result) return c`
- Pass pipeline results via the result property, or load - them from the REST API when your app executes a pipeline. + Pass pipeline results via the result property.
No pipeline results.
`; @@ -1507,7 +1545,7 @@ let R = class extends $ { `; } }; -R.styles = B` +H.styles = B` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1704,16 +1742,16 @@ R.styles = B` `; Q([ f({ attribute: "api-url" }) -], R.prototype, "apiUrl", 2); +], H.prototype, "apiUrl", 2); Q([ f({ type: Object }) -], R.prototype, "result", 2); +], H.prototype, "result", 2); Q([ u() -], R.prototype, "expandedOutputs", 2); -R = Q([ +], H.prototype, "expandedOutputs", 2); +H = Q([ F("core-process-runner") -], R); +], H); var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); @@ -1947,11 +1985,11 @@ _ = z([ F("core-process-panel") ], _); export { - Fe as ProcessApi, + Ae as ProcessApi, g as ProcessDaemons, y as ProcessList, v as ProcessOutput, _ as ProcessPanel, - R as ProcessRunner, + H as ProcessRunner, xe as connectProcessEvents }; diff --git a/ui/src/process-list.ts b/ui/src/process-list.ts index 6962acc..c380764 100644 --- a/ui/src/process-list.ts +++ b/ui/src/process-list.ts @@ -2,7 +2,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { ProcessInfo } from './shared/api.js'; +import { ProcessApi, type ProcessInfo } from './shared/api.js'; /** * — Running processes with status and actions. @@ -192,16 +192,25 @@ export class ProcessList extends LitElement { @state() private error = ''; @state() private killing = new Set(); + private api!: ProcessApi; + connectedCallback() { super.connectedCallback(); + this.api = new ProcessApi(this.apiUrl); this.loadProcesses(); } async loadProcesses() { - // Process-level REST endpoints are not yet available. - // This element will populate via WS events once endpoints exist. - this.loading = false; - this.processes = []; + this.loading = true; + this.error = ''; + try { + this.processes = await this.api.listProcesses(); + } catch (e: any) { + this.error = e.message ?? 'Failed to load processes'; + this.processes = []; + } finally { + this.loading = false; + } } private handleSelect(proc: ProcessInfo) { @@ -214,6 +223,20 @@ export class ProcessList extends LitElement { ); } + private async handleKill(proc: ProcessInfo) { + this.killing = new Set([...this.killing, proc.id]); + try { + await this.api.killProcess(proc.id); + await this.loadProcesses(); + } catch (e: any) { + this.error = e.message ?? 'Failed to kill process'; + } finally { + const next = new Set(this.killing); + next.delete(proc.id); + this.killing = next; + } + } + private formatUptime(started: string): string { try { const ms = Date.now() - new Date(started).getTime(); @@ -238,8 +261,7 @@ export class ProcessList extends LitElement { ${this.processes.length === 0 ? html`
- Process list endpoints are pending. Processes will appear here once - the REST API for managed processes is available. + Managed processes are loaded from the process REST API.
No managed processes.
` @@ -278,6 +300,7 @@ export class ProcessList extends LitElement { ?disabled=${this.killing.has(proc.id)} @click=${(e: Event) => { e.stopPropagation(); + void this.handleKill(proc); }} > ${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'} diff --git a/ui/src/process-runner.ts b/ui/src/process-runner.ts index e824eef..bc3fa71 100644 --- a/ui/src/process-runner.ts +++ b/ui/src/process-runner.ts @@ -9,10 +9,6 @@ import type { RunResult, RunAllResult } from './shared/api.js'; * * Shows RunSpec execution results with pass/fail/skip badges, duration, * dependency chains, and aggregate summary. - * - * Note: Pipeline runner REST endpoints are not yet in the provider. - * This element renders from WS events and accepts data via properties - * until those endpoints are available. */ @customElement('core-process-runner') export class ProcessRunner extends LitElement { @@ -223,8 +219,9 @@ export class ProcessRunner extends LitElement { } async loadResults() { - // Pipeline runner REST endpoints are not yet available. - // Results can be passed in via the `result` property. + // Results are supplied via the `result` property. The REST API can be + // used by the surrounding application to execute a pipeline and then + // assign the returned data here. } private toggleOutput(name: string) { @@ -253,9 +250,7 @@ export class ProcessRunner extends LitElement { if (!this.result) { return html`
- Pipeline runner endpoints are pending. Pass pipeline results via the - result property, or results will appear here once the REST - API for pipeline execution is available. + Pass pipeline results via the result property.
No pipeline results.
`; diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts index bd74a09..56e616c 100644 --- a/ui/src/shared/api.ts +++ b/ui/src/shared/api.ts @@ -37,6 +37,19 @@ export interface ProcessInfo { pid: number; } +/** + * RunSpec payload for pipeline execution. + */ +export interface RunSpec { + name: string; + command: string; + args?: string[]; + dir?: string; + env?: string[]; + after?: string[]; + allowFailure?: boolean; +} + /** * Pipeline run result for a single spec. */ @@ -102,4 +115,30 @@ export class ProcessApi { healthCheck(code: string, daemon: string): Promise { return this.request(`/daemons/${code}/${daemon}/health`); } + + /** List all managed processes. */ + listProcesses(): Promise { + return this.request('/processes'); + } + + /** Get a single managed process by ID. */ + getProcess(id: string): Promise { + return this.request(`/processes/${id}`); + } + + /** Kill a managed process by ID. */ + killProcess(id: string): Promise<{ killed: boolean }> { + return this.request<{ killed: boolean }>(`/processes/${id}/kill`, { + method: 'POST', + }); + } + + /** Run a process pipeline using the configured runner. */ + runPipeline(mode: 'all' | 'sequential' | 'parallel', specs: RunSpec[]): Promise { + return this.request('/pipelines/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, specs }), + }); + } } From c5adc8066e59832a5cf347cbfbfa9d0d8f17f339 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:40:53 +0000 Subject: [PATCH 42/97] fix(process): terminate unmanaged pids with sigterm --- service.go | 4 ++-- service_test.go | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/service.go b/service.go index 35fedc2..4794310 100644 --- a/service.go +++ b/service.go @@ -407,7 +407,7 @@ func (s *Service) Kill(id string) error { return nil } -// KillPID forcefully terminates a process by operating-system PID. +// KillPID terminates a process by operating-system PID. // // Example: // @@ -428,7 +428,7 @@ func (s *Service) KillPID(pid int) error { return nil } - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) } diff --git a/service_test.go b/service_test.go index ba3b0ef..3824ac7 100644 --- a/service_test.go +++ b/service_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "strings" "sync" + "syscall" "testing" "time" @@ -467,7 +468,7 @@ func TestService_Kill(t *testing.T) { } func TestService_KillPID(t *testing.T) { - t.Run("force kills unmanaged process", func(t *testing.T) { + t.Run("terminates unmanaged process with SIGTERM", func(t *testing.T) { svc, _ := newTestService(t) cmd := exec.Command("sleep", "60") @@ -494,6 +495,12 @@ func TestService_KillPID(t *testing.T) { select { case err := <-waitCh: require.Error(t, err) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.True(t, ws.Signaled()) + assert.Equal(t, syscall.SIGTERM, ws.Signal()) case <-time.After(2 * time.Second): t.Fatal("unmanaged process should have been killed") } From 911abb6ee884b6336ce2a4152df1ad88a3b1c337 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:45:09 +0000 Subject: [PATCH 43/97] feat(process): add process output snapshot endpoint --- pkg/api/provider.go | 30 ++++ pkg/api/provider_test.go | 24 +++ pkg/api/ui/dist/core-process.js | 305 ++++++++++++++++++-------------- ui/src/process-output.ts | 84 +++++++-- ui/src/shared/api.ts | 6 + 5 files changed, 307 insertions(+), 142 deletions(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index a3adbcd..59c01e7 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -92,6 +92,7 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/daemons/:code/:daemon/health", p.healthCheck) rg.GET("/processes", p.listProcesses) rg.GET("/processes/:id", p.getProcess) + rg.GET("/processes/:id/output", p.getProcessOutput) rg.POST("/processes/:id/kill", p.killProcess) rg.POST("/pipelines/run", p.runPipeline) } @@ -213,6 +214,16 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "GET", + Path: "/processes/:id/output", + Summary: "Get process output", + Description: "Returns the captured stdout and stderr for a managed process.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "string", + }, + }, { Method: "POST", Path: "/processes/:id/kill", @@ -382,6 +393,25 @@ func (p *ProcessProvider) getProcess(c *gin.Context) { c.JSON(http.StatusOK, api.OK(proc.Info())) } +func (p *ProcessProvider) getProcessOutput(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + output, err := p.service.Output(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("not_found", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(output)) +} + func (p *ProcessProvider) killProcess(c *gin.Context) { if p.service == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 34de0af..4787372 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -253,6 +253,29 @@ func TestProcessProvider_GetProcess_Good(t *testing.T) { assert.Equal(t, "echo", resp.Data.Command) } +func TestProcessProvider_GetProcessOutput_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "output-check") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID+"/output", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[string] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Contains(t, resp.Data, "output-check") +} + func TestProcessProvider_KillProcess_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "sleep", "60") @@ -289,6 +312,7 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { cases := []string{ "/api/process/processes", "/api/process/processes/anything", + "/api/process/processes/anything/output", "/api/process/processes/anything/kill", } diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index e312e18..d276042 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -3,8 +3,8 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const V = globalThis, se = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), ne = /* @__PURE__ */ new WeakMap(); -let $e = class { +const V = globalThis, ie = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), ae = /* @__PURE__ */ new WeakMap(); +let ye = class { constructor(e, t, i) { if (this._$cssResult$ = !0, i !== re) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = e, this.t = t; @@ -12,9 +12,9 @@ let $e = class { get styleSheet() { let e = this.o; const t = this.t; - if (se && e === void 0) { + if (ie && e === void 0) { const i = t !== void 0 && t.length === 1; - i && (e = ne.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ne.set(t, e)); + i && (e = ae.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ae.set(t, e)); } return e; } @@ -22,20 +22,20 @@ let $e = class { return this.cssText; } }; -const ke = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => { +const ke = (s) => new ye(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => { const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => { if (o._$cssResult$ === !0) return o.cssText; if (typeof o == "number") return o; throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + s[n + 1], s[0]); - return new $e(t, s, re); + return new ye(t, s, re); }, Se = (s, e) => { - if (se) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); + if (ie) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const t of e) { const i = document.createElement("style"), r = V.litNonce; r !== void 0 && i.setAttribute("nonce", r), i.textContent = t.cssText, s.appendChild(i); } -}, ae = se ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { +}, le = ie ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { let t = ""; for (const i of e.cssRules) t += i.cssText; return ke(t); @@ -45,7 +45,7 @@ const ke = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, le = A.trustedTypes, Te = le ? le.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { +const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, ce = A.trustedTypes, Te = ce ? ce.emptyScript : "", Y = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { switch (e) { case Boolean: s = s ? Te : null; @@ -73,7 +73,7 @@ const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnProperty } } return t; -} }, ie = (s, e) => !Pe(s, e), ce = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: ie }; +} }, oe = (s, e) => !Pe(s, e), de = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: oe }; Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); let T = class extends HTMLElement { static addInitializer(e) { @@ -82,7 +82,7 @@ let T = class extends HTMLElement { static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(e, t = ce) { + static createProperty(e, t = de) { if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) { const i = Symbol(), r = this.getPropertyDescriptor(e, i, t); r !== void 0 && Ce(this.prototype, e, r); @@ -100,7 +100,7 @@ let T = class extends HTMLElement { }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(e) { - return this.elementProperties.get(e) ?? ce; + return this.elementProperties.get(e) ?? de; } static _$Ei() { if (this.hasOwnProperty(j("elementProperties"))) return; @@ -129,8 +129,8 @@ let T = class extends HTMLElement { const t = []; if (Array.isArray(e)) { const i = new Set(e.flat(1 / 0).reverse()); - for (const r of i) t.unshift(ae(r)); - } else e !== void 0 && t.push(ae(e)); + for (const r of i) t.unshift(le(r)); + } else e !== void 0 && t.push(le(e)); return t; } static _$Eu(e, t) { @@ -202,7 +202,7 @@ let T = class extends HTMLElement { var o; if (e !== void 0) { const l = this.constructor; - if (r === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? ie)(n, t) || i.useDefault && i.reflect && n === ((o = this._$Ej) == null ? void 0 : o.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return; + if (r === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? oe)(n, t) || i.useDefault && i.reflect && n === ((o = this._$Ej) == null ? void 0 : o.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return; this.C(e, t, i); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); @@ -278,30 +278,30 @@ let T = class extends HTMLElement { firstUpdated(e) { } }; -T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[j("elementProperties")] = /* @__PURE__ */ new Map(), T[j("finalized")] = /* @__PURE__ */ new Map(), X == null || X({ ReactiveElement: T }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); +T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[j("elementProperties")] = /* @__PURE__ */ new Map(), T[j("finalized")] = /* @__PURE__ */ new Map(), Y == null || Y({ ReactiveElement: T }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const N = globalThis, de = (s) => s, Z = N.trustedTypes, he = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ye = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, ve = "?" + x, De = `<${ve}>`, E = document, I = () => E.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", oe = Array.isArray, Me = (s) => oe(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[ -\f\r]`, R = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, pe = /-->/g, ue = />/g, S = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), me = /'/g, fe = /"/g, _e = /^(?:script|style|textarea|title)$/i, He = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = He(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), ge = /* @__PURE__ */ new WeakMap(), P = E.createTreeWalker(E, 129); -function we(s, e) { - if (!oe(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return he !== void 0 ? he.createHTML(e) : e; +const N = globalThis, he = (s) => s, Z = N.trustedTypes, pe = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ve = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + x, De = `<${_e}>`, U = document, I = () => U.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = `[ +\f\r]`, R = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ue = /-->/g, me = />/g, P = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), fe = /'/g, ge = /"/g, we = /^(?:script|style|textarea|title)$/i, He = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = He(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), be = /* @__PURE__ */ new WeakMap(), C = U.createTreeWalker(U, 129); +function xe(s, e) { + if (!ne(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return pe !== void 0 ? pe.createHTML(e) : e; } const Re = (s, e) => { const t = s.length - 1, i = []; let r, n = e === 2 ? "" : e === 3 ? "" : "", o = R; for (let l = 0; l < t; l++) { const a = s[l]; - let p, m, h = -1, b = 0; - for (; b < a.length && (o.lastIndex = b, m = o.exec(a), m !== null); ) b = o.lastIndex, o === R ? m[1] === "!--" ? o = pe : m[1] !== void 0 ? o = ue : m[2] !== void 0 ? (_e.test(m[2]) && (r = RegExp("" ? (o = r ?? R, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? S : m[3] === '"' ? fe : me) : o === fe || o === me ? o = S : o === pe || o === ue ? o = R : (o = S, r = void 0); - const w = o === S && s[l + 1].startsWith("/>") ? " " : ""; - n += o === R ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ye + a.slice(h) + x + w) : a + x + (h === -2 ? l : w); + let p, m, h = -1, $ = 0; + for (; $ < a.length && (o.lastIndex = $, m = o.exec(a), m !== null); ) $ = o.lastIndex, o === R ? m[1] === "!--" ? o = ue : m[1] !== void 0 ? o = me : m[2] !== void 0 ? (we.test(m[2]) && (r = RegExp("" ? (o = r ?? R, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? P : m[3] === '"' ? ge : fe) : o === ge || o === fe ? o = P : o === ue || o === me ? o = R : (o = P, r = void 0); + const w = o === P && s[l + 1].startsWith("/>") ? " " : ""; + n += o === R ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ve + a.slice(h) + x + w) : a + x + (h === -2 ? l : w); } - return [we(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; + return [xe(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; }; class q { constructor({ strings: e, _$litType$: t }, i) { @@ -309,25 +309,25 @@ class q { this.parts = []; let n = 0, o = 0; const l = e.length - 1, a = this.parts, [p, m] = Re(e, t); - if (this.el = q.createElement(p, i), P.currentNode = this.el.content, t === 2 || t === 3) { + if (this.el = q.createElement(p, i), C.currentNode = this.el.content, t === 2 || t === 3) { const h = this.el.content.firstChild; h.replaceWith(...h.childNodes); } - for (; (r = P.nextNode()) !== null && a.length < l; ) { + for (; (r = C.nextNode()) !== null && a.length < l; ) { if (r.nodeType === 1) { - if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(ye)) { - const b = m[o++], w = r.getAttribute(h).split(x), K = /([.?@])?(.*)/.exec(b); - a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? Ne : K[1] === "?" ? Ie : K[1] === "@" ? Le : G }), r.removeAttribute(h); + if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(ve)) { + const $ = m[o++], w = r.getAttribute(h).split(x), K = /([.?@])?(.*)/.exec($); + a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? Ne : K[1] === "?" ? Ie : K[1] === "@" ? Le : Q }), r.removeAttribute(h); } else h.startsWith(x) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); - if (_e.test(r.tagName)) { - const h = r.textContent.split(x), b = h.length - 1; - if (b > 0) { + if (we.test(r.tagName)) { + const h = r.textContent.split(x), $ = h.length - 1; + if ($ > 0) { r.textContent = Z ? Z.emptyScript : ""; - for (let w = 0; w < b; w++) r.append(h[w], I()), P.nextNode(), a.push({ type: 2, index: ++n }); - r.append(h[b], I()); + for (let w = 0; w < $; w++) r.append(h[w], I()), C.nextNode(), a.push({ type: 2, index: ++n }); + r.append(h[$], I()); } } - } else if (r.nodeType === 8) if (r.data === ve) a.push({ type: 2, index: n }); + } else if (r.nodeType === 8) if (r.data === _e) a.push({ type: 2, index: n }); else { let h = -1; for (; (h = r.data.indexOf(x, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += x.length - 1; @@ -336,7 +336,7 @@ class q { } } static createElement(e, t) { - const i = E.createElement("template"); + const i = U.createElement("template"); return i.innerHTML = e, i; } } @@ -358,24 +358,24 @@ class je { return this._$AM._$AU; } u(e) { - const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? E).importNode(t, !0); - P.currentNode = r; - let n = P.nextNode(), o = 0, l = 0, a = i[0]; + const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? U).importNode(t, !0); + C.currentNode = r; + let n = C.nextNode(), o = 0, l = 0, a = i[0]; for (; a !== void 0; ) { if (o === a.index) { let p; - a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; + a.type === 2 ? p = new F(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; } - o !== (a == null ? void 0 : a.index) && (n = P.nextNode(), o++); + o !== (a == null ? void 0 : a.index) && (n = C.nextNode(), o++); } - return P.currentNode = E, r; + return C.currentNode = U, r; } p(e) { let t = 0; for (const i of this._$AV) i !== void 0 && (i.strings !== void 0 ? (i._$AI(e, i, t), t += i.strings.length - 2) : i._$AI(e[t])), t++; } } -class W { +class F { get _$AU() { var e; return ((e = this._$AM) == null ? void 0 : e._$AU) ?? this._$Cv; @@ -404,11 +404,11 @@ class W { this._$AH !== e && (this._$AR(), this._$AH = this.O(e)); } _(e) { - this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(E.createTextNode(e)), this._$AH = e; + this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(U.createTextNode(e)), this._$AH = e; } $(e) { var n; - const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(we(i.h, i.h[0]), this.options)), i); + const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(xe(i.h, i.h[0]), this.options)), i); if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t); else { const o = new je(r, this), l = o.u(this.options); @@ -416,21 +416,21 @@ class W { } } _$AC(e) { - let t = ge.get(e.strings); - return t === void 0 && ge.set(e.strings, t = new q(e)), t; + let t = be.get(e.strings); + return t === void 0 && be.set(e.strings, t = new q(e)), t; } k(e) { - oe(this._$AH) || (this._$AH = [], this._$AR()); + ne(this._$AH) || (this._$AH = [], this._$AR()); const t = this._$AH; let i, r = 0; - for (const n of e) r === t.length ? t.push(i = new W(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++; + for (const n of e) r === t.length ? t.push(i = new F(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++; r < t.length && (this._$AR(i && i._$AB.nextSibling, r), t.length = r); } _$AR(e = this._$AA.nextSibling, t) { var i; for ((i = this._$AP) == null ? void 0 : i.call(this, !1, !0, t); e !== this._$AB; ) { - const r = de(e).nextSibling; - de(e).remove(), e = r; + const r = he(e).nextSibling; + he(e).remove(), e = r; } } setConnected(e) { @@ -438,7 +438,7 @@ class W { this._$AM === void 0 && (this._$Cv = e, (t = this._$AP) == null || t.call(this, e)); } } -class G { +class Q { get tagName() { return this.element.tagName; } @@ -463,7 +463,7 @@ class G { e === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); } } -class Ne extends G { +class Ne extends Q { constructor() { super(...arguments), this.type = 3; } @@ -471,7 +471,7 @@ class Ne extends G { this.element[this.name] = e === d ? void 0 : e; } } -class Ie extends G { +class Ie extends Q { constructor() { super(...arguments), this.type = 4; } @@ -479,7 +479,7 @@ class Ie extends G { this.element.toggleAttribute(this.name, !!e && e !== d); } } -class Le extends G { +class Le extends Q { constructor(e, t, i, r, n) { super(e, t, i, r, n), this.type = 5; } @@ -504,14 +504,14 @@ class qe { M(this, e); } } -const ee = N.litHtmlPolyfillSupport; -ee == null || ee(q, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); +const te = N.litHtmlPolyfillSupport; +te == null || te(q, F), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); const Be = (s, e, t) => { const i = (t == null ? void 0 : t.renderBefore) ?? e; let r = i._$litPart$; if (r === void 0) { const n = (t == null ? void 0 : t.renderBefore) ?? null; - i._$litPart$ = r = new W(e.insertBefore(I(), n), n, void 0, t ?? {}); + i._$litPart$ = r = new F(e.insertBefore(I(), n), n, void 0, t ?? {}); } return r._$AI(s), r; }; @@ -520,8 +520,8 @@ const Be = (s, e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const C = globalThis; -class $ extends T { +const E = globalThis; +class y extends T { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -546,17 +546,17 @@ class $ extends T { return D; } } -var be; -$._$litElement$ = !0, $.finalized = !0, (be = C.litElementHydrateSupport) == null || be.call(C, { LitElement: $ }); -const te = C.litElementPolyfillSupport; -te == null || te({ LitElement: $ }); -(C.litElementVersions ?? (C.litElementVersions = [])).push("4.2.2"); +var $e; +y._$litElement$ = !0, y.finalized = !0, ($e = E.litElementHydrateSupport) == null || $e.call(E, { LitElement: y }); +const se = E.litElementPolyfillSupport; +se == null || se({ LitElement: y }); +(E.litElementVersions ?? (E.litElementVersions = [])).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const F = (s) => (e, t) => { +const W = (s) => (e, t) => { t !== void 0 ? t.addInitializer(() => { customElements.define(s, e); }) : customElements.define(s, e); @@ -566,7 +566,7 @@ const F = (s) => (e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const We = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: ie }, Fe = (s = We, e, t) => { +const Fe = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: oe }, We = (s = Fe, e, t) => { const { kind: i, metadata: r } = t; let n = globalThis.litPropertyMetadata.get(r); if (n === void 0 && globalThis.litPropertyMetadata.set(r, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") { @@ -588,7 +588,7 @@ const We = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: throw Error("Unsupported decorator location: " + i); }; function f(s) { - return (e, t) => typeof t == "object" ? Fe(s, e, t) : ((i, r, n) => { + return (e, t) => typeof t == "object" ? We(s, e, t) : ((i, r, n) => { const o = r.hasOwnProperty(n); return r.constructor.createProperty(n, i), o ? Object.getOwnPropertyDescriptor(r, n) : void 0; })(s, e, t); @@ -601,7 +601,7 @@ function f(s) { function u(s) { return f({ ...s, state: !0, attribute: !1 }); } -function xe(s, e) { +function Ae(s, e) { const t = new WebSocket(s); return t.onmessage = (i) => { var r, n, o, l; @@ -612,7 +612,7 @@ function xe(s, e) { } }, t; } -class Ae { +class G { constructor(e = "") { this.baseUrl = e; } @@ -652,6 +652,10 @@ class Ae { getProcess(e) { return this.request(`/processes/${e}`); } + /** Get the captured stdout/stderr for a managed process by ID. */ + getProcessOutput(e) { + return this.request(`/processes/${e}/output`); + } /** Kill a managed process by ID. */ killProcess(e) { return this.request(`/processes/${e}/kill`, { @@ -672,12 +676,12 @@ var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, k = (s, e, (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Ke(e, t, r), r; }; -let g = class extends $ { +let g = class extends y { constructor() { super(...arguments), this.apiUrl = "", this.daemons = [], this.loading = !0, this.error = "", this.stopping = /* @__PURE__ */ new Set(), this.checking = /* @__PURE__ */ new Set(), this.healthResults = /* @__PURE__ */ new Map(); } connectedCallback() { - super.connectedCallback(), this.api = new Ae(this.apiUrl), this.loadDaemons(); + super.connectedCallback(), this.api = new G(this.apiUrl), this.loadDaemons(); } async loadDaemons() { this.loading = !0, this.error = ""; @@ -967,19 +971,19 @@ k([ u() ], g.prototype, "healthResults", 2); g = k([ - F("core-process-daemons") + W("core-process-daemons") ], g); -var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, U = (s, e, t, i) => { +var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, O = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Je(e, t, r), r; }; -let y = class extends $ { +let v = class extends y { constructor() { super(...arguments), this.apiUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.killing = /* @__PURE__ */ new Set(); } connectedCallback() { - super.connectedCallback(), this.api = new Ae(this.apiUrl), this.loadProcesses(); + super.connectedCallback(), this.api = new G(this.apiUrl), this.loadProcesses(); } async loadProcesses() { this.loading = !0, this.error = ""; @@ -1076,7 +1080,7 @@ let y = class extends $ { `; } }; -y.styles = B` +v.styles = B` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1240,47 +1244,81 @@ y.styles = B` margin-bottom: 1rem; } `; -U([ +O([ f({ attribute: "api-url" }) -], y.prototype, "apiUrl", 2); -U([ +], v.prototype, "apiUrl", 2); +O([ f({ attribute: "selected-id" }) -], y.prototype, "selectedId", 2); -U([ +], v.prototype, "selectedId", 2); +O([ u() -], y.prototype, "processes", 2); -U([ +], v.prototype, "processes", 2); +O([ u() -], y.prototype, "loading", 2); -U([ +], v.prototype, "loading", 2); +O([ u() -], y.prototype, "error", 2); -U([ +], v.prototype, "error", 2); +O([ u() -], y.prototype, "killing", 2); -y = U([ - F("core-process-list") -], y); -var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, O = (s, e, t, i) => { +], v.prototype, "killing", 2); +v = O([ + W("core-process-list") +], v); +var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Ge(e, t, r), r; }; -let v = class extends $ { +let b = class extends y { constructor() { - super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.ws = null; + super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.loadingSnapshot = !1, this.ws = null, this.api = new G(this.apiUrl), this.syncToken = 0; } connectedCallback() { - super.connectedCallback(), this.wsUrl && this.processId && this.connect(); + super.connectedCallback(), this.syncSources(); } disconnectedCallback() { super.disconnectedCallback(), this.disconnect(); } updated(s) { - (s.has("processId") || s.has("wsUrl")) && (this.disconnect(), this.lines = [], this.wsUrl && this.processId && this.connect()), this.autoScroll && this.scrollToBottom(); + s.has("apiUrl") && (this.api = new G(this.apiUrl)), (s.has("processId") || s.has("wsUrl") || s.has("apiUrl")) && this.syncSources(), this.autoScroll && this.scrollToBottom(); + } + syncSources() { + this.disconnect(), this.lines = [], this.processId && this.loadSnapshotAndConnect(); + } + async loadSnapshotAndConnect() { + const s = ++this.syncToken; + if (this.processId) { + if (this.apiUrl) { + this.loadingSnapshot = !0; + try { + const e = await this.api.getProcessOutput(this.processId); + if (s !== this.syncToken) + return; + const t = this.linesFromOutput(e); + t.length > 0 && (this.lines = t); + } catch { + } finally { + s === this.syncToken && (this.loadingSnapshot = !1); + } + } + s === this.syncToken && this.wsUrl && this.connect(); + } + } + linesFromOutput(s) { + if (!s) + return []; + const t = s.replace(/\r\n/g, ` +`).split(` +`); + return t.length > 0 && t[t.length - 1] === "" && t.pop(), t.map((i) => ({ + text: i, + stream: "stdout", + timestamp: Date.now() + })); } connect() { - this.ws = xe(this.wsUrl, (s) => { + this.ws = Ae(this.wsUrl, (s) => { const e = s.data; if (!e) return; (s.channel ?? s.type ?? "") === "process.output" && e.id === this.processId && (this.lines = [ @@ -1328,7 +1366,7 @@ let v = class extends $ {
- ${this.lines.length === 0 ? c`
Waiting for output\u2026
` : this.lines.map( + ${this.loadingSnapshot && this.lines.length === 0 ? c`
Loading snapshot\u2026
` : this.lines.length === 0 ? c`
Waiting for output\u2026
` : this.lines.map( (s) => c`
${s.stream}${s.text} @@ -1339,7 +1377,7 @@ let v = class extends $ { ` : c`
Select a process to view its output.
`; } }; -v.styles = B` +b.styles = B` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1443,33 +1481,36 @@ v.styles = B` font-size: 0.8125rem; } `; -O([ +S([ f({ attribute: "api-url" }) -], v.prototype, "apiUrl", 2); -O([ +], b.prototype, "apiUrl", 2); +S([ f({ attribute: "ws-url" }) -], v.prototype, "wsUrl", 2); -O([ +], b.prototype, "wsUrl", 2); +S([ f({ attribute: "process-id" }) -], v.prototype, "processId", 2); -O([ +], b.prototype, "processId", 2); +S([ u() -], v.prototype, "lines", 2); -O([ +], b.prototype, "lines", 2); +S([ u() -], v.prototype, "autoScroll", 2); -O([ +], b.prototype, "autoScroll", 2); +S([ u() -], v.prototype, "connected", 2); -v = O([ - F("core-process-output") -], v); -var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, Q = (s, e, t, i) => { +], b.prototype, "connected", 2); +S([ + u() +], b.prototype, "loadingSnapshot", 2); +b = S([ + W("core-process-output") +], b); +var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, X = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Xe(e, t, r), r; }; -let H = class extends $ { +let H = class extends y { constructor() { super(...arguments), this.apiUrl = "", this.result = null, this.expandedOutputs = /* @__PURE__ */ new Set(); } @@ -1740,24 +1781,24 @@ H.styles = B` margin-bottom: 1rem; } `; -Q([ +X([ f({ attribute: "api-url" }) ], H.prototype, "apiUrl", 2); -Q([ +X([ f({ type: Object }) ], H.prototype, "result", 2); -Q([ +X([ u() ], H.prototype, "expandedOutputs", 2); -H = Q([ - F("core-process-runner") +H = X([ + W("core-process-runner") ], H); var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && et(e, t, r), r; }; -let _ = class extends $ { +let _ = class extends y { constructor() { super(...arguments), this.apiUrl = "", this.wsUrl = "", this.activeTab = "daemons", this.wsConnected = !1, this.lastEvent = "", this.selectedProcessId = "", this.ws = null, this.tabs = [ { id: "daemons", label: "Daemons" }, @@ -1772,7 +1813,7 @@ let _ = class extends $ { super.disconnectedCallback(), this.ws && (this.ws.close(), this.ws = null); } connectWs() { - this.ws = xe(this.wsUrl, (s) => { + this.ws = Ae(this.wsUrl, (s) => { this.lastEvent = s.channel ?? s.type ?? "", this.requestUpdate(); }), this.ws.onopen = () => { this.wsConnected = !0; @@ -1982,14 +2023,14 @@ z([ u() ], _.prototype, "selectedProcessId", 2); _ = z([ - F("core-process-panel") + W("core-process-panel") ], _); export { - Ae as ProcessApi, + G as ProcessApi, g as ProcessDaemons, - y as ProcessList, - v as ProcessOutput, + v as ProcessList, + b as ProcessOutput, _ as ProcessPanel, H as ProcessRunner, - xe as connectProcessEvents + Ae as connectProcessEvents }; diff --git a/ui/src/process-output.ts b/ui/src/process-output.ts index e16fcbc..91948bb 100644 --- a/ui/src/process-output.ts +++ b/ui/src/process-output.ts @@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { connectProcessEvents, type ProcessEvent } from './shared/events.js'; +import { ProcessApi } from './shared/api.js'; interface OutputLine { text: string; @@ -131,14 +132,15 @@ export class ProcessOutput extends LitElement { @state() private lines: OutputLine[] = []; @state() private autoScroll = true; @state() private connected = false; + @state() private loadingSnapshot = false; private ws: WebSocket | null = null; + private api = new ProcessApi(this.apiUrl); + private syncToken = 0; connectedCallback() { super.connectedCallback(); - if (this.wsUrl && this.processId) { - this.connect(); - } + this.syncSources(); } disconnectedCallback() { @@ -147,12 +149,12 @@ export class ProcessOutput extends LitElement { } updated(changed: Map) { - if (changed.has('processId') || changed.has('wsUrl')) { - this.disconnect(); - this.lines = []; - if (this.wsUrl && this.processId) { - this.connect(); - } + if (changed.has('apiUrl')) { + this.api = new ProcessApi(this.apiUrl); + } + + if (changed.has('processId') || changed.has('wsUrl') || changed.has('apiUrl')) { + this.syncSources(); } if (this.autoScroll) { @@ -160,6 +162,66 @@ export class ProcessOutput extends LitElement { } } + private syncSources() { + this.disconnect(); + this.lines = []; + if (!this.processId) { + return; + } + + void this.loadSnapshotAndConnect(); + } + + private async loadSnapshotAndConnect() { + const token = ++this.syncToken; + + if (!this.processId) { + return; + } + + if (this.apiUrl) { + this.loadingSnapshot = true; + try { + const output = await this.api.getProcessOutput(this.processId); + if (token !== this.syncToken) { + return; + } + const snapshot = this.linesFromOutput(output); + if (snapshot.length > 0) { + this.lines = snapshot; + } + } catch { + // Ignore missing snapshot data and continue with live streaming. + } finally { + if (token === this.syncToken) { + this.loadingSnapshot = false; + } + } + } + + if (token === this.syncToken && this.wsUrl) { + this.connect(); + } + } + + private linesFromOutput(output: string): OutputLine[] { + if (!output) { + return []; + } + + const normalized = output.replace(/\r\n/g, '\n'); + const parts = normalized.split('\n'); + if (parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop(); + } + + return parts.map((text) => ({ + text, + stream: 'stdout' as const, + timestamp: Date.now(), + })); + } + private connect() { this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { const data = event.data; @@ -231,7 +293,9 @@ export class ProcessOutput extends LitElement {
- ${this.lines.length === 0 + ${this.loadingSnapshot && this.lines.length === 0 + ? html`
Loading snapshot\u2026
` + : this.lines.length === 0 ? html`
Waiting for output\u2026
` : this.lines.map( (line) => html` diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts index 56e616c..0d05c87 100644 --- a/ui/src/shared/api.ts +++ b/ui/src/shared/api.ts @@ -31,6 +31,7 @@ export interface ProcessInfo { args: string[]; dir: string; startedAt: string; + running: boolean; status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'; exitCode: number; duration: number; @@ -126,6 +127,11 @@ export class ProcessApi { return this.request(`/processes/${id}`); } + /** Get the captured stdout/stderr for a managed process by ID. */ + getProcessOutput(id: string): Promise { + return this.request(`/processes/${id}/output`); + } + /** Kill a managed process by ID. */ killProcess(id: string): Promise<{ killed: boolean }> { return this.request<{ killed: boolean }>(`/processes/${id}/kill`, { From b097e0ef0e8b1338f54baf3dc5e3dfd49b707ea9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:48:07 +0000 Subject: [PATCH 44/97] fix(process): mark daemon not-ready before shutdown Co-Authored-By: Virgil --- daemon.go | 20 ++++++++------ daemon_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/daemon.go b/daemon.go index ecf8cea..323e135 100644 --- a/daemon.go +++ b/daemon.go @@ -174,10 +174,14 @@ func (d *Daemon) Stop() error { shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) defer cancel() - // Auto-unregister - if d.opts.Registry != nil { - if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) + // Mark the daemon unavailable before tearing down listeners or registry state. + if d.health != nil { + d.health.SetReady(false) + } + + if d.health != nil { + if err := d.health.Stop(shutdownCtx); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) } } @@ -187,10 +191,10 @@ func (d *Daemon) Stop() error { } } - if d.health != nil { - d.health.SetReady(false) - if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) + // Auto-unregister after the process is no longer serving traffic. + if d.opts.Registry != nil { + if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) } } diff --git a/daemon_test.go b/daemon_test.go index cb862b5..c6ae5df 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "sync" "testing" "time" @@ -36,6 +37,78 @@ func TestDaemon_StartAndStop(t *testing.T) { require.NoError(t, err) } +func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) { + blockCheck := make(chan struct{}) + checkEntered := make(chan struct{}) + var once sync.Once + + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + ShutdownTimeout: 5 * time.Second, + HealthChecks: []HealthCheck{ + func() error { + once.Do(func() { close(checkEntered) }) + <-blockCheck + return nil + }, + }, + }) + + err := d.Start() + require.NoError(t, err) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + healthErr := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + healthErr <- err + return + } + _ = resp.Body.Close() + healthErr <- nil + }() + + select { + case <-checkEntered: + case <-time.After(2 * time.Second): + t.Fatal("/health request did not enter the blocking check") + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- d.Stop() + }() + + require.Eventually(t, func() bool { + return !d.Ready() + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes") + + select { + case err := <-stopDone: + t.Fatalf("daemon stopped too early: %v", err) + default: + } + + close(blockCheck) + + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("daemon stop did not finish after health check unblocked") + } + + select { + case err := <-healthErr: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("/health request did not finish") + } +} + func TestDaemon_DoubleStartFails(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", From 945e760542a360d2cc4cb615f8863d4860d205b7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:51:39 +0000 Subject: [PATCH 45/97] fix(process): unregister daemon before health shutdown Co-Authored-By: Virgil --- daemon.go | 14 ++++----- daemon_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/daemon.go b/daemon.go index 323e135..2e63d02 100644 --- a/daemon.go +++ b/daemon.go @@ -179,9 +179,10 @@ func (d *Daemon) Stop() error { d.health.SetReady(false) } - if d.health != nil { - if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) + // Auto-unregister after the process is no longer serving traffic. + if d.opts.Registry != nil { + if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) } } @@ -191,10 +192,9 @@ func (d *Daemon) Stop() error { } } - // Auto-unregister after the process is no longer serving traffic. - if d.opts.Registry != nil { - if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) + if d.health != nil { + if err := d.health.Stop(shutdownCtx); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) } } diff --git a/daemon_test.go b/daemon_test.go index c6ae5df..f32a0e7 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -109,6 +109,90 @@ func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) { } } +func TestDaemon_StopUnregistersBeforeHealthShutdownCompletes(t *testing.T) { + blockCheck := make(chan struct{}) + checkEntered := make(chan struct{}) + var once sync.Once + dir := t.TempDir() + reg := NewRegistry(filepath.Join(dir, "registry")) + + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + ShutdownTimeout: 5 * time.Second, + Registry: reg, + RegistryEntry: DaemonEntry{ + Code: "test-app", + Daemon: "serve", + }, + HealthChecks: []HealthCheck{ + func() error { + once.Do(func() { close(checkEntered) }) + <-blockCheck + return nil + }, + }, + }) + + err := d.Start() + require.NoError(t, err) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + healthErr := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + healthErr <- err + return + } + _ = resp.Body.Close() + healthErr <- nil + }() + + select { + case <-checkEntered: + case <-time.After(2 * time.Second): + t.Fatal("/health request did not enter the blocking check") + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- d.Stop() + }() + + require.Eventually(t, func() bool { + return !d.Ready() + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes") + + require.Eventually(t, func() bool { + _, ok := reg.Get("test-app", "serve") + return !ok + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister before health shutdown completes") + + select { + case err := <-stopDone: + t.Fatalf("daemon stopped too early: %v", err) + default: + } + + close(blockCheck) + + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("daemon stop did not finish after health check unblocked") + } + + select { + case err := <-healthErr: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("/health request did not finish") + } +} + func TestDaemon_DoubleStartFails(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", From 66d5b0a15eb4203d0e1172b2a7d1f4808f79bc21 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:55:10 +0000 Subject: [PATCH 46/97] fix(process): make registry unregister idempotent Co-Authored-By: Virgil --- registry.go | 3 +++ registry_test.go | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/registry.go b/registry.go index cfc0722..0c091c4 100644 --- a/registry.go +++ b/registry.go @@ -89,6 +89,9 @@ func (r *Registry) Register(entry DaemonEntry) error { // _ = reg.Unregister("app", "serve") func (r *Registry) Unregister(code, daemon string) error { if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil { + if os.IsNotExist(err) { + return nil + } return coreerr.E("Registry.Unregister", "failed to delete entry file", err) } return nil diff --git a/registry_test.go b/registry_test.go index e442580..a3f1c41 100644 --- a/registry_test.go +++ b/registry_test.go @@ -65,6 +65,14 @@ func TestRegistry_Unregister(t *testing.T) { assert.True(t, os.IsNotExist(err)) } +func TestRegistry_UnregisterMissingIsNoop(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + err := reg.Unregister("missing", "entry") + require.NoError(t, err) +} + func TestRegistry_List(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) From 6f35954ac220c773fd42dc485cfab79f1d377e5f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:59:58 +0000 Subject: [PATCH 47/97] feat(process-ui): stream live process list from websocket --- pkg/api/ui/dist/core-process.js | 392 +++++++++++++++++++------------- ui/src/process-list.ts | 118 +++++++++- ui/src/process-panel.ts | 1 + 3 files changed, 344 insertions(+), 167 deletions(-) diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index d276042..93201bf 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -3,8 +3,8 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const V = globalThis, ie = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), ae = /* @__PURE__ */ new WeakMap(); -let ye = class { +const J = globalThis, ie = J.ShadowRoot && (J.ShadyCSS === void 0 || J.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), le = /* @__PURE__ */ new WeakMap(); +let ve = class { constructor(e, t, i) { if (this._$cssResult$ = !0, i !== re) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = e, this.t = t; @@ -14,7 +14,7 @@ let ye = class { const t = this.t; if (ie && e === void 0) { const i = t !== void 0 && t.length === 1; - i && (e = ae.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ae.set(t, e)); + i && (e = le.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && le.set(t, e)); } return e; } @@ -22,30 +22,30 @@ let ye = class { return this.cssText; } }; -const ke = (s) => new ye(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => { +const Ae = (s) => new ve(typeof s == "string" ? s : s + "", void 0, re), F = (s, ...e) => { const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => { if (o._$cssResult$ === !0) return o.cssText; if (typeof o == "number") return o; throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + s[n + 1], s[0]); - return new ye(t, s, re); + return new ve(t, s, re); }, Se = (s, e) => { if (ie) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const t of e) { - const i = document.createElement("style"), r = V.litNonce; + const i = document.createElement("style"), r = J.litNonce; r !== void 0 && i.setAttribute("nonce", r), i.textContent = t.cssText, s.appendChild(i); } -}, le = ie ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { +}, ce = ie ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { let t = ""; for (const i of e.cssRules) t += i.cssText; - return ke(t); + return Ae(t); })(s) : s; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, ce = A.trustedTypes, Te = ce ? ce.emptyScript : "", Y = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { +const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, de = A.trustedTypes, Te = de ? de.emptyScript : "", Y = A.reactiveElementPolyfillSupport, j = (s, e) => s, Z = { toAttribute(s, e) { switch (e) { case Boolean: s = s ? Te : null; @@ -73,7 +73,7 @@ const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnProperty } } return t; -} }, oe = (s, e) => !Pe(s, e), de = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: oe }; +} }, oe = (s, e) => !Pe(s, e), he = { attribute: !0, type: String, converter: Z, reflect: !1, useDefault: !1, hasChanged: oe }; Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); let T = class extends HTMLElement { static addInitializer(e) { @@ -82,7 +82,7 @@ let T = class extends HTMLElement { static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(e, t = de) { + static createProperty(e, t = he) { if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) { const i = Symbol(), r = this.getPropertyDescriptor(e, i, t); r !== void 0 && Ce(this.prototype, e, r); @@ -100,7 +100,7 @@ let T = class extends HTMLElement { }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(e) { - return this.elementProperties.get(e) ?? de; + return this.elementProperties.get(e) ?? he; } static _$Ei() { if (this.hasOwnProperty(j("elementProperties"))) return; @@ -129,8 +129,8 @@ let T = class extends HTMLElement { const t = []; if (Array.isArray(e)) { const i = new Set(e.flat(1 / 0).reverse()); - for (const r of i) t.unshift(le(r)); - } else e !== void 0 && t.push(le(e)); + for (const r of i) t.unshift(ce(r)); + } else e !== void 0 && t.push(ce(e)); return t; } static _$Eu(e, t) { @@ -184,7 +184,7 @@ let T = class extends HTMLElement { var n; const i = this.constructor.elementProperties.get(e), r = this.constructor._$Eu(e, i); if (r !== void 0 && i.reflect === !0) { - const o = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : J).toAttribute(t, i.type); + const o = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : Z).toAttribute(t, i.type); this._$Em = e, o == null ? this.removeAttribute(r) : this.setAttribute(r, o), this._$Em = null; } } @@ -192,7 +192,7 @@ let T = class extends HTMLElement { var n, o; const i = this.constructor, r = i._$Eh.get(e); if (r !== void 0 && this._$Em !== r) { - const l = i.getPropertyOptions(r), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : J; + const l = i.getPropertyOptions(r), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : Z; this._$Em = r; const p = a.fromAttribute(t, l.type); this[r] = p ?? ((o = this._$Ej) == null ? void 0 : o.get(r)) ?? p, this._$Em = null; @@ -284,59 +284,59 @@ T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[j("elementProper * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const N = globalThis, he = (s) => s, Z = N.trustedTypes, pe = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ve = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + x, De = `<${_e}>`, U = document, I = () => U.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = `[ -\f\r]`, R = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ue = /-->/g, me = />/g, P = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), fe = /'/g, ge = /"/g, we = /^(?:script|style|textarea|title)$/i, He = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = He(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), be = /* @__PURE__ */ new WeakMap(), C = U.createTreeWalker(U, 129); -function xe(s, e) { +const N = globalThis, pe = (s) => s, G = N.trustedTypes, ue = G ? G.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, we = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + k, De = `<${_e}>`, O = document, I = () => O.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = `[ +\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, me = /-->/g, fe = />/g, C = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), ge = /'/g, be = /"/g, xe = /^(?:script|style|textarea|title)$/i, Re = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Re(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), $e = /* @__PURE__ */ new WeakMap(), E = O.createTreeWalker(O, 129); +function ke(s, e) { if (!ne(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return pe !== void 0 ? pe.createHTML(e) : e; + return ue !== void 0 ? ue.createHTML(e) : e; } -const Re = (s, e) => { +const He = (s, e) => { const t = s.length - 1, i = []; - let r, n = e === 2 ? "" : e === 3 ? "" : "", o = R; + let r, n = e === 2 ? "" : e === 3 ? "" : "", o = H; for (let l = 0; l < t; l++) { const a = s[l]; - let p, m, h = -1, $ = 0; - for (; $ < a.length && (o.lastIndex = $, m = o.exec(a), m !== null); ) $ = o.lastIndex, o === R ? m[1] === "!--" ? o = ue : m[1] !== void 0 ? o = me : m[2] !== void 0 ? (we.test(m[2]) && (r = RegExp("" ? (o = r ?? R, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? P : m[3] === '"' ? ge : fe) : o === ge || o === fe ? o = P : o === ue || o === me ? o = R : (o = P, r = void 0); - const w = o === P && s[l + 1].startsWith("/>") ? " " : ""; - n += o === R ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ve + a.slice(h) + x + w) : a + x + (h === -2 ? l : w); + let p, m, h = -1, y = 0; + for (; y < a.length && (o.lastIndex = y, m = o.exec(a), m !== null); ) y = o.lastIndex, o === H ? m[1] === "!--" ? o = me : m[1] !== void 0 ? o = fe : m[2] !== void 0 ? (xe.test(m[2]) && (r = RegExp("" ? (o = r ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? C : m[3] === '"' ? be : ge) : o === be || o === ge ? o = C : o === me || o === fe ? o = H : (o = C, r = void 0); + const x = o === C && s[l + 1].startsWith("/>") ? " " : ""; + n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + we + a.slice(h) + k + x) : a + k + (h === -2 ? l : x); } - return [xe(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; + return [ke(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; }; class q { constructor({ strings: e, _$litType$: t }, i) { let r; this.parts = []; let n = 0, o = 0; - const l = e.length - 1, a = this.parts, [p, m] = Re(e, t); - if (this.el = q.createElement(p, i), C.currentNode = this.el.content, t === 2 || t === 3) { + const l = e.length - 1, a = this.parts, [p, m] = He(e, t); + if (this.el = q.createElement(p, i), E.currentNode = this.el.content, t === 2 || t === 3) { const h = this.el.content.firstChild; h.replaceWith(...h.childNodes); } - for (; (r = C.nextNode()) !== null && a.length < l; ) { + for (; (r = E.nextNode()) !== null && a.length < l; ) { if (r.nodeType === 1) { - if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(ve)) { - const $ = m[o++], w = r.getAttribute(h).split(x), K = /([.?@])?(.*)/.exec($); - a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? Ne : K[1] === "?" ? Ie : K[1] === "@" ? Le : Q }), r.removeAttribute(h); - } else h.startsWith(x) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); - if (we.test(r.tagName)) { - const h = r.textContent.split(x), $ = h.length - 1; - if ($ > 0) { - r.textContent = Z ? Z.emptyScript : ""; - for (let w = 0; w < $; w++) r.append(h[w], I()), C.nextNode(), a.push({ type: 2, index: ++n }); - r.append(h[$], I()); + if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(we)) { + const y = m[o++], x = r.getAttribute(h).split(k), V = /([.?@])?(.*)/.exec(y); + a.push({ type: 1, index: n, name: V[2], strings: x, ctor: V[1] === "." ? Ne : V[1] === "?" ? Ie : V[1] === "@" ? Le : Q }), r.removeAttribute(h); + } else h.startsWith(k) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); + if (xe.test(r.tagName)) { + const h = r.textContent.split(k), y = h.length - 1; + if (y > 0) { + r.textContent = G ? G.emptyScript : ""; + for (let x = 0; x < y; x++) r.append(h[x], I()), E.nextNode(), a.push({ type: 2, index: ++n }); + r.append(h[y], I()); } } } else if (r.nodeType === 8) if (r.data === _e) a.push({ type: 2, index: n }); else { let h = -1; - for (; (h = r.data.indexOf(x, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += x.length - 1; + for (; (h = r.data.indexOf(k, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += k.length - 1; } n++; } } static createElement(e, t) { - const i = U.createElement("template"); + const i = O.createElement("template"); return i.innerHTML = e, i; } } @@ -358,24 +358,24 @@ class je { return this._$AM._$AU; } u(e) { - const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? U).importNode(t, !0); - C.currentNode = r; - let n = C.nextNode(), o = 0, l = 0, a = i[0]; + const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? O).importNode(t, !0); + E.currentNode = r; + let n = E.nextNode(), o = 0, l = 0, a = i[0]; for (; a !== void 0; ) { if (o === a.index) { let p; - a.type === 2 ? p = new F(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; + a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; } - o !== (a == null ? void 0 : a.index) && (n = C.nextNode(), o++); + o !== (a == null ? void 0 : a.index) && (n = E.nextNode(), o++); } - return C.currentNode = U, r; + return E.currentNode = O, r; } p(e) { let t = 0; for (const i of this._$AV) i !== void 0 && (i.strings !== void 0 ? (i._$AI(e, i, t), t += i.strings.length - 2) : i._$AI(e[t])), t++; } } -class F { +class W { get _$AU() { var e; return ((e = this._$AM) == null ? void 0 : e._$AU) ?? this._$Cv; @@ -404,11 +404,11 @@ class F { this._$AH !== e && (this._$AR(), this._$AH = this.O(e)); } _(e) { - this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(U.createTextNode(e)), this._$AH = e; + this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(O.createTextNode(e)), this._$AH = e; } $(e) { var n; - const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(xe(i.h, i.h[0]), this.options)), i); + const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(ke(i.h, i.h[0]), this.options)), i); if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t); else { const o = new je(r, this), l = o.u(this.options); @@ -416,21 +416,21 @@ class F { } } _$AC(e) { - let t = be.get(e.strings); - return t === void 0 && be.set(e.strings, t = new q(e)), t; + let t = $e.get(e.strings); + return t === void 0 && $e.set(e.strings, t = new q(e)), t; } k(e) { ne(this._$AH) || (this._$AH = [], this._$AR()); const t = this._$AH; let i, r = 0; - for (const n of e) r === t.length ? t.push(i = new F(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++; + for (const n of e) r === t.length ? t.push(i = new W(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++; r < t.length && (this._$AR(i && i._$AB.nextSibling, r), t.length = r); } _$AR(e = this._$AA.nextSibling, t) { var i; for ((i = this._$AP) == null ? void 0 : i.call(this, !1, !0, t); e !== this._$AB; ) { - const r = he(e).nextSibling; - he(e).remove(), e = r; + const r = pe(e).nextSibling; + pe(e).remove(), e = r; } } setConnected(e) { @@ -505,13 +505,13 @@ class qe { } } const te = N.litHtmlPolyfillSupport; -te == null || te(q, F), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); +te == null || te(q, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); const Be = (s, e, t) => { const i = (t == null ? void 0 : t.renderBefore) ?? e; let r = i._$litPart$; if (r === void 0) { const n = (t == null ? void 0 : t.renderBefore) ?? null; - i._$litPart$ = r = new F(e.insertBefore(I(), n), n, void 0, t ?? {}); + i._$litPart$ = r = new W(e.insertBefore(I(), n), n, void 0, t ?? {}); } return r._$AI(s), r; }; @@ -520,8 +520,8 @@ const Be = (s, e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const E = globalThis; -class y extends T { +const U = globalThis; +class v extends T { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -546,17 +546,17 @@ class y extends T { return D; } } -var $e; -y._$litElement$ = !0, y.finalized = !0, ($e = E.litElementHydrateSupport) == null || $e.call(E, { LitElement: y }); -const se = E.litElementPolyfillSupport; -se == null || se({ LitElement: y }); -(E.litElementVersions ?? (E.litElementVersions = [])).push("4.2.2"); +var ye; +v._$litElement$ = !0, v.finalized = !0, (ye = U.litElementHydrateSupport) == null || ye.call(U, { LitElement: v }); +const se = U.litElementPolyfillSupport; +se == null || se({ LitElement: v }); +(U.litElementVersions ?? (U.litElementVersions = [])).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const W = (s) => (e, t) => { +const K = (s) => (e, t) => { t !== void 0 ? t.addInitializer(() => { customElements.define(s, e); }) : customElements.define(s, e); @@ -566,7 +566,7 @@ const W = (s) => (e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Fe = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: oe }, We = (s = Fe, e, t) => { +const Fe = { attribute: !0, type: String, converter: Z, reflect: !1, hasChanged: oe }, We = (s = Fe, e, t) => { const { kind: i, metadata: r } = t; let n = globalThis.litPropertyMetadata.get(r); if (n === void 0 && globalThis.litPropertyMetadata.set(r, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") { @@ -601,7 +601,7 @@ function f(s) { function u(s) { return f({ ...s, state: !0, attribute: !1 }); } -function Ae(s, e) { +function ae(s, e) { const t = new WebSocket(s); return t.onmessage = (i) => { var r, n, o, l; @@ -612,7 +612,7 @@ function Ae(s, e) { } }, t; } -class G { +class B { constructor(e = "") { this.baseUrl = e; } @@ -671,17 +671,17 @@ class G { }); } } -var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, k = (s, e, t, i) => { +var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ve(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Ke(e, t, r), r; }; -let g = class extends y { +let b = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.daemons = [], this.loading = !0, this.error = "", this.stopping = /* @__PURE__ */ new Set(), this.checking = /* @__PURE__ */ new Set(), this.healthResults = /* @__PURE__ */ new Map(); } connectedCallback() { - super.connectedCallback(), this.api = new G(this.apiUrl), this.loadDaemons(); + super.connectedCallback(), this.api = new B(this.apiUrl), this.loadDaemons(); } async loadDaemons() { this.loading = !0, this.error = ""; @@ -796,7 +796,7 @@ let g = class extends y { `; } }; -g.styles = B` +b.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -949,46 +949,52 @@ g.styles = B` margin-bottom: 1rem; } `; -k([ +S([ f({ attribute: "api-url" }) -], g.prototype, "apiUrl", 2); -k([ +], b.prototype, "apiUrl", 2); +S([ u() -], g.prototype, "daemons", 2); -k([ +], b.prototype, "daemons", 2); +S([ u() -], g.prototype, "loading", 2); -k([ +], b.prototype, "loading", 2); +S([ u() -], g.prototype, "error", 2); -k([ +], b.prototype, "error", 2); +S([ u() -], g.prototype, "stopping", 2); -k([ +], b.prototype, "stopping", 2); +S([ u() -], g.prototype, "checking", 2); -k([ +], b.prototype, "checking", 2); +S([ u() -], g.prototype, "healthResults", 2); -g = k([ - W("core-process-daemons") -], g); -var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, O = (s, e, t, i) => { +], b.prototype, "healthResults", 2); +b = S([ + K("core-process-daemons") +], b); +var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, _ = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Je(e, t, r), r; }; -let v = class extends y { +let g = class extends v { constructor() { - super(...arguments), this.apiUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.killing = /* @__PURE__ */ new Set(); + super(...arguments), this.apiUrl = "", this.wsUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.connected = !1, this.killing = /* @__PURE__ */ new Set(), this.ws = null; } connectedCallback() { - super.connectedCallback(), this.api = new G(this.apiUrl), this.loadProcesses(); + super.connectedCallback(), this.api = new B(this.apiUrl), this.loadProcesses(); + } + disconnectedCallback() { + super.disconnectedCallback(), this.disconnect(); + } + updated(s) { + s.has("apiUrl") && (this.api = new B(this.apiUrl)), (s.has("wsUrl") || s.has("apiUrl")) && (this.disconnect(), this.loadProcesses()); } async loadProcesses() { this.loading = !0, this.error = ""; try { - this.processes = await this.api.listProcesses(); + this.processes = await this.api.listProcesses(), this.wsUrl && this.connect(); } catch (s) { this.error = s.message ?? "Failed to load processes", this.processes = []; } finally { @@ -1015,6 +1021,59 @@ let v = class extends y { e.delete(s.id), this.killing = e; } } + connect() { + !this.wsUrl || this.ws || (this.ws = ae(this.wsUrl, (s) => { + this.applyEvent(s); + }), this.ws.onopen = () => { + this.connected = !0; + }, this.ws.onclose = () => { + this.connected = !1; + }); + } + disconnect() { + this.ws && (this.ws.close(), this.ws = null), this.connected = !1; + } + applyEvent(s) { + const e = s.channel ?? s.type ?? "", t = s.data ?? {}; + if (!t.id) + return; + const i = new Map(this.processes.map((n) => [n.id, n])), r = i.get(t.id); + switch (e) { + case "process.started": + i.set(t.id, this.normalizeProcess(t, r, "running")); + break; + case "process.exited": + i.set(t.id, this.normalizeProcess(t, r, t.exitCode === -1 && t.error ? "failed" : "exited")); + break; + case "process.killed": + i.set(t.id, this.normalizeProcess(t, r, "killed")); + break; + default: + return; + } + this.processes = this.sortProcesses(i); + } + normalizeProcess(s, e, t) { + const i = s.startedAt ?? (e == null ? void 0 : e.startedAt) ?? (/* @__PURE__ */ new Date()).toISOString(); + return { + id: s.id, + command: s.command ?? (e == null ? void 0 : e.command) ?? "", + args: s.args ?? (e == null ? void 0 : e.args) ?? [], + dir: s.dir ?? (e == null ? void 0 : e.dir) ?? "", + startedAt: i, + running: t === "running", + status: t, + exitCode: s.exitCode ?? (e == null ? void 0 : e.exitCode) ?? (t === "killed" ? -1 : 0), + duration: s.duration ?? (e == null ? void 0 : e.duration) ?? 0, + pid: s.pid ?? (e == null ? void 0 : e.pid) ?? 0 + }; + } + sortProcesses(s) { + return [...s.values()].sort((e, t) => { + const i = new Date(e.startedAt).getTime(), r = new Date(t.startedAt).getTime(); + return i === r ? e.id.localeCompare(t.id) : i - r; + }); + } formatUptime(s) { try { const e = Date.now() - new Date(s).getTime(), t = Math.floor(e / 1e3); @@ -1030,7 +1089,7 @@ let v = class extends y { ${this.error ? c`
${this.error}
` : d} ${this.processes.length === 0 ? c`
- Managed processes are loaded from the process REST API. + ${this.wsUrl ? this.connected ? "Receiving live process updates." : "Connecting to the process event stream..." : "Managed processes are loaded from the process REST API."}
No managed processes.
` : c` @@ -1080,7 +1139,7 @@ let v = class extends y { `; } }; -v.styles = B` +g.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1244,35 +1303,41 @@ v.styles = B` margin-bottom: 1rem; } `; -O([ +_([ f({ attribute: "api-url" }) -], v.prototype, "apiUrl", 2); -O([ +], g.prototype, "apiUrl", 2); +_([ + f({ attribute: "ws-url" }) +], g.prototype, "wsUrl", 2); +_([ f({ attribute: "selected-id" }) -], v.prototype, "selectedId", 2); -O([ +], g.prototype, "selectedId", 2); +_([ u() -], v.prototype, "processes", 2); -O([ +], g.prototype, "processes", 2); +_([ u() -], v.prototype, "loading", 2); -O([ +], g.prototype, "loading", 2); +_([ u() -], v.prototype, "error", 2); -O([ +], g.prototype, "error", 2); +_([ u() -], v.prototype, "killing", 2); -v = O([ - W("core-process-list") -], v); -var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => { +], g.prototype, "connected", 2); +_([ + u() +], g.prototype, "killing", 2); +g = _([ + K("core-process-list") +], g); +var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, P = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Ge(e, t, r), r; }; -let b = class extends y { +let $ = class extends v { constructor() { - super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.loadingSnapshot = !1, this.ws = null, this.api = new G(this.apiUrl), this.syncToken = 0; + super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.loadingSnapshot = !1, this.ws = null, this.api = new B(this.apiUrl), this.syncToken = 0; } connectedCallback() { super.connectedCallback(), this.syncSources(); @@ -1281,7 +1346,7 @@ let b = class extends y { super.disconnectedCallback(), this.disconnect(); } updated(s) { - s.has("apiUrl") && (this.api = new G(this.apiUrl)), (s.has("processId") || s.has("wsUrl") || s.has("apiUrl")) && this.syncSources(), this.autoScroll && this.scrollToBottom(); + s.has("apiUrl") && (this.api = new B(this.apiUrl)), (s.has("processId") || s.has("wsUrl") || s.has("apiUrl")) && this.syncSources(), this.autoScroll && this.scrollToBottom(); } syncSources() { this.disconnect(), this.lines = [], this.processId && this.loadSnapshotAndConnect(); @@ -1318,7 +1383,7 @@ let b = class extends y { })); } connect() { - this.ws = Ae(this.wsUrl, (s) => { + this.ws = ae(this.wsUrl, (s) => { const e = s.data; if (!e) return; (s.channel ?? s.type ?? "") === "process.output" && e.id === this.processId && (this.lines = [ @@ -1377,7 +1442,7 @@ let b = class extends y { ` : c`
Select a process to view its output.
`; } }; -b.styles = B` +$.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1481,36 +1546,36 @@ b.styles = B` font-size: 0.8125rem; } `; -S([ +P([ f({ attribute: "api-url" }) -], b.prototype, "apiUrl", 2); -S([ +], $.prototype, "apiUrl", 2); +P([ f({ attribute: "ws-url" }) -], b.prototype, "wsUrl", 2); -S([ +], $.prototype, "wsUrl", 2); +P([ f({ attribute: "process-id" }) -], b.prototype, "processId", 2); -S([ +], $.prototype, "processId", 2); +P([ u() -], b.prototype, "lines", 2); -S([ +], $.prototype, "lines", 2); +P([ u() -], b.prototype, "autoScroll", 2); -S([ +], $.prototype, "autoScroll", 2); +P([ u() -], b.prototype, "connected", 2); -S([ +], $.prototype, "connected", 2); +P([ u() -], b.prototype, "loadingSnapshot", 2); -b = S([ - W("core-process-output") -], b); +], $.prototype, "loadingSnapshot", 2); +$ = P([ + K("core-process-output") +], $); var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, X = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Xe(e, t, r), r; }; -let H = class extends y { +let R = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.result = null, this.expandedOutputs = /* @__PURE__ */ new Set(); } @@ -1586,7 +1651,7 @@ let H = class extends y { `; } }; -H.styles = B` +R.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1783,22 +1848,22 @@ H.styles = B` `; X([ f({ attribute: "api-url" }) -], H.prototype, "apiUrl", 2); +], R.prototype, "apiUrl", 2); X([ f({ type: Object }) -], H.prototype, "result", 2); +], R.prototype, "result", 2); X([ u() -], H.prototype, "expandedOutputs", 2); -H = X([ - W("core-process-runner") -], H); +], R.prototype, "expandedOutputs", 2); +R = X([ + K("core-process-runner") +], R); var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && et(e, t, r), r; }; -let _ = class extends y { +let w = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.wsUrl = "", this.activeTab = "daemons", this.wsConnected = !1, this.lastEvent = "", this.selectedProcessId = "", this.ws = null, this.tabs = [ { id: "daemons", label: "Daemons" }, @@ -1813,7 +1878,7 @@ let _ = class extends y { super.disconnectedCallback(), this.ws && (this.ws.close(), this.ws = null); } connectWs() { - this.ws = Ae(this.wsUrl, (s) => { + this.ws = ae(this.wsUrl, (s) => { this.lastEvent = s.channel ?? s.type ?? "", this.requestUpdate(); }), this.ws.onopen = () => { this.wsConnected = !0; @@ -1843,6 +1908,7 @@ let _ = class extends y { return c` ${this.selectedProcessId ? c`(); private api!: ProcessApi; + private ws: WebSocket | null = null; connectedCallback() { super.connectedCallback(); @@ -200,11 +203,30 @@ export class ProcessList extends LitElement { this.loadProcesses(); } + disconnectedCallback() { + super.disconnectedCallback(); + this.disconnect(); + } + + updated(changed: Map) { + if (changed.has('apiUrl')) { + this.api = new ProcessApi(this.apiUrl); + } + + if (changed.has('wsUrl') || changed.has('apiUrl')) { + this.disconnect(); + void this.loadProcesses(); + } + } + async loadProcesses() { this.loading = true; this.error = ''; try { this.processes = await this.api.listProcesses(); + if (this.wsUrl) { + this.connect(); + } } catch (e: any) { this.error = e.message ?? 'Failed to load processes'; this.processes = []; @@ -237,6 +259,90 @@ export class ProcessList extends LitElement { } } + private connect() { + if (!this.wsUrl || this.ws) { + return; + } + + this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { + this.applyEvent(event); + }); + + this.ws.onopen = () => { + this.connected = true; + }; + this.ws.onclose = () => { + this.connected = false; + }; + } + + private disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.connected = false; + } + + private applyEvent(event: ProcessEvent) { + const channel = event.channel ?? event.type ?? ''; + const data = (event.data ?? {}) as Partial & { id?: string }; + + if (!data.id) { + return; + } + + const next = new Map(this.processes.map((proc) => [proc.id, proc] as const)); + const current = next.get(data.id); + + switch (channel) { + case 'process.started': + next.set(data.id, this.normalizeProcess(data, current, 'running')); + break; + case 'process.exited': + next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited')); + break; + case 'process.killed': + next.set(data.id, this.normalizeProcess(data, current, 'killed')); + break; + default: + return; + } + + this.processes = this.sortProcesses(next); + } + + private normalizeProcess( + data: Partial & { id: string; error?: unknown }, + current: ProcessInfo | undefined, + status: ProcessInfo['status'], + ): ProcessInfo { + const startedAt = data.startedAt ?? current?.startedAt ?? new Date().toISOString(); + return { + id: data.id, + command: data.command ?? current?.command ?? '', + args: data.args ?? current?.args ?? [], + dir: data.dir ?? current?.dir ?? '', + startedAt, + running: status === 'running', + status, + exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0), + duration: data.duration ?? current?.duration ?? 0, + pid: data.pid ?? current?.pid ?? 0, + }; + } + + private sortProcesses(processes: Map): ProcessInfo[] { + return [...processes.values()].sort((a, b) => { + const aStarted = new Date(a.startedAt).getTime(); + const bStarted = new Date(b.startedAt).getTime(); + if (aStarted === bStarted) { + return a.id.localeCompare(b.id); + } + return aStarted - bStarted; + }); + } + private formatUptime(started: string): string { try { const ms = Date.now() - new Date(started).getTime(); @@ -261,7 +367,11 @@ export class ProcessList extends LitElement { ${this.processes.length === 0 ? html`
- Managed processes are loaded from the process REST API. + ${this.wsUrl + ? this.connected + ? 'Receiving live process updates.' + : 'Connecting to the process event stream...' + : 'Managed processes are loaded from the process REST API.'}
No managed processes.
` diff --git a/ui/src/process-panel.ts b/ui/src/process-panel.ts index 703c72f..5d006c5 100644 --- a/ui/src/process-panel.ts +++ b/ui/src/process-panel.ts @@ -206,6 +206,7 @@ export class ProcessPanel extends LitElement { return html` ${this.selectedProcessId From 73b0ffecc0fdfc272616dda93cf048f6bb449e3e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:03:49 +0000 Subject: [PATCH 48/97] fix(process): reject nil start context --- service.go | 4 ++++ service_test.go | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/service.go b/service.go index 4794310..13cc019 100644 --- a/service.go +++ b/service.go @@ -25,6 +25,7 @@ var ( ErrProcessNotFound = coreerr.E("", "process not found", nil) ErrProcessNotRunning = coreerr.E("", "process is not running", nil) ErrStdinNotAvailable = coreerr.E("", "stdin not available", nil) + ErrContextRequired = coreerr.E("", "context is required", nil) ) // Service manages process execution with Core IPC integration. @@ -138,6 +139,9 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce if opts.Command == "" { return nil, coreerr.E("Service.StartWithOptions", "command is required", nil) } + if ctx == nil { + return nil, coreerr.E("Service.StartWithOptions", "context is required", ErrContextRequired) + } id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) startedAt := time.Now() diff --git a/service_test.go b/service_test.go index 3824ac7..6579bdf 100644 --- a/service_test.go +++ b/service_test.go @@ -90,6 +90,16 @@ func TestService_Start(t *testing.T) { assert.Contains(t, err.Error(), "command is required") }) + t.Run("nil context is rejected", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(nil, RunOptions{ + Command: "echo", + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrContextRequired) + }) + t.Run("with working directory", func(t *testing.T) { svc, _ := newTestService(t) @@ -698,6 +708,16 @@ func TestService_RunWithOptions(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "exited with code 2") }) + + t.Run("rejects nil context", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.RunWithOptions(nil, RunOptions{ + Command: "echo", + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrContextRequired) + }) } func TestService_Running(t *testing.T) { From 38a9f034a7ea41e2711fb6dffb52ac8e37e33268 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:07:23 +0000 Subject: [PATCH 49/97] fix(process): handle zero-capacity ring buffers --- buffer.go | 7 +++++++ buffer_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/buffer.go b/buffer.go index bf02f59..88c61a2 100644 --- a/buffer.go +++ b/buffer.go @@ -15,6 +15,9 @@ type RingBuffer struct { // NewRingBuffer creates a ring buffer with the given capacity. func NewRingBuffer(size int) *RingBuffer { + if size < 0 { + size = 0 + } return &RingBuffer{ data: make([]byte, size), size: size, @@ -26,6 +29,10 @@ func (rb *RingBuffer) Write(p []byte) (n int, err error) { rb.mu.Lock() defer rb.mu.Unlock() + if rb.size == 0 { + return len(p), nil + } + for _, b := range p { rb.data[rb.end] = b rb.end = (rb.end + 1) % rb.size diff --git a/buffer_test.go b/buffer_test.go index bbd4f1c..4d4e79f 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -69,4 +69,18 @@ func TestRingBuffer(t *testing.T) { bytes[0] = 'x' assert.Equal(t, "hello", rb.String()) }) + + t.Run("zero or negative capacity is a no-op", func(t *testing.T) { + for _, size := range []int{0, -1} { + rb := NewRingBuffer(size) + + n, err := rb.Write([]byte("discarded")) + assert.NoError(t, err) + assert.Equal(t, len("discarded"), n) + assert.Equal(t, 0, rb.Cap()) + assert.Equal(t, 0, rb.Len()) + assert.Equal(t, "", rb.String()) + assert.Nil(t, rb.Bytes()) + } + }) } From 26af69d87bd46489fb9abba731cb16300a804db0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:10:35 +0000 Subject: [PATCH 50/97] fix(process): kill process groups on shutdown --- process.go | 16 ++++++++++++++++ service.go | 9 ++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/process.go b/process.go index 2c153d8..76cfc5d 100644 --- a/process.go +++ b/process.go @@ -166,6 +166,22 @@ func (p *Process) kill() (bool, error) { return true, p.cmd.Process.Kill() } +// killTree forcefully terminates the process group when one exists. +func (p *Process) killTree() (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.Status != StatusRunning { + return false, nil + } + + if p.cmd == nil || p.cmd.Process == nil { + return false, nil + } + + return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) +} + // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. // If GracePeriod was not set (zero), falls back to immediate Kill(). // If KillGroup is set, signals are sent to the entire process group. diff --git a/service.go b/service.go index 13cc019..c0d3c6b 100644 --- a/service.go +++ b/service.go @@ -112,7 +112,7 @@ func (s *Service) OnShutdown(ctx context.Context) error { s.mu.RUnlock() for _, p := range procs { - _ = p.Kill() + _, _ = p.killTree() } return nil @@ -165,10 +165,9 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce cmd.Env = append(cmd.Environ(), opts.Env...) } - // Detached processes get their own process group - if opts.Detach { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - } + // Put every subprocess in its own process group so shutdown can terminate + // the full tree without affecting the parent process. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Set up pipes stdout, err := cmd.StdoutPipe() From 87da81ffebd9b57e364c1e20d4858be2af5531b0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:14:58 +0000 Subject: [PATCH 51/97] fix(process): leave exit action errors unset --- actions.go | 2 +- service.go | 6 +++--- service_test.go | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/actions.go b/actions.go index 8a2526d..f4ab6d1 100644 --- a/actions.go +++ b/actions.go @@ -84,7 +84,7 @@ type ActionProcessExited struct { ID string ExitCode int Duration time.Duration - Error error // Non-nil if failed to start or was killed + Error error // Reserved for future exit metadata; currently left unset by the service } // ActionProcessKilled is broadcast when a process is terminated. diff --git a/service.go b/service.go index c0d3c6b..5458d1c 100644 --- a/service.go +++ b/service.go @@ -224,7 +224,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: -1, Duration: time.Since(startedAt), - Error: coreerr.E("Service.StartWithOptions", "failed to start process", err), + Error: nil, }) } return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) @@ -283,7 +283,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce err := cmd.Wait() duration := time.Since(proc.StartedAt) - status, exitCode, exitErr, signalName := classifyProcessExit(err) + status, exitCode, _, signalName := classifyProcessExit(err) proc.mu.Lock() proc.Duration = duration @@ -301,7 +301,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: exitCode, Duration: duration, - Error: exitErr, + Error: nil, } if c := s.coreApp(); c != nil { diff --git a/service_test.go b/service_test.go index 6579bdf..518106b 100644 --- a/service_test.go +++ b/service_test.go @@ -274,6 +274,7 @@ func TestService_Actions(t *testing.T) { assert.Len(t, exited, 1) assert.Equal(t, 0, exited[0].ExitCode) + assert.Nil(t, exited[0].Error) }) t.Run("broadcasts killed events", func(t *testing.T) { @@ -326,7 +327,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Error(t, exited[0].Error) + assert.Nil(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) @@ -359,7 +360,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() require.Len(t, exited, 1) assert.Equal(t, -1, exited[0].ExitCode) - assert.Error(t, exited[0].Error) + assert.Nil(t, exited[0].Error) }) } From 98fe626d8e2cb83858c76a2fa8f68936c7bdf1b5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:21:01 +0000 Subject: [PATCH 52/97] feat(process): add process get core task --- actions.go | 10 ++++++++++ service.go | 11 +++++++++++ service_test.go | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/actions.go b/actions.go index f4ab6d1..f6b43f1 100644 --- a/actions.go +++ b/actions.go @@ -39,6 +39,16 @@ type TaskProcessKill struct { PID int } +// TaskProcessGet requests a snapshot of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessGet{ID: "proc-1"}) +type TaskProcessGet struct { + // ID identifies a managed process started by this service. + ID string +} + // TaskProcessList requests a snapshot of managed processes through Core.PERFORM. // If RunningOnly is true, only active processes are returned. // diff --git a/service.go b/service.go index 5458d1c..6602f3e 100644 --- a/service.go +++ b/service.go @@ -586,6 +586,17 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { default: return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} } + case TaskProcessGet: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process get requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: proc.Info(), OK: true} case TaskProcessList: procs := s.List() if m.RunningOnly { diff --git a/service_test.go b/service_test.go index 518106b..08c7332 100644 --- a/service_test.go +++ b/service_test.go @@ -685,6 +685,29 @@ func TestService_OnStartup(t *testing.T) { cancel() <-proc.Done() }) + + t.Run("registers process.get task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "snapshot") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessGet{ID: proc.ID}) + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, proc.Command, info.Command) + assert.Equal(t, proc.Args, info.Args) + assert.Equal(t, proc.Status, info.Status) + assert.Equal(t, proc.ExitCode, info.ExitCode) + assert.Equal(t, proc.Info().PID, info.PID) + }) } func TestService_RunWithOptions(t *testing.T) { From ec2a6838b83866bbee4fb2e55baede9a43e1cba7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:24:32 +0000 Subject: [PATCH 53/97] Propagate process exit errors --- runner.go | 15 ++++++++++++++- service.go | 9 +++++---- service_test.go | 4 ++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/runner.go b/runner.go index 8bd0a34..db24a7c 100644 --- a/runner.go +++ b/runner.go @@ -2,6 +2,7 @@ package process import ( "context" + "fmt" "sync" "time" @@ -257,13 +258,25 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { <-proc.Done() + var runErr error + switch proc.Status { + case StatusKilled: + runErr = coreerr.E("Runner.runSpec", "process was killed", nil) + case StatusExited: + if proc.ExitCode != 0 { + runErr = coreerr.E("Runner.runSpec", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) + } + case StatusFailed: + runErr = coreerr.E("Runner.runSpec", "process failed to start", nil) + } + return RunResult{ Name: spec.Name, Spec: spec, ExitCode: proc.ExitCode, Duration: proc.Duration, Output: proc.Output(), - Error: nil, + Error: runErr, } } diff --git a/service.go b/service.go index 6602f3e..ee80a32 100644 --- a/service.go +++ b/service.go @@ -224,7 +224,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: -1, Duration: time.Since(startedAt), - Error: nil, + Error: err, }) } return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) @@ -283,7 +283,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce err := cmd.Wait() duration := time.Since(proc.StartedAt) - status, exitCode, _, signalName := classifyProcessExit(err) + status, exitCode, exitErr, signalName := classifyProcessExit(err) proc.mu.Lock() proc.Duration = duration @@ -301,7 +301,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: exitCode, Duration: duration, - Error: nil, + Error: exitErr, } if c := s.coreApp(); c != nil { @@ -629,7 +629,8 @@ func classifyProcessExit(err error) (Status, int, error, string) { } return StatusKilled, -1, coreerr.E("Service.StartWithOptions", "process was killed", nil), signalName } - return StatusExited, exitErr.ExitCode(), nil, "" + exitCode := exitErr.ExitCode() + return StatusExited, exitCode, coreerr.E("Service.StartWithOptions", fmt.Sprintf("process exited with code %d", exitCode), nil), "" } return StatusFailed, 0, err, "" diff --git a/service_test.go b/service_test.go index 08c7332..90a57f2 100644 --- a/service_test.go +++ b/service_test.go @@ -327,7 +327,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Nil(t, exited[0].Error) + assert.Error(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) @@ -360,7 +360,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() require.Len(t, exited, 1) assert.Equal(t, -1, exited[0].ExitCode) - assert.Nil(t, exited[0].Error) + assert.Error(t, exited[0].Error) }) } From cffe06631b0a65b5babdcf85939ba78ad1c11e33 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:29:52 +0000 Subject: [PATCH 54/97] feat(process): add process output task --- actions.go | 10 ++++++++++ service.go | 11 +++++++++++ service_test.go | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/actions.go b/actions.go index f6b43f1..d5514ec 100644 --- a/actions.go +++ b/actions.go @@ -49,6 +49,16 @@ type TaskProcessGet struct { ID string } +// TaskProcessOutput requests the captured output of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessOutput{ID: "proc-1"}) +type TaskProcessOutput struct { + // ID identifies a managed process started by this service. + ID string +} + // TaskProcessList requests a snapshot of managed processes through Core.PERFORM. // If RunningOnly is true, only active processes are returned. // diff --git a/service.go b/service.go index ee80a32..a426e01 100644 --- a/service.go +++ b/service.go @@ -597,6 +597,17 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { } return core.Result{Value: proc.Info(), OK: true} + case TaskProcessOutput: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process output requires an id", nil), OK: false} + } + + output, err := s.Output(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: output, OK: true} case TaskProcessList: procs := s.List() if m.RunningOnly { diff --git a/service_test.go b/service_test.go index 90a57f2..e26cfc3 100644 --- a/service_test.go +++ b/service_test.go @@ -708,6 +708,24 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, proc.ExitCode, info.ExitCode) assert.Equal(t, proc.Info().PID, info.PID) }) + + t.Run("registers process.output task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "snapshot-output") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessOutput{ID: proc.ID}) + require.True(t, result.OK) + + output, ok := result.Value.(string) + require.True(t, ok) + assert.Contains(t, output, "snapshot-output") + }) } func TestService_RunWithOptions(t *testing.T) { From 6c1d53a2377036c41014c9750d4469b7c63632d9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:39:59 +0000 Subject: [PATCH 55/97] fix(process): preserve leading whitespace in Program output --- program.go | 6 ++++-- program_test.go | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/program.go b/program.go index cf5cb94..4661efe 100644 --- a/program.go +++ b/program.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "os/exec" + "strings" + "unicode" core "dappco.re/go/core" coreerr "dappco.re/go/core/log" @@ -93,7 +95,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin } if err := cmd.Run(); err != nil { - return core.Trim(out.String()), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err) + return strings.TrimRightFunc(out.String(), unicode.IsSpace), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err) } - return core.Trim(out.String()), nil + return strings.TrimRightFunc(out.String(), unicode.IsSpace), nil } diff --git a/program_test.go b/program_test.go index 739e516..9627691 100644 --- a/program_test.go +++ b/program_test.go @@ -46,6 +46,15 @@ func TestProgram_Run_ReturnsOutput(t *testing.T) { assert.Equal(t, "hello", out) } +func TestProgram_Run_PreservesLeadingWhitespace(t *testing.T) { + p := &process.Program{Name: "sh"} + require.NoError(t, p.Find()) + + out, err := p.Run(testCtx(t), "-c", "printf ' hello \n'") + require.NoError(t, err) + assert.Equal(t, " hello", out) +} + func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) { // Path is empty; RunDir should fall back to Name for OS PATH resolution. p := &process.Program{Name: "echo"} From ceea10fc7aa0108b0cce588bc55c1001afc6b2b3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:42:45 +0000 Subject: [PATCH 56/97] feat(process): add async process start task --- actions.go | 23 +++++++++++++++++++++++ service.go | 16 ++++++++++++++++ service_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/actions.go b/actions.go index d5514ec..6686456 100644 --- a/actions.go +++ b/actions.go @@ -4,6 +4,29 @@ import "time" // --- ACTION messages (broadcast via Core.ACTION) --- +// TaskProcessStart requests asynchronous process execution through Core.PERFORM. +// The handler returns a snapshot of the started process immediately. +// +// Example: +// +// c.PERFORM(process.TaskProcessStart{Command: "sleep", Args: []string{"10"}}) +type TaskProcessStart struct { + Command string + Args []string + Dir string + Env []string + // DisableCapture skips buffering process output before returning it. + DisableCapture bool + // Detach runs the command in its own process group. + Detach bool + // Timeout bounds the execution duration. + Timeout time.Duration + // GracePeriod controls SIGTERM-to-SIGKILL escalation. + GracePeriod time.Duration + // KillGroup terminates the entire process group instead of only the leader. + KillGroup bool +} + // TaskProcessRun requests synchronous command execution through Core.PERFORM. // The handler returns the combined command output on success. // diff --git a/service.go b/service.go index a426e01..21d8c0b 100644 --- a/service.go +++ b/service.go @@ -555,6 +555,22 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, // handleTask dispatches Core.PERFORM messages for the process service. func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { switch m := task.(type) { + case TaskProcessStart: + proc, err := s.StartWithOptions(c.Context(), RunOptions{ + Command: m.Command, + Args: m.Args, + Dir: m.Dir, + Env: m.Env, + DisableCapture: m.DisableCapture, + Detach: m.Detach, + Timeout: m.Timeout, + GracePeriod: m.GracePeriod, + KillGroup: m.KillGroup, + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: proc.Info(), OK: true} case TaskProcessRun: output, err := s.RunWithOptions(c.Context(), RunOptions{ Command: m.Command, diff --git a/service_test.go b/service_test.go index e26cfc3..bd381ed 100644 --- a/service_test.go +++ b/service_test.go @@ -596,6 +596,33 @@ func TestService_OnShutdown(t *testing.T) { } func TestService_OnStartup(t *testing.T) { + t.Run("registers process.start task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessStart{ + Command: "sleep", + Args: []string{"1"}, + }) + + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.NotEmpty(t, info.ID) + assert.Equal(t, StatusRunning, info.Status) + assert.True(t, info.Running) + + proc, err := svc.Get(info.ID) + require.NoError(t, err) + assert.True(t, proc.IsRunning()) + + <-proc.Done() + assert.Equal(t, StatusExited, proc.Status) + }) + t.Run("registers process.run task", func(t *testing.T) { svc, c := newTestService(t) From 227739638b021bef72d2ba81047506a242490d69 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:45:56 +0000 Subject: [PATCH 57/97] feat(process): add Core stdin task --- actions.go | 12 ++++++++++++ service.go | 15 +++++++++++++++ service_test.go | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/actions.go b/actions.go index 6686456..88a9095 100644 --- a/actions.go +++ b/actions.go @@ -82,6 +82,18 @@ type TaskProcessOutput struct { ID string } +// TaskProcessInput writes data to the stdin of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessInput{ID: "proc-1", Input: "hello\n"}) +type TaskProcessInput struct { + // ID identifies a managed process started by this service. + ID string + // Input is written verbatim to the process stdin pipe. + Input string +} + // TaskProcessList requests a snapshot of managed processes through Core.PERFORM. // If RunningOnly is true, only active processes are returned. // diff --git a/service.go b/service.go index 21d8c0b..5d0a465 100644 --- a/service.go +++ b/service.go @@ -624,6 +624,21 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { } return core.Result{Value: output, OK: true} + case TaskProcessInput: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process input requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + if err := proc.SendInput(m.Input); err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{OK: true} case TaskProcessList: procs := s.List() if m.RunningOnly { diff --git a/service_test.go b/service_test.go index bd381ed..7e17775 100644 --- a/service_test.go +++ b/service_test.go @@ -753,6 +753,29 @@ func TestService_OnStartup(t *testing.T) { require.True(t, ok) assert.Contains(t, output, "snapshot-output") }) + + t.Run("registers process.input task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessInput{ + ID: proc.ID, + Input: "typed-through-core\n", + }) + require.True(t, result.OK) + + err = proc.CloseStdin() + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "typed-through-core") + }) } func TestService_RunWithOptions(t *testing.T) { From 155f216a7cb284dcfce842360c220606f879a09a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:49:37 +0000 Subject: [PATCH 58/97] feat(process): add stdin close task --- actions.go | 10 ++++++++++ service.go | 15 +++++++++++++++ service_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/actions.go b/actions.go index 88a9095..dbd26c7 100644 --- a/actions.go +++ b/actions.go @@ -94,6 +94,16 @@ type TaskProcessInput struct { Input string } +// TaskProcessCloseStdin closes the stdin pipe of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessCloseStdin{ID: "proc-1"}) +type TaskProcessCloseStdin struct { + // ID identifies a managed process started by this service. + ID string +} + // TaskProcessList requests a snapshot of managed processes through Core.PERFORM. // If RunningOnly is true, only active processes are returned. // diff --git a/service.go b/service.go index 5d0a465..a96b456 100644 --- a/service.go +++ b/service.go @@ -638,6 +638,21 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { return core.Result{Value: err, OK: false} } + return core.Result{OK: true} + case TaskProcessCloseStdin: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process close stdin requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + if err := proc.CloseStdin(); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} case TaskProcessList: procs := s.List() diff --git a/service_test.go b/service_test.go index 7e17775..58ac612 100644 --- a/service_test.go +++ b/service_test.go @@ -776,6 +776,33 @@ func TestService_OnStartup(t *testing.T) { assert.Contains(t, proc.Output(), "typed-through-core") }) + + t.Run("registers process.close_stdin task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessInput{ + ID: proc.ID, + Input: "close-through-core\n", + }) + require.True(t, result.OK) + + result = c.PERFORM(TaskProcessCloseStdin{ID: proc.ID}) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have exited after stdin was closed") + } + + assert.Contains(t, proc.Output(), "close-through-core") + }) } func TestService_RunWithOptions(t *testing.T) { From a8c193d07caeabfcac5fdd2e62f42eb4370ab18b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:53:12 +0000 Subject: [PATCH 59/97] feat(process): report live duration snapshots --- process.go | 7 ++++++- process_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/process.go b/process.go index 76cfc5d..63fc7a0 100644 --- a/process.go +++ b/process.go @@ -56,6 +56,11 @@ func (p *Process) Info() Info { pid = p.cmd.Process.Pid } + duration := p.Duration + if p.Status == StatusRunning { + duration = time.Since(p.StartedAt) + } + return Info{ ID: p.ID, Command: p.Command, @@ -65,7 +70,7 @@ func (p *Process) Info() Info { Running: p.Status == StatusRunning, Status: p.Status, ExitCode: p.ExitCode, - Duration: p.Duration, + Duration: duration, PID: pid, } } diff --git a/process_test.go b/process_test.go index 91c3405..ed91eb3 100644 --- a/process_test.go +++ b/process_test.go @@ -40,6 +40,25 @@ func TestProcess_Info_Pending(t *testing.T) { assert.False(t, info.Running) } +func TestProcess_Info_RunningDuration(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) + + time.Sleep(10 * time.Millisecond) + info := proc.Info() + assert.True(t, info.Running) + assert.Equal(t, StatusRunning, info.Status) + assert.Greater(t, info.Duration, time.Duration(0)) + + cancel() + <-proc.Done() +} + func TestProcess_InfoSnapshot(t *testing.T) { svc, _ := newTestService(t) From d34ab22ad3ef550602597a48a5fe3b23d27fab72 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:57:25 +0000 Subject: [PATCH 60/97] feat(process): add global output helper --- global_test.go | 22 ++++++++++++++++++++++ process_global.go | 13 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/global_test.go b/global_test.go index 4b07f88..f024eb0 100644 --- a/global_test.go +++ b/global_test.go @@ -30,6 +30,9 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { _, err = Get("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) + _, err = Output("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + assert.Nil(t, List()) assert.Nil(t, Running()) @@ -242,6 +245,25 @@ func TestGlobal_RunWithOptions(t *testing.T) { assert.Contains(t, output, "run options") } +func TestGlobal_Output(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "global-output") + require.NoError(t, err) + <-proc.Done() + + output, err := Output(proc.ID) + require.NoError(t, err) + assert.Contains(t, output, "global-output") +} + func TestGlobal_Running(t *testing.T) { svc, _ := newTestService(t) diff --git a/process_global.go b/process_global.go index 2689010..8cf1022 100644 --- a/process_global.go +++ b/process_global.go @@ -100,6 +100,19 @@ func Get(id string) (*Process, error) { return svc.Get(id) } +// Output returns the captured output for a process from the default service. +// +// Example: +// +// out, err := process.Output("proc-1") +func Output(id string) (string, error) { + svc := Default() + if svc == nil { + return "", ErrServiceNotInitialized + } + return svc.Output(id) +} + // List returns all processes from the default service. // // Example: From 02e2b3611c4bc6b8a6b7d7da8523945926e34ceb Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:00:09 +0000 Subject: [PATCH 61/97] fix(process): reorder daemon shutdown teardown Co-Authored-By: Virgil --- daemon.go | 15 ++++++++------- daemon_test.go | 13 ++++++++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/daemon.go b/daemon.go index 2e63d02..5adbb26 100644 --- a/daemon.go +++ b/daemon.go @@ -179,10 +179,9 @@ func (d *Daemon) Stop() error { d.health.SetReady(false) } - // Auto-unregister after the process is no longer serving traffic. - if d.opts.Registry != nil { - if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) + if d.health != nil { + if err := d.health.Stop(shutdownCtx); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) } } @@ -192,9 +191,11 @@ func (d *Daemon) Stop() error { } } - if d.health != nil { - if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) + // Auto-unregister after the daemon has stopped serving traffic and + // relinquished its PID file. + if d.opts.Registry != nil { + if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { + errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) } } diff --git a/daemon_test.go b/daemon_test.go index f32a0e7..2c12333 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -109,7 +109,7 @@ func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) { } } -func TestDaemon_StopUnregistersBeforeHealthShutdownCompletes(t *testing.T) { +func TestDaemon_StopUnregistersAfterHealthShutdownCompletes(t *testing.T) { blockCheck := make(chan struct{}) checkEntered := make(chan struct{}) var once sync.Once @@ -165,10 +165,8 @@ func TestDaemon_StopUnregistersBeforeHealthShutdownCompletes(t *testing.T) { return !d.Ready() }, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes") - require.Eventually(t, func() bool { - _, ok := reg.Get("test-app", "serve") - return !ok - }, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister before health shutdown completes") + _, ok := reg.Get("test-app", "serve") + assert.True(t, ok, "daemon should remain registered until health shutdown completes") select { case err := <-stopDone: @@ -185,6 +183,11 @@ func TestDaemon_StopUnregistersBeforeHealthShutdownCompletes(t *testing.T) { t.Fatal("daemon stop did not finish after health check unblocked") } + require.Eventually(t, func() bool { + _, ok := reg.Get("test-app", "serve") + return !ok + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister after health shutdown completes") + select { case err := <-healthErr: require.NoError(t, err) From f43e8a6e38e78f7a47214fc70562f36021e798ec Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:04:00 +0000 Subject: [PATCH 62/97] feat(process): add global remove and clear helpers Co-Authored-By: Virgil --- global_test.go | 36 ++++++++++++++++++++++++++++++++++++ process_global.go | 26 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/global_test.go b/global_test.go index f024eb0..aef2035 100644 --- a/global_test.go +++ b/global_test.go @@ -36,6 +36,12 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { assert.Nil(t, List()) assert.Nil(t, Running()) + err = Remove("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + // Clear is a no-op without a default service. + Clear() + err = Kill("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) @@ -290,3 +296,33 @@ func TestGlobal_Running(t *testing.T) { running = Running() assert.Len(t, running, 0) } + +func TestGlobal_RemoveAndClear(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "remove-me") + require.NoError(t, err) + <-proc.Done() + + err = Remove(proc.ID) + require.NoError(t, err) + + _, err = Get(proc.ID) + require.ErrorIs(t, err, ErrProcessNotFound) + + proc2, err := Start(context.Background(), "echo", "clear-me") + require.NoError(t, err) + <-proc2.Done() + + Clear() + + _, err = Get(proc2.ID) + require.ErrorIs(t, err, ErrProcessNotFound) +} diff --git a/process_global.go b/process_global.go index 8cf1022..a89580e 100644 --- a/process_global.go +++ b/process_global.go @@ -191,6 +191,32 @@ func Running() []*Process { return svc.Running() } +// Remove removes a completed process from the default service. +// +// Example: +// +// _ = process.Remove("proc-1") +func Remove(id string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Remove(id) +} + +// Clear removes all completed processes from the default service. +// +// Example: +// +// process.Clear() +func Clear() { + svc := Default() + if svc == nil { + return + } + svc.Clear() +} + // Errors var ( // ErrServiceNotInitialized is returned when the service is not initialized. From c9deb8fdfd312034ba6ed5c1d81b1429eaeda27b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:07:13 +0000 Subject: [PATCH 63/97] fix(process): let Program.Find validate existing paths --- program.go | 10 +++++++--- program_test.go | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/program.go b/program.go index 4661efe..2bc76a1 100644 --- a/program.go +++ b/program.go @@ -44,12 +44,16 @@ type Program struct { // // if err := p.Find(); err != nil { return err } func (p *Program) Find() error { - if p.Name == "" { + target := p.Name + if target == "" { + target = p.Path + } + if target == "" { return coreerr.E("Program.Find", "program name is empty", nil) } - path, err := exec.LookPath(p.Name) + path, err := exec.LookPath(target) if err != nil { - return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound) + return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", target), ErrProgramNotFound) } p.Path = path return nil diff --git a/program_test.go b/program_test.go index 9627691..0f89097 100644 --- a/program_test.go +++ b/program_test.go @@ -2,6 +2,7 @@ package process_test import ( "context" + "os/exec" "path/filepath" "testing" "time" @@ -32,6 +33,15 @@ func TestProgram_Find_UnknownBinary(t *testing.T) { assert.ErrorIs(t, err, process.ErrProgramNotFound) } +func TestProgram_Find_UsesExistingPath(t *testing.T) { + path, err := exec.LookPath("echo") + require.NoError(t, err) + + p := &process.Program{Path: path} + require.NoError(t, p.Find()) + assert.Equal(t, path, p.Path) +} + func TestProgram_Find_EmptyName(t *testing.T) { p := &process.Program{} require.Error(t, p.Find()) From 4974b0fd0810c93761d236e1c375f92405c2201d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:10:39 +0000 Subject: [PATCH 64/97] fix(process): prefer resolved program path --- program.go | 4 ++-- program_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/program.go b/program.go index 2bc76a1..f7b0822 100644 --- a/program.go +++ b/program.go @@ -44,9 +44,9 @@ type Program struct { // // if err := p.Find(); err != nil { return err } func (p *Program) Find() error { - target := p.Name + target := p.Path if target == "" { - target = p.Path + target = p.Name } if target == "" { return coreerr.E("Program.Find", "program name is empty", nil) diff --git a/program_test.go b/program_test.go index 0f89097..7d38a76 100644 --- a/program_test.go +++ b/program_test.go @@ -42,6 +42,19 @@ func TestProgram_Find_UsesExistingPath(t *testing.T) { assert.Equal(t, path, p.Path) } +func TestProgram_Find_PrefersExistingPathOverName(t *testing.T) { + path, err := exec.LookPath("echo") + require.NoError(t, err) + + p := &process.Program{ + Name: "no-such-binary-xyzzy-42", + Path: path, + } + + require.NoError(t, p.Find()) + assert.Equal(t, path, p.Path) +} + func TestProgram_Find_EmptyName(t *testing.T) { p := &process.Program{} require.Error(t, p.Find()) From e85abe1ee690777fad01337f6cebd3c5650b0a50 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:14:25 +0000 Subject: [PATCH 65/97] feat(process): add ManagedProcess alias --- process.go | 35 +++++++++++++++++++---------------- process_test.go | 2 ++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/process.go b/process.go index 63fc7a0..e414301 100644 --- a/process.go +++ b/process.go @@ -13,12 +13,12 @@ import ( coreerr "dappco.re/go/core/log" ) -// Process represents a managed external process. +// ManagedProcess represents a managed external process. // // Example: // // proc, err := svc.Start(ctx, "echo", "hello") -type Process struct { +type ManagedProcess struct { ID string Command string Args []string @@ -42,12 +42,15 @@ type Process struct { killSignal string } +// Process is kept as an alias for ManagedProcess for compatibility. +type Process = ManagedProcess + // Info returns a snapshot of process state. // // Example: // // info := proc.Info() -func (p *Process) Info() Info { +func (p *ManagedProcess) Info() Info { p.mu.RLock() defer p.mu.RUnlock() @@ -80,7 +83,7 @@ func (p *Process) Info() Info { // Example: // // fmt.Println(proc.Output()) -func (p *Process) Output() string { +func (p *ManagedProcess) Output() string { p.mu.RLock() defer p.mu.RUnlock() if p.output == nil { @@ -94,7 +97,7 @@ func (p *Process) Output() string { // Example: // // data := proc.OutputBytes() -func (p *Process) OutputBytes() []byte { +func (p *ManagedProcess) OutputBytes() []byte { p.mu.RLock() defer p.mu.RUnlock() if p.output == nil { @@ -104,7 +107,7 @@ func (p *Process) OutputBytes() []byte { } // IsRunning returns true if the process is still executing. -func (p *Process) IsRunning() bool { +func (p *ManagedProcess) IsRunning() bool { p.mu.RLock() defer p.mu.RUnlock() return p.Status == StatusRunning @@ -115,7 +118,7 @@ func (p *Process) IsRunning() bool { // Example: // // if err := proc.Wait(); err != nil { return err } -func (p *Process) Wait() error { +func (p *ManagedProcess) Wait() error { <-p.done p.mu.RLock() defer p.mu.RUnlock() @@ -136,7 +139,7 @@ func (p *Process) Wait() error { // Example: // // <-proc.Done() -func (p *Process) Done() <-chan struct{} { +func (p *ManagedProcess) Done() <-chan struct{} { return p.done } @@ -146,13 +149,13 @@ func (p *Process) Done() <-chan struct{} { // Example: // // _ = proc.Kill() -func (p *Process) Kill() error { +func (p *ManagedProcess) Kill() error { _, err := p.kill() return err } // kill terminates the process and reports whether a signal was actually sent. -func (p *Process) kill() (bool, error) { +func (p *ManagedProcess) kill() (bool, error) { p.mu.Lock() defer p.mu.Unlock() @@ -172,7 +175,7 @@ func (p *Process) kill() (bool, error) { } // killTree forcefully terminates the process group when one exists. -func (p *Process) killTree() (bool, error) { +func (p *ManagedProcess) killTree() (bool, error) { p.mu.Lock() defer p.mu.Unlock() @@ -194,7 +197,7 @@ func (p *Process) killTree() (bool, error) { // Example: // // _ = proc.Shutdown() -func (p *Process) Shutdown() error { +func (p *ManagedProcess) Shutdown() error { p.mu.RLock() grace := p.gracePeriod p.mu.RUnlock() @@ -218,7 +221,7 @@ func (p *Process) Shutdown() error { } // terminate sends SIGTERM to the process (or process group if KillGroup is set). -func (p *Process) terminate() error { +func (p *ManagedProcess) terminate() error { p.mu.Lock() defer p.mu.Unlock() @@ -242,7 +245,7 @@ func (p *Process) terminate() error { // Example: // // _ = proc.Signal(os.Interrupt) -func (p *Process) Signal(sig os.Signal) error { +func (p *ManagedProcess) Signal(sig os.Signal) error { p.mu.RLock() status := p.Status cmd := p.cmd @@ -303,7 +306,7 @@ func (p *Process) Signal(sig os.Signal) error { // Example: // // _ = proc.SendInput("hello\n") -func (p *Process) SendInput(input string) error { +func (p *ManagedProcess) SendInput(input string) error { p.mu.RLock() defer p.mu.RUnlock() @@ -324,7 +327,7 @@ func (p *Process) SendInput(input string) error { // Example: // // _ = proc.CloseStdin() -func (p *Process) CloseStdin() error { +func (p *ManagedProcess) CloseStdin() error { p.mu.Lock() defer p.mu.Unlock() diff --git a/process_test.go b/process_test.go index ed91eb3..1a4f037 100644 --- a/process_test.go +++ b/process_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/require" ) +var _ *Process = (*ManagedProcess)(nil) + func TestProcess_Info(t *testing.T) { svc, _ := newTestService(t) From c31f3faa2bee805cdefe9a8a4954f70432d01636 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:17:30 +0000 Subject: [PATCH 66/97] Tighten process package API contracts --- exec/exec.go | 14 +++++++------- exec/logger.go | 2 ++ process.go | 4 ++-- process_global.go | 4 ++-- service.go | 4 ++-- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/exec/exec.go b/exec/exec.go index d196820..5e76ada 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -4,12 +4,12 @@ import ( "bytes" "context" "fmt" - "io" "os" "os/exec" "strings" coreerr "dappco.re/go/core/log" + goio "io" ) // ErrCommandContextRequired is returned when a command is created without a context. @@ -19,9 +19,9 @@ var ErrCommandContextRequired = coreerr.E("", "exec: command context is required type Options struct { Dir string Env []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer + Stdin goio.Reader + Stdout goio.Writer + Stderr goio.Writer // Background runs the command asynchronously and returns from Run immediately. Background bool } @@ -74,7 +74,7 @@ func (c *Cmd) WithEnv(env []string) *Cmd { // Example: // // cmd.WithStdin(strings.NewReader("input")) -func (c *Cmd) WithStdin(r io.Reader) *Cmd { +func (c *Cmd) WithStdin(r goio.Reader) *Cmd { c.opts.Stdin = r return c } @@ -84,7 +84,7 @@ func (c *Cmd) WithStdin(r io.Reader) *Cmd { // Example: // // cmd.WithStdout(os.Stdout) -func (c *Cmd) WithStdout(w io.Writer) *Cmd { +func (c *Cmd) WithStdout(w goio.Writer) *Cmd { c.opts.Stdout = w return c } @@ -94,7 +94,7 @@ func (c *Cmd) WithStdout(w io.Writer) *Cmd { // Example: // // cmd.WithStderr(os.Stderr) -func (c *Cmd) WithStderr(w io.Writer) *Cmd { +func (c *Cmd) WithStderr(w goio.Writer) *Cmd { c.opts.Stderr = w return c } diff --git a/exec/logger.go b/exec/logger.go index 9a2992b..0340710 100644 --- a/exec/logger.go +++ b/exec/logger.go @@ -24,6 +24,8 @@ func (NopLogger) Debug(string, ...any) {} // Error discards the message (no-op implementation). func (NopLogger) Error(string, ...any) {} +var _ Logger = NopLogger{} + var defaultLogger Logger = NopLogger{} // SetDefaultLogger sets the package-level default logger. diff --git a/process.go b/process.go index e414301..ceb993d 100644 --- a/process.go +++ b/process.go @@ -3,7 +3,6 @@ package process import ( "context" "fmt" - "io" "os" "os/exec" "sync" @@ -11,6 +10,7 @@ import ( "time" coreerr "dappco.re/go/core/log" + goio "io" ) // ManagedProcess represents a managed external process. @@ -33,7 +33,7 @@ type ManagedProcess struct { ctx context.Context cancel context.CancelFunc output *RingBuffer - stdin io.WriteCloser + stdin goio.WriteCloser done chan struct{} mu sync.RWMutex gracePeriod time.Duration diff --git a/process_global.go b/process_global.go index a89580e..e7d08f1 100644 --- a/process_global.go +++ b/process_global.go @@ -17,7 +17,7 @@ var ( ) // Default returns the global process service. -// Returns nil if not initialized. +// Returns nil if not initialised. // // Example: // @@ -219,7 +219,7 @@ func Clear() { // Errors var ( - // ErrServiceNotInitialized is returned when the service is not initialized. + // ErrServiceNotInitialized is returned when the service is not initialised. ErrServiceNotInitialized = coreerr.E("", "process: service not initialized; call process.Init(core) first", nil) // ErrSetDefaultNil is returned when SetDefault is called with nil. ErrSetDefaultNil = coreerr.E("", "process: SetDefault called with nil service", nil) diff --git a/service.go b/service.go index a96b456..f11587b 100644 --- a/service.go +++ b/service.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "os/exec" "sort" "sync" @@ -15,6 +14,7 @@ import ( "dappco.re/go/core" coreerr "dappco.re/go/core/log" + goio "io" ) // Default buffer size for process output (1MB). @@ -313,7 +313,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce } // streamOutput reads from a pipe and broadcasts lines via ACTION. -func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) { +func (s *Service) streamOutput(proc *Process, r goio.Reader, stream Stream) { scanner := bufio.NewScanner(r) // Increase buffer for long lines scanner.Buffer(make([]byte, 64*1024), 1024*1024) From 86f5fadff7f5bbb60e6bcf8ae5c510c5b81e3bc8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:21:01 +0000 Subject: [PATCH 67/97] fix(process): treat unresolved runner specs as failures --- runner.go | 1 - runner_test.go | 16 ++++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/runner.go b/runner.go index db24a7c..66956fc 100644 --- a/runner.go +++ b/runner.go @@ -147,7 +147,6 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er Name: name, Spec: remaining[name], ExitCode: 1, - Skipped: true, Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), } } diff --git a/runner_test.go b/runner_test.go index 9b16729..97fe0a9 100644 --- a/runner_test.go +++ b/runner_test.go @@ -178,11 +178,11 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.True(t, result.Success()) - assert.Equal(t, 0, result.Failed) - assert.Equal(t, 2, result.Skipped) + assert.False(t, result.Success()) + assert.Equal(t, 2, result.Failed) + assert.Equal(t, 0, result.Skipped) for _, res := range result.Results { - assert.True(t, res.Skipped) + assert.False(t, res.Skipped) assert.Equal(t, 1, res.ExitCode) assert.Error(t, res.Error) } @@ -196,11 +196,11 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.True(t, result.Success()) - assert.Equal(t, 0, result.Failed) - assert.Equal(t, 1, result.Skipped) + assert.False(t, result.Success()) + assert.Equal(t, 1, result.Failed) + assert.Equal(t, 0, result.Skipped) require.Len(t, result.Results, 1) - assert.True(t, result.Results[0].Skipped) + assert.False(t, result.Results[0].Skipped) assert.Equal(t, 1, result.Results[0].ExitCode) assert.Error(t, result.Results[0].Error) }) From 04543700bce3409ea794ce3fbfc0ce5be5c8cf9b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:24:33 +0000 Subject: [PATCH 68/97] fix(process): skip unresolved runner dependencies Co-Authored-By: Virgil --- runner.go | 12 ++++++------ runner_test.go | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/runner.go b/runner.go index 66956fc..27d3952 100644 --- a/runner.go +++ b/runner.go @@ -80,13 +80,13 @@ type RunAllResult struct { Skipped int } -// Success returns true if all non-skipped specs passed. +// Success returns true if every spec completed successfully. // // Example: // // if result.Success() { ... } func (r RunAllResult) Success() bool { - return r.Failed == 0 + return r.Failed == 0 && r.Skipped == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. @@ -144,10 +144,10 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er // Keep the output aligned with the input order. for name := range remaining { results[indexMap[name]] = RunResult{ - Name: name, - Spec: remaining[name], - ExitCode: 1, - Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), + Name: name, + Spec: remaining[name], + Skipped: true, + Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), } } break diff --git a/runner_test.go b/runner_test.go index 97fe0a9..25c6ec3 100644 --- a/runner_test.go +++ b/runner_test.go @@ -179,11 +179,11 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success()) - assert.Equal(t, 2, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) for _, res := range result.Results { - assert.False(t, res.Skipped) - assert.Equal(t, 1, res.ExitCode) + assert.True(t, res.Skipped) + assert.Equal(t, 0, res.ExitCode) assert.Error(t, res.Error) } }) @@ -197,11 +197,11 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success()) - assert.Equal(t, 1, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 1, result.Skipped) require.Len(t, result.Results, 1) - assert.False(t, result.Results[0].Skipped) - assert.Equal(t, 1, result.Results[0].ExitCode) + assert.True(t, result.Results[0].Skipped) + assert.Equal(t, 0, result.Results[0].ExitCode) assert.Error(t, result.Results[0].Error) }) } From 79e2ffa6ed3b06643953eb0b15ff0e8f36cd767c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:28:28 +0000 Subject: [PATCH 69/97] feat(process): add signal task surface Co-authored-by: Virgil --- actions.go | 19 ++++++++++- global_test.go | 65 +++++++++++++++++++++++++++++++++++++ process_global.go | 27 ++++++++++++++++ service.go | 59 ++++++++++++++++++++++++++++++++++ service_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) diff --git a/actions.go b/actions.go index dbd26c7..ecc7239 100644 --- a/actions.go +++ b/actions.go @@ -1,6 +1,9 @@ package process -import "time" +import ( + "syscall" + "time" +) // --- ACTION messages (broadcast via Core.ACTION) --- @@ -62,6 +65,20 @@ type TaskProcessKill struct { PID int } +// TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessSignal{ID: "proc-1", Signal: syscall.SIGTERM}) +type TaskProcessSignal struct { + // ID identifies a managed process started by this service. + ID string + // PID targets a process directly when ID is not available. + PID int + // Signal is delivered to the process or process group. + Signal syscall.Signal +} + // TaskProcessGet requests a snapshot of a managed process through Core.PERFORM. // // Example: diff --git a/global_test.go b/global_test.go index aef2035..8abd570 100644 --- a/global_test.go +++ b/global_test.go @@ -2,8 +2,11 @@ package process import ( "context" + "os/exec" "sync" + "syscall" "testing" + "time" framework "dappco.re/go/core" "github.com/stretchr/testify/assert" @@ -270,6 +273,68 @@ func TestGlobal_Output(t *testing.T) { assert.Contains(t, output, "global-output") } +func TestGlobal_Signal(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = Signal(proc.ID, syscall.SIGTERM) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through the global helper") + } +} + +func TestGlobal_SignalPID(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled through the global helper") + } +} + func TestGlobal_Running(t *testing.T) { svc, _ := newTestService(t) diff --git a/process_global.go b/process_global.go index e7d08f1..0f76f71 100644 --- a/process_global.go +++ b/process_global.go @@ -2,6 +2,7 @@ package process import ( "context" + "os" "sync" "sync/atomic" @@ -152,6 +153,32 @@ func KillPID(pid int) error { return svc.KillPID(pid) } +// Signal sends a signal to a process by ID using the default service. +// +// Example: +// +// _ = process.Signal("proc-1", syscall.SIGTERM) +func Signal(id string, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Signal(id, sig) +} + +// SignalPID sends a signal to a process by operating-system PID using the default service. +// +// Example: +// +// _ = process.SignalPID(1234, syscall.SIGTERM) +func SignalPID(pid int, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.SignalPID(pid, sig) +} + // StartWithOptions spawns a process with full configuration using the default service. // // Example: diff --git a/service.go b/service.go index f11587b..5b3ae31 100644 --- a/service.go +++ b/service.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "sort" "sync" @@ -438,6 +439,45 @@ func (s *Service) KillPID(pid int) error { return nil } +// Signal sends a signal to a process by ID. +// +// Example: +// +// _ = svc.Signal("proc-1", syscall.SIGTERM) +func (s *Service) Signal(id string, sig os.Signal) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.Signal(sig) +} + +// SignalPID sends a signal to a process by operating-system PID. +// +// Example: +// +// _ = svc.SignalPID(1234, syscall.SIGTERM) +func (s *Service) SignalPID(pid int, sig os.Signal) error { + if pid <= 0 { + return coreerr.E("Service.SignalPID", "pid must be positive", nil) + } + + if proc := s.findByPID(pid); proc != nil { + return proc.Signal(sig) + } + + target, err := os.FindProcess(pid) + if err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to find pid %d", pid), err) + } + + if err := target.Signal(sig); err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to signal pid %d", pid), err) + } + + return nil +} + // Remove removes a completed process from the list. // // Example: @@ -602,6 +642,25 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { default: return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} } + case TaskProcessSignal: + if m.Signal == 0 { + return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires a signal", nil), OK: false} + } + + switch { + case m.ID != "": + if err := s.Signal(m.ID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + case m.PID > 0: + if err := s.SignalPID(m.PID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + default: + return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires an id or pid", nil), OK: false} + } case TaskProcessGet: if m.ID == "" { return core.Result{Value: coreerr.E("Service.handleTask", "task process get requires an id", nil), OK: false} diff --git a/service_test.go b/service_test.go index 58ac612..46c0985 100644 --- a/service_test.go +++ b/service_test.go @@ -518,6 +518,64 @@ func TestService_KillPID(t *testing.T) { }) } +func TestService_Signal(t *testing.T) { + t.Run("signals running process by id", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = svc.Signal(proc.ID, syscall.SIGTERM) + assert.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + + t.Run("signals unmanaged process by pid", func(t *testing.T) { + svc, _ := newTestService(t) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := svc.SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.True(t, ws.Signaled()) + assert.Equal(t, syscall.SIGTERM, ws.Signal()) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled") + } + }) +} + func TestService_Output(t *testing.T) { t.Run("returns captured output", func(t *testing.T) { svc, _ := newTestService(t) @@ -688,6 +746,30 @@ func TestService_OnStartup(t *testing.T) { assert.NotEmpty(t, killed[0].Signal) }) + t.Run("registers process.signal task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.SIGTERM, + }) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through core") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t) From 85cd6dd7c81ce8ef0a991d604b4efcee5e123d05 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:33:29 +0000 Subject: [PATCH 70/97] feat(process): add wait task surface Co-authored-by: Virgil --- actions.go | 10 +++++++++ global_test.go | 20 ++++++++++++++++++ process_global.go | 13 ++++++++++++ service.go | 29 +++++++++++++++++++++++++ service_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) diff --git a/actions.go b/actions.go index ecc7239..1ca89c5 100644 --- a/actions.go +++ b/actions.go @@ -89,6 +89,16 @@ type TaskProcessGet struct { ID string } +// TaskProcessWait waits for a managed process to finish through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessWait{ID: "proc-1"}) +type TaskProcessWait struct { + // ID identifies a managed process started by this service. + ID string +} + // TaskProcessOutput requests the captured output of a managed process through Core.PERFORM. // // Example: diff --git a/global_test.go b/global_test.go index 8abd570..55f88d3 100644 --- a/global_test.go +++ b/global_test.go @@ -273,6 +273,26 @@ func TestGlobal_Output(t *testing.T) { assert.Contains(t, output, "global-output") } +func TestGlobal_Wait(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "global-wait") + require.NoError(t, err) + + info, err := Wait(proc.ID) + require.NoError(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) +} + func TestGlobal_Signal(t *testing.T) { svc, _ := newTestService(t) diff --git a/process_global.go b/process_global.go index 0f76f71..bde259a 100644 --- a/process_global.go +++ b/process_global.go @@ -114,6 +114,19 @@ func Output(id string) (string, error) { return svc.Output(id) } +// Wait blocks until a managed process exits and returns its final snapshot. +// +// Example: +// +// info, err := process.Wait("proc-1") +func Wait(id string) (Info, error) { + svc := Default() + if svc == nil { + return Info{}, ErrServiceNotInitialized + } + return svc.Wait(id) +} + // List returns all processes from the default service. // // Example: diff --git a/service.go b/service.go index 5b3ae31..1c3c5b4 100644 --- a/service.go +++ b/service.go @@ -529,6 +529,24 @@ func (s *Service) Output(id string) (string, error) { return proc.Output(), nil } +// Wait blocks until a managed process exits and returns its final snapshot. +// +// Example: +// +// info, err := svc.Wait("proc-1") +func (s *Service) Wait(id string) (Info, error) { + proc, err := s.Get(id) + if err != nil { + return Info{}, err + } + + if err := proc.Wait(); err != nil { + return proc.Info(), err + } + + return proc.Info(), nil +} + // findByPID locates a managed process by operating-system PID. func (s *Service) findByPID(pid int) *Process { s.mu.RLock() @@ -672,6 +690,17 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { } return core.Result{Value: proc.Info(), OK: true} + case TaskProcessWait: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process wait requires an id", nil), OK: false} + } + + info, err := s.Wait(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: info, OK: true} case TaskProcessOutput: if m.ID == "" { return core.Result{Value: coreerr.E("Service.handleTask", "task process output requires an id", nil), OK: false} diff --git a/service_test.go b/service_test.go index 46c0985..3390e71 100644 --- a/service_test.go +++ b/service_test.go @@ -597,6 +597,41 @@ func TestService_Output(t *testing.T) { }) } +func TestService_Wait(t *testing.T) { + t.Run("returns final info on success", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "echo", "waited") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.NoError(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + + t.Run("returns error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.Wait("nonexistent") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) + + t.Run("returns info alongside failure", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.Error(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 7, info.ExitCode) + }) +} + func TestService_OnShutdown(t *testing.T) { t.Run("kills all running processes", func(t *testing.T) { svc, _ := newTestService(t) @@ -770,6 +805,25 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, StatusKilled, proc.Status) }) + t.Run("registers process.wait task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "action-wait") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t) From 8d1a0d0655aa7dfdf123587e86064a9bd9d2bd7c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:37:26 +0000 Subject: [PATCH 71/97] fix(process): retain failed starts in service state Co-Authored-By: Virgil --- service.go | 11 +++++++++-- service_test.go | 12 +++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/service.go b/service.go index 1c3c5b4..d0c1f14 100644 --- a/service.go +++ b/service.go @@ -217,18 +217,25 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce if err := cmd.Start(); err != nil { proc.mu.Lock() proc.Status = StatusFailed + proc.ExitCode = -1 + proc.Duration = time.Since(startedAt) proc.mu.Unlock() + s.mu.Lock() + s.processes[id] = proc + s.mu.Unlock() + + close(proc.done) cancel() if c := s.coreApp(); c != nil { _ = c.ACTION(ActionProcessExited{ ID: id, ExitCode: -1, - Duration: time.Since(startedAt), + Duration: proc.Duration, Error: err, }) } - return nil, coreerr.E("Service.StartWithOptions", "failed to start process", err) + return proc, coreerr.E("Service.StartWithOptions", "failed to start process", err) } proc.mu.Lock() diff --git a/service_test.go b/service_test.go index 3390e71..9482a55 100644 --- a/service_test.go +++ b/service_test.go @@ -78,8 +78,18 @@ 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") + proc, err := svc.Start(context.Background(), "nonexistent_command_xyz") assert.Error(t, err) + require.NotNil(t, proc) + assert.Equal(t, StatusFailed, proc.Status) + assert.Equal(t, -1, proc.ExitCode) + assert.NotNil(t, proc.Done()) + <-proc.Done() + + got, getErr := svc.Get(proc.ID) + require.NoError(t, getErr) + assert.Equal(t, proc.ID, got.ID) + assert.Equal(t, StatusFailed, got.Status) }) t.Run("empty command is rejected", func(t *testing.T) { From 3930aed49a21e831f493bbc100047e326668089a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 04:19:17 +0000 Subject: [PATCH 72/97] feat(process): allow zero-value task signals Co-Authored-By: Virgil --- actions.go | 1 + service.go | 4 ---- service_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/actions.go b/actions.go index 1ca89c5..c4d4856 100644 --- a/actions.go +++ b/actions.go @@ -66,6 +66,7 @@ type TaskProcessKill struct { } // TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM. +// Signal 0 is allowed for liveness checks. // // Example: // diff --git a/service.go b/service.go index d0c1f14..cbb8769 100644 --- a/service.go +++ b/service.go @@ -668,10 +668,6 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} } case TaskProcessSignal: - if m.Signal == 0 { - return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires a signal", nil), OK: false} - } - switch { case m.ID != "": if err := s.Signal(m.ID, m.Signal); err != nil { diff --git a/service_test.go b/service_test.go index 9482a55..ff018c8 100644 --- a/service_test.go +++ b/service_test.go @@ -815,6 +815,30 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, StatusKilled, proc.Status) }) + t.Run("allows signal zero liveness checks", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.Signal(0), + }) + require.True(t, result.OK) + + assert.True(t, proc.IsRunning()) + + cancel() + <-proc.Done() + }) + t.Run("registers process.wait task", func(t *testing.T) { svc, c := newTestService(t) From dec0231938e7394270e81dc30c0b5f58a4b503a9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 04:23:28 +0000 Subject: [PATCH 73/97] fix(process): leave exit action errors unset Align ActionProcessExited with the documented contract by keeping the reserved Error field nil for both start failures and normal exits. Co-Authored-By: Virgil --- service.go | 6 +++--- service_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/service.go b/service.go index cbb8769..83a7fbd 100644 --- a/service.go +++ b/service.go @@ -232,7 +232,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: -1, Duration: proc.Duration, - Error: err, + Error: nil, }) } return proc, coreerr.E("Service.StartWithOptions", "failed to start process", err) @@ -291,7 +291,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce err := cmd.Wait() duration := time.Since(proc.StartedAt) - status, exitCode, exitErr, signalName := classifyProcessExit(err) + status, exitCode, _, signalName := classifyProcessExit(err) proc.mu.Lock() proc.Duration = duration @@ -309,7 +309,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: exitCode, Duration: duration, - Error: exitErr, + Error: nil, } if c := s.coreApp(); c != nil { diff --git a/service_test.go b/service_test.go index ff018c8..f4f952c 100644 --- a/service_test.go +++ b/service_test.go @@ -337,7 +337,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Error(t, exited[0].Error) + assert.Nil(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) }) @@ -370,7 +370,7 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() require.Len(t, exited, 1) assert.Equal(t, -1, exited[0].ExitCode) - assert.Error(t, exited[0].Error) + assert.Nil(t, exited[0].Error) }) } From f717fc66c3016713805af1e8a6e9526c4ac09488 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 04:28:06 +0000 Subject: [PATCH 74/97] feat(process): add stdin service helpers Co-Authored-By: Virgil --- global_test.go | 30 ++++++++++++++++++++++++++++ process_global.go | 26 ++++++++++++++++++++++++ service.go | 26 ++++++++++++++++++++++++ service_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/global_test.go b/global_test.go index 55f88d3..d1248f0 100644 --- a/global_test.go +++ b/global_test.go @@ -36,6 +36,12 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) { _, err = Output("proc-1") assert.ErrorIs(t, err, ErrServiceNotInitialized) + err = Input("proc-1", "test") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + err = CloseStdin("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + assert.Nil(t, List()) assert.Nil(t, Running()) @@ -273,6 +279,30 @@ func TestGlobal_Output(t *testing.T) { assert.Contains(t, output, "global-output") } +func TestGlobal_InputAndCloseStdin(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "cat") + require.NoError(t, err) + + err = Input(proc.ID, "global-input\n") + require.NoError(t, err) + + err = CloseStdin(proc.ID) + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "global-input") +} + func TestGlobal_Wait(t *testing.T) { svc, _ := newTestService(t) diff --git a/process_global.go b/process_global.go index bde259a..12a5b54 100644 --- a/process_global.go +++ b/process_global.go @@ -114,6 +114,32 @@ func Output(id string) (string, error) { return svc.Output(id) } +// Input writes data to the stdin of a managed process using the default service. +// +// Example: +// +// _ = process.Input("proc-1", "hello\n") +func Input(id string, input string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Input(id, input) +} + +// CloseStdin closes the stdin pipe of a managed process using the default service. +// +// Example: +// +// _ = process.CloseStdin("proc-1") +func CloseStdin(id string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.CloseStdin(id) +} + // Wait blocks until a managed process exits and returns its final snapshot. // // Example: diff --git a/service.go b/service.go index 83a7fbd..739f964 100644 --- a/service.go +++ b/service.go @@ -536,6 +536,32 @@ func (s *Service) Output(id string) (string, error) { return proc.Output(), nil } +// Input writes data to the stdin of a managed process. +// +// Example: +// +// _ = svc.Input("proc-1", "hello\n") +func (s *Service) Input(id string, input string) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.SendInput(input) +} + +// CloseStdin closes the stdin pipe of a managed process. +// +// Example: +// +// _ = svc.CloseStdin("proc-1") +func (s *Service) CloseStdin(id string) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.CloseStdin() +} + // Wait blocks until a managed process exits and returns its final snapshot. // // Example: diff --git a/service_test.go b/service_test.go index f4f952c..f481352 100644 --- a/service_test.go +++ b/service_test.go @@ -607,6 +607,57 @@ func TestService_Output(t *testing.T) { }) } +func TestService_Input(t *testing.T) { + t.Run("writes to stdin", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + err = svc.Input(proc.ID, "service-input\n") + require.NoError(t, err) + + err = svc.CloseStdin(proc.ID) + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "service-input") + }) + + t.Run("error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + err := svc.Input("nonexistent", "test") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) +} + +func TestService_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 = svc.CloseStdin(proc.ID) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("cat should exit when stdin is closed") + } + }) + + t.Run("error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + err := svc.CloseStdin("nonexistent") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) +} + func TestService_Wait(t *testing.T) { t.Run("returns final info on success", func(t *testing.T) { svc, _ := newTestService(t) From dcf20c78c8cb85bded669c43e7ca6d7a6eb2e030 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 05:49:58 +0000 Subject: [PATCH 75/97] feat(process): add cleanup tasks to core service --- actions.go | 17 +++++++++++++++++ service.go | 13 +++++++++++++ service_test.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/actions.go b/actions.go index c4d4856..2181c6c 100644 --- a/actions.go +++ b/actions.go @@ -142,6 +142,23 @@ type TaskProcessList struct { RunningOnly bool } +// TaskProcessRemove removes a completed managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessRemove{ID: "proc-1"}) +type TaskProcessRemove struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessClear removes all completed managed processes through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessClear{}) +type TaskProcessClear struct{} + // ActionProcessStarted is broadcast when a process begins execution. // // Example: diff --git a/service.go b/service.go index 739f964..9c4ca09 100644 --- a/service.go +++ b/service.go @@ -783,6 +783,19 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { } return core.Result{Value: infos, OK: true} + case TaskProcessRemove: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process remove requires an id", nil), OK: false} + } + + if err := s.Remove(m.ID); err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{OK: true} + case TaskProcessClear: + s.Clear() + return core.Result{OK: true} default: return core.Result{} } diff --git a/service_test.go b/service_test.go index f481352..d531687 100644 --- a/service_test.go +++ b/service_test.go @@ -957,6 +957,43 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, proc.Info().PID, info.PID) }) + t.Run("registers process.remove task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "remove-through-core") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessRemove{ID: proc.ID}) + require.True(t, result.OK) + + _, err = svc.Get(proc.ID) + assert.ErrorIs(t, err, ErrProcessNotFound) + }) + + t.Run("registers process.clear task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + first, err := svc.Start(context.Background(), "echo", "clear-through-core-1") + require.NoError(t, err) + second, err := svc.Start(context.Background(), "echo", "clear-through-core-2") + require.NoError(t, err) + <-first.Done() + <-second.Done() + + require.Len(t, svc.List(), 2) + + result := c.PERFORM(TaskProcessClear{}) + require.True(t, result.OK) + assert.Len(t, svc.List(), 0) + }) + t.Run("registers process.output task", func(t *testing.T) { svc, c := newTestService(t) From c7542939c7c7c49f6cb7b0f2989d82914353eb50 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:00:52 +0000 Subject: [PATCH 76/97] fix(process): count skipped runner results as success Co-Authored-By: Virgil --- runner.go | 4 ++-- runner_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/runner.go b/runner.go index 27d3952..710abc9 100644 --- a/runner.go +++ b/runner.go @@ -80,13 +80,13 @@ type RunAllResult struct { Skipped int } -// Success returns true if every spec completed successfully. +// Success returns true when no spec failed. // // Example: // // if result.Success() { ... } func (r RunAllResult) Success() bool { - return r.Failed == 0 && r.Skipped == 0 + return r.Failed == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. diff --git a/runner_test.go b/runner_test.go index 25c6ec3..43705a8 100644 --- a/runner_test.go +++ b/runner_test.go @@ -178,7 +178,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) + assert.True(t, result.Success()) assert.Equal(t, 0, result.Failed) assert.Equal(t, 2, result.Skipped) for _, res := range result.Results { @@ -196,7 +196,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) + assert.True(t, result.Success()) assert.Equal(t, 0, result.Failed) assert.Equal(t, 1, result.Skipped) require.Len(t, result.Results, 1) From 040500f3e1c641b183290d58c468ce3251f7f08a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:09:01 +0000 Subject: [PATCH 77/97] feat(process): broadcast provider process ws events --- pkg/api/provider.go | 93 +++++++++++++++++++++++++- pkg/api/provider_test.go | 138 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 59c01e7..7f4e2ad 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -10,8 +10,11 @@ import ( "os" "strconv" "strings" + "sync" "syscall" + "time" + "dappco.re/go/core" "dappco.re/go/core/api" "dappco.re/go/core/api/pkg/provider" process "dappco.re/go/core/process" @@ -27,6 +30,7 @@ type ProcessProvider struct { service *process.Service runner *process.Runner hub *ws.Hub + actions sync.Once } // compile-time interface checks @@ -54,6 +58,7 @@ func NewProvider(registry *process.Registry, service *process.Service, hub *ws.H if service != nil { p.runner = process.NewRunner(service) } + p.registerProcessEvents() return p } @@ -486,10 +491,16 @@ func (p *ProcessProvider) emitEvent(channel string, data any) { if p.hub == nil { return } - _ = p.hub.SendToChannel(channel, ws.Message{ + msg := ws.Message{ Type: ws.TypeEvent, Data: data, + } + _ = p.hub.Broadcast(ws.Message{ + Type: msg.Type, + Channel: channel, + Data: data, }) + _ = p.hub.SendToChannel(channel, msg) } // PIDAlive checks whether a PID is still running. Exported for use by @@ -510,3 +521,83 @@ func intParam(c *gin.Context, name string) int { v, _ := strconv.Atoi(c.Param(name)) return v } + +func (p *ProcessProvider) registerProcessEvents() { + if p == nil || p.hub == nil || p.service == nil { + return + } + + coreApp := p.service.Core() + if coreApp == nil { + return + } + + p.actions.Do(func() { + coreApp.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + p.forwardProcessEvent(msg) + return core.Result{OK: true} + }) + }) +} + +func (p *ProcessProvider) forwardProcessEvent(msg core.Message) { + switch m := msg.(type) { + case process.ActionProcessStarted: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["command"] = m.Command + payload["args"] = append([]string(nil), m.Args...) + payload["dir"] = m.Dir + payload["pid"] = m.PID + if _, ok := payload["startedAt"]; !ok { + payload["startedAt"] = time.Now().UTC() + } + p.emitEvent("process.started", payload) + case process.ActionProcessOutput: + p.emitEvent("process.output", map[string]any{ + "id": m.ID, + "line": m.Line, + "stream": m.Stream, + }) + case process.ActionProcessExited: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["exitCode"] = m.ExitCode + payload["duration"] = m.Duration + if m.Error != nil { + payload["error"] = m.Error.Error() + } + p.emitEvent("process.exited", payload) + case process.ActionProcessKilled: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["signal"] = m.Signal + payload["exitCode"] = -1 + p.emitEvent("process.killed", payload) + } +} + +func (p *ProcessProvider) processEventPayload(id string) map[string]any { + if p == nil || p.service == nil || id == "" { + return map[string]any{} + } + + proc, err := p.service.Get(id) + if err != nil { + return map[string]any{} + } + + info := proc.Info() + return map[string]any{ + "id": info.ID, + "command": info.Command, + "args": append([]string(nil), info.Args...), + "dir": info.Dir, + "startedAt": info.StartedAt, + "running": info.Running, + "status": info.Status, + "exitCode": info.ExitCode, + "duration": info.Duration, + "pid": info.PID, + } +} diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 4787372..c8ee7c7 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -16,7 +16,9 @@ import ( goapi "dappco.re/go/core/api" process "dappco.re/go/core/process" processapi "dappco.re/go/core/process/pkg/api" + corews "dappco.re/go/core/ws" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -305,6 +307,98 @@ func TestProcessProvider_KillProcess_Good(t *testing.T) { assert.Equal(t, process.StatusKilled, proc.Status) } +func TestProcessProvider_BroadcastsProcessEvents_Good(t *testing.T) { + svc := newTestProcessService(t) + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + _ = processapi.NewProvider(nil, svc, hub) + + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + proc, err := svc.Start(context.Background(), "sh", "-c", "echo live-event") + require.NoError(t, err) + <-proc.Done() + + events := readWSEvents(t, conn, "process.started", "process.output", "process.exited") + + started := events["process.started"] + require.NotNil(t, started) + startedData := started.Data.(map[string]any) + assert.Equal(t, proc.ID, startedData["id"]) + assert.Equal(t, "sh", startedData["command"]) + assert.Equal(t, float64(proc.Info().PID), startedData["pid"]) + + output := events["process.output"] + require.NotNil(t, output) + outputData := output.Data.(map[string]any) + assert.Equal(t, proc.ID, outputData["id"]) + assert.Equal(t, "stdout", outputData["stream"]) + assert.Contains(t, outputData["line"], "live-event") + + exited := events["process.exited"] + require.NotNil(t, exited) + exitedData := exited.Data.(map[string]any) + assert.Equal(t, proc.ID, exitedData["id"]) + assert.Equal(t, float64(0), exitedData["exitCode"]) +} + +func TestProcessProvider_BroadcastsKilledEvents_Good(t *testing.T) { + svc := newTestProcessService(t) + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + _ = processapi.NewProvider(nil, svc, hub) + + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + require.NoError(t, svc.Kill(proc.ID)) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed") + } + + events := readWSEvents(t, conn, "process.killed", "process.exited") + + killed := events["process.killed"] + require.NotNil(t, killed) + killedData := killed.Data.(map[string]any) + assert.Equal(t, proc.ID, killedData["id"]) + assert.Equal(t, "SIGKILL", killedData["signal"]) + assert.Equal(t, float64(-1), killedData["exitCode"]) + + exited := events["process.exited"] + require.NotNil(t, exited) + exitedData := exited.Data.(map[string]any) + assert.Equal(t, proc.ID, exitedData["id"]) + assert.Equal(t, float64(-1), exitedData["exitCode"]) +} + func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { p := processapi.NewProvider(nil, nil, nil) r := setupRouter(p) @@ -352,3 +446,47 @@ func newTestProcessService(t *testing.T) *process.Service { return raw.(*process.Service) } + +func connectWS(t *testing.T, serverURL string) *websocket.Conn { + t.Helper() + + wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + return conn +} + +func readWSEvents(t *testing.T, conn *websocket.Conn, channels ...string) map[string]corews.Message { + t.Helper() + + want := make(map[string]struct{}, len(channels)) + for _, channel := range channels { + want[channel] = struct{}{} + } + + events := make(map[string]corews.Message, len(channels)) + deadline := time.Now().Add(3 * time.Second) + + for len(events) < len(channels) && time.Now().Before(deadline) { + require.NoError(t, conn.SetReadDeadline(time.Now().Add(500*time.Millisecond))) + + _, payload, err := conn.ReadMessage() + require.NoError(t, err) + + for _, line := range strings.Split(strings.TrimSpace(string(payload)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + var msg corews.Message + require.NoError(t, json.Unmarshal([]byte(line), &msg)) + + if _, ok := want[msg.Channel]; ok { + events[msg.Channel] = msg + } + } + } + + require.Len(t, events, len(channels)) + return events +} From 9b3dd1ec494b1d1111c228f4e5fab04ab121da31 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:12:49 +0000 Subject: [PATCH 78/97] feat(process): emit daemon started discovery events Co-Authored-By: Virgil --- pkg/api/provider.go | 16 +++++++++++++++ pkg/api/provider_test.go | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 7f4e2ad..71c48c3 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -275,6 +275,9 @@ func (p *ProcessProvider) listDaemons(c *gin.Context) { if entries == nil { entries = []process.DaemonEntry{} } + for _, entry := range entries { + p.emitEvent("process.daemon.started", daemonEventPayload(entry)) + } c.JSON(http.StatusOK, api.OK(entries)) } @@ -287,6 +290,7 @@ func (p *ProcessProvider) getDaemon(c *gin.Context) { c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running")) return } + p.emitEvent("process.daemon.started", daemonEventPayload(*entry)) c.JSON(http.StatusOK, api.OK(entry)) } @@ -503,6 +507,18 @@ func (p *ProcessProvider) emitEvent(channel string, data any) { _ = p.hub.SendToChannel(channel, msg) } +func daemonEventPayload(entry process.DaemonEntry) map[string]any { + return map[string]any{ + "code": entry.Code, + "daemon": entry.Daemon, + "pid": entry.PID, + "health": entry.Health, + "project": entry.Project, + "binary": entry.Binary, + "started": entry.Started, + } +} + // PIDAlive checks whether a PID is still running. Exported for use by // consumers that need to verify daemon liveness outside the REST API. func PIDAlive(pid int) bool { diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index c8ee7c7..f5aa610 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -87,6 +87,48 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) { assert.True(t, resp.Success) } +func TestProcessProvider_ListDaemons_BroadcastsStarted_Good(t *testing.T) { + dir := t.TempDir() + registry := newTestRegistry(dir) + require.NoError(t, registry.Register(process.DaemonEntry{ + Code: "test", + Daemon: "serve", + PID: os.Getpid(), + })) + + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + p := processapi.NewProvider(registry, nil, hub) + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/process/daemons", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + events := readWSEvents(t, conn, "process.daemon.started") + started := events["process.daemon.started"] + require.NotNil(t, started) + + startedData := started.Data.(map[string]any) + assert.Equal(t, "test", startedData["code"]) + assert.Equal(t, "serve", startedData["daemon"]) + assert.Equal(t, float64(os.Getpid()), startedData["pid"]) +} + func TestProcessProvider_GetDaemon_Bad(t *testing.T) { dir := t.TempDir() registry := newTestRegistry(dir) From 8d8267543de79f9323d8ba80c2eb90f0ca0d6f0f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:39:22 +0000 Subject: [PATCH 79/97] feat(process): include exit errors in action payloads --- actions.go | 2 +- service.go | 9 +++++---- service_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/actions.go b/actions.go index 2181c6c..8723afc 100644 --- a/actions.go +++ b/actions.go @@ -194,7 +194,7 @@ type ActionProcessExited struct { ID string ExitCode int Duration time.Duration - Error error // Reserved for future exit metadata; currently left unset by the service + Error error // Set for failed starts, non-zero exits, or killed processes. } // ActionProcessKilled is broadcast when a process is terminated. diff --git a/service.go b/service.go index 9c4ca09..2a7fb55 100644 --- a/service.go +++ b/service.go @@ -215,6 +215,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce // Start the process if err := cmd.Start(); err != nil { + startErr := coreerr.E("Service.StartWithOptions", "failed to start process", err) proc.mu.Lock() proc.Status = StatusFailed proc.ExitCode = -1 @@ -232,10 +233,10 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: -1, Duration: proc.Duration, - Error: nil, + Error: startErr, }) } - return proc, coreerr.E("Service.StartWithOptions", "failed to start process", err) + return proc, startErr } proc.mu.Lock() @@ -291,7 +292,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce err := cmd.Wait() duration := time.Since(proc.StartedAt) - status, exitCode, _, signalName := classifyProcessExit(err) + status, exitCode, exitErr, signalName := classifyProcessExit(err) proc.mu.Lock() proc.Duration = duration @@ -309,7 +310,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce ID: id, ExitCode: exitCode, Duration: duration, - Error: nil, + Error: exitErr, } if c := s.coreApp(); c != nil { diff --git a/service_test.go b/service_test.go index d531687..42d135b 100644 --- a/service_test.go +++ b/service_test.go @@ -337,7 +337,8 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() assert.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Nil(t, exited[0].Error) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "process was killed") assert.Equal(t, StatusKilled, proc.Status) }) @@ -370,7 +371,42 @@ func TestService_Actions(t *testing.T) { defer mu.Unlock() require.Len(t, exited, 1) assert.Equal(t, -1, exited[0].ExitCode) - assert.Nil(t, exited[0].Error) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "failed to start process") + }) + + t.Run("broadcasts exited error on non-zero exit", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) + + var exited []ActionProcessExited + var mu sync.Mutex + + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + mu.Lock() + defer mu.Unlock() + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } + return framework.Result{OK: true} + }) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + <-proc.Done() + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Len(t, exited, 1) + assert.Equal(t, 7, exited[0].ExitCode) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "process exited with code 7") }) } From 208dac3c82171664baca6802d2513a1b2e5087a0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:44:01 +0000 Subject: [PATCH 80/97] feat(api): expose process stdin control Co-Authored-By: Virgil --- go.mod | 2 +- pkg/api/provider.go | 81 ++++++++++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 47 +++++++++++++++++++++-- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index acae565..68977e5 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( dappco.re/go/core/log v0.1.0 dappco.re/go/core/ws v0.3.0 github.com/gin-gonic/gin v1.12.0 + github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.11.1 ) @@ -66,7 +67,6 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 71c48c3..b2a92fa 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -98,6 +98,8 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/processes", p.listProcesses) rg.GET("/processes/:id", p.getProcess) rg.GET("/processes/:id/output", p.getProcessOutput) + rg.POST("/processes/:id/input", p.inputProcess) + rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) rg.POST("/processes/:id/kill", p.killProcess) rg.POST("/pipelines/run", p.runPipeline) } @@ -229,6 +231,39 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { "type": "string", }, }, + { + Method: "POST", + Path: "/processes/:id/input", + Summary: "Write process input", + Description: "Writes the provided input string to a managed process stdin pipe.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{"type": "string"}, + }, + "required": []string{"input"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "written": map[string]any{"type": "boolean"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/close-stdin", + Summary: "Close process stdin", + Description: "Closes the stdin pipe of a managed process so it can exit cleanly.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "closed": map[string]any{"type": "boolean"}, + }, + }, + }, { Method: "POST", Path: "/processes/:id/kill", @@ -421,6 +456,52 @@ func (p *ProcessProvider) getProcessOutput(c *gin.Context) { c.JSON(http.StatusOK, api.OK(output)) } +type processInputRequest struct { + Input string `json:"input"` +} + +func (p *ProcessProvider) inputProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req processInputRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + if err := p.service.Input(c.Param("id"), req.Input); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("input_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"written": true})) +} + +func (p *ProcessProvider) closeProcessStdin(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + if err := p.service.CloseStdin(c.Param("id")); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("close_stdin_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"closed": true})) +} + func (p *ProcessProvider) killProcess(c *gin.Context) { if p.service == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index f5aa610..d5a1541 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -320,6 +320,41 @@ func TestProcessProvider_GetProcessOutput_Good(t *testing.T) { assert.Contains(t, resp.Data, "output-check") } +func TestProcessProvider_InputAndCloseStdin_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + inputReq := strings.NewReader("{\"input\":\"hello-api\\n\"}") + inputHTTPReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/input", inputReq) + require.NoError(t, err) + inputHTTPReq.Header.Set("Content-Type", "application/json") + + inputResp := httptest.NewRecorder() + r.ServeHTTP(inputResp, inputHTTPReq) + + assert.Equal(t, http.StatusOK, inputResp.Code) + + closeReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/close-stdin", nil) + require.NoError(t, err) + + closeResp := httptest.NewRecorder() + r.ServeHTTP(closeResp, closeReq) + + assert.Equal(t, http.StatusOK, closeResp.Code) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have exited after stdin was closed") + } + + assert.Contains(t, proc.Output(), "hello-api") +} + func TestProcessProvider_KillProcess_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "sleep", "60") @@ -449,15 +484,21 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { "/api/process/processes", "/api/process/processes/anything", "/api/process/processes/anything/output", + "/api/process/processes/anything/input", + "/api/process/processes/anything/close-stdin", "/api/process/processes/anything/kill", } for _, path := range cases { w := httptest.NewRecorder() - req, err := http.NewRequest("GET", path, nil) - if strings.HasSuffix(path, "/kill") { - req, err = http.NewRequest("POST", path, nil) + method := "GET" + switch { + case strings.HasSuffix(path, "/kill"), + strings.HasSuffix(path, "/input"), + strings.HasSuffix(path, "/close-stdin"): + method = "POST" } + req, err := http.NewRequest(method, path, nil) require.NoError(t, err) r.ServeHTTP(w, req) assert.Equal(t, http.StatusServiceUnavailable, w.Code) From 2461466f55a47646ac949e211ba13d417907f638 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:48:18 +0000 Subject: [PATCH 81/97] Handle nil contexts in runner and daemon --- daemon.go | 7 +++++++ daemon_test.go | 8 ++++++++ runner.go | 19 +++++++++++++++++++ runner_test.go | 16 ++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/daemon.go b/daemon.go index 5adbb26..6199fc6 100644 --- a/daemon.go +++ b/daemon.go @@ -144,6 +144,10 @@ func (d *Daemon) Start() error { // // if err := daemon.Run(ctx); err != nil { return err } func (d *Daemon) Run(ctx context.Context) error { + if ctx == nil { + return coreerr.E("Daemon.Run", "daemon context is required", ErrDaemonContextRequired) + } + d.mu.Lock() if !d.running { d.mu.Unlock() @@ -243,3 +247,6 @@ func (d *Daemon) HealthAddr() string { } return "" } + +// ErrDaemonContextRequired is returned when Run is called without a context. +var ErrDaemonContextRequired = coreerr.E("", "daemon context is required", nil) diff --git a/daemon_test.go b/daemon_test.go index 2c12333..57c2cc6 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -221,6 +221,14 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) { assert.Contains(t, err.Error(), "not started") } +func TestDaemon_RunNilContextFails(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + + err := d.Run(nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDaemonContextRequired) +} + func TestDaemon_SetReady(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", diff --git a/runner.go b/runner.go index 710abc9..e7b045a 100644 --- a/runner.go +++ b/runner.go @@ -20,6 +20,9 @@ var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) // ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated. var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil) +// ErrRunnerContextRequired is returned when a runner method is called without a context. +var ErrRunnerContextRequired = coreerr.E("", "runner context is required", nil) + // NewRunner creates a runner for the given service. // // Example: @@ -98,6 +101,9 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } if err := validateSpecs(specs); err != nil { return nil, err } @@ -288,6 +294,9 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } if err := validateSpecs(specs); err != nil { return nil, err } @@ -339,6 +348,9 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } if err := validateSpecs(specs); err != nil { return nil, err } @@ -391,6 +403,13 @@ func validateSpecs(specs []RunSpec) error { return nil } +func ensureRunnerContext(ctx context.Context) error { + if ctx == nil { + return coreerr.E("Runner.ensureRunnerContext", "runner context is required", ErrRunnerContextRequired) + } + return nil +} + func skippedRunResult(op string, spec RunSpec, err error) RunResult { result := RunResult{ Name: spec.Name, diff --git a/runner_test.go b/runner_test.go index 43705a8..94dbf50 100644 --- a/runner_test.go +++ b/runner_test.go @@ -294,6 +294,22 @@ func TestRunner_NilService(t *testing.T) { assert.ErrorIs(t, err, ErrRunnerNoService) } +func TestRunner_NilContext(t *testing.T) { + runner := newTestRunner(t) + + _, err := runner.RunAll(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) + + _, err = runner.RunSequential(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) + + _, err = runner.RunParallel(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) +} + func TestRunner_InvalidSpecNames(t *testing.T) { runner := newTestRunner(t) From 1398c4b8eac8ce5c31942f8d2b3b053975b071d5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:51:14 +0000 Subject: [PATCH 82/97] feat(process): kill unmanaged pids forcefully --- service.go | 2 +- service_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/service.go b/service.go index 2a7fb55..b322200 100644 --- a/service.go +++ b/service.go @@ -440,7 +440,7 @@ func (s *Service) KillPID(pid int) error { return nil } - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) } diff --git a/service_test.go b/service_test.go index 42d135b..52fff4f 100644 --- a/service_test.go +++ b/service_test.go @@ -525,10 +525,11 @@ func TestService_Kill(t *testing.T) { } func TestService_KillPID(t *testing.T) { - t.Run("terminates unmanaged process with SIGTERM", func(t *testing.T) { + t.Run("terminates unmanaged process with SIGKILL", func(t *testing.T) { svc, _ := newTestService(t) - cmd := exec.Command("sleep", "60") + // Ignore SIGTERM so the test proves KillPID uses a forceful signal. + cmd := exec.Command("sh", "-c", "trap '' TERM; while :; do :; done") require.NoError(t, cmd.Start()) waitCh := make(chan error, 1) @@ -557,7 +558,7 @@ func TestService_KillPID(t *testing.T) { ws, ok := exitErr.Sys().(syscall.WaitStatus) require.True(t, ok) assert.True(t, ws.Signaled()) - assert.Equal(t, syscall.SIGTERM, ws.Signal()) + assert.Equal(t, syscall.SIGKILL, ws.Signal()) case <-time.After(2 * time.Second): t.Fatal("unmanaged process should have been killed") } From ac5a938b70059f47f04c4eef9d4f100c9ca83e48 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:55:42 +0000 Subject: [PATCH 83/97] feat(process): add readiness polling helpers --- health.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++--- health_test.go | 27 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/health.go b/health.go index fba36f1..0cd54ed 100644 --- a/health.go +++ b/health.go @@ -118,11 +118,14 @@ func (h *HealthServer) Start() error { return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err) } + server := &http.Server{Handler: mux} + h.mu.Lock() h.listener = listener - h.server = &http.Server{Handler: mux} + h.server = server + h.mu.Unlock() go func() { - _ = h.server.Serve(listener) + _ = server.Serve(listener) }() return nil @@ -134,10 +137,17 @@ func (h *HealthServer) Start() error { // // _ = server.Stop(context.Background()) func (h *HealthServer) Stop(ctx context.Context) error { - if h.server == nil { + h.mu.Lock() + server := h.server + h.server = nil + h.listener = nil + h.ready = false + h.mu.Unlock() + + if server == nil { return nil } - return h.server.Shutdown(ctx) + return server.Shutdown(ctx) } // Addr returns the actual address the server is listening on. @@ -146,6 +156,8 @@ func (h *HealthServer) Stop(ctx context.Context) error { // // addr := server.Addr() func (h *HealthServer) Addr() string { + h.mu.Lock() + defer h.mu.Unlock() if h.listener != nil { return h.listener.Addr().String() } @@ -200,3 +212,52 @@ func ProbeHealth(addr string, timeoutMs int) (bool, string) { } return false, lastReason } + +// WaitForReady polls `/ready` until it responds 200 or the timeout expires. +// +// Example: +// +// if !process.WaitForReady("127.0.0.1:8080", 5_000) { +// return errors.New("service did not become ready") +// } +func WaitForReady(addr string, timeoutMs int) bool { + ok, _ := ProbeReady(addr, timeoutMs) + return ok +} + +// ProbeReady polls `/ready` until it responds 200 or the timeout expires. +// It returns the readiness status and the last observed failure reason. +// +// Example: +// +// ok, reason := process.ProbeReady("127.0.0.1:8080", 5_000) +func ProbeReady(addr string, timeoutMs int) (bool, string) { + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + url := fmt.Sprintf("http://%s/ready", addr) + + client := &http.Client{Timeout: 2 * time.Second} + var lastReason string + + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true, "" + } + lastReason = strings.TrimSpace(string(body)) + if lastReason == "" { + lastReason = resp.Status + } + } else { + lastReason = err.Error() + } + time.Sleep(200 * time.Millisecond) + } + + if lastReason == "" { + lastReason = "readiness check timed out" + } + return false, lastReason +} diff --git a/health_test.go b/health_test.go index d744661..386b2ed 100644 --- a/health_test.go +++ b/health_test.go @@ -90,3 +90,30 @@ func TestWaitForHealth_Unreachable(t *testing.T) { ok := WaitForHealth("127.0.0.1:19999", 500) assert.False(t, ok) } + +func TestWaitForReady_Reachable(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + defer func() { _ = hs.Stop(context.Background()) }() + + ok := WaitForReady(hs.Addr(), 2_000) + assert.True(t, ok) +} + +func TestWaitForReady_Unreachable(t *testing.T) { + ok := WaitForReady("127.0.0.1:19999", 500) + assert.False(t, ok) +} + +func TestHealthServer_StopMarksNotReady(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + + require.NotEmpty(t, hs.Addr()) + assert.True(t, hs.Ready()) + + require.NoError(t, hs.Stop(context.Background())) + + assert.False(t, hs.Ready()) + assert.NotEmpty(t, hs.Addr()) +} From e1f5b0ff4087c81d0f844f7f36dbda51e75c34df Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:00:45 +0000 Subject: [PATCH 84/97] fix(process): harden health server snapshots Co-authored-by: Virgil --- health.go | 35 +++++++++++++++++++++++++---------- health_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/health.go b/health.go index 0cd54ed..a5a2ca0 100644 --- a/health.go +++ b/health.go @@ -21,7 +21,7 @@ type HealthServer struct { addr string server *http.Server listener net.Listener - mu sync.Mutex + mu sync.RWMutex ready bool checks []HealthCheck } @@ -68,8 +68,8 @@ func (h *HealthServer) SetReady(ready bool) { // // publish the service // } func (h *HealthServer) Ready() bool { - h.mu.Lock() - defer h.mu.Unlock() + h.mu.RLock() + defer h.mu.RUnlock() return h.ready } @@ -82,11 +82,12 @@ func (h *HealthServer) Start() error { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - checks := h.checks - h.mu.Unlock() + checks := h.checksSnapshot() for _, check := range checks { + if check == nil { + continue + } if err := check(); err != nil { w.WriteHeader(http.StatusServiceUnavailable) _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) @@ -99,9 +100,9 @@ func (h *HealthServer) Start() error { }) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() + h.mu.RLock() ready := h.ready - h.mu.Unlock() + h.mu.RUnlock() if !ready { w.WriteHeader(http.StatusServiceUnavailable) @@ -131,6 +132,20 @@ func (h *HealthServer) Start() error { return nil } +// checksSnapshot returns a stable copy of the registered health checks. +func (h *HealthServer) checksSnapshot() []HealthCheck { + h.mu.RLock() + defer h.mu.RUnlock() + + if len(h.checks) == 0 { + return nil + } + + checks := make([]HealthCheck, len(h.checks)) + copy(checks, h.checks) + return checks +} + // Stop gracefully shuts down the health server. // // Example: @@ -156,8 +171,8 @@ func (h *HealthServer) Stop(ctx context.Context) error { // // addr := server.Addr() func (h *HealthServer) Addr() string { - h.mu.Lock() - defer h.mu.Unlock() + h.mu.RLock() + defer h.mu.RUnlock() if h.listener != nil { return h.listener.Addr().String() } diff --git a/health_test.go b/health_test.go index 386b2ed..faf9b3b 100644 --- a/health_test.go +++ b/health_test.go @@ -77,6 +77,35 @@ func TestHealthServer_WithChecks(t *testing.T) { _ = resp.Body.Close() } +func TestHealthServer_NilCheckIgnored(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + + var check HealthCheck + hs.AddCheck(check) + + err := hs.Start() + require.NoError(t, err) + defer func() { _ = hs.Stop(context.Background()) }() + + addr := hs.Addr() + + resp, err := http.Get("http://" + addr + "/health") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() +} + +func TestHealthServer_ChecksSnapshotIsStable(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + + hs.AddCheck(func() error { return nil }) + snapshot := hs.checksSnapshot() + hs.AddCheck(func() error { return assert.AnError }) + + require.Len(t, snapshot, 1) + require.NotNil(t, snapshot[0]) +} + func TestWaitForHealth_Reachable(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") require.NoError(t, hs.Start()) From 3ac213a058e5f9fde3ff8bba1d398f4c670ee317 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:08:54 +0000 Subject: [PATCH 85/97] feat(process): preserve wait task snapshots on failure --- actions.go | 26 ++++++++++++++++++++++++++ service.go | 8 +++++++- service_test.go | 22 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/actions.go b/actions.go index 8723afc..ae238e7 100644 --- a/actions.go +++ b/actions.go @@ -91,6 +91,8 @@ type TaskProcessGet struct { } // TaskProcessWait waits for a managed process to finish through Core.PERFORM. +// Successful exits return an Info snapshot. Unsuccessful exits return a +// TaskProcessWaitError value that preserves the final snapshot. // // Example: // @@ -100,6 +102,30 @@ type TaskProcessWait struct { ID string } +// TaskProcessWaitError is returned as the task value when TaskProcessWait +// completes with a non-successful process outcome. It preserves the final +// process snapshot while still behaving like the underlying wait error. +type TaskProcessWaitError struct { + Info Info + Err error +} + +// Error implements error. +func (e *TaskProcessWaitError) Error() string { + if e == nil || e.Err == nil { + return "" + } + return e.Err.Error() +} + +// Unwrap returns the underlying wait error. +func (e *TaskProcessWaitError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + // TaskProcessOutput requests the captured output of a managed process through Core.PERFORM. // // Example: diff --git a/service.go b/service.go index b322200..edb30c5 100644 --- a/service.go +++ b/service.go @@ -727,7 +727,13 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { info, err := s.Wait(m.ID) if err != nil { - return core.Result{Value: err, OK: false} + return core.Result{ + Value: &TaskProcessWaitError{ + Info: info, + Err: err, + }, + OK: true, + } } return core.Result{Value: info, OK: true} diff --git a/service_test.go b/service_test.go index 52fff4f..9663435 100644 --- a/service_test.go +++ b/service_test.go @@ -946,6 +946,28 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, 0, info.ExitCode) }) + t.Run("preserves final snapshot when process.wait task fails", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + require.True(t, result.OK) + + errValue, ok := result.Value.(error) + require.True(t, ok) + var waitErr *TaskProcessWaitError + require.ErrorAs(t, errValue, &waitErr) + assert.Contains(t, waitErr.Error(), "process exited with code 7") + assert.Equal(t, proc.ID, waitErr.Info.ID) + assert.Equal(t, StatusExited, waitErr.Info.Status) + assert.Equal(t, 7, waitErr.Info.ExitCode) + }) + t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t) From 588f4e173b273c10ceeb2f751a7d1fdfc9749596 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:15:04 +0000 Subject: [PATCH 86/97] fix(exec): guard default logger access Co-authored-by: Virgil --- exec/exec_test.go | 24 ++++++++++++++++++++++++ exec/logger.go | 13 ++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/exec/exec_test.go b/exec/exec_test.go index 8519468..d5a498e 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -138,6 +139,29 @@ func TestSetDefaultLogger(t *testing.T) { } } +func TestDefaultLogger_IsConcurrentSafe(t *testing.T) { + original := exec.DefaultLogger() + defer exec.SetDefaultLogger(original) + + logger := &mockLogger{} + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(2) + go func() { + defer wg.Done() + exec.SetDefaultLogger(logger) + }() + go func() { + defer wg.Done() + _ = exec.DefaultLogger() + }() + } + wg.Wait() + + assert.NotNil(t, exec.DefaultLogger()) +} + func TestCommand_UsesDefaultLogger(t *testing.T) { original := exec.DefaultLogger() defer exec.SetDefaultLogger(original) diff --git a/exec/logger.go b/exec/logger.go index 0340710..5ff59e6 100644 --- a/exec/logger.go +++ b/exec/logger.go @@ -1,5 +1,7 @@ package exec +import "sync" + // Logger interface for command execution logging. // Compatible with pkg/log.Logger and other structured loggers. type Logger interface { @@ -26,7 +28,10 @@ func (NopLogger) Error(string, ...any) {} var _ Logger = NopLogger{} -var defaultLogger Logger = NopLogger{} +var ( + defaultLoggerMu sync.RWMutex + defaultLogger Logger = NopLogger{} +) // SetDefaultLogger sets the package-level default logger. // Commands without an explicit logger will use this. @@ -35,6 +40,9 @@ var defaultLogger Logger = NopLogger{} // // exec.SetDefaultLogger(logger) func SetDefaultLogger(l Logger) { + defaultLoggerMu.Lock() + defer defaultLoggerMu.Unlock() + if l == nil { l = NopLogger{} } @@ -47,5 +55,8 @@ func SetDefaultLogger(l Logger) { // // logger := exec.DefaultLogger() func DefaultLogger() Logger { + defaultLoggerMu.RLock() + defer defaultLoggerMu.RUnlock() + return defaultLogger } From 429675ca2997e2051eb9e29df610f0586ed1d3ea Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:19:56 +0000 Subject: [PATCH 87/97] feat(process): add package register helper Co-Authored-By: Virgil --- global_test.go | 13 +++++++++++++ process_global.go | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/global_test.go b/global_test.go index d1248f0..f68ec92 100644 --- a/global_test.go +++ b/global_test.go @@ -95,6 +95,19 @@ func TestGlobal_SetDefault(t *testing.T) { }) } +func TestGlobal_Register(t *testing.T) { + c := framework.New() + + result := Register(c) + require.True(t, result.OK) + + svc, ok := result.Value.(*Service) + require.True(t, ok) + require.NotNil(t, svc) + assert.NotNil(t, svc.ServiceRuntime) + assert.Equal(t, DefaultBufferSize, svc.bufSize) +} + func TestGlobal_ConcurrentDefault(t *testing.T) { old := defaultService.Swap(nil) defer func() { diff --git a/process_global.go b/process_global.go index 12a5b54..79862b3 100644 --- a/process_global.go +++ b/process_global.go @@ -60,6 +60,20 @@ func Init(c *core.Core) error { return defaultErr } +// Register creates a process service for Core registration. +// +// Example: +// +// result := process.Register(coreInstance) +func Register(c *core.Core) core.Result { + factory := NewService(Options{}) + svc, err := factory(c) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: svc, OK: true} +} + // --- Global convenience functions --- // Start spawns a new process using the default service. From b74ee080a2bcd68b4269127fef0b1d1e508252a6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:24:12 +0000 Subject: [PATCH 88/97] feat(process): add service error helper Co-Authored-By: Virgil --- errors.go | 12 ++++++++++++ errors_test.go | 15 +++++++++++++++ service.go | 8 ++++---- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 errors.go create mode 100644 errors_test.go diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ba540e6 --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package process + +import coreerr "dappco.re/go/core/log" + +// ServiceError wraps a service-level failure with a message string. +// +// Example: +// +// return process.ServiceError("context is required", process.ErrContextRequired) +func ServiceError(message string, err error) error { + return coreerr.E("ServiceError", message, err) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..61d3f42 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,15 @@ +package process + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceError(t *testing.T) { + err := ServiceError("service failed", ErrContextRequired) + require.Error(t, err) + assert.Contains(t, err.Error(), "service failed") + assert.ErrorIs(t, err, ErrContextRequired) +} diff --git a/service.go b/service.go index edb30c5..28979f1 100644 --- a/service.go +++ b/service.go @@ -138,10 +138,10 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P // proc, err := svc.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"}) func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { if opts.Command == "" { - return nil, coreerr.E("Service.StartWithOptions", "command is required", nil) + return nil, ServiceError("command is required", nil) } if ctx == nil { - return nil, coreerr.E("Service.StartWithOptions", "context is required", ErrContextRequired) + return nil, ServiceError("context is required", ErrContextRequired) } id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) @@ -426,7 +426,7 @@ func (s *Service) Kill(id string) error { // _ = svc.KillPID(1234) func (s *Service) KillPID(pid int) error { if pid <= 0 { - return coreerr.E("Service.KillPID", "pid must be positive", nil) + return ServiceError("pid must be positive", nil) } if proc := s.findByPID(pid); proc != nil { @@ -467,7 +467,7 @@ func (s *Service) Signal(id string, sig os.Signal) error { // _ = svc.SignalPID(1234, syscall.SIGTERM) func (s *Service) SignalPID(pid int, sig os.Signal) error { if pid <= 0 { - return coreerr.E("Service.SignalPID", "pid must be positive", nil) + return ServiceError("pid must be positive", nil) } if proc := s.findByPID(pid); proc != nil { From f4da274ce6892f044cdde0f9609e4133b3951c9f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:29:00 +0000 Subject: [PATCH 89/97] fix(process): keep signal zero as liveness probe Co-Authored-By: Virgil --- process.go | 4 ++++ process_test.go | 28 ++++++++++++++++++++++++++++ service_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/process.go b/process.go index ceb993d..30c7e3d 100644 --- a/process.go +++ b/process.go @@ -269,6 +269,10 @@ func (p *ManagedProcess) Signal(sig os.Signal) error { return cmd.Process.Signal(sig) } + if sysSig == 0 { + return syscall.Kill(-cmd.Process.Pid, 0) + } + if err := syscall.Kill(-cmd.Process.Pid, sysSig); err != nil { return err } diff --git a/process_test.go b/process_test.go index 1a4f037..596bc31 100644 --- a/process_test.go +++ b/process_test.go @@ -3,6 +3,7 @@ package process import ( "context" "os" + "syscall" "testing" "time" @@ -300,6 +301,33 @@ func TestProcess_Signal(t *testing.T) { t.Fatal("process group should have been terminated by signal") } }) + + t.Run("signal zero only probes process group liveness", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "sleep 60 & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + err = proc.Signal(syscall.Signal(0)) + assert.NoError(t, err) + + time.Sleep(300 * time.Millisecond) + assert.True(t, proc.IsRunning()) + + err = proc.Kill() + assert.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process group should have been killed for cleanup") + } + }) } func TestProcess_CloseStdin(t *testing.T) { diff --git a/service_test.go b/service_test.go index 9663435..0d98959 100644 --- a/service_test.go +++ b/service_test.go @@ -927,6 +927,34 @@ func TestService_OnStartup(t *testing.T) { <-proc.Done() }) + t.Run("signal zero does not kill process groups", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "sleep 60 & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.Signal(0), + }) + require.True(t, result.OK) + + time.Sleep(300 * time.Millisecond) + assert.True(t, proc.IsRunning()) + + err = proc.Kill() + require.NoError(t, err) + <-proc.Done() + }) + t.Run("registers process.wait task", func(t *testing.T) { svc, c := newTestService(t) From bc2cb6ae9d7526a39abe441bee1c03586c40d447 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:33:50 +0000 Subject: [PATCH 90/97] fix(process): keep runner exit errors nil --- runner.go | 12 ++++++------ runner_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/runner.go b/runner.go index e7b045a..72e23f0 100644 --- a/runner.go +++ b/runner.go @@ -2,7 +2,6 @@ package process import ( "context" - "fmt" "sync" "time" @@ -61,8 +60,10 @@ type RunResult struct { ExitCode int Duration time.Duration Output string - Error error - Skipped bool + // Error only reports start-time or orchestration failures. A started process + // that exits non-zero uses ExitCode to report failure and leaves Error nil. + Error error + Skipped bool } // Passed returns true if the process succeeded. @@ -268,9 +269,8 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { case StatusKilled: runErr = coreerr.E("Runner.runSpec", "process was killed", nil) case StatusExited: - if proc.ExitCode != 0 { - runErr = coreerr.E("Runner.runSpec", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) - } + // Non-zero exits are surfaced through ExitCode; Error remains nil so + // callers can distinguish execution failure from orchestration failure. case StatusFailed: runErr = coreerr.E("Runner.runSpec", "process failed to start", nil) } diff --git a/runner_test.go b/runner_test.go index 94dbf50..84b85b5 100644 --- a/runner_test.go +++ b/runner_test.go @@ -51,6 +51,12 @@ func TestRunner_RunSequential(t *testing.T) { assert.Equal(t, 1, result.Passed) assert.Equal(t, 1, result.Failed) assert.Equal(t, 1, result.Skipped) + require.Len(t, result.Results, 3) + assert.Equal(t, 0, result.Results[0].ExitCode) + assert.NoError(t, result.Results[0].Error) + assert.Equal(t, 1, result.Results[1].ExitCode) + assert.NoError(t, result.Results[1].Error) + assert.True(t, result.Results[2].Skipped) }) t.Run("allow failure continues", func(t *testing.T) { From 720104babc651baf15ead9eb919584bfcef1e0de Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:37:50 +0000 Subject: [PATCH 91/97] feat(process): validate runner dependencies --- runner.go | 17 +++++++++++++++++ runner_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/runner.go b/runner.go index 72e23f0..9ff5835 100644 --- a/runner.go +++ b/runner.go @@ -19,6 +19,9 @@ var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) // ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated. var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil) +// ErrRunnerInvalidDependencyName is returned when a RunSpec dependency name is empty, duplicated, or self-referential. +var ErrRunnerInvalidDependencyName = coreerr.E("", "runner dependency names must be non-empty, unique, and not self-referential", nil) + // ErrRunnerContextRequired is returned when a runner method is called without a context. var ErrRunnerContextRequired = coreerr.E("", "runner context is required", nil) @@ -399,6 +402,20 @@ func validateSpecs(specs []RunSpec) error { return coreerr.E("Runner.validateSpecs", "runner spec name is duplicated", ErrRunnerInvalidSpecName) } seen[spec.Name] = struct{}{} + + deps := make(map[string]struct{}, len(spec.After)) + for _, dep := range spec.After { + if dep == "" { + return coreerr.E("Runner.validateSpecs", "runner dependency name is required", ErrRunnerInvalidDependencyName) + } + if dep == spec.Name { + return coreerr.E("Runner.validateSpecs", "runner dependency cannot reference itself", ErrRunnerInvalidDependencyName) + } + if _, ok := deps[dep]; ok { + return coreerr.E("Runner.validateSpecs", "runner dependency name is duplicated", ErrRunnerInvalidDependencyName) + } + deps[dep] = struct{}{} + } } return nil } diff --git a/runner_test.go b/runner_test.go index 84b85b5..f27e078 100644 --- a/runner_test.go +++ b/runner_test.go @@ -327,6 +327,30 @@ func TestRunner_InvalidSpecNames(t *testing.T) { assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) }) + t.Run("rejects empty dependency names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{""}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + + t.Run("rejects duplicated dependency names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"two", "two"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + + t.Run("rejects self dependency", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"one"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + t.Run("rejects duplicate names", func(t *testing.T) { _, err := runner.RunAll(context.Background(), []RunSpec{ {Name: "same", Command: "echo", Args: []string{"a"}}, From cf9291d09544fc39093ee8ebed59a5a8680bf998 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:41:05 +0000 Subject: [PATCH 92/97] feat(process): add wait API endpoint --- pkg/api/provider.go | 45 ++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index b2a92fa..4d96b2e 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -98,6 +98,7 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/processes", p.listProcesses) rg.GET("/processes/:id", p.getProcess) rg.GET("/processes/:id/output", p.getProcessOutput) + rg.POST("/processes/:id/wait", p.waitProcess) rg.POST("/processes/:id/input", p.inputProcess) rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) rg.POST("/processes/:id/kill", p.killProcess) @@ -231,6 +232,28 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { "type": "string", }, }, + { + Method: "POST", + Path: "/processes/:id/wait", + Summary: "Wait for a managed process", + Description: "Blocks until the process exits and returns the final process snapshot. Non-zero exits include the snapshot in the error details payload.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, { Method: "POST", Path: "/processes/:id/input", @@ -456,6 +479,28 @@ func (p *ProcessProvider) getProcessOutput(c *gin.Context) { c.JSON(http.StatusOK, api.OK(output)) } +func (p *ProcessProvider) waitProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + info, err := p.service.Wait(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + switch { + case err == process.ErrProcessNotFound: + status = http.StatusNotFound + case info.Status == process.StatusExited || info.Status == process.StatusKilled: + status = http.StatusConflict + } + c.JSON(status, api.FailWithDetails("wait_failed", err.Error(), info)) + return + } + + c.JSON(http.StatusOK, api.OK(info)) +} + type processInputRequest struct { Input string `json:"input"` } diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index d5a1541..b8e9920 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -320,6 +320,60 @@ func TestProcessProvider_GetProcessOutput_Good(t *testing.T) { assert.Contains(t, resp.Data, "output-check") } +func TestProcessProvider_WaitProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "wait-check") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, proc.ID, resp.Data.ID) + assert.Equal(t, process.StatusExited, resp.Data.Status) + assert.Equal(t, 0, resp.Data.ExitCode) +} + +func TestProcessProvider_WaitProcess_NonZeroExit_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + + var resp goapi.Response[any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.False(t, resp.Success) + require.NotNil(t, resp.Error) + assert.Equal(t, "wait_failed", resp.Error.Code) + assert.Contains(t, resp.Error.Message, "process exited with code 7") + + details, ok := resp.Error.Details.(map[string]any) + require.True(t, ok) + assert.Equal(t, "exited", details["status"]) + assert.Equal(t, float64(7), details["exitCode"]) + assert.Equal(t, proc.ID, details["id"]) +} + func TestProcessProvider_InputAndCloseStdin_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "cat") @@ -484,6 +538,7 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { "/api/process/processes", "/api/process/processes/anything", "/api/process/processes/anything/output", + "/api/process/processes/anything/wait", "/api/process/processes/anything/input", "/api/process/processes/anything/close-stdin", "/api/process/processes/anything/kill", @@ -494,6 +549,7 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { method := "GET" switch { case strings.HasSuffix(path, "/kill"), + strings.HasSuffix(path, "/wait"), strings.HasSuffix(path, "/input"), strings.HasSuffix(path, "/close-stdin"): method = "POST" From f9537fb24d61ab525235fcdcd4b48033ae9cafa6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:44:37 +0000 Subject: [PATCH 93/97] feat(api): add process signal endpoint --- pkg/api/provider.go | 90 ++++++++++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 57 ++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 4d96b2e..46cba47 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -17,6 +17,7 @@ import ( "dappco.re/go/core" "dappco.re/go/core/api" "dappco.re/go/core/api/pkg/provider" + coreerr "dappco.re/go/core/log" process "dappco.re/go/core/process" "dappco.re/go/core/ws" "github.com/gin-gonic/gin" @@ -102,6 +103,7 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.POST("/processes/:id/input", p.inputProcess) rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) rg.POST("/processes/:id/kill", p.killProcess) + rg.POST("/processes/:id/signal", p.signalProcess) rg.POST("/pipelines/run", p.runPipeline) } @@ -300,6 +302,26 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "POST", + Path: "/processes/:id/signal", + Summary: "Signal a managed process", + Description: "Sends a Unix signal to the managed process identified by ID.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "signal": map[string]any{"type": "string"}, + }, + "required": []string{"signal"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "signalled": map[string]any{"type": "boolean"}, + }, + }, + }, { Method: "POST", Path: "/pipelines/run", @@ -565,6 +587,40 @@ func (p *ProcessProvider) killProcess(c *gin.Context) { c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) } +type processSignalRequest struct { + Signal string `json:"signal"` +} + +func (p *ProcessProvider) signalProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req processSignalRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + sig, err := parseSignal(req.Signal) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_signal", err.Error())) + return + } + + if err := p.service.Signal(c.Param("id"), sig); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("signal_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true})) +} + type pipelineRunRequest struct { Mode string `json:"mode"` Specs []process.RunSpec `json:"specs"` @@ -664,6 +720,40 @@ func intParam(c *gin.Context, name string) int { return v } +func parseSignal(value string) (syscall.Signal, error) { + trimmed := strings.TrimSpace(strings.ToUpper(value)) + if trimmed == "" { + return 0, coreerr.E("ProcessProvider.parseSignal", "signal is required", nil) + } + + if n, err := strconv.Atoi(trimmed); err == nil { + return syscall.Signal(n), nil + } + + switch trimmed { + case "SIGTERM", "TERM": + return syscall.SIGTERM, nil + case "SIGKILL", "KILL": + return syscall.SIGKILL, nil + case "SIGINT", "INT": + return syscall.SIGINT, nil + case "SIGQUIT", "QUIT": + return syscall.SIGQUIT, nil + case "SIGHUP", "HUP": + return syscall.SIGHUP, nil + case "SIGSTOP", "STOP": + return syscall.SIGSTOP, nil + case "SIGCONT", "CONT": + return syscall.SIGCONT, nil + case "SIGUSR1", "USR1": + return syscall.SIGUSR1, nil + case "SIGUSR2", "USR2": + return syscall.SIGUSR2, nil + default: + return 0, coreerr.E("ProcessProvider.parseSignal", "unsupported signal", nil) + } +} + func (p *ProcessProvider) registerProcessEvents() { if p == nil || p.hub == nil || p.service == nil { return diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index b8e9920..9fc6bfe 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -59,13 +59,17 @@ func TestProcessProvider_Describe_Good(t *testing.T) { } foundPipelineRoute := false + foundSignalRoute := false for _, d := range descs { if d.Method == "POST" && d.Path == "/pipelines/run" { foundPipelineRoute = true - break + } + if d.Method == "POST" && d.Path == "/processes/:id/signal" { + foundSignalRoute = true } } assert.True(t, foundPipelineRoute, "pipeline route should be described") + assert.True(t, foundSignalRoute, "signal route should be described") } func TestProcessProvider_ListDaemons_Good(t *testing.T) { @@ -438,6 +442,57 @@ func TestProcessProvider_KillProcess_Good(t *testing.T) { assert.Equal(t, process.StatusKilled, proc.Status) } +func TestProcessProvider_SignalProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["signalled"]) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been signalled") + } + assert.Equal(t, process.StatusKilled, proc.Status) +} + +func TestProcessProvider_SignalProcess_InvalidSignal_Bad(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"NOPE"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.True(t, proc.IsRunning()) + + require.NoError(t, svc.Kill(proc.ID)) + <-proc.Done() +} + func TestProcessProvider_BroadcastsProcessEvents_Good(t *testing.T) { svc := newTestProcessService(t) hub := corews.NewHub() From 56bc171add490c9fb8f504b38097ae68ee726928 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:48:31 +0000 Subject: [PATCH 94/97] feat(process): add running-only process listing --- pkg/api/provider.go | 5 ++++- pkg/api/provider_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 46cba47..7717458 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -181,7 +181,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { Method: "GET", Path: "/processes", Summary: "List managed processes", - Description: "Returns the current process service snapshot as serialisable process info entries.", + Description: "Returns the current process service snapshot as serialisable process info entries. Pass runningOnly=true to limit results to active processes.", Tags: []string{"process"}, Response: map[string]any{ "type": "array", @@ -459,6 +459,9 @@ func (p *ProcessProvider) listProcesses(c *gin.Context) { } procs := p.service.List() + if runningOnly, _ := strconv.ParseBool(c.Query("runningOnly")); runningOnly { + procs = p.service.Running() + } infos := make([]process.Info, 0, len(procs)) for _, proc := range procs { infos = append(infos, proc.Info()) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 9fc6bfe..165910b 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -277,6 +277,38 @@ func TestProcessProvider_ListProcesses_Good(t *testing.T) { assert.Equal(t, "echo", resp.Data[0].Command) } +func TestProcessProvider_ListProcesses_RunningOnly_Good(t *testing.T) { + svc := newTestProcessService(t) + + runningProc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + exitedProc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-exitedProc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes?runningOnly=true", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + require.Len(t, resp.Data, 1) + assert.Equal(t, runningProc.ID, resp.Data[0].ID) + assert.Equal(t, process.StatusRunning, resp.Data[0].Status) + + require.NoError(t, svc.Kill(runningProc.ID)) + <-runningProc.Done() +} + func TestProcessProvider_GetProcess_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "echo", "single") From a7cde26b9ba2e9224bb2ca320c3866de9c401e89 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 07:52:42 +0000 Subject: [PATCH 95/97] feat(api): allow pid targeting for process controls --- pkg/api/provider.go | 34 +++++++++++++-- pkg/api/provider_test.go | 91 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 7717458..1e5442e 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -293,7 +293,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { Method: "POST", Path: "/processes/:id/kill", Summary: "Kill a managed process", - Description: "Sends SIGKILL to the managed process identified by ID.", + Description: "Sends SIGKILL to the managed process identified by ID, or to a raw OS PID when the path value is numeric.", Tags: []string{"process"}, Response: map[string]any{ "type": "object", @@ -306,7 +306,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { Method: "POST", Path: "/processes/:id/signal", Summary: "Signal a managed process", - Description: "Sends a Unix signal to the managed process identified by ID.", + Description: "Sends a Unix signal to the managed process identified by ID, or to a raw OS PID when the path value is numeric.", Tags: []string{"process"}, RequestBody: map[string]any{ "type": "object", @@ -578,7 +578,16 @@ func (p *ProcessProvider) killProcess(c *gin.Context) { return } - if err := p.service.Kill(c.Param("id")); err != nil { + id := c.Param("id") + if err := p.service.Kill(id); err != nil { + if pid, ok := pidFromString(id); ok { + if pidErr := p.service.KillPID(pid); pidErr == nil { + c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) + return + } else { + err = pidErr + } + } status := http.StatusInternalServerError if err == process.ErrProcessNotFound { status = http.StatusNotFound @@ -612,7 +621,16 @@ func (p *ProcessProvider) signalProcess(c *gin.Context) { return } - if err := p.service.Signal(c.Param("id"), sig); err != nil { + id := c.Param("id") + if err := p.service.Signal(id, sig); err != nil { + if pid, ok := pidFromString(id); ok { + if pidErr := p.service.SignalPID(pid, sig); pidErr == nil { + c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true})) + return + } else { + err = pidErr + } + } status := http.StatusInternalServerError if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning { status = http.StatusNotFound @@ -723,6 +741,14 @@ func intParam(c *gin.Context, name string) int { return v } +func pidFromString(value string) (int, bool) { + pid, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || pid <= 0 { + return 0, false + } + return pid, true +} + func parseSignal(value string) (syscall.Signal, error) { trimmed := strings.TrimSpace(strings.ToUpper(value)) if trimmed == "" { diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 165910b..53cae02 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" + "strconv" "strings" "testing" "time" @@ -474,6 +476,50 @@ func TestProcessProvider_KillProcess_Good(t *testing.T) { assert.Equal(t, process.StatusKilled, proc.Status) } +func TestProcessProvider_KillProcess_ByPID_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/kill", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["killed"]) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("unmanaged process should have been killed by PID") + } +} + func TestProcessProvider_SignalProcess_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "sleep", "60") @@ -504,6 +550,51 @@ func TestProcessProvider_SignalProcess_Good(t *testing.T) { assert.Equal(t, process.StatusKilled, proc.Status) } +func TestProcessProvider_SignalProcess_ByPID_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["signalled"]) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("unmanaged process should have been signalled by PID") + } +} + func TestProcessProvider_SignalProcess_InvalidSignal_Bad(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "sleep", "60") From 3dd65af0a55374879acbd8863cb7931305a71b49 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 08:02:33 +0000 Subject: [PATCH 96/97] feat(api): add process start and run endpoints --- pkg/api/provider.go | 136 +++++++++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 64 ++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 1e5442e..f0b48ef 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -97,6 +97,8 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon) rg.GET("/daemons/:code/:daemon/health", p.healthCheck) rg.GET("/processes", p.listProcesses) + rg.POST("/processes", p.startProcess) + rg.POST("/processes/run", p.runProcess) rg.GET("/processes/:id", p.getProcess) rg.GET("/processes/:id/output", p.getProcessOutput) rg.POST("/processes/:id/wait", p.waitProcess) @@ -202,6 +204,68 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "POST", + Path: "/processes", + Summary: "Start a managed process", + Description: "Starts a process asynchronously and returns its initial snapshot immediately.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "env": map[string]any{"type": "array"}, + "disableCapture": map[string]any{"type": "boolean"}, + "detach": map[string]any{"type": "boolean"}, + "timeout": map[string]any{"type": "integer"}, + "gracePeriod": map[string]any{"type": "integer"}, + "killGroup": map[string]any{"type": "boolean"}, + }, + "required": []string{"command"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/run", + Summary: "Run a managed process", + Description: "Runs a process synchronously and returns its combined output on success.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "env": map[string]any{"type": "array"}, + "disableCapture": map[string]any{"type": "boolean"}, + "detach": map[string]any{"type": "boolean"}, + "timeout": map[string]any{"type": "integer"}, + "gracePeriod": map[string]any{"type": "integer"}, + "killGroup": map[string]any{"type": "boolean"}, + }, + "required": []string{"command"}, + }, + Response: map[string]any{ + "type": "string", + }, + }, { Method: "GET", Path: "/processes/:id", @@ -470,6 +534,78 @@ func (p *ProcessProvider) listProcesses(c *gin.Context) { c.JSON(http.StatusOK, api.OK(infos)) } +func (p *ProcessProvider) startProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req process.TaskProcessStart + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + if strings.TrimSpace(req.Command) == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) + return + } + + proc, err := p.service.StartWithOptions(c.Request.Context(), process.RunOptions{ + Command: req.Command, + Args: req.Args, + Dir: req.Dir, + Env: req.Env, + DisableCapture: req.DisableCapture, + Detach: req.Detach, + Timeout: req.Timeout, + GracePeriod: req.GracePeriod, + KillGroup: req.KillGroup, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("start_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(proc.Info())) +} + +func (p *ProcessProvider) runProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req process.TaskProcessRun + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + if strings.TrimSpace(req.Command) == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) + return + } + + output, err := p.service.RunWithOptions(c.Request.Context(), process.RunOptions{ + Command: req.Command, + Args: req.Args, + Dir: req.Dir, + Env: req.Env, + DisableCapture: req.DisableCapture, + Detach: req.Detach, + Timeout: req.Timeout, + GracePeriod: req.GracePeriod, + KillGroup: req.KillGroup, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.FailWithDetails("run_failed", err.Error(), map[string]any{ + "output": output, + })) + return + } + + c.JSON(http.StatusOK, api.OK(output)) +} + func (p *ProcessProvider) getProcess(c *gin.Context) { if p.service == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 53cae02..a9331d0 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -311,6 +311,70 @@ func TestProcessProvider_ListProcesses_RunningOnly_Good(t *testing.T) { <-runningProc.Done() } +func TestProcessProvider_StartProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + body := strings.NewReader(`{ + "command": "sleep", + "args": ["60"], + "detach": true, + "killGroup": true + }`) + req, err := http.NewRequest("POST", "/api/process/processes", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, "sleep", resp.Data.Command) + assert.Equal(t, process.StatusRunning, resp.Data.Status) + assert.True(t, resp.Data.Running) + assert.NotEmpty(t, resp.Data.ID) + + managed, err := svc.Get(resp.Data.ID) + require.NoError(t, err) + require.NoError(t, svc.Kill(managed.ID)) + select { + case <-managed.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been killed after start test") + } +} + +func TestProcessProvider_RunProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + body := strings.NewReader(`{ + "command": "echo", + "args": ["run-check"] + }`) + req, err := http.NewRequest("POST", "/api/process/processes/run", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[string] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Contains(t, resp.Data, "run-check") +} + func TestProcessProvider_GetProcess_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "echo", "single") From 1e536f1a7cc6a699ff98f71681ebfb68904ad814 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 08:11:19 +0000 Subject: [PATCH 97/97] feat(ui): expose process control client methods --- pkg/api/ui/dist/core-process.js | 161 ++++++++++++++++++++------------ ui/src/shared/api.ts | 70 +++++++++++++- 2 files changed, 170 insertions(+), 61 deletions(-) diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index 93201bf..b9711a6 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -3,7 +3,7 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const J = globalThis, ie = J.ShadowRoot && (J.ShadyCSS === void 0 || J.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), le = /* @__PURE__ */ new WeakMap(); +const V = globalThis, ie = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), le = /* @__PURE__ */ new WeakMap(); let ve = class { constructor(e, t, i) { if (this._$cssResult$ = !0, i !== re) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); @@ -22,33 +22,33 @@ let ve = class { return this.cssText; } }; -const Ae = (s) => new ve(typeof s == "string" ? s : s + "", void 0, re), F = (s, ...e) => { +const ke = (s) => new ve(typeof s == "string" ? s : s + "", void 0, re), F = (s, ...e) => { const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => { if (o._$cssResult$ === !0) return o.cssText; if (typeof o == "number") return o; throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + s[n + 1], s[0]); return new ve(t, s, re); -}, Se = (s, e) => { +}, Ae = (s, e) => { if (ie) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const t of e) { - const i = document.createElement("style"), r = J.litNonce; + const i = document.createElement("style"), r = V.litNonce; r !== void 0 && i.setAttribute("nonce", r), i.textContent = t.cssText, s.appendChild(i); } }, ce = ie ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { let t = ""; for (const i of e.cssRules) t += i.cssText; - return Ae(t); + return ke(t); })(s) : s; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: ze } = Object, A = globalThis, de = A.trustedTypes, Te = de ? de.emptyScript : "", Y = A.reactiveElementPolyfillSupport, j = (s, e) => s, Z = { toAttribute(s, e) { +const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: Te } = Object, k = globalThis, de = k.trustedTypes, ze = de ? de.emptyScript : "", Y = k.reactiveElementPolyfillSupport, j = (s, e) => s, Z = { toAttribute(s, e) { switch (e) { case Boolean: - s = s ? Te : null; + s = s ? ze : null; break; case Object: case Array: @@ -74,8 +74,8 @@ const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnProperty } return t; } }, oe = (s, e) => !Pe(s, e), he = { attribute: !0, type: String, converter: Z, reflect: !1, useDefault: !1, hasChanged: oe }; -Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); -let T = class extends HTMLElement { +Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), k.litPropertyMetadata ?? (k.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); +let z = class extends HTMLElement { static addInitializer(e) { this._$Ei(), (this.l ?? (this.l = [])).push(e); } @@ -104,7 +104,7 @@ let T = class extends HTMLElement { } static _$Ei() { if (this.hasOwnProperty(j("elementProperties"))) return; - const e = ze(this); + const e = Te(this); e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties); } static finalize() { @@ -159,7 +159,7 @@ let T = class extends HTMLElement { } createRenderRoot() { const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return Se(e, this.constructor.elementStyles), e; + return Ae(e, this.constructor.elementStyles), e; } connectedCallback() { var e; @@ -278,16 +278,14 @@ let T = class extends HTMLElement { firstUpdated(e) { } }; -T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[j("elementProperties")] = /* @__PURE__ */ new Map(), T[j("finalized")] = /* @__PURE__ */ new Map(), Y == null || Y({ ReactiveElement: T }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); +z.elementStyles = [], z.shadowRootOptions = { mode: "open" }, z[j("elementProperties")] = /* @__PURE__ */ new Map(), z[j("finalized")] = /* @__PURE__ */ new Map(), Y == null || Y({ ReactiveElement: z }), (k.reactiveElementVersions ?? (k.reactiveElementVersions = [])).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const N = globalThis, pe = (s) => s, G = N.trustedTypes, ue = G ? G.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, we = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + k, De = `<${_e}>`, O = document, I = () => O.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = `[ -\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, me = /-->/g, fe = />/g, C = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), ge = /'/g, be = /"/g, xe = /^(?:script|style|textarea|title)$/i, Re = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Re(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), $e = /* @__PURE__ */ new WeakMap(), E = O.createTreeWalker(O, 129); -function ke(s, e) { +const N = globalThis, pe = (s) => s, G = N.trustedTypes, ue = G ? G.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, we = "$lit$", S = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + S, De = `<${_e}>`, O = document, I = () => O.createComment(""), q = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = "[ \\t\\n\\f\\r]", H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, me = /-->/g, fe = />/g, C = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ \\t\\n\\f\\r"'\`<>=]|("|')|))|$)`, "g"), ge = /'/g, be = /"/g, xe = /^(?:script|style|textarea|title)$/i, Re = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Re(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), $e = /* @__PURE__ */ new WeakMap(), E = O.createTreeWalker(O, 129); +function Se(s, e) { if (!ne(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); return ue !== void 0 ? ue.createHTML(e) : e; } @@ -299,28 +297,28 @@ const He = (s, e) => { let p, m, h = -1, y = 0; for (; y < a.length && (o.lastIndex = y, m = o.exec(a), m !== null); ) y = o.lastIndex, o === H ? m[1] === "!--" ? o = me : m[1] !== void 0 ? o = fe : m[2] !== void 0 ? (xe.test(m[2]) && (r = RegExp("" ? (o = r ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? C : m[3] === '"' ? be : ge) : o === be || o === ge ? o = C : o === me || o === fe ? o = H : (o = C, r = void 0); const x = o === C && s[l + 1].startsWith("/>") ? " " : ""; - n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + we + a.slice(h) + k + x) : a + k + (h === -2 ? l : x); + n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + we + a.slice(h) + S + x) : a + S + (h === -2 ? l : x); } - return [ke(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; + return [Se(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; }; -class q { +class L { constructor({ strings: e, _$litType$: t }, i) { let r; this.parts = []; let n = 0, o = 0; const l = e.length - 1, a = this.parts, [p, m] = He(e, t); - if (this.el = q.createElement(p, i), E.currentNode = this.el.content, t === 2 || t === 3) { + if (this.el = L.createElement(p, i), E.currentNode = this.el.content, t === 2 || t === 3) { const h = this.el.content.firstChild; h.replaceWith(...h.childNodes); } for (; (r = E.nextNode()) !== null && a.length < l; ) { if (r.nodeType === 1) { if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(we)) { - const y = m[o++], x = r.getAttribute(h).split(k), V = /([.?@])?(.*)/.exec(y); - a.push({ type: 1, index: n, name: V[2], strings: x, ctor: V[1] === "." ? Ne : V[1] === "?" ? Ie : V[1] === "@" ? Le : Q }), r.removeAttribute(h); - } else h.startsWith(k) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); + const y = m[o++], x = r.getAttribute(h).split(S), J = /([.?@])?(.*)/.exec(y); + a.push({ type: 1, index: n, name: J[2], strings: x, ctor: J[1] === "." ? Ne : J[1] === "?" ? Ie : J[1] === "@" ? qe : Q }), r.removeAttribute(h); + } else h.startsWith(S) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); if (xe.test(r.tagName)) { - const h = r.textContent.split(k), y = h.length - 1; + const h = r.textContent.split(S), y = h.length - 1; if (y > 0) { r.textContent = G ? G.emptyScript : ""; for (let x = 0; x < y; x++) r.append(h[x], I()), E.nextNode(), a.push({ type: 2, index: ++n }); @@ -330,7 +328,7 @@ class q { } else if (r.nodeType === 8) if (r.data === _e) a.push({ type: 2, index: n }); else { let h = -1; - for (; (h = r.data.indexOf(k, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += k.length - 1; + for (; (h = r.data.indexOf(S, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += S.length - 1; } n++; } @@ -344,7 +342,7 @@ function M(s, e, t = s, i) { var o, l; if (e === D) return e; let r = i !== void 0 ? (o = t._$Co) == null ? void 0 : o[i] : t._$Cl; - const n = L(e) ? void 0 : e._$litDirective$; + const n = q(e) ? void 0 : e._$litDirective$; return (r == null ? void 0 : r.constructor) !== n && ((l = r == null ? void 0 : r._$AO) == null || l.call(r, !1), n === void 0 ? r = void 0 : (r = new n(s), r._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = r : t._$Cl = r), r !== void 0 && (e = M(s, r._$AS(s, e.values), r, i)), e; } class je { @@ -364,7 +362,7 @@ class je { for (; a !== void 0; ) { if (o === a.index) { let p; - a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new qe(n, this, e)), this._$AV.push(p), a = i[++l]; + a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l]; } o !== (a == null ? void 0 : a.index) && (n = E.nextNode(), o++); } @@ -395,7 +393,7 @@ class W { return this._$AB; } _$AI(e, t = this) { - e = M(this, e, t), L(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== D && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Me(e) ? this.k(e) : this._(e); + e = M(this, e, t), q(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== D && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Me(e) ? this.k(e) : this._(e); } O(e) { return this._$AA.parentNode.insertBefore(e, this._$AB); @@ -404,11 +402,11 @@ class W { this._$AH !== e && (this._$AR(), this._$AH = this.O(e)); } _(e) { - this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(O.createTextNode(e)), this._$AH = e; + this._$AH !== d && q(this._$AH) ? this._$AA.nextSibling.data = e : this.T(O.createTextNode(e)), this._$AH = e; } $(e) { var n; - const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(ke(i.h, i.h[0]), this.options)), i); + const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = L.createElement(Se(i.h, i.h[0]), this.options)), i); if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t); else { const o = new je(r, this), l = o.u(this.options); @@ -417,7 +415,7 @@ class W { } _$AC(e) { let t = $e.get(e.strings); - return t === void 0 && $e.set(e.strings, t = new q(e)), t; + return t === void 0 && $e.set(e.strings, t = new L(e)), t; } k(e) { ne(this._$AH) || (this._$AH = [], this._$AR()); @@ -451,11 +449,11 @@ class Q { _$AI(e, t = this, i, r) { const n = this.strings; let o = !1; - if (n === void 0) e = M(this, e, t, 0), o = !L(e) || e !== this._$AH && e !== D, o && (this._$AH = e); + if (n === void 0) e = M(this, e, t, 0), o = !q(e) || e !== this._$AH && e !== D, o && (this._$AH = e); else { const l = e; let a, p; - for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === D && (p = this._$AH[a]), o || (o = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; + for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === D && (p = this._$AH[a]), o || (o = !q(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; } o && !r && this.j(e); } @@ -479,7 +477,7 @@ class Ie extends Q { this.element.toggleAttribute(this.name, !!e && e !== d); } } -class Le extends Q { +class qe extends Q { constructor(e, t, i, r, n) { super(e, t, i, r, n), this.type = 5; } @@ -493,7 +491,7 @@ class Le extends Q { typeof this._$AH == "function" ? this._$AH.call(((t = this.options) == null ? void 0 : t.host) ?? this.element, e) : this._$AH.handleEvent(e); } } -class qe { +class Le { constructor(e, t, i) { this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = i; } @@ -505,7 +503,7 @@ class qe { } } const te = N.litHtmlPolyfillSupport; -te == null || te(q, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); +te == null || te(L, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); const Be = (s, e, t) => { const i = (t == null ? void 0 : t.renderBefore) ?? e; let r = i._$litPart$; @@ -521,7 +519,7 @@ const Be = (s, e, t) => { * SPDX-License-Identifier: BSD-3-Clause */ const U = globalThis; -class v extends T { +class v extends z { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -645,8 +643,9 @@ class B { return this.request(`/daemons/${e}/${t}/health`); } /** List all managed processes. */ - listProcesses() { - return this.request("/processes"); + listProcesses(e = !1) { + const t = e ? "?runningOnly=true" : ""; + return this.request(`/processes${t}`); } /** Get a single managed process by ID. */ getProcess(e) { @@ -656,12 +655,56 @@ class B { getProcessOutput(e) { return this.request(`/processes/${e}/output`); } + /** Start a managed process asynchronously. */ + startProcess(e) { + return this.request("/processes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(e) + }); + } + /** Run a managed process synchronously and return its combined output. */ + runProcess(e) { + return this.request("/processes/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(e) + }); + } + /** Wait for a managed process to exit and return its final snapshot. */ + waitProcess(e) { + return this.request(`/processes/${e}/wait`, { + method: "POST" + }); + } + /** Write input to a managed process stdin pipe. */ + inputProcess(e, t) { + return this.request(`/processes/${e}/input`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: t }) + }); + } + /** Close a managed process stdin pipe. */ + closeProcessStdin(e) { + return this.request(`/processes/${e}/close-stdin`, { + method: "POST" + }); + } /** Kill a managed process by ID. */ killProcess(e) { return this.request(`/processes/${e}/kill`, { method: "POST" }); } + /** Send a signal to a managed process by ID. */ + signalProcess(e, t) { + return this.request(`/processes/${e}/signal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signal: String(t) }) + }); + } /** Run a process pipeline using the configured runner. */ runPipeline(e, t) { return this.request("/pipelines/run", { @@ -671,8 +714,8 @@ class B { }); } } -var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => { - for (var r = i > 1 ? void 0 : i ? Ve(e, t) : e, n = s.length - 1, o; n >= 0; n--) +var Ke = Object.defineProperty, Je = Object.getOwnPropertyDescriptor, A = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? Je(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && Ke(e, t, r), r; }; @@ -949,34 +992,34 @@ b.styles = F` margin-bottom: 1rem; } `; -S([ +A([ f({ attribute: "api-url" }) ], b.prototype, "apiUrl", 2); -S([ +A([ u() ], b.prototype, "daemons", 2); -S([ +A([ u() ], b.prototype, "loading", 2); -S([ +A([ u() ], b.prototype, "error", 2); -S([ +A([ u() ], b.prototype, "stopping", 2); -S([ +A([ u() ], b.prototype, "checking", 2); -S([ +A([ u() ], b.prototype, "healthResults", 2); -b = S([ +b = A([ K("core-process-daemons") ], b); -var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, _ = (s, e, t, i) => { +var Ve = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, _ = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); - return i && r && Je(e, t, r), r; + return i && r && Ve(e, t, r), r; }; let g = class extends v { constructor() { @@ -1858,7 +1901,7 @@ X([ R = X([ K("core-process-runner") ], R); -var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => { +var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, T = (s, e, t, i) => { for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--) (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); return i && r && et(e, t, r), r; @@ -2070,25 +2113,25 @@ w.styles = F` background: #d1d5db; } `; -z([ +T([ f({ attribute: "api-url" }) ], w.prototype, "apiUrl", 2); -z([ +T([ f({ attribute: "ws-url" }) ], w.prototype, "wsUrl", 2); -z([ +T([ u() ], w.prototype, "activeTab", 2); -z([ +T([ u() ], w.prototype, "wsConnected", 2); -z([ +T([ u() ], w.prototype, "lastEvent", 2); -z([ +T([ u() ], w.prototype, "selectedProcessId", 2); -w = z([ +w = T([ K("core-process-panel") ], w); export { diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts index 0d05c87..08021c3 100644 --- a/ui/src/shared/api.ts +++ b/ui/src/shared/api.ts @@ -76,6 +76,21 @@ export interface RunAllResult { success: boolean; } +/** + * Process start and run payload shared by the control endpoints. + */ +export interface ProcessControlRequest { + command: string; + args?: string[]; + dir?: string; + env?: string[]; + disableCapture?: boolean; + detach?: boolean; + timeout?: number; + gracePeriod?: number; + killGroup?: boolean; +} + /** * ProcessApi provides a typed fetch wrapper for the /api/process/* endpoints. */ @@ -118,8 +133,9 @@ export class ProcessApi { } /** List all managed processes. */ - listProcesses(): Promise { - return this.request('/processes'); + listProcesses(runningOnly = false): Promise { + const query = runningOnly ? '?runningOnly=true' : ''; + return this.request(`/processes${query}`); } /** Get a single managed process by ID. */ @@ -132,6 +148,47 @@ export class ProcessApi { return this.request(`/processes/${id}/output`); } + /** Start a managed process asynchronously. */ + startProcess(opts: ProcessControlRequest): Promise { + return this.request('/processes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(opts), + }); + } + + /** Run a managed process synchronously and return its combined output. */ + runProcess(opts: ProcessControlRequest): Promise { + return this.request('/processes/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(opts), + }); + } + + /** Wait for a managed process to exit and return its final snapshot. */ + waitProcess(id: string): Promise { + return this.request(`/processes/${id}/wait`, { + method: 'POST', + }); + } + + /** Write input to a managed process stdin pipe. */ + inputProcess(id: string, input: string): Promise<{ written: boolean }> { + return this.request<{ written: boolean }>(`/processes/${id}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + }); + } + + /** Close a managed process stdin pipe. */ + closeProcessStdin(id: string): Promise<{ closed: boolean }> { + return this.request<{ closed: boolean }>(`/processes/${id}/close-stdin`, { + method: 'POST', + }); + } + /** Kill a managed process by ID. */ killProcess(id: string): Promise<{ killed: boolean }> { return this.request<{ killed: boolean }>(`/processes/${id}/kill`, { @@ -139,6 +196,15 @@ export class ProcessApi { }); } + /** Send a signal to a managed process by ID. */ + signalProcess(id: string, signal: string | number): Promise<{ signalled: boolean }> { + return this.request<{ signalled: boolean }>(`/processes/${id}/signal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signal: String(signal) }), + }); + } + /** Run a process pipeline using the configured runner. */ runPipeline(mode: 'all' | 'sequential' | 'parallel', specs: RunSpec[]): Promise { return this.request('/pipelines/run', {