Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0bf57f10b | ||
|
|
1ad4c2aa72 | ||
|
|
e2f84b69e1 | ||
|
|
f94b83fe6d | ||
|
|
8b0fe175b9 | ||
|
|
2d68f89197 | ||
|
|
1a6a74085e | ||
|
|
3a60b9f1e7 | ||
|
|
cd16b014da | ||
|
|
7c3801e741 | ||
|
|
8f359bb004 | ||
|
|
c60f355b25 | ||
|
|
9a93ebea66 | ||
|
|
0e4dde9307 | ||
|
|
8a6c253ea2 | ||
|
|
8a85c3cd86 | ||
|
|
e75cb1fc97 | ||
|
|
b0dd22fc5e | ||
|
|
aa3602fbb0 | ||
|
|
15e4c8ddeb | ||
|
|
4ff0d0b745 | ||
|
|
a09ca4f408 | ||
|
|
93f3ab054c | ||
| 94c4fde4b0 | |||
|
|
87b16ca41c | ||
|
|
e0df0d3b34 | ||
|
|
cdea149a20 | ||
|
|
1a015ed6ba | ||
| eac2d0b0cd | |||
|
|
f3e995ffd0 | ||
| 75cb802ef6 | |||
|
|
87ef2dbe16 | ||
|
|
d955ffc0e7 | ||
|
|
d73dfa3d73 |
44 changed files with 3843 additions and 1302 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
`forge.lthn.ai/core/go-process` is the process management framework for CoreGO. It handles process execution (spawn, monitor, stream, kill), daemon lifecycle (PID files, health checks, graceful shutdown, registry), and pipeline orchestration (parallel, sequential, or DAG-ordered multi-process runs). All process events broadcast via Core IPC actions.
|
||||
`dappco.re/go/core/process` is the process management framework for CoreGO. It handles process execution (spawn, monitor, stream, kill), daemon lifecycle (PID files, health checks, graceful shutdown, registry), and pipeline orchestration (parallel, sequential, or DAG-ordered multi-process runs). All process events broadcast via Core IPC actions.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -20,11 +20,12 @@ core go vet # Vet
|
|||
|
||||
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).
|
||||
|
||||
`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)
|
||||
|
||||
|
|
@ -45,14 +46,20 @@ Builder-pattern wrapper around `os/exec` with structured logging via a pluggable
|
|||
|
||||
## 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(...)))`.
|
||||
- **Output capture**: All process output goes through a fixed-size `RingBuffer` (default 1MB). Oldest data is silently overwritten when full.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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()`.
|
||||
- **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 `core.E()`, never `fmt.Errorf` or
|
||||
`errors.New`. Sentinel errors are package-level vars created with `core.E("", "message", nil)`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go/pkg/core` — Core DI framework, IPC actions, `ServiceRuntime`
|
||||
- `dappco.re/go/core` — Core DI framework, IPC actions, `ServiceRuntime`
|
||||
- `dappco.re/go/core/io` — Filesystem abstraction (`coreio.Local`) used by PIDFile and Registry
|
||||
- `github.com/stretchr/testify` — test assertions (require/assert)
|
||||
|
||||
## Testing
|
||||
|
|
|
|||
119
actions.go
119
actions.go
|
|
@ -1,6 +1,12 @@
|
|||
package process
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// --- ACTION messages (broadcast via Core.ACTION) ---
|
||||
|
||||
|
|
@ -35,3 +41,114 @@ type ActionProcessKilled struct {
|
|||
ID 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import "sync"
|
|||
|
||||
// RingBuffer is a fixed-size circular buffer that overwrites old data.
|
||||
// Thread-safe for concurrent reads and writes.
|
||||
//
|
||||
// rb := process.NewRingBuffer(1024)
|
||||
type RingBuffer struct {
|
||||
data []byte
|
||||
size int
|
||||
|
|
@ -14,7 +16,13 @@ type RingBuffer struct {
|
|||
}
|
||||
|
||||
// NewRingBuffer creates a ring buffer with the given capacity.
|
||||
//
|
||||
// rb := process.NewRingBuffer(256)
|
||||
func NewRingBuffer(size int) *RingBuffer {
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
return &RingBuffer{
|
||||
data: make([]byte, size),
|
||||
size: size,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"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) {
|
||||
rb := NewRingBuffer(10)
|
||||
|
||||
|
|
|
|||
40
daemon.go
40
daemon.go
|
|
@ -2,14 +2,15 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
//
|
||||
// opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"}
|
||||
type DaemonOptions struct {
|
||||
// PIDFile path for single-instance enforcement.
|
||||
// Leave empty to skip PID file management.
|
||||
|
|
@ -36,6 +37,8 @@ type DaemonOptions struct {
|
|||
}
|
||||
|
||||
// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown.
|
||||
//
|
||||
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
pid *PIDFile
|
||||
|
|
@ -45,6 +48,8 @@ type Daemon struct {
|
|||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
//
|
||||
// daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"})
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.ShutdownTimeout == 0 {
|
||||
opts.ShutdownTimeout = 30 * time.Second
|
||||
|
|
@ -72,7 +77,7 @@ func (d *Daemon) Start() error {
|
|||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return errors.New("daemon already running")
|
||||
return core.E("daemon.start", "daemon already running", nil)
|
||||
}
|
||||
|
||||
if d.pid != nil {
|
||||
|
|
@ -95,12 +100,21 @@ func (d *Daemon) Start() error {
|
|||
// Auto-register if registry is set
|
||||
if d.opts.Registry != nil {
|
||||
entry := d.opts.RegistryEntry
|
||||
entry.PID = os.Getpid()
|
||||
entry.PID = currentPID()
|
||||
if d.health != nil {
|
||||
entry.Health = d.health.Addr()
|
||||
}
|
||||
if err := d.opts.Registry.Register(entry); err != nil {
|
||||
return fmt.Errorf("registry: %w", err)
|
||||
if d.health != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||
_ = d.health.Stop(shutdownCtx)
|
||||
cancel()
|
||||
}
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
d.running = false
|
||||
return core.E("daemon.start", "registry", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +126,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
d.mu.Lock()
|
||||
if !d.running {
|
||||
d.mu.Unlock()
|
||||
return errors.New("daemon not started - call Start() first")
|
||||
return core.E("daemon.run", "daemon not started - call Start() first", nil)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
|
|
@ -138,25 +152,27 @@ func (d *Daemon) Stop() error {
|
|||
if d.health != nil {
|
||||
d.health.SetReady(false)
|
||||
if err := d.health.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("health server: %w", err))
|
||||
errs = append(errs, core.E("daemon.stop", "health server", err))
|
||||
}
|
||||
}
|
||||
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, fmt.Errorf("pid file: %w", err))
|
||||
if err := d.pid.Release(); err != nil && !isNotExist(err) {
|
||||
errs = append(errs, core.E("daemon.stop", "pid file", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-unregister
|
||||
if d.opts.Registry != nil {
|
||||
_ = d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon)
|
||||
if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil {
|
||||
errs = append(errs, core.E("daemon.stop", "registry", err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
return core.ErrorJoin(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDaemon_StartAndStop(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
func TestDaemon_Lifecycle_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidPath,
|
||||
|
|
@ -36,7 +36,7 @@ func TestDaemon_StartAndStop(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_DoubleStartFails(t *testing.T) {
|
||||
func TestDaemon_AlreadyRunning_Bad(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -50,7 +50,7 @@ func TestDaemon_DoubleStartFails(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "already running")
|
||||
}
|
||||
|
||||
func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
||||
func TestDaemon_RunUnstarted_Bad(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -61,7 +61,7 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "not started")
|
||||
}
|
||||
|
||||
func TestDaemon_SetReady(t *testing.T) {
|
||||
func TestDaemon_SetReady_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -83,19 +83,60 @@ func TestDaemon_SetReady(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) {
|
||||
func TestDaemon_HealthAddrDisabled_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
}
|
||||
|
||||
func TestDaemon_DefaultShutdownTimeout(t *testing.T) {
|
||||
func TestDaemon_DefaultTimeout_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
}
|
||||
|
||||
func TestDaemon_AutoRegisters(t *testing.T) {
|
||||
func TestDaemon_RunBlocking_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- d.Run(ctx)
|
||||
}()
|
||||
|
||||
// Run should be blocking
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Run should block until context is cancelled")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected — still blocking
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run should return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_StopIdempotent_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
// Stop without Start should be a no-op
|
||||
err := d.Stop()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_AutoRegister_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(filepath.Join(dir, "daemons"))
|
||||
reg := NewRegistry(core.JoinPath(dir, "daemons"))
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
|
|
|
|||
302
docs/RFC.md
Normal file
302
docs/RFC.md
Normal 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.
|
||||
|
|
@ -60,32 +60,28 @@ participate in the Core DI container and implements both `Startable` and
|
|||
```go
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
processes map[string]*Process
|
||||
mu sync.RWMutex
|
||||
managed *core.Registry[*ManagedProcess]
|
||||
bufSize int
|
||||
idCounter atomic.Uint64
|
||||
}
|
||||
```
|
||||
|
||||
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,
|
||||
ensuring no orphaned child processes when the application exits.
|
||||
- Process IDs are generated as `proc-N` using an atomic counter, guaranteeing
|
||||
uniqueness without locks.
|
||||
- Process IDs are generated with `core.ID()` and stored in a Core registry.
|
||||
|
||||
#### Registration
|
||||
|
||||
The service is registered with Core via a factory function:
|
||||
|
||||
```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
|
||||
Core service factory signature. The `Options` struct is captured by the closure
|
||||
and applied when Core instantiates the service.
|
||||
`Register` returns `core.Result{Value: *Service, OK: true}` — the standard
|
||||
Core `WithService` factory signature used by the v0.8.0 contract.
|
||||
|
||||
### Process
|
||||
|
||||
|
|
@ -163,12 +159,12 @@ const (
|
|||
When `Service.StartWithOptions()` is called:
|
||||
|
||||
```
|
||||
1. Generate unique ID (atomic counter)
|
||||
1. Generate a unique ID with `core.ID()`
|
||||
2. Create context with cancel
|
||||
3. Build os/exec.Cmd with dir, env, pipes
|
||||
4. Create RingBuffer (unless DisableCapture is set)
|
||||
5. cmd.Start()
|
||||
6. Store process in map
|
||||
6. Store process in the Core registry
|
||||
7. Broadcast ActionProcessStarted via Core.ACTION
|
||||
8. Spawn 2 goroutines to stream stdout and stderr
|
||||
- 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
|
||||
- Waits for output goroutines to finish first
|
||||
- Calls cmd.Wait()
|
||||
- Updates process status and exit code
|
||||
- Classifies the exit as exited, failed, or killed
|
||||
- Closes the done channel
|
||||
- Broadcasts ActionProcessKilled when the process died from a signal
|
||||
- Broadcasts ActionProcessExited
|
||||
```
|
||||
|
||||
|
|
@ -296,12 +293,12 @@ File naming convention: `{code}-{daemon}.json` (slashes replaced with dashes).
|
|||
|
||||
## 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
|
||||
integration:
|
||||
|
||||
```go
|
||||
import "forge.lthn.ai/core/go-process/exec"
|
||||
import "dappco.re/go/core/process/exec"
|
||||
|
||||
// Fluent API
|
||||
err := exec.Command(ctx, "go", "build", "./...").
|
||||
|
|
|
|||
|
|
@ -101,9 +101,7 @@ go-process/
|
|||
pidfile.go # PID file single-instance lock
|
||||
pidfile_test.go # PID file tests
|
||||
process.go # Process type and methods
|
||||
process_global.go # Global singleton and convenience API
|
||||
process_test.go # Process tests
|
||||
global_test.go # Global API tests (concurrency)
|
||||
registry.go # Daemon registry (JSON file store)
|
||||
registry_test.go # Registry tests
|
||||
runner.go # Pipeline runner (sequential, parallel, DAG)
|
||||
|
|
@ -142,8 +140,6 @@ go-process/
|
|||
| `ErrProcessNotFound` | No process with the given ID exists in the service |
|
||||
| `ErrProcessNotRunning` | Operation requires a running process (e.g. SendInput, Signal) |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ description: Process management with Core IPC integration for Go applications.
|
|||
|
||||
# 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
|
||||
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
|
||||
|
||||
|
|
@ -28,22 +28,17 @@ streaming via the Core ACTION (IPC) system. It integrates directly with the
|
|||
```go
|
||||
import (
|
||||
"context"
|
||||
framework "forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go-process"
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/process"
|
||||
)
|
||||
|
||||
// Create a Core instance with the process service
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", process.NewService(process.Options{})),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Create a Core instance with the process service registered.
|
||||
c := core.New(core.WithService(process.Register))
|
||||
|
||||
// Retrieve the typed service
|
||||
svc, err := framework.ServiceFor[*process.Service](c, "process")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
svc, ok := core.ServiceFor[*process.Service](c, "process")
|
||||
if !ok {
|
||||
panic("process service not registered")
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -51,15 +46,19 @@ if err != nil {
|
|||
|
||||
```go
|
||||
// Fire-and-forget (async)
|
||||
proc, err := svc.Start(ctx, "go", "test", "./...")
|
||||
if err != nil {
|
||||
return err
|
||||
start := svc.Start(ctx, "go", "test", "./...")
|
||||
if !start.OK {
|
||||
return start.Value.(error)
|
||||
}
|
||||
proc := start.Value.(*process.Process)
|
||||
<-proc.Done()
|
||||
fmt.Println(proc.Output())
|
||||
|
||||
// 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
|
||||
|
|
@ -67,7 +66,7 @@ output, err := svc.Run(ctx, "echo", "hello world")
|
|||
Process lifecycle events are broadcast through Core's ACTION system:
|
||||
|
||||
```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) {
|
||||
case process.ActionProcessStarted:
|
||||
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:
|
||||
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
|
||||
is available:
|
||||
Core's process primitive delegates to named actions registered by this module.
|
||||
Without `process.Register`, `c.Process().Run(...)` fails with `OK: false`.
|
||||
|
||||
```go
|
||||
// Initialise once at startup
|
||||
process.Init(coreInstance)
|
||||
c := core.New()
|
||||
r := c.Process().Run(ctx, "echo", "blocked")
|
||||
fmt.Println(r.OK) // false
|
||||
|
||||
// Then use package-level functions anywhere
|
||||
proc, _ := process.Start(ctx, "ls", "-la")
|
||||
output, _ := process.Run(ctx, "date")
|
||||
procs := process.List()
|
||||
running := process.Running()
|
||||
c = core.New(core.WithService(process.Register))
|
||||
_ = c.ServiceStartup(ctx, nil)
|
||||
r = c.Process().Run(ctx, "echo", "allowed")
|
||||
fmt.Println(r.OK) // true
|
||||
```
|
||||
|
||||
## Package Layout
|
||||
|
|
@ -109,7 +108,7 @@ running := process.Running()
|
|||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Module path | `forge.lthn.ai/core/go-process` |
|
||||
| Module path | `dappco.re/go/core/process` |
|
||||
| Go version | 1.26.0 |
|
||||
| Licence | EUPL-1.2 |
|
||||
|
||||
|
|
@ -117,7 +116,7 @@ running := process.Running()
|
|||
|
||||
| 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) |
|
||||
|
||||
The package has no other runtime dependencies beyond the Go standard library
|
||||
|
|
|
|||
62
docs/plans/2026-03-18-absorb-sail-program.md
Normal file
62
docs/plans/2026-03-18-absorb-sail-program.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Plan: Absorb Sail Program
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Branch:** agent/implement-the-plan-at-docs-plans-2026-03
|
||||
|
||||
## Overview
|
||||
|
||||
Add a `Program` struct to the `process` package that locates a named binary on PATH and provides lightweight run helpers. This absorbs the "sail program" pattern — a simple way to find and invoke a known CLI tool without wiring the full Core IPC machinery.
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
// Program represents a named executable located on the system PATH.
|
||||
type Program struct {
|
||||
Name string // binary name, e.g. "go", "node"
|
||||
Path string // absolute path resolved by Find()
|
||||
}
|
||||
|
||||
// Find resolves the program's absolute path via exec.LookPath.
|
||||
func (p *Program) Find() error
|
||||
|
||||
// Run executes the program with args in the current working directory.
|
||||
// Returns combined stdout+stderr output and any error.
|
||||
func (p *Program) Run(ctx context.Context, args ...string) (string, error)
|
||||
|
||||
// RunDir executes the program with args in dir.
|
||||
// Returns combined stdout+stderr output and any error.
|
||||
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Implement Program in program.go
|
||||
|
||||
- Create `program.go` in the root `process` package
|
||||
- Add `ErrProgramNotFound` sentinel error using `coreerr.E`
|
||||
- Add `Program` struct with exported `Name` and `Path` fields
|
||||
- Implement `Find() error` using `exec.LookPath`; if `Name` is empty return error
|
||||
- Implement `RunDir(ctx, dir, args...) (string, error)` using `exec.CommandContext`
|
||||
- Capture combined stdout+stderr into a `bytes.Buffer`
|
||||
- Set `cmd.Dir` if `dir` is non-empty
|
||||
- Wrap run errors with `coreerr.E`
|
||||
- Trim trailing whitespace from output
|
||||
- Implement `Run(ctx, args...) (string, error)` as `p.RunDir(ctx, "", args...)`
|
||||
- Commit: `feat(process): add Program struct`
|
||||
|
||||
### Task 2: Write tests in program_test.go
|
||||
|
||||
- Create `program_test.go` in the root `process` package
|
||||
- Test `Find()` succeeds for a binary that exists on PATH (`echo`)
|
||||
- Test `Find()` fails for a binary that does not exist
|
||||
- Test `Run()` executes and returns output
|
||||
- Test `RunDir()` runs in the specified directory (verify via `pwd` or `ls`)
|
||||
- Test `Run()` before `Find()` still works (falls back to `Name`)
|
||||
- Commit: `test(process): add Program tests`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `go test ./...` passes with zero failures
|
||||
- No `fmt.Errorf` or `errors.New` — only `coreerr.E`
|
||||
- `Program` is in the root `process` package (not exec subpackage)
|
||||
- `Run` delegates to `RunDir` — no duplication
|
||||
151
docs/plans/2026-03-25-v0.7.0-core-alignment.md
Normal file
151
docs/plans/2026-03-25-v0.7.0-core-alignment.md
Normal 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
|
||||
6
exec/doc.go
Normal file
6
exec/doc.go
Normal 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
|
||||
70
exec/exec.go
70
exec/exec.go
|
|
@ -3,25 +3,27 @@ package exec
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Options configuration for command execution
|
||||
// Options configures command execution.
|
||||
//
|
||||
// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}}
|
||||
type Options struct {
|
||||
Dir string
|
||||
Env []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
// If true, command will run in background (not implemented in this wrapper yet)
|
||||
// Background bool
|
||||
}
|
||||
|
||||
// Command wraps os/exec.Command with logging and context
|
||||
// Command wraps `os/exec.Command` with logging and context.
|
||||
//
|
||||
// cmd := exec.Command(ctx, "git", "status").WithDir("/workspace")
|
||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||
return &Cmd{
|
||||
name: name,
|
||||
|
|
@ -30,7 +32,7 @@ func Command(ctx context.Context, name string, args ...string) *Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
// Cmd represents a wrapped command
|
||||
// Cmd represents a wrapped command.
|
||||
type Cmd struct {
|
||||
name string
|
||||
args []string
|
||||
|
|
@ -40,31 +42,31 @@ type Cmd struct {
|
|||
logger Logger
|
||||
}
|
||||
|
||||
// WithDir sets the working directory
|
||||
// WithDir sets the working directory.
|
||||
func (c *Cmd) WithDir(dir string) *Cmd {
|
||||
c.opts.Dir = dir
|
||||
return c
|
||||
}
|
||||
|
||||
// WithEnv sets the environment variables
|
||||
// WithEnv sets the environment variables.
|
||||
func (c *Cmd) WithEnv(env []string) *Cmd {
|
||||
c.opts.Env = env
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdin sets stdin
|
||||
// WithStdin sets stdin.
|
||||
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
|
||||
c.opts.Stdin = r
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdout sets stdout
|
||||
// WithStdout sets stdout.
|
||||
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
|
||||
c.opts.Stdout = w
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStderr sets stderr
|
||||
// WithStderr sets stderr.
|
||||
func (c *Cmd) WithStderr(w io.Writer) *Cmd {
|
||||
c.opts.Stderr = w
|
||||
return c
|
||||
|
|
@ -84,7 +86,7 @@ func (c *Cmd) Run() error {
|
|||
c.logDebug("executing command")
|
||||
|
||||
if err := c.cmd.Run(); err != nil {
|
||||
wrapped := wrapError(err, c.name, c.args)
|
||||
wrapped := wrapError("exec.cmd.run", err, c.name, c.args)
|
||||
c.logError("command failed", wrapped)
|
||||
return wrapped
|
||||
}
|
||||
|
|
@ -98,7 +100,7 @@ func (c *Cmd) Output() ([]byte, error) {
|
|||
|
||||
out, err := c.cmd.Output()
|
||||
if err != nil {
|
||||
wrapped := wrapError(err, c.name, c.args)
|
||||
wrapped := wrapError("exec.cmd.output", err, c.name, c.args)
|
||||
c.logError("command failed", wrapped)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
|
@ -112,7 +114,7 @@ func (c *Cmd) CombinedOutput() ([]byte, error) {
|
|||
|
||||
out, err := c.cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
wrapped := wrapError(err, c.name, c.args)
|
||||
wrapped := wrapError("exec.cmd.combined_output", err, c.name, c.args)
|
||||
c.logError("command failed", wrapped)
|
||||
return out, wrapped
|
||||
}
|
||||
|
|
@ -120,16 +122,13 @@ func (c *Cmd) CombinedOutput() ([]byte, error) {
|
|||
}
|
||||
|
||||
func (c *Cmd) prepare() {
|
||||
if c.ctx != nil {
|
||||
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...)
|
||||
} else {
|
||||
// Should we enforce context? The issue says "Enforce context usage".
|
||||
// For now, let's allow nil but log a warning if we had a logger?
|
||||
// Or strictly panic/error?
|
||||
// Let's fallback to Background for now but maybe strict later.
|
||||
c.cmd = exec.Command(c.name, c.args...)
|
||||
ctx := c.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
c.cmd = exec.CommandContext(ctx, c.name, c.args...)
|
||||
|
||||
c.cmd.Dir = c.opts.Dir
|
||||
if len(c.opts.Env) > 0 {
|
||||
c.cmd.Env = append(os.Environ(), c.opts.Env...)
|
||||
|
|
@ -142,22 +141,23 @@ func (c *Cmd) prepare() {
|
|||
|
||||
// RunQuiet executes the command suppressing stdout unless there is an error.
|
||||
// Useful for internal commands.
|
||||
//
|
||||
// _ = exec.RunQuiet(ctx, "go", "test", "./...")
|
||||
func RunQuiet(ctx context.Context, name string, args ...string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := Command(ctx, name, args...).WithStderr(&stderr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Include stderr in error message
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
return core.E("exec.run_quiet", core.Trim(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapError(err error, name string, args []string) error {
|
||||
cmdStr := name + " " + strings.Join(args, " ")
|
||||
func wrapError(caller string, err error, name string, args []string) error {
|
||||
cmdStr := commandString(name, args)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return fmt.Errorf("command %q failed with exit code %d: %w", cmdStr, exitErr.ExitCode(), err)
|
||||
return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
|
||||
}
|
||||
return fmt.Errorf("failed to execute %q: %w", cmdStr, err)
|
||||
return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err)
|
||||
}
|
||||
|
||||
func (c *Cmd) getLogger() Logger {
|
||||
|
|
@ -168,9 +168,17 @@ func (c *Cmd) getLogger() Logger {
|
|||
}
|
||||
|
||||
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) {
|
||||
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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package exec_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go-process/exec"
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/process/exec"
|
||||
)
|
||||
|
||||
// mockLogger captures log calls for testing
|
||||
|
|
@ -27,7 +27,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) {
|
|||
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{}
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
@ -49,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{}
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
@ -71,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) {
|
||||
logger := &mockLogger{}
|
||||
ctx := context.Background()
|
||||
|
|
@ -81,7 +89,7 @@ func TestCommand_Output_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
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))
|
||||
}
|
||||
if len(logger.debugCalls) != 1 {
|
||||
|
|
@ -99,7 +107,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
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))
|
||||
}
|
||||
if len(logger.debugCalls) != 1 {
|
||||
|
|
@ -107,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
|
||||
var nop exec.NopLogger
|
||||
nop.Debug("msg", "key", "val")
|
||||
nop.Error("msg", "key", "val")
|
||||
}
|
||||
|
||||
func TestSetDefaultLogger(t *testing.T) {
|
||||
func TestLogger_SetDefault_Good(t *testing.T) {
|
||||
original := exec.DefaultLogger()
|
||||
defer exec.SetDefaultLogger(original)
|
||||
|
||||
|
|
@ -132,7 +140,7 @@ func TestSetDefaultLogger(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_UsesDefaultLogger(t *testing.T) {
|
||||
func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
|
||||
original := exec.DefaultLogger()
|
||||
defer exec.SetDefaultLogger(original)
|
||||
|
||||
|
|
@ -146,3 +154,68 @@ func TestCommand_UsesDefaultLogger(t *testing.T) {
|
|||
t.Errorf("expected default logger to receive 1 debug call, got %d", len(logger.debugCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithDir_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
out, err := exec.Command(ctx, "pwd").
|
||||
WithDir("/tmp").
|
||||
WithLogger(&mockLogger{}).
|
||||
Output()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
trimmed := core.Trim(string(out))
|
||||
if trimmed != "/tmp" && trimmed != "/private/tmp" {
|
||||
t.Errorf("expected /tmp or /private/tmp, got %q", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithEnv_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
|
||||
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
|
||||
WithLogger(&mockLogger{}).
|
||||
Output()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if core.Trim(string(out)) != "exec_val" {
|
||||
t.Errorf("expected 'exec_val', got %q", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
input := core.NewReader("piped input\n")
|
||||
stdout := core.NewBuilder()
|
||||
stderr := core.NewBuilder()
|
||||
|
||||
err := exec.Command(ctx, "cat").
|
||||
WithStdin(input).
|
||||
WithStdout(stdout).
|
||||
WithStderr(stderr).
|
||||
WithLogger(&mockLogger{}).
|
||||
Run()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if core.Trim(stdout.String()) != "piped input" {
|
||||
t.Errorf("expected 'piped input', got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuiet_Command_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := exec.RunQuiet(ctx, "echo", "quiet")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuiet_Command_Bad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package exec
|
|||
|
||||
// Logger interface for command execution logging.
|
||||
// Compatible with pkg/log.Logger and other structured loggers.
|
||||
//
|
||||
// exec.SetDefaultLogger(myLogger)
|
||||
type Logger interface {
|
||||
// Debug logs a debug-level message with optional key-value pairs.
|
||||
Debug(msg string, keyvals ...any)
|
||||
|
|
@ -10,6 +12,8 @@ type Logger interface {
|
|||
}
|
||||
|
||||
// NopLogger is a no-op logger that discards all messages.
|
||||
//
|
||||
// var logger exec.NopLogger
|
||||
type NopLogger struct{}
|
||||
|
||||
// Debug discards the message (no-op implementation).
|
||||
|
|
@ -22,6 +26,8 @@ var defaultLogger Logger = NopLogger{}
|
|||
|
||||
// SetDefaultLogger sets the package-level default logger.
|
||||
// Commands without an explicit logger will use this.
|
||||
//
|
||||
// exec.SetDefaultLogger(myLogger)
|
||||
func SetDefaultLogger(l Logger) {
|
||||
if l == nil {
|
||||
l = NopLogger{}
|
||||
|
|
@ -30,6 +36,8 @@ func SetDefaultLogger(l Logger) {
|
|||
}
|
||||
|
||||
// DefaultLogger returns the current default logger.
|
||||
//
|
||||
// logger := exec.DefaultLogger()
|
||||
func DefaultLogger() Logger {
|
||||
return defaultLogger
|
||||
}
|
||||
|
|
|
|||
297
global_test.go
297
global_test.go
|
|
@ -1,297 +0,0 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
framework "forge.lthn.ai/core/go/pkg/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)
|
||||
|
||||
assert.Nil(t, List())
|
||||
assert.Nil(t, Running())
|
||||
|
||||
err = Kill("proc-1")
|
||||
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 TestGlobal_SetDefault(t *testing.T) {
|
||||
t.Run("sets and retrieves service", func(t *testing.T) {
|
||||
// Reset global state
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
|
||||
SetDefault(svc)
|
||||
assert.Equal(t, svc, Default())
|
||||
})
|
||||
|
||||
t.Run("errors on nil", func(t *testing.T) {
|
||||
err := SetDefault(nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobal_ConcurrentDefault(t *testing.T) {
|
||||
// Reset global state
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
|
||||
SetDefault(svc)
|
||||
|
||||
// Concurrent reads of Default()
|
||||
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) {
|
||||
// Reset global state
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create multiple services
|
||||
var services []*Service
|
||||
for i := 0; i < 10; i++ {
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
services = append(services, svc)
|
||||
}
|
||||
|
||||
// Concurrent SetDefault calls - should not panic or race
|
||||
var wg sync.WaitGroup
|
||||
for _, svc := range services {
|
||||
wg.Add(1)
|
||||
go func(s *Service) {
|
||||
defer wg.Done()
|
||||
SetDefault(s)
|
||||
}(svc)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Final state should be one of the services
|
||||
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) {
|
||||
// Reset global state
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
|
||||
SetDefault(svc)
|
||||
|
||||
// Concurrent Start, List, Get operations
|
||||
var wg sync.WaitGroup
|
||||
var processes []*Process
|
||||
var procMu sync.Mutex
|
||||
|
||||
// Start 20 processes concurrently
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent List calls while starting
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = List()
|
||||
_ = Running()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Wait for all processes to complete
|
||||
procMu.Lock()
|
||||
for _, p := range processes {
|
||||
<-p.Done()
|
||||
}
|
||||
procMu.Unlock()
|
||||
|
||||
// All should have succeeded
|
||||
assert.Len(t, processes, 20)
|
||||
|
||||
// Concurrent Get calls
|
||||
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)
|
||||
|
||||
// Set as default
|
||||
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)
|
||||
|
||||
// Set as default
|
||||
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_Running(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
// Set as default
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
defaultService.Store(old)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Start a long-running process
|
||||
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)
|
||||
}
|
||||
16
go.mod
16
go.mod
|
|
@ -1,24 +1,26 @@
|
|||
module forge.lthn.ai/core/go-process
|
||||
module dappco.re/go/core/process
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/api v0.1.2
|
||||
forge.lthn.ai/core/go v0.3.1
|
||||
forge.lthn.ai/core/go-io v0.1.2
|
||||
forge.lthn.ai/core/go-ws v0.2.0
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/io v0.2.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/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-log v0.0.2 // 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/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
|
|
|
|||
28
go.sum
28
go.sum
|
|
@ -1,13 +1,17 @@
|
|||
forge.lthn.ai/core/api v0.1.2 h1:VKHOQhjWcNCG4Xf9lJfrABRwk/+1tp8YsdQzPNVxzek=
|
||||
forge.lthn.ai/core/api v0.1.2/go.mod h1:vDkEihL/Cn1yKF8oA2jjf1CVOcd7kOP/WYWoIHIu2+E=
|
||||
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
|
||||
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
|
||||
forge.lthn.ai/core/go-log v0.0.2 h1:zJEgbajs5AjvhYzsbyybzn+S2Titiv56r3BG5E1cNUo=
|
||||
forge.lthn.ai/core/go-log v0.0.2/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-ws v0.2.0 h1:w1uG5MgUeGoWGu1hBh8liTXcsMJDrjvPGeIsg6fyvYk=
|
||||
forge.lthn.ai/core/go-ws v0.2.0/go.mod h1:iDbJuR1NT27czjtNIluxnEdLrnfsYQdEBIrsoZnpkCk=
|
||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ=
|
||||
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
|
||||
forge.lthn.ai/core/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/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
|
|
@ -31,8 +35,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
|
|
|
|||
43
health.go
43
health.go
|
|
@ -2,17 +2,22 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
//
|
||||
// check := process.HealthCheck(func() error { return nil })
|
||||
type HealthCheck func() error
|
||||
|
||||
// HealthServer provides HTTP /health and /ready endpoints for process monitoring.
|
||||
//
|
||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
||||
type HealthServer struct {
|
||||
addr string
|
||||
server *http.Server
|
||||
|
|
@ -23,6 +28,8 @@ type HealthServer struct {
|
|||
}
|
||||
|
||||
// NewHealthServer creates a health check server on the given address.
|
||||
//
|
||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
|
|
@ -56,13 +63,13 @@ func (h *HealthServer) Start() error {
|
|||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
_, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -72,35 +79,43 @@ func (h *HealthServer) Start() error {
|
|||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
_, _ = w.Write([]byte("not ready\n"))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", h.addr, err)
|
||||
return core.E("health.start", core.Concat("failed to listen on ", h.addr), err)
|
||||
}
|
||||
|
||||
server := &http.Server{Handler: mux}
|
||||
h.listener = listener
|
||||
h.server = &http.Server{Handler: mux}
|
||||
h.server = server
|
||||
|
||||
go func() {
|
||||
_ = h.server.Serve(listener)
|
||||
}()
|
||||
go func(srv *http.Server, ln net.Listener) {
|
||||
_ = srv.Serve(ln)
|
||||
}(server, listener)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health server.
|
||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||
if h.server == nil {
|
||||
h.mu.Lock()
|
||||
server := h.server
|
||||
h.server = nil
|
||||
h.listener = nil
|
||||
h.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
return h.server.Shutdown(ctx)
|
||||
|
||||
return server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Addr returns the actual address the server is listening on.
|
||||
|
|
@ -113,9 +128,11 @@ func (h *HealthServer) Addr() string {
|
|||
|
||||
// WaitForHealth polls a health endpoint until it responds 200 or the timeout
|
||||
// (in milliseconds) expires. Returns true if healthy, false on timeout.
|
||||
//
|
||||
// ok := process.WaitForHealth("127.0.0.1:9000", 2_000)
|
||||
func WaitForHealth(addr string, timeoutMs int) bool {
|
||||
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}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"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")
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -36,7 +36,7 @@ func TestHealthServer_Endpoints(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestHealthServer_WithChecks(t *testing.T) {
|
||||
func TestHealthServer_WithChecks_Good(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
healthy := true
|
||||
|
|
@ -66,7 +66,13 @@ func TestHealthServer_WithChecks(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Reachable(t *testing.T) {
|
||||
func TestHealthServer_StopImmediately_Good(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
require.NoError(t, hs.Start())
|
||||
require.NoError(t, hs.Stop(context.Background()))
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Reachable_Good(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
require.NoError(t, hs.Start())
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
|
@ -75,7 +81,7 @@ func TestWaitForHealth_Reachable(t *testing.T) {
|
|||
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)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
|
|||
32
pidfile.go
32
pidfile.go
|
|
@ -1,15 +1,14 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"bytes"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
// PIDFile manages a process ID file for single-instance enforcement.
|
||||
|
|
@ -30,26 +29,26 @@ func (p *PIDFile) Acquire() error {
|
|||
defer p.mu.Unlock()
|
||||
|
||||
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 proc, err := os.FindProcess(pid); err == nil {
|
||||
if proc, err := processHandle(pid); err == nil {
|
||||
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||
return fmt.Errorf("another instance is running (PID %d)", pid)
|
||||
return core.E("pidfile.acquire", core.Concat("another instance is running (PID ", strconv.Itoa(pid), ")"), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = 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 {
|
||||
return fmt.Errorf("failed to create PID directory: %w", 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 {
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
return core.E("pidfile.acquire", "failed to write PID file", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -59,7 +58,10 @@ func (p *PIDFile) Acquire() error {
|
|||
func (p *PIDFile) Release() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return coreio.Local.Delete(p.path)
|
||||
if err := coreio.Local.Delete(p.path); err != nil {
|
||||
return core.E("pidfile.release", "failed to remove PID file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the PID file path.
|
||||
|
|
@ -76,12 +78,12 @@ func ReadPID(path string) (int, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(data))
|
||||
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
proc, err := processHandle(pid)
|
||||
if err != nil {
|
||||
return pid, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ package process
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPIDFile_AcquireAndRelease(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
func TestPIDFile_Acquire_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -23,8 +23,8 @@ func TestPIDFile_AcquireAndRelease(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestPIDFile_StalePID(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||
func TestPIDFile_AcquireStale_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
|
|
@ -33,8 +33,8 @@ func TestPIDFile_StalePID(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPIDFile_CreatesParentDirectory(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
func TestPIDFile_CreateDirectory_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -42,27 +42,27 @@ func TestPIDFile_CreatesParentDirectory(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPIDFile_Path(t *testing.T) {
|
||||
func TestPIDFile_Path_Good(t *testing.T) {
|
||||
pid := NewPIDFile("/tmp/test.pid")
|
||||
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")
|
||||
assert.Equal(t, 0, pid)
|
||||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_InvalidContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.pid")
|
||||
func TestReadPID_Invalid_Bad(t *testing.T) {
|
||||
path := core.JoinPath(t.TempDir(), "bad.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
assert.Equal(t, 0, pid)
|
||||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_StalePID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stale.pid")
|
||||
func TestReadPID_Stale_Bad(t *testing.T) {
|
||||
path := core.JoinPath(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
assert.Equal(t, 999999999, pid)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import (
|
|||
"strconv"
|
||||
"syscall"
|
||||
|
||||
process "dappco.re/go/core/process"
|
||||
"dappco.re/go/core/ws"
|
||||
"forge.lthn.ai/core/api"
|
||||
"forge.lthn.ai/core/api/pkg/provider"
|
||||
process "forge.lthn.ai/core/go-process"
|
||||
"forge.lthn.ai/core/go-ws"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
|
@ -119,6 +119,8 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
|
|||
"daemon": map[string]any{"type": "string"},
|
||||
"pid": map[string]any{"type": "integer"},
|
||||
"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"},
|
||||
},
|
||||
},
|
||||
|
|
@ -147,6 +149,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
|
|||
"properties": map[string]any{
|
||||
"healthy": map[string]any{"type": "boolean"},
|
||||
"address": map[string]any{"type": "string"},
|
||||
"reason": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -233,10 +236,15 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
|
|||
}
|
||||
|
||||
healthy := process.WaitForHealth(entry.Health, 2000)
|
||||
reason := ""
|
||||
if !healthy {
|
||||
reason = "health endpoint did not report healthy"
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"healthy": healthy,
|
||||
"address": entry.Health,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
// Emit health event
|
||||
|
|
@ -244,6 +252,7 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
|
|||
"code": code,
|
||||
"daemon": daemon,
|
||||
"healthy": healthy,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
statusCode := http.StatusOK
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
process "dappco.re/go/core/process"
|
||||
processapi "dappco.re/go/core/process/pkg/api"
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
process "forge.lthn.ai/core/go-process"
|
||||
processapi "forge.lthn.ai/core/go-process/pkg/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -50,6 +50,14 @@ func TestProcessProvider_Describe_Good(t *testing.T) {
|
|||
assert.NotEmpty(t, d.Summary)
|
||||
assert.NotEmpty(t, d.Tags)
|
||||
}
|
||||
|
||||
for _, d := range descs {
|
||||
if d.Path == "/daemons/:code/:daemon/health" {
|
||||
props, ok := d.Response["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, props, "reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
||||
|
|
@ -65,10 +73,8 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
|||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp goapi.Response[[]any]
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Success)
|
||||
body := w.Body.String()
|
||||
assert.NotEmpty(t, body)
|
||||
}
|
||||
|
||||
func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
||||
|
|
@ -84,6 +90,27 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
|||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestProcessProvider_HealthCheck_NoEndpoint_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registry := newTestRegistry(dir)
|
||||
require.NoError(t, registry.Register(process.DaemonEntry{
|
||||
Code: "test",
|
||||
Daemon: "nohealth",
|
||||
PID: os.Getpid(),
|
||||
}))
|
||||
|
||||
p := processapi.NewProvider(registry, nil)
|
||||
|
||||
r := setupRouter(p)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/process/daemons/test/nohealth/health", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "no health endpoint configured")
|
||||
assert.Contains(t, w.Body.String(), "\"reason\"")
|
||||
}
|
||||
|
||||
func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
||||
p := processapi.NewProvider(nil, nil)
|
||||
|
||||
|
|
@ -95,7 +122,7 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
|||
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)
|
||||
|
||||
engine, err := goapi.New()
|
||||
|
|
|
|||
525
pkg/api/ui/dist/core-process.js
vendored
525
pkg/api/ui/dist/core-process.js
vendored
|
|
@ -3,10 +3,10 @@
|
|||
* Copyright 2019 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const V = globalThis, se = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), ne = /* @__PURE__ */ new WeakMap();
|
||||
let $e = class {
|
||||
const K = globalThis, se = K.ShadowRoot && (K.ShadyCSS === void 0 || K.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, ie = Symbol(), ae = /* @__PURE__ */ new WeakMap();
|
||||
let ye = class {
|
||||
constructor(e, t, i) {
|
||||
if (this._$cssResult$ = !0, i !== re) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
|
||||
if (this._$cssResult$ = !0, i !== ie) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
|
||||
this.cssText = e, this.t = t;
|
||||
}
|
||||
get styleSheet() {
|
||||
|
|
@ -14,7 +14,7 @@ let $e = class {
|
|||
const t = this.t;
|
||||
if (se && e === void 0) {
|
||||
const i = t !== void 0 && t.length === 1;
|
||||
i && (e = ne.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ne.set(t, e));
|
||||
i && (e = ae.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ae.set(t, e));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
|
@ -22,20 +22,20 @@ let $e = class {
|
|||
return this.cssText;
|
||||
}
|
||||
};
|
||||
const Ae = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s, ...e) => {
|
||||
const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => {
|
||||
if (o._$cssResult$ === !0) return o.cssText;
|
||||
if (typeof o == "number") return o;
|
||||
throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.");
|
||||
})(r) + s[n + 1], s[0]);
|
||||
return new $e(t, s, re);
|
||||
const Ae = (s) => new ye(typeof s == "string" ? s : s + "", void 0, ie), q = (s, ...e) => {
|
||||
const t = s.length === 1 ? s[0] : e.reduce((i, o, n) => i + ((r) => {
|
||||
if (r._$cssResult$ === !0) return r.cssText;
|
||||
if (typeof r == "number") return r;
|
||||
throw Error("Value passed to 'css' function must be a 'css' function result: " + r + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.");
|
||||
})(o) + s[n + 1], s[0]);
|
||||
return new ye(t, s, ie);
|
||||
}, ke = (s, e) => {
|
||||
if (se) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet);
|
||||
else for (const t of e) {
|
||||
const i = document.createElement("style"), r = V.litNonce;
|
||||
r !== void 0 && i.setAttribute("nonce", r), i.textContent = t.cssText, s.appendChild(i);
|
||||
const i = document.createElement("style"), o = K.litNonce;
|
||||
o !== void 0 && i.setAttribute("nonce", o), i.textContent = t.cssText, s.appendChild(i);
|
||||
}
|
||||
}, ae = se ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => {
|
||||
}, le = se ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => {
|
||||
let t = "";
|
||||
for (const i of e.cssRules) t += i.cssText;
|
||||
return Ae(t);
|
||||
|
|
@ -45,7 +45,7 @@ const Ae = (s) => new $e(typeof s == "string" ? s : s + "", void 0, re), B = (s,
|
|||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnPropertyNames: Ee, getOwnPropertySymbols: Ue, getPrototypeOf: Oe } = Object, A = globalThis, le = A.trustedTypes, ze = le ? le.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) {
|
||||
const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnPropertyNames: Ee, getOwnPropertySymbols: Ue, getPrototypeOf: Oe } = Object, A = globalThis, ce = A.trustedTypes, ze = ce ? ce.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) {
|
||||
switch (e) {
|
||||
case Boolean:
|
||||
s = s ? ze : null;
|
||||
|
|
@ -73,7 +73,7 @@ const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnProperty
|
|||
}
|
||||
}
|
||||
return t;
|
||||
} }, ie = (s, e) => !Se(s, e), ce = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: ie };
|
||||
} }, oe = (s, e) => !Se(s, e), de = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: oe };
|
||||
Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap());
|
||||
let D = class extends HTMLElement {
|
||||
static addInitializer(e) {
|
||||
|
|
@ -82,25 +82,25 @@ let D = class extends HTMLElement {
|
|||
static get observedAttributes() {
|
||||
return this.finalize(), this._$Eh && [...this._$Eh.keys()];
|
||||
}
|
||||
static createProperty(e, t = ce) {
|
||||
static createProperty(e, t = de) {
|
||||
if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) {
|
||||
const i = Symbol(), r = this.getPropertyDescriptor(e, i, t);
|
||||
r !== void 0 && Pe(this.prototype, e, r);
|
||||
const i = Symbol(), o = this.getPropertyDescriptor(e, i, t);
|
||||
o !== void 0 && Pe(this.prototype, e, o);
|
||||
}
|
||||
}
|
||||
static getPropertyDescriptor(e, t, i) {
|
||||
const { get: r, set: n } = Ce(this.prototype, e) ?? { get() {
|
||||
const { get: o, set: n } = Ce(this.prototype, e) ?? { get() {
|
||||
return this[t];
|
||||
}, set(o) {
|
||||
this[t] = o;
|
||||
}, set(r) {
|
||||
this[t] = r;
|
||||
} };
|
||||
return { get: r, set(o) {
|
||||
const l = r == null ? void 0 : r.call(this);
|
||||
n == null || n.call(this, o), this.requestUpdate(e, l, i);
|
||||
return { get: o, set(r) {
|
||||
const l = o == null ? void 0 : o.call(this);
|
||||
n == null || n.call(this, r), this.requestUpdate(e, l, i);
|
||||
}, configurable: !0, enumerable: !0 };
|
||||
}
|
||||
static getPropertyOptions(e) {
|
||||
return this.elementProperties.get(e) ?? ce;
|
||||
return this.elementProperties.get(e) ?? de;
|
||||
}
|
||||
static _$Ei() {
|
||||
if (this.hasOwnProperty(j("elementProperties"))) return;
|
||||
|
|
@ -111,17 +111,17 @@ let D = class extends HTMLElement {
|
|||
if (this.hasOwnProperty(j("finalized"))) return;
|
||||
if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(j("properties"))) {
|
||||
const t = this.properties, i = [...Ee(t), ...Ue(t)];
|
||||
for (const r of i) this.createProperty(r, t[r]);
|
||||
for (const o of i) this.createProperty(o, t[o]);
|
||||
}
|
||||
const e = this[Symbol.metadata];
|
||||
if (e !== null) {
|
||||
const t = litPropertyMetadata.get(e);
|
||||
if (t !== void 0) for (const [i, r] of t) this.elementProperties.set(i, r);
|
||||
if (t !== void 0) for (const [i, o] of t) this.elementProperties.set(i, o);
|
||||
}
|
||||
this._$Eh = /* @__PURE__ */ new Map();
|
||||
for (const [t, i] of this.elementProperties) {
|
||||
const r = this._$Eu(t, i);
|
||||
r !== void 0 && this._$Eh.set(r, t);
|
||||
const o = this._$Eu(t, i);
|
||||
o !== void 0 && this._$Eh.set(o, t);
|
||||
}
|
||||
this.elementStyles = this.finalizeStyles(this.styles);
|
||||
}
|
||||
|
|
@ -129,8 +129,8 @@ let D = class extends HTMLElement {
|
|||
const t = [];
|
||||
if (Array.isArray(e)) {
|
||||
const i = new Set(e.flat(1 / 0).reverse());
|
||||
for (const r of i) t.unshift(ae(r));
|
||||
} else e !== void 0 && t.push(ae(e));
|
||||
for (const o of i) t.unshift(le(o));
|
||||
} else e !== void 0 && t.push(le(e));
|
||||
return t;
|
||||
}
|
||||
static _$Eu(e, t) {
|
||||
|
|
@ -182,33 +182,33 @@ let D = class extends HTMLElement {
|
|||
}
|
||||
_$ET(e, t) {
|
||||
var n;
|
||||
const i = this.constructor.elementProperties.get(e), r = this.constructor._$Eu(e, i);
|
||||
if (r !== void 0 && i.reflect === !0) {
|
||||
const o = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : J).toAttribute(t, i.type);
|
||||
this._$Em = e, o == null ? this.removeAttribute(r) : this.setAttribute(r, o), this._$Em = null;
|
||||
const i = this.constructor.elementProperties.get(e), o = this.constructor._$Eu(e, i);
|
||||
if (o !== void 0 && i.reflect === !0) {
|
||||
const r = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : J).toAttribute(t, i.type);
|
||||
this._$Em = e, r == null ? this.removeAttribute(o) : this.setAttribute(o, r), this._$Em = null;
|
||||
}
|
||||
}
|
||||
_$AK(e, t) {
|
||||
var n, o;
|
||||
const i = this.constructor, r = i._$Eh.get(e);
|
||||
if (r !== void 0 && this._$Em !== r) {
|
||||
const l = i.getPropertyOptions(r), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : J;
|
||||
this._$Em = r;
|
||||
var n, r;
|
||||
const i = this.constructor, o = i._$Eh.get(e);
|
||||
if (o !== void 0 && this._$Em !== o) {
|
||||
const l = i.getPropertyOptions(o), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : J;
|
||||
this._$Em = o;
|
||||
const p = a.fromAttribute(t, l.type);
|
||||
this[r] = p ?? ((o = this._$Ej) == null ? void 0 : o.get(r)) ?? p, this._$Em = null;
|
||||
this[o] = p ?? ((r = this._$Ej) == null ? void 0 : r.get(o)) ?? p, this._$Em = null;
|
||||
}
|
||||
}
|
||||
requestUpdate(e, t, i, r = !1, n) {
|
||||
var o;
|
||||
requestUpdate(e, t, i, o = !1, n) {
|
||||
var r;
|
||||
if (e !== void 0) {
|
||||
const l = this.constructor;
|
||||
if (r === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? ie)(n, t) || i.useDefault && i.reflect && n === ((o = this._$Ej) == null ? void 0 : o.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return;
|
||||
if (o === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? oe)(n, t) || i.useDefault && i.reflect && n === ((r = this._$Ej) == null ? void 0 : r.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return;
|
||||
this.C(e, t, i);
|
||||
}
|
||||
this.isUpdatePending === !1 && (this._$ES = this._$EP());
|
||||
}
|
||||
C(e, t, { useDefault: i, reflect: r, wrapped: n }, o) {
|
||||
i && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(e) && (this._$Ej.set(e, o ?? t ?? this[e]), n !== !0 || o !== void 0) || (this._$AL.has(e) || (this.hasUpdated || i || (t = void 0), this._$AL.set(e, t)), r === !0 && this._$Em !== e && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(e));
|
||||
C(e, t, { useDefault: i, reflect: o, wrapped: n }, r) {
|
||||
i && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(e) && (this._$Ej.set(e, r ?? t ?? this[e]), n !== !0 || r !== void 0) || (this._$AL.has(e) || (this.hasUpdated || i || (t = void 0), this._$AL.set(e, t)), o === !0 && this._$Em !== e && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(e));
|
||||
}
|
||||
async _$EP() {
|
||||
this.isUpdatePending = !0;
|
||||
|
|
@ -228,24 +228,24 @@ let D = class extends HTMLElement {
|
|||
if (!this.isUpdatePending) return;
|
||||
if (!this.hasUpdated) {
|
||||
if (this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this._$Ep) {
|
||||
for (const [n, o] of this._$Ep) this[n] = o;
|
||||
for (const [n, r] of this._$Ep) this[n] = r;
|
||||
this._$Ep = void 0;
|
||||
}
|
||||
const r = this.constructor.elementProperties;
|
||||
if (r.size > 0) for (const [n, o] of r) {
|
||||
const { wrapped: l } = o, a = this[n];
|
||||
l !== !0 || this._$AL.has(n) || a === void 0 || this.C(n, void 0, o, a);
|
||||
const o = this.constructor.elementProperties;
|
||||
if (o.size > 0) for (const [n, r] of o) {
|
||||
const { wrapped: l } = r, a = this[n];
|
||||
l !== !0 || this._$AL.has(n) || a === void 0 || this.C(n, void 0, r, a);
|
||||
}
|
||||
}
|
||||
let e = !1;
|
||||
const t = this._$AL;
|
||||
try {
|
||||
e = this.shouldUpdate(t), e ? (this.willUpdate(t), (i = this._$EO) == null || i.forEach((r) => {
|
||||
e = this.shouldUpdate(t), e ? (this.willUpdate(t), (i = this._$EO) == null || i.forEach((o) => {
|
||||
var n;
|
||||
return (n = r.hostUpdate) == null ? void 0 : n.call(r);
|
||||
return (n = o.hostUpdate) == null ? void 0 : n.call(o);
|
||||
}), this.update(t)) : this._$EM();
|
||||
} catch (r) {
|
||||
throw e = !1, this._$EM(), r;
|
||||
} catch (o) {
|
||||
throw e = !1, this._$EM(), o;
|
||||
}
|
||||
e && this._$AE(t);
|
||||
}
|
||||
|
|
@ -254,8 +254,8 @@ let D = class extends HTMLElement {
|
|||
_$AE(e) {
|
||||
var t;
|
||||
(t = this._$EO) == null || t.forEach((i) => {
|
||||
var r;
|
||||
return (r = i.hostUpdated) == null ? void 0 : r.call(i);
|
||||
var o;
|
||||
return (o = i.hostUpdated) == null ? void 0 : o.call(i);
|
||||
}), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(e)), this.updated(e);
|
||||
}
|
||||
_$EM() {
|
||||
|
|
@ -284,68 +284,68 @@ D.elementStyles = [], D.shadowRootOptions = { mode: "open" }, D[j("elementProper
|
|||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const N = globalThis, de = (s) => s, Z = N.trustedTypes, he = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ye = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, ve = "?" + x, De = `<${ve}>`, E = document, I = () => E.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", oe = Array.isArray, Te = (s) => oe(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[
|
||||
\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, pe = /-->/g, ue = />/g, S = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^
|
||||
\f\r"'\`<>=]|("|')|))|$)`, "g"), me = /'/g, fe = /"/g, _e = /^(?:script|style|textarea|title)$/i, Me = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Me(1), T = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), ge = /* @__PURE__ */ new WeakMap(), P = E.createTreeWalker(E, 129);
|
||||
function we(s, e) {
|
||||
if (!oe(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array");
|
||||
return he !== void 0 ? he.createHTML(e) : e;
|
||||
const N = globalThis, he = (s) => s, Z = N.trustedTypes, pe = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ve = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, we = "?" + x, De = `<${we}>`, U = document, I = () => U.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", re = Array.isArray, Te = (s) => re(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[
|
||||
\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ue = /-->/g, me = />/g, P = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^
|
||||
\f\r"'\`<>=]|("|')|))|$)`, "g"), fe = /'/g, ge = /"/g, _e = /^(?:script|style|textarea|title)$/i, Me = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Me(1), T = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), be = /* @__PURE__ */ new WeakMap(), C = U.createTreeWalker(U, 129);
|
||||
function xe(s, e) {
|
||||
if (!re(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array");
|
||||
return pe !== void 0 ? pe.createHTML(e) : e;
|
||||
}
|
||||
const Re = (s, e) => {
|
||||
const t = s.length - 1, i = [];
|
||||
let r, n = e === 2 ? "<svg>" : e === 3 ? "<math>" : "", o = H;
|
||||
let o, n = e === 2 ? "<svg>" : e === 3 ? "<math>" : "", r = H;
|
||||
for (let l = 0; l < t; l++) {
|
||||
const a = s[l];
|
||||
let p, m, h = -1, b = 0;
|
||||
for (; b < a.length && (o.lastIndex = b, m = o.exec(a), m !== null); ) b = o.lastIndex, o === H ? m[1] === "!--" ? o = pe : m[1] !== void 0 ? o = ue : m[2] !== void 0 ? (_e.test(m[2]) && (r = RegExp("</" + m[2], "g")), o = S) : m[3] !== void 0 && (o = S) : o === S ? m[0] === ">" ? (o = r ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? S : m[3] === '"' ? fe : me) : o === fe || o === me ? o = S : o === pe || o === ue ? o = H : (o = S, r = void 0);
|
||||
const w = o === S && s[l + 1].startsWith("/>") ? " " : "";
|
||||
n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ye + a.slice(h) + x + w) : a + x + (h === -2 ? l : w);
|
||||
let p, m, h = -1, $ = 0;
|
||||
for (; $ < a.length && (r.lastIndex = $, m = r.exec(a), m !== null); ) $ = r.lastIndex, r === H ? m[1] === "!--" ? r = ue : m[1] !== void 0 ? r = me : m[2] !== void 0 ? (_e.test(m[2]) && (o = RegExp("</" + m[2], "g")), r = P) : m[3] !== void 0 && (r = P) : r === P ? m[0] === ">" ? (r = o ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = r.lastIndex - m[2].length, p = m[1], r = m[3] === void 0 ? P : m[3] === '"' ? ge : fe) : r === ge || r === fe ? r = P : r === ue || r === me ? r = H : (r = P, o = void 0);
|
||||
const _ = r === P && s[l + 1].startsWith("/>") ? " " : "";
|
||||
n += r === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ve + a.slice(h) + x + _) : a + x + (h === -2 ? l : _);
|
||||
}
|
||||
return [we(s, n + (s[t] || "<?>") + (e === 2 ? "</svg>" : e === 3 ? "</math>" : "")), i];
|
||||
return [xe(s, n + (s[t] || "<?>") + (e === 2 ? "</svg>" : e === 3 ? "</math>" : "")), i];
|
||||
};
|
||||
class q {
|
||||
class W {
|
||||
constructor({ strings: e, _$litType$: t }, i) {
|
||||
let r;
|
||||
let o;
|
||||
this.parts = [];
|
||||
let n = 0, o = 0;
|
||||
let n = 0, r = 0;
|
||||
const l = e.length - 1, a = this.parts, [p, m] = Re(e, t);
|
||||
if (this.el = q.createElement(p, i), P.currentNode = this.el.content, t === 2 || t === 3) {
|
||||
if (this.el = W.createElement(p, i), C.currentNode = this.el.content, t === 2 || t === 3) {
|
||||
const h = this.el.content.firstChild;
|
||||
h.replaceWith(...h.childNodes);
|
||||
}
|
||||
for (; (r = P.nextNode()) !== null && a.length < l; ) {
|
||||
if (r.nodeType === 1) {
|
||||
if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(ye)) {
|
||||
const b = m[o++], w = r.getAttribute(h).split(x), K = /([.?@])?(.*)/.exec(b);
|
||||
a.push({ type: 1, index: n, name: K[2], strings: w, ctor: K[1] === "." ? je : K[1] === "?" ? Ne : K[1] === "@" ? Ie : G }), r.removeAttribute(h);
|
||||
} else h.startsWith(x) && (a.push({ type: 6, index: n }), r.removeAttribute(h));
|
||||
if (_e.test(r.tagName)) {
|
||||
const h = r.textContent.split(x), b = h.length - 1;
|
||||
if (b > 0) {
|
||||
r.textContent = Z ? Z.emptyScript : "";
|
||||
for (let w = 0; w < b; w++) r.append(h[w], I()), P.nextNode(), a.push({ type: 2, index: ++n });
|
||||
r.append(h[b], I());
|
||||
for (; (o = C.nextNode()) !== null && a.length < l; ) {
|
||||
if (o.nodeType === 1) {
|
||||
if (o.hasAttributes()) for (const h of o.getAttributeNames()) if (h.endsWith(ve)) {
|
||||
const $ = m[r++], _ = o.getAttribute(h).split(x), V = /([.?@])?(.*)/.exec($);
|
||||
a.push({ type: 1, index: n, name: V[2], strings: _, ctor: V[1] === "." ? je : V[1] === "?" ? Ne : V[1] === "@" ? Ie : G }), o.removeAttribute(h);
|
||||
} else h.startsWith(x) && (a.push({ type: 6, index: n }), o.removeAttribute(h));
|
||||
if (_e.test(o.tagName)) {
|
||||
const h = o.textContent.split(x), $ = h.length - 1;
|
||||
if ($ > 0) {
|
||||
o.textContent = Z ? Z.emptyScript : "";
|
||||
for (let _ = 0; _ < $; _++) o.append(h[_], I()), C.nextNode(), a.push({ type: 2, index: ++n });
|
||||
o.append(h[$], I());
|
||||
}
|
||||
}
|
||||
} else if (r.nodeType === 8) if (r.data === ve) a.push({ type: 2, index: n });
|
||||
} else if (o.nodeType === 8) if (o.data === we) a.push({ type: 2, index: n });
|
||||
else {
|
||||
let h = -1;
|
||||
for (; (h = r.data.indexOf(x, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += x.length - 1;
|
||||
for (; (h = o.data.indexOf(x, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += x.length - 1;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
static createElement(e, t) {
|
||||
const i = E.createElement("template");
|
||||
const i = U.createElement("template");
|
||||
return i.innerHTML = e, i;
|
||||
}
|
||||
}
|
||||
function M(s, e, t = s, i) {
|
||||
var o, l;
|
||||
var r, l;
|
||||
if (e === T) return e;
|
||||
let r = i !== void 0 ? (o = t._$Co) == null ? void 0 : o[i] : t._$Cl;
|
||||
let o = i !== void 0 ? (r = t._$Co) == null ? void 0 : r[i] : t._$Cl;
|
||||
const n = L(e) ? void 0 : e._$litDirective$;
|
||||
return (r == null ? void 0 : r.constructor) !== n && ((l = r == null ? void 0 : r._$AO) == null || l.call(r, !1), n === void 0 ? r = void 0 : (r = new n(s), r._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = r : t._$Cl = r), r !== void 0 && (e = M(s, r._$AS(s, e.values), r, i)), e;
|
||||
return (o == null ? void 0 : o.constructor) !== n && ((l = o == null ? void 0 : o._$AO) == null || l.call(o, !1), n === void 0 ? o = void 0 : (o = new n(s), o._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = o : t._$Cl = o), o !== void 0 && (e = M(s, o._$AS(s, e.values), o, i)), e;
|
||||
}
|
||||
class He {
|
||||
constructor(e, t) {
|
||||
|
|
@ -358,30 +358,30 @@ class He {
|
|||
return this._$AM._$AU;
|
||||
}
|
||||
u(e) {
|
||||
const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? E).importNode(t, !0);
|
||||
P.currentNode = r;
|
||||
let n = P.nextNode(), o = 0, l = 0, a = i[0];
|
||||
const { el: { content: t }, parts: i } = this._$AD, o = ((e == null ? void 0 : e.creationScope) ?? U).importNode(t, !0);
|
||||
C.currentNode = o;
|
||||
let n = C.nextNode(), r = 0, l = 0, a = i[0];
|
||||
for (; a !== void 0; ) {
|
||||
if (o === a.index) {
|
||||
if (r === a.index) {
|
||||
let p;
|
||||
a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l];
|
||||
a.type === 2 ? p = new B(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l];
|
||||
}
|
||||
o !== (a == null ? void 0 : a.index) && (n = P.nextNode(), o++);
|
||||
r !== (a == null ? void 0 : a.index) && (n = C.nextNode(), r++);
|
||||
}
|
||||
return P.currentNode = E, r;
|
||||
return C.currentNode = U, o;
|
||||
}
|
||||
p(e) {
|
||||
let t = 0;
|
||||
for (const i of this._$AV) i !== void 0 && (i.strings !== void 0 ? (i._$AI(e, i, t), t += i.strings.length - 2) : i._$AI(e[t])), t++;
|
||||
}
|
||||
}
|
||||
class W {
|
||||
class B {
|
||||
get _$AU() {
|
||||
var e;
|
||||
return ((e = this._$AM) == null ? void 0 : e._$AU) ?? this._$Cv;
|
||||
}
|
||||
constructor(e, t, i, r) {
|
||||
this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = i, this.options = r, this._$Cv = (r == null ? void 0 : r.isConnected) ?? !0;
|
||||
constructor(e, t, i, o) {
|
||||
this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = i, this.options = o, this._$Cv = (o == null ? void 0 : o.isConnected) ?? !0;
|
||||
}
|
||||
get parentNode() {
|
||||
let e = this._$AA.parentNode;
|
||||
|
|
@ -404,33 +404,33 @@ class W {
|
|||
this._$AH !== e && (this._$AR(), this._$AH = this.O(e));
|
||||
}
|
||||
_(e) {
|
||||
this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(E.createTextNode(e)), this._$AH = e;
|
||||
this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(U.createTextNode(e)), this._$AH = e;
|
||||
}
|
||||
$(e) {
|
||||
var n;
|
||||
const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = q.createElement(we(i.h, i.h[0]), this.options)), i);
|
||||
if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t);
|
||||
const { values: t, _$litType$: i } = e, o = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = W.createElement(xe(i.h, i.h[0]), this.options)), i);
|
||||
if (((n = this._$AH) == null ? void 0 : n._$AD) === o) this._$AH.p(t);
|
||||
else {
|
||||
const o = new He(r, this), l = o.u(this.options);
|
||||
o.p(t), this.T(l), this._$AH = o;
|
||||
const r = new He(o, this), l = r.u(this.options);
|
||||
r.p(t), this.T(l), this._$AH = r;
|
||||
}
|
||||
}
|
||||
_$AC(e) {
|
||||
let t = ge.get(e.strings);
|
||||
return t === void 0 && ge.set(e.strings, t = new q(e)), t;
|
||||
let t = be.get(e.strings);
|
||||
return t === void 0 && be.set(e.strings, t = new W(e)), t;
|
||||
}
|
||||
k(e) {
|
||||
oe(this._$AH) || (this._$AH = [], this._$AR());
|
||||
re(this._$AH) || (this._$AH = [], this._$AR());
|
||||
const t = this._$AH;
|
||||
let i, r = 0;
|
||||
for (const n of e) r === t.length ? t.push(i = new W(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++;
|
||||
r < t.length && (this._$AR(i && i._$AB.nextSibling, r), t.length = r);
|
||||
let i, o = 0;
|
||||
for (const n of e) o === t.length ? t.push(i = new B(this.O(I()), this.O(I()), this, this.options)) : i = t[o], i._$AI(n), o++;
|
||||
o < t.length && (this._$AR(i && i._$AB.nextSibling, o), t.length = o);
|
||||
}
|
||||
_$AR(e = this._$AA.nextSibling, t) {
|
||||
var i;
|
||||
for ((i = this._$AP) == null ? void 0 : i.call(this, !1, !0, t); e !== this._$AB; ) {
|
||||
const r = de(e).nextSibling;
|
||||
de(e).remove(), e = r;
|
||||
const o = he(e).nextSibling;
|
||||
he(e).remove(), e = o;
|
||||
}
|
||||
}
|
||||
setConnected(e) {
|
||||
|
|
@ -445,19 +445,19 @@ class G {
|
|||
get _$AU() {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
constructor(e, t, i, r, n) {
|
||||
this.type = 1, this._$AH = d, this._$AN = void 0, this.element = e, this.name = t, this._$AM = r, this.options = n, i.length > 2 || i[0] !== "" || i[1] !== "" ? (this._$AH = Array(i.length - 1).fill(new String()), this.strings = i) : this._$AH = d;
|
||||
constructor(e, t, i, o, n) {
|
||||
this.type = 1, this._$AH = d, this._$AN = void 0, this.element = e, this.name = t, this._$AM = o, this.options = n, i.length > 2 || i[0] !== "" || i[1] !== "" ? (this._$AH = Array(i.length - 1).fill(new String()), this.strings = i) : this._$AH = d;
|
||||
}
|
||||
_$AI(e, t = this, i, r) {
|
||||
_$AI(e, t = this, i, o) {
|
||||
const n = this.strings;
|
||||
let o = !1;
|
||||
if (n === void 0) e = M(this, e, t, 0), o = !L(e) || e !== this._$AH && e !== T, o && (this._$AH = e);
|
||||
let r = !1;
|
||||
if (n === void 0) e = M(this, e, t, 0), r = !L(e) || e !== this._$AH && e !== T, r && (this._$AH = e);
|
||||
else {
|
||||
const l = e;
|
||||
let a, p;
|
||||
for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === T && (p = this._$AH[a]), o || (o = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p;
|
||||
for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === T && (p = this._$AH[a]), r || (r = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p;
|
||||
}
|
||||
o && !r && this.j(e);
|
||||
r && !o && this.j(e);
|
||||
}
|
||||
j(e) {
|
||||
e === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? "");
|
||||
|
|
@ -480,13 +480,13 @@ class Ne extends G {
|
|||
}
|
||||
}
|
||||
class Ie extends G {
|
||||
constructor(e, t, i, r, n) {
|
||||
super(e, t, i, r, n), this.type = 5;
|
||||
constructor(e, t, i, o, n) {
|
||||
super(e, t, i, o, n), this.type = 5;
|
||||
}
|
||||
_$AI(e, t = this) {
|
||||
if ((e = M(this, e, t, 0) ?? d) === T) return;
|
||||
const i = this._$AH, r = e === d && i !== d || e.capture !== i.capture || e.once !== i.once || e.passive !== i.passive, n = e !== d && (i === d || r);
|
||||
r && this.element.removeEventListener(this.name, this, i), n && this.element.addEventListener(this.name, this, e), this._$AH = e;
|
||||
const i = this._$AH, o = e === d && i !== d || e.capture !== i.capture || e.once !== i.once || e.passive !== i.passive, n = e !== d && (i === d || o);
|
||||
o && this.element.removeEventListener(this.name, this, i), n && this.element.addEventListener(this.name, this, e), this._$AH = e;
|
||||
}
|
||||
handleEvent(e) {
|
||||
var t;
|
||||
|
|
@ -505,23 +505,23 @@ class Le {
|
|||
}
|
||||
}
|
||||
const ee = N.litHtmlPolyfillSupport;
|
||||
ee == null || ee(q, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2");
|
||||
const qe = (s, e, t) => {
|
||||
ee == null || ee(W, B), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2");
|
||||
const We = (s, e, t) => {
|
||||
const i = (t == null ? void 0 : t.renderBefore) ?? e;
|
||||
let r = i._$litPart$;
|
||||
if (r === void 0) {
|
||||
let o = i._$litPart$;
|
||||
if (o === void 0) {
|
||||
const n = (t == null ? void 0 : t.renderBefore) ?? null;
|
||||
i._$litPart$ = r = new W(e.insertBefore(I(), n), n, void 0, t ?? {});
|
||||
i._$litPart$ = o = new B(e.insertBefore(I(), n), n, void 0, t ?? {});
|
||||
}
|
||||
return r._$AI(s), r;
|
||||
return o._$AI(s), o;
|
||||
};
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const C = globalThis;
|
||||
class $ extends D {
|
||||
const E = globalThis;
|
||||
class y extends D {
|
||||
constructor() {
|
||||
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
|
||||
}
|
||||
|
|
@ -532,7 +532,7 @@ class $ extends D {
|
|||
}
|
||||
update(e) {
|
||||
const t = this.render();
|
||||
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = qe(t, this.renderRoot, this.renderOptions);
|
||||
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = We(t, this.renderRoot, this.renderOptions);
|
||||
}
|
||||
connectedCallback() {
|
||||
var e;
|
||||
|
|
@ -546,11 +546,11 @@ class $ extends D {
|
|||
return T;
|
||||
}
|
||||
}
|
||||
var be;
|
||||
$._$litElement$ = !0, $.finalized = !0, (be = C.litElementHydrateSupport) == null || be.call(C, { LitElement: $ });
|
||||
const te = C.litElementPolyfillSupport;
|
||||
te == null || te({ LitElement: $ });
|
||||
(C.litElementVersions ?? (C.litElementVersions = [])).push("4.2.2");
|
||||
var $e;
|
||||
y._$litElement$ = !0, y.finalized = !0, ($e = E.litElementHydrateSupport) == null || $e.call(E, { LitElement: y });
|
||||
const te = E.litElementPolyfillSupport;
|
||||
te == null || te({ LitElement: y });
|
||||
(E.litElementVersions ?? (E.litElementVersions = [])).push("4.2.2");
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
|
|
@ -566,31 +566,31 @@ const F = (s) => (e, t) => {
|
|||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const Be = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: ie }, We = (s = Be, e, t) => {
|
||||
const { kind: i, metadata: r } = t;
|
||||
let n = globalThis.litPropertyMetadata.get(r);
|
||||
if (n === void 0 && globalThis.litPropertyMetadata.set(r, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") {
|
||||
const { name: o } = t;
|
||||
const qe = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: oe }, Be = (s = qe, e, t) => {
|
||||
const { kind: i, metadata: o } = t;
|
||||
let n = globalThis.litPropertyMetadata.get(o);
|
||||
if (n === void 0 && globalThis.litPropertyMetadata.set(o, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") {
|
||||
const { name: r } = t;
|
||||
return { set(l) {
|
||||
const a = e.get.call(this);
|
||||
e.set.call(this, l), this.requestUpdate(o, a, s, !0, l);
|
||||
e.set.call(this, l), this.requestUpdate(r, a, s, !0, l);
|
||||
}, init(l) {
|
||||
return l !== void 0 && this.C(o, void 0, s, l), l;
|
||||
return l !== void 0 && this.C(r, void 0, s, l), l;
|
||||
} };
|
||||
}
|
||||
if (i === "setter") {
|
||||
const { name: o } = t;
|
||||
const { name: r } = t;
|
||||
return function(l) {
|
||||
const a = this[o];
|
||||
e.call(this, l), this.requestUpdate(o, a, s, !0, l);
|
||||
const a = this[r];
|
||||
e.call(this, l), this.requestUpdate(r, a, s, !0, l);
|
||||
};
|
||||
}
|
||||
throw Error("Unsupported decorator location: " + i);
|
||||
};
|
||||
function f(s) {
|
||||
return (e, t) => typeof t == "object" ? We(s, e, t) : ((i, r, n) => {
|
||||
const o = r.hasOwnProperty(n);
|
||||
return r.constructor.createProperty(n, i), o ? Object.getOwnPropertyDescriptor(r, n) : void 0;
|
||||
return (e, t) => typeof t == "object" ? Be(s, e, t) : ((i, o, n) => {
|
||||
const r = o.hasOwnProperty(n);
|
||||
return o.constructor.createProperty(n, i), r ? Object.getOwnPropertyDescriptor(o, n) : void 0;
|
||||
})(s, e, t);
|
||||
}
|
||||
/**
|
||||
|
|
@ -601,13 +601,13 @@ function f(s) {
|
|||
function u(s) {
|
||||
return f({ ...s, state: !0, attribute: !1 });
|
||||
}
|
||||
function xe(s, e) {
|
||||
function ne(s, e) {
|
||||
const t = new WebSocket(s);
|
||||
return t.onmessage = (i) => {
|
||||
var r, n, o, l;
|
||||
var o, n, r, l;
|
||||
try {
|
||||
const a = JSON.parse(i.data);
|
||||
((n = (r = a.type) == null ? void 0 : r.startsWith) != null && n.call(r, "process.") || (l = (o = a.channel) == null ? void 0 : o.startsWith) != null && l.call(o, "process.")) && e(a);
|
||||
((n = (o = a.type) == null ? void 0 : o.startsWith) != null && n.call(o, "process.") || (l = (r = a.channel) == null ? void 0 : r.startsWith) != null && l.call(r, "process.")) && e(a);
|
||||
} catch {
|
||||
}
|
||||
}, t;
|
||||
|
|
@ -621,10 +621,10 @@ class Fe {
|
|||
}
|
||||
async request(e, t) {
|
||||
var n;
|
||||
const r = await (await fetch(`${this.base}${e}`, t)).json();
|
||||
if (!r.success)
|
||||
throw new Error(((n = r.error) == null ? void 0 : n.message) ?? "Request failed");
|
||||
return r.data;
|
||||
const o = await (await fetch(`${this.base}${e}`, t)).json();
|
||||
if (!o.success)
|
||||
throw new Error(((n = o.error) == null ? void 0 : n.message) ?? "Request failed");
|
||||
return o.data;
|
||||
}
|
||||
/** List all alive daemons from the registry. */
|
||||
listDaemons() {
|
||||
|
|
@ -645,12 +645,12 @@ class Fe {
|
|||
return this.request(`/daemons/${e}/${t}/health`);
|
||||
}
|
||||
}
|
||||
var Ke = Object.defineProperty, Ve = Object.getOwnPropertyDescriptor, k = (s, e, t, i) => {
|
||||
for (var r = i > 1 ? void 0 : i ? Ve(e, t) : e, n = s.length - 1, o; n >= 0; n--)
|
||||
(o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r);
|
||||
return i && r && Ke(e, t, r), r;
|
||||
var Ve = Object.defineProperty, Ke = Object.getOwnPropertyDescriptor, k = (s, e, t, i) => {
|
||||
for (var o = i > 1 ? void 0 : i ? Ke(e, t) : e, n = s.length - 1, r; n >= 0; n--)
|
||||
(r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o);
|
||||
return i && o && Ve(e, t, o), o;
|
||||
};
|
||||
let g = class extends $ {
|
||||
let g = class extends y {
|
||||
constructor() {
|
||||
super(...arguments), this.apiUrl = "", this.daemons = [], this.loading = !0, this.error = "", this.stopping = /* @__PURE__ */ new Set(), this.checking = /* @__PURE__ */ new Set(), this.healthResults = /* @__PURE__ */ new Map();
|
||||
}
|
||||
|
|
@ -770,7 +770,7 @@ let g = class extends $ {
|
|||
`;
|
||||
}
|
||||
};
|
||||
g.styles = B`
|
||||
g.styles = q`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
|
@ -947,20 +947,30 @@ k([
|
|||
g = k([
|
||||
F("core-process-daemons")
|
||||
], g);
|
||||
var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, U = (s, e, t, i) => {
|
||||
for (var r = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, o; n >= 0; n--)
|
||||
(o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r);
|
||||
return i && r && Je(e, t, r), r;
|
||||
var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => {
|
||||
for (var o = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, r; n >= 0; n--)
|
||||
(r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o);
|
||||
return i && o && Je(e, t, o), o;
|
||||
};
|
||||
let y = class extends $ {
|
||||
let b = class extends y {
|
||||
constructor() {
|
||||
super(...arguments), this.apiUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.killing = /* @__PURE__ */ new Set();
|
||||
super(...arguments), this.apiUrl = "", this.wsUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.connected = !1, this.ws = null;
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback(), this.loadProcesses();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback(), this.disconnect();
|
||||
}
|
||||
updated(s) {
|
||||
s.has("wsUrl") && (this.disconnect(), this.processes = [], this.loadProcesses());
|
||||
}
|
||||
async loadProcesses() {
|
||||
this.loading = !1, this.processes = [];
|
||||
if (this.error = "", this.loading = !1, !this.wsUrl) {
|
||||
this.processes = [];
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
handleSelect(s) {
|
||||
this.dispatchEvent(
|
||||
|
|
@ -981,13 +991,60 @@ let y = class extends $ {
|
|||
return "unknown";
|
||||
}
|
||||
}
|
||||
connect() {
|
||||
this.ws = ne(this.wsUrl, (s) => {
|
||||
this.applyEvent(s);
|
||||
}), this.ws.onopen = () => {
|
||||
this.connected = !0;
|
||||
}, this.ws.onclose = () => {
|
||||
this.connected = !1;
|
||||
};
|
||||
}
|
||||
disconnect() {
|
||||
this.ws && (this.ws.close(), this.ws = null), this.connected = !1;
|
||||
}
|
||||
applyEvent(s) {
|
||||
const e = s.channel ?? s.type ?? "", t = s.data ?? {};
|
||||
if (!t.id)
|
||||
return;
|
||||
const i = new Map(this.processes.map((n) => [n.id, n])), o = i.get(t.id);
|
||||
if (e === "process.started") {
|
||||
i.set(t.id, this.normalizeProcess(t, o, "running")), this.processes = this.sortProcesses(i);
|
||||
return;
|
||||
}
|
||||
if (e === "process.exited") {
|
||||
i.set(t.id, this.normalizeProcess(t, o, "exited")), this.processes = this.sortProcesses(i);
|
||||
return;
|
||||
}
|
||||
if (e === "process.killed") {
|
||||
i.set(t.id, this.normalizeProcess(t, o, "killed")), this.processes = this.sortProcesses(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
normalizeProcess(s, e, t) {
|
||||
return {
|
||||
id: s.id,
|
||||
command: s.command ?? (e == null ? void 0 : e.command) ?? "",
|
||||
args: s.args ?? (e == null ? void 0 : e.args) ?? [],
|
||||
dir: s.dir ?? (e == null ? void 0 : e.dir) ?? "",
|
||||
startedAt: s.startedAt ?? (e == null ? void 0 : e.startedAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
status: t,
|
||||
exitCode: s.exitCode ?? (e == null ? void 0 : e.exitCode) ?? (t === "killed" ? -1 : 0),
|
||||
duration: s.duration ?? (e == null ? void 0 : e.duration) ?? 0,
|
||||
pid: s.pid ?? (e == null ? void 0 : e.pid) ?? 0
|
||||
};
|
||||
}
|
||||
sortProcesses(s) {
|
||||
return [...s.values()].sort(
|
||||
(e, t) => new Date(t.startedAt).getTime() - new Date(e.startedAt).getTime()
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return this.loading ? c`<div class="loading">Loading processes\u2026</div>` : c`
|
||||
${this.error ? c`<div class="error">${this.error}</div>` : d}
|
||||
${this.processes.length === 0 ? c`
|
||||
<div class="info-notice">
|
||||
Process list endpoints are pending. Processes will appear here once
|
||||
the REST API for managed processes is available.
|
||||
${this.wsUrl ? this.connected ? "Waiting for process events from the WebSocket feed." : "Connecting to the process event stream..." : "Set a WebSocket URL to receive live process events."}
|
||||
</div>
|
||||
<div class="empty">No managed processes.</div>
|
||||
` : c`
|
||||
|
|
@ -1019,12 +1076,12 @@ let y = class extends $ {
|
|||
<div class="item-actions">
|
||||
<button
|
||||
class="kill-btn"
|
||||
?disabled=${this.killing.has(s.id)}
|
||||
disabled
|
||||
@click=${(t) => {
|
||||
t.stopPropagation();
|
||||
}}
|
||||
>
|
||||
${this.killing.has(s.id) ? "Killing…" : "Kill"}
|
||||
Live only
|
||||
</button>
|
||||
</div>
|
||||
` : d}
|
||||
|
|
@ -1037,7 +1094,7 @@ let y = class extends $ {
|
|||
`;
|
||||
}
|
||||
};
|
||||
y.styles = B`
|
||||
b.styles = q`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
|
@ -1201,33 +1258,36 @@ y.styles = B`
|
|||
margin-bottom: 1rem;
|
||||
}
|
||||
`;
|
||||
U([
|
||||
S([
|
||||
f({ attribute: "api-url" })
|
||||
], y.prototype, "apiUrl", 2);
|
||||
U([
|
||||
], b.prototype, "apiUrl", 2);
|
||||
S([
|
||||
f({ attribute: "ws-url" })
|
||||
], b.prototype, "wsUrl", 2);
|
||||
S([
|
||||
f({ attribute: "selected-id" })
|
||||
], y.prototype, "selectedId", 2);
|
||||
U([
|
||||
], b.prototype, "selectedId", 2);
|
||||
S([
|
||||
u()
|
||||
], y.prototype, "processes", 2);
|
||||
U([
|
||||
], b.prototype, "processes", 2);
|
||||
S([
|
||||
u()
|
||||
], y.prototype, "loading", 2);
|
||||
U([
|
||||
], b.prototype, "loading", 2);
|
||||
S([
|
||||
u()
|
||||
], y.prototype, "error", 2);
|
||||
U([
|
||||
], b.prototype, "error", 2);
|
||||
S([
|
||||
u()
|
||||
], y.prototype, "killing", 2);
|
||||
y = U([
|
||||
], b.prototype, "connected", 2);
|
||||
b = S([
|
||||
F("core-process-list")
|
||||
], y);
|
||||
], b);
|
||||
var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, O = (s, e, t, i) => {
|
||||
for (var r = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, o; n >= 0; n--)
|
||||
(o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r);
|
||||
return i && r && Ge(e, t, r), r;
|
||||
for (var o = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, r; n >= 0; n--)
|
||||
(r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o);
|
||||
return i && o && Ge(e, t, o), o;
|
||||
};
|
||||
let v = class extends $ {
|
||||
let v = class extends y {
|
||||
constructor() {
|
||||
super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.ws = null;
|
||||
}
|
||||
|
|
@ -1241,7 +1301,7 @@ let v = class extends $ {
|
|||
(s.has("processId") || s.has("wsUrl")) && (this.disconnect(), this.lines = [], this.wsUrl && this.processId && this.connect()), this.autoScroll && this.scrollToBottom();
|
||||
}
|
||||
connect() {
|
||||
this.ws = xe(this.wsUrl, (s) => {
|
||||
this.ws = ne(this.wsUrl, (s) => {
|
||||
const e = s.data;
|
||||
if (!e) return;
|
||||
(s.channel ?? s.type ?? "") === "process.output" && e.id === this.processId && (this.lines = [
|
||||
|
|
@ -1300,7 +1360,7 @@ let v = class extends $ {
|
|||
` : c`<div class="empty">Select a process to view its output.</div>`;
|
||||
}
|
||||
};
|
||||
v.styles = B`
|
||||
v.styles = q`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
|
@ -1426,11 +1486,11 @@ v = O([
|
|||
F("core-process-output")
|
||||
], v);
|
||||
var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, Q = (s, e, t, i) => {
|
||||
for (var r = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, o; n >= 0; n--)
|
||||
(o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r);
|
||||
return i && r && Xe(e, t, r), r;
|
||||
for (var o = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, r; n >= 0; n--)
|
||||
(r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o);
|
||||
return i && o && Xe(e, t, o), o;
|
||||
};
|
||||
let R = class extends $ {
|
||||
let R = class extends y {
|
||||
constructor() {
|
||||
super(...arguments), this.apiUrl = "", this.result = null, this.expandedOutputs = /* @__PURE__ */ new Set();
|
||||
}
|
||||
|
|
@ -1459,7 +1519,7 @@ let R = class extends $ {
|
|||
</div>
|
||||
<div class="empty">No pipeline results.</div>
|
||||
`;
|
||||
const { results: s, duration: e, passed: t, failed: i, skipped: r, success: n } = this.result;
|
||||
const { results: s, duration: e, passed: t, failed: i, skipped: o, success: n } = this.result;
|
||||
return c`
|
||||
<div class="summary">
|
||||
<span class="overall-badge ${n ? "success" : "failure"}">
|
||||
|
|
@ -1474,7 +1534,7 @@ let R = class extends $ {
|
|||
<span class="summary-label">Failed</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-value skipped">${r}</span>
|
||||
<span class="summary-value skipped">${o}</span>
|
||||
<span class="summary-label">Skipped</span>
|
||||
</div>
|
||||
<span class="summary-duration">${this.formatDuration(e)}</span>
|
||||
|
|
@ -1482,24 +1542,24 @@ let R = class extends $ {
|
|||
|
||||
<div class="list">
|
||||
${s.map(
|
||||
(o) => c`
|
||||
(r) => c`
|
||||
<div class="spec">
|
||||
<div class="spec-header">
|
||||
<div class="spec-name">
|
||||
<span>${o.name}</span>
|
||||
<span class="result-badge ${this.resultStatus(o)}">${this.resultStatus(o)}</span>
|
||||
<span>${r.name}</span>
|
||||
<span class="result-badge ${this.resultStatus(r)}">${this.resultStatus(r)}</span>
|
||||
</div>
|
||||
<span class="duration">${this.formatDuration(o.duration)}</span>
|
||||
<span class="duration">${this.formatDuration(r.duration)}</span>
|
||||
</div>
|
||||
<div class="spec-meta">
|
||||
${o.exitCode !== 0 && !o.skipped ? c`<span>exit ${o.exitCode}</span>` : d}
|
||||
${r.exitCode !== 0 && !r.skipped ? c`<span>exit ${r.exitCode}</span>` : d}
|
||||
</div>
|
||||
${o.error ? c`<div class="spec-error">${o.error}</div>` : d}
|
||||
${o.output ? c`
|
||||
<button class="toggle-output" @click=${() => this.toggleOutput(o.name)}>
|
||||
${this.expandedOutputs.has(o.name) ? "Hide output" : "Show output"}
|
||||
${r.error ? c`<div class="spec-error">${r.error}</div>` : d}
|
||||
${r.output ? c`
|
||||
<button class="toggle-output" @click=${() => this.toggleOutput(r.name)}>
|
||||
${this.expandedOutputs.has(r.name) ? "Hide output" : "Show output"}
|
||||
</button>
|
||||
${this.expandedOutputs.has(o.name) ? c`<div class="spec-output">${o.output}</div>` : d}
|
||||
${this.expandedOutputs.has(r.name) ? c`<div class="spec-output">${r.output}</div>` : d}
|
||||
` : d}
|
||||
</div>
|
||||
`
|
||||
|
|
@ -1508,7 +1568,7 @@ let R = class extends $ {
|
|||
`;
|
||||
}
|
||||
};
|
||||
R.styles = B`
|
||||
R.styles = q`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
|
@ -1716,11 +1776,11 @@ R = Q([
|
|||
F("core-process-runner")
|
||||
], R);
|
||||
var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => {
|
||||
for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--)
|
||||
(o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r);
|
||||
return i && r && et(e, t, r), r;
|
||||
for (var o = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, r; n >= 0; n--)
|
||||
(r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o);
|
||||
return i && o && et(e, t, o), o;
|
||||
};
|
||||
let _ = class extends $ {
|
||||
let w = class extends y {
|
||||
constructor() {
|
||||
super(...arguments), this.apiUrl = "", this.wsUrl = "", this.activeTab = "daemons", this.wsConnected = !1, this.lastEvent = "", this.selectedProcessId = "", this.ws = null, this.tabs = [
|
||||
{ id: "daemons", label: "Daemons" },
|
||||
|
|
@ -1735,7 +1795,7 @@ let _ = class extends $ {
|
|||
super.disconnectedCallback(), this.ws && (this.ws.close(), this.ws = null);
|
||||
}
|
||||
connectWs() {
|
||||
this.ws = xe(this.wsUrl, (s) => {
|
||||
this.ws = ne(this.wsUrl, (s) => {
|
||||
this.lastEvent = s.channel ?? s.type ?? "", this.requestUpdate();
|
||||
}), this.ws.onopen = () => {
|
||||
this.wsConnected = !0;
|
||||
|
|
@ -1765,6 +1825,7 @@ let _ = class extends $ {
|
|||
return c`
|
||||
<core-process-list
|
||||
api-url=${this.apiUrl}
|
||||
ws-url=${this.wsUrl}
|
||||
@process-selected=${this.handleProcessSelected}
|
||||
></core-process-list>
|
||||
${this.selectedProcessId ? c`<core-process-output
|
||||
|
|
@ -1812,7 +1873,7 @@ let _ = class extends $ {
|
|||
`;
|
||||
}
|
||||
};
|
||||
_.styles = B`
|
||||
w.styles = q`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1928,31 +1989,31 @@ _.styles = B`
|
|||
`;
|
||||
z([
|
||||
f({ attribute: "api-url" })
|
||||
], _.prototype, "apiUrl", 2);
|
||||
], w.prototype, "apiUrl", 2);
|
||||
z([
|
||||
f({ attribute: "ws-url" })
|
||||
], _.prototype, "wsUrl", 2);
|
||||
], w.prototype, "wsUrl", 2);
|
||||
z([
|
||||
u()
|
||||
], _.prototype, "activeTab", 2);
|
||||
], w.prototype, "activeTab", 2);
|
||||
z([
|
||||
u()
|
||||
], _.prototype, "wsConnected", 2);
|
||||
], w.prototype, "wsConnected", 2);
|
||||
z([
|
||||
u()
|
||||
], _.prototype, "lastEvent", 2);
|
||||
], w.prototype, "lastEvent", 2);
|
||||
z([
|
||||
u()
|
||||
], _.prototype, "selectedProcessId", 2);
|
||||
_ = z([
|
||||
], w.prototype, "selectedProcessId", 2);
|
||||
w = z([
|
||||
F("core-process-panel")
|
||||
], _);
|
||||
], w);
|
||||
export {
|
||||
Fe as ProcessApi,
|
||||
g as ProcessDaemons,
|
||||
y as ProcessList,
|
||||
b as ProcessList,
|
||||
v as ProcessOutput,
|
||||
_ as ProcessPanel,
|
||||
w as ProcessPanel,
|
||||
R as ProcessRunner,
|
||||
xe as connectProcessEvents
|
||||
ne as connectProcessEvents
|
||||
};
|
||||
|
|
|
|||
146
process.go
146
process.go
|
|
@ -2,17 +2,23 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Process represents a managed external process.
|
||||
type Process struct {
|
||||
type processStdin interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ManagedProcess represents a tracked external process started by the service.
|
||||
type ManagedProcess struct {
|
||||
ID string
|
||||
PID int
|
||||
Command string
|
||||
Args []string
|
||||
Dir string
|
||||
|
|
@ -22,40 +28,43 @@ type Process struct {
|
|||
ExitCode int
|
||||
Duration time.Duration
|
||||
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
output *RingBuffer
|
||||
stdin io.WriteCloser
|
||||
done chan struct{}
|
||||
mu sync.RWMutex
|
||||
cmd *execCmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
output *RingBuffer
|
||||
stdin processStdin
|
||||
done chan struct{}
|
||||
mu sync.RWMutex
|
||||
gracePeriod time.Duration
|
||||
killGroup bool
|
||||
lastSignal string
|
||||
killEmitted bool
|
||||
}
|
||||
|
||||
// Process is kept as a compatibility alias for ManagedProcess.
|
||||
type Process = ManagedProcess
|
||||
|
||||
// Info returns a snapshot of process state.
|
||||
func (p *Process) Info() Info {
|
||||
func (p *ManagedProcess) Info() ProcessInfo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
pid := 0
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
pid = p.cmd.Process.Pid
|
||||
}
|
||||
|
||||
return Info{
|
||||
return ProcessInfo{
|
||||
ID: p.ID,
|
||||
Command: p.Command,
|
||||
Args: p.Args,
|
||||
Args: append([]string(nil), p.Args...),
|
||||
Dir: p.Dir,
|
||||
StartedAt: p.StartedAt,
|
||||
Running: p.Status == StatusRunning,
|
||||
Status: p.Status,
|
||||
ExitCode: p.ExitCode,
|
||||
Duration: p.Duration,
|
||||
PID: pid,
|
||||
PID: p.PID,
|
||||
}
|
||||
}
|
||||
|
||||
// Output returns the captured output as a string.
|
||||
func (p *Process) Output() string {
|
||||
func (p *ManagedProcess) Output() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -65,7 +74,7 @@ func (p *Process) Output() string {
|
|||
}
|
||||
|
||||
// OutputBytes returns the captured output as bytes.
|
||||
func (p *Process) OutputBytes() []byte {
|
||||
func (p *ManagedProcess) OutputBytes() []byte {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -75,36 +84,40 @@ func (p *Process) OutputBytes() []byte {
|
|||
}
|
||||
|
||||
// IsRunning returns true if the process is still executing.
|
||||
func (p *Process) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.Status == StatusRunning
|
||||
func (p *ManagedProcess) IsRunning() bool {
|
||||
select {
|
||||
case <-p.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the process exits.
|
||||
func (p *Process) Wait() error {
|
||||
func (p *ManagedProcess) Wait() error {
|
||||
<-p.done
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.Status == StatusFailed {
|
||||
return fmt.Errorf("process failed to start: %s", p.ID)
|
||||
return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil)
|
||||
}
|
||||
if p.Status == StatusKilled {
|
||||
return fmt.Errorf("process was killed: %s", p.ID)
|
||||
return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil)
|
||||
}
|
||||
if p.ExitCode != 0 {
|
||||
return fmt.Errorf("process exited with code %d", p.ExitCode)
|
||||
return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done returns a channel that closes when the process exits.
|
||||
func (p *Process) Done() <-chan struct{} {
|
||||
func (p *ManagedProcess) Done() <-chan struct{} {
|
||||
return p.done
|
||||
}
|
||||
|
||||
// Kill forcefully terminates the process.
|
||||
func (p *Process) Kill() error {
|
||||
// If KillGroup is set, kills the entire process group.
|
||||
func (p *ManagedProcess) Kill() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
@ -116,27 +129,63 @@ func (p *Process) Kill() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
p.lastSignal = "SIGKILL"
|
||||
if p.killGroup {
|
||||
// Kill entire process group (negative PID)
|
||||
return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return p.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
// Signal sends a signal to the process.
|
||||
func (p *Process) Signal(sig os.Signal) error {
|
||||
// Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period.
|
||||
// If GracePeriod was not set (zero), falls back to immediate Kill().
|
||||
// If KillGroup is set, signals are sent to the entire process group.
|
||||
func (p *ManagedProcess) Shutdown() error {
|
||||
p.mu.RLock()
|
||||
grace := p.gracePeriod
|
||||
p.mu.RUnlock()
|
||||
|
||||
if grace <= 0 {
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// Send SIGTERM
|
||||
if err := p.terminate(); err != nil {
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// Wait for exit or grace period
|
||||
select {
|
||||
case <-p.done:
|
||||
return nil
|
||||
case <-time.After(grace):
|
||||
return p.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// terminate sends SIGTERM to the process (or process group if KillGroup is set).
|
||||
func (p *ManagedProcess) terminate() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.Status != StatusRunning {
|
||||
return ErrProcessNotRunning
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.cmd.Process.Signal(sig)
|
||||
pid := p.cmd.Process.Pid
|
||||
if p.killGroup {
|
||||
pid = -pid
|
||||
}
|
||||
p.lastSignal = "SIGTERM"
|
||||
return syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
// SendInput writes to the process stdin.
|
||||
func (p *Process) SendInput(input string) error {
|
||||
func (p *ManagedProcess) SendInput(input string) error {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
|
|
@ -153,7 +202,7 @@ func (p *Process) SendInput(input string) error {
|
|||
}
|
||||
|
||||
// CloseStdin closes the process stdin pipe.
|
||||
func (p *Process) CloseStdin() error {
|
||||
func (p *ManagedProcess) CloseStdin() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
@ -165,3 +214,20 @@ func (p *Process) CloseStdin() error {
|
|||
p.stdin = nil
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Global default service (follows i18n pattern).
|
||||
var (
|
||||
defaultService atomic.Pointer[Service]
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Default returns the global process service.
|
||||
// Returns nil if not initialized.
|
||||
func Default() *Service {
|
||||
return defaultService.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the global process service.
|
||||
// Thread-safe: can be called concurrently with Default().
|
||||
func SetDefault(s *Service) error {
|
||||
if s == nil {
|
||||
return &ServiceError{msg: "process: SetDefault called with nil service"}
|
||||
}
|
||||
defaultService.Store(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init initializes the default global service with a Core instance.
|
||||
// This is typically called during application startup.
|
||||
func Init(c *core.Core) error {
|
||||
defaultOnce.Do(func() {
|
||||
factory := NewService(Options{})
|
||||
svc, err := factory(c)
|
||||
if err != nil {
|
||||
defaultErr = err
|
||||
return
|
||||
}
|
||||
defaultService.Store(svc.(*Service))
|
||||
})
|
||||
return defaultErr
|
||||
}
|
||||
|
||||
// --- Global convenience functions ---
|
||||
|
||||
// Start spawns a new process using the default service.
|
||||
func Start(ctx context.Context, command string, args ...string) (*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.
|
||||
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.
|
||||
func Get(id string) (*Process, error) {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil, ErrServiceNotInitialized
|
||||
}
|
||||
return svc.Get(id)
|
||||
}
|
||||
|
||||
// List returns all processes from the default service.
|
||||
func List() []*Process {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.List()
|
||||
}
|
||||
|
||||
// Kill terminates a process by ID using the default service.
|
||||
func Kill(id string) error {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return ErrServiceNotInitialized
|
||||
}
|
||||
return svc.Kill(id)
|
||||
}
|
||||
|
||||
// StartWithOptions spawns a process with full configuration using the default service.
|
||||
func StartWithOptions(ctx context.Context, opts RunOptions) (*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.
|
||||
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.
|
||||
func Running() []*Process {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.Running()
|
||||
}
|
||||
|
||||
// ErrServiceNotInitialized is returned when the service is not initialized.
|
||||
var ErrServiceNotInitialized = &ServiceError{msg: "process: service not initialized; call process.Init(core) first"}
|
||||
|
||||
// ServiceError represents a service-level error.
|
||||
type ServiceError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
// Error returns the service error message.
|
||||
func (e *ServiceError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
282
process_test.go
282
process_test.go
|
|
@ -2,6 +2,7 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -9,11 +10,10 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcess_Info(t *testing.T) {
|
||||
func TestProcess_Info_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "hello")
|
||||
require.NoError(t, err)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "hello")
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
|
|
@ -26,183 +26,175 @@ func TestProcess_Info(t *testing.T) {
|
|||
assert.Greater(t, info.Duration, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestProcess_Output(t *testing.T) {
|
||||
func TestProcess_Info_Pending_Good(t *testing.T) {
|
||||
proc := &ManagedProcess{
|
||||
ID: "pending",
|
||||
Status: StatusPending,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
info := proc.Info()
|
||||
assert.Equal(t, StatusPending, info.Status)
|
||||
assert.False(t, info.Running)
|
||||
}
|
||||
|
||||
func TestProcess_Output_Good(t *testing.T) {
|
||||
t.Run("captures stdout", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "hello world")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "hello world")
|
||||
<-proc.Done()
|
||||
|
||||
output := proc.Output()
|
||||
assert.Contains(t, output, "hello world")
|
||||
assert.Contains(t, proc.Output(), "hello world")
|
||||
})
|
||||
|
||||
t.Run("OutputBytes returns copy", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
<-proc.Done()
|
||||
|
||||
bytes := proc.OutputBytes()
|
||||
assert.NotNil(t, bytes)
|
||||
assert.Contains(t, string(bytes), "test")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_IsRunning(t *testing.T) {
|
||||
func TestProcess_IsRunning_Good(t *testing.T) {
|
||||
t.Run("true while running", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc, err := svc.Start(ctx, "sleep", "10")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, ctx, "sleep", "10")
|
||||
assert.True(t, proc.IsRunning())
|
||||
|
||||
cancel()
|
||||
<-proc.Done()
|
||||
|
||||
assert.False(t, proc.IsRunning())
|
||||
})
|
||||
|
||||
t.Run("false after completion", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
||||
<-proc.Done()
|
||||
|
||||
assert.False(t, proc.IsRunning())
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_Wait(t *testing.T) {
|
||||
func TestProcess_Wait_Good(t *testing.T) {
|
||||
t.Run("returns nil on success", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = proc.Wait()
|
||||
proc := startProc(t, svc, context.Background(), "echo", "ok")
|
||||
err := proc.Wait()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("returns error on failure", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = proc.Wait()
|
||||
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1")
|
||||
err := proc.Wait()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_Done(t *testing.T) {
|
||||
func TestProcess_Done_Good(t *testing.T) {
|
||||
t.Run("channel closes on completion", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "test")
|
||||
require.NoError(t, err)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
// Success - channel closed
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Done channel should have closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_Kill(t *testing.T) {
|
||||
func TestProcess_Kill_Good(t *testing.T) {
|
||||
t.Run("terminates running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc, err := svc.Start(ctx, "sleep", "60")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, ctx, "sleep", "60")
|
||||
assert.True(t, proc.IsRunning())
|
||||
|
||||
err = proc.Kill()
|
||||
err := proc.Kill()
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
// Good - process terminated
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
assert.Equal(t, -1, proc.ExitCode)
|
||||
})
|
||||
|
||||
t.Run("noop on completed process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
||||
<-proc.Done()
|
||||
|
||||
err = proc.Kill()
|
||||
err := proc.Kill()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_SendInput(t *testing.T) {
|
||||
func TestProcess_SendInput_Good(t *testing.T) {
|
||||
t.Run("writes to stdin", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "cat")
|
||||
|
||||
// Use cat to echo back stdin
|
||||
proc, err := svc.Start(context.Background(), "cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = proc.SendInput("hello\n")
|
||||
err := proc.SendInput("hello\n")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = proc.CloseStdin()
|
||||
assert.NoError(t, err)
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
assert.Contains(t, proc.Output(), "hello")
|
||||
})
|
||||
|
||||
t.Run("error on completed process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
||||
<-proc.Done()
|
||||
|
||||
err = proc.SendInput("test")
|
||||
err := proc.SendInput("test")
|
||||
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_CloseStdin(t *testing.T) {
|
||||
t.Run("closes stdin pipe", func(t *testing.T) {
|
||||
func TestProcess_Signal_Good(t *testing.T) {
|
||||
t.Run("sends signal to running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc, err := svc.Start(context.Background(), "cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = proc.CloseStdin()
|
||||
proc := startProc(t, svc, ctx, "sleep", "60")
|
||||
err := proc.Signal(os.Interrupt)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been terminated by signal")
|
||||
}
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
|
||||
t.Run("error on completed process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
||||
<-proc.Done()
|
||||
err := proc.Signal(os.Interrupt)
|
||||
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_CloseStdin_Good(t *testing.T) {
|
||||
t.Run("closes stdin pipe", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "cat")
|
||||
err := proc.CloseStdin()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Process should exit now that stdin is closed
|
||||
select {
|
||||
case <-proc.Done():
|
||||
// Good
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cat should exit when stdin is closed")
|
||||
}
|
||||
|
|
@ -210,18 +202,132 @@ func TestProcess_CloseStdin(t *testing.T) {
|
|||
|
||||
t.Run("double close is safe", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
// First close
|
||||
err = proc.CloseStdin()
|
||||
proc := startProc(t, svc, context.Background(), "cat")
|
||||
err := proc.CloseStdin()
|
||||
assert.NoError(t, err)
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
// Second close should be safe (stdin already nil)
|
||||
err = proc.CloseStdin()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_Timeout_Good(t *testing.T) {
|
||||
t.Run("kills process after timeout", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sleep",
|
||||
Args: []string{"60"},
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("process should have been killed by timeout")
|
||||
}
|
||||
assert.False(t, proc.IsRunning())
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
|
||||
t.Run("no timeout when zero", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"fast"},
|
||||
Timeout: 0,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
<-proc.Done()
|
||||
assert.Equal(t, 0, proc.ExitCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_Shutdown_Good(t *testing.T) {
|
||||
t.Run("graceful with grace period", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sleep",
|
||||
Args: []string{"60"},
|
||||
GracePeriod: 100 * time.Millisecond,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
assert.True(t, proc.IsRunning())
|
||||
err := proc.Shutdown()
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown should have completed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("immediate kill without grace period", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sleep",
|
||||
Args: []string{"60"},
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
err := proc.Shutdown()
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("kill should be immediate")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_KillGroup_Good(t *testing.T) {
|
||||
t.Run("kills child processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "sleep 60 & wait"},
|
||||
Detach: true,
|
||||
KillGroup: true,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
|
||||
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sleep",
|
||||
Args: []string{"60"},
|
||||
Timeout: 200 * time.Millisecond,
|
||||
GracePeriod: 100 * time.Millisecond,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("process should have been killed by timeout")
|
||||
}
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
84
program.go
Normal file
84
program.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
|
||||
// Callers may use core.Is to detect this condition.
|
||||
var ErrProgramNotFound = core.E("", "program: binary not found in PATH", nil)
|
||||
|
||||
// Program represents a named executable located on the system PATH.
|
||||
// Create one with a Name, call Find to resolve its path, then Run or RunDir.
|
||||
//
|
||||
// p := &process.Program{Name: "go"}
|
||||
type Program struct {
|
||||
// Name is the binary name (e.g. "go", "node", "git").
|
||||
Name string
|
||||
// Path is the absolute path resolved by Find.
|
||||
// If empty, Run and RunDir fall back to Name for OS PATH resolution.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Find resolves the program's absolute path using exec.LookPath.
|
||||
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
|
||||
//
|
||||
// err := p.Find()
|
||||
func (p *Program) Find() error {
|
||||
if p.Name == "" {
|
||||
return core.E("program.find", "program name is empty", nil)
|
||||
}
|
||||
path, err := execLookPath(p.Name)
|
||||
if err != nil {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes the program with args in the current working directory.
|
||||
// Returns trimmed combined stdout+stderr output and any error.
|
||||
//
|
||||
// out, err := p.Run(ctx, "version")
|
||||
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
||||
return p.RunDir(ctx, "", args...)
|
||||
}
|
||||
|
||||
// RunDir executes the program with args in dir.
|
||||
// Returns trimmed combined stdout+stderr output and any error.
|
||||
// If dir is empty, the process inherits the caller's working directory.
|
||||
//
|
||||
// out, err := p.RunDir(ctx, "/workspace", "test", "./...")
|
||||
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
binary := p.Path
|
||||
if binary == "" {
|
||||
binary = p.Name
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := execCommandContext(ctx, binary, args...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return string(bytes.TrimSpace(out.Bytes())), core.E("program.run", core.Concat(strconv.Quote(p.Name), ": command failed"), err)
|
||||
}
|
||||
return string(bytes.TrimSpace(out.Bytes())), nil
|
||||
}
|
||||
90
program_test.go
Normal file
90
program_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package process_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
process "dappco.re/go/core/process"
|
||||
)
|
||||
|
||||
func testCtx(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestProgram_Find_Good(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
assert.NotEmpty(t, p.Path)
|
||||
assert.True(t, filepath.IsAbs(p.Path))
|
||||
}
|
||||
|
||||
func TestProgram_FindUnknown_Bad(t *testing.T) {
|
||||
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
|
||||
err := p.Find()
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, process.ErrProgramNotFound)
|
||||
}
|
||||
|
||||
func TestProgram_FindEmpty_Bad(t *testing.T) {
|
||||
p := &process.Program{}
|
||||
require.Error(t, p.Find())
|
||||
}
|
||||
|
||||
func TestProgram_Run_Good(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
out, err := p.Run(testCtx(t), "hello")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", out)
|
||||
}
|
||||
|
||||
func TestProgram_RunFallback_Good(t *testing.T) {
|
||||
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
|
||||
p := &process.Program{Name: "echo"}
|
||||
|
||||
out, err := p.Run(testCtx(t), "fallback")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "fallback", out)
|
||||
}
|
||||
|
||||
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"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
out, err := p.RunDir(testCtx(t), dir)
|
||||
require.NoError(t, err)
|
||||
dirInfo, err := os.Stat(dir)
|
||||
require.NoError(t, err)
|
||||
outInfo, err := os.Stat(core.Trim(out))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, os.SameFile(dirInfo, outInfo))
|
||||
}
|
||||
|
||||
func TestProgram_RunFailure_Bad(t *testing.T) {
|
||||
p := &process.Program{Name: "false"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
_, err := p.Run(testCtx(t))
|
||||
require.Error(t, err)
|
||||
}
|
||||
321
registry.go
321
registry.go
|
|
@ -1,17 +1,18 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"path"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
// DaemonEntry records a running daemon in the registry.
|
||||
//
|
||||
// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234}
|
||||
type DaemonEntry struct {
|
||||
Code string `json:"code"`
|
||||
Daemon string `json:"daemon"`
|
||||
|
|
@ -23,22 +24,28 @@ type DaemonEntry struct {
|
|||
}
|
||||
|
||||
// Registry tracks running daemons via JSON files in a directory.
|
||||
//
|
||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
||||
type Registry struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry backed by the given directory.
|
||||
//
|
||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
||||
func NewRegistry(dir string) *Registry {
|
||||
return &Registry{dir: dir}
|
||||
}
|
||||
|
||||
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
||||
//
|
||||
// reg := process.DefaultRegistry()
|
||||
func DefaultRegistry() *Registry {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := userHomeDir()
|
||||
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.
|
||||
|
|
@ -50,20 +57,26 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
}
|
||||
|
||||
if err := coreio.Local.EnsureDir(r.dir); err != nil {
|
||||
return err
|
||||
return core.E("registry.register", "failed to create registry directory", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
data, err := marshalDaemonEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
return core.E("registry.register", "failed to marshal entry", err)
|
||||
}
|
||||
|
||||
return coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), string(data))
|
||||
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil {
|
||||
return core.E("registry.register", "failed to write entry file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister removes a daemon entry from the registry.
|
||||
func (r *Registry) Unregister(code, daemon string) error {
|
||||
return coreio.Local.Delete(r.entryPath(code, daemon))
|
||||
if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil {
|
||||
return core.E("registry.unregister", "failed to delete entry file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get reads a single daemon entry and checks whether its process is alive.
|
||||
|
|
@ -76,8 +89,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
|||
return nil, false
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||
entry, err := unmarshalDaemonEntry(data)
|
||||
if err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
return nil, false
|
||||
}
|
||||
|
|
@ -92,20 +105,28 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
|||
|
||||
// List returns all alive daemon entries, pruning any with dead PIDs.
|
||||
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 {
|
||||
return nil, err
|
||||
return nil, core.E("registry.list", "failed to list registry directory", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||
entry, err := unmarshalDaemonEntry(data)
|
||||
if err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
continue
|
||||
}
|
||||
|
|
@ -123,8 +144,8 @@ func (r *Registry) List() ([]DaemonEntry, error) {
|
|||
|
||||
// entryPath returns the filesystem path for a daemon entry.
|
||||
func (r *Registry) entryPath(code, daemon string) string {
|
||||
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json"
|
||||
return filepath.Join(r.dir, name)
|
||||
name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
|
||||
return path.Join(r.dir, name)
|
||||
}
|
||||
|
||||
// isAlive checks whether a process with the given PID is running.
|
||||
|
|
@ -132,9 +153,263 @@ func isAlive(pid int) bool {
|
|||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
proc, err := processHandle(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ package process
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
func TestRegistry_Register_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ func TestRegistry_RegisterAndGet(t *testing.T) {
|
|||
assert.Equal(t, started, got.Started)
|
||||
}
|
||||
|
||||
func TestRegistry_Unregister(t *testing.T) {
|
||||
func TestRegistry_Unregister_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ func TestRegistry_Unregister(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// File should exist
|
||||
path := filepath.Join(dir, "myapp-server.json")
|
||||
path := core.JoinPath(dir, "myapp-server.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ func TestRegistry_Unregister(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_List(t *testing.T) {
|
||||
func TestRegistry_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ func TestRegistry_List(t *testing.T) {
|
|||
assert.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestRegistry_List_PrunesStale(t *testing.T) {
|
||||
func TestRegistry_PruneStale_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// File should exist before listing
|
||||
path := filepath.Join(dir, "dead-proc.json")
|
||||
path := core.JoinPath(dir, "dead-proc.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_Get_NotFound(t *testing.T) {
|
||||
func TestRegistry_GetMissing_Bad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -109,8 +109,8 @@ func TestRegistry_Get_NotFound(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistry_CreatesDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
|
||||
func TestRegistry_CreateDirectory_Good(t *testing.T) {
|
||||
dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons")
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
|
||||
|
|
@ -121,7 +121,7 @@ func TestRegistry_CreatesDirectory(t *testing.T) {
|
|||
assert.True(t, info.IsDir())
|
||||
}
|
||||
|
||||
func TestDefaultRegistry(t *testing.T) {
|
||||
func TestRegistry_Default_Good(t *testing.T) {
|
||||
reg := DefaultRegistry()
|
||||
assert.NotNil(t, reg)
|
||||
}
|
||||
|
|
|
|||
60
runner.go
60
runner.go
|
|
@ -2,9 +2,10 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Runner orchestrates multiple processes with dependencies.
|
||||
|
|
@ -12,6 +13,9 @@ type Runner struct {
|
|||
service *Service
|
||||
}
|
||||
|
||||
// ErrRunnerNoService is returned when a runner was created without a service.
|
||||
var ErrRunnerNoService = core.E("", "runner service is nil", nil)
|
||||
|
||||
// NewRunner creates a runner for the given service.
|
||||
func NewRunner(svc *Service) *Runner {
|
||||
return &Runner{service: svc}
|
||||
|
|
@ -67,20 +71,24 @@ func (r RunAllResult) Success() bool {
|
|||
|
||||
// RunAll executes specs respecting dependencies, parallelising where possible.
|
||||
func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||
if err := r.ensureService(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
// Build dependency graph
|
||||
specMap := make(map[string]RunSpec)
|
||||
for _, spec := range specs {
|
||||
indexMap := make(map[string]int)
|
||||
for i, spec := range specs {
|
||||
specMap[spec.Name] = spec
|
||||
indexMap[spec.Name] = i
|
||||
}
|
||||
|
||||
// Track completion
|
||||
completed := make(map[string]*RunResult)
|
||||
var completedMu sync.Mutex
|
||||
|
||||
results := make([]RunResult, 0, len(specs))
|
||||
var resultsMu sync.Mutex
|
||||
results := make([]RunResult, len(specs))
|
||||
|
||||
// Process specs in waves
|
||||
remaining := make(map[string]RunSpec)
|
||||
|
|
@ -98,14 +106,14 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
}
|
||||
|
||||
if len(ready) == 0 && len(remaining) > 0 {
|
||||
// Deadlock - circular dependency or missing specs
|
||||
for name := range remaining {
|
||||
results = append(results, RunResult{
|
||||
Name: name,
|
||||
Spec: remaining[name],
|
||||
Skipped: true,
|
||||
Error: errors.New("circular dependency or missing dependency"),
|
||||
})
|
||||
// Deadlock — circular dependency or missing specs. Mark as failed, not skipped.
|
||||
for name, spec := range remaining {
|
||||
results[indexMap[name]] = RunResult{
|
||||
Name: name,
|
||||
Spec: spec,
|
||||
ExitCode: 1,
|
||||
Error: core.E("runner.run_all", "circular dependency or missing dependency", nil),
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -136,7 +144,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
Name: spec.Name,
|
||||
Spec: spec,
|
||||
Skipped: true,
|
||||
Error: errors.New("skipped due to dependency failure"),
|
||||
Error: core.E("runner.run_all", "skipped due to dependency failure", nil),
|
||||
}
|
||||
} else {
|
||||
result = r.runSpec(ctx, spec)
|
||||
|
|
@ -146,9 +154,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
completed[spec.Name] = &result
|
||||
completedMu.Unlock()
|
||||
|
||||
resultsMu.Lock()
|
||||
results = append(results, result)
|
||||
resultsMu.Unlock()
|
||||
results[indexMap[spec.Name]] = result
|
||||
}(spec)
|
||||
}
|
||||
wg.Wait()
|
||||
|
|
@ -192,13 +198,17 @@ func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
|
|||
func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
||||
start := time.Now()
|
||||
|
||||
proc, err := r.service.StartWithOptions(ctx, RunOptions{
|
||||
sr := r.service.StartWithOptions(ctx, RunOptions{
|
||||
Command: spec.Command,
|
||||
Args: spec.Args,
|
||||
Dir: spec.Dir,
|
||||
Env: spec.Env,
|
||||
})
|
||||
if err != nil {
|
||||
if !sr.OK {
|
||||
err, _ := sr.Value.(error)
|
||||
if err == nil {
|
||||
err = core.E("runner.run_spec", core.Concat("failed to start: ", spec.Name), nil)
|
||||
}
|
||||
return RunResult{
|
||||
Name: spec.Name,
|
||||
Spec: spec,
|
||||
|
|
@ -207,6 +217,7 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
|||
}
|
||||
}
|
||||
|
||||
proc := sr.Value.(*Process)
|
||||
<-proc.Done()
|
||||
|
||||
return RunResult{
|
||||
|
|
@ -221,6 +232,9 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
|||
|
||||
// RunSequential executes specs one after another, stopping on first failure.
|
||||
func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||
if err := r.ensureService(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
results := make([]RunResult, 0, len(specs))
|
||||
|
||||
|
|
@ -261,6 +275,9 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes
|
|||
|
||||
// RunParallel executes all specs concurrently, regardless of dependencies.
|
||||
func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||
if err := r.ensureService(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
results := make([]RunResult, len(specs))
|
||||
|
||||
|
|
@ -291,3 +308,10 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
|
|||
|
||||
return aggResult, nil
|
||||
}
|
||||
|
||||
func (r *Runner) ensureService() error {
|
||||
if r == nil || r.service == nil {
|
||||
return ErrRunnerNoService
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"testing"
|
||||
|
||||
framework "forge.lthn.ai/core/go/pkg/core"
|
||||
framework "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -12,18 +12,13 @@ import (
|
|||
func newTestRunner(t *testing.T) *Runner {
|
||||
t.Helper()
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewRunner(svc)
|
||||
c := framework.New()
|
||||
r := Register(c)
|
||||
require.True(t, r.OK)
|
||||
return NewRunner(r.Value.(*Service))
|
||||
}
|
||||
|
||||
func TestRunner_RunSequential(t *testing.T) {
|
||||
func TestRunner_RunSequential_Good(t *testing.T) {
|
||||
t.Run("all pass", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -73,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) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -105,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) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -151,9 +146,43 @@ func TestRunner_RunAll(t *testing.T) {
|
|||
assert.True(t, result.Success())
|
||||
assert.Equal(t, 4, result.Passed)
|
||||
})
|
||||
|
||||
t.Run("preserves input order", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
specs := []RunSpec{
|
||||
{Name: "third", Command: "echo", Args: []string{"3"}, After: []string{"second"}},
|
||||
{Name: "first", Command: "echo", Args: []string{"1"}},
|
||||
{Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}},
|
||||
}
|
||||
|
||||
result, err := runner.RunAll(context.Background(), specs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, result.Results, len(specs))
|
||||
for i, res := range result.Results {
|
||||
assert.Equal(t, specs[i].Name, res.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunResult_Passed(t *testing.T) {
|
||||
func TestRunner_CircularDeps_Bad(t *testing.T) {
|
||||
t.Run("circular dependency counts as failed", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
result, err := runner.RunAll(context.Background(), []RunSpec{
|
||||
{Name: "a", Command: "echo", Args: []string{"a"}, After: []string{"b"}},
|
||||
{Name: "b", Command: "echo", Args: []string{"b"}, After: []string{"a"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, result.Success())
|
||||
assert.Equal(t, 2, result.Failed)
|
||||
assert.Equal(t, 0, result.Skipped)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunResult_Passed_Good(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
r := RunResult{ExitCode: 0}
|
||||
assert.True(t, r.Passed())
|
||||
|
|
@ -174,3 +203,19 @@ func TestRunResult_Passed(t *testing.T) {
|
|||
assert.False(t, r.Passed())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_NilService_Bad(t *testing.T) {
|
||||
runner := NewRunner(nil)
|
||||
|
||||
_, err := runner.RunAll(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
||||
|
||||
_, err = runner.RunSequential(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
||||
|
||||
_, err = runner.RunParallel(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
||||
}
|
||||
|
|
|
|||
464
service.go
464
service.go
|
|
@ -3,36 +3,37 @@ package process
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type execCmd = exec.Cmd
|
||||
|
||||
type streamReader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
// Default buffer size for process output (1MB).
|
||||
const DefaultBufferSize = 1024 * 1024
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrProcessNotFound = errors.New("process not found")
|
||||
ErrProcessNotRunning = errors.New("process is not running")
|
||||
ErrStdinNotAvailable = errors.New("stdin not available")
|
||||
ErrProcessNotFound = core.E("", "process not found", nil)
|
||||
ErrProcessNotRunning = core.E("", "process is not running", nil)
|
||||
ErrStdinNotAvailable = core.E("", "stdin not available", nil)
|
||||
)
|
||||
|
||||
// Service manages process execution with Core IPC integration.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
|
||||
processes map[string]*Process
|
||||
mu sync.RWMutex
|
||||
bufSize int
|
||||
idCounter atomic.Uint64
|
||||
managed *core.Registry[*ManagedProcess]
|
||||
bufSize int
|
||||
}
|
||||
|
||||
// Options configures the process service.
|
||||
|
|
@ -42,51 +43,46 @@ type Options struct {
|
|||
BufferSize int
|
||||
}
|
||||
|
||||
// NewService creates a process service factory for Core registration.
|
||||
// Register constructs a Service bound to the provided Core instance.
|
||||
//
|
||||
// core, _ := core.New(
|
||||
// core.WithName("process", process.NewService(process.Options{})),
|
||||
// )
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
if opts.BufferSize == 0 {
|
||||
opts.BufferSize = DefaultBufferSize
|
||||
}
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
processes: make(map[string]*Process),
|
||||
bufSize: opts.BufferSize,
|
||||
}
|
||||
return svc, nil
|
||||
// c := core.New()
|
||||
// svc := process.Register(c).Value.(*process.Service)
|
||||
func Register(c *core.Core) core.Result {
|
||||
opts := Options{BufferSize: DefaultBufferSize}
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
managed: core.NewRegistry[*ManagedProcess](),
|
||||
bufSize: opts.BufferSize,
|
||||
}
|
||||
return core.Result{Value: svc, OK: true}
|
||||
}
|
||||
|
||||
// OnStartup implements core.Startable.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
return nil
|
||||
func (s *Service) OnStartup(ctx context.Context) core.Result {
|
||||
c := s.Core()
|
||||
c.Action("process.run", s.handleRun)
|
||||
c.Action("process.start", s.handleStart)
|
||||
c.Action("process.kill", s.handleKill)
|
||||
c.Action("process.list", s.handleList)
|
||||
c.Action("process.get", s.handleGet)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
// OnShutdown implements core.Stoppable.
|
||||
// Kills all running processes on shutdown.
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
procs := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
if p.IsRunning() {
|
||||
procs = append(procs, p)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, p := range procs {
|
||||
_ = p.Kill()
|
||||
}
|
||||
|
||||
return nil
|
||||
// OnShutdown implements core.Stoppable — kills all managed processes.
|
||||
//
|
||||
// c.ServiceShutdown(ctx) // calls OnShutdown on all Stoppable services
|
||||
func (s *Service) OnShutdown(ctx context.Context) core.Result {
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
_ = proc.Kill()
|
||||
})
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
// Start spawns a new process with the given command and args.
|
||||
func (s *Service) Start(ctx context.Context, command string, args ...string) (*Process, error) {
|
||||
//
|
||||
// r := svc.Start(ctx, "echo", "hello")
|
||||
// if r.OK { proc := r.Value.(*Process) }
|
||||
func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result {
|
||||
return s.StartWithOptions(ctx, RunOptions{
|
||||
Command: command,
|
||||
Args: args,
|
||||
|
|
@ -94,8 +90,18 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P
|
|||
}
|
||||
|
||||
// StartWithOptions spawns a process with full configuration.
|
||||
func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) {
|
||||
id := fmt.Sprintf("proc-%d", s.idCounter.Add(1))
|
||||
//
|
||||
// r := svc.StartWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test", "./..."}})
|
||||
// if r.OK { proc := r.Value.(*Process) }
|
||||
func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
if opts.Command == "" {
|
||||
return core.Result{Value: core.E("process.start", "command is required", nil), OK: false}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
id := core.ID()
|
||||
|
||||
// Detached processes use Background context so they survive parent death
|
||||
parentCtx := ctx
|
||||
|
|
@ -103,7 +109,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
parentCtx = context.Background()
|
||||
}
|
||||
procCtx, cancel := context.WithCancel(parentCtx)
|
||||
cmd := exec.CommandContext(procCtx, opts.Command, opts.Args...)
|
||||
cmd := execCommandContext(procCtx, opts.Command, opts.Args...)
|
||||
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
|
|
@ -121,19 +127,19 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stdout pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stderr pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
return core.Result{Value: core.E("process.start", core.Concat("stdin pipe failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
|
||||
// Create output buffer (enabled by default)
|
||||
|
|
@ -142,35 +148,57 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
output = NewRingBuffer(s.bufSize)
|
||||
}
|
||||
|
||||
proc := &Process{
|
||||
ID: id,
|
||||
Command: opts.Command,
|
||||
Args: opts.Args,
|
||||
Dir: opts.Dir,
|
||||
Env: opts.Env,
|
||||
StartedAt: time.Now(),
|
||||
Status: StatusRunning,
|
||||
cmd: cmd,
|
||||
ctx: procCtx,
|
||||
cancel: cancel,
|
||||
output: output,
|
||||
stdin: stdin,
|
||||
done: make(chan struct{}),
|
||||
proc := &ManagedProcess{
|
||||
ID: id,
|
||||
Command: opts.Command,
|
||||
Args: append([]string(nil), opts.Args...),
|
||||
Dir: opts.Dir,
|
||||
Env: append([]string(nil), opts.Env...),
|
||||
StartedAt: time.Now(),
|
||||
Status: StatusPending,
|
||||
cmd: cmd,
|
||||
ctx: procCtx,
|
||||
cancel: cancel,
|
||||
output: output,
|
||||
stdin: stdin,
|
||||
done: make(chan struct{}),
|
||||
gracePeriod: opts.GracePeriod,
|
||||
killGroup: opts.KillGroup && opts.Detach,
|
||||
}
|
||||
|
||||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc.mu.Lock()
|
||||
proc.Status = StatusFailed
|
||||
proc.mu.Unlock()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||
return core.Result{Value: core.E("process.start", core.Concat("command failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
proc.PID = cmd.Process.Pid
|
||||
proc.mu.Lock()
|
||||
proc.Status = StatusRunning
|
||||
proc.mu.Unlock()
|
||||
|
||||
// Store process
|
||||
s.mu.Lock()
|
||||
s.processes[id] = proc
|
||||
s.mu.Unlock()
|
||||
if r := s.managed.Set(id, proc); !r.OK {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
return r
|
||||
}
|
||||
|
||||
// Start timeout watchdog if configured
|
||||
if opts.Timeout > 0 {
|
||||
go func() {
|
||||
select {
|
||||
case <-proc.done:
|
||||
case <-time.After(opts.Timeout):
|
||||
proc.Shutdown()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Broadcast start
|
||||
_ = s.Core().ACTION(ActionProcessStarted{
|
||||
s.Core().ACTION(ActionProcessStarted{
|
||||
ID: id,
|
||||
Command: opts.Command,
|
||||
Args: opts.Args,
|
||||
|
|
@ -192,52 +220,37 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
|
||||
// Wait for process completion
|
||||
go func() {
|
||||
// Wait for output streaming to complete
|
||||
wg.Wait()
|
||||
|
||||
// Wait for process exit
|
||||
err := cmd.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
duration := time.Since(proc.StartedAt)
|
||||
status, exitCode, _, killedSignal := classifyProcessExit(proc, waitErr)
|
||||
|
||||
proc.mu.Lock()
|
||||
proc.PID = cmd.Process.Pid
|
||||
proc.Duration = duration
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
proc.ExitCode = exitErr.ExitCode()
|
||||
proc.Status = StatusExited
|
||||
} else {
|
||||
proc.Status = StatusFailed
|
||||
}
|
||||
} else {
|
||||
proc.ExitCode = 0
|
||||
proc.Status = StatusExited
|
||||
}
|
||||
status := proc.Status
|
||||
exitCode := proc.ExitCode
|
||||
proc.ExitCode = exitCode
|
||||
proc.Status = status
|
||||
proc.mu.Unlock()
|
||||
|
||||
close(proc.done)
|
||||
|
||||
// Broadcast exit
|
||||
var exitErr error
|
||||
if status == StatusFailed {
|
||||
exitErr = err
|
||||
if status == StatusKilled {
|
||||
s.emitKilledAction(proc, killedSignal)
|
||||
}
|
||||
_ = s.Core().ACTION(ActionProcessExited{
|
||||
s.Core().ACTION(ActionProcessExited{
|
||||
ID: id,
|
||||
ExitCode: exitCode,
|
||||
Duration: duration,
|
||||
Error: exitErr,
|
||||
Error: nil,
|
||||
})
|
||||
}()
|
||||
|
||||
return proc, nil
|
||||
return core.Result{Value: proc, OK: true}
|
||||
}
|
||||
|
||||
// streamOutput reads from a pipe and broadcasts lines via ACTION.
|
||||
func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
|
||||
func (s *Service) streamOutput(proc *ManagedProcess, r streamReader, stream Stream) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Increase buffer for long lines
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
|
@ -260,40 +273,31 @@ func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
|
|||
}
|
||||
|
||||
// Get returns a process by ID.
|
||||
func (s *Service) Get(id string) (*Process, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
proc, ok := s.processes[id]
|
||||
if !ok {
|
||||
func (s *Service) Get(id string) (*ManagedProcess, error) {
|
||||
r := s.managed.Get(id)
|
||||
if !r.OK {
|
||||
return nil, ErrProcessNotFound
|
||||
}
|
||||
return proc, nil
|
||||
return r.Value.(*ManagedProcess), nil
|
||||
}
|
||||
|
||||
// List returns all processes.
|
||||
func (s *Service) List() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
result = append(result, p)
|
||||
}
|
||||
func (s *Service) List() []*ManagedProcess {
|
||||
result := make([]*ManagedProcess, 0, s.managed.Len())
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
result = append(result, proc)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Running returns all currently running processes.
|
||||
func (s *Service) Running() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*Process
|
||||
for _, p := range s.processes {
|
||||
if p.IsRunning() {
|
||||
result = append(result, p)
|
||||
func (s *Service) Running() []*ManagedProcess {
|
||||
result := make([]*ManagedProcess, 0, s.managed.Len())
|
||||
s.managed.Each(func(_ string, proc *ManagedProcess) {
|
||||
if proc.IsRunning() {
|
||||
result = append(result, proc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -307,42 +311,36 @@ func (s *Service) Kill(id string) error {
|
|||
if err := proc.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = s.Core().ACTION(ActionProcessKilled{
|
||||
ID: id,
|
||||
Signal: "SIGKILL",
|
||||
})
|
||||
|
||||
s.emitKilledAction(proc, proc.requestedSignal())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a completed process from the list.
|
||||
func (s *Service) Remove(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
proc, ok := s.processes[id]
|
||||
if !ok {
|
||||
proc, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proc.IsRunning() {
|
||||
return core.E("process.remove", core.Concat("cannot remove running process: ", id), nil)
|
||||
}
|
||||
r := s.managed.Delete(id)
|
||||
if !r.OK {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
|
||||
if proc.IsRunning() {
|
||||
return errors.New("cannot remove running process")
|
||||
}
|
||||
|
||||
delete(s.processes, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes all completed processes.
|
||||
func (s *Service) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for id, p := range s.processes {
|
||||
if !p.IsRunning() {
|
||||
delete(s.processes, id)
|
||||
ids := make([]string, 0)
|
||||
s.managed.Each(func(id string, proc *ManagedProcess) {
|
||||
if !proc.IsRunning() {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
})
|
||||
for _, id := range ids {
|
||||
s.managed.Delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,34 +354,156 @@ func (s *Service) Output(id string) (string, error) {
|
|||
}
|
||||
|
||||
// Run executes a command and waits for completion.
|
||||
// Returns the combined output and any error.
|
||||
func (s *Service) Run(ctx context.Context, command string, args ...string) (string, error) {
|
||||
proc, err := s.Start(ctx, command, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
output := proc.Output()
|
||||
if proc.ExitCode != 0 {
|
||||
return output, fmt.Errorf("process exited with code %d", proc.ExitCode)
|
||||
}
|
||||
return output, nil
|
||||
// Value is always the output string. OK is true if exit code is 0.
|
||||
//
|
||||
// r := svc.Run(ctx, "go", "test", "./...")
|
||||
// output := r.Value.(string)
|
||||
func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result {
|
||||
return s.RunWithOptions(ctx, RunOptions{
|
||||
Command: command,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
// RunWithOptions executes a command with options and waits for completion.
|
||||
func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, error) {
|
||||
proc, err := s.StartWithOptions(ctx, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
output := proc.Output()
|
||||
if proc.ExitCode != 0 {
|
||||
return output, fmt.Errorf("process exited with code %d", proc.ExitCode)
|
||||
}
|
||||
return output, nil
|
||||
// Value is always the output string. OK is true if exit code is 0.
|
||||
//
|
||||
// r := svc.RunWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test"}})
|
||||
// output := r.Value.(string)
|
||||
func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
return s.runCommand(ctx, opts)
|
||||
}
|
||||
|
||||
func (s *Service) runCommand(ctx context.Context, opts RunOptions) core.Result {
|
||||
if opts.Command == "" {
|
||||
return core.Result{Value: core.E("process.run", "command is required", nil), OK: false}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
cmd := execCommandContext(ctx, opts.Command, opts.Args...)
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
}
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return core.Result{Value: core.E("process.run", core.Concat("command failed: ", opts.Command), err), OK: false}
|
||||
}
|
||||
return core.Result{Value: string(output), OK: true}
|
||||
}
|
||||
|
||||
// Signal sends a signal to the process.
|
||||
func (p *ManagedProcess) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.Status != StatusRunning {
|
||||
return ErrProcessNotRunning
|
||||
}
|
||||
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if signal, ok := sig.(syscall.Signal); ok {
|
||||
p.lastSignal = normalizeSignalName(signal)
|
||||
}
|
||||
return p.cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
func execCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
func execLookPath(name string) (string, error) {
|
||||
return exec.LookPath(name)
|
||||
}
|
||||
|
||||
func currentPID() int {
|
||||
return os.Getpid()
|
||||
}
|
||||
|
||||
func processHandle(pid int) (*os.Process, error) {
|
||||
return os.FindProcess(pid)
|
||||
}
|
||||
|
||||
func userHomeDir() (string, error) {
|
||||
return os.UserHomeDir()
|
||||
}
|
||||
|
||||
func tempDir() string {
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
func isNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func classifyProcessExit(proc *ManagedProcess, err error) (Status, int, error, string) {
|
||||
if err == nil {
|
||||
return StatusExited, 0, nil, ""
|
||||
}
|
||||
|
||||
if sig, ok := processExitSignal(err); ok {
|
||||
return StatusKilled, -1, err, normalizeSignalName(sig)
|
||||
}
|
||||
|
||||
if ctxErr := proc.ctx.Err(); ctxErr != nil {
|
||||
signal := proc.requestedSignal()
|
||||
if signal == "" {
|
||||
signal = "SIGKILL"
|
||||
}
|
||||
return StatusKilled, -1, ctxErr, signal
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if core.As(err, &exitErr) {
|
||||
return StatusExited, exitErr.ExitCode(), err, ""
|
||||
}
|
||||
|
||||
return StatusFailed, -1, err, ""
|
||||
}
|
||||
|
||||
func processExitSignal(err error) (syscall.Signal, bool) {
|
||||
var exitErr *exec.ExitError
|
||||
if !core.As(err, &exitErr) || exitErr.ProcessState == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
waitStatus, ok := exitErr.ProcessState.Sys().(syscall.WaitStatus)
|
||||
if !ok || !waitStatus.Signaled() {
|
||||
return 0, false
|
||||
}
|
||||
return waitStatus.Signal(), true
|
||||
}
|
||||
|
||||
func normalizeSignalName(sig syscall.Signal) string {
|
||||
switch sig {
|
||||
case syscall.SIGINT:
|
||||
return "SIGINT"
|
||||
case syscall.SIGKILL:
|
||||
return "SIGKILL"
|
||||
case syscall.SIGTERM:
|
||||
return "SIGTERM"
|
||||
default:
|
||||
return sig.String()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) emitKilledAction(proc *ManagedProcess, signal string) {
|
||||
if proc == nil || !proc.markKillEmitted() {
|
||||
return
|
||||
}
|
||||
if signal == "" {
|
||||
signal = "SIGKILL"
|
||||
}
|
||||
_ = s.Core().ACTION(ActionProcessKilled{
|
||||
ID: proc.ID,
|
||||
Signal: signal,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
564
service_test.go
564
service_test.go
|
|
@ -2,12 +2,11 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
framework "forge.lthn.ai/core/go/pkg/core"
|
||||
framework "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -15,30 +14,290 @@ import (
|
|||
func newTestService(t *testing.T) (*Service, *framework.Core) {
|
||||
t.Helper()
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{BufferSize: 1024})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
c := framework.New()
|
||||
r := Register(c)
|
||||
require.True(t, r.OK)
|
||||
return r.Value.(*Service), c
|
||||
}
|
||||
|
||||
svc, err := framework.ServiceFor[*Service](c, "process")
|
||||
require.NoError(t, err)
|
||||
func newStartedTestService(t *testing.T) (*Service, *framework.Core) {
|
||||
t.Helper()
|
||||
|
||||
svc, c := newTestService(t)
|
||||
r := svc.OnStartup(context.Background())
|
||||
require.True(t, r.OK)
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestService_Start(t *testing.T) {
|
||||
func TestService_Register_Good(t *testing.T) {
|
||||
c := framework.New(framework.WithService(Register))
|
||||
|
||||
svc, ok := framework.ServiceFor[*Service](c, "process")
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
svc, c := newTestService(t)
|
||||
|
||||
r := svc.OnStartup(context.Background())
|
||||
require.True(t, r.OK)
|
||||
|
||||
assert.True(t, c.Action("process.run").Exists())
|
||||
assert.True(t, c.Action("process.start").Exists())
|
||||
assert.True(t, c.Action("process.kill").Exists())
|
||||
assert.True(t, c.Action("process.list").Exists())
|
||||
assert.True(t, c.Action("process.get").Exists())
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Good(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.run").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "echo"},
|
||||
framework.Option{Key: "args", Value: []string{"hello"}},
|
||||
))
|
||||
require.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "hello")
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.run").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "nonexistent_command_xyz"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleRun_Ugly(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
r := c.Action("process.run").Run(ctx, framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"1"}},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleStart_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, r.OK)
|
||||
|
||||
id := r.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, proc.IsRunning())
|
||||
|
||||
kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: id},
|
||||
))
|
||||
require.True(t, kill.OK)
|
||||
<-proc.Done()
|
||||
|
||||
t.Run("respects detach=false", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
start := c.Action("process.start").Run(ctx, framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
framework.Option{Key: "detach", Value: false},
|
||||
))
|
||||
require.True(t, start.OK)
|
||||
|
||||
id := start.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should honor detached=false context cancellation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults to non-detached", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
start := c.Action("process.start").Run(ctx, framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, start.OK)
|
||||
|
||||
id := start.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should honor context cancellation by default")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_HandleStart_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "nonexistent_command_xyz"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleKill_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
start := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, start.OK)
|
||||
|
||||
id := start.Value.(string)
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: id},
|
||||
))
|
||||
require.True(t, kill.OK)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_HandleKill_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
r := c.Action("process.kill").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: "missing"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_HandleList_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
startOne := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, startOne.OK)
|
||||
startTwo := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, startTwo.OK)
|
||||
|
||||
r := c.Action("process.list").Run(context.Background(), framework.NewOptions())
|
||||
require.True(t, r.OK)
|
||||
|
||||
ids := r.Value.([]string)
|
||||
assert.Len(t, ids, 2)
|
||||
|
||||
for _, id := range ids {
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
_ = proc.Kill()
|
||||
<-proc.Done()
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_HandleGet_Good(t *testing.T) {
|
||||
svc, c := newStartedTestService(t)
|
||||
|
||||
start := c.Action("process.start").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "command", Value: "sleep"},
|
||||
framework.Option{Key: "args", Value: []string{"60"}},
|
||||
))
|
||||
require.True(t, start.OK)
|
||||
|
||||
id := start.Value.(string)
|
||||
r := c.Action("process.get").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: id},
|
||||
))
|
||||
require.True(t, r.OK)
|
||||
|
||||
info := r.Value.(ProcessInfo)
|
||||
assert.Equal(t, id, info.ID)
|
||||
assert.Equal(t, "sleep", info.Command)
|
||||
assert.True(t, info.Running)
|
||||
assert.Equal(t, StatusRunning, info.Status)
|
||||
assert.Positive(t, info.PID)
|
||||
|
||||
proc, err := svc.Get(id)
|
||||
require.NoError(t, err)
|
||||
_ = proc.Kill()
|
||||
<-proc.Done()
|
||||
}
|
||||
|
||||
func TestService_HandleGet_Bad(t *testing.T) {
|
||||
_, c := newStartedTestService(t)
|
||||
|
||||
missingID := c.Action("process.get").Run(context.Background(), framework.NewOptions())
|
||||
assert.False(t, missingID.OK)
|
||||
|
||||
missingProc := c.Action("process.get").Run(context.Background(), framework.NewOptions(
|
||||
framework.Option{Key: "id", Value: "missing"},
|
||||
))
|
||||
assert.False(t, missingProc.OK)
|
||||
}
|
||||
|
||||
func TestService_Ugly_PermissionModel(t *testing.T) {
|
||||
c := framework.New()
|
||||
|
||||
r := c.Process().Run(context.Background(), "echo", "blocked")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
c = framework.New(framework.WithService(Register))
|
||||
startup := c.ServiceStartup(context.Background(), nil)
|
||||
require.True(t, startup.OK)
|
||||
defer func() {
|
||||
shutdown := c.ServiceShutdown(context.Background())
|
||||
assert.True(t, shutdown.OK)
|
||||
}()
|
||||
|
||||
r = c.Process().Run(context.Background(), "echo", "allowed")
|
||||
require.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "allowed")
|
||||
}
|
||||
|
||||
func startProc(t *testing.T, svc *Service, ctx context.Context, command string, args ...string) *Process {
|
||||
t.Helper()
|
||||
r := svc.Start(ctx, command, args...)
|
||||
require.True(t, r.OK)
|
||||
return r.Value.(*Process)
|
||||
}
|
||||
|
||||
func TestService_Start_Good(t *testing.T) {
|
||||
t.Run("echo command", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "echo", "hello")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, proc)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "hello")
|
||||
|
||||
assert.NotEmpty(t, proc.ID)
|
||||
assert.Positive(t, proc.PID)
|
||||
assert.Equal(t, "echo", proc.Command)
|
||||
assert.Equal(t, []string{"hello"}, proc.Args)
|
||||
|
||||
// Wait for completion
|
||||
<-proc.Done()
|
||||
|
||||
assert.Equal(t, StatusExited, proc.Status)
|
||||
|
|
@ -49,8 +308,7 @@ func TestService_Start(t *testing.T) {
|
|||
t.Run("failing command", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.Start(context.Background(), "sh", "-c", "exit 42")
|
||||
require.NoError(t, err)
|
||||
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 42")
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
|
|
@ -61,23 +319,23 @@ func TestService_Start(t *testing.T) {
|
|||
t.Run("non-existent command", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
_, err := svc.Start(context.Background(), "nonexistent_command_xyz")
|
||||
assert.Error(t, err)
|
||||
r := svc.Start(context.Background(), "nonexistent_command_xyz")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
|
||||
t.Run("with working directory", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "pwd",
|
||||
Dir: "/tmp",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
// On macOS /tmp is a symlink to /private/tmp
|
||||
output := strings.TrimSpace(proc.Output())
|
||||
output := framework.Trim(proc.Output())
|
||||
assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output)
|
||||
})
|
||||
|
||||
|
|
@ -85,52 +343,100 @@ func TestService_Start(t *testing.T) {
|
|||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
proc, err := svc.Start(ctx, "sleep", "10")
|
||||
require.NoError(t, err)
|
||||
proc := startProc(t, svc, ctx, "sleep", "10")
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
// Good - process was killed
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable capture", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"no-capture"},
|
||||
DisableCapture: true,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
<-proc.Done()
|
||||
|
||||
assert.Equal(t, StatusExited, proc.Status)
|
||||
assert.Equal(t, "", proc.Output(), "output should be empty when capture is disabled")
|
||||
})
|
||||
|
||||
t.Run("with environment variables", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "echo $MY_TEST_VAR"},
|
||||
Env: []string{"MY_TEST_VAR=hello_env"},
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
<-proc.Done()
|
||||
|
||||
assert.Contains(t, proc.Output(), "hello_env")
|
||||
})
|
||||
|
||||
t.Run("detach survives parent context", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
r := svc.StartWithOptions(ctx, RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"detached"},
|
||||
Detach: true,
|
||||
})
|
||||
require.True(t, r.OK)
|
||||
proc := r.Value.(*Process)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
assert.Equal(t, StatusExited, proc.Status)
|
||||
assert.Equal(t, 0, proc.ExitCode)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("detached process should have completed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Run(t *testing.T) {
|
||||
func TestService_Run_Good(t *testing.T) {
|
||||
t.Run("returns output", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
output, err := svc.Run(context.Background(), "echo", "hello world")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "hello world")
|
||||
r := svc.Run(context.Background(), "echo", "hello world")
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "hello world")
|
||||
})
|
||||
|
||||
t.Run("returns error on failure", func(t *testing.T) {
|
||||
t.Run("returns !OK on failure", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
_, err := svc.Run(context.Background(), "sh", "-c", "exit 1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exited with code 1")
|
||||
r := svc.Run(context.Background(), "sh", "-c", "exit 1")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Actions(t *testing.T) {
|
||||
func TestService_Actions_Good(t *testing.T) {
|
||||
t.Run("broadcasts events", func(t *testing.T) {
|
||||
c, err := framework.New(
|
||||
framework.WithName("process", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
svc, c := newTestService(t)
|
||||
|
||||
var started []ActionProcessStarted
|
||||
var outputs []ActionProcessOutput
|
||||
var exited []ActionProcessExited
|
||||
var mu sync.Mutex
|
||||
|
||||
c.RegisterAction(func(cc *framework.Core, msg framework.Message) error {
|
||||
c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch m := msg.(type) {
|
||||
|
|
@ -141,16 +447,12 @@ func TestService_Actions(t *testing.T) {
|
|||
case ActionProcessExited:
|
||||
exited = append(exited, m)
|
||||
}
|
||||
return nil
|
||||
return framework.Result{OK: true}
|
||||
})
|
||||
|
||||
svc, _ := framework.ServiceFor[*Service](c, "process")
|
||||
proc, err := svc.Start(context.Background(), "echo", "test")
|
||||
require.NoError(t, err)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
|
||||
<-proc.Done()
|
||||
|
||||
// Give time for events to propagate
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
|
|
@ -163,7 +465,7 @@ func TestService_Actions(t *testing.T) {
|
|||
assert.NotEmpty(t, outputs)
|
||||
foundTest := false
|
||||
for _, o := range outputs {
|
||||
if strings.Contains(o.Line, "test") {
|
||||
if framework.Contains(o.Line, "test") {
|
||||
foundTest = true
|
||||
break
|
||||
}
|
||||
|
|
@ -172,15 +474,46 @@ func TestService_Actions(t *testing.T) {
|
|||
|
||||
assert.Len(t, exited, 1)
|
||||
assert.Equal(t, 0, exited[0].ExitCode)
|
||||
assert.Nil(t, exited[0].Error)
|
||||
})
|
||||
|
||||
t.Run("broadcasts killed event", func(t *testing.T) {
|
||||
svc, c := newTestService(t)
|
||||
|
||||
var killed []ActionProcessKilled
|
||||
var mu sync.Mutex
|
||||
|
||||
c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if m, ok := msg.(ActionProcessKilled); ok {
|
||||
killed = append(killed, m)
|
||||
}
|
||||
return framework.Result{OK: true}
|
||||
})
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "sleep", "60")
|
||||
err := svc.Kill(proc.ID)
|
||||
require.NoError(t, err)
|
||||
<-proc.Done()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
require.Len(t, killed, 1)
|
||||
assert.Equal(t, proc.ID, killed[0].ID)
|
||||
assert.Equal(t, "SIGKILL", killed[0].Signal)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_List(t *testing.T) {
|
||||
func TestService_List_Good(t *testing.T) {
|
||||
t.Run("tracks processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc1, _ := svc.Start(context.Background(), "echo", "1")
|
||||
proc2, _ := svc.Start(context.Background(), "echo", "2")
|
||||
proc1 := startProc(t, svc, context.Background(), "echo", "1")
|
||||
proc2 := startProc(t, svc, context.Background(), "echo", "2")
|
||||
|
||||
<-proc1.Done()
|
||||
<-proc2.Done()
|
||||
|
|
@ -192,7 +525,7 @@ func TestService_List(t *testing.T) {
|
|||
t.Run("get by id", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, _ := svc.Start(context.Background(), "echo", "test")
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
<-proc.Done()
|
||||
|
||||
got, err := svc.Get(proc.ID)
|
||||
|
|
@ -208,11 +541,11 @@ func TestService_List(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Remove(t *testing.T) {
|
||||
func TestService_Remove_Good(t *testing.T) {
|
||||
t.Run("removes completed process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc, _ := svc.Start(context.Background(), "echo", "test")
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
<-proc.Done()
|
||||
|
||||
err := svc.Remove(proc.ID)
|
||||
|
|
@ -228,7 +561,7 @@ func TestService_Remove(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc, _ := svc.Start(ctx, "sleep", "10")
|
||||
proc := startProc(t, svc, ctx, "sleep", "10")
|
||||
|
||||
err := svc.Remove(proc.ID)
|
||||
assert.Error(t, err)
|
||||
|
|
@ -238,12 +571,12 @@ func TestService_Remove(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Clear(t *testing.T) {
|
||||
func TestService_Clear_Good(t *testing.T) {
|
||||
t.Run("clears completed processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc1, _ := svc.Start(context.Background(), "echo", "1")
|
||||
proc2, _ := svc.Start(context.Background(), "echo", "2")
|
||||
proc1 := startProc(t, svc, context.Background(), "echo", "1")
|
||||
proc2 := startProc(t, svc, context.Background(), "echo", "2")
|
||||
|
||||
<-proc1.Done()
|
||||
<-proc2.Done()
|
||||
|
|
@ -255,3 +588,122 @@ func TestService_Clear(t *testing.T) {
|
|||
assert.Len(t, svc.List(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Kill_Good(t *testing.T) {
|
||||
t.Run("kills running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc := startProc(t, svc, ctx, "sleep", "60")
|
||||
|
||||
err := svc.Kill(proc.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-proc.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error on unknown id", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
err := svc.Kill("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrProcessNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Output_Good(t *testing.T) {
|
||||
t.Run("returns captured output", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "captured")
|
||||
<-proc.Done()
|
||||
|
||||
output, err := svc.Output(proc.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "captured")
|
||||
})
|
||||
|
||||
t.Run("error on unknown id", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
_, err := svc.Output("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrProcessNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_OnShutdown_Good(t *testing.T) {
|
||||
t.Run("kills all running processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc1 := startProc(t, svc, ctx, "sleep", "60")
|
||||
proc2 := startProc(t, svc, ctx, "sleep", "60")
|
||||
|
||||
assert.True(t, proc1.IsRunning())
|
||||
assert.True(t, proc2.IsRunning())
|
||||
|
||||
r := svc.OnShutdown(context.Background())
|
||||
assert.True(t, r.OK)
|
||||
|
||||
select {
|
||||
case <-proc1.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("proc1 should have been killed")
|
||||
}
|
||||
select {
|
||||
case <-proc2.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("proc2 should have been killed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RunWithOptions_Good(t *testing.T) {
|
||||
t.Run("returns output on success", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
r := svc.RunWithOptions(context.Background(), RunOptions{
|
||||
Command: "echo",
|
||||
Args: []string{"opts-test"},
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "opts-test")
|
||||
})
|
||||
|
||||
t.Run("returns !OK on failure", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
r := svc.RunWithOptions(context.Background(), RunOptions{
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "exit 2"},
|
||||
})
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Running_Good(t *testing.T) {
|
||||
t.Run("returns only running processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
proc1 := startProc(t, svc, ctx, "sleep", "60")
|
||||
proc2 := startProc(t, svc, context.Background(), "echo", "done")
|
||||
<-proc2.Done()
|
||||
|
||||
running := svc.Running()
|
||||
assert.Len(t, running, 1)
|
||||
assert.Equal(t, proc1.ID, running[0].ID)
|
||||
|
||||
cancel()
|
||||
<-proc1.Done()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
29
specs/api/RFC.md
Normal file
29
specs/api/RFC.md
Normal 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
68
specs/exec/RFC.md
Normal 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
207
specs/process-ui.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# @core/process-ui
|
||||
**Import:** `@core/process-ui`
|
||||
**Files:** 8
|
||||
|
||||
## Types
|
||||
|
||||
### `DaemonEntry`
|
||||
`interface`
|
||||
|
||||
Daemon-registry row returned by `ProcessApi.listDaemons` and `ProcessApi.getDaemon`.
|
||||
|
||||
Properties:
|
||||
- `code: string`: Application or component code.
|
||||
- `daemon: string`: Daemon name.
|
||||
- `pid: number`: Process ID.
|
||||
- `health?: string`: Optional health-endpoint address.
|
||||
- `project?: string`: Optional project label.
|
||||
- `binary?: string`: Optional binary label.
|
||||
- `started: string`: Start timestamp string from the API.
|
||||
|
||||
### `HealthResult`
|
||||
`interface`
|
||||
|
||||
Result returned by the daemon health endpoint.
|
||||
|
||||
Properties:
|
||||
- `healthy: boolean`: Health outcome.
|
||||
- `address: string`: Health endpoint address that was checked.
|
||||
- `reason?: string`: Optional explanation such as the absence of a health endpoint.
|
||||
|
||||
### `ProcessInfo`
|
||||
`interface`
|
||||
|
||||
Process snapshot shape used by the UI package.
|
||||
|
||||
Properties:
|
||||
- `id: string`: Managed-process identifier.
|
||||
- `command: string`: Executable name.
|
||||
- `args: string[]`: Command arguments.
|
||||
- `dir: string`: Working directory.
|
||||
- `startedAt: string`: Start timestamp string.
|
||||
- `status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'`: Process status string.
|
||||
- `exitCode: number`: Exit code.
|
||||
- `duration: number`: Numeric duration value from the API payload.
|
||||
- `pid: number`: Child PID.
|
||||
|
||||
### `RunResult`
|
||||
`interface`
|
||||
|
||||
Pipeline result row used by `ProcessRunner`.
|
||||
|
||||
Properties:
|
||||
- `name: string`: Spec name.
|
||||
- `exitCode: number`: Exit code.
|
||||
- `duration: number`: Numeric duration value.
|
||||
- `output: string`: Captured output.
|
||||
- `error?: string`: Optional error message.
|
||||
- `skipped: boolean`: Whether the spec was skipped.
|
||||
- `passed: boolean`: Whether the spec passed.
|
||||
|
||||
### `RunAllResult`
|
||||
`interface`
|
||||
|
||||
Aggregate pipeline result consumed by `ProcessRunner`.
|
||||
|
||||
Properties:
|
||||
- `results: RunResult[]`: Per-spec results.
|
||||
- `duration: number`: Aggregate duration.
|
||||
- `passed: number`: Count of passed specs.
|
||||
- `failed: number`: Count of failed specs.
|
||||
- `skipped: number`: Count of skipped specs.
|
||||
- `success: boolean`: Aggregate success flag.
|
||||
|
||||
### `ProcessApi`
|
||||
`class`
|
||||
|
||||
Typed fetch client for `/api/process/*`.
|
||||
|
||||
Public API:
|
||||
- `new ProcessApi(baseUrl?: string)`: Stores an optional URL prefix. The default is `""`.
|
||||
- `listDaemons(): Promise<DaemonEntry[]>`: Fetches `GET /api/process/daemons`.
|
||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Fetches one daemon entry.
|
||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Sends `POST /api/process/daemons/:code/:daemon/stop`.
|
||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Fetches `GET /api/process/daemons/:code/:daemon/health`.
|
||||
|
||||
### `ProcessEvent`
|
||||
`interface`
|
||||
|
||||
Event envelope consumed by `connectProcessEvents`.
|
||||
|
||||
Properties:
|
||||
- `type: string`: Event type.
|
||||
- `channel?: string`: Optional channel name.
|
||||
- `data?: any`: Event payload.
|
||||
- `timestamp?: string`: Optional timestamp string.
|
||||
|
||||
### `ProcessPanel`
|
||||
`class`
|
||||
|
||||
Top-level custom element registered as `<core-process-panel>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Forwarded to child elements through the `api-url` attribute.
|
||||
- `wsUrl: string`: WebSocket endpoint URL from the `ws-url` attribute.
|
||||
|
||||
Behavior:
|
||||
- Renders tabbed daemon, process, and pipeline views.
|
||||
- Opens a process-event WebSocket when `wsUrl` is set.
|
||||
- Shows the last received process channel or event type in the footer.
|
||||
|
||||
### `ProcessDaemons`
|
||||
`class`
|
||||
|
||||
Daemon-list custom element registered as `<core-process-daemons>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Base URL prefix for `ProcessApi`.
|
||||
|
||||
Behavior:
|
||||
- Loads daemon entries on connect.
|
||||
- Can trigger per-daemon health checks and stop requests.
|
||||
- Emits `daemon-stopped` after a successful stop request.
|
||||
|
||||
### `ProcessList`
|
||||
`class`
|
||||
|
||||
Managed-process list custom element registered as `<core-process-list>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property.
|
||||
- `selectedId: string`: Selected process ID, reflected from `selected-id`.
|
||||
|
||||
Behavior:
|
||||
- Emits `process-selected` when a row is chosen.
|
||||
- Currently renders from local state only because the process REST endpoints referenced by the component are not implemented in this package.
|
||||
|
||||
### `ProcessOutput`
|
||||
`class`
|
||||
|
||||
Live output custom element registered as `<core-process-output>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property. The current implementation does not use it.
|
||||
- `wsUrl: string`: WebSocket endpoint URL.
|
||||
- `processId: string`: Selected process ID from the `process-id` attribute.
|
||||
|
||||
Behavior:
|
||||
- Connects to the WebSocket when both `wsUrl` and `processId` are present.
|
||||
- Filters for `process.output` events whose payload `data.id` matches `processId`.
|
||||
- Appends output lines and auto-scrolls by default.
|
||||
|
||||
### `ProcessRunner`
|
||||
`class`
|
||||
|
||||
Pipeline-results custom element registered as `<core-process-runner>`.
|
||||
|
||||
Public properties:
|
||||
- `apiUrl: string`: Declared API prefix property.
|
||||
- `result: RunAllResult | null`: Aggregate pipeline result used for rendering.
|
||||
|
||||
Behavior:
|
||||
- Renders summary counts plus expandable per-spec output.
|
||||
- Depends on the `result` property today because pipeline REST endpoints are not implemented in the package.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `function connectProcessEvents(wsUrl: string, handler: (event: ProcessEvent) => void): WebSocket`: Opens a WebSocket, parses incoming JSON, forwards only messages whose `type` or `channel` starts with `process.`, ignores malformed payloads, and returns the `WebSocket` instance.
|
||||
|
||||
### `ProcessPanel` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` is set.
|
||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
||||
- `render(): unknown`: Renders the header, tab strip, active child element, and connection footer.
|
||||
|
||||
### `ProcessDaemons` Methods
|
||||
|
||||
- `connectedCallback(): void`: Instantiates `ProcessApi` and loads daemon data.
|
||||
- `loadDaemons(): Promise<void>`: Fetches daemon entries, stores them in component state, and records any request error message.
|
||||
- `render(): unknown`: Renders the daemon list, loading state, empty state, and action buttons.
|
||||
|
||||
### `ProcessList` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadProcesses`.
|
||||
- `loadProcesses(): Promise<void>`: Current placeholder implementation that clears state because the referenced process REST endpoints are not implemented yet.
|
||||
- `render(): unknown`: Renders the process list or an informational empty state explaining the missing REST support.
|
||||
|
||||
### `ProcessOutput` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` and `processId` are both set.
|
||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
||||
- `updated(changed: Map<string, unknown>): void`: Reconnects when `processId` or `wsUrl` changes, resets buffered lines on reconnection, and auto-scrolls when enabled.
|
||||
- `render(): unknown`: Renders the output panel, waiting state, and accumulated stdout or stderr lines.
|
||||
|
||||
### `ProcessRunner` Methods
|
||||
|
||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadResults`.
|
||||
- `loadResults(): Promise<void>`: Current placeholder method. The implementation is empty because pipeline endpoints are not present.
|
||||
- `render(): unknown`: Renders the empty-state notice when `result` is absent, or the aggregate summary plus per-spec details when `result` is present.
|
||||
|
||||
### `ProcessApi` Methods
|
||||
|
||||
- `listDaemons(): Promise<DaemonEntry[]>`: Returns the `data` field from a successful daemon-list response.
|
||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Returns one daemon entry from the provider API.
|
||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Issues the stop request and returns the provider's `{ stopped }` payload.
|
||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Returns the daemon-health payload.
|
||||
372
specs/process.md
Normal file
372
specs/process.md
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
# process
|
||||
**Import:** `dappco.re/go/core/process`
|
||||
**Files:** 11
|
||||
|
||||
## Types
|
||||
|
||||
### `ActionProcessStarted`
|
||||
`struct`
|
||||
|
||||
Broadcast payload for a managed process that has successfully started.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Generated managed-process identifier.
|
||||
- `Command string`: Executable name passed to the service.
|
||||
- `Args []string`: Argument vector used to start the process.
|
||||
- `Dir string`: Working directory supplied at start time.
|
||||
- `PID int`: OS process ID of the child process.
|
||||
|
||||
### `ActionProcessOutput`
|
||||
`struct`
|
||||
|
||||
Broadcast payload for one scanned line of process output.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Line string`: One line from stdout or stderr, without the trailing newline.
|
||||
- `Stream Stream`: Output source, using `StreamStdout` or `StreamStderr`.
|
||||
|
||||
### `ActionProcessExited`
|
||||
`struct`
|
||||
|
||||
Broadcast payload emitted after the service wait goroutine finishes.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `ExitCode int`: Process exit code.
|
||||
- `Duration time.Duration`: Time elapsed since `StartedAt`.
|
||||
- `Error error`: Declared error slot for exit metadata. The current `Service` emitter does not populate it.
|
||||
|
||||
### `ActionProcessKilled`
|
||||
`struct`
|
||||
|
||||
Broadcast payload emitted by `Service.Kill`.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Signal string`: Signal name reported by the service. The current implementation emits `"SIGKILL"`.
|
||||
|
||||
### `RingBuffer`
|
||||
`struct`
|
||||
|
||||
Fixed-size circular byte buffer used for captured process output. The implementation is mutex-protected and overwrites the oldest bytes when full.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `DaemonOptions`
|
||||
`struct`
|
||||
|
||||
Configuration for `NewDaemon`.
|
||||
|
||||
Fields:
|
||||
- `PIDFile string`: PID file path. Empty disables PID-file management.
|
||||
- `ShutdownTimeout time.Duration`: Grace period used by `Stop`. Zero is normalized to 30 seconds by `NewDaemon`.
|
||||
- `HealthAddr string`: Listen address for the health server. Empty disables health endpoints.
|
||||
- `HealthChecks []HealthCheck`: Additional `/health` checks to register on the health server.
|
||||
- `Registry *Registry`: Optional daemon registry used for automatic register/unregister.
|
||||
- `RegistryEntry DaemonEntry`: Base registry payload. `Start` fills in `PID`, `Health`, and `Started` behavior through `Registry.Register`.
|
||||
|
||||
### `Daemon`
|
||||
`struct`
|
||||
|
||||
Lifecycle wrapper around a PID file, optional health server, and optional registry entry.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `HealthCheck`
|
||||
`type HealthCheck func() error`
|
||||
|
||||
Named function type used by `HealthServer` and `DaemonOptions`. Returning `nil` marks the check healthy; returning an error makes `/health` respond with `503`.
|
||||
|
||||
### `HealthServer`
|
||||
`struct`
|
||||
|
||||
HTTP server exposing `/health` and `/ready` endpoints.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `PIDFile`
|
||||
`struct`
|
||||
|
||||
Single-instance guard backed by a PID file on disk.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `ManagedProcess`
|
||||
`struct`
|
||||
|
||||
Service-owned process record for a started child process.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier generated by `core.ID()`.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory used when starting the process.
|
||||
- `Env []string`: Extra environment entries appended to the command environment.
|
||||
- `StartedAt time.Time`: Timestamp recorded immediately before `cmd.Start`.
|
||||
- `Status Status`: Current lifecycle state tracked by the service.
|
||||
- `ExitCode int`: Exit status after completion.
|
||||
- `Duration time.Duration`: Runtime duration set when the wait goroutine finishes.
|
||||
|
||||
### `Process`
|
||||
`type alias of ManagedProcess`
|
||||
|
||||
Compatibility alias that exposes the same fields and methods as `ManagedProcess`.
|
||||
|
||||
### `Program`
|
||||
`struct`
|
||||
|
||||
Thin helper for finding and running a named executable.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Binary name to look up or execute.
|
||||
- `Path string`: Resolved absolute path populated by `Find`. When empty, `Run` and `RunDir` fall back to `Name`.
|
||||
|
||||
### `DaemonEntry`
|
||||
`struct`
|
||||
|
||||
Serialized daemon-registry record written as JSON.
|
||||
|
||||
Fields:
|
||||
- `Code string`: Application or component code.
|
||||
- `Daemon string`: Daemon name within that code.
|
||||
- `PID int`: Running process ID.
|
||||
- `Health string`: Health endpoint address, if any.
|
||||
- `Project string`: Optional project label.
|
||||
- `Binary string`: Optional binary label.
|
||||
- `Started time.Time`: Start timestamp persisted in RFC3339Nano format.
|
||||
|
||||
### `Registry`
|
||||
`struct`
|
||||
|
||||
Filesystem-backed daemon registry that stores one JSON file per daemon entry.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `Runner`
|
||||
`struct`
|
||||
|
||||
Pipeline orchestrator that starts `RunSpec` processes through a `Service`.
|
||||
|
||||
Exported fields:
|
||||
- None.
|
||||
|
||||
### `RunSpec`
|
||||
`struct`
|
||||
|
||||
One process specification for `Runner`.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Friendly name used for dependencies and result reporting.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `Env []string`: Additional environment variables.
|
||||
- `After []string`: Dependency names that must complete before this spec can run in `RunAll`.
|
||||
- `AllowFailure bool`: When true, downstream work is not skipped because of this spec's failure.
|
||||
|
||||
### `RunResult`
|
||||
`struct`
|
||||
|
||||
Per-spec runner result.
|
||||
|
||||
Fields:
|
||||
- `Name string`: Spec name.
|
||||
- `Spec RunSpec`: Original spec payload.
|
||||
- `ExitCode int`: Exit code from the managed process.
|
||||
- `Duration time.Duration`: Process duration or start-attempt duration.
|
||||
- `Output string`: Captured output returned from the managed process.
|
||||
- `Error error`: Start or orchestration error. For a started process that exits non-zero, this remains `nil`.
|
||||
- `Skipped bool`: Whether the spec was skipped instead of run.
|
||||
|
||||
### `RunAllResult`
|
||||
`struct`
|
||||
|
||||
Aggregate result returned by `RunAll`, `RunSequential`, and `RunParallel`.
|
||||
|
||||
Fields:
|
||||
- `Results []RunResult`: Collected per-spec results.
|
||||
- `Duration time.Duration`: End-to-end runtime for the orchestration method.
|
||||
- `Passed int`: Count of results where `Passed()` is true.
|
||||
- `Failed int`: Count of non-skipped results that did not pass.
|
||||
- `Skipped int`: Count of skipped results.
|
||||
|
||||
### `Service`
|
||||
`struct`
|
||||
|
||||
Core service that owns managed processes and registers action handlers.
|
||||
|
||||
Fields:
|
||||
- `*core.ServiceRuntime[Options]`: Embedded Core runtime used for lifecycle hooks and access to `Core()`.
|
||||
|
||||
### `Options`
|
||||
`struct`
|
||||
|
||||
Service configuration.
|
||||
|
||||
Fields:
|
||||
- `BufferSize int`: Ring-buffer capacity for captured output. `Register` currently initializes this from `DefaultBufferSize`.
|
||||
|
||||
### `Status`
|
||||
`type Status string`
|
||||
|
||||
Named lifecycle-state type for a managed process.
|
||||
|
||||
Exported values:
|
||||
- `StatusPending`: queued but not started.
|
||||
- `StatusRunning`: currently executing.
|
||||
- `StatusExited`: completed and waited.
|
||||
- `StatusFailed`: start or wait failure state.
|
||||
- `StatusKilled`: terminated by signal.
|
||||
|
||||
### `Stream`
|
||||
`type Stream string`
|
||||
|
||||
Named output-stream discriminator for process output events.
|
||||
|
||||
Exported values:
|
||||
- `StreamStdout`: stdout line.
|
||||
- `StreamStderr`: stderr line.
|
||||
|
||||
### `RunOptions`
|
||||
`struct`
|
||||
|
||||
Execution settings accepted by `Service.StartWithOptions` and `Service.RunWithOptions`.
|
||||
|
||||
Fields:
|
||||
- `Command string`: Executable name. Required by both start and run paths.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `Env []string`: Additional environment entries appended to the command environment.
|
||||
- `DisableCapture bool`: Disables the managed-process ring buffer when true.
|
||||
- `Detach bool`: Starts the child in a separate process group and replaces the parent context with `context.Background()`.
|
||||
- `Timeout time.Duration`: Optional watchdog timeout that calls `Shutdown` after the duration elapses.
|
||||
- `GracePeriod time.Duration`: Delay between `SIGTERM` and fallback kill in `Shutdown`.
|
||||
- `KillGroup bool`: Requests process-group termination. The current service only enables this when `Detach` is also true.
|
||||
|
||||
### `ProcessInfo`
|
||||
`struct`
|
||||
|
||||
Serializable snapshot returned by `ManagedProcess.Info` and `Service` action lookups.
|
||||
|
||||
Fields:
|
||||
- `ID string`: Managed-process identifier.
|
||||
- `Command string`: Executable name.
|
||||
- `Args []string`: Command arguments.
|
||||
- `Dir string`: Working directory.
|
||||
- `StartedAt time.Time`: Start timestamp.
|
||||
- `Running bool`: Convenience boolean derived from `Status`.
|
||||
- `Status Status`: Current lifecycle state.
|
||||
- `ExitCode int`: Exit status.
|
||||
- `Duration time.Duration`: Runtime duration.
|
||||
- `PID int`: Child PID, or `0` if no process handle is available.
|
||||
|
||||
### `Info`
|
||||
`type alias of ProcessInfo`
|
||||
|
||||
Compatibility alias that exposes the same fields as `ProcessInfo`.
|
||||
|
||||
## Functions
|
||||
|
||||
### Package Functions
|
||||
|
||||
- `func Register(c *core.Core) core.Result`: Builds a `Service` with a fresh `core.Registry[*ManagedProcess]`, sets the buffer size to `DefaultBufferSize`, and returns the service in `Result.Value`.
|
||||
- `func NewRingBuffer(size int) *RingBuffer`: Allocates a fixed-capacity ring buffer of exactly `size` bytes.
|
||||
- `func NewDaemon(opts DaemonOptions) *Daemon`: Normalizes `ShutdownTimeout`, creates optional `PIDFile` and `HealthServer` helpers, and attaches any configured health checks.
|
||||
- `func NewHealthServer(addr string) *HealthServer`: Returns a health server with the supplied listen address and readiness initialized to `true`.
|
||||
- `func WaitForHealth(addr string, timeoutMs int) bool`: Polls `http://<addr>/health` every 200 ms until it gets HTTP 200 or the timeout expires.
|
||||
- `func NewPIDFile(path string) *PIDFile`: Returns a PID-file manager for `path`.
|
||||
- `func ReadPID(path string) (int, bool)`: Reads and parses a PID file, then uses signal `0` to report whether that PID is still alive.
|
||||
- `func NewRegistry(dir string) *Registry`: Returns a daemon registry rooted at `dir`.
|
||||
- `func DefaultRegistry() *Registry`: Returns a registry at `~/.core/daemons`, falling back to the OS temp directory if the home directory cannot be resolved.
|
||||
- `func NewRunner(svc *Service) *Runner`: Returns a runner bound to a specific `Service`.
|
||||
|
||||
### `RingBuffer` Methods
|
||||
|
||||
- `func (rb *RingBuffer) Write(p []byte) (n int, err error)`: Appends bytes one by one, advancing the circular window and overwriting the oldest bytes when capacity is exceeded.
|
||||
- `func (rb *RingBuffer) String() string`: Returns the current buffer contents in logical order as a string.
|
||||
- `func (rb *RingBuffer) Bytes() []byte`: Returns a copied byte slice of the current logical contents, or `nil` when the buffer is empty.
|
||||
- `func (rb *RingBuffer) Len() int`: Returns the number of bytes currently retained.
|
||||
- `func (rb *RingBuffer) Cap() int`: Returns the configured capacity.
|
||||
- `func (rb *RingBuffer) Reset()`: Clears the buffer indexes and full flag.
|
||||
|
||||
### `Daemon` Methods
|
||||
|
||||
- `func (d *Daemon) Start() error`: Acquires the PID file, starts the health server, marks the daemon running, and auto-registers it when `Registry` is configured. If a later step fails, it rolls back earlier setup.
|
||||
- `func (d *Daemon) Run(ctx context.Context) error`: Requires a started daemon, waits for `ctx.Done()`, and then calls `Stop`.
|
||||
- `func (d *Daemon) Stop() error`: Sets readiness false, shuts down the health server, releases the PID file, unregisters the daemon, and joins health or PID teardown errors with `core.ErrorJoin`.
|
||||
- `func (d *Daemon) SetReady(ready bool)`: Forwards readiness changes to the health server when one exists.
|
||||
- `func (d *Daemon) HealthAddr() string`: Returns the bound health-server address or `""` when health endpoints are disabled.
|
||||
|
||||
### `HealthServer` Methods
|
||||
|
||||
- `func (h *HealthServer) AddCheck(check HealthCheck)`: Appends a health-check callback under lock.
|
||||
- `func (h *HealthServer) SetReady(ready bool)`: Updates the readiness flag used by `/ready`.
|
||||
- `func (h *HealthServer) Start() error`: Installs `/health` and `/ready` handlers, listens on `addr`, stores the listener and `http.Server`, and serves in a goroutine.
|
||||
- `func (h *HealthServer) Stop(ctx context.Context) error`: Calls `Shutdown` on the underlying `http.Server` when started; otherwise returns `nil`.
|
||||
- `func (h *HealthServer) Addr() string`: Returns the actual bound listener address after `Start`, or the configured address before startup.
|
||||
|
||||
### `PIDFile` Methods
|
||||
|
||||
- `func (p *PIDFile) Acquire() error`: Rejects a live existing PID file, deletes stale state, creates the parent directory when needed, and writes the current process ID.
|
||||
- `func (p *PIDFile) Release() error`: Deletes the PID file.
|
||||
- `func (p *PIDFile) Path() string`: Returns the configured PID-file path.
|
||||
|
||||
### `ManagedProcess` Methods
|
||||
|
||||
- `func (p *ManagedProcess) Info() ProcessInfo`: Returns a snapshot containing public fields plus the current child PID.
|
||||
- `func (p *ManagedProcess) Output() string`: Returns captured output as a string, or `""` when capture is disabled.
|
||||
- `func (p *ManagedProcess) OutputBytes() []byte`: Returns captured output as bytes, or `nil` when capture is disabled.
|
||||
- `func (p *ManagedProcess) IsRunning() bool`: Reports running state by checking whether the `done` channel has closed.
|
||||
- `func (p *ManagedProcess) Wait() error`: Blocks for completion and then returns a wrapped error for failed-start, killed, or non-zero-exit outcomes.
|
||||
- `func (p *ManagedProcess) Done() <-chan struct{}`: Returns the completion channel.
|
||||
- `func (p *ManagedProcess) Kill() error`: Sends `SIGKILL` to the child, or to the entire process group when group killing is enabled.
|
||||
- `func (p *ManagedProcess) Shutdown() error`: Sends `SIGTERM`, waits for the configured grace period, and falls back to `Kill`. With no grace period configured, it immediately calls `Kill`.
|
||||
- `func (p *ManagedProcess) SendInput(input string) error`: Writes to the child's stdin pipe while the process is running.
|
||||
- `func (p *ManagedProcess) CloseStdin() error`: Closes the stdin pipe and clears the stored handle.
|
||||
- `func (p *ManagedProcess) Signal(sig os.Signal) error`: Sends an arbitrary signal while the process is in `StatusRunning`.
|
||||
|
||||
### `Program` Methods
|
||||
|
||||
- `func (p *Program) Find() error`: Resolves `Name` through `exec.LookPath`, stores the absolute path in `Path`, and wraps `ErrProgramNotFound` when lookup fails.
|
||||
- `func (p *Program) Run(ctx context.Context, args ...string) (string, error)`: Executes the program in the current working directory by delegating to `RunDir("", args...)`.
|
||||
- `func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)`: Runs the program with combined stdout/stderr capture, trims the combined output, and returns that output even when the command fails.
|
||||
|
||||
### `Registry` Methods
|
||||
|
||||
- `func (r *Registry) Register(entry DaemonEntry) error`: Ensures the registry directory exists, defaults `Started` when zero, marshals the entry with the package's JSON writer, and writes one `<code>-<daemon>.json` file.
|
||||
- `func (r *Registry) Unregister(code, daemon string) error`: Deletes the registry file for the supplied daemon key.
|
||||
- `func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool)`: Reads one entry, prunes invalid or stale files, and returns `(nil, false)` when the daemon is missing or dead.
|
||||
- `func (r *Registry) List() ([]DaemonEntry, error)`: Lists all JSON files in the registry directory, prunes invalid or stale entries, and returns only live daemons. A missing registry directory returns `nil, nil`.
|
||||
|
||||
### `RunResult` and `RunAllResult` Methods
|
||||
|
||||
- `func (r RunResult) Passed() bool`: Returns true only when the result is not skipped, has no `Error`, and has `ExitCode == 0`.
|
||||
- `func (r RunAllResult) Success() bool`: Returns true when `Failed == 0`, regardless of skipped count.
|
||||
|
||||
### `Runner` Methods
|
||||
|
||||
- `func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Executes dependency-aware waves of specs, skips dependents after failing required dependencies, and marks circular or missing dependency sets as failed results with `ExitCode` 1.
|
||||
- `func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs specs in order and marks remaining specs skipped after the first disallowed failure.
|
||||
- `func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs all specs concurrently and aggregates counts after all goroutines finish.
|
||||
|
||||
### `Service` Methods
|
||||
|
||||
- `func (s *Service) OnStartup(ctx context.Context) core.Result`: Registers the Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
|
||||
- `func (s *Service) OnShutdown(ctx context.Context) core.Result`: Iterates all managed processes and calls `Kill` on each one.
|
||||
- `func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper that builds `RunOptions` and delegates to `StartWithOptions`.
|
||||
- `func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result`: Starts a managed process, configures pipes, optional capture, detach and timeout behavior, stores it in the registry, emits `ActionProcessStarted`, streams stdout/stderr lines, and emits `ActionProcessExited` after completion.
|
||||
- `func (s *Service) Get(id string) (*ManagedProcess, error)`: Returns one managed process or `ErrProcessNotFound`.
|
||||
- `func (s *Service) List() []*ManagedProcess`: Returns all managed processes currently stored in the service registry.
|
||||
- `func (s *Service) Running() []*ManagedProcess`: Returns only processes whose `done` channel has not closed yet.
|
||||
- `func (s *Service) Kill(id string) error`: Kills the managed process by ID and emits `ActionProcessKilled`.
|
||||
- `func (s *Service) Remove(id string) error`: Deletes a completed process from the registry and rejects removal while it is still running.
|
||||
- `func (s *Service) Clear()`: Deletes every completed process from the registry.
|
||||
- `func (s *Service) Output(id string) (string, error)`: Returns the managed process's captured output.
|
||||
- `func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper around `RunWithOptions`.
|
||||
- `func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result`: Executes an unmanaged one-shot command with `CombinedOutput`. On success it returns the output string in `Value`; on failure it returns a wrapped error in `Value` and sets `OK` false.
|
||||
38
types.go
38
types.go
|
|
@ -5,30 +5,24 @@
|
|||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// // Register with Core
|
||||
// core, _ := framework.New(
|
||||
// framework.WithName("process", process.NewService(process.Options{})),
|
||||
// )
|
||||
// c := core.New(core.WithService(process.Register))
|
||||
// _ = c.ServiceStartup(ctx, nil)
|
||||
//
|
||||
// // Get service and run a process
|
||||
// svc, err := framework.ServiceFor[*process.Service](core, "process")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// proc, err := svc.Start(ctx, "go", "test", "./...")
|
||||
// r := c.Process().Run(ctx, "go", "test", "./...")
|
||||
// output := r.Value.(string)
|
||||
//
|
||||
// # Listening for Events
|
||||
//
|
||||
// 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) {
|
||||
// case process.ActionProcessOutput:
|
||||
// fmt.Print(m.Line)
|
||||
// case process.ActionProcessExited:
|
||||
// fmt.Printf("Exit code: %d\n", m.ExitCode)
|
||||
// }
|
||||
// return nil
|
||||
// return core.Result{OK: true}
|
||||
// })
|
||||
package process
|
||||
|
||||
|
|
@ -77,17 +71,33 @@ type RunOptions struct {
|
|||
// Detached processes survive parent death and context cancellation.
|
||||
// The context is replaced with context.Background() when Detach is true.
|
||||
Detach bool
|
||||
// Timeout is the maximum duration the process may run.
|
||||
// After this duration, the process receives SIGTERM (or SIGKILL if
|
||||
// GracePeriod is zero). Zero means no timeout.
|
||||
Timeout time.Duration
|
||||
// GracePeriod is the time between SIGTERM and SIGKILL when stopping
|
||||
// a process (via timeout or Shutdown). Zero means immediate SIGKILL.
|
||||
// Default: 0 (immediate kill for backwards compatibility).
|
||||
GracePeriod time.Duration
|
||||
// KillGroup kills the entire process group instead of just the leader.
|
||||
// Requires Detach to be true (process must be its own group leader).
|
||||
// This ensures child processes spawned by the command are also killed.
|
||||
KillGroup bool
|
||||
}
|
||||
|
||||
// Info provides a snapshot of process state without internal fields.
|
||||
type Info struct {
|
||||
// ProcessInfo provides a snapshot of process state without internal fields.
|
||||
type ProcessInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Dir string `json:"dir"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Running bool `json:"running"`
|
||||
Status Status `json:"status"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
|
||||
// Info is kept as a compatibility alias for ProcessInfo.
|
||||
type Info = ProcessInfo
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
||||
import type { ProcessInfo } from './shared/api.js';
|
||||
|
||||
/**
|
||||
|
|
@ -185,23 +186,45 @@ export class ProcessList extends LitElement {
|
|||
`;
|
||||
|
||||
@property({ attribute: 'api-url' }) apiUrl = '';
|
||||
@property({ attribute: 'ws-url' }) wsUrl = '';
|
||||
@property({ attribute: 'selected-id' }) selectedId = '';
|
||||
|
||||
@state() private processes: ProcessInfo[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private error = '';
|
||||
@state() private killing = new Set<string>();
|
||||
@state() private connected = false;
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadProcesses();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('wsUrl')) {
|
||||
this.disconnect();
|
||||
this.processes = [];
|
||||
this.loadProcesses();
|
||||
}
|
||||
}
|
||||
|
||||
async loadProcesses() {
|
||||
// Process-level REST endpoints are not yet available.
|
||||
// This element will populate via WS events once endpoints exist.
|
||||
// The process list is built from the shared process event stream.
|
||||
this.error = '';
|
||||
this.loading = false;
|
||||
this.processes = [];
|
||||
|
||||
if (!this.wsUrl) {
|
||||
this.processes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private handleSelect(proc: ProcessInfo) {
|
||||
|
|
@ -228,6 +251,84 @@ export class ProcessList extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
||||
this.applyEvent(event);
|
||||
});
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
};
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private applyEvent(event: ProcessEvent) {
|
||||
const channel = event.channel ?? event.type ?? '';
|
||||
const data = (event.data ?? {}) as Partial<ProcessInfo> & {
|
||||
id?: string;
|
||||
signal?: string;
|
||||
};
|
||||
|
||||
if (!data.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
|
||||
const current = next.get(data.id);
|
||||
|
||||
if (channel === 'process.started') {
|
||||
next.set(data.id, this.normalizeProcess(data, current, 'running'));
|
||||
this.processes = this.sortProcesses(next);
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
data: Partial<ProcessInfo> & { id: string; signal?: string },
|
||||
current: ProcessInfo | undefined,
|
||||
status: ProcessInfo['status'],
|
||||
): ProcessInfo {
|
||||
return {
|
||||
id: data.id,
|
||||
command: data.command ?? current?.command ?? '',
|
||||
args: data.args ?? current?.args ?? [],
|
||||
dir: data.dir ?? current?.dir ?? '',
|
||||
startedAt: data.startedAt ?? current?.startedAt ?? new Date().toISOString(),
|
||||
status,
|
||||
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
|
||||
duration: data.duration ?? current?.duration ?? 0,
|
||||
pid: data.pid ?? current?.pid ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
|
||||
return [...processes.values()].sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`<div class="loading">Loading processes\u2026</div>`;
|
||||
|
|
@ -238,8 +339,11 @@ export class ProcessList extends LitElement {
|
|||
${this.processes.length === 0
|
||||
? html`
|
||||
<div class="info-notice">
|
||||
Process list endpoints are pending. Processes will appear here once
|
||||
the REST API for managed processes is available.
|
||||
${this.wsUrl
|
||||
? this.connected
|
||||
? 'Waiting for process events from the WebSocket feed.'
|
||||
: 'Connecting to the process event stream...'
|
||||
: 'Set a WebSocket URL to receive live process events.'}
|
||||
</div>
|
||||
<div class="empty">No managed processes.</div>
|
||||
`
|
||||
|
|
@ -275,12 +379,12 @@ export class ProcessList extends LitElement {
|
|||
<div class="item-actions">
|
||||
<button
|
||||
class="kill-btn"
|
||||
?disabled=${this.killing.has(proc.id)}
|
||||
disabled
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'}
|
||||
Live only
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ export class ProcessPanel extends LitElement {
|
|||
return html`
|
||||
<core-process-list
|
||||
api-url=${this.apiUrl}
|
||||
ws-url=${this.wsUrl}
|
||||
@process-selected=${this.handleProcessSelected}
|
||||
></core-process-list>
|
||||
${this.selectedProcessId
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue