From 2d52b83f600960ab962d642f32bde7e4c5ecb136 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Mar 2026 10:05:04 +0000 Subject: [PATCH] docs: rewrite documentation suite against AX spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex-authored docs covering primitives, commands, messaging, lifecycle, subsystems, and getting started — all using the current DTO/Options/Result API with concrete usage examples. Co-Authored-By: Virgil --- AGENTS.md | 110 ++++++ README.md | 199 +++++----- docs/commands.md | 177 +++++++++ docs/configuration.md | 196 +++------- docs/errors.md | 181 ++++----- docs/getting-started.md | 265 ++++++------- docs/index.md | 144 +++---- docs/lifecycle.md | 226 +++++------ docs/messaging.md | 313 +++++----------- docs/pkg/PACKAGE_STANDARDS.md | 652 +++++--------------------------- docs/pkg/core.md | 681 ++++------------------------------ docs/pkg/log.md | 112 +++--- docs/primitives.md | 169 +++++++++ docs/services.md | 317 +++++++--------- docs/subsystems.md | 158 ++++++++ docs/testing.md | 358 ++++-------------- 16 files changed, 1702 insertions(+), 2556 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/commands.md create mode 100644 docs/primitives.md create mode 100644 docs/subsystems.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c09e0cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Session Context + +Running on **Codex Max20 plan** with **1M context window** (Opus 4.6). This enables marathon sessions — use the full context for complex multi-repo work, dispatch coordination, and ecosystem-wide operations. Compact when needed, but don't be afraid of long sessions. + +## Project Overview + +Core (`forge.lthn.ai/core/go`) is a **dependency injection and service lifecycle framework** for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services. + +This is the foundation layer — it has no CLI, no GUI, and minimal dependencies (`go-io`, `go-log`, `testify`). + +## Build & Development Commands + +This project uses `core go` commands (no Taskfile). Build configuration lives in `.core/build.yaml`. + +```bash +# Run all tests +core go test + +# Generate test coverage +core go cov +core go cov --open # Opens coverage HTML report + +# Format, lint, vet +core go fmt +core go lint +core go vet + +# Quality assurance +core go qa # fmt + vet + lint + test +core go qa full # + race, vuln, security + +# Build +core build # Auto-detects project type +core build --ci # All targets, JSON output +``` + +Run a single test: `core go test --run TestName` + +## Architecture + +### Core Framework (`pkg/core/`) + +The `Core` struct is the central application container managing: +- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()` +- **Actions/IPC**: Message-passing system where services communicate via `ACTION(msg Message)` and register handlers via `RegisterAction()` +- **Lifecycle**: Services implementing `Startable` (OnStartup) and/or `Stoppable` (OnShutdown) interfaces are automatically called during app lifecycle + +Creating a Core instance: +```go +core, err := core.New( + core.WithService(myServiceFactory), + core.WithAssets(assets), + core.WithServiceLock(), // Prevents late service registration +) +``` + +### Service Registration Pattern + +Services are registered via factory functions that receive the Core instance: +```go +func NewMyService(c *core.Core) (any, error) { + return &MyService{runtime: core.NewServiceRuntime(c, opts)}, nil +} + +core.New(core.WithService(NewMyService)) +``` + +- `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method +- `WithName`: Explicitly names a service + +### ServiceRuntime Generic Helper (`runtime_pkg.go`) + +Embed `ServiceRuntime[T]` in services to get access to Core and typed options: +```go +type MyService struct { + *core.ServiceRuntime[MyServiceOptions] +} +``` + +### Error Handling (go-log) + +All errors MUST use `E()` from `go-log` (re-exported in `e.go`), never `fmt.Errorf`: +```go +return core.E("service.Method", "what failed", underlyingErr) +return core.E("service.Method", fmt.Sprintf("service %q not found", name), nil) +``` + +### Test Naming Convention + +Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern: +- `_Good`: Happy path tests +- `_Bad`: Expected error conditions +- `_Ugly`: Panic/edge cases + +## Packages + +| Package | Description | +|---------|-------------| +| `pkg/core` | DI container, service registry, lifecycle, query/task bus | +| `pkg/log` | Structured logger service with Core integration | + +## Go Workspace + +Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`. + +After adding modules: `go work sync` diff --git a/README.md b/README.md index 55dea27..eb2c12b 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,151 @@ -# Core +# CoreGO -[![codecov](https://codecov.io/gh/dAppCore/core/branch/main/graph/badge.svg)](https://codecov.io/gh/dAppCore/core) -[![Go Version](https://img.shields.io/github/go-mod/go-version/dAppCore/core)](https://go.dev/) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://opensource.org/licenses/EUPL-1.2) -[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core.svg)](https://pkg.go.dev/dappco.re/go/core) +Dependency injection, service lifecycle, command routing, and message-passing for Go. -Dependency injection and service lifecycle framework for Go. Zero external dependencies beyond `testify` for tests. +Import path: ```go import "dappco.re/go/core" ``` -## Quick Start +CoreGO is the foundation layer for the Core ecosystem. It gives you: + +- one container: `Core` +- 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. + +## Quick Example ```go -c := core.New(core.Options{ - {Key: "name", Value: "myapp"}, -}) +package main -// Register a service -c.Service("auth", core.Service{ - OnStart: func() core.Result { return core.Result{OK: true} }, - OnStop: func() core.Result { return core.Result{OK: true} }, -}) +import ( + "context" + "fmt" -// Retrieve it -r := c.Service("auth") -if r.OK { /* use r.Value */ } + "dappco.re/go/core" +) -// Register and run commands -c.Command("deploy", handler) -c.Cli().Run() -``` - -## Primitives - -### Options - -Key-value pairs that flow through all subsystems: - -```go -opts := core.Options{ - {Key: "name", Value: "brain"}, - {Key: "port", Value: 8080}, +type flushCacheTask struct { + Name string } -name := opts.String("name") -port := opts.Int("port") -ok := opts.Has("debug") -``` +func main() { + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) -### Result + 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} + }, + }) -Universal return type replacing `(value, error)`: + 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{} + }) -```go -r := c.Data().New(core.Options{{Key: "name", Value: "store"}}) -if r.OK { use(r.Value) } + c.Command("cache/flush", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(flushCacheTask{ + Name: opts.String("name"), + }) + }, + }) -// Map from Go conventions -r.Result(file, err) // OK = err == nil, Value = file -``` + if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") + } -### Service + r := c.Cli().Run("cache", "flush", "--name=session-store") + fmt.Println(r.Value) -Managed component with optional lifecycle hooks: - -```go -core.Service{ - Name: "cache", - Options: opts, - OnStart: func() core.Result { /* ... */ }, - OnStop: func() core.Result { /* ... */ }, - OnReload: func() core.Result { /* ... */ }, + _ = c.ServiceShutdown(context.Background()) } ``` -## Subsystems +## Core Surfaces -| Accessor | Purpose | -|----------|---------| -| `c.Options()` | Input configuration | -| `c.App()` | Application identity | -| `c.Data()` | Embedded/stored content | -| `c.Drive()` | Resource handle registry | -| `c.Fs()` | Local filesystem I/O | -| `c.Config()` | Configuration + feature flags | -| `c.Cli()` | CLI surface layer | -| `c.Command("path")` | Command tree | -| `c.Service("name")` | Service registry | -| `c.Lock("name")` | Named mutexes | -| `c.IPC()` | Message bus | +| Surface | Purpose | +|---------|---------| +| `Core` | Central container and access point | +| `Service` | Managed lifecycle component | +| `Command` | Path-based executable operation | +| `Cli` | CLI surface over the command tree | +| `Data` | Embedded filesystem mounts | +| `Drive` | Named transport handles | +| `Fs` | Local filesystem operations | +| `Config` | Runtime settings and feature flags | +| `I18n` | Locale collection and translation delegation | +| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery | -## IPC / Message Bus +## AX-Friendly Model -Fire-and-forget actions, request/response queries, and task dispatch: +CoreGO follows the same design direction as the AX spec: -```go -// Register a handler -c.IPC().On(func(c *core.Core, msg core.Message) core.Result { - // handle message - return core.Result{OK: true} -}) - -// Dispatch -c.IPC().Action(core.Message{Action: "cache.flush"}) -``` +- 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 ```bash -go get dappco.re/go/core@latest +go get dappco.re/go/core ``` +Requires Go 1.26 or later. + +## Test + +```bash +core go test +``` + +Or with the standard toolchain: + +```bash +go test ./... +``` + +## 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 | + ## License -[EUPL-1.2](https://opensource.org/licenses/EUPL-1.2) +EUPL-1.2 diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..46e2022 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,177 @@ +--- +title: Commands +description: Path-based command registration and CLI execution. +--- + +# Commands + +Commands are one of the most AX-native parts of CoreGO. The path is the identity. + +## Register a Command + +```go +c.Command("deploy/to/homelab", core.Command{ + Action: func(opts core.Options) core.Result { + target := opts.String("target") + return core.Result{Value: "deploying to " + target, OK: true} + }, +}) +``` + +## Command Paths + +Paths must be clean: + +- no empty path +- no leading slash +- no trailing slash +- no double slash + +These paths are valid: + +```text +deploy +deploy/to/homelab +workspace/create +``` + +These are rejected: + +```text +/deploy +deploy/ +deploy//to +``` + +## Parent Commands Are Auto-Created + +When you register `deploy/to/homelab`, CoreGO also creates placeholder parents if they do not already exist: + +- `deploy` +- `deploy/to` + +This makes the path tree navigable without extra setup. + +## Read a Command Back + +```go +r := c.Command("deploy/to/homelab") +if r.OK { + cmd := r.Value.(*core.Command) + _ = cmd +} +``` + +## Run a Command Directly + +```go +cmd := c.Command("deploy/to/homelab").Value.(*core.Command) + +r := cmd.Run(core.Options{ + {Key: "target", Value: "uk-prod"}, +}) +``` + +If `Action` is nil, `Run` returns `Result{OK:false}` with a structured error. + +## Run Through the CLI Surface + +```go +r := c.Cli().Run("deploy", "to", "homelab", "--target=uk-prod", "--debug") +``` + +`Cli.Run` resolves the longest matching command path from the arguments, then converts the remaining args into `core.Options`. + +## Flag Parsing Rules + +### Double Dash + +```text +--target=uk-prod -> key "target", value "uk-prod" +--debug -> key "debug", value true +``` + +### Single Dash + +```text +-v -> key "v", value true +-n=4 -> key "n", value "4" +``` + +### Positional Arguments + +Non-flag arguments after the command path are stored as repeated `_arg` options. + +```go +r := c.Cli().Run("workspace", "open", "alpha") +``` + +That produces an option like: + +```go +core.Option{Key: "_arg", Value: "alpha"} +``` + +### Important Details + +- flag values stay as strings +- `opts.Int("port")` only works if some code stored an actual `int` +- invalid flags such as `-verbose` and `--v` are ignored + +## Help Output + +`Cli.PrintHelp()` prints executable commands: + +```go +c.Cli().PrintHelp() +``` + +It skips: + +- hidden commands +- placeholder parents with no `Action` and no `Lifecycle` + +Descriptions are resolved through `cmd.I18nKey()`. + +## I18n Description Keys + +If `Description` is empty, CoreGO derives a key from the path. + +```text +deploy -> cmd.deploy.description +deploy/to/homelab -> cmd.deploy.to.homelab.description +workspace/create -> cmd.workspace.create.description +``` + +If `Description` is already set, CoreGO uses it as-is. + +## Lifecycle Commands + +Commands can also delegate to a lifecycle implementation. + +```go +type daemonCommand struct{} + +func (d *daemonCommand) Start(opts core.Options) core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Stop() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Restart() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Reload() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Signal(sig string) core.Result { return core.Result{Value: sig, OK: true} } + +c.Command("agent/serve", core.Command{ + Lifecycle: &daemonCommand{}, +}) +``` + +Important behavior: + +- `Start` falls back to `Run` when `Lifecycle` is nil +- `Stop`, `Restart`, `Reload`, and `Signal` return an empty `Result` when `Lifecycle` is nil + +## List Command Paths + +```go +paths := c.Commands() +``` + +Like the service registry, the command registry is map-backed, so iteration order is not guaranteed. diff --git a/docs/configuration.md b/docs/configuration.md index f5f992c..0a0cf11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,178 +1,96 @@ --- -title: Configuration Options -description: WithService, WithName, WithApp, WithAssets, and WithServiceLock options. +title: Configuration +description: Constructor options, runtime settings, and feature flags. --- -# Configuration Options +# Configuration -The `Core` is configured through **options** -- functions with the signature `func(*Core) error`. These are passed to `core.New()` and applied in order during initialisation. +CoreGO uses two different configuration layers: + +- constructor-time `core.Options` +- runtime `c.Config()` + +## Constructor-Time Options ```go -type Option func(*Core) error +c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, +}) ``` -## Available Options +### Current Behavior -### WithService +- `New` accepts `opts ...Options` +- the current implementation copies only the first `Options` slice +- the `name` key is applied to `c.App().Name` + +If you need more constructor data, put it in the first `core.Options` slice. + +## Runtime Settings with `Config` + +Use `c.Config()` for mutable process settings. ```go -func WithService(factory func(*Core) (any, error)) Option +c.Config().Set("workspace.root", "/srv/workspaces") +c.Config().Set("max_agents", 8) +c.Config().Set("debug", true) ``` -Registers a service using a factory function. The service name is **auto-discovered** from the Go package path of the returned type (the last path segment, lowercased). +Read them back with: ```go -// If the returned type is from package "myapp/services/calculator", -// the service name becomes "calculator". -core.New( - core.WithService(calculator.NewService), -) +root := c.Config().String("workspace.root") +maxAgents := c.Config().Int("max_agents") +debug := c.Config().Bool("debug") +raw := c.Config().Get("workspace.root") ``` -`WithService` also performs two automatic behaviours: +### Important Details -1. **Name discovery** -- uses `reflect` to extract the package name from the returned type. -2. **IPC handler discovery** -- if the service has a `HandleIPCEvents(c *Core, msg Message) error` method, it is registered as an action handler automatically. +- missing keys return zero values +- typed accessors do not coerce strings into ints or bools +- `Get` returns `core.Result` -If the factory returns an error or `nil`, `New()` fails with an error. +## Feature Flags -If the returned type has no package path (e.g. a primitive or anonymous type), `New()` fails with a descriptive error. - -### WithName +`Config` also tracks named feature flags. ```go -func WithName(name string, factory func(*Core) (any, error)) Option +c.Config().Enable("workspace.templates") +c.Config().Enable("agent.review") +c.Config().Disable("agent.review") ``` -Registers a service with an **explicit name**. Use this when the auto-discovered name would be wrong (e.g. anonymous functions, or when you want a different name). +Read them with: ```go -core.New( - core.WithName("greet", func(c *core.Core) (any, error) { - return &Greeter{}, nil - }), -) +enabled := c.Config().Enabled("workspace.templates") +features := c.Config().EnabledFeatures() ``` -Unlike `WithService`, `WithName` does **not** auto-discover IPC handlers. If your service needs to handle actions, register the handler manually: +Feature names are case-sensitive. + +## `ConfigVar[T]` + +Use `ConfigVar[T]` when you need a typed value that can also represent “set versus unset”. ```go -core.WithName("greet", func(c *core.Core) (any, error) { - svc := &Greeter{} - c.RegisterAction(svc.HandleIPCEvents) - return svc, nil -}), -``` +theme := core.NewConfigVar("amber") -### WithApp - -```go -func WithApp(app any) Option -``` - -Injects a GUI runtime (e.g. a Wails App instance) into the Core. The app is stored in the `Core.App` field and can be accessed globally via `core.App()` after `SetInstance` is called. - -```go -core.New( - core.WithApp(wailsApp), -) -``` - -This is primarily used for desktop applications where services need access to the windowing runtime. - -### WithAssets - -```go -func WithAssets(fs embed.FS) Option -``` - -Registers the application's embedded assets filesystem. Retrieve it later with `c.Assets()`. - -```go -//go:embed frontend/dist -var assets embed.FS - -core.New( - core.WithAssets(assets), -) -``` - -### WithServiceLock - -```go -func WithServiceLock() Option -``` - -Prevents any services from being registered after `New()` returns. Any call to `RegisterService` after initialisation will return an error. - -```go -c, err := core.New( - core.WithService(myService), - core.WithServiceLock(), // no more services can be added -) -// c.RegisterService("late", &svc) -> error -``` - -This is a safety measure to ensure all services are declared upfront, preventing accidental late-binding that could cause ordering or lifecycle issues. - -**How it works:** The lock is recorded during option processing but only **applied** after all options have been processed. This means options that register services (like `WithService`) can appear in any order relative to `WithServiceLock`. - -## Option Ordering - -Options are applied in the order they are passed to `New()`. This means: - -- Services registered earlier are available to later factories (via `c.Service()`). -- `WithServiceLock()` can appear at any position -- it only takes effect after all options have been processed. -- `WithApp` and `WithAssets` can appear at any position. - -```go -core.New( - core.WithServiceLock(), // recorded, not yet applied - core.WithService(factory1), // succeeds (lock not yet active) - core.WithService(factory2), // succeeds - // After New() returns, the lock is applied -) -``` - -## Global Instance - -For applications that need global access to the Core (typically GUI runtimes), there is a global instance mechanism: - -```go -// Set the global instance (typically during app startup) -core.SetInstance(c) - -// Retrieve it (panics if not set) -app := core.App() - -// Non-panicking access -c := core.GetInstance() -if c == nil { - // not set +if theme.IsSet() { + fmt.Println(theme.Get()) } -// Clear it (useful in tests) -core.ClearInstance() +theme.Unset() ``` -These functions are thread-safe. +This is useful for package-local state where zero values are not enough to describe configuration presence. -## Features +## Recommended Pattern -The `Core` struct includes a `Features` field for simple feature flagging: +Use the two layers for different jobs: -```go -c.Features.Flags = []string{"experimental-ui", "beta-api"} +- put startup identity such as `name` into `core.Options` +- put mutable runtime values and feature switches into `c.Config()` -if c.Features.IsEnabled("experimental-ui") { - // enable experimental UI -} -``` - -Feature flags are string-matched (case-sensitive). This is a lightweight mechanism -- for complex feature management, register a dedicated service. - -## Related Pages - -- [Services](services.md) -- service registration and retrieval -- [Lifecycle](lifecycle.md) -- startup/shutdown after configuration -- [Getting Started](getting-started.md) -- end-to-end example +That keeps constructor intent separate from live process state. diff --git a/docs/errors.md b/docs/errors.md index 866b4ad..9b7d3f3 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,139 +1,120 @@ --- title: Errors -description: The E() helper function and Error struct for contextual error handling. +description: Structured errors, logging helpers, and panic recovery. --- # Errors -Core provides a standardised error type and constructor for wrapping errors with operational context. This makes it easier to trace where an error originated and provide meaningful feedback. +CoreGO treats failures as structured operational data. -## The Error Struct +Repository convention: use `E()` instead of `fmt.Errorf` for framework and service errors. + +## `Err` + +The structured error type is: ```go -type Error struct { - Op string // the operation, e.g. "config.Load" - Msg string // human-readable explanation - Err error // the underlying error (may be nil) +type Err struct { + Operation string + Message string + Cause error + Code string } ``` -- **Op** identifies the operation that failed. Use the format `package.Function` or `service.Method`. -- **Msg** is a human-readable message explaining what went wrong. -- **Err** is the underlying error being wrapped. May be `nil` for root errors. +## Create Errors -## The E() Helper - -`E()` is the primary way to create contextual errors: +### `E` ```go -func E(op, msg string, err error) error +err := core.E("workspace.Load", "failed to read workspace manifest", cause) ``` -### With an Underlying Error +### `Wrap` ```go -data, err := os.ReadFile(path) -if err != nil { - return core.E("config.Load", "failed to read config file", err) -} +err := core.Wrap(cause, "workspace.Load", "manifest parse failed") ``` -This produces: `config.Load: failed to read config file: open /path/to/file: no such file or directory` - -### Without an Underlying Error (Root Error) +### `WrapCode` ```go -if name == "" { - return core.E("user.Create", "name cannot be empty", nil) -} +err := core.WrapCode(cause, "WORKSPACE_INVALID", "workspace.Load", "manifest parse failed") ``` -This produces: `user.Create: name cannot be empty` - -When `err` is `nil`, the `Err` field is not set and the output omits the trailing error. - -## Error Output Format - -The `Error()` method produces a string in one of two formats: - -``` -// With underlying error: -op: msg: underlying error text - -// Without underlying error: -op: msg -``` - -## Unwrapping - -`Error` implements the `Unwrap() error` method, making it compatible with Go's `errors.Is` and `errors.As`: +### `NewCode` ```go -originalErr := errors.New("connection refused") -wrapped := core.E("db.Connect", "failed to connect", originalErr) - -// errors.Is traverses the chain -errors.Is(wrapped, originalErr) // true - -// errors.As extracts the Error -var coreErr *core.Error -if errors.As(wrapped, &coreErr) { - fmt.Println(coreErr.Op) // "db.Connect" - fmt.Println(coreErr.Msg) // "failed to connect" -} +err := core.NewCode("NOT_FOUND", "workspace not found") ``` -## Building Error Chains - -Because `E()` wraps errors, you can build a logical call stack by wrapping at each layer: +## Inspect Errors ```go -// Low-level -func readConfig(path string) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, core.E("config.readConfig", "failed to read file", err) - } - return data, nil -} - -// Mid-level -func loadConfig() (*Config, error) { - data, err := readConfig("/etc/app/config.yaml") - if err != nil { - return nil, core.E("config.Load", "failed to load configuration", err) - } - // parse data... - return cfg, nil -} - -// Top-level -func (s *Service) OnStartup(ctx context.Context) error { - cfg, err := loadConfig() - if err != nil { - return core.E("service.OnStartup", "startup failed", err) - } - s.config = cfg - return nil -} +op := core.Operation(err) +code := core.ErrorCode(err) +msg := core.ErrorMessage(err) +root := core.Root(err) +stack := core.StackTrace(err) +pretty := core.FormatStackTrace(err) ``` -The resulting error message reads like a stack trace: +These helpers keep the operational chain visible without extra type assertions. -``` -service.OnStartup: startup failed: config.Load: failed to load configuration: config.readConfig: failed to read file: open /etc/app/config.yaml: no such file or directory +## Join and Standard Wrappers + +```go +combined := core.ErrorJoin(err1, err2) +same := core.Is(combined, err1) ``` -## Conventions +`core.As` and `core.NewError` mirror the standard library for convenience. -1. **Op format**: Use `package.Function` or `service.Method`. Keep it short and specific. -2. **Msg format**: Use lowercase, describe what failed (not what succeeded). Write messages that make sense to a developer reading logs. -3. **Wrap at boundaries**: Wrap with `E()` when crossing package or layer boundaries, not at every function call. -4. **Always return `error`**: `E()` returns the `error` interface, not `*Error`. Callers should not need to know the concrete type. -5. **Nil underlying error**: Pass `nil` for `err` when creating root errors (errors that do not wrap another error). +## Log-and-Return Helpers -## Related Pages +`Core` exposes two convenience wrappers: -- [Services](services.md) -- services that return errors -- [Lifecycle](lifecycle.md) -- lifecycle error aggregation -- [Testing](testing.md) -- testing error conditions (`_Bad` suffix) +```go +r1 := c.LogError(err, "workspace.Load", "workspace load failed") +r2 := c.LogWarn(err, "workspace.Load", "workspace load degraded") +``` + +These log through the default logger and return `core.Result`. + +You can also use the underlying `ErrorLog` directly: + +```go +r := c.Log().Error(err, "workspace.Load", "workspace load failed") +``` + +`Must` logs and then panics when the error is non-nil: + +```go +c.Must(err, "workspace.Load", "workspace load failed") +``` + +## Panic Recovery + +`ErrorPanic` handles process-safe panic capture. + +```go +defer c.Error().Recover() +``` + +Run background work with recovery: + +```go +c.Error().SafeGo(func() { + panic("captured") +}) +``` + +If `ErrorPanic` has a configured crash file path, it appends JSON crash reports and `Reports(n)` reads them back. + +That crash file path is currently internal state on `ErrorPanic`, not a public constructor option on `Core.New()`. + +## Logging and Error Context + +The logging subsystem automatically extracts `op` and logical stack information from structured errors when those values are present in the key-value list. + +That makes errors created with `E`, `Wrap`, or `WrapCode` much easier to follow in logs. diff --git a/docs/getting-started.md b/docs/getting-started.md index 0f6b82f..d2d8166 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,191 +1,208 @@ --- title: Getting Started -description: How to create a Core application and register services. +description: Build a first CoreGO application with the current API. --- # Getting Started -This guide walks you through creating a Core application, registering services, and running the lifecycle. +This page shows the shortest path to a useful CoreGO application using the API that exists in this repository today. -## Installation +## Install ```bash -go get forge.lthn.ai/core/go +go get dappco.re/go/core ``` -## Creating a Core Instance +## Create a Core -Everything starts with `core.New()`. It accepts a variadic list of `Option` functions that configure the container before it is returned. +`New` takes zero or more `core.Options` slices, but the current implementation only reads the first one. In practice, treat the constructor as `core.New(core.Options{...})`. ```go package main -import "forge.lthn.ai/core/go/pkg/core" +import "dappco.re/go/core" func main() { - c, err := core.New() - if err != nil { - panic(err) - } - _ = c // empty container, ready for use + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) + + _ = c } ``` -In practice you will pass options to register services, embed assets, or lock the registry: +The `name` option is copied into `c.App().Name`. + +## Register a Service + +Services are registered explicitly with a name and a `core.Service` DTO. ```go -c, err := core.New( - core.WithService(mypackage.NewService), - core.WithAssets(embeddedFS), - core.WithServiceLock(), -) +c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("audit service started", "app", c.App().Name) + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("audit service stopped", "app", c.App().Name) + return core.Result{OK: true} + }, +}) ``` -See [Configuration](configuration.md) for the full list of options. +This registry stores `core.Service` values. It is a lifecycle registry, not a typed object container. -## Registering a Service - -Services are registered via **factory functions**. A factory receives the `*Core` and returns `(any, error)`: +## Register a Query, Task, and Command ```go -package greeter +type workspaceCountQuery struct{} -import "forge.lthn.ai/core/go/pkg/core" - -type Service struct { - greeting string +type createWorkspaceTask struct { + Name string } -func (s *Service) Hello(name string) string { - return s.greeting + ", " + name + "!" -} +c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case workspaceCountQuery: + return core.Result{Value: 1, OK: true} + } + return core.Result{} +}) -func NewService(c *core.Core) (any, error) { - return &Service{greeting: "Hello"}, nil +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.Command("workspace/create", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(createWorkspaceTask{ + Name: opts.String("name"), + }) + }, +}) +``` + +## Start the Runtime + +```go +if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") } ``` -Register it with `WithService`: +`ServiceStartup` returns `core.Result`, not `error`. + +## Run Through the CLI Surface ```go -c, err := core.New( - core.WithService(greeter.NewService), -) +r := c.Cli().Run("workspace", "create", "--name=alpha") +if r.OK { + fmt.Println("created:", r.Value) +} ``` -`WithService` automatically discovers the service name from the package path. In this case, the service is registered under the name `"greeter"`. +For flags with values, the CLI stores the value as a string. `--name=alpha` becomes `opts.String("name") == "alpha"`. -If you need to control the name explicitly, use `WithName`: +## Query the System ```go -c, err := core.New( - core.WithName("greet", greeter.NewService), -) +count := c.QUERY(workspaceCountQuery{}) +if count.OK { + fmt.Println("workspace count:", count.Value) +} ``` -See [Services](services.md) for the full registration API and the `ServiceRuntime` helper. - -## Retrieving a Service - -Once registered, services can be retrieved by name: +## Shut Down Cleanly ```go -// Untyped retrieval (returns any) -svc := c.Service("greeter") - -// Type-safe retrieval (returns error if not found or wrong type) -greet, err := core.ServiceFor[*greeter.Service](c, "greeter") - -// Panicking retrieval (for init-time wiring where failure is fatal) -greet := core.MustServiceFor[*greeter.Service](c, "greeter") +_ = c.ServiceShutdown(context.Background()) ``` -## Running the Lifecycle +Shutdown cancels `c.Context()`, broadcasts `ActionServiceShutdown{}`, waits for background tasks to finish, and then runs service stop hooks. -Services that implement `Startable` and/or `Stoppable` are automatically called during startup and shutdown: - -```go -import "context" - -// Start all Startable services (in registration order) -err := c.ServiceStartup(context.Background(), nil) - -// ... application runs ... - -// Stop all Stoppable services (in reverse registration order) -err = c.ServiceShutdown(context.Background()) -``` - -See [Lifecycle](lifecycle.md) for details on the `Startable` and `Stoppable` interfaces. - -## Sending Messages - -Services communicate through the message bus without needing direct imports of each other: - -```go -// Broadcast to all handlers (fire-and-forget) -err := c.ACTION(MyEvent{Data: "something happened"}) - -// Request data from the first handler that responds -result, handled, err := c.QUERY(MyQuery{Key: "setting"}) - -// Ask a handler to perform work -result, handled, err := c.PERFORM(MyTask{Input: "data"}) -``` - -See [Messaging](messaging.md) for the full message bus API. - -## Putting It All Together - -Here is a minimal but complete application: +## Full Example ```go package main import ( - "context" - "fmt" + "context" + "fmt" - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/go/pkg/log" + "dappco.re/go/core" ) +type workspaceCountQuery struct{} + +type createWorkspaceTask struct { + Name string +} + func main() { - c, err := core.New( - core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})), - core.WithServiceLock(), - ) - if err != nil { - panic(err) - } + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) - // Start lifecycle - if err := c.ServiceStartup(context.Background(), nil); err != nil { - panic(err) - } + c.Config().Set("workspace.root", "/tmp/agent-workbench") + c.Config().Enable("workspace.templates") - // Use services - logger := core.MustServiceFor[*log.Service](c, "log") - fmt.Println("Logger started at level:", logger.Level()) + c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("service started", "service", "audit") + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("service stopped", "service", "audit") + return core.Result{OK: true} + }, + }) - // Query the log level through the message bus - level, handled, _ := c.QUERY(log.QueryLevel{}) - if handled { - fmt.Println("Log level via QUERY:", level) - } + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case workspaceCountQuery: + return core.Result{Value: 1, OK: true} + } + return core.Result{} + }) - // Clean shutdown - if err := c.ServiceShutdown(context.Background()); err != nil { - fmt.Println("shutdown error:", err) - } + 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.Command("workspace/create", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(createWorkspaceTask{ + Name: opts.String("name"), + }) + }, + }) + + if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") + } + + created := c.Cli().Run("workspace", "create", "--name=alpha") + fmt.Println("created:", created.Value) + + count := c.QUERY(workspaceCountQuery{}) + fmt.Println("workspace count:", count.Value) + + _ = c.ServiceShutdown(context.Background()) } ``` ## Next Steps -- [Services](services.md) -- service registration patterns in depth -- [Lifecycle](lifecycle.md) -- startup/shutdown ordering and error handling -- [Messaging](messaging.md) -- ACTION, QUERY, and PERFORM -- [Configuration](configuration.md) -- all `With*` options -- [Errors](errors.md) -- the `E()` error helper -- [Testing](testing.md) -- test conventions and helpers +- Read [primitives.md](primitives.md) next so the repeated shapes are clear. +- Read [commands.md](commands.md) if you are building a CLI-first system. +- Read [messaging.md](messaging.md) if services need to collaborate without direct imports. diff --git a/docs/index.md b/docs/index.md index 40c70ef..0ec8647 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,36 +1,33 @@ --- -title: Core Go Framework -description: Dependency injection and service lifecycle framework for Go. +title: CoreGO +description: AX-first documentation for the CoreGO framework. --- -# Core Go Framework +# CoreGO -Core (`forge.lthn.ai/core/go`) is a dependency injection and service lifecycle framework for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services. +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. -This is the foundation layer of the ecosystem. It has no CLI, no GUI, and minimal dependencies. +The current module path is `dappco.re/go/core`. -## Installation +## AX View -```bash -go get forge.lthn.ai/core/go -``` +CoreGO already follows the main AX ideas from RFC-025: -Requires Go 1.26 or later. +- 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 It Does +## What CoreGO Owns -Core solves three problems that every non-trivial Go application eventually faces: - -1. **Service wiring** -- how do you register, retrieve, and type-check services without import cycles? -2. **Lifecycle management** -- how do you start and stop services in the right order? -3. **Decoupled communication** -- how do services talk to each other without knowing each other's types? - -## Packages - -| Package | Purpose | +| Surface | Purpose | |---------|---------| -| [`pkg/core`](services.md) | DI container, service registry, lifecycle, message bus | -| `pkg/log` | Structured logger service with Core integration | +| `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 | ## Quick Example @@ -38,59 +35,78 @@ Core solves three problems that every non-trivial Go application eventually face package main import ( - "context" - "fmt" + "context" + "fmt" - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/go/pkg/log" + "dappco.re/go/core" ) +type flushCacheTask struct { + Name string +} + func main() { - c, err := core.New( - core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})), - core.WithServiceLock(), // Prevent late registration - ) - if err != nil { - panic(err) - } + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) - // Start all services - if err := c.ServiceStartup(context.Background(), nil); err != nil { - panic(err) - } + 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} + }, + }) - // Type-safe retrieval - logger, err := core.ServiceFor[*log.Service](c, "log") - if err != nil { - panic(err) - } - fmt.Println("Log level:", logger.Level()) + 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{} + }) - // Shut down (reverse order) - _ = c.ServiceShutdown(context.Background()) + 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()) } ``` -## Documentation +## Documentation Paths -| Page | Covers | +| Path | Covers | |------|--------| -| [Getting Started](getting-started.md) | Creating a Core app, registering your first service | -| [Services](services.md) | Service registration, `ServiceRuntime`, factory pattern | -| [Lifecycle](lifecycle.md) | `Startable`/`Stoppable` interfaces, startup/shutdown order | -| [Messaging](messaging.md) | ACTION, QUERY, PERFORM -- the message bus | -| [Configuration](configuration.md) | `WithService`, `WithName`, `WithAssets`, `WithServiceLock` options | -| [Testing](testing.md) | Test naming conventions, test helpers, fuzz testing | -| [Errors](errors.md) | `E()` helper, `Error` struct, unwrapping | +| [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 | -## Dependencies +## Good Reading Order -Core is deliberately minimal: - -- `forge.lthn.ai/core/go-io` -- abstract storage (local, S3, SFTP, WebDAV) -- `forge.lthn.ai/core/go-log` -- structured logging -- `github.com/stretchr/testify` -- test assertions (test-only) - -## Licence - -EUPL-1.2 +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. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 3048d6f..59ba644 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -1,165 +1,111 @@ --- title: Lifecycle -description: Startable and Stoppable interfaces, startup and shutdown ordering. +description: Startup, shutdown, context ownership, and background task draining. --- # Lifecycle -Core manages the startup and shutdown of services through two opt-in interfaces. Services implement one or both to participate in the application lifecycle. +CoreGO manages lifecycle through `core.Service` callbacks, not through reflection or implicit interfaces. -## Interfaces - -### Startable +## Service Hooks ```go -type Startable interface { - OnStartup(ctx context.Context) error -} -``` - -Services implementing `Startable` have their `OnStartup` method called during `ServiceStartup`. This is the place to: - -- Open database connections -- Register message bus handlers (queries, tasks) -- Start background workers -- Validate configuration - -### Stoppable - -```go -type Stoppable interface { - OnShutdown(ctx context.Context) error -} -``` - -Services implementing `Stoppable` have their `OnShutdown` method called during `ServiceShutdown`. This is the place to: - -- Close database connections -- Flush buffers -- Save state -- Cancel background workers - -A service can implement both interfaces: - -```go -type Service struct{} - -func (s *Service) OnStartup(ctx context.Context) error { - // Initialise resources - return nil -} - -func (s *Service) OnShutdown(ctx context.Context) error { - // Release resources - return nil -} -``` - -## Ordering - -### Startup: Registration Order - -Services are started in the order they were registered. If you register services A, B, C (in that order), their `OnStartup` methods are called as A, B, C. - -### Shutdown: Reverse Registration Order - -Services are stopped in **reverse** registration order. If A, B, C were registered, their `OnShutdown` methods are called as C, B, A. - -This ensures that services which depend on earlier services are torn down first. - -```go -c, err := core.New() - -_ = c.RegisterService("database", dbService) // started 1st, stopped 3rd -_ = c.RegisterService("cache", cacheService) // started 2nd, stopped 2nd -_ = c.RegisterService("api", apiService) // started 3rd, stopped 1st - -_ = c.ServiceStartup(ctx, nil) // database -> cache -> api -_ = c.ServiceShutdown(ctx) // api -> cache -> database -``` - -## ServiceStartup - -```go -func (c *Core) ServiceStartup(ctx context.Context, options any) error -``` - -`ServiceStartup` does two things, in order: - -1. Calls `OnStartup(ctx)` on every `Startable` service, in registration order. -2. Broadcasts an `ActionServiceStartup{}` message via the message bus. - -If any service returns an error, it is collected but does **not** prevent other services from starting. All errors are aggregated with `errors.Join` and returned together. - -If the context is cancelled before all services have started, the remaining services are skipped and the context error is included in the aggregate. - -## ServiceShutdown - -```go -func (c *Core) ServiceShutdown(ctx context.Context) error -``` - -`ServiceShutdown` does three things, in order: - -1. Broadcasts an `ActionServiceShutdown{}` message via the message bus. -2. Calls `OnShutdown(ctx)` on every `Stoppable` service, in reverse registration order. -3. Waits for any in-flight `PerformAsync` background tasks to complete (respecting the context deadline). - -As with startup, errors are aggregated rather than short-circuiting. If the context is cancelled during shutdown, the remaining services are skipped but the method still waits for background tasks. - -## Built-in Lifecycle Messages - -Core broadcasts two action messages as part of the lifecycle. You can listen for these in any registered action handler: - -| Message | When | -|---------|------| -| `ActionServiceStartup{}` | After all `Startable` services have been called | -| `ActionServiceShutdown{}` | Before `Stoppable` services are called | - -```go -c.RegisterAction(func(c *core.Core, msg core.Message) error { - switch msg.(type) { - case core.ActionServiceStartup: - // All services are up - case core.ActionServiceShutdown: - // Shutdown is beginning - } - return nil +c.Service("cache", core.Service{ + OnStart: func() core.Result { + return core.Result{OK: true} + }, + OnStop: func() core.Result { + return core.Result{OK: true} + }, }) ``` -## Error Handling +Only services with `OnStart` appear in `Startables()`. Only services with `OnStop` appear in `Stoppables()`. -Lifecycle methods never panic. All errors from individual services are collected via `errors.Join` and returned as a single error. You can inspect individual errors with `errors.Is` and `errors.As`: +## `ServiceStartup` ```go -err := c.ServiceStartup(ctx, nil) -if err != nil { - // err may contain multiple wrapped errors - if errors.Is(err, context.Canceled) { - // context was cancelled - } -} +r := c.ServiceStartup(context.Background(), nil) ``` -## Context Cancellation +### What It Does -Both `ServiceStartup` and `ServiceShutdown` respect context cancellation. If the context is cancelled or its deadline is exceeded, the remaining services are skipped: +1. clears the shutdown flag +2. stores a new cancellable context on `c.Context()` +3. runs each `OnStart` +4. broadcasts `ActionServiceStartup{}` + +### Failure Behavior + +- if the input context is already cancelled, startup returns that error +- if any `OnStart` returns `OK:false`, startup stops immediately and returns that result + +## `ServiceShutdown` ```go -ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) -defer cancel() - -err := c.ServiceStartup(ctx, nil) -// If startup takes longer than 5 seconds, remaining services are skipped +r := c.ServiceShutdown(context.Background()) ``` -## Detection +### What It Does -Lifecycle interface detection happens at registration time. When you call `RegisterService`, Core checks whether the service implements `Startable` and/or `Stoppable` and adds it to the appropriate internal list. There is no need to declare anything beyond implementing the interface. +1. sets the shutdown flag +2. cancels `c.Context()` +3. broadcasts `ActionServiceShutdown{}` +4. waits for background tasks created by `PerformAsync` +5. runs each `OnStop` -## Related Pages +### Failure Behavior -- [Services](services.md) -- how services are registered -- [Messaging](messaging.md) -- the `ACTION` broadcast used during lifecycle -- [Configuration](configuration.md) -- `WithServiceLock` and other options +- if draining background tasks hits the shutdown context deadline, shutdown returns that context error +- when service stop hooks fail, CoreGO returns the first error it sees + +## Ordering + +The current implementation builds `Startables()` and `Stoppables()` by iterating over a map-backed registry. + +That means lifecycle order is not guaranteed today. + +If your application needs strict startup or shutdown ordering, orchestrate it explicitly inside a smaller number of service callbacks instead of relying on registry order. + +## `c.Context()` + +`ServiceStartup` creates the context returned by `c.Context()`. + +Use it for background work that should stop when the application shuts down: + +```go +c.Service("watcher", core.Service{ + OnStart: func() core.Result { + go func(ctx context.Context) { + <-ctx.Done() + }(c.Context()) + return core.Result{OK: true} + }, +}) +``` + +## Built-In Lifecycle Actions + +You can listen for lifecycle state changes through the action bus. + +```go +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + switch msg.(type) { + case core.ActionServiceStartup: + core.Info("core startup completed") + case core.ActionServiceShutdown: + core.Info("core shutdown started") + } + return core.Result{OK: true} +}) +``` + +## Background Task Draining + +`ServiceShutdown` waits for the internal task waitgroup to finish before calling stop hooks. + +This is what makes `PerformAsync` safe for long-running work that should complete before teardown. + +## `OnReload` + +`Service` includes an `OnReload` callback field, but CoreGO does not currently expose a top-level lifecycle runner for reload operations. diff --git a/docs/messaging.md b/docs/messaging.md index 77ca6f7..688893a 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -1,286 +1,171 @@ --- title: Messaging -description: ACTION, QUERY, and PERFORM -- the message bus for decoupled service communication. +description: ACTION, QUERY, QUERYALL, PERFORM, and async task flow. --- # Messaging -The message bus enables services to communicate without importing each other. It supports three patterns: - -| Pattern | Method | Semantics | -|---------|--------|-----------| -| **ACTION** | `c.ACTION(msg)` | Broadcast to all handlers (fire-and-forget) | -| **QUERY** | `c.QUERY(q)` | First responder wins (read-only) | -| **PERFORM** | `c.PERFORM(t)` | First responder executes (side effects) | - -All three are type-safe at the handler level through Go type switches, while the bus itself uses `any` to avoid import cycles. +CoreGO uses one message bus for broadcasts, lookups, and work dispatch. ## Message Types ```go -// Any struct can be a message -- no interface to implement. -type Message any // Used with ACTION -type Query any // Used with QUERY / QUERYALL -type Task any // Used with PERFORM / PerformAsync +type Message any +type Query any +type Task any ``` -Define your message types as plain structs: +Your own structs define the protocol. ```go -// In your package -type UserCreated struct { - UserID string - Email string +type repositoryIndexed struct { + Name string } -type GetUserCount struct{} +type repositoryCountQuery struct{} -type SendEmail struct { - To string - Subject string - Body string +type syncRepositoryTask struct { + Name string } ``` -## ACTION -- Broadcast +## `ACTION` -`ACTION` dispatches a message to **every** registered action handler. Handlers receive the message and can inspect it via type switch. All handlers are called regardless of whether they handle the specific message type. - -### Dispatching +`ACTION` is a broadcast. ```go -err := c.ACTION(UserCreated{UserID: "123", Email: "user@example.com"}) -``` - -Errors from all handlers are aggregated with `errors.Join`. If no handlers are registered, `ACTION` returns `nil`. - -### Handling - -```go -c.RegisterAction(func(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case UserCreated: - fmt.Printf("New user: %s (%s)\n", m.UserID, m.Email) - } - return nil +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} }) + +r := c.ACTION(repositoryIndexed{Name: "core-go"}) ``` -You can register multiple handlers. Each handler receives every message -- use a type switch to filter. +### 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` is first-match request-response. ```go -// Register multiple handlers at once -c.RegisterActions(handler1, handler2, handler3) -``` - -### Auto-Discovery - -If a service registered via `WithService` has a method called `HandleIPCEvents` with the signature `func(*Core, Message) error`, it is automatically registered as an action handler: - -```go -type Service struct{} - -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - switch msg.(type) { - case UserCreated: - // react to event - } - return nil -} -``` - -## QUERY -- Request/Response - -`QUERY` dispatches a query to handlers in registration order. The **first** handler that returns `handled == true` wins -- subsequent handlers are not called. - -### Dispatching - -```go -result, handled, err := c.QUERY(GetUserCount{}) -if !handled { - // no handler recognised this query -} -count := result.(int) -``` - -### Handling - -```go -c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case GetUserCount: - return 42, true, nil - } - return nil, false, nil // not handled -- pass to next handler +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{} }) + +r := c.QUERY(repositoryCountQuery{}) ``` -Return `false` for `handled` to let the query fall through to the next handler. +### Behavior -### QUERYALL -- Collect All Responses +- handlers run until one returns `OK:true` +- the first successful result wins +- if nothing handles the query, CoreGO returns an empty `Result` -`QUERYALL` calls **every** handler and collects all responses where `handled == true`: +## `QUERYALL` + +`QUERYALL` collects every successful non-nil response. ```go -results, err := c.QUERYALL(GetPluginInfo{}) -// results contains one entry per handler that responded +r := c.QUERYALL(repositoryCountQuery{}) +results := r.Value.([]any) ``` -Errors from all handlers are aggregated. Results from handlers that returned `handled == false` or `result == nil` are excluded. +### Behavior -## PERFORM -- Execute a Task +- 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 -`PERFORM` dispatches a task to handlers in registration order. Like `QUERY`, the first handler that returns `handled == true` wins. +## `PERFORM` -### Dispatching +`PERFORM` dispatches a task to the first handler that accepts it. ```go -result, handled, err := c.PERFORM(SendEmail{ - To: "user@example.com", - Subject: "Welcome", - Body: "Hello!", +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{} }) -if !handled { - // no handler could execute this task -} + +r := c.PERFORM(syncRepositoryTask{Name: "core-go"}) ``` -### Handling +### 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. ```go -c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) { - switch m := t.(type) { - case SendEmail: - err := sendMail(m.To, m.Subject, m.Body) - return nil, true, err - } - return nil, false, nil -}) +r := c.PerformAsync(syncRepositoryTask{Name: "core-go"}) +taskID := r.Value.(string) ``` -## PerformAsync -- Background Tasks +### Generated Events -`PerformAsync` dispatches a task to be executed in a background goroutine. It returns a task ID immediately. - -```go -taskID := c.PerformAsync(SendEmail{ - To: "user@example.com", - Subject: "Report", - Body: "...", -}) -// taskID is something like "task-1" -``` - -The lifecycle of an async task produces three action messages: +Async execution emits three action messages: | Message | When | |---------|------| -| `ActionTaskStarted{TaskID, Task}` | Immediately, before execution begins | -| `ActionTaskProgress{TaskID, Task, Progress, Message}` | When `c.Progress()` is called | -| `ActionTaskCompleted{TaskID, Task, Result, Error}` | After execution finishes | +| `ActionTaskStarted` | just before background execution begins | +| `ActionTaskProgress` | whenever `Progress` is called | +| `ActionTaskCompleted` | after the task finishes or panics | -### Listening for Completion +Example listener: ```go -c.RegisterAction(func(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case core.ActionTaskCompleted: - fmt.Printf("Task %s finished: result=%v err=%v\n", - m.TaskID, m.Result, m.Error) - } - return nil +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} }) ``` -### Reporting Progress - -From within a task handler (or anywhere that has the task ID): +## Progress Updates ```go -c.Progress(taskID, 0.5, "halfway done", myTask) +c.Progress(taskID, 0.5, "indexing commits", syncRepositoryTask{Name: "core-go"}) ``` -This broadcasts an `ActionTaskProgress` message. +That broadcasts `ActionTaskProgress`. -### TaskWithID +## `TaskWithIdentifier` -If your task struct implements `TaskWithID`, `PerformAsync` will inject the assigned task ID before dispatching: +Tasks that implement `TaskWithIdentifier` receive the generated ID before dispatch. ```go -type TaskWithID interface { - Task - SetTaskID(id string) - GetTaskID() string +type trackedTask struct { + ID string + Name string } + +func (t *trackedTask) SetTaskIdentifier(id string) { t.ID = id } +func (t *trackedTask) GetTaskIdentifier() string { return t.ID } ``` -```go -type MyLongTask struct { - id string -} +## Shutdown Interaction -func (t *MyLongTask) SetTaskID(id string) { t.id = id } -func (t *MyLongTask) GetTaskID() string { return t.id } -``` +When shutdown has started, `PerformAsync` returns an empty `Result` instead of scheduling more work. -### Shutdown Behaviour - -- `PerformAsync` returns an empty string if the Core is already shut down. -- `ServiceShutdown` waits for all in-flight async tasks to complete (respecting the context deadline). - -## Real-World Example: Log Service - -The `pkg/log` service demonstrates both query and task handling: - -```go -// Query type: "what is the current log level?" -type QueryLevel struct{} - -// Task type: "change the log level" -type TaskSetLevel struct { - Level Level -} - -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryLevel: - return s.Level(), true, nil - } - return nil, false, nil -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch m := t.(type) { - case TaskSetLevel: - s.SetLevel(m.Level) - return nil, true, nil - } - return nil, false, nil -} -``` - -Other services can query or change the log level without importing the log package: - -```go -// Query the level -level, handled, _ := c.QUERY(log.QueryLevel{}) - -// Change the level -_, _, _ = c.PERFORM(log.TaskSetLevel{Level: log.LevelDebug}) -``` - -## Thread Safety - -The message bus uses `sync.RWMutex` for each handler list (actions, queries, tasks). Registration and dispatch are safe to call concurrently from multiple goroutines. Handler lists are snapshot-cloned before dispatch, so handlers registered during dispatch will not be called until the next dispatch. - -## Related Pages - -- [Services](services.md) -- how services are registered -- [Lifecycle](lifecycle.md) -- `ActionServiceStartup` and `ActionServiceShutdown` messages -- [Testing](testing.md) -- testing message handlers +This is why `ServiceShutdown` can safely drain the outstanding background tasks before stopping services. diff --git a/docs/pkg/PACKAGE_STANDARDS.md b/docs/pkg/PACKAGE_STANDARDS.md index 5b37a4e..398bbf6 100644 --- a/docs/pkg/PACKAGE_STANDARDS.md +++ b/docs/pkg/PACKAGE_STANDARDS.md @@ -1,616 +1,138 @@ -# Core Package Standards +# AX Package Standards -This document defines the standards for creating packages in the Core framework. The `pkg/log` service is the reference implementation within this repo; standalone packages (go-session, go-store, etc.) follow the same patterns. +This page describes how to build packages on top of CoreGO in the style described by RFC-025. -## Package Structure +## 1. Prefer Predictable Names -A well-structured Core package follows this layout: +Use names that tell an agent what the thing is without translation. -``` -pkg/mypackage/ -├── types.go # Public types, constants, interfaces -├── service.go # Service struct with framework integration -├── mypackage.go # Global convenience functions -├── actions.go # ACTION messages for Core IPC (if needed) -├── hooks.go # Event hooks with atomic handlers (if needed) -├── [feature].go # Additional feature files -├── [feature]_test.go # Tests alongside implementation -└── service_test.go # Service tests -``` +Good: -## Core Principles +- `RepositoryService` +- `RepositoryServiceOptions` +- `WorkspaceCountQuery` +- `SyncRepositoryTask` -1. **Service-oriented**: Packages expose a `Service` struct that integrates with the Core framework -2. **Thread-safe**: All public APIs must be safe for concurrent use -3. **Global convenience**: Provide package-level functions that use a default service instance -4. **Options pattern**: Use functional options for configuration -5. **ACTION-based IPC**: Communicate via Core's ACTION system, not callbacks +Avoid shortening names unless the abbreviation is already universal. ---- +## 2. Put Real Usage in Comments -## Service Pattern +Write comments that show a real call with realistic values. -### Service Struct - -Embed `framework.ServiceRuntime[T]` for Core integration: +Good: ```go -// pkg/mypackage/service.go -package mypackage - -import ( - "sync" - "forge.lthn.ai/core/go/pkg/core" -) - -// Service provides mypackage functionality with Core integration. -type Service struct { - *core.ServiceRuntime[Options] - - // Internal state (protected by mutex) - data map[string]any - mu sync.RWMutex -} - -// Options configures the service. -type Options struct { - // Document each option - BufferSize int - EnableFoo bool -} +// Sync a repository into the local workspace cache. +// svc.SyncRepository("core-go", "/srv/repos/core-go") ``` -### Service Factory +Avoid comments that only repeat the signature. -Create a factory function for Core registration: +## 3. Keep Paths Semantic + +If a command or template lives at a path, let the path explain the intent. + +Good: + +```text +deploy/to/homelab +workspace/create +template/workspace/go +``` + +That keeps the CLI, tests, docs, and message vocabulary aligned. + +## 4. Reuse CoreGO Primitives + +At Core boundaries, prefer the shared shapes: + +- `core.Options` for lightweight input +- `core.Result` for output +- `core.Service` for lifecycle registration +- `core.Message`, `core.Query`, `core.Task` for bus protocols + +Inside your package, typed structs are still good. Use `ServiceRuntime[T]` when you want typed package options plus a `Core` reference. ```go -// NewService creates a service factory for Core registration. -// -// core, _ := core.New( -// core.WithName("mypackage", mypackage.NewService(mypackage.Options{})), -// ) -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - // Apply defaults - if opts.BufferSize == 0 { - opts.BufferSize = DefaultBufferSize - } +type repositoryServiceOptions struct { + BaseDirectory string +} - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - data: make(map[string]any), - } - return svc, nil - } +type repositoryService struct { + *core.ServiceRuntime[repositoryServiceOptions] } ``` -### Lifecycle Hooks +## 5. Prefer Explicit Registration -Implement `core.Startable` and/or `core.Stoppable`: +Register services and commands with names and paths that stay readable in grep results. ```go -// OnStartup implements core.Startable. -func (s *Service) OnStartup(ctx context.Context) error { - // Register query/task handlers - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterAction(s.handleAction) - return nil -} - -// OnShutdown implements core.Stoppable. -func (s *Service) OnShutdown(ctx context.Context) error { - // Cleanup resources - return nil -} +c.Service("repository", core.Service{...}) +c.Command("repository/sync", core.Command{...}) ``` ---- +## 6. Use the Bus for Decoupling -## Global Default Pattern - -Provide a global default service with atomic access: +When one package needs another package’s behavior, prefer queries and tasks over tight package coupling. ```go -// pkg/mypackage/mypackage.go -package mypackage - -import ( - "sync" - "sync/atomic" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Global default service -var ( - defaultService atomic.Pointer[Service] - defaultOnce sync.Once - defaultErr error -) - -// Default returns the global service instance. -// Returns nil if not initialised. -func Default() *Service { - return defaultService.Load() -} - -// SetDefault sets the global service instance. -// Thread-safe. Panics if s is nil. -func SetDefault(s *Service) { - if s == nil { - panic("mypackage: SetDefault called with nil service") - } - defaultService.Store(s) -} - -// Init initialises the default service with a Core instance. -func Init(c *core.Core) error { - defaultOnce.Do(func() { - factory := NewService(Options{}) - svc, err := factory(c) - if err != nil { - defaultErr = err - return - } - defaultService.Store(svc.(*Service)) - }) - return defaultErr +type repositoryCountQuery struct{} +type syncRepositoryTask struct { + Name string } ``` -### Global Convenience Functions +That keeps the protocol visible in code and easy for agents to follow. -Expose the most common operations at package level: +## 7. Use Structured Errors + +Use `core.E`, `core.Wrap`, and `core.WrapCode`. ```go -// ErrServiceNotInitialised is returned when the service is not initialised. -var ErrServiceNotInitialised = errors.New("mypackage: service not initialised") - -// DoSomething performs an operation using the default service. -func DoSomething(arg string) (string, error) { - svc := Default() - if svc == nil { - return "", ErrServiceNotInitialised - } - return svc.DoSomething(arg) +return core.Result{ + Value: core.E("repository.Sync", "git fetch failed", err), + OK: false, } ``` ---- +Do not introduce free-form `fmt.Errorf` chains in framework code. -## Options Pattern +## 8. Keep Testing Names Predictable -Use functional options for complex configuration: +Follow the repository pattern: + +- `_Good` +- `_Bad` +- `_Ugly` + +Example: ```go -// Option configures a Service during construction. -type Option func(*Service) - -// WithBufferSize sets the buffer size. -func WithBufferSize(size int) Option { - return func(s *Service) { - s.bufSize = size - } -} - -// WithFoo enables foo feature. -func WithFoo(enabled bool) Option { - return func(s *Service) { - s.fooEnabled = enabled - } -} - -// New creates a service with options. -func New(opts ...Option) (*Service, error) { - s := &Service{ - bufSize: DefaultBufferSize, - } - for _, opt := range opts { - opt(s) - } - return s, nil -} +func TestRepositorySync_Good(t *testing.T) {} +func TestRepositorySync_Bad(t *testing.T) {} +func TestRepositorySync_Ugly(t *testing.T) {} ``` ---- +## 9. Prefer Stable Shapes Over Clever APIs -## ACTION Messages (IPC) +For package APIs, avoid patterns that force an agent to infer too much hidden control flow. -For services that need to communicate events, define ACTION message types: +Prefer: -```go -// pkg/mypackage/actions.go -package mypackage +- clear structs +- explicit names +- path-based commands +- visible message types -import "time" +Avoid: -// ActionItemCreated is broadcast when an item is created. -type ActionItemCreated struct { - ID string - Name string - CreatedAt time.Time -} +- implicit global state unless it is truly a default service +- panic-hiding constructors +- dense option chains when a small explicit struct would do -// ActionItemUpdated is broadcast when an item changes. -type ActionItemUpdated struct { - ID string - Changes map[string]any -} +## 10. Document the Current Reality -// ActionItemDeleted is broadcast when an item is removed. -type ActionItemDeleted struct { - ID string -} -``` +If the implementation is in transition, document what the code does now, not the API shape you plan to have later. -Dispatch actions via `s.Core().ACTION()`: - -```go -func (s *Service) CreateItem(name string) (*Item, error) { - item := &Item{ID: generateID(), Name: name} - - // Store item... - - // Broadcast to listeners - s.Core().ACTION(ActionItemCreated{ - ID: item.ID, - Name: item.Name, - CreatedAt: time.Now(), - }) - - return item, nil -} -``` - -Consumers register handlers: - -```go -core.RegisterAction(func(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case mypackage.ActionItemCreated: - log.Printf("Item created: %s", m.Name) - case mypackage.ActionItemDeleted: - log.Printf("Item deleted: %s", m.ID) - } - return nil -}) -``` - ---- - -## Hooks Pattern - -For user-customisable behaviour, use atomic handlers: - -```go -// pkg/mypackage/hooks.go -package mypackage - -import ( - "sync/atomic" -) - -// ErrorHandler is called when an error occurs. -type ErrorHandler func(err error) - -var errorHandler atomic.Value // stores ErrorHandler - -// OnError registers an error handler. -// Thread-safe. Pass nil to clear. -func OnError(h ErrorHandler) { - if h == nil { - errorHandler.Store((ErrorHandler)(nil)) - return - } - errorHandler.Store(h) -} - -// dispatchError calls the registered error handler. -func dispatchError(err error) { - v := errorHandler.Load() - if v == nil { - return - } - h, ok := v.(ErrorHandler) - if !ok || h == nil { - return - } - h(err) -} -``` - ---- - -## Thread Safety - -### Mutex Patterns - -Use `sync.RWMutex` for state that is read more than written: - -```go -type Service struct { - data map[string]any - mu sync.RWMutex -} - -func (s *Service) Get(key string) (any, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - v, ok := s.data[key] - return v, ok -} - -func (s *Service) Set(key string, value any) { - s.mu.Lock() - defer s.mu.Unlock() - s.data[key] = value -} -``` - -### Atomic Values - -Use `atomic.Pointer[T]` for single values accessed frequently: - -```go -var config atomic.Pointer[Config] - -func GetConfig() *Config { - return config.Load() -} - -func SetConfig(c *Config) { - config.Store(c) -} -``` - ---- - -## Error Handling - -### Error Types - -Define package-level errors: - -```go -// Errors -var ( - ErrNotFound = errors.New("mypackage: not found") - ErrInvalidArg = errors.New("mypackage: invalid argument") - ErrNotRunning = errors.New("mypackage: not running") -) -``` - -### Wrapped Errors - -Use `fmt.Errorf` with `%w` for context: - -```go -func (s *Service) Load(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - // ... -} -``` - -### Error Struct (optional) - -For errors needing additional context: - -```go -type ServiceError struct { - Op string // Operation that failed - Path string // Resource path - Err error // Underlying error -} - -func (e *ServiceError) Error() string { - return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err) -} - -func (e *ServiceError) Unwrap() error { - return e.Err -} -``` - ---- - -## Testing - -### Test File Organisation - -Place tests alongside implementation: - -``` -mypackage.go → mypackage_test.go -service.go → service_test.go -buffer.go → buffer_test.go -``` - -### Test Helpers - -Create helpers for common setup: - -```go -func newTestService(t *testing.T) (*Service, *core.Core) { - t.Helper() - - core, err := core.New( - core.WithName("mypackage", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := core.ServiceFor[*Service](core, "mypackage") - require.NoError(t, err) - - return svc, core -} -``` - -### Test Naming Convention - -Use descriptive subtests: - -```go -func TestService_DoSomething(t *testing.T) { - t.Run("valid input", func(t *testing.T) { - // ... - }) - - t.Run("empty input returns error", func(t *testing.T) { - // ... - }) - - t.Run("concurrent access", func(t *testing.T) { - // ... - }) -} -``` - -### Testing Actions - -Verify ACTION broadcasts: - -```go -func TestService_BroadcastsActions(t *testing.T) { - core, _ := core.New( - core.WithName("mypackage", NewService(Options{})), - ) - - var received []ActionItemCreated - var mu sync.Mutex - - core.RegisterAction(func(c *core.Core, msg core.Message) error { - if m, ok := msg.(ActionItemCreated); ok { - mu.Lock() - received = append(received, m) - mu.Unlock() - } - return nil - }) - - svc, _ := core.ServiceFor[*Service](core, "mypackage") - svc.CreateItem("test") - - mu.Lock() - assert.Len(t, received, 1) - assert.Equal(t, "test", received[0].Name) - mu.Unlock() -} -``` - ---- - -## Documentation - -### Package Doc - -Every package needs a doc comment in the main file: - -```go -// Package mypackage provides functionality for X. -// -// # Getting Started -// -// svc, err := mypackage.New() -// result := svc.DoSomething("input") -// -// # Core Integration -// -// core, _ := core.New( -// core.WithName("mypackage", mypackage.NewService(mypackage.Options{})), -// ) -package mypackage -``` - -### Function Documentation - -Document public functions with examples: - -```go -// DoSomething performs X operation with the given input. -// Returns ErrInvalidArg if input is empty. -// -// result, err := svc.DoSomething("hello") -// if err != nil { -// return err -// } -func (s *Service) DoSomething(input string) (string, error) { - // ... -} -``` - ---- - -## Checklist - -When creating a new package, ensure: - -- [ ] `Service` struct embeds `framework.ServiceRuntime[Options]` -- [ ] `NewService()` factory function for Core registration -- [ ] `Default()` / `SetDefault()` with `atomic.Pointer` -- [ ] Package-level convenience functions -- [ ] Thread-safe public APIs (mutex or atomic) -- [ ] ACTION messages for events (if applicable) -- [ ] Hooks with atomic handlers (if applicable) -- [ ] Comprehensive tests with helpers -- [ ] Package documentation with examples - -## Reference Implementations - -- **`pkg/log`** (this repo) — Service struct with Core integration, query/task handlers -- **`core/go-store`** — SQLite KV store with Watch/OnChange, full service pattern -- **`core/go-session`** — Transcript parser with analytics, factory pattern - ---- - -## Background Operations - -For long-running operations that could block the UI, use the framework's background task mechanism. - -### Principles - -1. **Non-blocking**: Long-running operations must not block the main IPC thread. -2. **Lifecycle Events**: Use `PerformAsync` to automatically broadcast start and completion events. -3. **Progress Reporting**: Services should broadcast `ActionTaskProgress` for granular updates. - -### Using PerformAsync - -The `Core.PerformAsync(task)` method runs any registered task in a background goroutine and returns a unique `TaskID` immediately. - -```go -// From the frontend or another service -taskID := core.PerformAsync(git.TaskPush{Path: "/repo"}) -// taskID is returned immediately, e.g., "task-123" -``` - -The framework automatically broadcasts lifecycle actions: -- `ActionTaskStarted`: When the background goroutine begins. -- `ActionTaskCompleted`: When the task finishes (contains Result and Error). - -### Reporting Progress - -For very long operations, the service handler should broadcast progress: - -```go -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch m := t.(type) { - case MyLongTask: - // Optional: If you need to report progress, you might need to pass - // a TaskID or use a specific progress channel. - // For now, simple tasks just use ActionTaskCompleted. - return s.doLongWork(m), true, nil - } - return nil, false, nil -} -``` - -### Implementing Background-Safe Handlers - -Ensure that handlers for long-running tasks: -1. Use `context.Background()` or a long-lived context, as the request context might expire. -2. Are thread-safe and don't hold global locks for the duration of the work. -3. Do not use interactive CLI functions like `cli.Scanln` if they are intended for GUI use. +That keeps agents correct on first pass, which is the real AX metric. diff --git a/docs/pkg/core.md b/docs/pkg/core.md index 695ae5d..88bd18b 100644 --- a/docs/pkg/core.md +++ b/docs/pkg/core.md @@ -1,610 +1,81 @@ -# pkg/core -- Dependency Injection & Service Framework +# Package Reference: `core` -`pkg/core` is the foundation of the Core application framework. It provides a dependency injection container, service lifecycle management, and a message bus for inter-service communication. Every other package in the ecosystem builds on top of it. - -The package is designed for use with Wails v3 (desktop GUI) but is equally useful in CLI and headless applications. - ---- - -## Core Struct - -`Core` is the central application object. It owns the service registry, the message bus, embedded assets, and feature flags. +Import path: ```go -type Core struct { - App any // GUI runtime (e.g. Wails App), set by WithApp - Features *Features // Feature flags - // unexported: svc *serviceManager, bus *messageBus, assets embed.FS -} +import "dappco.re/go/core" ``` -### Creating a Core Instance - -`New()` is the sole constructor. It accepts a variadic list of `Option` functions that configure the instance before it is returned. After all options are applied, the service lock is finalised. - -```go -c, err := core.New( - core.WithService(mypackage.NewMyService), - core.WithAssets(embeddedFS), - core.WithServiceLock(), -) -``` - -If any option returns an error, `New()` returns `nil` and that error immediately. - -### Options - -| Option | Purpose | -|--------|---------| -| `WithService(factory)` | Register a service via factory function. Auto-discovers the service name from the factory's return type package path and auto-registers an IPC handler if the service has a `HandleIPCEvents` method. | -| `WithName(name, factory)` | Register a service with an explicit name. Does **not** auto-discover IPC handlers. | -| `WithApp(app)` | Inject a GUI runtime (e.g. Wails `*application.App`) into `Core.App`. | -| `WithAssets(fs)` | Attach an `embed.FS` containing frontend assets. | -| `WithServiceLock()` | Prevent any further service registration after `New()` completes. Calls to `RegisterService` after the lock is applied return an error. | - -The `Option` type is defined as: - -```go -type Option func(*Core) error -``` - -### Service Retrieval - -Services are retrieved by name. Two generic helpers provide type-safe access: - -```go -// Returns (T, error) -- safe version -svc, err := core.ServiceFor[*MyService](c, "myservice") - -// Panics if not found or wrong type -- use in init paths -svc := core.MustServiceFor[*MyService](c, "myservice") -``` - -The untyped `Service(name)` method returns `any` (or `nil` if not found). - -### Convenience Accessors - -`Core` provides shorthand methods for well-known services: - -```go -c.Config() // returns Config interface -c.Display() // returns Display interface -c.Workspace() // returns Workspace interface -c.Crypt() // returns Crypt interface -``` - -Each calls `MustServiceFor` internally and will panic if the named service is not registered. - -### Global Instance - -For GUI runtimes that require global access, a singleton pattern is available: - -```go -core.SetInstance(c) // store globally (thread-safe) -app := core.App() // retrieve Core.App (panics if not set) -inst := core.GetInstance() // retrieve *Core (returns nil if not set) -core.ClearInstance() // reset to nil (primarily for tests) -``` - -### Feature Flags - -The `Features` struct holds a simple string slice of enabled flags: - -```go -c.Features.Flags = []string{"dark-mode", "beta-api"} -c.Features.IsEnabled("dark-mode") // true -``` - ---- - -## Service Pattern - -### Factory Functions - -Services are created via factory functions that receive the `*Core` and return `(any, error)`: - -```go -func NewMyService(c *core.Core) (any, error) { - return &MyService{ - ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 64}), - }, nil -} -``` - -The factory is called during `New()` when the corresponding `WithService` or `WithName` option is processed. - -### ServiceRuntime[T] - -`ServiceRuntime[T]` is a generic helper struct that services embed to gain access to the `Core` instance and typed options: - -```go -type ServiceRuntime[T any] struct { - core *core.Core - opts T -} -``` - -Constructor: - -```go -rt := core.NewServiceRuntime[MyOptions](c, MyOptions{BufferSize: 64}) -``` - -Methods: - -| Method | Returns | -|--------|---------| -| `Core()` | `*Core` -- the parent container | -| `Opts()` | `T` -- the service's typed options | -| `Config()` | `Config` -- shorthand for `Core().Config()` | - -Example service: - -```go -type MyService struct { - *core.ServiceRuntime[MyOptions] - items map[string]string -} - -type MyOptions struct { - BufferSize int -} - -func NewMyService(c *core.Core) (any, error) { - return &MyService{ - ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 128}), - items: make(map[string]string), - }, nil -} -``` - -### Startable and Stoppable Interfaces - -Services that need lifecycle hooks implement one or both of: - -```go -type Startable interface { - OnStartup(ctx context.Context) error -} - -type Stoppable interface { - OnShutdown(ctx context.Context) error -} -``` - -The service manager detects these interfaces at registration time and stores references internally. - -- **Startup**: `ServiceStartup()` calls `OnStartup` on every `Startable` service in registration order, then broadcasts `ActionServiceStartup{}` via the message bus. -- **Shutdown**: `ServiceShutdown()` first broadcasts `ActionServiceShutdown{}`, then calls `OnShutdown` on every `Stoppable` service in **reverse** registration order. This ensures that services which were started last are stopped first, respecting dependency order. - -Errors from individual services are aggregated via `errors.Join` and returned together, so one failing service does not prevent others from completing their lifecycle. - -### Service Lock - -When `WithServiceLock()` is passed to `New()`, the `serviceManager` sets `lockEnabled = true` during option processing. After all options have been applied, `applyLock()` sets `locked = true`. Any subsequent call to `RegisterService` returns an error: - -``` -core: service "late-service" is not permitted by the serviceLock setting -``` - -This prevents accidental late-binding of services after the application has been fully wired. - -### Service Name Discovery - -`WithService` derives the service name from the Go package path of the returned struct. For a type `myapp/services.Calculator`, the name becomes `services`. For `myapp/calculator.Service`, it becomes `calculator`. - -To control the name explicitly, use `WithName("calc", factory)`. - -### IPC Handler Discovery - -`WithService` also checks whether the service has a method named `HandleIPCEvents` with signature `func(*Core, Message) error`. If found, it is automatically registered as an ACTION handler via `RegisterAction`. - -`WithName` does **not** perform this discovery. Register handlers manually if needed. - ---- - -## Message Bus - -The message bus provides three distinct communication patterns, all thread-safe: - -### 1. ACTION -- Fire-and-Forget Broadcast - -`ACTION` dispatches a message to **all** registered handlers. Every handler is called; errors are aggregated. - -```go -// Define a message type -type OrderPlaced struct { - OrderID string - Total float64 -} - -// Dispatch -err := c.ACTION(OrderPlaced{OrderID: "abc", Total: 42.50}) - -// Register a handler -c.RegisterAction(func(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case OrderPlaced: - log.Printf("Order %s placed for %.2f", m.OrderID, m.Total) - } - return nil -}) -``` - -Multiple handlers can be registered at once with `RegisterActions(h1, h2, h3)`. - -The `Message` type is defined as `any`, so any struct can serve as a message. Handlers use a type switch to filter messages they care about. - -**Built-in action messages:** - -| Message | Broadcast when | -|---------|---------------| -| `ActionServiceStartup{}` | After all `Startable.OnStartup` calls complete | -| `ActionServiceShutdown{}` | Before `Stoppable.OnShutdown` calls begin | -| `ActionTaskStarted{TaskID, Task}` | A `PerformAsync` task begins | -| `ActionTaskProgress{TaskID, Task, Progress, Message}` | A background task reports progress | -| `ActionTaskCompleted{TaskID, Task, Result, Error}` | A `PerformAsync` task finishes | - -### 2. QUERY -- Read-Only Request/Response - -`QUERY` dispatches a query to handlers until the **first** one responds (returns `handled = true`). Remaining handlers are skipped. - -```go -type GetUserByID struct { - ID string -} - -// Register -c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) { - switch req := q.(type) { - case GetUserByID: - user, err := db.Find(req.ID) - return user, true, err - } - return nil, false, nil // not handled -- pass to next handler -}) - -// Dispatch -result, handled, err := c.QUERY(GetUserByID{ID: "u-123"}) -if !handled { - // no handler recognised this query -} -user := result.(*User) -``` - -`QUERYALL` dispatches the query to **all** handlers and collects every non-nil result: - -```go -results, err := c.QUERYALL(ListPlugins{}) -// results is []any containing responses from every handler that responded -``` - -The `Query` type is `any`. The `QueryHandler` signature is: - -```go -type QueryHandler func(*Core, Query) (any, bool, error) -``` - -### 3. TASK -- Side-Effect Request/Response - -`PERFORM` dispatches a task to handlers until the **first** one executes it (returns `handled = true`). Semantically identical to `QUERY` but intended for operations with side effects. - -```go -type SendEmail struct { - To string - Subject string - Body string -} - -c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) { - switch task := t.(type) { - case SendEmail: - err := mailer.Send(task.To, task.Subject, task.Body) - return nil, true, err - } - return nil, false, nil -}) - -result, handled, err := c.PERFORM(SendEmail{ - To: "user@example.com", - Subject: "Welcome", - Body: "Hello!", -}) -``` - -The `Task` type is `any`. The `TaskHandler` signature is: - -```go -type TaskHandler func(*Core, Task) (any, bool, error) -``` - -### Background Tasks - -`PerformAsync` runs a `PERFORM` dispatch in a background goroutine and returns a task ID immediately: - -```go -taskID := c.PerformAsync(BuildProject{Path: "/src"}) -// taskID is "task-1", "task-2", etc. -``` - -The framework automatically broadcasts: - -1. `ActionTaskStarted` -- when the goroutine begins -2. `ActionTaskCompleted` -- when the task finishes (includes `Result` and `Error`) - -If the task implements `TaskWithID`, the framework injects the assigned ID before execution: - -```go -type TaskWithID interface { - Task - SetTaskID(id string) - GetTaskID() string -} -``` - -Services can report progress during long-running tasks: - -```go -c.Progress(taskID, 0.5, "Compiling 50%...", task) -// Broadcasts ActionTaskProgress{TaskID: taskID, Progress: 0.5, Message: "..."} -``` - -### Thread Safety - -The message bus uses `sync.RWMutex` for each handler slice (IPC, query, task). Handler registration acquires a write lock; dispatch acquires a read lock and copies the handler slice before iterating, so dispatches never block registrations. - ---- - -## Error Handling - -The `Error` struct provides contextual error wrapping: - -```go -type Error struct { - Op string // operation, e.g. "config.Load" - Msg string // human-readable description - Err error // underlying error (optional) -} -``` - -### E() Helper - -`E()` is the primary constructor: - -```go -return core.E("config.Load", "failed to read config file", err) -// Output: "config.Load: failed to read config file: " - -return core.E("auth.Login", "invalid credentials", nil) -// Output: "auth.Login: invalid credentials" -``` - -When `err` is `nil`, the resulting `Error` has no wrapped cause. - -### Error Chain Compatibility - -`Error` implements `Unwrap()`, so it works with `errors.Is()` and `errors.As()`: - -```go -var coreErr *core.Error -if errors.As(err, &coreErr) { - log.Printf("Operation: %s, Message: %s", coreErr.Op, coreErr.Msg) -} -``` - -### Convention - -The `Op` field should follow `package.Function` or `service.Method` format. The `Msg` field should be a human-readable sentence suitable for display to end users. - ---- - -## Runtime (Wails Integration) - -The `Runtime` struct wraps `Core` for use as a Wails service. It implements the Wails service interface (`ServiceName`, `ServiceStartup`, `ServiceShutdown`). - -```go -type Runtime struct { - app any // GUI runtime - Core *Core -} -``` - -### NewRuntime - -Creates a minimal runtime with no custom services: - -```go -rt, err := core.NewRuntime(wailsApp) -``` - -### NewWithFactories - -Creates a runtime with named service factories. Factories are called in sorted (alphabetical) order to ensure deterministic initialisation: - -```go -rt, err := core.NewWithFactories(wailsApp, map[string]core.ServiceFactory{ - "calculator": func() (any, error) { return &Calculator{}, nil }, - "logger": func() (any, error) { return &Logger{}, nil }, -}) -``` - -`ServiceFactory` is defined as `func() (any, error)` -- note it does **not** receive `*Core`, unlike the `WithService` factory. The `Runtime` wraps each factory result with `WithName` internally. - -### Lifecycle Delegation - -`Runtime.ServiceStartup` and `Runtime.ServiceShutdown` delegate directly to `Core.ServiceStartup` and `Core.ServiceShutdown`. The Wails runtime calls these automatically. - -```go -func (r *Runtime) ServiceStartup(ctx context.Context, options any) { - _ = r.Core.ServiceStartup(ctx, options) -} - -func (r *Runtime) ServiceShutdown(ctx context.Context) { - if r.Core != nil { - _ = r.Core.ServiceShutdown(ctx) - } -} -``` - ---- - -## Interfaces - -`pkg/core` defines several interfaces that services may implement or consume. These decouple services from concrete implementations. - -### Lifecycle Interfaces - -| Interface | Method | Purpose | -|-----------|--------|---------| -| `Startable` | `OnStartup(ctx) error` | Initialisation on app start | -| `Stoppable` | `OnShutdown(ctx) error` | Cleanup on app shutdown | - -### Well-Known Service Interfaces - -| Interface | Service name | Key methods | -|-----------|-------------|-------------| -| `Config` | `"config"` | `Get(key, out) error`, `Set(key, v) error` | -| `Display` | `"display"` | `OpenWindow(opts...) error` | -| `Workspace` | `"workspace"` | `CreateWorkspace`, `SwitchWorkspace`, `WorkspaceFileGet`, `WorkspaceFileSet` | -| `Crypt` | `"crypt"` | `CreateKeyPair`, `EncryptPGP`, `DecryptPGP` | - -These interfaces live in `interfaces.go` and define the contracts that concrete implementations must satisfy. - -### Contract - -The `Contract` struct configures resilience behaviour: - -```go -type Contract struct { - DontPanic bool // recover from panics, return errors instead - DisableLogging bool // suppress all logging -} -``` - ---- - -## Complete Example - -Putting it all together -- a service that stores items, broadcasts actions, and responds to queries: - -```go -package inventory - -import ( - "context" - "sync" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options configures the inventory service. -type Options struct { - MaxItems int -} - -// Service manages an inventory of items. -type Service struct { - *core.ServiceRuntime[Options] - items map[string]string - mu sync.RWMutex -} - -// NewService creates a factory for Core registration. -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - if opts.MaxItems == 0 { - opts.MaxItems = 1000 - } - return &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - items: make(map[string]string), - }, nil - } -} - -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// -- Query: look up an item -- - -type GetItem struct{ ID string } - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch req := q.(type) { - case GetItem: - s.mu.RLock() - val, ok := s.items[req.ID] - s.mu.RUnlock() - if !ok { - return nil, true, core.E("inventory.GetItem", "not found", nil) - } - return val, true, nil - } - return nil, false, nil -} - -// -- Task: add an item -- - -type AddItem struct { - ID string - Name string -} - -type ItemAdded struct { - ID string - Name string -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch task := t.(type) { - case AddItem: - s.mu.Lock() - s.items[task.ID] = task.Name - s.mu.Unlock() - - _ = c.ACTION(ItemAdded{ID: task.ID, Name: task.Name}) - return task.ID, true, nil - } - return nil, false, nil -} - -// -- Wiring it up -- - -func main() { - c, err := core.New( - core.WithName("inventory", NewService(Options{MaxItems: 500})), - core.WithServiceLock(), - ) - if err != nil { - panic(err) - } - - // Start lifecycle - _ = c.ServiceStartup(context.Background(), nil) - - // Use the bus - _, _, _ = c.PERFORM(AddItem{ID: "item-1", Name: "Widget"}) - result, _, _ := c.QUERY(GetItem{ID: "item-1"}) - // result == "Widget" - - // Shutdown - _ = c.ServiceShutdown(context.Background()) -} -``` - ---- - -## File Map - -| File | Responsibility | -|------|---------------| -| `core.go` | `New()`, options (`WithService`, `WithName`, `WithApp`, `WithAssets`, `WithServiceLock`), `ServiceFor[T]`, `MustServiceFor[T]`, lifecycle dispatch, global instance, bus method delegation | -| `interfaces.go` | `Core` struct definition, `Option`, `Message`, `Query`, `Task`, `QueryHandler`, `TaskHandler`, `Startable`, `Stoppable`, `Contract`, `Features`, well-known service interfaces (`Config`, `Display`, `Workspace`, `Crypt`), built-in action message types | -| `message_bus.go` | `messageBus` struct, `action()`, `query()`, `queryAll()`, `perform()`, handler registration | -| `service_manager.go` | `serviceManager` struct, service registry, `Startable`/`Stoppable` tracking, service lock mechanism | -| `runtime_pkg.go` | `ServiceRuntime[T]` generic helper, `Runtime` struct (Wails integration), `NewRuntime()`, `NewWithFactories()` | -| `e.go` | `Error` struct, `E()` constructor, `Unwrap()` for error chain compatibility | +This repository exposes one root package. The main areas are: + +## Constructors and Accessors + +| Name | Purpose | +|------|---------| +| `New` | Create a `*Core` | +| `NewRuntime` | Create an empty runtime wrapper | +| `NewWithFactories` | Create a runtime wrapper from named service factories | +| `Options`, `App`, `Data`, `Drive`, `Fs`, `Config`, `Error`, `Log`, `Cli`, `IPC`, `I18n`, `Context` | Access the built-in subsystems | + +## Core Primitives + +| Name | Purpose | +|------|---------| +| `Option`, `Options` | Input configuration and metadata | +| `Result` | Shared output shape | +| `Service` | Lifecycle DTO | +| `Command` | Command tree node | +| `Message`, `Query`, `Task` | Message bus payload types | + +## Service and Runtime APIs + +| Name | Purpose | +|------|---------| +| `Service` | Register or read a named service | +| `Services` | List registered service names | +| `Startables`, `Stoppables` | Snapshot lifecycle-capable services | +| `LockEnable`, `LockApply` | Activate the service registry lock | +| `ServiceRuntime[T]` | Helper for package authors | + +## Command and CLI APIs + +| Name | Purpose | +|------|---------| +| `Command` | Register or read a command by path | +| `Commands` | List command paths | +| `Cli().Run` | Resolve arguments to a command and execute it | +| `Cli().PrintHelp` | Show executable commands | + +## Messaging APIs + +| Name | Purpose | +|------|---------| +| `ACTION`, `Action` | Broadcast a message | +| `QUERY`, `Query` | Return the first successful query result | +| `QUERYALL`, `QueryAll` | Collect all successful query results | +| `PERFORM`, `Perform` | Run the first task handler that accepts the task | +| `PerformAsync` | Run a task in the background | +| `Progress` | Broadcast async task progress | +| `RegisterAction`, `RegisterActions`, `RegisterQuery`, `RegisterTask` | Register bus handlers | + +## Subsystems + +| Name | Purpose | +|------|---------| +| `Config` | Runtime settings and feature flags | +| `Data` | Embedded filesystem mounts | +| `Drive` | Named transport handles | +| `Fs` | Local filesystem operations | +| `I18n` | Locale collection and translation delegation | +| `App`, `Find` | Application identity and executable lookup | + +## Errors and Logging + +| Name | Purpose | +|------|---------| +| `E`, `Wrap`, `WrapCode`, `NewCode` | Structured error creation | +| `Operation`, `ErrorCode`, `ErrorMessage`, `Root`, `StackTrace`, `FormatStackTrace` | Error inspection | +| `NewLog`, `Default`, `SetDefault`, `SetLevel`, `SetRedactKeys` | Logger creation and defaults | +| `LogErr`, `LogPanic`, `ErrorLog`, `ErrorPanic` | Error-aware logging and panic recovery | + +Use the top-level docs in `docs/` for task-oriented guidance, then use this page as a compact reference. diff --git a/docs/pkg/log.md b/docs/pkg/log.md index 56bce7f..15e9db1 100644 --- a/docs/pkg/log.md +++ b/docs/pkg/log.md @@ -1,55 +1,83 @@ -# Log Retention Policy +# Logging Reference -The `log` package provides structured logging with automatic log rotation and retention management. +Logging lives in the root `core` package in this repository. There is no separate `pkg/log` import path here. -## Retention Policy - -By default, the following log retention policy is applied when log rotation is enabled: - -- **Max Size**: 100 MB per log file. -- **Max Backups**: 5 old log files are retained. -- **Max Age**: 28 days. Old log files beyond this age are automatically deleted. (Set to -1 to disable age-based retention). -- **Compression**: Rotated log files can be compressed (future feature). - -## Configuration - -Logging can be configured using the `log.Options` struct. To enable log rotation to a file, provide a `RotationOptions` struct. If both `Output` and `Rotation` are provided, `Rotation` takes precedence and `Output` is ignored. - -### Standalone Usage +## Create a Logger ```go -logger := log.New(log.Options{ - Level: log.LevelInfo, - Rotation: &log.RotationOptions{ - Filename: "app.log", - MaxSize: 100, // MB - MaxBackups: 5, - MaxAge: 28, // days - }, +logger := core.NewLog(core.LogOptions{ + Level: core.LevelInfo, }) - -logger.Info("application started") ``` -### Framework Integration +## Levels -When using the Core framework, logging is usually configured during application initialization: +| Level | Meaning | +|-------|---------| +| `LevelQuiet` | no output | +| `LevelError` | errors and security events | +| `LevelWarn` | warnings, errors, security events | +| `LevelInfo` | informational, warnings, errors, security events | +| `LevelDebug` | everything | + +## Log Methods ```go -app, _ := core.New( - core.WithName("log", log.NewService(log.Options{ - Level: log.LevelDebug, - Rotation: &log.RotationOptions{ - Filename: "/var/log/my-app.log", - }, - })), -) +logger.Debug("workspace discovered", "path", "/srv/workspaces") +logger.Info("service started", "service", "audit") +logger.Warn("retrying fetch", "attempt", 2) +logger.Error("fetch failed", "err", err) +logger.Security("sandbox escape detected", "path", attemptedPath) ``` -## How It Works +## Default Logger -1. **Rotation**: When the current log file exceeds `MaxSize`, it is rotated. The current file is renamed to `filename.1`, `filename.1` is renamed to `filename.2`, and so on. -2. **Retention**: - - Files beyond `MaxBackups` are automatically deleted during rotation. - - Files older than `MaxAge` days are automatically deleted during the cleanup process. -3. **Appends**: When an application restarts, it appends to the existing log file instead of truncating it. +The package owns a default logger. + +```go +core.SetLevel(core.LevelDebug) +core.SetRedactKeys("token", "password") + +core.Info("service started", "service", "audit") +``` + +## Redaction + +Values for keys listed in `RedactKeys` are replaced with `[REDACTED]`. + +```go +logger.SetRedactKeys("token") +logger.Info("login", "user", "cladius", "token", "secret-value") +``` + +## Output and Rotation + +```go +logger := core.NewLog(core.LogOptions{ + Level: core.LevelInfo, + Output: os.Stderr, +}) +``` + +If you provide `Rotation` and set `RotationWriterFactory`, the logger writes to the rotating writer instead of the plain output stream. + +## Error-Aware Logging + +`LogErr` extracts structured error context before logging: + +```go +le := core.NewLogErr(logger) +le.Log(err) +``` + +`ErrorLog` is the log-and-return wrapper exposed through `c.Log()`. + +## Panic-Aware Logging + +`LogPanic` is the lightweight panic logger: + +```go +defer core.NewLogPanic(logger).Recover() +``` + +It logs the recovered panic but does not manage crash files. For crash reports, use `c.Error().Recover()`. diff --git a/docs/primitives.md b/docs/primitives.md new file mode 100644 index 0000000..43701f2 --- /dev/null +++ b/docs/primitives.md @@ -0,0 +1,169 @@ +--- +title: Core Primitives +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. + +## Primitive Map + +| Type | Used For | +|------|----------| +| `Options` | Input values and lightweight metadata | +| `Result` | Output values and success state | +| `Service` | Lifecycle-managed components | +| `Message` | Broadcast events | +| `Query` | Request-response lookups | +| `Task` | Side-effecting work items | + +## `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: + +```go +name := opts.String("name") +path := opts.String("path") +debug := opts.Bool("debug") +hasPath := opts.Has("path") +raw := opts.Get("name") +``` + +### 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. + +```go +r := core.Result{Value: "ready", OK: true} + +if r.OK { + fmt.Println(r.Value) +} +``` + +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`. + +```go +r1 := core.Result{}.Result("hello") +r2 := 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. + +```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} + }, +} +``` + +### Important Details + +- `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. + +## `Message`, `Query`, and `Task` + +These are simple aliases to `any`. + +```go +type Message any +type Query any +type Task any +``` + +That means your own structs become the protocol: + +```go +type deployStarted struct { + Environment string +} + +type workspaceCountQuery struct{} + +type syncRepositoryTask struct { + Name string +} +``` + +## `TaskWithIdentifier` + +Long-running tasks can opt into task identifiers. + +```go +type indexedTask struct { + ID string +} + +func (t *indexedTask) SetTaskIdentifier(id string) { t.ID = id } +func (t *indexedTask) GetTaskIdentifier() string { return t.ID } +``` + +If a task implements `TaskWithIdentifier`, `PerformAsync` injects the generated `task-N` identifier before dispatch. + +## `ServiceRuntime[T]` + +`ServiceRuntime[T]` is the small helper for packages that want to keep a Core reference and a typed options struct together. + +```go +type agentServiceOptions struct { + WorkspacePath string +} + +type agentService struct { + *core.ServiceRuntime[agentServiceOptions] +} + +runtime := core.NewServiceRuntime(c, agentServiceOptions{ + WorkspacePath: "/srv/agent-workspaces", +}) +``` + +It exposes: + +- `Core()` +- `Options()` +- `Config()` + +This helper does not register anything by itself. It is a composition aid for package authors. diff --git a/docs/services.md b/docs/services.md index 8deca33..ad95d64 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1,215 +1,152 @@ --- title: Services -description: Service registration, retrieval, ServiceRuntime, and factory patterns. +description: Register, inspect, and lock CoreGO services. --- # Services -Services are the building blocks of a Core application. They are plain Go structs registered into a named registry and retrieved by name with optional type assertions. +In CoreGO, a service is a named lifecycle entry stored in the Core registry. -## Registration - -### Factory Functions - -The primary way to register a service is via a **factory function** -- a function with the signature `func(*Core) (any, error)`. The factory receives the `Core` instance so it can access other services or register message handlers during construction. +## Register a Service ```go -func NewMyService(c *core.Core) (any, error) { - return &MyService{}, nil -} -``` +c := core.New() -### WithService (auto-named) - -`WithService` registers a service and automatically discovers its name from the Go package path. The last segment of the package path becomes the service name, lowercased. - -```go -// If MyService lives in package "myapp/services/calculator", -// it is registered as "calculator". -c, err := core.New( - core.WithService(calculator.NewService), -) -``` - -`WithService` also performs **IPC handler discovery**: if the returned service has a method named `HandleIPCEvents` with the signature `func(*Core, Message) error`, it is automatically registered as an action handler. - -```go -type Service struct{} - -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - // Handle messages - return nil -} -``` - -### WithName (explicitly named) - -When you need to control the service name (or the factory is an anonymous function), use `WithName`: - -```go -c, err := core.New( - core.WithName("my-service", func(c *core.Core) (any, error) { - return &MyService{}, nil - }), -) -``` - -Unlike `WithService`, `WithName` does **not** auto-discover IPC handlers. Register them manually if needed. - -### Direct Registration - -You can also register a service directly on an existing `Core` instance: - -```go -err := c.RegisterService("my-service", &MyService{}) -``` - -This is useful for tests or when constructing services outside the `New()` options flow. - -### Registration Rules - -- Service names **must not be empty**. -- **Duplicate names** are rejected with an error. -- If `WithServiceLock()` was passed to `New()`, registration after initialisation is rejected. - -## Retrieval - -### By Name (untyped) - -```go -svc := c.Service("calculator") -if svc == nil { - // not found -} -``` - -Returns `nil` if no service is registered under that name. - -### Type-Safe Retrieval - -`ServiceFor[T]` retrieves and type-asserts in one step: - -```go -calc, err := core.ServiceFor[*calculator.Service](c, "calculator") -if err != nil { - // "service 'calculator' not found" - // or "service 'calculator' is of type *Foo, but expected *calculator.Service" -} -``` - -### Panicking Retrieval - -For init-time wiring where a missing service is a fatal programming error: - -```go -calc := core.MustServiceFor[*calculator.Service](c, "calculator") -// panics if not found or wrong type -``` - -## ServiceRuntime - -`ServiceRuntime[T]` is a generic helper you embed in your service struct. It provides typed access to the `Core` instance and your service's options struct. - -```go -type Options struct { - Precision int -} - -type Service struct { - *core.ServiceRuntime[Options] -} - -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - }, nil - } -} -``` - -`ServiceRuntime` provides these methods: - -| Method | Returns | Description | -|--------|---------|-------------| -| `Core()` | `*Core` | The central Core instance | -| `Opts()` | `T` | The service's typed options | -| `Config()` | `Config` | Convenience shortcut for `Core().Config()` | - -### Real-World Example: The Log Service - -The `pkg/log` package in this repository is the reference implementation of a Core service: - -```go -type Service struct { - *core.ServiceRuntime[Options] - *Logger -} - -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - logger := New(opts) - return &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - Logger: logger, - }, nil - } -} - -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} -``` - -Key patterns to note: - -1. The factory is a **closure** -- `NewService` takes options and returns a factory function. -2. `ServiceRuntime` is embedded, giving access to `Core()` and `Opts()`. -3. The service implements `Startable` to register its query/task handlers at startup. - -## Runtime and NewWithFactories - -For applications that wire services from a map of named factories, `NewWithFactories` offers a bulk registration path: - -```go -type ServiceFactory func() (any, error) - -rt, err := core.NewWithFactories(app, map[string]core.ServiceFactory{ - "config": configFactory, - "database": dbFactory, - "cache": cacheFactory, +r := c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("audit started") + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("audit stopped") + return core.Result{OK: true} + }, }) ``` -Factories are called in sorted key order. The resulting `Runtime` wraps a `Core` and exposes `ServiceStartup`/`ServiceShutdown` for GUI runtime integration. +Registration succeeds when: -For the simplest case with no custom services: +- the name is not empty +- the registry is not locked +- the name is not already in use + +## Read a Service Back ```go -rt, err := core.NewRuntime(app) +r := c.Service("audit") +if r.OK { + svc := r.Value.(*core.Service) + _ = svc +} ``` -## Well-Known Services +The returned value is `*core.Service`. -Core provides convenience methods for commonly needed services. These use `MustServiceFor` internally and will panic if the service is not registered: +## List Registered Services -| Method | Expected Name | Expected Interface | -|--------|--------------|-------------------| -| `c.Config()` | `"config"` | `Config` | -| `c.Display()` | `"display"` | `Display` | -| `c.Workspace()` | `"workspace"` | `Workspace` | -| `c.Crypt()` | `"crypt"` | `Crypt` | +```go +names := c.Services() +``` -These are optional -- only call them if you have registered the corresponding service. +### Important Detail -## Thread Safety +The current registry is map-backed. `Services()`, `Startables()`, and `Stoppables()` do not promise a stable order. -The service registry is protected by `sync.RWMutex`. Registration, retrieval, and lifecycle operations are safe to call from multiple goroutines. +## Lifecycle Snapshots -## Related Pages +Use these helpers when you want the current set of startable or stoppable services: -- [Lifecycle](lifecycle.md) -- `Startable` and `Stoppable` interfaces -- [Messaging](messaging.md) -- how services communicate -- [Configuration](configuration.md) -- all `With*` options +```go +startables := c.Startables() +stoppables := c.Stoppables() +``` + +They return `[]*core.Service` inside `Result.Value`. + +## Lock the Registry + +CoreGO has a service-lock mechanism, but it is explicit. + +```go +c := core.New() + +c.LockEnable() +c.Service("audit", core.Service{}) +c.Service("cache", core.Service{}) +c.LockApply() +``` + +After `LockApply`, new registrations fail: + +```go +r := c.Service("late", core.Service{}) +fmt.Println(r.OK) // false +``` + +The default lock name is `"srv"`. You can pass a different name if you need a custom lock namespace. + +For the service registry itself, use the default `"srv"` lock path. That is the path used by `Core.Service(...)`. + +## `NewWithFactories` + +For GUI runtimes or factory-driven setup, CoreGO provides `NewWithFactories`. + +```go +r := core.NewWithFactories(nil, map[string]core.ServiceFactory{ + "audit": func() core.Result { + return core.Result{Value: core.Service{ + OnStart: func() core.Result { + return core.Result{OK: true} + }, + }, OK: true} + }, + "cache": func() core.Result { + return core.Result{Value: core.Service{}, OK: true} + }, +}) +``` + +### Important Details + +- each factory must return a `core.Service` in `Result.Value` +- factories are executed in sorted key order +- nil factories are skipped +- the return value is `*core.Runtime` + +## `Runtime` + +`Runtime` is a small wrapper used for external runtimes such as GUI bindings. + +```go +r := core.NewRuntime(nil) +rt := r.Value.(*core.Runtime) + +_ = rt.ServiceStartup(context.Background(), nil) +_ = rt.ServiceShutdown(context.Background()) +``` + +`Runtime.ServiceName()` returns `"Core"`. + +## `ServiceRuntime[T]` for Package Authors + +If you are writing a package on top of CoreGO, use `ServiceRuntime[T]` to keep a typed options struct and the parent `Core` together. + +```go +type repositoryServiceOptions struct { + BaseDirectory string +} + +type repositoryService struct { + *core.ServiceRuntime[repositoryServiceOptions] +} + +func newRepositoryService(c *core.Core) *repositoryService { + return &repositoryService{ + ServiceRuntime: core.NewServiceRuntime(c, repositoryServiceOptions{ + BaseDirectory: "/srv/repos", + }), + } +} +``` + +This is a package-authoring helper. It does not replace the `core.Service` registry entry. diff --git a/docs/subsystems.md b/docs/subsystems.md new file mode 100644 index 0000000..f39ea16 --- /dev/null +++ b/docs/subsystems.md @@ -0,0 +1,158 @@ +--- +title: Subsystems +description: Built-in accessors for app metadata, embedded data, filesystem, transport handles, i18n, and CLI. +--- + +# Subsystems + +`Core` gives you a set of built-in subsystems so small applications do not need extra plumbing before they can do useful work. + +## Accessor Map + +| Accessor | Purpose | +|----------|---------| +| `App()` | Application identity and external runtime | +| `Data()` | Named embedded filesystem mounts | +| `Drive()` | Named transport handles | +| `Fs()` | Local filesystem access | +| `I18n()` | Locale collection and translation delegation | +| `Cli()` | Command-line surface over the command tree | + +## `App` + +`App` stores process identity and optional GUI runtime state. + +```go +app := c.App() +app.Name = "agent-workbench" +app.Version = "0.25.0" +app.Description = "workspace runner" +app.Runtime = myRuntime +``` + +`Find` resolves an executable on `PATH` and returns an `*App`. + +```go +r := core.Find("go", "Go toolchain") +``` + +## `Data` + +`Data` mounts named embedded filesystems and makes them addressable through paths like `mount-name/path/to/file`. + +```go +c.Data().New(core.Options{ + {Key: "name", Value: "app"}, + {Key: "source", Value: appFS}, + {Key: "path", Value: "templates"}, +}) +``` + +Read content: + +```go +text := c.Data().ReadString("app/agent.md") +bytes := c.Data().ReadFile("app/agent.md") +list := c.Data().List("app") +names := c.Data().ListNames("app") +``` + +Extract a mounted directory: + +```go +r := c.Data().Extract("app/workspace", "/tmp/workspace", nil) +``` + +### Path Rule + +The first path segment is always the mount name. + +## `Drive` + +`Drive` is a registry for named transport handles. + +```go +c.Drive().New(core.Options{ + {Key: "name", Value: "api"}, + {Key: "transport", Value: "https://api.lthn.ai"}, +}) + +c.Drive().New(core.Options{ + {Key: "name", Value: "mcp"}, + {Key: "transport", Value: "mcp://mcp.lthn.sh"}, +}) +``` + +Read them back: + +```go +handle := c.Drive().Get("api") +hasMCP := c.Drive().Has("mcp") +names := c.Drive().Names() +``` + +## `Fs` + +`Fs` wraps local filesystem operations with a consistent `Result` shape. + +```go +c.Fs().Write("/tmp/core-go/example.txt", "hello") +r := c.Fs().Read("/tmp/core-go/example.txt") +``` + +Other helpers: + +```go +c.Fs().EnsureDir("/tmp/core-go/cache") +c.Fs().List("/tmp/core-go") +c.Fs().Stat("/tmp/core-go/example.txt") +c.Fs().Rename("/tmp/core-go/example.txt", "/tmp/core-go/example-2.txt") +c.Fs().Delete("/tmp/core-go/example-2.txt") +``` + +### Important Details + +- the default `Core` starts with `Fs{root:"/"}` +- relative paths resolve from the current working directory +- `Delete` and `DeleteAll` refuse to remove `/` and `$HOME` + +## `I18n` + +`I18n` collects locale mounts and forwards translation work to a translator implementation when one is registered. + +```go +c.I18n().SetLanguage("en-GB") +``` + +Without a translator, `Translate` returns the message key itself: + +```go +r := c.I18n().Translate("cmd.deploy.description") +``` + +With a translator: + +```go +c.I18n().SetTranslator(myTranslator) +``` + +Then: + +```go +langs := c.I18n().AvailableLanguages() +current := c.I18n().Language() +``` + +## `Cli` + +`Cli` exposes the command registry through a terminal-facing API. + +```go +c.Cli().SetBanner(func(_ *core.Cli) string { + return "Agent Workbench" +}) + +r := c.Cli().Run("workspace", "create", "--name=alpha") +``` + +Use [commands.md](commands.md) for the full command and flag model. diff --git a/docs/testing.md b/docs/testing.md index a67141f..656634a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,340 +1,118 @@ --- title: Testing -description: Test naming conventions, test helpers, and patterns for Core applications. +description: Test naming and testing patterns used by CoreGO. --- # Testing -Core uses `github.com/stretchr/testify` for assertions and follows a structured test naming convention. This page covers the patterns used in the framework itself and recommended for services built on it. +The repository uses `github.com/stretchr/testify/assert` and a simple AX-friendly naming pattern. -## Naming Convention +## Test Names -Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern: +Use: -| Suffix | Purpose | Example | -|--------|---------|---------| -| `_Good` | Happy path -- expected behaviour | `TestCore_New_Good` | -| `_Bad` | Expected error conditions | `TestCore_WithService_Bad` | -| `_Ugly` | Panics, edge cases, degenerate input | `TestCore_MustServiceFor_Ugly` | +- `_Good` for expected success +- `_Bad` for expected failure +- `_Ugly` for panics, degenerate input, and edge behavior -The format is `Test{Component}_{Method}_{Suffix}`: +Examples from this repository: ```go -func TestCore_New_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - assert.NotNil(t, c) -} - -func TestCore_WithService_Bad(t *testing.T) { - factory := func(c *Core) (any, error) { - return nil, assert.AnError - } - _, err := New(WithService(factory)) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) -} - -func TestCore_MustServiceFor_Ugly(t *testing.T) { - c, _ := New() - assert.Panics(t, func() { - MustServiceFor[*MockService](c, "nonexistent") - }) -} +func TestNew_Good(t *testing.T) {} +func TestService_Register_Duplicate_Bad(t *testing.T) {} +func TestCore_Must_Ugly(t *testing.T) {} ``` -## Creating a Test Core - -For unit tests, create a minimal Core with only the services needed: +## Start with a Small Core ```go -func TestMyFeature(t *testing.T) { - c, err := core.New() - assert.NoError(t, err) - - // Register only what the test needs - err = c.RegisterService("my-service", &MyService{}) - assert.NoError(t, err) -} +c := core.New(core.Options{ + {Key: "name", Value: "test-core"}, +}) ``` -## Mock Services +Then register only the pieces your test needs. -Define mock services as test-local structs. Core's interface-based design makes this straightforward: +## Test a Service ```go -// Mock a Startable service -type MockStartable struct { - started bool - err error -} +started := false -func (m *MockStartable) OnStartup(ctx context.Context) error { - m.started = true - return m.err -} +c.Service("audit", core.Service{ + OnStart: func() core.Result { + started = true + return core.Result{OK: true} + }, +}) -// Mock a Stoppable service -type MockStoppable struct { - stopped bool - err error -} - -func (m *MockStoppable) OnShutdown(ctx context.Context) error { - m.stopped = true - return m.err -} +r := c.ServiceStartup(context.Background(), nil) +assert.True(t, r.OK) +assert.True(t, started) ``` -For services implementing both lifecycle interfaces: +## Test a Command ```go -type MockLifecycle struct { - MockStartable - MockStoppable -} +c.Command("greet", core.Command{ + Action: func(opts core.Options) core.Result { + return core.Result{Value: "hello " + opts.String("name"), OK: true} + }, +}) + +r := c.Cli().Run("greet", "--name=world") +assert.True(t, r.OK) +assert.Equal(t, "hello world", r.Value) ``` -## Testing Lifecycle - -Verify that startup and shutdown are called in the correct order: +## Test a Query or Task ```go -func TestLifecycleOrder(t *testing.T) { - c, _ := core.New() - var callOrder []string +c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if q == "ping" { + return core.Result{Value: "pong", OK: true} + } + return core.Result{} +}) - s1 := &OrderTracker{id: "1", log: &callOrder} - s2 := &OrderTracker{id: "2", log: &callOrder} - - _ = c.RegisterService("s1", s1) - _ = c.RegisterService("s2", s2) - - _ = c.ServiceStartup(context.Background(), nil) - assert.Equal(t, []string{"start-1", "start-2"}, callOrder) - - callOrder = nil - _ = c.ServiceShutdown(context.Background()) - assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder) // reverse order -} +assert.Equal(t, "pong", c.QUERY("ping").Value) ``` -## Testing Message Handlers - -### Actions - -Register an action handler and verify it receives the expected message: - ```go -func TestAction(t *testing.T) { - c, _ := core.New() - var received core.Message +c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { + if t == "compute" { + return core.Result{Value: 42, OK: true} + } + return core.Result{} +}) - c.RegisterAction(func(c *core.Core, msg core.Message) error { - received = msg - return nil - }) - - _ = c.ACTION(MyEvent{Data: "test"}) - event, ok := received.(MyEvent) - assert.True(t, ok) - assert.Equal(t, "test", event.Data) -} +assert.Equal(t, 42, c.PERFORM("compute").Value) ``` -### Queries +## Test Async Work + +For `PerformAsync`, observe completion through the action bus. ```go -func TestQuery(t *testing.T) { - c, _ := core.New() +completed := make(chan core.ActionTaskCompleted, 1) - c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) { - if _, ok := q.(GetStatus); ok { - return "healthy", true, nil - } - return nil, false, nil - }) - - result, handled, err := c.QUERY(GetStatus{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "healthy", result) -} +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if event, ok := msg.(core.ActionTaskCompleted); ok { + completed <- event + } + return core.Result{OK: true} +}) ``` -### Tasks +Then wait with normal Go test tools such as channels, timers, or `assert.Eventually`. -```go -func TestTask(t *testing.T) { - c, _ := core.New() +## Use Real Temporary Paths - c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) { - if m, ok := t.(ProcessItem); ok { - return "processed-" + m.ID, true, nil - } - return nil, false, nil - }) +When testing `Fs`, `Data.Extract`, or other I/O helpers, use `t.TempDir()` and create realistic paths instead of mocking the filesystem by default. - result, handled, err := c.PERFORM(ProcessItem{ID: "42"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "processed-42", result) -} -``` - -### Async Tasks - -Use `assert.Eventually` to wait for background task completion: - -```go -func TestAsyncTask(t *testing.T) { - c, _ := core.New() - - var completed atomic.Bool - var resultReceived any - - c.RegisterAction(func(c *core.Core, msg core.Message) error { - if tc, ok := msg.(core.ActionTaskCompleted); ok { - resultReceived = tc.Result - completed.Store(true) - } - return nil - }) - - c.RegisterTask(func(c *core.Core, task core.Task) (any, bool, error) { - return "async-result", true, nil - }) - - taskID := c.PerformAsync(MyTask{}) - assert.NotEmpty(t, taskID) - - assert.Eventually(t, func() bool { - return completed.Load() - }, 1*time.Second, 10*time.Millisecond) - - assert.Equal(t, "async-result", resultReceived) -} -``` - -## Testing with Context Cancellation - -Verify that lifecycle methods respect context cancellation: - -```go -func TestStartupCancellation(t *testing.T) { - c, _ := core.New() - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - s := &MockStartable{} - _ = c.RegisterService("s1", s) - - err := c.ServiceStartup(ctx, nil) - assert.Error(t, err) - assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s.started) -} -``` - -## Global Instance in Tests - -If your code under test uses `core.App()` or `core.GetInstance()`, save and restore the global instance: - -```go -func TestWithGlobalInstance(t *testing.T) { - original := core.GetInstance() - defer core.SetInstance(original) - - c, _ := core.New(core.WithApp(&mockApp{})) - core.SetInstance(c) - - // Test code that calls core.App() - assert.NotNil(t, core.App()) -} -``` - -Or use `ClearInstance()` to ensure a clean state: - -```go -func TestAppPanicsWhenNotSet(t *testing.T) { - original := core.GetInstance() - core.ClearInstance() - defer core.SetInstance(original) - - assert.Panics(t, func() { - core.App() - }) -} -``` - -## Fuzz Testing - -Core includes fuzz tests for critical paths. The pattern is to exercise constructors and registries with arbitrary input: - -```go -func FuzzE(f *testing.F) { - f.Add("svc.Method", "something broke", true) - f.Add("", "", false) - - f.Fuzz(func(t *testing.T, op, msg string, withErr bool) { - var underlying error - if withErr { - underlying = errors.New("wrapped") - } - e := core.E(op, msg, underlying) - if e == nil { - t.Fatal("E() returned nil") - } - }) -} -``` - -Run fuzz tests with: +## Repository Commands ```bash -core go test --run Fuzz --fuzz FuzzE -``` - -Or directly with `go test`: - -```bash -go test -fuzz FuzzE ./pkg/core/ -``` - -## Benchmarks - -Core includes benchmarks for the message bus. Run them with: - -```bash -go test -bench . ./pkg/core/ -``` - -Available benchmarks: - -- `BenchmarkMessageBus_Action` -- ACTION dispatch throughput -- `BenchmarkMessageBus_Query` -- QUERY dispatch throughput -- `BenchmarkMessageBus_Perform` -- PERFORM dispatch throughput - -## Running Tests - -```bash -# All tests core go test - -# Single test -core go test --run TestCore_New_Good - -# With race detector -go test -race ./pkg/core/ - -# Coverage -core go cov -core go cov --open # opens HTML report in browser +core go test --run TestPerformAsync_Good +go test ./... ``` - -## Related Pages - -- [Services](services.md) -- what you are testing -- [Lifecycle](lifecycle.md) -- startup/shutdown behaviour -- [Messaging](messaging.md) -- ACTION/QUERY/PERFORM -- [Errors](errors.md) -- the `E()` helper used in tests