Merge pull request '[agent/claude] Review the README.md and docs/ directory. Verify all code ex...' (#20) from agent/review-the-readme-md-and-docs--directory into main
Some checks failed
CI / test (push) Failing after 5s

This commit is contained in:
Virgil 2026-03-21 11:10:43 +00:00
commit a06b779e3c
17 changed files with 1690 additions and 2967 deletions

26
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests with coverage
run: |
go test -coverprofile=coverage.out ./tests/...
sed -i 's|dappco.re/go/core/||g' coverage.out
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
files: coverage.out
token: ${{ secrets.CODECOV_TOKEN }}

140
CLAUDE.md
View file

@ -1,110 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code and Codex when working with this repository.
## Session Context
## Module
Running on **Claude 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.
`dappco.re/go/core` — dependency injection, service lifecycle, command routing, and message-passing for Go.
## Project Overview
Source files live at the module root (not `pkg/core/`). Tests live in `tests/`.
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`.
## Build & Test
```bash
go test ./tests/... # run all tests
go build . # verify compilation
GOWORK=off go test ./tests/ # test without workspace
```
Or via the Core CLI:
```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
core go qa # fmt + vet + lint + test
```
Run a single test: `core go test --run TestName`
## API Shape
## Architecture
CoreGO uses the DTO/Options/Result pattern, not functional options:
### 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
)
c := core.New(core.Options{
{Key: "name", Value: "myapp"},
})
c.Service("cache", core.Service{
OnStart: func() core.Result { return core.Result{OK: true} },
OnStop: func() core.Result { return core.Result{OK: true} },
})
c.Command("deploy/to/homelab", core.Command{
Action: func(opts core.Options) core.Result {
return core.Result{Value: "deployed", OK: true}
},
})
r := c.Cli().Run("deploy", "to", "homelab")
```
### Service Registration Pattern
**Do not use:** `WithService`, `WithName`, `WithApp`, `WithServiceLock`, `Must*`, `ServiceFor[T]` — these no longer exist.
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
}
## Subsystems
core.New(core.WithService(NewMyService))
```
| Accessor | Returns | Purpose |
|----------|---------|---------|
| `c.Options()` | `*Options` | Input configuration |
| `c.App()` | `*App` | Application identity |
| `c.Data()` | `*Data` | Embedded filesystem mounts |
| `c.Drive()` | `*Drive` | Named transport handles |
| `c.Fs()` | `*Fs` | Local filesystem I/O |
| `c.Config()` | `*Config` | Runtime settings |
| `c.Cli()` | `*Cli` | CLI surface |
| `c.Command("path")` | `Result` | Command tree |
| `c.Service("name")` | `Result` | Service registry |
| `c.Lock("name")` | `*Lock` | Named mutexes |
| `c.IPC()` | `*Ipc` | Message bus |
| `c.I18n()` | `*I18n` | Locale + translation |
- `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method
- `WithName`: Explicitly names a service
## Messaging
### ServiceRuntime Generic Helper (`runtime_pkg.go`)
| Method | Pattern |
|--------|---------|
| `c.ACTION(msg)` | Broadcast to all handlers |
| `c.QUERY(q)` | First responder wins |
| `c.QUERYALL(q)` | Collect all responses |
| `c.PERFORM(task)` | First executor wins |
| `c.PerformAsync(task)` | Background goroutine |
Embed `ServiceRuntime[T]` in services to get access to Core and typed options:
```go
type MyService struct {
*core.ServiceRuntime[MyServiceOptions]
}
```
## Error Handling
### Error Handling (go-log)
Use `core.E()` for structured errors:
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
## Test Naming
Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern:
- `_Good`: Happy path tests
- `_Bad`: Expected error conditions
- `_Ugly`: Panic/edge cases
`_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases).
## Packages
## Docs
| Package | Description |
|---------|-------------|
| `pkg/core` | DI container, service registry, lifecycle, query/task bus |
| `pkg/log` | Structured logger service with Core integration |
Full documentation in `docs/`. Start with `docs/getting-started.md`.
## Go Workspace
Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`.
After adding modules: `go work sync`
Part of `~/Code/go.work`. Use `GOWORK=off` to test in isolation.

542
README.md
View file

@ -1,443 +1,151 @@
# Core
# CoreGO
[![codecov](https://codecov.io/gh/host-uk/core/branch/dev/graph/badge.svg)](https://codecov.io/gh/host-uk/core)
[![Go Test Coverage](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml/badge.svg)](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml)
[![Code Scanning](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml/badge.svg)](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml)
[![Go Version](https://img.shields.io/github/go-mod/go-version/host-uk/core)](https://go.dev/)
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://opensource.org/licenses/EUPL-1.2)
Dependency injection, service lifecycle, command routing, and message-passing for Go.
Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement.
- Repo: https://forge.lthn.ai/core/cli
## Vision
Core is an **opinionated Web3 desktop application framework** providing:
1. **Service-Oriented Architecture** - Pluggable services with dependency injection
2. **Encrypted Workspaces** - Each workspace gets its own PGP keypair, files are obfuscated
3. **Cross-Platform Storage** - Abstract storage backends (local, SFTP, WebDAV) behind a `Medium` interface
4. **Multi-Brand Support** - Same codebase powers different "hub" apps (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
5. **Built-in Crypto** - PGP encryption/signing, hashing, checksums as first-class citizens
**Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n.
## CLI Quick Start
```bash
# 1. Install Core
go install forge.lthn.ai/core/cli/cmd/core@latest
# 2. Verify environment
core doctor
# 3. Run tests in any Go/PHP project
core go test # or core php test
# 4. Build and preview release
core build
core ci
```
For more details, see the [User Guide](docs/user-guide.md).
## Framework Quick Start (Go)
Import path:
```go
import core "forge.lthn.ai/core/cli/pkg/framework/core"
import "dappco.re/go/core"
```
app, err := core.New(
core.WithServiceLock(),
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
package main
import (
"context"
"fmt"
"dappco.re/go/core"
)
type flushCacheTask struct {
Name string
}
func main() {
c := core.New(core.Options{
{Key: "name", Value: "agent-workbench"},
})
c.Service("cache", core.Service{
OnStart: func() core.Result {
core.Info("cache started", "app", c.App().Name)
return core.Result{OK: true}
},
OnStop: func() core.Result {
core.Info("cache stopped", "app", c.App().Name)
return core.Result{OK: true}
},
})
c.RegisterTask(func(_ *core.Core, task core.Task) core.Result {
switch t := task.(type) {
case flushCacheTask:
return core.Result{Value: "cache flushed for " + t.Name, OK: true}
}
return core.Result{}
})
c.Command("cache/flush", core.Command{
Action: func(opts core.Options) core.Result {
return c.PERFORM(flushCacheTask{
Name: opts.String("name"),
})
},
})
if !c.ServiceStartup(context.Background(), nil).OK {
panic("startup failed")
}
r := c.Cli().Run("cache", "flush", "--name=session-store")
fmt.Println(r.Value)
_ = c.ServiceShutdown(context.Background())
}
```
## Prerequisites
## Core Surfaces
- [Go](https://go.dev/) 1.25+
- [Node.js](https://nodejs.org/)
- [Wails](https://wails.io/) v3
- [Task](https://taskfile.dev/)
| 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 |
## Development Workflow (TDD)
## AX-Friendly Model
CoreGO follows the same design direction as the AX spec:
- predictable names over compressed names
- paths as documentation, such as `deploy/to/homelab`
- one repeated vocabulary across the framework
- examples that show how to call real APIs
## Install
```bash
task test-gen # 1. Generate test stubs
task test # 2. Run tests (watch them fail)
# 3. Implement your feature
task test # 4. Run tests (watch them pass)
task review # 5. CodeRabbit review
go get dappco.re/go/core
```
## Building & Running
Requires Go 1.26 or later.
## Test
```bash
# GUI (Wails)
task gui:dev # Development with hot-reload
task gui:build # Production build
# CLI
task cli:build # Build to cmd/core/bin/core
task cli:run # Build and run
core go test
```
## Configuration
Core uses a layered configuration system where values are resolved in the following priority:
1. **Command-line flags** (if applicable)
2. **Environment variables**
3. **Configuration file**
4. **Default values**
### Configuration File
The default configuration file is located at `~/.core/config.yaml`.
#### Format
The file uses YAML format and supports nested structures.
```yaml
# ~/.core/config.yaml
dev:
editor: vim
debug: true
log:
level: info
```
### Environment Variables
#### Layered Configuration Mapping
Any configuration value can be overridden using environment variables with the `CORE_CONFIG_` prefix. After stripping the `CORE_CONFIG_` prefix, the remaining variable name is converted to lowercase and underscores are replaced with dots to map to the configuration hierarchy.
**Examples:**
- `CORE_CONFIG_DEV_EDITOR=nano` maps to `dev.editor: nano`
- `CORE_CONFIG_LOG_LEVEL=debug` maps to `log.level: debug`
#### Common Environment Variables
| Variable | Description |
|----------|-------------|
| `CORE_DAEMON` | Set to `1` to run the application in daemon mode. |
| `NO_COLOR` | If set (to any value), disables ANSI color output. |
| `MCP_ADDR` | Address for the MCP TCP server (e.g., `localhost:9100`). If not set, MCP uses Stdio. |
| `COOLIFY_TOKEN` | API token for Coolify deployments. |
| `AGENTIC_TOKEN` | API token for Agentic services. |
| `UNIFI_URL` | URL of the UniFi controller (e.g., `https://192.168.1.1`). |
| `UNIFI_INSECURE` | Set to `1` or `true` to skip UniFi TLS verification. |
## All Tasks
| Task | Description |
|------|-------------|
| `task test` | Run all Go tests |
| `task test-gen` | Generate test stubs for public API |
| `task check` | go mod tidy + tests + review |
| `task review` | CodeRabbit review |
| `task cov` | Run tests with coverage report |
| `task cov-view` | Open HTML coverage report |
| `task sync` | Update public API Go files |
---
## Architecture
### Project Structure
```
.
├── main.go # CLI application entry point
├── pkg/
│ ├── framework/core/ # Service container, DI, Runtime[T]
│ ├── crypt/ # Hashing, checksums, PGP
│ ├── io/ # Medium interface + backends
│ ├── help/ # In-app documentation
│ ├── i18n/ # Internationalization
│ ├── repos/ # Multi-repo registry & management
│ ├── agentic/ # AI agent task management
│ └── mcp/ # Model Context Protocol service
├── internal/
│ ├── cmd/ # CLI command implementations
│ └── variants/ # Build variants (full, minimal, etc.)
└── go.mod # Go module definition
```
### Service Pattern (Dual-Constructor DI)
Every service follows this pattern:
```go
// Static DI - standalone use/testing (no core.Runtime)
func New() (*Service, error)
// Dynamic DI - for core.WithService() registration
func Register(c *core.Core) (any, error)
```
Services embed `*core.Runtime[Options]` for access to `Core()` and `Config()`.
### IPC/Action System
Services implement `HandleIPCEvents(c *core.Core, msg core.Message) error` - auto-discovered via reflection. Handles typed actions like `core.ActionServiceStartup`.
---
## Wails v3 Frontend Bindings
Core uses [Wails v3](https://v3alpha.wails.io/) to expose Go methods to a WebView2 browser runtime. Wails automatically generates TypeScript bindings for registered services.
**Documentation:** [Wails v3 Method Bindings](https://v3alpha.wails.io/features/bindings/methods/)
### How It Works
1. **Go services** with exported methods are registered with Wails
2. Run `wails3 generate bindings` (or `wails3 dev` / `wails3 build`)
3. **TypeScript SDK** is generated in `frontend/bindings/`
4. Frontend calls Go methods with full type safety, no HTTP overhead
### Current Binding Architecture
```go
// cmd/core-gui/main.go
app.RegisterService(application.NewService(coreService)) // Only Core is registered
```
**Problem:** Only `Core` is registered with Wails. Sub-services (crypt, workspace, display, etc.) are internal to Core's service map - their methods aren't directly exposed to JS.
**Currently exposed** (see `cmd/core-gui/public/bindings/`):
```typescript
// From frontend:
import { ACTION, Config, Service } from './bindings/forge.lthn.ai/core/cli/pkg/core'
ACTION(msg) // Broadcast IPC message
Config() // Get config service reference
Service("workspace") // Get service by name (returns any)
```
**NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`.
## Configuration Management
Core uses a **centralized configuration service** implemented in `pkg/config`, with YAML-based persistence and layered overrides.
The `pkg/config` package provides:
- YAML-backed persistence at `~/.core/config.yaml`
- Dot-notation key access (for example: `cfg.Set("dev.editor", "vim")`, `cfg.GetString("dev.editor")`)
- Environment variable overlay support (env vars can override persisted values)
- Thread-safe operations for concurrent reads/writes
Application code should treat `pkg/config` as the **primary configuration mechanism**. Direct reads/writes to YAML files should generally be avoided from application logic in favour of using this centralized service.
### Project and Service Configuration Files
In addition to the centralized configuration service, Core uses several YAML files for project-specific build/CI and service configuration. These live alongside (but are distinct from) the centralized configuration:
- **Project Configuration** (in the `.core/` directory of the project root):
- `build.yaml`: Build targets, flags, and project metadata.
- `release.yaml`: Release automation, changelog settings, and publishing targets.
- `ci.yaml`: CI pipeline configuration.
- **Global Configuration** (in the `~/.core/` directory):
- `config.yaml`: Centralized user/framework settings and defaults, managed via `pkg/config`.
- `agentic.yaml`: Configuration for agentic services (BaseURL, Token, etc.).
- **Registry Configuration** (`repos.yaml`, auto-discovered):
- Multi-repo registry definition.
- Searched in the current directory and its parent directories (walking up).
- Then in `~/Code/host-uk/repos.yaml`.
- Finally in `~/.config/core/repos.yaml`.
### Format
All persisted configuration files described above use **YAML** format for readability and nested structure support.
### The IPC Bridge Pattern (Chosen Architecture)
Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings:
```typescript
// Frontend calls Core.ACTION() with typed messages
import { ACTION } from './bindings/forge.lthn.ai/core/cli/pkg/core'
// Open a window
ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } })
// Switch workspace
ACTION({ action: "workspace.switch_workspace", name: "myworkspace" })
```
Each service implements `HandleIPCEvents(c *core.Core, msg core.Message)` to process these messages:
```go
// pkg/display/display.go
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
switch m := msg.(type) {
case map[string]any:
if action, ok := m["action"].(string); ok && action == "display.open_window" {
return s.handleOpenWindowAction(m)
}
}
return nil
}
```
**Why this pattern:**
- Single Wails service (Core) = simpler binding generation
- Services remain decoupled from Wails
- Centralized message routing via `ACTION()`
- Services can communicate internally using same pattern
**Current gap:** Not all service methods have IPC handlers yet. See `HandleIPCEvents` in each service to understand what's wired up.
### Generating Bindings
Wails v3 bindings are typically generated in the GUI repository (e.g., `core-gui`).
Or with the standard toolchain:
```bash
wails3 generate bindings # Regenerate after Go changes
go test ./...
```
---
## Docs
### Service Interfaces (`pkg/framework/core/interfaces.go`)
The full documentation set lives in `docs/`.
```go
type Config interface {
Get(key string, out any) error
Set(key string, v any) error
}
| 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 |
type Display interface {
OpenWindow(opts ...WindowOption) error
}
## License
type Workspace interface {
CreateWorkspace(identifier, password string) (string, error)
SwitchWorkspace(name string) error
WorkspaceFileGet(filename string) (string, error)
WorkspaceFileSet(filename, content string) error
}
type Crypt interface {
EncryptPGP(writer io.Writer, recipientPath, data string, ...) (string, error)
DecryptPGP(recipientPath, message, passphrase string, ...) (string, error)
}
```
---
## Current State (Prototype)
### Working
| Package | Notes |
|---------|-------|
| `pkg/framework/core` | Service container, DI, thread-safe - solid |
| `pkg/config` | Layered YAML configuration, XDG paths - solid |
| `pkg/crypt` | Hashing, checksums, symmetric/asymmetric - solid, well-tested |
| `pkg/help` | Embedded docs, full-text search - solid |
| `pkg/i18n` | Multi-language with go-i18n - solid |
| `pkg/io` | Medium interface + local backend - solid |
| `pkg/repos` | Multi-repo registry & management - solid |
| `pkg/agentic` | AI agent task management - solid |
| `pkg/mcp` | Model Context Protocol service - solid |
---
## Package Deep Dives
### pkg/crypt
The crypt package provides a comprehensive suite of cryptographic primitives:
- **Hashing & Checksums**: SHA-256, SHA-512, and CRC32 support.
- **Symmetric Encryption**: AES-GCM and ChaCha20-Poly1305 for secure data at rest.
- **Key Derivation**: Argon2id for secure password hashing.
- **Asymmetric Encryption**: PGP implementation in the `pkg/crypt/openpgp` subpackage using `github.com/ProtonMail/go-crypto`.
### pkg/io - Storage Abstraction
```go
type Medium interface {
Read(path string) (string, error)
Write(path, content string) error
EnsureDir(path string) error
IsFile(path string) bool
FileGet(path string) (string, error)
FileSet(path, content string) error
}
```
Implementations: `local/`, `sftp/`, `webdav/`
---
## Future Work
### Phase 1: Core Stability
- [x] ~~Fix workspace medium injection (critical blocker)~~
- [x] ~~Initialize `io.Local` global~~
- [x] ~~Clean up dead code (orphaned vars, broken wrappers)~~
- [x] ~~Wire up IPC handlers for all services (config, crypt, display, help, i18n, workspace)~~
- [x] ~~Complete display menu handlers (New/List workspace)~~
- [x] ~~Tray icon setup with asset embedding~~
- [x] ~~Test coverage for io packages~~
- [ ] System tray brand-specific menus
### Phase 2: Multi-Brand Support
- [ ] Define brand configuration system (config? build flags?)
- [ ] Implement brand-specific tray menus (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
- [ ] Brand-specific theming/assets
- [ ] Per-brand default workspace configurations
### Phase 3: Remote Storage
- [ ] Complete SFTP backend (`pkg/io/sftp/`)
- [ ] Complete WebDAV backend (`pkg/io/webdav/`)
- [ ] Workspace sync across storage backends
- [ ] Conflict resolution for multi-device access
### Phase 4: Enhanced Crypto
- [ ] Key management UI (import/export, key rotation)
- [ ] Multi-recipient encryption
- [ ] Hardware key support (YubiKey, etc.)
- [ ] Encrypted workspace backup/restore
### Phase 5: Developer Experience
- [ ] TypeScript types for IPC messages (codegen from Go structs)
- [ ] Hot-reload for service registration
- [ ] Plugin system for third-party services
- [ ] CLI tooling for workspace management
### Phase 6: Distribution
- [ ] Auto-update mechanism
- [ ] Platform installers (DMG, MSI, AppImage)
- [ ] Signing and notarization
- [ ] Crash reporting integration
---
## Getting Help
- **[User Guide](docs/user-guide.md)**: Detailed usage and concepts.
- **[FAQ](docs/faq.md)**: Frequently asked questions.
- **[Workflows](docs/workflows.md)**: Common task sequences.
- **[Troubleshooting](docs/troubleshooting.md)**: Solving common issues.
- **[Configuration](docs/configuration.md)**: Config file reference.
```bash
# Check environment
core doctor
# Command help
core <command> --help
```
---
## For New Contributors
1. Run `task test` to verify all tests pass
2. Follow TDD: `task test-gen` creates stubs, implement to pass
3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime
4. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge
EUPL-1.2

177
docs/commands.md Normal file
View 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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