Compare commits

..

20 commits
main ... dev

Author SHA1 Message Date
Snider
a0bf57f10b fix: migrate module paths from forge.lthn.ai to dappco.re
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:13 +01:00
Virgil
1ad4c2aa72 fix(process): guard runner without service 2026-04-03 23:13:57 +00:00
Virgil
e2f84b69e1 fix(process): capture health server in serve goroutine 2026-04-03 23:10:33 +00:00
Virgil
f94b83fe6d chore: verify process package against RFC contract 2026-04-03 07:52:18 +00:00
Virgil
8b0fe175b9 Harden process ring buffer and daemon/health shutdown behavior 2026-04-03 07:36:44 +00:00
Virgil
2d68f89197 fix(process): keep runner results ordered 2026-04-01 09:53:02 +00:00
Virgil
1a6a74085e fix(process): leave exit action error unset
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:59:32 +00:00
Virgil
3a60b9f1e7 fix(process): ensure program paths are absolute
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:56:13 +00:00
Virgil
cd16b014da fix(api): include health-check reason payload
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:53:19 +00:00
Virgil
7c3801e741 feat(process): honor pending process lifecycle
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:49:31 +00:00
Virgil
8f359bb004 fix(process): make process.start non-detached by default
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:46:05 +00:00
Virgil
c60f355b25 fix(process): emit kill action immediately
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:33:51 +00:00
Virgil
9a93ebea66 feat(process-ui): stream live process list from websocket
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:30:38 +00:00
Virgil
0e4dde9307 fix(process): harden program helpers and health schema
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:22:49 +00:00
Virgil
8a6c253ea2 fix(ax): align action handlers and exec errors 2026-03-30 13:43:00 +00:00
Virgil
8a85c3cd86 fix(ax): complete Agent Experience service alignment 2026-03-30 06:34:42 +00:00
Virgil
e75cb1fc97 docs(ax): add RFC/spec artifacts for AX contract alignment 2026-03-30 06:34:38 +00:00
Virgil
b0dd22fc5e docs(ax): align process docs with AX action/result contract 2026-03-30 06:34:38 +00:00
Virgil
aa3602fbb0 refactor(ax): remove legacy global process singleton 2026-03-30 06:34:38 +00:00
Virgil
15e4c8ddeb fix(process): align service APIs with AX-compatible error boundaries 2026-03-30 06:34:35 +00:00
47 changed files with 3063 additions and 6560 deletions

View file

@ -20,11 +20,12 @@ core go vet # Vet
The package has three layers, all in the root `process` package (plus a `exec` subpackage): The package has three layers, all in the root `process` package (plus a `exec` subpackage):
### Layer 1: Process Execution (service.go, process.go, process_global.go) ### Layer 1: Process Execution (service.go, process.go)
`Service` is a Core service (`*core.ServiceRuntime[Options]`) that manages all `Process` instances. It spawns subprocesses, pipes stdout/stderr through goroutines, captures output to a `RingBuffer`, and broadcasts IPC actions (`ActionProcessStarted`, `ActionProcessOutput`, `ActionProcessExited`, `ActionProcessKilled` — defined in actions.go). `Service` is a Core service (`*core.ServiceRuntime[Options]`) that manages all `Process` instances. It spawns subprocesses, pipes stdout/stderr through goroutines, captures output to a `RingBuffer`, and broadcasts IPC actions (`ActionProcessStarted`, `ActionProcessOutput`, `ActionProcessExited`, `ActionProcessKilled` — defined in actions.go).
`process_global.go` provides package-level convenience functions (`Start`, `Run`, `Kill`, `List`) that delegate to a global `Service` singleton initialized via `Init(core)`. Follows the same pattern as Go's `i18n` package. The legacy global singleton API (`process_global.go`) was removed in favor of
explicit Core service registration.
### Layer 2: Daemon Lifecycle (daemon.go, pidfile.go, health.go, registry.go) ### Layer 2: Daemon Lifecycle (daemon.go, pidfile.go, health.go, registry.go)
@ -45,19 +46,19 @@ Builder-pattern wrapper around `os/exec` with structured logging via a pluggable
## Key Patterns ## Key Patterns
- **Core integration**: `Service` embeds `*core.ServiceRuntime[Options]` and uses `s.Core().ACTION(...)` to broadcast typed action messages. Tests create a Core instance via `framework.New(framework.WithName("process", NewService(...)))`. - **Core integration**: `Service` embeds `*core.ServiceRuntime[Options]` and uses `s.Core().ACTION(...)` to broadcast typed action messages. Tests create a Core instance via `framework.New(framework.WithService(Register))`.
- **Output capture**: All process output goes through a fixed-size `RingBuffer` (default 1MB). Oldest data is silently overwritten when full. Set `RunOptions.DisableCapture` to skip buffering for long-running processes where output is only streamed via IPC. - **Output capture**: All process output goes through a fixed-size `RingBuffer` (default 1MB). Oldest data is silently overwritten when full. Set `RunOptions.DisableCapture` to skip buffering for long-running processes where output is only streamed via IPC.
- **Process lifecycle**: Status transitions are `StatusPending → StatusRunning → StatusExited|StatusFailed|StatusKilled`. The `done` channel closes on exit; use `<-proc.Done()` or `proc.Wait()`. - **Process lifecycle**: Status transitions are `StatusPending → StatusRunning → StatusExited|StatusFailed|StatusKilled`. The `done` channel closes on exit; use `<-proc.Done()` or `proc.Wait()`.
- **Detach / process group isolation**: Set `RunOptions.Detach = true` to run the subprocess in its own process group (`Setpgid`). Detached processes use `context.Background()` so they survive parent context cancellation and parent death. - **Detach / process group isolation**: Set `RunOptions.Detach = true` to run the subprocess in its own process group (`Setpgid`). Detached processes use `context.Background()` so they survive parent context cancellation and parent death.
- **Graceful shutdown**: `Service.OnShutdown` kills all running processes. `Daemon.Stop()` performs ordered teardown: sets health to not-ready → shuts down health server → releases PID file → unregisters from registry. `DaemonOptions.ShutdownTimeout` (default 30 s) bounds the shutdown context. - **Graceful shutdown**: `Service.OnShutdown` kills all running processes. `Daemon.Stop()` performs ordered teardown: sets health to not-ready → shuts down health server → releases PID file → unregisters from registry. `DaemonOptions.ShutdownTimeout` (default 30 s) bounds the shutdown context.
- **Auto-registration**: Pass a `Registry` and `RegistryEntry` in `DaemonOptions` to automatically register the daemon on `Start()` and unregister on `Stop()`. - **Auto-registration**: Pass a `Registry` and `RegistryEntry` in `DaemonOptions` to automatically register the daemon on `Start()` and unregister on `Stop()`.
- **PID liveness checks**: Both `PIDFile` and `Registry` use `proc.Signal(syscall.Signal(0))` to check if a PID is alive before trusting stored state. - **PID liveness checks**: Both `PIDFile` and `Registry` use `proc.Signal(syscall.Signal(0))` to check if a PID is alive before trusting stored state.
- **Error handling**: All errors MUST use `coreerr.E()` from `go-log` (imported as `coreerr`), never `fmt.Errorf` or `errors.New`. Sentinel errors are package-level vars created with `coreerr.E("", "message", nil)`. - **Error handling**: All errors MUST use `core.E()`, never `fmt.Errorf` or
`errors.New`. Sentinel errors are package-level vars created with `core.E("", "message", nil)`.
## Dependencies ## Dependencies
- `dappco.re/go/core` — Core DI framework, IPC actions, `ServiceRuntime` - `dappco.re/go/core` — Core DI framework, IPC actions, `ServiceRuntime`
- `dappco.re/go/core/log` — Structured error constructor (`coreerr.E()`)
- `dappco.re/go/core/io` — Filesystem abstraction (`coreio.Local`) used by PIDFile and Registry - `dappco.re/go/core/io` — Filesystem abstraction (`coreio.Local`) used by PIDFile and Registry
- `github.com/stretchr/testify` — test assertions (require/assert) - `github.com/stretchr/testify` — test assertions (require/assert)

View file

@ -1,195 +1,16 @@
package process package process
import ( import (
"context"
"syscall" "syscall"
"time" "time"
"dappco.re/go/core"
) )
// --- ACTION messages (broadcast via Core.ACTION) --- // --- ACTION messages (broadcast via Core.ACTION) ---
// TaskProcessStart requests asynchronous process execution through Core.PERFORM.
// The handler returns a snapshot of the started process immediately.
//
// Example:
//
// c.PERFORM(process.TaskProcessStart{Command: "sleep", Args: []string{"10"}})
type TaskProcessStart struct {
Command string
Args []string
Dir string
Env []string
// DisableCapture skips buffering process output before returning it.
DisableCapture bool
// Detach runs the command in its own process group.
Detach bool
// Timeout bounds the execution duration.
Timeout time.Duration
// GracePeriod controls SIGTERM-to-SIGKILL escalation.
GracePeriod time.Duration
// KillGroup terminates the entire process group instead of only the leader.
KillGroup bool
}
// TaskProcessRun requests synchronous command execution through Core.PERFORM.
// The handler returns the combined command output on success.
//
// Example:
//
// c.PERFORM(process.TaskProcessRun{Command: "echo", Args: []string{"hello"}})
type TaskProcessRun struct {
Command string
Args []string
Dir string
Env []string
// DisableCapture skips buffering process output before returning it.
DisableCapture bool
// Detach runs the command in its own process group.
Detach bool
// Timeout bounds the execution duration.
Timeout time.Duration
// GracePeriod controls SIGTERM-to-SIGKILL escalation.
GracePeriod time.Duration
// KillGroup terminates the entire process group instead of only the leader.
KillGroup bool
}
// TaskProcessKill requests termination of a managed process by ID or PID.
//
// Example:
//
// c.PERFORM(process.TaskProcessKill{ID: "proc-1"})
type TaskProcessKill struct {
// ID identifies a managed process started by this service.
ID string
// PID targets a process directly when ID is not available.
PID int
}
// TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM.
// Signal 0 is allowed for liveness checks.
//
// Example:
//
// c.PERFORM(process.TaskProcessSignal{ID: "proc-1", Signal: syscall.SIGTERM})
type TaskProcessSignal struct {
// ID identifies a managed process started by this service.
ID string
// PID targets a process directly when ID is not available.
PID int
// Signal is delivered to the process or process group.
Signal syscall.Signal
}
// TaskProcessGet requests a snapshot of a managed process through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessGet{ID: "proc-1"})
type TaskProcessGet struct {
// ID identifies a managed process started by this service.
ID string
}
// TaskProcessWait waits for a managed process to finish through Core.PERFORM.
// Successful exits return an Info snapshot. Unsuccessful exits return a
// TaskProcessWaitError value that preserves the final snapshot.
//
// Example:
//
// c.PERFORM(process.TaskProcessWait{ID: "proc-1"})
type TaskProcessWait struct {
// ID identifies a managed process started by this service.
ID string
}
// TaskProcessWaitError is returned as the task value when TaskProcessWait
// completes with a non-successful process outcome. It preserves the final
// process snapshot while still behaving like the underlying wait error.
type TaskProcessWaitError struct {
Info Info
Err error
}
// Error implements error.
func (e *TaskProcessWaitError) Error() string {
if e == nil || e.Err == nil {
return ""
}
return e.Err.Error()
}
// Unwrap returns the underlying wait error.
func (e *TaskProcessWaitError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// TaskProcessOutput requests the captured output of a managed process through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessOutput{ID: "proc-1"})
type TaskProcessOutput struct {
// ID identifies a managed process started by this service.
ID string
}
// TaskProcessInput writes data to the stdin of a managed process through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessInput{ID: "proc-1", Input: "hello\n"})
type TaskProcessInput struct {
// ID identifies a managed process started by this service.
ID string
// Input is written verbatim to the process stdin pipe.
Input string
}
// TaskProcessCloseStdin closes the stdin pipe of a managed process through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessCloseStdin{ID: "proc-1"})
type TaskProcessCloseStdin struct {
// ID identifies a managed process started by this service.
ID string
}
// TaskProcessList requests a snapshot of managed processes through Core.PERFORM.
// If RunningOnly is true, only active processes are returned.
//
// Example:
//
// c.PERFORM(process.TaskProcessList{RunningOnly: true})
type TaskProcessList struct {
RunningOnly bool
}
// TaskProcessRemove removes a completed managed process through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessRemove{ID: "proc-1"})
type TaskProcessRemove struct {
// ID identifies a managed process started by this service.
ID string
}
// TaskProcessClear removes all completed managed processes through Core.PERFORM.
//
// Example:
//
// c.PERFORM(process.TaskProcessClear{})
type TaskProcessClear struct{}
// ActionProcessStarted is broadcast when a process begins execution. // ActionProcessStarted is broadcast when a process begins execution.
//
// Example:
//
// case process.ActionProcessStarted: fmt.Println("started", msg.ID)
type ActionProcessStarted struct { type ActionProcessStarted struct {
ID string ID string
Command string Command string
@ -200,10 +21,6 @@ type ActionProcessStarted struct {
// ActionProcessOutput is broadcast for each line of output. // ActionProcessOutput is broadcast for each line of output.
// Subscribe to this for real-time streaming. // Subscribe to this for real-time streaming.
//
// Example:
//
// case process.ActionProcessOutput: fmt.Println(msg.Line)
type ActionProcessOutput struct { type ActionProcessOutput struct {
ID string ID string
Line string Line string
@ -212,23 +29,126 @@ type ActionProcessOutput struct {
// ActionProcessExited is broadcast when a process completes. // ActionProcessExited is broadcast when a process completes.
// Check ExitCode for success (0) or failure. // Check ExitCode for success (0) or failure.
//
// Example:
//
// case process.ActionProcessExited: fmt.Println(msg.ExitCode)
type ActionProcessExited struct { type ActionProcessExited struct {
ID string ID string
ExitCode int ExitCode int
Duration time.Duration Duration time.Duration
Error error // Set for failed starts, non-zero exits, or killed processes. Error error // Non-nil if failed to start or was killed
} }
// ActionProcessKilled is broadcast when a process is terminated. // ActionProcessKilled is broadcast when a process is terminated.
//
// Example:
//
// case process.ActionProcessKilled: fmt.Println(msg.Signal)
type ActionProcessKilled struct { type ActionProcessKilled struct {
ID string ID string
Signal string Signal string
} }
// --- Core Action Handlers ---------------------------------------------------
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
if command == "" {
return core.Result{Value: core.E("process.run", "command is required", nil), OK: false}
}
runOpts := RunOptions{
Command: command,
Dir: opts.String("dir"),
}
if r := opts.Get("args"); r.OK {
runOpts.Args = optionStrings(r.Value)
}
if r := opts.Get("env"); r.OK {
runOpts.Env = optionStrings(r.Value)
}
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: core.E("process.start", "command is required", nil), OK: false}
}
runOpts := RunOptions{
Command: command,
Dir: opts.String("dir"),
Detach: opts.Bool("detach"),
}
if r := opts.Get("args"); r.OK {
runOpts.Args = optionStrings(r.Value)
}
if r := opts.Get("env"); r.OK {
runOpts.Env = optionStrings(r.Value)
}
r := s.StartWithOptions(ctx, runOpts)
if !r.OK {
return r
}
return core.Result{Value: r.Value.(*ManagedProcess).ID, OK: true}
}
func (s *Service) handleKill(_ 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: core.E("process.kill", core.Concat("kill failed: ", id), err), OK: false}
}
return core.Result{OK: true}
}
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(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: s.managed.Names(), OK: true}
}
func (s *Service) handleGet(_ 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: core.E("process.get", core.Concat("not found: ", id), err), OK: false}
}
return core.Result{Value: proc.Info(), OK: true}
}
func optionStrings(value any) []string {
switch typed := value.(type) {
case nil:
return nil
case []string:
return append([]string(nil), typed...)
case []any:
result := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
return nil
}
result = append(result, text)
}
return result
default:
return nil
}
}

View file

@ -4,6 +4,8 @@ import "sync"
// RingBuffer is a fixed-size circular buffer that overwrites old data. // RingBuffer is a fixed-size circular buffer that overwrites old data.
// Thread-safe for concurrent reads and writes. // Thread-safe for concurrent reads and writes.
//
// rb := process.NewRingBuffer(1024)
type RingBuffer struct { type RingBuffer struct {
data []byte data []byte
size int size int
@ -14,10 +16,13 @@ type RingBuffer struct {
} }
// NewRingBuffer creates a ring buffer with the given capacity. // NewRingBuffer creates a ring buffer with the given capacity.
//
// rb := process.NewRingBuffer(256)
func NewRingBuffer(size int) *RingBuffer { func NewRingBuffer(size int) *RingBuffer {
if size < 0 { if size <= 0 {
size = 0 size = 1
} }
return &RingBuffer{ return &RingBuffer{
data: make([]byte, size), data: make([]byte, size),
size: size, size: size,
@ -29,10 +34,6 @@ func (rb *RingBuffer) Write(p []byte) (n int, err error) {
rb.mu.Lock() rb.mu.Lock()
defer rb.mu.Unlock() defer rb.mu.Unlock()
if rb.size == 0 {
return len(p), nil
}
for _, b := range p { for _, b := range p {
rb.data[rb.end] = b rb.data[rb.end] = b
rb.end = (rb.end + 1) % rb.size rb.end = (rb.end + 1) % rb.size

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestRingBuffer(t *testing.T) { func TestRingBuffer_Basics_Good(t *testing.T) {
t.Run("write and read", func(t *testing.T) { t.Run("write and read", func(t *testing.T) {
rb := NewRingBuffer(10) rb := NewRingBuffer(10)
@ -69,18 +69,4 @@ func TestRingBuffer(t *testing.T) {
bytes[0] = 'x' bytes[0] = 'x'
assert.Equal(t, "hello", rb.String()) assert.Equal(t, "hello", rb.String())
}) })
t.Run("zero or negative capacity is a no-op", func(t *testing.T) {
for _, size := range []int{0, -1} {
rb := NewRingBuffer(size)
n, err := rb.Write([]byte("discarded"))
assert.NoError(t, err)
assert.Equal(t, len("discarded"), n)
assert.Equal(t, 0, rb.Cap())
assert.Equal(t, 0, rb.Len())
assert.Equal(t, "", rb.String())
assert.Nil(t, rb.Bytes())
}
})
} }

105
daemon.go
View file

@ -2,22 +2,15 @@ package process
import ( import (
"context" "context"
"errors"
"os"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" "dappco.re/go/core"
) )
// DaemonOptions configures daemon mode execution. // DaemonOptions configures daemon mode execution.
// //
// Example: // opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"}
//
// opts := process.DaemonOptions{
// PIDFile: "/var/run/myapp.pid",
// HealthAddr: "127.0.0.1:0",
// }
type DaemonOptions struct { type DaemonOptions struct {
// PIDFile path for single-instance enforcement. // PIDFile path for single-instance enforcement.
// Leave empty to skip PID file management. // Leave empty to skip PID file management.
@ -39,11 +32,13 @@ type DaemonOptions struct {
Registry *Registry Registry *Registry
// RegistryEntry provides the code and daemon name for registration. // RegistryEntry provides the code and daemon name for registration.
// PID, Health, Project, Binary, and Started are filled automatically. // PID, Health, and Started are filled automatically.
RegistryEntry DaemonEntry RegistryEntry DaemonEntry
} }
// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown. // Daemon manages daemon lifecycle: PID file, health server, graceful shutdown.
//
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
type Daemon struct { type Daemon struct {
opts DaemonOptions opts DaemonOptions
pid *PIDFile pid *PIDFile
@ -54,9 +49,7 @@ type Daemon struct {
// NewDaemon creates a daemon runner with the given options. // NewDaemon creates a daemon runner with the given options.
// //
// Example: // daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"})
//
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
func NewDaemon(opts DaemonOptions) *Daemon { func NewDaemon(opts DaemonOptions) *Daemon {
if opts.ShutdownTimeout == 0 { if opts.ShutdownTimeout == 0 {
opts.ShutdownTimeout = 30 * time.Second opts.ShutdownTimeout = 30 * time.Second
@ -79,16 +72,12 @@ func NewDaemon(opts DaemonOptions) *Daemon {
} }
// Start initialises the daemon (PID file, health server). // Start initialises the daemon (PID file, health server).
//
// Example:
//
// if err := daemon.Start(); err != nil { return err }
func (d *Daemon) Start() error { func (d *Daemon) Start() error {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
if d.running { 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 { if d.pid != nil {
@ -106,52 +95,38 @@ func (d *Daemon) Start() error {
} }
} }
d.running = true
// Auto-register if registry is set // Auto-register if registry is set
if d.opts.Registry != nil { if d.opts.Registry != nil {
entry := d.opts.RegistryEntry entry := d.opts.RegistryEntry
entry.PID = os.Getpid() entry.PID = currentPID()
if d.health != nil { if d.health != nil {
entry.Health = d.health.Addr() entry.Health = d.health.Addr()
} }
if entry.Project == "" {
if wd, err := os.Getwd(); err == nil {
entry.Project = wd
}
}
if entry.Binary == "" {
if binary, err := os.Executable(); err == nil {
entry.Binary = binary
}
}
if err := d.opts.Registry.Register(entry); err != nil { if err := d.opts.Registry.Register(entry); err != nil {
if d.health != nil { if d.health != nil {
_ = d.health.Stop(context.Background()) shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
_ = d.health.Stop(shutdownCtx)
cancel()
} }
if d.pid != nil { if d.pid != nil {
_ = d.pid.Release() _ = d.pid.Release()
} }
return coreerr.E("Daemon.Start", "registry", err) d.running = false
return core.E("daemon.start", "registry", err)
} }
} }
d.running = true
return nil return nil
} }
// Run blocks until the context is cancelled. // Run blocks until the context is cancelled.
//
// Example:
//
// if err := daemon.Run(ctx); err != nil { return err }
func (d *Daemon) Run(ctx context.Context) error { func (d *Daemon) Run(ctx context.Context) error {
if ctx == nil {
return coreerr.E("Daemon.Run", "daemon context is required", ErrDaemonContextRequired)
}
d.mu.Lock() d.mu.Lock()
if !d.running { if !d.running {
d.mu.Unlock() 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() d.mu.Unlock()
@ -161,10 +136,6 @@ func (d *Daemon) Run(ctx context.Context) error {
} }
// Stop performs graceful shutdown. // Stop performs graceful shutdown.
//
// Example:
//
// _ = daemon.Stop()
func (d *Daemon) Stop() error { func (d *Daemon) Stop() error {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
@ -178,75 +149,45 @@ func (d *Daemon) Stop() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
defer cancel() defer cancel()
// Mark the daemon unavailable before tearing down listeners or registry state.
if d.health != nil { if d.health != nil {
d.health.SetReady(false) d.health.SetReady(false)
}
if d.health != nil {
if err := d.health.Stop(shutdownCtx); err != nil { 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 d.pid != nil {
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) { 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))
} }
} }
// Auto-unregister after the daemon has stopped serving traffic and // Auto-unregister
// relinquished its PID file.
if d.opts.Registry != nil { if d.opts.Registry != nil {
if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil {
errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) errs = append(errs, core.E("daemon.stop", "registry", err))
} }
} }
d.running = false d.running = false
if len(errs) > 0 { if len(errs) > 0 {
return errors.Join(errs...) return core.ErrorJoin(errs...)
} }
return nil return nil
} }
// SetReady sets the daemon readiness status for `/ready`. // SetReady sets the daemon readiness status for health checks.
//
// Example:
//
// daemon.SetReady(false)
func (d *Daemon) SetReady(ready bool) { func (d *Daemon) SetReady(ready bool) {
if d.health != nil { if d.health != nil {
d.health.SetReady(ready) d.health.SetReady(ready)
} }
} }
// Ready reports whether the daemon is currently ready for traffic.
//
// Example:
//
// if daemon.Ready() {
// // expose the service to callers
// }
func (d *Daemon) Ready() bool {
if d.health != nil {
return d.health.Ready()
}
return false
}
// HealthAddr returns the health server address, or empty if disabled. // HealthAddr returns the health server address, or empty if disabled.
//
// Example:
//
// addr := daemon.HealthAddr()
func (d *Daemon) HealthAddr() string { func (d *Daemon) HealthAddr() string {
if d.health != nil { if d.health != nil {
return d.health.Addr() return d.health.Addr()
} }
return "" return ""
} }
// ErrDaemonContextRequired is returned when Run is called without a context.
var ErrDaemonContextRequired = coreerr.E("", "daemon context is required", nil)

View file

@ -4,17 +4,16 @@ import (
"context" "context"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sync"
"testing" "testing"
"time" "time"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDaemon_StartAndStop(t *testing.T) { func TestDaemon_Lifecycle_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid") pidPath := core.JoinPath(t.TempDir(), "test.pid")
d := NewDaemon(DaemonOptions{ d := NewDaemon(DaemonOptions{
PIDFile: pidPath, PIDFile: pidPath,
@ -37,166 +36,7 @@ func TestDaemon_StartAndStop(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) { func TestDaemon_AlreadyRunning_Bad(t *testing.T) {
blockCheck := make(chan struct{})
checkEntered := make(chan struct{})
var once sync.Once
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
ShutdownTimeout: 5 * time.Second,
HealthChecks: []HealthCheck{
func() error {
once.Do(func() { close(checkEntered) })
<-blockCheck
return nil
},
},
})
err := d.Start()
require.NoError(t, err)
addr := d.HealthAddr()
require.NotEmpty(t, addr)
healthErr := make(chan error, 1)
go func() {
resp, err := http.Get("http://" + addr + "/health")
if err != nil {
healthErr <- err
return
}
_ = resp.Body.Close()
healthErr <- nil
}()
select {
case <-checkEntered:
case <-time.After(2 * time.Second):
t.Fatal("/health request did not enter the blocking check")
}
stopDone := make(chan error, 1)
go func() {
stopDone <- d.Stop()
}()
require.Eventually(t, func() bool {
return !d.Ready()
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes")
select {
case err := <-stopDone:
t.Fatalf("daemon stopped too early: %v", err)
default:
}
close(blockCheck)
select {
case err := <-stopDone:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("daemon stop did not finish after health check unblocked")
}
select {
case err := <-healthErr:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("/health request did not finish")
}
}
func TestDaemon_StopUnregistersAfterHealthShutdownCompletes(t *testing.T) {
blockCheck := make(chan struct{})
checkEntered := make(chan struct{})
var once sync.Once
dir := t.TempDir()
reg := NewRegistry(filepath.Join(dir, "registry"))
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
ShutdownTimeout: 5 * time.Second,
Registry: reg,
RegistryEntry: DaemonEntry{
Code: "test-app",
Daemon: "serve",
},
HealthChecks: []HealthCheck{
func() error {
once.Do(func() { close(checkEntered) })
<-blockCheck
return nil
},
},
})
err := d.Start()
require.NoError(t, err)
addr := d.HealthAddr()
require.NotEmpty(t, addr)
healthErr := make(chan error, 1)
go func() {
resp, err := http.Get("http://" + addr + "/health")
if err != nil {
healthErr <- err
return
}
_ = resp.Body.Close()
healthErr <- nil
}()
select {
case <-checkEntered:
case <-time.After(2 * time.Second):
t.Fatal("/health request did not enter the blocking check")
}
stopDone := make(chan error, 1)
go func() {
stopDone <- d.Stop()
}()
require.Eventually(t, func() bool {
return !d.Ready()
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes")
_, ok := reg.Get("test-app", "serve")
assert.True(t, ok, "daemon should remain registered until health shutdown completes")
select {
case err := <-stopDone:
t.Fatalf("daemon stopped too early: %v", err)
default:
}
close(blockCheck)
select {
case err := <-stopDone:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("daemon stop did not finish after health check unblocked")
}
require.Eventually(t, func() bool {
_, ok := reg.Get("test-app", "serve")
return !ok
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister after health shutdown completes")
select {
case err := <-healthErr:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("/health request did not finish")
}
}
func TestDaemon_DoubleStartFails(t *testing.T) {
d := NewDaemon(DaemonOptions{ d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0", HealthAddr: "127.0.0.1:0",
}) })
@ -210,7 +50,7 @@ func TestDaemon_DoubleStartFails(t *testing.T) {
assert.Contains(t, err.Error(), "already running") assert.Contains(t, err.Error(), "already running")
} }
func TestDaemon_RunWithoutStartFails(t *testing.T) { func TestDaemon_RunUnstarted_Bad(t *testing.T) {
d := NewDaemon(DaemonOptions{}) d := NewDaemon(DaemonOptions{})
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -221,15 +61,7 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) {
assert.Contains(t, err.Error(), "not started") assert.Contains(t, err.Error(), "not started")
} }
func TestDaemon_RunNilContextFails(t *testing.T) { func TestDaemon_SetReady_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{})
err := d.Run(nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrDaemonContextRequired)
}
func TestDaemon_SetReady(t *testing.T) {
d := NewDaemon(DaemonOptions{ d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0", HealthAddr: "127.0.0.1:0",
}) })
@ -243,32 +75,25 @@ func TestDaemon_SetReady(t *testing.T) {
resp, _ := http.Get("http://" + addr + "/ready") resp, _ := http.Get("http://" + addr + "/ready")
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close() _ = resp.Body.Close()
assert.True(t, d.Ready())
d.SetReady(false) d.SetReady(false)
assert.False(t, d.Ready())
resp, _ = http.Get("http://" + addr + "/ready") resp, _ = http.Get("http://" + addr + "/ready")
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
_ = resp.Body.Close() _ = resp.Body.Close()
} }
func TestDaemon_ReadyWithoutHealthServer(t *testing.T) { func TestDaemon_HealthAddrDisabled_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{})
assert.False(t, d.Ready())
}
func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) {
d := NewDaemon(DaemonOptions{}) d := NewDaemon(DaemonOptions{})
assert.Empty(t, d.HealthAddr()) assert.Empty(t, d.HealthAddr())
} }
func TestDaemon_DefaultShutdownTimeout(t *testing.T) { func TestDaemon_DefaultTimeout_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{}) d := NewDaemon(DaemonOptions{})
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
} }
func TestDaemon_RunBlocksUntilCancelled(t *testing.T) { func TestDaemon_RunBlocking_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{ d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0", HealthAddr: "127.0.0.1:0",
}) })
@ -301,7 +126,7 @@ func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
} }
} }
func TestDaemon_StopIdempotent(t *testing.T) { func TestDaemon_StopIdempotent_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{}) d := NewDaemon(DaemonOptions{})
// Stop without Start should be a no-op // Stop without Start should be a no-op
@ -309,13 +134,9 @@ func TestDaemon_StopIdempotent(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestDaemon_AutoRegisters(t *testing.T) { func TestDaemon_AutoRegister_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(filepath.Join(dir, "daemons")) reg := NewRegistry(core.JoinPath(dir, "daemons"))
wd, err := os.Getwd()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
d := NewDaemon(DaemonOptions{ d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0", HealthAddr: "127.0.0.1:0",
@ -326,7 +147,7 @@ func TestDaemon_AutoRegisters(t *testing.T) {
}, },
}) })
err = d.Start() err := d.Start()
require.NoError(t, err) require.NoError(t, err)
// Should be registered // Should be registered
@ -334,8 +155,6 @@ func TestDaemon_AutoRegisters(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, os.Getpid(), entry.PID) assert.Equal(t, os.Getpid(), entry.PID)
assert.NotEmpty(t, entry.Health) assert.NotEmpty(t, entry.Health)
assert.Equal(t, wd, entry.Project)
assert.Equal(t, exe, entry.Binary)
// Stop should unregister // Stop should unregister
err = d.Stop() err = d.Stop()
@ -344,40 +163,3 @@ func TestDaemon_AutoRegisters(t *testing.T) {
_, ok = reg.Get("test-app", "serve") _, ok = reg.Get("test-app", "serve")
assert.False(t, ok) assert.False(t, ok)
} }
func TestDaemon_StartRollsBackOnRegistryFailure(t *testing.T) {
dir := t.TempDir()
pidPath := filepath.Join(dir, "daemon.pid")
regDir := filepath.Join(dir, "registry")
require.NoError(t, os.MkdirAll(regDir, 0o755))
require.NoError(t, os.Chmod(regDir, 0o555))
d := NewDaemon(DaemonOptions{
PIDFile: pidPath,
HealthAddr: "127.0.0.1:0",
Registry: NewRegistry(regDir),
RegistryEntry: DaemonEntry{
Code: "broken",
Daemon: "start",
},
})
err := d.Start()
require.Error(t, err)
_, statErr := os.Stat(pidPath)
assert.True(t, os.IsNotExist(statErr))
addr := d.HealthAddr()
require.NotEmpty(t, addr)
client := &http.Client{Timeout: 250 * time.Millisecond}
resp, reqErr := client.Get("http://" + addr + "/health")
if resp != nil {
_ = resp.Body.Close()
}
assert.Error(t, reqErr)
assert.NoError(t, d.Stop())
}

302
docs/RFC.md Normal file
View file

@ -0,0 +1,302 @@
# go-process API Contract — RFC Specification
> `dappco.re/go/core/process` — Managed process execution for the Core ecosystem.
> This package is the ONLY package that imports `os/exec`. Everything else uses
> `c.Process()` which delegates to Actions registered by this package.
**Status:** v0.8.0
**Module:** `dappco.re/go/core/process`
**Depends on:** core/go v0.8.0
---
## 1. Purpose
go-process provides the implementation behind `c.Process()`. Core defines the primitive (Section 17). go-process registers the Action handlers that make it work.
```
core/go defines: c.Process().Run(ctx, "git", "log")
→ calls c.Action("process.run").Run(ctx, opts)
go-process provides: c.Action("process.run", s.handleRun)
→ actually executes the command via os/exec
```
Without go-process registered, `c.Process().Run()` returns `Result{OK: false}`. Permission-by-registration.
### Current State (2026-03-30)
The codebase now matches the v0.8.0 target. The bullets below are the historical migration delta that was closed out:
- `service.go``NewService(opts) func(*Core) (any, error)`**old factory signature**. Change to `Register(c *Core) core.Result`
- `OnStartup() error` / `OnShutdown() error`**Change** to return `core.Result`
- `process.SetDefault(svc)` global singleton — **Remove**. Service registers in Core conclave
- Own ID generation `fmt.Sprintf("proc-%d", ...)`**Replace** with `core.ID()`
- Custom `map[string]*ManagedProcess`**Replace** with `core.Registry[*ManagedProcess]`
- No named Actions registered — **Add** `process.run/start/kill/list/get` during OnStartup
### File Layout
```
service.go — main service (factory, lifecycle, process execution)
registry.go — daemon registry (PID files, health, restart)
daemon.go — DaemonEntry, managed daemon lifecycle
health.go — health check endpoints
pidfile.go — PID file management
buffer.go — output buffering
actions.go — Action payloads and Core action handlers
global.go — global Default() singleton — DELETE after migration
```
---
## 2. Registration
```go
// Register is the WithService factory.
//
// core.New(core.WithService(process.Register))
func Register(c *core.Core) core.Result {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(c, Options{}),
managed: core.NewRegistry[*ManagedProcess](),
}
return core.Result{Value: svc, OK: true}
}
```
### OnStartup — Register Actions
```go
func (s *Service) OnStartup(ctx context.Context) core.Result {
c := s.Core()
c.Action("process.run", s.handleRun)
c.Action("process.start", s.handleStart)
c.Action("process.kill", s.handleKill)
c.Action("process.list", s.handleList)
c.Action("process.get", s.handleGet)
return core.Result{OK: true}
}
```
### OnShutdown — Kill Managed Processes
```go
func (s *Service) OnShutdown(ctx context.Context) core.Result {
s.managed.Each(func(id string, p *ManagedProcess) {
p.Kill()
})
return core.Result{OK: true}
}
```
---
## 3. Action Handlers
### process.run — Synchronous Execution
```go
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
args, _ := opts.Get("args").Value.([]string)
dir := opts.String("dir")
env, _ := opts.Get("env").Value.([]string)
cmd := exec.CommandContext(ctx, command, args...)
if dir != "" { cmd.Dir = dir }
if len(env) > 0 { cmd.Env = append(os.Environ(), env...) }
output, err := cmd.CombinedOutput()
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: string(output), OK: true}
}
```
> Note: go-process is the ONLY package allowed to import `os` and `os/exec`.
### process.start — Detached/Background
```go
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
args, _ := opts.Get("args").Value.([]string)
cmd := exec.Command(command, args...)
cmd.Dir = opts.String("dir")
if err := cmd.Start(); err != nil {
return core.Result{Value: err, OK: false}
}
id := core.ID()
managed := &ManagedProcess{
ID: id, PID: cmd.Process.Pid, Command: command,
cmd: cmd, done: make(chan struct{}),
}
s.managed.Set(id, managed)
go func() {
cmd.Wait()
close(managed.done)
managed.ExitCode = cmd.ProcessState.ExitCode()
}()
return core.Result{Value: id, OK: true}
}
```
### process.kill — Terminate by ID or PID
```go
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
id := opts.String("id")
if id != "" {
r := s.managed.Get(id)
if !r.OK {
return core.Result{Value: core.E("process.kill", core.Concat("not found: ", id), nil), OK: false}
}
r.Value.(*ManagedProcess).Kill()
return core.Result{OK: true}
}
pid := opts.Int("pid")
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil { return core.Result{Value: err, OK: false} }
proc.Signal(syscall.SIGTERM)
return core.Result{OK: true}
}
return core.Result{Value: core.E("process.kill", "need id or pid", nil), OK: false}
}
```
### process.list / process.get
```go
func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result {
return core.Result{Value: s.managed.Names(), OK: true}
}
func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result {
id := opts.String("id")
r := s.managed.Get(id)
if !r.OK { return r }
return core.Result{Value: r.Value.(*ManagedProcess).Info(), OK: true}
}
```
---
## 4. ManagedProcess
```go
type ManagedProcess struct {
ID string
PID int
Command string
ExitCode int
StartedAt time.Time
cmd *exec.Cmd
done chan struct{}
}
func (p *ManagedProcess) IsRunning() bool {
select {
case <-p.done: return false
default: return true
}
}
func (p *ManagedProcess) Kill() {
if p.cmd != nil && p.cmd.Process != nil {
p.cmd.Process.Signal(syscall.SIGTERM)
}
}
func (p *ManagedProcess) Done() <-chan struct{} { return p.done }
func (p *ManagedProcess) Info() ProcessInfo {
return ProcessInfo{
ID: p.ID, PID: p.PID, Command: p.Command,
Running: p.IsRunning(), ExitCode: p.ExitCode, StartedAt: p.StartedAt,
}
}
```
---
## 5. Daemon Registry
Higher-level abstraction over `process.start`:
```
process.start → low level: start a command, get a handle
daemon.Start → high level: PID file, health endpoint, restart policy, signals
```
Daemon registry uses `core.Registry[*DaemonEntry]`.
---
## 6. Error Handling
All errors via `core.E()`. String building via `core.Concat()`.
```go
return core.Result{Value: core.E("process.run", core.Concat("command failed: ", command), err), OK: false}
```
---
## 7. Test Strategy
AX-7: `TestFile_Function_{Good,Bad,Ugly}`
```
TestService_Register_Good — factory returns Result
TestService_OnStartup_Good — registers 5 Actions
TestService_HandleRun_Good — runs command, returns output
TestService_HandleRun_Bad — command not found
TestService_HandleRun_Ugly — timeout via context
TestService_HandleStart_Good — starts detached, returns ID
TestService_HandleStart_Bad — invalid command
TestService_HandleKill_Good — kills by ID
TestService_HandleKill_Bad — unknown ID
TestService_HandleList_Good — returns managed process IDs
TestService_OnShutdown_Good — kills all managed processes
TestService_Ugly_PermissionModel — no go-process = c.Process().Run() fails
```
---
## 8. Quality Gates
go-process is the ONE exception — it imports `os` and `os/exec` because it IS the process primitive. All other disallowed imports still apply:
```bash
# Should only find os/exec in service.go, os in service.go
grep -rn '"os"\|"os/exec"' *.go | grep -v _test.go
# No other disallowed imports
grep -rn '"io"\|"fmt"\|"errors"\|"log"\|"encoding/json"\|"path/filepath"\|"unsafe"\|"strings"' *.go \
| grep -v _test.go
```
---
## Consumer RFCs
| Package | RFC | Role |
|---------|-----|------|
| core/go | `core/go/docs/RFC.md` | Primitives — Process primitive (Section 17) |
| core/agent | `core/agent/docs/RFC.md` | Consumer — `c.Process().RunIn()` for git/build ops |
---
## Changelog
- 2026-03-25: v0.8.0 spec — written with full core/go domain context.

View file

@ -60,32 +60,28 @@ participate in the Core DI container and implements both `Startable` and
```go ```go
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
processes map[string]*Process managed *core.Registry[*ManagedProcess]
mu sync.RWMutex
bufSize int bufSize int
idCounter atomic.Uint64
} }
``` ```
Key behaviours: Key behaviours:
- **OnStartup**currently a no-op; reserved for future initialisation. - **OnStartup**registers the named Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
- **OnShutdown** — iterates all running processes and calls `Kill()` on each, - **OnShutdown** — iterates all running processes and calls `Kill()` on each,
ensuring no orphaned child processes when the application exits. ensuring no orphaned child processes when the application exits.
- Process IDs are generated as `proc-N` using an atomic counter, guaranteeing - Process IDs are generated with `core.ID()` and stored in a Core registry.
uniqueness without locks.
#### Registration #### Registration
The service is registered with Core via a factory function: The service is registered with Core via a factory function:
```go ```go
process.NewService(process.Options{BufferSize: 2 * 1024 * 1024}) core.New(core.WithService(process.Register))
``` ```
`NewService` returns a `func(*core.Core) (any, error)` closure — the standard `Register` returns `core.Result{Value: *Service, OK: true}` — the standard
Core service factory signature. The `Options` struct is captured by the closure Core `WithService` factory signature used by the v0.8.0 contract.
and applied when Core instantiates the service.
### Process ### Process
@ -163,12 +159,12 @@ const (
When `Service.StartWithOptions()` is called: When `Service.StartWithOptions()` is called:
``` ```
1. Generate unique ID (atomic counter) 1. Generate a unique ID with `core.ID()`
2. Create context with cancel 2. Create context with cancel
3. Build os/exec.Cmd with dir, env, pipes 3. Build os/exec.Cmd with dir, env, pipes
4. Create RingBuffer (unless DisableCapture is set) 4. Create RingBuffer (unless DisableCapture is set)
5. cmd.Start() 5. cmd.Start()
6. Store process in map 6. Store process in the Core registry
7. Broadcast ActionProcessStarted via Core.ACTION 7. Broadcast ActionProcessStarted via Core.ACTION
8. Spawn 2 goroutines to stream stdout and stderr 8. Spawn 2 goroutines to stream stdout and stderr
- Each line is written to the RingBuffer - Each line is written to the RingBuffer
@ -176,8 +172,9 @@ When `Service.StartWithOptions()` is called:
9. Spawn 1 goroutine to wait for process exit 9. Spawn 1 goroutine to wait for process exit
- Waits for output goroutines to finish first - Waits for output goroutines to finish first
- Calls cmd.Wait() - Calls cmd.Wait()
- Updates process status and exit code - Classifies the exit as exited, failed, or killed
- Closes the done channel - Closes the done channel
- Broadcasts ActionProcessKilled when the process died from a signal
- Broadcasts ActionProcessExited - Broadcasts ActionProcessExited
``` ```
@ -296,12 +293,12 @@ File naming convention: `{code}-{daemon}.json` (slashes replaced with dashes).
## exec Sub-Package ## exec Sub-Package
The `exec` package (`forge.lthn.ai/core/go-process/exec`) provides a fluent The `exec` package (`dappco.re/go/core/process/exec`) provides a fluent
wrapper around `os/exec` for simple, one-shot commands that do not need Core wrapper around `os/exec` for simple, one-shot commands that do not need Core
integration: integration:
```go ```go
import "forge.lthn.ai/core/go-process/exec" import "dappco.re/go/core/process/exec"
// Fluent API // Fluent API
err := exec.Command(ctx, "go", "build", "./..."). err := exec.Command(ctx, "go", "build", "./...").

View file

@ -101,9 +101,7 @@ go-process/
pidfile.go # PID file single-instance lock pidfile.go # PID file single-instance lock
pidfile_test.go # PID file tests pidfile_test.go # PID file tests
process.go # Process type and methods process.go # Process type and methods
process_global.go # Global singleton and convenience API
process_test.go # Process tests process_test.go # Process tests
global_test.go # Global API tests (concurrency)
registry.go # Daemon registry (JSON file store) registry.go # Daemon registry (JSON file store)
registry_test.go # Registry tests registry_test.go # Registry tests
runner.go # Pipeline runner (sequential, parallel, DAG) runner.go # Pipeline runner (sequential, parallel, DAG)
@ -142,8 +140,6 @@ go-process/
| `ErrProcessNotFound` | No process with the given ID exists in the service | | `ErrProcessNotFound` | No process with the given ID exists in the service |
| `ErrProcessNotRunning` | Operation requires a running process (e.g. SendInput, Signal) | | `ErrProcessNotRunning` | Operation requires a running process (e.g. SendInput, Signal) |
| `ErrStdinNotAvailable` | Stdin pipe is nil (already closed or never created) | | `ErrStdinNotAvailable` | Stdin pipe is nil (already closed or never created) |
| `ErrServiceNotInitialized` | Global convenience function called before `process.Init()` |
| `ServiceError` | Wraps service-level errors with a message string |
## Build Configuration ## Build Configuration

View file

@ -5,10 +5,10 @@ description: Process management with Core IPC integration for Go applications.
# go-process # go-process
`forge.lthn.ai/core/go-process` is a process management library that provides `dappco.re/go/core/process` is a process management library that provides
spawning, monitoring, and controlling external processes with real-time output spawning, monitoring, and controlling external processes with real-time output
streaming via the Core ACTION (IPC) system. It integrates directly with the streaming via the Core ACTION (IPC) system. It integrates directly with the
[Core DI framework](https://forge.lthn.ai/core/go) as a first-class service. [Core DI framework](https://dappco.re/go/core) as a first-class service.
## Features ## Features
@ -28,22 +28,17 @@ streaming via the Core ACTION (IPC) system. It integrates directly with the
```go ```go
import ( import (
"context" "context"
framework "forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
) )
// Create a Core instance with the process service // Create a Core instance with the process service registered.
c, err := framework.New( c := core.New(core.WithService(process.Register))
framework.WithName("process", process.NewService(process.Options{})),
)
if err != nil {
log.Fatal(err)
}
// Retrieve the typed service // Retrieve the typed service
svc, err := framework.ServiceFor[*process.Service](c, "process") svc, ok := core.ServiceFor[*process.Service](c, "process")
if err != nil { if !ok {
log.Fatal(err) panic("process service not registered")
} }
``` ```
@ -51,15 +46,19 @@ if err != nil {
```go ```go
// Fire-and-forget (async) // Fire-and-forget (async)
proc, err := svc.Start(ctx, "go", "test", "./...") start := svc.Start(ctx, "go", "test", "./...")
if err != nil { if !start.OK {
return err return start.Value.(error)
} }
proc := start.Value.(*process.Process)
<-proc.Done() <-proc.Done()
fmt.Println(proc.Output()) fmt.Println(proc.Output())
// Synchronous convenience // Synchronous convenience
output, err := svc.Run(ctx, "echo", "hello world") run := svc.Run(ctx, "echo", "hello world")
if run.OK {
fmt.Println(run.Value.(string))
}
``` ```
### Listen for Events ### Listen for Events
@ -67,7 +66,7 @@ output, err := svc.Run(ctx, "echo", "hello world")
Process lifecycle events are broadcast through Core's ACTION system: Process lifecycle events are broadcast through Core's ACTION system:
```go ```go
c.RegisterAction(func(c *framework.Core, msg framework.Message) error { c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
switch m := msg.(type) { switch m := msg.(type) {
case process.ActionProcessStarted: case process.ActionProcessStarted:
fmt.Printf("Started: %s (PID %d)\n", m.Command, m.PID) fmt.Printf("Started: %s (PID %d)\n", m.Command, m.PID)
@ -78,24 +77,24 @@ c.RegisterAction(func(c *framework.Core, msg framework.Message) error {
case process.ActionProcessKilled: case process.ActionProcessKilled:
fmt.Printf("Killed with %s\n", m.Signal) fmt.Printf("Killed with %s\n", m.Signal)
} }
return nil return core.Result{OK: true}
}) })
``` ```
### Global Convenience API ### Permission Model
For applications that only need a single process service, a global singleton Core's process primitive delegates to named actions registered by this module.
is available: Without `process.Register`, `c.Process().Run(...)` fails with `OK: false`.
```go ```go
// Initialise once at startup c := core.New()
process.Init(coreInstance) r := c.Process().Run(ctx, "echo", "blocked")
fmt.Println(r.OK) // false
// Then use package-level functions anywhere c = core.New(core.WithService(process.Register))
proc, _ := process.Start(ctx, "ls", "-la") _ = c.ServiceStartup(ctx, nil)
output, _ := process.Run(ctx, "date") r = c.Process().Run(ctx, "echo", "allowed")
procs := process.List() fmt.Println(r.OK) // true
running := process.Running()
``` ```
## Package Layout ## Package Layout
@ -109,7 +108,7 @@ running := process.Running()
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| Module path | `forge.lthn.ai/core/go-process` | | Module path | `dappco.re/go/core/process` |
| Go version | 1.26.0 | | Go version | 1.26.0 |
| Licence | EUPL-1.2 | | Licence | EUPL-1.2 |
@ -117,7 +116,7 @@ running := process.Running()
| Module | Purpose | | Module | Purpose |
|--------|---------| |--------|---------|
| `forge.lthn.ai/core/go` | Core DI framework (`ServiceRuntime`, `Core.ACTION`, lifecycle interfaces) | | `dappco.re/go/core` | Core DI framework (`ServiceRuntime`, `Core.ACTION`, lifecycle interfaces) |
| `github.com/stretchr/testify` | Test assertions (test-only) | | `github.com/stretchr/testify` | Test assertions (test-only) |
The package has no other runtime dependencies beyond the Go standard library The package has no other runtime dependencies beyond the Go standard library

View file

@ -0,0 +1,151 @@
# go-process v0.7.0 — Core Alignment
> Written by Cladius with full core/go domain context (2026-03-25).
> Read core/go docs/RFC.md Section 17 for the full Process primitive spec.
## What Changed in core/go
core/go v0.8.0 added:
- `c.Process()` — primitive that delegates to `c.Action("process.*")`
- `c.Action("name")` — named action registry with panic recovery
- `Startable.OnStartup()` returns `core.Result` (not `error`)
- `Registry[T]` — universal thread-safe named collection
- `core.ID()` — unique identifier primitive
go-process needs to align its factory signature and register process Actions.
## Step 1: Fix Factory Signature
Current (`service.go`):
```go
func NewService(opts Options) func(*core.Core) (any, error) {
```
Target:
```go
func Register(c *core.Core) core.Result {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(c, Options{}),
processes: make(map[string]*ManagedProcess),
}
return core.Result{Value: svc, OK: true}
}
```
This matches `core.WithService(process.Register)` — the standard pattern.
## Step 2: Register Process Actions During OnStartup
```go
func (s *Service) OnStartup(ctx context.Context) core.Result {
c := s.Core()
// Register named actions — these are what c.Process() calls
c.Action("process.run", s.handleRun)
c.Action("process.start", s.handleStart)
c.Action("process.kill", s.handleKill)
return core.Result{OK: true}
}
```
Note: `OnStartup` now returns `core.Result` not `error`.
## Step 3: Implement Action Handlers
```go
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
args, _ := opts.Get("args").Value.([]string)
dir := opts.String("dir")
env, _ := opts.Get("env").Value.([]string)
// Use existing RunWithOptions internally
out, err := s.RunWithOptions(ctx, RunOptions{
Command: command,
Args: args,
Dir: dir,
Env: env,
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: out, OK: true}
}
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
// Detached process — returns handle ID
command := opts.String("command")
args, _ := opts.Get("args").Value.([]string)
handle, err := s.Start(ctx, StartOptions{
Command: command,
Args: args,
Dir: opts.String("dir"),
Detach: opts.Bool("detach"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: handle.ID, OK: true}
}
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
id := opts.String("id")
pid := opts.Int("pid")
if id != "" {
return s.KillByID(id)
}
return s.KillByPID(pid)
}
```
## Step 4: Remove Global Singleton Pattern
Current: `process.SetDefault(svc)` and `process.Default()` global state.
Target: Service registered in Core's conclave. No global state.
The `ensureProcess()` hack in core/agent exists because go-process doesn't register properly. Once this is done, that bridge can be deleted.
## Step 5: Update OnShutdown
```go
func (s *Service) OnShutdown(ctx context.Context) core.Result {
// Kill all managed processes
for _, p := range s.processes {
p.Kill()
}
return core.Result{OK: true}
}
```
## Step 6: Use core.ID() for Process IDs
Current: `fmt.Sprintf("proc-%d", s.idCounter.Add(1))`
Target: `core.ID()` — consistent format across ecosystem.
## Step 7: AX-7 Tests
All tests renamed to `TestFile_Function_{Good,Bad,Ugly}`:
- `TestService_Register_Good` — factory returns Result
- `TestService_HandleRun_Good` — runs command via Action
- `TestService_HandleRun_Bad` — command not found
- `TestService_HandleKill_Good` — kills by ID
- `TestService_OnStartup_Good` — registers Actions
- `TestService_OnShutdown_Good` — kills all processes
## What This Unlocks
Once go-process v0.7.0 ships:
- `core.New(core.WithService(process.Register))` — standard registration
- `c.Process().Run(ctx, "git", "log")` — works end-to-end
- core/agent deletes `proc.go`, `ensureProcess()`, `ProcessRegister`
- Tests can mock process execution by registering a fake handler
## Dependencies
- core/go v0.8.0 (already done — Action system, Process primitive, Result lifecycle)
- No other deps change

View file

@ -1,12 +0,0 @@
package process
import coreerr "dappco.re/go/core/log"
// ServiceError wraps a service-level failure with a message string.
//
// Example:
//
// return process.ServiceError("context is required", process.ErrContextRequired)
func ServiceError(message string, err error) error {
return coreerr.E("ServiceError", message, err)
}

View file

@ -1,15 +0,0 @@
package process
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceError(t *testing.T) {
err := ServiceError("service failed", ErrContextRequired)
require.Error(t, err)
assert.Contains(t, err.Error(), "service failed")
assert.ErrorIs(t, err, ErrContextRequired)
}

6
exec/doc.go Normal file
View file

@ -0,0 +1,6 @@
// Package exec provides a small command wrapper around `os/exec` with
// structured logging hooks.
//
// ctx := context.Background()
// out, err := exec.Command(ctx, "echo", "hello").Output()
package exec

View file

@ -3,34 +3,27 @@ package exec
import ( import (
"bytes" "bytes"
"context" "context"
"fmt" "io"
"os" "os"
"os/exec" "os/exec"
"strings"
coreerr "dappco.re/go/core/log" "dappco.re/go/core"
goio "io"
) )
// ErrCommandContextRequired is returned when a command is created without a context.
var ErrCommandContextRequired = coreerr.E("", "exec: command context is required", nil)
// Options configures command execution. // Options configures command execution.
//
// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}}
type Options struct { type Options struct {
Dir string Dir string
Env []string Env []string
Stdin goio.Reader Stdin io.Reader
Stdout goio.Writer Stdout io.Writer
Stderr goio.Writer Stderr io.Writer
// Background runs the command asynchronously and returns from Run immediately.
Background bool
} }
// Command wraps os/exec.Command with logging and context. // Command wraps `os/exec.Command` with logging and context.
// //
// Example: // cmd := exec.Command(ctx, "git", "status").WithDir("/workspace")
//
// cmd := exec.Command(ctx, "go", "test", "./...")
func Command(ctx context.Context, name string, args ...string) *Cmd { func Command(ctx context.Context, name string, args ...string) *Cmd {
return &Cmd{ return &Cmd{
name: name, name: name,
@ -50,51 +43,31 @@ type Cmd struct {
} }
// WithDir sets the working directory. // WithDir sets the working directory.
//
// Example:
//
// cmd.WithDir("/tmp")
func (c *Cmd) WithDir(dir string) *Cmd { func (c *Cmd) WithDir(dir string) *Cmd {
c.opts.Dir = dir c.opts.Dir = dir
return c return c
} }
// WithEnv sets the environment variables. // WithEnv sets the environment variables.
//
// Example:
//
// cmd.WithEnv([]string{"CGO_ENABLED=0"})
func (c *Cmd) WithEnv(env []string) *Cmd { func (c *Cmd) WithEnv(env []string) *Cmd {
c.opts.Env = env c.opts.Env = env
return c return c
} }
// WithStdin sets stdin. // WithStdin sets stdin.
// func (c *Cmd) WithStdin(r io.Reader) *Cmd {
// Example:
//
// cmd.WithStdin(strings.NewReader("input"))
func (c *Cmd) WithStdin(r goio.Reader) *Cmd {
c.opts.Stdin = r c.opts.Stdin = r
return c return c
} }
// WithStdout sets stdout. // WithStdout sets stdout.
// func (c *Cmd) WithStdout(w io.Writer) *Cmd {
// Example:
//
// cmd.WithStdout(os.Stdout)
func (c *Cmd) WithStdout(w goio.Writer) *Cmd {
c.opts.Stdout = w c.opts.Stdout = w
return c return c
} }
// WithStderr sets stderr. // WithStderr sets stderr.
// func (c *Cmd) WithStderr(w io.Writer) *Cmd {
// Example:
//
// cmd.WithStderr(os.Stderr)
func (c *Cmd) WithStderr(w goio.Writer) *Cmd {
c.opts.Stderr = w c.opts.Stderr = w
return c return c
} }
@ -106,56 +79,14 @@ func (c *Cmd) WithLogger(l Logger) *Cmd {
return c return c
} }
// WithBackground configures whether Run should wait for the command to finish.
func (c *Cmd) WithBackground(background bool) *Cmd {
c.opts.Background = background
return c
}
// Start launches the command.
//
// Example:
//
// if err := cmd.Start(); err != nil { return err }
func (c *Cmd) Start() error {
if err := c.prepare(); err != nil {
return err
}
c.logDebug("executing command")
if err := c.cmd.Start(); err != nil {
wrapped := wrapError("Cmd.Start", err, c.name, c.args)
c.logError("command failed", wrapped)
return wrapped
}
if c.opts.Background {
go func(cmd *exec.Cmd) {
_ = cmd.Wait()
}(c.cmd)
}
return nil
}
// Run executes the command and waits for it to finish. // Run executes the command and waits for it to finish.
// It automatically logs the command execution at debug level. // It automatically logs the command execution at debug level.
//
// Example:
//
// if err := cmd.Run(); err != nil { return err }
func (c *Cmd) Run() error { func (c *Cmd) Run() error {
if c.opts.Background { c.prepare()
return c.Start()
}
if err := c.prepare(); err != nil {
return err
}
c.logDebug("executing command") c.logDebug("executing command")
if err := c.cmd.Run(); err != nil { if err := c.cmd.Run(); err != nil {
wrapped := wrapError("Cmd.Run", err, c.name, c.args) wrapped := wrapError("exec.cmd.run", err, c.name, c.args)
c.logError("command failed", wrapped) c.logError("command failed", wrapped)
return wrapped return wrapped
} }
@ -163,23 +94,13 @@ func (c *Cmd) Run() error {
} }
// Output runs the command and returns its standard output. // Output runs the command and returns its standard output.
//
// Example:
//
// out, err := cmd.Output()
func (c *Cmd) Output() ([]byte, error) { func (c *Cmd) Output() ([]byte, error) {
if c.opts.Background { c.prepare()
return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil)
}
if err := c.prepare(); err != nil {
return nil, err
}
c.logDebug("executing command") c.logDebug("executing command")
out, err := c.cmd.Output() out, err := c.cmd.Output()
if err != nil { if err != nil {
wrapped := wrapError("Cmd.Output", err, c.name, c.args) wrapped := wrapError("exec.cmd.output", err, c.name, c.args)
c.logError("command failed", wrapped) c.logError("command failed", wrapped)
return nil, wrapped return nil, wrapped
} }
@ -187,35 +108,26 @@ func (c *Cmd) Output() ([]byte, error) {
} }
// CombinedOutput runs the command and returns its combined standard output and standard error. // CombinedOutput runs the command and returns its combined standard output and standard error.
//
// Example:
//
// out, err := cmd.CombinedOutput()
func (c *Cmd) CombinedOutput() ([]byte, error) { func (c *Cmd) CombinedOutput() ([]byte, error) {
if c.opts.Background { c.prepare()
return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil)
}
if err := c.prepare(); err != nil {
return nil, err
}
c.logDebug("executing command") c.logDebug("executing command")
out, err := c.cmd.CombinedOutput() out, err := c.cmd.CombinedOutput()
if err != nil { if err != nil {
wrapped := wrapError("Cmd.CombinedOutput", err, c.name, c.args) wrapped := wrapError("exec.cmd.combined_output", err, c.name, c.args)
c.logError("command failed", wrapped) c.logError("command failed", wrapped)
return out, wrapped return out, wrapped
} }
return out, nil return out, nil
} }
func (c *Cmd) prepare() error { func (c *Cmd) prepare() {
if c.ctx == nil { ctx := c.ctx
return coreerr.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired) if ctx == nil {
ctx = context.Background()
} }
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...) c.cmd = exec.CommandContext(ctx, c.name, c.args...)
c.cmd.Dir = c.opts.Dir c.cmd.Dir = c.opts.Dir
if len(c.opts.Env) > 0 { if len(c.opts.Env) > 0 {
@ -225,31 +137,27 @@ func (c *Cmd) prepare() error {
c.cmd.Stdin = c.opts.Stdin c.cmd.Stdin = c.opts.Stdin
c.cmd.Stdout = c.opts.Stdout c.cmd.Stdout = c.opts.Stdout
c.cmd.Stderr = c.opts.Stderr c.cmd.Stderr = c.opts.Stderr
return nil
} }
// RunQuiet executes the command suppressing stdout unless there is an error. // RunQuiet executes the command suppressing stdout unless there is an error.
// Useful for internal commands. // Useful for internal commands.
// //
// Example: // _ = exec.RunQuiet(ctx, "go", "test", "./...")
//
// err := exec.RunQuiet(ctx, "go", "vet", "./...")
func RunQuiet(ctx context.Context, name string, args ...string) error { func RunQuiet(ctx context.Context, name string, args ...string) error {
var stderr bytes.Buffer var stderr bytes.Buffer
cmd := Command(ctx, name, args...).WithStderr(&stderr) cmd := Command(ctx, name, args...).WithStderr(&stderr)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
// Include stderr in error message return core.E("exec.run_quiet", core.Trim(stderr.String()), err)
return coreerr.E("RunQuiet", strings.TrimSpace(stderr.String()), err)
} }
return nil return nil
} }
func wrapError(caller string, err error, name string, args []string) error { func wrapError(caller string, err error, name string, args []string) error {
cmdStr := name + " " + strings.Join(args, " ") cmdStr := commandString(name, args)
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return coreerr.E(caller, fmt.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err) return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
} }
return coreerr.E(caller, fmt.Sprintf("failed to execute %q", cmdStr), err) return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err)
} }
func (c *Cmd) getLogger() Logger { func (c *Cmd) getLogger() Logger {
@ -260,9 +168,17 @@ func (c *Cmd) getLogger() Logger {
} }
func (c *Cmd) logDebug(msg string) { func (c *Cmd) logDebug(msg string) {
c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " ")) c.getLogger().Debug(msg, "cmd", c.name, "args", core.Join(" ", c.args...))
} }
func (c *Cmd) logError(msg string, err error) { func (c *Cmd) logError(msg string, err error) {
c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err) c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", err)
}
func commandString(name string, args []string) string {
if len(args) == 0 {
return name
}
parts := append([]string{name}, args...)
return core.Join(" ", parts...)
} }

View file

@ -2,17 +2,10 @@ package exec_test
import ( import (
"context" "context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"time"
"dappco.re/go/core"
"dappco.re/go/core/process/exec" "dappco.re/go/core/process/exec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// mockLogger captures log calls for testing // mockLogger captures log calls for testing
@ -34,7 +27,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) {
m.errorCalls = append(m.errorCalls, logCall{msg, keyvals}) m.errorCalls = append(m.errorCalls, logCall{msg, keyvals})
} }
func TestCommand_Run_Good_LogsDebug(t *testing.T) { func TestCommand_Run_Good(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
ctx := context.Background() ctx := context.Background()
@ -56,7 +49,7 @@ func TestCommand_Run_Good_LogsDebug(t *testing.T) {
} }
} }
func TestCommand_Run_Bad_LogsError(t *testing.T) { func TestCommand_Run_Bad(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
ctx := context.Background() ctx := context.Background()
@ -78,6 +71,14 @@ func TestCommand_Run_Bad_LogsError(t *testing.T) {
} }
} }
func TestCommand_Run_WithNilContext_Good(t *testing.T) {
var ctx context.Context
if err := exec.Command(ctx, "echo", "hello").Run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCommand_Output_Good(t *testing.T) { func TestCommand_Output_Good(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
ctx := context.Background() ctx := context.Background()
@ -88,7 +89,7 @@ func TestCommand_Output_Good(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if strings.TrimSpace(string(out)) != "test" { if core.Trim(string(out)) != "test" {
t.Errorf("expected 'test', got %q", string(out)) t.Errorf("expected 'test', got %q", string(out))
} }
if len(logger.debugCalls) != 1 { if len(logger.debugCalls) != 1 {
@ -106,7 +107,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if strings.TrimSpace(string(out)) != "combined" { if core.Trim(string(out)) != "combined" {
t.Errorf("expected 'combined', got %q", string(out)) t.Errorf("expected 'combined', got %q", string(out))
} }
if len(logger.debugCalls) != 1 { if len(logger.debugCalls) != 1 {
@ -114,14 +115,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
} }
} }
func TestNopLogger(t *testing.T) { func TestNopLogger_Methods_Good(t *testing.T) {
// Verify NopLogger doesn't panic // Verify NopLogger doesn't panic
var nop exec.NopLogger var nop exec.NopLogger
nop.Debug("msg", "key", "val") nop.Debug("msg", "key", "val")
nop.Error("msg", "key", "val") nop.Error("msg", "key", "val")
} }
func TestSetDefaultLogger(t *testing.T) { func TestLogger_SetDefault_Good(t *testing.T) {
original := exec.DefaultLogger() original := exec.DefaultLogger()
defer exec.SetDefaultLogger(original) defer exec.SetDefaultLogger(original)
@ -139,30 +140,7 @@ func TestSetDefaultLogger(t *testing.T) {
} }
} }
func TestDefaultLogger_IsConcurrentSafe(t *testing.T) { func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
original := exec.DefaultLogger()
defer exec.SetDefaultLogger(original)
logger := &mockLogger{}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(2)
go func() {
defer wg.Done()
exec.SetDefaultLogger(logger)
}()
go func() {
defer wg.Done()
_ = exec.DefaultLogger()
}()
}
wg.Wait()
assert.NotNil(t, exec.DefaultLogger())
}
func TestCommand_UsesDefaultLogger(t *testing.T) {
original := exec.DefaultLogger() original := exec.DefaultLogger()
defer exec.SetDefaultLogger(original) defer exec.SetDefaultLogger(original)
@ -177,7 +155,7 @@ func TestCommand_UsesDefaultLogger(t *testing.T) {
} }
} }
func TestCommand_WithDir(t *testing.T) { func TestCommand_WithDir_Good(t *testing.T) {
ctx := context.Background() ctx := context.Background()
out, err := exec.Command(ctx, "pwd"). out, err := exec.Command(ctx, "pwd").
WithDir("/tmp"). WithDir("/tmp").
@ -186,13 +164,13 @@ func TestCommand_WithDir(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
trimmed := strings.TrimSpace(string(out)) trimmed := core.Trim(string(out))
if trimmed != "/tmp" && trimmed != "/private/tmp" { if trimmed != "/tmp" && trimmed != "/private/tmp" {
t.Errorf("expected /tmp or /private/tmp, got %q", trimmed) t.Errorf("expected /tmp or /private/tmp, got %q", trimmed)
} }
} }
func TestCommand_WithEnv(t *testing.T) { func TestCommand_WithEnv_Good(t *testing.T) {
ctx := context.Background() ctx := context.Background()
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR"). out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}). WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
@ -201,100 +179,32 @@ func TestCommand_WithEnv(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if strings.TrimSpace(string(out)) != "exec_val" { if core.Trim(string(out)) != "exec_val" {
t.Errorf("expected 'exec_val', got %q", string(out)) t.Errorf("expected 'exec_val', got %q", string(out))
} }
} }
func TestCommand_WithStdinStdoutStderr(t *testing.T) { func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
ctx := context.Background() ctx := context.Background()
input := strings.NewReader("piped input\n") input := core.NewReader("piped input\n")
var stdout, stderr strings.Builder stdout := core.NewBuilder()
stderr := core.NewBuilder()
err := exec.Command(ctx, "cat"). err := exec.Command(ctx, "cat").
WithStdin(input). WithStdin(input).
WithStdout(&stdout). WithStdout(stdout).
WithStderr(&stderr). WithStderr(stderr).
WithLogger(&mockLogger{}). WithLogger(&mockLogger{}).
Run() Run()
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if strings.TrimSpace(stdout.String()) != "piped input" { if core.Trim(stdout.String()) != "piped input" {
t.Errorf("expected 'piped input', got %q", stdout.String()) t.Errorf("expected 'piped input', got %q", stdout.String())
} }
} }
func TestCommand_Run_Background(t *testing.T) { func TestRunQuiet_Command_Good(t *testing.T) {
logger := &mockLogger{}
ctx := context.Background()
dir := t.TempDir()
marker := filepath.Join(dir, "marker.txt")
start := time.Now()
err := exec.Command(ctx, "sh", "-c", fmt.Sprintf("sleep 0.2; printf done > %q", marker)).
WithBackground(true).
WithLogger(logger).
Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("background run took too long: %s", elapsed)
}
deadline := time.Now().Add(2 * time.Second)
for {
data, readErr := os.ReadFile(marker)
if readErr == nil && strings.TrimSpace(string(data)) == "done" {
break
}
if time.Now().After(deadline) {
t.Fatalf("background command did not create marker file")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestCommand_NilContextRejected(t *testing.T) {
t.Run("start", func(t *testing.T) {
err := exec.Command(nil, "echo", "test").Start()
require.Error(t, err)
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
})
t.Run("run", func(t *testing.T) {
err := exec.Command(nil, "echo", "test").Run()
require.Error(t, err)
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
})
t.Run("output", func(t *testing.T) {
_, err := exec.Command(nil, "echo", "test").Output()
require.Error(t, err)
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
})
t.Run("combined output", func(t *testing.T) {
_, err := exec.Command(nil, "echo", "test").CombinedOutput()
require.Error(t, err)
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
})
}
func TestCommand_Output_BackgroundRejected(t *testing.T) {
ctx := context.Background()
_, err := exec.Command(ctx, "echo", "test").
WithBackground(true).
Output()
if err == nil {
t.Fatal("expected error")
}
}
func TestRunQuiet_Good(t *testing.T) {
ctx := context.Background() ctx := context.Background()
err := exec.RunQuiet(ctx, "echo", "quiet") err := exec.RunQuiet(ctx, "echo", "quiet")
if err != nil { if err != nil {
@ -302,7 +212,7 @@ func TestRunQuiet_Good(t *testing.T) {
} }
} }
func TestRunQuiet_Bad(t *testing.T) { func TestRunQuiet_Command_Bad(t *testing.T) {
ctx := context.Background() ctx := context.Background()
err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1") err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1")
if err == nil { if err == nil {

View file

@ -1,23 +1,19 @@
package exec package exec
import "sync"
// Logger interface for command execution logging. // Logger interface for command execution logging.
// Compatible with pkg/log.Logger and other structured loggers. // Compatible with pkg/log.Logger and other structured loggers.
//
// exec.SetDefaultLogger(myLogger)
type Logger interface { type Logger interface {
// Debug logs a debug-level message with optional key-value pairs. // Debug logs a debug-level message with optional key-value pairs.
//
// Example:
// logger.Debug("starting", "cmd", "go")
Debug(msg string, keyvals ...any) Debug(msg string, keyvals ...any)
// Error logs an error-level message with optional key-value pairs. // Error logs an error-level message with optional key-value pairs.
//
// Example:
// logger.Error("failed", "cmd", "go", "err", err)
Error(msg string, keyvals ...any) Error(msg string, keyvals ...any)
} }
// NopLogger is a no-op logger that discards all messages. // NopLogger is a no-op logger that discards all messages.
//
// var logger exec.NopLogger
type NopLogger struct{} type NopLogger struct{}
// Debug discards the message (no-op implementation). // Debug discards the message (no-op implementation).
@ -26,23 +22,13 @@ func (NopLogger) Debug(string, ...any) {}
// Error discards the message (no-op implementation). // Error discards the message (no-op implementation).
func (NopLogger) Error(string, ...any) {} func (NopLogger) Error(string, ...any) {}
var _ Logger = NopLogger{} var defaultLogger Logger = NopLogger{}
var (
defaultLoggerMu sync.RWMutex
defaultLogger Logger = NopLogger{}
)
// SetDefaultLogger sets the package-level default logger. // SetDefaultLogger sets the package-level default logger.
// Commands without an explicit logger will use this. // Commands without an explicit logger will use this.
// //
// Example: // exec.SetDefaultLogger(myLogger)
//
// exec.SetDefaultLogger(logger)
func SetDefaultLogger(l Logger) { func SetDefaultLogger(l Logger) {
defaultLoggerMu.Lock()
defer defaultLoggerMu.Unlock()
if l == nil { if l == nil {
l = NopLogger{} l = NopLogger{}
} }
@ -51,12 +37,7 @@ func SetDefaultLogger(l Logger) {
// DefaultLogger returns the current default logger. // DefaultLogger returns the current default logger.
// //
// Example:
//
// logger := exec.DefaultLogger() // logger := exec.DefaultLogger()
func DefaultLogger() Logger { func DefaultLogger() Logger {
defaultLoggerMu.RLock()
defer defaultLoggerMu.RUnlock()
return defaultLogger return defaultLogger
} }

View file

@ -1,456 +0,0 @@
package process
import (
"context"
"os/exec"
"sync"
"syscall"
"testing"
"time"
framework "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGlobal_DefaultNotInitialized(t *testing.T) {
// Reset global state for this test
old := defaultService.Swap(nil)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
assert.Nil(t, Default())
_, err := Start(context.Background(), "echo", "test")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
_, err = Run(context.Background(), "echo", "test")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
_, err = Get("proc-1")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
_, err = Output("proc-1")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
err = Input("proc-1", "test")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
err = CloseStdin("proc-1")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
assert.Nil(t, List())
assert.Nil(t, Running())
err = Remove("proc-1")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
// Clear is a no-op without a default service.
Clear()
err = Kill("proc-1")
assert.ErrorIs(t, err, ErrServiceNotInitialized)
err = KillPID(1234)
assert.ErrorIs(t, err, ErrServiceNotInitialized)
_, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"})
assert.ErrorIs(t, err, ErrServiceNotInitialized)
_, err = RunWithOptions(context.Background(), RunOptions{Command: "echo"})
assert.ErrorIs(t, err, ErrServiceNotInitialized)
}
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(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_Register(t *testing.T) {
c := framework.New()
result := Register(c)
require.True(t, result.OK)
svc, ok := result.Value.(*Service)
require.True(t, ok)
require.NotNil(t, svc)
assert.NotNil(t, svc.ServiceRuntime)
assert.Equal(t, DefaultBufferSize, svc.bufSize)
}
func TestGlobal_ConcurrentDefault(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
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_ConcurrentSetDefault(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_ConcurrentOperations(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()
proc, err := Start(context.Background(), "echo", "concurrent")
if err == nil {
procMu.Lock()
processes = append(processes, proc)
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(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := StartWithOptions(context.Background(), RunOptions{
Command: "echo",
Args: []string{"with", "options"},
})
require.NoError(t, err)
<-proc.Done()
assert.Equal(t, 0, proc.ExitCode)
assert.Contains(t, proc.Output(), "with options")
}
func TestGlobal_RunWithOptions(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
output, err := RunWithOptions(context.Background(), RunOptions{
Command: "echo",
Args: []string{"run", "options"},
})
require.NoError(t, err)
assert.Contains(t, output, "run options")
}
func TestGlobal_Output(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := Start(context.Background(), "echo", "global-output")
require.NoError(t, err)
<-proc.Done()
output, err := Output(proc.ID)
require.NoError(t, err)
assert.Contains(t, output, "global-output")
}
func TestGlobal_InputAndCloseStdin(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := Start(context.Background(), "cat")
require.NoError(t, err)
err = Input(proc.ID, "global-input\n")
require.NoError(t, err)
err = CloseStdin(proc.ID)
require.NoError(t, err)
<-proc.Done()
assert.Contains(t, proc.Output(), "global-input")
}
func TestGlobal_Wait(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := Start(context.Background(), "echo", "global-wait")
require.NoError(t, err)
info, err := Wait(proc.ID)
require.NoError(t, err)
assert.Equal(t, proc.ID, info.ID)
assert.Equal(t, StatusExited, info.Status)
assert.Equal(t, 0, info.ExitCode)
}
func TestGlobal_Signal(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := Start(context.Background(), "sleep", "60")
require.NoError(t, err)
err = Signal(proc.ID, syscall.SIGTERM)
require.NoError(t, err)
select {
case <-proc.Done():
case <-time.After(2 * time.Second):
t.Fatal("process should have been signalled through the global helper")
}
}
func TestGlobal_SignalPID(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
cmd := exec.Command("sleep", "60")
require.NoError(t, cmd.Start())
waitCh := make(chan error, 1)
go func() {
waitCh <- cmd.Wait()
}()
t.Cleanup(func() {
if cmd.ProcessState == nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
select {
case <-waitCh:
case <-time.After(2 * time.Second):
}
})
err := SignalPID(cmd.Process.Pid, syscall.SIGTERM)
require.NoError(t, err)
select {
case err := <-waitCh:
require.Error(t, err)
case <-time.After(2 * time.Second):
t.Fatal("unmanaged process should have been signalled through the global helper")
}
}
func TestGlobal_Running(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
proc, err := Start(ctx, "sleep", "60")
require.NoError(t, err)
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)
}
func TestGlobal_RemoveAndClear(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
if old != nil {
defaultService.Store(old)
}
}()
proc, err := Start(context.Background(), "echo", "remove-me")
require.NoError(t, err)
<-proc.Done()
err = Remove(proc.ID)
require.NoError(t, err)
_, err = Get(proc.ID)
require.ErrorIs(t, err, ErrProcessNotFound)
proc2, err := Start(context.Background(), "echo", "clear-me")
require.NoError(t, err)
<-proc2.Done()
Clear()
_, err = Get(proc2.ID)
require.ErrorIs(t, err, ErrProcessNotFound)
}

11
go.mod
View file

@ -3,18 +3,18 @@ module dappco.re/go/core/process
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.5.0 dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/api v0.2.0
dappco.re/go/core/io v0.2.0 dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
dappco.re/go/core/ws v0.3.0 dappco.re/go/core/ws v0.3.0
dappco.re/go/core/api v0.1.5
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
require ( require (
forge.lthn.ai/core/go-log v0.0.4 // indirect dappco.re/go/core/log v0.1.0 // indirect
dappco.re/go/core/io v0.1.5 // indirect
dappco.re/go/core/log v0.0.4 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
@ -67,6 +67,7 @@ require (
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect

10
go.sum
View file

@ -1,13 +1,15 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0=
dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ= dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ=
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic= dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=

189
health.go
View file

@ -2,35 +2,34 @@ package process
import ( import (
"context" "context"
"fmt"
"io"
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" "dappco.re/go/core"
) )
// HealthCheck is a function that returns nil when the service is healthy. // HealthCheck is a function that returns nil if healthy.
//
// check := process.HealthCheck(func() error { return nil })
type HealthCheck func() error type HealthCheck func() error
// HealthServer provides HTTP `/health` and `/ready` endpoints for process monitoring. // HealthServer provides HTTP /health and /ready endpoints for process monitoring.
//
// hs := process.NewHealthServer("127.0.0.1:0")
type HealthServer struct { type HealthServer struct {
addr string addr string
server *http.Server server *http.Server
listener net.Listener listener net.Listener
mu sync.RWMutex mu sync.Mutex
ready bool ready bool
checks []HealthCheck checks []HealthCheck
} }
// NewHealthServer creates a health check server on the given address. // NewHealthServer creates a health check server on the given address.
// //
// Example: // hs := process.NewHealthServer("127.0.0.1:0")
//
// server := process.NewHealthServer("127.0.0.1:0")
func NewHealthServer(addr string) *HealthServer { func NewHealthServer(addr string) *HealthServer {
return &HealthServer{ return &HealthServer{
addr: addr, addr: addr,
@ -39,240 +38,114 @@ func NewHealthServer(addr string) *HealthServer {
} }
// AddCheck registers a health check function. // AddCheck registers a health check function.
//
// Example:
//
// server.AddCheck(func() error { return nil })
func (h *HealthServer) AddCheck(check HealthCheck) { func (h *HealthServer) AddCheck(check HealthCheck) {
h.mu.Lock() h.mu.Lock()
h.checks = append(h.checks, check) h.checks = append(h.checks, check)
h.mu.Unlock() h.mu.Unlock()
} }
// SetReady sets the readiness status used by `/ready`. // SetReady sets the readiness status.
//
// Example:
//
// server.SetReady(false)
func (h *HealthServer) SetReady(ready bool) { func (h *HealthServer) SetReady(ready bool) {
h.mu.Lock() h.mu.Lock()
h.ready = ready h.ready = ready
h.mu.Unlock() h.mu.Unlock()
} }
// Ready reports whether `/ready` currently returns HTTP 200.
//
// Example:
//
// if server.Ready() {
// // publish the service
// }
func (h *HealthServer) Ready() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return h.ready
}
// Start begins serving health check endpoints. // Start begins serving health check endpoints.
//
// Example:
//
// if err := server.Start(); err != nil { return err }
func (h *HealthServer) Start() error { func (h *HealthServer) Start() error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
checks := h.checksSnapshot() h.mu.Lock()
checks := h.checks
h.mu.Unlock()
for _, check := range checks { for _, check := range checks {
if check == nil {
continue
}
if err := check(); err != nil { if err := check(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) _, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n"))
return return
} }
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok") _, _ = w.Write([]byte("ok\n"))
}) })
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
h.mu.RLock() h.mu.Lock()
ready := h.ready ready := h.ready
h.mu.RUnlock() h.mu.Unlock()
if !ready { if !ready {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintln(w, "not ready") _, _ = w.Write([]byte("not ready\n"))
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ready") _, _ = w.Write([]byte("ready\n"))
}) })
listener, err := net.Listen("tcp", h.addr) listener, err := net.Listen("tcp", h.addr)
if err != nil { if err != nil {
return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err) return core.E("health.start", core.Concat("failed to listen on ", h.addr), err)
} }
server := &http.Server{Handler: mux} server := &http.Server{Handler: mux}
h.mu.Lock()
h.listener = listener h.listener = listener
h.server = server h.server = server
h.mu.Unlock()
go func() { go func(srv *http.Server, ln net.Listener) {
_ = server.Serve(listener) _ = srv.Serve(ln)
}() }(server, listener)
return nil return nil
} }
// checksSnapshot returns a stable copy of the registered health checks.
func (h *HealthServer) checksSnapshot() []HealthCheck {
h.mu.RLock()
defer h.mu.RUnlock()
if len(h.checks) == 0 {
return nil
}
checks := make([]HealthCheck, len(h.checks))
copy(checks, h.checks)
return checks
}
// Stop gracefully shuts down the health server. // Stop gracefully shuts down the health server.
//
// Example:
//
// _ = server.Stop(context.Background())
func (h *HealthServer) Stop(ctx context.Context) error { func (h *HealthServer) Stop(ctx context.Context) error {
h.mu.Lock() h.mu.Lock()
server := h.server server := h.server
h.server = nil h.server = nil
h.listener = nil h.listener = nil
h.ready = false
h.mu.Unlock() h.mu.Unlock()
if server == nil { if server == nil {
return nil return nil
} }
return server.Shutdown(ctx) return server.Shutdown(ctx)
} }
// Addr returns the actual address the server is listening on. // Addr returns the actual address the server is listening on.
//
// Example:
//
// addr := server.Addr()
func (h *HealthServer) Addr() string { func (h *HealthServer) Addr() string {
h.mu.RLock()
defer h.mu.RUnlock()
if h.listener != nil { if h.listener != nil {
return h.listener.Addr().String() return h.listener.Addr().String()
} }
return h.addr return h.addr
} }
// WaitForHealth polls `/health` until it responds 200 or the timeout expires. // WaitForHealth polls a health endpoint until it responds 200 or the timeout
// (in milliseconds) expires. Returns true if healthy, false on timeout.
// //
// Example: // ok := process.WaitForHealth("127.0.0.1:9000", 2_000)
//
// if !process.WaitForHealth("127.0.0.1:8080", 5_000) {
// return errors.New("service did not become ready")
// }
func WaitForHealth(addr string, timeoutMs int) bool { func WaitForHealth(addr string, timeoutMs int) bool {
ok, _ := ProbeHealth(addr, timeoutMs)
return ok
}
// ProbeHealth polls `/health` until it responds 200 or the timeout expires.
// It returns the health status and the last observed failure reason.
//
// Example:
//
// ok, reason := process.ProbeHealth("127.0.0.1:8080", 5_000)
func ProbeHealth(addr string, timeoutMs int) (bool, string) {
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
url := fmt.Sprintf("http://%s/health", addr) url := core.Concat("http://", addr, "/health")
client := &http.Client{Timeout: 2 * time.Second} client := &http.Client{Timeout: 2 * time.Second}
var lastReason string
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
resp, err := client.Get(url) resp, err := client.Get(url)
if err == nil { if err == nil {
body, _ := io.ReadAll(resp.Body) resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return true, "" return true
} }
lastReason = strings.TrimSpace(string(body))
if lastReason == "" {
lastReason = resp.Status
}
} else {
lastReason = err.Error()
} }
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
if lastReason == "" { return false
lastReason = "health check timed out"
}
return false, lastReason
}
// WaitForReady polls `/ready` until it responds 200 or the timeout expires.
//
// Example:
//
// if !process.WaitForReady("127.0.0.1:8080", 5_000) {
// return errors.New("service did not become ready")
// }
func WaitForReady(addr string, timeoutMs int) bool {
ok, _ := ProbeReady(addr, timeoutMs)
return ok
}
// ProbeReady polls `/ready` until it responds 200 or the timeout expires.
// It returns the readiness status and the last observed failure reason.
//
// Example:
//
// ok, reason := process.ProbeReady("127.0.0.1:8080", 5_000)
func ProbeReady(addr string, timeoutMs int) (bool, string) {
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
url := fmt.Sprintf("http://%s/ready", addr)
client := &http.Client{Timeout: 2 * time.Second}
var lastReason string
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true, ""
}
lastReason = strings.TrimSpace(string(body))
if lastReason == "" {
lastReason = resp.Status
}
} else {
lastReason = err.Error()
}
time.Sleep(200 * time.Millisecond)
}
if lastReason == "" {
lastReason = "readiness check timed out"
}
return false, lastReason
} }

View file

@ -9,9 +9,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestHealthServer_Endpoints(t *testing.T) { func TestHealthServer_Endpoints_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0") hs := NewHealthServer("127.0.0.1:0")
assert.True(t, hs.Ready())
err := hs.Start() err := hs.Start()
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = hs.Stop(context.Background()) }() defer func() { _ = hs.Stop(context.Background()) }()
@ -30,7 +29,6 @@ func TestHealthServer_Endpoints(t *testing.T) {
_ = resp.Body.Close() _ = resp.Body.Close()
hs.SetReady(false) hs.SetReady(false)
assert.False(t, hs.Ready())
resp, err = http.Get("http://" + addr + "/ready") resp, err = http.Get("http://" + addr + "/ready")
require.NoError(t, err) require.NoError(t, err)
@ -38,16 +36,7 @@ func TestHealthServer_Endpoints(t *testing.T) {
_ = resp.Body.Close() _ = resp.Body.Close()
} }
func TestHealthServer_Ready(t *testing.T) { func TestHealthServer_WithChecks_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
assert.True(t, hs.Ready())
hs.SetReady(false)
assert.False(t, hs.Ready())
}
func TestHealthServer_WithChecks(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0") hs := NewHealthServer("127.0.0.1:0")
healthy := true healthy := true
@ -77,36 +66,13 @@ func TestHealthServer_WithChecks(t *testing.T) {
_ = resp.Body.Close() _ = resp.Body.Close()
} }
func TestHealthServer_NilCheckIgnored(t *testing.T) { func TestHealthServer_StopImmediately_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0") hs := NewHealthServer("127.0.0.1:0")
require.NoError(t, hs.Start())
var check HealthCheck require.NoError(t, hs.Stop(context.Background()))
hs.AddCheck(check)
err := hs.Start()
require.NoError(t, err)
defer func() { _ = hs.Stop(context.Background()) }()
addr := hs.Addr()
resp, err := http.Get("http://" + addr + "/health")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
} }
func TestHealthServer_ChecksSnapshotIsStable(t *testing.T) { func TestWaitForHealth_Reachable_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
hs.AddCheck(func() error { return nil })
snapshot := hs.checksSnapshot()
hs.AddCheck(func() error { return assert.AnError })
require.Len(t, snapshot, 1)
require.NotNil(t, snapshot[0])
}
func TestWaitForHealth_Reachable(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0") hs := NewHealthServer("127.0.0.1:0")
require.NoError(t, hs.Start()) require.NoError(t, hs.Start())
defer func() { _ = hs.Stop(context.Background()) }() defer func() { _ = hs.Stop(context.Background()) }()
@ -115,34 +81,7 @@ func TestWaitForHealth_Reachable(t *testing.T) {
assert.True(t, ok) assert.True(t, ok)
} }
func TestWaitForHealth_Unreachable(t *testing.T) { func TestWaitForHealth_Unreachable_Bad(t *testing.T) {
ok := WaitForHealth("127.0.0.1:19999", 500) ok := WaitForHealth("127.0.0.1:19999", 500)
assert.False(t, ok) assert.False(t, ok)
} }
func TestWaitForReady_Reachable(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
require.NoError(t, hs.Start())
defer func() { _ = hs.Stop(context.Background()) }()
ok := WaitForReady(hs.Addr(), 2_000)
assert.True(t, ok)
}
func TestWaitForReady_Unreachable(t *testing.T) {
ok := WaitForReady("127.0.0.1:19999", 500)
assert.False(t, ok)
}
func TestHealthServer_StopMarksNotReady(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
require.NoError(t, hs.Start())
require.NotEmpty(t, hs.Addr())
assert.True(t, hs.Ready())
require.NoError(t, hs.Stop(context.Background()))
assert.False(t, hs.Ready())
assert.NotEmpty(t, hs.Addr())
}

View file

@ -1,92 +1,70 @@
package process package process
import ( import (
"fmt" "bytes"
"os" "path"
"path/filepath"
"strconv" "strconv"
"strings"
"sync" "sync"
"syscall" "syscall"
"dappco.re/go/core"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
) )
// PIDFile manages a process ID file for single-instance enforcement. // PIDFile manages a process ID file for single-instance enforcement.
//
// Example:
//
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
type PIDFile struct { type PIDFile struct {
path string path string
mu sync.Mutex mu sync.Mutex
} }
// NewPIDFile creates a PID file manager. // NewPIDFile creates a PID file manager.
//
// Example:
//
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
func NewPIDFile(path string) *PIDFile { func NewPIDFile(path string) *PIDFile {
return &PIDFile{path: path} return &PIDFile{path: path}
} }
// Acquire writes the current PID to the file. // Acquire writes the current PID to the file.
// Returns error if another instance is running. // Returns error if another instance is running.
//
// Example:
//
// if err := pidFile.Acquire(); err != nil { return err }
func (p *PIDFile) Acquire() error { func (p *PIDFile) Acquire() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if data, err := coreio.Local.Read(p.path); err == nil { if data, err := coreio.Local.Read(p.path); err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(data)) pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
if err == nil && pid > 0 { if err == nil && pid > 0 {
if proc, err := os.FindProcess(pid); err == nil { if proc, err := processHandle(pid); err == nil {
if err := proc.Signal(syscall.Signal(0)); err == nil { if err := proc.Signal(syscall.Signal(0)); err == nil {
return coreerr.E("PIDFile.Acquire", fmt.Sprintf("another instance is running (PID %d)", pid), nil) return core.E("pidfile.acquire", core.Concat("another instance is running (PID ", strconv.Itoa(pid), ")"), nil)
} }
} }
} }
_ = coreio.Local.Delete(p.path) _ = coreio.Local.Delete(p.path)
} }
if dir := filepath.Dir(p.path); dir != "." { if dir := path.Dir(p.path); dir != "." {
if err := coreio.Local.EnsureDir(dir); err != nil { 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 := os.Getpid() pid := currentPID()
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil { 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 return nil
} }
// Release removes the PID file. // Release removes the PID file.
//
// Example:
//
// _ = pidFile.Release()
func (p *PIDFile) Release() error { func (p *PIDFile) Release() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if err := coreio.Local.Delete(p.path); err != nil { 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 return nil
} }
// Path returns the PID file path. // Path returns the PID file path.
//
// Example:
//
// path := pidFile.Path()
func (p *PIDFile) Path() string { func (p *PIDFile) Path() string {
return p.path return p.path
} }
@ -94,22 +72,18 @@ func (p *PIDFile) Path() string {
// ReadPID reads a PID file and checks if the process is still running. // ReadPID reads a PID file and checks if the process is still running.
// Returns (pid, true) if the process is alive, (pid, false) if dead/stale, // Returns (pid, true) if the process is alive, (pid, false) if dead/stale,
// or (0, false) if the file doesn't exist or is invalid. // or (0, false) if the file doesn't exist or is invalid.
//
// Example:
//
// pid, running := process.ReadPID("/var/run/myapp.pid")
func ReadPID(path string) (int, bool) { func ReadPID(path string) (int, bool) {
data, err := coreio.Local.Read(path) data, err := coreio.Local.Read(path)
if err != nil { if err != nil {
return 0, false return 0, false
} }
pid, err := strconv.Atoi(strings.TrimSpace(data)) pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
if err != nil || pid <= 0 { if err != nil || pid <= 0 {
return 0, false return 0, false
} }
proc, err := os.FindProcess(pid) proc, err := processHandle(pid)
if err != nil { if err != nil {
return pid, false return pid, false
} }

View file

@ -2,15 +2,15 @@ package process
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPIDFile_AcquireAndRelease(t *testing.T) { func TestPIDFile_Acquire_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid") pidPath := core.JoinPath(t.TempDir(), "test.pid")
pid := NewPIDFile(pidPath) pid := NewPIDFile(pidPath)
err := pid.Acquire() err := pid.Acquire()
require.NoError(t, err) require.NoError(t, err)
@ -23,8 +23,8 @@ func TestPIDFile_AcquireAndRelease(t *testing.T) {
assert.True(t, os.IsNotExist(err)) assert.True(t, os.IsNotExist(err))
} }
func TestPIDFile_StalePID(t *testing.T) { func TestPIDFile_AcquireStale_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "stale.pid") pidPath := core.JoinPath(t.TempDir(), "stale.pid")
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644)) require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
pid := NewPIDFile(pidPath) pid := NewPIDFile(pidPath)
err := pid.Acquire() err := pid.Acquire()
@ -33,8 +33,8 @@ func TestPIDFile_StalePID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestPIDFile_CreatesParentDirectory(t *testing.T) { func TestPIDFile_CreateDirectory_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid") pidPath := core.JoinPath(t.TempDir(), "subdir", "nested", "test.pid")
pid := NewPIDFile(pidPath) pid := NewPIDFile(pidPath)
err := pid.Acquire() err := pid.Acquire()
require.NoError(t, err) require.NoError(t, err)
@ -42,27 +42,27 @@ func TestPIDFile_CreatesParentDirectory(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestPIDFile_Path(t *testing.T) { func TestPIDFile_Path_Good(t *testing.T) {
pid := NewPIDFile("/tmp/test.pid") pid := NewPIDFile("/tmp/test.pid")
assert.Equal(t, "/tmp/test.pid", pid.Path()) assert.Equal(t, "/tmp/test.pid", pid.Path())
} }
func TestReadPID_Missing(t *testing.T) { func TestReadPID_Missing_Bad(t *testing.T) {
pid, running := ReadPID("/nonexistent/path.pid") pid, running := ReadPID("/nonexistent/path.pid")
assert.Equal(t, 0, pid) assert.Equal(t, 0, pid)
assert.False(t, running) assert.False(t, running)
} }
func TestReadPID_InvalidContent(t *testing.T) { func TestReadPID_Invalid_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.pid") path := core.JoinPath(t.TempDir(), "bad.pid")
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644)) require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
pid, running := ReadPID(path) pid, running := ReadPID(path)
assert.Equal(t, 0, pid) assert.Equal(t, 0, pid)
assert.False(t, running) assert.False(t, running)
} }
func TestReadPID_StalePID(t *testing.T) { func TestReadPID_Stale_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "stale.pid") path := core.JoinPath(t.TempDir(), "stale.pid")
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644)) require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
pid, running := ReadPID(path) pid, running := ReadPID(path)
assert.Equal(t, 999999999, pid) assert.Equal(t, 999999999, pid)

View file

@ -5,21 +5,15 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings"
"sync"
"syscall" "syscall"
"time"
"dappco.re/go/core"
"dappco.re/go/core/api"
"dappco.re/go/core/api/pkg/provider"
coreerr "dappco.re/go/core/log"
process "dappco.re/go/core/process" process "dappco.re/go/core/process"
"dappco.re/go/core/ws" "dappco.re/go/core/ws"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -28,10 +22,7 @@ import (
// and provider.Renderable. // and provider.Renderable.
type ProcessProvider struct { type ProcessProvider struct {
registry *process.Registry registry *process.Registry
service *process.Service
runner *process.Runner
hub *ws.Hub hub *ws.Hub
actions sync.Once
} }
// compile-time interface checks // compile-time interface checks
@ -42,25 +33,17 @@ var (
_ provider.Renderable = (*ProcessProvider)(nil) _ provider.Renderable = (*ProcessProvider)(nil)
) )
// NewProvider creates a process provider backed by the given daemon registry // NewProvider creates a process provider backed by the given daemon registry.
// and optional process service for pipeline execution.
//
// The WS hub is used to emit daemon state change events. Pass nil for hub // The WS hub is used to emit daemon state change events. Pass nil for hub
// if WebSocket streaming is not needed. // if WebSocket streaming is not needed.
func NewProvider(registry *process.Registry, service *process.Service, hub *ws.Hub) *ProcessProvider { func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider {
if registry == nil { if registry == nil {
registry = process.DefaultRegistry() registry = process.DefaultRegistry()
} }
p := &ProcessProvider{ return &ProcessProvider{
registry: registry, registry: registry,
service: service,
hub: hub, hub: hub,
} }
if service != nil {
p.runner = process.NewRunner(service)
}
p.registerProcessEvents()
return p
} }
// Name implements api.RouteGroup. // Name implements api.RouteGroup.
@ -96,17 +79,6 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/daemons/:code/:daemon", p.getDaemon) rg.GET("/daemons/:code/:daemon", p.getDaemon)
rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon) rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon)
rg.GET("/daemons/:code/:daemon/health", p.healthCheck) rg.GET("/daemons/:code/:daemon/health", p.healthCheck)
rg.GET("/processes", p.listProcesses)
rg.POST("/processes", p.startProcess)
rg.POST("/processes/run", p.runProcess)
rg.GET("/processes/:id", p.getProcess)
rg.GET("/processes/:id/output", p.getProcessOutput)
rg.POST("/processes/:id/wait", p.waitProcess)
rg.POST("/processes/:id/input", p.inputProcess)
rg.POST("/processes/:id/close-stdin", p.closeProcessStdin)
rg.POST("/processes/:id/kill", p.killProcess)
rg.POST("/processes/:id/signal", p.signalProcess)
rg.POST("/pipelines/run", p.runPipeline)
} }
// Describe implements api.DescribableGroup. // Describe implements api.DescribableGroup.
@ -147,6 +119,8 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
"daemon": map[string]any{"type": "string"}, "daemon": map[string]any{"type": "string"},
"pid": map[string]any{"type": "integer"}, "pid": map[string]any{"type": "integer"},
"health": map[string]any{"type": "string"}, "health": map[string]any{"type": "string"},
"project": map[string]any{"type": "string"},
"binary": map[string]any{"type": "string"},
"started": map[string]any{"type": "string", "format": "date-time"}, "started": map[string]any{"type": "string", "format": "date-time"},
}, },
}, },
@ -168,7 +142,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
Method: "GET", Method: "GET",
Path: "/daemons/:code/:daemon/health", Path: "/daemons/:code/:daemon/health",
Summary: "Check daemon health", Summary: "Check daemon health",
Description: "Probes the daemon's health endpoint and returns the result, including a failure reason when unhealthy.", Description: "Probes the daemon's health endpoint and returns the result.",
Tags: []string{"process"}, Tags: []string{"process"},
Response: map[string]any{ Response: map[string]any{
"type": "object", "type": "object",
@ -179,232 +153,6 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
}, },
}, },
}, },
{
Method: "GET",
Path: "/processes",
Summary: "List managed processes",
Description: "Returns the current process service snapshot as serialisable process info entries. Pass runningOnly=true to limit results to active processes.",
Tags: []string{"process"},
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"startedAt": map[string]any{"type": "string", "format": "date-time"},
"running": map[string]any{"type": "boolean"},
"status": map[string]any{"type": "string"},
"exitCode": map[string]any{"type": "integer"},
"duration": map[string]any{"type": "integer"},
"pid": map[string]any{"type": "integer"},
},
},
},
},
{
Method: "POST",
Path: "/processes",
Summary: "Start a managed process",
Description: "Starts a process asynchronously and returns its initial snapshot immediately.",
Tags: []string{"process"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"env": map[string]any{"type": "array"},
"disableCapture": map[string]any{"type": "boolean"},
"detach": map[string]any{"type": "boolean"},
"timeout": map[string]any{"type": "integer"},
"gracePeriod": map[string]any{"type": "integer"},
"killGroup": map[string]any{"type": "boolean"},
},
"required": []string{"command"},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"startedAt": map[string]any{"type": "string", "format": "date-time"},
"running": map[string]any{"type": "boolean"},
"status": map[string]any{"type": "string"},
"exitCode": map[string]any{"type": "integer"},
"duration": map[string]any{"type": "integer"},
"pid": map[string]any{"type": "integer"},
},
},
},
{
Method: "POST",
Path: "/processes/run",
Summary: "Run a managed process",
Description: "Runs a process synchronously and returns its combined output on success.",
Tags: []string{"process"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"env": map[string]any{"type": "array"},
"disableCapture": map[string]any{"type": "boolean"},
"detach": map[string]any{"type": "boolean"},
"timeout": map[string]any{"type": "integer"},
"gracePeriod": map[string]any{"type": "integer"},
"killGroup": map[string]any{"type": "boolean"},
},
"required": []string{"command"},
},
Response: map[string]any{
"type": "string",
},
},
{
Method: "GET",
Path: "/processes/:id",
Summary: "Get a managed process",
Description: "Returns a single managed process by ID as a process info snapshot.",
Tags: []string{"process"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"startedAt": map[string]any{"type": "string", "format": "date-time"},
"running": map[string]any{"type": "boolean"},
"status": map[string]any{"type": "string"},
"exitCode": map[string]any{"type": "integer"},
"duration": map[string]any{"type": "integer"},
"pid": map[string]any{"type": "integer"},
},
},
},
{
Method: "GET",
Path: "/processes/:id/output",
Summary: "Get process output",
Description: "Returns the captured stdout and stderr for a managed process.",
Tags: []string{"process"},
Response: map[string]any{
"type": "string",
},
},
{
Method: "POST",
Path: "/processes/:id/wait",
Summary: "Wait for a managed process",
Description: "Blocks until the process exits and returns the final process snapshot. Non-zero exits include the snapshot in the error details payload.",
Tags: []string{"process"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"command": map[string]any{"type": "string"},
"args": map[string]any{"type": "array"},
"dir": map[string]any{"type": "string"},
"startedAt": map[string]any{"type": "string", "format": "date-time"},
"running": map[string]any{"type": "boolean"},
"status": map[string]any{"type": "string"},
"exitCode": map[string]any{"type": "integer"},
"duration": map[string]any{"type": "integer"},
"pid": map[string]any{"type": "integer"},
},
},
},
{
Method: "POST",
Path: "/processes/:id/input",
Summary: "Write process input",
Description: "Writes the provided input string to a managed process stdin pipe.",
Tags: []string{"process"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{"type": "string"},
},
"required": []string{"input"},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"written": map[string]any{"type": "boolean"},
},
},
},
{
Method: "POST",
Path: "/processes/:id/close-stdin",
Summary: "Close process stdin",
Description: "Closes the stdin pipe of a managed process so it can exit cleanly.",
Tags: []string{"process"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"closed": map[string]any{"type": "boolean"},
},
},
},
{
Method: "POST",
Path: "/processes/:id/kill",
Summary: "Kill a managed process",
Description: "Sends SIGKILL to the managed process identified by ID, or to a raw OS PID when the path value is numeric.",
Tags: []string{"process"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"killed": map[string]any{"type": "boolean"},
},
},
},
{
Method: "POST",
Path: "/processes/:id/signal",
Summary: "Signal a managed process",
Description: "Sends a Unix signal to the managed process identified by ID, or to a raw OS PID when the path value is numeric.",
Tags: []string{"process"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"signal": map[string]any{"type": "string"},
},
"required": []string{"signal"},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"signalled": map[string]any{"type": "boolean"},
},
},
},
{
Method: "POST",
Path: "/pipelines/run",
Summary: "Run a process pipeline",
Description: "Executes a list of process specs using the configured runner in sequential, parallel, or dependency-aware mode.",
Tags: []string{"process"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"results": map[string]any{
"type": "array",
},
"duration": map[string]any{"type": "integer"},
"passed": map[string]any{"type": "integer"},
"failed": map[string]any{"type": "integer"},
"skipped": map[string]any{"type": "integer"},
},
},
},
} }
} }
@ -419,9 +167,6 @@ func (p *ProcessProvider) listDaemons(c *gin.Context) {
if entries == nil { if entries == nil {
entries = []process.DaemonEntry{} entries = []process.DaemonEntry{}
} }
for _, entry := range entries {
p.emitEvent("process.daemon.started", daemonEventPayload(entry))
}
c.JSON(http.StatusOK, api.OK(entries)) c.JSON(http.StatusOK, api.OK(entries))
} }
@ -434,7 +179,6 @@ func (p *ProcessProvider) getDaemon(c *gin.Context) {
c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running")) c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running"))
return return
} }
p.emitEvent("process.daemon.started", daemonEventPayload(*entry))
c.JSON(http.StatusOK, api.OK(entry)) c.JSON(http.StatusOK, api.OK(entry))
} }
@ -491,14 +235,16 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
return return
} }
healthy, reason := process.ProbeHealth(entry.Health, 2000) healthy := process.WaitForHealth(entry.Health, 2000)
reason := ""
if !healthy {
reason = "health endpoint did not report healthy"
}
result := map[string]any{ result := map[string]any{
"healthy": healthy, "healthy": healthy,
"address": entry.Health, "address": entry.Health,
} "reason": reason,
if !healthy && reason != "" {
result["reason"] = reason
} }
// Emit health event // Emit health event
@ -516,346 +262,15 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
c.JSON(statusCode, api.OK(result)) c.JSON(statusCode, api.OK(result))
} }
func (p *ProcessProvider) listProcesses(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
procs := p.service.List()
if runningOnly, _ := strconv.ParseBool(c.Query("runningOnly")); runningOnly {
procs = p.service.Running()
}
infos := make([]process.Info, 0, len(procs))
for _, proc := range procs {
infos = append(infos, proc.Info())
}
c.JSON(http.StatusOK, api.OK(infos))
}
func (p *ProcessProvider) startProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
var req process.TaskProcessStart
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error()))
return
}
if strings.TrimSpace(req.Command) == "" {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required"))
return
}
proc, err := p.service.StartWithOptions(c.Request.Context(), process.RunOptions{
Command: req.Command,
Args: req.Args,
Dir: req.Dir,
Env: req.Env,
DisableCapture: req.DisableCapture,
Detach: req.Detach,
Timeout: req.Timeout,
GracePeriod: req.GracePeriod,
KillGroup: req.KillGroup,
})
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("start_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(proc.Info()))
}
func (p *ProcessProvider) runProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
var req process.TaskProcessRun
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error()))
return
}
if strings.TrimSpace(req.Command) == "" {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required"))
return
}
output, err := p.service.RunWithOptions(c.Request.Context(), process.RunOptions{
Command: req.Command,
Args: req.Args,
Dir: req.Dir,
Env: req.Env,
DisableCapture: req.DisableCapture,
Detach: req.Detach,
Timeout: req.Timeout,
GracePeriod: req.GracePeriod,
KillGroup: req.KillGroup,
})
if err != nil {
c.JSON(http.StatusInternalServerError, api.FailWithDetails("run_failed", err.Error(), map[string]any{
"output": output,
}))
return
}
c.JSON(http.StatusOK, api.OK(output))
}
func (p *ProcessProvider) getProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
proc, err := p.service.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, api.Fail("not_found", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(proc.Info()))
}
func (p *ProcessProvider) getProcessOutput(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
output, err := p.service.Output(c.Param("id"))
if err != nil {
status := http.StatusInternalServerError
if err == process.ErrProcessNotFound {
status = http.StatusNotFound
}
c.JSON(status, api.Fail("not_found", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(output))
}
func (p *ProcessProvider) waitProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
info, err := p.service.Wait(c.Param("id"))
if err != nil {
status := http.StatusInternalServerError
switch {
case err == process.ErrProcessNotFound:
status = http.StatusNotFound
case info.Status == process.StatusExited || info.Status == process.StatusKilled:
status = http.StatusConflict
}
c.JSON(status, api.FailWithDetails("wait_failed", err.Error(), info))
return
}
c.JSON(http.StatusOK, api.OK(info))
}
type processInputRequest struct {
Input string `json:"input"`
}
func (p *ProcessProvider) inputProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
var req processInputRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error()))
return
}
if err := p.service.Input(c.Param("id"), req.Input); err != nil {
status := http.StatusInternalServerError
if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning {
status = http.StatusNotFound
}
c.JSON(status, api.Fail("input_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{"written": true}))
}
func (p *ProcessProvider) closeProcessStdin(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
if err := p.service.CloseStdin(c.Param("id")); err != nil {
status := http.StatusInternalServerError
if err == process.ErrProcessNotFound {
status = http.StatusNotFound
}
c.JSON(status, api.Fail("close_stdin_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{"closed": true}))
}
func (p *ProcessProvider) killProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
id := c.Param("id")
if err := p.service.Kill(id); err != nil {
if pid, ok := pidFromString(id); ok {
if pidErr := p.service.KillPID(pid); pidErr == nil {
c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true}))
return
} else {
err = pidErr
}
}
status := http.StatusInternalServerError
if err == process.ErrProcessNotFound {
status = http.StatusNotFound
}
c.JSON(status, api.Fail("kill_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true}))
}
type processSignalRequest struct {
Signal string `json:"signal"`
}
func (p *ProcessProvider) signalProcess(c *gin.Context) {
if p.service == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured"))
return
}
var req processSignalRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error()))
return
}
sig, err := parseSignal(req.Signal)
if err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_signal", err.Error()))
return
}
id := c.Param("id")
if err := p.service.Signal(id, sig); err != nil {
if pid, ok := pidFromString(id); ok {
if pidErr := p.service.SignalPID(pid, sig); pidErr == nil {
c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true}))
return
} else {
err = pidErr
}
}
status := http.StatusInternalServerError
if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning {
status = http.StatusNotFound
}
c.JSON(status, api.Fail("signal_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true}))
}
type pipelineRunRequest struct {
Mode string `json:"mode"`
Specs []process.RunSpec `json:"specs"`
}
func (p *ProcessProvider) runPipeline(c *gin.Context) {
if p.runner == nil {
c.JSON(http.StatusServiceUnavailable, api.Fail("runner_unavailable", "pipeline runner is not configured"))
return
}
var req pipelineRunRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error()))
return
}
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" {
mode = "all"
}
ctx := c.Request.Context()
if ctx == nil {
ctx = context.Background()
}
var (
result *process.RunAllResult
err error
)
switch mode {
case "all":
result, err = p.runner.RunAll(ctx, req.Specs)
case "sequential":
result, err = p.runner.RunSequential(ctx, req.Specs)
case "parallel":
result, err = p.runner.RunParallel(ctx, req.Specs)
default:
c.JSON(http.StatusBadRequest, api.Fail("invalid_mode", "mode must be one of: all, sequential, parallel"))
return
}
if err != nil {
c.JSON(http.StatusBadRequest, api.Fail("pipeline_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(result))
}
// emitEvent sends a WS event if the hub is available. // emitEvent sends a WS event if the hub is available.
func (p *ProcessProvider) emitEvent(channel string, data any) { func (p *ProcessProvider) emitEvent(channel string, data any) {
if p.hub == nil { if p.hub == nil {
return return
} }
msg := ws.Message{ _ = p.hub.SendToChannel(channel, ws.Message{
Type: ws.TypeEvent, Type: ws.TypeEvent,
Data: data, Data: data,
}
_ = p.hub.Broadcast(ws.Message{
Type: msg.Type,
Channel: channel,
Data: data,
}) })
_ = p.hub.SendToChannel(channel, msg)
}
func daemonEventPayload(entry process.DaemonEntry) map[string]any {
return map[string]any{
"code": entry.Code,
"daemon": entry.Daemon,
"pid": entry.PID,
"health": entry.Health,
"project": entry.Project,
"binary": entry.Binary,
"started": entry.Started,
}
} }
// PIDAlive checks whether a PID is still running. Exported for use by // PIDAlive checks whether a PID is still running. Exported for use by
@ -876,125 +291,3 @@ func intParam(c *gin.Context, name string) int {
v, _ := strconv.Atoi(c.Param(name)) v, _ := strconv.Atoi(c.Param(name))
return v return v
} }
func pidFromString(value string) (int, bool) {
pid, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || pid <= 0 {
return 0, false
}
return pid, true
}
func parseSignal(value string) (syscall.Signal, error) {
trimmed := strings.TrimSpace(strings.ToUpper(value))
if trimmed == "" {
return 0, coreerr.E("ProcessProvider.parseSignal", "signal is required", nil)
}
if n, err := strconv.Atoi(trimmed); err == nil {
return syscall.Signal(n), nil
}
switch trimmed {
case "SIGTERM", "TERM":
return syscall.SIGTERM, nil
case "SIGKILL", "KILL":
return syscall.SIGKILL, nil
case "SIGINT", "INT":
return syscall.SIGINT, nil
case "SIGQUIT", "QUIT":
return syscall.SIGQUIT, nil
case "SIGHUP", "HUP":
return syscall.SIGHUP, nil
case "SIGSTOP", "STOP":
return syscall.SIGSTOP, nil
case "SIGCONT", "CONT":
return syscall.SIGCONT, nil
case "SIGUSR1", "USR1":
return syscall.SIGUSR1, nil
case "SIGUSR2", "USR2":
return syscall.SIGUSR2, nil
default:
return 0, coreerr.E("ProcessProvider.parseSignal", "unsupported signal", nil)
}
}
func (p *ProcessProvider) registerProcessEvents() {
if p == nil || p.hub == nil || p.service == nil {
return
}
coreApp := p.service.Core()
if coreApp == nil {
return
}
p.actions.Do(func() {
coreApp.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
p.forwardProcessEvent(msg)
return core.Result{OK: true}
})
})
}
func (p *ProcessProvider) forwardProcessEvent(msg core.Message) {
switch m := msg.(type) {
case process.ActionProcessStarted:
payload := p.processEventPayload(m.ID)
payload["id"] = m.ID
payload["command"] = m.Command
payload["args"] = append([]string(nil), m.Args...)
payload["dir"] = m.Dir
payload["pid"] = m.PID
if _, ok := payload["startedAt"]; !ok {
payload["startedAt"] = time.Now().UTC()
}
p.emitEvent("process.started", payload)
case process.ActionProcessOutput:
p.emitEvent("process.output", map[string]any{
"id": m.ID,
"line": m.Line,
"stream": m.Stream,
})
case process.ActionProcessExited:
payload := p.processEventPayload(m.ID)
payload["id"] = m.ID
payload["exitCode"] = m.ExitCode
payload["duration"] = m.Duration
if m.Error != nil {
payload["error"] = m.Error.Error()
}
p.emitEvent("process.exited", payload)
case process.ActionProcessKilled:
payload := p.processEventPayload(m.ID)
payload["id"] = m.ID
payload["signal"] = m.Signal
payload["exitCode"] = -1
p.emitEvent("process.killed", payload)
}
}
func (p *ProcessProvider) processEventPayload(id string) map[string]any {
if p == nil || p.service == nil || id == "" {
return map[string]any{}
}
proc, err := p.service.Get(id)
if err != nil {
return map[string]any{}
}
info := proc.Info()
return map[string]any{
"id": info.ID,
"command": info.Command,
"args": append([]string(nil), info.Args...),
"dir": info.Dir,
"startedAt": info.StartedAt,
"running": info.Running,
"status": info.Status,
"exitCode": info.ExitCode,
"duration": info.Duration,
"pid": info.PID,
}
}

View file

@ -3,24 +3,15 @@
package api_test package api_test
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"strconv"
"strings"
"testing" "testing"
"time"
core "dappco.re/go/core"
goapi "dappco.re/go/core/api"
process "dappco.re/go/core/process" process "dappco.re/go/core/process"
processapi "dappco.re/go/core/process/pkg/api" processapi "dappco.re/go/core/process/pkg/api"
corews "dappco.re/go/core/ws" goapi "forge.lthn.ai/core/api"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -30,17 +21,17 @@ func init() {
} }
func TestProcessProvider_Name_Good(t *testing.T) { func TestProcessProvider_Name_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
assert.Equal(t, "process", p.Name()) assert.Equal(t, "process", p.Name())
} }
func TestProcessProvider_BasePath_Good(t *testing.T) { func TestProcessProvider_BasePath_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
assert.Equal(t, "/api/process", p.BasePath()) assert.Equal(t, "/api/process", p.BasePath())
} }
func TestProcessProvider_Channels_Good(t *testing.T) { func TestProcessProvider_Channels_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
channels := p.Channels() channels := p.Channels()
assert.Contains(t, channels, "process.daemon.started") assert.Contains(t, channels, "process.daemon.started")
assert.Contains(t, channels, "process.daemon.stopped") assert.Contains(t, channels, "process.daemon.stopped")
@ -48,9 +39,9 @@ func TestProcessProvider_Channels_Good(t *testing.T) {
} }
func TestProcessProvider_Describe_Good(t *testing.T) { func TestProcessProvider_Describe_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
descs := p.Describe() descs := p.Describe()
assert.GreaterOrEqual(t, len(descs), 5) assert.GreaterOrEqual(t, len(descs), 4)
// Verify all descriptions have required fields // Verify all descriptions have required fields
for _, d := range descs { for _, d := range descs {
@ -60,25 +51,20 @@ func TestProcessProvider_Describe_Good(t *testing.T) {
assert.NotEmpty(t, d.Tags) assert.NotEmpty(t, d.Tags)
} }
foundPipelineRoute := false
foundSignalRoute := false
for _, d := range descs { for _, d := range descs {
if d.Method == "POST" && d.Path == "/pipelines/run" { if d.Path == "/daemons/:code/:daemon/health" {
foundPipelineRoute = true props, ok := d.Response["properties"].(map[string]any)
} require.True(t, ok)
if d.Method == "POST" && d.Path == "/processes/:id/signal" { assert.Contains(t, props, "reason")
foundSignalRoute = true
} }
} }
assert.True(t, foundPipelineRoute, "pipeline route should be described")
assert.True(t, foundSignalRoute, "signal route should be described")
} }
func TestProcessProvider_ListDaemons_Good(t *testing.T) { func TestProcessProvider_ListDaemons_Good(t *testing.T) {
// Use a temp directory so the registry has no daemons // Use a temp directory so the registry has no daemons
dir := t.TempDir() dir := t.TempDir()
registry := newTestRegistry(dir) registry := newTestRegistry(dir)
p := processapi.NewProvider(registry, nil, nil) p := processapi.NewProvider(registry, nil)
r := setupRouter(p) r := setupRouter(p)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -87,58 +73,14 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[[]any] body := w.Body.String()
err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NotEmpty(t, body)
require.NoError(t, err)
assert.True(t, resp.Success)
}
func TestProcessProvider_ListDaemons_BroadcastsStarted_Good(t *testing.T) {
dir := t.TempDir()
registry := newTestRegistry(dir)
require.NoError(t, registry.Register(process.DaemonEntry{
Code: "test",
Daemon: "serve",
PID: os.Getpid(),
}))
hub := corews.NewHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
p := processapi.NewProvider(registry, nil, hub)
server := httptest.NewServer(hub.Handler())
defer server.Close()
conn := connectWS(t, server.URL)
defer conn.Close()
require.Eventually(t, func() bool {
return hub.ClientCount() == 1
}, time.Second, 10*time.Millisecond)
r := setupRouter(p)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/process/daemons", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
events := readWSEvents(t, conn, "process.daemon.started")
started := events["process.daemon.started"]
require.NotNil(t, started)
startedData := started.Data.(map[string]any)
assert.Equal(t, "test", startedData["code"])
assert.Equal(t, "serve", startedData["daemon"])
assert.Equal(t, float64(os.Getpid()), startedData["pid"])
} }
func TestProcessProvider_GetDaemon_Bad(t *testing.T) { func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
registry := newTestRegistry(dir) registry := newTestRegistry(dir)
p := processapi.NewProvider(registry, nil, nil) p := processapi.NewProvider(registry, nil)
r := setupRouter(p) r := setupRouter(p)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -148,45 +90,29 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, http.StatusNotFound, w.Code)
} }
func TestProcessProvider_HealthCheck_Bad(t *testing.T) { func TestProcessProvider_HealthCheck_NoEndpoint_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
registry := newTestRegistry(dir) registry := newTestRegistry(dir)
healthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("upstream health check failed"))
}))
defer healthSrv.Close()
hostPort := strings.TrimPrefix(healthSrv.URL, "http://")
require.NoError(t, registry.Register(process.DaemonEntry{ require.NoError(t, registry.Register(process.DaemonEntry{
Code: "test", Code: "test",
Daemon: "broken", Daemon: "nohealth",
PID: os.Getpid(), PID: os.Getpid(),
Health: hostPort,
})) }))
p := processapi.NewProvider(registry, nil, nil) p := processapi.NewProvider(registry, nil)
r := setupRouter(p) r := setupRouter(p)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/process/daemons/test/broken/health", nil) req, _ := http.NewRequest("GET", "/api/process/daemons/test/nohealth/health", nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code) assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "no health endpoint configured")
var resp goapi.Response[map[string]any] assert.Contains(t, w.Body.String(), "\"reason\"")
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, false, resp.Data["healthy"])
assert.Equal(t, hostPort, resp.Data["address"])
assert.Equal(t, "upstream health check failed", resp.Data["reason"])
} }
func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
engine, err := goapi.New() engine, err := goapi.New()
require.NoError(t, err) require.NoError(t, err)
@ -196,8 +122,8 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
assert.Equal(t, "process", engine.Groups()[0].Name()) assert.Equal(t, "process", engine.Groups()[0].Name())
} }
func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { func TestProcessProvider_StreamGroup_Good(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil) p := processapi.NewProvider(nil, nil)
engine, err := goapi.New() engine, err := goapi.New()
require.NoError(t, err) require.NoError(t, err)
@ -209,600 +135,6 @@ func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) {
assert.Contains(t, channels, "process.daemon.started") assert.Contains(t, channels, "process.daemon.started")
} }
func TestProcessProvider_RunPipeline_Good(t *testing.T) {
svc := newTestProcessService(t)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
body := strings.NewReader(`{
"mode": "parallel",
"specs": [
{"name": "first", "command": "echo", "args": ["1"]},
{"name": "second", "command": "echo", "args": ["2"]}
]
}`)
req, err := http.NewRequest("POST", "/api/process/pipelines/run", body)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[process.RunAllResult]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.True(t, resp.Success)
assert.Equal(t, 2, resp.Data.Passed)
assert.Len(t, resp.Data.Results, 2)
}
func TestProcessProvider_RunPipeline_Unavailable(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/pipelines/run", strings.NewReader(`{"mode":"all","specs":[]}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestProcessProvider_ListProcesses_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "echo", "hello-api")
require.NoError(t, err)
<-proc.Done()
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/process/processes", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[[]process.Info]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
require.Len(t, resp.Data, 1)
assert.Equal(t, proc.ID, resp.Data[0].ID)
assert.Equal(t, "echo", resp.Data[0].Command)
}
func TestProcessProvider_ListProcesses_RunningOnly_Good(t *testing.T) {
svc := newTestProcessService(t)
runningProc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
exitedProc, err := svc.Start(context.Background(), "echo", "done")
require.NoError(t, err)
<-exitedProc.Done()
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/process/processes?runningOnly=true", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[[]process.Info]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
require.Len(t, resp.Data, 1)
assert.Equal(t, runningProc.ID, resp.Data[0].ID)
assert.Equal(t, process.StatusRunning, resp.Data[0].Status)
require.NoError(t, svc.Kill(runningProc.ID))
<-runningProc.Done()
}
func TestProcessProvider_StartProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
body := strings.NewReader(`{
"command": "sleep",
"args": ["60"],
"detach": true,
"killGroup": true
}`)
req, err := http.NewRequest("POST", "/api/process/processes", body)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[process.Info]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, "sleep", resp.Data.Command)
assert.Equal(t, process.StatusRunning, resp.Data.Status)
assert.True(t, resp.Data.Running)
assert.NotEmpty(t, resp.Data.ID)
managed, err := svc.Get(resp.Data.ID)
require.NoError(t, err)
require.NoError(t, svc.Kill(managed.ID))
select {
case <-managed.Done():
case <-time.After(5 * time.Second):
t.Fatal("process should have been killed after start test")
}
}
func TestProcessProvider_RunProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
body := strings.NewReader(`{
"command": "echo",
"args": ["run-check"]
}`)
req, err := http.NewRequest("POST", "/api/process/processes/run", body)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[string]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Contains(t, resp.Data, "run-check")
}
func TestProcessProvider_GetProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "echo", "single")
require.NoError(t, err)
<-proc.Done()
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID, nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[process.Info]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, proc.ID, resp.Data.ID)
assert.Equal(t, "echo", resp.Data.Command)
}
func TestProcessProvider_GetProcessOutput_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "echo", "output-check")
require.NoError(t, err)
<-proc.Done()
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID+"/output", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[string]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Contains(t, resp.Data, "output-check")
}
func TestProcessProvider_WaitProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "echo", "wait-check")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[process.Info]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, proc.ID, resp.Data.ID)
assert.Equal(t, process.StatusExited, resp.Data.Status)
assert.Equal(t, 0, resp.Data.ExitCode)
}
func TestProcessProvider_WaitProcess_NonZeroExit_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
var resp goapi.Response[any]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.False(t, resp.Success)
require.NotNil(t, resp.Error)
assert.Equal(t, "wait_failed", resp.Error.Code)
assert.Contains(t, resp.Error.Message, "process exited with code 7")
details, ok := resp.Error.Details.(map[string]any)
require.True(t, ok)
assert.Equal(t, "exited", details["status"])
assert.Equal(t, float64(7), details["exitCode"])
assert.Equal(t, proc.ID, details["id"])
}
func TestProcessProvider_InputAndCloseStdin_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "cat")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
inputReq := strings.NewReader("{\"input\":\"hello-api\\n\"}")
inputHTTPReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/input", inputReq)
require.NoError(t, err)
inputHTTPReq.Header.Set("Content-Type", "application/json")
inputResp := httptest.NewRecorder()
r.ServeHTTP(inputResp, inputHTTPReq)
assert.Equal(t, http.StatusOK, inputResp.Code)
closeReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/close-stdin", nil)
require.NoError(t, err)
closeResp := httptest.NewRecorder()
r.ServeHTTP(closeResp, closeReq)
assert.Equal(t, http.StatusOK, closeResp.Code)
select {
case <-proc.Done():
case <-time.After(5 * time.Second):
t.Fatal("process should have exited after stdin was closed")
}
assert.Contains(t, proc.Output(), "hello-api")
}
func TestProcessProvider_KillProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/kill", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[map[string]any]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, true, resp.Data["killed"])
select {
case <-proc.Done():
case <-time.After(5 * time.Second):
t.Fatal("process should have been killed")
}
assert.Equal(t, process.StatusKilled, proc.Status)
}
func TestProcessProvider_KillProcess_ByPID_Good(t *testing.T) {
svc := newTestProcessService(t)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
cmd := exec.Command("sleep", "60")
require.NoError(t, cmd.Start())
waitCh := make(chan error, 1)
go func() {
waitCh <- cmd.Wait()
}()
t.Cleanup(func() {
if cmd.ProcessState == nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
select {
case <-waitCh:
case <-time.After(2 * time.Second):
}
})
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/kill", nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[map[string]any]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, true, resp.Data["killed"])
select {
case err := <-waitCh:
require.Error(t, err)
case <-time.After(5 * time.Second):
t.Fatal("unmanaged process should have been killed by PID")
}
}
func TestProcessProvider_SignalProcess_Good(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[map[string]any]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, true, resp.Data["signalled"])
select {
case <-proc.Done():
case <-time.After(5 * time.Second):
t.Fatal("process should have been signalled")
}
assert.Equal(t, process.StatusKilled, proc.Status)
}
func TestProcessProvider_SignalProcess_ByPID_Good(t *testing.T) {
svc := newTestProcessService(t)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
cmd := exec.Command("sleep", "60")
require.NoError(t, cmd.Start())
waitCh := make(chan error, 1)
go func() {
waitCh <- cmd.Wait()
}()
t.Cleanup(func() {
if cmd.ProcessState == nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
select {
case <-waitCh:
case <-time.After(2 * time.Second):
}
})
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp goapi.Response[map[string]any]
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.True(t, resp.Success)
assert.Equal(t, true, resp.Data["signalled"])
select {
case err := <-waitCh:
require.Error(t, err)
case <-time.After(5 * time.Second):
t.Fatal("unmanaged process should have been signalled by PID")
}
}
func TestProcessProvider_SignalProcess_InvalidSignal_Bad(t *testing.T) {
svc := newTestProcessService(t)
proc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
p := processapi.NewProvider(nil, svc, nil)
r := setupRouter(p)
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"NOPE"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.True(t, proc.IsRunning())
require.NoError(t, svc.Kill(proc.ID))
<-proc.Done()
}
func TestProcessProvider_BroadcastsProcessEvents_Good(t *testing.T) {
svc := newTestProcessService(t)
hub := corews.NewHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
_ = processapi.NewProvider(nil, svc, hub)
server := httptest.NewServer(hub.Handler())
defer server.Close()
conn := connectWS(t, server.URL)
defer conn.Close()
require.Eventually(t, func() bool {
return hub.ClientCount() == 1
}, time.Second, 10*time.Millisecond)
proc, err := svc.Start(context.Background(), "sh", "-c", "echo live-event")
require.NoError(t, err)
<-proc.Done()
events := readWSEvents(t, conn, "process.started", "process.output", "process.exited")
started := events["process.started"]
require.NotNil(t, started)
startedData := started.Data.(map[string]any)
assert.Equal(t, proc.ID, startedData["id"])
assert.Equal(t, "sh", startedData["command"])
assert.Equal(t, float64(proc.Info().PID), startedData["pid"])
output := events["process.output"]
require.NotNil(t, output)
outputData := output.Data.(map[string]any)
assert.Equal(t, proc.ID, outputData["id"])
assert.Equal(t, "stdout", outputData["stream"])
assert.Contains(t, outputData["line"], "live-event")
exited := events["process.exited"]
require.NotNil(t, exited)
exitedData := exited.Data.(map[string]any)
assert.Equal(t, proc.ID, exitedData["id"])
assert.Equal(t, float64(0), exitedData["exitCode"])
}
func TestProcessProvider_BroadcastsKilledEvents_Good(t *testing.T) {
svc := newTestProcessService(t)
hub := corews.NewHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
_ = processapi.NewProvider(nil, svc, hub)
server := httptest.NewServer(hub.Handler())
defer server.Close()
conn := connectWS(t, server.URL)
defer conn.Close()
require.Eventually(t, func() bool {
return hub.ClientCount() == 1
}, time.Second, 10*time.Millisecond)
proc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
require.NoError(t, svc.Kill(proc.ID))
select {
case <-proc.Done():
case <-time.After(2 * time.Second):
t.Fatal("process should have been killed")
}
events := readWSEvents(t, conn, "process.killed", "process.exited")
killed := events["process.killed"]
require.NotNil(t, killed)
killedData := killed.Data.(map[string]any)
assert.Equal(t, proc.ID, killedData["id"])
assert.Equal(t, "SIGKILL", killedData["signal"])
assert.Equal(t, float64(-1), killedData["exitCode"])
exited := events["process.exited"]
require.NotNil(t, exited)
exitedData := exited.Data.(map[string]any)
assert.Equal(t, proc.ID, exitedData["id"])
assert.Equal(t, float64(-1), exitedData["exitCode"])
}
func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) {
p := processapi.NewProvider(nil, nil, nil)
r := setupRouter(p)
cases := []string{
"/api/process/processes",
"/api/process/processes/anything",
"/api/process/processes/anything/output",
"/api/process/processes/anything/wait",
"/api/process/processes/anything/input",
"/api/process/processes/anything/close-stdin",
"/api/process/processes/anything/kill",
}
for _, path := range cases {
w := httptest.NewRecorder()
method := "GET"
switch {
case strings.HasSuffix(path, "/kill"),
strings.HasSuffix(path, "/wait"),
strings.HasSuffix(path, "/input"),
strings.HasSuffix(path, "/close-stdin"):
method = "POST"
}
req, err := http.NewRequest(method, path, nil)
require.NoError(t, err)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
}
// -- Test helpers ------------------------------------------------------------- // -- Test helpers -------------------------------------------------------------
func setupRouter(p *processapi.ProcessProvider) *gin.Engine { func setupRouter(p *processapi.ProcessProvider) *gin.Engine {
@ -816,58 +148,3 @@ func setupRouter(p *processapi.ProcessProvider) *gin.Engine {
func newTestRegistry(dir string) *process.Registry { func newTestRegistry(dir string) *process.Registry {
return process.NewRegistry(dir) return process.NewRegistry(dir)
} }
func newTestProcessService(t *testing.T) *process.Service {
t.Helper()
c := core.New()
factory := process.NewService(process.Options{})
raw, err := factory(c)
require.NoError(t, err)
return raw.(*process.Service)
}
func connectWS(t *testing.T, serverURL string) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(serverURL, "http")
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err)
return conn
}
func readWSEvents(t *testing.T, conn *websocket.Conn, channels ...string) map[string]corews.Message {
t.Helper()
want := make(map[string]struct{}, len(channels))
for _, channel := range channels {
want[channel] = struct{}{}
}
events := make(map[string]corews.Message, len(channels))
deadline := time.Now().Add(3 * time.Second)
for len(events) < len(channels) && time.Now().Before(deadline) {
require.NoError(t, conn.SetReadDeadline(time.Now().Add(500*time.Millisecond)))
_, payload, err := conn.ReadMessage()
require.NoError(t, err)
for _, line := range strings.Split(strings.TrimSpace(string(payload)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var msg corews.Message
require.NoError(t, json.Unmarshal([]byte(line), &msg))
if _, ok := want[msg.Channel]; ok {
events[msg.Channel] = msg
}
}
}
require.Len(t, events, len(channels))
return events
}

File diff suppressed because it is too large Load diff

View file

@ -2,24 +2,23 @@ package process
import ( import (
"context" "context"
"fmt" "strconv"
"os"
"os/exec"
"sync" "sync"
"syscall" "syscall"
"time" "time"
coreerr "dappco.re/go/core/log" "dappco.re/go/core"
goio "io"
) )
// ManagedProcess represents a managed external process. type processStdin interface {
// Write(p []byte) (n int, err error)
// Example: Close() error
// }
// proc, err := svc.Start(ctx, "echo", "hello")
// ManagedProcess represents a tracked external process started by the service.
type ManagedProcess struct { type ManagedProcess struct {
ID string ID string
PID int
Command string Command string
Args []string Args []string
Dir string Dir string
@ -29,42 +28,28 @@ type ManagedProcess struct {
ExitCode int ExitCode int
Duration time.Duration Duration time.Duration
cmd *exec.Cmd cmd *execCmd
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
output *RingBuffer output *RingBuffer
stdin goio.WriteCloser stdin processStdin
done chan struct{} done chan struct{}
mu sync.RWMutex mu sync.RWMutex
gracePeriod time.Duration gracePeriod time.Duration
killGroup bool killGroup bool
killNotified bool lastSignal string
killSignal string killEmitted bool
} }
// Process is kept as an alias for ManagedProcess for compatibility. // Process is kept as a compatibility alias for ManagedProcess.
type Process = ManagedProcess type Process = ManagedProcess
// Info returns a snapshot of process state. // Info returns a snapshot of process state.
// func (p *ManagedProcess) Info() ProcessInfo {
// Example:
//
// info := proc.Info()
func (p *ManagedProcess) Info() Info {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
pid := 0 return ProcessInfo{
if p.cmd != nil && p.cmd.Process != nil {
pid = p.cmd.Process.Pid
}
duration := p.Duration
if p.Status == StatusRunning {
duration = time.Since(p.StartedAt)
}
return Info{
ID: p.ID, ID: p.ID,
Command: p.Command, Command: p.Command,
Args: append([]string(nil), p.Args...), Args: append([]string(nil), p.Args...),
@ -73,16 +58,12 @@ func (p *ManagedProcess) Info() Info {
Running: p.Status == StatusRunning, Running: p.Status == StatusRunning,
Status: p.Status, Status: p.Status,
ExitCode: p.ExitCode, ExitCode: p.ExitCode,
Duration: duration, Duration: p.Duration,
PID: pid, PID: p.PID,
} }
} }
// Output returns the captured output as a string. // Output returns the captured output as a string.
//
// Example:
//
// fmt.Println(proc.Output())
func (p *ManagedProcess) Output() string { func (p *ManagedProcess) Output() string {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
@ -93,10 +74,6 @@ func (p *ManagedProcess) Output() string {
} }
// OutputBytes returns the captured output as bytes. // OutputBytes returns the captured output as bytes.
//
// Example:
//
// data := proc.OutputBytes()
func (p *ManagedProcess) OutputBytes() []byte { func (p *ManagedProcess) OutputBytes() []byte {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
@ -108,95 +85,61 @@ func (p *ManagedProcess) OutputBytes() []byte {
// IsRunning returns true if the process is still executing. // IsRunning returns true if the process is still executing.
func (p *ManagedProcess) IsRunning() bool { func (p *ManagedProcess) IsRunning() bool {
p.mu.RLock() select {
defer p.mu.RUnlock() case <-p.done:
return p.Status == StatusRunning return false
default:
return true
}
} }
// Wait blocks until the process exits. // Wait blocks until the process exits.
//
// Example:
//
// if err := proc.Wait(); err != nil { return err }
func (p *ManagedProcess) Wait() error { func (p *ManagedProcess) Wait() error {
<-p.done <-p.done
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
if p.Status == StatusFailed { if p.Status == StatusFailed {
return coreerr.E("Process.Wait", fmt.Sprintf("process failed to start: %s", p.ID), nil) return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil)
} }
if p.Status == StatusKilled { if p.Status == StatusKilled {
return coreerr.E("Process.Wait", fmt.Sprintf("process was killed: %s", p.ID), nil) return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil)
} }
if p.ExitCode != 0 { if p.ExitCode != 0 {
return coreerr.E("Process.Wait", fmt.Sprintf("process exited with code %d", p.ExitCode), nil) return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil)
} }
return nil return nil
} }
// Done returns a channel that closes when the process exits. // Done returns a channel that closes when the process exits.
//
// Example:
//
// <-proc.Done()
func (p *ManagedProcess) Done() <-chan struct{} { func (p *ManagedProcess) Done() <-chan struct{} {
return p.done return p.done
} }
// Kill forcefully terminates the process. // Kill forcefully terminates the process.
// If KillGroup is set, kills the entire process group. // If KillGroup is set, kills the entire process group.
//
// Example:
//
// _ = proc.Kill()
func (p *ManagedProcess) Kill() error { func (p *ManagedProcess) Kill() error {
_, err := p.kill()
return err
}
// kill terminates the process and reports whether a signal was actually sent.
func (p *ManagedProcess) kill() (bool, error) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.Status != StatusRunning { if p.Status != StatusRunning {
return false, nil return nil
} }
if p.cmd == nil || p.cmd.Process == nil { if p.cmd == nil || p.cmd.Process == nil {
return false, nil return nil
} }
p.lastSignal = "SIGKILL"
if p.killGroup { if p.killGroup {
// Kill entire process group (negative PID) // Kill entire process group (negative PID)
return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
} }
return true, p.cmd.Process.Kill() return p.cmd.Process.Kill()
}
// killTree forcefully terminates the process group when one exists.
func (p *ManagedProcess) killTree() (bool, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.Status != StatusRunning {
return false, nil
}
if p.cmd == nil || p.cmd.Process == nil {
return false, nil
}
return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
} }
// Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period.
// If GracePeriod was not set (zero), falls back to immediate Kill(). // If GracePeriod was not set (zero), falls back to immediate Kill().
// If KillGroup is set, signals are sent to the entire process group. // If KillGroup is set, signals are sent to the entire process group.
//
// Example:
//
// _ = proc.Shutdown()
func (p *ManagedProcess) Shutdown() error { func (p *ManagedProcess) Shutdown() error {
p.mu.RLock() p.mu.RLock()
grace := p.gracePeriod grace := p.gracePeriod
@ -237,79 +180,11 @@ func (p *ManagedProcess) terminate() error {
if p.killGroup { if p.killGroup {
pid = -pid pid = -pid
} }
p.lastSignal = "SIGTERM"
return syscall.Kill(pid, syscall.SIGTERM) return syscall.Kill(pid, syscall.SIGTERM)
} }
// Signal sends a signal to the process.
//
// Example:
//
// _ = proc.Signal(os.Interrupt)
func (p *ManagedProcess) Signal(sig os.Signal) error {
p.mu.RLock()
status := p.Status
cmd := p.cmd
killGroup := p.killGroup
p.mu.RUnlock()
if status != StatusRunning {
return ErrProcessNotRunning
}
if cmd == nil || cmd.Process == nil {
return nil
}
if !killGroup {
return cmd.Process.Signal(sig)
}
sysSig, ok := sig.(syscall.Signal)
if !ok {
return cmd.Process.Signal(sig)
}
if sysSig == 0 {
return syscall.Kill(-cmd.Process.Pid, 0)
}
if err := syscall.Kill(-cmd.Process.Pid, sysSig); err != nil {
return err
}
// Some shells briefly ignore or defer the signal while they are still
// initialising child jobs. Retry a few times after short delays so the
// whole process group is more reliably terminated. If the requested signal
// still does not stop the group, escalate to SIGKILL so callers do not hang.
go func(pid int, sig syscall.Signal, done <-chan struct{}) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for i := 0; i < 5; i++ {
select {
case <-done:
return
case <-ticker.C:
_ = syscall.Kill(-pid, sig)
}
}
select {
case <-done:
return
default:
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}(cmd.Process.Pid, sysSig, p.done)
return nil
}
// SendInput writes to the process stdin. // SendInput writes to the process stdin.
//
// Example:
//
// _ = proc.SendInput("hello\n")
func (p *ManagedProcess) SendInput(input string) error { func (p *ManagedProcess) SendInput(input string) error {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
@ -327,10 +202,6 @@ func (p *ManagedProcess) SendInput(input string) error {
} }
// CloseStdin closes the process stdin pipe. // CloseStdin closes the process stdin pipe.
//
// Example:
//
// _ = proc.CloseStdin()
func (p *ManagedProcess) CloseStdin() error { func (p *ManagedProcess) CloseStdin() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@ -343,3 +214,20 @@ func (p *ManagedProcess) CloseStdin() error {
p.stdin = nil p.stdin = nil
return err return err
} }
func (p *ManagedProcess) requestedSignal() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.lastSignal
}
func (p *ManagedProcess) markKillEmitted() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.killEmitted {
return false
}
p.killEmitted = true
return true
}

View file

@ -1,306 +0,0 @@
package process
import (
"context"
"os"
"sync"
"sync/atomic"
"dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Global default service used by package-level helpers.
var (
defaultService atomic.Pointer[Service]
defaultOnce sync.Once
defaultErr error
)
// Default returns the global process service.
// Returns nil if not initialised.
//
// Example:
//
// svc := process.Default()
func Default() *Service {
return defaultService.Load()
}
// SetDefault sets the global process service.
// Thread-safe: can be called concurrently with Default().
//
// Example:
//
// _ = process.SetDefault(svc)
func SetDefault(s *Service) error {
if s == nil {
return ErrSetDefaultNil
}
defaultService.Store(s)
return nil
}
// Init initializes the default global service with a Core instance.
// This is typically called during application startup.
//
// Example:
//
// _ = process.Init(coreInstance)
func Init(c *core.Core) error {
defaultOnce.Do(func() {
factory := NewService(Options{})
svc, err := factory(c)
if err != nil {
defaultErr = err
return
}
defaultService.Store(svc.(*Service))
})
return defaultErr
}
// Register creates a process service for Core registration.
//
// Example:
//
// result := process.Register(coreInstance)
func Register(c *core.Core) core.Result {
factory := NewService(Options{})
svc, err := factory(c)
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: svc, OK: true}
}
// --- Global convenience functions ---
// Start spawns a new process using the default service.
//
// Example:
//
// proc, err := process.Start(ctx, "echo", "hello")
func Start(ctx context.Context, command string, args ...string) (*Process, error) {
svc := Default()
if svc == nil {
return nil, ErrServiceNotInitialized
}
return svc.Start(ctx, command, args...)
}
// Run executes a command and waits for completion using the default service.
//
// Example:
//
// out, err := process.Run(ctx, "echo", "hello")
func Run(ctx context.Context, command string, args ...string) (string, error) {
svc := Default()
if svc == nil {
return "", ErrServiceNotInitialized
}
return svc.Run(ctx, command, args...)
}
// Get returns a process by ID from the default service.
//
// Example:
//
// proc, err := process.Get("proc-1")
func Get(id string) (*Process, error) {
svc := Default()
if svc == nil {
return nil, ErrServiceNotInitialized
}
return svc.Get(id)
}
// Output returns the captured output for a process from the default service.
//
// Example:
//
// out, err := process.Output("proc-1")
func Output(id string) (string, error) {
svc := Default()
if svc == nil {
return "", ErrServiceNotInitialized
}
return svc.Output(id)
}
// Input writes data to the stdin of a managed process using the default service.
//
// Example:
//
// _ = process.Input("proc-1", "hello\n")
func Input(id string, input string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.Input(id, input)
}
// CloseStdin closes the stdin pipe of a managed process using the default service.
//
// Example:
//
// _ = process.CloseStdin("proc-1")
func CloseStdin(id string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.CloseStdin(id)
}
// Wait blocks until a managed process exits and returns its final snapshot.
//
// Example:
//
// info, err := process.Wait("proc-1")
func Wait(id string) (Info, error) {
svc := Default()
if svc == nil {
return Info{}, ErrServiceNotInitialized
}
return svc.Wait(id)
}
// List returns all processes from the default service.
//
// Example:
//
// procs := process.List()
func List() []*Process {
svc := Default()
if svc == nil {
return nil
}
return svc.List()
}
// Kill terminates a process by ID using the default service.
//
// Example:
//
// _ = process.Kill("proc-1")
func Kill(id string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.Kill(id)
}
// KillPID terminates a process by operating-system PID using the default service.
//
// Example:
//
// _ = process.KillPID(1234)
func KillPID(pid int) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.KillPID(pid)
}
// Signal sends a signal to a process by ID using the default service.
//
// Example:
//
// _ = process.Signal("proc-1", syscall.SIGTERM)
func Signal(id string, sig os.Signal) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.Signal(id, sig)
}
// SignalPID sends a signal to a process by operating-system PID using the default service.
//
// Example:
//
// _ = process.SignalPID(1234, syscall.SIGTERM)
func SignalPID(pid int, sig os.Signal) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.SignalPID(pid, sig)
}
// StartWithOptions spawns a process with full configuration using the default service.
//
// Example:
//
// proc, err := process.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"})
func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) {
svc := Default()
if svc == nil {
return nil, ErrServiceNotInitialized
}
return svc.StartWithOptions(ctx, opts)
}
// RunWithOptions executes a command with options and waits using the default service.
//
// Example:
//
// out, err := process.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}})
func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) {
svc := Default()
if svc == nil {
return "", ErrServiceNotInitialized
}
return svc.RunWithOptions(ctx, opts)
}
// Running returns all currently running processes from the default service.
//
// Example:
//
// running := process.Running()
func Running() []*Process {
svc := Default()
if svc == nil {
return nil
}
return svc.Running()
}
// Remove removes a completed process from the default service.
//
// Example:
//
// _ = process.Remove("proc-1")
func Remove(id string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.Remove(id)
}
// Clear removes all completed processes from the default service.
//
// Example:
//
// process.Clear()
func Clear() {
svc := Default()
if svc == nil {
return
}
svc.Clear()
}
// Errors
var (
// ErrServiceNotInitialized is returned when the service is not initialised.
ErrServiceNotInitialized = coreerr.E("", "process: service not initialized; call process.Init(core) first", nil)
// ErrSetDefaultNil is returned when SetDefault is called with nil.
ErrSetDefaultNil = coreerr.E("", "process: SetDefault called with nil service", nil)
)

View file

@ -3,7 +3,6 @@ package process
import ( import (
"context" "context"
"os" "os"
"syscall"
"testing" "testing"
"time" "time"
@ -11,13 +10,10 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var _ *Process = (*ManagedProcess)(nil) func TestProcess_Info_Good(t *testing.T) {
func TestProcess_Info(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc, err := svc.Start(context.Background(), "echo", "hello") proc := startProc(t, svc, context.Background(), "echo", "hello")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
@ -25,14 +21,13 @@ func TestProcess_Info(t *testing.T) {
assert.Equal(t, proc.ID, info.ID) assert.Equal(t, proc.ID, info.ID)
assert.Equal(t, "echo", info.Command) assert.Equal(t, "echo", info.Command)
assert.Equal(t, []string{"hello"}, info.Args) assert.Equal(t, []string{"hello"}, info.Args)
assert.False(t, info.Running)
assert.Equal(t, StatusExited, info.Status) assert.Equal(t, StatusExited, info.Status)
assert.Equal(t, 0, info.ExitCode) assert.Equal(t, 0, info.ExitCode)
assert.Greater(t, info.Duration, time.Duration(0)) assert.Greater(t, info.Duration, time.Duration(0))
} }
func TestProcess_Info_Pending(t *testing.T) { func TestProcess_Info_Pending_Good(t *testing.T) {
proc := &Process{ proc := &ManagedProcess{
ID: "pending", ID: "pending",
Status: StatusPending, Status: StatusPending,
done: make(chan struct{}), done: make(chan struct{}),
@ -43,307 +38,163 @@ func TestProcess_Info_Pending(t *testing.T) {
assert.False(t, info.Running) assert.False(t, info.Running)
} }
func TestProcess_Info_RunningDuration(t *testing.T) { func TestProcess_Output_Good(t *testing.T) {
svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
proc, err := svc.Start(ctx, "sleep", "10")
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
info := proc.Info()
assert.True(t, info.Running)
assert.Equal(t, StatusRunning, info.Status)
assert.Greater(t, info.Duration, time.Duration(0))
cancel()
<-proc.Done()
}
func TestProcess_InfoSnapshot(t *testing.T) {
svc, _ := newTestService(t)
proc, err := svc.Start(context.Background(), "echo", "snapshot")
require.NoError(t, err)
<-proc.Done()
info := proc.Info()
require.NotEmpty(t, info.Args)
info.Args[0] = "mutated"
assert.Equal(t, "snapshot", proc.Args[0])
assert.Equal(t, "mutated", info.Args[0])
}
func TestProcess_Output(t *testing.T) {
t.Run("captures stdout", func(t *testing.T) { t.Run("captures stdout", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "hello world")
proc, err := svc.Start(context.Background(), "echo", "hello world")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
assert.Contains(t, proc.Output(), "hello world")
output := proc.Output()
assert.Contains(t, output, "hello world")
}) })
t.Run("OutputBytes returns copy", func(t *testing.T) { t.Run("OutputBytes returns copy", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "test")
proc, err := svc.Start(context.Background(), "echo", "test")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
bytes := proc.OutputBytes() bytes := proc.OutputBytes()
assert.NotNil(t, bytes) assert.NotNil(t, bytes)
assert.Contains(t, string(bytes), "test") assert.Contains(t, string(bytes), "test")
}) })
} }
func TestProcess_IsRunning(t *testing.T) { func TestProcess_IsRunning_Good(t *testing.T) {
t.Run("true while running", func(t *testing.T) { t.Run("true while running", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
proc, err := svc.Start(ctx, "sleep", "10") proc := startProc(t, svc, ctx, "sleep", "10")
require.NoError(t, err)
assert.True(t, proc.IsRunning()) assert.True(t, proc.IsRunning())
assert.True(t, proc.Info().Running)
cancel() cancel()
<-proc.Done() <-proc.Done()
assert.False(t, proc.IsRunning()) assert.False(t, proc.IsRunning())
assert.False(t, proc.Info().Running)
}) })
t.Run("false after completion", func(t *testing.T) { t.Run("false after completion", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "done")
proc, err := svc.Start(context.Background(), "echo", "done")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
assert.False(t, proc.IsRunning()) assert.False(t, proc.IsRunning())
}) })
} }
func TestProcess_Wait(t *testing.T) { func TestProcess_Wait_Good(t *testing.T) {
t.Run("returns nil on success", func(t *testing.T) { t.Run("returns nil on success", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "ok")
proc, err := svc.Start(context.Background(), "echo", "ok") err := proc.Wait()
require.NoError(t, err)
err = proc.Wait()
assert.NoError(t, err) assert.NoError(t, err)
}) })
t.Run("returns error on failure", func(t *testing.T) { t.Run("returns error on failure", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1")
proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1") err := proc.Wait()
require.NoError(t, err)
err = proc.Wait()
assert.Error(t, err) assert.Error(t, err)
}) })
} }
func TestProcess_Done(t *testing.T) { func TestProcess_Done_Good(t *testing.T) {
t.Run("channel closes on completion", func(t *testing.T) { t.Run("channel closes on completion", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "test")
proc, err := svc.Start(context.Background(), "echo", "test")
require.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Success - channel closed
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("Done channel should have closed") t.Fatal("Done channel should have closed")
} }
}) })
} }
func TestProcess_Kill(t *testing.T) { func TestProcess_Kill_Good(t *testing.T) {
t.Run("terminates running process", func(t *testing.T) { t.Run("terminates running process", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
proc, err := svc.Start(ctx, "sleep", "60") proc := startProc(t, svc, ctx, "sleep", "60")
require.NoError(t, err)
assert.True(t, proc.IsRunning()) assert.True(t, proc.IsRunning())
err = proc.Kill() err := proc.Kill()
assert.NoError(t, err) assert.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good - process terminated
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("process should have been killed") t.Fatal("process should have been killed")
} }
assert.Equal(t, StatusKilled, proc.Status) assert.Equal(t, StatusKilled, proc.Status)
assert.Equal(t, -1, proc.ExitCode)
}) })
t.Run("noop on completed process", func(t *testing.T) { t.Run("noop on completed process", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "done")
proc, err := svc.Start(context.Background(), "echo", "done")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
err := proc.Kill()
err = proc.Kill()
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
func TestProcess_SendInput(t *testing.T) { func TestProcess_SendInput_Good(t *testing.T) {
t.Run("writes to stdin", func(t *testing.T) { t.Run("writes to stdin", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "cat")
// Use cat to echo back stdin err := proc.SendInput("hello\n")
proc, err := svc.Start(context.Background(), "cat")
require.NoError(t, err)
err = proc.SendInput("hello\n")
assert.NoError(t, err) assert.NoError(t, err)
err = proc.CloseStdin() err = proc.CloseStdin()
assert.NoError(t, err) assert.NoError(t, err)
<-proc.Done() <-proc.Done()
assert.Contains(t, proc.Output(), "hello") assert.Contains(t, proc.Output(), "hello")
}) })
t.Run("error on completed process", func(t *testing.T) { t.Run("error on completed process", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "done")
proc, err := svc.Start(context.Background(), "echo", "done")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
err := proc.SendInput("test")
err = proc.SendInput("test")
assert.ErrorIs(t, err, ErrProcessNotRunning) assert.ErrorIs(t, err, ErrProcessNotRunning)
}) })
} }
func TestProcess_Signal(t *testing.T) { func TestProcess_Signal_Good(t *testing.T) {
t.Run("sends signal to running process", func(t *testing.T) { t.Run("sends signal to running process", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
proc, err := svc.Start(ctx, "sleep", "60") proc := startProc(t, svc, ctx, "sleep", "60")
require.NoError(t, err) err := proc.Signal(os.Interrupt)
err = proc.Signal(os.Interrupt)
assert.NoError(t, err) assert.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Process terminated by signal
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("process should have been terminated by signal") t.Fatal("process should have been terminated by signal")
} }
assert.Equal(t, StatusKilled, proc.Status) assert.Equal(t, StatusKilled, proc.Status)
}) })
t.Run("error on completed process", func(t *testing.T) { t.Run("error on completed process", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "done")
proc, err := svc.Start(context.Background(), "echo", "done")
require.NoError(t, err)
<-proc.Done() <-proc.Done()
err := proc.Signal(os.Interrupt)
err = proc.Signal(os.Interrupt)
assert.ErrorIs(t, err, ErrProcessNotRunning) assert.ErrorIs(t, err, ErrProcessNotRunning)
}) })
t.Run("signals process group when kill group is enabled", func(t *testing.T) {
svc, _ := newTestService(t)
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sh",
Args: []string{"-c", "trap '' INT; sh -c 'trap - INT; sleep 60' & wait"},
Detach: true,
KillGroup: true,
})
require.NoError(t, err)
err = proc.Signal(os.Interrupt)
assert.NoError(t, err)
select {
case <-proc.Done():
// Good - the whole process group responded to the signal.
case <-time.After(5 * time.Second):
t.Fatal("process group should have been terminated by signal")
}
})
t.Run("signal zero only probes process group liveness", func(t *testing.T) {
svc, _ := newTestService(t)
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sh",
Args: []string{"-c", "sleep 60 & wait"},
Detach: true,
KillGroup: true,
})
require.NoError(t, err)
err = proc.Signal(syscall.Signal(0))
assert.NoError(t, err)
time.Sleep(300 * time.Millisecond)
assert.True(t, proc.IsRunning())
err = proc.Kill()
assert.NoError(t, err)
select {
case <-proc.Done():
case <-time.After(5 * time.Second):
t.Fatal("process group should have been killed for cleanup")
}
})
} }
func TestProcess_CloseStdin(t *testing.T) { func TestProcess_CloseStdin_Good(t *testing.T) {
t.Run("closes stdin pipe", func(t *testing.T) { t.Run("closes stdin pipe", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "cat")
proc, err := svc.Start(context.Background(), "cat") err := proc.CloseStdin()
require.NoError(t, err)
err = proc.CloseStdin()
assert.NoError(t, err) assert.NoError(t, err)
// Process should exit now that stdin is closed
select { select {
case <-proc.Done(): case <-proc.Done():
// Good
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("cat should exit when stdin is closed") t.Fatal("cat should exit when stdin is closed")
} }
@ -351,156 +202,132 @@ func TestProcess_CloseStdin(t *testing.T) {
t.Run("double close is safe", func(t *testing.T) { t.Run("double close is safe", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "cat")
proc, err := svc.Start(context.Background(), "cat") err := proc.CloseStdin()
require.NoError(t, err)
// First close
err = proc.CloseStdin()
assert.NoError(t, err) assert.NoError(t, err)
<-proc.Done() <-proc.Done()
// Second close should be safe (stdin already nil)
err = proc.CloseStdin() err = proc.CloseStdin()
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
func TestProcess_Timeout(t *testing.T) { func TestProcess_Timeout_Good(t *testing.T) {
t.Run("kills process after timeout", func(t *testing.T) { t.Run("kills process after timeout", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sleep", Command: "sleep",
Args: []string{"60"}, Args: []string{"60"},
Timeout: 200 * time.Millisecond, Timeout: 200 * time.Millisecond,
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good — process was killed by timeout
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("process should have been killed by timeout") t.Fatal("process should have been killed by timeout")
} }
assert.False(t, proc.IsRunning()) assert.False(t, proc.IsRunning())
assert.Equal(t, StatusKilled, proc.Status) assert.Equal(t, StatusKilled, proc.Status)
}) })
t.Run("no timeout when zero", func(t *testing.T) { t.Run("no timeout when zero", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "echo", Command: "echo",
Args: []string{"fast"}, Args: []string{"fast"},
Timeout: 0, Timeout: 0,
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
<-proc.Done() <-proc.Done()
assert.Equal(t, 0, proc.ExitCode) assert.Equal(t, 0, proc.ExitCode)
}) })
} }
func TestProcess_Shutdown(t *testing.T) { func TestProcess_Shutdown_Good(t *testing.T) {
t.Run("graceful with grace period", func(t *testing.T) { t.Run("graceful with grace period", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
// Use a process that traps SIGTERM
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sleep", Command: "sleep",
Args: []string{"60"}, Args: []string{"60"},
GracePeriod: 100 * time.Millisecond, GracePeriod: 100 * time.Millisecond,
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
assert.True(t, proc.IsRunning()) assert.True(t, proc.IsRunning())
err := proc.Shutdown()
err = proc.Shutdown()
assert.NoError(t, err) assert.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("shutdown should have completed") t.Fatal("shutdown should have completed")
} }
assert.Equal(t, StatusKilled, proc.Status)
}) })
t.Run("immediate kill without grace period", func(t *testing.T) { t.Run("immediate kill without grace period", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sleep", Command: "sleep",
Args: []string{"60"}, Args: []string{"60"},
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
err = proc.Shutdown() err := proc.Shutdown()
assert.NoError(t, err) assert.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("kill should be immediate") t.Fatal("kill should be immediate")
} }
}) })
} }
func TestProcess_KillGroup(t *testing.T) { func TestProcess_KillGroup_Good(t *testing.T) {
t.Run("kills child processes", func(t *testing.T) { t.Run("kills child processes", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
// Spawn a parent that spawns a child — KillGroup should kill both
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sh", Command: "sh",
Args: []string{"-c", "sleep 60 & wait"}, Args: []string{"-c", "sleep 60 & wait"},
Detach: true, Detach: true,
KillGroup: true, KillGroup: true,
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
// Give child time to spawn
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
err := proc.Kill()
err = proc.Kill()
assert.NoError(t, err) assert.NoError(t, err)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good — whole group killed
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("process group should have been killed") t.Fatal("process group should have been killed")
} }
assert.Equal(t, StatusKilled, proc.Status)
}) })
} }
func TestProcess_TimeoutWithGrace(t *testing.T) { func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
t.Run("timeout triggers graceful shutdown", func(t *testing.T) { t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
svc, _ := newTestService(t) svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
Command: "sleep", Command: "sleep",
Args: []string{"60"}, Args: []string{"60"},
Timeout: 200 * time.Millisecond, Timeout: 200 * time.Millisecond,
GracePeriod: 100 * time.Millisecond, GracePeriod: 100 * time.Millisecond,
}) })
require.NoError(t, err) require.True(t, r.OK)
proc := r.Value.(*Process)
select { select {
case <-proc.Done(): case <-proc.Done():
// Good — timeout + grace triggered
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("process should have been killed by timeout") t.Fatal("process should have been killed by timeout")
} }
assert.Equal(t, StatusKilled, proc.Status) assert.Equal(t, StatusKilled, proc.Status)
}) })
} }

View file

@ -3,36 +3,24 @@ package process
import ( import (
"bytes" "bytes"
"context" "context"
"os/exec" "path/filepath"
"strings" "strconv"
"unicode"
core "dappco.re/go/core" "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH. // ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
// Callers may use errors.Is to detect this condition. // 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)
// ErrProgramContextRequired is returned when Run or RunDir is called without a context.
var ErrProgramContextRequired = coreerr.E("", "program: command context is required", nil)
// ErrProgramNameRequired is returned when Run or RunDir is called without a program name.
var ErrProgramNameRequired = coreerr.E("", "program: program name is empty", nil)
// Program represents a named executable located on the system PATH. // Program represents a named executable located on the system PATH.
// Create one with a Name, call Find to resolve its path, then Run or RunDir.
// //
// Example: // p := &process.Program{Name: "go"}
//
// git := &process.Program{Name: "git"}
// if err := git.Find(); err != nil { return err }
// out, err := git.Run(ctx, "status")
type Program struct { type Program struct {
// Name is the binary name (e.g. "go", "node", "git"). // Name is the binary name (e.g. "go", "node", "git").
Name string Name string
// Path is the absolute path resolved by Find. // Path is the absolute path resolved by Find.
// Example: "/usr/bin/git"
// If empty, Run and RunDir fall back to Name for OS PATH resolution. // If empty, Run and RunDir fall back to Name for OS PATH resolution.
Path string Path string
} }
@ -40,20 +28,20 @@ type Program struct {
// Find resolves the program's absolute path using exec.LookPath. // Find resolves the program's absolute path using exec.LookPath.
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH. // Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
// //
// Example: // err := p.Find()
//
// if err := p.Find(); err != nil { return err }
func (p *Program) Find() error { func (p *Program) Find() error {
target := p.Path if p.Name == "" {
if target == "" { return core.E("program.find", "program name is empty", nil)
target = p.Name
} }
if target == "" { path, err := execLookPath(p.Name)
return coreerr.E("Program.Find", "program name is empty", nil)
}
path, err := exec.LookPath(target)
if err != nil { if err != nil {
return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", target), ErrProgramNotFound) return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": not found in PATH"), ErrProgramNotFound)
}
if !filepath.IsAbs(path) {
path, err = filepath.Abs(path)
if err != nil {
return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": failed to resolve absolute path"), err)
}
} }
p.Path = path p.Path = path
return nil return nil
@ -62,9 +50,7 @@ func (p *Program) Find() error {
// Run executes the program with args in the current working directory. // Run executes the program with args in the current working directory.
// Returns trimmed combined stdout+stderr output and any error. // Returns trimmed combined stdout+stderr output and any error.
// //
// Example: // out, err := p.Run(ctx, "version")
//
// out, err := p.Run(ctx, "hello")
func (p *Program) Run(ctx context.Context, args ...string) (string, error) { func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
return p.RunDir(ctx, "", args...) return p.RunDir(ctx, "", args...)
} }
@ -73,25 +59,18 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
// Returns trimmed combined stdout+stderr output and any error. // Returns trimmed combined stdout+stderr output and any error.
// If dir is empty, the process inherits the caller's working directory. // If dir is empty, the process inherits the caller's working directory.
// //
// Example: // out, err := p.RunDir(ctx, "/workspace", "test", "./...")
//
// out, err := p.RunDir(ctx, "/tmp", "pwd")
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) {
if ctx == nil {
return "", coreerr.E("Program.RunDir", "program: command context is required", ErrProgramContextRequired)
}
binary := p.Path binary := p.Path
if binary == "" { if binary == "" {
binary = p.Name binary = p.Name
} }
if ctx == nil {
if binary == "" { ctx = context.Background()
return "", coreerr.E("Program.RunDir", "program name is empty", ErrProgramNameRequired)
} }
var out bytes.Buffer var out bytes.Buffer
cmd := exec.CommandContext(ctx, binary, args...) cmd := execCommandContext(ctx, binary, args...)
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
if dir != "" { if dir != "" {
@ -99,7 +78,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
} }
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return strings.TrimRightFunc(out.String(), unicode.IsSpace), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err) return string(bytes.TrimSpace(out.Bytes())), core.E("program.run", core.Concat(strconv.Quote(p.Name), ": command failed"), err)
} }
return strings.TrimRightFunc(out.String(), unicode.IsSpace), nil return string(bytes.TrimSpace(out.Bytes())), nil
} }

View file

@ -2,11 +2,12 @@ package process_test
import ( import (
"context" "context"
"os/exec" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -20,47 +21,26 @@ func testCtx(t *testing.T) context.Context {
return ctx return ctx
} }
func TestProgram_Find_KnownBinary(t *testing.T) { func TestProgram_Find_Good(t *testing.T) {
p := &process.Program{Name: "echo"} p := &process.Program{Name: "echo"}
require.NoError(t, p.Find()) require.NoError(t, p.Find())
assert.NotEmpty(t, p.Path) assert.NotEmpty(t, p.Path)
assert.True(t, filepath.IsAbs(p.Path))
} }
func TestProgram_Find_UnknownBinary(t *testing.T) { func TestProgram_FindUnknown_Bad(t *testing.T) {
p := &process.Program{Name: "no-such-binary-xyzzy-42"} p := &process.Program{Name: "no-such-binary-xyzzy-42"}
err := p.Find() err := p.Find()
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, process.ErrProgramNotFound) assert.ErrorIs(t, err, process.ErrProgramNotFound)
} }
func TestProgram_Find_UsesExistingPath(t *testing.T) { func TestProgram_FindEmpty_Bad(t *testing.T) {
path, err := exec.LookPath("echo")
require.NoError(t, err)
p := &process.Program{Path: path}
require.NoError(t, p.Find())
assert.Equal(t, path, p.Path)
}
func TestProgram_Find_PrefersExistingPathOverName(t *testing.T) {
path, err := exec.LookPath("echo")
require.NoError(t, err)
p := &process.Program{
Name: "no-such-binary-xyzzy-42",
Path: path,
}
require.NoError(t, p.Find())
assert.Equal(t, path, p.Path)
}
func TestProgram_Find_EmptyName(t *testing.T) {
p := &process.Program{} p := &process.Program{}
require.Error(t, p.Find()) require.Error(t, p.Find())
} }
func TestProgram_Run_ReturnsOutput(t *testing.T) { func TestProgram_Run_Good(t *testing.T) {
p := &process.Program{Name: "echo"} p := &process.Program{Name: "echo"}
require.NoError(t, p.Find()) require.NoError(t, p.Find())
@ -69,16 +49,7 @@ func TestProgram_Run_ReturnsOutput(t *testing.T) {
assert.Equal(t, "hello", out) assert.Equal(t, "hello", out)
} }
func TestProgram_Run_PreservesLeadingWhitespace(t *testing.T) { func TestProgram_RunFallback_Good(t *testing.T) {
p := &process.Program{Name: "sh"}
require.NoError(t, p.Find())
out, err := p.Run(testCtx(t), "-c", "printf ' hello \n'")
require.NoError(t, err)
assert.Equal(t, " hello", out)
}
func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
// Path is empty; RunDir should fall back to Name for OS PATH resolution. // Path is empty; RunDir should fall back to Name for OS PATH resolution.
p := &process.Program{Name: "echo"} p := &process.Program{Name: "echo"}
@ -87,7 +58,15 @@ func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
assert.Equal(t, "fallback", out) assert.Equal(t, "fallback", out)
} }
func TestProgram_RunDir_UsesDirectory(t *testing.T) { func TestProgram_RunNilContext_Good(t *testing.T) {
p := &process.Program{Name: "echo"}
out, err := p.Run(nil, "nil-context")
require.NoError(t, err)
assert.Equal(t, "nil-context", out)
}
func TestProgram_RunDir_Good(t *testing.T) {
p := &process.Program{Name: "pwd"} p := &process.Program{Name: "pwd"}
require.NoError(t, p.Find()) require.NoError(t, p.Find())
@ -95,34 +74,17 @@ func TestProgram_RunDir_UsesDirectory(t *testing.T) {
out, err := p.RunDir(testCtx(t), dir) out, err := p.RunDir(testCtx(t), dir)
require.NoError(t, err) require.NoError(t, err)
// Resolve symlinks on both sides for portability (macOS uses /private/ prefix). dirInfo, err := os.Stat(dir)
canonicalDir, err := filepath.EvalSymlinks(dir)
require.NoError(t, err) require.NoError(t, err)
canonicalOut, err := filepath.EvalSymlinks(out) outInfo, err := os.Stat(core.Trim(out))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, canonicalDir, canonicalOut) assert.True(t, os.SameFile(dirInfo, outInfo))
} }
func TestProgram_Run_FailingCommand(t *testing.T) { func TestProgram_RunFailure_Bad(t *testing.T) {
p := &process.Program{Name: "false"} p := &process.Program{Name: "false"}
require.NoError(t, p.Find()) require.NoError(t, p.Find())
_, err := p.Run(testCtx(t)) _, err := p.Run(testCtx(t))
require.Error(t, err) require.Error(t, err)
} }
func TestProgram_Run_NilContextRejected(t *testing.T) {
p := &process.Program{Name: "echo"}
_, err := p.Run(nil, "test")
require.Error(t, err)
assert.ErrorIs(t, err, process.ErrProgramContextRequired)
}
func TestProgram_RunDir_EmptyNameRejected(t *testing.T) {
p := &process.Program{}
_, err := p.RunDir(testCtx(t), "", "test")
require.Error(t, err)
assert.ErrorIs(t, err, process.ErrProgramNameRequired)
}

View file

@ -1,23 +1,18 @@
package process package process
import ( import (
"encoding/json" "path"
"os" "strconv"
"path/filepath"
"sort"
"strings"
"syscall" "syscall"
"time" "time"
"dappco.re/go/core"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
) )
// DaemonEntry records a running daemon in the registry. // DaemonEntry records a running daemon in the registry.
// //
// Example: // entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234}
//
// entry := process.DaemonEntry{Code: "app", Daemon: "serve", PID: os.Getpid()}
type DaemonEntry struct { type DaemonEntry struct {
Code string `json:"code"` Code string `json:"code"`
Daemon string `json:"daemon"` Daemon string `json:"daemon"`
@ -29,80 +24,63 @@ type DaemonEntry struct {
} }
// Registry tracks running daemons via JSON files in a directory. // Registry tracks running daemons via JSON files in a directory.
//
// reg := process.NewRegistry("/tmp/process-daemons")
type Registry struct { type Registry struct {
dir string dir string
} }
// NewRegistry creates a registry backed by the given directory. // NewRegistry creates a registry backed by the given directory.
// //
// Example: // reg := process.NewRegistry("/tmp/process-daemons")
//
// reg := process.NewRegistry("/tmp/daemons")
func NewRegistry(dir string) *Registry { func NewRegistry(dir string) *Registry {
return &Registry{dir: dir} return &Registry{dir: dir}
} }
// DefaultRegistry returns a registry using ~/.core/daemons/. // DefaultRegistry returns a registry using ~/.core/daemons/.
// //
// Example:
//
// reg := process.DefaultRegistry() // reg := process.DefaultRegistry()
func DefaultRegistry() *Registry { func DefaultRegistry() *Registry {
home, err := os.UserHomeDir() home, err := userHomeDir()
if err != nil { if err != nil {
home = os.TempDir() home = tempDir()
} }
return NewRegistry(filepath.Join(home, ".core", "daemons")) return NewRegistry(path.Join(home, ".core", "daemons"))
} }
// Register writes a daemon entry to the registry directory. // Register writes a daemon entry to the registry directory.
// If Started is zero, it is set to the current time. // If Started is zero, it is set to the current time.
// The directory is created if it does not exist. // The directory is created if it does not exist.
//
// Example:
//
// _ = reg.Register(entry)
func (r *Registry) Register(entry DaemonEntry) error { func (r *Registry) Register(entry DaemonEntry) error {
if entry.Started.IsZero() { if entry.Started.IsZero() {
entry.Started = time.Now() entry.Started = time.Now()
} }
if err := coreio.Local.EnsureDir(r.dir); err != nil { 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 := json.MarshalIndent(entry, "", " ") data, err := marshalDaemonEntry(entry)
if err != nil { 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), string(data)); err != nil { 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 return nil
} }
// Unregister removes a daemon entry from the registry. // Unregister removes a daemon entry from the registry.
//
// Example:
//
// _ = reg.Unregister("app", "serve")
func (r *Registry) Unregister(code, daemon string) error { func (r *Registry) Unregister(code, daemon string) error {
if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil { if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil {
if os.IsNotExist(err) { return core.E("registry.unregister", "failed to delete entry file", err)
return nil
}
return coreerr.E("Registry.Unregister", "failed to delete entry file", err)
} }
return nil return nil
} }
// Get reads a single daemon entry and checks whether its process is alive. // Get reads a single daemon entry and checks whether its process is alive.
// If the process is dead, the stale file is removed and (nil, false) is returned. // If the process is dead, the stale file is removed and (nil, false) is returned.
//
// Example:
//
// entry, ok := reg.Get("app", "serve")
func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
path := r.entryPath(code, daemon) path := r.entryPath(code, daemon)
@ -111,8 +89,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
return nil, false return nil, false
} }
var entry DaemonEntry entry, err := unmarshalDaemonEntry(data)
if err := json.Unmarshal([]byte(data), &entry); err != nil { if err != nil {
_ = coreio.Local.Delete(path) _ = coreio.Local.Delete(path)
return nil, false return nil, false
} }
@ -126,25 +104,29 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
} }
// List returns all alive daemon entries, pruning any with dead PIDs. // List returns all alive daemon entries, pruning any with dead PIDs.
//
// Example:
//
// entries, err := reg.List()
func (r *Registry) List() ([]DaemonEntry, error) { func (r *Registry) List() ([]DaemonEntry, error) {
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json")) if !coreio.Local.Exists(r.dir) {
return nil, nil
}
entries, err := coreio.Local.List(r.dir)
if err != nil { if err != nil {
return nil, err return nil, core.E("registry.list", "failed to list registry directory", err)
} }
var alive []DaemonEntry var alive []DaemonEntry
for _, path := range matches { for _, entryFile := range entries {
if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") {
continue
}
path := path.Join(r.dir, entryFile.Name())
data, err := coreio.Local.Read(path) data, err := coreio.Local.Read(path)
if err != nil { if err != nil {
continue continue
} }
var entry DaemonEntry entry, err := unmarshalDaemonEntry(data)
if err := json.Unmarshal([]byte(data), &entry); err != nil { if err != nil {
_ = coreio.Local.Delete(path) _ = coreio.Local.Delete(path)
continue continue
} }
@ -157,23 +139,13 @@ func (r *Registry) List() ([]DaemonEntry, error) {
alive = append(alive, entry) alive = append(alive, entry)
} }
sort.Slice(alive, func(i, j int) bool {
if alive[i].Started.Equal(alive[j].Started) {
if alive[i].Code == alive[j].Code {
return alive[i].Daemon < alive[j].Daemon
}
return alive[i].Code < alive[j].Code
}
return alive[i].Started.Before(alive[j].Started)
})
return alive, nil return alive, nil
} }
// entryPath returns the filesystem path for a daemon entry. // entryPath returns the filesystem path for a daemon entry.
func (r *Registry) entryPath(code, daemon string) string { func (r *Registry) entryPath(code, daemon string) string {
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json" name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
return filepath.Join(r.dir, name) return path.Join(r.dir, name)
} }
// isAlive checks whether a process with the given PID is running. // isAlive checks whether a process with the given PID is running.
@ -181,9 +153,263 @@ func isAlive(pid int) bool {
if pid <= 0 { if pid <= 0 {
return false return false
} }
proc, err := os.FindProcess(pid) proc, err := processHandle(pid)
if err != nil { if err != nil {
return false return false
} }
return proc.Signal(syscall.Signal(0)) == nil return proc.Signal(syscall.Signal(0)) == nil
} }
func sanitizeRegistryComponent(value string) string {
buf := make([]byte, len(value))
for i := 0; i < len(value); i++ {
if value[i] == '/' {
buf[i] = '-'
continue
}
buf[i] = value[i]
}
return string(buf)
}
func marshalDaemonEntry(entry DaemonEntry) (string, error) {
fields := []struct {
key string
value string
}{
{key: "code", value: quoteJSONString(entry.Code)},
{key: "daemon", value: quoteJSONString(entry.Daemon)},
{key: "pid", value: strconv.Itoa(entry.PID)},
}
if entry.Health != "" {
fields = append(fields, struct {
key string
value string
}{key: "health", value: quoteJSONString(entry.Health)})
}
if entry.Project != "" {
fields = append(fields, struct {
key string
value string
}{key: "project", value: quoteJSONString(entry.Project)})
}
if entry.Binary != "" {
fields = append(fields, struct {
key string
value string
}{key: "binary", value: quoteJSONString(entry.Binary)})
}
fields = append(fields, struct {
key string
value string
}{
key: "started",
value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)),
})
builder := core.NewBuilder()
builder.WriteString("{\n")
for i, field := range fields {
builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value))
if i < len(fields)-1 {
builder.WriteString(",")
}
builder.WriteString("\n")
}
builder.WriteString("}")
return builder.String(), nil
}
func unmarshalDaemonEntry(data string) (DaemonEntry, error) {
values, err := parseJSONObject(data)
if err != nil {
return DaemonEntry{}, err
}
entry := DaemonEntry{
Code: values["code"],
Daemon: values["daemon"],
Health: values["health"],
Project: values["project"],
Binary: values["binary"],
}
pidValue, ok := values["pid"]
if !ok {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil)
}
entry.PID, err = strconv.Atoi(pidValue)
if err != nil {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err)
}
startedValue, ok := values["started"]
if !ok {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil)
}
entry.Started, err = time.Parse(time.RFC3339Nano, startedValue)
if err != nil {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err)
}
return entry, nil
}
func parseJSONObject(data string) (map[string]string, error) {
trimmed := core.Trim(data)
if trimmed == "" {
return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil)
}
if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil)
}
values := make(map[string]string)
index := skipJSONSpace(trimmed, 1)
for index < len(trimmed) {
if trimmed[index] == '}' {
return values, nil
}
key, next, err := parseJSONString(trimmed, index)
if err != nil {
return nil, err
}
index = skipJSONSpace(trimmed, next)
if index >= len(trimmed) || trimmed[index] != ':' {
return nil, core.E("Registry.parseJSONObject", "missing key separator", nil)
}
index = skipJSONSpace(trimmed, index+1)
if index >= len(trimmed) {
return nil, core.E("Registry.parseJSONObject", "missing value", nil)
}
var value string
if trimmed[index] == '"' {
value, index, err = parseJSONString(trimmed, index)
if err != nil {
return nil, err
}
} else {
start := index
for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' {
index++
}
value = core.Trim(trimmed[start:index])
}
values[key] = value
index = skipJSONSpace(trimmed, index)
if index >= len(trimmed) {
break
}
if trimmed[index] == ',' {
index = skipJSONSpace(trimmed, index+1)
continue
}
if trimmed[index] == '}' {
return values, nil
}
return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil)
}
return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil)
}
func parseJSONString(data string, start int) (string, int, error) {
if start >= len(data) || data[start] != '"' {
return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil)
}
builder := core.NewBuilder()
for index := start + 1; index < len(data); index++ {
ch := data[index]
if ch == '"' {
return builder.String(), index + 1, nil
}
if ch != '\\' {
builder.WriteByte(ch)
continue
}
index++
if index >= len(data) {
return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil)
}
switch data[index] {
case '"', '\\', '/':
builder.WriteByte(data[index])
case 'b':
builder.WriteByte('\b')
case 'f':
builder.WriteByte('\f')
case 'n':
builder.WriteByte('\n')
case 'r':
builder.WriteByte('\r')
case 't':
builder.WriteByte('\t')
case 'u':
if index+4 >= len(data) {
return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil)
}
r, err := strconv.ParseInt(data[index+1:index+5], 16, 32)
if err != nil {
return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err)
}
builder.WriteRune(rune(r))
index += 4
default:
return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil)
}
}
return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil)
}
func skipJSONSpace(data string, index int) int {
for index < len(data) {
switch data[index] {
case ' ', '\n', '\r', '\t':
index++
default:
return index
}
}
return index
}
func quoteJSONString(value string) string {
builder := core.NewBuilder()
builder.WriteByte('"')
for i := 0; i < len(value); i++ {
switch value[i] {
case '\\', '"':
builder.WriteByte('\\')
builder.WriteByte(value[i])
case '\b':
builder.WriteString(`\b`)
case '\f':
builder.WriteString(`\f`)
case '\n':
builder.WriteString(`\n`)
case '\r':
builder.WriteString(`\r`)
case '\t':
builder.WriteString(`\t`)
default:
if value[i] < 0x20 {
builder.WriteString(core.Sprintf("\\u%04x", value[i]))
continue
}
builder.WriteByte(value[i])
}
}
builder.WriteByte('"')
return builder.String()
}

View file

@ -2,15 +2,15 @@ package process
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestRegistry_RegisterAndGet(t *testing.T) { func TestRegistry_Register_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(dir) reg := NewRegistry(dir)
@ -39,7 +39,7 @@ func TestRegistry_RegisterAndGet(t *testing.T) {
assert.Equal(t, started, got.Started) assert.Equal(t, started, got.Started)
} }
func TestRegistry_Unregister(t *testing.T) { func TestRegistry_Unregister_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(dir) reg := NewRegistry(dir)
@ -53,7 +53,7 @@ func TestRegistry_Unregister(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// File should exist // File should exist
path := filepath.Join(dir, "myapp-server.json") path := core.JoinPath(dir, "myapp-server.json")
_, err = os.Stat(path) _, err = os.Stat(path)
require.NoError(t, err) require.NoError(t, err)
@ -65,15 +65,7 @@ func TestRegistry_Unregister(t *testing.T) {
assert.True(t, os.IsNotExist(err)) assert.True(t, os.IsNotExist(err))
} }
func TestRegistry_UnregisterMissingIsNoop(t *testing.T) { func TestRegistry_List_Good(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
err := reg.Unregister("missing", "entry")
require.NoError(t, err)
}
func TestRegistry_List(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(dir) reg := NewRegistry(dir)
@ -84,12 +76,10 @@ func TestRegistry_List(t *testing.T) {
entries, err := reg.List() entries, err := reg.List()
require.NoError(t, err) require.NoError(t, err)
require.Len(t, entries, 2) assert.Len(t, entries, 2)
assert.Equal(t, "app1", entries[0].Code)
assert.Equal(t, "app2", entries[1].Code)
} }
func TestRegistry_List_PrunesStale(t *testing.T) { func TestRegistry_PruneStale_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(dir) reg := NewRegistry(dir)
@ -97,7 +87,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// File should exist before listing // File should exist before listing
path := filepath.Join(dir, "dead-proc.json") path := core.JoinPath(dir, "dead-proc.json")
_, err = os.Stat(path) _, err = os.Stat(path)
require.NoError(t, err) require.NoError(t, err)
@ -110,7 +100,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
assert.True(t, os.IsNotExist(err)) assert.True(t, os.IsNotExist(err))
} }
func TestRegistry_Get_NotFound(t *testing.T) { func TestRegistry_GetMissing_Bad(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
reg := NewRegistry(dir) reg := NewRegistry(dir)
@ -119,8 +109,8 @@ func TestRegistry_Get_NotFound(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestRegistry_CreatesDirectory(t *testing.T) { func TestRegistry_CreateDirectory_Good(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons") dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons")
reg := NewRegistry(dir) reg := NewRegistry(dir)
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()}) err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
@ -131,7 +121,7 @@ func TestRegistry_CreatesDirectory(t *testing.T) {
assert.True(t, info.IsDir()) assert.True(t, info.IsDir())
} }
func TestDefaultRegistry(t *testing.T) { func TestRegistry_Default_Good(t *testing.T) {
reg := DefaultRegistry() reg := DefaultRegistry()
assert.NotNil(t, reg) assert.NotNil(t, reg)
} }

195
runner.go
View file

@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" "dappco.re/go/core"
) )
// Runner orchestrates multiple processes with dependencies. // Runner orchestrates multiple processes with dependencies.
@ -14,31 +14,14 @@ type Runner struct {
} }
// ErrRunnerNoService is returned when a runner was created without a service. // ErrRunnerNoService is returned when a runner was created without a service.
var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) var ErrRunnerNoService = core.E("", "runner service is nil", nil)
// ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated.
var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil)
// ErrRunnerInvalidDependencyName is returned when a RunSpec dependency name is empty, duplicated, or self-referential.
var ErrRunnerInvalidDependencyName = coreerr.E("", "runner dependency names must be non-empty, unique, and not self-referential", nil)
// ErrRunnerContextRequired is returned when a runner method is called without a context.
var ErrRunnerContextRequired = coreerr.E("", "runner context is required", nil)
// NewRunner creates a runner for the given service. // NewRunner creates a runner for the given service.
//
// Example:
//
// runner := process.NewRunner(svc)
func NewRunner(svc *Service) *Runner { func NewRunner(svc *Service) *Runner {
return &Runner{service: svc} return &Runner{service: svc}
} }
// RunSpec defines a process to run with optional dependencies. // RunSpec defines a process to run with optional dependencies.
//
// Example:
//
// spec := process.RunSpec{Name: "test", Command: "go", Args: []string{"test", "./..."}}
type RunSpec struct { type RunSpec struct {
// Name is a friendly identifier (e.g., "lint", "test"). // Name is a friendly identifier (e.g., "lint", "test").
Name string Name string
@ -63,17 +46,11 @@ type RunResult struct {
ExitCode int ExitCode int
Duration time.Duration Duration time.Duration
Output string Output string
// Error only reports start-time or orchestration failures. A started process Error error
// that exits non-zero uses ExitCode to report failure and leaves Error nil. Skipped bool
Error error
Skipped bool
} }
// Passed returns true if the process succeeded. // Passed returns true if the process succeeded.
//
// Example:
//
// if result.Passed() { ... }
func (r RunResult) Passed() bool { func (r RunResult) Passed() bool {
return !r.Skipped && r.Error == nil && r.ExitCode == 0 return !r.Skipped && r.Error == nil && r.ExitCode == 0
} }
@ -87,38 +64,24 @@ type RunAllResult struct {
Skipped int Skipped int
} }
// Success returns true when no spec failed. // Success returns true if all non-skipped specs passed.
//
// Example:
//
// if result.Success() { ... }
func (r RunAllResult) Success() bool { func (r RunAllResult) Success() bool {
return r.Failed == 0 return r.Failed == 0
} }
// RunAll executes specs respecting dependencies, parallelising where possible. // RunAll executes specs respecting dependencies, parallelising where possible.
//
// Example:
//
// result, err := runner.RunAll(ctx, specs)
func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
if err := r.ensureService(); err != nil { if err := r.ensureService(); err != nil {
return nil, err return nil, err
} }
if err := ensureRunnerContext(ctx); err != nil {
return nil, err
}
if err := validateSpecs(specs); err != nil {
return nil, err
}
start := time.Now() start := time.Now()
// Build dependency graph // Build dependency graph
specMap := make(map[string]RunSpec) specMap := make(map[string]RunSpec)
indexMap := make(map[string]int, len(specs)) indexMap := make(map[string]int)
for _, spec := range specs { for i, spec := range specs {
specMap[spec.Name] = spec specMap[spec.Name] = spec
indexMap[spec.Name] = len(indexMap) indexMap[spec.Name] = i
} }
// Track completion // Track completion
@ -134,13 +97,6 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
} }
for len(remaining) > 0 { for len(remaining) > 0 {
if err := ctx.Err(); err != nil {
for name := range remaining {
results[indexMap[name]] = cancelledRunResult("Runner.RunAll", remaining[name], err)
}
break
}
// Find specs ready to run (all dependencies satisfied) // Find specs ready to run (all dependencies satisfied)
ready := make([]RunSpec, 0) ready := make([]RunSpec, 0)
for _, spec := range remaining { for _, spec := range remaining {
@ -150,14 +106,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
} }
if len(ready) == 0 && len(remaining) > 0 { if len(ready) == 0 && len(remaining) > 0 {
// Deadlock - circular dependency or missing specs. // Deadlock — circular dependency or missing specs. Mark as failed, not skipped.
// Keep the output aligned with the input order. for name, spec := range remaining {
for name := range remaining {
results[indexMap[name]] = RunResult{ results[indexMap[name]] = RunResult{
Name: name, Name: name,
Spec: remaining[name], Spec: spec,
Skipped: true, 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 break
@ -189,7 +144,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
Name: spec.Name, Name: spec.Name,
Spec: spec, Spec: spec,
Skipped: true, 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 { } else {
result = r.runSpec(ctx, spec) result = r.runSpec(ctx, spec)
@ -229,13 +184,6 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
return aggResult, nil return aggResult, nil
} }
func (r *Runner) ensureService() error {
if r == nil || r.service == nil {
return ErrRunnerNoService
}
return nil
}
// canRun checks if all dependencies are completed. // canRun checks if all dependencies are completed.
func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
for _, dep := range spec.After { for _, dep := range spec.After {
@ -250,13 +198,17 @@ func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
start := time.Now() start := time.Now()
proc, err := r.service.StartWithOptions(ctx, RunOptions{ sr := r.service.StartWithOptions(ctx, RunOptions{
Command: spec.Command, Command: spec.Command,
Args: spec.Args, Args: spec.Args,
Dir: spec.Dir, Dir: spec.Dir,
Env: spec.Env, Env: spec.Env,
}) })
if err != nil { 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{ return RunResult{
Name: spec.Name, Name: spec.Name,
Spec: spec, Spec: spec,
@ -265,60 +217,39 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
} }
} }
proc := sr.Value.(*Process)
<-proc.Done() <-proc.Done()
var runErr error
switch proc.Status {
case StatusKilled:
runErr = coreerr.E("Runner.runSpec", "process was killed", nil)
case StatusExited:
// Non-zero exits are surfaced through ExitCode; Error remains nil so
// callers can distinguish execution failure from orchestration failure.
case StatusFailed:
runErr = coreerr.E("Runner.runSpec", "process failed to start", nil)
}
return RunResult{ return RunResult{
Name: spec.Name, Name: spec.Name,
Spec: spec, Spec: spec,
ExitCode: proc.ExitCode, ExitCode: proc.ExitCode,
Duration: proc.Duration, Duration: proc.Duration,
Output: proc.Output(), Output: proc.Output(),
Error: runErr, Error: nil,
} }
} }
// RunSequential executes specs one after another, stopping on first failure. // RunSequential executes specs one after another, stopping on first failure.
//
// Example:
//
// result, err := runner.RunSequential(ctx, specs)
func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
if err := r.ensureService(); err != nil { if err := r.ensureService(); err != nil {
return nil, err return nil, err
} }
if err := ensureRunnerContext(ctx); err != nil {
return nil, err
}
if err := validateSpecs(specs); err != nil {
return nil, err
}
start := time.Now() start := time.Now()
results := make([]RunResult, 0, len(specs)) results := make([]RunResult, 0, len(specs))
for _, spec := range specs { for _, spec := range specs {
if err := ctx.Err(); err != nil {
results = append(results, cancelledRunResult("Runner.RunSequential", spec, err))
continue
}
result := r.runSpec(ctx, spec) result := r.runSpec(ctx, spec)
results = append(results, result) results = append(results, result)
if !result.Passed() && !spec.AllowFailure { if !result.Passed() && !spec.AllowFailure {
// Mark remaining as skipped // Mark remaining as skipped
for i := len(results); i < len(specs); i++ { for i := len(results); i < len(specs); i++ {
results = append(results, skippedRunResult("Runner.RunSequential", specs[i], nil)) results = append(results, RunResult{
Name: specs[i].Name,
Spec: specs[i],
Skipped: true,
})
} }
break break
} }
@ -343,20 +274,10 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes
} }
// RunParallel executes all specs concurrently, regardless of dependencies. // RunParallel executes all specs concurrently, regardless of dependencies.
//
// Example:
//
// result, err := runner.RunParallel(ctx, specs)
func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
if err := r.ensureService(); err != nil { if err := r.ensureService(); err != nil {
return nil, err return nil, err
} }
if err := ensureRunnerContext(ctx); err != nil {
return nil, err
}
if err := validateSpecs(specs); err != nil {
return nil, err
}
start := time.Now() start := time.Now()
results := make([]RunResult, len(specs)) results := make([]RunResult, len(specs))
@ -365,10 +286,6 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
wg.Add(1) wg.Add(1)
go func(i int, spec RunSpec) { go func(i int, spec RunSpec) {
defer wg.Done() defer wg.Done()
if err := ctx.Err(); err != nil {
results[i] = cancelledRunResult("Runner.RunParallel", spec, err)
return
}
results[i] = r.runSpec(ctx, spec) results[i] = r.runSpec(ctx, spec)
}(i, spec) }(i, spec)
} }
@ -392,59 +309,9 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
return aggResult, nil return aggResult, nil
} }
func validateSpecs(specs []RunSpec) error { func (r *Runner) ensureService() error {
seen := make(map[string]struct{}, len(specs)) if r == nil || r.service == nil {
for _, spec := range specs { return ErrRunnerNoService
if spec.Name == "" {
return coreerr.E("Runner.validateSpecs", "runner spec name is required", ErrRunnerInvalidSpecName)
}
if _, ok := seen[spec.Name]; ok {
return coreerr.E("Runner.validateSpecs", "runner spec name is duplicated", ErrRunnerInvalidSpecName)
}
seen[spec.Name] = struct{}{}
deps := make(map[string]struct{}, len(spec.After))
for _, dep := range spec.After {
if dep == "" {
return coreerr.E("Runner.validateSpecs", "runner dependency name is required", ErrRunnerInvalidDependencyName)
}
if dep == spec.Name {
return coreerr.E("Runner.validateSpecs", "runner dependency cannot reference itself", ErrRunnerInvalidDependencyName)
}
if _, ok := deps[dep]; ok {
return coreerr.E("Runner.validateSpecs", "runner dependency name is duplicated", ErrRunnerInvalidDependencyName)
}
deps[dep] = struct{}{}
}
} }
return nil return nil
} }
func ensureRunnerContext(ctx context.Context) error {
if ctx == nil {
return coreerr.E("Runner.ensureRunnerContext", "runner context is required", ErrRunnerContextRequired)
}
return nil
}
func skippedRunResult(op string, spec RunSpec, err error) RunResult {
result := RunResult{
Name: spec.Name,
Spec: spec,
Skipped: true,
}
if err != nil {
result.ExitCode = 1
result.Error = coreerr.E(op, "skipped", err)
}
return result
}
func cancelledRunResult(op string, spec RunSpec, err error) RunResult {
result := skippedRunResult(op, spec, err)
if result.Error == nil {
result.ExitCode = 1
result.Error = coreerr.E(op, "context cancelled", err)
}
return result
}

View file

@ -13,14 +13,12 @@ func newTestRunner(t *testing.T) *Runner {
t.Helper() t.Helper()
c := framework.New() c := framework.New()
factory := NewService(Options{}) r := Register(c)
raw, err := factory(c) require.True(t, r.OK)
require.NoError(t, err) return NewRunner(r.Value.(*Service))
return NewRunner(raw.(*Service))
} }
func TestRunner_RunSequential(t *testing.T) { func TestRunner_RunSequential_Good(t *testing.T) {
t.Run("all pass", func(t *testing.T) { t.Run("all pass", func(t *testing.T) {
runner := newTestRunner(t) runner := newTestRunner(t)
@ -51,12 +49,6 @@ func TestRunner_RunSequential(t *testing.T) {
assert.Equal(t, 1, result.Passed) assert.Equal(t, 1, result.Passed)
assert.Equal(t, 1, result.Failed) assert.Equal(t, 1, result.Failed)
assert.Equal(t, 1, result.Skipped) assert.Equal(t, 1, result.Skipped)
require.Len(t, result.Results, 3)
assert.Equal(t, 0, result.Results[0].ExitCode)
assert.NoError(t, result.Results[0].Error)
assert.Equal(t, 1, result.Results[1].ExitCode)
assert.NoError(t, result.Results[1].Error)
assert.True(t, result.Results[2].Skipped)
}) })
t.Run("allow failure continues", func(t *testing.T) { t.Run("allow failure continues", func(t *testing.T) {
@ -76,7 +68,7 @@ func TestRunner_RunSequential(t *testing.T) {
}) })
} }
func TestRunner_RunParallel(t *testing.T) { func TestRunner_RunParallel_Good(t *testing.T) {
t.Run("all run concurrently", func(t *testing.T) { t.Run("all run concurrently", func(t *testing.T) {
runner := newTestRunner(t) runner := newTestRunner(t)
@ -108,7 +100,7 @@ func TestRunner_RunParallel(t *testing.T) {
}) })
} }
func TestRunner_RunAll(t *testing.T) { func TestRunner_RunAll_Good(t *testing.T) {
t.Run("respects dependencies", func(t *testing.T) { t.Run("respects dependencies", func(t *testing.T) {
runner := newTestRunner(t) runner := newTestRunner(t)
@ -174,8 +166,8 @@ func TestRunner_RunAll(t *testing.T) {
}) })
} }
func TestRunner_RunAll_CircularDeps(t *testing.T) { func TestRunner_CircularDeps_Bad(t *testing.T) {
t.Run("circular dependency is skipped with error", func(t *testing.T) { t.Run("circular dependency counts as failed", func(t *testing.T) {
runner := newTestRunner(t) runner := newTestRunner(t)
result, err := runner.RunAll(context.Background(), []RunSpec{ result, err := runner.RunAll(context.Background(), []RunSpec{
@ -184,85 +176,13 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
assert.True(t, result.Success()) assert.False(t, result.Success())
assert.Equal(t, 0, result.Failed) assert.Equal(t, 2, result.Failed)
assert.Equal(t, 2, result.Skipped) assert.Equal(t, 0, result.Skipped)
for _, res := range result.Results {
assert.True(t, res.Skipped)
assert.Equal(t, 0, res.ExitCode)
assert.Error(t, res.Error)
}
})
t.Run("missing dependency is skipped with error", func(t *testing.T) {
runner := newTestRunner(t)
result, err := runner.RunAll(context.Background(), []RunSpec{
{Name: "a", Command: "echo", Args: []string{"a"}, After: []string{"missing"}},
})
require.NoError(t, err)
assert.True(t, result.Success())
assert.Equal(t, 0, result.Failed)
assert.Equal(t, 1, result.Skipped)
require.Len(t, result.Results, 1)
assert.True(t, result.Results[0].Skipped)
assert.Equal(t, 0, result.Results[0].ExitCode)
assert.Error(t, result.Results[0].Error)
}) })
} }
func TestRunner_ContextCancellation(t *testing.T) { func TestRunResult_Passed_Good(t *testing.T) {
t.Run("run sequential skips pending specs", func(t *testing.T) {
runner := newTestRunner(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := runner.RunSequential(ctx, []RunSpec{
{Name: "first", Command: "echo", Args: []string{"1"}},
{Name: "second", Command: "echo", Args: []string{"2"}},
})
require.NoError(t, err)
assert.Equal(t, 0, result.Passed)
assert.Equal(t, 0, result.Failed)
assert.Equal(t, 2, result.Skipped)
require.Len(t, result.Results, 2)
for _, res := range result.Results {
assert.True(t, res.Skipped)
assert.Equal(t, 1, res.ExitCode)
assert.Error(t, res.Error)
assert.Contains(t, res.Error.Error(), "context canceled")
}
})
t.Run("run all skips pending specs", func(t *testing.T) {
runner := newTestRunner(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := runner.RunAll(ctx, []RunSpec{
{Name: "first", Command: "echo", Args: []string{"1"}},
{Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}},
})
require.NoError(t, err)
assert.Equal(t, 0, result.Passed)
assert.Equal(t, 0, result.Failed)
assert.Equal(t, 2, result.Skipped)
require.Len(t, result.Results, 2)
for _, res := range result.Results {
assert.True(t, res.Skipped)
assert.Equal(t, 1, res.ExitCode)
assert.Error(t, res.Error)
assert.Contains(t, res.Error.Error(), "context canceled")
}
})
}
func TestRunResult_Passed(t *testing.T) {
t.Run("success", func(t *testing.T) { t.Run("success", func(t *testing.T) {
r := RunResult{ExitCode: 0} r := RunResult{ExitCode: 0}
assert.True(t, r.Passed()) assert.True(t, r.Passed())
@ -284,7 +204,7 @@ func TestRunResult_Passed(t *testing.T) {
}) })
} }
func TestRunner_NilService(t *testing.T) { func TestRunner_NilService_Bad(t *testing.T) {
runner := NewRunner(nil) runner := NewRunner(nil)
_, err := runner.RunAll(context.Background(), nil) _, err := runner.RunAll(context.Background(), nil)
@ -299,73 +219,3 @@ func TestRunner_NilService(t *testing.T) {
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerNoService) assert.ErrorIs(t, err, ErrRunnerNoService)
} }
func TestRunner_NilContext(t *testing.T) {
runner := newTestRunner(t)
_, err := runner.RunAll(nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerContextRequired)
_, err = runner.RunSequential(nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerContextRequired)
_, err = runner.RunParallel(nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerContextRequired)
}
func TestRunner_InvalidSpecNames(t *testing.T) {
runner := newTestRunner(t)
t.Run("rejects empty names", func(t *testing.T) {
_, err := runner.RunSequential(context.Background(), []RunSpec{
{Name: "", Command: "echo", Args: []string{"a"}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidSpecName)
})
t.Run("rejects empty dependency names", func(t *testing.T) {
_, err := runner.RunAll(context.Background(), []RunSpec{
{Name: "one", Command: "echo", Args: []string{"a"}, After: []string{""}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName)
})
t.Run("rejects duplicated dependency names", func(t *testing.T) {
_, err := runner.RunAll(context.Background(), []RunSpec{
{Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"two", "two"}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName)
})
t.Run("rejects self dependency", func(t *testing.T) {
_, err := runner.RunAll(context.Background(), []RunSpec{
{Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"one"}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName)
})
t.Run("rejects duplicate names", func(t *testing.T) {
_, err := runner.RunAll(context.Background(), []RunSpec{
{Name: "same", Command: "echo", Args: []string{"a"}},
{Name: "same", Command: "echo", Args: []string{"b"}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidSpecName)
})
t.Run("rejects duplicate names in parallel mode", func(t *testing.T) {
_, err := runner.RunParallel(context.Background(), []RunSpec{
{Name: "one", Command: "echo", Args: []string{"a"}},
{Name: "one", Command: "echo", Args: []string{"b"}},
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrRunnerInvalidSpecName)
})
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

29
specs/api/RFC.md Normal file
View file

@ -0,0 +1,29 @@
# api
**Import:** `dappco.re/go/core/process/pkg/api`
**Files:** 2
## Types
### `ProcessProvider`
`struct`
Service provider that wraps the go-process daemon registry and bundled UI entrypoint.
Exported fields:
- None.
## Functions
### Package Functions
- `func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider`: Returns a `ProcessProvider` for the supplied registry and WebSocket hub. When `registry` is `nil`, it uses `process.DefaultRegistry()`.
- `func PIDAlive(pid int) bool`: Returns `false` for non-positive PIDs and otherwise reports whether `os.FindProcess(pid)` followed by signal `0` succeeds.
### `ProcessProvider` Methods
- `func (p *ProcessProvider) Name() string`: Returns `"process"`.
- `func (p *ProcessProvider) BasePath() string`: Returns `"/api/process"`.
- `func (p *ProcessProvider) Element() provider.ElementSpec`: Returns an element spec with tag `core-process-panel` and source `/assets/core-process.js`.
- `func (p *ProcessProvider) Channels() []string`: Returns `process.daemon.started`, `process.daemon.stopped`, `process.daemon.health`, `process.started`, `process.output`, `process.exited`, and `process.killed`.
- `func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup)`: Registers the daemon list, daemon lookup, daemon stop, and daemon health routes.
- `func (p *ProcessProvider) Describe() []api.RouteDescription`: Returns static route descriptions for the registered daemon routes.

68
specs/exec/RFC.md Normal file
View file

@ -0,0 +1,68 @@
# exec
**Import:** `dappco.re/go/core/process/exec`
**Files:** 3
## Types
### `Options`
`struct`
Command execution options used by `Cmd`.
Fields:
- `Dir string`: Working directory.
- `Env []string`: Environment entries appended to `os.Environ()` when non-empty.
- `Stdin io.Reader`: Reader assigned to command stdin.
- `Stdout io.Writer`: Writer assigned to command stdout.
- `Stderr io.Writer`: Writer assigned to command stderr.
### `Cmd`
`struct`
Wrapped command with chainable configuration methods.
Exported fields:
- None.
### `Logger`
`interface`
Command-execution logger.
Methods:
- `Debug(msg string, keyvals ...any)`: Logs a debug-level message.
- `Error(msg string, keyvals ...any)`: Logs an error-level message.
### `NopLogger`
`struct`
No-op `Logger` implementation.
Exported fields:
- None.
## Functions
### Package Functions
- `func Command(ctx context.Context, name string, args ...string) *Cmd`: Returns a `Cmd` for the supplied context, executable name, and arguments.
- `func RunQuiet(ctx context.Context, name string, args ...string) error`: Runs a command with stderr captured into a buffer and returns `core.E("exec.run_quiet", core.Trim(stderr.String()), err)` on failure.
- `func SetDefaultLogger(l Logger)`: Sets the package-level default logger. Passing `nil` replaces it with `NopLogger`.
- `func DefaultLogger() Logger`: Returns the package-level default logger.
### `Cmd` Methods
- `func (c *Cmd) WithDir(dir string) *Cmd`: Sets `Options.Dir` and returns the same command.
- `func (c *Cmd) WithEnv(env []string) *Cmd`: Sets `Options.Env` and returns the same command.
- `func (c *Cmd) WithStdin(r io.Reader) *Cmd`: Sets `Options.Stdin` and returns the same command.
- `func (c *Cmd) WithStdout(w io.Writer) *Cmd`: Sets `Options.Stdout` and returns the same command.
- `func (c *Cmd) WithStderr(w io.Writer) *Cmd`: Sets `Options.Stderr` and returns the same command.
- `func (c *Cmd) WithLogger(l Logger) *Cmd`: Sets a command-specific logger and returns the same command.
- `func (c *Cmd) Run() error`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, runs it, and wraps failures with `wrapError("exec.cmd.run", ...)`.
- `func (c *Cmd) Output() ([]byte, error)`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, returns stdout bytes, and wraps failures with `wrapError("exec.cmd.output", ...)`.
- `func (c *Cmd) CombinedOutput() ([]byte, error)`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, returns combined stdout and stderr, and wraps failures with `wrapError("exec.cmd.combined_output", ...)`.
### `NopLogger` Methods
- `func (NopLogger) Debug(string, ...any)`: Discards the message.
- `func (NopLogger) Error(string, ...any)`: Discards the message.

207
specs/process-ui.md Normal file
View 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
View 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.

View file

@ -1,49 +1,34 @@
// Package process provides process management with Core IPC integration. // Package process provides process management with Core IPC integration.
// //
// Example:
//
// svc := process.NewService(process.Options{})
// proc, err := svc.Start(ctx, "echo", "hello")
//
// The process package enables spawning, monitoring, and controlling external // The process package enables spawning, monitoring, and controlling external
// processes with output streaming via the Core ACTION system. // processes with output streaming via the Core ACTION system.
// //
// # Getting Started // # Getting Started
// //
// // Register with Core // c := core.New(core.WithService(process.Register))
// core, _ := framework.New( // _ = c.ServiceStartup(ctx, nil)
// framework.WithName("process", process.NewService(process.Options{})),
// )
// //
// // Get service and run a process // r := c.Process().Run(ctx, "go", "test", "./...")
// svc, err := framework.ServiceFor[*process.Service](core, "process") // output := r.Value.(string)
// if err != nil {
// return err
// }
// proc, err := svc.Start(ctx, "go", "test", "./...")
// //
// # Listening for Events // # Listening for Events
// //
// Process events are broadcast via Core.ACTION: // Process events are broadcast via Core.ACTION:
// //
// core.RegisterAction(func(c *framework.Core, msg framework.Message) error { // c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
// switch m := msg.(type) { // switch m := msg.(type) {
// case process.ActionProcessOutput: // case process.ActionProcessOutput:
// fmt.Print(m.Line) // fmt.Print(m.Line)
// case process.ActionProcessExited: // case process.ActionProcessExited:
// fmt.Printf("Exit code: %d\n", m.ExitCode) // fmt.Printf("Exit code: %d\n", m.ExitCode)
// } // }
// return nil // return core.Result{OK: true}
// }) // })
package process package process
import "time" import "time"
// Status represents the process lifecycle state. // Status represents the process lifecycle state.
//
// Example:
//
// if proc.Status == process.StatusKilled { return }
type Status string type Status string
const ( const (
@ -60,10 +45,6 @@ const (
) )
// Stream identifies the output source. // Stream identifies the output source.
//
// Example:
//
// if event.Stream == process.StreamStdout { ... }
type Stream string type Stream string
const ( const (
@ -74,13 +55,6 @@ const (
) )
// RunOptions configures process execution. // RunOptions configures process execution.
//
// Example:
//
// opts := process.RunOptions{
// Command: "go",
// Args: []string{"test", "./..."},
// }
type RunOptions struct { type RunOptions struct {
// Command is the executable to run. // Command is the executable to run.
Command string Command string
@ -111,13 +85,8 @@ type RunOptions struct {
KillGroup bool KillGroup bool
} }
// Info provides a snapshot of process state without internal fields. // ProcessInfo provides a snapshot of process state without internal fields.
// type ProcessInfo struct {
// Example:
//
// info := proc.Info()
// fmt.Println(info.PID)
type Info struct {
ID string `json:"id"` ID string `json:"id"`
Command string `json:"command"` Command string `json:"command"`
Args []string `json:"args"` Args []string `json:"args"`
@ -129,3 +98,6 @@ type Info struct {
Duration time.Duration `json:"duration"` Duration time.Duration `json:"duration"`
PID int `json:"pid"` PID int `json:"pid"`
} }
// Info is kept as a compatibility alias for ProcessInfo.
type Info = ProcessInfo

View file

@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { connectProcessEvents, type ProcessEvent } from './shared/events.js'; import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
import { ProcessApi, type ProcessInfo } from './shared/api.js'; import type { ProcessInfo } from './shared/api.js';
/** /**
* <core-process-list> Running processes with status and actions. * <core-process-list> Running processes with status and actions.
@ -14,8 +14,9 @@ import { ProcessApi, type ProcessInfo } from './shared/api.js';
* Emits `process-selected` event when a process row is clicked, carrying * Emits `process-selected` event when a process row is clicked, carrying
* the process ID for the output viewer. * the process ID for the output viewer.
* *
* The list is seeded from the REST API and then kept in sync with the live * Note: Requires process-level REST endpoints (GET /processes, POST /processes/:id/kill)
* process event stream when a WebSocket URL is configured. * that are not yet in the provider. The element renders from WS events and local state
* until those endpoints are available.
*/ */
@customElement('core-process-list') @customElement('core-process-list')
export class ProcessList extends LitElement { export class ProcessList extends LitElement {
@ -192,14 +193,11 @@ export class ProcessList extends LitElement {
@state() private loading = false; @state() private loading = false;
@state() private error = ''; @state() private error = '';
@state() private connected = false; @state() private connected = false;
@state() private killing = new Set<string>();
private api!: ProcessApi;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.api = new ProcessApi(this.apiUrl);
this.loadProcesses(); this.loadProcesses();
} }
@ -209,30 +207,24 @@ export class ProcessList extends LitElement {
} }
updated(changed: Map<string, unknown>) { updated(changed: Map<string, unknown>) {
if (changed.has('apiUrl')) { if (changed.has('wsUrl')) {
this.api = new ProcessApi(this.apiUrl);
}
if (changed.has('wsUrl') || changed.has('apiUrl')) {
this.disconnect(); this.disconnect();
void this.loadProcesses(); this.processes = [];
this.loadProcesses();
} }
} }
async loadProcesses() { async loadProcesses() {
this.loading = true; // The process list is built from the shared process event stream.
this.error = ''; this.error = '';
try { this.loading = false;
this.processes = await this.api.listProcesses();
if (this.wsUrl) { if (!this.wsUrl) {
this.connect();
}
} catch (e: any) {
this.error = e.message ?? 'Failed to load processes';
this.processes = []; this.processes = [];
} finally { return;
this.loading = false;
} }
this.connect();
} }
private handleSelect(proc: ProcessInfo) { private handleSelect(proc: ProcessInfo) {
@ -245,25 +237,21 @@ export class ProcessList extends LitElement {
); );
} }
private async handleKill(proc: ProcessInfo) { private formatUptime(started: string): string {
this.killing = new Set([...this.killing, proc.id]);
try { try {
await this.api.killProcess(proc.id); const ms = Date.now() - new Date(started).getTime();
await this.loadProcesses(); const seconds = Math.floor(ms / 1000);
} catch (e: any) { if (seconds < 60) return `${seconds}s`;
this.error = e.message ?? 'Failed to kill process'; const minutes = Math.floor(seconds / 60);
} finally { if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const next = new Set(this.killing); const hours = Math.floor(minutes / 60);
next.delete(proc.id); return `${hours}h ${minutes % 60}m`;
this.killing = next; } catch {
return 'unknown';
} }
} }
private connect() { private connect() {
if (!this.wsUrl || this.ws) {
return;
}
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
this.applyEvent(event); this.applyEvent(event);
}); });
@ -286,7 +274,10 @@ export class ProcessList extends LitElement {
private applyEvent(event: ProcessEvent) { private applyEvent(event: ProcessEvent) {
const channel = event.channel ?? event.type ?? ''; const channel = event.channel ?? event.type ?? '';
const data = (event.data ?? {}) as Partial<ProcessInfo> & { id?: string }; const data = (event.data ?? {}) as Partial<ProcessInfo> & {
id?: string;
signal?: string;
};
if (!data.id) { if (!data.id) {
return; return;
@ -295,36 +286,36 @@ export class ProcessList extends LitElement {
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const)); const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
const current = next.get(data.id); const current = next.get(data.id);
switch (channel) { if (channel === 'process.started') {
case 'process.started': next.set(data.id, this.normalizeProcess(data, current, 'running'));
next.set(data.id, this.normalizeProcess(data, current, 'running')); this.processes = this.sortProcesses(next);
break; return;
case 'process.exited':
next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited'));
break;
case 'process.killed':
next.set(data.id, this.normalizeProcess(data, current, 'killed'));
break;
default:
return;
} }
this.processes = this.sortProcesses(next); if (channel === 'process.exited') {
next.set(data.id, this.normalizeProcess(data, current, 'exited'));
this.processes = this.sortProcesses(next);
return;
}
if (channel === 'process.killed') {
next.set(data.id, this.normalizeProcess(data, current, 'killed'));
this.processes = this.sortProcesses(next);
return;
}
} }
private normalizeProcess( private normalizeProcess(
data: Partial<ProcessInfo> & { id: string; error?: unknown }, data: Partial<ProcessInfo> & { id: string; signal?: string },
current: ProcessInfo | undefined, current: ProcessInfo | undefined,
status: ProcessInfo['status'], status: ProcessInfo['status'],
): ProcessInfo { ): ProcessInfo {
const startedAt = data.startedAt ?? current?.startedAt ?? new Date().toISOString();
return { return {
id: data.id, id: data.id,
command: data.command ?? current?.command ?? '', command: data.command ?? current?.command ?? '',
args: data.args ?? current?.args ?? [], args: data.args ?? current?.args ?? [],
dir: data.dir ?? current?.dir ?? '', dir: data.dir ?? current?.dir ?? '',
startedAt, startedAt: data.startedAt ?? current?.startedAt ?? new Date().toISOString(),
running: status === 'running',
status, status,
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0), exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
duration: data.duration ?? current?.duration ?? 0, duration: data.duration ?? current?.duration ?? 0,
@ -333,28 +324,9 @@ export class ProcessList extends LitElement {
} }
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] { private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
return [...processes.values()].sort((a, b) => { return [...processes.values()].sort(
const aStarted = new Date(a.startedAt).getTime(); (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
const bStarted = new Date(b.startedAt).getTime(); );
if (aStarted === bStarted) {
return a.id.localeCompare(b.id);
}
return aStarted - bStarted;
});
}
private formatUptime(started: string): string {
try {
const ms = Date.now() - new Date(started).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
} catch {
return 'unknown';
}
} }
render() { render() {
@ -369,9 +341,9 @@ export class ProcessList extends LitElement {
<div class="info-notice"> <div class="info-notice">
${this.wsUrl ${this.wsUrl
? this.connected ? this.connected
? 'Receiving live process updates.' ? 'Waiting for process events from the WebSocket feed.'
: 'Connecting to the process event stream...' : 'Connecting to the process event stream...'
: 'Managed processes are loaded from the process REST API.'} : 'Set a WebSocket URL to receive live process events.'}
</div> </div>
<div class="empty">No managed processes.</div> <div class="empty">No managed processes.</div>
` `
@ -407,13 +379,12 @@ export class ProcessList extends LitElement {
<div class="item-actions"> <div class="item-actions">
<button <button
class="kill-btn" class="kill-btn"
?disabled=${this.killing.has(proc.id)} disabled
@click=${(e: Event) => { @click=${(e: Event) => {
e.stopPropagation(); e.stopPropagation();
void this.handleKill(proc);
}} }}
> >
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'} Live only
</button> </button>
</div> </div>
` `

View file

@ -3,7 +3,6 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { connectProcessEvents, type ProcessEvent } from './shared/events.js'; import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
import { ProcessApi } from './shared/api.js';
interface OutputLine { interface OutputLine {
text: string; text: string;
@ -132,15 +131,14 @@ export class ProcessOutput extends LitElement {
@state() private lines: OutputLine[] = []; @state() private lines: OutputLine[] = [];
@state() private autoScroll = true; @state() private autoScroll = true;
@state() private connected = false; @state() private connected = false;
@state() private loadingSnapshot = false;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private api = new ProcessApi(this.apiUrl);
private syncToken = 0;
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.syncSources(); if (this.wsUrl && this.processId) {
this.connect();
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -149,12 +147,12 @@ export class ProcessOutput extends LitElement {
} }
updated(changed: Map<string, unknown>) { updated(changed: Map<string, unknown>) {
if (changed.has('apiUrl')) { if (changed.has('processId') || changed.has('wsUrl')) {
this.api = new ProcessApi(this.apiUrl); this.disconnect();
} this.lines = [];
if (this.wsUrl && this.processId) {
if (changed.has('processId') || changed.has('wsUrl') || changed.has('apiUrl')) { this.connect();
this.syncSources(); }
} }
if (this.autoScroll) { if (this.autoScroll) {
@ -162,66 +160,6 @@ export class ProcessOutput extends LitElement {
} }
} }
private syncSources() {
this.disconnect();
this.lines = [];
if (!this.processId) {
return;
}
void this.loadSnapshotAndConnect();
}
private async loadSnapshotAndConnect() {
const token = ++this.syncToken;
if (!this.processId) {
return;
}
if (this.apiUrl) {
this.loadingSnapshot = true;
try {
const output = await this.api.getProcessOutput(this.processId);
if (token !== this.syncToken) {
return;
}
const snapshot = this.linesFromOutput(output);
if (snapshot.length > 0) {
this.lines = snapshot;
}
} catch {
// Ignore missing snapshot data and continue with live streaming.
} finally {
if (token === this.syncToken) {
this.loadingSnapshot = false;
}
}
}
if (token === this.syncToken && this.wsUrl) {
this.connect();
}
}
private linesFromOutput(output: string): OutputLine[] {
if (!output) {
return [];
}
const normalized = output.replace(/\r\n/g, '\n');
const parts = normalized.split('\n');
if (parts.length > 0 && parts[parts.length - 1] === '') {
parts.pop();
}
return parts.map((text) => ({
text,
stream: 'stdout' as const,
timestamp: Date.now(),
}));
}
private connect() { private connect() {
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
const data = event.data; const data = event.data;
@ -293,9 +231,7 @@ export class ProcessOutput extends LitElement {
</div> </div>
</div> </div>
<div class="output-body"> <div class="output-body">
${this.loadingSnapshot && this.lines.length === 0 ${this.lines.length === 0
? html`<div class="waiting">Loading snapshot\u2026</div>`
: this.lines.length === 0
? html`<div class="waiting">Waiting for output\u2026</div>` ? html`<div class="waiting">Waiting for output\u2026</div>`
: this.lines.map( : this.lines.map(
(line) => html` (line) => html`

View file

@ -9,6 +9,10 @@ import type { RunResult, RunAllResult } from './shared/api.js';
* *
* Shows RunSpec execution results with pass/fail/skip badges, duration, * Shows RunSpec execution results with pass/fail/skip badges, duration,
* dependency chains, and aggregate summary. * dependency chains, and aggregate summary.
*
* Note: Pipeline runner REST endpoints are not yet in the provider.
* This element renders from WS events and accepts data via properties
* until those endpoints are available.
*/ */
@customElement('core-process-runner') @customElement('core-process-runner')
export class ProcessRunner extends LitElement { export class ProcessRunner extends LitElement {
@ -219,9 +223,8 @@ export class ProcessRunner extends LitElement {
} }
async loadResults() { async loadResults() {
// Results are supplied via the `result` property. The REST API can be // Pipeline runner REST endpoints are not yet available.
// used by the surrounding application to execute a pipeline and then // Results can be passed in via the `result` property.
// assign the returned data here.
} }
private toggleOutput(name: string) { private toggleOutput(name: string) {
@ -250,7 +253,9 @@ export class ProcessRunner extends LitElement {
if (!this.result) { if (!this.result) {
return html` return html`
<div class="info-notice"> <div class="info-notice">
Pass pipeline results via the <code>result</code> property. Pipeline runner endpoints are pending. Pass pipeline results via the
<code>result</code> property, or results will appear here once the REST
API for pipeline execution is available.
</div> </div>
<div class="empty">No pipeline results.</div> <div class="empty">No pipeline results.</div>
`; `;

View file

@ -31,26 +31,12 @@ export interface ProcessInfo {
args: string[]; args: string[];
dir: string; dir: string;
startedAt: string; startedAt: string;
running: boolean;
status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'; status: 'pending' | 'running' | 'exited' | 'failed' | 'killed';
exitCode: number; exitCode: number;
duration: number; duration: number;
pid: number; pid: number;
} }
/**
* RunSpec payload for pipeline execution.
*/
export interface RunSpec {
name: string;
command: string;
args?: string[];
dir?: string;
env?: string[];
after?: string[];
allowFailure?: boolean;
}
/** /**
* Pipeline run result for a single spec. * Pipeline run result for a single spec.
*/ */
@ -76,21 +62,6 @@ export interface RunAllResult {
success: boolean; success: boolean;
} }
/**
* Process start and run payload shared by the control endpoints.
*/
export interface ProcessControlRequest {
command: string;
args?: string[];
dir?: string;
env?: string[];
disableCapture?: boolean;
detach?: boolean;
timeout?: number;
gracePeriod?: number;
killGroup?: boolean;
}
/** /**
* ProcessApi provides a typed fetch wrapper for the /api/process/* endpoints. * ProcessApi provides a typed fetch wrapper for the /api/process/* endpoints.
*/ */
@ -131,86 +102,4 @@ export class ProcessApi {
healthCheck(code: string, daemon: string): Promise<HealthResult> { healthCheck(code: string, daemon: string): Promise<HealthResult> {
return this.request<HealthResult>(`/daemons/${code}/${daemon}/health`); return this.request<HealthResult>(`/daemons/${code}/${daemon}/health`);
} }
/** List all managed processes. */
listProcesses(runningOnly = false): Promise<ProcessInfo[]> {
const query = runningOnly ? '?runningOnly=true' : '';
return this.request<ProcessInfo[]>(`/processes${query}`);
}
/** Get a single managed process by ID. */
getProcess(id: string): Promise<ProcessInfo> {
return this.request<ProcessInfo>(`/processes/${id}`);
}
/** Get the captured stdout/stderr for a managed process by ID. */
getProcessOutput(id: string): Promise<string> {
return this.request<string>(`/processes/${id}/output`);
}
/** Start a managed process asynchronously. */
startProcess(opts: ProcessControlRequest): Promise<ProcessInfo> {
return this.request<ProcessInfo>('/processes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(opts),
});
}
/** Run a managed process synchronously and return its combined output. */
runProcess(opts: ProcessControlRequest): Promise<string> {
return this.request<string>('/processes/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(opts),
});
}
/** Wait for a managed process to exit and return its final snapshot. */
waitProcess(id: string): Promise<ProcessInfo> {
return this.request<ProcessInfo>(`/processes/${id}/wait`, {
method: 'POST',
});
}
/** Write input to a managed process stdin pipe. */
inputProcess(id: string, input: string): Promise<{ written: boolean }> {
return this.request<{ written: boolean }>(`/processes/${id}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
});
}
/** Close a managed process stdin pipe. */
closeProcessStdin(id: string): Promise<{ closed: boolean }> {
return this.request<{ closed: boolean }>(`/processes/${id}/close-stdin`, {
method: 'POST',
});
}
/** Kill a managed process by ID. */
killProcess(id: string): Promise<{ killed: boolean }> {
return this.request<{ killed: boolean }>(`/processes/${id}/kill`, {
method: 'POST',
});
}
/** Send a signal to a managed process by ID. */
signalProcess(id: string, signal: string | number): Promise<{ signalled: boolean }> {
return this.request<{ signalled: boolean }>(`/processes/${id}/signal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signal: String(signal) }),
});
}
/** Run a process pipeline using the configured runner. */
runPipeline(mode: 'all' | 'sequential' | 'parallel', specs: RunSpec[]): Promise<RunAllResult> {
return this.request<RunAllResult>('/pipelines/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, specs }),
});
}
} }