docs: rewrite documentation suite against AX spec
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 <virgil@lethean.io>
This commit is contained in:
parent
df1576b101
commit
2d52b83f60
16 changed files with 1702 additions and 2556 deletions
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
|
|
@ -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`
|
||||
199
README.md
199
README.md
|
|
@ -1,118 +1,151 @@
|
|||
# Core
|
||||
# CoreGO
|
||||
|
||||
[](https://codecov.io/gh/dAppCore/core)
|
||||
[](https://go.dev/)
|
||||
[](https://opensource.org/licenses/EUPL-1.2)
|
||||
[](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
|
||||
|
|
|
|||
177
docs/commands.md
Normal file
177
docs/commands.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
181
docs/errors.md
181
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
144
docs/index.md
144
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
681
docs/pkg/core.md
681
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: <underlying error>"
|
||||
|
||||
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.
|
||||
|
|
|
|||
112
docs/pkg/log.md
112
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()`.
|
||||
|
|
|
|||
169
docs/primitives.md
Normal file
169
docs/primitives.md
Normal file
|
|
@ -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.
|
||||
317
docs/services.md
317
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.
|
||||
|
|
|
|||
158
docs/subsystems.md
Normal file
158
docs/subsystems.md
Normal file
|
|
@ -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.
|
||||
358
docs/testing.md
358
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue