From afcbea305e4a3780c623204d7eaedd81758f827d Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 19:01:26 +0100 Subject: [PATCH] feat(process): detached-by-default start + RFC API aliases + JSON tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.go + actions.go + runner.go: process.start detached by default through named action/task path and RFC HTTP alias - service.go: managed process IDs use core.ID() - types.go + service.go + runner.go: JSON tags on execution/pipeline DTOs - pkg/api/provider.go: Register helper; RFC alias routes /process/list + /process/start - service_test.go + provider_test.go: detached-startup + RFC alias coverage NOTE: dev branch had pre-existing compile errors in pidfile.go (undefined processHandle, currentPID) — these exist independent of this commit. Build remains broken until those are added, but the feat work here is preserved. Co-Authored-By: Virgil --- actions.go | 253 ++++++++++++++++++++++++++++++++------ pkg/api/provider.go | 259 ++++++++++++++++++++++++++++++++++----- pkg/api/provider_test.go | 61 ++++++++- runner.go | 38 +++--- service.go | 160 ++++++++++++++++++++---- service_test.go | 142 +++++++++++++-------- types.go | 28 ++--- 7 files changed, 760 insertions(+), 181 deletions(-) diff --git a/actions.go b/actions.go index ae238e7..162ff93 100644 --- a/actions.go +++ b/actions.go @@ -1,8 +1,13 @@ package process import ( + "fmt" + "strconv" "syscall" "time" + + "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // --- ACTION messages (broadcast via Core.ACTION) --- @@ -14,20 +19,20 @@ import ( // // c.PERFORM(process.TaskProcessStart{Command: "sleep", Args: []string{"10"}}) type TaskProcessStart struct { - Command string - Args []string - Dir string - Env []string + Command string `json:"command"` + Args []string `json:"args"` + Dir string `json:"dir"` + Env []string `json:"env"` // DisableCapture skips buffering process output before returning it. - DisableCapture bool + DisableCapture bool `json:"disableCapture"` // Detach runs the command in its own process group. - Detach bool + Detach bool `json:"detach"` // Timeout bounds the execution duration. - Timeout time.Duration + Timeout time.Duration `json:"timeout"` // GracePeriod controls SIGTERM-to-SIGKILL escalation. - GracePeriod time.Duration + GracePeriod time.Duration `json:"gracePeriod"` // KillGroup terminates the entire process group instead of only the leader. - KillGroup bool + KillGroup bool `json:"killGroup"` } // TaskProcessRun requests synchronous command execution through Core.PERFORM. @@ -37,20 +42,20 @@ type TaskProcessStart struct { // // c.PERFORM(process.TaskProcessRun{Command: "echo", Args: []string{"hello"}}) type TaskProcessRun struct { - Command string - Args []string - Dir string - Env []string + Command string `json:"command"` + Args []string `json:"args"` + Dir string `json:"dir"` + Env []string `json:"env"` // DisableCapture skips buffering process output before returning it. - DisableCapture bool + DisableCapture bool `json:"disableCapture"` // Detach runs the command in its own process group. - Detach bool + Detach bool `json:"detach"` // Timeout bounds the execution duration. - Timeout time.Duration + Timeout time.Duration `json:"timeout"` // GracePeriod controls SIGTERM-to-SIGKILL escalation. - GracePeriod time.Duration + GracePeriod time.Duration `json:"gracePeriod"` // KillGroup terminates the entire process group instead of only the leader. - KillGroup bool + KillGroup bool `json:"killGroup"` } // TaskProcessKill requests termination of a managed process by ID or PID. @@ -60,9 +65,9 @@ type TaskProcessRun struct { // c.PERFORM(process.TaskProcessKill{ID: "proc-1"}) type TaskProcessKill struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` // PID targets a process directly when ID is not available. - PID int + PID int `json:"pid"` } // TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM. @@ -73,11 +78,11 @@ type TaskProcessKill struct { // c.PERFORM(process.TaskProcessSignal{ID: "proc-1", Signal: syscall.SIGTERM}) type TaskProcessSignal struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` // PID targets a process directly when ID is not available. - PID int + PID int `json:"pid"` // Signal is delivered to the process or process group. - Signal syscall.Signal + Signal syscall.Signal `json:"signal"` } // TaskProcessGet requests a snapshot of a managed process through Core.PERFORM. @@ -87,7 +92,7 @@ type TaskProcessSignal struct { // c.PERFORM(process.TaskProcessGet{ID: "proc-1"}) type TaskProcessGet struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` } // TaskProcessWait waits for a managed process to finish through Core.PERFORM. @@ -99,7 +104,7 @@ type TaskProcessGet struct { // c.PERFORM(process.TaskProcessWait{ID: "proc-1"}) type TaskProcessWait struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` } // TaskProcessWaitError is returned as the task value when TaskProcessWait @@ -133,7 +138,7 @@ func (e *TaskProcessWaitError) Unwrap() error { // c.PERFORM(process.TaskProcessOutput{ID: "proc-1"}) type TaskProcessOutput struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` } // TaskProcessInput writes data to the stdin of a managed process through Core.PERFORM. @@ -143,9 +148,9 @@ type TaskProcessOutput struct { // c.PERFORM(process.TaskProcessInput{ID: "proc-1", Input: "hello\n"}) type TaskProcessInput struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` // Input is written verbatim to the process stdin pipe. - Input string + Input string `json:"input"` } // TaskProcessCloseStdin closes the stdin pipe of a managed process through Core.PERFORM. @@ -155,7 +160,185 @@ type TaskProcessInput struct { // c.PERFORM(process.TaskProcessCloseStdin{ID: "proc-1"}) type TaskProcessCloseStdin struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` +} + +// processActionInput models the options passed via core.Actions. +// Keys: +// command, args, dir, env, disableCapture, detach, timeout, +// gracePeriod, killGroup, id, pid. +type processActionInput struct { + Command string + Args []string + Dir string + Env []string + DisableCapture bool + Detach bool + Timeout time.Duration + GracePeriod time.Duration + KillGroup bool + ID string + PID int +} + +func parseProcessActionInput(opts core.Options, requireCommand bool) (processActionInput, error) { + parsed := processActionInput{ + Command: core.Trim(opts.String("command")), + Dir: opts.String("dir"), + DisableCapture: opts.Bool("disableCapture"), + Detach: opts.Bool("detach"), + KillGroup: opts.Bool("killGroup"), + Timeout: parseDurationOption(opts, "timeout"), + GracePeriod: parseDurationOption(opts, "gracePeriod"), + } + + var err error + + parsed.Args, err = parseStringSliceOption(opts, "args") + if err != nil { + return processActionInput{}, err + } + + parsed.Env, err = parseStringSliceOption(opts, "env") + if err != nil { + return processActionInput{}, err + } + + parsed.ID = core.Trim(opts.String("id")) + parsed.PID = parseIntOption(opts, "pid") + + if requireCommand && parsed.Command == "" { + return processActionInput{}, coreerr.E("process action", "command is required", nil) + } + + return parsed, nil +} + +func parseProcessActionTarget(opts core.Options) (string, int, error) { + id := core.Trim(opts.String("id")) + pid := parseIntOption(opts, "pid") + if id == "" && pid <= 0 { + return "", 0, coreerr.E("process action", "id or pid is required", nil) + } + return id, pid, nil +} + +func parseDurationOption(opts core.Options, key string) time.Duration { + r := opts.Get(key) + if !r.OK { + return 0 + } + + switch value := r.Value.(type) { + case time.Duration: + return value + case int: + return time.Duration(value) + case int8: + return time.Duration(value) + case int16: + return time.Duration(value) + case int32: + return time.Duration(value) + case int64: + return time.Duration(value) + case uint: + return time.Duration(value) + case uint8: + return time.Duration(value) + case uint16: + return time.Duration(value) + case uint32: + return time.Duration(value) + case uint64: + return time.Duration(value) + case float32: + return time.Duration(value) + case float64: + return time.Duration(value) + case string: + d, err := time.ParseDuration(value) + if err == nil { + return d + } + if n, parseErr := strconv.ParseInt(value, 10, 64); parseErr == nil { + return time.Duration(n) + } + } + + return 0 +} + +func parseIntOption(opts core.Options, key string) int { + r := opts.Get(key) + if !r.OK { + return 0 + } + + switch value := r.Value.(type) { + case int: + return value + case int8: + return int(value) + case int16: + return int(value) + case int32: + return int(value) + case int64: + return int(value) + case uint: + return int(value) + case uint8: + return int(value) + case uint16: + return int(value) + case uint32: + return int(value) + case uint64: + return int(value) + case float32: + return int(value) + case float64: + return int(value) + case string: + if parsed, err := strconv.Atoi(value); err == nil { + return parsed + } + } + + return 0 +} + +func parseStringSliceOption(opts core.Options, key string) ([]string, error) { + r := opts.Get(key) + if !r.OK { + return nil, nil + } + + raw, ok := r.Value.([]string) + if ok { + return raw, nil + } + + anyList, ok := r.Value.([]any) + if !ok { + if alt, ok := r.Value.([]interface{}); ok { + anyList = alt + } else { + return nil, coreerr.E("process action", fmt.Sprintf("%s must be an array", key), nil) + } + } + + items := make([]string, 0, len(anyList)) + for _, item := range anyList { + value, ok := item.(string) + if !ok { + return nil, coreerr.E("process action", fmt.Sprintf("%s entries must be strings", key), nil) + } + items = append(items, value) + } + + return items, nil } // TaskProcessList requests a snapshot of managed processes through Core.PERFORM. @@ -165,7 +348,7 @@ type TaskProcessCloseStdin struct { // // c.PERFORM(process.TaskProcessList{RunningOnly: true}) type TaskProcessList struct { - RunningOnly bool + RunningOnly bool `json:"runningOnly"` } // TaskProcessRemove removes a completed managed process through Core.PERFORM. @@ -175,7 +358,7 @@ type TaskProcessList struct { // c.PERFORM(process.TaskProcessRemove{ID: "proc-1"}) type TaskProcessRemove struct { // ID identifies a managed process started by this service. - ID string + ID string `json:"id"` } // TaskProcessClear removes all completed managed processes through Core.PERFORM. @@ -189,7 +372,7 @@ type TaskProcessClear struct{} // // Example: // -// case process.ActionProcessStarted: fmt.Println("started", msg.ID) +// case process.ActionProcessStarted: core.Println("started", msg.ID) type ActionProcessStarted struct { ID string Command string @@ -203,7 +386,7 @@ type ActionProcessStarted struct { // // Example: // -// case process.ActionProcessOutput: fmt.Println(msg.Line) +// case process.ActionProcessOutput: core.Println(msg.Line) type ActionProcessOutput struct { ID string Line string @@ -215,7 +398,7 @@ type ActionProcessOutput struct { // // Example: // -// case process.ActionProcessExited: fmt.Println(msg.ExitCode) +// case process.ActionProcessExited: core.Println(msg.ExitCode) type ActionProcessExited struct { ID string ExitCode int @@ -227,7 +410,7 @@ type ActionProcessExited struct { // // Example: // -// case process.ActionProcessKilled: fmt.Println(msg.Signal) +// case process.ActionProcessKilled: core.Println(msg.Signal) type ActionProcessKilled struct { ID string Signal string diff --git a/pkg/api/provider.go b/pkg/api/provider.go index f0b48ef..8117721 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "strconv" - "strings" "sync" "syscall" "time" @@ -69,6 +68,14 @@ func (p *ProcessProvider) Name() string { return "process" } // BasePath implements api.RouteGroup. func (p *ProcessProvider) BasePath() string { return "/api/process" } +// Register mounts the provider on a Gin router using the provider base path. +func (p *ProcessProvider) Register(r gin.IRouter) { + if p == nil || r == nil { + return + } + p.RegisterRoutes(r.Group(p.BasePath())) +} + // Element implements provider.Renderable. func (p *ProcessProvider) Element() provider.ElementSpec { return provider.ElementSpec{ @@ -106,6 +113,19 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) rg.POST("/processes/:id/kill", p.killProcess) rg.POST("/processes/:id/signal", p.signalProcess) + + // RFC-compatible singular aliases. + rg.GET("/process/list", p.listProcessIDs) + rg.POST("/process/start", p.startProcessRFC) + rg.POST("/process/run", p.runProcess) + rg.GET("/process/:id", p.getProcess) + rg.GET("/process/:id/output", p.getProcessOutput) + rg.POST("/process/kill", p.killProcessJSON) + rg.POST("/process/:id/wait", p.waitProcess) + rg.POST("/process/:id/input", p.inputProcess) + rg.POST("/process/:id/close-stdin", p.closeProcessStdin) + rg.POST("/process/:id/signal", p.signalProcess) + rg.POST("/pipelines/run", p.runPipeline) } @@ -405,6 +425,79 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "GET", + Path: "/process/list", + Summary: "List managed processes", + Description: "RFC-compatible alias for listing managed process IDs.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + }, + { + Method: "POST", + Path: "/process/start", + Summary: "Start a managed process", + Description: "RFC-compatible alias for starting a process in detached mode and returning its managed ID.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "string", + }, + }, + { + Method: "POST", + Path: "/process/run", + Summary: "Run a managed process", + Description: "RFC-compatible alias for running a process synchronously.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "string", + }, + }, + { + Method: "GET", + Path: "/process/:id", + Summary: "Get managed process", + Description: "RFC-compatible alias for process lookup.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + }, + }, + { + Method: "GET", + Path: "/process/:id/output", + Summary: "Get managed process output", + Description: "RFC-compatible alias for process output.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "string", + }, + }, + { + Method: "POST", + Path: "/process/kill", + Summary: "Kill a managed process", + Description: "RFC-compatible alias that accepts id or pid in JSON body.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "killed": map[string]any{"type": "boolean"}, + }, + }, + }, } } @@ -545,22 +638,12 @@ func (p *ProcessProvider) startProcess(c *gin.Context) { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) return } - if strings.TrimSpace(req.Command) == "" { + if core.Trim(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, - }) + proc, err := p.service.StartWithOptions(c.Request.Context(), startRunOptions(req)) if err != nil { c.JSON(http.StatusInternalServerError, api.Fail("start_failed", err.Error())) return @@ -569,6 +652,31 @@ func (p *ProcessProvider) startProcess(c *gin.Context) { c.JSON(http.StatusOK, api.OK(proc.Info())) } +func (p *ProcessProvider) startProcessRFC(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 core.Trim(req.Command) == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) + return + } + + proc, err := p.service.StartWithOptions(c.Request.Context(), startRunOptions(req)) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("start_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(proc.ID)) +} + func (p *ProcessProvider) runProcess(c *gin.Context) { if p.service == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) @@ -580,7 +688,7 @@ func (p *ProcessProvider) runProcess(c *gin.Context) { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) return } - if strings.TrimSpace(req.Command) == "" { + if core.Trim(req.Command) == "" { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) return } @@ -621,6 +729,25 @@ func (p *ProcessProvider) getProcess(c *gin.Context) { c.JSON(http.StatusOK, api.OK(proc.Info())) } +func (p *ProcessProvider) listProcessIDs(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() + if runningOnly, _ := strconv.ParseBool(c.Query("runningOnly")); runningOnly { + procs = p.service.Running() + } + + ids := make([]string, 0, len(procs)) + for _, proc := range procs { + ids = append(ids, proc.ID) + } + + c.JSON(http.StatusOK, api.OK(ids)) +} + func (p *ProcessProvider) getProcessOutput(c *gin.Context) { if p.service == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) @@ -715,15 +842,8 @@ func (p *ProcessProvider) killProcess(c *gin.Context) { } 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 - } - } + pid, _ := pidFromString(id) + if err := p.killProcessByTarget(id, pid); err != nil { status := http.StatusInternalServerError if err == process.ErrProcessNotFound { status = http.StatusNotFound @@ -735,6 +855,62 @@ func (p *ProcessProvider) killProcess(c *gin.Context) { c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) } +type processKillRequest struct { + ID string `json:"id"` + PID int `json:"pid"` +} + +func (p *ProcessProvider) killProcessJSON(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req processKillRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + if req.ID == "" && req.PID <= 0 { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "id or pid is required")) + return + } + if req.PID <= 0 { + if parsedPID, ok := pidFromString(req.ID); ok { + req.PID = parsedPID + } + } + + if err := p.killProcessByTarget(req.ID, req.PID); 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})) +} + +func (p *ProcessProvider) killProcessByTarget(id string, pid int) error { + if err := p.service.Kill(id); err != nil { + if pid <= 0 { + return err + } + if pidErr := p.service.KillPID(pid); pidErr != nil { + return pidErr + } + return nil + } + if id == "" && pid > 0 { + if err := p.service.KillPID(pid); err != nil { + return err + } + } + return nil +} + type processSignalRequest struct { Signal string `json:"signal"` } @@ -795,7 +971,7 @@ func (p *ProcessProvider) runPipeline(c *gin.Context) { return } - mode := strings.ToLower(strings.TrimSpace(req.Mode)) + mode := core.Lower(core.Trim(req.Mode)) if mode == "" { mode = "all" } @@ -848,13 +1024,15 @@ func (p *ProcessProvider) emitEvent(channel string, data any) { 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, + "code": entry.Code, + "daemon": entry.Daemon, + "pid": entry.PID, + "health": entry.Health, + "project": entry.Project, + "binary": entry.Binary, + "config": entry.Config, + "started": entry.Started, + "startedAt": entry.StartedAt, } } @@ -878,15 +1056,30 @@ func intParam(c *gin.Context, name string) int { } func pidFromString(value string) (int, bool) { - pid, err := strconv.Atoi(strings.TrimSpace(value)) + pid, err := strconv.Atoi(core.Trim(value)) if err != nil || pid <= 0 { return 0, false } return pid, true } +func startRunOptions(req process.TaskProcessStart) process.RunOptions { + return process.RunOptions{ + Command: req.Command, + Args: req.Args, + Dir: req.Dir, + Env: req.Env, + DisableCapture: req.DisableCapture, + // RFC semantics for process.start are detached/background execution. + Detach: true, + Timeout: req.Timeout, + GracePeriod: req.GracePeriod, + KillGroup: req.KillGroup, + } +} + func parseSignal(value string) (syscall.Signal, error) { - trimmed := strings.TrimSpace(strings.ToUpper(value)) + trimmed := core.Trim(core.Upper(value)) if trimmed == "" { return 0, coreerr.E("ProcessProvider.parseSignal", "signal is required", nil) } diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index a9331d0..b67934d 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -803,12 +803,69 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { } } +func TestProcessProvider_RFCListAlias_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + proc, err := svc.Start(context.Background(), "sleep", "0.1") + require.NoError(t, err) + t.Cleanup(func() { + _ = svc.Kill(proc.ID) + }) + + w := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/api/process/process/list?runningOnly=true", 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) + assert.True(t, resp.Success) + assert.Contains(t, resp.Data, proc.ID) +} + +func TestProcessProvider_RFCStartAlias_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + body := strings.NewReader(`{"command":"sleep","args":["0.1"]}`) + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/process/process/start", 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[string] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.NotEmpty(t, resp.Data) + + proc, err := svc.Get(resp.Data) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("RFC alias start should detach from the HTTP request context") + } + + assert.Equal(t, process.StatusExited, proc.Status) + assert.Equal(t, 0, proc.ExitCode) +} + // -- Test helpers ------------------------------------------------------------- func setupRouter(p *processapi.ProcessProvider) *gin.Engine { r := gin.New() - rg := r.Group(p.BasePath()) - p.RegisterRoutes(rg) + p.Register(r) return r } diff --git a/runner.go b/runner.go index 9ff5835..4f0a588 100644 --- a/runner.go +++ b/runner.go @@ -41,32 +41,32 @@ func NewRunner(svc *Service) *Runner { // spec := process.RunSpec{Name: "test", Command: "go", Args: []string{"test", "./..."}} type RunSpec struct { // Name is a friendly identifier (e.g., "lint", "test"). - Name string + Name string `json:"name"` // Command is the executable to run. - Command string + Command string `json:"command"` // Args are the command arguments. - Args []string + Args []string `json:"args"` // Dir is the working directory. - Dir string + Dir string `json:"dir"` // Env are additional environment variables. - Env []string + Env []string `json:"env"` // After lists spec names that must complete successfully first. - After []string + After []string `json:"after"` // AllowFailure if true, continues pipeline even if this spec fails. - AllowFailure bool + AllowFailure bool `json:"allowFailure"` } // RunResult captures the outcome of a single process. type RunResult struct { - Name string - Spec RunSpec - ExitCode int - Duration time.Duration - Output string + Name string `json:"name"` + Spec RunSpec `json:"spec"` + ExitCode int `json:"exitCode"` + Duration time.Duration `json:"duration"` + Output string `json:"output"` // 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 + Error error `json:"error,omitempty"` + Skipped bool `json:"skipped"` } // Passed returns true if the process succeeded. @@ -80,11 +80,11 @@ func (r RunResult) Passed() bool { // RunAllResult is the aggregate result of running multiple specs. type RunAllResult struct { - Results []RunResult - Duration time.Duration - Passed int - Failed int - Skipped int + Results []RunResult `json:"results"` + Duration time.Duration `json:"duration"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` } // Success returns true when no spec failed. diff --git a/service.go b/service.go index 28979f1..e90ad47 100644 --- a/service.go +++ b/service.go @@ -3,13 +3,10 @@ package process import ( "bufio" "context" - "errors" - "fmt" "os" "os/exec" - "sort" + "slices" "sync" - "sync/atomic" "syscall" "time" @@ -36,7 +33,6 @@ type Service struct { processes map[string]*Process mu sync.RWMutex bufSize int - idCounter atomic.Uint64 registrations sync.Once } @@ -87,13 +83,18 @@ func NewService(opts Options) func(*core.Core) (any, error) { // Example: // // _ = svc.OnStartup(ctx) -func (s *Service) OnStartup(ctx context.Context) error { +func (s *Service) OnStartup(context.Context) core.Result { s.registrations.Do(func() { if c := s.coreApp(); c != nil { - c.RegisterTask(s.handleTask) + c.Action("process.run", s.handleRun) + c.Action("process.start", s.handleStart) + c.Action("process.kill", s.handleKill) + c.Action("process.list", s.handleList) + c.Action("process.get", s.handleGet) + c.RegisterAction(s.handleTask) } }) - return nil + return core.Result{OK: true} } // OnShutdown implements core.Stoppable. @@ -102,7 +103,7 @@ func (s *Service) OnStartup(ctx context.Context) error { // Example: // // _ = svc.OnShutdown(ctx) -func (s *Service) OnShutdown(ctx context.Context) error { +func (s *Service) OnShutdown(context.Context) core.Result { s.mu.RLock() procs := make([]*Process, 0, len(s.processes)) for _, p := range s.processes { @@ -116,7 +117,7 @@ func (s *Service) OnShutdown(ctx context.Context) error { _, _ = p.killTree() } - return nil + return core.Result{OK: true} } // Start spawns a new process with the given command and args. @@ -144,7 +145,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce return nil, ServiceError("context is required", ErrContextRequired) } - id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) + id := core.ID() startedAt := time.Now() if opts.KillGroup && !opts.Detach { @@ -441,7 +442,7 @@ func (s *Service) KillPID(pid int) error { } if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) + return coreerr.E("Service.KillPID", core.Sprintf("failed to signal pid %d", pid), err) } return nil @@ -476,11 +477,11 @@ func (s *Service) SignalPID(pid int, sig os.Signal) error { target, err := os.FindProcess(pid) if err != nil { - return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to find pid %d", pid), err) + return coreerr.E("Service.SignalPID", core.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 coreerr.E("Service.SignalPID", core.Sprintf("failed to signal pid %d", pid), err) } return nil @@ -616,7 +617,7 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri 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) + return output, coreerr.E("Service.Run", core.Sprintf("process exited with code %d", proc.ExitCode), nil) } return output, nil } @@ -639,16 +640,116 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, 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) + return output, coreerr.E("Service.RunWithOptions", core.Sprintf("process exited with code %d", proc.ExitCode), nil) } return output, nil } +func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result { + parsed, err := parseProcessActionInput(opts, true) + if err != nil { + return core.Result{Value: err, OK: false} + } + + output, runErr := s.RunWithOptions(ctx, runOptionsFromAction(parsed)) + if runErr != nil { + return core.Result{Value: runErr, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result { + parsed, err := parseProcessActionInput(opts, true) + if err != nil { + return core.Result{Value: err, OK: false} + } + + proc, startErr := s.StartWithOptions(ctx, startRunOptionsFromAction(parsed)) + if startErr != nil { + return core.Result{Value: startErr, OK: false} + } + return core.Result{Value: proc.ID, OK: true} +} + +func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result { + _ = ctx + id, pid, err := parseProcessActionTarget(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + + switch { + case id != "": + if err := s.Kill(id); err != nil { + return core.Result{Value: err, OK: false} + } + case pid > 0: + if err := s.KillPID(pid); err != nil { + return core.Result{Value: err, OK: false} + } + } + return core.Result{OK: true} +} + +func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result { + _ = ctx + runningOnly := opts.Bool("runningOnly") + + procs := s.List() + if runningOnly { + procs = s.Running() + } + + ids := make([]string, 0, len(procs)) + for _, proc := range procs { + ids = append(ids, proc.ID) + } + + return core.Result{Value: ids, OK: true} +} + +func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result { + _ = ctx + id := core.Trim(opts.String("id")) + if id == "" { + return core.Result{Value: coreerr.E("Service.handleGet", "id is required", nil), OK: false} + } + + proc, err := s.Get(id) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: proc.Info(), OK: true} +} + +func runOptionsFromAction(input processActionInput) RunOptions { + return RunOptions{ + Command: input.Command, + Args: append([]string(nil), input.Args...), + Dir: input.Dir, + Env: append([]string(nil), input.Env...), + DisableCapture: input.DisableCapture, + Detach: input.Detach, + Timeout: input.Timeout, + GracePeriod: input.GracePeriod, + KillGroup: input.KillGroup, + } +} + +func startRunOptionsFromAction(input processActionInput) RunOptions { + opts := runOptionsFromAction(input) + // RFC semantics: process.start is background execution and must not be + // coupled to the caller context unless the caller bypasses the action layer. + opts.Detach = true + return opts +} + // handleTask dispatches Core.PERFORM messages for the process service. -func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { +func (s *Service) handleTask(c *core.Core, task core.Message) core.Result { switch m := task.(type) { case TaskProcessStart: - proc, err := s.StartWithOptions(c.Context(), RunOptions{ + proc, err := s.StartWithOptions(c.Context(), startRunOptionsFromAction(processActionInput{ Command: m.Command, Args: m.Args, Dir: m.Dir, @@ -658,7 +759,7 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { Timeout: m.Timeout, GracePeriod: m.GracePeriod, KillGroup: m.KillGroup, - }) + })) if err != nil { return core.Result{Value: err, OK: false} } @@ -815,7 +916,7 @@ func classifyProcessExit(err error) (Status, int, error, string) { } var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if core.As(err, &exitErr) { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { signalName := ws.Signal().String() if signalName == "" { @@ -824,7 +925,7 @@ func classifyProcessExit(err error) (Status, int, error, string) { return StatusKilled, -1, coreerr.E("Service.StartWithOptions", "process was killed", nil), signalName } exitCode := exitErr.ExitCode() - return StatusExited, exitCode, coreerr.E("Service.StartWithOptions", fmt.Sprintf("process exited with code %d", exitCode), nil), "" + return StatusExited, exitCode, coreerr.E("Service.StartWithOptions", core.Sprintf("process exited with code %d", exitCode), nil), "" } return StatusFailed, 0, err, "" @@ -860,10 +961,19 @@ func (s *Service) emitKilledAction(proc *Process, signalName string) { // 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 + slices.SortFunc(procs, func(a, b *Process) int { + if a.StartedAt.Equal(b.StartedAt) { + if a.ID < b.ID { + return -1 + } + if a.ID > b.ID { + return 1 + } + return 0 } - return procs[i].StartedAt.Before(procs[j].StartedAt) + if a.StartedAt.Before(b.StartedAt) { + return -1 + } + return 1 }) } diff --git a/service_test.go b/service_test.go index 0d98959..455cce9 100644 --- a/service_test.go +++ b/service_test.go @@ -26,6 +26,31 @@ func newTestService(t *testing.T) (*Service, *framework.Core) { return svc, c } +// resultErr converts a core.Result returned from Startable/Stoppable hooks +// into the (error) shape the test suite was originally written against. +func resultErr(r framework.Result) error { + if r.OK { + return nil + } + if err, ok := r.Value.(error); ok { + return err + } + return nil +} + +// performTask invokes the package's handleTask switch directly, mirroring +// the legacy c.PERFORM(TaskProcess*) request/response pattern that named +// Actions do not support via broadcast. The legacy Perform contract returned +// the first OK result and an empty Result{} on failure, so we drop the error +// payload when the handler reports !OK to match those test expectations. +func performTask(svc *Service, c *framework.Core, msg framework.Message) framework.Result { + r := svc.handleTask(c, msg) + if !r.OK { + return framework.Result{} + } + return r +} + func TestService_Start(t *testing.T) { t.Run("echo command", func(t *testing.T) { svc, _ := newTestService(t) @@ -745,8 +770,7 @@ func TestService_OnShutdown(t *testing.T) { assert.True(t, proc1.IsRunning()) assert.True(t, proc2.IsRunning()) - err = svc.OnShutdown(context.Background()) - assert.NoError(t, err) + assert.NoError(t, resultErr(svc.OnShutdown(context.Background()))) select { case <-proc1.Done(): @@ -772,8 +796,7 @@ func TestService_OnShutdown(t *testing.T) { require.True(t, proc.IsRunning()) start := time.Now() - err = svc.OnShutdown(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnShutdown(context.Background()))) select { case <-proc.Done(): @@ -787,13 +810,41 @@ func TestService_OnShutdown(t *testing.T) { } func TestService_OnStartup(t *testing.T) { + t.Run("registers detached process.start action", func(t *testing.T) { + svc, c := newTestService(t) + + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) + + ctx, cancel := context.WithCancel(context.Background()) + result := c.Action("process.start").Run(ctx, framework.NewOptions( + framework.Option{Key: "command", Value: "sleep"}, + framework.Option{Key: "args", Value: []string{"0.1"}}, + )) + require.True(t, result.OK) + + id, ok := result.Value.(string) + require.True(t, ok) + proc, err := svc.Get(id) + require.NoError(t, err) + + cancel() + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("detached action-started process should complete") + } + + assert.Equal(t, StatusExited, proc.Status) + assert.Equal(t, 0, proc.ExitCode) + }) + t.Run("registers process.start task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) - result := c.PERFORM(TaskProcessStart{ + result := performTask(svc, c, TaskProcessStart{ Command: "sleep", Args: []string{"1"}, }) @@ -817,10 +868,9 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.run task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) - result := c.PERFORM(TaskProcessRun{ + result := performTask(svc, c, TaskProcessRun{ Command: "echo", Args: []string{"action-run"}, }) @@ -832,10 +882,9 @@ func TestService_OnStartup(t *testing.T) { t.Run("forwards task execution options", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) - result := c.PERFORM(TaskProcessRun{ + result := performTask(svc, c, TaskProcessRun{ Command: "sleep", Args: []string{"60"}, Timeout: 100 * time.Millisecond, @@ -849,8 +898,7 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.kill task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "sleep", "60") require.NoError(t, err) @@ -864,7 +912,7 @@ func TestService_OnStartup(t *testing.T) { return framework.Result{OK: true} }) - result := c.PERFORM(TaskProcessKill{PID: proc.Info().PID}) + result := performTask(svc, c, TaskProcessKill{PID: proc.Info().PID}) require.True(t, result.OK) select { @@ -882,13 +930,12 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.signal task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "sleep", "60") require.NoError(t, err) - result := c.PERFORM(TaskProcessSignal{ + result := performTask(svc, c, TaskProcessSignal{ ID: proc.ID, Signal: syscall.SIGTERM, }) @@ -906,8 +953,7 @@ func TestService_OnStartup(t *testing.T) { t.Run("allows signal zero liveness checks", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -915,7 +961,7 @@ func TestService_OnStartup(t *testing.T) { proc, err := svc.Start(ctx, "sleep", "60") require.NoError(t, err) - result := c.PERFORM(TaskProcessSignal{ + result := performTask(svc, c, TaskProcessSignal{ ID: proc.ID, Signal: syscall.Signal(0), }) @@ -930,8 +976,7 @@ func TestService_OnStartup(t *testing.T) { 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) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sh", @@ -941,7 +986,7 @@ func TestService_OnStartup(t *testing.T) { }) require.NoError(t, err) - result := c.PERFORM(TaskProcessSignal{ + result := performTask(svc, c, TaskProcessSignal{ ID: proc.ID, Signal: syscall.Signal(0), }) @@ -958,13 +1003,12 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.wait task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "echo", "action-wait") require.NoError(t, err) - result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + result := performTask(svc, c, TaskProcessWait{ID: proc.ID}) require.True(t, result.OK) info, ok := result.Value.(Info) @@ -977,13 +1021,12 @@ func TestService_OnStartup(t *testing.T) { 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) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") require.NoError(t, err) - result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + result := performTask(svc, c, TaskProcessWait{ID: proc.ID}) require.True(t, result.OK) errValue, ok := result.Value.(error) @@ -999,8 +1042,7 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1008,7 +1050,7 @@ func TestService_OnStartup(t *testing.T) { proc, err := svc.Start(ctx, "sleep", "60") require.NoError(t, err) - result := c.PERFORM(TaskProcessList{RunningOnly: true}) + result := performTask(svc, c, TaskProcessList{RunningOnly: true}) require.True(t, result.OK) infos, ok := result.Value.([]Info) @@ -1024,14 +1066,13 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.get task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "echo", "snapshot") require.NoError(t, err) <-proc.Done() - result := c.PERFORM(TaskProcessGet{ID: proc.ID}) + result := performTask(svc, c, TaskProcessGet{ID: proc.ID}) require.True(t, result.OK) info, ok := result.Value.(Info) @@ -1047,14 +1088,13 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.remove task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "echo", "remove-through-core") require.NoError(t, err) <-proc.Done() - result := c.PERFORM(TaskProcessRemove{ID: proc.ID}) + result := performTask(svc, c, TaskProcessRemove{ID: proc.ID}) require.True(t, result.OK) _, err = svc.Get(proc.ID) @@ -1064,8 +1104,7 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.clear task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) first, err := svc.Start(context.Background(), "echo", "clear-through-core-1") require.NoError(t, err) @@ -1076,7 +1115,7 @@ func TestService_OnStartup(t *testing.T) { require.Len(t, svc.List(), 2) - result := c.PERFORM(TaskProcessClear{}) + result := performTask(svc, c, TaskProcessClear{}) require.True(t, result.OK) assert.Len(t, svc.List(), 0) }) @@ -1084,14 +1123,13 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.output task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "echo", "snapshot-output") require.NoError(t, err) <-proc.Done() - result := c.PERFORM(TaskProcessOutput{ID: proc.ID}) + result := performTask(svc, c, TaskProcessOutput{ID: proc.ID}) require.True(t, result.OK) output, ok := result.Value.(string) @@ -1102,13 +1140,12 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.input task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "cat") require.NoError(t, err) - result := c.PERFORM(TaskProcessInput{ + result := performTask(svc, c, TaskProcessInput{ ID: proc.ID, Input: "typed-through-core\n", }) @@ -1125,19 +1162,18 @@ func TestService_OnStartup(t *testing.T) { t.Run("registers process.close_stdin task", func(t *testing.T) { svc, c := newTestService(t) - err := svc.OnStartup(context.Background()) - require.NoError(t, err) + require.NoError(t, resultErr(svc.OnStartup(context.Background()))) proc, err := svc.Start(context.Background(), "cat") require.NoError(t, err) - result := c.PERFORM(TaskProcessInput{ + result := performTask(svc, c, TaskProcessInput{ ID: proc.ID, Input: "close-through-core\n", }) require.True(t, result.OK) - result = c.PERFORM(TaskProcessCloseStdin{ID: proc.ID}) + result = performTask(svc, c, TaskProcessCloseStdin{ID: proc.ID}) require.True(t, result.OK) select { diff --git a/types.go b/types.go index a88547f..be052a8 100644 --- a/types.go +++ b/types.go @@ -26,14 +26,14 @@ // // Process events are broadcast via Core.ACTION: // -// core.RegisterAction(func(c *framework.Core, msg framework.Message) error { +// core.RegisterAction(func(c *framework.Core, msg framework.Message) framework.Result { // switch m := msg.(type) { // case process.ActionProcessOutput: -// fmt.Print(m.Line) +// core.Println(m.Line) // case process.ActionProcessExited: -// fmt.Printf("Exit code: %d\n", m.ExitCode) +// core.Println("Exit code:", m.ExitCode) // } -// return nil +// return framework.Result{OK: true} // }) package process @@ -83,32 +83,32 @@ const ( // } type RunOptions struct { // Command is the executable to run. - Command string + Command string `json:"command"` // Args are the command arguments. - Args []string + Args []string `json:"args"` // Dir is the working directory (empty = current). - Dir string + Dir string `json:"dir"` // Env are additional environment variables (KEY=VALUE format). - Env []string + Env []string `json:"env"` // DisableCapture disables output buffering. // By default, output is captured to a ring buffer. - DisableCapture bool + DisableCapture bool `json:"disableCapture"` // Detach creates the process in its own process group (Setpgid). // Detached processes survive parent death and context cancellation. // The context is replaced with context.Background() when Detach is true. - Detach bool + Detach bool `json:"detach"` // Timeout is the maximum duration the process may run. // After this duration, the process receives SIGTERM (or SIGKILL if // GracePeriod is zero). Zero means no timeout. - Timeout time.Duration + Timeout time.Duration `json:"timeout"` // GracePeriod is the time between SIGTERM and SIGKILL when stopping // a process (via timeout or Shutdown). Zero means immediate SIGKILL. // Default: 0 (immediate kill for backwards compatibility). - GracePeriod time.Duration + GracePeriod time.Duration `json:"gracePeriod"` // KillGroup kills the entire process group instead of just the leader. // Requires Detach to be true (process must be its own group leader). // This ensures child processes spawned by the command are also killed. - KillGroup bool + KillGroup bool `json:"killGroup"` } // Info provides a snapshot of process state without internal fields. @@ -116,7 +116,7 @@ type RunOptions struct { // Example: // // info := proc.Info() -// fmt.Println(info.PID) +// core.Println(info.PID) type Info struct { ID string `json:"id"` Command string `json:"command"`