From e75cb1fc9742697df41c3ff53e5ee43531cea6f8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 05:41:40 +0000 Subject: [PATCH] docs(ax): add RFC/spec artifacts for AX contract alignment --- docs/RFC.md | 302 ++++++++++++++ .../plans/2026-03-25-v0.7.0-core-alignment.md | 151 +++++++ exec/doc.go | 6 + specs/api/RFC.md | 29 ++ specs/exec/RFC.md | 68 ++++ specs/process-ui.md | 207 ++++++++++ specs/process.md | 372 ++++++++++++++++++ 7 files changed, 1135 insertions(+) create mode 100644 docs/RFC.md create mode 100644 docs/plans/2026-03-25-v0.7.0-core-alignment.md create mode 100644 exec/doc.go create mode 100644 specs/api/RFC.md create mode 100644 specs/exec/RFC.md create mode 100644 specs/process-ui.md create mode 100644 specs/process.md diff --git a/docs/RFC.md b/docs/RFC.md new file mode 100644 index 0000000..15ff2da --- /dev/null +++ b/docs/RFC.md @@ -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-25) + +The codebase is PRE-migration. The RFC describes the v0.8.0 target. What exists today: + +- `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 — WILL CONTAIN Action handlers after migration +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. diff --git a/docs/plans/2026-03-25-v0.7.0-core-alignment.md b/docs/plans/2026-03-25-v0.7.0-core-alignment.md new file mode 100644 index 0000000..b4b2e93 --- /dev/null +++ b/docs/plans/2026-03-25-v0.7.0-core-alignment.md @@ -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 diff --git a/exec/doc.go b/exec/doc.go new file mode 100644 index 0000000..b43ef6a --- /dev/null +++ b/exec/doc.go @@ -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 diff --git a/specs/api/RFC.md b/specs/api/RFC.md new file mode 100644 index 0000000..158b73f --- /dev/null +++ b/specs/api/RFC.md @@ -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. diff --git a/specs/exec/RFC.md b/specs/exec/RFC.md new file mode 100644 index 0000000..5a43ad8 --- /dev/null +++ b/specs/exec/RFC.md @@ -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("RunQuiet", 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("Cmd.Run", ...)`. +- `func (c *Cmd) Output() ([]byte, error)`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, returns stdout bytes, and wraps failures with `wrapError("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("Cmd.CombinedOutput", ...)`. + +### `NopLogger` Methods + +- `func (NopLogger) Debug(string, ...any)`: Discards the message. +- `func (NopLogger) Error(string, ...any)`: Discards the message. diff --git a/specs/process-ui.md b/specs/process-ui.md new file mode 100644 index 0000000..77ad8fb --- /dev/null +++ b/specs/process-ui.md @@ -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`: Fetches `GET /api/process/daemons`. +- `getDaemon(code: string, daemon: string): Promise`: 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`: 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 ``. + +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 ``. + +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 ``. + +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 ``. + +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 ``. + +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`: 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`: 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): 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`: 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`: Returns the `data` field from a successful daemon-list response. +- `getDaemon(code: string, daemon: string): Promise`: 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`: Returns the daemon-health payload. diff --git a/specs/process.md b/specs/process.md new file mode 100644 index 0000000..a6f4460 --- /dev/null +++ b/specs/process.md @@ -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:///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 `-.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.