feat: implement RFC plans 1-5 — Registry[T], Action/Task, Process, primitives

Plans 1-5 complete for core/go scope. 456 tests, 84.4% coverage, 100% AX-7 naming.

Critical bugs (Plan 1):
- P4-3+P7-3: ACTION broadcast calls all handlers with panic recovery
- P7-2+P7-4: RunE() with defer ServiceShutdown, Run() delegates
- P3-1: Startable/Stoppable return Result (breaking, clean)
- P9-1: Zero os/exec — App.Find() rewritten with os.Stat+PATH
- I3: Embed() removed, I15: New() comment fixed
- I9: CommandLifecycle removed → Command.Managed field

Registry[T] (Plan 2):
- Universal thread-safe named collection with 3 lock modes
- All 5 registries migrated: services, commands, drive, data, lock
- Insertion order preserved (fixes P4-1)
- c.RegistryOf("name") cross-cutting accessor

Action/Task system (Plan 3):
- Action type with Run()/Exists(), ActionHandler signature
- c.Action("name") dual-purpose accessor (register/invoke)
- TaskDef with Steps — sequential chain, async dispatch, previous-input piping
- Panic recovery on all Action execution
- broadcast() internal, ACTION() sugar

Process primitive (Plan 4):
- c.Process() returns Action sugar — Run/RunIn/RunWithEnv/Start/Kill/Exists
- No deps added — delegates to c.Action("process.*")
- Permission-by-registration: no handler = no capability

Missing primitives (Plan 5):
- core.ID() — atomic counter + crypto/rand suffix
- ValidateName() / SanitisePath() — reusable validation
- Fs.WriteAtomic() — write-to-temp-then-rename
- Fs.NewUnrestricted() / Fs.Root() — legitimate sandbox bypass
- AX-7: 456/456 tests renamed to TestFile_Function_{Good,Bad,Ugly}

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-25 15:18:25 +00:00
parent 0704a7a65b
commit 2dff772a40
42 changed files with 2302 additions and 619 deletions

226
action.go Normal file
View file

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

246
action_test.go Normal file
View file

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

44
app.go
View file

@ -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 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}
}
abs, err := filepath.Abs(path)
if isExecutable(abs) {
return Result{&App{Name: name, Filename: filename, Path: abs}, true}
}
return Result{E("app.Find", Concat(filename, " not found"), nil), false}
}
// Search PATH
pathEnv := os.Getenv("PATH")
if pathEnv == "" {
return Result{E("app.Find", "PATH is empty", nil), false}
}
for _, dir := range Split(pathEnv, string(os.PathListSeparator)) {
candidate := filepath.Join(dir, filename)
if isExecutable(candidate) {
abs, err := filepath.Abs(candidate)
if err != nil {
return Result{err, false}
continue
}
return Result{&App{
Name: name,
Filename: filename,
Path: abs,
}, true}
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
}

26
cli.go
View file

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

View file

@ -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
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,22 +92,20 @@ 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) {
// 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]
cmd.Name = pathName(path)
@ -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.

View file

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

View file

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

View file

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

View file

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

74
core.go
View file

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

View file

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

50
data.go
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

45
fs.go
View file

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

View file

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

View file

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

46
ipc.go
View file

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

View file

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

47
lock.go
View file

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

View file

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

View file

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

View file

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

View file

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

96
process.go Normal file
View file

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

144
process_test.go Normal file
View file

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

271
registry.go Normal file
View file

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

387
registry_test.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

19
task.go
View file

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

View file

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

View file

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

View file

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