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:
parent
0704a7a65b
commit
2dff772a40
42 changed files with 2302 additions and 619 deletions
226
action.go
Normal file
226
action.go
Normal 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
246
action_test.go
Normal 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
44
app.go
|
|
@ -5,7 +5,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
|
|
@ -47,21 +47,47 @@ func (a App) New(opts Options) App {
|
|||
}
|
||||
|
||||
// Find locates a program on PATH and returns a Result containing the App.
|
||||
// Uses os.Stat to search PATH directories — no os/exec dependency.
|
||||
//
|
||||
// r := core.App{}.Find("node", "Node.js")
|
||||
// if r.OK { app := r.Value.(*App) }
|
||||
func (a App) Find(filename, name string) Result {
|
||||
path, err := exec.LookPath(filename)
|
||||
// If 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
26
cli.go
|
|
@ -64,11 +64,7 @@ func (cl *Cli) Run(args ...string) Result {
|
|||
return Result{}
|
||||
}
|
||||
|
||||
c.commands.mu.RLock()
|
||||
cmdCount := len(c.commands.commands)
|
||||
c.commands.mu.RUnlock()
|
||||
|
||||
if cmdCount == 0 {
|
||||
if c.commands.Len() == 0 {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
|
|
@ -79,16 +75,14 @@ func (cl *Cli) Run(args ...string) Result {
|
|||
var cmd *Command
|
||||
var remaining []string
|
||||
|
||||
c.commands.mu.RLock()
|
||||
for i := len(clean); i > 0; i-- {
|
||||
path := JoinPath(clean[:i]...)
|
||||
if found, ok := c.commands.commands[path]; ok {
|
||||
cmd = found
|
||||
if r := c.commands.Get(path); r.OK {
|
||||
cmd = r.Value.(*Command)
|
||||
remaining = clean[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
c.commands.mu.RUnlock()
|
||||
|
||||
if cmd == nil {
|
||||
if cl.banner != nil {
|
||||
|
|
@ -116,9 +110,6 @@ func (cl *Cli) Run(args ...string) Result {
|
|||
if cmd.Action != nil {
|
||||
return cmd.Run(opts)
|
||||
}
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Start(opts)
|
||||
}
|
||||
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
||||
}
|
||||
|
||||
|
|
@ -141,12 +132,9 @@ func (cl *Cli) PrintHelp() {
|
|||
cl.Print("Commands:")
|
||||
}
|
||||
|
||||
c.commands.mu.RLock()
|
||||
defer c.commands.mu.RUnlock()
|
||||
|
||||
for path, cmd := range c.commands.commands {
|
||||
if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) {
|
||||
continue
|
||||
c.commands.Each(func(path string, cmd *Command) {
|
||||
if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) {
|
||||
return
|
||||
}
|
||||
tr := c.I18n().Translate(cmd.I18nKey())
|
||||
desc, _ := tr.Value.(string)
|
||||
|
|
@ -155,7 +143,7 @@ func (cl *Cli) PrintHelp() {
|
|||
} else {
|
||||
cl.Print(" %-30s %s", path, desc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetBanner sets the banner function.
|
||||
|
|
|
|||
117
command.go
117
command.go
|
|
@ -20,37 +20,31 @@
|
|||
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CommandAction is the function signature for command handlers.
|
||||
//
|
||||
// func(opts core.Options) core.Result
|
||||
type CommandAction func(Options) Result
|
||||
|
||||
// CommandLifecycle is implemented by commands that support managed lifecycle.
|
||||
// Basic commands only need an action. Daemon commands implement Start/Stop/Signal
|
||||
// via go-process.
|
||||
type CommandLifecycle interface {
|
||||
Start(Options) Result
|
||||
Stop() Result
|
||||
Restart() Result
|
||||
Reload() Result
|
||||
Signal(string) Result
|
||||
}
|
||||
|
||||
// Command is the DTO for an executable operation.
|
||||
// Commands are declarative — they carry enough information for multiple consumers:
|
||||
// - core.Cli() runs the Action
|
||||
// - core/cli adds rich help, completion, man pages
|
||||
// - go-process wraps Managed commands with lifecycle (PID, health, signals)
|
||||
//
|
||||
// c.Command("serve", core.Command{
|
||||
// Action: handler,
|
||||
// Managed: "process.daemon", // go-process provides start/stop/restart
|
||||
// })
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string // i18n key — derived from path if empty
|
||||
Path string // "deploy/to/homelab"
|
||||
Action CommandAction // business logic
|
||||
Lifecycle CommandLifecycle // optional — provided by go-process
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
32
contract.go
32
contract.go
|
|
@ -7,6 +7,7 @@ package core
|
|||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Message is the type for IPC broadcasts (fire-and-forget).
|
||||
|
|
@ -32,13 +33,21 @@ type QueryHandler func(*Core, Query) Result
|
|||
type TaskHandler func(*Core, Task) Result
|
||||
|
||||
// Startable is implemented by services that need startup initialisation.
|
||||
//
|
||||
// func (s *MyService) OnStartup(ctx context.Context) core.Result {
|
||||
// return core.Result{OK: true}
|
||||
// }
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
OnStartup(ctx context.Context) Result
|
||||
}
|
||||
|
||||
// Stoppable is implemented by services that need shutdown cleanup.
|
||||
//
|
||||
// func (s *MyService) OnShutdown(ctx context.Context) core.Result {
|
||||
// return core.Result{OK: true}
|
||||
// }
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
OnShutdown(ctx context.Context) Result
|
||||
}
|
||||
|
||||
// --- Action Messages ---
|
||||
|
|
@ -81,28 +90,27 @@ type CoreOption func(*Core) Result
|
|||
// Services registered here form the application conclave — they share
|
||||
// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown).
|
||||
//
|
||||
// r := core.New(
|
||||
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})),
|
||||
// c := core.New(
|
||||
// core.WithOption("name", "myapp"),
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
// if !r.OK { log.Fatal(r.Value) }
|
||||
// c := r.Value.(*Core)
|
||||
// c.Run()
|
||||
func New(opts ...CoreOption) *Core {
|
||||
c := &Core{
|
||||
app: &App{},
|
||||
data: &Data{},
|
||||
drive: &Drive{},
|
||||
data: &Data{Registry: NewRegistry[*Embed]()},
|
||||
drive: &Drive{Registry: NewRegistry[*DriveHandle]()},
|
||||
fs: (&Fs{}).New("/"),
|
||||
config: (&Config{}).New(),
|
||||
error: &ErrorPanic{},
|
||||
log: &ErrorLog{},
|
||||
lock: &Lock{},
|
||||
ipc: &Ipc{},
|
||||
lock: &Lock{locks: NewRegistry[*sync.RWMutex]()},
|
||||
ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*TaskDef]()},
|
||||
info: systemInfo,
|
||||
i18n: &I18n{},
|
||||
services: &serviceRegistry{services: make(map[string]*Service)},
|
||||
commands: &commandRegistry{commands: make(map[string]*Command)},
|
||||
services: &ServiceRegistry{Registry: NewRegistry[*Service]()},
|
||||
commands: &CommandRegistry{Registry: NewRegistry[*Command]()},
|
||||
}
|
||||
c.context, c.cancel = context.WithCancel(context.Background())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
74
core.go
|
|
@ -25,8 +25,8 @@ type Core struct {
|
|||
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
|
||||
log *ErrorLog // c.Log() — Structured logging + error wrapping
|
||||
// cli accessed via ServiceFor[*Cli](c, "cli")
|
||||
commands *commandRegistry // c.Command("path") — Command tree
|
||||
services *serviceRegistry // c.Service("name") — Service registry
|
||||
commands *CommandRegistry // c.Command("path") — Command tree
|
||||
services *ServiceRegistry // c.Service("name") — Service registry
|
||||
lock *Lock // c.Lock("name") — Named mutexes
|
||||
ipc *Ipc // c.IPC() — Message bus for IPC
|
||||
info *SysInfo // c.Env("key") — Read-only system/environment information
|
||||
|
|
@ -45,7 +45,6 @@ func (c *Core) Options() *Options { return c.options }
|
|||
func (c *Core) App() *App { return c.app }
|
||||
func (c *Core) Data() *Data { return c.data }
|
||||
func (c *Core) Drive() *Drive { return c.drive }
|
||||
func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data()
|
||||
func (c *Core) Fs() *Fs { return c.fs }
|
||||
func (c *Core) Config() *Config { return c.config }
|
||||
func (c *Core) Error() *ErrorPanic { return c.error }
|
||||
|
|
@ -62,37 +61,51 @@ func (c *Core) Core() *Core { return c }
|
|||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// Run starts all services, runs the CLI, then shuts down.
|
||||
// This is the standard application lifecycle for CLI apps.
|
||||
// RunE starts all services, runs the CLI, then shuts down.
|
||||
// Returns an error instead of calling os.Exit — let main() handle the exit.
|
||||
// ServiceShutdown is always called via defer, even on startup failure or panic.
|
||||
//
|
||||
// c := core.New(core.WithService(myService.Register)).Value.(*Core)
|
||||
// c.Run()
|
||||
func (c *Core) Run() {
|
||||
// if err := c.RunE(); err != nil {
|
||||
// os.Exit(1)
|
||||
// }
|
||||
func (c *Core) RunE() error {
|
||||
defer c.ServiceShutdown(context.Background())
|
||||
|
||||
r := c.ServiceStartup(c.context, nil)
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
Error(err.Error())
|
||||
return err
|
||||
}
|
||||
os.Exit(1)
|
||||
return E("core.Run", "startup failed", nil)
|
||||
}
|
||||
|
||||
if cli := c.Cli(); cli != nil {
|
||||
r = cli.Run()
|
||||
}
|
||||
|
||||
c.ServiceShutdown(context.Background())
|
||||
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
Error(err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts all services, runs the CLI, then shuts down.
|
||||
// Calls os.Exit(1) on failure. For error handling use RunE().
|
||||
//
|
||||
// c := core.New(core.WithService(myService.Register))
|
||||
// c.Run()
|
||||
func (c *Core) Run() {
|
||||
if err := c.RunE(); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- IPC (uppercase aliases) ---
|
||||
|
||||
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
|
||||
func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) }
|
||||
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
|
||||
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
|
||||
func (c *Core) PERFORM(t Task) Result { return c.Perform(t) }
|
||||
|
|
@ -114,4 +127,37 @@ func (c *Core) Must(err error, op, msg string) {
|
|||
c.log.Must(err, op, msg)
|
||||
}
|
||||
|
||||
// --- Registry Accessor ---
|
||||
|
||||
// RegistryOf returns a named registry for cross-cutting queries.
|
||||
// Known registries: "services", "commands", "actions".
|
||||
//
|
||||
// c.RegistryOf("services").Names() // all service names
|
||||
// c.RegistryOf("actions").List("process.*") // process capabilities
|
||||
// c.RegistryOf("commands").Len() // command count
|
||||
func (c *Core) RegistryOf(name string) *Registry[any] {
|
||||
// Bridge typed registries to untyped access for cross-cutting queries.
|
||||
// Each registry is wrapped in a read-only proxy.
|
||||
switch name {
|
||||
case "services":
|
||||
return registryProxy(c.services.Registry)
|
||||
case "commands":
|
||||
return registryProxy(c.commands.Registry)
|
||||
case "actions":
|
||||
return registryProxy(c.ipc.actions)
|
||||
default:
|
||||
return NewRegistry[any]() // empty registry for unknown names
|
||||
}
|
||||
}
|
||||
|
||||
// registryProxy creates a read-only any-typed view of a typed registry.
|
||||
// Copies current state — not a live view (avoids type parameter leaking).
|
||||
func registryProxy[T any](src *Registry[T]) *Registry[any] {
|
||||
proxy := NewRegistry[any]()
|
||||
src.Each(func(name string, item T) {
|
||||
proxy.Set(name, item)
|
||||
})
|
||||
return proxy
|
||||
}
|
||||
|
||||
// --- Global Instance ---
|
||||
|
|
|
|||
111
core_test.go
111
core_test.go
|
|
@ -13,24 +13,24 @@ import (
|
|||
|
||||
// --- New ---
|
||||
|
||||
func TestNew_Good(t *testing.T) {
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestNew_WithOptions_Good(t *testing.T) {
|
||||
func TestCore_New_WithOptions_Good(t *testing.T) {
|
||||
c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})))
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func TestNew_WithOptions_Bad(t *testing.T) {
|
||||
func TestCore_New_WithOptions_Bad(t *testing.T) {
|
||||
// Empty options — should still create a valid Core
|
||||
c := New(WithOptions(NewOptions()))
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestNew_WithService_Good(t *testing.T) {
|
||||
func TestCore_New_WithService_Good(t *testing.T) {
|
||||
started := false
|
||||
c := New(
|
||||
WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})),
|
||||
|
|
@ -49,7 +49,7 @@ func TestNew_WithService_Good(t *testing.T) {
|
|||
assert.True(t, started)
|
||||
}
|
||||
|
||||
func TestNew_WithServiceLock_Good(t *testing.T) {
|
||||
func TestCore_New_WithServiceLock_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("allowed", Service{})
|
||||
|
|
@ -63,7 +63,7 @@ func TestNew_WithServiceLock_Good(t *testing.T) {
|
|||
assert.False(t, reg.OK)
|
||||
}
|
||||
|
||||
func TestNew_WithService_Bad_FailingOption(t *testing.T) {
|
||||
func TestCore_New_WithService_Bad_FailingOption(t *testing.T) {
|
||||
secondCalled := false
|
||||
_ = New(
|
||||
WithService(func(c *Core) Result {
|
||||
|
|
@ -79,7 +79,7 @@ func TestNew_WithService_Bad_FailingOption(t *testing.T) {
|
|||
|
||||
// --- Accessors ---
|
||||
|
||||
func TestAccessors_Good(t *testing.T) {
|
||||
func TestCore_Accessors_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.NotNil(t, c.Data())
|
||||
|
|
@ -147,6 +147,103 @@ func TestCore_Must_Nil_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// --- RegistryOf ---
|
||||
|
||||
func TestCore_RegistryOf_Good_Services(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("alpha", Service{})
|
||||
}),
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("bravo", Service{})
|
||||
}),
|
||||
)
|
||||
reg := c.RegistryOf("services")
|
||||
// cli is auto-registered + our 2
|
||||
assert.True(t, reg.Has("alpha"))
|
||||
assert.True(t, reg.Has("bravo"))
|
||||
assert.True(t, reg.Has("cli"))
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Good_Commands(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
||||
reg := c.RegistryOf("commands")
|
||||
assert.True(t, reg.Has("deploy"))
|
||||
assert.True(t, reg.Has("test"))
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Good_Actions(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
|
||||
reg := c.RegistryOf("actions")
|
||||
assert.True(t, reg.Has("process.run"))
|
||||
assert.True(t, reg.Has("brain.recall"))
|
||||
assert.Equal(t, 2, reg.Len())
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Bad_Unknown(t *testing.T) {
|
||||
c := New()
|
||||
reg := c.RegistryOf("nonexistent")
|
||||
assert.Equal(t, 0, reg.Len(), "unknown registry returns empty")
|
||||
}
|
||||
|
||||
// --- RunE ---
|
||||
|
||||
func TestCore_RunE_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("healthy", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
OnStop: func() Result { return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCore_RunE_Bad_StartupFailure(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("startup failed"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup failed")
|
||||
}
|
||||
|
||||
func TestCore_RunE_Ugly_StartupFailureCallsShutdown(t *testing.T) {
|
||||
shutdownCalled := false
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("cleanup", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
OnStop: func() Result { shutdownCalled = true; return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("boom"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.Error(t, err)
|
||||
assert.True(t, shutdownCalled, "ServiceShutdown must be called even when startup fails — cleanup service must get OnStop")
|
||||
}
|
||||
|
||||
func TestCore_Run_HelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
|
|
|
|||
50
data.go
50
data.go
|
|
@ -25,13 +25,12 @@ package core
|
|||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Data manages mounted embedded filesystems from core packages.
|
||||
// Embeds Registry[*Embed] for thread-safe named storage.
|
||||
type Data struct {
|
||||
mounts map[string]*Embed
|
||||
mu sync.RWMutex
|
||||
*Registry[*Embed]
|
||||
}
|
||||
|
||||
// New registers an embedded filesystem under a named prefix.
|
||||
|
|
@ -62,54 +61,27 @@ func (d *Data) New(opts Options) Result {
|
|||
path = "."
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.mounts == nil {
|
||||
d.mounts = make(map[string]*Embed)
|
||||
}
|
||||
|
||||
mr := Mount(fsys, path)
|
||||
if !mr.OK {
|
||||
return mr
|
||||
}
|
||||
|
||||
emb := mr.Value.(*Embed)
|
||||
d.mounts[name] = emb
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// Get returns the Embed for a named mount point.
|
||||
//
|
||||
// r := c.Data().Get("brain")
|
||||
// if r.OK { emb := r.Value.(*Embed) }
|
||||
func (d *Data) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.mounts == nil {
|
||||
return Result{}
|
||||
}
|
||||
emb, ok := d.mounts[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
d.Set(name, emb)
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// resolve splits a path like "brain/coding.md" into mount name + relative path.
|
||||
func (d *Data) resolve(path string) (*Embed, string) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
parts := SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil, ""
|
||||
}
|
||||
if d.mounts == nil {
|
||||
r := d.Get(parts[0])
|
||||
if !r.OK {
|
||||
return nil, ""
|
||||
}
|
||||
emb := d.mounts[parts[0]]
|
||||
return emb, parts[1]
|
||||
return r.Value.(*Embed), parts[1]
|
||||
}
|
||||
|
||||
// ReadFile reads a file by full path.
|
||||
|
|
@ -188,15 +160,9 @@ func (d *Data) Extract(path, targetDir string, templateData any) Result {
|
|||
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
|
||||
}
|
||||
|
||||
// Mounts returns the names of all mounted content.
|
||||
// Mounts returns the names of all mounted content in registration order.
|
||||
//
|
||||
// names := c.Data().Mounts()
|
||||
func (d *Data) Mounts() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.mounts {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
return d.Names()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
29
docs/RFC.md
29
docs/RFC.md
|
|
@ -1367,7 +1367,7 @@ The naming convention encodes the architecture:
|
|||
| `CamelCase()` | **Primitive** — the Lego brick, the building block | `c.Action("name")`, `c.Service("name")`, `c.Config()` |
|
||||
| `UPPERCASE()` | **Consumer convenience** — sugar over primitives, works out of the box | `c.ACTION(msg)`, `c.QUERY(q)`, `c.PERFORM(t)` |
|
||||
|
||||
**Current code has this backwards.** `ACTION()` is the uppercase method but it's mapped to the raw dispatch. `Action()` is CamelCase but it's just an alias.
|
||||
**Implemented 2026-03-25.** `c.Action("name")` is the named action primitive. `c.ACTION(msg)` calls internal `broadcast()`. The rename from `Action(msg)` → `broadcast(msg)` is done.
|
||||
|
||||
**Resolution:**
|
||||
|
||||
|
|
@ -1401,13 +1401,9 @@ func MustServiceFor[T any](c *Core, name string) T {
|
|||
|
||||
**Rule:** Use `MustServiceFor` only in `OnStartup` / init paths where failure is fatal. Never in request handlers or runtime code. Document this in RFC-025.
|
||||
|
||||
### 3. Embed() Legacy Accessor (Resolved)
|
||||
### 3. Embed() Legacy Accessor (Resolved — Removed 2026-03-25)
|
||||
|
||||
```go
|
||||
func (c *Core) Embed() Result { return c.data.Get("app") }
|
||||
```
|
||||
|
||||
**Resolution:** Remove. It's a shortcut to `c.Data().Get("app")` with a misleading name. `Embed` sounds like it embeds something — it actually reads. An agent seeing `c.Embed()` can't know it's reading from Data. Dead code, remove in next refactor.
|
||||
**Removed.** `c.Embed()` deleted from core.go. Use `c.Data().Get("app")` instead.
|
||||
|
||||
### 4. Package-Level vs Core-Level Logging (Resolved)
|
||||
|
||||
|
|
@ -1426,17 +1422,22 @@ c.Log().Info("msg") // Core's logger instance
|
|||
|
||||
This is the same dual pattern as `process.Run()` (global) vs `c.Process().Run()` (Core). Package-level functions are the bootstrap path. Core methods are the runtime path.
|
||||
|
||||
### 5. RegisterAction Lives in task.go (Resolved by Issue 16)
|
||||
### 5. RegisterAction Lives in task.go (Resolved — Implemented 2026-03-25)
|
||||
|
||||
Resolved — task.go splits into ipc.go (registration) + action.go (execution). See Issue 16.
|
||||
**Done.** RegisterAction/RegisterActions/RegisterTask moved to ipc.go. action.go has ActionDef, TaskDef, execution. task.go has PerformAsync/Progress.
|
||||
|
||||
### 6. serviceRegistry Is Unexported (Resolved by Section 20)
|
||||
### 6. serviceRegistry Is Unexported (Resolved — Implemented 2026-03-25)
|
||||
|
||||
Resolved — `serviceRegistry` becomes `ServiceRegistry` embedding `Registry[*Service]`. See Section 20.
|
||||
**Done.** All 5 registries migrated to `Registry[T]`:
|
||||
- `serviceRegistry` → `ServiceRegistry` embedding `Registry[*Service]`
|
||||
- `commandRegistry` → `CommandRegistry` embedding `Registry[*Command]`
|
||||
- `Drive` embedding `Registry[*DriveHandle]`
|
||||
- `Data` embedding `Registry[*Embed]`
|
||||
- `Lock.locks` using `Registry[*sync.RWMutex]`
|
||||
|
||||
### 7. No c.Process() Accessor
|
||||
### 7. No c.Process() Accessor (Resolved — Implemented 2026-03-25)
|
||||
|
||||
Spec'd in Section 17. Blocked on go-process v0.7.0 update.
|
||||
**Done.** `c.Process()` returns `*Process` — sugar over `c.Action("process.run")`. No deps added to core/go. go-process v0.7.0 registers the actual handlers.
|
||||
|
||||
### 8. NewRuntime / NewWithFactories — GUI Bridge, Not Legacy (Resolved)
|
||||
|
||||
|
|
@ -1467,7 +1468,7 @@ c := core.New(
|
|||
|
||||
This unifies CLI and GUI bootstrap — same `core.New()`, just with `WithRuntime` added.
|
||||
|
||||
### 9. CommandLifecycle — The Three-Layer CLI Architecture
|
||||
### 9. CommandLifecycle — The Three-Layer CLI Architecture (Resolved — Implemented 2026-03-25)
|
||||
|
||||
```go
|
||||
type CommandLifecycle interface {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
59
drive.go
59
drive.go
|
|
@ -24,10 +24,6 @@
|
|||
// api := c.Drive().Get("api")
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DriveHandle holds a named transport resource.
|
||||
type DriveHandle struct {
|
||||
Name string
|
||||
|
|
@ -35,10 +31,9 @@ type DriveHandle struct {
|
|||
Options Options
|
||||
}
|
||||
|
||||
// Drive manages named transport handles.
|
||||
// Drive manages named transport handles. Embeds Registry[*DriveHandle].
|
||||
type Drive struct {
|
||||
handles map[string]*DriveHandle
|
||||
mu sync.RWMutex
|
||||
*Registry[*DriveHandle]
|
||||
}
|
||||
|
||||
// New registers a transport handle.
|
||||
|
|
@ -53,58 +48,12 @@ func (d *Drive) New(opts Options) Result {
|
|||
return Result{}
|
||||
}
|
||||
|
||||
transport := opts.String("transport")
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.handles == nil {
|
||||
d.handles = make(map[string]*DriveHandle)
|
||||
}
|
||||
|
||||
handle := &DriveHandle{
|
||||
Name: name,
|
||||
Transport: transport,
|
||||
Transport: opts.String("transport"),
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
d.handles[name] = handle
|
||||
d.Set(name, handle)
|
||||
return Result{handle, true}
|
||||
}
|
||||
|
||||
// Get returns a handle by name.
|
||||
//
|
||||
// r := c.Drive().Get("api")
|
||||
// if r.OK { handle := r.Value.(*DriveHandle) }
|
||||
func (d *Drive) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.handles == nil {
|
||||
return Result{}
|
||||
}
|
||||
h, ok := d.handles[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{h, true}
|
||||
}
|
||||
|
||||
// Has returns true if a handle is registered.
|
||||
//
|
||||
// if c.Drive().Has("ssh") { ... }
|
||||
func (d *Drive) Has(name string) bool {
|
||||
return d.Get(name).OK
|
||||
}
|
||||
|
||||
// Names returns all registered handle names.
|
||||
//
|
||||
// names := c.Drive().Names()
|
||||
func (d *Drive) Names() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.handles {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
45
fs.go
|
|
@ -25,6 +25,25 @@ func (m *Fs) New(root string) *Fs {
|
|||
return m
|
||||
}
|
||||
|
||||
// NewUnrestricted returns a new Fs with root "/", granting full filesystem access.
|
||||
// Use this instead of unsafe.Pointer to bypass the sandbox.
|
||||
//
|
||||
// fs := c.Fs().NewUnrestricted()
|
||||
// fs.Read("/etc/hostname") // works — no sandbox
|
||||
func (m *Fs) NewUnrestricted() *Fs {
|
||||
return (&Fs{}).New("/")
|
||||
}
|
||||
|
||||
// Root returns the sandbox root path.
|
||||
//
|
||||
// root := c.Fs().Root() // e.g. "/home/agent/.core"
|
||||
func (m *Fs) Root() string {
|
||||
if m.root == "" {
|
||||
return "/"
|
||||
}
|
||||
return m.root
|
||||
}
|
||||
|
||||
// path sanitises and returns the full path.
|
||||
// Absolute paths are sandboxed under root (unless root is "/").
|
||||
// Empty root defaults to "/" — the zero value of Fs is usable.
|
||||
|
|
@ -148,6 +167,32 @@ func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
|
|||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// WriteAtomic writes content by writing to a temp file then renaming.
|
||||
// Rename is atomic on POSIX — concurrent readers never see a partial file.
|
||||
// Use this for status files, config, or any file read from multiple goroutines.
|
||||
//
|
||||
// r := fs.WriteAtomic("/status.json", jsonData)
|
||||
func (m *Fs) WriteAtomic(p, content string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
tmp := full + ".tmp." + shortRand()
|
||||
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
os.Remove(tmp)
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// EnsureDir creates directory if it doesn't exist.
|
||||
func (m *Fs) EnsureDir(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
|
|
|
|||
100
fs_test.go
100
fs_test.go
|
|
@ -255,3 +255,103 @@ func TestFs_ReadStream_WriteStream_Good(t *testing.T) {
|
|||
w := c.Fs().WriteStream(path)
|
||||
assert.True(t, w.OK)
|
||||
}
|
||||
|
||||
// --- WriteAtomic ---
|
||||
|
||||
func TestFs_WriteAtomic_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "status.json")
|
||||
r := c.Fs().WriteAtomic(path, `{"status":"completed"}`)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.True(t, read.OK)
|
||||
assert.Equal(t, `{"status":"completed"}`, read.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Good_Overwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "data.txt")
|
||||
c.Fs().WriteAtomic(path, "first")
|
||||
c.Fs().WriteAtomic(path, "second")
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.Equal(t, "second", read.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Bad_ReadOnlyDir(t *testing.T) {
|
||||
// Write to a non-existent root that can't be created
|
||||
m := (&Fs{}).New("/proc/nonexistent")
|
||||
r := m.WriteAtomic("file.txt", "data")
|
||||
assert.False(t, r.OK, "WriteAtomic must fail when parent dir cannot be created")
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Ugly_NoTempFileLeftOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "clean.txt")
|
||||
c.Fs().WriteAtomic(path, "content")
|
||||
|
||||
// Check no .tmp files remain
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
assert.False(t, Contains(e.Name(), ".tmp."), "temp file should not remain after successful atomic write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Good_CreatesParentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "sub", "dir", "file.txt")
|
||||
r := c.Fs().WriteAtomic(path, "nested")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.Equal(t, "nested", read.Value)
|
||||
}
|
||||
|
||||
// --- NewUnrestricted ---
|
||||
|
||||
func TestFs_NewUnrestricted_Good(t *testing.T) {
|
||||
sandboxed := (&Fs{}).New(t.TempDir())
|
||||
unrestricted := sandboxed.NewUnrestricted()
|
||||
assert.Equal(t, "/", unrestricted.Root())
|
||||
}
|
||||
|
||||
func TestFs_NewUnrestricted_Good_CanReadOutsideSandbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(dir, "outside.txt")
|
||||
os.WriteFile(outside, []byte("hello"), 0644)
|
||||
|
||||
sandboxed := (&Fs{}).New(filepath.Join(dir, "sandbox"))
|
||||
unrestricted := sandboxed.NewUnrestricted()
|
||||
|
||||
r := unrestricted.Read(outside)
|
||||
assert.True(t, r.OK, "unrestricted Fs must read paths outside the original sandbox")
|
||||
assert.Equal(t, "hello", r.Value)
|
||||
}
|
||||
|
||||
func TestFs_NewUnrestricted_Ugly_OriginalStaysSandboxed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sandbox := filepath.Join(dir, "sandbox")
|
||||
os.MkdirAll(sandbox, 0755)
|
||||
|
||||
sandboxed := (&Fs{}).New(sandbox)
|
||||
_ = sandboxed.NewUnrestricted() // getting unrestricted doesn't affect original
|
||||
|
||||
assert.Equal(t, sandbox, sandboxed.Root(), "original Fs must remain sandboxed")
|
||||
}
|
||||
|
||||
// --- Root ---
|
||||
|
||||
func TestFs_Root_Good(t *testing.T) {
|
||||
m := (&Fs{}).New("/home/agent")
|
||||
assert.Equal(t, "/home/agent", m.Root())
|
||||
}
|
||||
|
||||
func TestFs_Root_Good_Default(t *testing.T) {
|
||||
m := (&Fs{}).New("")
|
||||
assert.Equal(t, "/", m.Root())
|
||||
}
|
||||
|
|
|
|||
34
info_test.go
34
info_test.go
|
|
@ -13,27 +13,27 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnv_OS(t *testing.T) {
|
||||
func TestInfo_Env_OS(t *testing.T) {
|
||||
assert.Equal(t, runtime.GOOS, core.Env("OS"))
|
||||
}
|
||||
|
||||
func TestEnv_ARCH(t *testing.T) {
|
||||
func TestInfo_Env_ARCH(t *testing.T) {
|
||||
assert.Equal(t, runtime.GOARCH, core.Env("ARCH"))
|
||||
}
|
||||
|
||||
func TestEnv_GO(t *testing.T) {
|
||||
func TestInfo_Env_GO(t *testing.T) {
|
||||
assert.Equal(t, runtime.Version(), core.Env("GO"))
|
||||
}
|
||||
|
||||
func TestEnv_DS(t *testing.T) {
|
||||
func TestInfo_Env_DS(t *testing.T) {
|
||||
assert.Equal(t, string(os.PathSeparator), core.Env("DS"))
|
||||
}
|
||||
|
||||
func TestEnv_PS(t *testing.T) {
|
||||
func TestInfo_Env_PS(t *testing.T) {
|
||||
assert.Equal(t, string(os.PathListSeparator), core.Env("PS"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_HOME(t *testing.T) {
|
||||
func TestInfo_Env_DIR_HOME(t *testing.T) {
|
||||
if ch := os.Getenv("CORE_HOME"); ch != "" {
|
||||
assert.Equal(t, ch, core.Env("DIR_HOME"))
|
||||
return
|
||||
|
|
@ -43,58 +43,58 @@ func TestEnv_DIR_HOME(t *testing.T) {
|
|||
assert.Equal(t, home, core.Env("DIR_HOME"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_TMP(t *testing.T) {
|
||||
func TestInfo_Env_DIR_TMP(t *testing.T) {
|
||||
assert.Equal(t, os.TempDir(), core.Env("DIR_TMP"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_CONFIG(t *testing.T) {
|
||||
func TestInfo_Env_DIR_CONFIG(t *testing.T) {
|
||||
cfg, err := os.UserConfigDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg, core.Env("DIR_CONFIG"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_CACHE(t *testing.T) {
|
||||
func TestInfo_Env_DIR_CACHE(t *testing.T) {
|
||||
cache, err := os.UserCacheDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cache, core.Env("DIR_CACHE"))
|
||||
}
|
||||
|
||||
func TestEnv_HOSTNAME(t *testing.T) {
|
||||
func TestInfo_Env_HOSTNAME(t *testing.T) {
|
||||
hostname, err := os.Hostname()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hostname, core.Env("HOSTNAME"))
|
||||
}
|
||||
|
||||
func TestEnv_USER(t *testing.T) {
|
||||
func TestInfo_Env_USER(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("USER"))
|
||||
}
|
||||
|
||||
func TestEnv_PID(t *testing.T) {
|
||||
func TestInfo_Env_PID(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("PID"))
|
||||
}
|
||||
|
||||
func TestEnv_NUM_CPU(t *testing.T) {
|
||||
func TestInfo_Env_NUM_CPU(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("NUM_CPU"))
|
||||
}
|
||||
|
||||
func TestEnv_CORE_START(t *testing.T) {
|
||||
func TestInfo_Env_CORE_START(t *testing.T) {
|
||||
ts := core.Env("CORE_START")
|
||||
require.NotEmpty(t, ts)
|
||||
_, err := time.Parse(time.RFC3339, ts)
|
||||
assert.NoError(t, err, "CORE_START should be valid RFC3339")
|
||||
}
|
||||
|
||||
func TestEnv_Unknown(t *testing.T) {
|
||||
func TestInfo_Env_Unknown(t *testing.T) {
|
||||
assert.Equal(t, "", core.Env("NOPE"))
|
||||
}
|
||||
|
||||
func TestEnv_CoreInstance(t *testing.T) {
|
||||
func TestInfo_Env_CoreInstance(t *testing.T) {
|
||||
c := core.New()
|
||||
assert.Equal(t, core.Env("OS"), c.Env("OS"))
|
||||
assert.Equal(t, core.Env("DIR_HOME"), c.Env("DIR_HOME"))
|
||||
}
|
||||
|
||||
func TestEnvKeys(t *testing.T) {
|
||||
func TestInfo_EnvKeys(t *testing.T) {
|
||||
keys := core.EnvKeys()
|
||||
assert.NotEmpty(t, keys)
|
||||
assert.Contains(t, keys, "OS")
|
||||
|
|
|
|||
46
ipc.go
46
ipc.go
|
|
@ -11,7 +11,7 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// Ipc holds IPC dispatch data.
|
||||
// Ipc holds IPC dispatch data and the named action registry.
|
||||
//
|
||||
// ipc := (&core.Ipc{}).New()
|
||||
type Ipc struct {
|
||||
|
|
@ -23,17 +23,27 @@ type Ipc struct {
|
|||
|
||||
taskMu sync.RWMutex
|
||||
taskHandlers []TaskHandler
|
||||
|
||||
actions *Registry[*Action] // named action registry
|
||||
tasks *Registry[*TaskDef] // named task registry
|
||||
}
|
||||
|
||||
func (c *Core) Action(msg Message) Result {
|
||||
// broadcast dispatches a message to all registered IPC handlers.
|
||||
// Each handler is wrapped in panic recovery. All handlers fire regardless of individual results.
|
||||
func (c *Core) broadcast(msg Message) Result {
|
||||
c.ipc.ipcMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.ipcHandlers)
|
||||
c.ipc.ipcMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
if r := h(c, msg); !r.OK {
|
||||
return r
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
Error("ACTION handler panicked", "panic", r)
|
||||
}
|
||||
}()
|
||||
h(c, msg)
|
||||
}()
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
|
@ -72,3 +82,31 @@ func (c *Core) RegisterQuery(handler QueryHandler) {
|
|||
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
|
||||
c.ipc.queryMu.Unlock()
|
||||
}
|
||||
|
||||
// --- IPC Registration (handlers) ---
|
||||
|
||||
// RegisterAction registers a broadcast handler for ACTION messages.
|
||||
//
|
||||
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
||||
// if ev, ok := msg.(AgentCompleted); ok { ... }
|
||||
// return core.Result{OK: true}
|
||||
// })
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterActions registers multiple broadcast handlers.
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterTask registers a handler for PERFORM task dispatch.
|
||||
func (c *Core) RegisterTask(handler TaskHandler) {
|
||||
c.ipc.taskMu.Lock()
|
||||
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
|
||||
c.ipc.taskMu.Unlock()
|
||||
}
|
||||
|
|
|
|||
57
ipc_test.go
57
ipc_test.go
|
|
@ -39,9 +39,58 @@ func TestAction_None_Good(t *testing.T) {
|
|||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Bad_HandlerFails(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
return Result{Value: NewError("intentional"), OK: false}
|
||||
})
|
||||
// ACTION is broadcast — even with a failing handler, dispatch succeeds
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Ugly_HandlerFailsChainContinues(t *testing.T) {
|
||||
c := New()
|
||||
var order []int
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 1)
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 2)
|
||||
return Result{Value: NewError("handler 2 fails"), OK: false}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 3)
|
||||
return Result{OK: true}
|
||||
})
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []int{1, 2, 3}, order, "all 3 handlers must fire even when handler 2 returns !OK")
|
||||
}
|
||||
|
||||
func TestAction_Ugly_HandlerPanicsChainContinues(t *testing.T) {
|
||||
c := New()
|
||||
var order []int
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 1)
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
panic("handler 2 explodes")
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 3)
|
||||
return Result{OK: true}
|
||||
})
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []int{1, 3}, order, "handlers 1 and 3 must fire even when handler 2 panics")
|
||||
}
|
||||
|
||||
// --- IPC: Queries ---
|
||||
|
||||
func TestQuery_Good(t *testing.T) {
|
||||
func TestIpc_Query_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
if q == "ping" {
|
||||
|
|
@ -54,7 +103,7 @@ func TestQuery_Good(t *testing.T) {
|
|||
assert.Equal(t, "pong", r.Value)
|
||||
}
|
||||
|
||||
func TestQuery_Unhandled_Good(t *testing.T) {
|
||||
func TestIpc_Query_Unhandled_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
return Result{}
|
||||
|
|
@ -63,7 +112,7 @@ func TestQuery_Unhandled_Good(t *testing.T) {
|
|||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestQueryAll_Good(t *testing.T) {
|
||||
func TestIpc_QueryAll_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "a", OK: true}
|
||||
|
|
@ -81,7 +130,7 @@ func TestQueryAll_Good(t *testing.T) {
|
|||
|
||||
// --- IPC: Tasks ---
|
||||
|
||||
func TestPerform_Good(t *testing.T) {
|
||||
func TestIpc_Perform_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterTask(func(_ *Core, t Task) Result {
|
||||
if t == "compute" {
|
||||
|
|
|
|||
47
lock.go
47
lock.go
|
|
@ -12,78 +12,57 @@ import (
|
|||
type Lock struct {
|
||||
Name string
|
||||
Mutex *sync.RWMutex
|
||||
mu sync.Mutex // protects locks map
|
||||
locks map[string]*sync.RWMutex // per-Core named mutexes
|
||||
locks *Registry[*sync.RWMutex] // per-Core named mutexes
|
||||
}
|
||||
|
||||
// Lock returns a named Lock, creating the mutex if needed.
|
||||
// Locks are per-Core — separate Core instances do not share mutexes.
|
||||
func (c *Core) Lock(name string) *Lock {
|
||||
c.lock.mu.Lock()
|
||||
if c.lock.locks == nil {
|
||||
c.lock.locks = make(map[string]*sync.RWMutex)
|
||||
r := c.lock.locks.Get(name)
|
||||
if r.OK {
|
||||
return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)}
|
||||
}
|
||||
m, ok := c.lock.locks[name]
|
||||
if !ok {
|
||||
m = &sync.RWMutex{}
|
||||
c.lock.locks[name] = m
|
||||
}
|
||||
c.lock.mu.Unlock()
|
||||
m := &sync.RWMutex{}
|
||||
c.lock.locks.Set(name, m)
|
||||
return &Lock{Name: name, Mutex: m}
|
||||
}
|
||||
|
||||
// LockEnable marks that the service lock should be applied after initialisation.
|
||||
func (c *Core) LockEnable(name ...string) {
|
||||
n := "srv"
|
||||
if len(name) > 0 {
|
||||
n = name[0]
|
||||
}
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
c.services.lockEnabled = true
|
||||
}
|
||||
|
||||
// LockApply activates the service lock if it was enabled.
|
||||
func (c *Core) LockApply(name ...string) {
|
||||
n := "srv"
|
||||
if len(name) > 0 {
|
||||
n = name[0]
|
||||
}
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
if c.services.lockEnabled {
|
||||
c.services.locked = true
|
||||
c.services.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
// Startables returns services that have an OnStart function.
|
||||
// Startables returns services that have an OnStart function, in registration order.
|
||||
func (c *Core) Startables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
c.services.Each(func(_ string, svc *Service) {
|
||||
if svc.OnStart != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return Result{out, true}
|
||||
}
|
||||
|
||||
// Stoppables returns services that have an OnStop function.
|
||||
// Stoppables returns services that have an OnStop function, in registration order.
|
||||
func (c *Core) Stoppables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
c.services.Each(func(_ string, svc *Service) {
|
||||
if svc.OnStop != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return Result{out, true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
24
path_test.go
24
path_test.go
|
|
@ -41,32 +41,32 @@ func TestPath_CleanDoubleSlash(t *testing.T) {
|
|||
assert.Equal(t, ds+"tmp"+ds+"file", core.Path("/tmp//file"))
|
||||
}
|
||||
|
||||
func TestPathBase(t *testing.T) {
|
||||
func TestPath_PathBase(t *testing.T) {
|
||||
assert.Equal(t, "core", core.PathBase("/Users/snider/Code/core"))
|
||||
assert.Equal(t, "homelab", core.PathBase("deploy/to/homelab"))
|
||||
}
|
||||
|
||||
func TestPathBase_Root(t *testing.T) {
|
||||
func TestPath_PathBase_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathBase("/"))
|
||||
}
|
||||
|
||||
func TestPathBase_Empty(t *testing.T) {
|
||||
func TestPath_PathBase_Empty(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathBase(""))
|
||||
}
|
||||
|
||||
func TestPathDir(t *testing.T) {
|
||||
func TestPath_PathDir(t *testing.T) {
|
||||
assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core"))
|
||||
}
|
||||
|
||||
func TestPathDir_Root(t *testing.T) {
|
||||
func TestPath_PathDir_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathDir("/file"))
|
||||
}
|
||||
|
||||
func TestPathDir_NoDir(t *testing.T) {
|
||||
func TestPath_PathDir_NoDir(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathDir("file.go"))
|
||||
}
|
||||
|
||||
func TestPathExt(t *testing.T) {
|
||||
func TestPath_PathExt(t *testing.T) {
|
||||
assert.Equal(t, ".go", core.PathExt("main.go"))
|
||||
assert.Equal(t, "", core.PathExt("Makefile"))
|
||||
assert.Equal(t, ".gz", core.PathExt("archive.tar.gz"))
|
||||
|
|
@ -76,7 +76,7 @@ func TestPath_EnvConsistency(t *testing.T) {
|
|||
assert.Equal(t, core.Env("DIR_HOME"), core.Path())
|
||||
}
|
||||
|
||||
func TestPathGlob_Good(t *testing.T) {
|
||||
func TestPath_PathGlob_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0644)
|
||||
|
|
@ -86,26 +86,26 @@ func TestPathGlob_Good(t *testing.T) {
|
|||
assert.Len(t, matches, 2)
|
||||
}
|
||||
|
||||
func TestPathGlob_NoMatch(t *testing.T) {
|
||||
func TestPath_PathGlob_NoMatch(t *testing.T) {
|
||||
matches := core.PathGlob("/nonexistent/pattern-*.xyz")
|
||||
assert.Empty(t, matches)
|
||||
}
|
||||
|
||||
func TestPathIsAbs_Good(t *testing.T) {
|
||||
func TestPath_PathIsAbs_Good(t *testing.T) {
|
||||
assert.True(t, core.PathIsAbs("/tmp"))
|
||||
assert.True(t, core.PathIsAbs("/"))
|
||||
assert.False(t, core.PathIsAbs("relative"))
|
||||
assert.False(t, core.PathIsAbs(""))
|
||||
}
|
||||
|
||||
func TestCleanPath_Good(t *testing.T) {
|
||||
func TestPath_CleanPath_Good(t *testing.T) {
|
||||
assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/"))
|
||||
assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/"))
|
||||
assert.Equal(t, "/", core.CleanPath("/", "/"))
|
||||
assert.Equal(t, ".", core.CleanPath("", "/"))
|
||||
}
|
||||
|
||||
func TestPathDir_TrailingSlash(t *testing.T) {
|
||||
func TestPath_PathDir_TrailingSlash(t *testing.T) {
|
||||
result := core.PathDir("/Users/snider/Code/")
|
||||
assert.Equal(t, "/Users/snider/Code", result)
|
||||
}
|
||||
|
|
|
|||
96
process.go
Normal file
96
process.go
Normal 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
144
process_test.go
Normal 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
271
registry.go
Normal 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
387
registry_test.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
55
service.go
55
service.go
|
|
@ -29,11 +29,11 @@ type Service struct {
|
|||
OnReload func() Result
|
||||
}
|
||||
|
||||
// serviceRegistry holds registered services.
|
||||
type serviceRegistry struct {
|
||||
services map[string]*Service
|
||||
// ServiceRegistry holds registered services. Embeds Registry[*Service]
|
||||
// for thread-safe named storage with insertion order.
|
||||
type ServiceRegistry struct {
|
||||
*Registry[*Service]
|
||||
lockEnabled bool
|
||||
locked bool
|
||||
}
|
||||
|
||||
// --- Core service methods ---
|
||||
|
|
@ -44,12 +44,11 @@ type serviceRegistry struct {
|
|||
// r := c.Service("auth")
|
||||
func (c *Core) Service(name string, service ...Service) Result {
|
||||
if len(service) == 0 {
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
svc, ok := c.services.services[name]
|
||||
c.Lock("srv").Mutex.RUnlock()
|
||||
if !ok || svc == nil {
|
||||
r := c.services.Get(name)
|
||||
if !r.OK {
|
||||
return Result{}
|
||||
}
|
||||
svc := r.Value.(*Service)
|
||||
// Return the instance if available, otherwise the Service DTO
|
||||
if svc.Instance != nil {
|
||||
return Result{svc.Instance, true}
|
||||
|
|
@ -61,21 +60,16 @@ func (c *Core) Service(name string, service ...Service) Result {
|
|||
return Result{E("core.Service", "service name cannot be empty", nil), false}
|
||||
}
|
||||
|
||||
c.Lock("srv").Mutex.Lock()
|
||||
defer c.Lock("srv").Mutex.Unlock()
|
||||
|
||||
if c.services.locked {
|
||||
if c.services.Locked() {
|
||||
return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if _, exists := c.services.services[name]; exists {
|
||||
if c.services.Has(name) {
|
||||
return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false}
|
||||
}
|
||||
|
||||
srv := &service[0]
|
||||
srv.Name = name
|
||||
c.services.services[name] = srv
|
||||
|
||||
return Result{OK: true}
|
||||
return c.services.Set(name, srv)
|
||||
}
|
||||
|
||||
// RegisterService registers a service instance by name.
|
||||
|
|
@ -88,13 +82,10 @@ func (c *Core) RegisterService(name string, instance any) Result {
|
|||
return Result{E("core.RegisterService", "service name cannot be empty", nil), false}
|
||||
}
|
||||
|
||||
c.Lock("srv").Mutex.Lock()
|
||||
defer c.Lock("srv").Mutex.Unlock()
|
||||
|
||||
if c.services.locked {
|
||||
if c.services.Locked() {
|
||||
return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if _, exists := c.services.services[name]; exists {
|
||||
if c.services.Has(name) {
|
||||
return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false}
|
||||
}
|
||||
|
||||
|
|
@ -103,22 +94,16 @@ func (c *Core) RegisterService(name string, instance any) Result {
|
|||
// Auto-discover lifecycle interfaces
|
||||
if s, ok := instance.(Startable); ok {
|
||||
srv.OnStart = func() Result {
|
||||
if err := s.OnStartup(c.context); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
return s.OnStartup(c.context)
|
||||
}
|
||||
}
|
||||
if s, ok := instance.(Stoppable); ok {
|
||||
srv.OnStop = func() Result {
|
||||
if err := s.OnShutdown(context.Background()); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
return s.OnShutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
c.services.services[name] = srv
|
||||
c.services.Set(name, srv)
|
||||
|
||||
// Auto-discover IPC handler
|
||||
if handler, ok := instance.(interface {
|
||||
|
|
@ -157,18 +142,12 @@ func MustServiceFor[T any](c *Core, name string) T {
|
|||
return v
|
||||
}
|
||||
|
||||
// Services returns all registered service names.
|
||||
// Services returns all registered service names in registration order.
|
||||
//
|
||||
// names := c.Services()
|
||||
func (c *Core) Services() []string {
|
||||
if c.services == nil {
|
||||
return nil
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var names []string
|
||||
for k := range c.services.services {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
return c.services.Names()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
19
task.go
|
|
@ -73,20 +73,5 @@ func (c *Core) Perform(t Task) Result {
|
|||
return Result{}
|
||||
}
|
||||
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Core) RegisterTask(handler TaskHandler) {
|
||||
c.ipc.taskMu.Lock()
|
||||
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
|
||||
c.ipc.taskMu.Unlock()
|
||||
}
|
||||
// Registration methods (RegisterAction, RegisterActions, RegisterTask)
|
||||
// are in ipc.go — registration is IPC's responsibility.
|
||||
|
|
|
|||
18
task_test.go
18
task_test.go
|
|
@ -12,7 +12,7 @@ import (
|
|||
|
||||
// --- PerformAsync ---
|
||||
|
||||
func TestPerformAsync_Good(t *testing.T) {
|
||||
func TestTask_PerformAsync_Good(t *testing.T) {
|
||||
c := New()
|
||||
var mu sync.Mutex
|
||||
var result string
|
||||
|
|
@ -36,7 +36,7 @@ func TestPerformAsync_Good(t *testing.T) {
|
|||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestPerformAsync_Progress_Good(t *testing.T) {
|
||||
func TestTask_PerformAsync_Progress_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
return Result{OK: true}
|
||||
|
|
@ -47,7 +47,7 @@ func TestPerformAsync_Progress_Good(t *testing.T) {
|
|||
c.Progress(taskID, 0.5, "halfway", "work")
|
||||
}
|
||||
|
||||
func TestPerformAsync_Completion_Good(t *testing.T) {
|
||||
func TestTask_PerformAsync_Completion_Good(t *testing.T) {
|
||||
c := New()
|
||||
completed := make(chan ActionTaskCompleted, 1)
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ func TestPerformAsync_Completion_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPerformAsync_NoHandler_Good(t *testing.T) {
|
||||
func TestTask_PerformAsync_NoHandler_Good(t *testing.T) {
|
||||
c := New()
|
||||
completed := make(chan ActionTaskCompleted, 1)
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ func TestPerformAsync_NoHandler_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPerformAsync_AfterShutdown_Bad(t *testing.T) {
|
||||
func TestTask_PerformAsync_AfterShutdown_Bad(t *testing.T) {
|
||||
c := New()
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
c.ServiceShutdown(context.Background())
|
||||
|
|
@ -104,22 +104,22 @@ func TestPerformAsync_AfterShutdown_Bad(t *testing.T) {
|
|||
|
||||
// --- RegisterAction + RegisterActions ---
|
||||
|
||||
func TestRegisterAction_Good(t *testing.T) {
|
||||
func TestTask_RegisterAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
called := false
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
called = true
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.Action(nil)
|
||||
c.ACTION(nil)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestRegisterActions_Good(t *testing.T) {
|
||||
func TestTask_RegisterActions_Good(t *testing.T) {
|
||||
c := New()
|
||||
count := 0
|
||||
h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(h, h)
|
||||
c.Action(nil)
|
||||
c.ACTION(nil)
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
|
|
|
|||
57
utils.go
57
utils.go
|
|
@ -6,11 +6,68 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// --- ID Generation ---
|
||||
|
||||
var idCounter atomic.Uint64
|
||||
|
||||
// ID returns a unique identifier. Format: "id-{counter}-{random}".
|
||||
// Counter is process-wide atomic. Random suffix prevents collision across restarts.
|
||||
//
|
||||
// id := core.ID() // "id-1-a3f2b1"
|
||||
// id2 := core.ID() // "id-2-c7e4d9"
|
||||
func ID() string {
|
||||
return Concat("id-", strconv.FormatUint(idCounter.Add(1), 10), "-", shortRand())
|
||||
}
|
||||
|
||||
func shortRand() string {
|
||||
b := make([]byte, 3)
|
||||
crand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
// ValidateName checks that a string is a valid service/action/command name.
|
||||
// Rejects empty, ".", "..", and names containing path separators.
|
||||
//
|
||||
// r := core.ValidateName("brain") // Result{"brain", true}
|
||||
// r := core.ValidateName("") // Result{error, false}
|
||||
// r := core.ValidateName("../escape") // Result{error, false}
|
||||
func ValidateName(name string) Result {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return Result{E("validate", Concat("invalid name: ", name), nil), false}
|
||||
}
|
||||
if Contains(name, "/") || Contains(name, "\\") {
|
||||
return Result{E("validate", Concat("name contains path separator: ", name), nil), false}
|
||||
}
|
||||
return Result{name, true}
|
||||
}
|
||||
|
||||
// SanitisePath extracts the base filename and rejects traversal attempts.
|
||||
// Returns "invalid" for dangerous inputs.
|
||||
//
|
||||
// core.SanitisePath("../../etc/passwd") // "passwd"
|
||||
// core.SanitisePath("") // "invalid"
|
||||
// core.SanitisePath("..") // "invalid"
|
||||
func SanitisePath(path string) string {
|
||||
safe := PathBase(path)
|
||||
if safe == "." || safe == ".." || safe == "" {
|
||||
return "invalid"
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// --- I/O ---
|
||||
|
||||
// Print writes a formatted line to a writer, defaulting to os.Stdout.
|
||||
//
|
||||
// core.Print(nil, "hello %s", "world") // → stdout
|
||||
|
|
|
|||
144
utils_test.go
144
utils_test.go
|
|
@ -8,22 +8,106 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- ID ---
|
||||
|
||||
func TestUtils_ID_Good(t *testing.T) {
|
||||
id := ID()
|
||||
assert.True(t, HasPrefix(id, "id-"))
|
||||
assert.True(t, len(id) > 5, "ID should have counter + random suffix")
|
||||
}
|
||||
|
||||
func TestUtils_ID_Good_Unique(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := ID()
|
||||
assert.False(t, seen[id], "ID collision: %s", id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtils_ID_Ugly_CounterMonotonic(t *testing.T) {
|
||||
// IDs should contain increasing counter values
|
||||
id1 := ID()
|
||||
id2 := ID()
|
||||
// Both should start with "id-" and have different counter parts
|
||||
assert.NotEqual(t, id1, id2)
|
||||
assert.True(t, HasPrefix(id1, "id-"))
|
||||
assert.True(t, HasPrefix(id2, "id-"))
|
||||
}
|
||||
|
||||
// --- ValidateName ---
|
||||
|
||||
func TestUtils_ValidateName_Good(t *testing.T) {
|
||||
r := ValidateName("brain")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "brain", r.Value)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Good_WithDots(t *testing.T) {
|
||||
r := ValidateName("process.run")
|
||||
assert.True(t, r.OK, "dots in names are valid — used for action namespacing")
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Empty(t *testing.T) {
|
||||
r := ValidateName("")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Dot(t *testing.T) {
|
||||
r := ValidateName(".")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_DotDot(t *testing.T) {
|
||||
r := ValidateName("..")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Slash(t *testing.T) {
|
||||
r := ValidateName("../escape")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Ugly_Backslash(t *testing.T) {
|
||||
r := ValidateName("windows\\path")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- SanitisePath ---
|
||||
|
||||
func TestUtils_SanitisePath_Good(t *testing.T) {
|
||||
assert.Equal(t, "file.txt", SanitisePath("/some/path/file.txt"))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Bad_Empty(t *testing.T) {
|
||||
assert.Equal(t, "invalid", SanitisePath(""))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Bad_DotDot(t *testing.T) {
|
||||
assert.Equal(t, "invalid", SanitisePath(".."))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Ugly_Traversal(t *testing.T) {
|
||||
// PathBase extracts "passwd" — the traversal is stripped
|
||||
assert.Equal(t, "passwd", SanitisePath("../../etc/passwd"))
|
||||
}
|
||||
|
||||
// --- FilterArgs ---
|
||||
|
||||
func TestFilterArgs_Good(t *testing.T) {
|
||||
func TestUtils_FilterArgs_Good(t *testing.T) {
|
||||
args := []string{"deploy", "", "to", "-test.v", "homelab", "-test.paniconexit0"}
|
||||
clean := FilterArgs(args)
|
||||
assert.Equal(t, []string{"deploy", "to", "homelab"}, clean)
|
||||
}
|
||||
|
||||
func TestFilterArgs_Empty_Good(t *testing.T) {
|
||||
func TestUtils_FilterArgs_Empty_Good(t *testing.T) {
|
||||
clean := FilterArgs(nil)
|
||||
assert.Nil(t, clean)
|
||||
}
|
||||
|
||||
// --- ParseFlag ---
|
||||
|
||||
func TestParseFlag_ShortValid_Good(t *testing.T) {
|
||||
func TestUtils_ParseFlag_ShortValid_Good(t *testing.T) {
|
||||
// Single letter
|
||||
k, v, ok := ParseFlag("-v")
|
||||
assert.True(t, ok)
|
||||
|
|
@ -43,7 +127,7 @@ func TestParseFlag_ShortValid_Good(t *testing.T) {
|
|||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_ShortInvalid_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_ShortInvalid_Bad(t *testing.T) {
|
||||
// Multiple chars with single dash — invalid
|
||||
_, _, ok := ParseFlag("-verbose")
|
||||
assert.False(t, ok)
|
||||
|
|
@ -52,7 +136,7 @@ func TestParseFlag_ShortInvalid_Bad(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongValid_Good(t *testing.T) {
|
||||
func TestUtils_ParseFlag_LongValid_Good(t *testing.T) {
|
||||
k, v, ok := ParseFlag("--verbose")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "verbose", k)
|
||||
|
|
@ -64,13 +148,13 @@ func TestParseFlag_LongValid_Good(t *testing.T) {
|
|||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongInvalid_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_LongInvalid_Bad(t *testing.T) {
|
||||
// Single char with double dash — invalid
|
||||
_, _, ok := ParseFlag("--v")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_NotAFlag_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_NotAFlag_Bad(t *testing.T) {
|
||||
_, _, ok := ParseFlag("hello")
|
||||
assert.False(t, ok)
|
||||
|
||||
|
|
@ -80,56 +164,56 @@ func TestParseFlag_NotAFlag_Bad(t *testing.T) {
|
|||
|
||||
// --- IsFlag ---
|
||||
|
||||
func TestIsFlag_Good(t *testing.T) {
|
||||
func TestUtils_IsFlag_Good(t *testing.T) {
|
||||
assert.True(t, IsFlag("-v"))
|
||||
assert.True(t, IsFlag("--verbose"))
|
||||
assert.True(t, IsFlag("-"))
|
||||
}
|
||||
|
||||
func TestIsFlag_Bad(t *testing.T) {
|
||||
func TestUtils_IsFlag_Bad(t *testing.T) {
|
||||
assert.False(t, IsFlag("hello"))
|
||||
assert.False(t, IsFlag(""))
|
||||
}
|
||||
|
||||
// --- Arg ---
|
||||
|
||||
func TestArg_String_Good(t *testing.T) {
|
||||
func TestUtils_Arg_String_Good(t *testing.T) {
|
||||
r := Arg(0, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello", r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Int_Good(t *testing.T) {
|
||||
func TestUtils_Arg_Int_Good(t *testing.T) {
|
||||
r := Arg(1, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 42, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Bool_Good(t *testing.T) {
|
||||
func TestUtils_Arg_Bool_Good(t *testing.T) {
|
||||
r := Arg(2, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, true, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_UnsupportedType_Good(t *testing.T) {
|
||||
func TestUtils_Arg_UnsupportedType_Good(t *testing.T) {
|
||||
r := Arg(0, 3.14)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 3.14, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_Arg_OutOfBounds_Bad(t *testing.T) {
|
||||
r := Arg(5, "only", "two")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_NoArgs_Bad(t *testing.T) {
|
||||
func TestUtils_Arg_NoArgs_Bad(t *testing.T) {
|
||||
r := Arg(0)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_ErrorDetection_Good(t *testing.T) {
|
||||
func TestUtils_Arg_ErrorDetection_Good(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
r := Arg(0, err)
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -138,78 +222,78 @@ func TestArg_ErrorDetection_Good(t *testing.T) {
|
|||
|
||||
// --- ArgString ---
|
||||
|
||||
func TestArgString_Good(t *testing.T) {
|
||||
func TestUtils_ArgString_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", ArgString(0, "hello", 42))
|
||||
assert.Equal(t, "world", ArgString(1, "hello", "world"))
|
||||
}
|
||||
|
||||
func TestArgString_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgString_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(0, 42))
|
||||
}
|
||||
|
||||
func TestArgString_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgString_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(3, "only"))
|
||||
}
|
||||
|
||||
// --- ArgInt ---
|
||||
|
||||
func TestArgInt_Good(t *testing.T) {
|
||||
func TestUtils_ArgInt_Good(t *testing.T) {
|
||||
assert.Equal(t, 42, ArgInt(0, 42, "hello"))
|
||||
assert.Equal(t, 99, ArgInt(1, 0, 99))
|
||||
}
|
||||
|
||||
func TestArgInt_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgInt_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(0, "not an int"))
|
||||
}
|
||||
|
||||
func TestArgInt_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgInt_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(5, 1, 2))
|
||||
}
|
||||
|
||||
// --- ArgBool ---
|
||||
|
||||
func TestArgBool_Good(t *testing.T) {
|
||||
func TestUtils_ArgBool_Good(t *testing.T) {
|
||||
assert.Equal(t, true, ArgBool(0, true, "hello"))
|
||||
assert.Equal(t, false, ArgBool(1, true, false))
|
||||
}
|
||||
|
||||
func TestArgBool_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgBool_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(0, "not a bool"))
|
||||
}
|
||||
|
||||
func TestArgBool_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgBool_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(5, true))
|
||||
}
|
||||
|
||||
// --- Result.Result() ---
|
||||
|
||||
func TestResult_Result_SingleArg_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_SingleArg_Good(t *testing.T) {
|
||||
r := Result{}.Result("value")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_NilError_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_NilError_Good(t *testing.T) {
|
||||
r := Result{}.Result("value", nil)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_WithError_Bad(t *testing.T) {
|
||||
func TestUtils_Result_Result_WithError_Bad(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
r := Result{}.Result("value", err)
|
||||
assert.False(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_ZeroArgs_Good(t *testing.T) {
|
||||
r := Result{"hello", true}
|
||||
got := r.Result()
|
||||
assert.Equal(t, "hello", got.Value)
|
||||
assert.True(t, got.OK)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Empty_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_ZeroArgs_Empty_Good(t *testing.T) {
|
||||
r := Result{}
|
||||
got := r.Result()
|
||||
assert.Nil(t, got.Value)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue