Compare commits

...

34 commits
v0.2.5 ... dev

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

RegisterAction handler signature: func(*Core, Message) Result (was error)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 00:04:07 +00:00
Claude
a09ca4f408
chore: migrate to dappco.re vanity import path
Module path: forge.lthn.ai/core/go-process -> dappco.re/go/core/process

Import path updates:
- forge.lthn.ai/core/go-log -> dappco.re/go/core/log
- forge.lthn.ai/core/go-io -> dappco.re/go/core/io
- forge.lthn.ai/core/go-ws -> dappco.re/go/core/ws
- forge.lthn.ai/core/go-process (self) -> dappco.re/go/core/process
- forge.lthn.ai/core/api left as-is (not yet migrated)

Local replace directives added until vanity URL server is configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:49:08 +00:00
Snider
93f3ab054c refactor: migrate core import to dappco.re/go/core
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 20:00:57 +00:00
94c4fde4b0 Merge pull request '[agent/claude:sonnet] Implement the plan at docs/plans/2026-03-18-absorb-sail-prog...' (#3) from agent/implement-the-plan-at-docs-plans-2026-03 into main 2026-03-18 15:07:17 +00:00
Snider
87b16ca41c feat(process): add Program struct with Find, Run, RunDir
Add Program to the process package as a lightweight tool-finder and runner.
Find() resolves a binary via exec.LookPath (wrapping ErrProgramNotFound),
Run() and RunDir() execute the binary and return trimmed combined output.
Includes 7 tests covering happy paths, error paths, and the errors.Is contract.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-18 15:07:03 +00:00
Snider
e0df0d3b34 chore: sync dependencies for v0.2.9
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:53:00 +00:00
Snider
cdea149a20 chore: sync dependencies for v0.2.8
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:48:46 +00:00
Snider
1a015ed6ba feat: add Timeout, GracePeriod, KillGroup, Shutdown to RunOptions
- RunOptions.Timeout: auto-kills after duration (SIGTERM → SIGKILL)
- RunOptions.GracePeriod: time between SIGTERM and SIGKILL
- RunOptions.KillGroup: kills entire process group (child processes too)
- Process.Shutdown(): graceful SIGTERM → wait → SIGKILL
- OnShutdown uses Shutdown() instead of Kill() for graceful stops
- Runner circular deps now count as Failed, not Skipped
- Tests for all new features

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:44:37 +00:00
eac2d0b0cd Merge pull request '[agent/claude:opus] Fix CodeRabbit findings. Verify each against current code, f...' (#2) from agent/fix-coderabbit-findings--verify-each-aga into main 2026-03-17 13:13:37 +00:00
Snider
f3e995ffd0 fix(coderabbit): address review findings
- Assert SetDefault error return in tests instead of ignoring it
- Pass caller location to wrapError for accurate error traces
- daemon.go reviewed: no actionable finding

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 13:13:26 +00:00
75cb802ef6 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#1) from agent/dx-audit-and-fix--1--review-claude-md into main 2026-03-17 08:43:17 +00:00
Snider
87ef2dbe16 fix(dx): audit CLAUDE.md, error handling, and test coverage
- Update CLAUDE.md: document Detach, DisableCapture, ShutdownTimeout,
  auto-registration, graceful shutdown, and error handling conventions;
  add missing go-log and go-io dependencies
- Replace ServiceError type in process_global.go with coreerr.E()
  sentinel errors for consistency with the rest of the package
- Wrap raw error returns in Registry.Register, Registry.Unregister,
  and PIDFile.Release with coreerr.E() for proper context
- Add tests for Service.Kill, Service.Output, Service.OnShutdown,
  Service.OnStartup, Service.RunWithOptions, Service.Running,
  Process.Signal, Daemon.Run (context cancellation),
  Daemon.Stop (idempotent), DisableCapture, Detach, env vars,
  exec.WithDir, exec.WithEnv, exec.WithStdin/Stdout/Stderr,
  exec.RunQuiet
- Coverage: root 82.7% → 88.3%, exec/ 61.9% → 87.3%

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:42:56 +00:00
Snider
d955ffc0e7 chore: sync dependencies for v0.2.7
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:17:52 +00:00
Snider
d73dfa3d73 refactor(process): replace fmt.Errorf and errors.New with coreerr.E()
Replace all 27 instances of fmt.Errorf/errors.New in production code
with coreerr.E() from forge.lthn.ai/core/go-log for structured error
context (op, message, cause). Promote go-log from indirect to direct
dependency in go.mod.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 20:38:08 +00:00
44 changed files with 3843 additions and 1302 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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
}

View file

@ -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
View file

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

View file

@ -60,32 +60,28 @@ participate in the Core DI container and implements both `Startable` and
```go
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", "./...").

View file

@ -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

View file

@ -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

View 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

View file

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

6
exec/doc.go Normal file
View file

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

View file

@ -3,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...)
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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
};

View file

@ -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
}

View file

@ -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
}

View file

@ -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
View 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
View 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)
}

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,
})
}

View file

@ -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
View file

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

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

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

207
specs/process-ui.md Normal file
View file

@ -0,0 +1,207 @@
# @core/process-ui
**Import:** `@core/process-ui`
**Files:** 8
## Types
### `DaemonEntry`
`interface`
Daemon-registry row returned by `ProcessApi.listDaemons` and `ProcessApi.getDaemon`.
Properties:
- `code: string`: Application or component code.
- `daemon: string`: Daemon name.
- `pid: number`: Process ID.
- `health?: string`: Optional health-endpoint address.
- `project?: string`: Optional project label.
- `binary?: string`: Optional binary label.
- `started: string`: Start timestamp string from the API.
### `HealthResult`
`interface`
Result returned by the daemon health endpoint.
Properties:
- `healthy: boolean`: Health outcome.
- `address: string`: Health endpoint address that was checked.
- `reason?: string`: Optional explanation such as the absence of a health endpoint.
### `ProcessInfo`
`interface`
Process snapshot shape used by the UI package.
Properties:
- `id: string`: Managed-process identifier.
- `command: string`: Executable name.
- `args: string[]`: Command arguments.
- `dir: string`: Working directory.
- `startedAt: string`: Start timestamp string.
- `status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'`: Process status string.
- `exitCode: number`: Exit code.
- `duration: number`: Numeric duration value from the API payload.
- `pid: number`: Child PID.
### `RunResult`
`interface`
Pipeline result row used by `ProcessRunner`.
Properties:
- `name: string`: Spec name.
- `exitCode: number`: Exit code.
- `duration: number`: Numeric duration value.
- `output: string`: Captured output.
- `error?: string`: Optional error message.
- `skipped: boolean`: Whether the spec was skipped.
- `passed: boolean`: Whether the spec passed.
### `RunAllResult`
`interface`
Aggregate pipeline result consumed by `ProcessRunner`.
Properties:
- `results: RunResult[]`: Per-spec results.
- `duration: number`: Aggregate duration.
- `passed: number`: Count of passed specs.
- `failed: number`: Count of failed specs.
- `skipped: number`: Count of skipped specs.
- `success: boolean`: Aggregate success flag.
### `ProcessApi`
`class`
Typed fetch client for `/api/process/*`.
Public API:
- `new ProcessApi(baseUrl?: string)`: Stores an optional URL prefix. The default is `""`.
- `listDaemons(): Promise<DaemonEntry[]>`: Fetches `GET /api/process/daemons`.
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Fetches one daemon entry.
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Sends `POST /api/process/daemons/:code/:daemon/stop`.
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Fetches `GET /api/process/daemons/:code/:daemon/health`.
### `ProcessEvent`
`interface`
Event envelope consumed by `connectProcessEvents`.
Properties:
- `type: string`: Event type.
- `channel?: string`: Optional channel name.
- `data?: any`: Event payload.
- `timestamp?: string`: Optional timestamp string.
### `ProcessPanel`
`class`
Top-level custom element registered as `<core-process-panel>`.
Public properties:
- `apiUrl: string`: Forwarded to child elements through the `api-url` attribute.
- `wsUrl: string`: WebSocket endpoint URL from the `ws-url` attribute.
Behavior:
- Renders tabbed daemon, process, and pipeline views.
- Opens a process-event WebSocket when `wsUrl` is set.
- Shows the last received process channel or event type in the footer.
### `ProcessDaemons`
`class`
Daemon-list custom element registered as `<core-process-daemons>`.
Public properties:
- `apiUrl: string`: Base URL prefix for `ProcessApi`.
Behavior:
- Loads daemon entries on connect.
- Can trigger per-daemon health checks and stop requests.
- Emits `daemon-stopped` after a successful stop request.
### `ProcessList`
`class`
Managed-process list custom element registered as `<core-process-list>`.
Public properties:
- `apiUrl: string`: Declared API prefix property.
- `selectedId: string`: Selected process ID, reflected from `selected-id`.
Behavior:
- Emits `process-selected` when a row is chosen.
- Currently renders from local state only because the process REST endpoints referenced by the component are not implemented in this package.
### `ProcessOutput`
`class`
Live output custom element registered as `<core-process-output>`.
Public properties:
- `apiUrl: string`: Declared API prefix property. The current implementation does not use it.
- `wsUrl: string`: WebSocket endpoint URL.
- `processId: string`: Selected process ID from the `process-id` attribute.
Behavior:
- Connects to the WebSocket when both `wsUrl` and `processId` are present.
- Filters for `process.output` events whose payload `data.id` matches `processId`.
- Appends output lines and auto-scrolls by default.
### `ProcessRunner`
`class`
Pipeline-results custom element registered as `<core-process-runner>`.
Public properties:
- `apiUrl: string`: Declared API prefix property.
- `result: RunAllResult | null`: Aggregate pipeline result used for rendering.
Behavior:
- Renders summary counts plus expandable per-spec output.
- Depends on the `result` property today because pipeline REST endpoints are not implemented in the package.
## Functions
### Package Functions
- `function connectProcessEvents(wsUrl: string, handler: (event: ProcessEvent) => void): WebSocket`: Opens a WebSocket, parses incoming JSON, forwards only messages whose `type` or `channel` starts with `process.`, ignores malformed payloads, and returns the `WebSocket` instance.
### `ProcessPanel` Methods
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` is set.
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
- `render(): unknown`: Renders the header, tab strip, active child element, and connection footer.
### `ProcessDaemons` Methods
- `connectedCallback(): void`: Instantiates `ProcessApi` and loads daemon data.
- `loadDaemons(): Promise<void>`: Fetches daemon entries, stores them in component state, and records any request error message.
- `render(): unknown`: Renders the daemon list, loading state, empty state, and action buttons.
### `ProcessList` Methods
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadProcesses`.
- `loadProcesses(): Promise<void>`: Current placeholder implementation that clears state because the referenced process REST endpoints are not implemented yet.
- `render(): unknown`: Renders the process list or an informational empty state explaining the missing REST support.
### `ProcessOutput` Methods
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` and `processId` are both set.
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
- `updated(changed: Map<string, unknown>): void`: Reconnects when `processId` or `wsUrl` changes, resets buffered lines on reconnection, and auto-scrolls when enabled.
- `render(): unknown`: Renders the output panel, waiting state, and accumulated stdout or stderr lines.
### `ProcessRunner` Methods
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadResults`.
- `loadResults(): Promise<void>`: Current placeholder method. The implementation is empty because pipeline endpoints are not present.
- `render(): unknown`: Renders the empty-state notice when `result` is absent, or the aggregate summary plus per-spec details when `result` is present.
### `ProcessApi` Methods
- `listDaemons(): Promise<DaemonEntry[]>`: Returns the `data` field from a successful daemon-list response.
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Returns one daemon entry from the provider API.
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Issues the stop request and returns the provider's `{ stopped }` payload.
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Returns the daemon-health payload.

372
specs/process.md Normal file
View file

@ -0,0 +1,372 @@
# process
**Import:** `dappco.re/go/core/process`
**Files:** 11
## Types
### `ActionProcessStarted`
`struct`
Broadcast payload for a managed process that has successfully started.
Fields:
- `ID string`: Generated managed-process identifier.
- `Command string`: Executable name passed to the service.
- `Args []string`: Argument vector used to start the process.
- `Dir string`: Working directory supplied at start time.
- `PID int`: OS process ID of the child process.
### `ActionProcessOutput`
`struct`
Broadcast payload for one scanned line of process output.
Fields:
- `ID string`: Managed-process identifier.
- `Line string`: One line from stdout or stderr, without the trailing newline.
- `Stream Stream`: Output source, using `StreamStdout` or `StreamStderr`.
### `ActionProcessExited`
`struct`
Broadcast payload emitted after the service wait goroutine finishes.
Fields:
- `ID string`: Managed-process identifier.
- `ExitCode int`: Process exit code.
- `Duration time.Duration`: Time elapsed since `StartedAt`.
- `Error error`: Declared error slot for exit metadata. The current `Service` emitter does not populate it.
### `ActionProcessKilled`
`struct`
Broadcast payload emitted by `Service.Kill`.
Fields:
- `ID string`: Managed-process identifier.
- `Signal string`: Signal name reported by the service. The current implementation emits `"SIGKILL"`.
### `RingBuffer`
`struct`
Fixed-size circular byte buffer used for captured process output. The implementation is mutex-protected and overwrites the oldest bytes when full.
Exported fields:
- None.
### `DaemonOptions`
`struct`
Configuration for `NewDaemon`.
Fields:
- `PIDFile string`: PID file path. Empty disables PID-file management.
- `ShutdownTimeout time.Duration`: Grace period used by `Stop`. Zero is normalized to 30 seconds by `NewDaemon`.
- `HealthAddr string`: Listen address for the health server. Empty disables health endpoints.
- `HealthChecks []HealthCheck`: Additional `/health` checks to register on the health server.
- `Registry *Registry`: Optional daemon registry used for automatic register/unregister.
- `RegistryEntry DaemonEntry`: Base registry payload. `Start` fills in `PID`, `Health`, and `Started` behavior through `Registry.Register`.
### `Daemon`
`struct`
Lifecycle wrapper around a PID file, optional health server, and optional registry entry.
Exported fields:
- None.
### `HealthCheck`
`type HealthCheck func() error`
Named function type used by `HealthServer` and `DaemonOptions`. Returning `nil` marks the check healthy; returning an error makes `/health` respond with `503`.
### `HealthServer`
`struct`
HTTP server exposing `/health` and `/ready` endpoints.
Exported fields:
- None.
### `PIDFile`
`struct`
Single-instance guard backed by a PID file on disk.
Exported fields:
- None.
### `ManagedProcess`
`struct`
Service-owned process record for a started child process.
Fields:
- `ID string`: Managed-process identifier generated by `core.ID()`.
- `Command string`: Executable name.
- `Args []string`: Command arguments.
- `Dir string`: Working directory used when starting the process.
- `Env []string`: Extra environment entries appended to the command environment.
- `StartedAt time.Time`: Timestamp recorded immediately before `cmd.Start`.
- `Status Status`: Current lifecycle state tracked by the service.
- `ExitCode int`: Exit status after completion.
- `Duration time.Duration`: Runtime duration set when the wait goroutine finishes.
### `Process`
`type alias of ManagedProcess`
Compatibility alias that exposes the same fields and methods as `ManagedProcess`.
### `Program`
`struct`
Thin helper for finding and running a named executable.
Fields:
- `Name string`: Binary name to look up or execute.
- `Path string`: Resolved absolute path populated by `Find`. When empty, `Run` and `RunDir` fall back to `Name`.
### `DaemonEntry`
`struct`
Serialized daemon-registry record written as JSON.
Fields:
- `Code string`: Application or component code.
- `Daemon string`: Daemon name within that code.
- `PID int`: Running process ID.
- `Health string`: Health endpoint address, if any.
- `Project string`: Optional project label.
- `Binary string`: Optional binary label.
- `Started time.Time`: Start timestamp persisted in RFC3339Nano format.
### `Registry`
`struct`
Filesystem-backed daemon registry that stores one JSON file per daemon entry.
Exported fields:
- None.
### `Runner`
`struct`
Pipeline orchestrator that starts `RunSpec` processes through a `Service`.
Exported fields:
- None.
### `RunSpec`
`struct`
One process specification for `Runner`.
Fields:
- `Name string`: Friendly name used for dependencies and result reporting.
- `Command string`: Executable name.
- `Args []string`: Command arguments.
- `Dir string`: Working directory.
- `Env []string`: Additional environment variables.
- `After []string`: Dependency names that must complete before this spec can run in `RunAll`.
- `AllowFailure bool`: When true, downstream work is not skipped because of this spec's failure.
### `RunResult`
`struct`
Per-spec runner result.
Fields:
- `Name string`: Spec name.
- `Spec RunSpec`: Original spec payload.
- `ExitCode int`: Exit code from the managed process.
- `Duration time.Duration`: Process duration or start-attempt duration.
- `Output string`: Captured output returned from the managed process.
- `Error error`: Start or orchestration error. For a started process that exits non-zero, this remains `nil`.
- `Skipped bool`: Whether the spec was skipped instead of run.
### `RunAllResult`
`struct`
Aggregate result returned by `RunAll`, `RunSequential`, and `RunParallel`.
Fields:
- `Results []RunResult`: Collected per-spec results.
- `Duration time.Duration`: End-to-end runtime for the orchestration method.
- `Passed int`: Count of results where `Passed()` is true.
- `Failed int`: Count of non-skipped results that did not pass.
- `Skipped int`: Count of skipped results.
### `Service`
`struct`
Core service that owns managed processes and registers action handlers.
Fields:
- `*core.ServiceRuntime[Options]`: Embedded Core runtime used for lifecycle hooks and access to `Core()`.
### `Options`
`struct`
Service configuration.
Fields:
- `BufferSize int`: Ring-buffer capacity for captured output. `Register` currently initializes this from `DefaultBufferSize`.
### `Status`
`type Status string`
Named lifecycle-state type for a managed process.
Exported values:
- `StatusPending`: queued but not started.
- `StatusRunning`: currently executing.
- `StatusExited`: completed and waited.
- `StatusFailed`: start or wait failure state.
- `StatusKilled`: terminated by signal.
### `Stream`
`type Stream string`
Named output-stream discriminator for process output events.
Exported values:
- `StreamStdout`: stdout line.
- `StreamStderr`: stderr line.
### `RunOptions`
`struct`
Execution settings accepted by `Service.StartWithOptions` and `Service.RunWithOptions`.
Fields:
- `Command string`: Executable name. Required by both start and run paths.
- `Args []string`: Command arguments.
- `Dir string`: Working directory.
- `Env []string`: Additional environment entries appended to the command environment.
- `DisableCapture bool`: Disables the managed-process ring buffer when true.
- `Detach bool`: Starts the child in a separate process group and replaces the parent context with `context.Background()`.
- `Timeout time.Duration`: Optional watchdog timeout that calls `Shutdown` after the duration elapses.
- `GracePeriod time.Duration`: Delay between `SIGTERM` and fallback kill in `Shutdown`.
- `KillGroup bool`: Requests process-group termination. The current service only enables this when `Detach` is also true.
### `ProcessInfo`
`struct`
Serializable snapshot returned by `ManagedProcess.Info` and `Service` action lookups.
Fields:
- `ID string`: Managed-process identifier.
- `Command string`: Executable name.
- `Args []string`: Command arguments.
- `Dir string`: Working directory.
- `StartedAt time.Time`: Start timestamp.
- `Running bool`: Convenience boolean derived from `Status`.
- `Status Status`: Current lifecycle state.
- `ExitCode int`: Exit status.
- `Duration time.Duration`: Runtime duration.
- `PID int`: Child PID, or `0` if no process handle is available.
### `Info`
`type alias of ProcessInfo`
Compatibility alias that exposes the same fields as `ProcessInfo`.
## Functions
### Package Functions
- `func Register(c *core.Core) core.Result`: Builds a `Service` with a fresh `core.Registry[*ManagedProcess]`, sets the buffer size to `DefaultBufferSize`, and returns the service in `Result.Value`.
- `func NewRingBuffer(size int) *RingBuffer`: Allocates a fixed-capacity ring buffer of exactly `size` bytes.
- `func NewDaemon(opts DaemonOptions) *Daemon`: Normalizes `ShutdownTimeout`, creates optional `PIDFile` and `HealthServer` helpers, and attaches any configured health checks.
- `func NewHealthServer(addr string) *HealthServer`: Returns a health server with the supplied listen address and readiness initialized to `true`.
- `func WaitForHealth(addr string, timeoutMs int) bool`: Polls `http://<addr>/health` every 200 ms until it gets HTTP 200 or the timeout expires.
- `func NewPIDFile(path string) *PIDFile`: Returns a PID-file manager for `path`.
- `func ReadPID(path string) (int, bool)`: Reads and parses a PID file, then uses signal `0` to report whether that PID is still alive.
- `func NewRegistry(dir string) *Registry`: Returns a daemon registry rooted at `dir`.
- `func DefaultRegistry() *Registry`: Returns a registry at `~/.core/daemons`, falling back to the OS temp directory if the home directory cannot be resolved.
- `func NewRunner(svc *Service) *Runner`: Returns a runner bound to a specific `Service`.
### `RingBuffer` Methods
- `func (rb *RingBuffer) Write(p []byte) (n int, err error)`: Appends bytes one by one, advancing the circular window and overwriting the oldest bytes when capacity is exceeded.
- `func (rb *RingBuffer) String() string`: Returns the current buffer contents in logical order as a string.
- `func (rb *RingBuffer) Bytes() []byte`: Returns a copied byte slice of the current logical contents, or `nil` when the buffer is empty.
- `func (rb *RingBuffer) Len() int`: Returns the number of bytes currently retained.
- `func (rb *RingBuffer) Cap() int`: Returns the configured capacity.
- `func (rb *RingBuffer) Reset()`: Clears the buffer indexes and full flag.
### `Daemon` Methods
- `func (d *Daemon) Start() error`: Acquires the PID file, starts the health server, marks the daemon running, and auto-registers it when `Registry` is configured. If a later step fails, it rolls back earlier setup.
- `func (d *Daemon) Run(ctx context.Context) error`: Requires a started daemon, waits for `ctx.Done()`, and then calls `Stop`.
- `func (d *Daemon) Stop() error`: Sets readiness false, shuts down the health server, releases the PID file, unregisters the daemon, and joins health or PID teardown errors with `core.ErrorJoin`.
- `func (d *Daemon) SetReady(ready bool)`: Forwards readiness changes to the health server when one exists.
- `func (d *Daemon) HealthAddr() string`: Returns the bound health-server address or `""` when health endpoints are disabled.
### `HealthServer` Methods
- `func (h *HealthServer) AddCheck(check HealthCheck)`: Appends a health-check callback under lock.
- `func (h *HealthServer) SetReady(ready bool)`: Updates the readiness flag used by `/ready`.
- `func (h *HealthServer) Start() error`: Installs `/health` and `/ready` handlers, listens on `addr`, stores the listener and `http.Server`, and serves in a goroutine.
- `func (h *HealthServer) Stop(ctx context.Context) error`: Calls `Shutdown` on the underlying `http.Server` when started; otherwise returns `nil`.
- `func (h *HealthServer) Addr() string`: Returns the actual bound listener address after `Start`, or the configured address before startup.
### `PIDFile` Methods
- `func (p *PIDFile) Acquire() error`: Rejects a live existing PID file, deletes stale state, creates the parent directory when needed, and writes the current process ID.
- `func (p *PIDFile) Release() error`: Deletes the PID file.
- `func (p *PIDFile) Path() string`: Returns the configured PID-file path.
### `ManagedProcess` Methods
- `func (p *ManagedProcess) Info() ProcessInfo`: Returns a snapshot containing public fields plus the current child PID.
- `func (p *ManagedProcess) Output() string`: Returns captured output as a string, or `""` when capture is disabled.
- `func (p *ManagedProcess) OutputBytes() []byte`: Returns captured output as bytes, or `nil` when capture is disabled.
- `func (p *ManagedProcess) IsRunning() bool`: Reports running state by checking whether the `done` channel has closed.
- `func (p *ManagedProcess) Wait() error`: Blocks for completion and then returns a wrapped error for failed-start, killed, or non-zero-exit outcomes.
- `func (p *ManagedProcess) Done() <-chan struct{}`: Returns the completion channel.
- `func (p *ManagedProcess) Kill() error`: Sends `SIGKILL` to the child, or to the entire process group when group killing is enabled.
- `func (p *ManagedProcess) Shutdown() error`: Sends `SIGTERM`, waits for the configured grace period, and falls back to `Kill`. With no grace period configured, it immediately calls `Kill`.
- `func (p *ManagedProcess) SendInput(input string) error`: Writes to the child's stdin pipe while the process is running.
- `func (p *ManagedProcess) CloseStdin() error`: Closes the stdin pipe and clears the stored handle.
- `func (p *ManagedProcess) Signal(sig os.Signal) error`: Sends an arbitrary signal while the process is in `StatusRunning`.
### `Program` Methods
- `func (p *Program) Find() error`: Resolves `Name` through `exec.LookPath`, stores the absolute path in `Path`, and wraps `ErrProgramNotFound` when lookup fails.
- `func (p *Program) Run(ctx context.Context, args ...string) (string, error)`: Executes the program in the current working directory by delegating to `RunDir("", args...)`.
- `func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)`: Runs the program with combined stdout/stderr capture, trims the combined output, and returns that output even when the command fails.
### `Registry` Methods
- `func (r *Registry) Register(entry DaemonEntry) error`: Ensures the registry directory exists, defaults `Started` when zero, marshals the entry with the package's JSON writer, and writes one `<code>-<daemon>.json` file.
- `func (r *Registry) Unregister(code, daemon string) error`: Deletes the registry file for the supplied daemon key.
- `func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool)`: Reads one entry, prunes invalid or stale files, and returns `(nil, false)` when the daemon is missing or dead.
- `func (r *Registry) List() ([]DaemonEntry, error)`: Lists all JSON files in the registry directory, prunes invalid or stale entries, and returns only live daemons. A missing registry directory returns `nil, nil`.
### `RunResult` and `RunAllResult` Methods
- `func (r RunResult) Passed() bool`: Returns true only when the result is not skipped, has no `Error`, and has `ExitCode == 0`.
- `func (r RunAllResult) Success() bool`: Returns true when `Failed == 0`, regardless of skipped count.
### `Runner` Methods
- `func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Executes dependency-aware waves of specs, skips dependents after failing required dependencies, and marks circular or missing dependency sets as failed results with `ExitCode` 1.
- `func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs specs in order and marks remaining specs skipped after the first disallowed failure.
- `func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs all specs concurrently and aggregates counts after all goroutines finish.
### `Service` Methods
- `func (s *Service) OnStartup(ctx context.Context) core.Result`: Registers the Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
- `func (s *Service) OnShutdown(ctx context.Context) core.Result`: Iterates all managed processes and calls `Kill` on each one.
- `func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper that builds `RunOptions` and delegates to `StartWithOptions`.
- `func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result`: Starts a managed process, configures pipes, optional capture, detach and timeout behavior, stores it in the registry, emits `ActionProcessStarted`, streams stdout/stderr lines, and emits `ActionProcessExited` after completion.
- `func (s *Service) Get(id string) (*ManagedProcess, error)`: Returns one managed process or `ErrProcessNotFound`.
- `func (s *Service) List() []*ManagedProcess`: Returns all managed processes currently stored in the service registry.
- `func (s *Service) Running() []*ManagedProcess`: Returns only processes whose `done` channel has not closed yet.
- `func (s *Service) Kill(id string) error`: Kills the managed process by ID and emits `ActionProcessKilled`.
- `func (s *Service) Remove(id string) error`: Deletes a completed process from the registry and rejects removal while it is still running.
- `func (s *Service) Clear()`: Deletes every completed process from the registry.
- `func (s *Service) Output(id string) (string, error)`: Returns the managed process's captured output.
- `func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper around `RunWithOptions`.
- `func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result`: Executes an unmanaged one-shot command with `CombinedOutput`. On success it returns the output string in `Value`; on failure it returns a wrapped error in `Value` and sets `OK` false.

View file

@ -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

View file

@ -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>
`

View file

@ -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