[agent/codex] A specs/ folder has been injected into this workspace with R... #9
19 changed files with 1064 additions and 614 deletions
23
daemon.go
23
daemon.go
|
|
@ -5,7 +5,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
|
|
@ -77,7 +77,7 @@ func (d *Daemon) Start() error {
|
|||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return coreerr.E("Daemon.Start", "daemon already running", nil)
|
||||
return core.E("daemon.start", "daemon already running", nil)
|
||||
}
|
||||
|
||||
if d.pid != nil {
|
||||
|
|
@ -105,7 +105,16 @@ func (d *Daemon) Start() error {
|
|||
entry.Health = d.health.Addr()
|
||||
}
|
||||
if err := d.opts.Registry.Register(entry); err != nil {
|
||||
return coreerr.E("Daemon.Start", "registry", err)
|
||||
if d.health != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||
_ = d.health.Stop(shutdownCtx)
|
||||
cancel()
|
||||
}
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
d.running = false
|
||||
return core.E("daemon.start", "registry", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +126,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
d.mu.Lock()
|
||||
if !d.running {
|
||||
d.mu.Unlock()
|
||||
return coreerr.E("Daemon.Run", "daemon not started - call Start() first", nil)
|
||||
return core.E("daemon.run", "daemon not started - call Start() first", nil)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
|
|
@ -143,13 +152,13 @@ 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))
|
||||
errs = append(errs, core.E("daemon.stop", "health server", err))
|
||||
}
|
||||
}
|
||||
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !isNotExist(err) {
|
||||
errs = append(errs, coreerr.E("Daemon.Stop", "pid file", err))
|
||||
errs = append(errs, core.E("daemon.stop", "pid file", err))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +170,7 @@ func (d *Daemon) Stop() error {
|
|||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return coreerr.Join(errs...)
|
||||
return core.ErrorJoin(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
261
global_test.go
261
global_test.go
|
|
@ -1,261 +0,0 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
framework "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGlobal_NotInitialized_Bad(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
assert.Nil(t, Default())
|
||||
|
||||
r := Start(context.Background(), "echo", "test")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = Run(context.Background(), "echo", "test")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
_, err := Get("proc-1")
|
||||
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||
|
||||
assert.Nil(t, List())
|
||||
assert.Nil(t, Running())
|
||||
|
||||
err = Kill("proc-1")
|
||||
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||
|
||||
r = StartWithOptions(context.Background(), RunOptions{Command: "echo"})
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = RunWithOptions(context.Background(), RunOptions{Command: "echo"})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func newGlobalTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
c := framework.New()
|
||||
factory := NewService(Options{})
|
||||
raw, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
return raw.(*Service)
|
||||
}
|
||||
|
||||
func TestGlobal_SetDefault_Good(t *testing.T) {
|
||||
t.Run("sets and retrieves service", func(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
svc := newGlobalTestService(t)
|
||||
err := SetDefault(svc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, svc, Default())
|
||||
})
|
||||
|
||||
t.Run("errors on nil", func(t *testing.T) {
|
||||
err := SetDefault(nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobal_DefaultConcurrent_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
svc := newGlobalTestService(t)
|
||||
err := SetDefault(svc)
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s := Default()
|
||||
assert.NotNil(t, s)
|
||||
assert.Equal(t, svc, s)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestGlobal_SetDefaultConcurrent_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
var services []*Service
|
||||
for i := 0; i < 10; i++ {
|
||||
svc := newGlobalTestService(t)
|
||||
services = append(services, svc)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, svc := range services {
|
||||
wg.Add(1)
|
||||
go func(s *Service) {
|
||||
defer wg.Done()
|
||||
_ = SetDefault(s)
|
||||
}(svc)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
final := Default()
|
||||
assert.NotNil(t, final)
|
||||
|
||||
found := false
|
||||
for _, svc := range services {
|
||||
if svc == final {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Default should be one of the set services")
|
||||
}
|
||||
|
||||
func TestGlobal_ConcurrentOps_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
svc := newGlobalTestService(t)
|
||||
err := SetDefault(svc)
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var processes []*Process
|
||||
var procMu sync.Mutex
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r := Start(context.Background(), "echo", "concurrent")
|
||||
if r.OK {
|
||||
procMu.Lock()
|
||||
processes = append(processes, r.Value.(*Process))
|
||||
procMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = List()
|
||||
_ = Running()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
procMu.Lock()
|
||||
for _, p := range processes {
|
||||
<-p.Done()
|
||||
}
|
||||
procMu.Unlock()
|
||||
|
||||
assert.Len(t, processes, 20)
|
||||
|
||||
var wg2 sync.WaitGroup
|
||||
for _, p := range processes {
|
||||
wg2.Add(1)
|
||||
go func(id string) {
|
||||
defer wg2.Done()
|
||||
got, err := Get(id)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, got)
|
||||
}(p.ID)
|
||||
}
|
||||
wg2.Wait()
|
||||
}
|
||||
|
||||
func TestGlobal_StartWithOptions_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
r := StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"with", "options"},
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
<-proc.Done()
|
||||
assert.Equal(t, 0, proc.ExitCode)
|
||||
assert.Contains(t, proc.Output(), "with options")
|
||||
}
|
||||
|
||||
func TestGlobal_RunWithOptions_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
r := RunWithOptions(context.Background(), RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"run", "options"},
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "run options")
|
||||
}
|
||||
|
||||
func TestGlobal_Running_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
r := Start(ctx, "sleep", "60")
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
running := Running()
|
||||
assert.Len(t, running, 1)
|
||||
assert.Equal(t, proc.ID, running[0].ID)
|
||||
|
||||
cancel()
|
||||
<-proc.Done()
|
||||
|
||||
running = Running()
|
||||
assert.Len(t, running, 0)
|
||||
}
|
||||
4
go.mod
4
go.mod
|
|
@ -3,9 +3,8 @@ module dappco.re/go/core/process
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.5.0
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core/ws v0.3.0
|
||||
forge.lthn.ai/core/api v0.1.5
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
|
|
@ -13,6 +12,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core/log v0.1.0 // indirect
|
||||
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
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
|||
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 v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
|
|
@ -90,7 +89,7 @@ func (h *HealthServer) Start() error {
|
|||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return coreerr.E("HealthServer.Start", "failed to listen on "+h.addr, err)
|
||||
return core.E("health.start", core.Concat("failed to listen on ", h.addr), err)
|
||||
}
|
||||
|
||||
h.listener = listener
|
||||
|
|
|
|||
10
pidfile.go
10
pidfile.go
|
|
@ -7,8 +7,8 @@ import (
|
|||
"sync"
|
||||
"syscall"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// PIDFile manages a process ID file for single-instance enforcement.
|
||||
|
|
@ -33,7 +33,7 @@ func (p *PIDFile) Acquire() error {
|
|||
if err == nil && pid > 0 {
|
||||
if proc, err := processHandle(pid); err == nil {
|
||||
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||
return coreerr.E("PIDFile.Acquire", "another instance is running (PID "+strconv.Itoa(pid)+")", nil)
|
||||
return core.E("pidfile.acquire", core.Concat("another instance is running (PID ", strconv.Itoa(pid), ")"), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,13 +42,13 @@ func (p *PIDFile) Acquire() error {
|
|||
|
||||
if dir := path.Dir(p.path); dir != "." {
|
||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||
return coreerr.E("PIDFile.Acquire", "failed to create PID directory", err)
|
||||
return core.E("pidfile.acquire", "failed to create PID directory", err)
|
||||
}
|
||||
}
|
||||
|
||||
pid := currentPID()
|
||||
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||
return coreerr.E("PIDFile.Acquire", "failed to write PID file", err)
|
||||
return core.E("pidfile.acquire", "failed to write PID file", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -59,7 +59,7 @@ func (p *PIDFile) Release() error {
|
|||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := coreio.Local.Delete(p.path); err != nil {
|
||||
return coreerr.E("PIDFile.Release", "failed to remove PID file", err)
|
||||
return core.E("pidfile.release", "failed to remove PID file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
49
process.go
49
process.go
|
|
@ -7,7 +7,7 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type processStdin interface {
|
||||
|
|
@ -15,8 +15,8 @@ type processStdin interface {
|
|||
Close() error
|
||||
}
|
||||
|
||||
// Process represents a managed external process.
|
||||
type Process struct {
|
||||
// ManagedProcess represents a tracked external process started by the service.
|
||||
type ManagedProcess struct {
|
||||
ID string
|
||||
Command string
|
||||
Args []string
|
||||
|
|
@ -38,8 +38,11 @@ type Process struct {
|
|||
killGroup bool
|
||||
}
|
||||
|
||||
// Process is kept as a compatibility alias for ManagedProcess.
|
||||
type Process = ManagedProcess
|
||||
|
||||
// Info returns a snapshot of process state.
|
||||
func (p *Process) Info() Info {
|
||||
func (p *ManagedProcess) Info() ProcessInfo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
|
|
@ -48,12 +51,13 @@ func (p *Process) Info() Info {
|
|||
pid = p.cmd.Process.Pid
|
||||
}
|
||||
|
||||
return Info{
|
||||
return ProcessInfo{
|
||||
ID: p.ID,
|
||||
Command: p.Command,
|
||||
Args: p.Args,
|
||||
Dir: p.Dir,
|
||||
StartedAt: p.StartedAt,
|
||||
Running: p.Status == StatusRunning,
|
||||
Status: p.Status,
|
||||
ExitCode: p.ExitCode,
|
||||
Duration: p.Duration,
|
||||
|
|
@ -62,7 +66,7 @@ func (p *Process) Info() Info {
|
|||
}
|
||||
|
||||
// Output returns the captured output as a string.
|
||||
func (p *Process) Output() string {
|
||||
func (p *ManagedProcess) Output() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -72,7 +76,7 @@ func (p *Process) Output() string {
|
|||
}
|
||||
|
||||
// OutputBytes returns the captured output as bytes.
|
||||
func (p *Process) OutputBytes() []byte {
|
||||
func (p *ManagedProcess) OutputBytes() []byte {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -82,37 +86,40 @@ func (p *Process) OutputBytes() []byte {
|
|||
}
|
||||
|
||||
// IsRunning returns true if the process is still executing.
|
||||
func (p *Process) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.Status == StatusRunning
|
||||
func (p *ManagedProcess) IsRunning() bool {
|
||||
select {
|
||||
case <-p.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the process exits.
|
||||
func (p *Process) Wait() error {
|
||||
func (p *ManagedProcess) Wait() error {
|
||||
<-p.done
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.Status == StatusFailed {
|
||||
return coreerr.E("Process.Wait", "process failed to start: "+p.ID, nil)
|
||||
return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil)
|
||||
}
|
||||
if p.Status == StatusKilled {
|
||||
return coreerr.E("Process.Wait", "process was killed: "+p.ID, nil)
|
||||
return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil)
|
||||
}
|
||||
if p.ExitCode != 0 {
|
||||
return coreerr.E("Process.Wait", "process exited with code "+strconv.Itoa(p.ExitCode), nil)
|
||||
return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done returns a channel that closes when the process exits.
|
||||
func (p *Process) Done() <-chan struct{} {
|
||||
func (p *ManagedProcess) Done() <-chan struct{} {
|
||||
return p.done
|
||||
}
|
||||
|
||||
// Kill forcefully terminates the process.
|
||||
// If KillGroup is set, kills the entire process group.
|
||||
func (p *Process) Kill() error {
|
||||
func (p *ManagedProcess) Kill() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
@ -134,7 +141,7 @@ 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.
|
||||
func (p *Process) Shutdown() error {
|
||||
func (p *ManagedProcess) Shutdown() error {
|
||||
p.mu.RLock()
|
||||
grace := p.gracePeriod
|
||||
p.mu.RUnlock()
|
||||
|
|
@ -158,7 +165,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()
|
||||
|
||||
|
|
@ -178,7 +185,7 @@ func (p *Process) terminate() error {
|
|||
}
|
||||
|
||||
// SendInput writes to the process stdin.
|
||||
func (p *Process) SendInput(input string) error {
|
||||
func (p *ManagedProcess) SendInput(input string) error {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
|
|
@ -195,7 +202,7 @@ func (p *Process) SendInput(input string) error {
|
|||
}
|
||||
|
||||
// CloseStdin closes the process stdin pipe.
|
||||
func (p *Process) CloseStdin() error {
|
||||
func (p *ManagedProcess) CloseStdin() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Global default service (follows i18n pattern).
|
||||
var (
|
||||
defaultService atomic.Pointer[Service]
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Default returns the global process service.
|
||||
// Returns nil if not initialized.
|
||||
func Default() *Service {
|
||||
return defaultService.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the global process service.
|
||||
// Thread-safe: can be called concurrently with Default().
|
||||
func SetDefault(s *Service) error {
|
||||
if s == nil {
|
||||
return ErrSetDefaultNil
|
||||
}
|
||||
defaultService.Store(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init initializes the default global service with a Core instance.
|
||||
// This is typically called during application startup.
|
||||
func Init(c *core.Core) error {
|
||||
defaultOnce.Do(func() {
|
||||
factory := NewService(Options{})
|
||||
svc, err := factory(c)
|
||||
if err != nil {
|
||||
defaultErr = err
|
||||
return
|
||||
}
|
||||
defaultService.Store(svc.(*Service))
|
||||
})
|
||||
return defaultErr
|
||||
}
|
||||
|
||||
// --- Global convenience functions ---
|
||||
|
||||
// Start spawns a new process using the default service.
|
||||
func Start(ctx context.Context, command string, args ...string) core.Result {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return core.Result{OK: false}
|
||||
}
|
||||
return svc.Start(ctx, command, args...)
|
||||
}
|
||||
|
||||
// Run executes a command and waits for completion using the default service.
|
||||
func Run(ctx context.Context, command string, args ...string) core.Result {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return core.Result{Value: "", OK: false}
|
||||
}
|
||||
return svc.Run(ctx, command, args...)
|
||||
}
|
||||
|
||||
// Get returns a process by ID from the default service.
|
||||
func Get(id string) (*Process, error) {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil, ErrServiceNotInitialized
|
||||
}
|
||||
return svc.Get(id)
|
||||
}
|
||||
|
||||
// List returns all processes from the default service.
|
||||
func List() []*Process {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.List()
|
||||
}
|
||||
|
||||
// Kill terminates a process by ID using the default service.
|
||||
func Kill(id string) error {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return ErrServiceNotInitialized
|
||||
}
|
||||
return svc.Kill(id)
|
||||
}
|
||||
|
||||
// StartWithOptions spawns a process with full configuration using the default service.
|
||||
func StartWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return core.Result{OK: false}
|
||||
}
|
||||
return svc.StartWithOptions(ctx, opts)
|
||||
}
|
||||
|
||||
// RunWithOptions executes a command with options and waits using the default service.
|
||||
func RunWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return core.Result{Value: "", OK: false}
|
||||
}
|
||||
return svc.RunWithOptions(ctx, opts)
|
||||
}
|
||||
|
||||
// Running returns all currently running processes from the default service.
|
||||
func Running() []*Process {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.Running()
|
||||
}
|
||||
|
||||
// Errors
|
||||
var (
|
||||
// ErrServiceNotInitialized is returned when the service is not initialized.
|
||||
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)
|
||||
)
|
||||
10
program.go
10
program.go
|
|
@ -5,12 +5,12 @@ import (
|
|||
"context"
|
||||
"strconv"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
|
||||
// Callers may use core.Is to detect this condition.
|
||||
var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil)
|
||||
var ErrProgramNotFound = core.E("", "program: binary not found in PATH", 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.
|
||||
|
|
@ -30,11 +30,11 @@ type Program struct {
|
|||
// err := p.Find()
|
||||
func (p *Program) Find() error {
|
||||
if p.Name == "" {
|
||||
return coreerr.E("Program.Find", "program name is empty", nil)
|
||||
return core.E("program.find", "program name is empty", nil)
|
||||
}
|
||||
path, err := execLookPath(p.Name)
|
||||
if err != nil {
|
||||
return coreerr.E("Program.Find", strconv.Quote(p.Name)+": not found in PATH", ErrProgramNotFound)
|
||||
return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": not found in PATH"), ErrProgramNotFound)
|
||||
}
|
||||
p.Path = path
|
||||
return nil
|
||||
|
|
@ -68,7 +68,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
|
|||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return string(bytes.TrimSpace(out.Bytes())), coreerr.E("Program.RunDir", strconv.Quote(p.Name)+": command failed", err)
|
||||
return string(bytes.TrimSpace(out.Bytes())), core.E("program.run", core.Concat(strconv.Quote(p.Name), ": command failed"), err)
|
||||
}
|
||||
return string(bytes.TrimSpace(out.Bytes())), nil
|
||||
}
|
||||
|
|
|
|||
11
registry.go
11
registry.go
|
|
@ -8,7 +8,6 @@ import (
|
|||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// DaemonEntry records a running daemon in the registry.
|
||||
|
|
@ -58,16 +57,16 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
}
|
||||
|
||||
if err := coreio.Local.EnsureDir(r.dir); err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to create registry directory", err)
|
||||
return core.E("registry.register", "failed to create registry directory", err)
|
||||
}
|
||||
|
||||
data, err := marshalDaemonEntry(entry)
|
||||
if err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to marshal entry", err)
|
||||
return core.E("registry.register", "failed to marshal entry", err)
|
||||
}
|
||||
|
||||
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to write entry file", err)
|
||||
return core.E("registry.register", "failed to write entry file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -75,7 +74,7 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
// Unregister removes a daemon entry from the registry.
|
||||
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)
|
||||
return core.E("registry.unregister", "failed to delete entry file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -112,7 +111,7 @@ func (r *Registry) List() ([]DaemonEntry, error) {
|
|||
|
||||
entries, err := coreio.Local.List(r.dir)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("Registry.List", "failed to list registry directory", err)
|
||||
return nil, core.E("registry.list", "failed to list registry directory", err)
|
||||
}
|
||||
|
||||
var alive []DaemonEntry
|
||||
|
|
|
|||
11
runner.go
11
runner.go
|
|
@ -5,7 +5,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Runner orchestrates multiple processes with dependencies.
|
||||
|
|
@ -105,7 +105,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
Name: name,
|
||||
Spec: remaining[name],
|
||||
ExitCode: 1,
|
||||
Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil),
|
||||
Error: core.E("runner.run_all", "circular dependency or missing dependency", nil),
|
||||
})
|
||||
}
|
||||
break
|
||||
|
|
@ -137,7 +137,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
Name: spec.Name,
|
||||
Spec: spec,
|
||||
Skipped: true,
|
||||
Error: coreerr.E("Runner.RunAll", "skipped due to dependency failure", nil),
|
||||
Error: core.E("runner.run_all", "skipped due to dependency failure", nil),
|
||||
}
|
||||
} else {
|
||||
result = r.runSpec(ctx, spec)
|
||||
|
|
@ -200,10 +200,15 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
|||
Env: spec.Env,
|
||||
})
|
||||
if !sr.OK {
|
||||
err, _ := sr.Value.(error)
|
||||
if err == nil {
|
||||
err = core.E("runner.run_spec", core.Concat("failed to start: ", spec.Name), nil)
|
||||
}
|
||||
return RunResult{
|
||||
Name: spec.Name,
|
||||
Spec: spec,
|
||||
Duration: time.Since(start),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,9 @@ func newTestRunner(t *testing.T) *Runner {
|
|||
t.Helper()
|
||||
|
||||
c := framework.New()
|
||||
factory := NewService(Options{})
|
||||
raw, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewRunner(raw.(*Service))
|
||||
r := Register(c)
|
||||
require.True(t, r.OK)
|
||||
return NewRunner(r.Value.(*Service))
|
||||
}
|
||||
|
||||
func TestRunner_RunSequential_Good(t *testing.T) {
|
||||
|
|
|
|||
263
service.go
263
service.go
|
|
@ -5,14 +5,11 @@ import (
|
|||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
type execCmd = exec.Cmd
|
||||
|
|
@ -26,19 +23,17 @@ const DefaultBufferSize = 1024 * 1024
|
|||
|
||||
// Errors
|
||||
var (
|
||||
ErrProcessNotFound = coreerr.E("", "process not found", nil)
|
||||
ErrProcessNotRunning = coreerr.E("", "process is not running", nil)
|
||||
ErrStdinNotAvailable = coreerr.E("", "stdin not available", nil)
|
||||
ErrProcessNotFound = core.E("", "process not found", nil)
|
||||
ErrProcessNotRunning = core.E("", "process is not running", nil)
|
||||
ErrStdinNotAvailable = core.E("", "stdin not available", nil)
|
||||
)
|
||||
|
||||
// Service manages process execution with Core IPC integration.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
|
||||
processes map[string]*Process
|
||||
mu sync.RWMutex
|
||||
bufSize int
|
||||
idCounter atomic.Uint64
|
||||
managed *core.Registry[*ManagedProcess]
|
||||
bufSize int
|
||||
}
|
||||
|
||||
// Options configures the process service.
|
||||
|
|
@ -53,40 +48,23 @@ type Options struct {
|
|||
// c := core.New()
|
||||
// svc := process.Register(c).Value.(*process.Service)
|
||||
func Register(c *core.Core) core.Result {
|
||||
opts := Options{BufferSize: DefaultBufferSize}
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, Options{BufferSize: DefaultBufferSize}),
|
||||
processes: make(map[string]*Process),
|
||||
bufSize: DefaultBufferSize,
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
managed: core.NewRegistry[*ManagedProcess](),
|
||||
bufSize: opts.BufferSize,
|
||||
}
|
||||
return core.Result{Value: svc, OK: true}
|
||||
}
|
||||
|
||||
// NewService creates a process service factory for Core registration.
|
||||
// Deprecated: Use Register(c) to construct a Service directly.
|
||||
//
|
||||
// c := core.New()
|
||||
// factory := process.NewService(process.Options{})
|
||||
// raw, err := factory(c)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// svc := raw.(*process.Service)
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
if opts.BufferSize == 0 {
|
||||
opts.BufferSize = DefaultBufferSize
|
||||
}
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
processes: make(map[string]*Process),
|
||||
bufSize: opts.BufferSize,
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup implements core.Startable.
|
||||
func (s *Service) OnStartup(ctx context.Context) core.Result {
|
||||
c := s.Core()
|
||||
c.Action("process.run", s.handleRun)
|
||||
c.Action("process.start", s.handleStart)
|
||||
c.Action("process.kill", s.handleKill)
|
||||
c.Action("process.list", s.handleList)
|
||||
c.Action("process.get", s.handleGet)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
|
|
@ -94,19 +72,9 @@ func (s *Service) OnStartup(ctx context.Context) core.Result {
|
|||
//
|
||||
// c.ServiceShutdown(ctx) // calls OnShutdown on all Stoppable services
|
||||
func (s *Service) OnShutdown(ctx context.Context) core.Result {
|
||||
s.mu.RLock()
|
||||
procs := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
if p.IsRunning() {
|
||||
procs = append(procs, p)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, p := range procs {
|
||||
_ = p.Shutdown()
|
||||
}
|
||||
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
_ = proc.Kill()
|
||||
})
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +94,14 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) cor
|
|||
// r := svc.StartWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test", "./..."}})
|
||||
// if r.OK { proc := r.Value.(*Process) }
|
||||
func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
id := s.nextProcessID()
|
||||
if opts.Command == "" {
|
||||
return core.Result{Value: core.E("process.start", "command is required", nil), OK: false}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
id := core.ID()
|
||||
|
||||
// Detached processes use Background context so they survive parent death
|
||||
parentCtx := ctx
|
||||
|
|
@ -152,19 +127,19 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return core.Result{OK: false}
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stdout pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return core.Result{OK: false}
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stderr pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return core.Result{OK: false}
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stdin pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
// Create output buffer (enabled by default)
|
||||
|
|
@ -173,7 +148,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
output = NewRingBuffer(s.bufSize)
|
||||
}
|
||||
|
||||
proc := &Process{
|
||||
proc := &ManagedProcess{
|
||||
ID: id,
|
||||
Command: opts.Command,
|
||||
Args: opts.Args,
|
||||
|
|
@ -194,13 +169,15 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return core.Result{OK: false}
|
||||
return core.Result{Value: core.E("process.start", core.Concat("command failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
// Store process
|
||||
s.mu.Lock()
|
||||
s.processes[id] = proc
|
||||
s.mu.Unlock()
|
||||
if r := s.managed.Set(id, proc); !r.OK {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
return r
|
||||
}
|
||||
|
||||
// Start timeout watchdog if configured
|
||||
if opts.Timeout > 0 {
|
||||
|
|
@ -273,7 +250,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
}
|
||||
|
||||
// streamOutput reads from a pipe and broadcasts lines via ACTION.
|
||||
func (s *Service) streamOutput(proc *Process, r streamReader, stream Stream) {
|
||||
func (s *Service) streamOutput(proc *ManagedProcess, r streamReader, stream Stream) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Increase buffer for long lines
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
|
@ -296,40 +273,31 @@ func (s *Service) streamOutput(proc *Process, r streamReader, stream Stream) {
|
|||
}
|
||||
|
||||
// Get returns a process by ID.
|
||||
func (s *Service) Get(id string) (*Process, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
proc, ok := s.processes[id]
|
||||
if !ok {
|
||||
func (s *Service) Get(id string) (*ManagedProcess, error) {
|
||||
r := s.managed.Get(id)
|
||||
if !r.OK {
|
||||
return nil, ErrProcessNotFound
|
||||
}
|
||||
return proc, nil
|
||||
return r.Value.(*ManagedProcess), nil
|
||||
}
|
||||
|
||||
// List returns all processes.
|
||||
func (s *Service) List() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
result = append(result, p)
|
||||
}
|
||||
func (s *Service) List() []*ManagedProcess {
|
||||
result := make([]*ManagedProcess, 0, s.managed.Len())
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
result = append(result, proc)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Running returns all currently running processes.
|
||||
func (s *Service) Running() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*Process
|
||||
for _, p := range s.processes {
|
||||
if p.IsRunning() {
|
||||
result = append(result, p)
|
||||
func (s *Service) Running() []*ManagedProcess {
|
||||
result := make([]*ManagedProcess, 0, s.managed.Len())
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
if proc.IsRunning() {
|
||||
result = append(result, proc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -354,31 +322,30 @@ func (s *Service) Kill(id string) error {
|
|||
|
||||
// Remove removes a completed process from the list.
|
||||
func (s *Service) Remove(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
proc, ok := s.processes[id]
|
||||
if !ok {
|
||||
proc, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proc.IsRunning() {
|
||||
return core.E("process.remove", core.Concat("cannot remove running process: ", id), nil)
|
||||
}
|
||||
r := s.managed.Delete(id)
|
||||
if !r.OK {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
|
||||
if proc.IsRunning() {
|
||||
return coreerr.E("Service.Remove", "cannot remove running process", nil)
|
||||
}
|
||||
|
||||
delete(s.processes, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes all completed processes.
|
||||
func (s *Service) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for id, p := range s.processes {
|
||||
if !p.IsRunning() {
|
||||
delete(s.processes, id)
|
||||
ids := make([]string, 0)
|
||||
s.managed.Each(func(id string, proc *ManagedProcess) {
|
||||
if !proc.IsRunning() {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
})
|
||||
for _, id := range ids {
|
||||
s.managed.Delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,15 +364,10 @@ func (s *Service) Output(id string) (string, error) {
|
|||
// r := svc.Run(ctx, "go", "test", "./...")
|
||||
// output := r.Value.(string)
|
||||
func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result {
|
||||
r := s.Start(ctx, command, args...)
|
||||
if !r.OK {
|
||||
return core.Result{Value: "", OK: false}
|
||||
}
|
||||
|
||||
proc := r.Value.(*Process)
|
||||
<-proc.Done()
|
||||
|
||||
return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0}
|
||||
return s.RunWithOptions(ctx, RunOptions{
|
||||
Command: command,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
// RunWithOptions executes a command with options and waits for completion.
|
||||
|
|
@ -414,15 +376,7 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) core.
|
|||
// r := svc.RunWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test"}})
|
||||
// output := r.Value.(string)
|
||||
func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
r := s.StartWithOptions(ctx, opts)
|
||||
if !r.OK {
|
||||
return core.Result{Value: "", OK: false}
|
||||
}
|
||||
|
||||
proc := r.Value.(*Process)
|
||||
<-proc.Done()
|
||||
|
||||
return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0}
|
||||
return s.runCommand(ctx, opts)
|
||||
}
|
||||
|
||||
// --- Internal Request Helpers ---
|
||||
|
|
@ -430,7 +384,7 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Resu
|
|||
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
|
||||
command := opts.String("command")
|
||||
if command == "" {
|
||||
return core.Result{Value: coreerr.E("process.run", "command is required", nil), OK: false}
|
||||
return core.Result{Value: core.E("process.run", "command is required", nil), OK: false}
|
||||
}
|
||||
|
||||
runOpts := RunOptions{
|
||||
|
|
@ -448,56 +402,94 @@ func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result
|
|||
}
|
||||
}
|
||||
|
||||
return s.RunWithOptions(ctx, runOpts)
|
||||
return s.runCommand(ctx, runOpts)
|
||||
}
|
||||
|
||||
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
|
||||
command := opts.String("command")
|
||||
if command == "" {
|
||||
return core.Result{Value: coreerr.E("process.start", "command is required", nil), OK: false}
|
||||
return core.Result{Value: core.E("process.start", "command is required", nil), OK: false}
|
||||
}
|
||||
|
||||
runOpts := RunOptions{
|
||||
Command: command,
|
||||
Dir: opts.String("dir"),
|
||||
Detach: true,
|
||||
}
|
||||
if r := opts.Get("args"); r.OK {
|
||||
if args, ok := r.Value.([]string); ok {
|
||||
runOpts.Args = args
|
||||
}
|
||||
}
|
||||
if r := opts.Get("env"); r.OK {
|
||||
if env, ok := r.Value.([]string); ok {
|
||||
runOpts.Env = env
|
||||
}
|
||||
}
|
||||
|
||||
r := s.StartWithOptions(ctx, runOpts)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return core.Result{Value: r.Value.(*Process).ID, OK: true}
|
||||
return core.Result{Value: r.Value.(*ManagedProcess).ID, OK: true}
|
||||
}
|
||||
|
||||
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
|
||||
id := opts.String("id")
|
||||
if id != "" {
|
||||
if err := s.Kill(id); err != nil {
|
||||
if core.Is(err, ErrProcessNotFound) {
|
||||
return core.Result{Value: core.E("process.kill", core.Concat("not found: ", id), nil), OK: false}
|
||||
}
|
||||
return core.Result{Value: err, OK: false}
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
return core.Result{Value: coreerr.E("process.kill", "id is required", nil), OK: false}
|
||||
|
||||
pid := opts.Int("pid")
|
||||
if pid > 0 {
|
||||
proc, err := processHandle(pid)
|
||||
if err != nil {
|
||||
return core.Result{Value: core.E("process.kill", core.Concat("find pid failed: ", core.Sprintf("%d", pid)), err), OK: false}
|
||||
}
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return core.Result{Value: core.E("process.kill", core.Concat("signal failed: ", core.Sprintf("%d", pid)), err), OK: false}
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
return core.Result{Value: core.E("process.kill", "need id or pid", nil), OK: false}
|
||||
}
|
||||
|
||||
func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return core.Result{Value: s.managed.Names(), OK: true}
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(s.processes))
|
||||
for id := range s.processes {
|
||||
ids = append(ids, id)
|
||||
func (s *Service) runCommand(ctx context.Context, opts RunOptions) core.Result {
|
||||
if opts.Command == "" {
|
||||
return core.Result{Value: core.E("process.run", "command is required", nil), OK: false}
|
||||
}
|
||||
return core.Result{Value: ids, OK: true}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
cmd := execCommandContext(ctx, opts.Command, opts.Args...)
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
}
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return core.Result{Value: core.E("process.run", core.Concat("command failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
return core.Result{Value: string(output), OK: true}
|
||||
}
|
||||
|
||||
// Signal sends a signal to the process.
|
||||
func (p *Process) Signal(sig os.Signal) error {
|
||||
func (p *ManagedProcess) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
@ -542,13 +534,12 @@ func isNotExist(err error) bool {
|
|||
|
||||
func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result {
|
||||
id := opts.String("id")
|
||||
if id == "" {
|
||||
return core.Result{Value: core.E("process.get", "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: core.E("process.get", core.Concat("not found: ", id), err), OK: false}
|
||||
}
|
||||
return core.Result{Value: proc.Info(), OK: true}
|
||||
}
|
||||
|
||||
func (s *Service) nextProcessID() string {
|
||||
return "proc-" + strconv.FormatUint(s.idCounter.Add(1), 10)
|
||||
}
|
||||
|
|
|
|||
194
service_test.go
194
service_test.go
|
|
@ -15,12 +15,183 @@ func newTestService(t *testing.T) (*Service, *framework.Core) {
|
|||
t.Helper()
|
||||
|
||||
c := framework.New()
|
||||
factory := NewService(Options{BufferSize: 1024})
|
||||
raw, err := factory(c)
|
||||
r := Register(c)
|
||||
require.True(t, r.OK)
|
||||
return r.Value.(*Service), c
|
||||
}
|
||||
|
||||
func newStartedTestService(t *testing.T) (*Service, *framework.Core) {
|
||||
t.Helper()
|
||||
|
||||
svc, c := newTestService(t)
|
||||
r := svc.OnStartup(context.Background())
|
||||
require.True(t, r.OK)
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestService_Register_Good(t *testing.T) {
|
||||
c := framework.New(framework.WithService(Register))
|
||||
|
||||
svc, ok := framework.ServiceFor[*Service](c, "process")
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
svc, c := newTestService(t)
|
||||
|
||||
r := svc.OnStartup(context.Background())
|
||||
require.True(t, r.OK)
|
||||
|
||||
assert.True(t, c.Action("process.run").Exists())
|
||||
assert.True(t, c.Action("process.start").Exists())
|
||||
assert.True(t, c.Action("process.kill").Exists())
|
||||
assert.True(t, c.Action("process.list").Exists())
|
||||
assert.True(t, c.Action("process.get").Exists())
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Good(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.run").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "echo"},
|
||||
framework.Option{Key: "args", Value: []string{"hello"}},
|
||||
))
|
||||
require.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "hello")
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.run").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "nonexistent_command_xyz"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Ugly(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
r := c.Action("process.run").Run(ctx, framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"1"}},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleStart_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, r.OK)
|
||||
|
||||
id := r.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, proc.IsRunning())
|
||||
|
||||
kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: id},
|
||||
))
|
||||
require.True(t, kill.OK)
|
||||
<-proc.Done()
|
||||
}
|
||||
|
||||
func TestService_HandleStart_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "nonexistent_command_xyz"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleKill_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
start := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, start.OK)
|
||||
|
||||
id := start.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := raw.(*Service)
|
||||
return svc, c
|
||||
kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: id},
|
||||
))
|
||||
require.True(t, kill.OK)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_HandleKill_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: "missing"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleList_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
startOne := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, startOne.OK)
|
||||
startTwo := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, startTwo.OK)
|
||||
|
||||
r := c.Action("process.list").Run(context.Background(), framework.NewOptions())
|
||||
require.True(t, r.OK)
|
||||
|
||||
ids := r.Value.([]string)
|
||||
assert.Len(t, ids, 2)
|
||||
|
||||
for _, id := range ids {
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
_ = proc.Kill()
|
||||
<-proc.Done()
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Ugly_PermissionModel(t *testing.T) {
|
||||
c := framework.New()
|
||||
|
||||
r := c.Process().Run(context.Background(), "echo", "blocked")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
c = framework.New(framework.WithService(Register))
|
||||
startup := c.ServiceStartup(context.Background(), nil)
|
||||
require.True(t, startup.OK)
|
||||
defer func() {
|
||||
shutdown := c.ServiceShutdown(context.Background())
|
||||
assert.True(t, shutdown.OK)
|
||||
}()
|
||||
|
||||
r = c.Process().Run(context.Background(), "echo", "allowed")
|
||||
require.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "allowed")
|
||||
}
|
||||
|
||||
func startProc(t *testing.T, svc *Service, ctx context.Context, command string, args ...string) *Process {
|
||||
|
|
@ -171,12 +342,7 @@ func TestService_Run_Good(t *testing.T) {
|
|||
|
||||
func TestService_Actions_Good(t *testing.T) {
|
||||
t.Run("broadcasts events", func(t *testing.T) {
|
||||
c := framework.New()
|
||||
|
||||
factory := NewService(Options{})
|
||||
raw, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := raw.(*Service)
|
||||
svc, c := newTestService(t)
|
||||
|
||||
var started []ActionProcessStarted
|
||||
var outputs []ActionProcessOutput
|
||||
|
|
@ -381,14 +547,6 @@ func TestService_OnShutdown_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
t.Run("returns ok", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.OnStartup(context.Background())
|
||||
assert.True(t, r.OK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RunWithOptions_Good(t *testing.T) {
|
||||
t.Run("returns output on success", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
|
|
|||
68
specs/exec.md
Normal file
68
specs/exec.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# exec
|
||||
**Import:** `dappco.re/go/core/process/exec`
|
||||
**Files:** 3
|
||||
|
||||
## Types
|
||||
|
||||
### `Options`
|
||||
`struct`
|
||||
|
||||
Execution settings stored on `Cmd`.
|
||||
|
||||
Fields:
|
||||
- `Dir string`: Working directory.
|
||||
- `Env []string`: Additional environment entries appended to `os.Environ()`.
|
||||
- `Stdin io.Reader`: Reader wired to command stdin.
|
||||
- `Stdout io.Writer`: Writer wired to command stdout.
|
||||
- `Stderr io.Writer`: Writer wired to command stderr.
|
||||
|
||||
### `Cmd`
|
||||
`struct`
|
||||
|
||||
Fluent wrapper around `os/exec.Cmd`.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `Logger`
|
||||
`interface`
|
||||
|
||||
Structured logger contract used by the package.
|
||||
|
||||
Methods:
|
||||
- `Debug(msg string, keyvals ...any)`: Debug-level event logging.
|
||||
- `Error(msg string, keyvals ...any)`: Error-level event logging.
|
||||
|
||||
### `NopLogger`
|
||||
`struct`
|
||||
|
||||
No-op logger implementation used as the package default.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `func Command(ctx context.Context, name string, args ...string) *Cmd`: Creates a `Cmd` with the supplied command name, arguments, and context.
|
||||
- `func RunQuiet(ctx context.Context, name string, args ...string) error`: Runs a command with stdout suppressed, captures stderr into a buffer, and wraps any failure with `core.E("RunQuiet", ...)`.
|
||||
- `func SetDefaultLogger(l Logger)`: Replaces the package-level default logger. Passing `nil` resets it to `NopLogger`.
|
||||
- `func DefaultLogger() Logger`: Returns the current package-level default logger.
|
||||
|
||||
### `Cmd` Methods
|
||||
|
||||
- `func (c *Cmd) WithDir(dir string) *Cmd`: Sets the working directory on the stored options and returns the same command for chaining.
|
||||
- `func (c *Cmd) WithEnv(env []string) *Cmd`: Sets extra environment variables and returns the same command for chaining.
|
||||
- `func (c *Cmd) WithStdin(r io.Reader) *Cmd`: Sets stdin and returns the same command for chaining.
|
||||
- `func (c *Cmd) WithStdout(w io.Writer) *Cmd`: Sets stdout and returns the same command for chaining.
|
||||
- `func (c *Cmd) WithStderr(w io.Writer) *Cmd`: Sets stderr and returns the same command for chaining.
|
||||
- `func (c *Cmd) WithLogger(l Logger) *Cmd`: Sets a per-command logger that overrides the package default.
|
||||
- `func (c *Cmd) Run() error`: Builds the underlying `exec.Cmd`, logs a debug event, runs the command, and wraps failures with command context.
|
||||
- `func (c *Cmd) Output() ([]byte, error)`: Builds the underlying `exec.Cmd`, logs a debug event, returns stdout bytes, and wraps failures.
|
||||
- `func (c *Cmd) CombinedOutput() ([]byte, error)`: Builds the underlying `exec.Cmd`, logs a debug event, returns combined stdout and stderr bytes, and wraps failures while preserving the output bytes from `exec.Cmd.CombinedOutput`.
|
||||
|
||||
### `NopLogger` Methods
|
||||
|
||||
- `func (NopLogger) Debug(string, ...any)`: Discards debug messages.
|
||||
- `func (NopLogger) Error(string, ...any)`: Discards error messages.
|
||||
29
specs/pkg-api.md
Normal file
29
specs/pkg-api.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# api
|
||||
**Import:** `dappco.re/go/core/process/pkg/api`
|
||||
**Files:** 2
|
||||
|
||||
## Types
|
||||
|
||||
### `ProcessProvider`
|
||||
`struct`
|
||||
|
||||
Route-group and renderable provider for process-daemon registry APIs and the bundled UI entrypoint.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider`: Creates a provider backed by the supplied registry and WebSocket hub. When `registry` is `nil`, it uses `process.DefaultRegistry()`.
|
||||
- `func PIDAlive(pid int) bool`: Uses `os.FindProcess` plus signal `0` to report whether a PID is still alive.
|
||||
|
||||
### `ProcessProvider` Methods
|
||||
|
||||
- `func (p *ProcessProvider) Name() string`: Returns the route-group name `"process"`.
|
||||
- `func (p *ProcessProvider) BasePath() string`: Returns the route-group base path `"/api/process"`.
|
||||
- `func (p *ProcessProvider) Element() provider.ElementSpec`: Returns the UI element spec with tag `core-process-panel` and script source `/assets/core-process.js`.
|
||||
- `func (p *ProcessProvider) Channels() []string`: Returns the WebSocket channels exposed by the provider: daemon started, daemon stopped, daemon health, process started, process output, process exited, and process killed.
|
||||
- `func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup)`: Registers `GET /daemons`, `GET /daemons/:code/:daemon`, `POST /daemons/:code/:daemon/stop`, and `GET /daemons/:code/:daemon/health`.
|
||||
- `func (p *ProcessProvider) Describe() []api.RouteDescription`: Returns static route descriptions for the daemon list, single-daemon lookup, daemon stop, and daemon health endpoints.
|
||||
207
specs/process-ui.md
Normal file
207
specs/process-ui.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# @core/process-ui
|
||||
**Import:** `@core/process-ui`
|
||||
**Files:** 8
|
||||
|
||||
## Types
|
||||
|
||||
### `DaemonEntry`
|
||||
`interface`
|
||||
|
||||
Daemon-registry row returned by `ProcessApi.listDaemons` and `ProcessApi.getDaemon`.
|
||||
|
||||
Properties:
|
||||
- `code: string`: Application or component code.
|
||||
- `daemon: string`: Daemon name.
|
||||
- `pid: number`: Process ID.
|
||||
- `health?: string`: Optional health-endpoint address.
|
||||
- `project?: string`: Optional project label.
|
||||
- `binary?: string`: Optional binary label.
|
||||
- `started: string`: Start timestamp string from the API.
|
||||
|
||||
### `HealthResult`
|
||||
`interface`
|
||||
|
||||
Result returned by the daemon health endpoint.
|
||||
|
||||
Properties:
|
||||
- `healthy: boolean`: Health outcome.
|
||||
- `address: string`: Health endpoint address that was checked.
|
||||
- `reason?: string`: Optional explanation such as the absence of a health endpoint.
|
||||
|
||||
### `ProcessInfo`
|
||||
`interface`
|
||||
|
||||
Process snapshot shape used by the UI package.
|
||||
|
||||
Properties:
|
||||
- `id: string`: Managed-process identifier.
|
||||
- `command: string`: Executable name.
|
||||
- `args: string[]`: Command arguments.
|
||||
- `dir: string`: Working directory.
|
||||
- `startedAt: string`: Start timestamp string.
|
||||
- `status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'`: Process status string.
|
||||
- `exitCode: number`: Exit code.
|
||||
- `duration: number`: Numeric duration value from the API payload.
|
||||
- `pid: number`: Child PID.
|
||||
|
||||
### `RunResult`
|
||||
`interface`
|
||||
|
||||
Pipeline result row used by `ProcessRunner`.
|
||||
|
||||
Properties:
|
||||
- `name: string`: Spec name.
|
||||
- `exitCode: number`: Exit code.
|
||||
- `duration: number`: Numeric duration value.
|
||||
- `output: string`: Captured output.
|
||||
- `error?: string`: Optional error message.
|
||||
- `skipped: boolean`: Whether the spec was skipped.
|
||||
- `passed: boolean`: Whether the spec passed.
|
||||
|
||||
### `RunAllResult`
|
||||
`interface`
|
||||
|
||||
Aggregate pipeline result consumed by `ProcessRunner`.
|
||||
|
||||
Properties:
|
||||
- `results: RunResult[]`: Per-spec results.
|
||||
- `duration: number`: Aggregate duration.
|
||||
- `passed: number`: Count of passed specs.
|
||||
- `failed: number`: Count of failed specs.
|
||||
- `skipped: number`: Count of skipped specs.
|
||||
- `success: boolean`: Aggregate success flag.
|
||||
|
||||
### `ProcessApi`
|
||||
`class`
|
||||
|
||||
Typed fetch client for `/api/process/*`.
|
||||
|
||||
Public API:
|
||||
- `new ProcessApi(baseUrl?: string)`: Stores an optional URL prefix. The default is `""`.
|
||||
- `listDaemons(): Promise<DaemonEntry[]>`: Fetches `GET /api/process/daemons`.
|
||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Fetches one daemon entry.
|
||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Sends `POST /api/process/daemons/:code/:daemon/stop`.
|
||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Fetches `GET /api/process/daemons/:code/:daemon/health`.
|
||||
|
||||
### `ProcessEvent`
|
||||
`interface`
|
||||
|
||||
Event envelope consumed by `connectProcessEvents`.
|
||||
|
||||
Properties:
|
||||
- `type: string`: Event type.
|
||||
- `channel?: string`: Optional channel name.
|
||||
- `data?: any`: Event payload.
|
||||
- `timestamp?: string`: Optional timestamp string.
|
||||
|
||||
### `ProcessPanel`
|
||||
`class`
|
||||
|
||||
Top-level custom element registered as `<core-process-panel>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Forwarded to child elements through the `api-url` attribute.
|
||||
- `wsUrl: string`: WebSocket endpoint URL from the `ws-url` attribute.
|
||||
|
||||
Behavior:
|
||||
- Renders tabbed daemon, process, and pipeline views.
|
||||
- Opens a process-event WebSocket when `wsUrl` is set.
|
||||
- Shows the last received process channel or event type in the footer.
|
||||
|
||||
### `ProcessDaemons`
|
||||
`class`
|
||||
|
||||
Daemon-list custom element registered as `<core-process-daemons>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Base URL prefix for `ProcessApi`.
|
||||
|
||||
Behavior:
|
||||
- Loads daemon entries on connect.
|
||||
- Can trigger per-daemon health checks and stop requests.
|
||||
- Emits `daemon-stopped` after a successful stop request.
|
||||
|
||||
### `ProcessList`
|
||||
`class`
|
||||
|
||||
Managed-process list custom element registered as `<core-process-list>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property.
|
||||
- `selectedId: string`: Selected process ID, reflected from `selected-id`.
|
||||
|
||||
Behavior:
|
||||
- Emits `process-selected` when a row is chosen.
|
||||
- Currently renders from local state only because the process REST endpoints referenced by the component are not implemented in this package.
|
||||
|
||||
### `ProcessOutput`
|
||||
`class`
|
||||
|
||||
Live output custom element registered as `<core-process-output>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property. The current implementation does not use it.
|
||||
- `wsUrl: string`: WebSocket endpoint URL.
|
||||
- `processId: string`: Selected process ID from the `process-id` attribute.
|
||||
|
||||
Behavior:
|
||||
- Connects to the WebSocket when both `wsUrl` and `processId` are present.
|
||||
- Filters for `process.output` events whose payload `data.id` matches `processId`.
|
||||
- Appends output lines and auto-scrolls by default.
|
||||
|
||||
### `ProcessRunner`
|
||||
`class`
|
||||
|
||||
Pipeline-results custom element registered as `<core-process-runner>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property.
|
||||
- `result: RunAllResult | null`: Aggregate pipeline result used for rendering.
|
||||
|
||||
Behavior:
|
||||
- Renders summary counts plus expandable per-spec output.
|
||||
- Depends on the `result` property today because pipeline REST endpoints are not implemented in the package.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `function connectProcessEvents(wsUrl: string, handler: (event: ProcessEvent) => void): WebSocket`: Opens a WebSocket, parses incoming JSON, forwards only messages whose `type` or `channel` starts with `process.`, ignores malformed payloads, and returns the `WebSocket` instance.
|
||||
|
||||
### `ProcessPanel` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` is set.
|
||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
||||
- `render(): unknown`: Renders the header, tab strip, active child element, and connection footer.
|
||||
|
||||
### `ProcessDaemons` Methods
|
||||
|
||||
- `connectedCallback(): void`: Instantiates `ProcessApi` and loads daemon data.
|
||||
- `loadDaemons(): Promise<void>`: Fetches daemon entries, stores them in component state, and records any request error message.
|
||||
- `render(): unknown`: Renders the daemon list, loading state, empty state, and action buttons.
|
||||
|
||||
### `ProcessList` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadProcesses`.
|
||||
- `loadProcesses(): Promise<void>`: Current placeholder implementation that clears state because the referenced process REST endpoints are not implemented yet.
|
||||
- `render(): unknown`: Renders the process list or an informational empty state explaining the missing REST support.
|
||||
|
||||
### `ProcessOutput` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` and `processId` are both set.
|
||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
||||
- `updated(changed: Map<string, unknown>): void`: Reconnects when `processId` or `wsUrl` changes, resets buffered lines on reconnection, and auto-scrolls when enabled.
|
||||
- `render(): unknown`: Renders the output panel, waiting state, and accumulated stdout or stderr lines.
|
||||
|
||||
### `ProcessRunner` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadResults`.
|
||||
- `loadResults(): Promise<void>`: Current placeholder method. The implementation is empty because pipeline endpoints are not present.
|
||||
- `render(): unknown`: Renders the empty-state notice when `result` is absent, or the aggregate summary plus per-spec details when `result` is present.
|
||||
|
||||
### `ProcessApi` Methods
|
||||
|
||||
- `listDaemons(): Promise<DaemonEntry[]>`: Returns the `data` field from a successful daemon-list response.
|
||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Returns one daemon entry from the provider API.
|
||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Issues the stop request and returns the provider's `{ stopped }` payload.
|
||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Returns the daemon-health payload.
|
||||
372
specs/process.md
Normal file
372
specs/process.md
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
# process
|
||||
**Import:** `dappco.re/go/core/process`
|
||||
**Files:** 11
|
||||
|
||||
## Types
|
||||
|
||||
### `ActionProcessStarted`
|
||||
`struct`
|
||||
|
||||
Broadcast payload for a managed process that has successfully started.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Generated managed-process identifier.
|
||||
- `Command string`: Executable name passed to the service.
|
||||
- `Args []string`: Argument vector used to start the process.
|
||||
- `Dir string`: Working directory supplied at start time.
|
||||
- `PID int`: OS process ID of the child process.
|
||||
|
||||
### `ActionProcessOutput`
|
||||
`struct`
|
||||
|
||||
Broadcast payload for one scanned line of process output.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Line string`: One line from stdout or stderr, without the trailing newline.
|
||||
- `Stream Stream`: Output source, using `StreamStdout` or `StreamStderr`.
|
||||
|
||||
### `ActionProcessExited`
|
||||
`struct`
|
||||
|
||||
Broadcast payload emitted after the service wait goroutine finishes.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `ExitCode int`: Process exit code.
|
||||
- `Duration time.Duration`: Time elapsed since `StartedAt`.
|
||||
- `Error error`: Declared error slot for exit metadata. The current `Service` emitter does not populate it.
|
||||
|
||||
### `ActionProcessKilled`
|
||||
`struct`
|
||||
|
||||
Broadcast payload emitted by `Service.Kill`.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Signal string`: Signal name reported by the service. The current implementation emits `"SIGKILL"`.
|
||||
|
||||
### `RingBuffer`
|
||||
`struct`
|
||||
|
||||
Fixed-size circular byte buffer used for captured process output. The implementation is mutex-protected and overwrites the oldest bytes when full.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `DaemonOptions`
|
||||
`struct`
|
||||
|
||||
Configuration for `NewDaemon`.
|
||||
|
||||
Fields:
|
||||
- `PIDFile string`: PID file path. Empty disables PID-file management.
|
||||
- `ShutdownTimeout time.Duration`: Grace period used by `Stop`. Zero is normalized to 30 seconds by `NewDaemon`.
|
||||
- `HealthAddr string`: Listen address for the health server. Empty disables health endpoints.
|
||||
- `HealthChecks []HealthCheck`: Additional `/health` checks to register on the health server.
|
||||
- `Registry *Registry`: Optional daemon registry used for automatic register/unregister.
|
||||
- `RegistryEntry DaemonEntry`: Base registry payload. `Start` fills in `PID`, `Health`, and `Started` behavior through `Registry.Register`.
|
||||
|
||||
### `Daemon`
|
||||
`struct`
|
||||
|
||||
Lifecycle wrapper around a PID file, optional health server, and optional registry entry.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `HealthCheck`
|
||||
`type HealthCheck func() error`
|
||||
|
||||
Named function type used by `HealthServer` and `DaemonOptions`. Returning `nil` marks the check healthy; returning an error makes `/health` respond with `503`.
|
||||
|
||||
### `HealthServer`
|
||||
`struct`
|
||||
|
||||
HTTP server exposing `/health` and `/ready` endpoints.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `PIDFile`
|
||||
`struct`
|
||||
|
||||
Single-instance guard backed by a PID file on disk.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `ManagedProcess`
|
||||
`struct`
|
||||
|
||||
Service-owned process record for a started child process.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier generated by `core.ID()`.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory used when starting the process.
|
||||
- `Env []string`: Extra environment entries appended to the command environment.
|
||||
- `StartedAt time.Time`: Timestamp recorded immediately before `cmd.Start`.
|
||||
- `Status Status`: Current lifecycle state tracked by the service.
|
||||
- `ExitCode int`: Exit status after completion.
|
||||
- `Duration time.Duration`: Runtime duration set when the wait goroutine finishes.
|
||||
|
||||
### `Process`
|
||||
`type alias of ManagedProcess`
|
||||
|
||||
Compatibility alias that exposes the same fields and methods as `ManagedProcess`.
|
||||
|
||||
### `Program`
|
||||
`struct`
|
||||
|
||||
Thin helper for finding and running a named executable.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Binary name to look up or execute.
|
||||
- `Path string`: Resolved absolute path populated by `Find`. When empty, `Run` and `RunDir` fall back to `Name`.
|
||||
|
||||
### `DaemonEntry`
|
||||
`struct`
|
||||
|
||||
Serialized daemon-registry record written as JSON.
|
||||
|
||||
Fields:
|
||||
- `Code string`: Application or component code.
|
||||
- `Daemon string`: Daemon name within that code.
|
||||
- `PID int`: Running process ID.
|
||||
- `Health string`: Health endpoint address, if any.
|
||||
- `Project string`: Optional project label.
|
||||
- `Binary string`: Optional binary label.
|
||||
- `Started time.Time`: Start timestamp persisted in RFC3339Nano format.
|
||||
|
||||
### `Registry`
|
||||
`struct`
|
||||
|
||||
Filesystem-backed daemon registry that stores one JSON file per daemon entry.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `Runner`
|
||||
`struct`
|
||||
|
||||
Pipeline orchestrator that starts `RunSpec` processes through a `Service`.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `RunSpec`
|
||||
`struct`
|
||||
|
||||
One process specification for `Runner`.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Friendly name used for dependencies and result reporting.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `Env []string`: Additional environment variables.
|
||||
- `After []string`: Dependency names that must complete before this spec can run in `RunAll`.
|
||||
- `AllowFailure bool`: When true, downstream work is not skipped because of this spec's failure.
|
||||
|
||||
### `RunResult`
|
||||
`struct`
|
||||
|
||||
Per-spec runner result.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Spec name.
|
||||
- `Spec RunSpec`: Original spec payload.
|
||||
- `ExitCode int`: Exit code from the managed process.
|
||||
- `Duration time.Duration`: Process duration or start-attempt duration.
|
||||
- `Output string`: Captured output returned from the managed process.
|
||||
- `Error error`: Start or orchestration error. For a started process that exits non-zero, this remains `nil`.
|
||||
- `Skipped bool`: Whether the spec was skipped instead of run.
|
||||
|
||||
### `RunAllResult`
|
||||
`struct`
|
||||
|
||||
Aggregate result returned by `RunAll`, `RunSequential`, and `RunParallel`.
|
||||
|
||||
Fields:
|
||||
- `Results []RunResult`: Collected per-spec results.
|
||||
- `Duration time.Duration`: End-to-end runtime for the orchestration method.
|
||||
- `Passed int`: Count of results where `Passed()` is true.
|
||||
- `Failed int`: Count of non-skipped results that did not pass.
|
||||
- `Skipped int`: Count of skipped results.
|
||||
|
||||
### `Service`
|
||||
`struct`
|
||||
|
||||
Core service that owns managed processes and registers action handlers.
|
||||
|
||||
Fields:
|
||||
- `*core.ServiceRuntime[Options]`: Embedded Core runtime used for lifecycle hooks and access to `Core()`.
|
||||
|
||||
### `Options`
|
||||
`struct`
|
||||
|
||||
Service configuration.
|
||||
|
||||
Fields:
|
||||
- `BufferSize int`: Ring-buffer capacity for captured output. `Register` currently initializes this from `DefaultBufferSize`.
|
||||
|
||||
### `Status`
|
||||
`type Status string`
|
||||
|
||||
Named lifecycle-state type for a managed process.
|
||||
|
||||
Exported values:
|
||||
- `StatusPending`: queued but not started.
|
||||
- `StatusRunning`: currently executing.
|
||||
- `StatusExited`: completed and waited.
|
||||
- `StatusFailed`: start or wait failure state.
|
||||
- `StatusKilled`: terminated by signal.
|
||||
|
||||
### `Stream`
|
||||
`type Stream string`
|
||||
|
||||
Named output-stream discriminator for process output events.
|
||||
|
||||
Exported values:
|
||||
- `StreamStdout`: stdout line.
|
||||
- `StreamStderr`: stderr line.
|
||||
|
||||
### `RunOptions`
|
||||
`struct`
|
||||
|
||||
Execution settings accepted by `Service.StartWithOptions` and `Service.RunWithOptions`.
|
||||
|
||||
Fields:
|
||||
- `Command string`: Executable name. Required by both start and run paths.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `Env []string`: Additional environment entries appended to the command environment.
|
||||
- `DisableCapture bool`: Disables the managed-process ring buffer when true.
|
||||
- `Detach bool`: Starts the child in a separate process group and replaces the parent context with `context.Background()`.
|
||||
- `Timeout time.Duration`: Optional watchdog timeout that calls `Shutdown` after the duration elapses.
|
||||
- `GracePeriod time.Duration`: Delay between `SIGTERM` and fallback kill in `Shutdown`.
|
||||
- `KillGroup bool`: Requests process-group termination. The current service only enables this when `Detach` is also true.
|
||||
|
||||
### `ProcessInfo`
|
||||
`struct`
|
||||
|
||||
Serializable snapshot returned by `ManagedProcess.Info` and `Service` action lookups.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `StartedAt time.Time`: Start timestamp.
|
||||
- `Running bool`: Convenience boolean derived from `Status`.
|
||||
- `Status Status`: Current lifecycle state.
|
||||
- `ExitCode int`: Exit status.
|
||||
- `Duration time.Duration`: Runtime duration.
|
||||
- `PID int`: Child PID, or `0` if no process handle is available.
|
||||
|
||||
### `Info`
|
||||
`type alias of ProcessInfo`
|
||||
|
||||
Compatibility alias that exposes the same fields as `ProcessInfo`.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `func Register(c *core.Core) core.Result`: Builds a `Service` with a fresh `core.Registry[*ManagedProcess]`, sets the buffer size to `DefaultBufferSize`, and returns the service in `Result.Value`.
|
||||
- `func NewRingBuffer(size int) *RingBuffer`: Allocates a fixed-capacity ring buffer of exactly `size` bytes.
|
||||
- `func NewDaemon(opts DaemonOptions) *Daemon`: Normalizes `ShutdownTimeout`, creates optional `PIDFile` and `HealthServer` helpers, and attaches any configured health checks.
|
||||
- `func NewHealthServer(addr string) *HealthServer`: Returns a health server with the supplied listen address and readiness initialized to `true`.
|
||||
- `func WaitForHealth(addr string, timeoutMs int) bool`: Polls `http://<addr>/health` every 200 ms until it gets HTTP 200 or the timeout expires.
|
||||
- `func NewPIDFile(path string) *PIDFile`: Returns a PID-file manager for `path`.
|
||||
- `func ReadPID(path string) (int, bool)`: Reads and parses a PID file, then uses signal `0` to report whether that PID is still alive.
|
||||
- `func NewRegistry(dir string) *Registry`: Returns a daemon registry rooted at `dir`.
|
||||
- `func DefaultRegistry() *Registry`: Returns a registry at `~/.core/daemons`, falling back to the OS temp directory if the home directory cannot be resolved.
|
||||
- `func NewRunner(svc *Service) *Runner`: Returns a runner bound to a specific `Service`.
|
||||
|
||||
### `RingBuffer` Methods
|
||||
|
||||
- `func (rb *RingBuffer) Write(p []byte) (n int, err error)`: Appends bytes one by one, advancing the circular window and overwriting the oldest bytes when capacity is exceeded.
|
||||
- `func (rb *RingBuffer) String() string`: Returns the current buffer contents in logical order as a string.
|
||||
- `func (rb *RingBuffer) Bytes() []byte`: Returns a copied byte slice of the current logical contents, or `nil` when the buffer is empty.
|
||||
- `func (rb *RingBuffer) Len() int`: Returns the number of bytes currently retained.
|
||||
- `func (rb *RingBuffer) Cap() int`: Returns the configured capacity.
|
||||
- `func (rb *RingBuffer) Reset()`: Clears the buffer indexes and full flag.
|
||||
|
||||
### `Daemon` Methods
|
||||
|
||||
- `func (d *Daemon) Start() error`: Acquires the PID file, starts the health server, marks the daemon running, and auto-registers it when `Registry` is configured. If a later step fails, it rolls back earlier setup.
|
||||
- `func (d *Daemon) Run(ctx context.Context) error`: Requires a started daemon, waits for `ctx.Done()`, and then calls `Stop`.
|
||||
- `func (d *Daemon) Stop() error`: Sets readiness false, shuts down the health server, releases the PID file, unregisters the daemon, and joins health or PID teardown errors with `core.ErrorJoin`.
|
||||
- `func (d *Daemon) SetReady(ready bool)`: Forwards readiness changes to the health server when one exists.
|
||||
- `func (d *Daemon) HealthAddr() string`: Returns the bound health-server address or `""` when health endpoints are disabled.
|
||||
|
||||
### `HealthServer` Methods
|
||||
|
||||
- `func (h *HealthServer) AddCheck(check HealthCheck)`: Appends a health-check callback under lock.
|
||||
- `func (h *HealthServer) SetReady(ready bool)`: Updates the readiness flag used by `/ready`.
|
||||
- `func (h *HealthServer) Start() error`: Installs `/health` and `/ready` handlers, listens on `addr`, stores the listener and `http.Server`, and serves in a goroutine.
|
||||
- `func (h *HealthServer) Stop(ctx context.Context) error`: Calls `Shutdown` on the underlying `http.Server` when started; otherwise returns `nil`.
|
||||
- `func (h *HealthServer) Addr() string`: Returns the actual bound listener address after `Start`, or the configured address before startup.
|
||||
|
||||
### `PIDFile` Methods
|
||||
|
||||
- `func (p *PIDFile) Acquire() error`: Rejects a live existing PID file, deletes stale state, creates the parent directory when needed, and writes the current process ID.
|
||||
- `func (p *PIDFile) Release() error`: Deletes the PID file.
|
||||
- `func (p *PIDFile) Path() string`: Returns the configured PID-file path.
|
||||
|
||||
### `ManagedProcess` Methods
|
||||
|
||||
- `func (p *ManagedProcess) Info() ProcessInfo`: Returns a snapshot containing public fields plus the current child PID.
|
||||
- `func (p *ManagedProcess) Output() string`: Returns captured output as a string, or `""` when capture is disabled.
|
||||
- `func (p *ManagedProcess) OutputBytes() []byte`: Returns captured output as bytes, or `nil` when capture is disabled.
|
||||
- `func (p *ManagedProcess) IsRunning() bool`: Reports running state by checking whether the `done` channel has closed.
|
||||
- `func (p *ManagedProcess) Wait() error`: Blocks for completion and then returns a wrapped error for failed-start, killed, or non-zero-exit outcomes.
|
||||
- `func (p *ManagedProcess) Done() <-chan struct{}`: Returns the completion channel.
|
||||
- `func (p *ManagedProcess) Kill() error`: Sends `SIGKILL` to the child, or to the entire process group when group killing is enabled.
|
||||
- `func (p *ManagedProcess) Shutdown() error`: Sends `SIGTERM`, waits for the configured grace period, and falls back to `Kill`. With no grace period configured, it immediately calls `Kill`.
|
||||
- `func (p *ManagedProcess) SendInput(input string) error`: Writes to the child's stdin pipe while the process is running.
|
||||
- `func (p *ManagedProcess) CloseStdin() error`: Closes the stdin pipe and clears the stored handle.
|
||||
- `func (p *ManagedProcess) Signal(sig os.Signal) error`: Sends an arbitrary signal while the process is in `StatusRunning`.
|
||||
|
||||
### `Program` Methods
|
||||
|
||||
- `func (p *Program) Find() error`: Resolves `Name` through `exec.LookPath`, stores the absolute path in `Path`, and wraps `ErrProgramNotFound` when lookup fails.
|
||||
- `func (p *Program) Run(ctx context.Context, args ...string) (string, error)`: Executes the program in the current working directory by delegating to `RunDir("", args...)`.
|
||||
- `func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)`: Runs the program with combined stdout/stderr capture, trims the combined output, and returns that output even when the command fails.
|
||||
|
||||
### `Registry` Methods
|
||||
|
||||
- `func (r *Registry) Register(entry DaemonEntry) error`: Ensures the registry directory exists, defaults `Started` when zero, marshals the entry with the package's JSON writer, and writes one `<code>-<daemon>.json` file.
|
||||
- `func (r *Registry) Unregister(code, daemon string) error`: Deletes the registry file for the supplied daemon key.
|
||||
- `func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool)`: Reads one entry, prunes invalid or stale files, and returns `(nil, false)` when the daemon is missing or dead.
|
||||
- `func (r *Registry) List() ([]DaemonEntry, error)`: Lists all JSON files in the registry directory, prunes invalid or stale entries, and returns only live daemons. A missing registry directory returns `nil, nil`.
|
||||
|
||||
### `RunResult` and `RunAllResult` Methods
|
||||
|
||||
- `func (r RunResult) Passed() bool`: Returns true only when the result is not skipped, has no `Error`, and has `ExitCode == 0`.
|
||||
- `func (r RunAllResult) Success() bool`: Returns true when `Failed == 0`, regardless of skipped count.
|
||||
|
||||
### `Runner` Methods
|
||||
|
||||
- `func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Executes dependency-aware waves of specs, skips dependents after failing required dependencies, and marks circular or missing dependency sets as failed results with `ExitCode` 1.
|
||||
- `func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs specs in order and marks remaining specs skipped after the first disallowed failure.
|
||||
- `func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs all specs concurrently and aggregates counts after all goroutines finish.
|
||||
|
||||
### `Service` Methods
|
||||
|
||||
- `func (s *Service) OnStartup(ctx context.Context) core.Result`: Registers the Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
|
||||
- `func (s *Service) OnShutdown(ctx context.Context) core.Result`: Iterates all managed processes and calls `Kill` on each one.
|
||||
- `func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper that builds `RunOptions` and delegates to `StartWithOptions`.
|
||||
- `func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result`: Starts a managed process, configures pipes, optional capture, detach and timeout behavior, stores it in the registry, emits `ActionProcessStarted`, streams stdout/stderr lines, and emits `ActionProcessExited` after completion.
|
||||
- `func (s *Service) Get(id string) (*ManagedProcess, error)`: Returns one managed process or `ErrProcessNotFound`.
|
||||
- `func (s *Service) List() []*ManagedProcess`: Returns all managed processes currently stored in the service registry.
|
||||
- `func (s *Service) Running() []*ManagedProcess`: Returns only processes whose `done` channel has not closed yet.
|
||||
- `func (s *Service) Kill(id string) error`: Kills the managed process by ID and emits `ActionProcessKilled`.
|
||||
- `func (s *Service) Remove(id string) error`: Deletes a completed process from the registry and rejects removal while it is still running.
|
||||
- `func (s *Service) Clear()`: Deletes every completed process from the registry.
|
||||
- `func (s *Service) Output(id string) (string, error)`: Returns the managed process's captured output.
|
||||
- `func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper around `RunWithOptions`.
|
||||
- `func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result`: Executes an unmanaged one-shot command with `CombinedOutput`. On success it returns the output string in `Value`; on failure it returns a wrapped error in `Value` and sets `OK` false.
|
||||
21
types.go
21
types.go
|
|
@ -5,16 +5,11 @@
|
|||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// c := core.New()
|
||||
// factory := process.NewService(process.Options{})
|
||||
// raw, err := factory(c)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// c := core.New(core.WithService(process.Register))
|
||||
// _ = c.ServiceStartup(ctx, nil)
|
||||
//
|
||||
// svc := raw.(*process.Service)
|
||||
// r := svc.Start(ctx, "go", "test", "./...")
|
||||
// proc := r.Value.(*process.Process)
|
||||
// r := c.Process().Run(ctx, "go", "test", "./...")
|
||||
// output := r.Value.(string)
|
||||
//
|
||||
// # Listening for Events
|
||||
//
|
||||
|
|
@ -90,15 +85,19 @@ type RunOptions struct {
|
|||
KillGroup bool
|
||||
}
|
||||
|
||||
// Info provides a snapshot of process state without internal fields.
|
||||
type Info struct {
|
||||
// ProcessInfo provides a snapshot of process state without internal fields.
|
||||
type ProcessInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
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"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
|
||||
// Info is kept as a compatibility alias for ProcessInfo.
|
||||
type Info = ProcessInfo
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue