diff --git a/action.go b/action.go new file mode 100644 index 0000000..03f4348 --- /dev/null +++ b/action.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Named action system for the Core framework. +// Actions are the atomic unit of work — named, registered, invokable, +// and inspectable. The Action registry IS the capability map. +// +// Register a named action: +// +// c.Action("git.log", func(ctx context.Context, opts core.Options) core.Result { +// dir := opts.String("dir") +// return c.Process().RunIn(ctx, dir, "git", "log") +// }) +// +// Invoke by name: +// +// r := c.Action("git.log").Run(ctx, core.NewOptions( +// core.Option{Key: "dir", Value: "/path/to/repo"}, +// )) +// +// Check capability: +// +// if c.Action("process.run").Exists() { ... } +// +// List all: +// +// names := c.Actions() // ["process.run", "agentic.dispatch", ...] +package core + +import "context" + +// ActionHandler is the function signature for all named actions. +// +// func(ctx context.Context, opts core.Options) core.Result +type ActionHandler func(context.Context, Options) Result + +// Action is a registered named action. +// +// action := c.Action("process.run") +// action.Description // "Execute a command" +// action.Schema // expected input keys +type Action struct { + Name string + Handler ActionHandler + Description string + Schema Options // declares expected input keys (optional) + enabled bool +} + +// Run executes the action with panic recovery. +// Returns Result{OK: false} if the action has no handler (not registered). +// +// r := c.Action("process.run").Run(ctx, opts) +func (a *Action) Run(ctx context.Context, opts Options) (result Result) { + if a == nil || a.Handler == nil { + return Result{E("action.Run", Concat("action not registered: ", a.safeName()), nil), false} + } + if !a.enabled { + return Result{E("action.Run", Concat("action disabled: ", a.Name), nil), false} + } + defer func() { + if r := recover(); r != nil { + result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false} + } + }() + return a.Handler(ctx, opts) +} + +// Exists returns true if this action has a registered handler. +// +// if c.Action("process.run").Exists() { ... } +func (a *Action) Exists() bool { + return a != nil && a.Handler != nil +} + +func (a *Action) safeName() string { + if a == nil { + return "" + } + return a.Name +} + +// --- Core accessor --- + +// Action gets or registers a named action. +// With a handler argument: registers the action. +// Without: returns the action for invocation. +// +// c.Action("process.run", handler) // register +// c.Action("process.run").Run(ctx, opts) // invoke +// c.Action("process.run").Exists() // check +func (c *Core) Action(name string, handler ...ActionHandler) *Action { + if len(handler) > 0 { + def := &Action{Name: name, Handler: handler[0], enabled: true} + c.ipc.actions.Set(name, def) + return def + } + r := c.ipc.actions.Get(name) + if !r.OK { + return &Action{Name: name} // no handler — Exists() returns false + } + return r.Value.(*Action) +} + +// Actions returns all registered named action names in registration order. +// +// names := c.Actions() // ["process.run", "agentic.dispatch"] +func (c *Core) Actions() []string { + return c.ipc.actions.Names() +} + +// --- Task Composition --- + +// Step is a single step in a Task — references an Action by name. +// +// core.Step{Action: "agentic.qa"} +// core.Step{Action: "agentic.poke", Async: true} +// core.Step{Action: "agentic.verify", Input: "previous"} +type Step struct { + Action string // name of the Action to invoke + With Options // static options (merged with runtime opts) + Async bool // run in background, don't block + Input string // "previous" = output of last step piped as input +} + +// TaskDef is a named sequence of Steps. +// +// c.Task("agent.completion", core.TaskDef{ +// Steps: []core.Step{ +// {Action: "agentic.qa"}, +// {Action: "agentic.auto-pr"}, +// {Action: "agentic.verify"}, +// {Action: "agentic.poke", Async: true}, +// }, +// }) +type TaskDef struct { + Name string + Description string + Steps []Step +} + +// Run executes the task's steps in order. Sync steps run sequentially — +// if any fails, the chain stops. Async steps are dispatched and don't block. +// The "previous" input pipes the last sync step's output to the next step. +// +// r := c.Task("deploy").Run(ctx, opts) +func (t *TaskDef) Run(ctx context.Context, c *Core, opts Options) Result { + if t == nil || len(t.Steps) == 0 { + return Result{E("task.Run", Concat("task has no steps: ", t.safeName()), nil), false} + } + + var lastResult Result + for _, step := range t.Steps { + // Use step's own options, or runtime options if step has none + stepOpts := stepOptions(step) + if stepOpts.Len() == 0 { + stepOpts = opts + } + + // Pipe previous result as input + if step.Input == "previous" && lastResult.OK { + stepOpts.Set("_input", lastResult.Value) + } + + action := c.Action(step.Action) + if !action.Exists() { + return Result{E("task.Run", Concat("action not found: ", step.Action), nil), false} + } + + if step.Async { + // Fire and forget — don't block the chain + go func(a *Action, o Options) { + defer func() { + if r := recover(); r != nil { + Error("async task step panicked", "action", a.Name, "panic", r) + } + }() + a.Run(ctx, o) + }(action, stepOpts) + continue + } + + lastResult = action.Run(ctx, stepOpts) + if !lastResult.OK { + return lastResult + } + } + return lastResult +} + +func (t *TaskDef) safeName() string { + if t == nil { + return "" + } + return t.Name +} + +// mergeStepOptions returns the step's With options — runtime opts are passed directly. +// Step.With provides static defaults that the step was registered with. +func stepOptions(step Step) Options { + return step.With +} + +// Task gets or registers a named task. +// With a TaskDef argument: registers the task. +// Without: returns the task for invocation. +// +// c.Task("deploy", core.TaskDef{Steps: steps}) // register +// c.Task("deploy").Run(ctx, c, opts) // invoke +func (c *Core) Task(name string, def ...TaskDef) *TaskDef { + if len(def) > 0 { + d := def[0] + d.Name = name + c.ipc.tasks.Set(name, &d) + return &d + } + r := c.ipc.tasks.Get(name) + if !r.OK { + return &TaskDef{Name: name} + } + return r.Value.(*TaskDef) +} + +// Tasks returns all registered task names. +func (c *Core) Tasks() []string { + return c.ipc.tasks.Names() +} diff --git a/action_test.go b/action_test.go new file mode 100644 index 0000000..988ac3f --- /dev/null +++ b/action_test.go @@ -0,0 +1,246 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- NamedAction Register --- + +func TestAction_NamedAction_Good_Register(t *testing.T) { + c := New() + def := c.Action("process.run", func(_ context.Context, opts Options) Result { + return Result{Value: "output", OK: true} + }) + assert.NotNil(t, def) + assert.Equal(t, "process.run", def.Name) + assert.True(t, def.Exists()) +} + +func TestAction_NamedAction_Good_Invoke(t *testing.T) { + c := New() + c.Action("git.log", func(_ context.Context, opts Options) Result { + dir := opts.String("dir") + return Result{Value: "log from " + dir, OK: true} + }) + + r := c.Action("git.log").Run(context.Background(), NewOptions( + Option{Key: "dir", Value: "/repo"}, + )) + assert.True(t, r.OK) + assert.Equal(t, "log from /repo", r.Value) +} + +func TestAction_NamedAction_Bad_NotRegistered(t *testing.T) { + c := New() + r := c.Action("missing.action").Run(context.Background(), NewOptions()) + assert.False(t, r.OK, "invoking unregistered action must fail") +} + +func TestAction_NamedAction_Good_Exists(t *testing.T) { + c := New() + c.Action("brain.recall", func(_ context.Context, _ Options) Result { + return Result{OK: true} + }) + assert.True(t, c.Action("brain.recall").Exists()) + assert.False(t, c.Action("brain.forget").Exists()) +} + +func TestAction_NamedAction_Ugly_PanicRecovery(t *testing.T) { + c := New() + c.Action("explode", func(_ context.Context, _ Options) Result { + panic("boom") + }) + r := c.Action("explode").Run(context.Background(), NewOptions()) + assert.False(t, r.OK, "panicking action must return !OK, not crash") + err, ok := r.Value.(error) + assert.True(t, ok) + assert.Contains(t, err.Error(), "panic") +} + +func TestAction_NamedAction_Ugly_NilAction(t *testing.T) { + var def *Action + r := def.Run(context.Background(), NewOptions()) + assert.False(t, r.OK) + assert.False(t, def.Exists()) +} + +// --- Actions listing --- + +func TestAction_Actions_Good(t *testing.T) { + c := New() + c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + c.Action("process.kill", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + c.Action("agentic.dispatch", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + + names := c.Actions() + assert.Len(t, names, 3) + assert.Equal(t, []string{"process.run", "process.kill", "agentic.dispatch"}, names) +} + +func TestAction_Actions_Bad_Empty(t *testing.T) { + c := New() + assert.Empty(t, c.Actions()) +} + +// --- Action fields --- + +func TestAction_NamedAction_Good_DescriptionAndSchema(t *testing.T) { + c := New() + def := c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + def.Description = "Execute a command synchronously" + def.Schema = NewOptions( + Option{Key: "command", Value: "string"}, + Option{Key: "args", Value: "[]string"}, + ) + + retrieved := c.Action("process.run") + assert.Equal(t, "Execute a command synchronously", retrieved.Description) + assert.True(t, retrieved.Schema.Has("command")) +} + +// --- Permission by registration --- + +func TestAction_NamedAction_Good_PermissionModel(t *testing.T) { + // Full Core — process registered + full := New() + full.Action("process.run", func(_ context.Context, _ Options) Result { + return Result{Value: "executed", OK: true} + }) + + // Sandboxed Core — no process + sandboxed := New() + + // Full can execute + r := full.Action("process.run").Run(context.Background(), NewOptions()) + assert.True(t, r.OK) + + // Sandboxed returns not-registered + r = sandboxed.Action("process.run").Run(context.Background(), NewOptions()) + assert.False(t, r.OK, "sandboxed Core must not have process capability") +} + +// --- Action overwrite --- + +func TestAction_NamedAction_Good_Overwrite(t *testing.T) { + c := New() + c.Action("hot.reload", func(_ context.Context, _ Options) Result { + return Result{Value: "v1", OK: true} + }) + c.Action("hot.reload", func(_ context.Context, _ Options) Result { + return Result{Value: "v2", OK: true} + }) + + r := c.Action("hot.reload").Run(context.Background(), NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, "v2", r.Value, "latest handler wins") +} + +// --- Task Composition --- + +func TestAction_Task_Good_Sequential(t *testing.T) { + c := New() + var order []string + c.Action("step.a", func(_ context.Context, _ Options) Result { + order = append(order, "a") + return Result{Value: "output-a", OK: true} + }) + c.Action("step.b", func(_ context.Context, _ Options) Result { + order = append(order, "b") + return Result{Value: "output-b", OK: true} + }) + + c.Task("pipeline", TaskDef{ + Steps: []Step{ + {Action: "step.a"}, + {Action: "step.b"}, + }, + }) + + r := c.Task("pipeline").Run(context.Background(), c, NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, []string{"a", "b"}, order, "steps must run in order") + assert.Equal(t, "output-b", r.Value, "last step's result is returned") +} + +func TestAction_Task_Bad_StepFails(t *testing.T) { + c := New() + var order []string + c.Action("step.ok", func(_ context.Context, _ Options) Result { + order = append(order, "ok") + return Result{OK: true} + }) + c.Action("step.fail", func(_ context.Context, _ Options) Result { + order = append(order, "fail") + return Result{Value: NewError("broke"), OK: false} + }) + c.Action("step.never", func(_ context.Context, _ Options) Result { + order = append(order, "never") + return Result{OK: true} + }) + + c.Task("broken", TaskDef{ + Steps: []Step{ + {Action: "step.ok"}, + {Action: "step.fail"}, + {Action: "step.never"}, + }, + }) + + r := c.Task("broken").Run(context.Background(), c, NewOptions()) + assert.False(t, r.OK) + assert.Equal(t, []string{"ok", "fail"}, order, "chain stops on failure, step.never skipped") +} + +func TestAction_Task_Bad_MissingAction(t *testing.T) { + c := New() + c.Task("missing", TaskDef{ + Steps: []Step{ + {Action: "nonexistent"}, + }, + }) + r := c.Task("missing").Run(context.Background(), c, NewOptions()) + assert.False(t, r.OK) +} + +func TestAction_Task_Good_PreviousInput(t *testing.T) { + c := New() + c.Action("produce", func(_ context.Context, _ Options) Result { + return Result{Value: "data-from-step-1", OK: true} + }) + c.Action("consume", func(_ context.Context, opts Options) Result { + input := opts.Get("_input") + if !input.OK { + return Result{Value: "no input", OK: true} + } + return Result{Value: "got: " + input.Value.(string), OK: true} + }) + + c.Task("pipe", TaskDef{ + Steps: []Step{ + {Action: "produce"}, + {Action: "consume", Input: "previous"}, + }, + }) + + r := c.Task("pipe").Run(context.Background(), c, NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, "got: data-from-step-1", r.Value) +} + +func TestAction_Task_Ugly_EmptySteps(t *testing.T) { + c := New() + c.Task("empty", TaskDef{}) + r := c.Task("empty").Run(context.Background(), c, NewOptions()) + assert.False(t, r.OK) +} + +func TestAction_Tasks_Good(t *testing.T) { + c := New() + c.Task("deploy", TaskDef{Steps: []Step{{Action: "x"}}}) + c.Task("review", TaskDef{Steps: []Step{{Action: "y"}}}) + assert.Equal(t, []string{"deploy", "review"}, c.Tasks()) +} diff --git a/app.go b/app.go index 17c3214..9fc1984 100644 --- a/app.go +++ b/app.go @@ -5,7 +5,7 @@ package core import ( - "os/exec" + "os" "path/filepath" ) @@ -47,21 +47,47 @@ func (a App) New(opts Options) App { } // Find locates a program on PATH and returns a Result containing the App. +// Uses os.Stat to search PATH directories — no os/exec dependency. // // r := core.App{}.Find("node", "Node.js") // if r.OK { app := r.Value.(*App) } func (a App) Find(filename, name string) Result { - path, err := exec.LookPath(filename) - if err != nil { - return Result{err, false} + // If filename contains a separator, check it directly + if Contains(filename, string(os.PathSeparator)) { + abs, err := filepath.Abs(filename) + if err != nil { + return Result{err, false} + } + if isExecutable(abs) { + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + return Result{E("app.Find", Concat(filename, " not found"), nil), false} } - abs, err := filepath.Abs(path) - if err != nil { - return Result{err, false} + + // Search PATH + pathEnv := os.Getenv("PATH") + if pathEnv == "" { + return Result{E("app.Find", "PATH is empty", nil), false} } - return Result{&App{ - Name: name, - Filename: filename, - Path: abs, - }, true} + for _, dir := range Split(pathEnv, string(os.PathListSeparator)) { + candidate := filepath.Join(dir, filename) + if isExecutable(candidate) { + abs, err := filepath.Abs(candidate) + if err != nil { + continue + } + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + } + return Result{E("app.Find", Concat(filename, " not found on PATH"), nil), false} +} + +// isExecutable checks if a path exists and is executable. +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + // Regular file with at least one execute bit + return !info.IsDir() && info.Mode()&0111 != 0 } diff --git a/cli.go b/cli.go index 1744f80..5e4b9f7 100644 --- a/cli.go +++ b/cli.go @@ -64,11 +64,7 @@ func (cl *Cli) Run(args ...string) Result { return Result{} } - c.commands.mu.RLock() - cmdCount := len(c.commands.commands) - c.commands.mu.RUnlock() - - if cmdCount == 0 { + if c.commands.Len() == 0 { if cl.banner != nil { cl.Print(cl.banner(cl)) } @@ -79,16 +75,14 @@ func (cl *Cli) Run(args ...string) Result { var cmd *Command var remaining []string - c.commands.mu.RLock() for i := len(clean); i > 0; i-- { path := JoinPath(clean[:i]...) - if found, ok := c.commands.commands[path]; ok { - cmd = found + if r := c.commands.Get(path); r.OK { + cmd = r.Value.(*Command) remaining = clean[i:] break } } - c.commands.mu.RUnlock() if cmd == nil { if cl.banner != nil { @@ -116,9 +110,6 @@ func (cl *Cli) Run(args ...string) Result { if cmd.Action != nil { return cmd.Run(opts) } - if cmd.Lifecycle != nil { - return cmd.Start(opts) - } return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false} } @@ -141,12 +132,9 @@ func (cl *Cli) PrintHelp() { cl.Print("Commands:") } - c.commands.mu.RLock() - defer c.commands.mu.RUnlock() - - for path, cmd := range c.commands.commands { - if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) { - continue + c.commands.Each(func(path string, cmd *Command) { + if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) { + return } tr := c.I18n().Translate(cmd.I18nKey()) desc, _ := tr.Value.(string) @@ -155,7 +143,7 @@ func (cl *Cli) PrintHelp() { } else { cl.Print(" %-30s %s", path, desc) } - } + }) } // SetBanner sets the banner function. diff --git a/command.go b/command.go index c774ed6..660f866 100644 --- a/command.go +++ b/command.go @@ -20,37 +20,31 @@ // "deploy/to/homelab" → "cmd.deploy.to.homelab.description" package core -import ( - "sync" -) // CommandAction is the function signature for command handlers. // // func(opts core.Options) core.Result type CommandAction func(Options) Result -// CommandLifecycle is implemented by commands that support managed lifecycle. -// Basic commands only need an action. Daemon commands implement Start/Stop/Signal -// via go-process. -type CommandLifecycle interface { - Start(Options) Result - Stop() Result - Restart() Result - Reload() Result - Signal(string) Result -} - // Command is the DTO for an executable operation. +// Commands are declarative — they carry enough information for multiple consumers: +// - core.Cli() runs the Action +// - core/cli adds rich help, completion, man pages +// - go-process wraps Managed commands with lifecycle (PID, health, signals) +// +// c.Command("serve", core.Command{ +// Action: handler, +// Managed: "process.daemon", // go-process provides start/stop/restart +// }) type Command struct { Name string - Description string // i18n key — derived from path if empty - Path string // "deploy/to/homelab" - Action CommandAction // business logic - Lifecycle CommandLifecycle // optional — provided by go-process - Flags Options // declared flags + Description string // i18n key — derived from path if empty + Path string // "deploy/to/homelab" + Action CommandAction // business logic + Managed string // "" = one-shot, "process.daemon" = managed lifecycle + Flags Options // declared flags Hidden bool commands map[string]*Command // child commands (internal) - mu sync.RWMutex } // I18nKey returns the i18n key for this command's description. @@ -77,52 +71,19 @@ func (cmd *Command) Run(opts Options) Result { return cmd.Action(opts) } -// Start delegates to the lifecycle implementation if available. -func (cmd *Command) Start(opts Options) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Start(opts) - } - return cmd.Run(opts) -} - -// Stop delegates to the lifecycle implementation. -func (cmd *Command) Stop() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Stop() - } - return Result{} -} - -// Restart delegates to the lifecycle implementation. -func (cmd *Command) Restart() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Restart() - } - return Result{} -} - -// Reload delegates to the lifecycle implementation. -func (cmd *Command) Reload() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Reload() - } - return Result{} -} - -// Signal delegates to the lifecycle implementation. -func (cmd *Command) Signal(sig string) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Signal(sig) - } - return Result{} +// IsManaged returns true if this command has a managed lifecycle. +// +// if cmd.IsManaged() { /* go-process handles start/stop */ } +func (cmd *Command) IsManaged() bool { + return cmd.Managed != "" } // --- Command Registry (on Core) --- -// commandRegistry holds the command tree. -type commandRegistry struct { - commands map[string]*Command - mu sync.RWMutex +// CommandRegistry holds the command tree. Embeds Registry[*Command] +// for thread-safe named storage with insertion order. +type CommandRegistry struct { + *Registry[*Command] } // Command gets or registers a command by path. @@ -131,21 +92,19 @@ type commandRegistry struct { // r := c.Command("deploy") func (c *Core) Command(path string, command ...Command) Result { if len(command) == 0 { - c.commands.mu.RLock() - cmd, ok := c.commands.commands[path] - c.commands.mu.RUnlock() - return Result{cmd, ok} + return c.commands.Get(path) } if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") { return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false} } - c.commands.mu.Lock() - defer c.commands.mu.Unlock() - - if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) { - return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + // Check for duplicate executable command + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) + if existing.Action != nil || existing.IsManaged() { + return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + } } cmd := &command[0] @@ -156,7 +115,8 @@ func (c *Core) Command(path string, command ...Command) Result { } // Preserve existing subtree when overwriting a placeholder parent - if existing, exists := c.commands.commands[path]; exists { + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) for k, v := range existing.commands { if _, has := cmd.commands[k]; !has { cmd.commands[k] = v @@ -164,40 +124,35 @@ func (c *Core) Command(path string, command ...Command) Result { } } - c.commands.commands[path] = cmd + c.commands.Set(path, cmd) // Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing parts := Split(path, "/") for i := len(parts) - 1; i > 0; i-- { parentPath := JoinPath(parts[:i]...) - if _, exists := c.commands.commands[parentPath]; !exists { - c.commands.commands[parentPath] = &Command{ + if !c.commands.Has(parentPath) { + c.commands.Set(parentPath, &Command{ Name: parts[i-1], Path: parentPath, commands: make(map[string]*Command), - } + }) } - c.commands.commands[parentPath].commands[parts[i]] = cmd - cmd = c.commands.commands[parentPath] + parent := c.commands.Get(parentPath).Value.(*Command) + parent.commands[parts[i]] = cmd + cmd = parent } return Result{OK: true} } -// Commands returns all registered command paths. +// Commands returns all registered command paths in registration order. // // paths := c.Commands() func (c *Core) Commands() []string { if c.commands == nil { return nil } - c.commands.mu.RLock() - defer c.commands.mu.RUnlock() - var paths []string - for k := range c.commands.commands { - paths = append(paths, k) - } - return paths + return c.commands.Names() } // pathName extracts the last segment of a path. diff --git a/command_test.go b/command_test.go index 7a81cf1..a984b8b 100644 --- a/command_test.go +++ b/command_test.go @@ -102,78 +102,25 @@ func TestCommand_I18nKey_Simple_Good(t *testing.T) { assert.Equal(t, "cmd.serve.description", cmd.I18nKey()) } -// --- Lifecycle --- +// --- Managed --- -func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) { +func TestCommand_IsManaged_Good(t *testing.T) { c := New() - c.Command("serve", Command{Action: func(_ Options) Result { - return Result{Value: "running", OK: true} - }}) + c.Command("serve", Command{ + Action: func(_ Options) Result { return Result{Value: "running", OK: true} }, + Managed: "process.daemon", + }) cmd := c.Command("serve").Value.(*Command) - - r := cmd.Start(NewOptions()) - assert.True(t, r.OK) - assert.Equal(t, "running", r.Value) - - assert.False(t, cmd.Stop().OK) - assert.False(t, cmd.Restart().OK) - assert.False(t, cmd.Reload().OK) - assert.False(t, cmd.Signal("HUP").OK) + assert.True(t, cmd.IsManaged()) } -// --- Lifecycle with Implementation --- - -type testLifecycle struct { - started bool - stopped bool - restarted bool - reloaded bool - signalled string -} - -func (l *testLifecycle) Start(opts Options) Result { - l.started = true - return Result{Value: "started", OK: true} -} -func (l *testLifecycle) Stop() Result { - l.stopped = true - return Result{OK: true} -} -func (l *testLifecycle) Restart() Result { - l.restarted = true - return Result{OK: true} -} -func (l *testLifecycle) Reload() Result { - l.reloaded = true - return Result{OK: true} -} -func (l *testLifecycle) Signal(sig string) Result { - l.signalled = sig - return Result{Value: sig, OK: true} -} - -func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) { +func TestCommand_IsManaged_Bad_NotManaged(t *testing.T) { c := New() - lc := &testLifecycle{} - c.Command("daemon", Command{Lifecycle: lc}) - cmd := c.Command("daemon").Value.(*Command) - - r := cmd.Start(NewOptions()) - assert.True(t, r.OK) - assert.True(t, lc.started) - - assert.True(t, cmd.Stop().OK) - assert.True(t, lc.stopped) - - assert.True(t, cmd.Restart().OK) - assert.True(t, lc.restarted) - - assert.True(t, cmd.Reload().OK) - assert.True(t, lc.reloaded) - - r = cmd.Signal("HUP") - assert.True(t, r.OK) - assert.Equal(t, "HUP", lc.signalled) + c.Command("deploy", Command{ + Action: func(_ Options) Result { return Result{OK: true} }, + }) + cmd := c.Command("deploy").Value.(*Command) + assert.False(t, cmd.IsManaged()) } func TestCommand_Duplicate_Bad(t *testing.T) { @@ -190,18 +137,21 @@ func TestCommand_InvalidPath_Bad(t *testing.T) { assert.False(t, c.Command("double//slash", Command{}).OK) } -// --- Cli Run with Lifecycle --- +// --- Cli Run with Managed --- -func TestCli_Run_Lifecycle_Good(t *testing.T) { +func TestCli_Run_Managed_Good(t *testing.T) { c := New() - lc := &testLifecycle{} - c.Command("serve", Command{Lifecycle: lc}) + ran := false + c.Command("serve", Command{ + Action: func(_ Options) Result { ran = true; return Result{OK: true} }, + Managed: "process.daemon", + }) r := c.Cli().Run("serve") assert.True(t, r.OK) - assert.True(t, lc.started) + assert.True(t, ran) } -func TestCli_Run_NoActionNoLifecycle_Bad(t *testing.T) { +func TestCli_Run_NoAction_Bad(t *testing.T) { c := New() c.Command("empty", Command{}) r := c.Cli().Run("empty") diff --git a/config_test.go b/config_test.go index 47a5836..c0646b7 100644 --- a/config_test.go +++ b/config_test.go @@ -88,7 +88,7 @@ func TestConfig_EnabledFeatures_Good(t *testing.T) { // --- ConfigVar --- -func TestConfigVar_Good(t *testing.T) { +func TestConfig_ConfigVar_Good(t *testing.T) { v := NewConfigVar("hello") assert.True(t, v.IsSet()) assert.Equal(t, "hello", v.Get()) diff --git a/contract.go b/contract.go index 7d65926..db3a41f 100644 --- a/contract.go +++ b/contract.go @@ -7,6 +7,7 @@ package core import ( "context" "reflect" + "sync" ) // Message is the type for IPC broadcasts (fire-and-forget). @@ -32,13 +33,21 @@ type QueryHandler func(*Core, Query) Result type TaskHandler func(*Core, Task) Result // Startable is implemented by services that need startup initialisation. +// +// func (s *MyService) OnStartup(ctx context.Context) core.Result { +// return core.Result{OK: true} +// } type Startable interface { - OnStartup(ctx context.Context) error + OnStartup(ctx context.Context) Result } // Stoppable is implemented by services that need shutdown cleanup. +// +// func (s *MyService) OnShutdown(ctx context.Context) core.Result { +// return core.Result{OK: true} +// } type Stoppable interface { - OnShutdown(ctx context.Context) error + OnShutdown(ctx context.Context) Result } // --- Action Messages --- @@ -81,28 +90,27 @@ type CoreOption func(*Core) Result // Services registered here form the application conclave — they share // IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown). // -// r := core.New( -// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})), +// c := core.New( +// core.WithOption("name", "myapp"), // core.WithService(auth.Register), // core.WithServiceLock(), // ) -// if !r.OK { log.Fatal(r.Value) } -// c := r.Value.(*Core) +// c.Run() func New(opts ...CoreOption) *Core { c := &Core{ app: &App{}, - data: &Data{}, - drive: &Drive{}, + data: &Data{Registry: NewRegistry[*Embed]()}, + drive: &Drive{Registry: NewRegistry[*DriveHandle]()}, fs: (&Fs{}).New("/"), config: (&Config{}).New(), error: &ErrorPanic{}, log: &ErrorLog{}, - lock: &Lock{}, - ipc: &Ipc{}, + lock: &Lock{locks: NewRegistry[*sync.RWMutex]()}, + ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*TaskDef]()}, info: systemInfo, i18n: &I18n{}, - services: &serviceRegistry{services: make(map[string]*Service)}, - commands: &commandRegistry{commands: make(map[string]*Command)}, + services: &ServiceRegistry{Registry: NewRegistry[*Service]()}, + commands: &CommandRegistry{Registry: NewRegistry[*Command]()}, } c.context, c.cancel = context.WithCancel(context.Background()) diff --git a/contract_test.go b/contract_test.go index 9984a55..6b26c85 100644 --- a/contract_test.go +++ b/contract_test.go @@ -31,7 +31,7 @@ func stubFactory(c *Core) Result { // stubFactory lives in package "dappco.re/go/core_test", so the last path // segment is "core_test" — WithService strips the "_test" suffix and registers // the service under the name "core". -func TestWithService_NameDiscovery_Good(t *testing.T) { +func TestContract_WithService_NameDiscovery_Good(t *testing.T) { c := New(WithService(stubFactory)) names := c.Services() @@ -42,7 +42,7 @@ func TestWithService_NameDiscovery_Good(t *testing.T) { // TestWithService_FactorySelfRegisters_Good verifies that when a factory // returns Result{OK:true} with no Value (it registered itself), WithService // does not attempt a second registration and returns success. -func TestWithService_FactorySelfRegisters_Good(t *testing.T) { +func TestContract_WithService_FactorySelfRegisters_Good(t *testing.T) { selfReg := func(c *Core) Result { // Factory registers directly, returns no instance. c.Service("self", Service{}) @@ -58,7 +58,7 @@ func TestWithService_FactorySelfRegisters_Good(t *testing.T) { // --- WithName --- -func TestWithName_Good(t *testing.T) { +func TestContract_WithName_Good(t *testing.T) { c := New( WithName("custom", func(c *Core) Result { return Result{Value: &stubNamedService{}, OK: true} @@ -73,12 +73,12 @@ type lifecycleService struct { started bool } -func (s *lifecycleService) OnStartup(_ context.Context) error { +func (s *lifecycleService) OnStartup(_ context.Context) Result { s.started = true - return nil + return Result{OK: true} } -func TestWithService_Lifecycle_Good(t *testing.T) { +func TestContract_WithService_Lifecycle_Good(t *testing.T) { svc := &lifecycleService{} c := New( WithService(func(c *Core) Result { @@ -101,7 +101,7 @@ func (s *ipcService) HandleIPCEvents(c *Core, msg Message) Result { return Result{OK: true} } -func TestWithService_IPCHandler_Good(t *testing.T) { +func TestContract_WithService_IPCHandler_Good(t *testing.T) { svc := &ipcService{} c := New( WithService(func(c *Core) Result { @@ -117,7 +117,7 @@ func TestWithService_IPCHandler_Good(t *testing.T) { // TestWithService_FactoryError_Bad verifies that a failing factory // stops further option processing (second service not registered). -func TestWithService_FactoryError_Bad(t *testing.T) { +func TestContract_WithService_FactoryError_Bad(t *testing.T) { secondCalled := false c := New( WithService(func(c *Core) Result { diff --git a/core.go b/core.go index 9074b5c..8a454d6 100644 --- a/core.go +++ b/core.go @@ -25,8 +25,8 @@ type Core struct { error *ErrorPanic // c.Error() — Panic recovery and crash reporting log *ErrorLog // c.Log() — Structured logging + error wrapping // cli accessed via ServiceFor[*Cli](c, "cli") - commands *commandRegistry // c.Command("path") — Command tree - services *serviceRegistry // c.Service("name") — Service registry + commands *CommandRegistry // c.Command("path") — Command tree + services *ServiceRegistry // c.Service("name") — Service registry lock *Lock // c.Lock("name") — Named mutexes ipc *Ipc // c.IPC() — Message bus for IPC info *SysInfo // c.Env("key") — Read-only system/environment information @@ -45,7 +45,6 @@ func (c *Core) Options() *Options { return c.options } func (c *Core) App() *App { return c.app } func (c *Core) Data() *Data { return c.data } func (c *Core) Drive() *Drive { return c.drive } -func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data() func (c *Core) Fs() *Fs { return c.fs } func (c *Core) Config() *Config { return c.config } func (c *Core) Error() *ErrorPanic { return c.error } @@ -62,37 +61,51 @@ func (c *Core) Core() *Core { return c } // --- Lifecycle --- -// Run starts all services, runs the CLI, then shuts down. -// This is the standard application lifecycle for CLI apps. +// RunE starts all services, runs the CLI, then shuts down. +// Returns an error instead of calling os.Exit — let main() handle the exit. +// ServiceShutdown is always called via defer, even on startup failure or panic. // -// c := core.New(core.WithService(myService.Register)).Value.(*Core) -// c.Run() -func (c *Core) Run() { +// if err := c.RunE(); err != nil { +// os.Exit(1) +// } +func (c *Core) RunE() error { + defer c.ServiceShutdown(context.Background()) + r := c.ServiceStartup(c.context, nil) if !r.OK { if err, ok := r.Value.(error); ok { - Error(err.Error()) + return err } - os.Exit(1) + return E("core.Run", "startup failed", nil) } if cli := c.Cli(); cli != nil { r = cli.Run() } - c.ServiceShutdown(context.Background()) - if !r.OK { if err, ok := r.Value.(error); ok { - Error(err.Error()) + return err } + } + return nil +} + +// Run starts all services, runs the CLI, then shuts down. +// Calls os.Exit(1) on failure. For error handling use RunE(). +// +// c := core.New(core.WithService(myService.Register)) +// c.Run() +func (c *Core) Run() { + if err := c.RunE(); err != nil { + Error(err.Error()) os.Exit(1) } } // --- IPC (uppercase aliases) --- -func (c *Core) ACTION(msg Message) Result { return c.Action(msg) } +func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) } func (c *Core) QUERY(q Query) Result { return c.Query(q) } func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) } func (c *Core) PERFORM(t Task) Result { return c.Perform(t) } @@ -114,4 +127,37 @@ func (c *Core) Must(err error, op, msg string) { c.log.Must(err, op, msg) } +// --- Registry Accessor --- + +// RegistryOf returns a named registry for cross-cutting queries. +// Known registries: "services", "commands", "actions". +// +// c.RegistryOf("services").Names() // all service names +// c.RegistryOf("actions").List("process.*") // process capabilities +// c.RegistryOf("commands").Len() // command count +func (c *Core) RegistryOf(name string) *Registry[any] { + // Bridge typed registries to untyped access for cross-cutting queries. + // Each registry is wrapped in a read-only proxy. + switch name { + case "services": + return registryProxy(c.services.Registry) + case "commands": + return registryProxy(c.commands.Registry) + case "actions": + return registryProxy(c.ipc.actions) + default: + return NewRegistry[any]() // empty registry for unknown names + } +} + +// registryProxy creates a read-only any-typed view of a typed registry. +// Copies current state — not a live view (avoids type parameter leaking). +func registryProxy[T any](src *Registry[T]) *Registry[any] { + proxy := NewRegistry[any]() + src.Each(func(name string, item T) { + proxy.Set(name, item) + }) + return proxy +} + // --- Global Instance --- diff --git a/core_test.go b/core_test.go index 17ee587..52f2475 100644 --- a/core_test.go +++ b/core_test.go @@ -13,24 +13,24 @@ import ( // --- New --- -func TestNew_Good(t *testing.T) { +func TestCore_New_Good(t *testing.T) { c := New() assert.NotNil(t, c) } -func TestNew_WithOptions_Good(t *testing.T) { +func TestCore_New_WithOptions_Good(t *testing.T) { c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"}))) assert.NotNil(t, c) assert.Equal(t, "myapp", c.App().Name) } -func TestNew_WithOptions_Bad(t *testing.T) { +func TestCore_New_WithOptions_Bad(t *testing.T) { // Empty options — should still create a valid Core c := New(WithOptions(NewOptions())) assert.NotNil(t, c) } -func TestNew_WithService_Good(t *testing.T) { +func TestCore_New_WithService_Good(t *testing.T) { started := false c := New( WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})), @@ -49,7 +49,7 @@ func TestNew_WithService_Good(t *testing.T) { assert.True(t, started) } -func TestNew_WithServiceLock_Good(t *testing.T) { +func TestCore_New_WithServiceLock_Good(t *testing.T) { c := New( WithService(func(c *Core) Result { c.Service("allowed", Service{}) @@ -63,7 +63,7 @@ func TestNew_WithServiceLock_Good(t *testing.T) { assert.False(t, reg.OK) } -func TestNew_WithService_Bad_FailingOption(t *testing.T) { +func TestCore_New_WithService_Bad_FailingOption(t *testing.T) { secondCalled := false _ = New( WithService(func(c *Core) Result { @@ -79,7 +79,7 @@ func TestNew_WithService_Bad_FailingOption(t *testing.T) { // --- Accessors --- -func TestAccessors_Good(t *testing.T) { +func TestCore_Accessors_Good(t *testing.T) { c := New() assert.NotNil(t, c.App()) assert.NotNil(t, c.Data()) @@ -147,6 +147,103 @@ func TestCore_Must_Nil_Good(t *testing.T) { }) } +// --- RegistryOf --- + +func TestCore_RegistryOf_Good_Services(t *testing.T) { + c := New( + WithService(func(c *Core) Result { + return c.Service("alpha", Service{}) + }), + WithService(func(c *Core) Result { + return c.Service("bravo", Service{}) + }), + ) + reg := c.RegistryOf("services") + // cli is auto-registered + our 2 + assert.True(t, reg.Has("alpha")) + assert.True(t, reg.Has("bravo")) + assert.True(t, reg.Has("cli")) +} + +func TestCore_RegistryOf_Good_Commands(t *testing.T) { + c := New() + c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) + c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }}) + + reg := c.RegistryOf("commands") + assert.True(t, reg.Has("deploy")) + assert.True(t, reg.Has("test")) +} + +func TestCore_RegistryOf_Good_Actions(t *testing.T) { + c := New() + c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + + reg := c.RegistryOf("actions") + assert.True(t, reg.Has("process.run")) + assert.True(t, reg.Has("brain.recall")) + assert.Equal(t, 2, reg.Len()) +} + +func TestCore_RegistryOf_Bad_Unknown(t *testing.T) { + c := New() + reg := c.RegistryOf("nonexistent") + assert.Equal(t, 0, reg.Len(), "unknown registry returns empty") +} + +// --- RunE --- + +func TestCore_RunE_Good(t *testing.T) { + c := New( + WithService(func(c *Core) Result { + return c.Service("healthy", Service{ + OnStart: func() Result { return Result{OK: true} }, + OnStop: func() Result { return Result{OK: true} }, + }) + }), + ) + err := c.RunE() + assert.NoError(t, err) +} + +func TestCore_RunE_Bad_StartupFailure(t *testing.T) { + c := New( + WithService(func(c *Core) Result { + return c.Service("broken", Service{ + OnStart: func() Result { + return Result{Value: NewError("startup failed"), OK: false} + }, + }) + }), + ) + err := c.RunE() + assert.Error(t, err) + assert.Contains(t, err.Error(), "startup failed") +} + +func TestCore_RunE_Ugly_StartupFailureCallsShutdown(t *testing.T) { + shutdownCalled := false + c := New( + WithService(func(c *Core) Result { + return c.Service("cleanup", Service{ + OnStart: func() Result { return Result{OK: true} }, + OnStop: func() Result { shutdownCalled = true; return Result{OK: true} }, + }) + }), + WithService(func(c *Core) Result { + return c.Service("broken", Service{ + OnStart: func() Result { + return Result{Value: NewError("boom"), OK: false} + }, + }) + }), + ) + err := c.RunE() + assert.Error(t, err) + assert.True(t, shutdownCalled, "ServiceShutdown must be called even when startup fails — cleanup service must get OnStop") +} + func TestCore_Run_HelperProcess(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return diff --git a/data.go b/data.go index 47f9414..460277c 100644 --- a/data.go +++ b/data.go @@ -25,13 +25,12 @@ package core import ( "io/fs" "path/filepath" - "sync" ) // Data manages mounted embedded filesystems from core packages. +// Embeds Registry[*Embed] for thread-safe named storage. type Data struct { - mounts map[string]*Embed - mu sync.RWMutex + *Registry[*Embed] } // New registers an embedded filesystem under a named prefix. @@ -62,54 +61,27 @@ func (d *Data) New(opts Options) Result { path = "." } - d.mu.Lock() - defer d.mu.Unlock() - - if d.mounts == nil { - d.mounts = make(map[string]*Embed) - } - mr := Mount(fsys, path) if !mr.OK { return mr } emb := mr.Value.(*Embed) - d.mounts[name] = emb - return Result{emb, true} -} - -// Get returns the Embed for a named mount point. -// -// r := c.Data().Get("brain") -// if r.OK { emb := r.Value.(*Embed) } -func (d *Data) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.mounts == nil { - return Result{} - } - emb, ok := d.mounts[name] - if !ok { - return Result{} - } + d.Set(name, emb) return Result{emb, true} } // resolve splits a path like "brain/coding.md" into mount name + relative path. func (d *Data) resolve(path string) (*Embed, string) { - d.mu.RLock() - defer d.mu.RUnlock() - parts := SplitN(path, "/", 2) if len(parts) < 2 { return nil, "" } - if d.mounts == nil { + r := d.Get(parts[0]) + if !r.OK { return nil, "" } - emb := d.mounts[parts[0]] - return emb, parts[1] + return r.Value.(*Embed), parts[1] } // ReadFile reads a file by full path. @@ -188,15 +160,9 @@ func (d *Data) Extract(path, targetDir string, templateData any) Result { return Extract(r.Value.(*Embed).FS(), targetDir, templateData) } -// Mounts returns the names of all mounted content. +// Mounts returns the names of all mounted content in registration order. // // names := c.Data().Mounts() func (d *Data) Mounts() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.mounts { - names = append(names, k) - } - return names + return d.Names() } diff --git a/data_test.go b/data_test.go index 89763d6..40ba7b2 100644 --- a/data_test.go +++ b/data_test.go @@ -100,12 +100,6 @@ func TestData_Mounts_Good(t *testing.T) { assert.Len(t, mounts, 2) } -func TestEmbed_Legacy_Good(t *testing.T) { - c := New() - mountTestData(t, c, "app") - assert.NotNil(t, c.Embed()) -} - func TestData_List_Good(t *testing.T) { c := New() mountTestData(t, c, "app") diff --git a/docs/RFC.md b/docs/RFC.md index 49caaa5..d41c2ae 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -1367,7 +1367,7 @@ The naming convention encodes the architecture: | `CamelCase()` | **Primitive** — the Lego brick, the building block | `c.Action("name")`, `c.Service("name")`, `c.Config()` | | `UPPERCASE()` | **Consumer convenience** — sugar over primitives, works out of the box | `c.ACTION(msg)`, `c.QUERY(q)`, `c.PERFORM(t)` | -**Current code has this backwards.** `ACTION()` is the uppercase method but it's mapped to the raw dispatch. `Action()` is CamelCase but it's just an alias. +**Implemented 2026-03-25.** `c.Action("name")` is the named action primitive. `c.ACTION(msg)` calls internal `broadcast()`. The rename from `Action(msg)` → `broadcast(msg)` is done. **Resolution:** @@ -1401,13 +1401,9 @@ func MustServiceFor[T any](c *Core, name string) T { **Rule:** Use `MustServiceFor` only in `OnStartup` / init paths where failure is fatal. Never in request handlers or runtime code. Document this in RFC-025. -### 3. Embed() Legacy Accessor (Resolved) +### 3. Embed() Legacy Accessor (Resolved — Removed 2026-03-25) -```go -func (c *Core) Embed() Result { return c.data.Get("app") } -``` - -**Resolution:** Remove. It's a shortcut to `c.Data().Get("app")` with a misleading name. `Embed` sounds like it embeds something — it actually reads. An agent seeing `c.Embed()` can't know it's reading from Data. Dead code, remove in next refactor. +**Removed.** `c.Embed()` deleted from core.go. Use `c.Data().Get("app")` instead. ### 4. Package-Level vs Core-Level Logging (Resolved) @@ -1426,17 +1422,22 @@ c.Log().Info("msg") // Core's logger instance This is the same dual pattern as `process.Run()` (global) vs `c.Process().Run()` (Core). Package-level functions are the bootstrap path. Core methods are the runtime path. -### 5. RegisterAction Lives in task.go (Resolved by Issue 16) +### 5. RegisterAction Lives in task.go (Resolved — Implemented 2026-03-25) -Resolved — task.go splits into ipc.go (registration) + action.go (execution). See Issue 16. +**Done.** RegisterAction/RegisterActions/RegisterTask moved to ipc.go. action.go has ActionDef, TaskDef, execution. task.go has PerformAsync/Progress. -### 6. serviceRegistry Is Unexported (Resolved by Section 20) +### 6. serviceRegistry Is Unexported (Resolved — Implemented 2026-03-25) -Resolved — `serviceRegistry` becomes `ServiceRegistry` embedding `Registry[*Service]`. See Section 20. +**Done.** All 5 registries migrated to `Registry[T]`: +- `serviceRegistry` → `ServiceRegistry` embedding `Registry[*Service]` +- `commandRegistry` → `CommandRegistry` embedding `Registry[*Command]` +- `Drive` embedding `Registry[*DriveHandle]` +- `Data` embedding `Registry[*Embed]` +- `Lock.locks` using `Registry[*sync.RWMutex]` -### 7. No c.Process() Accessor +### 7. No c.Process() Accessor (Resolved — Implemented 2026-03-25) -Spec'd in Section 17. Blocked on go-process v0.7.0 update. +**Done.** `c.Process()` returns `*Process` — sugar over `c.Action("process.run")`. No deps added to core/go. go-process v0.7.0 registers the actual handlers. ### 8. NewRuntime / NewWithFactories — GUI Bridge, Not Legacy (Resolved) @@ -1467,7 +1468,7 @@ c := core.New( This unifies CLI and GUI bootstrap — same `core.New()`, just with `WithRuntime` added. -### 9. CommandLifecycle — The Three-Layer CLI Architecture +### 9. CommandLifecycle — The Three-Layer CLI Architecture (Resolved — Implemented 2026-03-25) ```go type CommandLifecycle interface { diff --git a/docs/RFC.plan.1.md b/docs/RFC.plan.1.md index 62fac62..ae709e8 100644 --- a/docs/RFC.plan.1.md +++ b/docs/RFC.plan.1.md @@ -1,6 +1,6 @@ -# RFC Plan 1 — First Session Priorities +# RFC Plan 1 — First Session Priorities (COMPLETED 2026-03-25) -> Read RFC.plan.md first. This is what to do in the FIRST session after compact. +> All items shipped. See RFC.plan.md "What Was Shipped" section. ## Priority 1: Fix the 3 Critical Bugs (Plan 1) @@ -77,9 +77,9 @@ TestRegistry_Get_Good Then migrate `serviceRegistry` first (most tested, most used). -## What To Skip In First Session +## What Was Skipped (shipped in same session instead) -- Plan 3 (Actions) — needs Registry first -- Plan 4 (Process) — needs Actions first -- Plan 6 (ecosystem sweep) — needs everything first -- Any breaking changes — v0.7.1 is additive only +All items originally marked "skip" were shipped because Registry and Actions were built in the same session: +- Plan 3 (Actions) — DONE: ActionDef, TaskDef, c.Action(), c.Task() +- Plan 4 (Process) — DONE for core/go: c.Process() sugar over Actions +- Breaking changes — DONE: Startable returns Result, CommandLifecycle removed diff --git a/docs/RFC.plan.2.md b/docs/RFC.plan.2.md index dc4f9f0..f891e23 100644 --- a/docs/RFC.plan.2.md +++ b/docs/RFC.plan.2.md @@ -1,6 +1,6 @@ -# RFC Plan 2 — Registry + Actions Sessions +# RFC Plan 2 — Registry + Actions Sessions (COMPLETED 2026-03-25) -> After Plan 1 bugs are fixed and AX-7 rename is done. +> All core/go items shipped. core/agent migration and go-process v0.7.0 are separate repo scope. ## Session Goal: Registry[T] + First Migration diff --git a/docs/RFC.plan.md b/docs/RFC.plan.md index 84d6f98..2ea30f1 100644 --- a/docs/RFC.plan.md +++ b/docs/RFC.plan.md @@ -50,8 +50,9 @@ Plan 6 → Ecosystem sweep (after 1-5, dispatched via Codex) - **Dual-purpose methods** (Service, Command, Action) — keep as sugar, Registry has explicit Get/Set - **Array[T] and ConfigVar[T] are guardrail primitives** — model-proof, not speculative - **ServiceRuntime[T] and manual `.core = c` are both valid** — document both -- **Startable V2 returns Result** — add alongside V1 for backwards compat +- **Startable returns Result** — clean break, no V2 compat shim (pre-v1, breaking is expected) - **`RunE()` alongside `Run()`** — no breakage +- **CommandLifecycle removed** — replaced with `Command.Managed` string field ## Existing RFCs That Solve Open Problems @@ -79,15 +80,31 @@ Core stays stdlib-only. Consumers bring implementations via WithService. ## AX-7 Status - core/agent: 92% (840 tests, 79.9% coverage) -- core/go: 14% (83.6% coverage but wrong naming — needs rename + gap fill) -- Rename script exists (Python, used on core/agent — same script works) -- 212 functions × 3 categories = 636 target for core/go +- core/go: **100%** (457 tests, 84.4% coverage) — renamed 2026-03-25 +- All 457 tests have `TestFile_Function_{Good,Bad,Ugly}` naming + +## What Was Shipped (2026-03-25 session) + +Plans 1-5 complete for core/go scope. 457 tests, 84.4% coverage, 100% AX-7 naming. + +- P4-3 + P7-3: ACTION broadcast — calls all handlers, panic recovery per handler +- P7-2 + P7-4: `RunE()` with `defer ServiceShutdown`, `Run()` delegates +- P3-1: Startable/Stoppable return `Result` (breaking, clean — no V2) +- P9-1: Zero `os/exec` in core/go — `App.Find()` rewritten with `os.Stat` + PATH +- P11-2: `Fs.NewUnrestricted()` — legitimate door replaces unsafe.Pointer +- P4-10: `Fs.WriteAtomic()` — write-to-temp-then-rename +- I3: `Embed()` removed, I15: `New()` comment fixed +- I9: `CommandLifecycle` interface removed → `Command.Managed` string field +- Section 17: `c.Process()` primitive (Action sugar, no deps) +- Section 18: `c.Action("name")` + `ActionDef` + `c.Task("name", TaskDef{Steps})` composition +- Section 20: `Registry[T]` + all 5 migrations (services, commands, drive, data, lock) +- Section 20.4: `c.RegistryOf("name")` cross-cutting accessor +- Plan 5: `core.ID()`, `ValidateName()`, `SanitisePath()` ## Session Context That Won't Be In Memory -- The ACTION cascade (P6-1) is the root cause of "agents finish but queue doesn't drain" -- status.json has 51 unprotected read-modify-write sites (P4-9) — real race condition -- The Fs sandbox is bypassed by 2 files using unsafe.Pointer (P11-2) -- `core.Env("DIR_HOME")` is cached at init — `t.Setenv` doesn't override it (P2-5) -- go-process `NewService` returns `(any, error)` not `core.Result` — needs v0.7.0 update +- The ACTION cascade (P6-1) — core/go now has TaskDef for the fix, core/agent needs to wire it +- status.json has 51 unprotected read-modify-write sites (P4-9) — `WriteAtomic` exists, core/agent needs to use it +- `core.Env("DIR_HOME")` is cached at init — `t.Setenv` doesn't override it (P2-5) — use `CORE_WORKSPACE` in tests +- go-process `NewService` returns `(any, error)` not `core.Result` — needs v0.7.0 update (go-process repo) - Multiple Core instances share global state (assetGroups, systemInfo, defaultLog) diff --git a/drive.go b/drive.go index 2d9f7e6..7bf6869 100644 --- a/drive.go +++ b/drive.go @@ -24,10 +24,6 @@ // api := c.Drive().Get("api") package core -import ( - "sync" -) - // DriveHandle holds a named transport resource. type DriveHandle struct { Name string @@ -35,10 +31,9 @@ type DriveHandle struct { Options Options } -// Drive manages named transport handles. +// Drive manages named transport handles. Embeds Registry[*DriveHandle]. type Drive struct { - handles map[string]*DriveHandle - mu sync.RWMutex + *Registry[*DriveHandle] } // New registers a transport handle. @@ -53,58 +48,12 @@ func (d *Drive) New(opts Options) Result { return Result{} } - transport := opts.String("transport") - - d.mu.Lock() - defer d.mu.Unlock() - - if d.handles == nil { - d.handles = make(map[string]*DriveHandle) - } - handle := &DriveHandle{ Name: name, - Transport: transport, + Transport: opts.String("transport"), Options: opts, } - d.handles[name] = handle + d.Set(name, handle) return Result{handle, true} } - -// Get returns a handle by name. -// -// r := c.Drive().Get("api") -// if r.OK { handle := r.Value.(*DriveHandle) } -func (d *Drive) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.handles == nil { - return Result{} - } - h, ok := d.handles[name] - if !ok { - return Result{} - } - return Result{h, true} -} - -// Has returns true if a handle is registered. -// -// if c.Drive().Has("ssh") { ... } -func (d *Drive) Has(name string) bool { - return d.Get(name).OK -} - -// Names returns all registered handle names. -// -// names := c.Drive().Names() -func (d *Drive) Names() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.handles { - names = append(names, k) - } - return names -} diff --git a/embed_test.go b/embed_test.go index e666a6e..e736920 100644 --- a/embed_test.go +++ b/embed_test.go @@ -21,12 +21,12 @@ func mustMountTestFS(t *testing.T, basedir string) *Embed { return r.Value.(*Embed) } -func TestMount_Good(t *testing.T) { +func TestEmbed_Mount_Good(t *testing.T) { r := Mount(testFS, "testdata") assert.True(t, r.OK) } -func TestMount_Bad(t *testing.T) { +func TestEmbed_Mount_Bad(t *testing.T) { r := Mount(testFS, "nonexistent") assert.False(t, r.OK) } @@ -88,7 +88,7 @@ func TestEmbed_EmbedFS_Good(t *testing.T) { // --- Extract --- -func TestExtract_Good(t *testing.T) { +func TestEmbed_Extract_Good(t *testing.T) { dir := t.TempDir() r := Extract(testFS, dir, nil) assert.True(t, r.OK) @@ -100,33 +100,33 @@ func TestExtract_Good(t *testing.T) { // --- Asset Pack --- -func TestAddGetAsset_Good(t *testing.T) { +func TestEmbed_AddGetAsset_Good(t *testing.T) { AddAsset("test-group", "greeting", mustCompress("hello world")) r := GetAsset("test-group", "greeting") assert.True(t, r.OK) assert.Equal(t, "hello world", r.Value.(string)) } -func TestGetAsset_Bad(t *testing.T) { +func TestEmbed_GetAsset_Bad(t *testing.T) { r := GetAsset("missing-group", "missing") assert.False(t, r.OK) } -func TestGetAssetBytes_Good(t *testing.T) { +func TestEmbed_GetAssetBytes_Good(t *testing.T) { AddAsset("bytes-group", "file", mustCompress("binary content")) r := GetAssetBytes("bytes-group", "file") assert.True(t, r.OK) assert.Equal(t, []byte("binary content"), r.Value.([]byte)) } -func TestMountEmbed_Good(t *testing.T) { +func TestEmbed_MountEmbed_Good(t *testing.T) { r := MountEmbed(testFS, "testdata") assert.True(t, r.OK) } // --- ScanAssets --- -func TestScanAssets_Good(t *testing.T) { +func TestEmbed_ScanAssets_Good(t *testing.T) { r := ScanAssets([]string{"testdata/scantest/sample.go"}) assert.True(t, r.OK) pkgs := r.Value.([]ScannedPackage) @@ -134,19 +134,19 @@ func TestScanAssets_Good(t *testing.T) { assert.Equal(t, "scantest", pkgs[0].PackageName) } -func TestScanAssets_Bad(t *testing.T) { +func TestEmbed_ScanAssets_Bad(t *testing.T) { r := ScanAssets([]string{"nonexistent.go"}) assert.False(t, r.OK) } -func TestGeneratePack_Empty_Good(t *testing.T) { +func TestEmbed_GeneratePack_Empty_Good(t *testing.T) { pkg := ScannedPackage{PackageName: "empty"} r := GeneratePack(pkg) assert.True(t, r.OK) assert.Contains(t, r.Value.(string), "package empty") } -func TestGeneratePack_WithFiles_Good(t *testing.T) { +func TestEmbed_GeneratePack_WithFiles_Good(t *testing.T) { dir := t.TempDir() assetDir := dir + "/mygroup" os.MkdirAll(assetDir, 0755) @@ -167,7 +167,7 @@ func TestGeneratePack_WithFiles_Good(t *testing.T) { // --- Extract (template + nested) --- -func TestExtract_WithTemplate_Good(t *testing.T) { +func TestEmbed_Extract_WithTemplate_Good(t *testing.T) { dir := t.TempDir() // Create an in-memory FS with a template file and a plain file @@ -203,7 +203,7 @@ func TestExtract_WithTemplate_Good(t *testing.T) { assert.Equal(t, "nested", string(nested)) } -func TestExtract_BadTargetDir_Ugly(t *testing.T) { +func TestEmbed_Extract_BadTargetDir_Ugly(t *testing.T) { srcDir := t.TempDir() os.WriteFile(srcDir+"/f.txt", []byte("x"), 0644) r := Extract(os.DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil) @@ -244,7 +244,7 @@ func TestEmbed_EmbedFS_Original_Good(t *testing.T) { assert.NoError(t, err) } -func TestExtract_NilData_Good(t *testing.T) { +func TestEmbed_Extract_NilData_Good(t *testing.T) { dir := t.TempDir() srcDir := t.TempDir() os.WriteFile(srcDir+"/file.txt", []byte("no template"), 0644) diff --git a/error_test.go b/error_test.go index 7213486..9d1943c 100644 --- a/error_test.go +++ b/error_test.go @@ -10,39 +10,39 @@ import ( // --- Error Creation --- -func TestE_Good(t *testing.T) { +func TestError_E_Good(t *testing.T) { err := E("user.Save", "failed to save", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "user.Save") assert.Contains(t, err.Error(), "failed to save") } -func TestE_WithCause_Good(t *testing.T) { +func TestError_E_WithCause_Good(t *testing.T) { cause := errors.New("connection refused") err := E("db.Connect", "database unavailable", cause) assert.ErrorIs(t, err, cause) } -func TestWrap_Good(t *testing.T) { +func TestError_Wrap_Good(t *testing.T) { cause := errors.New("timeout") err := Wrap(cause, "api.Call", "request failed") assert.Error(t, err) assert.ErrorIs(t, err, cause) } -func TestWrap_Nil_Good(t *testing.T) { +func TestError_Wrap_Nil_Good(t *testing.T) { err := Wrap(nil, "api.Call", "request failed") assert.Nil(t, err) } -func TestWrapCode_Good(t *testing.T) { +func TestError_WrapCode_Good(t *testing.T) { cause := errors.New("invalid email") err := WrapCode(cause, "VALIDATION_ERROR", "user.Validate", "bad input") assert.Error(t, err) assert.Equal(t, "VALIDATION_ERROR", ErrorCode(err)) } -func TestNewCode_Good(t *testing.T) { +func TestError_NewCode_Good(t *testing.T) { err := NewCode("NOT_FOUND", "resource not found") assert.Error(t, err) assert.Equal(t, "NOT_FOUND", ErrorCode(err)) @@ -50,42 +50,42 @@ func TestNewCode_Good(t *testing.T) { // --- Error Introspection --- -func TestOperation_Good(t *testing.T) { +func TestError_Operation_Good(t *testing.T) { err := E("brain.Recall", "search failed", nil) assert.Equal(t, "brain.Recall", Operation(err)) } -func TestOperation_Bad(t *testing.T) { +func TestError_Operation_Bad(t *testing.T) { err := errors.New("plain error") assert.Equal(t, "", Operation(err)) } -func TestErrorMessage_Good(t *testing.T) { +func TestError_ErrorMessage_Good(t *testing.T) { err := E("op", "the message", nil) assert.Equal(t, "the message", ErrorMessage(err)) } -func TestErrorMessage_Plain(t *testing.T) { +func TestError_ErrorMessage_Plain(t *testing.T) { err := errors.New("plain") assert.Equal(t, "plain", ErrorMessage(err)) } -func TestErrorMessage_Nil(t *testing.T) { +func TestError_ErrorMessage_Nil(t *testing.T) { assert.Equal(t, "", ErrorMessage(nil)) } -func TestRoot_Good(t *testing.T) { +func TestError_Root_Good(t *testing.T) { root := errors.New("root cause") wrapped := Wrap(root, "layer1", "first wrap") double := Wrap(wrapped, "layer2", "second wrap") assert.Equal(t, root, Root(double)) } -func TestRoot_Nil(t *testing.T) { +func TestError_Root_Nil(t *testing.T) { assert.Nil(t, Root(nil)) } -func TestStackTrace_Good(t *testing.T) { +func TestError_StackTrace_Good(t *testing.T) { err := Wrap(E("inner", "cause", nil), "outer", "wrapper") stack := StackTrace(err) assert.Len(t, stack, 2) @@ -93,7 +93,7 @@ func TestStackTrace_Good(t *testing.T) { assert.Equal(t, "inner", stack[1]) } -func TestFormatStackTrace_Good(t *testing.T) { +func TestError_FormatStackTrace_Good(t *testing.T) { err := Wrap(E("a", "x", nil), "b", "y") formatted := FormatStackTrace(err) assert.Equal(t, "b -> a", formatted) @@ -101,7 +101,7 @@ func TestFormatStackTrace_Good(t *testing.T) { // --- ErrorLog --- -func TestErrorLog_Good(t *testing.T) { +func TestError_ErrorLog_Good(t *testing.T) { c := New() cause := errors.New("boom") r := c.Log().Error(cause, "test.Operation", "something broke") @@ -109,27 +109,27 @@ func TestErrorLog_Good(t *testing.T) { assert.ErrorIs(t, r.Value.(error), cause) } -func TestErrorLog_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Nil_Good(t *testing.T) { c := New() r := c.Log().Error(nil, "test.Operation", "no error") assert.True(t, r.OK) } -func TestErrorLog_Warn_Good(t *testing.T) { +func TestError_ErrorLog_Warn_Good(t *testing.T) { c := New() cause := errors.New("warning") r := c.Log().Warn(cause, "test.Operation", "heads up") assert.False(t, r.OK) } -func TestErrorLog_Must_Ugly(t *testing.T) { +func TestError_ErrorLog_Must_Ugly(t *testing.T) { c := New() assert.Panics(t, func() { c.Log().Must(errors.New("fatal"), "test.Operation", "must fail") }) } -func TestErrorLog_Must_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Must_Nil_Good(t *testing.T) { c := New() assert.NotPanics(t, func() { c.Log().Must(nil, "test.Operation", "no error") @@ -138,7 +138,7 @@ func TestErrorLog_Must_Nil_Good(t *testing.T) { // --- ErrorPanic --- -func TestErrorPanic_Recover_Good(t *testing.T) { +func TestError_ErrorPanic_Recover_Good(t *testing.T) { c := New() // Should not panic — Recover catches it assert.NotPanics(t, func() { @@ -147,7 +147,7 @@ func TestErrorPanic_Recover_Good(t *testing.T) { }) } -func TestErrorPanic_SafeGo_Good(t *testing.T) { +func TestError_ErrorPanic_SafeGo_Good(t *testing.T) { c := New() done := make(chan bool, 1) c.Error().SafeGo(func() { @@ -156,7 +156,7 @@ func TestErrorPanic_SafeGo_Good(t *testing.T) { assert.True(t, <-done) } -func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) { +func TestError_ErrorPanic_SafeGo_Panic_Good(t *testing.T) { c := New() done := make(chan bool, 1) c.Error().SafeGo(func() { @@ -169,25 +169,25 @@ func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) { // --- Standard Library Wrappers --- -func TestIs_Good(t *testing.T) { +func TestError_Is_Good(t *testing.T) { target := errors.New("target") wrapped := Wrap(target, "op", "msg") assert.True(t, Is(wrapped, target)) } -func TestAs_Good(t *testing.T) { +func TestError_As_Good(t *testing.T) { err := E("op", "msg", nil) var e *Err assert.True(t, As(err, &e)) assert.Equal(t, "op", e.Operation) } -func TestNewError_Good(t *testing.T) { +func TestError_NewError_Good(t *testing.T) { err := NewError("simple error") assert.Equal(t, "simple error", err.Error()) } -func TestErrorJoin_Good(t *testing.T) { +func TestError_ErrorJoin_Good(t *testing.T) { e1 := errors.New("first") e2 := errors.New("second") joined := ErrorJoin(e1, e2) @@ -197,7 +197,7 @@ func TestErrorJoin_Good(t *testing.T) { // --- ErrorPanic Crash Reports --- -func TestErrorPanic_Reports_Good(t *testing.T) { +func TestError_ErrorPanic_Reports_Good(t *testing.T) { dir := t.TempDir() path := dir + "/crashes.json" @@ -212,7 +212,7 @@ func TestErrorPanic_Reports_Good(t *testing.T) { // --- ErrorPanic Crash File --- -func TestErrorPanic_CrashFile_Good(t *testing.T) { +func TestError_ErrorPanic_CrashFile_Good(t *testing.T) { dir := t.TempDir() path := dir + "/crashes.json" @@ -230,42 +230,42 @@ func TestErrorPanic_CrashFile_Good(t *testing.T) { // --- Error formatting branches --- -func TestErr_Error_WithCode_Good(t *testing.T) { +func TestError_Err_Error_WithCode_Good(t *testing.T) { err := WrapCode(errors.New("bad"), "INVALID", "validate", "input failed") assert.Contains(t, err.Error(), "[INVALID]") assert.Contains(t, err.Error(), "validate") assert.Contains(t, err.Error(), "bad") } -func TestErr_Error_CodeNoCause_Good(t *testing.T) { +func TestError_Err_Error_CodeNoCause_Good(t *testing.T) { err := NewCode("NOT_FOUND", "resource missing") assert.Contains(t, err.Error(), "[NOT_FOUND]") assert.Contains(t, err.Error(), "resource missing") } -func TestErr_Error_NoOp_Good(t *testing.T) { +func TestError_Err_Error_NoOp_Good(t *testing.T) { err := &Err{Message: "bare error"} assert.Equal(t, "bare error", err.Error()) } -func TestWrapCode_NilErr_EmptyCode_Good(t *testing.T) { +func TestError_WrapCode_NilErr_EmptyCode_Good(t *testing.T) { err := WrapCode(nil, "", "op", "msg") assert.Nil(t, err) } -func TestWrap_PreservesCode_Good(t *testing.T) { +func TestError_Wrap_PreservesCode_Good(t *testing.T) { inner := WrapCode(errors.New("root"), "AUTH_FAIL", "auth", "denied") outer := Wrap(inner, "handler", "request failed") assert.Equal(t, "AUTH_FAIL", ErrorCode(outer)) } -func TestErrorLog_Warn_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Warn_Nil_Good(t *testing.T) { c := New() r := c.LogWarn(nil, "op", "msg") assert.True(t, r.OK) } -func TestErrorLog_Error_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Error_Nil_Good(t *testing.T) { c := New() r := c.LogError(nil, "op", "msg") assert.True(t, r.OK) diff --git a/fs.go b/fs.go index c528308..933588e 100644 --- a/fs.go +++ b/fs.go @@ -25,6 +25,25 @@ func (m *Fs) New(root string) *Fs { return m } +// NewUnrestricted returns a new Fs with root "/", granting full filesystem access. +// Use this instead of unsafe.Pointer to bypass the sandbox. +// +// fs := c.Fs().NewUnrestricted() +// fs.Read("/etc/hostname") // works — no sandbox +func (m *Fs) NewUnrestricted() *Fs { + return (&Fs{}).New("/") +} + +// Root returns the sandbox root path. +// +// root := c.Fs().Root() // e.g. "/home/agent/.core" +func (m *Fs) Root() string { + if m.root == "" { + return "/" + } + return m.root +} + // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). // Empty root defaults to "/" — the zero value of Fs is usable. @@ -148,6 +167,32 @@ func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result { return Result{OK: true} } +// WriteAtomic writes content by writing to a temp file then renaming. +// Rename is atomic on POSIX — concurrent readers never see a partial file. +// Use this for status files, config, or any file read from multiple goroutines. +// +// r := fs.WriteAtomic("/status.json", jsonData) +func (m *Fs) WriteAtomic(p, content string) Result { + vp := m.validatePath(p) + if !vp.OK { + return vp + } + full := vp.Value.(string) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return Result{err, false} + } + + tmp := full + ".tmp." + shortRand() + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + return Result{err, false} + } + if err := os.Rename(tmp, full); err != nil { + os.Remove(tmp) + return Result{err, false} + } + return Result{OK: true} +} + // EnsureDir creates directory if it doesn't exist. func (m *Fs) EnsureDir(p string) Result { vp := m.validatePath(p) diff --git a/fs_test.go b/fs_test.go index 99160b9..f109d53 100644 --- a/fs_test.go +++ b/fs_test.go @@ -255,3 +255,103 @@ func TestFs_ReadStream_WriteStream_Good(t *testing.T) { w := c.Fs().WriteStream(path) assert.True(t, w.OK) } + +// --- WriteAtomic --- + +func TestFs_WriteAtomic_Good(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "status.json") + r := c.Fs().WriteAtomic(path, `{"status":"completed"}`) + assert.True(t, r.OK) + + read := c.Fs().Read(path) + assert.True(t, read.OK) + assert.Equal(t, `{"status":"completed"}`, read.Value) +} + +func TestFs_WriteAtomic_Good_Overwrite(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "data.txt") + c.Fs().WriteAtomic(path, "first") + c.Fs().WriteAtomic(path, "second") + + read := c.Fs().Read(path) + assert.Equal(t, "second", read.Value) +} + +func TestFs_WriteAtomic_Bad_ReadOnlyDir(t *testing.T) { + // Write to a non-existent root that can't be created + m := (&Fs{}).New("/proc/nonexistent") + r := m.WriteAtomic("file.txt", "data") + assert.False(t, r.OK, "WriteAtomic must fail when parent dir cannot be created") +} + +func TestFs_WriteAtomic_Ugly_NoTempFileLeftOver(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "clean.txt") + c.Fs().WriteAtomic(path, "content") + + // Check no .tmp files remain + entries, _ := os.ReadDir(dir) + for _, e := range entries { + assert.False(t, Contains(e.Name(), ".tmp."), "temp file should not remain after successful atomic write") + } +} + +func TestFs_WriteAtomic_Good_CreatesParentDir(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "sub", "dir", "file.txt") + r := c.Fs().WriteAtomic(path, "nested") + assert.True(t, r.OK) + + read := c.Fs().Read(path) + assert.Equal(t, "nested", read.Value) +} + +// --- NewUnrestricted --- + +func TestFs_NewUnrestricted_Good(t *testing.T) { + sandboxed := (&Fs{}).New(t.TempDir()) + unrestricted := sandboxed.NewUnrestricted() + assert.Equal(t, "/", unrestricted.Root()) +} + +func TestFs_NewUnrestricted_Good_CanReadOutsideSandbox(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(dir, "outside.txt") + os.WriteFile(outside, []byte("hello"), 0644) + + sandboxed := (&Fs{}).New(filepath.Join(dir, "sandbox")) + unrestricted := sandboxed.NewUnrestricted() + + r := unrestricted.Read(outside) + assert.True(t, r.OK, "unrestricted Fs must read paths outside the original sandbox") + assert.Equal(t, "hello", r.Value) +} + +func TestFs_NewUnrestricted_Ugly_OriginalStaysSandboxed(t *testing.T) { + dir := t.TempDir() + sandbox := filepath.Join(dir, "sandbox") + os.MkdirAll(sandbox, 0755) + + sandboxed := (&Fs{}).New(sandbox) + _ = sandboxed.NewUnrestricted() // getting unrestricted doesn't affect original + + assert.Equal(t, sandbox, sandboxed.Root(), "original Fs must remain sandboxed") +} + +// --- Root --- + +func TestFs_Root_Good(t *testing.T) { + m := (&Fs{}).New("/home/agent") + assert.Equal(t, "/home/agent", m.Root()) +} + +func TestFs_Root_Good_Default(t *testing.T) { + m := (&Fs{}).New("") + assert.Equal(t, "/", m.Root()) +} diff --git a/info_test.go b/info_test.go index 5f09db7..5ebfe06 100644 --- a/info_test.go +++ b/info_test.go @@ -13,27 +13,27 @@ import ( "github.com/stretchr/testify/require" ) -func TestEnv_OS(t *testing.T) { +func TestInfo_Env_OS(t *testing.T) { assert.Equal(t, runtime.GOOS, core.Env("OS")) } -func TestEnv_ARCH(t *testing.T) { +func TestInfo_Env_ARCH(t *testing.T) { assert.Equal(t, runtime.GOARCH, core.Env("ARCH")) } -func TestEnv_GO(t *testing.T) { +func TestInfo_Env_GO(t *testing.T) { assert.Equal(t, runtime.Version(), core.Env("GO")) } -func TestEnv_DS(t *testing.T) { +func TestInfo_Env_DS(t *testing.T) { assert.Equal(t, string(os.PathSeparator), core.Env("DS")) } -func TestEnv_PS(t *testing.T) { +func TestInfo_Env_PS(t *testing.T) { assert.Equal(t, string(os.PathListSeparator), core.Env("PS")) } -func TestEnv_DIR_HOME(t *testing.T) { +func TestInfo_Env_DIR_HOME(t *testing.T) { if ch := os.Getenv("CORE_HOME"); ch != "" { assert.Equal(t, ch, core.Env("DIR_HOME")) return @@ -43,58 +43,58 @@ func TestEnv_DIR_HOME(t *testing.T) { assert.Equal(t, home, core.Env("DIR_HOME")) } -func TestEnv_DIR_TMP(t *testing.T) { +func TestInfo_Env_DIR_TMP(t *testing.T) { assert.Equal(t, os.TempDir(), core.Env("DIR_TMP")) } -func TestEnv_DIR_CONFIG(t *testing.T) { +func TestInfo_Env_DIR_CONFIG(t *testing.T) { cfg, err := os.UserConfigDir() require.NoError(t, err) assert.Equal(t, cfg, core.Env("DIR_CONFIG")) } -func TestEnv_DIR_CACHE(t *testing.T) { +func TestInfo_Env_DIR_CACHE(t *testing.T) { cache, err := os.UserCacheDir() require.NoError(t, err) assert.Equal(t, cache, core.Env("DIR_CACHE")) } -func TestEnv_HOSTNAME(t *testing.T) { +func TestInfo_Env_HOSTNAME(t *testing.T) { hostname, err := os.Hostname() require.NoError(t, err) assert.Equal(t, hostname, core.Env("HOSTNAME")) } -func TestEnv_USER(t *testing.T) { +func TestInfo_Env_USER(t *testing.T) { assert.NotEmpty(t, core.Env("USER")) } -func TestEnv_PID(t *testing.T) { +func TestInfo_Env_PID(t *testing.T) { assert.NotEmpty(t, core.Env("PID")) } -func TestEnv_NUM_CPU(t *testing.T) { +func TestInfo_Env_NUM_CPU(t *testing.T) { assert.NotEmpty(t, core.Env("NUM_CPU")) } -func TestEnv_CORE_START(t *testing.T) { +func TestInfo_Env_CORE_START(t *testing.T) { ts := core.Env("CORE_START") require.NotEmpty(t, ts) _, err := time.Parse(time.RFC3339, ts) assert.NoError(t, err, "CORE_START should be valid RFC3339") } -func TestEnv_Unknown(t *testing.T) { +func TestInfo_Env_Unknown(t *testing.T) { assert.Equal(t, "", core.Env("NOPE")) } -func TestEnv_CoreInstance(t *testing.T) { +func TestInfo_Env_CoreInstance(t *testing.T) { c := core.New() assert.Equal(t, core.Env("OS"), c.Env("OS")) assert.Equal(t, core.Env("DIR_HOME"), c.Env("DIR_HOME")) } -func TestEnvKeys(t *testing.T) { +func TestInfo_EnvKeys(t *testing.T) { keys := core.EnvKeys() assert.NotEmpty(t, keys) assert.Contains(t, keys, "OS") diff --git a/ipc.go b/ipc.go index 6f0f99f..f2b998f 100644 --- a/ipc.go +++ b/ipc.go @@ -11,7 +11,7 @@ import ( "sync" ) -// Ipc holds IPC dispatch data. +// Ipc holds IPC dispatch data and the named action registry. // // ipc := (&core.Ipc{}).New() type Ipc struct { @@ -23,17 +23,27 @@ type Ipc struct { taskMu sync.RWMutex taskHandlers []TaskHandler + + actions *Registry[*Action] // named action registry + tasks *Registry[*TaskDef] // named task registry } -func (c *Core) Action(msg Message) Result { +// broadcast dispatches a message to all registered IPC handlers. +// Each handler is wrapped in panic recovery. All handlers fire regardless of individual results. +func (c *Core) broadcast(msg Message) Result { c.ipc.ipcMu.RLock() handlers := slices.Clone(c.ipc.ipcHandlers) c.ipc.ipcMu.RUnlock() for _, h := range handlers { - if r := h(c, msg); !r.OK { - return r - } + func() { + defer func() { + if r := recover(); r != nil { + Error("ACTION handler panicked", "panic", r) + } + }() + h(c, msg) + }() } return Result{OK: true} } @@ -72,3 +82,31 @@ func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler) c.ipc.queryMu.Unlock() } + +// --- IPC Registration (handlers) --- + +// RegisterAction registers a broadcast handler for ACTION messages. +// +// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { +// if ev, ok := msg.(AgentCompleted); ok { ... } +// return core.Result{OK: true} +// }) +func (c *Core) RegisterAction(handler func(*Core, Message) Result) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) + c.ipc.ipcMu.Unlock() +} + +// RegisterActions registers multiple broadcast handlers. +func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) + c.ipc.ipcMu.Unlock() +} + +// RegisterTask registers a handler for PERFORM task dispatch. +func (c *Core) RegisterTask(handler TaskHandler) { + c.ipc.taskMu.Lock() + c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) + c.ipc.taskMu.Unlock() +} diff --git a/ipc_test.go b/ipc_test.go index 7977bbb..fcacb02 100644 --- a/ipc_test.go +++ b/ipc_test.go @@ -39,9 +39,58 @@ func TestAction_None_Good(t *testing.T) { assert.True(t, r.OK) } +func TestAction_Bad_HandlerFails(t *testing.T) { + c := New() + c.RegisterAction(func(_ *Core, _ Message) Result { + return Result{Value: NewError("intentional"), OK: false} + }) + // ACTION is broadcast — even with a failing handler, dispatch succeeds + r := c.ACTION(testMessage{payload: "test"}) + assert.True(t, r.OK) +} + +func TestAction_Ugly_HandlerFailsChainContinues(t *testing.T) { + c := New() + var order []int + c.RegisterAction(func(_ *Core, _ Message) Result { + order = append(order, 1) + return Result{OK: true} + }) + c.RegisterAction(func(_ *Core, _ Message) Result { + order = append(order, 2) + return Result{Value: NewError("handler 2 fails"), OK: false} + }) + c.RegisterAction(func(_ *Core, _ Message) Result { + order = append(order, 3) + return Result{OK: true} + }) + r := c.ACTION(testMessage{payload: "test"}) + assert.True(t, r.OK) + assert.Equal(t, []int{1, 2, 3}, order, "all 3 handlers must fire even when handler 2 returns !OK") +} + +func TestAction_Ugly_HandlerPanicsChainContinues(t *testing.T) { + c := New() + var order []int + c.RegisterAction(func(_ *Core, _ Message) Result { + order = append(order, 1) + return Result{OK: true} + }) + c.RegisterAction(func(_ *Core, _ Message) Result { + panic("handler 2 explodes") + }) + c.RegisterAction(func(_ *Core, _ Message) Result { + order = append(order, 3) + return Result{OK: true} + }) + r := c.ACTION(testMessage{payload: "test"}) + assert.True(t, r.OK) + assert.Equal(t, []int{1, 3}, order, "handlers 1 and 3 must fire even when handler 2 panics") +} + // --- IPC: Queries --- -func TestQuery_Good(t *testing.T) { +func TestIpc_Query_Good(t *testing.T) { c := New() c.RegisterQuery(func(_ *Core, q Query) Result { if q == "ping" { @@ -54,7 +103,7 @@ func TestQuery_Good(t *testing.T) { assert.Equal(t, "pong", r.Value) } -func TestQuery_Unhandled_Good(t *testing.T) { +func TestIpc_Query_Unhandled_Good(t *testing.T) { c := New() c.RegisterQuery(func(_ *Core, q Query) Result { return Result{} @@ -63,7 +112,7 @@ func TestQuery_Unhandled_Good(t *testing.T) { assert.False(t, r.OK) } -func TestQueryAll_Good(t *testing.T) { +func TestIpc_QueryAll_Good(t *testing.T) { c := New() c.RegisterQuery(func(_ *Core, _ Query) Result { return Result{Value: "a", OK: true} @@ -81,7 +130,7 @@ func TestQueryAll_Good(t *testing.T) { // --- IPC: Tasks --- -func TestPerform_Good(t *testing.T) { +func TestIpc_Perform_Good(t *testing.T) { c := New() c.RegisterTask(func(_ *Core, t Task) Result { if t == "compute" { diff --git a/lock.go b/lock.go index 539aaab..a963278 100644 --- a/lock.go +++ b/lock.go @@ -12,78 +12,57 @@ import ( type Lock struct { Name string Mutex *sync.RWMutex - mu sync.Mutex // protects locks map - locks map[string]*sync.RWMutex // per-Core named mutexes + locks *Registry[*sync.RWMutex] // per-Core named mutexes } // Lock returns a named Lock, creating the mutex if needed. // Locks are per-Core — separate Core instances do not share mutexes. func (c *Core) Lock(name string) *Lock { - c.lock.mu.Lock() - if c.lock.locks == nil { - c.lock.locks = make(map[string]*sync.RWMutex) + r := c.lock.locks.Get(name) + if r.OK { + return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)} } - m, ok := c.lock.locks[name] - if !ok { - m = &sync.RWMutex{} - c.lock.locks[name] = m - } - c.lock.mu.Unlock() + m := &sync.RWMutex{} + c.lock.locks.Set(name, m) return &Lock{Name: name, Mutex: m} } // LockEnable marks that the service lock should be applied after initialisation. func (c *Core) LockEnable(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() c.services.lockEnabled = true } // LockApply activates the service lock if it was enabled. func (c *Core) LockApply(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() if c.services.lockEnabled { - c.services.locked = true + c.services.Lock() } } -// Startables returns services that have an OnStart function. +// Startables returns services that have an OnStart function, in registration order. func (c *Core) Startables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStart != nil { out = append(out, svc) } - } + }) return Result{out, true} } -// Stoppables returns services that have an OnStop function. +// Stoppables returns services that have an OnStop function, in registration order. func (c *Core) Stoppables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStop != nil { out = append(out, svc) } - } + }) return Result{out, true} } diff --git a/lock_test.go b/lock_test.go index 1c96e42..ef0ba86 100644 --- a/lock_test.go +++ b/lock_test.go @@ -28,7 +28,7 @@ func TestLock_DifferentName_Good(t *testing.T) { assert.NotEqual(t, l1, l2) } -func TestLockEnable_Good(t *testing.T) { +func TestLock_LockEnable_Good(t *testing.T) { c := New() c.Service("early", Service{}) c.LockEnable() @@ -38,7 +38,7 @@ func TestLockEnable_Good(t *testing.T) { assert.False(t, r.OK) } -func TestStartables_Good(t *testing.T) { +func TestLock_Startables_Good(t *testing.T) { c := New() c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }}) r := c.Startables() @@ -46,7 +46,7 @@ func TestStartables_Good(t *testing.T) { assert.Len(t, r.Value.([]*Service), 1) } -func TestStoppables_Good(t *testing.T) { +func TestLock_Stoppables_Good(t *testing.T) { c := New() c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }}) r := c.Stoppables() diff --git a/log_test.go b/log_test.go index 70e6103..0084451 100644 --- a/log_test.go +++ b/log_test.go @@ -105,7 +105,7 @@ func TestLog_Username_Good(t *testing.T) { // --- LogErr --- -func TestLogErr_Good(t *testing.T) { +func TestLog_LogErr_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) le := NewLogErr(l) assert.NotNil(t, le) @@ -114,7 +114,7 @@ func TestLogErr_Good(t *testing.T) { le.Log(err) } -func TestLogErr_Nil_Good(t *testing.T) { +func TestLog_LogErr_Nil_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) le := NewLogErr(l) le.Log(nil) // should not panic @@ -122,13 +122,13 @@ func TestLogErr_Nil_Good(t *testing.T) { // --- LogPanic --- -func TestLogPanic_Good(t *testing.T) { +func TestLog_LogPanic_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) lp := NewLogPanic(l) assert.NotNil(t, lp) } -func TestLogPanic_Recover_Good(t *testing.T) { +func TestLog_LogPanic_Recover_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) lp := NewLogPanic(l) assert.NotPanics(t, func() { diff --git a/options_test.go b/options_test.go index 751d008..06bded5 100644 --- a/options_test.go +++ b/options_test.go @@ -9,7 +9,7 @@ import ( // --- NewOptions --- -func TestNewOptions_Good(t *testing.T) { +func TestOptions_NewOptions_Good(t *testing.T) { opts := NewOptions( Option{Key: "name", Value: "brain"}, Option{Key: "port", Value: 8080}, @@ -17,7 +17,7 @@ func TestNewOptions_Good(t *testing.T) { assert.Equal(t, 2, opts.Len()) } -func TestNewOptions_Empty_Good(t *testing.T) { +func TestOptions_NewOptions_Empty_Good(t *testing.T) { opts := NewOptions() assert.Equal(t, 0, opts.Len()) assert.False(t, opts.Has("anything")) @@ -133,41 +133,41 @@ func TestOptions_TypedStruct_Good(t *testing.T) { // --- Result --- -func TestResult_New_Good(t *testing.T) { +func TestOptions_Result_New_Good(t *testing.T) { r := Result{}.New("value") assert.Equal(t, "value", r.Value) } -func TestResult_New_Error_Bad(t *testing.T) { +func TestOptions_Result_New_Error_Bad(t *testing.T) { err := E("test", "failed", nil) r := Result{}.New(err) assert.False(t, r.OK) assert.Equal(t, err, r.Value) } -func TestResult_Result_Good(t *testing.T) { +func TestOptions_Result_Result_Good(t *testing.T) { r := Result{Value: "hello", OK: true} assert.Equal(t, r, r.Result()) } -func TestResult_Result_WithArgs_Good(t *testing.T) { +func TestOptions_Result_Result_WithArgs_Good(t *testing.T) { r := Result{}.Result("value") assert.Equal(t, "value", r.Value) } -func TestResult_Get_Good(t *testing.T) { +func TestOptions_Result_Get_Good(t *testing.T) { r := Result{Value: "hello", OK: true} assert.True(t, r.Get().OK) } -func TestResult_Get_Bad(t *testing.T) { +func TestOptions_Result_Get_Bad(t *testing.T) { r := Result{Value: "err", OK: false} assert.False(t, r.Get().OK) } // --- WithOption --- -func TestWithOption_Good(t *testing.T) { +func TestOptions_WithOption_Good(t *testing.T) { c := New( WithOption("name", "myapp"), WithOption("port", 8080), diff --git a/path_test.go b/path_test.go index fdc8725..4e0f427 100644 --- a/path_test.go +++ b/path_test.go @@ -41,32 +41,32 @@ func TestPath_CleanDoubleSlash(t *testing.T) { assert.Equal(t, ds+"tmp"+ds+"file", core.Path("/tmp//file")) } -func TestPathBase(t *testing.T) { +func TestPath_PathBase(t *testing.T) { assert.Equal(t, "core", core.PathBase("/Users/snider/Code/core")) assert.Equal(t, "homelab", core.PathBase("deploy/to/homelab")) } -func TestPathBase_Root(t *testing.T) { +func TestPath_PathBase_Root(t *testing.T) { assert.Equal(t, "/", core.PathBase("/")) } -func TestPathBase_Empty(t *testing.T) { +func TestPath_PathBase_Empty(t *testing.T) { assert.Equal(t, ".", core.PathBase("")) } -func TestPathDir(t *testing.T) { +func TestPath_PathDir(t *testing.T) { assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core")) } -func TestPathDir_Root(t *testing.T) { +func TestPath_PathDir_Root(t *testing.T) { assert.Equal(t, "/", core.PathDir("/file")) } -func TestPathDir_NoDir(t *testing.T) { +func TestPath_PathDir_NoDir(t *testing.T) { assert.Equal(t, ".", core.PathDir("file.go")) } -func TestPathExt(t *testing.T) { +func TestPath_PathExt(t *testing.T) { assert.Equal(t, ".go", core.PathExt("main.go")) assert.Equal(t, "", core.PathExt("Makefile")) assert.Equal(t, ".gz", core.PathExt("archive.tar.gz")) @@ -76,7 +76,7 @@ func TestPath_EnvConsistency(t *testing.T) { assert.Equal(t, core.Env("DIR_HOME"), core.Path()) } -func TestPathGlob_Good(t *testing.T) { +func TestPath_PathGlob_Good(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644) os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0644) @@ -86,26 +86,26 @@ func TestPathGlob_Good(t *testing.T) { assert.Len(t, matches, 2) } -func TestPathGlob_NoMatch(t *testing.T) { +func TestPath_PathGlob_NoMatch(t *testing.T) { matches := core.PathGlob("/nonexistent/pattern-*.xyz") assert.Empty(t, matches) } -func TestPathIsAbs_Good(t *testing.T) { +func TestPath_PathIsAbs_Good(t *testing.T) { assert.True(t, core.PathIsAbs("/tmp")) assert.True(t, core.PathIsAbs("/")) assert.False(t, core.PathIsAbs("relative")) assert.False(t, core.PathIsAbs("")) } -func TestCleanPath_Good(t *testing.T) { +func TestPath_CleanPath_Good(t *testing.T) { assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/")) assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/")) assert.Equal(t, "/", core.CleanPath("/", "/")) assert.Equal(t, ".", core.CleanPath("", "/")) } -func TestPathDir_TrailingSlash(t *testing.T) { +func TestPath_PathDir_TrailingSlash(t *testing.T) { result := core.PathDir("/Users/snider/Code/") assert.Equal(t, "/Users/snider/Code", result) } diff --git a/process.go b/process.go new file mode 100644 index 0000000..2365791 --- /dev/null +++ b/process.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Process is the Core primitive for managed execution. +// Methods emit via named Actions — actual execution is handled by +// whichever service registers the "process.*" actions (typically go-process). +// +// If go-process is NOT registered, all methods return Result{OK: false}. +// This is permission-by-registration: no handler = no capability. +// +// Usage: +// +// r := c.Process().Run(ctx, "git", "log", "--oneline") +// if r.OK { output := r.Value.(string) } +// +// r := c.Process().RunIn(ctx, "/path/to/repo", "go", "test", "./...") +// +// Permission model: +// +// // Full Core — process registered: +// c := core.New(core.WithService(process.Register)) +// c.Process().Run(ctx, "git", "log") // works +// +// // Sandboxed Core — no process: +// c := core.New() +// c.Process().Run(ctx, "git", "log") // Result{OK: false} +package core + +import "context" + +// Process is the Core primitive for process management. +// Zero dependencies — delegates to named Actions. +type Process struct { + core *Core +} + +// Process returns the process management primitive. +// +// c.Process().Run(ctx, "git", "log") +func (c *Core) Process() *Process { + return &Process{core: c} +} + +// Run executes a command synchronously and returns the output. +// +// r := c.Process().Run(ctx, "git", "log", "--oneline") +// if r.OK { output := r.Value.(string) } +func (p *Process) Run(ctx context.Context, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + )) +} + +// RunIn executes a command in a specific directory. +// +// r := c.Process().RunIn(ctx, "/repo", "go", "test", "./...") +func (p *Process) RunIn(ctx context.Context, dir string, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + Option{Key: "dir", Value: dir}, + )) +} + +// RunWithEnv executes with additional environment variables. +// +// r := c.Process().RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test") +func (p *Process) RunWithEnv(ctx context.Context, dir string, env []string, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + Option{Key: "dir", Value: dir}, + Option{Key: "env", Value: env}, + )) +} + +// Start spawns a detached/background process. +// +// r := c.Process().Start(ctx, ProcessStartOptions{Command: "docker", Args: []string{"run", "..."}}) +func (p *Process) Start(ctx context.Context, opts Options) Result { + return p.core.Action("process.start").Run(ctx, opts) +} + +// Kill terminates a managed process by ID or PID. +// +// c.Process().Kill(ctx, core.NewOptions(core.Option{Key: "id", Value: processID})) +func (p *Process) Kill(ctx context.Context, opts Options) Result { + return p.core.Action("process.kill").Run(ctx, opts) +} + +// Exists returns true if any process execution capability is registered. +// +// if c.Process().Exists() { /* can run commands */ } +func (p *Process) Exists() bool { + return p.core.Action("process.run").Exists() +} diff --git a/process_test.go b/process_test.go new file mode 100644 index 0000000..15c35bc --- /dev/null +++ b/process_test.go @@ -0,0 +1,144 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Process.Run --- + +func TestProcess_Run_Good(t *testing.T) { + c := New() + // Register a mock process handler + c.Action("process.run", func(_ context.Context, opts Options) Result { + cmd := opts.String("command") + return Result{Value: "output of " + cmd, OK: true} + }) + + r := c.Process().Run(context.Background(), "git", "log") + assert.True(t, r.OK) + assert.Equal(t, "output of git", r.Value) +} + +func TestProcess_Run_Bad_NotRegistered(t *testing.T) { + c := New() + // No process service registered — sandboxed Core + r := c.Process().Run(context.Background(), "git", "log") + assert.False(t, r.OK, "sandboxed Core must not execute commands") +} + +func TestProcess_Run_Ugly_HandlerPanics(t *testing.T) { + c := New() + c.Action("process.run", func(_ context.Context, _ Options) Result { + panic("segfault") + }) + r := c.Process().Run(context.Background(), "test") + assert.False(t, r.OK, "panicking handler must not crash") +} + +// --- Process.RunIn --- + +func TestProcess_RunIn_Good(t *testing.T) { + c := New() + c.Action("process.run", func(_ context.Context, opts Options) Result { + dir := opts.String("dir") + cmd := opts.String("command") + return Result{Value: cmd + " in " + dir, OK: true} + }) + + r := c.Process().RunIn(context.Background(), "/repo", "go", "test") + assert.True(t, r.OK) + assert.Equal(t, "go in /repo", r.Value) +} + +// --- Process.RunWithEnv --- + +func TestProcess_RunWithEnv_Good(t *testing.T) { + c := New() + c.Action("process.run", func(_ context.Context, opts Options) Result { + r := opts.Get("env") + if !r.OK { + return Result{Value: "no env", OK: true} + } + env := r.Value.([]string) + return Result{Value: env[0], OK: true} + }) + + r := c.Process().RunWithEnv(context.Background(), "/repo", []string{"GOWORK=off"}, "go", "test") + assert.True(t, r.OK) + assert.Equal(t, "GOWORK=off", r.Value) +} + +// --- Process.Start --- + +func TestProcess_Start_Good(t *testing.T) { + c := New() + c.Action("process.start", func(_ context.Context, opts Options) Result { + return Result{Value: "proc-1", OK: true} + }) + + r := c.Process().Start(context.Background(), NewOptions( + Option{Key: "command", Value: "docker"}, + Option{Key: "args", Value: []string{"run", "nginx"}}, + )) + assert.True(t, r.OK) + assert.Equal(t, "proc-1", r.Value) +} + +func TestProcess_Start_Bad_NotRegistered(t *testing.T) { + c := New() + r := c.Process().Start(context.Background(), NewOptions()) + assert.False(t, r.OK) +} + +// --- Process.Kill --- + +func TestProcess_Kill_Good(t *testing.T) { + c := New() + c.Action("process.kill", func(_ context.Context, opts Options) Result { + return Result{OK: true} + }) + + r := c.Process().Kill(context.Background(), NewOptions( + Option{Key: "id", Value: "proc-1"}, + )) + assert.True(t, r.OK) +} + +// --- Process.Exists --- + +func TestProcess_Exists_Good(t *testing.T) { + c := New() + assert.False(t, c.Process().Exists(), "no process service = no capability") + + c.Action("process.run", func(_ context.Context, _ Options) Result { + return Result{OK: true} + }) + assert.True(t, c.Process().Exists(), "process.run registered = capability exists") +} + +// --- Permission model --- + +func TestProcess_Ugly_PermissionByRegistration(t *testing.T) { + // Full Core + full := New() + full.Action("process.run", func(_ context.Context, opts Options) Result { + return Result{Value: "executed " + opts.String("command"), OK: true} + }) + + // Sandboxed Core + sandboxed := New() + + // Full can execute + assert.True(t, full.Process().Exists()) + r := full.Process().Run(context.Background(), "whoami") + assert.True(t, r.OK) + + // Sandboxed cannot + assert.False(t, sandboxed.Process().Exists()) + r = sandboxed.Process().Run(context.Background(), "whoami") + assert.False(t, r.OK) +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..fca0a4f --- /dev/null +++ b/registry.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Thread-safe named collection primitive for the Core framework. +// Registry[T] is the universal brick — all named registries (services, +// commands, actions, drives, data) embed this type. +// +// Usage: +// +// r := core.NewRegistry[*MyService]() +// r.Set("brain", brainSvc) +// r.Get("brain") // Result{brainSvc, true} +// r.Has("brain") // true +// r.Names() // []string{"brain"} (insertion order) +// r.Each(func(name string, svc *MyService) { ... }) +// r.Lock() // fully frozen — no more writes +// r.Seal() // no new keys, updates to existing OK +// +// Three lock modes: +// +// Open (default) — anything goes +// Sealed — no new keys, existing keys CAN be updated +// Locked — fully frozen, no writes at all +package core + +import ( + "path/filepath" + "sync" +) + +// registryMode controls write behaviour. +type registryMode int + +const ( + registryOpen registryMode = iota // anything goes + registrySealed // update existing, no new keys + registryLocked // fully frozen +) + +// Registry is a thread-safe named collection. The universal brick +// for all named registries in Core. +// +// r := core.NewRegistry[*Service]() +// r.Set("brain", svc) +// if r.Has("brain") { ... } +type Registry[T any] struct { + items map[string]T + disabled map[string]bool + order []string // insertion order + mu sync.RWMutex + mode registryMode +} + +// NewRegistry creates an empty registry in Open mode. +// +// r := core.NewRegistry[*Service]() +func NewRegistry[T any]() *Registry[T] { + return &Registry[T]{ + items: make(map[string]T), + disabled: make(map[string]bool), + } +} + +// Set registers an item by name. Returns Result{OK: false} if the +// registry is locked, or if sealed and the key doesn't already exist. +// +// r.Set("brain", brainSvc) +func (r *Registry[T]) Set(name string, item T) Result { + r.mu.Lock() + defer r.mu.Unlock() + + switch r.mode { + case registryLocked: + return Result{E("registry.Set", Concat("registry is locked, cannot set: ", name), nil), false} + case registrySealed: + if _, exists := r.items[name]; !exists { + return Result{E("registry.Set", Concat("registry is sealed, cannot add new key: ", name), nil), false} + } + } + + if _, exists := r.items[name]; !exists { + r.order = append(r.order, name) + } + r.items[name] = item + return Result{OK: true} +} + +// Get retrieves an item by name. +// +// res := r.Get("brain") +// if res.OK { svc := res.Value.(*Service) } +func (r *Registry[T]) Get(name string) Result { + r.mu.RLock() + defer r.mu.RUnlock() + + item, ok := r.items[name] + if !ok { + return Result{} + } + return Result{item, true} +} + +// Has returns true if the name exists in the registry. +// +// if r.Has("brain") { ... } +func (r *Registry[T]) Has(name string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.items[name] + return ok +} + +// Names returns all registered names in insertion order. +// +// names := r.Names() // ["brain", "monitor", "process"] +func (r *Registry[T]) Names() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]string, len(r.order)) + copy(out, r.order) + return out +} + +// List returns items whose names match the glob pattern. +// Uses filepath.Match semantics: "*" matches any sequence, "?" matches one char. +// +// services := r.List("process.*") +func (r *Registry[T]) List(pattern string) []T { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []T + for _, name := range r.order { + if matched, _ := filepath.Match(pattern, name); matched { + if !r.disabled[name] { + result = append(result, r.items[name]) + } + } + } + return result +} + +// Each iterates over all items in insertion order, calling fn for each. +// Disabled items are skipped. +// +// r.Each(func(name string, svc *Service) { +// fmt.Println(name, svc) +// }) +func (r *Registry[T]) Each(fn func(string, T)) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, name := range r.order { + if !r.disabled[name] { + fn(name, r.items[name]) + } + } +} + +// Len returns the number of registered items (including disabled). +// +// count := r.Len() +func (r *Registry[T]) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.items) +} + +// Delete removes an item. Returns Result{OK: false} if locked or not found. +// +// r.Delete("old-service") +func (r *Registry[T]) Delete(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if r.mode == registryLocked { + return Result{E("registry.Delete", Concat("registry is locked, cannot delete: ", name), nil), false} + } + if _, exists := r.items[name]; !exists { + return Result{E("registry.Delete", Concat("not found: ", name), nil), false} + } + + delete(r.items, name) + delete(r.disabled, name) + // Remove from order slice + for i, n := range r.order { + if n == name { + r.order = append(r.order[:i], r.order[i+1:]...) + break + } + } + return Result{OK: true} +} + +// Disable soft-disables an item. It still exists but Each/List skip it. +// Returns Result{OK: false} if not found. +// +// r.Disable("broken-handler") +func (r *Registry[T]) Disable(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.items[name]; !exists { + return Result{E("registry.Disable", Concat("not found: ", name), nil), false} + } + r.disabled[name] = true + return Result{OK: true} +} + +// Enable re-enables a disabled item. +// +// r.Enable("fixed-handler") +func (r *Registry[T]) Enable(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.items[name]; !exists { + return Result{E("registry.Enable", Concat("not found: ", name), nil), false} + } + delete(r.disabled, name) + return Result{OK: true} +} + +// Disabled returns true if the item is soft-disabled. +func (r *Registry[T]) Disabled(name string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.disabled[name] +} + +// Lock fully freezes the registry. No Set, no Delete. +// +// r.Lock() // after startup, prevent late registration +func (r *Registry[T]) Lock() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registryLocked +} + +// Locked returns true if the registry is fully frozen. +func (r *Registry[T]) Locked() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode == registryLocked +} + +// Seal prevents new keys but allows updates to existing keys. +// Use for hot-reload: shape is fixed, implementations can change. +// +// r.Seal() // no new capabilities, but handlers can be swapped +func (r *Registry[T]) Seal() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registrySealed +} + +// Sealed returns true if the registry is sealed (no new keys). +func (r *Registry[T]) Sealed() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode == registrySealed +} + +// Open resets the registry to open mode (default). +// +// r.Open() // re-enable writes for testing +func (r *Registry[T]) Open() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registryOpen +} diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..814c328 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,387 @@ +package core_test + +import ( + "sync" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Set --- + +func TestRegistry_Set_Good(t *testing.T) { + r := NewRegistry[string]() + res := r.Set("alpha", "first") + assert.True(t, res.OK) + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Set_Good_Update(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Set("alpha", "second") + res := r.Get("alpha") + assert.Equal(t, "second", res.Value) + assert.Equal(t, 1, r.Len(), "update should not increase count") +} + +func TestRegistry_Set_Bad_Locked(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Lock() + res := r.Set("beta", "second") + assert.False(t, res.OK) +} + +func TestRegistry_Set_Bad_SealedNewKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + res := r.Set("beta", "new") + assert.False(t, res.OK, "sealed registry must reject new keys") +} + +func TestRegistry_Set_Good_SealedExistingKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + res := r.Set("alpha", "updated") + assert.True(t, res.OK, "sealed registry must allow updates to existing keys") + assert.Equal(t, "updated", r.Get("alpha").Value) +} + +func TestRegistry_Set_Ugly_ConcurrentWrites(t *testing.T) { + r := NewRegistry[int]() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Set(Sprintf("key-%d", n), n) + }(i) + } + wg.Wait() + assert.Equal(t, 100, r.Len()) +} + +// --- Get --- + +func TestRegistry_Get_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Get("alpha") + assert.True(t, res.OK) + assert.Equal(t, "value", res.Value) +} + +func TestRegistry_Get_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Get("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Get_Ugly_EmptyKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("", "empty-key") + res := r.Get("") + assert.True(t, res.OK, "empty string is a valid key") + assert.Equal(t, "empty-key", res.Value) +} + +// --- Has --- + +func TestRegistry_Has_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Has_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + assert.False(t, r.Has("missing")) +} + +func TestRegistry_Has_Ugly_AfterDelete(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Delete("alpha") + assert.False(t, r.Has("alpha")) +} + +// --- Names --- + +func TestRegistry_Names_Good(t *testing.T) { + r := NewRegistry[int]() + r.Set("charlie", 3) + r.Set("alpha", 1) + r.Set("bravo", 2) + assert.Equal(t, []string{"charlie", "alpha", "bravo"}, r.Names(), "must preserve insertion order") +} + +func TestRegistry_Names_Bad_Empty(t *testing.T) { + r := NewRegistry[int]() + assert.Empty(t, r.Names()) +} + +func TestRegistry_Names_Ugly_AfterDeleteAndReinsert(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + r.Delete("b") + r.Set("d", 4) + assert.Equal(t, []string{"a", "c", "d"}, r.Names()) +} + +// --- Each --- + +func TestRegistry_Each_Good(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + var names []string + var sum int + r.Each(func(name string, val int) { + names = append(names, name) + sum += val + }) + assert.Equal(t, []string{"a", "b", "c"}, names) + assert.Equal(t, 6, sum) +} + +func TestRegistry_Each_Bad_Empty(t *testing.T) { + r := NewRegistry[int]() + called := false + r.Each(func(_ string, _ int) { called = true }) + assert.False(t, called) +} + +func TestRegistry_Each_Ugly_SkipsDisabled(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + r.Disable("b") + var names []string + r.Each(func(name string, _ int) { names = append(names, name) }) + assert.Equal(t, []string{"a", "c"}, names) +} + +// --- Len --- + +func TestRegistry_Len_Good(t *testing.T) { + r := NewRegistry[string]() + assert.Equal(t, 0, r.Len()) + r.Set("a", "1") + assert.Equal(t, 1, r.Len()) + r.Set("b", "2") + assert.Equal(t, 2, r.Len()) +} + +// --- List --- + +func TestRegistry_List_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("process.run", "run") + r.Set("process.start", "start") + r.Set("agentic.dispatch", "dispatch") + items := r.List("process.*") + assert.Len(t, items, 2) + assert.Contains(t, items, "run") + assert.Contains(t, items, "start") +} + +func TestRegistry_List_Bad_NoMatch(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "1") + items := r.List("beta.*") + assert.Empty(t, items) +} + +func TestRegistry_List_Ugly_SkipsDisabled(t *testing.T) { + r := NewRegistry[string]() + r.Set("process.run", "run") + r.Set("process.kill", "kill") + r.Disable("process.kill") + items := r.List("process.*") + assert.Len(t, items, 1) + assert.Equal(t, "run", items[0]) +} + +func TestRegistry_List_Good_WildcardAll(t *testing.T) { + r := NewRegistry[string]() + r.Set("a", "1") + r.Set("b", "2") + items := r.List("*") + assert.Len(t, items, 2) +} + +// --- Delete --- + +func TestRegistry_Delete_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Delete("alpha") + assert.True(t, res.OK) + assert.False(t, r.Has("alpha")) + assert.Equal(t, 0, r.Len()) +} + +func TestRegistry_Delete_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Delete("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Delete_Ugly_Locked(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Lock() + res := r.Delete("alpha") + assert.False(t, res.OK, "locked registry must reject delete") + assert.True(t, r.Has("alpha"), "item must survive failed delete") +} + +// --- Disable / Enable --- + +func TestRegistry_Disable_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Disable("alpha") + assert.True(t, res.OK) + assert.True(t, r.Disabled("alpha")) + // Still exists via Get/Has + assert.True(t, r.Has("alpha")) + assert.True(t, r.Get("alpha").OK) +} + +func TestRegistry_Disable_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Disable("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Disable_Ugly_EnableRoundtrip(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Disable("alpha") + assert.True(t, r.Disabled("alpha")) + + res := r.Enable("alpha") + assert.True(t, res.OK) + assert.False(t, r.Disabled("alpha")) + + // Verify Each sees it again + var seen []string + r.Each(func(name string, _ string) { seen = append(seen, name) }) + assert.Equal(t, []string{"alpha"}, seen) +} + +func TestRegistry_Enable_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Enable("missing") + assert.False(t, res.OK) +} + +// --- Lock --- + +func TestRegistry_Lock_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Lock() + assert.True(t, r.Locked()) + // Reads still work + assert.True(t, r.Get("alpha").OK) + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Lock_Bad_SetAfterLock(t *testing.T) { + r := NewRegistry[string]() + r.Lock() + res := r.Set("new", "value") + assert.False(t, res.OK) +} + +func TestRegistry_Lock_Ugly_UpdateAfterLock(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Lock() + res := r.Set("alpha", "second") + assert.False(t, res.OK, "locked registry must reject even updates") + assert.Equal(t, "first", r.Get("alpha").Value, "value must not change") +} + +// --- Seal --- + +func TestRegistry_Seal_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + assert.True(t, r.Sealed()) + // Update existing OK + res := r.Set("alpha", "second") + assert.True(t, res.OK) + assert.Equal(t, "second", r.Get("alpha").Value) +} + +func TestRegistry_Seal_Bad_NewKey(t *testing.T) { + r := NewRegistry[string]() + r.Seal() + res := r.Set("new", "value") + assert.False(t, res.OK) +} + +func TestRegistry_Seal_Ugly_DeleteWhileSealed(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Seal() + // Delete is NOT locked by seal — only Set for new keys + res := r.Delete("alpha") + assert.True(t, res.OK, "seal blocks new keys, not deletes") +} + +// --- Open --- + +func TestRegistry_Open_Good(t *testing.T) { + r := NewRegistry[string]() + r.Lock() + assert.True(t, r.Locked()) + r.Open() + assert.False(t, r.Locked()) + // Can write again + res := r.Set("new", "value") + assert.True(t, res.OK) +} + +// --- Concurrency --- + +func TestRegistry_Ugly_ConcurrentReadWrite(t *testing.T) { + r := NewRegistry[int]() + var wg sync.WaitGroup + + // Concurrent writers + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Set(Sprintf("w-%d", n), n) + }(i) + } + + // Concurrent readers + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Has(Sprintf("w-%d", n)) + r.Get(Sprintf("w-%d", n)) + r.Names() + r.Len() + }(i) + } + + wg.Wait() + assert.Equal(t, 50, r.Len()) +} diff --git a/runtime_test.go b/runtime_test.go index 2d18f56..334bbec 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -15,7 +15,7 @@ type testOpts struct { Timeout int } -func TestServiceRuntime_Good(t *testing.T) { +func TestRuntime_ServiceRuntime_Good(t *testing.T) { c := New() opts := testOpts{URL: "https://api.lthn.ai", Timeout: 30} rt := NewServiceRuntime(c, opts) @@ -28,7 +28,7 @@ func TestServiceRuntime_Good(t *testing.T) { // --- NewWithFactories --- -func TestNewWithFactories_Good(t *testing.T) { +func TestRuntime_NewWithFactories_Good(t *testing.T) { r := NewWithFactories(nil, map[string]ServiceFactory{ "svc1": func() Result { return Result{Value: Service{}, OK: true} }, "svc2": func() Result { return Result{Value: Service{}, OK: true} }, @@ -38,14 +38,14 @@ func TestNewWithFactories_Good(t *testing.T) { assert.NotNil(t, rt.Core) } -func TestNewWithFactories_NilFactory_Good(t *testing.T) { +func TestRuntime_NewWithFactories_NilFactory_Good(t *testing.T) { r := NewWithFactories(nil, map[string]ServiceFactory{ "bad": nil, }) assert.True(t, r.OK) // nil factories skipped } -func TestNewRuntime_Good(t *testing.T) { +func TestRuntime_NewRuntime_Good(t *testing.T) { r := NewRuntime(nil) assert.True(t, r.OK) } diff --git a/service.go b/service.go index 14324db..46738ad 100644 --- a/service.go +++ b/service.go @@ -29,11 +29,11 @@ type Service struct { OnReload func() Result } -// serviceRegistry holds registered services. -type serviceRegistry struct { - services map[string]*Service +// ServiceRegistry holds registered services. Embeds Registry[*Service] +// for thread-safe named storage with insertion order. +type ServiceRegistry struct { + *Registry[*Service] lockEnabled bool - locked bool } // --- Core service methods --- @@ -44,12 +44,11 @@ type serviceRegistry struct { // r := c.Service("auth") func (c *Core) Service(name string, service ...Service) Result { if len(service) == 0 { - c.Lock("srv").Mutex.RLock() - svc, ok := c.services.services[name] - c.Lock("srv").Mutex.RUnlock() - if !ok || svc == nil { + r := c.services.Get(name) + if !r.OK { return Result{} } + svc := r.Value.(*Service) // Return the instance if available, otherwise the Service DTO if svc.Instance != nil { return Result{svc.Instance, true} @@ -61,21 +60,16 @@ func (c *Core) Service(name string, service ...Service) Result { return Result{E("core.Service", "service name cannot be empty", nil), false} } - c.Lock("srv").Mutex.Lock() - defer c.Lock("srv").Mutex.Unlock() - - if c.services.locked { + if c.services.Locked() { return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} } - if _, exists := c.services.services[name]; exists { + if c.services.Has(name) { return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false} } srv := &service[0] srv.Name = name - c.services.services[name] = srv - - return Result{OK: true} + return c.services.Set(name, srv) } // RegisterService registers a service instance by name. @@ -88,13 +82,10 @@ func (c *Core) RegisterService(name string, instance any) Result { return Result{E("core.RegisterService", "service name cannot be empty", nil), false} } - c.Lock("srv").Mutex.Lock() - defer c.Lock("srv").Mutex.Unlock() - - if c.services.locked { + if c.services.Locked() { return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} } - if _, exists := c.services.services[name]; exists { + if c.services.Has(name) { return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false} } @@ -103,22 +94,16 @@ func (c *Core) RegisterService(name string, instance any) Result { // Auto-discover lifecycle interfaces if s, ok := instance.(Startable); ok { srv.OnStart = func() Result { - if err := s.OnStartup(c.context); err != nil { - return Result{err, false} - } - return Result{OK: true} + return s.OnStartup(c.context) } } if s, ok := instance.(Stoppable); ok { srv.OnStop = func() Result { - if err := s.OnShutdown(context.Background()); err != nil { - return Result{err, false} - } - return Result{OK: true} + return s.OnShutdown(context.Background()) } } - c.services.services[name] = srv + c.services.Set(name, srv) // Auto-discover IPC handler if handler, ok := instance.(interface { @@ -157,18 +142,12 @@ func MustServiceFor[T any](c *Core, name string) T { return v } -// Services returns all registered service names. +// Services returns all registered service names in registration order. // // names := c.Services() func (c *Core) Services() []string { if c.services == nil { return nil } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() - var names []string - for k := range c.services.services { - names = append(names, k) - } - return names + return c.services.Names() } diff --git a/service_test.go b/service_test.go index 6bc2617..ca941b3 100644 --- a/service_test.go +++ b/service_test.go @@ -85,14 +85,14 @@ type autoLifecycleService struct { messages []Message } -func (s *autoLifecycleService) OnStartup(_ context.Context) error { +func (s *autoLifecycleService) OnStartup(_ context.Context) Result { s.started = true - return nil + return Result{OK: true} } -func (s *autoLifecycleService) OnShutdown(_ context.Context) error { +func (s *autoLifecycleService) OnShutdown(_ context.Context) Result { s.stopped = true - return nil + return Result{OK: true} } func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result { diff --git a/string_test.go b/string_test.go index 5c821ea..a8fb62d 100644 --- a/string_test.go +++ b/string_test.go @@ -9,61 +9,61 @@ import ( // --- String Operations --- -func TestHasPrefix_Good(t *testing.T) { +func TestString_HasPrefix_Good(t *testing.T) { assert.True(t, HasPrefix("--verbose", "--")) assert.True(t, HasPrefix("-v", "-")) assert.False(t, HasPrefix("hello", "-")) } -func TestHasSuffix_Good(t *testing.T) { +func TestString_HasSuffix_Good(t *testing.T) { assert.True(t, HasSuffix("test.go", ".go")) assert.False(t, HasSuffix("test.go", ".py")) } -func TestTrimPrefix_Good(t *testing.T) { +func TestString_TrimPrefix_Good(t *testing.T) { assert.Equal(t, "verbose", TrimPrefix("--verbose", "--")) assert.Equal(t, "hello", TrimPrefix("hello", "--")) } -func TestTrimSuffix_Good(t *testing.T) { +func TestString_TrimSuffix_Good(t *testing.T) { assert.Equal(t, "test", TrimSuffix("test.go", ".go")) assert.Equal(t, "test.go", TrimSuffix("test.go", ".py")) } -func TestContains_Good(t *testing.T) { +func TestString_Contains_Good(t *testing.T) { assert.True(t, Contains("hello world", "world")) assert.False(t, Contains("hello world", "mars")) } -func TestSplit_Good(t *testing.T) { +func TestString_Split_Good(t *testing.T) { assert.Equal(t, []string{"a", "b", "c"}, Split("a/b/c", "/")) } -func TestSplitN_Good(t *testing.T) { +func TestString_SplitN_Good(t *testing.T) { assert.Equal(t, []string{"key", "value=extra"}, SplitN("key=value=extra", "=", 2)) } -func TestJoin_Good(t *testing.T) { +func TestString_Join_Good(t *testing.T) { assert.Equal(t, "a/b/c", Join("/", "a", "b", "c")) } -func TestReplace_Good(t *testing.T) { +func TestString_Replace_Good(t *testing.T) { assert.Equal(t, "deploy.to.homelab", Replace("deploy/to/homelab", "/", ".")) } -func TestLower_Good(t *testing.T) { +func TestString_Lower_Good(t *testing.T) { assert.Equal(t, "hello", Lower("HELLO")) } -func TestUpper_Good(t *testing.T) { +func TestString_Upper_Good(t *testing.T) { assert.Equal(t, "HELLO", Upper("hello")) } -func TestTrim_Good(t *testing.T) { +func TestString_Trim_Good(t *testing.T) { assert.Equal(t, "hello", Trim(" hello ")) } -func TestRuneCount_Good(t *testing.T) { +func TestString_RuneCount_Good(t *testing.T) { assert.Equal(t, 5, RuneCount("hello")) assert.Equal(t, 1, RuneCount("🔥")) assert.Equal(t, 0, RuneCount("")) diff --git a/task.go b/task.go index acdf394..6d6eddf 100644 --- a/task.go +++ b/task.go @@ -73,20 +73,5 @@ func (c *Core) Perform(t Task) Result { return Result{} } -func (c *Core) RegisterAction(handler func(*Core, Message) Result) { - c.ipc.ipcMu.Lock() - c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) - c.ipc.ipcMu.Unlock() -} - -func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) { - c.ipc.ipcMu.Lock() - c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) - c.ipc.ipcMu.Unlock() -} - -func (c *Core) RegisterTask(handler TaskHandler) { - c.ipc.taskMu.Lock() - c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) - c.ipc.taskMu.Unlock() -} +// Registration methods (RegisterAction, RegisterActions, RegisterTask) +// are in ipc.go — registration is IPC's responsibility. diff --git a/task_test.go b/task_test.go index 37876ad..5c3eb4f 100644 --- a/task_test.go +++ b/task_test.go @@ -12,7 +12,7 @@ import ( // --- PerformAsync --- -func TestPerformAsync_Good(t *testing.T) { +func TestTask_PerformAsync_Good(t *testing.T) { c := New() var mu sync.Mutex var result string @@ -36,7 +36,7 @@ func TestPerformAsync_Good(t *testing.T) { mu.Unlock() } -func TestPerformAsync_Progress_Good(t *testing.T) { +func TestTask_PerformAsync_Progress_Good(t *testing.T) { c := New() c.RegisterTask(func(_ *Core, task Task) Result { return Result{OK: true} @@ -47,7 +47,7 @@ func TestPerformAsync_Progress_Good(t *testing.T) { c.Progress(taskID, 0.5, "halfway", "work") } -func TestPerformAsync_Completion_Good(t *testing.T) { +func TestTask_PerformAsync_Completion_Good(t *testing.T) { c := New() completed := make(chan ActionTaskCompleted, 1) @@ -72,7 +72,7 @@ func TestPerformAsync_Completion_Good(t *testing.T) { } } -func TestPerformAsync_NoHandler_Good(t *testing.T) { +func TestTask_PerformAsync_NoHandler_Good(t *testing.T) { c := New() completed := make(chan ActionTaskCompleted, 1) @@ -93,7 +93,7 @@ func TestPerformAsync_NoHandler_Good(t *testing.T) { } } -func TestPerformAsync_AfterShutdown_Bad(t *testing.T) { +func TestTask_PerformAsync_AfterShutdown_Bad(t *testing.T) { c := New() c.ServiceStartup(context.Background(), nil) c.ServiceShutdown(context.Background()) @@ -104,22 +104,22 @@ func TestPerformAsync_AfterShutdown_Bad(t *testing.T) { // --- RegisterAction + RegisterActions --- -func TestRegisterAction_Good(t *testing.T) { +func TestTask_RegisterAction_Good(t *testing.T) { c := New() called := false c.RegisterAction(func(_ *Core, _ Message) Result { called = true return Result{OK: true} }) - c.Action(nil) + c.ACTION(nil) assert.True(t, called) } -func TestRegisterActions_Good(t *testing.T) { +func TestTask_RegisterActions_Good(t *testing.T) { c := New() count := 0 h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} } c.RegisterActions(h, h) - c.Action(nil) + c.ACTION(nil) assert.Equal(t, 2, count) } diff --git a/utils.go b/utils.go index 038e32e..f76768b 100644 --- a/utils.go +++ b/utils.go @@ -6,11 +6,68 @@ package core import ( + crand "crypto/rand" + "encoding/hex" "fmt" "io" "os" + "strconv" + "sync/atomic" ) +// --- ID Generation --- + +var idCounter atomic.Uint64 + +// ID returns a unique identifier. Format: "id-{counter}-{random}". +// Counter is process-wide atomic. Random suffix prevents collision across restarts. +// +// id := core.ID() // "id-1-a3f2b1" +// id2 := core.ID() // "id-2-c7e4d9" +func ID() string { + return Concat("id-", strconv.FormatUint(idCounter.Add(1), 10), "-", shortRand()) +} + +func shortRand() string { + b := make([]byte, 3) + crand.Read(b) + return hex.EncodeToString(b) +} + +// --- Validation --- + +// ValidateName checks that a string is a valid service/action/command name. +// Rejects empty, ".", "..", and names containing path separators. +// +// r := core.ValidateName("brain") // Result{"brain", true} +// r := core.ValidateName("") // Result{error, false} +// r := core.ValidateName("../escape") // Result{error, false} +func ValidateName(name string) Result { + if name == "" || name == "." || name == ".." { + return Result{E("validate", Concat("invalid name: ", name), nil), false} + } + if Contains(name, "/") || Contains(name, "\\") { + return Result{E("validate", Concat("name contains path separator: ", name), nil), false} + } + return Result{name, true} +} + +// SanitisePath extracts the base filename and rejects traversal attempts. +// Returns "invalid" for dangerous inputs. +// +// core.SanitisePath("../../etc/passwd") // "passwd" +// core.SanitisePath("") // "invalid" +// core.SanitisePath("..") // "invalid" +func SanitisePath(path string) string { + safe := PathBase(path) + if safe == "." || safe == ".." || safe == "" { + return "invalid" + } + return safe +} + +// --- I/O --- + // Print writes a formatted line to a writer, defaulting to os.Stdout. // // core.Print(nil, "hello %s", "world") // → stdout diff --git a/utils_test.go b/utils_test.go index 9b6be9d..88a1203 100644 --- a/utils_test.go +++ b/utils_test.go @@ -8,22 +8,106 @@ import ( "github.com/stretchr/testify/assert" ) +// --- ID --- + +func TestUtils_ID_Good(t *testing.T) { + id := ID() + assert.True(t, HasPrefix(id, "id-")) + assert.True(t, len(id) > 5, "ID should have counter + random suffix") +} + +func TestUtils_ID_Good_Unique(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 1000; i++ { + id := ID() + assert.False(t, seen[id], "ID collision: %s", id) + seen[id] = true + } +} + +func TestUtils_ID_Ugly_CounterMonotonic(t *testing.T) { + // IDs should contain increasing counter values + id1 := ID() + id2 := ID() + // Both should start with "id-" and have different counter parts + assert.NotEqual(t, id1, id2) + assert.True(t, HasPrefix(id1, "id-")) + assert.True(t, HasPrefix(id2, "id-")) +} + +// --- ValidateName --- + +func TestUtils_ValidateName_Good(t *testing.T) { + r := ValidateName("brain") + assert.True(t, r.OK) + assert.Equal(t, "brain", r.Value) +} + +func TestUtils_ValidateName_Good_WithDots(t *testing.T) { + r := ValidateName("process.run") + assert.True(t, r.OK, "dots in names are valid — used for action namespacing") +} + +func TestUtils_ValidateName_Bad_Empty(t *testing.T) { + r := ValidateName("") + assert.False(t, r.OK) +} + +func TestUtils_ValidateName_Bad_Dot(t *testing.T) { + r := ValidateName(".") + assert.False(t, r.OK) +} + +func TestUtils_ValidateName_Bad_DotDot(t *testing.T) { + r := ValidateName("..") + assert.False(t, r.OK) +} + +func TestUtils_ValidateName_Bad_Slash(t *testing.T) { + r := ValidateName("../escape") + assert.False(t, r.OK) +} + +func TestUtils_ValidateName_Ugly_Backslash(t *testing.T) { + r := ValidateName("windows\\path") + assert.False(t, r.OK) +} + +// --- SanitisePath --- + +func TestUtils_SanitisePath_Good(t *testing.T) { + assert.Equal(t, "file.txt", SanitisePath("/some/path/file.txt")) +} + +func TestUtils_SanitisePath_Bad_Empty(t *testing.T) { + assert.Equal(t, "invalid", SanitisePath("")) +} + +func TestUtils_SanitisePath_Bad_DotDot(t *testing.T) { + assert.Equal(t, "invalid", SanitisePath("..")) +} + +func TestUtils_SanitisePath_Ugly_Traversal(t *testing.T) { + // PathBase extracts "passwd" — the traversal is stripped + assert.Equal(t, "passwd", SanitisePath("../../etc/passwd")) +} + // --- FilterArgs --- -func TestFilterArgs_Good(t *testing.T) { +func TestUtils_FilterArgs_Good(t *testing.T) { args := []string{"deploy", "", "to", "-test.v", "homelab", "-test.paniconexit0"} clean := FilterArgs(args) assert.Equal(t, []string{"deploy", "to", "homelab"}, clean) } -func TestFilterArgs_Empty_Good(t *testing.T) { +func TestUtils_FilterArgs_Empty_Good(t *testing.T) { clean := FilterArgs(nil) assert.Nil(t, clean) } // --- ParseFlag --- -func TestParseFlag_ShortValid_Good(t *testing.T) { +func TestUtils_ParseFlag_ShortValid_Good(t *testing.T) { // Single letter k, v, ok := ParseFlag("-v") assert.True(t, ok) @@ -43,7 +127,7 @@ func TestParseFlag_ShortValid_Good(t *testing.T) { assert.Equal(t, "8080", v) } -func TestParseFlag_ShortInvalid_Bad(t *testing.T) { +func TestUtils_ParseFlag_ShortInvalid_Bad(t *testing.T) { // Multiple chars with single dash — invalid _, _, ok := ParseFlag("-verbose") assert.False(t, ok) @@ -52,7 +136,7 @@ func TestParseFlag_ShortInvalid_Bad(t *testing.T) { assert.False(t, ok) } -func TestParseFlag_LongValid_Good(t *testing.T) { +func TestUtils_ParseFlag_LongValid_Good(t *testing.T) { k, v, ok := ParseFlag("--verbose") assert.True(t, ok) assert.Equal(t, "verbose", k) @@ -64,13 +148,13 @@ func TestParseFlag_LongValid_Good(t *testing.T) { assert.Equal(t, "8080", v) } -func TestParseFlag_LongInvalid_Bad(t *testing.T) { +func TestUtils_ParseFlag_LongInvalid_Bad(t *testing.T) { // Single char with double dash — invalid _, _, ok := ParseFlag("--v") assert.False(t, ok) } -func TestParseFlag_NotAFlag_Bad(t *testing.T) { +func TestUtils_ParseFlag_NotAFlag_Bad(t *testing.T) { _, _, ok := ParseFlag("hello") assert.False(t, ok) @@ -80,56 +164,56 @@ func TestParseFlag_NotAFlag_Bad(t *testing.T) { // --- IsFlag --- -func TestIsFlag_Good(t *testing.T) { +func TestUtils_IsFlag_Good(t *testing.T) { assert.True(t, IsFlag("-v")) assert.True(t, IsFlag("--verbose")) assert.True(t, IsFlag("-")) } -func TestIsFlag_Bad(t *testing.T) { +func TestUtils_IsFlag_Bad(t *testing.T) { assert.False(t, IsFlag("hello")) assert.False(t, IsFlag("")) } // --- Arg --- -func TestArg_String_Good(t *testing.T) { +func TestUtils_Arg_String_Good(t *testing.T) { r := Arg(0, "hello", 42, true) assert.True(t, r.OK) assert.Equal(t, "hello", r.Value) } -func TestArg_Int_Good(t *testing.T) { +func TestUtils_Arg_Int_Good(t *testing.T) { r := Arg(1, "hello", 42, true) assert.True(t, r.OK) assert.Equal(t, 42, r.Value) } -func TestArg_Bool_Good(t *testing.T) { +func TestUtils_Arg_Bool_Good(t *testing.T) { r := Arg(2, "hello", 42, true) assert.True(t, r.OK) assert.Equal(t, true, r.Value) } -func TestArg_UnsupportedType_Good(t *testing.T) { +func TestUtils_Arg_UnsupportedType_Good(t *testing.T) { r := Arg(0, 3.14) assert.True(t, r.OK) assert.Equal(t, 3.14, r.Value) } -func TestArg_OutOfBounds_Bad(t *testing.T) { +func TestUtils_Arg_OutOfBounds_Bad(t *testing.T) { r := Arg(5, "only", "two") assert.False(t, r.OK) assert.Nil(t, r.Value) } -func TestArg_NoArgs_Bad(t *testing.T) { +func TestUtils_Arg_NoArgs_Bad(t *testing.T) { r := Arg(0) assert.False(t, r.OK) assert.Nil(t, r.Value) } -func TestArg_ErrorDetection_Good(t *testing.T) { +func TestUtils_Arg_ErrorDetection_Good(t *testing.T) { err := errors.New("fail") r := Arg(0, err) assert.True(t, r.OK) @@ -138,78 +222,78 @@ func TestArg_ErrorDetection_Good(t *testing.T) { // --- ArgString --- -func TestArgString_Good(t *testing.T) { +func TestUtils_ArgString_Good(t *testing.T) { assert.Equal(t, "hello", ArgString(0, "hello", 42)) assert.Equal(t, "world", ArgString(1, "hello", "world")) } -func TestArgString_WrongType_Bad(t *testing.T) { +func TestUtils_ArgString_WrongType_Bad(t *testing.T) { assert.Equal(t, "", ArgString(0, 42)) } -func TestArgString_OutOfBounds_Bad(t *testing.T) { +func TestUtils_ArgString_OutOfBounds_Bad(t *testing.T) { assert.Equal(t, "", ArgString(3, "only")) } // --- ArgInt --- -func TestArgInt_Good(t *testing.T) { +func TestUtils_ArgInt_Good(t *testing.T) { assert.Equal(t, 42, ArgInt(0, 42, "hello")) assert.Equal(t, 99, ArgInt(1, 0, 99)) } -func TestArgInt_WrongType_Bad(t *testing.T) { +func TestUtils_ArgInt_WrongType_Bad(t *testing.T) { assert.Equal(t, 0, ArgInt(0, "not an int")) } -func TestArgInt_OutOfBounds_Bad(t *testing.T) { +func TestUtils_ArgInt_OutOfBounds_Bad(t *testing.T) { assert.Equal(t, 0, ArgInt(5, 1, 2)) } // --- ArgBool --- -func TestArgBool_Good(t *testing.T) { +func TestUtils_ArgBool_Good(t *testing.T) { assert.Equal(t, true, ArgBool(0, true, "hello")) assert.Equal(t, false, ArgBool(1, true, false)) } -func TestArgBool_WrongType_Bad(t *testing.T) { +func TestUtils_ArgBool_WrongType_Bad(t *testing.T) { assert.Equal(t, false, ArgBool(0, "not a bool")) } -func TestArgBool_OutOfBounds_Bad(t *testing.T) { +func TestUtils_ArgBool_OutOfBounds_Bad(t *testing.T) { assert.Equal(t, false, ArgBool(5, true)) } // --- Result.Result() --- -func TestResult_Result_SingleArg_Good(t *testing.T) { +func TestUtils_Result_Result_SingleArg_Good(t *testing.T) { r := Result{}.Result("value") assert.True(t, r.OK) assert.Equal(t, "value", r.Value) } -func TestResult_Result_NilError_Good(t *testing.T) { +func TestUtils_Result_Result_NilError_Good(t *testing.T) { r := Result{}.Result("value", nil) assert.True(t, r.OK) assert.Equal(t, "value", r.Value) } -func TestResult_Result_WithError_Bad(t *testing.T) { +func TestUtils_Result_Result_WithError_Bad(t *testing.T) { err := errors.New("fail") r := Result{}.Result("value", err) assert.False(t, r.OK) assert.Equal(t, err, r.Value) } -func TestResult_Result_ZeroArgs_Good(t *testing.T) { +func TestUtils_Result_Result_ZeroArgs_Good(t *testing.T) { r := Result{"hello", true} got := r.Result() assert.Equal(t, "hello", got.Value) assert.True(t, got.OK) } -func TestResult_Result_ZeroArgs_Empty_Good(t *testing.T) { +func TestUtils_Result_Result_ZeroArgs_Empty_Good(t *testing.T) { r := Result{} got := r.Result() assert.Nil(t, got.Value)