diff --git a/CLAUDE.md b/CLAUDE.md index 22c84b1..7ca5f91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,15 @@ Guidance for Claude Code and Codex when working with this repository. ## Module -`dappco.re/go/core` — dependency injection, service lifecycle, command routing, and message-passing for Go. +`dappco.re/go/core` — dependency injection, service lifecycle, permission, and message-passing for Go. -Source files live at the module root (not `pkg/core/`). Tests live in `tests/`. +Source files and tests live at the module root. No `pkg/` nesting. ## Build & Test ```bash -go test ./tests/... # run all tests -go build . # verify compilation -GOWORK=off go test ./tests/ # test without workspace +go test ./... -count=1 # run all tests (483 tests, 84.7% coverage) +go build ./... # verify compilation ``` Or via the Core CLI: @@ -25,28 +24,23 @@ core go qa # fmt + vet + lint + test ## API Shape -CoreGO uses the DTO/Options/Result pattern, not functional options: - ```go -c := core.New(core.Options{ - {Key: "name", Value: "myapp"}, -}) - -c.Service("cache", core.Service{ - OnStart: func() core.Result { return core.Result{OK: true} }, - OnStop: func() core.Result { return core.Result{OK: true} }, -}) - -c.Command("deploy/to/homelab", core.Command{ - Action: func(opts core.Options) core.Result { - return core.Result{Value: "deployed", OK: true} - }, -}) - -r := c.Cli().Run("deploy", "to", "homelab") +c := core.New( + core.WithOption("name", "myapp"), + core.WithService(mypackage.Register), + core.WithServiceLock(), +) +c.Run() // or: if err := c.RunE(); err != nil { ... } ``` -**Do not use:** `WithService`, `WithName`, `WithApp`, `WithServiceLock`, `Must*`, `ServiceFor[T]` — these no longer exist. +Service factory: + +```go +func Register(c *core.Core) core.Result { + svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, MyOpts{})} + return core.Result{Value: svc, OK: true} +} +``` ## Subsystems @@ -54,26 +48,37 @@ r := c.Cli().Run("deploy", "to", "homelab") |----------|---------|---------| | `c.Options()` | `*Options` | Input configuration | | `c.App()` | `*App` | Application identity | -| `c.Data()` | `*Data` | Embedded filesystem mounts | -| `c.Drive()` | `*Drive` | Named transport handles | -| `c.Fs()` | `*Fs` | Local filesystem I/O | -| `c.Config()` | `*Config` | Runtime settings | -| `c.Cli()` | `*Cli` | CLI surface | -| `c.Command("path")` | `Result` | Command tree | -| `c.Service("name")` | `Result` | Service registry | -| `c.Lock("name")` | `*Lock` | Named mutexes | -| `c.IPC()` | `*Ipc` | Message bus | -| `c.I18n()` | `*I18n` | Locale + translation | +| `c.Config()` | `*Config` | Runtime settings, feature flags | +| `c.Data()` | `*Data` | Embedded assets (Registry[*Embed]) | +| `c.Drive()` | `*Drive` | Transport handles (Registry[*DriveHandle]) | +| `c.Fs()` | `*Fs` | Filesystem I/O (sandboxable) | +| `c.Cli()` | `*Cli` | CLI command framework | +| `c.IPC()` | `*Ipc` | Message bus internals | +| `c.Process()` | `*Process` | Managed execution (Action sugar) | +| `c.API()` | `*API` | Remote streams (protocol handlers) | +| `c.Action(name)` | `*Action` | Named callable (register/invoke) | +| `c.Task(name)` | `*Task` | Composed Action sequence | +| `c.Entitled(name)` | `Entitlement` | Permission check | +| `c.RegistryOf(n)` | `*Registry` | Cross-cutting queries | +| `c.I18n()` | `*I18n` | Internationalisation | ## Messaging | Method | Pattern | |--------|---------| -| `c.ACTION(msg)` | Broadcast to all handlers | +| `c.ACTION(msg)` | Broadcast to all handlers (panic recovery per handler) | | `c.QUERY(q)` | First responder wins | | `c.QUERYALL(q)` | Collect all responses | -| `c.PERFORM(task)` | First executor wins | -| `c.PerformAsync(task)` | Background goroutine | +| `c.PerformAsync(action, opts)` | Background goroutine with progress | + +## Lifecycle + +```go +type Startable interface { OnStartup(ctx context.Context) Result } +type Stoppable interface { OnShutdown(ctx context.Context) Result } +``` + +`RunE()` always calls `defer ServiceShutdown` — even on startup failure or panic. ## Error Handling @@ -83,13 +88,15 @@ Use `core.E()` for structured errors: return core.E("service.Method", "what failed", underlyingErr) ``` -## Test Naming +**Never** use `fmt.Errorf`, `errors.New`, `os/exec`, or `unsafe.Pointer` on Core types. + +## Test Naming (AX-7) -`_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases). +`TestFile_Function_{Good,Bad,Ugly}` — 100% compliance. ## Docs -Full documentation in `docs/`. Start with `docs/getting-started.md`. +Full API contract: `docs/RFC.md` (1476 lines, 21 sections). ## Go Workspace diff --git a/README.md b/README.md index eb2c12b..d746ef7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # CoreGO -Dependency injection, service lifecycle, command routing, and message-passing for Go. - -Import path: +Dependency injection, service lifecycle, permission, and message-passing for Go. ```go import "dappco.re/go/core" @@ -14,75 +12,24 @@ CoreGO is the foundation layer for the Core ecosystem. It gives you: - one input shape: `Options` - one output shape: `Result` - one command tree: `Command` -- one message bus: `ACTION`, `QUERY`, `PERFORM` - -## Why It Exists - -Most non-trivial Go systems end up needing the same small set of infrastructure: - -- a place to keep runtime state and shared subsystems -- a predictable way to start and stop managed components -- a clean command surface for CLI-style workflows -- decoupled communication between components without tight imports - -CoreGO keeps those pieces small and explicit. +- one message bus: `ACTION`, `QUERY` + named `Action` callables +- one permission gate: `Entitled` +- one collection primitive: `Registry[T]` ## Quick Example ```go package main -import ( - "context" - "fmt" - - "dappco.re/go/core" -) - -type flushCacheTask struct { - Name string -} +import "dappco.re/go/core" func main() { - c := core.New(core.Options{ - {Key: "name", Value: "agent-workbench"}, - }) - - c.Service("cache", core.Service{ - OnStart: func() core.Result { - core.Info("cache started", "app", c.App().Name) - return core.Result{OK: true} - }, - OnStop: func() core.Result { - core.Info("cache stopped", "app", c.App().Name) - return core.Result{OK: true} - }, - }) - - c.RegisterTask(func(_ *core.Core, task core.Task) core.Result { - switch t := task.(type) { - case flushCacheTask: - return core.Result{Value: "cache flushed for " + t.Name, OK: true} - } - return core.Result{} - }) - - c.Command("cache/flush", core.Command{ - Action: func(opts core.Options) core.Result { - return c.PERFORM(flushCacheTask{ - Name: opts.String("name"), - }) - }, - }) - - if !c.ServiceStartup(context.Background(), nil).OK { - panic("startup failed") - } - - r := c.Cli().Run("cache", "flush", "--name=session-store") - fmt.Println(r.Value) - - _ = c.ServiceShutdown(context.Background()) + c := core.New( + core.WithOption("name", "agent-workbench"), + core.WithService(cache.Register), + core.WithServiceLock(), + ) + c.Run() } ``` @@ -93,22 +40,16 @@ func main() { | `Core` | Central container and access point | | `Service` | Managed lifecycle component | | `Command` | Path-based executable operation | -| `Cli` | CLI surface over the command tree | +| `Action` | Named callable with panic recovery + entitlement | +| `Task` | Composed sequence of Actions | +| `Registry[T]` | Thread-safe named collection | +| `Process` | Managed execution (Action sugar) | +| `API` | Remote streams (protocol handlers) | +| `Entitlement` | Permission check result | | `Data` | Embedded filesystem mounts | | `Drive` | Named transport handles | -| `Fs` | Local filesystem operations | +| `Fs` | Local filesystem (sandboxable) | | `Config` | Runtime settings and feature flags | -| `I18n` | Locale collection and translation delegation | -| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery | - -## AX-Friendly Model - -CoreGO follows the same design direction as the AX spec: - -- predictable names over compressed names -- paths as documentation, such as `deploy/to/homelab` -- one repeated vocabulary across the framework -- examples that show how to call real APIs ## Install @@ -121,30 +62,12 @@ Requires Go 1.26 or later. ## Test ```bash -core go test -``` - -Or with the standard toolchain: - -```bash -go test ./... +go test ./... # 483 tests, 84.7% coverage ``` ## Docs -The full documentation set lives in `docs/`. - -| Path | Covers | -|------|--------| -| `docs/getting-started.md` | First runnable CoreGO app | -| `docs/primitives.md` | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` | -| `docs/services.md` | Service registry, runtime helpers, service locks | -| `docs/commands.md` | Path-based commands and CLI execution | -| `docs/messaging.md` | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` | -| `docs/lifecycle.md` | Startup, shutdown, context, and task draining | -| `docs/subsystems.md` | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` | -| `docs/errors.md` | Structured errors, logging helpers, panic recovery | -| `docs/testing.md` | Test naming and framework testing patterns | +The authoritative API contract is `docs/RFC.md` (21 sections). ## License diff --git a/action.go b/action.go new file mode 100644 index 0000000..22f55c8 --- /dev/null +++ b/action.go @@ -0,0 +1,233 @@ +// 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 + core *Core // for entitlement checks during Run() +} + +// 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} + } + // Entitlement check — permission boundary + if a.core != nil { + if e := a.core.Entitled(a.Name); !e.Allowed { + return Result{E("action.Run", Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false} + } + } + defer func() { + if r := recover(); r != nil { + result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false} + } + }() + return a.Handler(ctx, opts) +} + +// Exists returns true if this action has a registered handler. +// +// if c.Action("process.run").Exists() { ... } +func (a *Action) Exists() bool { + return a != nil && a.Handler != nil +} + +func (a *Action) safeName() string { + if a == nil { + return "" + } + return a.Name +} + +// --- Core accessor --- + +// Action gets or registers a named action. +// With a handler argument: registers the action. +// Without: returns the action for invocation. +// +// c.Action("process.run", handler) // register +// c.Action("process.run").Run(ctx, opts) // invoke +// c.Action("process.run").Exists() // check +func (c *Core) Action(name string, handler ...ActionHandler) *Action { + if len(handler) > 0 { + def := &Action{Name: name, Handler: handler[0], enabled: true, core: c} + 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 +} + +// Task is a named sequence of Steps. +// +// c.Task("agent.completion", core.Task{ +// Steps: []core.Step{ +// {Action: "agentic.qa"}, +// {Action: "agentic.auto-pr"}, +// {Action: "agentic.verify"}, +// {Action: "agentic.poke", Async: true}, +// }, +// }) +type Task 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 *Task) 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 *Task) safeName() string { + if t == nil { + return "" + } + return t.Name +} + +// mergeStepOptions returns the step's With options — runtime opts are passed directly. +// Step.With provides static defaults that the step was registered with. +func stepOptions(step Step) Options { + return step.With +} + +// Task gets or registers a named task. +// With a Task argument: registers the task. +// Without: returns the task for invocation. +// +// c.Task("deploy", core.Task{Steps: steps}) // register +// c.Task("deploy").Run(ctx, c, opts) // invoke +func (c *Core) Task(name string, def ...Task) *Task { + 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 &Task{Name: name} + } + return r.Value.(*Task) +} + +// Tasks returns all registered task names. +func (c *Core) Tasks() []string { + return c.ipc.tasks.Names() +} diff --git a/action_example_test.go b/action_example_test.go new file mode 100644 index 0000000..7efc21b --- /dev/null +++ b/action_example_test.go @@ -0,0 +1,59 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +func ExampleAction_Run() { + c := New() + c.Action("double", func(_ context.Context, opts Options) Result { + return Result{Value: opts.Int("n") * 2, OK: true} + }) + + r := c.Action("double").Run(context.Background(), NewOptions( + Option{Key: "n", Value: 21}, + )) + Println(r.Value) + // Output: 42 +} + +func ExampleAction_Exists() { + c := New() + Println(c.Action("missing").Exists()) + + c.Action("present", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + Println(c.Action("present").Exists()) + // Output: + // false + // true +} + +func ExampleAction_Run_panicRecovery() { + c := New() + c.Action("boom", func(_ context.Context, _ Options) Result { + panic("explosion") + }) + + r := c.Action("boom").Run(context.Background(), NewOptions()) + Println(r.OK) + // Output: false +} + +func ExampleAction_Run_entitlementDenied() { + c := New() + c.Action("premium", func(_ context.Context, _ Options) Result { + return Result{Value: "secret", OK: true} + }) + c.SetEntitlementChecker(func(action string, _ int, _ context.Context) Entitlement { + if action == "premium" { + return Entitlement{Allowed: false, Reason: "upgrade"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + r := c.Action("premium").Run(context.Background(), NewOptions()) + Println(r.OK) + // Output: false +} diff --git a/action_test.go b/action_test.go new file mode 100644 index 0000000..9d6e8d6 --- /dev/null +++ b/action_test.go @@ -0,0 +1,246 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- NamedAction Register --- + +func TestAction_NamedAction_Good_Register(t *testing.T) { + c := New() + def := c.Action("process.run", func(_ context.Context, opts Options) Result { + return Result{Value: "output", OK: true} + }) + assert.NotNil(t, def) + assert.Equal(t, "process.run", def.Name) + assert.True(t, def.Exists()) +} + +func TestAction_NamedAction_Good_Invoke(t *testing.T) { + c := New() + c.Action("git.log", func(_ context.Context, opts Options) Result { + dir := opts.String("dir") + return Result{Value: Concat("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", Task{ + 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", Task{ + 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", Task{ + 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", Task{ + 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", Task{}) + 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", Task{Steps: []Step{{Action: "x"}}}) + c.Task("review", Task{Steps: []Step{{Action: "y"}}}) + assert.Equal(t, []string{"deploy", "review"}, c.Tasks()) +} diff --git a/api.go b/api.go new file mode 100644 index 0000000..a5a6288 --- /dev/null +++ b/api.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Remote communication primitive for the Core framework. +// API manages named streams to remote endpoints. The transport protocol +// (HTTP, WebSocket, SSE, MCP, TCP) is handled by protocol handlers +// registered by consumer packages. +// +// Drive is the phone book (WHERE to connect). +// API is the phone (HOW to connect). +// +// Usage: +// +// // Configure endpoint +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "charon"}, +// core.Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"}, +// )) +// +// // Open stream +// s := c.API().Stream("charon") +// if s.OK { stream := s.Value.(Stream) } +// +// // Remote Action dispatch +// r := c.API().Call("charon", "agentic.status", opts) +package core + +import "context" + +// Stream is a bidirectional connection to a remote endpoint. +// Consumers implement this for each transport protocol. +// +// type httpStream struct { ... } +// func (s *httpStream) Send(data []byte) error { ... } +// func (s *httpStream) Receive() ([]byte, error) { ... } +// func (s *httpStream) Close() error { ... } +type Stream interface { + Send(data []byte) error + Receive() ([]byte, error) + Close() error +} + +// StreamFactory creates a Stream from a DriveHandle's transport config. +// Registered per-protocol by consumer packages. +type StreamFactory func(handle *DriveHandle) (Stream, error) + +// API manages remote streams and protocol handlers. +type API struct { + core *Core + protocols *Registry[StreamFactory] +} + +// API returns the remote communication primitive. +// +// c.API().Stream("charon") +func (c *Core) API() *API { + return c.api +} + +// RegisterProtocol registers a stream factory for a URL scheme. +// Consumer packages call this during OnStartup. +// +// c.API().RegisterProtocol("http", httpStreamFactory) +// c.API().RegisterProtocol("https", httpStreamFactory) +// c.API().RegisterProtocol("mcp", mcpStreamFactory) +func (a *API) RegisterProtocol(scheme string, factory StreamFactory) { + a.protocols.Set(scheme, factory) +} + +// Stream opens a connection to a named endpoint. +// Looks up the endpoint in Drive, extracts the protocol from the transport URL, +// and delegates to the registered protocol handler. +// +// r := c.API().Stream("charon") +// if r.OK { stream := r.Value.(Stream) } +func (a *API) Stream(name string) Result { + r := a.core.Drive().Get(name) + if !r.OK { + return Result{E("api.Stream", Concat("endpoint not found in Drive: ", name), nil), false} + } + + handle := r.Value.(*DriveHandle) + scheme := extractScheme(handle.Transport) + + fr := a.protocols.Get(scheme) + if !fr.OK { + return Result{E("api.Stream", Concat("no protocol handler for scheme: ", scheme), nil), false} + } + + factory := fr.Value.(StreamFactory) + stream, err := factory(handle) + if err != nil { + return Result{err, false} + } + return Result{stream, true} +} + +// Call invokes a named Action on a remote endpoint. +// This is the remote equivalent of c.Action("name").Run(ctx, opts). +// +// r := c.API().Call("charon", "agentic.status", opts) +func (a *API) Call(endpoint string, action string, opts Options) Result { + r := a.Stream(endpoint) + if !r.OK { + return r + } + + stream := r.Value.(Stream) + defer stream.Close() + + // Encode the action call as JSON-RPC (MCP compatible) + payload := Concat(`{"action":"`, action, `","options":`, JSONMarshalString(opts), `}`) + + if err := stream.Send([]byte(payload)); err != nil { + return Result{err, false} + } + + response, err := stream.Receive() + if err != nil { + return Result{err, false} + } + + return Result{string(response), true} +} + +// Protocols returns all registered protocol scheme names. +func (a *API) Protocols() []string { + return a.protocols.Names() +} + +// extractScheme pulls the protocol from a transport URL. +// "http://host:port/path" → "http" +// "mcp://host:port" → "mcp" +func extractScheme(transport string) string { + for i, c := range transport { + if c == ':' { + return transport[:i] + } + } + return transport +} + + +// RemoteAction resolves "host:action.name" syntax for transparent remote dispatch. +// If the action name contains ":", the prefix is the endpoint and the suffix is the action. +// +// c.Action("charon:agentic.status") // → c.API().Call("charon", "agentic.status", opts) +func (c *Core) RemoteAction(name string, ctx context.Context, opts Options) Result { + for i, ch := range name { + if ch == ':' { + endpoint := name[:i] + action := name[i+1:] + return c.API().Call(endpoint, action, opts) + } + } + // No ":" — local action + return c.Action(name).Run(ctx, opts) +} diff --git a/api_example_test.go b/api_example_test.go new file mode 100644 index 0000000..1a08d5d --- /dev/null +++ b/api_example_test.go @@ -0,0 +1,49 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +func ExampleAPI_RegisterProtocol() { + c := New() + c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) { + return &mockStream{response: []byte("pong")}, nil + }) + Println(c.API().Protocols()) + // Output: [http] +} + +func ExampleAPI_Stream() { + c := New() + c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) { + return &mockStream{response: []byte(Concat("connected to ", h.Name))}, nil + }) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "charon"}, + Option{Key: "transport", Value: "http://10.69.69.165:9101"}, + )) + + r := c.API().Stream("charon") + if r.OK { + stream := r.Value.(Stream) + resp, _ := stream.Receive() + Println(string(resp)) + stream.Close() + } + // Output: connected to charon +} + +func ExampleCore_RemoteAction() { + c := New() + // Local action + c.Action("status", func(_ context.Context, _ Options) Result { + return Result{Value: "running", OK: true} + }) + + // No colon — resolves locally + r := c.RemoteAction("status", context.Background(), NewOptions()) + Println(r.Value) + // Output: running +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..7db590a --- /dev/null +++ b/api_test.go @@ -0,0 +1,156 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- mock stream for testing --- + +type mockStream struct { + sent []byte + response []byte + closed bool +} + +func (s *mockStream) Send(data []byte) error { + s.sent = data + return nil +} + +func (s *mockStream) Receive() ([]byte, error) { + return s.response, nil +} + +func (s *mockStream) Close() error { + s.closed = true + return nil +} + +func mockFactory(response string) StreamFactory { + return func(handle *DriveHandle) (Stream, error) { + return &mockStream{response: []byte(response)}, nil + } +} + +// --- API --- + +func TestApi_API_Good_Accessor(t *testing.T) { + c := New() + assert.NotNil(t, c.API()) +} + +// --- RegisterProtocol --- + +func TestApi_RegisterProtocol_Good(t *testing.T) { + c := New() + c.API().RegisterProtocol("http", mockFactory("ok")) + assert.Contains(t, c.API().Protocols(), "http") +} + +// --- Stream --- + +func TestApi_Stream_Good(t *testing.T) { + c := New() + c.API().RegisterProtocol("http", mockFactory("pong")) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "charon"}, + Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"}, + )) + + r := c.API().Stream("charon") + assert.True(t, r.OK) + + stream := r.Value.(Stream) + stream.Send([]byte("ping")) + resp, err := stream.Receive() + assert.NoError(t, err) + assert.Equal(t, "pong", string(resp)) + stream.Close() +} + +func TestApi_Stream_Bad_EndpointNotFound(t *testing.T) { + c := New() + r := c.API().Stream("nonexistent") + assert.False(t, r.OK) +} + +func TestApi_Stream_Bad_NoProtocolHandler(t *testing.T) { + c := New() + c.Drive().New(NewOptions( + Option{Key: "name", Value: "unknown"}, + Option{Key: "transport", Value: "grpc://host:port"}, + )) + + r := c.API().Stream("unknown") + assert.False(t, r.OK) +} + +// --- Call --- + +func TestApi_Call_Good(t *testing.T) { + c := New() + c.API().RegisterProtocol("http", mockFactory(`{"status":"ok"}`)) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "charon"}, + Option{Key: "transport", Value: "http://10.69.69.165:9101"}, + )) + + r := c.API().Call("charon", "agentic.status", NewOptions()) + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "ok") +} + +func TestApi_Call_Bad_EndpointNotFound(t *testing.T) { + c := New() + r := c.API().Call("missing", "action", NewOptions()) + assert.False(t, r.OK) +} + +// --- RemoteAction --- + +func TestApi_RemoteAction_Good_Local(t *testing.T) { + c := New() + c.Action("local.action", func(_ context.Context, _ Options) Result { + return Result{Value: "local", OK: true} + }) + + r := c.RemoteAction("local.action", context.Background(), NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, "local", r.Value) +} + +func TestApi_RemoteAction_Good_Remote(t *testing.T) { + c := New() + c.API().RegisterProtocol("http", mockFactory(`{"value":"remote"}`)) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "charon"}, + Option{Key: "transport", Value: "http://10.69.69.165:9101"}, + )) + + r := c.RemoteAction("charon:agentic.status", context.Background(), NewOptions()) + assert.True(t, r.OK) + assert.Contains(t, r.Value.(string), "remote") +} + +func TestApi_RemoteAction_Ugly_NoColon(t *testing.T) { + c := New() + // No colon — falls through to local action (which doesn't exist) + r := c.RemoteAction("nonexistent", context.Background(), NewOptions()) + assert.False(t, r.OK, "non-existent local action should fail") +} + +// --- extractScheme --- + +func TestApi_Ugly_SchemeExtraction(t *testing.T) { + c := New() + // Verify scheme parsing works by registering different protocols + c.API().RegisterProtocol("http", mockFactory("http")) + c.API().RegisterProtocol("mcp", mockFactory("mcp")) + c.API().RegisterProtocol("ws", mockFactory("ws")) + + assert.Equal(t, 3, len(c.API().Protocols())) +} diff --git a/app.go b/app.go index 17c3214..9fc1984 100644 --- a/app.go +++ b/app.go @@ -5,7 +5,7 @@ package core import ( - "os/exec" + "os" "path/filepath" ) @@ -47,21 +47,47 @@ func (a App) New(opts Options) App { } // Find locates a program on PATH and returns a Result containing the App. +// Uses os.Stat to search PATH directories — no os/exec dependency. // // r := core.App{}.Find("node", "Node.js") // if r.OK { app := r.Value.(*App) } func (a App) Find(filename, name string) Result { - path, err := exec.LookPath(filename) - if err != nil { - return Result{err, false} + // If filename contains a separator, check it directly + if Contains(filename, string(os.PathSeparator)) { + abs, err := filepath.Abs(filename) + if err != nil { + return Result{err, false} + } + if isExecutable(abs) { + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + return Result{E("app.Find", Concat(filename, " not found"), nil), false} + } + + // Search PATH + pathEnv := os.Getenv("PATH") + if pathEnv == "" { + return Result{E("app.Find", "PATH is empty", nil), false} } - abs, err := filepath.Abs(path) + for _, dir := range Split(pathEnv, string(os.PathListSeparator)) { + candidate := filepath.Join(dir, filename) + if isExecutable(candidate) { + abs, err := filepath.Abs(candidate) + if err != nil { + continue + } + return Result{&App{Name: name, Filename: filename, Path: abs}, true} + } + } + return Result{E("app.Find", Concat(filename, " not found on PATH"), nil), false} +} + +// isExecutable checks if a path exists and is executable. +func isExecutable(path string) bool { + info, err := os.Stat(path) if err != nil { - return Result{err, false} + return false } - return Result{&App{ - Name: name, - Filename: filename, - Path: abs, - }, true} + // Regular file with at least one execute bit + return !info.IsDir() && info.Mode()&0111 != 0 } diff --git a/array_example_test.go b/array_example_test.go new file mode 100644 index 0000000..839c8b5 --- /dev/null +++ b/array_example_test.go @@ -0,0 +1,41 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleNewArray() { + a := NewArray[string]() + a.Add("alpha") + a.Add("bravo") + a.Add("charlie") + + Println(a.Len()) + Println(a.Contains("bravo")) + // Output: + // 3 + // true +} + +func ExampleArray_AddUnique() { + a := NewArray[string]() + a.AddUnique("alpha") + a.AddUnique("alpha") // no duplicate + a.AddUnique("bravo") + + Println(a.Len()) + // Output: 2 +} + +func ExampleArray_Filter() { + a := NewArray[int]() + a.Add(1) + a.Add(2) + a.Add(3) + a.Add(4) + + r := a.Filter(func(n int) bool { return n%2 == 0 }) + Println(r.OK) + // Output: true +} diff --git a/cli.go b/cli.go index 1744f80..5e4b9f7 100644 --- a/cli.go +++ b/cli.go @@ -64,11 +64,7 @@ func (cl *Cli) Run(args ...string) Result { return Result{} } - c.commands.mu.RLock() - cmdCount := len(c.commands.commands) - c.commands.mu.RUnlock() - - if cmdCount == 0 { + if c.commands.Len() == 0 { if cl.banner != nil { cl.Print(cl.banner(cl)) } @@ -79,16 +75,14 @@ func (cl *Cli) Run(args ...string) Result { var cmd *Command var remaining []string - c.commands.mu.RLock() for i := len(clean); i > 0; i-- { path := JoinPath(clean[:i]...) - if found, ok := c.commands.commands[path]; ok { - cmd = found + if r := c.commands.Get(path); r.OK { + cmd = r.Value.(*Command) remaining = clean[i:] break } } - c.commands.mu.RUnlock() if cmd == nil { if cl.banner != nil { @@ -116,9 +110,6 @@ func (cl *Cli) Run(args ...string) Result { if cmd.Action != nil { return cmd.Run(opts) } - if cmd.Lifecycle != nil { - return cmd.Start(opts) - } return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false} } @@ -141,12 +132,9 @@ func (cl *Cli) PrintHelp() { cl.Print("Commands:") } - c.commands.mu.RLock() - defer c.commands.mu.RUnlock() - - for path, cmd := range c.commands.commands { - if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) { - continue + c.commands.Each(func(path string, cmd *Command) { + if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) { + return } tr := c.I18n().Translate(cmd.I18nKey()) desc, _ := tr.Value.(string) @@ -155,7 +143,7 @@ func (cl *Cli) PrintHelp() { } else { cl.Print(" %-30s %s", path, desc) } - } + }) } // SetBanner sets the banner function. diff --git a/command.go b/command.go index c774ed6..660f866 100644 --- a/command.go +++ b/command.go @@ -20,37 +20,31 @@ // "deploy/to/homelab" → "cmd.deploy.to.homelab.description" package core -import ( - "sync" -) // CommandAction is the function signature for command handlers. // // func(opts core.Options) core.Result type CommandAction func(Options) Result -// CommandLifecycle is implemented by commands that support managed lifecycle. -// Basic commands only need an action. Daemon commands implement Start/Stop/Signal -// via go-process. -type CommandLifecycle interface { - Start(Options) Result - Stop() Result - Restart() Result - Reload() Result - Signal(string) Result -} - // Command is the DTO for an executable operation. +// Commands are declarative — they carry enough information for multiple consumers: +// - core.Cli() runs the Action +// - core/cli adds rich help, completion, man pages +// - go-process wraps Managed commands with lifecycle (PID, health, signals) +// +// c.Command("serve", core.Command{ +// Action: handler, +// Managed: "process.daemon", // go-process provides start/stop/restart +// }) type Command struct { Name string - Description string // i18n key — derived from path if empty - Path string // "deploy/to/homelab" - Action CommandAction // business logic - Lifecycle CommandLifecycle // optional — provided by go-process - Flags Options // declared flags + Description string // i18n key — derived from path if empty + Path string // "deploy/to/homelab" + Action CommandAction // business logic + Managed string // "" = one-shot, "process.daemon" = managed lifecycle + Flags Options // declared flags Hidden bool commands map[string]*Command // child commands (internal) - mu sync.RWMutex } // I18nKey returns the i18n key for this command's description. @@ -77,52 +71,19 @@ func (cmd *Command) Run(opts Options) Result { return cmd.Action(opts) } -// Start delegates to the lifecycle implementation if available. -func (cmd *Command) Start(opts Options) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Start(opts) - } - return cmd.Run(opts) -} - -// Stop delegates to the lifecycle implementation. -func (cmd *Command) Stop() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Stop() - } - return Result{} -} - -// Restart delegates to the lifecycle implementation. -func (cmd *Command) Restart() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Restart() - } - return Result{} -} - -// Reload delegates to the lifecycle implementation. -func (cmd *Command) Reload() Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Reload() - } - return Result{} -} - -// Signal delegates to the lifecycle implementation. -func (cmd *Command) Signal(sig string) Result { - if cmd.Lifecycle != nil { - return cmd.Lifecycle.Signal(sig) - } - return Result{} +// IsManaged returns true if this command has a managed lifecycle. +// +// if cmd.IsManaged() { /* go-process handles start/stop */ } +func (cmd *Command) IsManaged() bool { + return cmd.Managed != "" } // --- Command Registry (on Core) --- -// commandRegistry holds the command tree. -type commandRegistry struct { - commands map[string]*Command - mu sync.RWMutex +// CommandRegistry holds the command tree. Embeds Registry[*Command] +// for thread-safe named storage with insertion order. +type CommandRegistry struct { + *Registry[*Command] } // Command gets or registers a command by path. @@ -131,21 +92,19 @@ type commandRegistry struct { // r := c.Command("deploy") func (c *Core) Command(path string, command ...Command) Result { if len(command) == 0 { - c.commands.mu.RLock() - cmd, ok := c.commands.commands[path] - c.commands.mu.RUnlock() - return Result{cmd, ok} + return c.commands.Get(path) } if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") { return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false} } - c.commands.mu.Lock() - defer c.commands.mu.Unlock() - - if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) { - return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + // Check for duplicate executable command + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) + if existing.Action != nil || existing.IsManaged() { + return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false} + } } cmd := &command[0] @@ -156,7 +115,8 @@ func (c *Core) Command(path string, command ...Command) Result { } // Preserve existing subtree when overwriting a placeholder parent - if existing, exists := c.commands.commands[path]; exists { + if r := c.commands.Get(path); r.OK { + existing := r.Value.(*Command) for k, v := range existing.commands { if _, has := cmd.commands[k]; !has { cmd.commands[k] = v @@ -164,40 +124,35 @@ func (c *Core) Command(path string, command ...Command) Result { } } - c.commands.commands[path] = cmd + c.commands.Set(path, cmd) // Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing parts := Split(path, "/") for i := len(parts) - 1; i > 0; i-- { parentPath := JoinPath(parts[:i]...) - if _, exists := c.commands.commands[parentPath]; !exists { - c.commands.commands[parentPath] = &Command{ + if !c.commands.Has(parentPath) { + c.commands.Set(parentPath, &Command{ Name: parts[i-1], Path: parentPath, commands: make(map[string]*Command), - } + }) } - c.commands.commands[parentPath].commands[parts[i]] = cmd - cmd = c.commands.commands[parentPath] + parent := c.commands.Get(parentPath).Value.(*Command) + parent.commands[parts[i]] = cmd + cmd = parent } return Result{OK: true} } -// Commands returns all registered command paths. +// Commands returns all registered command paths in registration order. // // paths := c.Commands() func (c *Core) Commands() []string { if c.commands == nil { return nil } - c.commands.mu.RLock() - defer c.commands.mu.RUnlock() - var paths []string - for k := range c.commands.commands { - paths = append(paths, k) - } - return paths + return c.commands.Names() } // pathName extracts the last segment of a path. diff --git a/command_example_test.go b/command_example_test.go new file mode 100644 index 0000000..659830a --- /dev/null +++ b/command_example_test.go @@ -0,0 +1,40 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleCore_Command_register() { + c := New() + c.Command("deploy/to/homelab", Command{ + Description: "Deploy to homelab", + Action: func(opts Options) Result { + return Result{Value: "deployed", OK: true} + }, + }) + + Println(c.Command("deploy/to/homelab").OK) + // Output: true +} + +func ExampleCore_Command_managed() { + c := New() + c.Command("serve", Command{ + Action: func(_ Options) Result { return Result{OK: true} }, + Managed: "process.daemon", + }) + + cmd := c.Command("serve").Value.(*Command) + Println(cmd.IsManaged()) + // Output: true +} + +func ExampleCore_Commands() { + 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} }}) + + Println(c.Commands()) + // Output: [deploy test] +} diff --git a/command_test.go b/command_test.go index 7a81cf1..a984b8b 100644 --- a/command_test.go +++ b/command_test.go @@ -102,78 +102,25 @@ func TestCommand_I18nKey_Simple_Good(t *testing.T) { assert.Equal(t, "cmd.serve.description", cmd.I18nKey()) } -// --- Lifecycle --- +// --- Managed --- -func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) { +func TestCommand_IsManaged_Good(t *testing.T) { c := New() - c.Command("serve", Command{Action: func(_ Options) Result { - return Result{Value: "running", OK: true} - }}) + c.Command("serve", Command{ + Action: func(_ Options) Result { return Result{Value: "running", OK: true} }, + Managed: "process.daemon", + }) cmd := c.Command("serve").Value.(*Command) - - r := cmd.Start(NewOptions()) - assert.True(t, r.OK) - assert.Equal(t, "running", r.Value) - - assert.False(t, cmd.Stop().OK) - assert.False(t, cmd.Restart().OK) - assert.False(t, cmd.Reload().OK) - assert.False(t, cmd.Signal("HUP").OK) + assert.True(t, cmd.IsManaged()) } -// --- Lifecycle with Implementation --- - -type testLifecycle struct { - started bool - stopped bool - restarted bool - reloaded bool - signalled string -} - -func (l *testLifecycle) Start(opts Options) Result { - l.started = true - return Result{Value: "started", OK: true} -} -func (l *testLifecycle) Stop() Result { - l.stopped = true - return Result{OK: true} -} -func (l *testLifecycle) Restart() Result { - l.restarted = true - return Result{OK: true} -} -func (l *testLifecycle) Reload() Result { - l.reloaded = true - return Result{OK: true} -} -func (l *testLifecycle) Signal(sig string) Result { - l.signalled = sig - return Result{Value: sig, OK: true} -} - -func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) { +func TestCommand_IsManaged_Bad_NotManaged(t *testing.T) { c := New() - lc := &testLifecycle{} - c.Command("daemon", Command{Lifecycle: lc}) - cmd := c.Command("daemon").Value.(*Command) - - r := cmd.Start(NewOptions()) - assert.True(t, r.OK) - assert.True(t, lc.started) - - assert.True(t, cmd.Stop().OK) - assert.True(t, lc.stopped) - - assert.True(t, cmd.Restart().OK) - assert.True(t, lc.restarted) - - assert.True(t, cmd.Reload().OK) - assert.True(t, lc.reloaded) - - r = cmd.Signal("HUP") - assert.True(t, r.OK) - assert.Equal(t, "HUP", lc.signalled) + c.Command("deploy", Command{ + Action: func(_ Options) Result { return Result{OK: true} }, + }) + cmd := c.Command("deploy").Value.(*Command) + assert.False(t, cmd.IsManaged()) } func TestCommand_Duplicate_Bad(t *testing.T) { @@ -190,18 +137,21 @@ func TestCommand_InvalidPath_Bad(t *testing.T) { assert.False(t, c.Command("double//slash", Command{}).OK) } -// --- Cli Run with Lifecycle --- +// --- Cli Run with Managed --- -func TestCli_Run_Lifecycle_Good(t *testing.T) { +func TestCli_Run_Managed_Good(t *testing.T) { c := New() - lc := &testLifecycle{} - c.Command("serve", Command{Lifecycle: lc}) + ran := false + c.Command("serve", Command{ + Action: func(_ Options) Result { ran = true; return Result{OK: true} }, + Managed: "process.daemon", + }) r := c.Cli().Run("serve") assert.True(t, r.OK) - assert.True(t, lc.started) + assert.True(t, ran) } -func TestCli_Run_NoActionNoLifecycle_Bad(t *testing.T) { +func TestCli_Run_NoAction_Bad(t *testing.T) { c := New() c.Command("empty", Command{}) r := c.Cli().Run("empty") diff --git a/config.go b/config.go index cf71d1b..2f45f13 100644 --- a/config.go +++ b/config.go @@ -14,15 +14,34 @@ type ConfigVar[T any] struct { set bool } -func (v *ConfigVar[T]) Get() T { return v.val } -func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } +// Get returns the current value. +// +// val := v.Get() +func (v *ConfigVar[T]) Get() T { return v.val } + +// Set sets the value and marks it as explicitly set. +// +// v.Set(true) +func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } + +// IsSet returns true if the value was explicitly set (distinguishes "set to false" from "never set"). +// +// if v.IsSet() { /* explicitly configured */ } func (v *ConfigVar[T]) IsSet() bool { return v.set } + +// Unset resets to zero value and marks as not set. +// +// v.Unset() +// v.IsSet() // false func (v *ConfigVar[T]) Unset() { v.set = false var zero T v.val = zero } +// NewConfigVar creates a ConfigVar with an initial value marked as set. +// +// debug := core.NewConfigVar(true) func NewConfigVar[T any](val T) ConfigVar[T] { return ConfigVar[T]{val: val, set: true} } @@ -82,9 +101,20 @@ func (e *Config) Get(key string) Result { return Result{val, true} } +// String retrieves a string config value (empty string if missing). +// +// host := c.Config().String("database.host") func (e *Config) String(key string) string { return ConfigGet[string](e, key) } -func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } -func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } + +// Int retrieves an int config value (0 if missing). +// +// port := c.Config().Int("database.port") +func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } + +// Bool retrieves a bool config value (false if missing). +// +// debug := c.Config().Bool("debug") +func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } // ConfigGet retrieves a typed configuration value. func ConfigGet[T any](e *Config, key string) T { @@ -99,6 +129,9 @@ func ConfigGet[T any](e *Config, key string) T { // --- Feature Flags --- +// Enable activates a feature flag. +// +// c.Config().Enable("dark-mode") func (e *Config) Enable(feature string) { e.mu.Lock() if e.ConfigOptions == nil { @@ -109,6 +142,9 @@ func (e *Config) Enable(feature string) { e.mu.Unlock() } +// Disable deactivates a feature flag. +// +// c.Config().Disable("dark-mode") func (e *Config) Disable(feature string) { e.mu.Lock() if e.ConfigOptions == nil { @@ -119,6 +155,9 @@ func (e *Config) Disable(feature string) { e.mu.Unlock() } +// Enabled returns true if a feature flag is active. +// +// if c.Config().Enabled("dark-mode") { ... } func (e *Config) Enabled(feature string) bool { e.mu.RLock() defer e.mu.RUnlock() @@ -128,6 +167,9 @@ func (e *Config) Enabled(feature string) bool { return e.Features[feature] } +// EnabledFeatures returns all active feature flag names. +// +// features := c.Config().EnabledFeatures() func (e *Config) EnabledFeatures() []string { e.mu.RLock() defer e.mu.RUnlock() diff --git a/config_example_test.go b/config_example_test.go new file mode 100644 index 0000000..00de513 --- /dev/null +++ b/config_example_test.go @@ -0,0 +1,41 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleConfig_Set() { + c := New() + c.Config().Set("database.host", "localhost") + c.Config().Set("database.port", 5432) + + Println(c.Config().String("database.host")) + Println(c.Config().Int("database.port")) + // Output: + // localhost + // 5432 +} + +func ExampleConfig_Enable() { + c := New() + c.Config().Enable("dark-mode") + c.Config().Enable("beta-features") + + Println(c.Config().Enabled("dark-mode")) + Println(c.Config().EnabledFeatures()) + // Output: + // true + // [dark-mode beta-features] +} + +func ExampleConfigVar() { + v := NewConfigVar(42) + Println(v.Get(), v.IsSet()) + + v.Unset() + Println(v.Get(), v.IsSet()) + // Output: + // 42 true + // 0 false +} diff --git a/config_test.go b/config_test.go index 47a5836..c0646b7 100644 --- a/config_test.go +++ b/config_test.go @@ -88,7 +88,7 @@ func TestConfig_EnabledFeatures_Good(t *testing.T) { // --- ConfigVar --- -func TestConfigVar_Good(t *testing.T) { +func TestConfig_ConfigVar_Good(t *testing.T) { v := NewConfigVar("hello") assert.True(t, v.IsSet()) assert.Equal(t, "hello", v.Get()) diff --git a/contract.go b/contract.go index 7d65926..8718a90 100644 --- a/contract.go +++ b/contract.go @@ -7,6 +7,7 @@ package core import ( "context" "reflect" + "sync" ) // Message is the type for IPC broadcasts (fire-and-forget). @@ -15,30 +16,25 @@ type Message any // Query is the type for read-only IPC requests. type Query any -// Task is the type for IPC requests that perform side effects. -type Task any - -// TaskWithIdentifier is an optional interface for tasks that need to know their assigned identifier. -type TaskWithIdentifier interface { - Task - SetTaskIdentifier(id string) - GetTaskIdentifier() string -} - // QueryHandler handles Query requests. Returns Result{Value, OK}. type QueryHandler func(*Core, Query) Result -// TaskHandler handles Task requests. Returns Result{Value, OK}. -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 --- @@ -48,21 +44,21 @@ type ActionServiceShutdown struct{} type ActionTaskStarted struct { TaskIdentifier string - Task Task + Action string + Options Options } type ActionTaskProgress struct { TaskIdentifier string - Task Task + Action string Progress float64 Message string } type ActionTaskCompleted struct { TaskIdentifier string - Task Task - Result any - Error error + Action string + Result Result } // --- Constructor --- @@ -81,30 +77,32 @@ 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[*Task]()}, info: systemInfo, i18n: &I18n{}, - services: &serviceRegistry{services: make(map[string]*Service)}, - commands: &commandRegistry{commands: make(map[string]*Command)}, + api: &API{protocols: NewRegistry[StreamFactory]()}, + services: &ServiceRegistry{Registry: NewRegistry[*Service]()}, + commands: &CommandRegistry{Registry: NewRegistry[*Command]()}, + entitlementChecker: defaultChecker, } c.context, c.cancel = context.WithCancel(context.Background()) + c.api.core = c // Core services CliRegister(c) diff --git a/contract_test.go b/contract_test.go index 9984a55..6b26c85 100644 --- a/contract_test.go +++ b/contract_test.go @@ -31,7 +31,7 @@ func stubFactory(c *Core) Result { // stubFactory lives in package "dappco.re/go/core_test", so the last path // segment is "core_test" — WithService strips the "_test" suffix and registers // the service under the name "core". -func TestWithService_NameDiscovery_Good(t *testing.T) { +func TestContract_WithService_NameDiscovery_Good(t *testing.T) { c := New(WithService(stubFactory)) names := c.Services() @@ -42,7 +42,7 @@ func TestWithService_NameDiscovery_Good(t *testing.T) { // TestWithService_FactorySelfRegisters_Good verifies that when a factory // returns Result{OK:true} with no Value (it registered itself), WithService // does not attempt a second registration and returns success. -func TestWithService_FactorySelfRegisters_Good(t *testing.T) { +func TestContract_WithService_FactorySelfRegisters_Good(t *testing.T) { selfReg := func(c *Core) Result { // Factory registers directly, returns no instance. c.Service("self", Service{}) @@ -58,7 +58,7 @@ func TestWithService_FactorySelfRegisters_Good(t *testing.T) { // --- WithName --- -func TestWithName_Good(t *testing.T) { +func TestContract_WithName_Good(t *testing.T) { c := New( WithName("custom", func(c *Core) Result { return Result{Value: &stubNamedService{}, OK: true} @@ -73,12 +73,12 @@ type lifecycleService struct { started bool } -func (s *lifecycleService) OnStartup(_ context.Context) error { +func (s *lifecycleService) OnStartup(_ context.Context) Result { s.started = true - return nil + return Result{OK: true} } -func TestWithService_Lifecycle_Good(t *testing.T) { +func TestContract_WithService_Lifecycle_Good(t *testing.T) { svc := &lifecycleService{} c := New( WithService(func(c *Core) Result { @@ -101,7 +101,7 @@ func (s *ipcService) HandleIPCEvents(c *Core, msg Message) Result { return Result{OK: true} } -func TestWithService_IPCHandler_Good(t *testing.T) { +func TestContract_WithService_IPCHandler_Good(t *testing.T) { svc := &ipcService{} c := New( WithService(func(c *Core) Result { @@ -117,7 +117,7 @@ func TestWithService_IPCHandler_Good(t *testing.T) { // TestWithService_FactoryError_Bad verifies that a failing factory // stops further option processing (second service not registered). -func TestWithService_FactoryError_Bad(t *testing.T) { +func TestContract_WithService_FactoryError_Bad(t *testing.T) { secondCalled := false c := New( WithService(func(c *Core) Result { diff --git a/core.go b/core.go index 9074b5c..21f13c1 100644 --- a/core.go +++ b/core.go @@ -25,13 +25,17 @@ 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 + api *API // c.API() — Remote streams info *SysInfo // c.Env("key") — Read-only system/environment information i18n *I18n // c.I18n() — Internationalisation and locale collection + entitlementChecker EntitlementChecker // default: everything permitted + usageRecorder UsageRecorder // default: nil (no-op) + context context.Context cancel context.CancelFunc taskIDCounter atomic.Uint64 @@ -41,61 +45,146 @@ type Core struct { // --- Accessors --- -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 } -func (c *Core) Log() *ErrorLog { return c.log } +// Options returns the input configuration passed to core.New(). +// +// opts := c.Options() +// name := opts.String("name") +func (c *Core) Options() *Options { return c.options } + +// App returns application identity metadata. +// +// c.App().Name // "my-app" +// c.App().Version // "1.0.0" +func (c *Core) App() *App { return c.app } + +// Data returns the embedded asset registry (Registry[*Embed]). +// +// r := c.Data().ReadString("prompts/coding.md") +func (c *Core) Data() *Data { return c.data } + +// Drive returns the transport handle registry (Registry[*DriveHandle]). +// +// r := c.Drive().Get("forge") +func (c *Core) Drive() *Drive { return c.drive } + +// Fs returns the sandboxed filesystem. +// +// r := c.Fs().Read("/path/to/file") +// c.Fs().WriteAtomic("/status.json", data) +func (c *Core) Fs() *Fs { return c.fs } + +// Config returns runtime settings and feature flags. +// +// host := c.Config().String("database.host") +// c.Config().Enable("dark-mode") +func (c *Core) Config() *Config { return c.config } + +// Error returns the panic recovery subsystem. +// +// c.Error().Recover() +func (c *Core) Error() *ErrorPanic { return c.error } + +// Log returns the structured logging subsystem. +// +// c.Log().Info("started", "port", 8080) +func (c *Core) Log() *ErrorLog { return c.log } + +// Cli returns the CLI command framework (registered as service "cli"). +// +// c.Cli().Run("deploy", "to", "homelab") func (c *Core) Cli() *Cli { cl, _ := ServiceFor[*Cli](c, "cli") return cl } -func (c *Core) IPC() *Ipc { return c.ipc } -func (c *Core) I18n() *I18n { return c.i18n } -func (c *Core) Env(key string) string { return Env(key) } + +// IPC returns the message bus internals. +// +// c.IPC() +func (c *Core) IPC() *Ipc { return c.ipc } + +// I18n returns the internationalisation subsystem. +// +// tr := c.I18n().Translate("cmd.deploy.description") +func (c *Core) I18n() *I18n { return c.i18n } + +// Env returns an environment variable by key (cached at init, falls back to os.Getenv). +// +// home := c.Env("DIR_HOME") +// token := c.Env("FORGE_TOKEN") +func (c *Core) Env(key string) string { return Env(key) } + +// Context returns Core's lifecycle context (cancelled on shutdown). +// +// ctx := c.Context() func (c *Core) Context() context.Context { return c.context } -func (c *Core) Core() *Core { return c } + +// Core returns self — satisfies the ServiceRuntime interface. +// +// c := s.Core() +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) 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) } +// ACTION broadcasts a message to all registered handlers (fire-and-forget). +// Each handler is wrapped in panic recovery. All handlers fire regardless. +// +// c.ACTION(messages.AgentCompleted{Agent: "codex", Status: "completed"}) +func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) } + +// QUERY sends a request — first handler to return OK wins. +// +// r := c.QUERY(MyQuery{Name: "brain"}) +func (c *Core) QUERY(q Query) Result { return c.Query(q) } + +// QUERYALL sends a request — collects all OK responses. +// +// r := c.QUERYALL(countQuery{}) +// results := r.Value.([]any) +func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) } // --- Error+Log --- @@ -114,4 +203,37 @@ func (c *Core) Must(err error, op, msg string) { c.log.Must(err, op, msg) } +// --- Registry Accessor --- + +// RegistryOf returns a named registry for cross-cutting queries. +// Known registries: "services", "commands", "actions". +// +// c.RegistryOf("services").Names() // all service names +// c.RegistryOf("actions").List("process.*") // process capabilities +// c.RegistryOf("commands").Len() // command count +func (c *Core) RegistryOf(name string) *Registry[any] { + // Bridge typed registries to untyped access for cross-cutting queries. + // Each registry is wrapped in a read-only proxy. + switch name { + case "services": + return registryProxy(c.services.Registry) + case "commands": + return registryProxy(c.commands.Registry) + case "actions": + return registryProxy(c.ipc.actions) + default: + return NewRegistry[any]() // empty registry for unknown names + } +} + +// registryProxy creates a read-only any-typed view of a typed registry. +// Copies current state — not a live view (avoids type parameter leaking). +func registryProxy[T any](src *Registry[T]) *Registry[any] { + proxy := NewRegistry[any]() + src.Each(func(name string, item T) { + proxy.Set(name, item) + }) + return proxy +} + // --- Global Instance --- diff --git a/core_test.go b/core_test.go index 17ee587..2beb4cd 100644 --- a/core_test.go +++ b/core_test.go @@ -2,9 +2,6 @@ package core_test import ( "context" - "os" - "os/exec" - "path/filepath" "testing" . "dappco.re/go/core" @@ -13,24 +10,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 +46,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 +60,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 +76,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,75 +144,102 @@ func TestCore_Must_Nil_Good(t *testing.T) { }) } -func TestCore_Run_HelperProcess(t *testing.T) { - if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { - return - } - - switch os.Getenv("CORE_RUN_MODE") { - case "startup-fail": - c := New( - WithService(func(c *Core) Result { - return c.Service("broken", Service{ - OnStart: func() Result { - return Result{Value: NewError("startup failed"), OK: false} - }, - }) - }), - ) - c.Run() - case "cli-fail": - shutdownFile := os.Getenv("CORE_RUN_SHUTDOWN_FILE") - c := New( - WithService(func(c *Core) Result { - return c.Service("cleanup", Service{ - OnStop: func() Result { - if err := os.WriteFile(shutdownFile, []byte("stopped"), 0o600); err != nil { - return Result{Value: err, OK: false} - } - return Result{OK: true} - }, - }) - }), - ) - c.Command("explode", Command{ - Action: func(_ Options) Result { - return Result{Value: NewError("cli failed"), OK: false} - }, - }) - os.Args = []string{"core-test", "explode"} - c.Run() - default: - os.Exit(2) - } -} - -func TestCore_Run_Bad(t *testing.T) { - err := runCoreRunHelper(t, "startup-fail") - var exitErr *exec.ExitError - if assert.ErrorAs(t, err, &exitErr) { - assert.Equal(t, 1, exitErr.ExitCode()) - } -} - -func TestCore_Run_Ugly(t *testing.T) { - shutdownFile := filepath.Join(t.TempDir(), "shutdown.txt") - err := runCoreRunHelper(t, "cli-fail", "CORE_RUN_SHUTDOWN_FILE="+shutdownFile) - var exitErr *exec.ExitError - if assert.ErrorAs(t, err, &exitErr) { - assert.Equal(t, 1, exitErr.ExitCode()) - } - - data, readErr := os.ReadFile(shutdownFile) - assert.NoError(t, readErr) - assert.Equal(t, "stopped", string(data)) -} - -func runCoreRunHelper(t *testing.T, mode string, extraEnv ...string) error { - t.Helper() - - cmd := exec.Command(os.Args[0], "-test.run=^TestCore_Run_HelperProcess$") - cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "CORE_RUN_MODE="+mode) - cmd.Env = append(cmd.Env, extraEnv...) - return cmd.Run() +// --- 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") +} + +// Run() delegates to RunE() — tested via RunE tests above. +// os.Exit behaviour is verified by RunE returning error correctly. diff --git a/data.go b/data.go index 47f9414..460277c 100644 --- a/data.go +++ b/data.go @@ -25,13 +25,12 @@ package core import ( "io/fs" "path/filepath" - "sync" ) // Data manages mounted embedded filesystems from core packages. +// Embeds Registry[*Embed] for thread-safe named storage. type Data struct { - mounts map[string]*Embed - mu sync.RWMutex + *Registry[*Embed] } // New registers an embedded filesystem under a named prefix. @@ -62,54 +61,27 @@ func (d *Data) New(opts Options) Result { path = "." } - d.mu.Lock() - defer d.mu.Unlock() - - if d.mounts == nil { - d.mounts = make(map[string]*Embed) - } - mr := Mount(fsys, path) if !mr.OK { return mr } emb := mr.Value.(*Embed) - d.mounts[name] = emb - return Result{emb, true} -} - -// Get returns the Embed for a named mount point. -// -// r := c.Data().Get("brain") -// if r.OK { emb := r.Value.(*Embed) } -func (d *Data) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.mounts == nil { - return Result{} - } - emb, ok := d.mounts[name] - if !ok { - return Result{} - } + d.Set(name, emb) return Result{emb, true} } // resolve splits a path like "brain/coding.md" into mount name + relative path. func (d *Data) resolve(path string) (*Embed, string) { - d.mu.RLock() - defer d.mu.RUnlock() - parts := SplitN(path, "/", 2) if len(parts) < 2 { return nil, "" } - if d.mounts == nil { + r := d.Get(parts[0]) + if !r.OK { return nil, "" } - emb := d.mounts[parts[0]] - return emb, parts[1] + return r.Value.(*Embed), parts[1] } // ReadFile reads a file by full path. @@ -188,15 +160,9 @@ func (d *Data) Extract(path, targetDir string, templateData any) Result { return Extract(r.Value.(*Embed).FS(), targetDir, templateData) } -// Mounts returns the names of all mounted content. +// Mounts returns the names of all mounted content in registration order. // // names := c.Data().Mounts() func (d *Data) Mounts() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.mounts { - names = append(names, k) - } - return names + return d.Names() } diff --git a/data_test.go b/data_test.go index 89763d6..55cdfbe 100644 --- a/data_test.go +++ b/data_test.go @@ -2,7 +2,6 @@ package core_test import ( "embed" - "io" "testing" . "dappco.re/go/core" @@ -80,10 +79,9 @@ func TestData_Get_Good(t *testing.T) { r := emb.Open("test.txt") assert.True(t, r.OK) - file := r.Value.(io.ReadCloser) - defer file.Close() - content, _ := io.ReadAll(file) - assert.Equal(t, "hello from testdata\n", string(content)) + cr := ReadAll(r.Value) + assert.True(t, cr.OK) + assert.Equal(t, "hello from testdata\n", cr.Value) } func TestData_Get_Bad(t *testing.T) { @@ -100,12 +98,6 @@ func TestData_Mounts_Good(t *testing.T) { assert.Len(t, mounts, 2) } -func TestEmbed_Legacy_Good(t *testing.T) { - c := New() - mountTestData(t, c, "app") - assert.NotNil(t, c.Embed()) -} - func TestData_List_Good(t *testing.T) { c := New() mountTestData(t, c, "app") diff --git a/docs/RFC.md b/docs/RFC.md new file mode 100644 index 0000000..39df9d2 --- /dev/null +++ b/docs/RFC.md @@ -0,0 +1,1284 @@ +# CoreGO API Contract — RFC Specification + +> `dappco.re/go/core` — Dependency injection, service lifecycle, permission, and message-passing framework. +> This document is the authoritative API contract. An agent should be able to write a service +> that registers with Core from this document alone. + +**Status:** Living document +**Module:** `dappco.re/go/core` +**Version:** v0.8.0 + +--- + +## 1. Core — The Container + +Core is the central application container. Everything registers with Core, communicates through Core, and has its lifecycle managed by Core. + +### 1.1 Creation + +```go +c := core.New( + core.WithOption("name", "my-app"), + core.WithService(mypackage.Register), + core.WithService(anotherpackage.Register), + core.WithServiceLock(), +) +c.Run() +``` + +`core.New()` returns `*Core` (not Result — Core is the one type that can't wrap its own creation error). Functional options are applied in order. `WithServiceLock()` prevents late service registration. + +### 1.2 Lifecycle + +``` +New() → WithService factories called → LockApply() +RunE() → defer ServiceShutdown() → ServiceStartup() → Cli.Run() → returns error +Run() → RunE() → os.Exit(1) on error +``` + +`RunE()` is the primary lifecycle — returns `error`, always calls `ServiceShutdown` via defer (even on startup failure or panic). `Run()` is sugar that calls `RunE()` and exits on error. `ServiceStartup` calls `OnStartup(ctx)` on all `Startable` services in registration order. `ServiceShutdown` calls `OnShutdown(ctx)` on all `Stoppable` services. + +### 1.3 Subsystem Accessors + +Every subsystem is accessed via a method on Core: + +```go +c.Options() // *Options — input configuration +c.App() // *App — application metadata (name, version) +c.Config() // *Config — runtime settings, feature flags +c.Data() // *Data — embedded assets (Registry[*Embed]) +c.Drive() // *Drive — transport handles (Registry[*DriveHandle]) +c.Fs() // *Fs — filesystem I/O (sandboxable) +c.Cli() // *Cli — CLI command framework +c.IPC() // *Ipc — message bus internals +c.I18n() // *I18n — internationalisation +c.Error() // *ErrorPanic — panic recovery +c.Log() // *ErrorLog — structured logging +c.Process() // *Process — managed execution (Action sugar) +c.API() // *API — remote streams (protocol handlers) +c.Action(name) // *Action — named callable (register/invoke) +c.Task(name) // *Task — composed Action sequence +c.Entitled(name) // Entitlement — permission check +c.RegistryOf(n) // *Registry — cross-cutting queries +c.Context() // context.Context +c.Env(key) // string — environment variable (cached at init) +``` + +--- + +## 2. Primitive Types + +### 2.1 Option + +The atom. A single key-value pair. + +```go +core.Option{Key: "name", Value: "brain"} +core.Option{Key: "port", Value: 8080} +core.Option{Key: "debug", Value: true} +``` + +### 2.2 Options + +A collection of Option with typed accessors. + +```go +opts := core.NewOptions( + core.Option{Key: "name", Value: "myapp"}, + core.Option{Key: "port", Value: 8080}, + core.Option{Key: "debug", Value: true}, +) + +opts.String("name") // "myapp" +opts.Int("port") // 8080 +opts.Bool("debug") // true +opts.Has("name") // true +opts.Len() // 3 + +opts.Set("name", "new-name") +opts.Get("name") // Result{Value: "new-name", OK: true} +``` + +### 2.3 Result + +Universal return type. Every Core operation returns Result. + +```go +type Result struct { + Value any + OK bool +} +``` + +Usage patterns: + +```go +// Check success +r := c.Config().Get("database.host") +if r.OK { + host := r.Value.(string) +} + +// Service factory returns Result +func Register(c *core.Core) core.Result { + svc := &MyService{} + return core.Result{Value: svc, OK: true} +} + +// Error as Result +return core.Result{Value: err, OK: false} +``` + +No generics on Result. Type-assert the Value when needed. This is deliberate — `Result` is universal across all subsystems without carrying type parameters. + +### 2.4 Message, Query + +IPC type aliases for the broadcast/request system: + +```go +type Message any // broadcast via ACTION — fire and forget +type Query any // request/response via QUERY — returns first handler's result +``` + +For tracked work, use named Actions: `c.PerformAsync("action.name", opts)`. + +--- + +## 3. Service System + +### 3.1 Registration + +Services register via factory functions passed to `WithService`: + +```go +core.New( + core.WithService(mypackage.Register), +) +``` + +The factory signature is `func(*Core) Result`. The returned `Result.Value` is the service instance. + +### 3.2 Factory Pattern + +```go +func Register(c *core.Core) core.Result { + svc := &MyService{ + runtime: core.NewServiceRuntime(c, MyOptions{}), + } + return core.Result{Value: svc, OK: true} +} +``` + +`NewServiceRuntime[T]` gives the service access to Core and typed options: + +```go +type MyService struct { + *core.ServiceRuntime[MyOptions] +} + +// Access Core from within the service: +func (s *MyService) doSomething() { + c := s.Core() + cfg := s.Config().String("my.setting") +} +``` + +### 3.3 Auto-Discovery + +`WithService` reflects on the returned instance to discover: +- **Package name** → service name (from reflect type path) +- **Startable interface** → `OnStartup(ctx) Result` called during `ServiceStartup` +- **Stoppable interface** → `OnShutdown(ctx) Result` called during `ServiceShutdown` +- **HandleIPCEvents method** → auto-registered as IPC handler + +### 3.4 Retrieval + +```go +// Type-safe retrieval +svc, ok := core.ServiceFor[*MyService](c, "mypackage") +if !ok { + // service not registered +} + +// Must variant (panics if not found) +svc := core.MustServiceFor[*MyService](c, "mypackage") + +// List all registered services +names := c.Services() // []string in registration order +``` + +### 3.5 Lifecycle Interfaces + +```go +type Startable interface { + OnStartup(ctx context.Context) Result +} + +type Stoppable interface { + OnShutdown(ctx context.Context) Result +} +``` + +Services implementing these are called during `RunE()` / `Run()` in registration order. + +--- + +## 4. IPC — Message Passing + +### 4.1 ACTION (broadcast) + +Fire-and-forget broadcast to all registered handlers: + +```go +// Send +c.ACTION(messages.AgentCompleted{ + Agent: "codex", Repo: "go-io", Status: "completed", +}) + +// Register handler +c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(messages.AgentCompleted); ok { + // handle completion + } + return core.Result{OK: true} +}) +``` + +All handlers receive all messages. Type-switch to filter. Handler return values are ignored — broadcast calls ALL handlers regardless. Each handler is wrapped in panic recovery. + +### 4.2 QUERY (request/response) + +First handler to return a non-empty result wins: + +```go +// Send +result := c.QUERY(MyQuery{Name: "brain"}) +if result.OK { + svc := result.Value +} + +// Register handler +c.RegisterQuery(func(c *core.Core, q core.Query) core.Result { + if mq, ok := q.(MyQuery); ok { + return core.Result{Value: found, OK: true} + } + return core.Result{OK: false} // not my query +}) +``` + +### 4.3 PerformAsync (background action) + +```go +// Execute a named action in background with progress tracking +r := c.PerformAsync("agentic.dispatch", opts) +taskID := r.Value.(string) + +// Report progress +c.Progress(taskID, 0.5, "halfway done", "agentic.dispatch") +``` + +Broadcasts `ActionTaskStarted`, `ActionTaskProgress`, `ActionTaskCompleted` as ACTION messages. + +--- + +## 5. Config + +Runtime configuration with typed accessors and feature flags. + +```go +c.Config().Set("database.host", "localhost") +c.Config().Set("database.port", 5432) + +host := c.Config().String("database.host") // "localhost" +port := c.Config().Int("database.port") // 5432 + +// Feature flags +c.Config().Enable("dark-mode") +c.Config().Enabled("dark-mode") // true +c.Config().Disable("dark-mode") +c.Config().EnabledFeatures() // []string + +// Type-safe generic getter +val := core.ConfigGet[string](c.Config(), "database.host") +``` + +--- + +## 6. Data — Embedded Assets + +Mount embedded filesystems and read from them: + +```go +//go:embed prompts/* +var promptFS embed.FS + +// Mount during service registration +c.Data().New(core.NewOptions( + core.Option{Key: "name", Value: "prompts"}, + core.Option{Key: "source", Value: promptFS}, + core.Option{Key: "path", Value: "prompts"}, +)) + +// Read +r := c.Data().ReadString("prompts/coding.md") +if r.OK { + content := r.Value.(string) +} + +// List +r := c.Data().List("prompts/") +r := c.Data().ListNames("prompts/") +r := c.Data().Mounts() // []string (insertion order) + +// Data embeds Registry[*Embed] — all Registry methods available: +c.Data().Has("prompts") +c.Data().Each(func(name string, emb *Embed) { ... }) +``` + +--- + +## 7. Drive — Transport Handles + +Registry of named transport handles (API endpoints, MCP servers, etc): + +```go +c.Drive().New(core.NewOptions( + core.Option{Key: "name", Value: "forge"}, + core.Option{Key: "transport", Value: "https://forge.lthn.ai"}, +)) + +r := c.Drive().Get("forge") // Result with *DriveHandle +c.Drive().Has("forge") // true +c.Drive().Names() // []string (insertion order) + +// Drive embeds Registry[*DriveHandle] — all Registry methods available. +``` + +--- + +## 8. Fs — Filesystem + +Sandboxable filesystem I/O. All paths are validated against the root. + +```go +fs := c.Fs() + +// Read/Write +r := fs.Read("/path/to/file") // Result{Value: string} +r := fs.Write("/path/to/file", content) // Result{OK: bool} +r := fs.WriteMode(path, content, 0600) // With permissions + +// Directory ops +r := fs.EnsureDir("/path/to/dir") +r := fs.List("/path/to/dir") // Result{Value: []os.DirEntry} +fs.IsDir(path) // bool +fs.IsFile(path) // bool +fs.Exists(path) // bool + +// Streams +r := fs.Open(path) // Result{Value: *os.File} +r := fs.Create(path) // Result{Value: *os.File} +r := fs.Append(path) // Result{Value: io.WriteCloser} +r := fs.ReadStream(path) // Result{Value: io.ReadCloser} +r := fs.WriteStream(path) // Result{Value: io.WriteCloser} + +// Atomic write (write-to-temp-then-rename, safe for concurrent readers) +r := fs.WriteAtomic(path, content) + +// Delete +r := fs.Delete(path) // single file +r := fs.DeleteAll(path) // recursive +r := fs.Rename(old, new) +r := fs.Stat(path) // Result{Value: os.FileInfo} + +// Sandbox control +fs.Root() // sandbox root path +fs.NewUnrestricted() // Fs with root "/" — full access +``` + +--- + +## 9. CLI + +Command tree with path-based routing: + +```go +c.Command("issue/get", core.Command{ + Description: "Get a Forge issue", + Action: s.cmdIssueGet, +}) + +c.Command("issue/list", core.Command{ + Description: "List Forge issues", + Action: s.cmdIssueList, +}) + +// Action signature +func (s *MyService) cmdIssueGet(opts core.Options) core.Result { + repo := opts.String("_arg") // positional arg + num := opts.String("number") // --number=N flag + // ... + return core.Result{OK: true} +} +``` + +Path = command hierarchy. `issue/get` becomes `myapp issue get` in CLI. + +Managed commands have lifecycle provided by go-process: + +```go +c.Command("serve", core.Command{ + Action: handler, + Managed: "process.daemon", // go-process provides start/stop/restart +}) +``` + +--- + +## 10. Error Handling + +All errors use `core.E()`: + +```go +// Standard error +return core.E("service.Method", "what failed", underlyingErr) + +// With format +return core.E("service.Method", core.Sprintf("not found: %s", name), nil) + +// Error inspection +core.Operation(err) // "service.Method" +core.ErrorMessage(err) // "what failed" +core.ErrorCode(err) // code if set via WrapCode +core.Root(err) // unwrap to root cause +core.Is(err, target) // errors.Is +core.As(err, &target) // errors.As +``` + +**NEVER use `fmt.Errorf`, `errors.New`, or `log.*`.** Core handles all error reporting. + +--- + +## 11. Logging + +```go +core.Info("server started", "port", 8080) +core.Debug("processing", "item", name) +core.Warn("deprecated", "feature", "old-api") +core.Error("failed", "err", err) +core.Security("access denied", "user", username) +``` + +Key-value pairs after the message. Structured, not formatted strings. + +--- + +## 12. String Helpers + +Core re-exports string operations to avoid `strings` import: + +```go +core.Contains(s, substr) +core.HasPrefix(s, prefix) +core.HasSuffix(s, suffix) +core.TrimPrefix(s, prefix) +core.TrimSuffix(s, suffix) +core.Split(s, sep) +core.SplitN(s, sep, n) +core.Join(sep, parts...) +core.Replace(s, old, new) +core.Lower(s) / core.Upper(s) +core.Trim(s) +core.Sprintf(format, args...) +core.Concat(parts...) +core.NewBuilder() / core.NewReader(s) +``` + +--- + +## 13. Path Helpers + +```go +core.Path(segments...) // ~/segments joined +core.JoinPath(segments...) // filepath.Join +core.PathBase(p) // filepath.Base +core.PathDir(p) // filepath.Dir +core.PathExt(p) // filepath.Ext +core.PathIsAbs(p) // filepath.IsAbs +core.PathGlob(pattern) // filepath.Glob +core.CleanPath(p, sep) // normalise separators +``` + +--- + +## 14. Utility Functions + +```go +core.Print(writer, format, args...) // formatted output +core.Env(key) // cached env var (set at init) +core.EnvKeys() // all available env keys + +// Arg extraction (positional) +core.Arg(0, args...) // Result +core.ArgString(0, args...) // string +core.ArgInt(0, args...) // int +core.ArgBool(0, args...) // bool + +// Flag parsing +core.IsFlag("--name") // true +core.ParseFlag("--name=value") // "name", "value", true +core.FilterArgs(args) // strip flags, keep positional + +// Identifiers and validation +core.ID() // "id-42-a3f2b1" — unique per process +core.ValidateName("brain") // Result{OK: true} — rejects "", ".", "..", path seps +core.SanitisePath("../../x") // "x" — extracts safe base, "invalid" for dangerous + +// JSON (wraps encoding/json — consumers don't import it directly) +core.JSONMarshal(myStruct) // Result{Value: []byte, OK: bool} +core.JSONMarshalString(myStruct) // string (returns "{}" on error) +core.JSONUnmarshal(data, &target) // Result{OK: bool} +core.JSONUnmarshalString(s, &target) +``` + +--- + +## 15. Lock System + +Per-Core mutex registry for coordinating concurrent access: + +```go +c.Lock("drain").Mutex.Lock() +defer c.Lock("drain").Mutex.Unlock() + +// Enable named locks +c.LockEnable("service-registry") + +// Apply lock (prevents further registration) +c.LockApply() +``` + +--- + +## 16. ServiceRuntime Generic Helper + +Embed in services to get Core access and typed options: + +```go +type MyService struct { + *core.ServiceRuntime[MyOptions] +} + +type MyOptions struct { + BufferSize int + Timeout time.Duration +} + +func NewMyService(c *core.Core) core.Result { + svc := &MyService{ + ServiceRuntime: core.NewServiceRuntime(c, MyOptions{ + BufferSize: 1024, + Timeout: 30 * time.Second, + }), + } + return core.Result{Value: svc, OK: true} +} + +// Within the service: +func (s *MyService) DoWork() { + c := s.Core() // access Core + opts := s.Options() // MyOptions{BufferSize: 1024, ...} + cfg := s.Config() // shortcut to s.Core().Config() +} +``` + +--- + +## 17. Process — Managed Execution + +`c.Process()` is sugar over named Actions. core/go defines the primitive. go-process provides the implementation via `c.Action("process.run", handler)`. + +```go +// Synchronous — returns Result +r := c.Process().Run(ctx, "git", "log", "--oneline") +r := c.Process().RunIn(ctx, "/repo", "go", "test", "./...") +r := c.Process().RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test") + +// Async — returns process ID +r := c.Process().Start(ctx, opts) + +// Control +c.Process().Kill(ctx, core.NewOptions(core.Option{Key: "id", Value: processID})) + +// Capability check +if c.Process().Exists() { /* go-process is registered */ } +``` + +**Permission by registration:** No go-process registered → `c.Process().Run()` returns `Result{OK: false}`. No config, no tokens. The service either exists or it doesn't. + +```go +// Sandboxed Core — no process capability +c := core.New() +c.Process().Run(ctx, "rm", "-rf", "/") // Result{OK: false} — nothing happens + +// Full Core — process registered +c := core.New(core.WithService(process.Register)) +c.Process().Run(ctx, "git", "log") // executes, returns output +``` + +> Consumer implementation: see `go-process/docs/RFC.md` + +--- + +## 18. Action and Task — The Execution Primitives + +An Action is a named, registered callable. A Task is a composed sequence of Actions. + +### 18.1 Action — The Atomic Unit + +```go +// Register +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", "--oneline") +}) + +// Invoke +r := c.Action("git.log").Run(ctx, core.NewOptions( + core.Option{Key: "dir", Value: "/repo"}, +)) + +// Check capability +c.Action("process.run").Exists() // true if go-process registered + +// List all +c.Actions() // []string{"process.run", "agentic.dispatch", ...} +``` + +`c.Action(name)` is dual-purpose: with handler arg → register; without → return for invocation. + +### 18.2 Action Type + +```go +type ActionHandler func(context.Context, Options) Result + +type Action struct { + Name string + Handler ActionHandler + Description string + Schema Options // expected input keys +} +``` + +`Action.Run()` has panic recovery and entitlement checking (Section 21) built in. + +### 18.3 Where Actions Come From + +Services register during `OnStartup`: + +```go +func (s *MyService) OnStartup(ctx context.Context) core.Result { + c := s.Core() + c.Action("process.run", s.handleRun) + c.Action("git.clone", s.handleGitClone) + return core.Result{OK: true} +} +``` + +The action namespace IS the capability map. go-process registers `process.*`, core/agent registers `agentic.*`. + +### 18.4 Permission Model + +Three states for any action: + +| State | `Exists()` | `Entitled()` | `Run()` | +|-------|-----------|-------------|---------| +| Not registered | false | — | `Result{OK: false}` not registered | +| Registered, not entitled | true | false | `Result{OK: false}` not entitled | +| Registered and entitled | true | true | executes handler | + +### 18.5 Task — Composing Actions + +```go +c.Task("deploy", core.Task{ + Description: "Build, test, deploy", + Steps: []core.Step{ + {Action: "go.build"}, + {Action: "go.test"}, + {Action: "docker.push"}, + {Action: "ansible.deploy", Async: true}, // doesn't block + }, +}) + +r := c.Task("deploy").Run(ctx, c, opts) +``` + +Sequential steps stop on first failure. `Async: true` steps fire without blocking. +`Input: "previous"` pipes last step's output to next step. + +### 18.6 Background Execution + +```go +r := c.PerformAsync("agentic.dispatch", opts) +taskID := r.Value.(string) + +// Broadcasts ActionTaskStarted, ActionTaskProgress, ActionTaskCompleted +c.Progress(taskID, 0.5, "halfway", "agentic.dispatch") +``` + +### 18.7 How Process Fits + +`c.Process()` is sugar over Actions: + +```go +c.Process().Run(ctx, "git", "log") +// equivalent to: +c.Action("process.run").Run(ctx, core.NewOptions( + core.Option{Key: "command", Value: "git"}, + core.Option{Key: "args", Value: []string{"log"}}, +)) +``` + +--- + +## 19. API — Remote Streams + +Drive is the phone book (WHERE). API is the phone (HOW). Consumer packages register protocol handlers. + +```go +// Configure endpoint in Drive +c.Drive().New(core.NewOptions( + core.Option{Key: "name", Value: "charon"}, + core.Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"}, +)) + +// Open stream — looks up Drive, finds protocol handler +r := c.API().Stream("charon") +if r.OK { + stream := r.Value.(core.Stream) + stream.Send(payload) + resp, _ := stream.Receive() + stream.Close() +} +``` + +### 19.1 Stream Interface + +```go +type Stream interface { + Send(data []byte) error + Receive() ([]byte, error) + Close() error +} +``` + +### 19.2 Protocol Handlers + +Consumer packages register factories per URL scheme: + +```go +// In a transport package's OnStartup: +c.API().RegisterProtocol("http", httpStreamFactory) +c.API().RegisterProtocol("mcp", mcpStreamFactory) +``` + +Resolution: `c.API().Stream("charon")` → Drive lookup → extract scheme → find factory → create Stream. + +No protocol handler = no capability. + +### 19.3 Remote Action Dispatch + +Actions transparently cross machine boundaries via `host:action` syntax: + +```go +// Local +r := c.RemoteAction("agentic.status", ctx, opts) + +// Remote — same API, different host +r := c.RemoteAction("charon:agentic.status", ctx, opts) +// → splits on ":" → endpoint="charon", action="agentic.status" +// → c.API().Call("charon", "agentic.status", opts) + +// Web3 — Lethean dVPN routed +r := c.RemoteAction("snider.lthn:brain.recall", ctx, opts) +``` + +### 19.4 Direct Call + +```go +r := c.API().Call("charon", "agentic.dispatch", opts) +// Opens stream, sends JSON-RPC, receives response, closes stream +``` + +--- + +## 20. Registry — The Universal Collection Primitive + +Thread-safe named collection. The brick all registries build on. + +### 20.1 The Type + +```go +// Registry is a thread-safe named collection. The universal brick +// for all named registries in Core. +type Registry[T any] struct { + items map[string]T + mu sync.RWMutex + locked bool +} +``` + +### 20.3 Operations + +```go +r := core.NewRegistry[*Service]() + +r.Set("brain", brainSvc) // register +r.Get("brain") // Result{brainSvc, true} +r.Has("brain") // true +r.Names() // []string{"brain", "monitor", ...} +r.List("brain.*") // glob/prefix match +r.Each(func(name string, item T)) // iterate +r.Len() // count +r.Lock() // prevent further Set calls +r.Locked() // bool +r.Delete("brain") // remove (if not locked) +``` + +### 20.4 Core Accessor + +`c.Registry(name)` accesses named registries. Each subsystem's registry is accessible through it: + +```go +c.RegistryOf("services") // the service registry +c.Registry("commands") // the command tree +c.RegistryOf("actions") // IPC action handlers +c.RegistryOf("drives") // transport handles +c.Registry("data") // mounted filesystems +``` + +Cross-cutting queries become natural: + +```go +c.RegistryOf("actions").List("process.*") // all process capabilities +c.RegistryOf("drives").Names() // all configured transports +c.RegistryOf("services").Has("brain") // is brain service loaded? +c.RegistryOf("actions").Len() // how many actions registered? +``` + +### 20.5 Typed Accessors Are Sugar + +The existing subsystem accessors become typed convenience over Registry: + +```go +// These are equivalent: +c.Service("brain") // typed sugar +c.RegistryOf("services").Get("brain") // universal access + +c.Drive().Get("forge") // typed sugar +c.RegistryOf("drives").Get("forge") // universal access + +c.Action("process.run") // typed sugar +c.RegistryOf("actions").Get("process.run") // universal access +``` + +The typed accessors stay — they're ergonomic and type-safe. `c.Registry()` adds the universal query layer on top. + +### 20.6 What Embeds Registry + +All named collections in Core embed `Registry[T]`: + +- `ServiceRegistry` → `Registry[*Service]` +- `CommandRegistry` → `Registry[*Command]` +- `Drive` → `Registry[*DriveHandle]` +- `Data` → `Registry[*Embed]` +- `Lock.locks` → `Registry[*sync.RWMutex]` +- `IPC.actions` → `Registry[*Action]` +- `IPC.tasks` → `Registry[*Task]` + +--- + +## Design Philosophy + +### Core Is Lego Bricks + +Core is infrastructure, not an encapsulated library. Downstream packages (core/agent, core/mcp, go-process) compose with Core's primitives. **Exported fields are intentional, not accidental.** Every unexported field that forces a consumer to write a wrapper method adds LOC downstream — the opposite of Core's purpose. + +```go +// Core reduces downstream code: +if r.OK { use(r.Value) } + +// vs Go convention that adds downstream LOC: +val, err := thing.Get() +if err != nil { + return fmt.Errorf("get: %w", err) +} +``` + +This is why `core.Result` exists — it replaces multiple lines of error handling with `if r.OK {}`. That's the design: expose the primitive, reduce consumer code. + +### Export Rules + +| Should Export | Why | +|--------------|-----| +| Struct fields used by consumers | Removes accessor boilerplate downstream | +| Registry types (`ServiceRegistry`) | Lets consumers extend service management | +| IPC internals (`Ipc` handlers) | Lets consumers build custom dispatch | +| Lifecycle hooks (`OnStart`, `OnStop`) | Composable without interface overhead | + +| Should NOT Export | Why | +|------------------|-----| +| Mutexes and sync primitives | Concurrency must be managed by Core | +| Context/cancel pairs | Lifecycle is Core's responsibility | +| Internal counters | Implementation detail, not a brick | + +### Why core/go Is Minimal + +core/go deliberately avoids importing anything beyond stdlib + go-io + go-log. This keeps it as a near-pure stdlib implementation. Packages that add external dependencies (CLI frameworks, HTTP routers, MCP SDK) live in separate repos: + +``` +core/go — pure primitives (stdlib only) +core/go-process — process management (adds os/exec) +core/mcp — MCP server (adds go-sdk) +core/agent — orchestration (adds forge, yaml, mcp) +``` + +Each layer imports the one below. core/go imports nothing from the ecosystem — everything imports core/go. + + + +## Consumer RFCs + +core/go provides the primitives. These RFCs describe how consumers use them: + +| Package | RFC | Scope | +|---------|-----|-------| +| go-process | `core/go-process/docs/RFC.md` | Action handlers for process.run/start/kill, ManagedProcess, daemon registry | +| core/agent | `core/agent/docs/RFC.md` | Named Actions, completion pipeline (P6-1 fix), WriteAtomic migration, Process migration, Entitlement gating | + +Each consumer RFC is self-contained — an agent can implement it from the document alone. + +--- + +## Versioning + +### Release Model + +The patch count after a release IS the quality metric. v0.8.1 means the spec missed one thing. + +### Cadence + +1. **RFC spec** — design the version in prose +2. **Implement** — build to spec with AX-7 tests +3. **Refine** — review passes catch drift +4. **Tag** — when all sections pass +5. **Measure** — patches tell you what was missed + +## 21. Entitlement — The Permission Primitive + +Core provides the primitive. go-entitlements and commerce-matrix provide implementations. + +### 21.1 The Problem + +`*Core` grants God Mode (P11-1). Every service sees everything. The 14 findings in Root Cause 2 all stem from this. The conclave is trusted — but the SaaS platform (RFC-004), the commerce hierarchy (RFC-005), and the agent sandbox all need boundaries. + +Three systems ask the same question with different vocabulary: + +``` +Can [subject] do [action] with [quantity] in [context]? +``` + +| System | Subject | Action | Quantity | Context | +|--------|---------|--------|----------|---------| +| RFC-004 Entitlements | workspace | feature.code | N | active packages | +| RFC-005 Commerce Matrix | entity (M1/M2/M3) | permission.key | 1 | hierarchy path | +| Core Actions | this Core instance | action.name | 1 | registered services | + +### 21.2 The Primitive + +```go +// Entitlement is the result of a permission check. +// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining). +// Maps directly to RFC-004 EntitlementResult and RFC-005 PermissionResult. +type Entitlement struct { + Allowed bool // permission granted + Unlimited bool // no cap (agency tier, admin, trusted conclave) + Limit int // total allowed (0 = boolean gate, no quantity dimension) + Used int // current consumption + Remaining int // Limit - Used + Reason string // denial reason — for UI feedback and audit logging +} + +// Entitled checks if an action is permitted in the current context. +// Default: always returns Allowed=true, Unlimited=true (trusted conclave). +// With go-entitlements: checks workspace packages, features, usage, boosts. +// With commerce-matrix: checks entity hierarchy, lock cascade. +// +// e := c.Entitled("process.run") // boolean — can this Core run processes? +// e := c.Entitled("social.accounts", 3) // quantity — can workspace create 3 more accounts? +// if e.Allowed { proceed() } +// if e.NearLimit(0.8) { showWarning() } +func (c *Core) Entitled(action string, quantity ...int) Entitlement +``` + +### 21.3 The Checker — Consumer-Provided + +Core defines the interface. Consumer packages provide the implementation. + +```go +// EntitlementChecker answers "can [subject] do [action] with [quantity]?" +// Subject comes from context (workspace, entity, user — consumer's concern). +type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement +``` + +Registration via Core: + +```go +// SetEntitlementChecker replaces the default (permissive) checker. +// Called by go-entitlements or commerce-matrix during OnStartup. +// +// func (s *EntitlementService) OnStartup(ctx context.Context) core.Result { +// s.Core().SetEntitlementChecker(s.check) +// return core.Result{OK: true} +// } +func (c *Core) SetEntitlementChecker(checker EntitlementChecker) +``` + +Default checker (no entitlements package loaded): + +```go +// defaultChecker — trusted conclave, everything permitted +func defaultChecker(action string, quantity int, ctx context.Context) Entitlement { + return Entitlement{Allowed: true, Unlimited: true} +} +``` + +### 21.4 Enforcement Point — Action.Run() + +The entitlement check lives in `Action.Run()`, before execution. One enforcement point for all capabilities. + +```go +func (a *Action) Run(ctx context.Context, opts Options) (result Result) { + if !a.Exists() { return not-registered } + if !a.enabled { return disabled } + + // Entitlement check — permission boundary + if e := a.core.Entitled(a.Name); !e.Allowed { + return Result{E("action.Run", + Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false} + } + + defer func() { /* panic recovery */ }() + return a.Handler(ctx, opts) +} +``` + +Three states for any action: + +| State | Exists() | Entitled() | Run() | +|-------|----------|------------|-------| +| Not registered | false | — | Result{OK: false} "not registered" | +| Registered, not entitled | true | false | Result{OK: false} "not entitled" | +| Registered and entitled | true | true | executes handler | + +### 21.5 How RFC-004 (SaaS Entitlements) Plugs In + +go-entitlements registers as a service and replaces the checker: + +```go +// In go-entitlements: +func (s *Service) OnStartup(ctx context.Context) core.Result { + s.Core().SetEntitlementChecker(func(action string, qty int, ctx context.Context) core.Entitlement { + workspace := s.workspaceFromContext(ctx) + if workspace == nil { + return core.Entitlement{Allowed: true, Unlimited: true} // no workspace = system context + } + + result := s.Can(workspace, action, qty) + + return core.Entitlement{ + Allowed: result.IsAllowed(), + Unlimited: result.IsUnlimited(), + Limit: result.Limit, + Used: result.Used, + Remaining: result.Remaining, + Reason: result.Message(), + } + }) + return core.Result{OK: true} +} +``` + +Maps 1:1 to RFC-004's `EntitlementResult`: +- `$result->isAllowed()` → `e.Allowed` +- `$result->isUnlimited()` → `e.Unlimited` +- `$result->limit` → `e.Limit` +- `$result->used` → `e.Used` +- `$result->remaining` → `e.Remaining` +- `$result->getMessage()` → `e.Reason` +- `$result->isNearLimit()` → `e.NearLimit(0.8)` +- `$result->getUsagePercentage()` → `e.UsagePercent()` + +### 21.6 How RFC-005 (Commerce Matrix) Plugs In + +commerce-matrix registers and replaces the checker with hierarchy-aware logic: + +```go +// In commerce-matrix: +func (s *MatrixService) OnStartup(ctx context.Context) core.Result { + s.Core().SetEntitlementChecker(func(action string, qty int, ctx context.Context) core.Entitlement { + entity := s.entityFromContext(ctx) + if entity == nil { + return core.Entitlement{Allowed: true, Unlimited: true} + } + + result := s.Can(entity, action, "") + + return core.Entitlement{ + Allowed: result.IsAllowed(), + Reason: result.Reason, + } + }) + return core.Result{OK: true} +} +``` + +Maps to RFC-005's cascade model: +- `M1 says NO → everything below is NO` → checker walks hierarchy, returns `{Allowed: false, Reason: "Locked by M1"}` +- Training mode → checker returns `{Allowed: false, Reason: "undefined — training required"}` +- Production strict mode → undefined = denied + +### 21.7 Composing Both Systems + +When a SaaS platform ALSO has commerce hierarchy (Host UK), the checker composes internally: + +```go +func (s *CompositeService) check(action string, qty int, ctx context.Context) core.Entitlement { + // Check commerce matrix first (hard permissions) + matrixResult := s.matrix.Can(entityFromCtx(ctx), action, "") + if matrixResult.IsDenied() { + return core.Entitlement{Allowed: false, Reason: matrixResult.Reason} + } + + // Then check entitlements (usage limits) + entResult := s.entitlements.Can(workspaceFromCtx(ctx), action, qty) + return core.Entitlement{ + Allowed: entResult.IsAllowed(), + Unlimited: entResult.IsUnlimited(), + Limit: entResult.Limit, + Used: entResult.Used, + Remaining: entResult.Remaining, + Reason: entResult.Message(), + } +} +``` + +Matrix (hierarchy) gates first. Entitlements (usage) gate second. One checker, composed. + +### 21.8 Convenience Methods on Entitlement + +```go +// NearLimit returns true if usage exceeds the threshold percentage. +// RFC-004: $result->isNearLimit() uses 80% threshold. +// +// if e.NearLimit(0.8) { showUpgradePrompt() } +func (e Entitlement) NearLimit(threshold float64) bool + +// UsagePercent returns current usage as a percentage of the limit. +// RFC-004: $result->getUsagePercentage() +// +// pct := e.UsagePercent() // 75.0 +func (e Entitlement) UsagePercent() float64 + +// RecordUsage is called after a gated action succeeds. +// Delegates to the entitlement service for usage tracking. +// This is the equivalent of RFC-004's $workspace->recordUsage(). +// +// e := c.Entitled("ai.credits", 10) +// if e.Allowed { +// doWork() +// c.RecordUsage("ai.credits", 10) +// } +func (c *Core) RecordUsage(action string, quantity ...int) +``` + +### 21.9 Audit Trail — RFC-004 Section: Audit Logging + +Every entitlement check can be logged via `core.Security()`: + +```go +func (c *Core) Entitled(action string, quantity ...int) Entitlement { + qty := 1 + if len(quantity) > 0 { + qty = quantity[0] + } + + e := c.entitlementChecker(action, qty, c.Context()) + + // Audit logging for denials (P11-6) + if !e.Allowed { + Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason) + } + + return e +} +``` + +### 21.10 Core Struct Changes + +```go +type Core struct { + // ... existing fields ... + entitlementChecker EntitlementChecker // default: everything permitted +} +``` + +Constructor: + +```go +func New(opts ...CoreOption) *Core { + c := &Core{ + // ... existing ... + entitlementChecker: defaultChecker, + } + // ... +} +``` + +### 21.11 What This Does NOT Do + +- **Does not add database dependencies** — Core is stdlib only. Usage tracking, package management, billing — all in consumer packages. +- **Does not define features** — The feature catalogue (social.accounts, ai.credits, etc.) is defined by the SaaS platform, not Core. +- **Does not manage subscriptions** — Commerce (RFC-005) and billing (Blesta/Stripe) are consumer concerns. +- **Does not replace Action registration** — Registration IS capability. Entitlement IS permission. Both must be true. +- **Does not enforce at Config/Data/Fs level** — v0.8.0 gates Actions. Config/Data/Fs gating requires per-subsystem entitlement checks (same pattern, more integration points). + +### 21.12 The Subsystem Map (Updated) + +``` +c.Registry() — universal named collection +c.Options() — input configuration +c.App() — identity +c.Config() — runtime settings +c.Data() — embedded assets +c.Drive() — connection config (WHERE) +c.API() — remote streams (HOW) [planned] +c.Fs() — filesystem +c.Process() — managed execution (Action sugar) +c.Action() — named callables (register, invoke, inspect) +c.Task() — composed Action sequences +c.IPC() — local message bus +c.Cli() — command tree +c.Log() — logging +c.Error() — panic recovery +c.I18n() — internationalisation +c.Entitled() — permission check (NEW) +c.RecordUsage() — usage tracking (NEW) +``` + +--- + +## Changelog + +- 2026-03-25: v0.8.0 — All 21 sections implemented. 483 tests, 84.7% coverage, 100% AX-7 naming. +- 2026-03-25: Initial specification created from 500k token discovery session. 108 findings, 5 root causes, 13 review passes. Discovery detail preserved in git history. diff --git a/docs/getting-started.md b/docs/getting-started.md index d2d8166..0663905 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,20 +69,15 @@ c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { return core.Result{} }) -c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { - switch task := t.(type) { - case createWorkspaceTask: - path := "/tmp/agent-workbench/" + task.Name - return core.Result{Value: path, OK: true} - } - return core.Result{} +c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result { + name := opts.String("name") + path := "/tmp/agent-workbench/" + name + return core.Result{Value: path, OK: true} }) c.Command("workspace/create", core.Command{ Action: func(opts core.Options) core.Result { - return c.PERFORM(createWorkspaceTask{ - Name: opts.String("name"), - }) + return c.Action("workspace.create").Run(context.Background(), opts) }, }) ``` @@ -170,20 +165,15 @@ func main() { return core.Result{} }) - c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { - switch task := t.(type) { - case createWorkspaceTask: - path := c.Config().String("workspace.root") + "/" + task.Name - return core.Result{Value: path, OK: true} - } - return core.Result{} + c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result { + name := opts.String("name") + path := c.Config().String("workspace.root") + "/" + name + return core.Result{Value: path, OK: true} }) c.Command("workspace/create", core.Command{ Action: func(opts core.Options) core.Result { - return c.PERFORM(createWorkspaceTask{ - Name: opts.String("name"), - }) + return c.Action("workspace.create").Run(context.Background(), opts) }, }) diff --git a/docs/index.md b/docs/index.md index 0ec8647..93e203c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,108 +5,56 @@ description: AX-first documentation for the CoreGO framework. # CoreGO -CoreGO is the foundation layer for the Core ecosystem. It gives you one container, one command tree, one message bus, and a small set of shared primitives that repeat across the whole framework. - -The current module path is `dappco.re/go/core`. - -## AX View - -CoreGO already follows the main AX ideas from RFC-025: - -- predictable names such as `Core`, `Service`, `Command`, `Options`, `Result`, `Message` -- path-shaped command registration such as `deploy/to/homelab` -- one repeated input shape (`Options`) and one repeated return shape (`Result`) -- comments and examples that show real usage instead of restating the type signature - -## What CoreGO Owns - -| Surface | Purpose | -|---------|---------| -| `Core` | Central container and access point | -| `Service` | Managed lifecycle component | -| `Command` | Path-based command tree node | -| `ACTION`, `QUERY`, `PERFORM` | Decoupled communication between components | -| `Data`, `Drive`, `Fs`, `Config`, `I18n`, `Cli` | Built-in subsystems for common runtime work | -| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery | +CoreGO is the foundation layer for the Core ecosystem. Module: `dappco.re/go/core`. + +## What CoreGO Provides + +| Primitive | Purpose | +|-----------|---------| +| `Core` | Central container — everything registers here | +| `Service` | Lifecycle-managed component (Startable/Stoppable return Result) | +| `Action` | Named callable with panic recovery + entitlement | +| `Task` | Composed sequence of Actions | +| `Registry[T]` | Thread-safe named collection (universal brick) | +| `Command` | Path-based CLI command tree | +| `Process` | Managed execution (Action sugar over go-process) | +| `API` | Remote streams (protocol handlers + Drive) | +| `Entitlement` | Permission gate (default permissive, consumer replaces) | +| `ACTION`, `QUERY` | Anonymous broadcast + request/response | +| `Data`, `Drive`, `Fs`, `Config`, `I18n` | Built-in subsystems | ## Quick Example ```go package main -import ( - "context" - "fmt" - - "dappco.re/go/core" -) - -type flushCacheTask struct { - Name string -} +import "dappco.re/go/core" func main() { - c := core.New(core.Options{ - {Key: "name", Value: "agent-workbench"}, - }) - - c.Service("cache", core.Service{ - OnStart: func() core.Result { - core.Info("cache ready", "app", c.App().Name) - return core.Result{OK: true} - }, - OnStop: func() core.Result { - core.Info("cache stopped", "app", c.App().Name) - return core.Result{OK: true} - }, - }) - - c.RegisterTask(func(_ *core.Core, task core.Task) core.Result { - switch task.(type) { - case flushCacheTask: - return core.Result{Value: "cache flushed", OK: true} - } - return core.Result{} - }) - - c.Command("cache/flush", core.Command{ - Action: func(opts core.Options) core.Result { - return c.PERFORM(flushCacheTask{Name: opts.String("name")}) - }, - }) - - if !c.ServiceStartup(context.Background(), nil).OK { - panic("startup failed") - } - - r := c.Cli().Run("cache", "flush", "--name=session-store") - fmt.Println(r.Value) - - _ = c.ServiceShutdown(context.Background()) + c := core.New( + core.WithOption("name", "agent-workbench"), + core.WithService(cache.Register), + core.WithServiceLock(), + ) + c.Run() } ``` -## Documentation Paths +## API Specification -| Path | Covers | -|------|--------| -| [getting-started.md](getting-started.md) | First runnable CoreGO app | -| [primitives.md](primitives.md) | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` | -| [services.md](services.md) | Service registry, service locks, runtime helpers | -| [commands.md](commands.md) | Path-based commands and CLI execution | -| [messaging.md](messaging.md) | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` | -| [lifecycle.md](lifecycle.md) | Startup, shutdown, context, background task draining | -| [configuration.md](configuration.md) | Constructor options, config state, feature flags | -| [subsystems.md](subsystems.md) | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` | -| [errors.md](errors.md) | Structured errors, logging helpers, panic recovery | -| [testing.md](testing.md) | Test naming and framework-level testing patterns | -| [pkg/core.md](pkg/core.md) | Package-level reference summary | -| [pkg/log.md](pkg/log.md) | Logging reference for the root package | -| [pkg/PACKAGE_STANDARDS.md](pkg/PACKAGE_STANDARDS.md) | AX package-authoring guidance | +The full contract is `docs/RFC.md` (21 sections, 1476 lines). An agent should be able to write a service from RFC.md alone. -## Good Reading Order +## Documentation -1. Start with [getting-started.md](getting-started.md). -2. Learn the repeated shapes in [primitives.md](primitives.md). -3. Pick the integration path you need next: [services.md](services.md), [commands.md](commands.md), or [messaging.md](messaging.md). -4. Use [subsystems.md](subsystems.md), [errors.md](errors.md), and [testing.md](testing.md) as reference pages while building. +| Path | Covers | +|------|--------| +| [RFC.md](RFC.md) | Authoritative API contract (21 sections) | +| [primitives.md](primitives.md) | Option, Result, Action, Task, Registry, Entitlement | +| [services.md](services.md) | Service registry, ServiceRuntime, service locks | +| [commands.md](commands.md) | Path-based commands, Managed field | +| [messaging.md](messaging.md) | ACTION, QUERY, named Actions, PerformAsync | +| [lifecycle.md](lifecycle.md) | RunE, ServiceStartup, ServiceShutdown | +| [subsystems.md](subsystems.md) | App, Data, Drive, Fs, Config, I18n | +| [errors.md](errors.md) | core.E(), structured errors, panic recovery | +| [testing.md](testing.md) | AX-7 TestFile_Function_{Good,Bad,Ugly} | +| [configuration.md](configuration.md) | WithOption, WithService, WithServiceLock | diff --git a/docs/messaging.md b/docs/messaging.md index 688893a..785a0c4 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -1,171 +1,127 @@ --- title: Messaging -description: ACTION, QUERY, QUERYALL, PERFORM, and async task flow. +description: ACTION, QUERY, QUERYALL, named Actions, and async dispatch. --- # Messaging -CoreGO uses one message bus for broadcasts, lookups, and work dispatch. +CoreGO has two messaging layers: anonymous broadcast (ACTION/QUERY) and named Actions. -## Message Types +## Anonymous Broadcast -```go -type Message any -type Query any -type Task any -``` +### `ACTION` -Your own structs define the protocol. - -```go -type repositoryIndexed struct { - Name string -} - -type repositoryCountQuery struct{} - -type syncRepositoryTask struct { - Name string -} -``` - -## `ACTION` - -`ACTION` is a broadcast. +Fire-and-forget broadcast to all registered handlers. Each handler is wrapped in panic recovery. Handler return values are ignored — all handlers fire regardless. ```go c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { - switch m := msg.(type) { - case repositoryIndexed: - core.Info("repository indexed", "name", m.Name) - return core.Result{OK: true} - } - return core.Result{OK: true} + if ev, ok := msg.(repositoryIndexed); ok { + core.Info("indexed", "name", ev.Name) + } + return core.Result{OK: true} }) -r := c.ACTION(repositoryIndexed{Name: "core-go"}) +c.ACTION(repositoryIndexed{Name: "core-go"}) ``` -### Behavior - -- all registered action handlers are called in their current registration order -- if a handler returns `OK:false`, dispatch stops and that `Result` is returned -- if no handler fails, `ACTION` returns `Result{OK:true}` - -## `QUERY` +### `QUERY` -`QUERY` is first-match request-response. +First handler to return `OK:true` wins. ```go c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { - switch q.(type) { - case repositoryCountQuery: - return core.Result{Value: 42, OK: true} - } - return core.Result{} + if _, ok := q.(repositoryCountQuery); ok { + return core.Result{Value: 42, OK: true} + } + return core.Result{} }) r := c.QUERY(repositoryCountQuery{}) ``` -### Behavior +### `QUERYALL` -- handlers run until one returns `OK:true` -- the first successful result wins -- if nothing handles the query, CoreGO returns an empty `Result` - -## `QUERYALL` - -`QUERYALL` collects every successful non-nil response. +Collects every successful non-nil response. ```go r := c.QUERYALL(repositoryCountQuery{}) results := r.Value.([]any) ``` -### Behavior +## Named Actions -- every query handler is called -- only `OK:true` results with non-nil `Value` are collected -- the call itself returns `OK:true` even when the result list is empty +Named Actions are the typed, inspectable replacement for anonymous dispatch. See Section 18 of `RFC.md`. -## `PERFORM` - -`PERFORM` dispatches a task to the first handler that accepts it. +### Register and Invoke ```go -c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { - switch task := t.(type) { - case syncRepositoryTask: - return core.Result{Value: "synced " + task.Name, OK: true} - } - return core.Result{} +// Register during OnStartup +c.Action("repo.sync", func(ctx context.Context, opts core.Options) core.Result { + name := opts.String("name") + return core.Result{Value: "synced " + name, OK: true} }) -r := c.PERFORM(syncRepositoryTask{Name: "core-go"}) +// Invoke by name +r := c.Action("repo.sync").Run(ctx, core.NewOptions( + core.Option{Key: "name", Value: "core-go"}, +)) ``` -### Behavior - -- handlers run until one returns `OK:true` -- the first successful result wins -- if nothing handles the task, CoreGO returns an empty `Result` - -## `PerformAsync` - -`PerformAsync` runs a task in a background goroutine and returns a generated task identifier. +### Capability Check ```go -r := c.PerformAsync(syncRepositoryTask{Name: "core-go"}) -taskID := r.Value.(string) +if c.Action("process.run").Exists() { + // go-process is registered +} + +c.Actions() // []string of all registered action names ``` -### Generated Events +### Permission Gate -Async execution emits three action messages: +Every `Action.Run()` checks `c.Entitled(action.Name)` before executing. See Section 21 of `RFC.md`. -| Message | When | -|---------|------| -| `ActionTaskStarted` | just before background execution begins | -| `ActionTaskProgress` | whenever `Progress` is called | -| `ActionTaskCompleted` | after the task finishes or panics | +## Task Composition -Example listener: +A Task is a named sequence of Actions: ```go -c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { - switch m := msg.(type) { - case core.ActionTaskCompleted: - core.Info("task completed", "task", m.TaskIdentifier, "err", m.Error) - } - return core.Result{OK: true} +c.Task("deploy", core.Task{ + Steps: []core.Step{ + {Action: "go.build"}, + {Action: "go.test"}, + {Action: "docker.push"}, + {Action: "notify.slack", Async: true}, + }, }) + +r := c.Task("deploy").Run(ctx, c, opts) ``` -## Progress Updates +Sequential steps stop on first failure. `Async: true` steps fire without blocking. `Input: "previous"` pipes output. + +## Background Execution ```go -c.Progress(taskID, 0.5, "indexing commits", syncRepositoryTask{Name: "core-go"}) -``` +r := c.PerformAsync("repo.sync", opts) +taskID := r.Value.(string) -That broadcasts `ActionTaskProgress`. +c.Progress(taskID, 0.5, "indexing commits", "repo.sync") +``` -## `TaskWithIdentifier` +Broadcasts `ActionTaskStarted`, `ActionTaskProgress`, `ActionTaskCompleted` as ACTION messages. -Tasks that implement `TaskWithIdentifier` receive the generated ID before dispatch. +### Completion Listener ```go -type trackedTask struct { - ID string - Name string -} - -func (t *trackedTask) SetTaskIdentifier(id string) { t.ID = id } -func (t *trackedTask) GetTaskIdentifier() string { return t.ID } +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if ev, ok := msg.(core.ActionTaskCompleted); ok { + core.Info("done", "task", ev.TaskIdentifier, "ok", ev.Result.OK) + } + return core.Result{OK: true} +}) ``` -## Shutdown Interaction - -When shutdown has started, `PerformAsync` returns an empty `Result` instead of scheduling more work. +## Shutdown -This is why `ServiceShutdown` can safely drain the outstanding background tasks before stopping services. +When shutdown has started, `PerformAsync` returns an empty `Result`. `ServiceShutdown` drains outstanding background work before stopping services. diff --git a/docs/primitives.md b/docs/primitives.md index 43701f2..0719311 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -5,165 +5,172 @@ description: The repeated shapes that make CoreGO easy to navigate. # Core Primitives -CoreGO is easiest to use when you read it as a small vocabulary repeated everywhere. Most of the framework is built from the same handful of types. +CoreGO is built from a small vocabulary repeated everywhere. ## Primitive Map | Type | Used For | |------|----------| -| `Options` | Input values and lightweight metadata | +| `Option` / `Options` | Input values and metadata | | `Result` | Output values and success state | | `Service` | Lifecycle-managed components | -| `Message` | Broadcast events | -| `Query` | Request-response lookups | -| `Task` | Side-effecting work items | +| `Action` | Named callable with panic recovery + entitlement | +| `Task` | Composed sequence of Actions | +| `Registry[T]` | Thread-safe named collection | +| `Entitlement` | Permission check result | +| `Message` | Broadcast events (ACTION) | +| `Query` | Request-response lookups (QUERY) | ## `Option` and `Options` `Option` is one key-value pair. `Options` is an ordered slice of them. ```go -opts := core.Options{ - {Key: "name", Value: "brain"}, - {Key: "path", Value: "prompts"}, - {Key: "debug", Value: true}, -} -``` - -Use the helpers to read values: +opts := core.NewOptions( + core.Option{Key: "name", Value: "brain"}, + core.Option{Key: "path", Value: "prompts"}, + core.Option{Key: "debug", Value: true}, +) -```go name := opts.String("name") -path := opts.String("path") debug := opts.Bool("debug") -hasPath := opts.Has("path") -raw := opts.Get("name") +raw := opts.Get("name") // Result{Value, OK} +opts.Has("path") // true +opts.Len() // 3 ``` -### Important Details - -- `Get` returns the first matching key. -- `String`, `Int`, and `Bool` do not convert between types. -- Missing keys return zero values. -- CLI flags with values are stored as strings, so `--port=8080` should be read with `opts.String("port")`, not `opts.Int("port")`. - ## `Result` -`Result` is the universal return shape. +Universal return shape. Every Core operation returns Result. ```go -r := core.Result{Value: "ready", OK: true} +type Result struct { + Value any + OK bool +} +r := c.Config().Get("host") if r.OK { - fmt.Println(r.Value) + host := r.Value.(string) } ``` -It has two jobs: - -- carry a value when work succeeds -- carry either an error or an empty state when work does not succeed - -### `Result.Result(...)` - -The `Result()` method adapts plain Go values and `(value, error)` pairs into a `core.Result`. +The `Result()` method adapts Go `(value, error)` pairs: ```go -r1 := core.Result{}.Result("hello") -r2 := core.Result{}.Result(file, err) +r := core.Result{}.Result(file, err) ``` -This is how several built-in helpers bridge standard-library calls. - ## `Service` -`Service` is the managed lifecycle DTO stored in the registry. +Managed lifecycle component stored in the `ServiceRegistry`. ```go -svc := core.Service{ - Name: "cache", - Options: core.Options{ - {Key: "backend", Value: "memory"}, - }, - OnStart: func() core.Result { - return core.Result{OK: true} - }, - OnStop: func() core.Result { - return core.Result{OK: true} - }, - OnReload: func() core.Result { - return core.Result{OK: true} - }, +core.Service{ + OnStart: func() core.Result { return core.Result{OK: true} }, + OnStop: func() core.Result { return core.Result{OK: true} }, } ``` -### Important Details +Or via `Startable`/`Stoppable` interfaces (preferred for named services): -- `OnStart` and `OnStop` are used by the framework lifecycle. -- `OnReload` is stored on the service DTO, but CoreGO does not currently call it automatically. -- The registry stores `*core.Service`, not arbitrary typed service instances. +```go +type Startable interface { OnStartup(ctx context.Context) Result } +type Stoppable interface { OnShutdown(ctx context.Context) Result } +``` -## `Message`, `Query`, and `Task` +## `Action` -These are simple aliases to `any`. +Named callable — the atomic unit of work. Registered by name, invoked by name. ```go -type Message any -type Query any -type Task any +type ActionHandler func(context.Context, Options) Result + +type Action struct { + Name string + Handler ActionHandler + Description string + Schema Options +} ``` -That means your own structs become the protocol: +`Action.Run()` includes panic recovery and entitlement checking. + +## `Task` + +Composed sequence of Actions: ```go -type deployStarted struct { - Environment string +type Task struct { + Name string + Description string + Steps []Step } -type workspaceCountQuery struct{} - -type syncRepositoryTask struct { - Name string +type Step struct { + Action string + With Options + Async bool + Input string // "previous" = output of last step } ``` -## `TaskWithIdentifier` +## `Registry[T]` -Long-running tasks can opt into task identifiers. +Thread-safe named collection with insertion order and 3 lock modes: ```go -type indexedTask struct { - ID string -} - -func (t *indexedTask) SetTaskIdentifier(id string) { t.ID = id } -func (t *indexedTask) GetTaskIdentifier() string { return t.ID } +r := core.NewRegistry[*MyService]() +r.Set("brain", svc) +r.Get("brain") // Result +r.Has("brain") // bool +r.Names() // []string (insertion order) +r.Each(func(name string, svc *MyService) { ... }) +r.Lock() // fully frozen +r.Seal() // no new keys, updates OK ``` -If a task implements `TaskWithIdentifier`, `PerformAsync` injects the generated `task-N` identifier before dispatch. - -## `ServiceRuntime[T]` +## `Entitlement` -`ServiceRuntime[T]` is the small helper for packages that want to keep a Core reference and a typed options struct together. +Permission check result: ```go -type agentServiceOptions struct { - WorkspacePath string +type Entitlement struct { + Allowed bool + Unlimited bool + Limit int + Used int + Remaining int + Reason string } -type agentService struct { - *core.ServiceRuntime[agentServiceOptions] -} +e := c.Entitled("social.accounts", 3) +e.NearLimit(0.8) // true if > 80% used +e.UsagePercent() // 75.0 +``` + +## `Message` and `Query` -runtime := core.NewServiceRuntime(c, agentServiceOptions{ - WorkspacePath: "/srv/agent-workspaces", -}) +IPC type aliases for the anonymous broadcast system: + +```go +type Message any // broadcast via ACTION +type Query any // request/response via QUERY ``` -It exposes: +For typed, named dispatch use `c.Action("name").Run(ctx, opts)`. + +## `ServiceRuntime[T]` + +Composition helper for services that need Core access and typed options: -- `Core()` -- `Options()` -- `Config()` +```go +type MyService struct { + *core.ServiceRuntime[MyOptions] +} -This helper does not register anything by itself. It is a composition aid for package authors. +runtime := core.NewServiceRuntime(c, MyOptions{BufferSize: 1024}) +runtime.Core() // *Core +runtime.Options() // MyOptions +runtime.Config() // shortcut to Core().Config() +``` diff --git a/docs/testing.md b/docs/testing.md index 656634a..3e0c7d4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -78,14 +78,12 @@ assert.Equal(t, "pong", c.QUERY("ping").Value) ``` ```go -c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { - if t == "compute" { - return core.Result{Value: 42, OK: true} - } - return core.Result{} +c.Action("compute", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} }) -assert.Equal(t, 42, c.PERFORM("compute").Value) +r := c.Action("compute").Run(context.Background(), core.NewOptions()) +assert.Equal(t, 42, r.Value) ``` ## Test Async Work diff --git a/drive.go b/drive.go index 2d9f7e6..7bf6869 100644 --- a/drive.go +++ b/drive.go @@ -24,10 +24,6 @@ // api := c.Drive().Get("api") package core -import ( - "sync" -) - // DriveHandle holds a named transport resource. type DriveHandle struct { Name string @@ -35,10 +31,9 @@ type DriveHandle struct { Options Options } -// Drive manages named transport handles. +// Drive manages named transport handles. Embeds Registry[*DriveHandle]. type Drive struct { - handles map[string]*DriveHandle - mu sync.RWMutex + *Registry[*DriveHandle] } // New registers a transport handle. @@ -53,58 +48,12 @@ func (d *Drive) New(opts Options) Result { return Result{} } - transport := opts.String("transport") - - d.mu.Lock() - defer d.mu.Unlock() - - if d.handles == nil { - d.handles = make(map[string]*DriveHandle) - } - handle := &DriveHandle{ Name: name, - Transport: transport, + Transport: opts.String("transport"), Options: opts, } - d.handles[name] = handle + d.Set(name, handle) return Result{handle, true} } - -// Get returns a handle by name. -// -// r := c.Drive().Get("api") -// if r.OK { handle := r.Value.(*DriveHandle) } -func (d *Drive) Get(name string) Result { - d.mu.RLock() - defer d.mu.RUnlock() - if d.handles == nil { - return Result{} - } - h, ok := d.handles[name] - if !ok { - return Result{} - } - return Result{h, true} -} - -// Has returns true if a handle is registered. -// -// if c.Drive().Has("ssh") { ... } -func (d *Drive) Has(name string) bool { - return d.Get(name).OK -} - -// Names returns all registered handle names. -// -// names := c.Drive().Names() -func (d *Drive) Names() []string { - d.mu.RLock() - defer d.mu.RUnlock() - var names []string - for k := range d.handles { - names = append(names, k) - } - return names -} diff --git a/drive_example_test.go b/drive_example_test.go new file mode 100644 index 0000000..66340b1 --- /dev/null +++ b/drive_example_test.go @@ -0,0 +1,35 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleDrive_New() { + c := New() + c.Drive().New(NewOptions( + Option{Key: "name", Value: "forge"}, + Option{Key: "transport", Value: "https://forge.lthn.ai"}, + )) + + Println(c.Drive().Has("forge")) + Println(c.Drive().Names()) + // Output: + // true + // [forge] +} + +func ExampleDrive_Get() { + c := New() + c.Drive().New(NewOptions( + Option{Key: "name", Value: "charon"}, + Option{Key: "transport", Value: "http://10.69.69.165:9101"}, + )) + + r := c.Drive().Get("charon") + if r.OK { + h := r.Value.(*DriveHandle) + Println(h.Transport) + } + // Output: http://10.69.69.165:9101 +} diff --git a/embed_test.go b/embed_test.go index e666a6e..1ab5790 100644 --- a/embed_test.go +++ b/embed_test.go @@ -4,7 +4,6 @@ import ( "bytes" "compress/gzip" "encoding/base64" - "os" "testing" . "dappco.re/go/core" @@ -21,12 +20,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,45 +87,45 @@ 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) - content, err := os.ReadFile(dir + "/testdata/test.txt") - assert.NoError(t, err) - assert.Equal(t, "hello from testdata\n", string(content)) + cr := (&Fs{}).New("/").Read(Path(dir, "testdata/test.txt")) + assert.True(t, cr.OK) + assert.Equal(t, "hello from testdata\n", cr.Value) } // --- 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,27 +133,27 @@ 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) - os.WriteFile(assetDir+"/hello.txt", []byte("hello world"), 0644) + assetDir := Path(dir, "mygroup") + (&Fs{}).New("/").EnsureDir(assetDir) + (&Fs{}).New("/").Write(Path(assetDir, "hello.txt"), "hello world") source := "package test\nimport \"dappco.re/go/core\"\nfunc example() {\n\t_, _ = core.GetAsset(\"mygroup\", \"hello.txt\")\n}\n" - goFile := dir + "/test.go" - os.WriteFile(goFile, []byte(source), 0644) + goFile := Path(dir, "test.go") + (&Fs{}).New("/").Write(goFile, source) sr := ScanAssets([]string{goFile}) assert.True(t, sr.OK) @@ -167,46 +166,48 @@ 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 - tmplDir := os.DirFS(t.TempDir()) + tmplDir := DirFS(t.TempDir()) // Use a real temp dir with files srcDir := t.TempDir() - os.WriteFile(srcDir+"/plain.txt", []byte("static content"), 0644) - os.WriteFile(srcDir+"/greeting.tmpl", []byte("Hello {{.Name}}!"), 0644) - os.MkdirAll(srcDir+"/sub", 0755) - os.WriteFile(srcDir+"/sub/nested.txt", []byte("nested"), 0644) + (&Fs{}).New("/").Write(Path(srcDir, "plain.txt"), "static content") + (&Fs{}).New("/").Write(Path(srcDir, "greeting.tmpl"), "Hello {{.Name}}!") + (&Fs{}).New("/").EnsureDir(Path(srcDir, "sub")) + (&Fs{}).New("/").Write(Path(srcDir, "sub/nested.txt"), "nested") _ = tmplDir - fsys := os.DirFS(srcDir) + fsys := DirFS(srcDir) data := map[string]string{"Name": "World"} r := Extract(fsys, dir, data) assert.True(t, r.OK) + f := (&Fs{}).New("/") + // Plain file copied - content, err := os.ReadFile(dir + "/plain.txt") - assert.NoError(t, err) - assert.Equal(t, "static content", string(content)) + cr := f.Read(Path(dir, "plain.txt")) + assert.True(t, cr.OK) + assert.Equal(t, "static content", cr.Value) // Template processed and .tmpl stripped - greeting, err := os.ReadFile(dir + "/greeting") - assert.NoError(t, err) - assert.Equal(t, "Hello World!", string(greeting)) + gr := f.Read(Path(dir, "greeting")) + assert.True(t, gr.OK) + assert.Equal(t, "Hello World!", gr.Value) // Nested directory preserved - nested, err := os.ReadFile(dir + "/sub/nested.txt") - assert.NoError(t, err) - assert.Equal(t, "nested", string(nested)) + nr := f.Read(Path(dir, "sub/nested.txt")) + assert.True(t, nr.OK) + assert.Equal(t, "nested", nr.Value) } -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) + (&Fs{}).New("/").Write(Path(srcDir, "f.txt"), "x") + r := Extract(DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil) // Should fail gracefully, not panic _ = r } @@ -244,12 +245,12 @@ 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) + (&Fs{}).New("/").Write(Path(srcDir, "file.txt"), "no template") - r := Extract(os.DirFS(srcDir), dir, nil) + r := Extract(DirFS(srcDir), dir, nil) assert.True(t, r.OK) } diff --git a/entitlement.go b/entitlement.go new file mode 100644 index 0000000..8607f14 --- /dev/null +++ b/entitlement.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Permission primitive for the Core framework. +// Entitlement answers "can [subject] do [action] with [quantity]?" +// Default: everything permitted (trusted conclave). +// With go-entitlements: checks workspace packages, features, usage, boosts. +// With commerce-matrix: checks entity hierarchy, lock cascade. +// +// Usage: +// +// e := c.Entitled("process.run") // boolean gate +// e := c.Entitled("social.accounts", 3) // quantity check +// if e.Allowed { proceed() } +// if e.NearLimit(0.8) { showUpgradePrompt() } +// +// Registration: +// +// c.SetEntitlementChecker(myChecker) +// c.SetUsageRecorder(myRecorder) +package core + +import "context" + +// Entitlement is the result of a permission check. +// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining). +// +// e := c.Entitled("social.accounts", 3) +// e.Allowed // true +// e.Limit // 5 +// e.Used // 2 +// e.Remaining // 3 +// e.NearLimit(0.8) // false +type Entitlement struct { + Allowed bool // permission granted + Unlimited bool // no cap (agency tier, admin, trusted conclave) + Limit int // total allowed (0 = boolean gate) + Used int // current consumption + Remaining int // Limit - Used + Reason string // denial reason — for UI and audit logging +} + +// NearLimit returns true if usage exceeds the threshold percentage. +// +// if e.NearLimit(0.8) { showUpgradePrompt() } +func (e Entitlement) NearLimit(threshold float64) bool { + if e.Unlimited || e.Limit == 0 { + return false + } + return float64(e.Used)/float64(e.Limit) >= threshold +} + +// UsagePercent returns current usage as a percentage of the limit. +// +// pct := e.UsagePercent() // 75.0 +func (e Entitlement) UsagePercent() float64 { + if e.Limit == 0 { + return 0 + } + return float64(e.Used) / float64(e.Limit) * 100 +} + +// EntitlementChecker answers "can [subject] do [action] with [quantity]?" +// Subject comes from context (workspace, entity, user — consumer's concern). +type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement + +// UsageRecorder records consumption after a gated action succeeds. +// Consumer packages provide the implementation (database, cache, etc). +type UsageRecorder func(action string, quantity int, ctx context.Context) + +// defaultChecker — trusted conclave, everything permitted. +func defaultChecker(_ string, _ int, _ context.Context) Entitlement { + return Entitlement{Allowed: true, Unlimited: true} +} + +// Entitled checks if an action is permitted in the current context. +// Default: always returns Allowed=true, Unlimited=true. +// Denials are logged via core.Security(). +// +// e := c.Entitled("process.run") +// e := c.Entitled("social.accounts", 3) +func (c *Core) Entitled(action string, quantity ...int) Entitlement { + qty := 1 + if len(quantity) > 0 { + qty = quantity[0] + } + + e := c.entitlementChecker(action, qty, c.Context()) + + if !e.Allowed { + Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason) + } + + return e +} + +// SetEntitlementChecker replaces the default (permissive) checker. +// Called by go-entitlements or commerce-matrix during OnStartup. +// +// func (s *EntitlementService) OnStartup(ctx context.Context) core.Result { +// s.Core().SetEntitlementChecker(s.check) +// return core.Result{OK: true} +// } +func (c *Core) SetEntitlementChecker(checker EntitlementChecker) { + c.entitlementChecker = checker +} + +// RecordUsage records consumption after a gated action succeeds. +// Delegates to the registered UsageRecorder. No-op if none registered. +// +// e := c.Entitled("ai.credits", 10) +// if e.Allowed { +// doWork() +// c.RecordUsage("ai.credits", 10) +// } +func (c *Core) RecordUsage(action string, quantity ...int) { + if c.usageRecorder == nil { + return + } + qty := 1 + if len(quantity) > 0 { + qty = quantity[0] + } + c.usageRecorder(action, qty, c.Context()) +} + +// SetUsageRecorder registers a usage tracking function. +// Called by go-entitlements during OnStartup. +func (c *Core) SetUsageRecorder(recorder UsageRecorder) { + c.usageRecorder = recorder +} diff --git a/entitlement_example_test.go b/entitlement_example_test.go new file mode 100644 index 0000000..b3a528a --- /dev/null +++ b/entitlement_example_test.go @@ -0,0 +1,52 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +func ExampleEntitlement_UsagePercent() { + e := Entitlement{Limit: 100, Used: 75} + Println(e.UsagePercent()) + // Output: 75 +} + +func ExampleCore_SetEntitlementChecker() { + c := New() + c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement { + limits := map[string]int{"social.accounts": 5, "ai.credits": 100} + usage := map[string]int{"social.accounts": 3, "ai.credits": 95} + + limit, ok := limits[action] + if !ok { + return Entitlement{Allowed: false, Reason: "not in package"} + } + used := usage[action] + remaining := limit - used + if qty > remaining { + return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"} + } + return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining} + }) + + Println(c.Entitled("social.accounts", 2).Allowed) + Println(c.Entitled("social.accounts", 5).Allowed) + Println(c.Entitled("ai.credits").NearLimit(0.9)) + // Output: + // true + // false + // true +} + +func ExampleCore_RecordUsage() { + c := New() + var recorded string + c.SetUsageRecorder(func(action string, qty int, _ context.Context) { + recorded = Concat(action, ":", Sprint(qty)) + }) + + c.RecordUsage("ai.credits", 10) + Println(recorded) + // Output: ai.credits:10 +} diff --git a/entitlement_test.go b/entitlement_test.go new file mode 100644 index 0000000..b7acf69 --- /dev/null +++ b/entitlement_test.go @@ -0,0 +1,235 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Entitled --- + +func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) { + c := New() + e := c.Entitled("anything") + assert.True(t, e.Allowed, "default checker permits everything") + assert.True(t, e.Unlimited) +} + +func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "premium.feature" { + return Entitlement{Allowed: true} + } + return Entitlement{Allowed: false, Reason: "not in package"} + }) + + assert.True(t, c.Entitled("premium.feature").Allowed) + assert.False(t, c.Entitled("other.feature").Allowed) + assert.Equal(t, "not in package", c.Entitled("other.feature").Reason) +} + +func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "social.accounts" { + limit := 5 + used := 3 + remaining := limit - used + if qty > remaining { + return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"} + } + return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + // Can create 2 more (3 used of 5) + e := c.Entitled("social.accounts", 2) + assert.True(t, e.Allowed) + assert.Equal(t, 5, e.Limit) + assert.Equal(t, 3, e.Used) + assert.Equal(t, 2, e.Remaining) + + // Can't create 3 more + e = c.Entitled("social.accounts", 3) + assert.False(t, e.Allowed) + assert.Equal(t, "limit exceeded", e.Reason) +} + +func TestEntitlement_Entitled_Bad_Denied(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + return Entitlement{Allowed: false, Reason: "locked by M1"} + }) + + e := c.Entitled("product.create") + assert.False(t, e.Allowed) + assert.Equal(t, "locked by M1", e.Reason) +} + +func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) { + c := New() + var receivedQty int + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + receivedQty = qty + return Entitlement{Allowed: true} + }) + + c.Entitled("test") + assert.Equal(t, 1, receivedQty, "default quantity should be 1") +} + +// --- Action.Run Entitlement Enforcement --- + +func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) { + c := New() + c.Action("work", func(_ context.Context, _ Options) Result { + return Result{Value: "done", OK: true} + }) + + r := c.Action("work").Run(context.Background(), NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, "done", r.Value) +} + +func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) { + c := New() + c.Action("restricted", func(_ context.Context, _ Options) Result { + return Result{Value: "should not reach", OK: true} + }) + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "restricted" { + return Entitlement{Allowed: false, Reason: "tier too low"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + r := c.Action("restricted").Run(context.Background(), NewOptions()) + assert.False(t, r.OK, "denied action must not execute") + err, ok := r.Value.(error) + assert.True(t, ok) + assert.Contains(t, err.Error(), "not entitled") + assert.Contains(t, err.Error(), "tier too low") +} + +func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) { + c := New() + c.Action("allowed", func(_ context.Context, _ Options) Result { + return Result{Value: "ok", OK: true} + }) + c.Action("blocked", func(_ context.Context, _ Options) Result { + return Result{Value: "nope", OK: true} + }) + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "blocked" { + return Entitlement{Allowed: false, Reason: "nope"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK) + assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK) +} + +// --- NearLimit --- + +func TestEntitlement_NearLimit_Good(t *testing.T) { + e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15} + assert.True(t, e.NearLimit(0.8)) + assert.False(t, e.NearLimit(0.9)) +} + +func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) { + e := Entitlement{Allowed: true, Unlimited: true} + assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit") +} + +func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) { + e := Entitlement{Allowed: true, Limit: 0} + assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit") +} + +// --- UsagePercent --- + +func TestEntitlement_UsagePercent_Good(t *testing.T) { + e := Entitlement{Limit: 100, Used: 75} + assert.Equal(t, 75.0, e.UsagePercent()) +} + +func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) { + e := Entitlement{Limit: 0, Used: 5} + assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage") +} + +// --- RecordUsage --- + +func TestEntitlement_RecordUsage_Good(t *testing.T) { + c := New() + var recorded string + var recordedQty int + + c.SetUsageRecorder(func(action string, qty int, ctx context.Context) { + recorded = action + recordedQty = qty + }) + + c.RecordUsage("ai.credits", 10) + assert.Equal(t, "ai.credits", recorded) + assert.Equal(t, 10, recordedQty) +} + +func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) { + c := New() + // No recorder set — should not panic + assert.NotPanics(t, func() { + c.RecordUsage("anything", 5) + }) +} + +// --- Permission Model Integration --- + +func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) { + c := New() + + // Simulate RFC-004 entitlement service + packages := map[string]int{ + "social.accounts": 5, + "social.posts.scheduled": 100, + "ai.credits": 50, + } + usage := map[string]int{ + "social.accounts": 3, + "social.posts.scheduled": 45, + "ai.credits": 48, + } + + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + limit, hasFeature := packages[action] + if !hasFeature { + return Entitlement{Allowed: false, Reason: "feature not in package"} + } + used := usage[action] + remaining := limit - used + if qty > remaining { + return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"} + } + return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining} + }) + + // Can create 2 social accounts + e := c.Entitled("social.accounts", 2) + assert.True(t, e.Allowed) + + // AI credits near limit + e = c.Entitled("ai.credits", 1) + assert.True(t, e.Allowed) + assert.True(t, e.NearLimit(0.8)) + assert.Equal(t, 96.0, e.UsagePercent()) + + // Feature not in package + e = c.Entitled("premium.feature") + assert.False(t, e.Allowed) +} diff --git a/error_example_test.go b/error_example_test.go new file mode 100644 index 0000000..411455a --- /dev/null +++ b/error_example_test.go @@ -0,0 +1,33 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleE() { + err := E("cache.Get", "key not found", nil) + Println(Operation(err)) + Println(ErrorMessage(err)) + // Output: + // cache.Get + // key not found +} + +func ExampleWrap() { + cause := NewError("connection refused") + err := Wrap(cause, "database.Connect", "failed to reach host") + Println(Operation(err)) + Println(Is(err, cause)) + // Output: + // database.Connect + // true +} + +func ExampleRoot() { + cause := NewError("original") + wrapped := Wrap(cause, "op1", "first wrap") + double := Wrap(wrapped, "op2", "second wrap") + Println(Root(double)) + // Output: original +} diff --git a/error_test.go b/error_test.go index 7213486..fe84a8d 100644 --- a/error_test.go +++ b/error_test.go @@ -1,7 +1,6 @@ package core_test import ( - "errors" "testing" . "dappco.re/go/core" @@ -10,39 +9,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) { - cause := errors.New("connection refused") +func TestError_E_WithCause_Good(t *testing.T) { + cause := NewError("connection refused") err := E("db.Connect", "database unavailable", cause) assert.ErrorIs(t, err, cause) } -func TestWrap_Good(t *testing.T) { - cause := errors.New("timeout") +func TestError_Wrap_Good(t *testing.T) { + cause := NewError("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) { - cause := errors.New("invalid email") +func TestError_WrapCode_Good(t *testing.T) { + cause := NewError("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 +49,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) { - err := errors.New("plain error") +func TestError_Operation_Bad(t *testing.T) { + err := NewError("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) { - err := errors.New("plain") +func TestError_ErrorMessage_Plain(t *testing.T) { + err := NewError("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) { - root := errors.New("root cause") +func TestError_Root_Good(t *testing.T) { + root := NewError("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 +92,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,35 +100,35 @@ 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") + cause := NewError("boom") r := c.Log().Error(cause, "test.Operation", "something broke") assert.False(t, r.OK) 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") + cause := NewError("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") + c.Log().Must(NewError("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 +137,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 +146,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 +155,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,27 +168,27 @@ func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) { // --- Standard Library Wrappers --- -func TestIs_Good(t *testing.T) { - target := errors.New("target") +func TestError_Is_Good(t *testing.T) { + target := NewError("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) { - e1 := errors.New("first") - e2 := errors.New("second") +func TestError_ErrorJoin_Good(t *testing.T) { + e1 := NewError("first") + e2 := NewError("second") joined := ErrorJoin(e1, e2) assert.ErrorIs(t, joined, e1) assert.ErrorIs(t, joined, e2) @@ -197,9 +196,9 @@ 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" + path := Path(dir, "crashes.json") // Create ErrorPanic with file output c := New() @@ -212,9 +211,9 @@ 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" + path := Path(dir, "crashes.json") // Create Core, trigger a panic through SafeGo, check crash file // ErrorPanic.filePath is unexported — but we can test via the package-level @@ -230,42 +229,42 @@ func TestErrorPanic_CrashFile_Good(t *testing.T) { // --- Error formatting branches --- -func TestErr_Error_WithCode_Good(t *testing.T) { - err := WrapCode(errors.New("bad"), "INVALID", "validate", "input failed") +func TestError_Err_Error_WithCode_Good(t *testing.T) { + err := WrapCode(NewError("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) { - inner := WrapCode(errors.New("root"), "AUTH_FAIL", "auth", "denied") +func TestError_Wrap_PreservesCode_Good(t *testing.T) { + inner := WrapCode(NewError("root"), "AUTH_FAIL", "auth", "denied") outer := Wrap(inner, "handler", "request failed") assert.Equal(t, "AUTH_FAIL", ErrorCode(outer)) } -func TestErrorLog_Warn_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Warn_Nil_Good(t *testing.T) { c := New() r := c.LogWarn(nil, "op", "msg") assert.True(t, r.OK) } -func TestErrorLog_Error_Nil_Good(t *testing.T) { +func TestError_ErrorLog_Error_Nil_Good(t *testing.T) { c := New() r := c.LogError(nil, "op", "msg") assert.True(t, r.OK) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..0472f45 --- /dev/null +++ b/example_test.go @@ -0,0 +1,314 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +// --- Core Creation --- + +func ExampleNew() { + c := New( + WithOption("name", "my-app"), + WithServiceLock(), + ) + Println(c.App().Name) + // Output: my-app +} + +func ExampleNew_withService() { + c := New( + WithOption("name", "example"), + WithService(func(c *Core) Result { + return c.Service("greeter", Service{ + OnStart: func() Result { + Info("greeter started", "app", c.App().Name) + return Result{OK: true} + }, + }) + }), + ) + c.ServiceStartup(context.Background(), nil) + Println(c.Services()) + c.ServiceShutdown(context.Background()) + // Output is non-deterministic (map order), so no Output comment +} + +// --- Options --- + +func ExampleNewOptions() { + opts := NewOptions( + Option{Key: "name", Value: "brain"}, + Option{Key: "port", Value: 8080}, + Option{Key: "debug", Value: true}, + ) + Println(opts.String("name")) + Println(opts.Int("port")) + Println(opts.Bool("debug")) + // Output: + // brain + // 8080 + // true +} + +// --- Result --- + +func ExampleResult() { + r := Result{Value: "hello", OK: true} + if r.OK { + Println(r.Value) + } + // Output: hello +} + +// --- Action --- + +func ExampleCore_Action_register() { + c := New() + c.Action("greet", func(_ context.Context, opts Options) Result { + name := opts.String("name") + return Result{Value: Concat("hello ", name), OK: true} + }) + Println(c.Action("greet").Exists()) + // Output: true +} + +func ExampleCore_Action_invoke() { + c := New() + c.Action("add", func(_ context.Context, opts Options) Result { + a := opts.Int("a") + b := opts.Int("b") + return Result{Value: a + b, OK: true} + }) + + r := c.Action("add").Run(context.Background(), NewOptions( + Option{Key: "a", Value: 3}, + Option{Key: "b", Value: 4}, + )) + Println(r.Value) + // Output: 7 +} + +func ExampleCore_Actions() { + 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} }) + + Println(c.Actions()) + // Output: [process.run brain.recall] +} + +// --- Task --- + +func ExampleCore_Task() { + c := New() + order := "" + + c.Action("step.a", func(_ context.Context, _ Options) Result { + order += "a" + return Result{Value: "from-a", OK: true} + }) + c.Action("step.b", func(_ context.Context, opts Options) Result { + order += "b" + return Result{OK: true} + }) + + c.Task("pipeline", Task{ + Steps: []Step{ + {Action: "step.a"}, + {Action: "step.b", Input: "previous"}, + }, + }) + + c.Task("pipeline").Run(context.Background(), c, NewOptions()) + Println(order) + // Output: ab +} + +// --- Registry --- + +func ExampleNewRegistry() { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Set("bravo", "second") + + Println(r.Has("alpha")) + Println(r.Names()) + Println(r.Len()) + // Output: + // true + // [alpha bravo] + // 2 +} + +func ExampleRegistry_Lock() { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Lock() + + result := r.Set("beta", "second") + Println(result.OK) + // Output: false +} + +func ExampleRegistry_Seal() { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + + // Can update existing + Println(r.Set("alpha", "updated").OK) + // Can't add new + Println(r.Set("beta", "new").OK) + // Output: + // true + // false +} + +// --- Entitlement --- + +func ExampleCore_Entitled_default() { + c := New() + e := c.Entitled("anything") + Println(e.Allowed) + Println(e.Unlimited) + // Output: + // true + // true +} + +func ExampleCore_Entitled_custom() { + c := New() + c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement { + if action == "premium" { + return Entitlement{Allowed: false, Reason: "upgrade required"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + Println(c.Entitled("basic").Allowed) + Println(c.Entitled("premium").Allowed) + Println(c.Entitled("premium").Reason) + // Output: + // true + // false + // upgrade required +} + +func ExampleEntitlement_NearLimit() { + e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15} + Println(e.NearLimit(0.8)) + Println(e.UsagePercent()) + // Output: + // true + // 85 +} + +// --- Process --- + +func ExampleCore_Process() { + c := New() + // No go-process registered — permission by registration + Println(c.Process().Exists()) + + // Register a mock process handler + c.Action("process.run", func(_ context.Context, opts Options) Result { + return Result{Value: Concat("output of ", opts.String("command")), OK: true} + }) + Println(c.Process().Exists()) + + r := c.Process().Run(context.Background(), "echo", "hello") + Println(r.Value) + // Output: + // false + // true + // output of echo +} + +// --- JSON --- + +func ExampleJSONMarshal() { + type config struct { + Host string `json:"host"` + Port int `json:"port"` + } + r := JSONMarshal(config{Host: "localhost", Port: 8080}) + Println(string(r.Value.([]byte))) + // Output: {"host":"localhost","port":8080} +} + +func ExampleJSONUnmarshalString() { + type config struct { + Host string `json:"host"` + Port int `json:"port"` + } + var cfg config + JSONUnmarshalString(`{"host":"localhost","port":8080}`, &cfg) + Println(cfg.Host, cfg.Port) + // Output: localhost 8080 +} + +// --- Utilities --- + +func ExampleID() { + id := ID() + Println(HasPrefix(id, "id-")) + // Output: true +} + +func ExampleValidateName() { + Println(ValidateName("brain").OK) + Println(ValidateName("").OK) + Println(ValidateName("..").OK) + Println(ValidateName("path/traversal").OK) + // Output: + // true + // false + // false + // false +} + +func ExampleSanitisePath() { + Println(SanitisePath("../../etc/passwd")) + Println(SanitisePath("")) + Println(SanitisePath("/some/path/file.txt")) + // Output: + // passwd + // invalid + // file.txt +} + +// --- Command --- + +func ExampleCore_Command() { + c := New() + c.Command("deploy/to/homelab", Command{ + Action: func(opts Options) Result { + return Result{Value: Concat("deployed to ", opts.String("_arg")), OK: true} + }, + }) + + r := c.Cli().Run("deploy", "to", "homelab") + Println(r.OK) + // Output: true +} + +// --- Config --- + +func ExampleConfig() { + c := New() + c.Config().Set("database.host", "localhost") + c.Config().Set("database.port", 5432) + c.Config().Enable("dark-mode") + + Println(c.Config().String("database.host")) + Println(c.Config().Int("database.port")) + Println(c.Config().Enabled("dark-mode")) + // Output: + // localhost + // 5432 + // true +} + +// Error examples in error_example_test.go diff --git a/fs.go b/fs.go index c528308..a18a3d2 100644 --- a/fs.go +++ b/fs.go @@ -2,6 +2,8 @@ package core import ( + "io" + "io/fs" "os" "os/user" "path/filepath" @@ -25,6 +27,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 +169,52 @@ func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result { return Result{OK: true} } +// TempDir creates a temporary directory and returns its path. +// The caller is responsible for cleanup via fs.DeleteAll(). +// +// dir := fs.TempDir("agent-workspace") +// defer fs.DeleteAll(dir) +func (m *Fs) TempDir(prefix string) string { + dir, err := os.MkdirTemp("", prefix) + if err != nil { + return "" + } + return dir +} + +// DirFS returns an fs.FS rooted at the given directory path. +// +// fsys := core.DirFS("/path/to/templates") +func DirFS(dir string) fs.FS { + return os.DirFS(dir) +} + +// 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) @@ -259,6 +326,54 @@ func (m *Fs) WriteStream(path string) Result { return m.Create(path) } +// ReadAll reads all bytes from a ReadCloser and closes it. +// Wraps io.ReadAll so consumers don't import "io". +// +// r := fs.ReadStream(path) +// data := core.ReadAll(r.Value) +func ReadAll(reader any) Result { + rc, ok := reader.(io.Reader) + if !ok { + return Result{E("core.ReadAll", "not a reader", nil), false} + } + data, err := io.ReadAll(rc) + if closer, ok := reader.(io.Closer); ok { + closer.Close() + } + if err != nil { + return Result{err, false} + } + return Result{string(data), true} +} + +// WriteAll writes content to a writer and closes it if it implements Closer. +// +// r := fs.WriteStream(path) +// core.WriteAll(r.Value, "content") +func WriteAll(writer any, content string) Result { + wc, ok := writer.(io.Writer) + if !ok { + return Result{E("core.WriteAll", "not a writer", nil), false} + } + _, err := wc.Write([]byte(content)) + if closer, ok := writer.(io.Closer); ok { + closer.Close() + } + if err != nil { + return Result{err, false} + } + return Result{OK: true} +} + +// CloseStream closes any value that implements io.Closer. +// +// core.CloseStream(r.Value) +func CloseStream(v any) { + if closer, ok := v.(io.Closer); ok { + closer.Close() + } +} + // Delete removes a file or empty directory. func (m *Fs) Delete(p string) Result { vp := m.validatePath(p) diff --git a/fs_example_test.go b/fs_example_test.go new file mode 100644 index 0000000..aeb5b34 --- /dev/null +++ b/fs_example_test.go @@ -0,0 +1,42 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleFs_WriteAtomic() { + f := (&Fs{}).New("/") + dir := f.TempDir("example") + defer f.DeleteAll(dir) + + path := Path(dir, "status.json") + f.WriteAtomic(path, `{"status":"completed"}`) + + r := f.Read(path) + Println(r.Value) + // Output: {"status":"completed"} +} + +func ExampleFs_NewUnrestricted() { + f := (&Fs{}).New("/") + dir := f.TempDir("example") + defer f.DeleteAll(dir) + + // Write outside sandbox using Core's Fs + outside := Path(dir, "outside.txt") + f.Write(outside, "hello") + + sandbox := (&Fs{}).New(Path(dir, "sandbox")) + unrestricted := sandbox.NewUnrestricted() + + r := unrestricted.Read(outside) + Println(r.Value) + // Output: hello +} + +func ExampleFs_Root() { + f := (&Fs{}).New("/srv/workspaces") + Println(f.Root()) + // Output: /srv/workspaces +} diff --git a/fs_test.go b/fs_test.go index 99160b9..36f5476 100644 --- a/fs_test.go +++ b/fs_test.go @@ -1,10 +1,7 @@ package core_test import ( - "io" "io/fs" - "os" - "path/filepath" "testing" . "dappco.re/go/core" @@ -17,7 +14,7 @@ func TestFs_WriteRead_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "test.txt") + path := Path(dir, "test.txt") assert.True(t, c.Fs().Write(path, "hello core").OK) r := c.Fs().Read(path) @@ -34,7 +31,7 @@ func TestFs_Read_Bad(t *testing.T) { func TestFs_EnsureDir_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "sub", "dir") + path := Path(dir, "sub", "dir") assert.True(t, c.Fs().EnsureDir(path).OK) assert.True(t, c.Fs().IsDir(path)) } @@ -43,14 +40,14 @@ func TestFs_IsDir_Good(t *testing.T) { c := New() dir := t.TempDir() assert.True(t, c.Fs().IsDir(dir)) - assert.False(t, c.Fs().IsDir(filepath.Join(dir, "nonexistent"))) + assert.False(t, c.Fs().IsDir(Path(dir, "nonexistent"))) assert.False(t, c.Fs().IsDir("")) } func TestFs_IsFile_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "test.txt") + path := Path(dir, "test.txt") c.Fs().Write(path, "data") assert.True(t, c.Fs().IsFile(path)) assert.False(t, c.Fs().IsFile(dir)) @@ -60,18 +57,18 @@ func TestFs_IsFile_Good(t *testing.T) { func TestFs_Exists_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "exists.txt") + path := Path(dir, "exists.txt") c.Fs().Write(path, "yes") assert.True(t, c.Fs().Exists(path)) assert.True(t, c.Fs().Exists(dir)) - assert.False(t, c.Fs().Exists(filepath.Join(dir, "nope"))) + assert.False(t, c.Fs().Exists(Path(dir, "nope"))) } func TestFs_List_Good(t *testing.T) { dir := t.TempDir() c := New() - c.Fs().Write(filepath.Join(dir, "a.txt"), "a") - c.Fs().Write(filepath.Join(dir, "b.txt"), "b") + c.Fs().Write(Path(dir, "a.txt"), "a") + c.Fs().Write(Path(dir, "b.txt"), "b") r := c.Fs().List(dir) assert.True(t, r.OK) assert.Len(t, r.Value.([]fs.DirEntry), 2) @@ -80,32 +77,30 @@ func TestFs_List_Good(t *testing.T) { func TestFs_Stat_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "stat.txt") + path := Path(dir, "stat.txt") c.Fs().Write(path, "data") r := c.Fs().Stat(path) assert.True(t, r.OK) - assert.Equal(t, "stat.txt", r.Value.(os.FileInfo).Name()) + assert.Equal(t, "stat.txt", r.Value.(fs.FileInfo).Name()) } func TestFs_Open_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "open.txt") + path := Path(dir, "open.txt") c.Fs().Write(path, "content") r := c.Fs().Open(path) assert.True(t, r.OK) - r.Value.(io.Closer).Close() + CloseStream(r.Value) } func TestFs_Create_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "sub", "created.txt") + path := Path(dir, "sub", "created.txt") r := c.Fs().Create(path) assert.True(t, r.OK) - w := r.Value.(io.WriteCloser) - w.Write([]byte("hello")) - w.Close() + WriteAll(r.Value, "hello") rr := c.Fs().Read(path) assert.Equal(t, "hello", rr.Value.(string)) } @@ -113,13 +108,11 @@ func TestFs_Create_Good(t *testing.T) { func TestFs_Append_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "append.txt") + path := Path(dir, "append.txt") c.Fs().Write(path, "first") r := c.Fs().Append(path) assert.True(t, r.OK) - w := r.Value.(io.WriteCloser) - w.Write([]byte(" second")) - w.Close() + WriteAll(r.Value, " second") rr := c.Fs().Read(path) assert.Equal(t, "first second", rr.Value.(string)) } @@ -127,28 +120,26 @@ func TestFs_Append_Good(t *testing.T) { func TestFs_ReadStream_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "stream.txt") + path := Path(dir, "stream.txt") c.Fs().Write(path, "streamed") r := c.Fs().ReadStream(path) assert.True(t, r.OK) - r.Value.(io.Closer).Close() + CloseStream(r.Value) } func TestFs_WriteStream_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "sub", "ws.txt") + path := Path(dir, "sub", "ws.txt") r := c.Fs().WriteStream(path) assert.True(t, r.OK) - w := r.Value.(io.WriteCloser) - w.Write([]byte("stream")) - w.Close() + WriteAll(r.Value, "stream") } func TestFs_Delete_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "delete.txt") + path := Path(dir, "delete.txt") c.Fs().Write(path, "gone") assert.True(t, c.Fs().Delete(path).OK) assert.False(t, c.Fs().Exists(path)) @@ -157,18 +148,18 @@ func TestFs_Delete_Good(t *testing.T) { func TestFs_DeleteAll_Good(t *testing.T) { dir := t.TempDir() c := New() - sub := filepath.Join(dir, "deep", "nested") + sub := Path(dir, "deep", "nested") c.Fs().EnsureDir(sub) - c.Fs().Write(filepath.Join(sub, "file.txt"), "data") - assert.True(t, c.Fs().DeleteAll(filepath.Join(dir, "deep")).OK) - assert.False(t, c.Fs().Exists(filepath.Join(dir, "deep"))) + c.Fs().Write(Path(sub, "file.txt"), "data") + assert.True(t, c.Fs().DeleteAll(Path(dir, "deep")).OK) + assert.False(t, c.Fs().Exists(Path(dir, "deep"))) } func TestFs_Rename_Good(t *testing.T) { dir := t.TempDir() c := New() - old := filepath.Join(dir, "old.txt") - nw := filepath.Join(dir, "new.txt") + old := Path(dir, "old.txt") + nw := Path(dir, "new.txt") c.Fs().Write(old, "data") assert.True(t, c.Fs().Rename(old, nw).OK) assert.False(t, c.Fs().Exists(old)) @@ -178,11 +169,11 @@ func TestFs_Rename_Good(t *testing.T) { func TestFs_WriteMode_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "secret.txt") + path := Path(dir, "secret.txt") assert.True(t, c.Fs().WriteMode(path, "secret", 0600).OK) r := c.Fs().Stat(path) assert.True(t, r.OK) - assert.Equal(t, "secret.txt", r.Value.(os.FileInfo).Name()) + assert.Equal(t, "secret.txt", r.Value.(fs.FileInfo).Name()) } // --- Zero Value --- @@ -191,7 +182,7 @@ func TestFs_ZeroValue_Good(t *testing.T) { dir := t.TempDir() zeroFs := &Fs{} - path := filepath.Join(dir, "zero.txt") + path := Path(dir, "zero.txt") assert.True(t, zeroFs.Write(path, "zero value works").OK) r := zeroFs.Read(path) assert.True(t, r.OK) @@ -205,7 +196,7 @@ func TestFs_ZeroValue_List_Good(t *testing.T) { dir := t.TempDir() zeroFs := &Fs{} - os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644) + (&Fs{}).New("/").Write(Path(dir, "a.txt"), "a") r := zeroFs.List(dir) assert.True(t, r.OK) entries := r.Value.([]fs.DirEntry) @@ -246,7 +237,7 @@ func TestFs_DeleteAll_Protected_Ugly(t *testing.T) { func TestFs_ReadStream_WriteStream_Good(t *testing.T) { dir := t.TempDir() c := New() - path := filepath.Join(dir, "stream.txt") + path := Path(dir, "stream.txt") c.Fs().Write(path, "streamed") r := c.Fs().ReadStream(path) @@ -255,3 +246,104 @@ 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 := Path(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 := Path(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 := Path(dir, "clean.txt") + c.Fs().WriteAtomic(path, "content") + + // Check no .tmp files remain + lr := c.Fs().List(dir) + entries, _ := lr.Value.([]fs.DirEntry) + 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 := Path(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 := Path(dir, "outside.txt") + (&Fs{}).New("/").Write(outside, "hello") + + sandboxed := (&Fs{}).New(Path(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 := Path(dir, "sandbox") + (&Fs{}).New("/").EnsureDir(sandbox) + + sandboxed := (&Fs{}).New(sandbox) + _ = sandboxed.NewUnrestricted() // getting unrestricted doesn't affect original + + assert.Equal(t, sandbox, sandboxed.Root(), "original Fs must remain sandboxed") +} + +// --- Root --- + +func TestFs_Root_Good(t *testing.T) { + m := (&Fs{}).New("/home/agent") + assert.Equal(t, "/home/agent", m.Root()) +} + +func TestFs_Root_Good_Default(t *testing.T) { + m := (&Fs{}).New("") + assert.Equal(t, "/", m.Root()) +} diff --git a/i18n_test.go b/i18n_test.go index 956ee12..2eb1693 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -75,7 +75,7 @@ type mockTranslator struct { } func (m *mockTranslator) Translate(id string, args ...any) Result { - return Result{"translated:" + id, true} + return Result{Concat("translated:", id), true} } func (m *mockTranslator) SetLanguage(lang string) error { m.lang = lang; return nil } func (m *mockTranslator) Language() string { return m.lang } diff --git a/info_example_test.go b/info_example_test.go new file mode 100644 index 0000000..ac1593a --- /dev/null +++ b/info_example_test.go @@ -0,0 +1,17 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleEnv() { + Println(Env("OS")) // e.g. "darwin" + Println(Env("ARCH")) // e.g. "arm64" +} + +func ExampleEnvKeys() { + keys := EnvKeys() + Println(len(keys) > 0) + // Output: true +} diff --git a/info_test.go b/info_test.go index 5f09db7..53de66e 100644 --- a/info_test.go +++ b/info_test.go @@ -3,8 +3,6 @@ package core_test import ( - "os" - "runtime" "testing" "time" @@ -13,88 +11,84 @@ import ( "github.com/stretchr/testify/require" ) -func TestEnv_OS(t *testing.T) { - assert.Equal(t, runtime.GOOS, core.Env("OS")) +func TestInfo_Env_OS_Good(t *testing.T) { + v := core.Env("OS") + assert.NotEmpty(t, v) + assert.Contains(t, []string{"darwin", "linux", "windows"}, v) } -func TestEnv_ARCH(t *testing.T) { - assert.Equal(t, runtime.GOARCH, core.Env("ARCH")) +func TestInfo_Env_ARCH_Good(t *testing.T) { + v := core.Env("ARCH") + assert.NotEmpty(t, v) + assert.Contains(t, []string{"amd64", "arm64", "386"}, v) } -func TestEnv_GO(t *testing.T) { - assert.Equal(t, runtime.Version(), core.Env("GO")) +func TestInfo_Env_GO_Good(t *testing.T) { + assert.True(t, core.HasPrefix(core.Env("GO"), "go")) } -func TestEnv_DS(t *testing.T) { - assert.Equal(t, string(os.PathSeparator), core.Env("DS")) +func TestInfo_Env_DS_Good(t *testing.T) { + ds := core.Env("DS") + assert.Contains(t, []string{"/", "\\"}, ds) } -func TestEnv_PS(t *testing.T) { - assert.Equal(t, string(os.PathListSeparator), core.Env("PS")) +func TestInfo_Env_PS_Good(t *testing.T) { + ps := core.Env("PS") + assert.Contains(t, []string{":", ";"}, ps) } -func TestEnv_DIR_HOME(t *testing.T) { - if ch := os.Getenv("CORE_HOME"); ch != "" { - assert.Equal(t, ch, core.Env("DIR_HOME")) - return - } - home, err := os.UserHomeDir() - require.NoError(t, err) - assert.Equal(t, home, core.Env("DIR_HOME")) +func TestInfo_Env_DIR_HOME_Good(t *testing.T) { + home := core.Env("DIR_HOME") + assert.NotEmpty(t, home) + assert.True(t, core.PathIsAbs(home), "DIR_HOME should be absolute") } -func TestEnv_DIR_TMP(t *testing.T) { - assert.Equal(t, os.TempDir(), core.Env("DIR_TMP")) +func TestInfo_Env_DIR_TMP_Good(t *testing.T) { + assert.NotEmpty(t, core.Env("DIR_TMP")) } -func TestEnv_DIR_CONFIG(t *testing.T) { - cfg, err := os.UserConfigDir() - require.NoError(t, err) - assert.Equal(t, cfg, core.Env("DIR_CONFIG")) +func TestInfo_Env_DIR_CONFIG_Good(t *testing.T) { + assert.NotEmpty(t, core.Env("DIR_CONFIG")) } -func TestEnv_DIR_CACHE(t *testing.T) { - cache, err := os.UserCacheDir() - require.NoError(t, err) - assert.Equal(t, cache, core.Env("DIR_CACHE")) +func TestInfo_Env_DIR_CACHE_Good(t *testing.T) { + assert.NotEmpty(t, core.Env("DIR_CACHE")) } -func TestEnv_HOSTNAME(t *testing.T) { - hostname, err := os.Hostname() - require.NoError(t, err) - assert.Equal(t, hostname, core.Env("HOSTNAME")) +func TestInfo_Env_HOSTNAME_Good(t *testing.T) { + assert.NotEmpty(t, core.Env("HOSTNAME")) } -func TestEnv_USER(t *testing.T) { +func TestInfo_Env_USER_Good(t *testing.T) { assert.NotEmpty(t, core.Env("USER")) } -func TestEnv_PID(t *testing.T) { +func TestInfo_Env_PID_Good(t *testing.T) { assert.NotEmpty(t, core.Env("PID")) } -func TestEnv_NUM_CPU(t *testing.T) { +func TestInfo_Env_NUM_CPU_Good(t *testing.T) { assert.NotEmpty(t, core.Env("NUM_CPU")) } -func TestEnv_CORE_START(t *testing.T) { +func TestInfo_Env_CORE_START_Good(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_Bad_Unknown(t *testing.T) { assert.Equal(t, "", core.Env("NOPE")) } -func TestEnv_CoreInstance(t *testing.T) { +func TestInfo_Env_Good_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_Good(t *testing.T) { keys := core.EnvKeys() assert.NotEmpty(t, keys) assert.Contains(t, keys, "OS") diff --git a/ipc.go b/ipc.go index 6f0f99f..bedbd65 100644 --- a/ipc.go +++ b/ipc.go @@ -11,7 +11,7 @@ import ( "sync" ) -// Ipc holds IPC dispatch data. +// Ipc holds IPC dispatch data and the named action registry. // // ipc := (&core.Ipc{}).New() type Ipc struct { @@ -21,23 +21,33 @@ type Ipc struct { queryMu sync.RWMutex queryHandlers []QueryHandler - taskMu sync.RWMutex - taskHandlers []TaskHandler + actions *Registry[*Action] // named action registry + tasks *Registry[*Task] // 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} } +// Query dispatches a request — first handler to return OK wins. +// +// r := c.Query(MyQuery{}) func (c *Core) Query(q Query) Result { c.ipc.queryMu.RLock() handlers := slices.Clone(c.ipc.queryHandlers) @@ -52,6 +62,10 @@ func (c *Core) Query(q Query) Result { return Result{} } +// QueryAll dispatches a request — collects all OK responses. +// +// r := c.QueryAll(countQuery{}) +// results := r.Value.([]any) func (c *Core) QueryAll(q Query) Result { c.ipc.queryMu.RLock() handlers := slices.Clone(c.ipc.queryHandlers) @@ -67,8 +81,33 @@ func (c *Core) QueryAll(q Query) Result { return Result{results, true} } +// RegisterQuery registers a handler for QUERY dispatch. +// +// c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { ... }) func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.queryMu.Lock() 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() +} + diff --git a/ipc_test.go b/ipc_test.go index 7977bbb..e0a4b20 100644 --- a/ipc_test.go +++ b/ipc_test.go @@ -1,6 +1,7 @@ package core_test import ( + "context" "testing" . "dappco.re/go/core" @@ -39,9 +40,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 +104,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 +113,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} @@ -79,17 +129,14 @@ func TestQueryAll_Good(t *testing.T) { assert.Contains(t, results, "b") } -// --- IPC: Tasks --- +// --- IPC: Named Action Invocation --- -func TestPerform_Good(t *testing.T) { +func TestIpc_ActionInvoke_Good(t *testing.T) { c := New() - c.RegisterTask(func(_ *Core, t Task) Result { - if t == "compute" { - return Result{Value: 42, OK: true} - } - return Result{} + c.Action("compute", func(_ context.Context, opts Options) Result { + return Result{Value: 42, OK: true} }) - r := c.PERFORM("compute") + r := c.Action("compute").Run(context.Background(), NewOptions()) assert.True(t, r.OK) assert.Equal(t, 42, r.Value) } diff --git a/json.go b/json.go new file mode 100644 index 0000000..65e119f --- /dev/null +++ b/json.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// JSON helpers for the Core framework. +// Wraps encoding/json so consumers don't import it directly. +// Same guardrail pattern as string.go wraps strings. +// +// Usage: +// +// data := core.JSONMarshal(myStruct) +// if data.OK { json := data.Value.([]byte) } +// +// r := core.JSONUnmarshal(jsonBytes, &target) +// if !r.OK { /* handle error */ } +package core + +import "encoding/json" + +// JSONMarshal serialises a value to JSON bytes. +// +// r := core.JSONMarshal(myStruct) +// if r.OK { data := r.Value.([]byte) } +func JSONMarshal(v any) Result { + data, err := json.Marshal(v) + if err != nil { + return Result{err, false} + } + return Result{data, true} +} + +// JSONMarshalString serialises a value to a JSON string. +// +// s := core.JSONMarshalString(myStruct) +func JSONMarshalString(v any) string { + data, err := json.Marshal(v) + if err != nil { + return "{}" + } + return string(data) +} + +// JSONUnmarshal deserialises JSON bytes into a target. +// +// var cfg Config +// r := core.JSONUnmarshal(data, &cfg) +func JSONUnmarshal(data []byte, target any) Result { + if err := json.Unmarshal(data, target); err != nil { + return Result{err, false} + } + return Result{OK: true} +} + +// JSONUnmarshalString deserialises a JSON string into a target. +// +// var cfg Config +// r := core.JSONUnmarshalString(`{"port":8080}`, &cfg) +func JSONUnmarshalString(s string, target any) Result { + return JSONUnmarshal([]byte(s), target) +} diff --git a/json_test.go b/json_test.go new file mode 100644 index 0000000..6841e39 --- /dev/null +++ b/json_test.go @@ -0,0 +1,63 @@ +package core_test + +import ( + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +type testJSON struct { + Name string `json:"name"` + Port int `json:"port"` +} + +// --- JSONMarshal --- + +func TestJson_JSONMarshal_Good(t *testing.T) { + r := JSONMarshal(testJSON{Name: "brain", Port: 8080}) + assert.True(t, r.OK) + assert.Contains(t, string(r.Value.([]byte)), `"name":"brain"`) +} + +func TestJson_JSONMarshal_Bad_Unmarshalable(t *testing.T) { + r := JSONMarshal(make(chan int)) + assert.False(t, r.OK) +} + +// --- JSONMarshalString --- + +func TestJson_JSONMarshalString_Good(t *testing.T) { + s := JSONMarshalString(testJSON{Name: "x", Port: 1}) + assert.Contains(t, s, `"name":"x"`) +} + +func TestJson_JSONMarshalString_Ugly_Fallback(t *testing.T) { + s := JSONMarshalString(make(chan int)) + assert.Equal(t, "{}", s) +} + +// --- JSONUnmarshal --- + +func TestJson_JSONUnmarshal_Good(t *testing.T) { + var target testJSON + r := JSONUnmarshal([]byte(`{"name":"brain","port":8080}`), &target) + assert.True(t, r.OK) + assert.Equal(t, "brain", target.Name) + assert.Equal(t, 8080, target.Port) +} + +func TestJson_JSONUnmarshal_Bad_Invalid(t *testing.T) { + var target testJSON + r := JSONUnmarshal([]byte(`not json`), &target) + assert.False(t, r.OK) +} + +// --- JSONUnmarshalString --- + +func TestJson_JSONUnmarshalString_Good(t *testing.T) { + var target testJSON + r := JSONUnmarshalString(`{"name":"x","port":1}`, &target) + assert.True(t, r.OK) + assert.Equal(t, "x", target.Name) +} diff --git a/llm.txt b/llm.txt new file mode 100644 index 0000000..f8f1f4f --- /dev/null +++ b/llm.txt @@ -0,0 +1,46 @@ +# core/go — CoreGO Framework + +> dappco.re/go/core — Dependency injection, service lifecycle, permission, +> and message-passing framework for Go. Foundation layer for the Lethean ecosystem. + +## Entry Points + +- CLAUDE.md — Agent instructions, build commands, subsystem table +- docs/RFC.md — API contract specification (21 sections, the authoritative spec) + +## Package Layout + +All source files at module root. No pkg/ nesting. Tests are *_test.go alongside source. + +## Key Types + +- Core — Central application container (core.New() returns *Core) +- Option — Single key-value pair {Key: string, Value: any} +- Options — Collection of Option with typed accessors +- Result — Universal return type {Value: any, OK: bool} +- Service — Managed component with lifecycle (Startable/Stoppable return Result) +- Action — Named callable with panic recovery and entitlement enforcement +- Task — Composed sequence of Actions (Steps, Async, Input piping) +- Registry[T] — Thread-safe named collection (universal brick) +- Process — Managed execution (sugar over Actions) +- API — Remote streams (protocol handlers, Drive integration) +- Entitlement — Permission check result (Allowed, Limit, Used, Remaining) +- Message — IPC broadcast type for ACTION +- Query — IPC request/response type for QUERY + +## Service Pattern + + core.New( + core.WithService(mypackage.Register), + ) + + func Register(c *core.Core) core.Result { + svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, opts)} + return core.Result{Value: svc, OK: true} + } + +## Conventions + +Follows RFC-025 Agent Experience (AX) principles. +Tests: TestFile_Function_{Good,Bad,Ugly} — 100% AX-7 naming. +See: https://core.help/specs/RFC-025-AGENT-EXPERIENCE/ diff --git a/lock.go b/lock.go index 539aaab..a963278 100644 --- a/lock.go +++ b/lock.go @@ -12,78 +12,57 @@ import ( type Lock struct { Name string Mutex *sync.RWMutex - mu sync.Mutex // protects locks map - locks map[string]*sync.RWMutex // per-Core named mutexes + locks *Registry[*sync.RWMutex] // per-Core named mutexes } // Lock returns a named Lock, creating the mutex if needed. // Locks are per-Core — separate Core instances do not share mutexes. func (c *Core) Lock(name string) *Lock { - c.lock.mu.Lock() - if c.lock.locks == nil { - c.lock.locks = make(map[string]*sync.RWMutex) + r := c.lock.locks.Get(name) + if r.OK { + return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)} } - m, ok := c.lock.locks[name] - if !ok { - m = &sync.RWMutex{} - c.lock.locks[name] = m - } - c.lock.mu.Unlock() + m := &sync.RWMutex{} + c.lock.locks.Set(name, m) return &Lock{Name: name, Mutex: m} } // LockEnable marks that the service lock should be applied after initialisation. func (c *Core) LockEnable(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() c.services.lockEnabled = true } // LockApply activates the service lock if it was enabled. func (c *Core) LockApply(name ...string) { - n := "srv" - if len(name) > 0 { - n = name[0] - } - c.Lock(n).Mutex.Lock() - defer c.Lock(n).Mutex.Unlock() if c.services.lockEnabled { - c.services.locked = true + c.services.Lock() } } -// Startables returns services that have an OnStart function. +// Startables returns services that have an OnStart function, in registration order. func (c *Core) Startables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStart != nil { out = append(out, svc) } - } + }) return Result{out, true} } -// Stoppables returns services that have an OnStop function. +// Stoppables returns services that have an OnStop function, in registration order. func (c *Core) Stoppables() Result { if c.services == nil { return Result{} } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() var out []*Service - for _, svc := range c.services.services { + c.services.Each(func(_ string, svc *Service) { if svc.OnStop != nil { out = append(out, svc) } - } + }) return Result{out, true} } diff --git a/lock_example_test.go b/lock_example_test.go new file mode 100644 index 0000000..61497b0 --- /dev/null +++ b/lock_example_test.go @@ -0,0 +1,18 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleCore_Lock() { + c := New() + lock := c.Lock("drain") + lock.Mutex.Lock() + Println("locked") + lock.Mutex.Unlock() + Println("unlocked") + // Output: + // locked + // unlocked +} diff --git a/lock_test.go b/lock_test.go index 1c96e42..ef0ba86 100644 --- a/lock_test.go +++ b/lock_test.go @@ -28,7 +28,7 @@ func TestLock_DifferentName_Good(t *testing.T) { assert.NotEqual(t, l1, l2) } -func TestLockEnable_Good(t *testing.T) { +func TestLock_LockEnable_Good(t *testing.T) { c := New() c.Service("early", Service{}) c.LockEnable() @@ -38,7 +38,7 @@ func TestLockEnable_Good(t *testing.T) { assert.False(t, r.OK) } -func TestStartables_Good(t *testing.T) { +func TestLock_Startables_Good(t *testing.T) { c := New() c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }}) r := c.Startables() @@ -46,7 +46,7 @@ func TestStartables_Good(t *testing.T) { assert.Len(t, r.Value.([]*Service), 1) } -func TestStoppables_Good(t *testing.T) { +func TestLock_Stoppables_Good(t *testing.T) { c := New() c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }}) r := c.Stoppables() diff --git a/log_example_test.go b/log_example_test.go new file mode 100644 index 0000000..69cc228 --- /dev/null +++ b/log_example_test.go @@ -0,0 +1,15 @@ +package core_test + +import . "dappco.re/go/core" + +func ExampleInfo() { + Info("server started", "port", 8080) +} + +func ExampleWarn() { + Warn("deprecated", "feature", "old-api") +} + +func ExampleSecurity() { + Security("access denied", "user", "unknown", "action", "admin.nuke") +} diff --git a/log_test.go b/log_test.go index 70e6103..765713b 100644 --- a/log_test.go +++ b/log_test.go @@ -1,7 +1,6 @@ package core_test import ( - "os" "testing" . "dappco.re/go/core" @@ -105,7 +104,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 +113,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 +121,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() { @@ -141,7 +140,7 @@ func TestLogPanic_Recover_Good(t *testing.T) { func TestLog_SetOutput_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) - l.SetOutput(os.Stderr) + l.SetOutput(NewBuilder()) l.Info("redirected") } diff --git a/options.go b/options.go index cf730a2..3721232 100644 --- a/options.go +++ b/options.go @@ -38,6 +38,9 @@ func (r Result) Result(args ...any) Result { return r.New(args...) } +// New adapts Go (value, error) pairs into a Result. +// +// r := core.Result{}.New(file, err) func (r Result) New(args ...any) Result { if len(args) == 0 { return r @@ -67,6 +70,9 @@ func (r Result) New(args ...any) Result { return r } +// Get returns the Result if OK, empty Result otherwise. +// +// r := core.Result{Value: "hello", OK: true}.Get() func (r Result) Get() Result { if r.OK { return r diff --git a/options_test.go b/options_test.go index 751d008..06bded5 100644 --- a/options_test.go +++ b/options_test.go @@ -9,7 +9,7 @@ import ( // --- NewOptions --- -func TestNewOptions_Good(t *testing.T) { +func TestOptions_NewOptions_Good(t *testing.T) { opts := NewOptions( Option{Key: "name", Value: "brain"}, Option{Key: "port", Value: 8080}, @@ -17,7 +17,7 @@ func TestNewOptions_Good(t *testing.T) { assert.Equal(t, 2, opts.Len()) } -func TestNewOptions_Empty_Good(t *testing.T) { +func TestOptions_NewOptions_Empty_Good(t *testing.T) { opts := NewOptions() assert.Equal(t, 0, opts.Len()) assert.False(t, opts.Has("anything")) @@ -133,41 +133,41 @@ func TestOptions_TypedStruct_Good(t *testing.T) { // --- Result --- -func TestResult_New_Good(t *testing.T) { +func TestOptions_Result_New_Good(t *testing.T) { r := Result{}.New("value") assert.Equal(t, "value", r.Value) } -func TestResult_New_Error_Bad(t *testing.T) { +func TestOptions_Result_New_Error_Bad(t *testing.T) { err := E("test", "failed", nil) r := Result{}.New(err) assert.False(t, r.OK) assert.Equal(t, err, r.Value) } -func TestResult_Result_Good(t *testing.T) { +func TestOptions_Result_Result_Good(t *testing.T) { r := Result{Value: "hello", OK: true} assert.Equal(t, r, r.Result()) } -func TestResult_Result_WithArgs_Good(t *testing.T) { +func TestOptions_Result_Result_WithArgs_Good(t *testing.T) { r := Result{}.Result("value") assert.Equal(t, "value", r.Value) } -func TestResult_Get_Good(t *testing.T) { +func TestOptions_Result_Get_Good(t *testing.T) { r := Result{Value: "hello", OK: true} assert.True(t, r.Get().OK) } -func TestResult_Get_Bad(t *testing.T) { +func TestOptions_Result_Get_Bad(t *testing.T) { r := Result{Value: "err", OK: false} assert.False(t, r.Get().OK) } // --- WithOption --- -func TestWithOption_Good(t *testing.T) { +func TestOptions_WithOption_Good(t *testing.T) { c := New( WithOption("name", "myapp"), WithOption("port", 8080), diff --git a/path_example_test.go b/path_example_test.go new file mode 100644 index 0000000..96ad907 --- /dev/null +++ b/path_example_test.go @@ -0,0 +1,37 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleJoinPath() { + Println(JoinPath("deploy", "to", "homelab")) + // Output: deploy/to/homelab +} + +func ExamplePathBase() { + Println(PathBase("/srv/workspaces/alpha")) + // Output: alpha +} + +func ExamplePathDir() { + Println(PathDir("/srv/workspaces/alpha")) + // Output: /srv/workspaces +} + +func ExamplePathExt() { + Println(PathExt("report.pdf")) + // Output: .pdf +} + +func ExampleCleanPath() { + Println(CleanPath("/tmp//file", "/")) + Println(CleanPath("a/b/../c", "/")) + Println(CleanPath("deploy/to/homelab", "/")) + // Output: + // /tmp/file + // a/c + // deploy/to/homelab +} + diff --git a/path_test.go b/path_test.go index fdc8725..5c7d7e3 100644 --- a/path_test.go +++ b/path_test.go @@ -3,18 +3,15 @@ package core_test import ( - "os" - "path/filepath" "testing" core "dappco.re/go/core" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestPath_Relative(t *testing.T) { - home, err := os.UserHomeDir() - require.NoError(t, err) + home := core.Env("DIR_HOME") + ds := core.Env("DS") assert.Equal(t, home+ds+"Code"+ds+".core", core.Path("Code", ".core")) } @@ -25,14 +22,14 @@ func TestPath_Absolute(t *testing.T) { } func TestPath_Empty(t *testing.T) { - home, err := os.UserHomeDir() - require.NoError(t, err) + home := core.Env("DIR_HOME") + assert.Equal(t, home, core.Path()) } func TestPath_Cleans(t *testing.T) { - home, err := os.UserHomeDir() - require.NoError(t, err) + home := core.Env("DIR_HOME") + assert.Equal(t, home+core.Env("DS")+"Code", core.Path("Code", "sub", "..")) } @@ -41,32 +38,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,36 +73,38 @@ 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) - os.WriteFile(filepath.Join(dir, "c.log"), []byte("c"), 0644) + f := (&core.Fs{}).New("/") + f.Write(core.Path(dir, "a.txt"), "a") + f.Write(core.Path(dir, "b.txt"), "b") + f.Write(core.Path(dir, "c.log"), "c") - matches := core.PathGlob(filepath.Join(dir, "*.txt")) + matches := core.PathGlob(core.Path(dir, "*.txt")) assert.Len(t, matches, 2) } -func TestPathGlob_NoMatch(t *testing.T) { +func TestPath_PathGlob_NoMatch(t *testing.T) { matches := core.PathGlob("/nonexistent/pattern-*.xyz") assert.Empty(t, matches) } -func TestPathIsAbs_Good(t *testing.T) { +func TestPath_PathIsAbs_Good(t *testing.T) { assert.True(t, core.PathIsAbs("/tmp")) assert.True(t, core.PathIsAbs("/")) assert.False(t, core.PathIsAbs("relative")) assert.False(t, core.PathIsAbs("")) } -func TestCleanPath_Good(t *testing.T) { +func TestPath_CleanPath_Good(t *testing.T) { assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/")) assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/")) assert.Equal(t, "/", core.CleanPath("/", "/")) assert.Equal(t, ".", core.CleanPath("", "/")) } -func TestPathDir_TrailingSlash(t *testing.T) { +func TestPath_PathDir_TrailingSlash(t *testing.T) { result := core.PathDir("/Users/snider/Code/") assert.Equal(t, "/Users/snider/Code", result) } + diff --git a/process.go b/process.go new file mode 100644 index 0000000..2365791 --- /dev/null +++ b/process.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Process is the Core primitive for managed execution. +// Methods emit via named Actions — actual execution is handled by +// whichever service registers the "process.*" actions (typically go-process). +// +// If go-process is NOT registered, all methods return Result{OK: false}. +// This is permission-by-registration: no handler = no capability. +// +// Usage: +// +// r := c.Process().Run(ctx, "git", "log", "--oneline") +// if r.OK { output := r.Value.(string) } +// +// r := c.Process().RunIn(ctx, "/path/to/repo", "go", "test", "./...") +// +// Permission model: +// +// // Full Core — process registered: +// c := core.New(core.WithService(process.Register)) +// c.Process().Run(ctx, "git", "log") // works +// +// // Sandboxed Core — no process: +// c := core.New() +// c.Process().Run(ctx, "git", "log") // Result{OK: false} +package core + +import "context" + +// Process is the Core primitive for process management. +// Zero dependencies — delegates to named Actions. +type Process struct { + core *Core +} + +// Process returns the process management primitive. +// +// c.Process().Run(ctx, "git", "log") +func (c *Core) Process() *Process { + return &Process{core: c} +} + +// Run executes a command synchronously and returns the output. +// +// r := c.Process().Run(ctx, "git", "log", "--oneline") +// if r.OK { output := r.Value.(string) } +func (p *Process) Run(ctx context.Context, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + )) +} + +// RunIn executes a command in a specific directory. +// +// r := c.Process().RunIn(ctx, "/repo", "go", "test", "./...") +func (p *Process) RunIn(ctx context.Context, dir string, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + Option{Key: "dir", Value: dir}, + )) +} + +// RunWithEnv executes with additional environment variables. +// +// r := c.Process().RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test") +func (p *Process) RunWithEnv(ctx context.Context, dir string, env []string, command string, args ...string) Result { + return p.core.Action("process.run").Run(ctx, NewOptions( + Option{Key: "command", Value: command}, + Option{Key: "args", Value: args}, + Option{Key: "dir", Value: dir}, + Option{Key: "env", Value: env}, + )) +} + +// Start spawns a detached/background process. +// +// r := c.Process().Start(ctx, ProcessStartOptions{Command: "docker", Args: []string{"run", "..."}}) +func (p *Process) Start(ctx context.Context, opts Options) Result { + return p.core.Action("process.start").Run(ctx, opts) +} + +// Kill terminates a managed process by ID or PID. +// +// c.Process().Kill(ctx, core.NewOptions(core.Option{Key: "id", Value: processID})) +func (p *Process) Kill(ctx context.Context, opts Options) Result { + return p.core.Action("process.kill").Run(ctx, opts) +} + +// Exists returns true if any process execution capability is registered. +// +// if c.Process().Exists() { /* can run commands */ } +func (p *Process) Exists() bool { + return p.core.Action("process.run").Exists() +} diff --git a/process_test.go b/process_test.go new file mode 100644 index 0000000..b9cf164 --- /dev/null +++ b/process_test.go @@ -0,0 +1,144 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Process.Run --- + +func TestProcess_Run_Good(t *testing.T) { + c := New() + // Register a mock process handler + c.Action("process.run", func(_ context.Context, opts Options) Result { + cmd := opts.String("command") + return Result{Value: Concat("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: Concat(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: Concat("executed ", opts.String("command")), OK: true} + }) + + // Sandboxed Core + sandboxed := New() + + // Full can execute + assert.True(t, full.Process().Exists()) + r := full.Process().Run(context.Background(), "whoami") + assert.True(t, r.OK) + + // Sandboxed cannot + assert.False(t, sandboxed.Process().Exists()) + r = sandboxed.Process().Run(context.Background(), "whoami") + assert.False(t, r.OK) +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..fca0a4f --- /dev/null +++ b/registry.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Thread-safe named collection primitive for the Core framework. +// Registry[T] is the universal brick — all named registries (services, +// commands, actions, drives, data) embed this type. +// +// Usage: +// +// r := core.NewRegistry[*MyService]() +// r.Set("brain", brainSvc) +// r.Get("brain") // Result{brainSvc, true} +// r.Has("brain") // true +// r.Names() // []string{"brain"} (insertion order) +// r.Each(func(name string, svc *MyService) { ... }) +// r.Lock() // fully frozen — no more writes +// r.Seal() // no new keys, updates to existing OK +// +// Three lock modes: +// +// Open (default) — anything goes +// Sealed — no new keys, existing keys CAN be updated +// Locked — fully frozen, no writes at all +package core + +import ( + "path/filepath" + "sync" +) + +// registryMode controls write behaviour. +type registryMode int + +const ( + registryOpen registryMode = iota // anything goes + registrySealed // update existing, no new keys + registryLocked // fully frozen +) + +// Registry is a thread-safe named collection. The universal brick +// for all named registries in Core. +// +// r := core.NewRegistry[*Service]() +// r.Set("brain", svc) +// if r.Has("brain") { ... } +type Registry[T any] struct { + items map[string]T + disabled map[string]bool + order []string // insertion order + mu sync.RWMutex + mode registryMode +} + +// NewRegistry creates an empty registry in Open mode. +// +// r := core.NewRegistry[*Service]() +func NewRegistry[T any]() *Registry[T] { + return &Registry[T]{ + items: make(map[string]T), + disabled: make(map[string]bool), + } +} + +// Set registers an item by name. Returns Result{OK: false} if the +// registry is locked, or if sealed and the key doesn't already exist. +// +// r.Set("brain", brainSvc) +func (r *Registry[T]) Set(name string, item T) Result { + r.mu.Lock() + defer r.mu.Unlock() + + switch r.mode { + case registryLocked: + return Result{E("registry.Set", Concat("registry is locked, cannot set: ", name), nil), false} + case registrySealed: + if _, exists := r.items[name]; !exists { + return Result{E("registry.Set", Concat("registry is sealed, cannot add new key: ", name), nil), false} + } + } + + if _, exists := r.items[name]; !exists { + r.order = append(r.order, name) + } + r.items[name] = item + return Result{OK: true} +} + +// Get retrieves an item by name. +// +// res := r.Get("brain") +// if res.OK { svc := res.Value.(*Service) } +func (r *Registry[T]) Get(name string) Result { + r.mu.RLock() + defer r.mu.RUnlock() + + item, ok := r.items[name] + if !ok { + return Result{} + } + return Result{item, true} +} + +// Has returns true if the name exists in the registry. +// +// if r.Has("brain") { ... } +func (r *Registry[T]) Has(name string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.items[name] + return ok +} + +// Names returns all registered names in insertion order. +// +// names := r.Names() // ["brain", "monitor", "process"] +func (r *Registry[T]) Names() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]string, len(r.order)) + copy(out, r.order) + return out +} + +// List returns items whose names match the glob pattern. +// Uses filepath.Match semantics: "*" matches any sequence, "?" matches one char. +// +// services := r.List("process.*") +func (r *Registry[T]) List(pattern string) []T { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []T + for _, name := range r.order { + if matched, _ := filepath.Match(pattern, name); matched { + if !r.disabled[name] { + result = append(result, r.items[name]) + } + } + } + return result +} + +// Each iterates over all items in insertion order, calling fn for each. +// Disabled items are skipped. +// +// r.Each(func(name string, svc *Service) { +// fmt.Println(name, svc) +// }) +func (r *Registry[T]) Each(fn func(string, T)) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, name := range r.order { + if !r.disabled[name] { + fn(name, r.items[name]) + } + } +} + +// Len returns the number of registered items (including disabled). +// +// count := r.Len() +func (r *Registry[T]) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.items) +} + +// Delete removes an item. Returns Result{OK: false} if locked or not found. +// +// r.Delete("old-service") +func (r *Registry[T]) Delete(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if r.mode == registryLocked { + return Result{E("registry.Delete", Concat("registry is locked, cannot delete: ", name), nil), false} + } + if _, exists := r.items[name]; !exists { + return Result{E("registry.Delete", Concat("not found: ", name), nil), false} + } + + delete(r.items, name) + delete(r.disabled, name) + // Remove from order slice + for i, n := range r.order { + if n == name { + r.order = append(r.order[:i], r.order[i+1:]...) + break + } + } + return Result{OK: true} +} + +// Disable soft-disables an item. It still exists but Each/List skip it. +// Returns Result{OK: false} if not found. +// +// r.Disable("broken-handler") +func (r *Registry[T]) Disable(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.items[name]; !exists { + return Result{E("registry.Disable", Concat("not found: ", name), nil), false} + } + r.disabled[name] = true + return Result{OK: true} +} + +// Enable re-enables a disabled item. +// +// r.Enable("fixed-handler") +func (r *Registry[T]) Enable(name string) Result { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.items[name]; !exists { + return Result{E("registry.Enable", Concat("not found: ", name), nil), false} + } + delete(r.disabled, name) + return Result{OK: true} +} + +// Disabled returns true if the item is soft-disabled. +func (r *Registry[T]) Disabled(name string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.disabled[name] +} + +// Lock fully freezes the registry. No Set, no Delete. +// +// r.Lock() // after startup, prevent late registration +func (r *Registry[T]) Lock() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registryLocked +} + +// Locked returns true if the registry is fully frozen. +func (r *Registry[T]) Locked() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode == registryLocked +} + +// Seal prevents new keys but allows updates to existing keys. +// Use for hot-reload: shape is fixed, implementations can change. +// +// r.Seal() // no new capabilities, but handlers can be swapped +func (r *Registry[T]) Seal() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registrySealed +} + +// Sealed returns true if the registry is sealed (no new keys). +func (r *Registry[T]) Sealed() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode == registrySealed +} + +// Open resets the registry to open mode (default). +// +// r.Open() // re-enable writes for testing +func (r *Registry[T]) Open() { + r.mu.Lock() + defer r.mu.Unlock() + r.mode = registryOpen +} diff --git a/registry_example_test.go b/registry_example_test.go new file mode 100644 index 0000000..daf0383 --- /dev/null +++ b/registry_example_test.go @@ -0,0 +1,70 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleRegistry_Set() { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Set("bravo", "second") + Println(r.Get("alpha").Value) + // Output: first +} + +func ExampleRegistry_Names() { + r := NewRegistry[int]() + r.Set("charlie", 3) + r.Set("alpha", 1) + r.Set("bravo", 2) + Println(r.Names()) + // Output: [charlie alpha bravo] +} + +func ExampleRegistry_List() { + r := NewRegistry[string]() + r.Set("process.run", "run") + r.Set("process.kill", "kill") + r.Set("brain.recall", "recall") + + items := r.List("process.*") + Println(len(items)) + // Output: 2 +} + +func ExampleRegistry_Each() { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + + sum := 0 + r.Each(func(_ string, v int) { sum += v }) + Println(sum) + // Output: 6 +} + +func ExampleRegistry_Disable() { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Set("bravo", "second") + r.Disable("alpha") + + var names []string + r.Each(func(name string, _ string) { names = append(names, name) }) + Println(names) + // Output: [bravo] +} + +func ExampleRegistry_Delete() { + r := NewRegistry[string]() + r.Set("temp", "value") + Println(r.Has("temp")) + + r.Delete("temp") + Println(r.Has("temp")) + // Output: + // true + // false +} diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..814c328 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,387 @@ +package core_test + +import ( + "sync" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Set --- + +func TestRegistry_Set_Good(t *testing.T) { + r := NewRegistry[string]() + res := r.Set("alpha", "first") + assert.True(t, res.OK) + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Set_Good_Update(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Set("alpha", "second") + res := r.Get("alpha") + assert.Equal(t, "second", res.Value) + assert.Equal(t, 1, r.Len(), "update should not increase count") +} + +func TestRegistry_Set_Bad_Locked(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Lock() + res := r.Set("beta", "second") + assert.False(t, res.OK) +} + +func TestRegistry_Set_Bad_SealedNewKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + res := r.Set("beta", "new") + assert.False(t, res.OK, "sealed registry must reject new keys") +} + +func TestRegistry_Set_Good_SealedExistingKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + res := r.Set("alpha", "updated") + assert.True(t, res.OK, "sealed registry must allow updates to existing keys") + assert.Equal(t, "updated", r.Get("alpha").Value) +} + +func TestRegistry_Set_Ugly_ConcurrentWrites(t *testing.T) { + r := NewRegistry[int]() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Set(Sprintf("key-%d", n), n) + }(i) + } + wg.Wait() + assert.Equal(t, 100, r.Len()) +} + +// --- Get --- + +func TestRegistry_Get_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Get("alpha") + assert.True(t, res.OK) + assert.Equal(t, "value", res.Value) +} + +func TestRegistry_Get_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Get("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Get_Ugly_EmptyKey(t *testing.T) { + r := NewRegistry[string]() + r.Set("", "empty-key") + res := r.Get("") + assert.True(t, res.OK, "empty string is a valid key") + assert.Equal(t, "empty-key", res.Value) +} + +// --- Has --- + +func TestRegistry_Has_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Has_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + assert.False(t, r.Has("missing")) +} + +func TestRegistry_Has_Ugly_AfterDelete(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Delete("alpha") + assert.False(t, r.Has("alpha")) +} + +// --- Names --- + +func TestRegistry_Names_Good(t *testing.T) { + r := NewRegistry[int]() + r.Set("charlie", 3) + r.Set("alpha", 1) + r.Set("bravo", 2) + assert.Equal(t, []string{"charlie", "alpha", "bravo"}, r.Names(), "must preserve insertion order") +} + +func TestRegistry_Names_Bad_Empty(t *testing.T) { + r := NewRegistry[int]() + assert.Empty(t, r.Names()) +} + +func TestRegistry_Names_Ugly_AfterDeleteAndReinsert(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + r.Delete("b") + r.Set("d", 4) + assert.Equal(t, []string{"a", "c", "d"}, r.Names()) +} + +// --- Each --- + +func TestRegistry_Each_Good(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + var names []string + var sum int + r.Each(func(name string, val int) { + names = append(names, name) + sum += val + }) + assert.Equal(t, []string{"a", "b", "c"}, names) + assert.Equal(t, 6, sum) +} + +func TestRegistry_Each_Bad_Empty(t *testing.T) { + r := NewRegistry[int]() + called := false + r.Each(func(_ string, _ int) { called = true }) + assert.False(t, called) +} + +func TestRegistry_Each_Ugly_SkipsDisabled(t *testing.T) { + r := NewRegistry[int]() + r.Set("a", 1) + r.Set("b", 2) + r.Set("c", 3) + r.Disable("b") + var names []string + r.Each(func(name string, _ int) { names = append(names, name) }) + assert.Equal(t, []string{"a", "c"}, names) +} + +// --- Len --- + +func TestRegistry_Len_Good(t *testing.T) { + r := NewRegistry[string]() + assert.Equal(t, 0, r.Len()) + r.Set("a", "1") + assert.Equal(t, 1, r.Len()) + r.Set("b", "2") + assert.Equal(t, 2, r.Len()) +} + +// --- List --- + +func TestRegistry_List_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("process.run", "run") + r.Set("process.start", "start") + r.Set("agentic.dispatch", "dispatch") + items := r.List("process.*") + assert.Len(t, items, 2) + assert.Contains(t, items, "run") + assert.Contains(t, items, "start") +} + +func TestRegistry_List_Bad_NoMatch(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "1") + items := r.List("beta.*") + assert.Empty(t, items) +} + +func TestRegistry_List_Ugly_SkipsDisabled(t *testing.T) { + r := NewRegistry[string]() + r.Set("process.run", "run") + r.Set("process.kill", "kill") + r.Disable("process.kill") + items := r.List("process.*") + assert.Len(t, items, 1) + assert.Equal(t, "run", items[0]) +} + +func TestRegistry_List_Good_WildcardAll(t *testing.T) { + r := NewRegistry[string]() + r.Set("a", "1") + r.Set("b", "2") + items := r.List("*") + assert.Len(t, items, 2) +} + +// --- Delete --- + +func TestRegistry_Delete_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Delete("alpha") + assert.True(t, res.OK) + assert.False(t, r.Has("alpha")) + assert.Equal(t, 0, r.Len()) +} + +func TestRegistry_Delete_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Delete("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Delete_Ugly_Locked(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Lock() + res := r.Delete("alpha") + assert.False(t, res.OK, "locked registry must reject delete") + assert.True(t, r.Has("alpha"), "item must survive failed delete") +} + +// --- Disable / Enable --- + +func TestRegistry_Disable_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + res := r.Disable("alpha") + assert.True(t, res.OK) + assert.True(t, r.Disabled("alpha")) + // Still exists via Get/Has + assert.True(t, r.Has("alpha")) + assert.True(t, r.Get("alpha").OK) +} + +func TestRegistry_Disable_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Disable("missing") + assert.False(t, res.OK) +} + +func TestRegistry_Disable_Ugly_EnableRoundtrip(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Disable("alpha") + assert.True(t, r.Disabled("alpha")) + + res := r.Enable("alpha") + assert.True(t, res.OK) + assert.False(t, r.Disabled("alpha")) + + // Verify Each sees it again + var seen []string + r.Each(func(name string, _ string) { seen = append(seen, name) }) + assert.Equal(t, []string{"alpha"}, seen) +} + +func TestRegistry_Enable_Bad_NotFound(t *testing.T) { + r := NewRegistry[string]() + res := r.Enable("missing") + assert.False(t, res.OK) +} + +// --- Lock --- + +func TestRegistry_Lock_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Lock() + assert.True(t, r.Locked()) + // Reads still work + assert.True(t, r.Get("alpha").OK) + assert.True(t, r.Has("alpha")) +} + +func TestRegistry_Lock_Bad_SetAfterLock(t *testing.T) { + r := NewRegistry[string]() + r.Lock() + res := r.Set("new", "value") + assert.False(t, res.OK) +} + +func TestRegistry_Lock_Ugly_UpdateAfterLock(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Lock() + res := r.Set("alpha", "second") + assert.False(t, res.OK, "locked registry must reject even updates") + assert.Equal(t, "first", r.Get("alpha").Value, "value must not change") +} + +// --- Seal --- + +func TestRegistry_Seal_Good(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "first") + r.Seal() + assert.True(t, r.Sealed()) + // Update existing OK + res := r.Set("alpha", "second") + assert.True(t, res.OK) + assert.Equal(t, "second", r.Get("alpha").Value) +} + +func TestRegistry_Seal_Bad_NewKey(t *testing.T) { + r := NewRegistry[string]() + r.Seal() + res := r.Set("new", "value") + assert.False(t, res.OK) +} + +func TestRegistry_Seal_Ugly_DeleteWhileSealed(t *testing.T) { + r := NewRegistry[string]() + r.Set("alpha", "value") + r.Seal() + // Delete is NOT locked by seal — only Set for new keys + res := r.Delete("alpha") + assert.True(t, res.OK, "seal blocks new keys, not deletes") +} + +// --- Open --- + +func TestRegistry_Open_Good(t *testing.T) { + r := NewRegistry[string]() + r.Lock() + assert.True(t, r.Locked()) + r.Open() + assert.False(t, r.Locked()) + // Can write again + res := r.Set("new", "value") + assert.True(t, res.OK) +} + +// --- Concurrency --- + +func TestRegistry_Ugly_ConcurrentReadWrite(t *testing.T) { + r := NewRegistry[int]() + var wg sync.WaitGroup + + // Concurrent writers + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Set(Sprintf("w-%d", n), n) + }(i) + } + + // Concurrent readers + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + r.Has(Sprintf("w-%d", n)) + r.Get(Sprintf("w-%d", n)) + r.Names() + r.Len() + }(i) + } + + wg.Wait() + assert.Equal(t, 50, r.Len()) +} diff --git a/runtime.go b/runtime.go index a0fab83..c9a8223 100644 --- a/runtime.go +++ b/runtime.go @@ -25,8 +25,19 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { return &ServiceRuntime[T]{core: c, opts: opts} } -func (r *ServiceRuntime[T]) Core() *Core { return r.core } -func (r *ServiceRuntime[T]) Options() T { return r.opts } +// Core returns the Core instance this service is registered with. +// +// c := s.Core() +func (r *ServiceRuntime[T]) Core() *Core { return r.core } + +// Options returns the typed options this service was created with. +// +// opts := s.Options() // MyOptions{BufferSize: 1024, ...} +func (r *ServiceRuntime[T]) Options() T { return r.opts } + +// Config is a shortcut to s.Core().Config(). +// +// host := s.Config().String("database.host") func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() } // --- Lifecycle --- @@ -137,10 +148,14 @@ func NewRuntime(app any) Result { return NewWithFactories(app, map[string]ServiceFactory{}) } +// ServiceName returns "Core" — the Runtime's service identity. func (r *Runtime) ServiceName() string { return "Core" } + +// ServiceStartup starts all services via the embedded Core. func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result { return r.Core.ServiceStartup(ctx, options) } +// ServiceShutdown stops all services via the embedded Core. func (r *Runtime) ServiceShutdown(ctx context.Context) Result { if r.Core != nil { return r.Core.ServiceShutdown(ctx) diff --git a/runtime_test.go b/runtime_test.go index 2d18f56..334bbec 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -15,7 +15,7 @@ type testOpts struct { Timeout int } -func TestServiceRuntime_Good(t *testing.T) { +func TestRuntime_ServiceRuntime_Good(t *testing.T) { c := New() opts := testOpts{URL: "https://api.lthn.ai", Timeout: 30} rt := NewServiceRuntime(c, opts) @@ -28,7 +28,7 @@ func TestServiceRuntime_Good(t *testing.T) { // --- NewWithFactories --- -func TestNewWithFactories_Good(t *testing.T) { +func TestRuntime_NewWithFactories_Good(t *testing.T) { r := NewWithFactories(nil, map[string]ServiceFactory{ "svc1": func() Result { return Result{Value: Service{}, OK: true} }, "svc2": func() Result { return Result{Value: Service{}, OK: true} }, @@ -38,14 +38,14 @@ func TestNewWithFactories_Good(t *testing.T) { assert.NotNil(t, rt.Core) } -func TestNewWithFactories_NilFactory_Good(t *testing.T) { +func TestRuntime_NewWithFactories_NilFactory_Good(t *testing.T) { r := NewWithFactories(nil, map[string]ServiceFactory{ "bad": nil, }) assert.True(t, r.OK) // nil factories skipped } -func TestNewRuntime_Good(t *testing.T) { +func TestRuntime_NewRuntime_Good(t *testing.T) { r := NewRuntime(nil) assert.True(t, r.OK) } diff --git a/service.go b/service.go index 14324db..46738ad 100644 --- a/service.go +++ b/service.go @@ -29,11 +29,11 @@ type Service struct { OnReload func() Result } -// serviceRegistry holds registered services. -type serviceRegistry struct { - services map[string]*Service +// ServiceRegistry holds registered services. Embeds Registry[*Service] +// for thread-safe named storage with insertion order. +type ServiceRegistry struct { + *Registry[*Service] lockEnabled bool - locked bool } // --- Core service methods --- @@ -44,12 +44,11 @@ type serviceRegistry struct { // r := c.Service("auth") func (c *Core) Service(name string, service ...Service) Result { if len(service) == 0 { - c.Lock("srv").Mutex.RLock() - svc, ok := c.services.services[name] - c.Lock("srv").Mutex.RUnlock() - if !ok || svc == nil { + r := c.services.Get(name) + if !r.OK { return Result{} } + svc := r.Value.(*Service) // Return the instance if available, otherwise the Service DTO if svc.Instance != nil { return Result{svc.Instance, true} @@ -61,21 +60,16 @@ func (c *Core) Service(name string, service ...Service) Result { return Result{E("core.Service", "service name cannot be empty", nil), false} } - c.Lock("srv").Mutex.Lock() - defer c.Lock("srv").Mutex.Unlock() - - if c.services.locked { + if c.services.Locked() { return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} } - if _, exists := c.services.services[name]; exists { + if c.services.Has(name) { return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false} } srv := &service[0] srv.Name = name - c.services.services[name] = srv - - return Result{OK: true} + return c.services.Set(name, srv) } // RegisterService registers a service instance by name. @@ -88,13 +82,10 @@ func (c *Core) RegisterService(name string, instance any) Result { return Result{E("core.RegisterService", "service name cannot be empty", nil), false} } - c.Lock("srv").Mutex.Lock() - defer c.Lock("srv").Mutex.Unlock() - - if c.services.locked { + if c.services.Locked() { return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} } - if _, exists := c.services.services[name]; exists { + if c.services.Has(name) { return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false} } @@ -103,22 +94,16 @@ func (c *Core) RegisterService(name string, instance any) Result { // Auto-discover lifecycle interfaces if s, ok := instance.(Startable); ok { srv.OnStart = func() Result { - if err := s.OnStartup(c.context); err != nil { - return Result{err, false} - } - return Result{OK: true} + return s.OnStartup(c.context) } } if s, ok := instance.(Stoppable); ok { srv.OnStop = func() Result { - if err := s.OnShutdown(context.Background()); err != nil { - return Result{err, false} - } - return Result{OK: true} + return s.OnShutdown(context.Background()) } } - c.services.services[name] = srv + c.services.Set(name, srv) // Auto-discover IPC handler if handler, ok := instance.(interface { @@ -157,18 +142,12 @@ func MustServiceFor[T any](c *Core, name string) T { return v } -// Services returns all registered service names. +// Services returns all registered service names in registration order. // // names := c.Services() func (c *Core) Services() []string { if c.services == nil { return nil } - c.Lock("srv").Mutex.RLock() - defer c.Lock("srv").Mutex.RUnlock() - var names []string - for k := range c.services.services { - names = append(names, k) - } - return names + return c.services.Names() } diff --git a/service_example_test.go b/service_example_test.go new file mode 100644 index 0000000..448ea7a --- /dev/null +++ b/service_example_test.go @@ -0,0 +1,50 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +func ExampleServiceFor() { + c := New( + WithService(func(c *Core) Result { + return c.Service("cache", Service{ + OnStart: func() Result { return Result{OK: true} }, + }) + }), + ) + + svc := c.Service("cache") + Println(svc.OK) + // Output: true +} + +func ExampleWithService() { + started := false + c := New( + WithService(func(c *Core) Result { + return c.Service("worker", Service{ + OnStart: func() Result { started = true; return Result{OK: true} }, + }) + }), + ) + c.ServiceStartup(context.Background(), nil) + Println(started) + c.ServiceShutdown(context.Background()) + // Output: true +} + +func ExampleWithServiceLock() { + c := New( + WithService(func(c *Core) Result { + return c.Service("allowed", Service{}) + }), + WithServiceLock(), + ) + + // Can't register after lock + r := c.Service("blocked", Service{}) + Println(r.OK) + // Output: false +} diff --git a/service_test.go b/service_test.go index 6bc2617..ca941b3 100644 --- a/service_test.go +++ b/service_test.go @@ -85,14 +85,14 @@ type autoLifecycleService struct { messages []Message } -func (s *autoLifecycleService) OnStartup(_ context.Context) error { +func (s *autoLifecycleService) OnStartup(_ context.Context) Result { s.started = true - return nil + return Result{OK: true} } -func (s *autoLifecycleService) OnShutdown(_ context.Context) error { +func (s *autoLifecycleService) OnShutdown(_ context.Context) Result { s.stopped = true - return nil + return Result{OK: true} } func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result { diff --git a/string_example_test.go b/string_example_test.go new file mode 100644 index 0000000..46e7dba --- /dev/null +++ b/string_example_test.go @@ -0,0 +1,35 @@ +package core_test + +import ( + + . "dappco.re/go/core" +) + +func ExampleContains() { + Println(Contains("hello world", "world")) + Println(Contains("hello world", "mars")) + // Output: + // true + // false +} + +func ExampleSplit() { + parts := Split("deploy/to/homelab", "/") + Println(parts) + // Output: [deploy to homelab] +} + +func ExampleJoin() { + Println(Join("/", "deploy", "to", "homelab")) + // Output: deploy/to/homelab +} + +func ExampleConcat() { + Println(Concat("hello", " ", "world")) + // Output: hello world +} + +func ExampleTrim() { + Println(Trim(" spaced ")) + // Output: spaced +} diff --git a/string_test.go b/string_test.go index 5c821ea..a8fb62d 100644 --- a/string_test.go +++ b/string_test.go @@ -9,61 +9,61 @@ import ( // --- String Operations --- -func TestHasPrefix_Good(t *testing.T) { +func TestString_HasPrefix_Good(t *testing.T) { assert.True(t, HasPrefix("--verbose", "--")) assert.True(t, HasPrefix("-v", "-")) assert.False(t, HasPrefix("hello", "-")) } -func TestHasSuffix_Good(t *testing.T) { +func TestString_HasSuffix_Good(t *testing.T) { assert.True(t, HasSuffix("test.go", ".go")) assert.False(t, HasSuffix("test.go", ".py")) } -func TestTrimPrefix_Good(t *testing.T) { +func TestString_TrimPrefix_Good(t *testing.T) { assert.Equal(t, "verbose", TrimPrefix("--verbose", "--")) assert.Equal(t, "hello", TrimPrefix("hello", "--")) } -func TestTrimSuffix_Good(t *testing.T) { +func TestString_TrimSuffix_Good(t *testing.T) { assert.Equal(t, "test", TrimSuffix("test.go", ".go")) assert.Equal(t, "test.go", TrimSuffix("test.go", ".py")) } -func TestContains_Good(t *testing.T) { +func TestString_Contains_Good(t *testing.T) { assert.True(t, Contains("hello world", "world")) assert.False(t, Contains("hello world", "mars")) } -func TestSplit_Good(t *testing.T) { +func TestString_Split_Good(t *testing.T) { assert.Equal(t, []string{"a", "b", "c"}, Split("a/b/c", "/")) } -func TestSplitN_Good(t *testing.T) { +func TestString_SplitN_Good(t *testing.T) { assert.Equal(t, []string{"key", "value=extra"}, SplitN("key=value=extra", "=", 2)) } -func TestJoin_Good(t *testing.T) { +func TestString_Join_Good(t *testing.T) { assert.Equal(t, "a/b/c", Join("/", "a", "b", "c")) } -func TestReplace_Good(t *testing.T) { +func TestString_Replace_Good(t *testing.T) { assert.Equal(t, "deploy.to.homelab", Replace("deploy/to/homelab", "/", ".")) } -func TestLower_Good(t *testing.T) { +func TestString_Lower_Good(t *testing.T) { assert.Equal(t, "hello", Lower("HELLO")) } -func TestUpper_Good(t *testing.T) { +func TestString_Upper_Good(t *testing.T) { assert.Equal(t, "HELLO", Upper("hello")) } -func TestTrim_Good(t *testing.T) { +func TestString_Trim_Good(t *testing.T) { assert.Equal(t, "hello", Trim(" hello ")) } -func TestRuneCount_Good(t *testing.T) { +func TestString_RuneCount_Good(t *testing.T) { assert.Equal(t, 5, RuneCount("hello")) assert.Equal(t, 1, RuneCount("🔥")) assert.Equal(t, 0, RuneCount("")) diff --git a/task.go b/task.go index acdf394..b761f9d 100644 --- a/task.go +++ b/task.go @@ -1,92 +1,61 @@ // SPDX-License-Identifier: EUPL-1.2 -// Background task dispatch for the Core framework. +// Background action dispatch for the Core framework. +// PerformAsync runs a named Action in a background goroutine with +// panic recovery and progress broadcasting. package core -import ( - "reflect" - "slices" - "strconv" -) +import "context" -// TaskState holds background task state. -type TaskState struct { - Identifier string - Task Task - Result any - Error error -} - -// PerformAsync dispatches a task in a background goroutine. -func (c *Core) PerformAsync(t Task) Result { +// PerformAsync dispatches a named action in a background goroutine. +// Broadcasts ActionTaskStarted, ActionTaskProgress, and ActionTaskCompleted +// as IPC messages so other services can track progress. +// +// r := c.PerformAsync("agentic.dispatch", opts) +// taskID := r.Value.(string) +func (c *Core) PerformAsync(action string, opts Options) Result { if c.shutdown.Load() { return Result{} } - taskID := Concat("task-", strconv.FormatUint(c.taskIDCounter.Add(1), 10)) - if tid, ok := t.(TaskWithIdentifier); ok { - tid.SetTaskIdentifier(taskID) - } - c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Task: t}) + taskID := ID() + + c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Action: action, Options: opts}) + c.waitGroup.Go(func() { defer func() { if rec := recover(); rec != nil { - err := E("core.PerformAsync", Sprint("panic: ", rec), nil) - c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: nil, Error: err}) + c.ACTION(ActionTaskCompleted{ + TaskIdentifier: taskID, + Action: action, + Result: Result{E("core.PerformAsync", Sprint("panic: ", rec), nil), false}, + }) } }() - r := c.PERFORM(t) - var err error - if !r.OK { - if e, ok := r.Value.(error); ok { - err = e - } else { - taskType := reflect.TypeOf(t) - typeName := "" - if taskType != nil { - typeName = taskType.String() - } - err = E("core.PerformAsync", Join(" ", "no handler found for task type", typeName), nil) - } - } - c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: r.Value, Error: err}) - }) - return Result{taskID, true} -} -// Progress broadcasts a progress update for a background task. -func (c *Core) Progress(taskID string, progress float64, message string, t Task) { - c.ACTION(ActionTaskProgress{TaskIdentifier: taskID, Task: t, Progress: progress, Message: message}) -} - -func (c *Core) Perform(t Task) Result { - c.ipc.taskMu.RLock() - handlers := slices.Clone(c.ipc.taskHandlers) - c.ipc.taskMu.RUnlock() + r := c.Action(action).Run(context.Background(), opts) - for _, h := range handlers { - r := h(c, t) - if r.OK { - return r - } - } - return Result{} -} + c.ACTION(ActionTaskCompleted{ + TaskIdentifier: taskID, + Action: action, + Result: r, + }) + }) -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() + return Result{taskID, true} } -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() +// Progress broadcasts a progress update for a background task. +// +// c.Progress(taskID, 0.5, "halfway done", "agentic.dispatch") +func (c *Core) Progress(taskID string, progress float64, message string, action string) { + c.ACTION(ActionTaskProgress{ + TaskIdentifier: taskID, + Action: action, + Progress: progress, + Message: message, + }) } -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) +// are in ipc.go — registration is IPC's responsibility. diff --git a/task_example_test.go b/task_example_test.go new file mode 100644 index 0000000..f088a1e --- /dev/null +++ b/task_example_test.go @@ -0,0 +1,50 @@ +package core_test + +import ( + "context" + + . "dappco.re/go/core" +) + +func ExampleTask_Run() { + c := New() + var order string + + c.Action("step.a", func(_ context.Context, _ Options) Result { + order += "a" + return Result{Value: "from-a", OK: true} + }) + c.Action("step.b", func(_ context.Context, opts Options) Result { + order += "b" + input := opts.Get("_input") + if input.OK { + return Result{Value: "got:" + input.Value.(string), OK: true} + } + return Result{OK: true} + }) + + c.Task("pipe", Task{ + Steps: []Step{ + {Action: "step.a"}, + {Action: "step.b", Input: "previous"}, + }, + }) + + r := c.Task("pipe").Run(context.Background(), c, NewOptions()) + Println(order) + Println(r.Value) + // Output: + // ab + // got:from-a +} + +func ExampleCore_PerformAsync() { + c := New() + c.Action("bg.work", func(_ context.Context, _ Options) Result { + return Result{Value: "done", OK: true} + }) + + r := c.PerformAsync("bg.work", NewOptions()) + Println(HasPrefix(r.Value.(string), "id-")) + // Output: true +} diff --git a/task_test.go b/task_test.go index 37876ad..b23600e 100644 --- a/task_test.go +++ b/task_test.go @@ -12,22 +12,21 @@ import ( // --- PerformAsync --- -func TestPerformAsync_Good(t *testing.T) { +func TestTask_PerformAsync_Good(t *testing.T) { c := New() var mu sync.Mutex var result string - c.RegisterTask(func(_ *Core, task Task) Result { + c.Action("work", func(_ context.Context, _ Options) Result { mu.Lock() result = "done" mu.Unlock() - return Result{"completed", true} + return Result{Value: "done", OK: true} }) - r := c.PerformAsync("work") + r := c.PerformAsync("work", NewOptions()) assert.True(t, r.OK) - taskID := r.Value.(string) - assert.NotEmpty(t, taskID) + assert.True(t, HasPrefix(r.Value.(string), "id-"), "should return task ID") time.Sleep(100 * time.Millisecond) @@ -36,24 +35,25 @@ func TestPerformAsync_Good(t *testing.T) { mu.Unlock() } -func TestPerformAsync_Progress_Good(t *testing.T) { +func TestTask_PerformAsync_Good_Progress(t *testing.T) { c := New() - c.RegisterTask(func(_ *Core, task Task) Result { + c.Action("tracked", func(_ context.Context, _ Options) Result { return Result{OK: true} }) - r := c.PerformAsync("work") + r := c.PerformAsync("tracked", NewOptions()) taskID := r.Value.(string) - c.Progress(taskID, 0.5, "halfway", "work") + c.Progress(taskID, 0.5, "halfway", "tracked") } -func TestPerformAsync_Completion_Good(t *testing.T) { +func TestTask_PerformAsync_Good_Completion(t *testing.T) { c := New() completed := make(chan ActionTaskCompleted, 1) - c.RegisterTask(func(_ *Core, task Task) Result { - return Result{Value: "result", OK: true} + c.Action("completable", func(_ context.Context, _ Options) Result { + return Result{Value: "output", OK: true} }) + c.RegisterAction(func(_ *Core, msg Message) Result { if evt, ok := msg.(ActionTaskCompleted); ok { completed <- evt @@ -61,18 +61,18 @@ func TestPerformAsync_Completion_Good(t *testing.T) { return Result{OK: true} }) - c.PerformAsync("work") + c.PerformAsync("completable", NewOptions()) select { case evt := <-completed: - assert.Nil(t, evt.Error) - assert.Equal(t, "result", evt.Result) + assert.True(t, evt.Result.OK) + assert.Equal(t, "output", evt.Result.Value) case <-time.After(2 * time.Second): t.Fatal("timed out waiting for completion") } } -func TestPerformAsync_NoHandler_Good(t *testing.T) { +func TestTask_PerformAsync_Bad_ActionNotRegistered(t *testing.T) { c := New() completed := make(chan ActionTaskCompleted, 1) @@ -83,43 +83,45 @@ func TestPerformAsync_NoHandler_Good(t *testing.T) { return Result{OK: true} }) - c.PerformAsync("unhandled") + c.PerformAsync("nonexistent", NewOptions()) select { case evt := <-completed: - assert.NotNil(t, evt.Error) + assert.False(t, evt.Result.OK, "unregistered action should fail") case <-time.After(2 * time.Second): t.Fatal("timed out") } } -func TestPerformAsync_AfterShutdown_Bad(t *testing.T) { +func TestTask_PerformAsync_Bad_AfterShutdown(t *testing.T) { c := New() + c.Action("work", func(_ context.Context, _ Options) Result { return Result{OK: true} }) + c.ServiceStartup(context.Background(), nil) c.ServiceShutdown(context.Background()) - r := c.PerformAsync("should not run") + r := c.PerformAsync("work", NewOptions()) assert.False(t, r.OK) } -// --- RegisterAction + RegisterActions --- +// --- RegisterAction + RegisterActions (broadcast handlers) --- -func TestRegisterAction_Good(t *testing.T) { +func TestTask_RegisterAction_Good(t *testing.T) { c := New() called := false c.RegisterAction(func(_ *Core, _ Message) Result { called = true return Result{OK: true} }) - c.Action(nil) + c.ACTION(nil) assert.True(t, called) } -func TestRegisterActions_Good(t *testing.T) { +func TestTask_RegisterActions_Good(t *testing.T) { c := New() count := 0 h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} } c.RegisterActions(h, h) - c.Action(nil) + c.ACTION(nil) assert.Equal(t, 2, count) } diff --git a/utils.go b/utils.go index 038e32e..e510b78 100644 --- a/utils.go +++ b/utils.go @@ -6,11 +6,75 @@ 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 --- + +// Println prints values to stdout with a newline. Replaces fmt.Println. +// +// core.Println("hello", 42, true) +func Println(args ...any) { + fmt.Println(args...) +} + // Print writes a formatted line to a writer, defaulting to os.Stdout. // // core.Print(nil, "hello %s", "world") // → stdout diff --git a/utils_test.go b/utils_test.go index 9b6be9d..68ba436 100644 --- a/utils_test.go +++ b/utils_test.go @@ -1,29 +1,112 @@ package core_test import ( - "errors" "testing" . "dappco.re/go/core" "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 +126,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 +135,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 +147,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,57 +163,57 @@ 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) { - err := errors.New("fail") +func TestUtils_Arg_ErrorDetection_Good(t *testing.T) { + err := NewError("fail") r := Arg(0, err) assert.True(t, r.OK) assert.Equal(t, err, r.Value) @@ -138,78 +221,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) { - err := errors.New("fail") +func TestUtils_Result_Result_WithError_Bad(t *testing.T) { + err := NewError("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)