Compare commits

..

No commits in common. "dev" and "v0.3.2" have entirely different histories.
dev ... v0.3.2

128 changed files with 6252 additions and 16751 deletions

View file

@ -1,26 +0,0 @@
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 }}

22
.golangci.yml Normal file
View file

@ -0,0 +1,22 @@
run:
timeout: 5m
go: "1.26"
linters:
enable:
- govet
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gocritic
- gofmt
disable:
- exhaustive
- wrapcheck
issues:
exclude-use-default: false
max-same-issues: 0

145
CLAUDE.md
View file

@ -1,103 +1,106 @@
# CLAUDE.md
Guidance for Claude Code and Codex when working with this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Module
## Project Overview
`dappco.re/go/core` — dependency injection, service lifecycle, permission, and message-passing for Go.
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.
Source files and tests live at the module root. No `pkg/` nesting.
This is the foundation layer — it has no CLI, no GUI, and minimal dependencies (`go-io`, `go-log`, `testify`).
## Build & Test
```bash
go test ./... -count=1 # run all tests (483 tests, 84.7% coverage)
go build ./... # verify compilation
```
Or via the Core CLI:
## Build & Development Commands
This project uses `core go` commands (no Taskfile). Build configuration lives in `.core/build.yaml`.
```bash
# Run all tests
core go test
core go qa # fmt + vet + lint + 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
```
## API Shape
Run a single test: `core go test --run TestName`
## Architecture
### Core Framework (`pkg/core/`)
The `Core` struct is the central application container managing:
- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()`
- **Actions/IPC**: Message-passing system where services communicate via `ACTION(msg Message)` and register handlers via `RegisterAction()`
- **Lifecycle**: Services implementing `Startable` (OnStartup) and/or `Stoppable` (OnShutdown) interfaces are automatically called during app lifecycle
Creating a Core instance:
```go
c := core.New(
core.WithOption("name", "myapp"),
core.WithService(mypackage.Register),
core.WithServiceLock(),
core, err := core.New(
core.WithService(myServiceFactory),
core.WithAssets(assets),
core.WithServiceLock(), // Prevents late service registration
)
c.Run() // or: if err := c.RunE(); err != nil { ... }
```
Service factory:
### Service Registration Pattern
Services are registered via factory functions that receive the Core instance:
```go
func Register(c *core.Core) core.Result {
svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, MyOpts{})}
return core.Result{Value: svc, OK: true}
func NewMyService(c *core.Core) (any, error) {
return &MyService{runtime: core.NewServiceRuntime(c, opts)}, nil
}
core.New(core.WithService(NewMyService))
```
- `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method
- `WithName`: Explicitly names a service
### ServiceRuntime Generic Helper (`runtime_pkg.go`)
Embed `ServiceRuntime[T]` in services to get access to Core and typed options:
```go
type MyService struct {
*core.ServiceRuntime[MyServiceOptions]
}
```
## Subsystems
| Accessor | Returns | Purpose |
|----------|---------|---------|
| `c.Options()` | `*Options` | Input configuration |
| `c.App()` | `*App` | Application identity |
| `c.Config()` | `*Config` | Runtime settings, feature flags |
| `c.Data()` | `*Data` | Embedded assets (Registry[*Embed]) |
| `c.Drive()` | `*Drive` | Transport handles (Registry[*DriveHandle]) |
| `c.Fs()` | `*Fs` | Filesystem I/O (sandboxable) |
| `c.Cli()` | `*Cli` | CLI command framework |
| `c.IPC()` | `*Ipc` | Message bus internals |
| `c.Process()` | `*Process` | Managed execution (Action sugar) |
| `c.API()` | `*API` | Remote streams (protocol handlers) |
| `c.Action(name)` | `*Action` | Named callable (register/invoke) |
| `c.Task(name)` | `*Task` | Composed Action sequence |
| `c.Entitled(name)` | `Entitlement` | Permission check |
| `c.RegistryOf(n)` | `*Registry` | Cross-cutting queries |
| `c.I18n()` | `*I18n` | Internationalisation |
## Messaging
| Method | Pattern |
|--------|---------|
| `c.ACTION(msg)` | Broadcast to all handlers (panic recovery per handler) |
| `c.QUERY(q)` | First responder wins |
| `c.QUERYALL(q)` | Collect all responses |
| `c.PerformAsync(action, opts)` | Background goroutine with progress |
## Lifecycle
```go
type Startable interface { OnStartup(ctx context.Context) Result }
type Stoppable interface { OnShutdown(ctx context.Context) Result }
```
`RunE()` always calls `defer ServiceShutdown` — even on startup failure or panic.
## Error Handling
Use `core.E()` for structured errors:
### Error Handling (go-log)
All errors MUST use `E()` from `go-log` (re-exported in `e.go`), never `fmt.Errorf`:
```go
return core.E("service.Method", "what failed", underlyingErr)
return core.E("service.Method", fmt.Sprintf("service %q not found", name), nil)
```
**Never** use `fmt.Errorf`, `errors.New`, `os/exec`, or `unsafe.Pointer` on Core types.
### Test Naming Convention
## Test Naming (AX-7)
Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern:
- `_Good`: Happy path tests
- `_Bad`: Expected error conditions
- `_Ugly`: Panic/edge cases
`TestFile_Function_{Good,Bad,Ugly}` — 100% compliance.
## Packages
## Docs
Full API contract: `docs/RFC.md` (1476 lines, 21 sections).
| Package | Description |
|---------|-------------|
| `pkg/core` | DI container, service registry, lifecycle, query/task bus |
| `pkg/log` | Structured logger service with Core integration |
## Go Workspace
Part of `~/Code/go.work`. Use `GOWORK=off` to test in isolation.
Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`.
After adding modules: `go work sync`

View file

@ -1,38 +0,0 @@
# Specification Mismatches
## Scope
Findings are mismatches between current repository source behavior and existing docs/spec pages under `docs/`.
### 1) `docs/getting-started.md` uses deprecated constructor pattern
- Example and prose show `core.New(core.Options{...})` and say constructor reads only the first `core.Options`.
- Current code uses variadic `core.New(...CoreOption)` only; passing `core.Options` requires `core.WithOptions(core.NewOptions(...))`.
- References: `docs/getting-started.md:18`, `docs/getting-started.md:26`, `docs/getting-started.md:142`.
### 2) `docs/testing.md` and `docs/configuration.md` repeat outdated constructor usage
- Both files document `core.New(core.Options{...})` examples.
- Current constructor is variadic `CoreOption` values.
- References: `docs/testing.md:29`, `docs/configuration.md:16`.
### 3) `docs/lifecycle.md` claims registry order is map-backed and unstable
- File states `Startables()/Stoppables()` are built from a map-backed registry and therefore non-deterministic.
- Current `Registry` stores an explicit insertion-order slice and iterates in insertion order.
- References: `docs/lifecycle.md:64-67`.
### 4) `docs/services.md` stale ordering and lock-name behavior
- Claims registry is map-backed; actual behavior is insertion-order iteration.
- States default service lock name is `"srv"`, but `LockEnable`/`LockApply` do not expose/use a default namespace in implementation.
- References: `docs/services.md:53`, `docs/services.md:86-88`.
### 5) `docs/commands.md` documents removed managed lifecycle field
- Section “Lifecycle Commands” shows `Lifecycle` field with `Start/Stop/Restart/Reload/Signal` callbacks.
- Current `Command` struct has `Managed string` and no `Lifecycle` field.
- References: `docs/commands.md:155-159`.
### 6) `docs/subsystems.md` documents legacy options creation call for subsystem registration
- Uses `c.Data().New(core.Options{...})` and `c.Drive().New(core.Options{...})`.
- `Data.New` and `Drive.New` expect `core.Options` via varargs usage helpers (`core.NewOptions` in current docs/usage pattern).
- References: `docs/subsystems.md:44`, `docs/subsystems.md:75`, `docs/subsystems.md:80`.
### 7) `docs/index.md` RFC summary is stale
- Claims `docs/RFC.md` is 21 sections, 1476 lines, but current RFC content has expanded sections/size.
- Reference: `docs/index.md` table header note.

467
README.md
View file

@ -1,74 +1,443 @@
# CoreGO
# Core
Dependency injection, service lifecycle, permission, and message-passing for Go.
[![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)
```go
import "dappco.re/go/core"
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
```
CoreGO is the foundation layer for the Core ecosystem. It gives you:
For more details, see the [User Guide](docs/user-guide.md).
- one container: `Core`
- one input shape: `Options`
- one output shape: `Result`
- one command tree: `Command`
- one message bus: `ACTION`, `QUERY` + named `Action` callables
- one permission gate: `Entitled`
- one collection primitive: `Registry[T]`
## Quick Example
## Framework Quick Start (Go)
```go
package main
import core "forge.lthn.ai/core/cli/pkg/framework/core"
import "dappco.re/go/core"
app, err := core.New(
core.WithServiceLock(),
)
```
func main() {
c := core.New(
core.WithOption("name", "agent-workbench"),
core.WithService(cache.Register),
core.WithServiceLock(),
)
c.Run()
## Prerequisites
- [Go](https://go.dev/) 1.25+
- [Node.js](https://nodejs.org/)
- [Wails](https://wails.io/) v3
- [Task](https://taskfile.dev/)
## Development Workflow (TDD)
```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
```
## Building & Running
```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
```
## 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
}
```
## Core Surfaces
**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
| Surface | Purpose |
|---------|---------|
| `Core` | Central container and access point |
| `Service` | Managed lifecycle component |
| `Command` | Path-based executable operation |
| `Action` | Named callable with panic recovery + entitlement |
| `Task` | Composed sequence of Actions |
| `Registry[T]` | Thread-safe named collection |
| `Process` | Managed execution (Action sugar) |
| `API` | Remote streams (protocol handlers) |
| `Entitlement` | Permission check result |
| `Data` | Embedded filesystem mounts |
| `Drive` | Named transport handles |
| `Fs` | Local filesystem (sandboxable) |
| `Config` | Runtime settings and feature flags |
**Current gap:** Not all service methods have IPC handlers yet. See `HandleIPCEvents` in each service to understand what's wired up.
## Install
### Generating Bindings
Wails v3 bindings are typically generated in the GUI repository (e.g., `core-gui`).
```bash
go get dappco.re/go/core
wails3 generate bindings # Regenerate after Go changes
```
Requires Go 1.26 or later.
---
## Test
### Service Interfaces (`pkg/framework/core/interfaces.go`)
```go
type Config interface {
Get(key string, out any) error
Set(key string, v any) error
}
type Display interface {
OpenWindow(opts ...WindowOption) error
}
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
go test ./... # 483 tests, 84.7% coverage
# Check environment
core doctor
# Command help
core <command> --help
```
## Docs
---
The authoritative API contract is `docs/RFC.md` (21 sections).
## For New Contributors
## License
EUPL-1.2
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

233
action.go
View file

@ -1,233 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Named action system for the Core framework.
// Actions are the atomic unit of work — named, registered, invokable,
// and inspectable. The Action registry IS the capability map.
//
// Register a named action:
//
// c.Action("git.log", func(ctx context.Context, opts core.Options) core.Result {
// dir := opts.String("dir")
// return c.Process().RunIn(ctx, dir, "git", "log")
// })
//
// Invoke by name:
//
// r := c.Action("git.log").Run(ctx, core.NewOptions(
// core.Option{Key: "dir", Value: "/path/to/repo"},
// ))
//
// Check capability:
//
// if c.Action("process.run").Exists() { ... }
//
// List all:
//
// names := c.Actions() // ["process.run", "agentic.dispatch", ...]
package core
import "context"
// ActionHandler is the function signature for all named actions.
//
// func(ctx context.Context, opts core.Options) core.Result
type ActionHandler func(context.Context, Options) Result
// Action is a registered named action.
//
// action := c.Action("process.run")
// action.Description // "Execute a command"
// action.Schema // expected input keys
type Action struct {
Name string
Handler ActionHandler
Description string
Schema Options // declares expected input keys (optional)
enabled bool
core *Core // for entitlement checks during Run()
}
// Run executes the action with panic recovery.
// Returns Result{OK: false} if the action has no handler (not registered).
//
// r := c.Action("process.run").Run(ctx, opts)
func (a *Action) Run(ctx context.Context, opts Options) (result Result) {
if a == nil || a.Handler == nil {
return Result{E("action.Run", Concat("action not registered: ", a.safeName()), nil), false}
}
if !a.enabled {
return Result{E("action.Run", Concat("action disabled: ", a.Name), nil), false}
}
// Entitlement check — permission boundary
if a.core != nil {
if e := a.core.Entitled(a.Name); !e.Allowed {
return Result{E("action.Run", Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false}
}
}
defer func() {
if r := recover(); r != nil {
result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false}
}
}()
return a.Handler(ctx, opts)
}
// Exists returns true if this action has a registered handler.
//
// if c.Action("process.run").Exists() { ... }
func (a *Action) Exists() bool {
return a != nil && a.Handler != nil
}
func (a *Action) safeName() string {
if a == nil {
return "<nil>"
}
return a.Name
}
// --- Core accessor ---
// Action gets or registers a named action.
// With a handler argument: registers the action.
// Without: returns the action for invocation.
//
// c.Action("process.run", handler) // register
// c.Action("process.run").Run(ctx, opts) // invoke
// c.Action("process.run").Exists() // check
func (c *Core) Action(name string, handler ...ActionHandler) *Action {
if len(handler) > 0 {
def := &Action{Name: name, Handler: handler[0], enabled: true, core: c}
c.ipc.actions.Set(name, def)
return def
}
r := c.ipc.actions.Get(name)
if !r.OK {
return &Action{Name: name} // no handler — Exists() returns false
}
return r.Value.(*Action)
}
// Actions returns all registered named action names in registration order.
//
// names := c.Actions() // ["process.run", "agentic.dispatch"]
func (c *Core) Actions() []string {
return c.ipc.actions.Names()
}
// --- Task Composition ---
// Step is a single step in a Task — references an Action by name.
//
// core.Step{Action: "agentic.qa"}
// core.Step{Action: "agentic.poke", Async: true}
// core.Step{Action: "agentic.verify", Input: "previous"}
type Step struct {
Action string // name of the Action to invoke
With Options // static options (merged with runtime opts)
Async bool // run in background, don't block
Input string // "previous" = output of last step piped as input
}
// Task is a named sequence of Steps.
//
// c.Task("agent.completion", core.Task{
// Steps: []core.Step{
// {Action: "agentic.qa"},
// {Action: "agentic.auto-pr"},
// {Action: "agentic.verify"},
// {Action: "agentic.poke", Async: true},
// },
// })
type Task struct {
Name string
Description string
Steps []Step
}
// Run executes the task's steps in order. Sync steps run sequentially —
// if any fails, the chain stops. Async steps are dispatched and don't block.
// The "previous" input pipes the last sync step's output to the next step.
//
// r := c.Task("deploy").Run(ctx, opts)
func (t *Task) Run(ctx context.Context, c *Core, opts Options) Result {
if t == nil || len(t.Steps) == 0 {
return Result{E("task.Run", Concat("task has no steps: ", t.safeName()), nil), false}
}
var lastResult Result
for _, step := range t.Steps {
// Use step's own options, or runtime options if step has none
stepOpts := stepOptions(step)
if stepOpts.Len() == 0 {
stepOpts = opts
}
// Pipe previous result as input
if step.Input == "previous" && lastResult.OK {
stepOpts.Set("_input", lastResult.Value)
}
action := c.Action(step.Action)
if !action.Exists() {
return Result{E("task.Run", Concat("action not found: ", step.Action), nil), false}
}
if step.Async {
// Fire and forget — don't block the chain
go func(a *Action, o Options) {
defer func() {
if r := recover(); r != nil {
Error("async task step panicked", "action", a.Name, "panic", r)
}
}()
a.Run(ctx, o)
}(action, stepOpts)
continue
}
lastResult = action.Run(ctx, stepOpts)
if !lastResult.OK {
return lastResult
}
}
return lastResult
}
func (t *Task) safeName() string {
if t == nil {
return "<nil>"
}
return t.Name
}
// mergeStepOptions returns the step's With options — runtime opts are passed directly.
// Step.With provides static defaults that the step was registered with.
func stepOptions(step Step) Options {
return step.With
}
// Task gets or registers a named task.
// With a Task argument: registers the task.
// Without: returns the task for invocation.
//
// c.Task("deploy", core.Task{Steps: steps}) // register
// c.Task("deploy").Run(ctx, c, opts) // invoke
func (c *Core) Task(name string, def ...Task) *Task {
if len(def) > 0 {
d := def[0]
d.Name = name
c.ipc.tasks.Set(name, &d)
return &d
}
r := c.ipc.tasks.Get(name)
if !r.OK {
return &Task{Name: name}
}
return r.Value.(*Task)
}
// Tasks returns all registered task names.
func (c *Core) Tasks() []string {
return c.ipc.tasks.Names()
}

View file

@ -1,59 +0,0 @@
package core_test
import (
"context"
. "dappco.re/go/core"
)
func ExampleAction_Run() {
c := New()
c.Action("double", func(_ context.Context, opts Options) Result {
return Result{Value: opts.Int("n") * 2, OK: true}
})
r := c.Action("double").Run(context.Background(), NewOptions(
Option{Key: "n", Value: 21},
))
Println(r.Value)
// Output: 42
}
func ExampleAction_Exists() {
c := New()
Println(c.Action("missing").Exists())
c.Action("present", func(_ context.Context, _ Options) Result { return Result{OK: true} })
Println(c.Action("present").Exists())
// Output:
// false
// true
}
func ExampleAction_Run_panicRecovery() {
c := New()
c.Action("boom", func(_ context.Context, _ Options) Result {
panic("explosion")
})
r := c.Action("boom").Run(context.Background(), NewOptions())
Println(r.OK)
// Output: false
}
func ExampleAction_Run_entitlementDenied() {
c := New()
c.Action("premium", func(_ context.Context, _ Options) Result {
return Result{Value: "secret", OK: true}
})
c.SetEntitlementChecker(func(action string, _ int, _ context.Context) Entitlement {
if action == "premium" {
return Entitlement{Allowed: false, Reason: "upgrade"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
r := c.Action("premium").Run(context.Background(), NewOptions())
Println(r.OK)
// Output: false
}

View file

@ -1,246 +0,0 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- NamedAction Register ---
func TestAction_NamedAction_Good_Register(t *testing.T) {
c := New()
def := c.Action("process.run", func(_ context.Context, opts Options) Result {
return Result{Value: "output", OK: true}
})
assert.NotNil(t, def)
assert.Equal(t, "process.run", def.Name)
assert.True(t, def.Exists())
}
func TestAction_NamedAction_Good_Invoke(t *testing.T) {
c := New()
c.Action("git.log", func(_ context.Context, opts Options) Result {
dir := opts.String("dir")
return Result{Value: Concat("log from ", dir), OK: true}
})
r := c.Action("git.log").Run(context.Background(), NewOptions(
Option{Key: "dir", Value: "/repo"},
))
assert.True(t, r.OK)
assert.Equal(t, "log from /repo", r.Value)
}
func TestAction_NamedAction_Bad_NotRegistered(t *testing.T) {
c := New()
r := c.Action("missing.action").Run(context.Background(), NewOptions())
assert.False(t, r.OK, "invoking unregistered action must fail")
}
func TestAction_NamedAction_Good_Exists(t *testing.T) {
c := New()
c.Action("brain.recall", func(_ context.Context, _ Options) Result {
return Result{OK: true}
})
assert.True(t, c.Action("brain.recall").Exists())
assert.False(t, c.Action("brain.forget").Exists())
}
func TestAction_NamedAction_Ugly_PanicRecovery(t *testing.T) {
c := New()
c.Action("explode", func(_ context.Context, _ Options) Result {
panic("boom")
})
r := c.Action("explode").Run(context.Background(), NewOptions())
assert.False(t, r.OK, "panicking action must return !OK, not crash")
err, ok := r.Value.(error)
assert.True(t, ok)
assert.Contains(t, err.Error(), "panic")
}
func TestAction_NamedAction_Ugly_NilAction(t *testing.T) {
var def *Action
r := def.Run(context.Background(), NewOptions())
assert.False(t, r.OK)
assert.False(t, def.Exists())
}
// --- Actions listing ---
func TestAction_Actions_Good(t *testing.T) {
c := New()
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
c.Action("process.kill", func(_ context.Context, _ Options) Result { return Result{OK: true} })
c.Action("agentic.dispatch", func(_ context.Context, _ Options) Result { return Result{OK: true} })
names := c.Actions()
assert.Len(t, names, 3)
assert.Equal(t, []string{"process.run", "process.kill", "agentic.dispatch"}, names)
}
func TestAction_Actions_Bad_Empty(t *testing.T) {
c := New()
assert.Empty(t, c.Actions())
}
// --- Action fields ---
func TestAction_NamedAction_Good_DescriptionAndSchema(t *testing.T) {
c := New()
def := c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
def.Description = "Execute a command synchronously"
def.Schema = NewOptions(
Option{Key: "command", Value: "string"},
Option{Key: "args", Value: "[]string"},
)
retrieved := c.Action("process.run")
assert.Equal(t, "Execute a command synchronously", retrieved.Description)
assert.True(t, retrieved.Schema.Has("command"))
}
// --- Permission by registration ---
func TestAction_NamedAction_Good_PermissionModel(t *testing.T) {
// Full Core — process registered
full := New()
full.Action("process.run", func(_ context.Context, _ Options) Result {
return Result{Value: "executed", OK: true}
})
// Sandboxed Core — no process
sandboxed := New()
// Full can execute
r := full.Action("process.run").Run(context.Background(), NewOptions())
assert.True(t, r.OK)
// Sandboxed returns not-registered
r = sandboxed.Action("process.run").Run(context.Background(), NewOptions())
assert.False(t, r.OK, "sandboxed Core must not have process capability")
}
// --- Action overwrite ---
func TestAction_NamedAction_Good_Overwrite(t *testing.T) {
c := New()
c.Action("hot.reload", func(_ context.Context, _ Options) Result {
return Result{Value: "v1", OK: true}
})
c.Action("hot.reload", func(_ context.Context, _ Options) Result {
return Result{Value: "v2", OK: true}
})
r := c.Action("hot.reload").Run(context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "v2", r.Value, "latest handler wins")
}
// --- Task Composition ---
func TestAction_Task_Good_Sequential(t *testing.T) {
c := New()
var order []string
c.Action("step.a", func(_ context.Context, _ Options) Result {
order = append(order, "a")
return Result{Value: "output-a", OK: true}
})
c.Action("step.b", func(_ context.Context, _ Options) Result {
order = append(order, "b")
return Result{Value: "output-b", OK: true}
})
c.Task("pipeline", Task{
Steps: []Step{
{Action: "step.a"},
{Action: "step.b"},
},
})
r := c.Task("pipeline").Run(context.Background(), c, NewOptions())
assert.True(t, r.OK)
assert.Equal(t, []string{"a", "b"}, order, "steps must run in order")
assert.Equal(t, "output-b", r.Value, "last step's result is returned")
}
func TestAction_Task_Bad_StepFails(t *testing.T) {
c := New()
var order []string
c.Action("step.ok", func(_ context.Context, _ Options) Result {
order = append(order, "ok")
return Result{OK: true}
})
c.Action("step.fail", func(_ context.Context, _ Options) Result {
order = append(order, "fail")
return Result{Value: NewError("broke"), OK: false}
})
c.Action("step.never", func(_ context.Context, _ Options) Result {
order = append(order, "never")
return Result{OK: true}
})
c.Task("broken", Task{
Steps: []Step{
{Action: "step.ok"},
{Action: "step.fail"},
{Action: "step.never"},
},
})
r := c.Task("broken").Run(context.Background(), c, NewOptions())
assert.False(t, r.OK)
assert.Equal(t, []string{"ok", "fail"}, order, "chain stops on failure, step.never skipped")
}
func TestAction_Task_Bad_MissingAction(t *testing.T) {
c := New()
c.Task("missing", Task{
Steps: []Step{
{Action: "nonexistent"},
},
})
r := c.Task("missing").Run(context.Background(), c, NewOptions())
assert.False(t, r.OK)
}
func TestAction_Task_Good_PreviousInput(t *testing.T) {
c := New()
c.Action("produce", func(_ context.Context, _ Options) Result {
return Result{Value: "data-from-step-1", OK: true}
})
c.Action("consume", func(_ context.Context, opts Options) Result {
input := opts.Get("_input")
if !input.OK {
return Result{Value: "no input", OK: true}
}
return Result{Value: "got: " + input.Value.(string), OK: true}
})
c.Task("pipe", Task{
Steps: []Step{
{Action: "produce"},
{Action: "consume", Input: "previous"},
},
})
r := c.Task("pipe").Run(context.Background(), c, NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "got: data-from-step-1", r.Value)
}
func TestAction_Task_Ugly_EmptySteps(t *testing.T) {
c := New()
c.Task("empty", Task{})
r := c.Task("empty").Run(context.Background(), c, NewOptions())
assert.False(t, r.OK)
}
func TestAction_Tasks_Good(t *testing.T) {
c := New()
c.Task("deploy", Task{Steps: []Step{{Action: "x"}}})
c.Task("review", Task{Steps: []Step{{Action: "y"}}})
assert.Equal(t, []string{"deploy", "review"}, c.Tasks())
}

157
api.go
View file

@ -1,157 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Remote communication primitive for the Core framework.
// API manages named streams to remote endpoints. The transport protocol
// (HTTP, WebSocket, SSE, MCP, TCP) is handled by protocol handlers
// registered by consumer packages.
//
// Drive is the phone book (WHERE to connect).
// API is the phone (HOW to connect).
//
// Usage:
//
// // Configure endpoint
// c.Drive().New(core.NewOptions(
// core.Option{Key: "name", Value: "charon"},
// core.Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"},
// ))
//
// // Open stream
// s := c.API().Stream("charon")
// if s.OK { stream := s.Value.(Stream) }
//
// // Remote Action dispatch
// r := c.API().Call("charon", "agentic.status", opts)
package core
import "context"
// Stream is a bidirectional connection to a remote endpoint.
// Consumers implement this for each transport protocol.
//
// type httpStream struct { ... }
// func (s *httpStream) Send(data []byte) error { ... }
// func (s *httpStream) Receive() ([]byte, error) { ... }
// func (s *httpStream) Close() error { ... }
type Stream interface {
Send(data []byte) error
Receive() ([]byte, error)
Close() error
}
// StreamFactory creates a Stream from a DriveHandle's transport config.
// Registered per-protocol by consumer packages.
type StreamFactory func(handle *DriveHandle) (Stream, error)
// API manages remote streams and protocol handlers.
type API struct {
core *Core
protocols *Registry[StreamFactory]
}
// API returns the remote communication primitive.
//
// c.API().Stream("charon")
func (c *Core) API() *API {
return c.api
}
// RegisterProtocol registers a stream factory for a URL scheme.
// Consumer packages call this during OnStartup.
//
// c.API().RegisterProtocol("http", httpStreamFactory)
// c.API().RegisterProtocol("https", httpStreamFactory)
// c.API().RegisterProtocol("mcp", mcpStreamFactory)
func (a *API) RegisterProtocol(scheme string, factory StreamFactory) {
a.protocols.Set(scheme, factory)
}
// Stream opens a connection to a named endpoint.
// Looks up the endpoint in Drive, extracts the protocol from the transport URL,
// and delegates to the registered protocol handler.
//
// r := c.API().Stream("charon")
// if r.OK { stream := r.Value.(Stream) }
func (a *API) Stream(name string) Result {
r := a.core.Drive().Get(name)
if !r.OK {
return Result{E("api.Stream", Concat("endpoint not found in Drive: ", name), nil), false}
}
handle := r.Value.(*DriveHandle)
scheme := extractScheme(handle.Transport)
fr := a.protocols.Get(scheme)
if !fr.OK {
return Result{E("api.Stream", Concat("no protocol handler for scheme: ", scheme), nil), false}
}
factory := fr.Value.(StreamFactory)
stream, err := factory(handle)
if err != nil {
return Result{err, false}
}
return Result{stream, true}
}
// Call invokes a named Action on a remote endpoint.
// This is the remote equivalent of c.Action("name").Run(ctx, opts).
//
// r := c.API().Call("charon", "agentic.status", opts)
func (a *API) Call(endpoint string, action string, opts Options) Result {
r := a.Stream(endpoint)
if !r.OK {
return r
}
stream := r.Value.(Stream)
defer stream.Close()
// Encode the action call as JSON-RPC (MCP compatible)
payload := Concat(`{"action":"`, action, `","options":`, JSONMarshalString(opts), `}`)
if err := stream.Send([]byte(payload)); err != nil {
return Result{err, false}
}
response, err := stream.Receive()
if err != nil {
return Result{err, false}
}
return Result{string(response), true}
}
// Protocols returns all registered protocol scheme names.
func (a *API) Protocols() []string {
return a.protocols.Names()
}
// extractScheme pulls the protocol from a transport URL.
// "http://host:port/path" → "http"
// "mcp://host:port" → "mcp"
func extractScheme(transport string) string {
for i, c := range transport {
if c == ':' {
return transport[:i]
}
}
return transport
}
// RemoteAction resolves "host:action.name" syntax for transparent remote dispatch.
// If the action name contains ":", the prefix is the endpoint and the suffix is the action.
//
// c.Action("charon:agentic.status") // → c.API().Call("charon", "agentic.status", opts)
func (c *Core) RemoteAction(name string, ctx context.Context, opts Options) Result {
for i, ch := range name {
if ch == ':' {
endpoint := name[:i]
action := name[i+1:]
return c.API().Call(endpoint, action, opts)
}
}
// No ":" — local action
return c.Action(name).Run(ctx, opts)
}

View file

@ -1,49 +0,0 @@
package core_test
import (
"context"
. "dappco.re/go/core"
)
func ExampleAPI_RegisterProtocol() {
c := New()
c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) {
return &mockStream{response: []byte("pong")}, nil
})
Println(c.API().Protocols())
// Output: [http]
}
func ExampleAPI_Stream() {
c := New()
c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) {
return &mockStream{response: []byte(Concat("connected to ", h.Name))}, nil
})
c.Drive().New(NewOptions(
Option{Key: "name", Value: "charon"},
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
))
r := c.API().Stream("charon")
if r.OK {
stream := r.Value.(Stream)
resp, _ := stream.Receive()
Println(string(resp))
stream.Close()
}
// Output: connected to charon
}
func ExampleCore_RemoteAction() {
c := New()
// Local action
c.Action("status", func(_ context.Context, _ Options) Result {
return Result{Value: "running", OK: true}
})
// No colon — resolves locally
r := c.RemoteAction("status", context.Background(), NewOptions())
Println(r.Value)
// Output: running
}

View file

@ -1,156 +0,0 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- mock stream for testing ---
type mockStream struct {
sent []byte
response []byte
closed bool
}
func (s *mockStream) Send(data []byte) error {
s.sent = data
return nil
}
func (s *mockStream) Receive() ([]byte, error) {
return s.response, nil
}
func (s *mockStream) Close() error {
s.closed = true
return nil
}
func mockFactory(response string) StreamFactory {
return func(handle *DriveHandle) (Stream, error) {
return &mockStream{response: []byte(response)}, nil
}
}
// --- API ---
func TestApi_API_Good_Accessor(t *testing.T) {
c := New()
assert.NotNil(t, c.API())
}
// --- RegisterProtocol ---
func TestApi_RegisterProtocol_Good(t *testing.T) {
c := New()
c.API().RegisterProtocol("http", mockFactory("ok"))
assert.Contains(t, c.API().Protocols(), "http")
}
// --- Stream ---
func TestApi_Stream_Good(t *testing.T) {
c := New()
c.API().RegisterProtocol("http", mockFactory("pong"))
c.Drive().New(NewOptions(
Option{Key: "name", Value: "charon"},
Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"},
))
r := c.API().Stream("charon")
assert.True(t, r.OK)
stream := r.Value.(Stream)
stream.Send([]byte("ping"))
resp, err := stream.Receive()
assert.NoError(t, err)
assert.Equal(t, "pong", string(resp))
stream.Close()
}
func TestApi_Stream_Bad_EndpointNotFound(t *testing.T) {
c := New()
r := c.API().Stream("nonexistent")
assert.False(t, r.OK)
}
func TestApi_Stream_Bad_NoProtocolHandler(t *testing.T) {
c := New()
c.Drive().New(NewOptions(
Option{Key: "name", Value: "unknown"},
Option{Key: "transport", Value: "grpc://host:port"},
))
r := c.API().Stream("unknown")
assert.False(t, r.OK)
}
// --- Call ---
func TestApi_Call_Good(t *testing.T) {
c := New()
c.API().RegisterProtocol("http", mockFactory(`{"status":"ok"}`))
c.Drive().New(NewOptions(
Option{Key: "name", Value: "charon"},
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
))
r := c.API().Call("charon", "agentic.status", NewOptions())
assert.True(t, r.OK)
assert.Contains(t, r.Value.(string), "ok")
}
func TestApi_Call_Bad_EndpointNotFound(t *testing.T) {
c := New()
r := c.API().Call("missing", "action", NewOptions())
assert.False(t, r.OK)
}
// --- RemoteAction ---
func TestApi_RemoteAction_Good_Local(t *testing.T) {
c := New()
c.Action("local.action", func(_ context.Context, _ Options) Result {
return Result{Value: "local", OK: true}
})
r := c.RemoteAction("local.action", context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "local", r.Value)
}
func TestApi_RemoteAction_Good_Remote(t *testing.T) {
c := New()
c.API().RegisterProtocol("http", mockFactory(`{"value":"remote"}`))
c.Drive().New(NewOptions(
Option{Key: "name", Value: "charon"},
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
))
r := c.RemoteAction("charon:agentic.status", context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Contains(t, r.Value.(string), "remote")
}
func TestApi_RemoteAction_Ugly_NoColon(t *testing.T) {
c := New()
// No colon — falls through to local action (which doesn't exist)
r := c.RemoteAction("nonexistent", context.Background(), NewOptions())
assert.False(t, r.OK, "non-existent local action should fail")
}
// --- extractScheme ---
func TestApi_Ugly_SchemeExtraction(t *testing.T) {
c := New()
// Verify scheme parsing works by registering different protocols
c.API().RegisterProtocol("http", mockFactory("http"))
c.API().RegisterProtocol("mcp", mockFactory("mcp"))
c.API().RegisterProtocol("ws", mockFactory("ws"))
assert.Equal(t, 3, len(c.API().Protocols()))
}

93
app.go
View file

@ -1,93 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Application identity for the Core framework.
package core
import (
"os"
"path/filepath"
)
// App holds the application identity and optional GUI runtime.
//
// app := core.App{}.New(core.NewOptions(
// core.Option{Key: "name", Value: "Core CLI"},
// core.Option{Key: "version", Value: "1.0.0"},
// ))
type App struct {
Name string
Version string
Description string
Filename string
Path string
Runtime any // GUI runtime (e.g., Wails App). Nil for CLI-only.
}
// New creates an App from Options.
//
// app := core.App{}.New(core.NewOptions(
// core.Option{Key: "name", Value: "myapp"},
// core.Option{Key: "version", Value: "1.0.0"},
// ))
func (a App) New(opts Options) App {
if name := opts.String("name"); name != "" {
a.Name = name
}
if version := opts.String("version"); version != "" {
a.Version = version
}
if desc := opts.String("description"); desc != "" {
a.Description = desc
}
if filename := opts.String("filename"); filename != "" {
a.Filename = filename
}
return a
}
// Find locates a program on PATH and returns a Result containing the App.
// Uses os.Stat to search PATH directories — no os/exec dependency.
//
// r := core.App{}.Find("node", "Node.js")
// if r.OK { app := r.Value.(*App) }
func (a App) Find(filename, name string) Result {
// If filename contains a separator, check it directly
if Contains(filename, string(os.PathSeparator)) {
abs, err := filepath.Abs(filename)
if err != nil {
return Result{err, false}
}
if isExecutable(abs) {
return Result{&App{Name: name, Filename: filename, Path: abs}, true}
}
return Result{E("app.Find", Concat(filename, " not found"), nil), false}
}
// Search PATH
pathEnv := os.Getenv("PATH")
if pathEnv == "" {
return Result{E("app.Find", "PATH is empty", nil), false}
}
for _, dir := range Split(pathEnv, string(os.PathListSeparator)) {
candidate := filepath.Join(dir, filename)
if isExecutable(candidate) {
abs, err := filepath.Abs(candidate)
if err != nil {
continue
}
return Result{&App{Name: name, Filename: filename, Path: abs}, true}
}
}
return Result{E("app.Find", Concat(filename, " not found on PATH"), nil), false}
}
// isExecutable checks if a path exists and is executable.
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
// Regular file with at least one execute bit
return !info.IsDir() && info.Mode()&0111 != 0
}

View file

@ -1,68 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- App.New ---
func TestApp_New_Good(t *testing.T) {
app := App{}.New(NewOptions(
Option{Key: "name", Value: "myapp"},
Option{Key: "version", Value: "1.0.0"},
Option{Key: "description", Value: "test app"},
))
assert.Equal(t, "myapp", app.Name)
assert.Equal(t, "1.0.0", app.Version)
assert.Equal(t, "test app", app.Description)
}
func TestApp_New_Empty_Good(t *testing.T) {
app := App{}.New(NewOptions())
assert.Equal(t, "", app.Name)
assert.Equal(t, "", app.Version)
}
func TestApp_New_Partial_Good(t *testing.T) {
app := App{}.New(NewOptions(
Option{Key: "name", Value: "myapp"},
))
assert.Equal(t, "myapp", app.Name)
assert.Equal(t, "", app.Version)
}
// --- App via Core ---
func TestApp_Core_Good(t *testing.T) {
c := New(WithOption("name", "myapp"))
assert.Equal(t, "myapp", c.App().Name)
}
func TestApp_Core_Empty_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.App())
assert.Equal(t, "", c.App().Name)
}
func TestApp_Runtime_Good(t *testing.T) {
c := New()
c.App().Runtime = &struct{ Name string }{Name: "wails"}
assert.NotNil(t, c.App().Runtime)
}
// --- App.Find ---
func TestApp_Find_Good(t *testing.T) {
r := App{}.Find("go", "go")
assert.True(t, r.OK)
app := r.Value.(*App)
assert.NotEmpty(t, app.Path)
}
func TestApp_Find_Bad(t *testing.T) {
r := App{}.Find("nonexistent-binary-xyz", "test")
assert.False(t, r.OK)
}

101
array.go
View file

@ -1,101 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Generic slice operations for the Core framework.
// Based on leaanthony/slicer, rewritten with Go 1.18+ generics.
package core
// Array is a typed slice with common operations.
type Array[T comparable] struct {
items []T
}
// NewArray creates an empty Array.
func NewArray[T comparable](items ...T) *Array[T] {
return &Array[T]{items: items}
}
// Add appends values.
func (s *Array[T]) Add(values ...T) {
s.items = append(s.items, values...)
}
// AddUnique appends values only if not already present.
func (s *Array[T]) AddUnique(values ...T) {
for _, v := range values {
if !s.Contains(v) {
s.items = append(s.items, v)
}
}
}
// Contains returns true if the value is in the slice.
func (s *Array[T]) Contains(val T) bool {
for _, v := range s.items {
if v == val {
return true
}
}
return false
}
// Filter returns a new Array with elements matching the predicate.
func (s *Array[T]) Filter(fn func(T) bool) Result {
filtered := &Array[T]{}
for _, v := range s.items {
if fn(v) {
filtered.items = append(filtered.items, v)
}
}
return Result{filtered, true}
}
// Each runs a function on every element.
func (s *Array[T]) Each(fn func(T)) {
for _, v := range s.items {
fn(v)
}
}
// Remove removes the first occurrence of a value.
func (s *Array[T]) Remove(val T) {
for i, v := range s.items {
if v == val {
s.items = append(s.items[:i], s.items[i+1:]...)
return
}
}
}
// Deduplicate removes duplicate values, preserving order.
func (s *Array[T]) Deduplicate() {
seen := make(map[T]struct{})
result := make([]T, 0, len(s.items))
for _, v := range s.items {
if _, exists := seen[v]; !exists {
seen[v] = struct{}{}
result = append(result, v)
}
}
s.items = result
}
// Len returns the number of elements.
func (s *Array[T]) Len() int {
return len(s.items)
}
// Clear removes all elements.
func (s *Array[T]) Clear() {
s.items = nil
}
// AsSlice returns a copy of the underlying slice.
func (s *Array[T]) AsSlice() []T {
if s.items == nil {
return nil
}
out := make([]T, len(s.items))
copy(out, s.items)
return out
}

View file

@ -1,41 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleNewArray() {
a := NewArray[string]()
a.Add("alpha")
a.Add("bravo")
a.Add("charlie")
Println(a.Len())
Println(a.Contains("bravo"))
// Output:
// 3
// true
}
func ExampleArray_AddUnique() {
a := NewArray[string]()
a.AddUnique("alpha")
a.AddUnique("alpha") // no duplicate
a.AddUnique("bravo")
Println(a.Len())
// Output: 2
}
func ExampleArray_Filter() {
a := NewArray[int]()
a.Add(1)
a.Add(2)
a.Add(3)
a.Add(4)
r := a.Filter(func(n int) bool { return n%2 == 0 })
Println(r.OK)
// Output: true
}

View file

@ -1,90 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Array[T] ---
func TestArray_New_Good(t *testing.T) {
a := NewArray("a", "b", "c")
assert.Equal(t, 3, a.Len())
}
func TestArray_Add_Good(t *testing.T) {
a := NewArray[string]()
a.Add("x", "y")
assert.Equal(t, 2, a.Len())
assert.True(t, a.Contains("x"))
assert.True(t, a.Contains("y"))
}
func TestArray_AddUnique_Good(t *testing.T) {
a := NewArray("a", "b")
a.AddUnique("b", "c")
assert.Equal(t, 3, a.Len())
}
func TestArray_Contains_Good(t *testing.T) {
a := NewArray(1, 2, 3)
assert.True(t, a.Contains(2))
assert.False(t, a.Contains(99))
}
func TestArray_Filter_Good(t *testing.T) {
a := NewArray(1, 2, 3, 4, 5)
r := a.Filter(func(n int) bool { return n%2 == 0 })
assert.True(t, r.OK)
evens := r.Value.(*Array[int])
assert.Equal(t, 2, evens.Len())
assert.True(t, evens.Contains(2))
assert.True(t, evens.Contains(4))
}
func TestArray_Each_Good(t *testing.T) {
a := NewArray("a", "b", "c")
var collected []string
a.Each(func(s string) { collected = append(collected, s) })
assert.Equal(t, []string{"a", "b", "c"}, collected)
}
func TestArray_Remove_Good(t *testing.T) {
a := NewArray("a", "b", "c")
a.Remove("b")
assert.Equal(t, 2, a.Len())
assert.False(t, a.Contains("b"))
}
func TestArray_Remove_Bad(t *testing.T) {
a := NewArray("a", "b")
a.Remove("missing")
assert.Equal(t, 2, a.Len())
}
func TestArray_Deduplicate_Good(t *testing.T) {
a := NewArray("a", "b", "a", "c", "b")
a.Deduplicate()
assert.Equal(t, 3, a.Len())
}
func TestArray_Clear_Good(t *testing.T) {
a := NewArray(1, 2, 3)
a.Clear()
assert.Equal(t, 0, a.Len())
}
func TestArray_AsSlice_Good(t *testing.T) {
a := NewArray("x", "y")
s := a.AsSlice()
assert.Equal(t, []string{"x", "y"}, s)
}
func TestArray_Empty_Good(t *testing.T) {
a := NewArray[int]()
assert.Equal(t, 0, a.Len())
assert.False(t, a.Contains(0))
assert.Equal(t, []int(nil), a.AsSlice())
}

166
cli.go
View file

@ -1,166 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Cli is the CLI surface layer for the Core command tree.
//
// c := core.New(core.WithOption("name", "myapp")).Value.(*Core)
// c.Command("deploy", core.Command{Action: handler})
// c.Cli().Run()
package core
import (
"io"
"os"
)
// CliOptions holds configuration for the Cli service.
type CliOptions struct{}
// Cli is the CLI surface for the Core command tree.
type Cli struct {
*ServiceRuntime[CliOptions]
output io.Writer
banner func(*Cli) string
}
// Register creates a Cli service factory for core.WithService.
//
// core.New(core.WithService(core.CliRegister))
func CliRegister(c *Core) Result {
cl := &Cli{output: os.Stdout}
cl.ServiceRuntime = NewServiceRuntime[CliOptions](c, CliOptions{})
return c.RegisterService("cli", cl)
}
// Print writes to the CLI output (defaults to os.Stdout).
//
// c.Cli().Print("hello %s", "world")
func (cl *Cli) Print(format string, args ...any) {
Print(cl.output, format, args...)
}
// SetOutput sets the CLI output writer.
//
// c.Cli().SetOutput(os.Stderr)
func (cl *Cli) SetOutput(w io.Writer) {
cl.output = w
}
// Run resolves os.Args to a command path and executes it.
//
// c.Cli().Run()
// c.Cli().Run("deploy", "to", "homelab")
func (cl *Cli) Run(args ...string) Result {
if len(args) == 0 {
args = os.Args[1:]
}
clean := FilterArgs(args)
c := cl.Core()
if c == nil || c.commands == nil {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
return Result{}
}
if c.commands.Len() == 0 {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
return Result{}
}
// Resolve command path from args
var cmd *Command
var remaining []string
for i := len(clean); i > 0; i-- {
path := JoinPath(clean[:i]...)
if r := c.commands.Get(path); r.OK {
cmd = r.Value.(*Command)
remaining = clean[i:]
break
}
}
if cmd == nil {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
cl.PrintHelp()
return Result{}
}
// Build options from remaining args
opts := NewOptions()
for _, arg := range remaining {
key, val, valid := ParseFlag(arg)
if valid {
if Contains(arg, "=") {
opts.Set(key, val)
} else {
opts.Set(key, true)
}
} else if !IsFlag(arg) {
opts.Set("_arg", arg)
}
}
if cmd.Action != nil {
return cmd.Run(opts)
}
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
}
// PrintHelp prints available commands.
//
// c.Cli().PrintHelp()
func (cl *Cli) PrintHelp() {
c := cl.Core()
if c == nil || c.commands == nil {
return
}
name := ""
if c.app != nil {
name = c.app.Name
}
if name != "" {
cl.Print("%s commands:", name)
} else {
cl.Print("Commands:")
}
c.commands.Each(func(path string, cmd *Command) {
if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) {
return
}
tr := c.I18n().Translate(cmd.I18nKey())
desc, _ := tr.Value.(string)
if desc == "" || desc == cmd.I18nKey() {
cl.Print(" %s", path)
} else {
cl.Print(" %-30s %s", path, desc)
}
})
}
// SetBanner sets the banner function.
//
// c.Cli().SetBanner(func(_ *core.Cli) string { return "My App v1.0" })
func (cl *Cli) SetBanner(fn func(*Cli) string) {
cl.banner = fn
}
// Banner returns the banner string.
func (cl *Cli) Banner() string {
if cl.banner != nil {
return cl.banner(cl)
}
c := cl.Core()
if c != nil && c.app != nil && c.app.Name != "" {
return c.app.Name
}
return ""
}

View file

@ -1,85 +0,0 @@
package core_test
import (
"bytes"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Cli Surface ---
func TestCli_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.Cli())
}
func TestCli_Banner_Good(t *testing.T) {
c := New(WithOption("name", "myapp"))
assert.Equal(t, "myapp", c.Cli().Banner())
}
func TestCli_SetBanner_Good(t *testing.T) {
c := New()
c.Cli().SetBanner(func(_ *Cli) string { return "Custom Banner" })
assert.Equal(t, "Custom Banner", c.Cli().Banner())
}
func TestCli_Run_Good(t *testing.T) {
c := New()
executed := false
c.Command("hello", Command{Action: func(_ Options) Result {
executed = true
return Result{Value: "world", OK: true}
}})
r := c.Cli().Run("hello")
assert.True(t, r.OK)
assert.Equal(t, "world", r.Value)
assert.True(t, executed)
}
func TestCli_Run_Nested_Good(t *testing.T) {
c := New()
executed := false
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
executed = true
return Result{OK: true}
}})
r := c.Cli().Run("deploy", "to", "homelab")
assert.True(t, r.OK)
assert.True(t, executed)
}
func TestCli_Run_WithFlags_Good(t *testing.T) {
c := New()
var received Options
c.Command("serve", Command{Action: func(opts Options) Result {
received = opts
return Result{OK: true}
}})
c.Cli().Run("serve", "--port=8080", "--debug")
assert.Equal(t, "8080", received.String("port"))
assert.True(t, received.Bool("debug"))
}
func TestCli_Run_NoCommand_Good(t *testing.T) {
c := New()
r := c.Cli().Run()
assert.False(t, r.OK)
}
func TestCli_PrintHelp_Good(t *testing.T) {
c := New(WithOption("name", "myapp"))
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Cli().PrintHelp()
}
func TestCli_SetOutput_Good(t *testing.T) {
c := New()
var buf bytes.Buffer
c.Cli().SetOutput(&buf)
c.Cli().Print("hello %s", "world")
assert.Contains(t, buf.String(), "hello world")
}

View file

@ -1,163 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Command is a DTO representing an executable operation.
// Commands don't know if they're root, child, or nested — the tree
// structure comes from composition via path-based registration.
//
// Register a command:
//
// c.Command("deploy", func(opts core.Options) core.Result {
// return core.Result{"deployed", true}
// })
//
// Register a nested command:
//
// c.Command("deploy/to/homelab", handler)
//
// Description is an i18n key — derived from path if omitted:
//
// "deploy" → "cmd.deploy.description"
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
package core
// CommandAction is the function signature for command handlers.
//
// func(opts core.Options) core.Result
type CommandAction func(Options) Result
// Command is the DTO for an executable operation.
// Commands are declarative — they carry enough information for multiple consumers:
// - core.Cli() runs the Action
// - core/cli adds rich help, completion, man pages
// - go-process wraps Managed commands with lifecycle (PID, health, signals)
//
// c.Command("serve", core.Command{
// Action: handler,
// Managed: "process.daemon", // go-process provides start/stop/restart
// })
type Command struct {
Name string
Description string // i18n key — derived from path if empty
Path string // "deploy/to/homelab"
Action CommandAction // business logic
Managed string // "" = one-shot, "process.daemon" = managed lifecycle
Flags Options // declared flags
Hidden bool
commands map[string]*Command // child commands (internal)
}
// I18nKey returns the i18n key for this command's description.
//
// cmd with path "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
func (cmd *Command) I18nKey() string {
if cmd.Description != "" {
return cmd.Description
}
path := cmd.Path
if path == "" {
path = cmd.Name
}
return Concat("cmd.", Replace(path, "/", "."), ".description")
}
// Run executes the command's action with the given options.
//
// result := cmd.Run(core.NewOptions(core.Option{Key: "target", Value: "homelab"}))
func (cmd *Command) Run(opts Options) Result {
if cmd.Action == nil {
return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
}
return cmd.Action(opts)
}
// IsManaged returns true if this command has a managed lifecycle.
//
// if cmd.IsManaged() { /* go-process handles start/stop */ }
func (cmd *Command) IsManaged() bool {
return cmd.Managed != ""
}
// --- Command Registry (on Core) ---
// CommandRegistry holds the command tree. Embeds Registry[*Command]
// for thread-safe named storage with insertion order.
type CommandRegistry struct {
*Registry[*Command]
}
// Command gets or registers a command by path.
//
// c.Command("deploy", Command{Action: handler})
// r := c.Command("deploy")
func (c *Core) Command(path string, command ...Command) Result {
if len(command) == 0 {
return c.commands.Get(path)
}
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
}
// Check for duplicate executable command
if r := c.commands.Get(path); r.OK {
existing := r.Value.(*Command)
if existing.Action != nil || existing.IsManaged() {
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
}
}
cmd := &command[0]
cmd.Name = pathName(path)
cmd.Path = path
if cmd.commands == nil {
cmd.commands = make(map[string]*Command)
}
// Preserve existing subtree when overwriting a placeholder parent
if r := c.commands.Get(path); r.OK {
existing := r.Value.(*Command)
for k, v := range existing.commands {
if _, has := cmd.commands[k]; !has {
cmd.commands[k] = v
}
}
}
c.commands.Set(path, cmd)
// Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing
parts := Split(path, "/")
for i := len(parts) - 1; i > 0; i-- {
parentPath := JoinPath(parts[:i]...)
if !c.commands.Has(parentPath) {
c.commands.Set(parentPath, &Command{
Name: parts[i-1],
Path: parentPath,
commands: make(map[string]*Command),
})
}
parent := c.commands.Get(parentPath).Value.(*Command)
parent.commands[parts[i]] = cmd
cmd = parent
}
return Result{OK: true}
}
// Commands returns all registered command paths in registration order.
//
// paths := c.Commands()
func (c *Core) Commands() []string {
if c.commands == nil {
return nil
}
return c.commands.Names()
}
// pathName extracts the last segment of a path.
// "deploy/to/homelab" → "homelab"
func pathName(path string) string {
parts := Split(path, "/")
return parts[len(parts)-1]
}

View file

@ -1,40 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleCore_Command_register() {
c := New()
c.Command("deploy/to/homelab", Command{
Description: "Deploy to homelab",
Action: func(opts Options) Result {
return Result{Value: "deployed", OK: true}
},
})
Println(c.Command("deploy/to/homelab").OK)
// Output: true
}
func ExampleCore_Command_managed() {
c := New()
c.Command("serve", Command{
Action: func(_ Options) Result { return Result{OK: true} },
Managed: "process.daemon",
})
cmd := c.Command("serve").Value.(*Command)
Println(cmd.IsManaged())
// Output: true
}
func ExampleCore_Commands() {
c := New()
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }})
Println(c.Commands())
// Output: [deploy test]
}

View file

@ -1,167 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Command DTO ---
func TestCommand_Register_Good(t *testing.T) {
c := New()
r := c.Command("deploy", Command{Action: func(_ Options) Result {
return Result{Value: "deployed", OK: true}
}})
assert.True(t, r.OK)
}
func TestCommand_Get_Good(t *testing.T) {
c := New()
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
r := c.Command("deploy")
assert.True(t, r.OK)
assert.NotNil(t, r.Value)
}
func TestCommand_Get_Bad(t *testing.T) {
c := New()
r := c.Command("nonexistent")
assert.False(t, r.OK)
}
func TestCommand_Run_Good(t *testing.T) {
c := New()
c.Command("greet", Command{Action: func(opts Options) Result {
return Result{Value: Concat("hello ", opts.String("name")), OK: true}
}})
cmd := c.Command("greet").Value.(*Command)
r := cmd.Run(NewOptions(Option{Key: "name", Value: "world"}))
assert.True(t, r.OK)
assert.Equal(t, "hello world", r.Value)
}
func TestCommand_Run_NoAction_Good(t *testing.T) {
c := New()
c.Command("empty", Command{Description: "no action"})
cmd := c.Command("empty").Value.(*Command)
r := cmd.Run(NewOptions())
assert.False(t, r.OK)
}
// --- Nested Commands ---
func TestCommand_Nested_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
return Result{Value: "deployed to homelab", OK: true}
}})
r := c.Command("deploy/to/homelab")
assert.True(t, r.OK)
// Parent auto-created
assert.True(t, c.Command("deploy").OK)
assert.True(t, c.Command("deploy/to").OK)
}
func TestCommand_Paths_Good(t *testing.T) {
c := New()
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }})
paths := c.Commands()
assert.Contains(t, paths, "deploy")
assert.Contains(t, paths, "serve")
assert.Contains(t, paths, "deploy/to/homelab")
assert.Contains(t, paths, "deploy/to")
}
// --- I18n Key Derivation ---
func TestCommand_I18nKey_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", Command{})
cmd := c.Command("deploy/to/homelab").Value.(*Command)
assert.Equal(t, "cmd.deploy.to.homelab.description", cmd.I18nKey())
}
func TestCommand_I18nKey_Custom_Good(t *testing.T) {
c := New()
c.Command("deploy", Command{Description: "custom.deploy.key"})
cmd := c.Command("deploy").Value.(*Command)
assert.Equal(t, "custom.deploy.key", cmd.I18nKey())
}
func TestCommand_I18nKey_Simple_Good(t *testing.T) {
c := New()
c.Command("serve", Command{})
cmd := c.Command("serve").Value.(*Command)
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
}
// --- Managed ---
func TestCommand_IsManaged_Good(t *testing.T) {
c := New()
c.Command("serve", Command{
Action: func(_ Options) Result { return Result{Value: "running", OK: true} },
Managed: "process.daemon",
})
cmd := c.Command("serve").Value.(*Command)
assert.True(t, cmd.IsManaged())
}
func TestCommand_IsManaged_Bad_NotManaged(t *testing.T) {
c := New()
c.Command("deploy", Command{
Action: func(_ Options) Result { return Result{OK: true} },
})
cmd := c.Command("deploy").Value.(*Command)
assert.False(t, cmd.IsManaged())
}
func TestCommand_Duplicate_Bad(t *testing.T) {
c := New()
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
assert.False(t, r.OK)
}
func TestCommand_InvalidPath_Bad(t *testing.T) {
c := New()
assert.False(t, c.Command("/leading", Command{}).OK)
assert.False(t, c.Command("trailing/", Command{}).OK)
assert.False(t, c.Command("double//slash", Command{}).OK)
}
// --- Cli Run with Managed ---
func TestCli_Run_Managed_Good(t *testing.T) {
c := New()
ran := false
c.Command("serve", Command{
Action: func(_ Options) Result { ran = true; return Result{OK: true} },
Managed: "process.daemon",
})
r := c.Cli().Run("serve")
assert.True(t, r.OK)
assert.True(t, ran)
}
func TestCli_Run_NoAction_Bad(t *testing.T) {
c := New()
c.Command("empty", Command{})
r := c.Cli().Run("empty")
assert.False(t, r.OK)
}
// --- Empty path ---
func TestCommand_EmptyPath_Bad(t *testing.T) {
c := New()
r := c.Command("", Command{})
assert.False(t, r.OK)
}

186
config.go
View file

@ -1,186 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Settings, feature flags, and typed configuration for the Core framework.
package core
import (
"sync"
)
// ConfigVar is a variable that can be set, unset, and queried for its state.
type ConfigVar[T any] struct {
val T
set bool
}
// Get returns the current value.
//
// val := v.Get()
func (v *ConfigVar[T]) Get() T { return v.val }
// Set sets the value and marks it as explicitly set.
//
// v.Set(true)
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
// IsSet returns true if the value was explicitly set (distinguishes "set to false" from "never set").
//
// if v.IsSet() { /* explicitly configured */ }
func (v *ConfigVar[T]) IsSet() bool { return v.set }
// Unset resets to zero value and marks as not set.
//
// v.Unset()
// v.IsSet() // false
func (v *ConfigVar[T]) Unset() {
v.set = false
var zero T
v.val = zero
}
// NewConfigVar creates a ConfigVar with an initial value marked as set.
//
// debug := core.NewConfigVar(true)
func NewConfigVar[T any](val T) ConfigVar[T] {
return ConfigVar[T]{val: val, set: true}
}
// ConfigOptions holds configuration data.
type ConfigOptions struct {
Settings map[string]any
Features map[string]bool
}
func (o *ConfigOptions) init() {
if o.Settings == nil {
o.Settings = make(map[string]any)
}
if o.Features == nil {
o.Features = make(map[string]bool)
}
}
// Config holds configuration settings and feature flags.
type Config struct {
*ConfigOptions
mu sync.RWMutex
}
// New initialises a Config with empty settings and features.
//
// cfg := (&core.Config{}).New()
func (e *Config) New() *Config {
e.ConfigOptions = &ConfigOptions{}
e.ConfigOptions.init()
return e
}
// Set stores a configuration value by key.
func (e *Config) Set(key string, val any) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Settings[key] = val
e.mu.Unlock()
}
// Get retrieves a configuration value by key.
func (e *Config) Get(key string) Result {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Settings == nil {
return Result{}
}
val, ok := e.Settings[key]
if !ok {
return Result{}
}
return Result{val, true}
}
// String retrieves a string config value (empty string if missing).
//
// host := c.Config().String("database.host")
func (e *Config) String(key string) string { return ConfigGet[string](e, key) }
// Int retrieves an int config value (0 if missing).
//
// port := c.Config().Int("database.port")
func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
// Bool retrieves a bool config value (false if missing).
//
// debug := c.Config().Bool("debug")
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
// ConfigGet retrieves a typed configuration value.
func ConfigGet[T any](e *Config, key string) T {
r := e.Get(key)
if !r.OK {
var zero T
return zero
}
typed, _ := r.Value.(T)
return typed
}
// --- Feature Flags ---
// Enable activates a feature flag.
//
// c.Config().Enable("dark-mode")
func (e *Config) Enable(feature string) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Features[feature] = true
e.mu.Unlock()
}
// Disable deactivates a feature flag.
//
// c.Config().Disable("dark-mode")
func (e *Config) Disable(feature string) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Features[feature] = false
e.mu.Unlock()
}
// Enabled returns true if a feature flag is active.
//
// if c.Config().Enabled("dark-mode") { ... }
func (e *Config) Enabled(feature string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Features == nil {
return false
}
return e.Features[feature]
}
// EnabledFeatures returns all active feature flag names.
//
// features := c.Config().EnabledFeatures()
func (e *Config) EnabledFeatures() []string {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Features == nil {
return nil
}
var result []string
for k, v := range e.Features {
if v {
result = append(result, k)
}
}
return result
}

View file

@ -1,41 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleConfig_Set() {
c := New()
c.Config().Set("database.host", "localhost")
c.Config().Set("database.port", 5432)
Println(c.Config().String("database.host"))
Println(c.Config().Int("database.port"))
// Output:
// localhost
// 5432
}
func ExampleConfig_Enable() {
c := New()
c.Config().Enable("dark-mode")
c.Config().Enable("beta-features")
Println(c.Config().Enabled("dark-mode"))
Println(c.Config().EnabledFeatures())
// Output:
// true
// [dark-mode beta-features]
}
func ExampleConfigVar() {
v := NewConfigVar(42)
Println(v.Get(), v.IsSet())
v.Unset()
Println(v.Get(), v.IsSet())
// Output:
// 42 true
// 0 false
}

View file

@ -1,102 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Config ---
func TestConfig_SetGet_Good(t *testing.T) {
c := New()
c.Config().Set("api_url", "https://api.lthn.ai")
c.Config().Set("max_agents", 5)
r := c.Config().Get("api_url")
assert.True(t, r.OK)
assert.Equal(t, "https://api.lthn.ai", r.Value)
}
func TestConfig_Get_Bad(t *testing.T) {
c := New()
r := c.Config().Get("missing")
assert.False(t, r.OK)
assert.Nil(t, r.Value)
}
func TestConfig_TypedAccessors_Good(t *testing.T) {
c := New()
c.Config().Set("url", "https://lthn.ai")
c.Config().Set("port", 8080)
c.Config().Set("debug", true)
assert.Equal(t, "https://lthn.ai", c.Config().String("url"))
assert.Equal(t, 8080, c.Config().Int("port"))
assert.True(t, c.Config().Bool("debug"))
}
func TestConfig_TypedAccessors_Bad(t *testing.T) {
c := New()
// Missing keys return zero values
assert.Equal(t, "", c.Config().String("missing"))
assert.Equal(t, 0, c.Config().Int("missing"))
assert.False(t, c.Config().Bool("missing"))
}
// --- Feature Flags ---
func TestConfig_Features_Good(t *testing.T) {
c := New()
c.Config().Enable("dark-mode")
c.Config().Enable("beta")
assert.True(t, c.Config().Enabled("dark-mode"))
assert.True(t, c.Config().Enabled("beta"))
assert.False(t, c.Config().Enabled("missing"))
}
func TestConfig_Features_Disable_Good(t *testing.T) {
c := New()
c.Config().Enable("feature")
assert.True(t, c.Config().Enabled("feature"))
c.Config().Disable("feature")
assert.False(t, c.Config().Enabled("feature"))
}
func TestConfig_Features_CaseSensitive(t *testing.T) {
c := New()
c.Config().Enable("Feature")
assert.True(t, c.Config().Enabled("Feature"))
assert.False(t, c.Config().Enabled("feature"))
}
func TestConfig_EnabledFeatures_Good(t *testing.T) {
c := New()
c.Config().Enable("a")
c.Config().Enable("b")
c.Config().Enable("c")
c.Config().Disable("b")
features := c.Config().EnabledFeatures()
assert.Contains(t, features, "a")
assert.Contains(t, features, "c")
assert.NotContains(t, features, "b")
}
// --- ConfigVar ---
func TestConfig_ConfigVar_Good(t *testing.T) {
v := NewConfigVar("hello")
assert.True(t, v.IsSet())
assert.Equal(t, "hello", v.Get())
v.Set("world")
assert.Equal(t, "world", v.Get())
v.Unset()
assert.False(t, v.IsSet())
assert.Equal(t, "", v.Get())
}

View file

@ -1,226 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Contracts, options, and type definitions for the Core framework.
package core
import (
"context"
"reflect"
"sync"
)
// Message is the type for IPC broadcasts (fire-and-forget).
type Message any
// Query is the type for read-only IPC requests.
type Query any
// QueryHandler handles Query requests. Returns Result{Value, OK}.
type QueryHandler func(*Core, Query) Result
// Startable is implemented by services that need startup initialisation.
//
// func (s *MyService) OnStartup(ctx context.Context) core.Result {
// return core.Result{OK: true}
// }
type Startable interface {
OnStartup(ctx context.Context) Result
}
// Stoppable is implemented by services that need shutdown cleanup.
//
// func (s *MyService) OnShutdown(ctx context.Context) core.Result {
// return core.Result{OK: true}
// }
type Stoppable interface {
OnShutdown(ctx context.Context) Result
}
// --- Action Messages ---
type ActionServiceStartup struct{}
type ActionServiceShutdown struct{}
type ActionTaskStarted struct {
TaskIdentifier string
Action string
Options Options
}
type ActionTaskProgress struct {
TaskIdentifier string
Action string
Progress float64
Message string
}
type ActionTaskCompleted struct {
TaskIdentifier string
Action string
Result Result
}
// --- Constructor ---
// CoreOption is a functional option applied during Core construction.
// Returns Result — if !OK, New() stops and returns the error.
//
// core.New(
// core.WithService(agentic.Register),
// core.WithService(monitor.Register),
// core.WithServiceLock(),
// )
type CoreOption func(*Core) Result
// New initialises a Core instance by applying options in order.
// Services registered here form the application conclave — they share
// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown).
//
// c := core.New(
// core.WithOption("name", "myapp"),
// core.WithService(auth.Register),
// core.WithServiceLock(),
// )
// c.Run()
func New(opts ...CoreOption) *Core {
c := &Core{
app: &App{},
data: &Data{Registry: NewRegistry[*Embed]()},
drive: &Drive{Registry: NewRegistry[*DriveHandle]()},
fs: (&Fs{}).New("/"),
config: (&Config{}).New(),
error: &ErrorPanic{},
log: &ErrorLog{},
lock: &Lock{locks: NewRegistry[*sync.RWMutex]()},
ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()},
info: systemInfo,
i18n: &I18n{},
api: &API{protocols: NewRegistry[StreamFactory]()},
services: &ServiceRegistry{Registry: NewRegistry[*Service]()},
commands: &CommandRegistry{Registry: NewRegistry[*Command]()},
entitlementChecker: defaultChecker,
}
c.context, c.cancel = context.WithCancel(context.Background())
c.api.core = c
// Core services
CliRegister(c)
for _, opt := range opts {
if r := opt(c); !r.OK {
Error("core.New failed", "err", r.Value)
break
}
}
// Apply service lock after all opts — v0.3.3 parity
c.LockApply()
return c
}
// WithOptions applies key-value configuration to Core.
//
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"}))
func WithOptions(opts Options) CoreOption {
return func(c *Core) Result {
c.options = &opts
if name := opts.String("name"); name != "" {
c.app.Name = name
}
return Result{OK: true}
}
}
// WithService registers a service via its factory function.
// If the factory returns a non-nil Value, WithService auto-discovers the
// service name from the factory's package path (last path segment, lowercase,
// with any "_test" suffix stripped) and calls RegisterService on the instance.
// IPC handler auto-registration is handled by RegisterService.
//
// If the factory returns nil Value (it registered itself), WithService
// returns success without a second registration.
//
// core.WithService(agentic.Register)
// core.WithService(display.Register(nil))
func WithService(factory func(*Core) Result) CoreOption {
return func(c *Core) Result {
r := factory(c)
if !r.OK {
return r
}
if r.Value == nil {
// Factory self-registered — nothing more to do.
return Result{OK: true}
}
// Auto-discover the service name from the instance's package path.
instance := r.Value
typeOf := reflect.TypeOf(instance)
if typeOf.Kind() == reflect.Ptr {
typeOf = typeOf.Elem()
}
pkgPath := typeOf.PkgPath()
parts := Split(pkgPath, "/")
name := Lower(parts[len(parts)-1])
if name == "" {
return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false}
}
// RegisterService handles Startable/Stoppable/HandleIPCEvents discovery
return c.RegisterService(name, instance)
}
}
// WithName registers a service with an explicit name (no reflect discovery).
//
// core.WithName("ws", func(c *Core) Result {
// return Result{Value: hub, OK: true}
// })
func WithName(name string, factory func(*Core) Result) CoreOption {
return func(c *Core) Result {
r := factory(c)
if !r.OK {
return r
}
if r.Value == nil {
return Result{E("core.WithName", Sprintf("failed to create service %q", name), nil), false}
}
return c.RegisterService(name, r.Value)
}
}
// WithOption is a convenience for setting a single key-value option.
//
// core.New(
// core.WithOption("name", "myapp"),
// core.WithOption("port", 8080),
// )
func WithOption(key string, value any) CoreOption {
return func(c *Core) Result {
if c.options == nil {
opts := NewOptions()
c.options = &opts
}
c.options.Set(key, value)
if key == "name" {
if s, ok := value.(string); ok {
c.app.Name = s
}
}
return Result{OK: true}
}
}
// WithServiceLock prevents further service registration after construction.
//
// core.New(
// core.WithService(auth.Register),
// core.WithServiceLock(),
// )
func WithServiceLock() CoreOption {
return func(c *Core) Result {
c.LockEnable()
return Result{OK: true}
}
}

View file

@ -1,133 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- WithService ---
// stub service used only for name-discovery tests.
type stubNamedService struct{}
// stubFactory is a package-level factory so the runtime function name carries
// the package path "core_test.stubFactory" — last segment after '/' is
// "core_test", and after stripping a "_test" suffix we get "core".
// For a real service package such as "dappco.re/go/agentic" the discovered
// name would be "agentic".
func stubFactory(c *Core) Result {
return Result{Value: &stubNamedService{}, OK: true}
}
// TestWithService_NameDiscovery_Good verifies that WithService discovers the
// service name from the factory's package path and registers the instance via
// RegisterService, making it retrievable through c.Services().
//
// stubFactory lives in package "dappco.re/go/core_test", so the last path
// segment is "core_test" — WithService strips the "_test" suffix and registers
// the service under the name "core".
func TestContract_WithService_NameDiscovery_Good(t *testing.T) {
c := New(WithService(stubFactory))
names := c.Services()
// Service should be auto-registered under a discovered name (not just "cli" which is built-in)
assert.Greater(t, len(names), 1, "expected auto-discovered service to be registered alongside built-in 'cli'")
}
// TestWithService_FactorySelfRegisters_Good verifies that when a factory
// returns Result{OK:true} with no Value (it registered itself), WithService
// does not attempt a second registration and returns success.
func TestContract_WithService_FactorySelfRegisters_Good(t *testing.T) {
selfReg := func(c *Core) Result {
// Factory registers directly, returns no instance.
c.Service("self", Service{})
return Result{OK: true}
}
c := New(WithService(selfReg))
// "self" must be present and registered exactly once.
svc := c.Service("self")
assert.True(t, svc.OK, "expected self-registered service to be present")
}
// --- WithName ---
func TestContract_WithName_Good(t *testing.T) {
c := New(
WithName("custom", func(c *Core) Result {
return Result{Value: &stubNamedService{}, OK: true}
}),
)
assert.Contains(t, c.Services(), "custom")
}
// --- Lifecycle ---
type lifecycleService struct {
started bool
}
func (s *lifecycleService) OnStartup(_ context.Context) Result {
s.started = true
return Result{OK: true}
}
func TestContract_WithService_Lifecycle_Good(t *testing.T) {
svc := &lifecycleService{}
c := New(
WithService(func(c *Core) Result {
return Result{Value: svc, OK: true}
}),
)
c.ServiceStartup(context.Background(), nil)
assert.True(t, svc.started)
}
// --- IPC Handler ---
type ipcService struct {
received Message
}
func (s *ipcService) HandleIPCEvents(c *Core, msg Message) Result {
s.received = msg
return Result{OK: true}
}
func TestContract_WithService_IPCHandler_Good(t *testing.T) {
svc := &ipcService{}
c := New(
WithService(func(c *Core) Result {
return Result{Value: svc, OK: true}
}),
)
c.ACTION("ping")
assert.Equal(t, "ping", svc.received)
}
// --- Error ---
// TestWithService_FactoryError_Bad verifies that a failing factory
// stops further option processing (second service not registered).
func TestContract_WithService_FactoryError_Bad(t *testing.T) {
secondCalled := false
c := New(
WithService(func(c *Core) Result {
return Result{Value: E("test", "factory failed", nil), OK: false}
}),
WithService(func(c *Core) Result {
secondCalled = true
return Result{OK: true}
}),
)
assert.NotNil(t, c)
assert.False(t, secondCalled, "second option should not run after first fails")
}

239
core.go
View file

@ -1,239 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Package core is a dependency injection and service lifecycle framework for Go.
// This file defines the Core struct, accessors, and IPC/error wrappers.
package core
import (
"context"
"os"
"sync"
"sync/atomic"
)
// --- Core Struct ---
// Core is the central application object that manages services, assets, and communication.
type Core struct {
options *Options // c.Options() — Input configuration used to create this Core
app *App // c.App() — Application identity + optional GUI runtime
data *Data // c.Data() — Embedded/stored content from packages
drive *Drive // c.Drive() — Resource handle registry (transports)
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
config *Config // c.Config() — Configuration, settings, feature flags
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
log *ErrorLog // c.Log() — Structured logging + error wrapping
// cli accessed via ServiceFor[*Cli](c, "cli")
commands *CommandRegistry // c.Command("path") — Command tree
services *ServiceRegistry // c.Service("name") — Service registry
lock *Lock // c.Lock("name") — Named mutexes
ipc *Ipc // c.IPC() — Message bus for IPC
api *API // c.API() — Remote streams
info *SysInfo // c.Env("key") — Read-only system/environment information
i18n *I18n // c.I18n() — Internationalisation and locale collection
entitlementChecker EntitlementChecker // default: everything permitted
usageRecorder UsageRecorder // default: nil (no-op)
context context.Context
cancel context.CancelFunc
taskIDCounter atomic.Uint64
waitGroup sync.WaitGroup
shutdown atomic.Bool
}
// --- Accessors ---
// Options returns the input configuration passed to core.New().
//
// opts := c.Options()
// name := opts.String("name")
func (c *Core) Options() *Options { return c.options }
// App returns application identity metadata.
//
// c.App().Name // "my-app"
// c.App().Version // "1.0.0"
func (c *Core) App() *App { return c.app }
// Data returns the embedded asset registry (Registry[*Embed]).
//
// r := c.Data().ReadString("prompts/coding.md")
func (c *Core) Data() *Data { return c.data }
// Drive returns the transport handle registry (Registry[*DriveHandle]).
//
// r := c.Drive().Get("forge")
func (c *Core) Drive() *Drive { return c.drive }
// Fs returns the sandboxed filesystem.
//
// r := c.Fs().Read("/path/to/file")
// c.Fs().WriteAtomic("/status.json", data)
func (c *Core) Fs() *Fs { return c.fs }
// Config returns runtime settings and feature flags.
//
// host := c.Config().String("database.host")
// c.Config().Enable("dark-mode")
func (c *Core) Config() *Config { return c.config }
// Error returns the panic recovery subsystem.
//
// c.Error().Recover()
func (c *Core) Error() *ErrorPanic { return c.error }
// Log returns the structured logging subsystem.
//
// c.Log().Info("started", "port", 8080)
func (c *Core) Log() *ErrorLog { return c.log }
// Cli returns the CLI command framework (registered as service "cli").
//
// c.Cli().Run("deploy", "to", "homelab")
func (c *Core) Cli() *Cli {
cl, _ := ServiceFor[*Cli](c, "cli")
return cl
}
// IPC returns the message bus internals.
//
// c.IPC()
func (c *Core) IPC() *Ipc { return c.ipc }
// I18n returns the internationalisation subsystem.
//
// tr := c.I18n().Translate("cmd.deploy.description")
func (c *Core) I18n() *I18n { return c.i18n }
// Env returns an environment variable by key (cached at init, falls back to os.Getenv).
//
// home := c.Env("DIR_HOME")
// token := c.Env("FORGE_TOKEN")
func (c *Core) Env(key string) string { return Env(key) }
// Context returns Core's lifecycle context (cancelled on shutdown).
//
// ctx := c.Context()
func (c *Core) Context() context.Context { return c.context }
// Core returns self — satisfies the ServiceRuntime interface.
//
// c := s.Core()
func (c *Core) Core() *Core { return c }
// --- Lifecycle ---
// RunE starts all services, runs the CLI, then shuts down.
// Returns an error instead of calling os.Exit — let main() handle the exit.
// ServiceShutdown is always called via defer, even on startup failure or panic.
//
// if err := c.RunE(); err != nil {
// os.Exit(1)
// }
func (c *Core) RunE() error {
defer c.ServiceShutdown(context.Background())
r := c.ServiceStartup(c.context, nil)
if !r.OK {
if err, ok := r.Value.(error); ok {
return err
}
return E("core.Run", "startup failed", nil)
}
if cli := c.Cli(); cli != nil {
r = cli.Run()
}
if !r.OK {
if err, ok := r.Value.(error); ok {
return err
}
}
return nil
}
// Run starts all services, runs the CLI, then shuts down.
// Calls os.Exit(1) on failure. For error handling use RunE().
//
// c := core.New(core.WithService(myService.Register))
// c.Run()
func (c *Core) Run() {
if err := c.RunE(); err != nil {
Error(err.Error())
os.Exit(1)
}
}
// --- IPC (uppercase aliases) ---
// ACTION broadcasts a message to all registered handlers (fire-and-forget).
// Each handler is wrapped in panic recovery. All handlers fire regardless.
//
// c.ACTION(messages.AgentCompleted{Agent: "codex", Status: "completed"})
func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) }
// QUERY sends a request — first handler to return OK wins.
//
// r := c.QUERY(MyQuery{Name: "brain"})
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
// QUERYALL sends a request — collects all OK responses.
//
// r := c.QUERYALL(countQuery{})
// results := r.Value.([]any)
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
// --- Error+Log ---
// LogError logs an error and returns the Result from ErrorLog.
func (c *Core) LogError(err error, op, msg string) Result {
return c.log.Error(err, op, msg)
}
// LogWarn logs a warning and returns the Result from ErrorLog.
func (c *Core) LogWarn(err error, op, msg string) Result {
return c.log.Warn(err, op, msg)
}
// Must logs and panics if err is not nil.
func (c *Core) Must(err error, op, msg string) {
c.log.Must(err, op, msg)
}
// --- Registry Accessor ---
// RegistryOf returns a named registry for cross-cutting queries.
// Known registries: "services", "commands", "actions".
//
// c.RegistryOf("services").Names() // all service names
// c.RegistryOf("actions").List("process.*") // process capabilities
// c.RegistryOf("commands").Len() // command count
func (c *Core) RegistryOf(name string) *Registry[any] {
// Bridge typed registries to untyped access for cross-cutting queries.
// Each registry is wrapped in a read-only proxy.
switch name {
case "services":
return registryProxy(c.services.Registry)
case "commands":
return registryProxy(c.commands.Registry)
case "actions":
return registryProxy(c.ipc.actions)
default:
return NewRegistry[any]() // empty registry for unknown names
}
}
// registryProxy creates a read-only any-typed view of a typed registry.
// Copies current state — not a live view (avoids type parameter leaking).
func registryProxy[T any](src *Registry[T]) *Registry[any] {
proxy := NewRegistry[any]()
src.Each(func(name string, item T) {
proxy.Set(name, item)
})
return proxy
}
// --- Global Instance ---

View file

@ -1,245 +0,0 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- New ---
func TestCore_New_Good(t *testing.T) {
c := New()
assert.NotNil(t, c)
}
func TestCore_New_WithOptions_Good(t *testing.T) {
c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})))
assert.NotNil(t, c)
assert.Equal(t, "myapp", c.App().Name)
}
func TestCore_New_WithOptions_Bad(t *testing.T) {
// Empty options — should still create a valid Core
c := New(WithOptions(NewOptions()))
assert.NotNil(t, c)
}
func TestCore_New_WithService_Good(t *testing.T) {
started := false
c := New(
WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})),
WithService(func(c *Core) Result {
c.Service("test", Service{
OnStart: func() Result { started = true; return Result{OK: true} },
})
return Result{OK: true}
}),
)
svc := c.Service("test")
assert.True(t, svc.OK)
c.ServiceStartup(context.Background(), nil)
assert.True(t, started)
}
func TestCore_New_WithServiceLock_Good(t *testing.T) {
c := New(
WithService(func(c *Core) Result {
c.Service("allowed", Service{})
return Result{OK: true}
}),
WithServiceLock(),
)
// Registration after lock should fail
reg := c.Service("blocked", Service{})
assert.False(t, reg.OK)
}
func TestCore_New_WithService_Bad_FailingOption(t *testing.T) {
secondCalled := false
_ = New(
WithService(func(c *Core) Result {
return Result{Value: E("test", "intentional failure", nil), OK: false}
}),
WithService(func(c *Core) Result {
secondCalled = true
return Result{OK: true}
}),
)
assert.False(t, secondCalled, "second option should not run after first fails")
}
// --- Accessors ---
func TestCore_Accessors_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.App())
assert.NotNil(t, c.Data())
assert.NotNil(t, c.Drive())
assert.NotNil(t, c.Fs())
assert.NotNil(t, c.Config())
assert.NotNil(t, c.Error())
assert.NotNil(t, c.Log())
assert.NotNil(t, c.Cli())
assert.NotNil(t, c.IPC())
assert.NotNil(t, c.I18n())
assert.Equal(t, c, c.Core())
}
func TestOptions_Accessor_Good(t *testing.T) {
c := New(WithOptions(NewOptions(
Option{Key: "name", Value: "testapp"},
Option{Key: "port", Value: 8080},
Option{Key: "debug", Value: true},
)))
opts := c.Options()
assert.NotNil(t, opts)
assert.Equal(t, "testapp", opts.String("name"))
assert.Equal(t, 8080, opts.Int("port"))
assert.True(t, opts.Bool("debug"))
}
func TestOptions_Accessor_Nil(t *testing.T) {
c := New()
// No options passed — Options() returns nil
assert.Nil(t, c.Options())
}
// --- Core Error/Log Helpers ---
func TestCore_LogError_Good(t *testing.T) {
c := New()
cause := assert.AnError
r := c.LogError(cause, "test.Operation", "something broke")
err, ok := r.Value.(error)
assert.True(t, ok)
assert.ErrorIs(t, err, cause)
}
func TestCore_LogWarn_Good(t *testing.T) {
c := New()
r := c.LogWarn(assert.AnError, "test.Operation", "heads up")
_, ok := r.Value.(error)
assert.True(t, ok)
}
func TestCore_Must_Ugly(t *testing.T) {
c := New()
assert.Panics(t, func() {
c.Must(assert.AnError, "test.Operation", "fatal")
})
}
func TestCore_Must_Nil_Good(t *testing.T) {
c := New()
assert.NotPanics(t, func() {
c.Must(nil, "test.Operation", "no error")
})
}
// --- RegistryOf ---
func TestCore_RegistryOf_Good_Services(t *testing.T) {
c := New(
WithService(func(c *Core) Result {
return c.Service("alpha", Service{})
}),
WithService(func(c *Core) Result {
return c.Service("bravo", Service{})
}),
)
reg := c.RegistryOf("services")
// cli is auto-registered + our 2
assert.True(t, reg.Has("alpha"))
assert.True(t, reg.Has("bravo"))
assert.True(t, reg.Has("cli"))
}
func TestCore_RegistryOf_Good_Commands(t *testing.T) {
c := New()
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }})
reg := c.RegistryOf("commands")
assert.True(t, reg.Has("deploy"))
assert.True(t, reg.Has("test"))
}
func TestCore_RegistryOf_Good_Actions(t *testing.T) {
c := New()
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} })
reg := c.RegistryOf("actions")
assert.True(t, reg.Has("process.run"))
assert.True(t, reg.Has("brain.recall"))
assert.Equal(t, 2, reg.Len())
}
func TestCore_RegistryOf_Bad_Unknown(t *testing.T) {
c := New()
reg := c.RegistryOf("nonexistent")
assert.Equal(t, 0, reg.Len(), "unknown registry returns empty")
}
// --- RunE ---
func TestCore_RunE_Good(t *testing.T) {
c := New(
WithService(func(c *Core) Result {
return c.Service("healthy", Service{
OnStart: func() Result { return Result{OK: true} },
OnStop: func() Result { return Result{OK: true} },
})
}),
)
err := c.RunE()
assert.NoError(t, err)
}
func TestCore_RunE_Bad_StartupFailure(t *testing.T) {
c := New(
WithService(func(c *Core) Result {
return c.Service("broken", Service{
OnStart: func() Result {
return Result{Value: NewError("startup failed"), OK: false}
},
})
}),
)
err := c.RunE()
assert.Error(t, err)
assert.Contains(t, err.Error(), "startup failed")
}
func TestCore_RunE_Ugly_StartupFailureCallsShutdown(t *testing.T) {
shutdownCalled := false
c := New(
WithService(func(c *Core) Result {
return c.Service("cleanup", Service{
OnStart: func() Result { return Result{OK: true} },
OnStop: func() Result { shutdownCalled = true; return Result{OK: true} },
})
}),
WithService(func(c *Core) Result {
return c.Service("broken", Service{
OnStart: func() Result {
return Result{Value: NewError("boom"), OK: false}
},
})
}),
)
err := c.RunE()
assert.Error(t, err)
assert.True(t, shutdownCalled, "ServiceShutdown must be called even when startup fails — cleanup service must get OnStop")
}
// Run() delegates to RunE() — tested via RunE tests above.
// os.Exit behaviour is verified by RunE returning error correctly.

168
data.go
View file

@ -1,168 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Data is the embedded/stored content system for core packages.
// Packages mount their embedded content here and other packages
// read from it by path.
//
// Mount a package's assets:
//
// c.Data().New(core.NewOptions(
// core.Option{Key: "name", Value: "brain"},
// core.Option{Key: "source", Value: brainFS},
// core.Option{Key: "path", Value: "prompts"},
// ))
//
// Read from any mounted path:
//
// content := c.Data().ReadString("brain/coding.md")
// entries := c.Data().List("agent/flow")
//
// Extract a template directory:
//
// c.Data().Extract("agent/workspace/default", "/tmp/ws", data)
package core
import (
"io/fs"
"path/filepath"
)
// Data manages mounted embedded filesystems from core packages.
// Embeds Registry[*Embed] for thread-safe named storage.
type Data struct {
*Registry[*Embed]
}
// New registers an embedded filesystem under a named prefix.
//
// c.Data().New(core.NewOptions(
// core.Option{Key: "name", Value: "brain"},
// core.Option{Key: "source", Value: brainFS},
// core.Option{Key: "path", Value: "prompts"},
// ))
func (d *Data) New(opts Options) Result {
name := opts.String("name")
if name == "" {
return Result{}
}
r := opts.Get("source")
if !r.OK {
return r
}
fsys, ok := r.Value.(fs.FS)
if !ok {
return Result{E("data.New", "source is not fs.FS", nil), false}
}
path := opts.String("path")
if path == "" {
path = "."
}
mr := Mount(fsys, path)
if !mr.OK {
return mr
}
emb := mr.Value.(*Embed)
d.Set(name, emb)
return Result{emb, true}
}
// resolve splits a path like "brain/coding.md" into mount name + relative path.
func (d *Data) resolve(path string) (*Embed, string) {
parts := SplitN(path, "/", 2)
if len(parts) < 2 {
return nil, ""
}
r := d.Get(parts[0])
if !r.OK {
return nil, ""
}
return r.Value.(*Embed), parts[1]
}
// ReadFile reads a file by full path.
//
// r := c.Data().ReadFile("brain/prompts/coding.md")
// if r.OK { data := r.Value.([]byte) }
func (d *Data) ReadFile(path string) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
return emb.ReadFile(rel)
}
// ReadString reads a file as a string.
//
// r := c.Data().ReadString("agent/flow/deploy/to/homelab.yaml")
// if r.OK { content := r.Value.(string) }
func (d *Data) ReadString(path string) Result {
r := d.ReadFile(path)
if !r.OK {
return r
}
return Result{string(r.Value.([]byte)), true}
}
// List returns directory entries at a path.
//
// r := c.Data().List("agent/persona/code")
// if r.OK { entries := r.Value.([]fs.DirEntry) }
func (d *Data) List(path string) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
r := emb.ReadDir(rel)
if !r.OK {
return r
}
return Result{r.Value, true}
}
// ListNames returns filenames (without extensions) at a path.
//
// r := c.Data().ListNames("agent/flow")
// if r.OK { names := r.Value.([]string) }
func (d *Data) ListNames(path string) Result {
r := d.List(path)
if !r.OK {
return r
}
entries := r.Value.([]fs.DirEntry)
var names []string
for _, e := range entries {
name := e.Name()
if !e.IsDir() {
name = TrimSuffix(name, filepath.Ext(name))
}
names = append(names, name)
}
return Result{names, true}
}
// Extract copies a template directory to targetDir.
//
// r := c.Data().Extract("agent/workspace/default", "/tmp/ws", templateData)
func (d *Data) Extract(path, targetDir string, templateData any) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
r := emb.Sub(rel)
if !r.OK {
return r
}
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
}
// Mounts returns the names of all mounted content in registration order.
//
// names := c.Data().Mounts()
func (d *Data) Mounts() []string {
return d.Names()
}

View file

@ -1,133 +0,0 @@
package core_test
import (
"embed"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
//go:embed testdata
var testFS embed.FS
// --- Data (Embedded Content Mounts) ---
func mountTestData(t *testing.T, c *Core, name string) {
t.Helper()
r := c.Data().New(NewOptions(
Option{Key: "name", Value: name},
Option{Key: "source", Value: testFS},
Option{Key: "path", Value: "testdata"},
))
assert.True(t, r.OK)
}
func TestData_New_Good(t *testing.T) {
c := New()
r := c.Data().New(NewOptions(
Option{Key: "name", Value: "test"},
Option{Key: "source", Value: testFS},
Option{Key: "path", Value: "testdata"},
))
assert.True(t, r.OK)
assert.NotNil(t, r.Value)
}
func TestData_New_Bad(t *testing.T) {
c := New()
r := c.Data().New(NewOptions(Option{Key: "source", Value: testFS}))
assert.False(t, r.OK)
r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}))
assert.False(t, r.OK)
r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}, Option{Key: "source", Value: "not-an-fs"}))
assert.False(t, r.OK)
}
func TestData_ReadString_Good(t *testing.T) {
c := New()
mountTestData(t, c, "app")
r := c.Data().ReadString("app/test.txt")
assert.True(t, r.OK)
assert.Equal(t, "hello from testdata\n", r.Value.(string))
}
func TestData_ReadString_Bad(t *testing.T) {
c := New()
r := c.Data().ReadString("nonexistent/file.txt")
assert.False(t, r.OK)
}
func TestData_ReadFile_Good(t *testing.T) {
c := New()
mountTestData(t, c, "app")
r := c.Data().ReadFile("app/test.txt")
assert.True(t, r.OK)
assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte)))
}
func TestData_Get_Good(t *testing.T) {
c := New()
mountTestData(t, c, "brain")
gr := c.Data().Get("brain")
assert.True(t, gr.OK)
emb := gr.Value.(*Embed)
r := emb.Open("test.txt")
assert.True(t, r.OK)
cr := ReadAll(r.Value)
assert.True(t, cr.OK)
assert.Equal(t, "hello from testdata\n", cr.Value)
}
func TestData_Get_Bad(t *testing.T) {
c := New()
r := c.Data().Get("nonexistent")
assert.False(t, r.OK)
}
func TestData_Mounts_Good(t *testing.T) {
c := New()
mountTestData(t, c, "a")
mountTestData(t, c, "b")
mounts := c.Data().Mounts()
assert.Len(t, mounts, 2)
}
func TestData_List_Good(t *testing.T) {
c := New()
mountTestData(t, c, "app")
r := c.Data().List("app/.")
assert.True(t, r.OK)
}
func TestData_List_Bad(t *testing.T) {
c := New()
r := c.Data().List("nonexistent/path")
assert.False(t, r.OK)
}
func TestData_ListNames_Good(t *testing.T) {
c := New()
mountTestData(t, c, "app")
r := c.Data().ListNames("app/.")
assert.True(t, r.OK)
assert.Contains(t, r.Value.([]string), "test")
}
func TestData_Extract_Good(t *testing.T) {
c := New()
mountTestData(t, c, "app")
r := c.Data().Extract("app/.", t.TempDir(), nil)
assert.True(t, r.OK)
}
func TestData_Extract_Bad(t *testing.T) {
c := New()
r := c.Data().Extract("nonexistent/path", t.TempDir(), nil)
assert.False(t, r.OK)
}

File diff suppressed because it is too large Load diff

View file

@ -1,177 +0,0 @@
---
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,96 +1,178 @@
---
title: Configuration
description: Constructor options, runtime settings, and feature flags.
title: Configuration Options
description: WithService, WithName, WithApp, WithAssets, and WithServiceLock options.
---
# Configuration
# Configuration Options
CoreGO uses two different configuration layers:
- constructor-time `core.Options`
- runtime `c.Config()`
## Constructor-Time Options
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.
```go
c := core.New(core.Options{
{Key: "name", Value: "agent-workbench"},
})
type Option func(*Core) error
```
### Current Behavior
## Available Options
- `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.
### WithService
```go
c.Config().Set("workspace.root", "/srv/workspaces")
c.Config().Set("max_agents", 8)
c.Config().Set("debug", true)
func WithService(factory func(*Core) (any, error)) Option
```
Read them back with:
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).
```go
root := c.Config().String("workspace.root")
maxAgents := c.Config().Int("max_agents")
debug := c.Config().Bool("debug")
raw := c.Config().Get("workspace.root")
// If the returned type is from package "myapp/services/calculator",
// the service name becomes "calculator".
core.New(
core.WithService(calculator.NewService),
)
```
### Important Details
`WithService` also performs two automatic behaviours:
- missing keys return zero values
- typed accessors do not coerce strings into ints or bools
- `Get` returns `core.Result`
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.
## Feature Flags
If the factory returns an error or `nil`, `New()` fails with an error.
`Config` also tracks named feature flags.
If the returned type has no package path (e.g. a primitive or anonymous type), `New()` fails with a descriptive error.
### WithName
```go
c.Config().Enable("workspace.templates")
c.Config().Enable("agent.review")
c.Config().Disable("agent.review")
func WithName(name string, factory func(*Core) (any, error)) Option
```
Read them with:
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).
```go
enabled := c.Config().Enabled("workspace.templates")
features := c.Config().EnabledFeatures()
core.New(
core.WithName("greet", func(c *core.Core) (any, error) {
return &Greeter{}, nil
}),
)
```
Feature names are case-sensitive.
## `ConfigVar[T]`
Use `ConfigVar[T]` when you need a typed value that can also represent “set versus unset”.
Unlike `WithService`, `WithName` does **not** auto-discover IPC handlers. If your service needs to handle actions, register the handler manually:
```go
theme := core.NewConfigVar("amber")
core.WithName("greet", func(c *core.Core) (any, error) {
svc := &Greeter{}
c.RegisterAction(svc.HandleIPCEvents)
return svc, nil
}),
```
if theme.IsSet() {
fmt.Println(theme.Get())
### 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
}
theme.Unset()
// Clear it (useful in tests)
core.ClearInstance()
```
This is useful for package-local state where zero values are not enough to describe configuration presence.
These functions are thread-safe.
## Recommended Pattern
## Features
Use the two layers for different jobs:
The `Core` struct includes a `Features` field for simple feature flagging:
- put startup identity such as `name` into `core.Options`
- put mutable runtime values and feature switches into `c.Config()`
```go
c.Features.Flags = []string{"experimental-ui", "beta-api"}
That keeps constructor intent separate from live process state.
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

View file

@ -1,120 +1,139 @@
---
title: Errors
description: Structured errors, logging helpers, and panic recovery.
description: The E() helper function and Error struct for contextual error handling.
---
# Errors
CoreGO treats failures as structured operational data.
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.
Repository convention: use `E()` instead of `fmt.Errorf` for framework and service errors.
## `Err`
The structured error type is:
## The Error Struct
```go
type Err struct {
Operation string
Message string
Cause error
Code string
type Error struct {
Op string // the operation, e.g. "config.Load"
Msg string // human-readable explanation
Err error // the underlying error (may be nil)
}
```
## Create Errors
- **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.
### `E`
## The E() Helper
`E()` is the primary way to create contextual errors:
```go
err := core.E("workspace.Load", "failed to read workspace manifest", cause)
func E(op, msg string, err error) error
```
### `Wrap`
### With an Underlying Error
```go
err := core.Wrap(cause, "workspace.Load", "manifest parse failed")
data, err := os.ReadFile(path)
if err != nil {
return core.E("config.Load", "failed to read config file", err)
}
```
### `WrapCode`
This produces: `config.Load: failed to read config file: open /path/to/file: no such file or directory`
### Without an Underlying Error (Root Error)
```go
err := core.WrapCode(cause, "WORKSPACE_INVALID", "workspace.Load", "manifest parse failed")
if name == "" {
return core.E("user.Create", "name cannot be empty", nil)
}
```
### `NewCode`
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`:
```go
err := core.NewCode("NOT_FOUND", "workspace not found")
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"
}
```
## Inspect Errors
## Building Error Chains
Because `E()` wraps errors, you can build a logical call stack by wrapping at each layer:
```go
op := core.Operation(err)
code := core.ErrorCode(err)
msg := core.ErrorMessage(err)
root := core.Root(err)
stack := core.StackTrace(err)
pretty := core.FormatStackTrace(err)
// 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
}
```
These helpers keep the operational chain visible without extra type assertions.
The resulting error message reads like a stack trace:
## Join and Standard Wrappers
```go
combined := core.ErrorJoin(err1, err2)
same := core.Is(combined, err1)
```
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
```
`core.As` and `core.NewError` mirror the standard library for convenience.
## Conventions
## Log-and-Return Helpers
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).
`Core` exposes two convenience wrappers:
## Related Pages
```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.
- [Services](services.md) -- services that return errors
- [Lifecycle](lifecycle.md) -- lifecycle error aggregation
- [Testing](testing.md) -- testing error conditions (`_Bad` suffix)

View file

@ -1,198 +1,191 @@
---
title: Getting Started
description: Build a first CoreGO application with the current API.
description: How to create a Core application and register services.
---
# Getting Started
This page shows the shortest path to a useful CoreGO application using the API that exists in this repository today.
This guide walks you through creating a Core application, registering services, and running the lifecycle.
## Install
## Installation
```bash
go get dappco.re/go/core
go get forge.lthn.ai/core/go
```
## Create a Core
## Creating a Core Instance
`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{...})`.
Everything starts with `core.New()`. It accepts a variadic list of `Option` functions that configure the container before it is returned.
```go
package main
import "dappco.re/go/core"
import "forge.lthn.ai/core/go/pkg/core"
func main() {
c := core.New(core.Options{
{Key: "name", Value: "agent-workbench"},
})
_ = c
c, err := core.New()
if err != nil {
panic(err)
}
_ = c // empty container, ready for use
}
```
The `name` option is copied into `c.App().Name`.
## Register a Service
Services are registered explicitly with a name and a `core.Service` DTO.
In practice you will pass options to register services, embed assets, or lock the registry:
```go
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}
},
})
c, err := core.New(
core.WithService(mypackage.NewService),
core.WithAssets(embeddedFS),
core.WithServiceLock(),
)
```
This registry stores `core.Service` values. It is a lifecycle registry, not a typed object container.
See [Configuration](configuration.md) for the full list of options.
## Register a Query, Task, and Command
## Registering a Service
Services are registered via **factory functions**. A factory receives the `*Core` and returns `(any, error)`:
```go
type workspaceCountQuery struct{}
package greeter
type createWorkspaceTask struct {
Name string
import "forge.lthn.ai/core/go/pkg/core"
type Service struct {
greeting string
}
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 (s *Service) Hello(name string) string {
return s.greeting + ", " + name + "!"
}
c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result {
name := opts.String("name")
path := "/tmp/agent-workbench/" + name
return core.Result{Value: path, OK: true}
})
c.Command("workspace/create", core.Command{
Action: func(opts core.Options) core.Result {
return c.Action("workspace.create").Run(context.Background(), opts)
},
})
```
## Start the Runtime
```go
if !c.ServiceStartup(context.Background(), nil).OK {
panic("startup failed")
func NewService(c *core.Core) (any, error) {
return &Service{greeting: "Hello"}, nil
}
```
`ServiceStartup` returns `core.Result`, not `error`.
## Run Through the CLI Surface
Register it with `WithService`:
```go
r := c.Cli().Run("workspace", "create", "--name=alpha")
if r.OK {
fmt.Println("created:", r.Value)
}
c, err := core.New(
core.WithService(greeter.NewService),
)
```
For flags with values, the CLI stores the value as a string. `--name=alpha` becomes `opts.String("name") == "alpha"`.
`WithService` automatically discovers the service name from the package path. In this case, the service is registered under the name `"greeter"`.
## Query the System
If you need to control the name explicitly, use `WithName`:
```go
count := c.QUERY(workspaceCountQuery{})
if count.OK {
fmt.Println("workspace count:", count.Value)
}
c, err := core.New(
core.WithName("greet", greeter.NewService),
)
```
## Shut Down Cleanly
See [Services](services.md) for the full registration API and the `ServiceRuntime` helper.
## Retrieving a Service
Once registered, services can be retrieved by name:
```go
_ = c.ServiceShutdown(context.Background())
// 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")
```
Shutdown cancels `c.Context()`, broadcasts `ActionServiceShutdown{}`, waits for background tasks to finish, and then runs service stop hooks.
## Running the Lifecycle
## Full Example
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:
```go
package main
import (
"context"
"fmt"
"context"
"fmt"
"dappco.re/go/core"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go/pkg/log"
)
type workspaceCountQuery struct{}
type createWorkspaceTask struct {
Name string
}
func main() {
c := core.New(core.Options{
{Key: "name", Value: "agent-workbench"},
})
c, err := core.New(
core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})),
core.WithServiceLock(),
)
if err != nil {
panic(err)
}
c.Config().Set("workspace.root", "/tmp/agent-workbench")
c.Config().Enable("workspace.templates")
// Start lifecycle
if err := c.ServiceStartup(context.Background(), nil); err != nil {
panic(err)
}
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}
},
})
// Use services
logger := core.MustServiceFor[*log.Service](c, "log")
fmt.Println("Logger started at level:", logger.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{}
})
// Query the log level through the message bus
level, handled, _ := c.QUERY(log.QueryLevel{})
if handled {
fmt.Println("Log level via QUERY:", level)
}
c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result {
name := opts.String("name")
path := c.Config().String("workspace.root") + "/" + name
return core.Result{Value: path, OK: true}
})
c.Command("workspace/create", core.Command{
Action: func(opts core.Options) core.Result {
return c.Action("workspace.create").Run(context.Background(), opts)
},
})
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())
// Clean shutdown
if err := c.ServiceShutdown(context.Background()); err != nil {
fmt.Println("shutdown error:", err)
}
}
```
## Next Steps
- 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.
- [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

View file

@ -1,60 +1,96 @@
---
title: CoreGO
description: AX-first documentation for the CoreGO framework.
title: Core Go Framework
description: Dependency injection and service lifecycle framework for Go.
---
# CoreGO
# Core Go Framework
CoreGO is the foundation layer for the Core ecosystem. Module: `dappco.re/go/core`.
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.
## What CoreGO Provides
This is the foundation layer of the ecosystem. It has no CLI, no GUI, and minimal dependencies.
| Primitive | Purpose |
|-----------|---------|
| `Core` | Central container — everything registers here |
| `Service` | Lifecycle-managed component (Startable/Stoppable return Result) |
| `Action` | Named callable with panic recovery + entitlement |
| `Task` | Composed sequence of Actions |
| `Registry[T]` | Thread-safe named collection (universal brick) |
| `Command` | Path-based CLI command tree |
| `Process` | Managed execution (Action sugar over go-process) |
| `API` | Remote streams (protocol handlers + Drive) |
| `Entitlement` | Permission gate (default permissive, consumer replaces) |
| `ACTION`, `QUERY` | Anonymous broadcast + request/response |
| `Data`, `Drive`, `Fs`, `Config`, `I18n` | Built-in subsystems |
## Installation
```bash
go get forge.lthn.ai/core/go
```
Requires Go 1.26 or later.
## What It Does
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 |
|---------|---------|
| [`pkg/core`](services.md) | DI container, service registry, lifecycle, message bus |
| `pkg/log` | Structured logger service with Core integration |
## Quick Example
```go
package main
import "dappco.re/go/core"
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go/pkg/log"
)
func main() {
c := core.New(
core.WithOption("name", "agent-workbench"),
core.WithService(cache.Register),
core.WithServiceLock(),
c, err := core.New(
core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})),
core.WithServiceLock(), // Prevent late registration
)
c.Run()
if err != nil {
panic(err)
}
// Start all services
if err := c.ServiceStartup(context.Background(), nil); err != nil {
panic(err)
}
// Type-safe retrieval
logger, err := core.ServiceFor[*log.Service](c, "log")
if err != nil {
panic(err)
}
fmt.Println("Log level:", logger.Level())
// Shut down (reverse order)
_ = c.ServiceShutdown(context.Background())
}
```
## API Specification
The full contract is `docs/RFC.md` (21 sections, 1476 lines). An agent should be able to write a service from RFC.md alone.
## Documentation
| Path | Covers |
| Page | Covers |
|------|--------|
| [RFC.md](RFC.md) | Authoritative API contract (21 sections) |
| [primitives.md](primitives.md) | Option, Result, Action, Task, Registry, Entitlement |
| [services.md](services.md) | Service registry, ServiceRuntime, service locks |
| [commands.md](commands.md) | Path-based commands, Managed field |
| [messaging.md](messaging.md) | ACTION, QUERY, named Actions, PerformAsync |
| [lifecycle.md](lifecycle.md) | RunE, ServiceStartup, ServiceShutdown |
| [subsystems.md](subsystems.md) | App, Data, Drive, Fs, Config, I18n |
| [errors.md](errors.md) | core.E(), structured errors, panic recovery |
| [testing.md](testing.md) | AX-7 TestFile_Function_{Good,Bad,Ugly} |
| [configuration.md](configuration.md) | WithOption, WithService, WithServiceLock |
| [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 |
## Dependencies
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

View file

@ -1,111 +1,165 @@
---
title: Lifecycle
description: Startup, shutdown, context ownership, and background task draining.
description: Startable and Stoppable interfaces, startup and shutdown ordering.
---
# Lifecycle
CoreGO manages lifecycle through `core.Service` callbacks, not through reflection or implicit interfaces.
Core manages the startup and shutdown of services through two opt-in interfaces. Services implement one or both to participate in the application lifecycle.
## Service Hooks
## Interfaces
### Startable
```go
c.Service("cache", core.Service{
OnStart: func() core.Result {
return core.Result{OK: true}
},
OnStop: func() core.Result {
return core.Result{OK: true}
},
})
type Startable interface {
OnStartup(ctx context.Context) error
}
```
Only services with `OnStart` appear in `Startables()`. Only services with `OnStop` appear in `Stoppables()`.
Services implementing `Startable` have their `OnStartup` method called during `ServiceStartup`. This is the place to:
## `ServiceStartup`
- Open database connections
- Register message bus handlers (queries, tasks)
- Start background workers
- Validate configuration
### Stoppable
```go
r := c.ServiceStartup(context.Background(), nil)
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
```
### What It Does
Services implementing `Stoppable` have their `OnShutdown` method called during `ServiceShutdown`. This is the place to:
1. clears the shutdown flag
2. stores a new cancellable context on `c.Context()`
3. runs each `OnStart`
4. broadcasts `ActionServiceStartup{}`
- Close database connections
- Flush buffers
- Save state
- Cancel background workers
### 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`
A service can implement both interfaces:
```go
r := c.ServiceShutdown(context.Background())
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
}
```
### What It Does
1. sets the shutdown flag
2. cancels `c.Context()`
3. broadcasts `ActionServiceShutdown{}`
4. waits for background tasks created by `PerformAsync`
5. runs each `OnStop`
### Failure Behavior
- 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.
### Startup: Registration Order
That means lifecycle order is not guaranteed today.
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.
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.
### Shutdown: Reverse Registration Order
## `c.Context()`
Services are stopped in **reverse** registration order. If A, B, C were registered, their `OnShutdown` methods are called as C, B, A.
`ServiceStartup` creates the context returned by `c.Context()`.
Use it for background work that should stop when the application shuts down:
This ensures that services which depend on earlier services are torn down first.
```go
c.Service("watcher", core.Service{
OnStart: func() core.Result {
go func(ctx context.Context) {
<-ctx.Done()
}(c.Context())
return core.Result{OK: true}
},
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
})
```
## Built-In Lifecycle Actions
## Error Handling
You can listen for lifecycle state changes through the action bus.
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`:
```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}
})
err := c.ServiceStartup(ctx, nil)
if err != nil {
// err may contain multiple wrapped errors
if errors.Is(err, context.Canceled) {
// context was cancelled
}
}
```
## Background Task Draining
## Context Cancellation
`ServiceShutdown` waits for the internal task waitgroup to finish before calling stop hooks.
Both `ServiceStartup` and `ServiceShutdown` respect context cancellation. If the context is cancelled or its deadline is exceeded, the remaining services are skipped:
This is what makes `PerformAsync` safe for long-running work that should complete before teardown.
```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
## `OnReload`
err := c.ServiceStartup(ctx, nil)
// If startup takes longer than 5 seconds, remaining services are skipped
```
`Service` includes an `OnReload` callback field, but CoreGO does not currently expose a top-level lifecycle runner for reload operations.
## Detection
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.
## Related Pages
- [Services](services.md) -- how services are registered
- [Messaging](messaging.md) -- the `ACTION` broadcast used during lifecycle
- [Configuration](configuration.md) -- `WithServiceLock` and other options

View file

@ -1,127 +1,286 @@
---
title: Messaging
description: ACTION, QUERY, QUERYALL, named Actions, and async dispatch.
description: ACTION, QUERY, and PERFORM -- the message bus for decoupled service communication.
---
# Messaging
CoreGO has two messaging layers: anonymous broadcast (ACTION/QUERY) and named Actions.
The message bus enables services to communicate without importing each other. It supports three patterns:
## Anonymous Broadcast
| 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) |
### `ACTION`
All three are type-safe at the handler level through Go type switches, while the bus itself uses `any` to avoid import cycles.
Fire-and-forget broadcast to all registered handlers. Each handler is wrapped in panic recovery. Handler return values are ignored — all handlers fire regardless.
## Message Types
```go
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if ev, ok := msg.(repositoryIndexed); ok {
core.Info("indexed", "name", ev.Name)
}
return core.Result{OK: true}
})
c.ACTION(repositoryIndexed{Name: "core-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
```
### `QUERY`
First handler to return `OK:true` wins.
Define your message types as plain structs:
```go
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
if _, ok := q.(repositoryCountQuery); ok {
return core.Result{Value: 42, OK: true}
}
return core.Result{}
})
r := c.QUERY(repositoryCountQuery{})
```
### `QUERYALL`
Collects every successful non-nil response.
```go
r := c.QUERYALL(repositoryCountQuery{})
results := r.Value.([]any)
```
## Named Actions
Named Actions are the typed, inspectable replacement for anonymous dispatch. See Section 18 of `RFC.md`.
### Register and Invoke
```go
// Register during OnStartup
c.Action("repo.sync", func(ctx context.Context, opts core.Options) core.Result {
name := opts.String("name")
return core.Result{Value: "synced " + name, OK: true}
})
// Invoke by name
r := c.Action("repo.sync").Run(ctx, core.NewOptions(
core.Option{Key: "name", Value: "core-go"},
))
```
### Capability Check
```go
if c.Action("process.run").Exists() {
// go-process is registered
// In your package
type UserCreated struct {
UserID string
Email string
}
c.Actions() // []string of all registered action names
type GetUserCount struct{}
type SendEmail struct {
To string
Subject string
Body string
}
```
### Permission Gate
## ACTION -- Broadcast
Every `Action.Run()` checks `c.Entitled(action.Name)` before executing. See Section 21 of `RFC.md`.
`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.
## Task Composition
A Task is a named sequence of Actions:
### Dispatching
```go
c.Task("deploy", core.Task{
Steps: []core.Step{
{Action: "go.build"},
{Action: "go.test"},
{Action: "docker.push"},
{Action: "notify.slack", Async: true},
},
})
r := c.Task("deploy").Run(ctx, c, opts)
err := c.ACTION(UserCreated{UserID: "123", Email: "user@example.com"})
```
Sequential steps stop on first failure. `Async: true` steps fire without blocking. `Input: "previous"` pipes output.
Errors from all handlers are aggregated with `errors.Join`. If no handlers are registered, `ACTION` returns `nil`.
## Background Execution
### Handling
```go
r := c.PerformAsync("repo.sync", opts)
taskID := r.Value.(string)
c.Progress(taskID, 0.5, "indexing commits", "repo.sync")
```
Broadcasts `ActionTaskStarted`, `ActionTaskProgress`, `ActionTaskCompleted` as ACTION messages.
### Completion Listener
```go
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if ev, ok := msg.(core.ActionTaskCompleted); ok {
core.Info("done", "task", ev.TaskIdentifier, "ok", ev.Result.OK)
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 core.Result{OK: true}
return nil
})
```
## Shutdown
You can register multiple handlers. Each handler receives every message -- use a type switch to filter.
When shutdown has started, `PerformAsync` returns an empty `Result`. `ServiceShutdown` drains outstanding background work before stopping services.
```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
})
```
Return `false` for `handled` to let the query fall through to the next handler.
### QUERYALL -- Collect All Responses
`QUERYALL` calls **every** handler and collects all responses where `handled == true`:
```go
results, err := c.QUERYALL(GetPluginInfo{})
// results contains one entry per handler that responded
```
Errors from all handlers are aggregated. Results from handlers that returned `handled == false` or `result == nil` are excluded.
## PERFORM -- Execute a Task
`PERFORM` dispatches a task to handlers in registration order. Like `QUERY`, the first handler that returns `handled == true` wins.
### Dispatching
```go
result, handled, err := c.PERFORM(SendEmail{
To: "user@example.com",
Subject: "Welcome",
Body: "Hello!",
})
if !handled {
// no handler could execute this task
}
```
### Handling
```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
})
```
## PerformAsync -- Background Tasks
`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:
| 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 |
### Listening for Completion
```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
})
```
### Reporting Progress
From within a task handler (or anywhere that has the task ID):
```go
c.Progress(taskID, 0.5, "halfway done", myTask)
```
This broadcasts an `ActionTaskProgress` message.
### TaskWithID
If your task struct implements `TaskWithID`, `PerformAsync` will inject the assigned task ID before dispatching:
```go
type TaskWithID interface {
Task
SetTaskID(id string)
GetTaskID() string
}
```
```go
type MyLongTask struct {
id string
}
func (t *MyLongTask) SetTaskID(id string) { t.id = id }
func (t *MyLongTask) GetTaskID() string { return t.id }
```
### 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

View file

@ -1,138 +1,616 @@
# AX Package Standards
# Core Package Standards
This page describes how to build packages on top of CoreGO in the style described by RFC-025.
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.
## 1. Prefer Predictable Names
## Package Structure
Use names that tell an agent what the thing is without translation.
A well-structured Core package follows this layout:
Good:
- `RepositoryService`
- `RepositoryServiceOptions`
- `WorkspaceCountQuery`
- `SyncRepositoryTask`
Avoid shortening names unless the abbreviation is already universal.
## 2. Put Real Usage in Comments
Write comments that show a real call with realistic values.
Good:
```go
// Sync a repository into the local workspace cache.
// svc.SyncRepository("core-go", "/srv/repos/core-go")
```
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
```
Avoid comments that only repeat the signature.
## Core Principles
## 3. Keep Paths Semantic
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
If a command or template lives at a path, let the path explain the intent.
---
Good:
## Service Pattern
```text
deploy/to/homelab
workspace/create
template/workspace/go
```
### Service Struct
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.
Embed `framework.ServiceRuntime[T]` for Core integration:
```go
type repositoryServiceOptions struct {
BaseDirectory string
// 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
}
type repositoryService struct {
*core.ServiceRuntime[repositoryServiceOptions]
// Options configures the service.
type Options struct {
// Document each option
BufferSize int
EnableFoo bool
}
```
## 5. Prefer Explicit Registration
### Service Factory
Register services and commands with names and paths that stay readable in grep results.
Create a factory function for Core registration:
```go
c.Service("repository", core.Service{...})
c.Command("repository/sync", core.Command{...})
```
// 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
}
## 6. Use the Bus for Decoupling
When one package needs another packages behavior, prefer queries and tasks over tight package coupling.
```go
type repositoryCountQuery struct{}
type syncRepositoryTask struct {
Name string
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(c, opts),
data: make(map[string]any),
}
return svc, nil
}
}
```
That keeps the protocol visible in code and easy for agents to follow.
### Lifecycle Hooks
## 7. Use Structured Errors
Use `core.E`, `core.Wrap`, and `core.WrapCode`.
Implement `core.Startable` and/or `core.Stoppable`:
```go
return core.Result{
Value: core.E("repository.Sync", "git fetch failed", err),
OK: false,
// 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
}
```
Do not introduce free-form `fmt.Errorf` chains in framework code.
---
## 8. Keep Testing Names Predictable
## Global Default Pattern
Follow the repository pattern:
- `_Good`
- `_Bad`
- `_Ugly`
Example:
Provide a global default service with atomic access:
```go
func TestRepositorySync_Good(t *testing.T) {}
func TestRepositorySync_Bad(t *testing.T) {}
func TestRepositorySync_Ugly(t *testing.T) {}
// 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
}
```
## 9. Prefer Stable Shapes Over Clever APIs
### Global Convenience Functions
For package APIs, avoid patterns that force an agent to infer too much hidden control flow.
Expose the most common operations at package level:
Prefer:
```go
// ErrServiceNotInitialised is returned when the service is not initialised.
var ErrServiceNotInitialised = errors.New("mypackage: service not initialised")
- clear structs
- explicit names
- path-based commands
- visible message types
// 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)
}
```
Avoid:
---
- implicit global state unless it is truly a default service
- panic-hiding constructors
- dense option chains when a small explicit struct would do
## Options Pattern
## 10. Document the Current Reality
Use functional options for complex configuration:
If the implementation is in transition, document what the code does now, not the API shape you plan to have later.
```go
// Option configures a Service during construction.
type Option func(*Service)
That keeps agents correct on first pass, which is the real AX metric.
// 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
}
```
---
## ACTION Messages (IPC)
For services that need to communicate events, define ACTION message types:
```go
// pkg/mypackage/actions.go
package mypackage
import "time"
// ActionItemCreated is broadcast when an item is created.
type ActionItemCreated struct {
ID string
Name string
CreatedAt time.Time
}
// ActionItemUpdated is broadcast when an item changes.
type ActionItemUpdated struct {
ID string
Changes map[string]any
}
// ActionItemDeleted is broadcast when an item is removed.
type ActionItemDeleted struct {
ID string
}
```
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.

View file

@ -1,81 +1,610 @@
# Package Reference: `core`
# pkg/core -- Dependency Injection & Service Framework
Import path:
`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.
```go
import "dappco.re/go/core"
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
}
```
This repository exposes one root package. The main areas are:
### Creating a Core Instance
## Constructors and Accessors
`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.
| 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 |
```go
c, err := core.New(
core.WithService(mypackage.NewMyService),
core.WithAssets(embeddedFS),
core.WithServiceLock(),
)
```
## Core Primitives
If any option returns an error, `New()` returns `nil` and that error immediately.
| 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 |
### Options
## Service and Runtime APIs
| 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. |
| 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 |
The `Option` type is defined as:
## Command and CLI APIs
```go
type Option func(*Core) error
```
| 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 |
### Service Retrieval
## Messaging APIs
Services are retrieved by name. Two generic helpers provide type-safe access:
| 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 |
```go
// Returns (T, error) -- safe version
svc, err := core.ServiceFor[*MyService](c, "myservice")
## Subsystems
// Panics if not found or wrong type -- use in init paths
svc := core.MustServiceFor[*MyService](c, "myservice")
```
| 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 |
The untyped `Service(name)` method returns `any` (or `nil` if not found).
## Errors and Logging
### Convenience Accessors
| 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 |
`Core` provides shorthand methods for well-known services:
Use the top-level docs in `docs/` for task-oriented guidance, then use this page as a compact reference.
```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 |

View file

@ -1,83 +1,55 @@
# Logging Reference
# Log Retention Policy
Logging lives in the root `core` package in this repository. There is no separate `pkg/log` import path here.
The `log` package provides structured logging with automatic log rotation and retention management.
## Create a Logger
## 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
```go
logger := core.NewLog(core.LogOptions{
Level: core.LevelInfo,
logger := log.New(log.Options{
Level: log.LevelInfo,
Rotation: &log.RotationOptions{
Filename: "app.log",
MaxSize: 100, // MB
MaxBackups: 5,
MaxAge: 28, // days
},
})
logger.Info("application started")
```
## Levels
### Framework Integration
| 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
When using the Core framework, logging is usually configured during application initialization:
```go
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)
app, _ := core.New(
core.WithName("log", log.NewService(log.Options{
Level: log.LevelDebug,
Rotation: &log.RotationOptions{
Filename: "/var/log/my-app.log",
},
})),
)
```
## Default Logger
## How It Works
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()`.
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.

View file

@ -1,176 +0,0 @@
---
title: Core Primitives
description: The repeated shapes that make CoreGO easy to navigate.
---
# Core Primitives
CoreGO is built from a small vocabulary repeated everywhere.
## Primitive Map
| Type | Used For |
|------|----------|
| `Option` / `Options` | Input values and metadata |
| `Result` | Output values and success state |
| `Service` | Lifecycle-managed components |
| `Action` | Named callable with panic recovery + entitlement |
| `Task` | Composed sequence of Actions |
| `Registry[T]` | Thread-safe named collection |
| `Entitlement` | Permission check result |
| `Message` | Broadcast events (ACTION) |
| `Query` | Request-response lookups (QUERY) |
## `Option` and `Options`
`Option` is one key-value pair. `Options` is an ordered slice of them.
```go
opts := core.NewOptions(
core.Option{Key: "name", Value: "brain"},
core.Option{Key: "path", Value: "prompts"},
core.Option{Key: "debug", Value: true},
)
name := opts.String("name")
debug := opts.Bool("debug")
raw := opts.Get("name") // Result{Value, OK}
opts.Has("path") // true
opts.Len() // 3
```
## `Result`
Universal return shape. Every Core operation returns Result.
```go
type Result struct {
Value any
OK bool
}
r := c.Config().Get("host")
if r.OK {
host := r.Value.(string)
}
```
The `Result()` method adapts Go `(value, error)` pairs:
```go
r := core.Result{}.Result(file, err)
```
## `Service`
Managed lifecycle component stored in the `ServiceRegistry`.
```go
core.Service{
OnStart: func() core.Result { return core.Result{OK: true} },
OnStop: func() core.Result { return core.Result{OK: true} },
}
```
Or via `Startable`/`Stoppable` interfaces (preferred for named services):
```go
type Startable interface { OnStartup(ctx context.Context) Result }
type Stoppable interface { OnShutdown(ctx context.Context) Result }
```
## `Action`
Named callable — the atomic unit of work. Registered by name, invoked by name.
```go
type ActionHandler func(context.Context, Options) Result
type Action struct {
Name string
Handler ActionHandler
Description string
Schema Options
}
```
`Action.Run()` includes panic recovery and entitlement checking.
## `Task`
Composed sequence of Actions:
```go
type Task struct {
Name string
Description string
Steps []Step
}
type Step struct {
Action string
With Options
Async bool
Input string // "previous" = output of last step
}
```
## `Registry[T]`
Thread-safe named collection with insertion order and 3 lock modes:
```go
r := core.NewRegistry[*MyService]()
r.Set("brain", svc)
r.Get("brain") // Result
r.Has("brain") // bool
r.Names() // []string (insertion order)
r.Each(func(name string, svc *MyService) { ... })
r.Lock() // fully frozen
r.Seal() // no new keys, updates OK
```
## `Entitlement`
Permission check result:
```go
type Entitlement struct {
Allowed bool
Unlimited bool
Limit int
Used int
Remaining int
Reason string
}
e := c.Entitled("social.accounts", 3)
e.NearLimit(0.8) // true if > 80% used
e.UsagePercent() // 75.0
```
## `Message` and `Query`
IPC type aliases for the anonymous broadcast system:
```go
type Message any // broadcast via ACTION
type Query any // request/response via QUERY
```
For typed, named dispatch use `c.Action("name").Run(ctx, opts)`.
## `ServiceRuntime[T]`
Composition helper for services that need Core access and typed options:
```go
type MyService struct {
*core.ServiceRuntime[MyOptions]
}
runtime := core.NewServiceRuntime(c, MyOptions{BufferSize: 1024})
runtime.Core() // *Core
runtime.Options() // MyOptions
runtime.Config() // shortcut to Core().Config()
```

View file

@ -1,152 +1,215 @@
---
title: Services
description: Register, inspect, and lock CoreGO services.
description: Service registration, retrieval, ServiceRuntime, and factory patterns.
---
# Services
In CoreGO, a service is a named lifecycle entry stored in the Core registry.
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.
## Register a Service
## 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.
```go
c := core.New()
func NewMyService(c *core.Core) (any, error) {
return &MyService{}, nil
}
```
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}
},
### 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,
})
```
Registration succeeds when:
Factories are called in sorted key order. The resulting `Runtime` wraps a `Core` and exposes `ServiceStartup`/`ServiceShutdown` for GUI runtime integration.
- the name is not empty
- the registry is not locked
- the name is not already in use
## Read a Service Back
For the simplest case with no custom services:
```go
r := c.Service("audit")
if r.OK {
svc := r.Value.(*core.Service)
_ = svc
}
rt, err := core.NewRuntime(app)
```
The returned value is `*core.Service`.
## Well-Known Services
## List Registered Services
Core provides convenience methods for commonly needed services. These use `MustServiceFor` internally and will panic if the service is not registered:
```go
names := c.Services()
```
| Method | Expected Name | Expected Interface |
|--------|--------------|-------------------|
| `c.Config()` | `"config"` | `Config` |
| `c.Display()` | `"display"` | `Display` |
| `c.Workspace()` | `"workspace"` | `Workspace` |
| `c.Crypt()` | `"crypt"` | `Crypt` |
### Important Detail
These are optional -- only call them if you have registered the corresponding service.
The current registry is map-backed. `Services()`, `Startables()`, and `Stoppables()` do not promise a stable order.
## Thread Safety
## Lifecycle Snapshots
The service registry is protected by `sync.RWMutex`. Registration, retrieval, and lifecycle operations are safe to call from multiple goroutines.
Use these helpers when you want the current set of startable or stoppable services:
## Related Pages
```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.
- [Lifecycle](lifecycle.md) -- `Startable` and `Stoppable` interfaces
- [Messaging](messaging.md) -- how services communicate
- [Configuration](configuration.md) -- all `With*` options

View file

@ -1,158 +0,0 @@
---
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,116 +1,340 @@
---
title: Testing
description: Test naming and testing patterns used by CoreGO.
description: Test naming conventions, test helpers, and patterns for Core applications.
---
# Testing
The repository uses `github.com/stretchr/testify/assert` and a simple AX-friendly naming pattern.
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.
## Test Names
## Naming Convention
Use:
Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern:
- `_Good` for expected success
- `_Bad` for expected failure
- `_Ugly` for panics, degenerate input, and edge behavior
| 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` |
Examples from this repository:
The format is `Test{Component}_{Method}_{Suffix}`:
```go
func TestNew_Good(t *testing.T) {}
func TestService_Register_Duplicate_Bad(t *testing.T) {}
func TestCore_Must_Ugly(t *testing.T) {}
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")
})
}
```
## Start with a Small Core
## Creating a Test Core
For unit tests, create a minimal Core with only the services needed:
```go
c := core.New(core.Options{
{Key: "name", Value: "test-core"},
})
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)
}
```
Then register only the pieces your test needs.
## Mock Services
## Test a Service
Define mock services as test-local structs. Core's interface-based design makes this straightforward:
```go
started := false
// Mock a Startable service
type MockStartable struct {
started bool
err error
}
c.Service("audit", core.Service{
OnStart: func() core.Result {
started = true
return core.Result{OK: true}
},
})
func (m *MockStartable) OnStartup(ctx context.Context) error {
m.started = true
return m.err
}
r := c.ServiceStartup(context.Background(), nil)
assert.True(t, r.OK)
assert.True(t, started)
// 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
}
```
## Test a Command
For services implementing both lifecycle interfaces:
```go
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)
type MockLifecycle struct {
MockStartable
MockStoppable
}
```
## Test a Query or Task
## Testing Lifecycle
Verify that startup and shutdown are called in the correct order:
```go
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
if q == "ping" {
return core.Result{Value: "pong", OK: true}
}
return core.Result{}
})
func TestLifecycleOrder(t *testing.T) {
c, _ := core.New()
var callOrder []string
assert.Equal(t, "pong", c.QUERY("ping").Value)
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
}
```
## Testing Message Handlers
### Actions
Register an action handler and verify it receives the expected message:
```go
c.Action("compute", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: 42, OK: true}
})
func TestAction(t *testing.T) {
c, _ := core.New()
var received core.Message
r := c.Action("compute").Run(context.Background(), core.NewOptions())
assert.Equal(t, 42, r.Value)
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)
}
```
## Test Async Work
For `PerformAsync`, observe completion through the action bus.
### Queries
```go
completed := make(chan core.ActionTaskCompleted, 1)
func TestQuery(t *testing.T) {
c, _ := core.New()
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if event, ok := msg.(core.ActionTaskCompleted); ok {
completed <- event
}
return core.Result{OK: true}
})
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)
}
```
Then wait with normal Go test tools such as channels, timers, or `assert.Eventually`.
### Tasks
## Use Real Temporary Paths
```go
func TestTask(t *testing.T) {
c, _ := core.New()
When testing `Fs`, `Data.Extract`, or other I/O helpers, use `t.TempDir()` and create realistic paths instead of mocking the filesystem by default.
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
})
## Repository Commands
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:
```bash
core go test
core go test --run TestPerformAsync_Good
go test ./...
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
```
## 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

View file

@ -1,59 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Drive is the resource handle registry for transport connections.
// Packages register their transport handles (API, MCP, SSH, VPN)
// and other packages access them by name.
//
// Register a transport:
//
// c.Drive().New(core.NewOptions(
// core.Option{Key: "name", Value: "api"},
// core.Option{Key: "transport", Value: "https://api.lthn.ai"},
// ))
// c.Drive().New(core.NewOptions(
// core.Option{Key: "name", Value: "ssh"},
// core.Option{Key: "transport", Value: "ssh://claude@10.69.69.165"},
// ))
// c.Drive().New(core.NewOptions(
// core.Option{Key: "name", Value: "mcp"},
// core.Option{Key: "transport", Value: "mcp://mcp.lthn.sh"},
// ))
//
// Retrieve a handle:
//
// api := c.Drive().Get("api")
package core
// DriveHandle holds a named transport resource.
type DriveHandle struct {
Name string
Transport string
Options Options
}
// Drive manages named transport handles. Embeds Registry[*DriveHandle].
type Drive struct {
*Registry[*DriveHandle]
}
// New registers a transport handle.
//
// c.Drive().New(core.NewOptions(
// core.Option{Key: "name", Value: "api"},
// core.Option{Key: "transport", Value: "https://api.lthn.ai"},
// ))
func (d *Drive) New(opts Options) Result {
name := opts.String("name")
if name == "" {
return Result{}
}
handle := &DriveHandle{
Name: name,
Transport: opts.String("transport"),
Options: opts,
}
d.Set(name, handle)
return Result{handle, true}
}

View file

@ -1,35 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleDrive_New() {
c := New()
c.Drive().New(NewOptions(
Option{Key: "name", Value: "forge"},
Option{Key: "transport", Value: "https://forge.lthn.ai"},
))
Println(c.Drive().Has("forge"))
Println(c.Drive().Names())
// Output:
// true
// [forge]
}
func ExampleDrive_Get() {
c := New()
c.Drive().New(NewOptions(
Option{Key: "name", Value: "charon"},
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
))
r := c.Drive().Get("charon")
if r.OK {
h := r.Value.(*DriveHandle)
Println(h.Transport)
}
// Output: http://10.69.69.165:9101
}

View file

@ -1,80 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Drive (Transport Handles) ---
func TestDrive_New_Good(t *testing.T) {
c := New()
r := c.Drive().New(NewOptions(
Option{Key: "name", Value: "api"},
Option{Key: "transport", Value: "https://api.lthn.ai"},
))
assert.True(t, r.OK)
assert.Equal(t, "api", r.Value.(*DriveHandle).Name)
assert.Equal(t, "https://api.lthn.ai", r.Value.(*DriveHandle).Transport)
}
func TestDrive_New_Bad(t *testing.T) {
c := New()
// Missing name
r := c.Drive().New(NewOptions(
Option{Key: "transport", Value: "https://api.lthn.ai"},
))
assert.False(t, r.OK)
}
func TestDrive_Get_Good(t *testing.T) {
c := New()
c.Drive().New(NewOptions(
Option{Key: "name", Value: "ssh"},
Option{Key: "transport", Value: "ssh://claude@10.69.69.165"},
))
r := c.Drive().Get("ssh")
assert.True(t, r.OK)
handle := r.Value.(*DriveHandle)
assert.Equal(t, "ssh://claude@10.69.69.165", handle.Transport)
}
func TestDrive_Get_Bad(t *testing.T) {
c := New()
r := c.Drive().Get("nonexistent")
assert.False(t, r.OK)
}
func TestDrive_Has_Good(t *testing.T) {
c := New()
c.Drive().New(NewOptions(Option{Key: "name", Value: "mcp"}, Option{Key: "transport", Value: "mcp://mcp.lthn.sh"}))
assert.True(t, c.Drive().Has("mcp"))
assert.False(t, c.Drive().Has("missing"))
}
func TestDrive_Names_Good(t *testing.T) {
c := New()
c.Drive().New(NewOptions(Option{Key: "name", Value: "api"}, Option{Key: "transport", Value: "https://api.lthn.ai"}))
c.Drive().New(NewOptions(Option{Key: "name", Value: "ssh"}, Option{Key: "transport", Value: "ssh://claude@10.69.69.165"}))
c.Drive().New(NewOptions(Option{Key: "name", Value: "mcp"}, Option{Key: "transport", Value: "mcp://mcp.lthn.sh"}))
names := c.Drive().Names()
assert.Len(t, names, 3)
assert.Contains(t, names, "api")
assert.Contains(t, names, "ssh")
assert.Contains(t, names, "mcp")
}
func TestDrive_OptionsPreserved_Good(t *testing.T) {
c := New()
c.Drive().New(NewOptions(
Option{Key: "name", Value: "api"},
Option{Key: "transport", Value: "https://api.lthn.ai"},
Option{Key: "timeout", Value: 30},
))
r := c.Drive().Get("api")
assert.True(t, r.OK)
handle := r.Value.(*DriveHandle)
assert.Equal(t, 30, handle.Options.Int("timeout"))
}

668
embed.go
View file

@ -1,668 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Embedded assets for the Core framework.
//
// Embed provides scoped filesystem access for go:embed and any fs.FS.
// Also includes build-time asset packing (AST scanner + compressor)
// and template-based directory extraction.
//
// Usage (mount):
//
// sub, _ := core.Mount(myFS, "lib/persona")
// content, _ := sub.ReadString("secops/developer.md")
//
// Usage (extract):
//
// core.Extract(fsys, "/tmp/workspace", data)
//
// Usage (pack):
//
// refs, _ := core.ScanAssets([]string{"main.go"})
// source, _ := core.GeneratePack(refs)
package core
import (
"bytes"
"compress/gzip"
"embed"
"encoding/base64"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
"text/template"
)
// --- Runtime: Asset Registry ---
// AssetGroup holds a named collection of packed assets.
type AssetGroup struct {
assets map[string]string // name → compressed data
}
var (
assetGroups = make(map[string]*AssetGroup)
assetGroupsMu sync.RWMutex
)
// AddAsset registers a packed asset at runtime (called from generated init()).
func AddAsset(group, name, data string) {
assetGroupsMu.Lock()
defer assetGroupsMu.Unlock()
g, ok := assetGroups[group]
if !ok {
g = &AssetGroup{assets: make(map[string]string)}
assetGroups[group] = g
}
g.assets[name] = data
}
// GetAsset retrieves and decompresses a packed asset.
//
// r := core.GetAsset("mygroup", "greeting")
// if r.OK { content := r.Value.(string) }
func GetAsset(group, name string) Result {
assetGroupsMu.RLock()
g, ok := assetGroups[group]
if !ok {
assetGroupsMu.RUnlock()
return Result{}
}
data, ok := g.assets[name]
assetGroupsMu.RUnlock()
if !ok {
return Result{}
}
s, err := decompress(data)
if err != nil {
return Result{err, false}
}
return Result{s, true}
}
// GetAssetBytes retrieves a packed asset as bytes.
//
// r := core.GetAssetBytes("mygroup", "file")
// if r.OK { data := r.Value.([]byte) }
func GetAssetBytes(group, name string) Result {
r := GetAsset(group, name)
if !r.OK {
return r
}
return Result{[]byte(r.Value.(string)), true}
}
// --- Build-time: AST Scanner ---
// AssetRef is a reference to an asset found in source code.
type AssetRef struct {
Name string
Path string
Group string
FullPath string
}
// ScannedPackage holds all asset references from a set of source files.
type ScannedPackage struct {
PackageName string
BaseDirectory string
Groups []string
Assets []AssetRef
}
// ScanAssets parses Go source files and finds asset references.
// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc.
func ScanAssets(filenames []string) Result {
packageMap := make(map[string]*ScannedPackage)
var scanErr error
for _, filename := range filenames {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
if err != nil {
return Result{err, false}
}
baseDir := filepath.Dir(filename)
pkg, ok := packageMap[baseDir]
if !ok {
pkg = &ScannedPackage{BaseDirectory: baseDir}
packageMap[baseDir] = pkg
}
pkg.PackageName = node.Name.Name
ast.Inspect(node, func(n ast.Node) bool {
if scanErr != nil {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
// Look for core.GetAsset or mewn.String patterns
if ident.Name == "core" || ident.Name == "mewn" {
switch sel.Sel.Name {
case "GetAsset", "GetAssetBytes", "String", "MustString", "Bytes", "MustBytes":
if len(call.Args) >= 1 {
if lit, ok := call.Args[len(call.Args)-1].(*ast.BasicLit); ok {
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
group := "."
if len(call.Args) >= 2 {
if glit, ok := call.Args[0].(*ast.BasicLit); ok {
group = TrimPrefix(TrimSuffix(glit.Value, "\""), "\"")
}
}
fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for asset", path, "in group", group))
return false
}
pkg.Assets = append(pkg.Assets, AssetRef{
Name: path,
Group: group,
FullPath: fullPath,
})
}
}
case "Group":
// Variable assignment: g := core.Group("./assets")
if len(call.Args) == 1 {
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
fullPath, err := filepath.Abs(filepath.Join(baseDir, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for group", path))
return false
}
pkg.Groups = append(pkg.Groups, fullPath)
// Track for variable resolution
}
}
}
}
return true
})
if scanErr != nil {
return Result{scanErr, false}
}
}
var result []ScannedPackage
for _, pkg := range packageMap {
result = append(result, *pkg)
}
return Result{result, true}
}
// GeneratePack creates Go source code that embeds the scanned assets.
func GeneratePack(pkg ScannedPackage) Result {
b := NewBuilder()
b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName))
b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n")
if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 {
return Result{b.String(), true}
}
b.WriteString("import \"dappco.re/go/core\"\n\n")
b.WriteString("func init() {\n")
// Pack groups (entire directories)
packed := make(map[string]bool)
for _, groupPath := range pkg.Groups {
files, err := getAllFiles(groupPath)
if err != nil {
return Result{err, false}
}
for _, file := range files {
if packed[file] {
continue
}
data, err := compressFile(file)
if err != nil {
return Result{err, false}
}
localPath := TrimPrefix(file, groupPath+"/")
relGroup, err := filepath.Rel(pkg.BaseDirectory, groupPath)
if err != nil {
return Result{err, false}
}
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data))
packed[file] = true
}
}
// Pack individual assets
for _, asset := range pkg.Assets {
if packed[asset.FullPath] {
continue
}
data, err := compressFile(asset.FullPath)
if err != nil {
return Result{err, false}
}
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data))
packed[asset.FullPath] = true
}
b.WriteString("}\n")
return Result{b.String(), true}
}
// --- Compression ---
func compressFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return compress(string(data))
}
func compress(input string) (string, error) {
var buf bytes.Buffer
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
gz, err := gzip.NewWriterLevel(b64, gzip.BestCompression)
if err != nil {
return "", err
}
if _, err := gz.Write([]byte(input)); err != nil {
_ = gz.Close()
_ = b64.Close()
return "", err
}
if err := gz.Close(); err != nil {
_ = b64.Close()
return "", err
}
if err := b64.Close(); err != nil {
return "", err
}
return buf.String(), nil
}
func decompress(input string) (string, error) {
b64 := base64.NewDecoder(base64.StdEncoding, NewReader(input))
gz, err := gzip.NewReader(b64)
if err != nil {
return "", err
}
data, err := io.ReadAll(gz)
if err != nil {
return "", err
}
if err := gz.Close(); err != nil {
return "", err
}
return string(data), nil
}
func getAllFiles(dir string) ([]string, error) {
var result []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
result = append(result, path)
}
return nil
})
return result, err
}
// --- Embed: Scoped Filesystem Mount ---
// Embed wraps an fs.FS with a basedir for scoped access.
// All paths are relative to basedir.
type Embed struct {
basedir string
fsys fs.FS
embedFS *embed.FS // original embed.FS for type-safe access via EmbedFS()
}
// Mount creates a scoped view of an fs.FS anchored at basedir.
//
// r := core.Mount(myFS, "lib/prompts")
// if r.OK { emb := r.Value.(*Embed) }
func Mount(fsys fs.FS, basedir string) Result {
s := &Embed{fsys: fsys, basedir: basedir}
if efs, ok := fsys.(embed.FS); ok {
s.embedFS = &efs
}
if r := s.ReadDir("."); !r.OK {
return r
}
return Result{s, true}
}
// MountEmbed creates a scoped view of an embed.FS.
//
// r := core.MountEmbed(myFS, "testdata")
func MountEmbed(efs embed.FS, basedir string) Result {
return Mount(efs, basedir)
}
func (s *Embed) path(name string) Result {
joined := filepath.ToSlash(filepath.Join(s.basedir, name))
if HasPrefix(joined, "..") || Contains(joined, "/../") || HasSuffix(joined, "/..") {
return Result{E("embed.path", Concat("path traversal rejected: ", name), nil), false}
}
return Result{joined, true}
}
// Open opens the named file for reading.
//
// r := emb.Open("test.txt")
// if r.OK { file := r.Value.(fs.File) }
func (s *Embed) Open(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
f, err := s.fsys.Open(r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{f, true}
}
// ReadDir reads the named directory.
func (s *Embed) ReadDir(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
return Result{}.New(fs.ReadDir(s.fsys, r.Value.(string)))
}
// ReadFile reads the named file.
//
// r := emb.ReadFile("test.txt")
// if r.OK { data := r.Value.([]byte) }
func (s *Embed) ReadFile(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
data, err := fs.ReadFile(s.fsys, r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{data, true}
}
// ReadString reads the named file as a string.
//
// r := emb.ReadString("test.txt")
// if r.OK { content := r.Value.(string) }
func (s *Embed) ReadString(name string) Result {
r := s.ReadFile(name)
if !r.OK {
return r
}
return Result{string(r.Value.([]byte)), true}
}
// Sub returns a new Embed anchored at a subdirectory within this mount.
//
// r := emb.Sub("testdata")
// if r.OK { sub := r.Value.(*Embed) }
func (s *Embed) Sub(subDir string) Result {
r := s.path(subDir)
if !r.OK {
return r
}
sub, err := fs.Sub(s.fsys, r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{&Embed{fsys: sub, basedir: "."}, true}
}
// FS returns the underlying fs.FS.
func (s *Embed) FS() fs.FS {
return s.fsys
}
// EmbedFS returns the underlying embed.FS if mounted from one.
// Returns zero embed.FS if mounted from a non-embed source.
func (s *Embed) EmbedFS() embed.FS {
if s.embedFS != nil {
return *s.embedFS
}
return embed.FS{}
}
// BaseDirectory returns the base directory this Embed is anchored at.
func (s *Embed) BaseDirectory() string {
return s.basedir
}
// --- Template Extraction ---
// ExtractOptions configures template extraction.
type ExtractOptions struct {
// TemplateFilters identifies template files by substring match.
// Default: [".tmpl"]
TemplateFilters []string
// IgnoreFiles is a set of filenames to skip during extraction.
IgnoreFiles map[string]struct{}
// RenameFiles maps original filenames to new names.
RenameFiles map[string]string
}
// Extract copies a template directory from an fs.FS to targetDir,
// processing Go text/template in filenames and file contents.
//
// Files containing a template filter substring (default: ".tmpl") have
// their contents processed through text/template with the given data.
// The filter is stripped from the output filename.
//
// Directory and file names can contain Go template expressions:
// {{.Name}}/main.go → myproject/main.go
//
// Data can be any struct or map[string]string for template substitution.
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) Result {
opt := ExtractOptions{
TemplateFilters: []string{".tmpl"},
IgnoreFiles: make(map[string]struct{}),
RenameFiles: make(map[string]string),
}
if len(opts) > 0 {
if len(opts[0].TemplateFilters) > 0 {
opt.TemplateFilters = opts[0].TemplateFilters
}
if opts[0].IgnoreFiles != nil {
opt.IgnoreFiles = opts[0].IgnoreFiles
}
if opts[0].RenameFiles != nil {
opt.RenameFiles = opts[0].RenameFiles
}
}
// Ensure target directory exists
targetDir, err := filepath.Abs(targetDir)
if err != nil {
return Result{err, false}
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return Result{err, false}
}
// Categorise files
var dirs []string
var templateFiles []string
var standardFiles []string
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == "." {
return nil
}
if d.IsDir() {
dirs = append(dirs, path)
return nil
}
filename := filepath.Base(path)
if _, ignored := opt.IgnoreFiles[filename]; ignored {
return nil
}
if isTemplate(filename, opt.TemplateFilters) {
templateFiles = append(templateFiles, path)
} else {
standardFiles = append(standardFiles, path)
}
return nil
})
if err != nil {
return Result{err, false}
}
// safePath ensures a rendered path stays under targetDir.
safePath := func(rendered string) (string, error) {
abs, err := filepath.Abs(rendered)
if err != nil {
return "", err
}
if !HasPrefix(abs, targetDir+string(filepath.Separator)) && abs != targetDir {
return "", E("embed.Extract", Concat("path escapes target: ", abs), nil)
}
return abs, nil
}
// Create directories (names may contain templates)
for _, dir := range dirs {
target, err := safePath(renderPath(filepath.Join(targetDir, dir), data))
if err != nil {
return Result{err, false}
}
if err := os.MkdirAll(target, 0755); err != nil {
return Result{err, false}
}
}
// Process template files
for _, path := range templateFiles {
tmpl, err := template.ParseFS(fsys, path)
if err != nil {
return Result{err, false}
}
targetFile := renderPath(filepath.Join(targetDir, path), data)
// Strip template filters from filename
dir := filepath.Dir(targetFile)
name := filepath.Base(targetFile)
for _, filter := range opt.TemplateFilters {
name = Replace(name, filter, "")
}
if renamed := opt.RenameFiles[name]; renamed != "" {
name = renamed
}
targetFile, err = safePath(filepath.Join(dir, name))
if err != nil {
return Result{err, false}
}
f, err := os.Create(targetFile)
if err != nil {
return Result{err, false}
}
if err := tmpl.Execute(f, data); err != nil {
f.Close()
return Result{err, false}
}
f.Close()
}
// Copy standard files
for _, path := range standardFiles {
targetPath := path
name := filepath.Base(path)
if renamed := opt.RenameFiles[name]; renamed != "" {
targetPath = filepath.Join(filepath.Dir(path), renamed)
}
target, err := safePath(renderPath(filepath.Join(targetDir, targetPath), data))
if err != nil {
return Result{err, false}
}
if err := copyFile(fsys, path, target); err != nil {
return Result{err, false}
}
}
return Result{OK: true}
}
func isTemplate(filename string, filters []string) bool {
for _, f := range filters {
if Contains(filename, f) {
return true
}
}
return false
}
func renderPath(path string, data any) string {
if data == nil {
return path
}
tmpl, err := template.New("path").Parse(path)
if err != nil {
return path
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return path
}
return buf.String()
}
func copyFile(fsys fs.FS, source, target string) error {
s, err := fsys.Open(source)
if err != nil {
return err
}
defer s.Close()
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
d, err := os.Create(target)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}

View file

@ -1,265 +0,0 @@
package core_test
import (
"bytes"
"compress/gzip"
"encoding/base64"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Mount ---
func mustMountTestFS(t *testing.T, basedir string) *Embed {
t.Helper()
r := Mount(testFS, basedir)
assert.True(t, r.OK)
return r.Value.(*Embed)
}
func TestEmbed_Mount_Good(t *testing.T) {
r := Mount(testFS, "testdata")
assert.True(t, r.OK)
}
func TestEmbed_Mount_Bad(t *testing.T) {
r := Mount(testFS, "nonexistent")
assert.False(t, r.OK)
}
// --- Embed methods ---
func TestEmbed_ReadFile_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.ReadFile("test.txt")
assert.True(t, r.OK)
assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte)))
}
func TestEmbed_ReadString_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.ReadString("test.txt")
assert.True(t, r.OK)
assert.Equal(t, "hello from testdata\n", r.Value.(string))
}
func TestEmbed_Open_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.Open("test.txt")
assert.True(t, r.OK)
}
func TestEmbed_ReadDir_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.ReadDir(".")
assert.True(t, r.OK)
assert.NotEmpty(t, r.Value)
}
func TestEmbed_Sub_Good(t *testing.T) {
emb := mustMountTestFS(t, ".")
r := emb.Sub("testdata")
assert.True(t, r.OK)
sub := r.Value.(*Embed)
r2 := sub.ReadFile("test.txt")
assert.True(t, r2.OK)
}
func TestEmbed_BaseDir_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
assert.Equal(t, "testdata", emb.BaseDirectory())
}
func TestEmbed_FS_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
assert.NotNil(t, emb.FS())
}
func TestEmbed_EmbedFS_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
efs := emb.EmbedFS()
_, err := efs.ReadFile("testdata/test.txt")
assert.NoError(t, err)
}
// --- Extract ---
func TestEmbed_Extract_Good(t *testing.T) {
dir := t.TempDir()
r := Extract(testFS, dir, nil)
assert.True(t, r.OK)
cr := (&Fs{}).New("/").Read(Path(dir, "testdata/test.txt"))
assert.True(t, cr.OK)
assert.Equal(t, "hello from testdata\n", cr.Value)
}
// --- Asset Pack ---
func TestEmbed_AddGetAsset_Good(t *testing.T) {
AddAsset("test-group", "greeting", mustCompress("hello world"))
r := GetAsset("test-group", "greeting")
assert.True(t, r.OK)
assert.Equal(t, "hello world", r.Value.(string))
}
func TestEmbed_GetAsset_Bad(t *testing.T) {
r := GetAsset("missing-group", "missing")
assert.False(t, r.OK)
}
func TestEmbed_GetAssetBytes_Good(t *testing.T) {
AddAsset("bytes-group", "file", mustCompress("binary content"))
r := GetAssetBytes("bytes-group", "file")
assert.True(t, r.OK)
assert.Equal(t, []byte("binary content"), r.Value.([]byte))
}
func TestEmbed_MountEmbed_Good(t *testing.T) {
r := MountEmbed(testFS, "testdata")
assert.True(t, r.OK)
}
// --- ScanAssets ---
func TestEmbed_ScanAssets_Good(t *testing.T) {
r := ScanAssets([]string{"testdata/scantest/sample.go"})
assert.True(t, r.OK)
pkgs := r.Value.([]ScannedPackage)
assert.Len(t, pkgs, 1)
assert.Equal(t, "scantest", pkgs[0].PackageName)
}
func TestEmbed_ScanAssets_Bad(t *testing.T) {
r := ScanAssets([]string{"nonexistent.go"})
assert.False(t, r.OK)
}
func TestEmbed_GeneratePack_Empty_Good(t *testing.T) {
pkg := ScannedPackage{PackageName: "empty"}
r := GeneratePack(pkg)
assert.True(t, r.OK)
assert.Contains(t, r.Value.(string), "package empty")
}
func TestEmbed_GeneratePack_WithFiles_Good(t *testing.T) {
dir := t.TempDir()
assetDir := Path(dir, "mygroup")
(&Fs{}).New("/").EnsureDir(assetDir)
(&Fs{}).New("/").Write(Path(assetDir, "hello.txt"), "hello world")
source := "package test\nimport \"dappco.re/go/core\"\nfunc example() {\n\t_, _ = core.GetAsset(\"mygroup\", \"hello.txt\")\n}\n"
goFile := Path(dir, "test.go")
(&Fs{}).New("/").Write(goFile, source)
sr := ScanAssets([]string{goFile})
assert.True(t, sr.OK)
pkgs := sr.Value.([]ScannedPackage)
r := GeneratePack(pkgs[0])
assert.True(t, r.OK)
assert.Contains(t, r.Value.(string), "core.AddAsset")
}
// --- Extract (template + nested) ---
func TestEmbed_Extract_WithTemplate_Good(t *testing.T) {
dir := t.TempDir()
// Create an in-memory FS with a template file and a plain file
tmplDir := DirFS(t.TempDir())
// Use a real temp dir with files
srcDir := t.TempDir()
(&Fs{}).New("/").Write(Path(srcDir, "plain.txt"), "static content")
(&Fs{}).New("/").Write(Path(srcDir, "greeting.tmpl"), "Hello {{.Name}}!")
(&Fs{}).New("/").EnsureDir(Path(srcDir, "sub"))
(&Fs{}).New("/").Write(Path(srcDir, "sub/nested.txt"), "nested")
_ = tmplDir
fsys := DirFS(srcDir)
data := map[string]string{"Name": "World"}
r := Extract(fsys, dir, data)
assert.True(t, r.OK)
f := (&Fs{}).New("/")
// Plain file copied
cr := f.Read(Path(dir, "plain.txt"))
assert.True(t, cr.OK)
assert.Equal(t, "static content", cr.Value)
// Template processed and .tmpl stripped
gr := f.Read(Path(dir, "greeting"))
assert.True(t, gr.OK)
assert.Equal(t, "Hello World!", gr.Value)
// Nested directory preserved
nr := f.Read(Path(dir, "sub/nested.txt"))
assert.True(t, nr.OK)
assert.Equal(t, "nested", nr.Value)
}
func TestEmbed_Extract_BadTargetDir_Ugly(t *testing.T) {
srcDir := t.TempDir()
(&Fs{}).New("/").Write(Path(srcDir, "f.txt"), "x")
r := Extract(DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil)
// Should fail gracefully, not panic
_ = r
}
func TestEmbed_PathTraversal_Ugly(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.ReadFile("../../etc/passwd")
assert.False(t, r.OK)
}
func TestEmbed_Sub_BaseDir_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.Sub("scantest")
assert.True(t, r.OK)
sub := r.Value.(*Embed)
assert.Equal(t, ".", sub.BaseDirectory())
}
func TestEmbed_Open_Bad(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.Open("nonexistent.txt")
assert.False(t, r.OK)
}
func TestEmbed_ReadDir_Bad(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
r := emb.ReadDir("nonexistent")
assert.False(t, r.OK)
}
func TestEmbed_EmbedFS_Original_Good(t *testing.T) {
emb := mustMountTestFS(t, "testdata")
efs := emb.EmbedFS()
_, err := efs.ReadFile("testdata/test.txt")
assert.NoError(t, err)
}
func TestEmbed_Extract_NilData_Good(t *testing.T) {
dir := t.TempDir()
srcDir := t.TempDir()
(&Fs{}).New("/").Write(Path(srcDir, "file.txt"), "no template")
r := Extract(DirFS(srcDir), dir, nil)
assert.True(t, r.OK)
}
func mustCompress(input string) string {
var buf bytes.Buffer
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
gz, _ := gzip.NewWriterLevel(b64, gzip.BestCompression)
gz.Write([]byte(input))
gz.Close()
b64.Close()
return buf.String()
}

View file

@ -1,130 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Permission primitive for the Core framework.
// Entitlement answers "can [subject] do [action] with [quantity]?"
// Default: everything permitted (trusted conclave).
// With go-entitlements: checks workspace packages, features, usage, boosts.
// With commerce-matrix: checks entity hierarchy, lock cascade.
//
// Usage:
//
// e := c.Entitled("process.run") // boolean gate
// e := c.Entitled("social.accounts", 3) // quantity check
// if e.Allowed { proceed() }
// if e.NearLimit(0.8) { showUpgradePrompt() }
//
// Registration:
//
// c.SetEntitlementChecker(myChecker)
// c.SetUsageRecorder(myRecorder)
package core
import "context"
// Entitlement is the result of a permission check.
// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining).
//
// e := c.Entitled("social.accounts", 3)
// e.Allowed // true
// e.Limit // 5
// e.Used // 2
// e.Remaining // 3
// e.NearLimit(0.8) // false
type Entitlement struct {
Allowed bool // permission granted
Unlimited bool // no cap (agency tier, admin, trusted conclave)
Limit int // total allowed (0 = boolean gate)
Used int // current consumption
Remaining int // Limit - Used
Reason string // denial reason — for UI and audit logging
}
// NearLimit returns true if usage exceeds the threshold percentage.
//
// if e.NearLimit(0.8) { showUpgradePrompt() }
func (e Entitlement) NearLimit(threshold float64) bool {
if e.Unlimited || e.Limit == 0 {
return false
}
return float64(e.Used)/float64(e.Limit) >= threshold
}
// UsagePercent returns current usage as a percentage of the limit.
//
// pct := e.UsagePercent() // 75.0
func (e Entitlement) UsagePercent() float64 {
if e.Limit == 0 {
return 0
}
return float64(e.Used) / float64(e.Limit) * 100
}
// EntitlementChecker answers "can [subject] do [action] with [quantity]?"
// Subject comes from context (workspace, entity, user — consumer's concern).
type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement
// UsageRecorder records consumption after a gated action succeeds.
// Consumer packages provide the implementation (database, cache, etc).
type UsageRecorder func(action string, quantity int, ctx context.Context)
// defaultChecker — trusted conclave, everything permitted.
func defaultChecker(_ string, _ int, _ context.Context) Entitlement {
return Entitlement{Allowed: true, Unlimited: true}
}
// Entitled checks if an action is permitted in the current context.
// Default: always returns Allowed=true, Unlimited=true.
// Denials are logged via core.Security().
//
// e := c.Entitled("process.run")
// e := c.Entitled("social.accounts", 3)
func (c *Core) Entitled(action string, quantity ...int) Entitlement {
qty := 1
if len(quantity) > 0 {
qty = quantity[0]
}
e := c.entitlementChecker(action, qty, c.Context())
if !e.Allowed {
Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason)
}
return e
}
// SetEntitlementChecker replaces the default (permissive) checker.
// Called by go-entitlements or commerce-matrix during OnStartup.
//
// func (s *EntitlementService) OnStartup(ctx context.Context) core.Result {
// s.Core().SetEntitlementChecker(s.check)
// return core.Result{OK: true}
// }
func (c *Core) SetEntitlementChecker(checker EntitlementChecker) {
c.entitlementChecker = checker
}
// RecordUsage records consumption after a gated action succeeds.
// Delegates to the registered UsageRecorder. No-op if none registered.
//
// e := c.Entitled("ai.credits", 10)
// if e.Allowed {
// doWork()
// c.RecordUsage("ai.credits", 10)
// }
func (c *Core) RecordUsage(action string, quantity ...int) {
if c.usageRecorder == nil {
return
}
qty := 1
if len(quantity) > 0 {
qty = quantity[0]
}
c.usageRecorder(action, qty, c.Context())
}
// SetUsageRecorder registers a usage tracking function.
// Called by go-entitlements during OnStartup.
func (c *Core) SetUsageRecorder(recorder UsageRecorder) {
c.usageRecorder = recorder
}

View file

@ -1,52 +0,0 @@
package core_test
import (
"context"
. "dappco.re/go/core"
)
func ExampleEntitlement_UsagePercent() {
e := Entitlement{Limit: 100, Used: 75}
Println(e.UsagePercent())
// Output: 75
}
func ExampleCore_SetEntitlementChecker() {
c := New()
c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement {
limits := map[string]int{"social.accounts": 5, "ai.credits": 100}
usage := map[string]int{"social.accounts": 3, "ai.credits": 95}
limit, ok := limits[action]
if !ok {
return Entitlement{Allowed: false, Reason: "not in package"}
}
used := usage[action]
remaining := limit - used
if qty > remaining {
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
}
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
})
Println(c.Entitled("social.accounts", 2).Allowed)
Println(c.Entitled("social.accounts", 5).Allowed)
Println(c.Entitled("ai.credits").NearLimit(0.9))
// Output:
// true
// false
// true
}
func ExampleCore_RecordUsage() {
c := New()
var recorded string
c.SetUsageRecorder(func(action string, qty int, _ context.Context) {
recorded = Concat(action, ":", Sprint(qty))
})
c.RecordUsage("ai.credits", 10)
Println(recorded)
// Output: ai.credits:10
}

View file

@ -1,235 +0,0 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Entitled ---
func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) {
c := New()
e := c.Entitled("anything")
assert.True(t, e.Allowed, "default checker permits everything")
assert.True(t, e.Unlimited)
}
func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "premium.feature" {
return Entitlement{Allowed: true}
}
return Entitlement{Allowed: false, Reason: "not in package"}
})
assert.True(t, c.Entitled("premium.feature").Allowed)
assert.False(t, c.Entitled("other.feature").Allowed)
assert.Equal(t, "not in package", c.Entitled("other.feature").Reason)
}
func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "social.accounts" {
limit := 5
used := 3
remaining := limit - used
if qty > remaining {
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
}
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
}
return Entitlement{Allowed: true, Unlimited: true}
})
// Can create 2 more (3 used of 5)
e := c.Entitled("social.accounts", 2)
assert.True(t, e.Allowed)
assert.Equal(t, 5, e.Limit)
assert.Equal(t, 3, e.Used)
assert.Equal(t, 2, e.Remaining)
// Can't create 3 more
e = c.Entitled("social.accounts", 3)
assert.False(t, e.Allowed)
assert.Equal(t, "limit exceeded", e.Reason)
}
func TestEntitlement_Entitled_Bad_Denied(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
return Entitlement{Allowed: false, Reason: "locked by M1"}
})
e := c.Entitled("product.create")
assert.False(t, e.Allowed)
assert.Equal(t, "locked by M1", e.Reason)
}
func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) {
c := New()
var receivedQty int
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
receivedQty = qty
return Entitlement{Allowed: true}
})
c.Entitled("test")
assert.Equal(t, 1, receivedQty, "default quantity should be 1")
}
// --- Action.Run Entitlement Enforcement ---
func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) {
c := New()
c.Action("work", func(_ context.Context, _ Options) Result {
return Result{Value: "done", OK: true}
})
r := c.Action("work").Run(context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "done", r.Value)
}
func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) {
c := New()
c.Action("restricted", func(_ context.Context, _ Options) Result {
return Result{Value: "should not reach", OK: true}
})
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "restricted" {
return Entitlement{Allowed: false, Reason: "tier too low"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
r := c.Action("restricted").Run(context.Background(), NewOptions())
assert.False(t, r.OK, "denied action must not execute")
err, ok := r.Value.(error)
assert.True(t, ok)
assert.Contains(t, err.Error(), "not entitled")
assert.Contains(t, err.Error(), "tier too low")
}
func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) {
c := New()
c.Action("allowed", func(_ context.Context, _ Options) Result {
return Result{Value: "ok", OK: true}
})
c.Action("blocked", func(_ context.Context, _ Options) Result {
return Result{Value: "nope", OK: true}
})
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "blocked" {
return Entitlement{Allowed: false, Reason: "nope"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK)
assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK)
}
// --- NearLimit ---
func TestEntitlement_NearLimit_Good(t *testing.T) {
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
assert.True(t, e.NearLimit(0.8))
assert.False(t, e.NearLimit(0.9))
}
func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) {
e := Entitlement{Allowed: true, Unlimited: true}
assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit")
}
func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) {
e := Entitlement{Allowed: true, Limit: 0}
assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit")
}
// --- UsagePercent ---
func TestEntitlement_UsagePercent_Good(t *testing.T) {
e := Entitlement{Limit: 100, Used: 75}
assert.Equal(t, 75.0, e.UsagePercent())
}
func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) {
e := Entitlement{Limit: 0, Used: 5}
assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage")
}
// --- RecordUsage ---
func TestEntitlement_RecordUsage_Good(t *testing.T) {
c := New()
var recorded string
var recordedQty int
c.SetUsageRecorder(func(action string, qty int, ctx context.Context) {
recorded = action
recordedQty = qty
})
c.RecordUsage("ai.credits", 10)
assert.Equal(t, "ai.credits", recorded)
assert.Equal(t, 10, recordedQty)
}
func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) {
c := New()
// No recorder set — should not panic
assert.NotPanics(t, func() {
c.RecordUsage("anything", 5)
})
}
// --- Permission Model Integration ---
func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) {
c := New()
// Simulate RFC-004 entitlement service
packages := map[string]int{
"social.accounts": 5,
"social.posts.scheduled": 100,
"ai.credits": 50,
}
usage := map[string]int{
"social.accounts": 3,
"social.posts.scheduled": 45,
"ai.credits": 48,
}
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
limit, hasFeature := packages[action]
if !hasFeature {
return Entitlement{Allowed: false, Reason: "feature not in package"}
}
used := usage[action]
remaining := limit - used
if qty > remaining {
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
}
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
})
// Can create 2 social accounts
e := c.Entitled("social.accounts", 2)
assert.True(t, e.Allowed)
// AI credits near limit
e = c.Entitled("ai.credits", 1)
assert.True(t, e.Allowed)
assert.True(t, e.NearLimit(0.8))
assert.Equal(t, 96.0, e.UsagePercent())
// Feature not in package
e = c.Entitled("premium.feature")
assert.False(t, e.Allowed)
}

395
error.go
View file

@ -1,395 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Structured errors, crash recovery, and reporting for the Core framework.
// Provides E() for error creation, Wrap()/WrapCode() for chaining,
// and Err for panic recovery and crash reporting.
package core
import (
"encoding/json"
"errors"
"iter"
"maps"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"time"
)
// ErrorSink is the shared interface for error reporting.
// Implemented by ErrorLog (structured logging) and ErrorPanic (panic recovery).
type ErrorSink interface {
Error(msg string, keyvals ...any)
Warn(msg string, keyvals ...any)
}
var _ ErrorSink = (*Log)(nil)
// Err represents a structured error with operational context.
// It implements the error interface and supports unwrapping.
type Err struct {
Operation string // Operation being performed (e.g., "user.Save")
Message string // Human-readable message
Cause error // Underlying error (optional)
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
}
// Error implements the error interface.
func (e *Err) Error() string {
var prefix string
if e.Operation != "" {
prefix = e.Operation + ": "
}
if e.Cause != nil {
if e.Code != "" {
return Concat(prefix, e.Message, " [", e.Code, "]: ", e.Cause.Error())
}
return Concat(prefix, e.Message, ": ", e.Cause.Error())
}
if e.Code != "" {
return Concat(prefix, e.Message, " [", e.Code, "]")
}
return Concat(prefix, e.Message)
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *Err) Unwrap() error {
return e.Cause
}
// --- Error Creation Functions ---
// E creates a new Err with operation context.
// The underlying error can be nil for creating errors without a cause.
//
// Example:
//
// return log.E("user.Save", "failed to save user", err)
// return log.E("api.Call", "rate limited", nil) // No underlying cause
func E(op, msg string, err error) error {
return &Err{Operation: op, Message: msg, Cause: err}
}
// Wrap wraps an error with operation context.
// Returns nil if err is nil, to support conditional wrapping.
// Preserves error Code if the wrapped error is an *Err.
//
// Example:
//
// return log.Wrap(err, "db.Query", "database query failed")
func Wrap(err error, op, msg string) error {
if err == nil {
return nil
}
// Preserve Code from wrapped *Err
var logErr *Err
if As(err, &logErr) && logErr.Code != "" {
return &Err{Operation: op, Message: msg, Cause: err, Code: logErr.Code}
}
return &Err{Operation: op, Message: msg, Cause: err}
}
// WrapCode wraps an error with operation context and error code.
// Returns nil only if both err is nil AND code is empty.
// Useful for API errors that need machine-readable codes.
//
// Example:
//
// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email")
func WrapCode(err error, code, op, msg string) error {
if err == nil && code == "" {
return nil
}
return &Err{Operation: op, Message: msg, Cause: err, Code: code}
}
// NewCode creates an error with just code and message (no underlying error).
// Useful for creating sentinel errors with codes.
//
// Example:
//
// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found")
func NewCode(code, msg string) error {
return &Err{Message: msg, Code: code}
}
// --- Standard Library Wrappers ---
// Is reports whether any error in err's tree matches target.
// Wrapper around errors.Is for convenience.
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As finds the first error in err's tree that matches target.
// Wrapper around errors.As for convenience.
func As(err error, target any) bool {
return errors.As(err, target)
}
// NewError creates a simple error with the given text.
// Wrapper around errors.New for convenience.
func NewError(text string) error {
return errors.New(text)
}
// ErrorJoin combines multiple errors into one.
//
// core.ErrorJoin(err1, err2, err3)
func ErrorJoin(errs ...error) error {
return errors.Join(errs...)
}
// --- Error Introspection Helpers ---
// Operation extracts the operation name from an error.
// Returns empty string if the error is not an *Err.
func Operation(err error) string {
var e *Err
if As(err, &e) {
return e.Operation
}
return ""
}
// ErrorCode extracts the error code from an error.
// Returns empty string if the error is not an *Err or has no code.
func ErrorCode(err error) string {
var e *Err
if As(err, &e) {
return e.Code
}
return ""
}
// Message extracts the message from an error.
// Returns the error's Error() string if not an *Err.
func ErrorMessage(err error) string {
if err == nil {
return ""
}
var e *Err
if As(err, &e) {
return e.Message
}
return err.Error()
}
// Root returns the root cause of an error chain.
// Unwraps until no more wrapped errors are found.
func Root(err error) error {
if err == nil {
return nil
}
for {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
}
// AllOperations returns an iterator over all operational contexts in the error chain.
// It traverses the error tree using errors.Unwrap.
func AllOperations(err error) iter.Seq[string] {
return func(yield func(string) bool) {
for err != nil {
if e, ok := err.(*Err); ok {
if e.Operation != "" {
if !yield(e.Operation) {
return
}
}
}
err = errors.Unwrap(err)
}
}
}
// StackTrace returns the logical stack trace (chain of operations) from an error.
// It returns an empty slice if no operational context is found.
func StackTrace(err error) []string {
var stack []string
for op := range AllOperations(err) {
stack = append(stack, op)
}
return stack
}
// FormatStackTrace returns a pretty-printed logical stack trace.
func FormatStackTrace(err error) string {
var ops []string
for op := range AllOperations(err) {
ops = append(ops, op)
}
if len(ops) == 0 {
return ""
}
return Join(" -> ", ops...)
}
// --- ErrorLog: Log-and-Return Error Helpers ---
// ErrorLog combines error creation with logging.
// Primary action: return an error. Secondary: log it.
type ErrorLog struct {
log *Log
}
func (el *ErrorLog) logger() *Log {
if el.log != nil {
return el.log
}
return Default()
}
// Error logs at Error level and returns a Result with the wrapped error.
func (el *ErrorLog) Error(err error, op, msg string) Result {
if err == nil {
return Result{OK: true}
}
wrapped := Wrap(err, op, msg)
el.logger().Error(msg, "op", op, "err", err)
return Result{wrapped, false}
}
// Warn logs at Warn level and returns a Result with the wrapped error.
func (el *ErrorLog) Warn(err error, op, msg string) Result {
if err == nil {
return Result{OK: true}
}
wrapped := Wrap(err, op, msg)
el.logger().Warn(msg, "op", op, "err", err)
return Result{wrapped, false}
}
// Must logs and panics if err is not nil.
func (el *ErrorLog) Must(err error, op, msg string) {
if err != nil {
el.logger().Error(msg, "op", op, "err", err)
panic(Wrap(err, op, msg))
}
}
// --- Crash Recovery & Reporting ---
// CrashReport represents a single crash event.
type CrashReport struct {
Timestamp time.Time `json:"timestamp"`
Error string `json:"error"`
Stack string `json:"stack"`
System CrashSystem `json:"system,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
// CrashSystem holds system information at crash time.
type CrashSystem struct {
OperatingSystem string `json:"operatingsystem"`
Architecture string `json:"architecture"`
Version string `json:"go_version"`
}
// ErrorPanic manages panic recovery and crash reporting.
type ErrorPanic struct {
filePath string
meta map[string]string
onCrash func(CrashReport)
}
// Recover captures a panic and creates a crash report.
// Use as: defer c.Error().Recover()
func (h *ErrorPanic) Recover() {
if h == nil {
return
}
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = NewError(Sprint("panic: ", r))
}
report := CrashReport{
Timestamp: time.Now(),
Error: err.Error(),
Stack: string(debug.Stack()),
System: CrashSystem{
OperatingSystem: runtime.GOOS,
Architecture: runtime.GOARCH,
Version: runtime.Version(),
},
Meta: maps.Clone(h.meta),
}
if h.onCrash != nil {
h.onCrash(report)
}
if h.filePath != "" {
h.appendReport(report)
}
}
// SafeGo runs a function in a goroutine with panic recovery.
func (h *ErrorPanic) SafeGo(fn func()) {
go func() {
defer h.Recover()
fn()
}()
}
// Reports returns the last n crash reports from the file.
func (h *ErrorPanic) Reports(n int) Result {
if h.filePath == "" {
return Result{}
}
crashMu.Lock()
defer crashMu.Unlock()
data, err := os.ReadFile(h.filePath)
if err != nil {
return Result{err, false}
}
var reports []CrashReport
if err := json.Unmarshal(data, &reports); err != nil {
return Result{err, false}
}
if n <= 0 || len(reports) <= n {
return Result{reports, true}
}
return Result{reports[len(reports)-n:], true}
}
var crashMu sync.Mutex
func (h *ErrorPanic) appendReport(report CrashReport) {
crashMu.Lock()
defer crashMu.Unlock()
var reports []CrashReport
if data, err := os.ReadFile(h.filePath); err == nil {
if err := json.Unmarshal(data, &reports); err != nil {
reports = nil
}
}
reports = append(reports, report)
data, err := json.MarshalIndent(reports, "", " ")
if err != nil {
Default().Error(Concat("crash report marshal failed: ", err.Error()))
return
}
if err := os.MkdirAll(filepath.Dir(h.filePath), 0755); err != nil {
Default().Error(Concat("crash report dir failed: ", err.Error()))
return
}
if err := os.WriteFile(h.filePath, data, 0600); err != nil {
Default().Error(Concat("crash report write failed: ", err.Error()))
}
}

View file

@ -1,33 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleE() {
err := E("cache.Get", "key not found", nil)
Println(Operation(err))
Println(ErrorMessage(err))
// Output:
// cache.Get
// key not found
}
func ExampleWrap() {
cause := NewError("connection refused")
err := Wrap(cause, "database.Connect", "failed to reach host")
Println(Operation(err))
Println(Is(err, cause))
// Output:
// database.Connect
// true
}
func ExampleRoot() {
cause := NewError("original")
wrapped := Wrap(cause, "op1", "first wrap")
double := Wrap(wrapped, "op2", "second wrap")
Println(Root(double))
// Output: original
}

View file

@ -1,271 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Error Creation ---
func TestError_E_Good(t *testing.T) {
err := E("user.Save", "failed to save", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user.Save")
assert.Contains(t, err.Error(), "failed to save")
}
func TestError_E_WithCause_Good(t *testing.T) {
cause := NewError("connection refused")
err := E("db.Connect", "database unavailable", cause)
assert.ErrorIs(t, err, cause)
}
func TestError_Wrap_Good(t *testing.T) {
cause := NewError("timeout")
err := Wrap(cause, "api.Call", "request failed")
assert.Error(t, err)
assert.ErrorIs(t, err, cause)
}
func TestError_Wrap_Nil_Good(t *testing.T) {
err := Wrap(nil, "api.Call", "request failed")
assert.Nil(t, err)
}
func TestError_WrapCode_Good(t *testing.T) {
cause := NewError("invalid email")
err := WrapCode(cause, "VALIDATION_ERROR", "user.Validate", "bad input")
assert.Error(t, err)
assert.Equal(t, "VALIDATION_ERROR", ErrorCode(err))
}
func TestError_NewCode_Good(t *testing.T) {
err := NewCode("NOT_FOUND", "resource not found")
assert.Error(t, err)
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
}
// --- Error Introspection ---
func TestError_Operation_Good(t *testing.T) {
err := E("brain.Recall", "search failed", nil)
assert.Equal(t, "brain.Recall", Operation(err))
}
func TestError_Operation_Bad(t *testing.T) {
err := NewError("plain error")
assert.Equal(t, "", Operation(err))
}
func TestError_ErrorMessage_Good(t *testing.T) {
err := E("op", "the message", nil)
assert.Equal(t, "the message", ErrorMessage(err))
}
func TestError_ErrorMessage_Plain(t *testing.T) {
err := NewError("plain")
assert.Equal(t, "plain", ErrorMessage(err))
}
func TestError_ErrorMessage_Nil(t *testing.T) {
assert.Equal(t, "", ErrorMessage(nil))
}
func TestError_Root_Good(t *testing.T) {
root := NewError("root cause")
wrapped := Wrap(root, "layer1", "first wrap")
double := Wrap(wrapped, "layer2", "second wrap")
assert.Equal(t, root, Root(double))
}
func TestError_Root_Nil(t *testing.T) {
assert.Nil(t, Root(nil))
}
func TestError_StackTrace_Good(t *testing.T) {
err := Wrap(E("inner", "cause", nil), "outer", "wrapper")
stack := StackTrace(err)
assert.Len(t, stack, 2)
assert.Equal(t, "outer", stack[0])
assert.Equal(t, "inner", stack[1])
}
func TestError_FormatStackTrace_Good(t *testing.T) {
err := Wrap(E("a", "x", nil), "b", "y")
formatted := FormatStackTrace(err)
assert.Equal(t, "b -> a", formatted)
}
// --- ErrorLog ---
func TestError_ErrorLog_Good(t *testing.T) {
c := New()
cause := NewError("boom")
r := c.Log().Error(cause, "test.Operation", "something broke")
assert.False(t, r.OK)
assert.ErrorIs(t, r.Value.(error), cause)
}
func TestError_ErrorLog_Nil_Good(t *testing.T) {
c := New()
r := c.Log().Error(nil, "test.Operation", "no error")
assert.True(t, r.OK)
}
func TestError_ErrorLog_Warn_Good(t *testing.T) {
c := New()
cause := NewError("warning")
r := c.Log().Warn(cause, "test.Operation", "heads up")
assert.False(t, r.OK)
}
func TestError_ErrorLog_Must_Ugly(t *testing.T) {
c := New()
assert.Panics(t, func() {
c.Log().Must(NewError("fatal"), "test.Operation", "must fail")
})
}
func TestError_ErrorLog_Must_Nil_Good(t *testing.T) {
c := New()
assert.NotPanics(t, func() {
c.Log().Must(nil, "test.Operation", "no error")
})
}
// --- ErrorPanic ---
func TestError_ErrorPanic_Recover_Good(t *testing.T) {
c := New()
// Should not panic — Recover catches it
assert.NotPanics(t, func() {
defer c.Error().Recover()
panic("test panic")
})
}
func TestError_ErrorPanic_SafeGo_Good(t *testing.T) {
c := New()
done := make(chan bool, 1)
c.Error().SafeGo(func() {
done <- true
})
assert.True(t, <-done)
}
func TestError_ErrorPanic_SafeGo_Panic_Good(t *testing.T) {
c := New()
done := make(chan bool, 1)
c.Error().SafeGo(func() {
defer func() { done <- true }()
panic("caught by SafeGo")
})
// SafeGo recovers — goroutine completes without crashing the process
<-done
}
// --- Standard Library Wrappers ---
func TestError_Is_Good(t *testing.T) {
target := NewError("target")
wrapped := Wrap(target, "op", "msg")
assert.True(t, Is(wrapped, target))
}
func TestError_As_Good(t *testing.T) {
err := E("op", "msg", nil)
var e *Err
assert.True(t, As(err, &e))
assert.Equal(t, "op", e.Operation)
}
func TestError_NewError_Good(t *testing.T) {
err := NewError("simple error")
assert.Equal(t, "simple error", err.Error())
}
func TestError_ErrorJoin_Good(t *testing.T) {
e1 := NewError("first")
e2 := NewError("second")
joined := ErrorJoin(e1, e2)
assert.ErrorIs(t, joined, e1)
assert.ErrorIs(t, joined, e2)
}
// --- ErrorPanic Crash Reports ---
func TestError_ErrorPanic_Reports_Good(t *testing.T) {
dir := t.TempDir()
path := Path(dir, "crashes.json")
// Create ErrorPanic with file output
c := New()
// Access internals via a crash that writes to file
// Since ErrorPanic fields are unexported, we test via Recover
_ = c
_ = path
// Crash reporting needs ErrorPanic configured with filePath — tested indirectly
}
// --- ErrorPanic Crash File ---
func TestError_ErrorPanic_CrashFile_Good(t *testing.T) {
dir := t.TempDir()
path := Path(dir, "crashes.json")
// Create Core, trigger a panic through SafeGo, check crash file
// ErrorPanic.filePath is unexported — but we can test via the package-level
// error handling that writes crash reports
// For now, test that Reports handles missing file gracefully
c := New()
r := c.Error().Reports(5)
assert.False(t, r.OK)
assert.Nil(t, r.Value)
_ = path
}
// --- Error formatting branches ---
func TestError_Err_Error_WithCode_Good(t *testing.T) {
err := WrapCode(NewError("bad"), "INVALID", "validate", "input failed")
assert.Contains(t, err.Error(), "[INVALID]")
assert.Contains(t, err.Error(), "validate")
assert.Contains(t, err.Error(), "bad")
}
func TestError_Err_Error_CodeNoCause_Good(t *testing.T) {
err := NewCode("NOT_FOUND", "resource missing")
assert.Contains(t, err.Error(), "[NOT_FOUND]")
assert.Contains(t, err.Error(), "resource missing")
}
func TestError_Err_Error_NoOp_Good(t *testing.T) {
err := &Err{Message: "bare error"}
assert.Equal(t, "bare error", err.Error())
}
func TestError_WrapCode_NilErr_EmptyCode_Good(t *testing.T) {
err := WrapCode(nil, "", "op", "msg")
assert.Nil(t, err)
}
func TestError_Wrap_PreservesCode_Good(t *testing.T) {
inner := WrapCode(NewError("root"), "AUTH_FAIL", "auth", "denied")
outer := Wrap(inner, "handler", "request failed")
assert.Equal(t, "AUTH_FAIL", ErrorCode(outer))
}
func TestError_ErrorLog_Warn_Nil_Good(t *testing.T) {
c := New()
r := c.LogWarn(nil, "op", "msg")
assert.True(t, r.OK)
}
func TestError_ErrorLog_Error_Nil_Good(t *testing.T) {
c := New()
r := c.LogError(nil, "op", "msg")
assert.True(t, r.OK)
}

View file

@ -1,314 +0,0 @@
package core_test
import (
"context"
. "dappco.re/go/core"
)
// --- Core Creation ---
func ExampleNew() {
c := New(
WithOption("name", "my-app"),
WithServiceLock(),
)
Println(c.App().Name)
// Output: my-app
}
func ExampleNew_withService() {
c := New(
WithOption("name", "example"),
WithService(func(c *Core) Result {
return c.Service("greeter", Service{
OnStart: func() Result {
Info("greeter started", "app", c.App().Name)
return Result{OK: true}
},
})
}),
)
c.ServiceStartup(context.Background(), nil)
Println(c.Services())
c.ServiceShutdown(context.Background())
// Output is non-deterministic (map order), so no Output comment
}
// --- Options ---
func ExampleNewOptions() {
opts := NewOptions(
Option{Key: "name", Value: "brain"},
Option{Key: "port", Value: 8080},
Option{Key: "debug", Value: true},
)
Println(opts.String("name"))
Println(opts.Int("port"))
Println(opts.Bool("debug"))
// Output:
// brain
// 8080
// true
}
// --- Result ---
func ExampleResult() {
r := Result{Value: "hello", OK: true}
if r.OK {
Println(r.Value)
}
// Output: hello
}
// --- Action ---
func ExampleCore_Action_register() {
c := New()
c.Action("greet", func(_ context.Context, opts Options) Result {
name := opts.String("name")
return Result{Value: Concat("hello ", name), OK: true}
})
Println(c.Action("greet").Exists())
// Output: true
}
func ExampleCore_Action_invoke() {
c := New()
c.Action("add", func(_ context.Context, opts Options) Result {
a := opts.Int("a")
b := opts.Int("b")
return Result{Value: a + b, OK: true}
})
r := c.Action("add").Run(context.Background(), NewOptions(
Option{Key: "a", Value: 3},
Option{Key: "b", Value: 4},
))
Println(r.Value)
// Output: 7
}
func ExampleCore_Actions() {
c := New()
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} })
Println(c.Actions())
// Output: [process.run brain.recall]
}
// --- Task ---
func ExampleCore_Task() {
c := New()
order := ""
c.Action("step.a", func(_ context.Context, _ Options) Result {
order += "a"
return Result{Value: "from-a", OK: true}
})
c.Action("step.b", func(_ context.Context, opts Options) Result {
order += "b"
return Result{OK: true}
})
c.Task("pipeline", Task{
Steps: []Step{
{Action: "step.a"},
{Action: "step.b", Input: "previous"},
},
})
c.Task("pipeline").Run(context.Background(), c, NewOptions())
Println(order)
// Output: ab
}
// --- Registry ---
func ExampleNewRegistry() {
r := NewRegistry[string]()
r.Set("alpha", "first")
r.Set("bravo", "second")
Println(r.Has("alpha"))
Println(r.Names())
Println(r.Len())
// Output:
// true
// [alpha bravo]
// 2
}
func ExampleRegistry_Lock() {
r := NewRegistry[string]()
r.Set("alpha", "first")
r.Lock()
result := r.Set("beta", "second")
Println(result.OK)
// Output: false
}
func ExampleRegistry_Seal() {
r := NewRegistry[string]()
r.Set("alpha", "first")
r.Seal()
// Can update existing
Println(r.Set("alpha", "updated").OK)
// Can't add new
Println(r.Set("beta", "new").OK)
// Output:
// true
// false
}
// --- Entitlement ---
func ExampleCore_Entitled_default() {
c := New()
e := c.Entitled("anything")
Println(e.Allowed)
Println(e.Unlimited)
// Output:
// true
// true
}
func ExampleCore_Entitled_custom() {
c := New()
c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement {
if action == "premium" {
return Entitlement{Allowed: false, Reason: "upgrade required"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
Println(c.Entitled("basic").Allowed)
Println(c.Entitled("premium").Allowed)
Println(c.Entitled("premium").Reason)
// Output:
// true
// false
// upgrade required
}
func ExampleEntitlement_NearLimit() {
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
Println(e.NearLimit(0.8))
Println(e.UsagePercent())
// Output:
// true
// 85
}
// --- Process ---
func ExampleCore_Process() {
c := New()
// No go-process registered — permission by registration
Println(c.Process().Exists())
// Register a mock process handler
c.Action("process.run", func(_ context.Context, opts Options) Result {
return Result{Value: Concat("output of ", opts.String("command")), OK: true}
})
Println(c.Process().Exists())
r := c.Process().Run(context.Background(), "echo", "hello")
Println(r.Value)
// Output:
// false
// true
// output of echo
}
// --- JSON ---
func ExampleJSONMarshal() {
type config struct {
Host string `json:"host"`
Port int `json:"port"`
}
r := JSONMarshal(config{Host: "localhost", Port: 8080})
Println(string(r.Value.([]byte)))
// Output: {"host":"localhost","port":8080}
}
func ExampleJSONUnmarshalString() {
type config struct {
Host string `json:"host"`
Port int `json:"port"`
}
var cfg config
JSONUnmarshalString(`{"host":"localhost","port":8080}`, &cfg)
Println(cfg.Host, cfg.Port)
// Output: localhost 8080
}
// --- Utilities ---
func ExampleID() {
id := ID()
Println(HasPrefix(id, "id-"))
// Output: true
}
func ExampleValidateName() {
Println(ValidateName("brain").OK)
Println(ValidateName("").OK)
Println(ValidateName("..").OK)
Println(ValidateName("path/traversal").OK)
// Output:
// true
// false
// false
// false
}
func ExampleSanitisePath() {
Println(SanitisePath("../../etc/passwd"))
Println(SanitisePath(""))
Println(SanitisePath("/some/path/file.txt"))
// Output:
// passwd
// invalid
// file.txt
}
// --- Command ---
func ExampleCore_Command() {
c := New()
c.Command("deploy/to/homelab", Command{
Action: func(opts Options) Result {
return Result{Value: Concat("deployed to ", opts.String("_arg")), OK: true}
},
})
r := c.Cli().Run("deploy", "to", "homelab")
Println(r.OK)
// Output: true
}
// --- Config ---
func ExampleConfig() {
c := New()
c.Config().Set("database.host", "localhost")
c.Config().Set("database.port", 5432)
c.Config().Enable("dark-mode")
Println(c.Config().String("database.host"))
Println(c.Config().Int("database.port"))
Println(c.Config().Enabled("dark-mode"))
// Output:
// localhost
// 5432
// true
}
// Error examples in error_example_test.go

423
fs.go
View file

@ -1,423 +0,0 @@
// Sandboxed local filesystem I/O for the Core framework.
package core
import (
"io"
"io/fs"
"os"
"os/user"
"path/filepath"
"time"
)
// Fs is a sandboxed local filesystem backend.
type Fs struct {
root string
}
// New initialises an Fs with the given root directory.
// Root "/" means unrestricted access. Empty root defaults to "/".
//
// fs := (&core.Fs{}).New("/")
func (m *Fs) New(root string) *Fs {
if root == "" {
root = "/"
}
m.root = root
return m
}
// NewUnrestricted returns a new Fs with root "/", granting full filesystem access.
// Use this instead of unsafe.Pointer to bypass the sandbox.
//
// fs := c.Fs().NewUnrestricted()
// fs.Read("/etc/hostname") // works — no sandbox
func (m *Fs) NewUnrestricted() *Fs {
return (&Fs{}).New("/")
}
// Root returns the sandbox root path.
//
// root := c.Fs().Root() // e.g. "/home/agent/.core"
func (m *Fs) Root() string {
if m.root == "" {
return "/"
}
return m.root
}
// path sanitises and returns the full path.
// Absolute paths are sandboxed under root (unless root is "/").
// Empty root defaults to "/" — the zero value of Fs is usable.
func (m *Fs) path(p string) string {
root := m.root
if root == "" {
root = "/"
}
if p == "" {
return root
}
// If the path is relative and the medium is rooted at "/",
// treat it as relative to the current working directory.
// This makes io.Local behave more like the standard 'os' package.
if root == "/" && !filepath.IsAbs(p) {
cwd, _ := os.Getwd()
return filepath.Join(cwd, p)
}
// Use filepath.Clean with a leading slash to resolve all .. and . internally
// before joining with the root. This is a standard way to sandbox paths.
clean := filepath.Clean("/" + p)
// If root is "/", allow absolute paths through
if root == "/" {
return clean
}
// Strip leading "/" so Join works correctly with root
return filepath.Join(root, clean[1:])
}
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
func (m *Fs) validatePath(p string) Result {
root := m.root
if root == "" {
root = "/"
}
if root == "/" {
return Result{m.path(p), true}
}
// Split the cleaned path into components
parts := Split(filepath.Clean("/"+p), string(os.PathSeparator))
current := root
for _, part := range parts {
if part == "" {
continue
}
next := filepath.Join(current, part)
realNext, err := filepath.EvalSymlinks(next)
if err != nil {
if os.IsNotExist(err) {
// Part doesn't exist, we can't follow symlinks anymore.
// Since the path is already Cleaned and current is safe,
// appending a component to current will not escape.
current = next
continue
}
return Result{err, false}
}
// Verify the resolved part is still within the root
rel, err := filepath.Rel(root, realNext)
if err != nil || HasPrefix(rel, "..") {
// Security event: sandbox escape attempt
username := "unknown"
if u, err := user.Current(); err == nil {
username = u.Username
}
Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s",
time.Now().Format(time.RFC3339), root, p, realNext, username)
if err == nil {
err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil)
}
return Result{err, false}
}
current = realNext
}
return Result{current, true}
}
// Read returns file contents as string.
func (m *Fs) Read(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
data, err := os.ReadFile(vp.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{string(data), true}
}
// Write saves content to file, creating parent directories as needed.
// Files are created with mode 0644. For sensitive files (keys, secrets),
// use WriteMode with 0600.
func (m *Fs) Write(p, content string) Result {
return m.WriteMode(p, content, 0644)
}
// WriteMode saves content to file with explicit permissions.
// Use 0600 for sensitive files (encryption output, private keys, auth hashes).
func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// TempDir creates a temporary directory and returns its path.
// The caller is responsible for cleanup via fs.DeleteAll().
//
// dir := fs.TempDir("agent-workspace")
// defer fs.DeleteAll(dir)
func (m *Fs) TempDir(prefix string) string {
dir, err := os.MkdirTemp("", prefix)
if err != nil {
return ""
}
return dir
}
// DirFS returns an fs.FS rooted at the given directory path.
//
// fsys := core.DirFS("/path/to/templates")
func DirFS(dir string) fs.FS {
return os.DirFS(dir)
}
// WriteAtomic writes content by writing to a temp file then renaming.
// Rename is atomic on POSIX — concurrent readers never see a partial file.
// Use this for status files, config, or any file read from multiple goroutines.
//
// r := fs.WriteAtomic("/status.json", jsonData)
func (m *Fs) WriteAtomic(p, content string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
tmp := full + ".tmp." + shortRand()
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
return Result{err, false}
}
if err := os.Rename(tmp, full); err != nil {
os.Remove(tmp)
return Result{err, false}
}
return Result{OK: true}
}
// EnsureDir creates directory if it doesn't exist.
func (m *Fs) EnsureDir(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
if err := os.MkdirAll(vp.Value.(string), 0755); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// IsDir returns true if path is a directory.
func (m *Fs) IsDir(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.IsDir()
}
// IsFile returns true if path is a regular file.
func (m *Fs) IsFile(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.Mode().IsRegular()
}
// Exists returns true if path exists.
func (m *Fs) Exists(p string) bool {
vp := m.validatePath(p)
if !vp.OK {
return false
}
_, err := os.Stat(vp.Value.(string))
return err == nil
}
// List returns directory entries.
func (m *Fs) List(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.ReadDir(vp.Value.(string)))
}
// Stat returns file info.
func (m *Fs) Stat(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.Stat(vp.Value.(string)))
}
// Open opens the named file for reading.
func (m *Fs) Open(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.Open(vp.Value.(string)))
}
// Create creates or truncates the named file.
func (m *Fs) Create(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.New(os.Create(full))
}
// Append opens the named file for appending, creating it if it doesn't exist.
func (m *Fs) Append(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
}
// ReadStream returns a reader for the file content.
func (m *Fs) ReadStream(path string) Result {
return m.Open(path)
}
// WriteStream returns a writer for the file content.
func (m *Fs) WriteStream(path string) Result {
return m.Create(path)
}
// ReadAll reads all bytes from a ReadCloser and closes it.
// Wraps io.ReadAll so consumers don't import "io".
//
// r := fs.ReadStream(path)
// data := core.ReadAll(r.Value)
func ReadAll(reader any) Result {
rc, ok := reader.(io.Reader)
if !ok {
return Result{E("core.ReadAll", "not a reader", nil), false}
}
data, err := io.ReadAll(rc)
if closer, ok := reader.(io.Closer); ok {
closer.Close()
}
if err != nil {
return Result{err, false}
}
return Result{string(data), true}
}
// WriteAll writes content to a writer and closes it if it implements Closer.
//
// r := fs.WriteStream(path)
// core.WriteAll(r.Value, "content")
func WriteAll(writer any, content string) Result {
wc, ok := writer.(io.Writer)
if !ok {
return Result{E("core.WriteAll", "not a writer", nil), false}
}
_, err := wc.Write([]byte(content))
if closer, ok := writer.(io.Closer); ok {
closer.Close()
}
if err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// CloseStream closes any value that implements io.Closer.
//
// core.CloseStream(r.Value)
func CloseStream(v any) {
if closer, ok := v.(io.Closer); ok {
closer.Close()
}
}
// Delete removes a file or empty directory.
func (m *Fs) Delete(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.Delete", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.Remove(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// DeleteAll removes a file or directory recursively.
func (m *Fs) DeleteAll(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.DeleteAll", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.RemoveAll(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// Rename moves a file or directory.
func (m *Fs) Rename(oldPath, newPath string) Result {
oldVp := m.validatePath(oldPath)
if !oldVp.OK {
return oldVp
}
newVp := m.validatePath(newPath)
if !newVp.OK {
return newVp
}
if err := os.Rename(oldVp.Value.(string), newVp.Value.(string)); err != nil {
return Result{err, false}
}
return Result{OK: true}
}

View file

@ -1,42 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleFs_WriteAtomic() {
f := (&Fs{}).New("/")
dir := f.TempDir("example")
defer f.DeleteAll(dir)
path := Path(dir, "status.json")
f.WriteAtomic(path, `{"status":"completed"}`)
r := f.Read(path)
Println(r.Value)
// Output: {"status":"completed"}
}
func ExampleFs_NewUnrestricted() {
f := (&Fs{}).New("/")
dir := f.TempDir("example")
defer f.DeleteAll(dir)
// Write outside sandbox using Core's Fs
outside := Path(dir, "outside.txt")
f.Write(outside, "hello")
sandbox := (&Fs{}).New(Path(dir, "sandbox"))
unrestricted := sandbox.NewUnrestricted()
r := unrestricted.Read(outside)
Println(r.Value)
// Output: hello
}
func ExampleFs_Root() {
f := (&Fs{}).New("/srv/workspaces")
Println(f.Root())
// Output: /srv/workspaces
}

View file

@ -1,349 +0,0 @@
package core_test
import (
"io/fs"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Fs (Sandboxed Filesystem) ---
func TestFs_WriteRead_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "test.txt")
assert.True(t, c.Fs().Write(path, "hello core").OK)
r := c.Fs().Read(path)
assert.True(t, r.OK)
assert.Equal(t, "hello core", r.Value.(string))
}
func TestFs_Read_Bad(t *testing.T) {
c := New()
r := c.Fs().Read("/nonexistent/path/to/file.txt")
assert.False(t, r.OK)
}
func TestFs_EnsureDir_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "sub", "dir")
assert.True(t, c.Fs().EnsureDir(path).OK)
assert.True(t, c.Fs().IsDir(path))
}
func TestFs_IsDir_Good(t *testing.T) {
c := New()
dir := t.TempDir()
assert.True(t, c.Fs().IsDir(dir))
assert.False(t, c.Fs().IsDir(Path(dir, "nonexistent")))
assert.False(t, c.Fs().IsDir(""))
}
func TestFs_IsFile_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "test.txt")
c.Fs().Write(path, "data")
assert.True(t, c.Fs().IsFile(path))
assert.False(t, c.Fs().IsFile(dir))
assert.False(t, c.Fs().IsFile(""))
}
func TestFs_Exists_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "exists.txt")
c.Fs().Write(path, "yes")
assert.True(t, c.Fs().Exists(path))
assert.True(t, c.Fs().Exists(dir))
assert.False(t, c.Fs().Exists(Path(dir, "nope")))
}
func TestFs_List_Good(t *testing.T) {
dir := t.TempDir()
c := New()
c.Fs().Write(Path(dir, "a.txt"), "a")
c.Fs().Write(Path(dir, "b.txt"), "b")
r := c.Fs().List(dir)
assert.True(t, r.OK)
assert.Len(t, r.Value.([]fs.DirEntry), 2)
}
func TestFs_Stat_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "stat.txt")
c.Fs().Write(path, "data")
r := c.Fs().Stat(path)
assert.True(t, r.OK)
assert.Equal(t, "stat.txt", r.Value.(fs.FileInfo).Name())
}
func TestFs_Open_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "open.txt")
c.Fs().Write(path, "content")
r := c.Fs().Open(path)
assert.True(t, r.OK)
CloseStream(r.Value)
}
func TestFs_Create_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "sub", "created.txt")
r := c.Fs().Create(path)
assert.True(t, r.OK)
WriteAll(r.Value, "hello")
rr := c.Fs().Read(path)
assert.Equal(t, "hello", rr.Value.(string))
}
func TestFs_Append_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "append.txt")
c.Fs().Write(path, "first")
r := c.Fs().Append(path)
assert.True(t, r.OK)
WriteAll(r.Value, " second")
rr := c.Fs().Read(path)
assert.Equal(t, "first second", rr.Value.(string))
}
func TestFs_ReadStream_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "stream.txt")
c.Fs().Write(path, "streamed")
r := c.Fs().ReadStream(path)
assert.True(t, r.OK)
CloseStream(r.Value)
}
func TestFs_WriteStream_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "sub", "ws.txt")
r := c.Fs().WriteStream(path)
assert.True(t, r.OK)
WriteAll(r.Value, "stream")
}
func TestFs_Delete_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "delete.txt")
c.Fs().Write(path, "gone")
assert.True(t, c.Fs().Delete(path).OK)
assert.False(t, c.Fs().Exists(path))
}
func TestFs_DeleteAll_Good(t *testing.T) {
dir := t.TempDir()
c := New()
sub := Path(dir, "deep", "nested")
c.Fs().EnsureDir(sub)
c.Fs().Write(Path(sub, "file.txt"), "data")
assert.True(t, c.Fs().DeleteAll(Path(dir, "deep")).OK)
assert.False(t, c.Fs().Exists(Path(dir, "deep")))
}
func TestFs_Rename_Good(t *testing.T) {
dir := t.TempDir()
c := New()
old := Path(dir, "old.txt")
nw := Path(dir, "new.txt")
c.Fs().Write(old, "data")
assert.True(t, c.Fs().Rename(old, nw).OK)
assert.False(t, c.Fs().Exists(old))
assert.True(t, c.Fs().Exists(nw))
}
func TestFs_WriteMode_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "secret.txt")
assert.True(t, c.Fs().WriteMode(path, "secret", 0600).OK)
r := c.Fs().Stat(path)
assert.True(t, r.OK)
assert.Equal(t, "secret.txt", r.Value.(fs.FileInfo).Name())
}
// --- Zero Value ---
func TestFs_ZeroValue_Good(t *testing.T) {
dir := t.TempDir()
zeroFs := &Fs{}
path := Path(dir, "zero.txt")
assert.True(t, zeroFs.Write(path, "zero value works").OK)
r := zeroFs.Read(path)
assert.True(t, r.OK)
assert.Equal(t, "zero value works", r.Value.(string))
assert.True(t, zeroFs.IsFile(path))
assert.True(t, zeroFs.Exists(path))
assert.True(t, zeroFs.IsDir(dir))
}
func TestFs_ZeroValue_List_Good(t *testing.T) {
dir := t.TempDir()
zeroFs := &Fs{}
(&Fs{}).New("/").Write(Path(dir, "a.txt"), "a")
r := zeroFs.List(dir)
assert.True(t, r.OK)
entries := r.Value.([]fs.DirEntry)
assert.Len(t, entries, 1)
}
func TestFs_Exists_NotFound_Bad(t *testing.T) {
c := New()
assert.False(t, c.Fs().Exists("/nonexistent/path/xyz"))
}
// --- Fs path/validatePath edge cases ---
func TestFs_Read_EmptyPath_Ugly(t *testing.T) {
c := New()
r := c.Fs().Read("")
assert.False(t, r.OK)
}
func TestFs_Write_EmptyPath_Ugly(t *testing.T) {
c := New()
r := c.Fs().Write("", "data")
assert.False(t, r.OK)
}
func TestFs_Delete_Protected_Ugly(t *testing.T) {
c := New()
r := c.Fs().Delete("/")
assert.False(t, r.OK)
}
func TestFs_DeleteAll_Protected_Ugly(t *testing.T) {
c := New()
r := c.Fs().DeleteAll("/")
assert.False(t, r.OK)
}
func TestFs_ReadStream_WriteStream_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "stream.txt")
c.Fs().Write(path, "streamed")
r := c.Fs().ReadStream(path)
assert.True(t, r.OK)
w := c.Fs().WriteStream(path)
assert.True(t, w.OK)
}
// --- WriteAtomic ---
func TestFs_WriteAtomic_Good(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "status.json")
r := c.Fs().WriteAtomic(path, `{"status":"completed"}`)
assert.True(t, r.OK)
read := c.Fs().Read(path)
assert.True(t, read.OK)
assert.Equal(t, `{"status":"completed"}`, read.Value)
}
func TestFs_WriteAtomic_Good_Overwrite(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "data.txt")
c.Fs().WriteAtomic(path, "first")
c.Fs().WriteAtomic(path, "second")
read := c.Fs().Read(path)
assert.Equal(t, "second", read.Value)
}
func TestFs_WriteAtomic_Bad_ReadOnlyDir(t *testing.T) {
// Write to a non-existent root that can't be created
m := (&Fs{}).New("/proc/nonexistent")
r := m.WriteAtomic("file.txt", "data")
assert.False(t, r.OK, "WriteAtomic must fail when parent dir cannot be created")
}
func TestFs_WriteAtomic_Ugly_NoTempFileLeftOver(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "clean.txt")
c.Fs().WriteAtomic(path, "content")
// Check no .tmp files remain
lr := c.Fs().List(dir)
entries, _ := lr.Value.([]fs.DirEntry)
for _, e := range entries {
assert.False(t, Contains(e.Name(), ".tmp."), "temp file should not remain after successful atomic write")
}
}
func TestFs_WriteAtomic_Good_CreatesParentDir(t *testing.T) {
dir := t.TempDir()
c := New()
path := Path(dir, "sub", "dir", "file.txt")
r := c.Fs().WriteAtomic(path, "nested")
assert.True(t, r.OK)
read := c.Fs().Read(path)
assert.Equal(t, "nested", read.Value)
}
// --- NewUnrestricted ---
func TestFs_NewUnrestricted_Good(t *testing.T) {
sandboxed := (&Fs{}).New(t.TempDir())
unrestricted := sandboxed.NewUnrestricted()
assert.Equal(t, "/", unrestricted.Root())
}
func TestFs_NewUnrestricted_Good_CanReadOutsideSandbox(t *testing.T) {
dir := t.TempDir()
outside := Path(dir, "outside.txt")
(&Fs{}).New("/").Write(outside, "hello")
sandboxed := (&Fs{}).New(Path(dir, "sandbox"))
unrestricted := sandboxed.NewUnrestricted()
r := unrestricted.Read(outside)
assert.True(t, r.OK, "unrestricted Fs must read paths outside the original sandbox")
assert.Equal(t, "hello", r.Value)
}
func TestFs_NewUnrestricted_Ugly_OriginalStaysSandboxed(t *testing.T) {
dir := t.TempDir()
sandbox := Path(dir, "sandbox")
(&Fs{}).New("/").EnsureDir(sandbox)
sandboxed := (&Fs{}).New(sandbox)
_ = sandboxed.NewUnrestricted() // getting unrestricted doesn't affect original
assert.Equal(t, sandbox, sandboxed.Root(), "original Fs must remain sandboxed")
}
// --- Root ---
func TestFs_Root_Good(t *testing.T) {
m := (&Fs{}).New("/home/agent")
assert.Equal(t, "/home/agent", m.Root())
}
func TestFs_Root_Good_Default(t *testing.T) {
m := (&Fs{}).New("")
assert.Equal(t, "/", m.Root())
}

11
go.mod
View file

@ -1,14 +1,15 @@
module dappco.re/go/core
module forge.lthn.ai/core/go
go 1.26.0
require github.com/stretchr/testify v1.11.1
require (
forge.lthn.ai/core/go-io v0.1.5
forge.lthn.ai/core/go-log v0.0.4
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

10
go.sum
View file

@ -1,17 +1,15 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

138
i18n.go
View file

@ -1,138 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Internationalisation for the Core framework.
// I18n collects locale mounts from services and delegates
// translation to a registered Translator implementation (e.g., go-i18n).
package core
import (
"sync"
)
// Translator defines the interface for translation services.
// Implemented by go-i18n's Srv.
type Translator interface {
// Translate translates a message by its ID with optional arguments.
Translate(messageID string, args ...any) Result
// SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de").
SetLanguage(lang string) error
// Language returns the current language code.
Language() string
// AvailableLanguages returns all loaded language codes.
AvailableLanguages() []string
}
// LocaleProvider is implemented by services that ship their own translation files.
// Core discovers this interface during service registration and collects the
// locale mounts. The i18n service loads them during startup.
//
// Usage in a service package:
//
// //go:embed locales
// var localeFS embed.FS
//
// func (s *MyService) Locales() *Embed {
// m, _ := Mount(localeFS, "locales")
// return m
// }
type LocaleProvider interface {
Locales() *Embed
}
// I18n manages locale collection and translation dispatch.
type I18n struct {
mu sync.RWMutex
locales []*Embed // collected from LocaleProvider services
locale string
translator Translator // registered implementation (nil until set)
}
// AddLocales adds locale mounts (called during service registration).
func (i *I18n) AddLocales(mounts ...*Embed) {
i.mu.Lock()
i.locales = append(i.locales, mounts...)
i.mu.Unlock()
}
// Locales returns all collected locale mounts.
func (i *I18n) Locales() Result {
i.mu.RLock()
out := make([]*Embed, len(i.locales))
copy(out, i.locales)
i.mu.RUnlock()
return Result{out, true}
}
// SetTranslator registers the translation implementation.
// Called by go-i18n's Srv during startup.
func (i *I18n) SetTranslator(t Translator) {
i.mu.Lock()
i.translator = t
locale := i.locale
i.mu.Unlock()
if t != nil && locale != "" {
_ = t.SetLanguage(locale)
}
}
// Translator returns the registered translation implementation, or nil.
func (i *I18n) Translator() Result {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t == nil {
return Result{}
}
return Result{t, true}
}
// Translate translates a message. Returns the key as-is if no translator is registered.
func (i *I18n) Translate(messageID string, args ...any) Result {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.Translate(messageID, args...)
}
return Result{messageID, true}
}
// SetLanguage sets the active language and forwards to the translator if registered.
func (i *I18n) SetLanguage(lang string) Result {
if lang == "" {
return Result{OK: true}
}
i.mu.Lock()
i.locale = lang
t := i.translator
i.mu.Unlock()
if t != nil {
if err := t.SetLanguage(lang); err != nil {
return Result{err, false}
}
}
return Result{OK: true}
}
// Language returns the current language code, or "en" if not set.
func (i *I18n) Language() string {
i.mu.RLock()
locale := i.locale
i.mu.RUnlock()
if locale != "" {
return locale
}
return "en"
}
// AvailableLanguages returns all loaded language codes.
func (i *I18n) AvailableLanguages() []string {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.AvailableLanguages()
}
return []string{"en"}
}

View file

@ -1,96 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- I18n ---
func TestI18n_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.I18n())
}
func TestI18n_AddLocales_Good(t *testing.T) {
c := New()
r := c.Data().New(NewOptions(
Option{Key: "name", Value: "lang"},
Option{Key: "source", Value: testFS},
Option{Key: "path", Value: "testdata"},
))
if r.OK {
c.I18n().AddLocales(r.Value.(*Embed))
}
r2 := c.I18n().Locales()
assert.True(t, r2.OK)
assert.Len(t, r2.Value.([]*Embed), 1)
}
func TestI18n_Locales_Empty_Good(t *testing.T) {
c := New()
r := c.I18n().Locales()
assert.True(t, r.OK)
assert.Empty(t, r.Value.([]*Embed))
}
// --- Translator (no translator registered) ---
func TestI18n_Translate_NoTranslator_Good(t *testing.T) {
c := New()
// Without a translator, Translate returns the key as-is
r := c.I18n().Translate("greeting.hello")
assert.True(t, r.OK)
assert.Equal(t, "greeting.hello", r.Value)
}
func TestI18n_SetLanguage_NoTranslator_Good(t *testing.T) {
c := New()
r := c.I18n().SetLanguage("de")
assert.True(t, r.OK) // no-op without translator
}
func TestI18n_Language_NoTranslator_Good(t *testing.T) {
c := New()
assert.Equal(t, "en", c.I18n().Language())
}
func TestI18n_AvailableLanguages_NoTranslator_Good(t *testing.T) {
c := New()
langs := c.I18n().AvailableLanguages()
assert.Equal(t, []string{"en"}, langs)
}
func TestI18n_Translator_Nil_Good(t *testing.T) {
c := New()
assert.False(t, c.I18n().Translator().OK)
}
// --- Translator (with mock) ---
type mockTranslator struct {
lang string
}
func (m *mockTranslator) Translate(id string, args ...any) Result {
return Result{Concat("translated:", id), true}
}
func (m *mockTranslator) SetLanguage(lang string) error { m.lang = lang; return nil }
func (m *mockTranslator) Language() string { return m.lang }
func (m *mockTranslator) AvailableLanguages() []string { return []string{"en", "de", "fr"} }
func TestI18n_WithTranslator_Good(t *testing.T) {
c := New()
tr := &mockTranslator{lang: "en"}
c.I18n().SetTranslator(tr)
assert.Equal(t, tr, c.I18n().Translator().Value)
assert.Equal(t, "translated:hello", c.I18n().Translate("hello").Value)
assert.Equal(t, "en", c.I18n().Language())
assert.Equal(t, []string{"en", "de", "fr"}, c.I18n().AvailableLanguages())
c.I18n().SetLanguage("de")
assert.Equal(t, "de", c.I18n().Language())
}

134
info.go
View file

@ -1,134 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// System information registry for the Core framework.
// Read-only key-value store of environment facts, populated once at init.
// Env is environment. Config is ours.
//
// System keys:
//
// core.Env("OS") // "darwin"
// core.Env("ARCH") // "arm64"
// core.Env("GO") // "go1.26"
// core.Env("DS") // "/" (directory separator)
// core.Env("PS") // ":" (path list separator)
// core.Env("HOSTNAME") // "cladius"
// core.Env("USER") // "snider"
// core.Env("PID") // "12345"
// core.Env("NUM_CPU") // "10"
//
// Directory keys:
//
// core.Env("DIR_HOME") // "/Users/snider"
// core.Env("DIR_CONFIG") // "~/Library/Application Support"
// core.Env("DIR_CACHE") // "~/Library/Caches"
// core.Env("DIR_DATA") // "~/Library" (platform-specific)
// core.Env("DIR_TMP") // "/tmp"
// core.Env("DIR_CWD") // current working directory
// core.Env("DIR_DOWNLOADS") // "~/Downloads"
// core.Env("DIR_CODE") // "~/Code"
//
// Timestamp keys:
//
// core.Env("CORE_START") // "2026-03-22T14:30:00Z"
package core
import (
"os"
"runtime"
"strconv"
"time"
)
// SysInfo holds read-only system information, populated once at init.
type SysInfo struct {
values map[string]string
}
// systemInfo is declared empty — populated in init() so Path() can be used
// without creating an init cycle.
var systemInfo = &SysInfo{values: make(map[string]string)}
func init() {
i := systemInfo
// System
i.values["OS"] = runtime.GOOS
i.values["ARCH"] = runtime.GOARCH
i.values["GO"] = runtime.Version()
i.values["DS"] = string(os.PathSeparator)
i.values["PS"] = string(os.PathListSeparator)
i.values["PID"] = strconv.Itoa(os.Getpid())
i.values["NUM_CPU"] = strconv.Itoa(runtime.NumCPU())
i.values["USER"] = Username()
if h, err := os.Hostname(); err == nil {
i.values["HOSTNAME"] = h
}
// Directories — DS and DIR_HOME set first so Path() can use them.
// CORE_HOME overrides os.UserHomeDir() (e.g., agent workspaces).
if d := os.Getenv("CORE_HOME"); d != "" {
i.values["DIR_HOME"] = d
} else if d, err := os.UserHomeDir(); err == nil {
i.values["DIR_HOME"] = d
}
// Derived directories via Path() — single point of responsibility
i.values["DIR_DOWNLOADS"] = Path("Downloads")
i.values["DIR_CODE"] = Path("Code")
if d, err := os.UserConfigDir(); err == nil {
i.values["DIR_CONFIG"] = d
}
if d, err := os.UserCacheDir(); err == nil {
i.values["DIR_CACHE"] = d
}
i.values["DIR_TMP"] = os.TempDir()
if d, err := os.Getwd(); err == nil {
i.values["DIR_CWD"] = d
}
// Platform-specific data directory
switch runtime.GOOS {
case "darwin":
i.values["DIR_DATA"] = Path(Env("DIR_HOME"), "Library")
case "windows":
if d := os.Getenv("LOCALAPPDATA"); d != "" {
i.values["DIR_DATA"] = d
}
default:
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
i.values["DIR_DATA"] = xdg
} else if Env("DIR_HOME") != "" {
i.values["DIR_DATA"] = Path(Env("DIR_HOME"), ".local", "share")
}
}
// Timestamps
i.values["CORE_START"] = time.Now().UTC().Format(time.RFC3339)
}
// Env returns a system information value by key.
// Core keys (OS, DIR_HOME, DS, etc.) are pre-populated at init.
// Unknown keys fall through to os.Getenv — making Env a universal
// replacement for os.Getenv.
//
// core.Env("OS") // "darwin" (pre-populated)
// core.Env("DIR_HOME") // "/Users/snider" (pre-populated)
// core.Env("FORGE_TOKEN") // falls through to os.Getenv
func Env(key string) string {
if v := systemInfo.values[key]; v != "" {
return v
}
return os.Getenv(key)
}
// EnvKeys returns all available environment keys.
//
// keys := core.EnvKeys()
func EnvKeys() []string {
keys := make([]string, 0, len(systemInfo.values))
for k := range systemInfo.values {
keys = append(keys, k)
}
return keys
}

View file

@ -1,17 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleEnv() {
Println(Env("OS")) // e.g. "darwin"
Println(Env("ARCH")) // e.g. "arm64"
}
func ExampleEnvKeys() {
keys := EnvKeys()
Println(len(keys) > 0)
// Output: true
}

View file

@ -1,97 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package core_test
import (
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInfo_Env_OS_Good(t *testing.T) {
v := core.Env("OS")
assert.NotEmpty(t, v)
assert.Contains(t, []string{"darwin", "linux", "windows"}, v)
}
func TestInfo_Env_ARCH_Good(t *testing.T) {
v := core.Env("ARCH")
assert.NotEmpty(t, v)
assert.Contains(t, []string{"amd64", "arm64", "386"}, v)
}
func TestInfo_Env_GO_Good(t *testing.T) {
assert.True(t, core.HasPrefix(core.Env("GO"), "go"))
}
func TestInfo_Env_DS_Good(t *testing.T) {
ds := core.Env("DS")
assert.Contains(t, []string{"/", "\\"}, ds)
}
func TestInfo_Env_PS_Good(t *testing.T) {
ps := core.Env("PS")
assert.Contains(t, []string{":", ";"}, ps)
}
func TestInfo_Env_DIR_HOME_Good(t *testing.T) {
home := core.Env("DIR_HOME")
assert.NotEmpty(t, home)
assert.True(t, core.PathIsAbs(home), "DIR_HOME should be absolute")
}
func TestInfo_Env_DIR_TMP_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("DIR_TMP"))
}
func TestInfo_Env_DIR_CONFIG_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("DIR_CONFIG"))
}
func TestInfo_Env_DIR_CACHE_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("DIR_CACHE"))
}
func TestInfo_Env_HOSTNAME_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("HOSTNAME"))
}
func TestInfo_Env_USER_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("USER"))
}
func TestInfo_Env_PID_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("PID"))
}
func TestInfo_Env_NUM_CPU_Good(t *testing.T) {
assert.NotEmpty(t, core.Env("NUM_CPU"))
}
func TestInfo_Env_CORE_START_Good(t *testing.T) {
ts := core.Env("CORE_START")
require.NotEmpty(t, ts)
_, err := time.Parse(time.RFC3339, ts)
assert.NoError(t, err, "CORE_START should be valid RFC3339")
}
func TestInfo_Env_Bad_Unknown(t *testing.T) {
assert.Equal(t, "", core.Env("NOPE"))
}
func TestInfo_Env_Good_CoreInstance(t *testing.T) {
c := core.New()
assert.Equal(t, core.Env("OS"), c.Env("OS"))
assert.Equal(t, core.Env("DIR_HOME"), c.Env("DIR_HOME"))
}
func TestInfo_EnvKeys_Good(t *testing.T) {
keys := core.EnvKeys()
assert.NotEmpty(t, keys)
assert.Contains(t, keys, "OS")
assert.Contains(t, keys, "DIR_HOME")
assert.Contains(t, keys, "CORE_START")
}

113
ipc.go
View file

@ -1,113 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Message bus for the Core framework.
// Dispatches actions (fire-and-forget), queries (first responder),
// and tasks (first executor) between registered handlers.
package core
import (
"slices"
"sync"
)
// Ipc holds IPC dispatch data and the named action registry.
//
// ipc := (&core.Ipc{}).New()
type Ipc struct {
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) Result
queryMu sync.RWMutex
queryHandlers []QueryHandler
actions *Registry[*Action] // named action registry
tasks *Registry[*Task] // named task registry
}
// broadcast dispatches a message to all registered IPC handlers.
// Each handler is wrapped in panic recovery. All handlers fire regardless of individual results.
func (c *Core) broadcast(msg Message) Result {
c.ipc.ipcMu.RLock()
handlers := slices.Clone(c.ipc.ipcHandlers)
c.ipc.ipcMu.RUnlock()
for _, h := range handlers {
func() {
defer func() {
if r := recover(); r != nil {
Error("ACTION handler panicked", "panic", r)
}
}()
h(c, msg)
}()
}
return Result{OK: true}
}
// Query dispatches a request — first handler to return OK wins.
//
// r := c.Query(MyQuery{})
func (c *Core) Query(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
for _, h := range handlers {
r := h(c, q)
if r.OK {
return r
}
}
return Result{}
}
// QueryAll dispatches a request — collects all OK responses.
//
// r := c.QueryAll(countQuery{})
// results := r.Value.([]any)
func (c *Core) QueryAll(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
var results []any
for _, h := range handlers {
r := h(c, q)
if r.OK && r.Value != nil {
results = append(results, r.Value)
}
}
return Result{results, true}
}
// RegisterQuery registers a handler for QUERY dispatch.
//
// c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { ... })
func (c *Core) RegisterQuery(handler QueryHandler) {
c.ipc.queryMu.Lock()
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
c.ipc.queryMu.Unlock()
}
// --- IPC Registration (handlers) ---
// RegisterAction registers a broadcast handler for ACTION messages.
//
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
// if ev, ok := msg.(AgentCompleted); ok { ... }
// return core.Result{OK: true}
// })
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
c.ipc.ipcMu.Unlock()
}
// RegisterActions registers multiple broadcast handlers.
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
c.ipc.ipcMu.Unlock()
}

View file

@ -1,142 +0,0 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- IPC: Actions ---
type testMessage struct{ payload string }
func TestAction_Good(t *testing.T) {
c := New()
var received Message
c.RegisterAction(func(_ *Core, msg Message) Result {
received = msg
return Result{OK: true}
})
r := c.ACTION(testMessage{payload: "hello"})
assert.True(t, r.OK)
assert.Equal(t, testMessage{payload: "hello"}, received)
}
func TestAction_Multiple_Good(t *testing.T) {
c := New()
count := 0
handler := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
c.RegisterActions(handler, handler, handler)
c.ACTION(nil)
assert.Equal(t, 3, count)
}
func TestAction_None_Good(t *testing.T) {
c := New()
// No handlers registered — should succeed
r := c.ACTION(nil)
assert.True(t, r.OK)
}
func TestAction_Bad_HandlerFails(t *testing.T) {
c := New()
c.RegisterAction(func(_ *Core, _ Message) Result {
return Result{Value: NewError("intentional"), OK: false}
})
// ACTION is broadcast — even with a failing handler, dispatch succeeds
r := c.ACTION(testMessage{payload: "test"})
assert.True(t, r.OK)
}
func TestAction_Ugly_HandlerFailsChainContinues(t *testing.T) {
c := New()
var order []int
c.RegisterAction(func(_ *Core, _ Message) Result {
order = append(order, 1)
return Result{OK: true}
})
c.RegisterAction(func(_ *Core, _ Message) Result {
order = append(order, 2)
return Result{Value: NewError("handler 2 fails"), OK: false}
})
c.RegisterAction(func(_ *Core, _ Message) Result {
order = append(order, 3)
return Result{OK: true}
})
r := c.ACTION(testMessage{payload: "test"})
assert.True(t, r.OK)
assert.Equal(t, []int{1, 2, 3}, order, "all 3 handlers must fire even when handler 2 returns !OK")
}
func TestAction_Ugly_HandlerPanicsChainContinues(t *testing.T) {
c := New()
var order []int
c.RegisterAction(func(_ *Core, _ Message) Result {
order = append(order, 1)
return Result{OK: true}
})
c.RegisterAction(func(_ *Core, _ Message) Result {
panic("handler 2 explodes")
})
c.RegisterAction(func(_ *Core, _ Message) Result {
order = append(order, 3)
return Result{OK: true}
})
r := c.ACTION(testMessage{payload: "test"})
assert.True(t, r.OK)
assert.Equal(t, []int{1, 3}, order, "handlers 1 and 3 must fire even when handler 2 panics")
}
// --- IPC: Queries ---
func TestIpc_Query_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, q Query) Result {
if q == "ping" {
return Result{Value: "pong", OK: true}
}
return Result{}
})
r := c.QUERY("ping")
assert.True(t, r.OK)
assert.Equal(t, "pong", r.Value)
}
func TestIpc_Query_Unhandled_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, q Query) Result {
return Result{}
})
r := c.QUERY("unknown")
assert.False(t, r.OK)
}
func TestIpc_QueryAll_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, _ Query) Result {
return Result{Value: "a", OK: true}
})
c.RegisterQuery(func(_ *Core, _ Query) Result {
return Result{Value: "b", OK: true}
})
r := c.QUERYALL("anything")
assert.True(t, r.OK)
results := r.Value.([]any)
assert.Len(t, results, 2)
assert.Contains(t, results, "a")
assert.Contains(t, results, "b")
}
// --- IPC: Named Action Invocation ---
func TestIpc_ActionInvoke_Good(t *testing.T) {
c := New()
c.Action("compute", func(_ context.Context, opts Options) Result {
return Result{Value: 42, OK: true}
})
r := c.Action("compute").Run(context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Equal(t, 42, r.Value)
}

58
json.go
View file

@ -1,58 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// JSON helpers for the Core framework.
// Wraps encoding/json so consumers don't import it directly.
// Same guardrail pattern as string.go wraps strings.
//
// Usage:
//
// data := core.JSONMarshal(myStruct)
// if data.OK { json := data.Value.([]byte) }
//
// r := core.JSONUnmarshal(jsonBytes, &target)
// if !r.OK { /* handle error */ }
package core
import "encoding/json"
// JSONMarshal serialises a value to JSON bytes.
//
// r := core.JSONMarshal(myStruct)
// if r.OK { data := r.Value.([]byte) }
func JSONMarshal(v any) Result {
data, err := json.Marshal(v)
if err != nil {
return Result{err, false}
}
return Result{data, true}
}
// JSONMarshalString serialises a value to a JSON string.
//
// s := core.JSONMarshalString(myStruct)
func JSONMarshalString(v any) string {
data, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(data)
}
// JSONUnmarshal deserialises JSON bytes into a target.
//
// var cfg Config
// r := core.JSONUnmarshal(data, &cfg)
func JSONUnmarshal(data []byte, target any) Result {
if err := json.Unmarshal(data, target); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// JSONUnmarshalString deserialises a JSON string into a target.
//
// var cfg Config
// r := core.JSONUnmarshalString(`{"port":8080}`, &cfg)
func JSONUnmarshalString(s string, target any) Result {
return JSONUnmarshal([]byte(s), target)
}

View file

@ -1,63 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
type testJSON struct {
Name string `json:"name"`
Port int `json:"port"`
}
// --- JSONMarshal ---
func TestJson_JSONMarshal_Good(t *testing.T) {
r := JSONMarshal(testJSON{Name: "brain", Port: 8080})
assert.True(t, r.OK)
assert.Contains(t, string(r.Value.([]byte)), `"name":"brain"`)
}
func TestJson_JSONMarshal_Bad_Unmarshalable(t *testing.T) {
r := JSONMarshal(make(chan int))
assert.False(t, r.OK)
}
// --- JSONMarshalString ---
func TestJson_JSONMarshalString_Good(t *testing.T) {
s := JSONMarshalString(testJSON{Name: "x", Port: 1})
assert.Contains(t, s, `"name":"x"`)
}
func TestJson_JSONMarshalString_Ugly_Fallback(t *testing.T) {
s := JSONMarshalString(make(chan int))
assert.Equal(t, "{}", s)
}
// --- JSONUnmarshal ---
func TestJson_JSONUnmarshal_Good(t *testing.T) {
var target testJSON
r := JSONUnmarshal([]byte(`{"name":"brain","port":8080}`), &target)
assert.True(t, r.OK)
assert.Equal(t, "brain", target.Name)
assert.Equal(t, 8080, target.Port)
}
func TestJson_JSONUnmarshal_Bad_Invalid(t *testing.T) {
var target testJSON
r := JSONUnmarshal([]byte(`not json`), &target)
assert.False(t, r.OK)
}
// --- JSONUnmarshalString ---
func TestJson_JSONUnmarshalString_Good(t *testing.T) {
var target testJSON
r := JSONUnmarshalString(`{"name":"x","port":1}`, &target)
assert.True(t, r.OK)
assert.Equal(t, "x", target.Name)
}

46
llm.txt
View file

@ -1,46 +0,0 @@
# core/go — CoreGO Framework
> dappco.re/go/core — Dependency injection, service lifecycle, permission,
> and message-passing framework for Go. Foundation layer for the Lethean ecosystem.
## Entry Points
- CLAUDE.md — Agent instructions, build commands, subsystem table
- docs/RFC.md — API contract specification (21 sections, the authoritative spec)
## Package Layout
All source files at module root. No pkg/ nesting. Tests are *_test.go alongside source.
## Key Types
- Core — Central application container (core.New() returns *Core)
- Option — Single key-value pair {Key: string, Value: any}
- Options — Collection of Option with typed accessors
- Result — Universal return type {Value: any, OK: bool}
- Service — Managed component with lifecycle (Startable/Stoppable return Result)
- Action — Named callable with panic recovery and entitlement enforcement
- Task — Composed sequence of Actions (Steps, Async, Input piping)
- Registry[T] — Thread-safe named collection (universal brick)
- Process — Managed execution (sugar over Actions)
- API — Remote streams (protocol handlers, Drive integration)
- Entitlement — Permission check result (Allowed, Limit, Used, Remaining)
- Message — IPC broadcast type for ACTION
- Query — IPC request/response type for QUERY
## Service Pattern
core.New(
core.WithService(mypackage.Register),
)
func Register(c *core.Core) core.Result {
svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, opts)}
return core.Result{Value: svc, OK: true}
}
## Conventions
Follows RFC-025 Agent Experience (AX) principles.
Tests: TestFile_Function_{Good,Bad,Ugly} — 100% AX-7 naming.
See: https://core.help/specs/RFC-025-AGENT-EXPERIENCE/

68
lock.go
View file

@ -1,68 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Synchronisation, locking, and lifecycle snapshots for the Core framework.
package core
import (
"sync"
)
// Lock is the DTO for a named mutex.
type Lock struct {
Name string
Mutex *sync.RWMutex
locks *Registry[*sync.RWMutex] // per-Core named mutexes
}
// Lock returns a named Lock, creating the mutex if needed.
// Locks are per-Core — separate Core instances do not share mutexes.
func (c *Core) Lock(name string) *Lock {
r := c.lock.locks.Get(name)
if r.OK {
return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)}
}
m := &sync.RWMutex{}
c.lock.locks.Set(name, m)
return &Lock{Name: name, Mutex: m}
}
// LockEnable marks that the service lock should be applied after initialisation.
func (c *Core) LockEnable(name ...string) {
c.services.lockEnabled = true
}
// LockApply activates the service lock if it was enabled.
func (c *Core) LockApply(name ...string) {
if c.services.lockEnabled {
c.services.Lock()
}
}
// Startables returns services that have an OnStart function, in registration order.
func (c *Core) Startables() Result {
if c.services == nil {
return Result{}
}
var out []*Service
c.services.Each(func(_ string, svc *Service) {
if svc.OnStart != nil {
out = append(out, svc)
}
})
return Result{out, true}
}
// Stoppables returns services that have an OnStop function, in registration order.
func (c *Core) Stoppables() Result {
if c.services == nil {
return Result{}
}
var out []*Service
c.services.Each(func(_ string, svc *Service) {
if svc.OnStop != nil {
out = append(out, svc)
}
})
return Result{out, true}
}

View file

@ -1,18 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleCore_Lock() {
c := New()
lock := c.Lock("drain")
lock.Mutex.Lock()
Println("locked")
lock.Mutex.Unlock()
Println("unlocked")
// Output:
// locked
// unlocked
}

View file

@ -1,55 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
func TestLock_Good(t *testing.T) {
c := New()
lock := c.Lock("test")
assert.NotNil(t, lock)
assert.NotNil(t, lock.Mutex)
}
func TestLock_SameName_Good(t *testing.T) {
c := New()
l1 := c.Lock("shared")
l2 := c.Lock("shared")
assert.Equal(t, l1, l2)
}
func TestLock_DifferentName_Good(t *testing.T) {
c := New()
l1 := c.Lock("a")
l2 := c.Lock("b")
assert.NotEqual(t, l1, l2)
}
func TestLock_LockEnable_Good(t *testing.T) {
c := New()
c.Service("early", Service{})
c.LockEnable()
c.LockApply()
r := c.Service("late", Service{})
assert.False(t, r.OK)
}
func TestLock_Startables_Good(t *testing.T) {
c := New()
c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }})
r := c.Startables()
assert.True(t, r.OK)
assert.Len(t, r.Value.([]*Service), 1)
}
func TestLock_Stoppables_Good(t *testing.T) {
c := New()
c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }})
r := c.Stoppables()
assert.True(t, r.OK)
assert.Len(t, r.Value.([]*Service), 1)
}

402
log.go
View file

@ -1,402 +0,0 @@
// Structured logging for the Core framework.
//
// core.SetLevel(core.LevelDebug)
// core.Info("server started", "port", 8080)
// core.Error("failed to connect", "err", err)
package core
import (
goio "io"
"os"
"os/user"
"slices"
"sync"
"sync/atomic"
"time"
)
// Level defines logging verbosity.
type Level int
// Logging level constants ordered by increasing verbosity.
const (
// LevelQuiet suppresses all log output.
LevelQuiet Level = iota
// LevelError shows only error messages.
LevelError
// LevelWarn shows warnings and errors.
LevelWarn
// LevelInfo shows informational messages, warnings, and errors.
LevelInfo
// LevelDebug shows all messages including debug details.
LevelDebug
)
// String returns the level name.
func (l Level) String() string {
switch l {
case LevelQuiet:
return "quiet"
case LevelError:
return "error"
case LevelWarn:
return "warn"
case LevelInfo:
return "info"
case LevelDebug:
return "debug"
default:
return "unknown"
}
}
// Log provides structured logging.
type Log struct {
mu sync.RWMutex
level Level
output goio.Writer
// RedactKeys is a list of keys whose values should be masked in logs.
redactKeys []string
// Style functions for formatting (can be overridden)
StyleTimestamp func(string) string
StyleDebug func(string) string
StyleInfo func(string) string
StyleWarn func(string) string
StyleError func(string) string
StyleSecurity func(string) string
}
// RotationLogOptions defines the log rotation and retention policy.
type RotationLogOptions struct {
// Filename is the log file path. If empty, rotation is disabled.
Filename string
// MaxSize is the maximum size of the log file in megabytes before it gets rotated.
// It defaults to 100 megabytes.
MaxSize int
// MaxAge is the maximum number of days to retain old log files based on their
// file modification time. It defaults to 28 days.
// Note: set to a negative value to disable age-based retention.
MaxAge int
// MaxBackups is the maximum number of old log files to retain.
// It defaults to 5 backups.
MaxBackups int
// Compress determines if the rotated log files should be compressed using gzip.
// It defaults to true.
Compress bool
}
// LogOptions configures a Log.
type LogOptions struct {
Level Level
// Output is the destination for log messages. If Rotation is provided,
// Output is ignored and logs are written to the rotating file instead.
Output goio.Writer
// Rotation enables log rotation to file. If provided, Filename must be set.
Rotation *RotationLogOptions
// RedactKeys is a list of keys whose values should be masked in logs.
RedactKeys []string
}
// RotationWriterFactory creates a rotating writer from options.
// Set this to enable log rotation (provided by core/go-io integration).
var RotationWriterFactory func(RotationLogOptions) goio.WriteCloser
// New creates a new Log with the given options.
func NewLog(opts LogOptions) *Log {
output := opts.Output
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
output = RotationWriterFactory(*opts.Rotation)
}
if output == nil {
output = os.Stderr
}
return &Log{
level: opts.Level,
output: output,
redactKeys: slices.Clone(opts.RedactKeys),
StyleTimestamp: identity,
StyleDebug: identity,
StyleInfo: identity,
StyleWarn: identity,
StyleError: identity,
StyleSecurity: identity,
}
}
func identity(s string) string { return s }
// SetLevel changes the log level.
func (l *Log) SetLevel(level Level) {
l.mu.Lock()
l.level = level
l.mu.Unlock()
}
// Level returns the current log level.
func (l *Log) Level() Level {
l.mu.RLock()
defer l.mu.RUnlock()
return l.level
}
// SetOutput changes the output writer.
func (l *Log) SetOutput(w goio.Writer) {
l.mu.Lock()
l.output = w
l.mu.Unlock()
}
// SetRedactKeys sets the keys to be redacted.
func (l *Log) SetRedactKeys(keys ...string) {
l.mu.Lock()
l.redactKeys = slices.Clone(keys)
l.mu.Unlock()
}
func (l *Log) shouldLog(level Level) bool {
l.mu.RLock()
defer l.mu.RUnlock()
return level <= l.level
}
func (l *Log) log(level Level, prefix, msg string, keyvals ...any) {
l.mu.RLock()
output := l.output
styleTimestamp := l.StyleTimestamp
redactKeys := l.redactKeys
l.mu.RUnlock()
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
// Copy keyvals to avoid mutating the caller's slice
keyvals = append([]any(nil), keyvals...)
// Automatically extract context from error if present in keyvals
origLen := len(keyvals)
for i := 0; i < origLen; i += 2 {
if i+1 < origLen {
if err, ok := keyvals[i+1].(error); ok {
if op := Operation(err); op != "" {
// Check if op is already in keyvals
hasOp := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "op" {
hasOp = true
break
}
}
if !hasOp {
keyvals = append(keyvals, "op", op)
}
}
if stack := FormatStackTrace(err); stack != "" {
// Check if stack is already in keyvals
hasStack := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "stack" {
hasStack = true
break
}
}
if !hasStack {
keyvals = append(keyvals, "stack", stack)
}
}
}
}
}
// Format key-value pairs
var kvStr string
if len(keyvals) > 0 {
kvStr = " "
for i := 0; i < len(keyvals); i += 2 {
if i > 0 {
kvStr += " "
}
key := keyvals[i]
var val any
if i+1 < len(keyvals) {
val = keyvals[i+1]
}
// Redaction logic
keyStr := Sprint(key)
if slices.Contains(redactKeys, keyStr) {
val = "[REDACTED]"
}
// Secure formatting to prevent log injection
if s, ok := val.(string); ok {
kvStr += Sprintf("%v=%q", key, s)
} else {
kvStr += Sprintf("%v=%v", key, val)
}
}
}
Print(output, "%s %s %s%s", timestamp, prefix, msg, kvStr)
}
// Debug logs a debug message with optional key-value pairs.
func (l *Log) Debug(msg string, keyvals ...any) {
if l.shouldLog(LevelDebug) {
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
}
}
// Info logs an info message with optional key-value pairs.
func (l *Log) Info(msg string, keyvals ...any) {
if l.shouldLog(LevelInfo) {
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
}
}
// Warn logs a warning message with optional key-value pairs.
func (l *Log) Warn(msg string, keyvals ...any) {
if l.shouldLog(LevelWarn) {
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
}
}
// Error logs an error message with optional key-value pairs.
func (l *Log) Error(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
}
}
// Security logs a security event with optional key-value pairs.
// It uses LevelError to ensure security events are visible even in restrictive
// log configurations.
func (l *Log) Security(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
}
}
// Username returns the current system username.
// It uses os/user for reliability and falls back to environment variables.
func Username() string {
if u, err := user.Current(); err == nil {
return u.Username
}
// Fallback for environments where user lookup might fail
if u := os.Getenv("USER"); u != "" {
return u
}
return os.Getenv("USERNAME")
}
// --- Default logger ---
var defaultLogPtr atomic.Pointer[Log]
func init() {
l := NewLog(LogOptions{Level: LevelInfo})
defaultLogPtr.Store(l)
}
// Default returns the default logger.
func Default() *Log {
return defaultLogPtr.Load()
}
// SetDefault sets the default logger.
func SetDefault(l *Log) {
defaultLogPtr.Store(l)
}
// SetLevel sets the default logger's level.
func SetLevel(level Level) {
Default().SetLevel(level)
}
// SetRedactKeys sets the default logger's redaction keys.
func SetRedactKeys(keys ...string) {
Default().SetRedactKeys(keys...)
}
// Debug logs to the default logger.
func Debug(msg string, keyvals ...any) {
Default().Debug(msg, keyvals...)
}
// Info logs to the default logger.
func Info(msg string, keyvals ...any) {
Default().Info(msg, keyvals...)
}
// Warn logs to the default logger.
func Warn(msg string, keyvals ...any) {
Default().Warn(msg, keyvals...)
}
// Error logs to the default logger.
func Error(msg string, keyvals ...any) {
Default().Error(msg, keyvals...)
}
// Security logs to the default logger.
func Security(msg string, keyvals ...any) {
Default().Security(msg, keyvals...)
}
// --- LogErr: Error-Aware Logger ---
// LogErr logs structured information extracted from errors.
// Primary action: log. Secondary: extract error context.
type LogErr struct {
log *Log
}
// NewLogErr creates a LogErr bound to the given logger.
func NewLogErr(log *Log) *LogErr {
return &LogErr{log: log}
}
// Log extracts context from an Err and logs it at Error level.
func (le *LogErr) Log(err error) {
if err == nil {
return
}
le.log.Error(ErrorMessage(err), "op", Operation(err), "code", ErrorCode(err), "stack", FormatStackTrace(err))
}
// --- LogPanic: Panic-Aware Logger ---
// LogPanic logs panic context without crash file management.
// Primary action: log. Secondary: recover panics.
type LogPanic struct {
log *Log
}
// NewLogPanic creates a LogPanic bound to the given logger.
func NewLogPanic(log *Log) *LogPanic {
return &LogPanic{log: log}
}
// Recover captures a panic and logs it. Does not write crash files.
// Use as: defer core.NewLogPanic(logger).Recover()
func (lp *LogPanic) Recover() {
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = NewError(Sprint("panic: ", r))
}
lp.log.Error("panic recovered",
"err", err,
"op", Operation(err),
"stack", FormatStackTrace(err),
)
}

View file

@ -1,15 +0,0 @@
package core_test
import . "dappco.re/go/core"
func ExampleInfo() {
Info("server started", "port", 8080)
}
func ExampleWarn() {
Warn("deprecated", "feature", "old-api")
}
func ExampleSecurity() {
Security("access denied", "user", "unknown", "action", "admin.nuke")
}

View file

@ -1,164 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Log ---
func TestLog_New_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
assert.NotNil(t, l)
}
func TestLog_AllLevels_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelDebug})
l.Debug("debug")
l.Info("info")
l.Warn("warn")
l.Error("error")
l.Security("security event")
}
func TestLog_LevelFiltering_Good(t *testing.T) {
// At Error level, Debug/Info/Warn should be suppressed (no panic)
l := NewLog(LogOptions{Level: LevelError})
l.Debug("suppressed")
l.Info("suppressed")
l.Warn("suppressed")
l.Error("visible")
}
func TestLog_SetLevel_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
l.SetLevel(LevelDebug)
assert.Equal(t, LevelDebug, l.Level())
}
func TestLog_SetRedactKeys_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
l.SetRedactKeys("password", "token")
// Redacted keys should mask values in output
l.Info("login", "password", "secret123", "user", "admin")
}
func TestLog_LevelString_Good(t *testing.T) {
assert.Equal(t, "debug", LevelDebug.String())
assert.Equal(t, "info", LevelInfo.String())
assert.Equal(t, "warn", LevelWarn.String())
assert.Equal(t, "error", LevelError.String())
}
func TestLog_CoreLog_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.Log())
}
func TestLog_ErrorSink_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
var sink ErrorSink = l
sink.Error("test")
sink.Warn("test")
}
// --- Default Logger ---
func TestLog_Default_Good(t *testing.T) {
d := Default()
assert.NotNil(t, d)
}
func TestLog_SetDefault_Good(t *testing.T) {
original := Default()
defer SetDefault(original)
custom := NewLog(LogOptions{Level: LevelDebug})
SetDefault(custom)
assert.Equal(t, custom, Default())
}
func TestLog_PackageLevelFunctions_Good(t *testing.T) {
// Package-level log functions use the default logger
Debug("debug msg")
Info("info msg")
Warn("warn msg")
Error("error msg")
Security("security msg")
}
func TestLog_PackageSetLevel_Good(t *testing.T) {
original := Default()
defer SetDefault(original)
SetLevel(LevelDebug)
SetRedactKeys("secret")
}
func TestLog_Username_Good(t *testing.T) {
u := Username()
assert.NotEmpty(t, u)
}
// --- LogErr ---
func TestLog_LogErr_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
le := NewLogErr(l)
assert.NotNil(t, le)
err := E("test.Operation", "something broke", nil)
le.Log(err)
}
func TestLog_LogErr_Nil_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
le := NewLogErr(l)
le.Log(nil) // should not panic
}
// --- LogPanic ---
func TestLog_LogPanic_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
lp := NewLogPanic(l)
assert.NotNil(t, lp)
}
func TestLog_LogPanic_Recover_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
lp := NewLogPanic(l)
assert.NotPanics(t, func() {
defer lp.Recover()
panic("caught")
})
}
// --- SetOutput ---
func TestLog_SetOutput_Good(t *testing.T) {
l := NewLog(LogOptions{Level: LevelInfo})
l.SetOutput(NewBuilder())
l.Info("redirected")
}
// --- Log suppression by level ---
func TestLog_Quiet_Suppresses_Ugly(t *testing.T) {
l := NewLog(LogOptions{Level: LevelQuiet})
// These should not panic even though nothing is logged
l.Debug("suppressed")
l.Info("suppressed")
l.Warn("suppressed")
l.Error("suppressed")
}
func TestLog_ErrorLevel_Suppresses_Ugly(t *testing.T) {
l := NewLog(LogOptions{Level: LevelError})
l.Debug("suppressed") // below threshold
l.Info("suppressed") // below threshold
l.Warn("suppressed") // below threshold
l.Error("visible") // at threshold
}

View file

@ -1,197 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Core primitives: Option, Options, Result.
//
// Options is the universal input type. Result is the universal output type.
// All Core operations accept Options and return Result.
//
// opts := core.NewOptions(
// core.Option{Key: "name", Value: "brain"},
// core.Option{Key: "path", Value: "prompts"},
// )
// r := c.Drive().New(opts)
// if !r.OK { log.Fatal(r.Error()) }
package core
// --- Result: Universal Output ---
// Result is the universal return type for Core operations.
// Replaces the (value, error) pattern — errors flow through Core internally.
//
// r := c.Data().New(opts)
// if !r.OK { core.Error("failed", "err", r.Error()) }
type Result struct {
Value any
OK bool
}
// Result gets or sets the value. Zero args returns Value. With args, maps
// Go (value, error) pairs to Result and returns self.
//
// r.Result(file, err) // OK = err == nil, Value = file
// r.Result(value) // OK = true, Value = value
// r.Result() // after set — returns the value
func (r Result) Result(args ...any) Result {
if len(args) == 0 {
return r
}
return r.New(args...)
}
// New adapts Go (value, error) pairs into a Result.
//
// r := core.Result{}.New(file, err)
func (r Result) New(args ...any) Result {
if len(args) == 0 {
return r
}
if len(args) > 1 {
if err, ok := args[len(args)-1].(error); ok {
if err != nil {
return Result{Value: err, OK: false}
}
r.Value = args[0]
r.OK = true
return r
}
}
r.Value = args[0]
if err, ok := r.Value.(error); ok {
if err != nil {
return Result{Value: err, OK: false}
}
return Result{OK: true}
}
r.OK = true
return r
}
// Get returns the Result if OK, empty Result otherwise.
//
// r := core.Result{Value: "hello", OK: true}.Get()
func (r Result) Get() Result {
if r.OK {
return r
}
return Result{Value: r.Value, OK: false}
}
// Option is a single key-value configuration pair.
//
// core.Option{Key: "name", Value: "brain"}
// core.Option{Key: "port", Value: 8080}
type Option struct {
Key string
Value any
}
// --- Options: Universal Input ---
// Options is the universal input type for Core operations.
// A structured collection of key-value pairs with typed accessors.
//
// opts := core.NewOptions(
// core.Option{Key: "name", Value: "myapp"},
// core.Option{Key: "port", Value: 8080},
// )
// name := opts.String("name")
type Options struct {
items []Option
}
// NewOptions creates an Options collection from key-value pairs.
//
// opts := core.NewOptions(
// core.Option{Key: "name", Value: "brain"},
// core.Option{Key: "path", Value: "prompts"},
// )
func NewOptions(items ...Option) Options {
cp := make([]Option, len(items))
copy(cp, items)
return Options{items: cp}
}
// Set adds or updates a key-value pair.
//
// opts.Set("port", 8080)
func (o *Options) Set(key string, value any) {
for i, opt := range o.items {
if opt.Key == key {
o.items[i].Value = value
return
}
}
o.items = append(o.items, Option{Key: key, Value: value})
}
// Get retrieves a value by key.
//
// r := opts.Get("name")
// if r.OK { name := r.Value.(string) }
func (o Options) Get(key string) Result {
for _, opt := range o.items {
if opt.Key == key {
return Result{opt.Value, true}
}
}
return Result{}
}
// Has returns true if a key exists.
//
// if opts.Has("debug") { ... }
func (o Options) Has(key string) bool {
return o.Get(key).OK
}
// String retrieves a string value, empty string if missing.
//
// name := opts.String("name")
func (o Options) String(key string) string {
r := o.Get(key)
if !r.OK {
return ""
}
s, _ := r.Value.(string)
return s
}
// Int retrieves an int value, 0 if missing.
//
// port := opts.Int("port")
func (o Options) Int(key string) int {
r := o.Get(key)
if !r.OK {
return 0
}
i, _ := r.Value.(int)
return i
}
// Bool retrieves a bool value, false if missing.
//
// debug := opts.Bool("debug")
func (o Options) Bool(key string) bool {
r := o.Get(key)
if !r.OK {
return false
}
b, _ := r.Value.(bool)
return b
}
// Len returns the number of options.
func (o Options) Len() int {
return len(o.items)
}
// Items returns a copy of the underlying option slice.
func (o Options) Items() []Option {
cp := make([]Option, len(o.items))
copy(cp, o.items)
return cp
}

View file

@ -1,177 +0,0 @@
package core_test
import (
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- NewOptions ---
func TestOptions_NewOptions_Good(t *testing.T) {
opts := NewOptions(
Option{Key: "name", Value: "brain"},
Option{Key: "port", Value: 8080},
)
assert.Equal(t, 2, opts.Len())
}
func TestOptions_NewOptions_Empty_Good(t *testing.T) {
opts := NewOptions()
assert.Equal(t, 0, opts.Len())
assert.False(t, opts.Has("anything"))
}
// --- Options.Set ---
func TestOptions_Set_Good(t *testing.T) {
opts := NewOptions()
opts.Set("name", "brain")
assert.Equal(t, "brain", opts.String("name"))
}
func TestOptions_Set_Update_Good(t *testing.T) {
opts := NewOptions(Option{Key: "name", Value: "old"})
opts.Set("name", "new")
assert.Equal(t, "new", opts.String("name"))
assert.Equal(t, 1, opts.Len())
}
// --- Options.Get ---
func TestOptions_Get_Good(t *testing.T) {
opts := NewOptions(
Option{Key: "name", Value: "brain"},
Option{Key: "port", Value: 8080},
)
r := opts.Get("name")
assert.True(t, r.OK)
assert.Equal(t, "brain", r.Value)
}
func TestOptions_Get_Bad(t *testing.T) {
opts := NewOptions(Option{Key: "name", Value: "brain"})
r := opts.Get("missing")
assert.False(t, r.OK)
assert.Nil(t, r.Value)
}
// --- Options.Has ---
func TestOptions_Has_Good(t *testing.T) {
opts := NewOptions(Option{Key: "debug", Value: true})
assert.True(t, opts.Has("debug"))
assert.False(t, opts.Has("missing"))
}
// --- Options.String ---
func TestOptions_String_Good(t *testing.T) {
opts := NewOptions(Option{Key: "name", Value: "brain"})
assert.Equal(t, "brain", opts.String("name"))
}
func TestOptions_String_Bad(t *testing.T) {
opts := NewOptions(Option{Key: "port", Value: 8080})
assert.Equal(t, "", opts.String("port"))
assert.Equal(t, "", opts.String("missing"))
}
// --- Options.Int ---
func TestOptions_Int_Good(t *testing.T) {
opts := NewOptions(Option{Key: "port", Value: 8080})
assert.Equal(t, 8080, opts.Int("port"))
}
func TestOptions_Int_Bad(t *testing.T) {
opts := NewOptions(Option{Key: "name", Value: "brain"})
assert.Equal(t, 0, opts.Int("name"))
assert.Equal(t, 0, opts.Int("missing"))
}
// --- Options.Bool ---
func TestOptions_Bool_Good(t *testing.T) {
opts := NewOptions(Option{Key: "debug", Value: true})
assert.True(t, opts.Bool("debug"))
}
func TestOptions_Bool_Bad(t *testing.T) {
opts := NewOptions(Option{Key: "name", Value: "brain"})
assert.False(t, opts.Bool("name"))
assert.False(t, opts.Bool("missing"))
}
// --- Options.Items ---
func TestOptions_Items_Good(t *testing.T) {
opts := NewOptions(Option{Key: "a", Value: 1}, Option{Key: "b", Value: 2})
items := opts.Items()
assert.Len(t, items, 2)
}
// --- Options with typed struct ---
func TestOptions_TypedStruct_Good(t *testing.T) {
type BrainConfig struct {
Name string
OllamaURL string
Collection string
}
cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"}
opts := NewOptions(Option{Key: "config", Value: cfg})
r := opts.Get("config")
assert.True(t, r.OK)
bc, ok := r.Value.(BrainConfig)
assert.True(t, ok)
assert.Equal(t, "brain", bc.Name)
assert.Equal(t, "http://localhost:11434", bc.OllamaURL)
}
// --- Result ---
func TestOptions_Result_New_Good(t *testing.T) {
r := Result{}.New("value")
assert.Equal(t, "value", r.Value)
}
func TestOptions_Result_New_Error_Bad(t *testing.T) {
err := E("test", "failed", nil)
r := Result{}.New(err)
assert.False(t, r.OK)
assert.Equal(t, err, r.Value)
}
func TestOptions_Result_Result_Good(t *testing.T) {
r := Result{Value: "hello", OK: true}
assert.Equal(t, r, r.Result())
}
func TestOptions_Result_Result_WithArgs_Good(t *testing.T) {
r := Result{}.Result("value")
assert.Equal(t, "value", r.Value)
}
func TestOptions_Result_Get_Good(t *testing.T) {
r := Result{Value: "hello", OK: true}
assert.True(t, r.Get().OK)
}
func TestOptions_Result_Get_Bad(t *testing.T) {
r := Result{Value: "err", OK: false}
assert.False(t, r.Get().OK)
}
// --- WithOption ---
func TestOptions_WithOption_Good(t *testing.T) {
c := New(
WithOption("name", "myapp"),
WithOption("port", 8080),
)
assert.Equal(t, "myapp", c.App().Name)
assert.Equal(t, 8080, c.Options().Int("port"))
}

174
path.go
View file

@ -1,174 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// OS-aware filesystem path operations for the Core framework.
// Uses Env("DS") for the separator and Core string primitives
// for path manipulation. filepath imported only for PathGlob.
//
// Path anchors relative segments to DIR_HOME:
//
// core.Path("Code", ".core") // "/Users/snider/Code/.core"
// core.Path("/tmp", "workspace") // "/tmp/workspace"
// core.Path() // "/Users/snider"
//
// Path component helpers:
//
// core.PathBase("/Users/snider/Code/core") // "core"
// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code"
// core.PathExt("main.go") // ".go"
package core
import "path/filepath"
// Path builds a clean, absolute filesystem path from segments.
// Uses Env("DS") for the OS directory separator.
// Relative paths are anchored to DIR_HOME. Absolute paths pass through.
//
// core.Path("Code", ".core") // "/Users/snider/Code/.core"
// core.Path("/tmp", "workspace") // "/tmp/workspace"
// core.Path() // "/Users/snider"
func Path(segments ...string) string {
ds := Env("DS")
home := Env("DIR_HOME")
if home == "" {
home = "."
}
if len(segments) == 0 {
return home
}
p := Join(ds, segments...)
if PathIsAbs(p) {
return CleanPath(p, ds)
}
return CleanPath(home+ds+p, ds)
}
// PathBase returns the last element of a path.
//
// core.PathBase("/Users/snider/Code/core") // "core"
// core.PathBase("deploy/to/homelab") // "homelab"
func PathBase(p string) string {
if p == "" {
return "."
}
ds := Env("DS")
p = TrimSuffix(p, ds)
if p == "" {
return ds
}
parts := Split(p, ds)
return parts[len(parts)-1]
}
// PathDir returns all but the last element of a path.
//
// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code"
func PathDir(p string) string {
if p == "" {
return "."
}
ds := Env("DS")
i := lastIndex(p, ds)
if i < 0 {
return "."
}
dir := p[:i]
if dir == "" {
return ds
}
return dir
}
// PathExt returns the file extension including the dot.
//
// core.PathExt("main.go") // ".go"
// core.PathExt("Makefile") // ""
func PathExt(p string) string {
base := PathBase(p)
i := lastIndex(base, ".")
if i <= 0 {
return ""
}
return base[i:]
}
// PathIsAbs returns true if the path is absolute.
// Handles Unix (starts with /) and Windows (drive letter like C:).
//
// core.PathIsAbs("/tmp") // true
// core.PathIsAbs("C:\\tmp") // true
// core.PathIsAbs("relative") // false
func PathIsAbs(p string) bool {
if p == "" {
return false
}
if p[0] == '/' {
return true
}
// Windows: C:\ or C:/
if len(p) >= 3 && p[1] == ':' && (p[2] == '/' || p[2] == '\\') {
return true
}
return false
}
// CleanPath removes redundant separators and resolves . and .. elements.
//
// core.CleanPath("/tmp//file", "/") // "/tmp/file"
// core.CleanPath("a/b/../c", "/") // "a/c"
func CleanPath(p, ds string) string {
if p == "" {
return "."
}
rooted := HasPrefix(p, ds)
parts := Split(p, ds)
var clean []string
for _, part := range parts {
switch part {
case "", ".":
continue
case "..":
if len(clean) > 0 && clean[len(clean)-1] != ".." {
clean = clean[:len(clean)-1]
} else if !rooted {
clean = append(clean, "..")
}
default:
clean = append(clean, part)
}
}
result := Join(ds, clean...)
if rooted {
result = ds + result
}
if result == "" {
if rooted {
return ds
}
return "."
}
return result
}
// PathGlob returns file paths matching a pattern.
//
// core.PathGlob("/tmp/agent-*.log")
func PathGlob(pattern string) []string {
matches, _ := filepath.Glob(pattern)
return matches
}
// lastIndex returns the index of the last occurrence of substr in s, or -1.
func lastIndex(s, substr string) int {
if substr == "" || s == "" {
return -1
}
for i := len(s) - len(substr); i >= 0; i-- {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View file

@ -1,37 +0,0 @@
package core_test
import (
. "dappco.re/go/core"
)
func ExampleJoinPath() {
Println(JoinPath("deploy", "to", "homelab"))
// Output: deploy/to/homelab
}
func ExamplePathBase() {
Println(PathBase("/srv/workspaces/alpha"))
// Output: alpha
}
func ExamplePathDir() {
Println(PathDir("/srv/workspaces/alpha"))
// Output: /srv/workspaces
}
func ExamplePathExt() {
Println(PathExt("report.pdf"))
// Output: .pdf
}
func ExampleCleanPath() {
Println(CleanPath("/tmp//file", "/"))
Println(CleanPath("a/b/../c", "/"))
Println(CleanPath("deploy/to/homelab", "/"))
// Output:
// /tmp/file
// a/c
// deploy/to/homelab
}

View file

@ -1,110 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package core_test
import (
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
func TestPath_Relative(t *testing.T) {
home := core.Env("DIR_HOME")
ds := core.Env("DS")
assert.Equal(t, home+ds+"Code"+ds+".core", core.Path("Code", ".core"))
}
func TestPath_Absolute(t *testing.T) {
ds := core.Env("DS")
assert.Equal(t, "/tmp"+ds+"workspace", core.Path("/tmp", "workspace"))
}
func TestPath_Empty(t *testing.T) {
home := core.Env("DIR_HOME")
assert.Equal(t, home, core.Path())
}
func TestPath_Cleans(t *testing.T) {
home := core.Env("DIR_HOME")
assert.Equal(t, home+core.Env("DS")+"Code", core.Path("Code", "sub", ".."))
}
func TestPath_CleanDoubleSlash(t *testing.T) {
ds := core.Env("DS")
assert.Equal(t, ds+"tmp"+ds+"file", core.Path("/tmp//file"))
}
func TestPath_PathBase(t *testing.T) {
assert.Equal(t, "core", core.PathBase("/Users/snider/Code/core"))
assert.Equal(t, "homelab", core.PathBase("deploy/to/homelab"))
}
func TestPath_PathBase_Root(t *testing.T) {
assert.Equal(t, "/", core.PathBase("/"))
}
func TestPath_PathBase_Empty(t *testing.T) {
assert.Equal(t, ".", core.PathBase(""))
}
func TestPath_PathDir(t *testing.T) {
assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core"))
}
func TestPath_PathDir_Root(t *testing.T) {
assert.Equal(t, "/", core.PathDir("/file"))
}
func TestPath_PathDir_NoDir(t *testing.T) {
assert.Equal(t, ".", core.PathDir("file.go"))
}
func TestPath_PathExt(t *testing.T) {
assert.Equal(t, ".go", core.PathExt("main.go"))
assert.Equal(t, "", core.PathExt("Makefile"))
assert.Equal(t, ".gz", core.PathExt("archive.tar.gz"))
}
func TestPath_EnvConsistency(t *testing.T) {
assert.Equal(t, core.Env("DIR_HOME"), core.Path())
}
func TestPath_PathGlob_Good(t *testing.T) {
dir := t.TempDir()
f := (&core.Fs{}).New("/")
f.Write(core.Path(dir, "a.txt"), "a")
f.Write(core.Path(dir, "b.txt"), "b")
f.Write(core.Path(dir, "c.log"), "c")
matches := core.PathGlob(core.Path(dir, "*.txt"))
assert.Len(t, matches, 2)
}
func TestPath_PathGlob_NoMatch(t *testing.T) {
matches := core.PathGlob("/nonexistent/pattern-*.xyz")
assert.Empty(t, matches)
}
func TestPath_PathIsAbs_Good(t *testing.T) {
assert.True(t, core.PathIsAbs("/tmp"))
assert.True(t, core.PathIsAbs("/"))
assert.False(t, core.PathIsAbs("relative"))
assert.False(t, core.PathIsAbs(""))
}
func TestPath_CleanPath_Good(t *testing.T) {
assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/"))
assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/"))
assert.Equal(t, "/", core.CleanPath("/", "/"))
assert.Equal(t, ".", core.CleanPath("", "/"))
}
func TestPath_PathDir_TrailingSlash(t *testing.T) {
result := core.PathDir("/Users/snider/Code/")
assert.Equal(t, "/Users/snider/Code", result)
}

139
pkg/core/async_test.go Normal file
View file

@ -0,0 +1,139 @@
package core
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCore_PerformAsync_Good(t *testing.T) {
c, _ := New()
var completed atomic.Bool
var resultReceived any
c.RegisterAction(func(c *Core, msg Message) error {
if tc, ok := msg.(ActionTaskCompleted); ok {
resultReceived = tc.Result
completed.Store(true)
}
return nil
})
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
return "async-result", true, nil
})
taskID := c.PerformAsync(TestTask{})
assert.NotEmpty(t, taskID)
// Wait for completion
assert.Eventually(t, func() bool {
return completed.Load()
}, 1*time.Second, 10*time.Millisecond)
assert.Equal(t, "async-result", resultReceived)
}
func TestCore_PerformAsync_Shutdown(t *testing.T) {
c, _ := New()
_ = c.ServiceShutdown(context.Background())
taskID := c.PerformAsync(TestTask{})
assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down")
}
func TestCore_Progress_Good(t *testing.T) {
c, _ := New()
var progressReceived float64
var messageReceived string
c.RegisterAction(func(c *Core, msg Message) error {
if tp, ok := msg.(ActionTaskProgress); ok {
progressReceived = tp.Progress
messageReceived = tp.Message
}
return nil
})
c.Progress("task-1", 0.5, "halfway", TestTask{})
assert.Equal(t, 0.5, progressReceived)
assert.Equal(t, "halfway", messageReceived)
}
func TestCore_WithService_UnnamedType(t *testing.T) {
// Primitive types have no package path
factory := func(c *Core) (any, error) {
s := "primitive"
return &s, nil
}
_, err := New(WithService(factory))
require.Error(t, err)
assert.Contains(t, err.Error(), "service name could not be discovered")
}
func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) {
rt, _ := NewRuntime(nil)
// Register a service that fails startup
errSvc := &MockStartable{err: errors.New("startup failed")}
_ = rt.Core.RegisterService("error-svc", errSvc)
err := rt.ServiceStartup(context.Background(), nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "startup failed")
}
func TestCore_ServiceStartup_ContextCancellation(t *testing.T) {
c, _ := New()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
s1 := &MockStartable{}
_ = c.RegisterService("s1", s1)
err := c.ServiceStartup(ctx, nil)
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.False(t, s1.started, "Service should not have started if context was cancelled before loop")
}
func TestCore_ServiceShutdown_ContextCancellation(t *testing.T) {
c, _ := New()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
s1 := &MockStoppable{}
_ = c.RegisterService("s1", s1)
err := c.ServiceShutdown(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.False(t, s1.stopped, "Service should not have stopped if context was cancelled before loop")
}
type TaskWithIDImpl struct {
id string
}
func (t *TaskWithIDImpl) SetTaskID(id string) { t.id = id }
func (t *TaskWithIDImpl) GetTaskID() string { return t.id }
func TestCore_PerformAsync_InjectsID(t *testing.T) {
c, _ := New()
c.RegisterTask(func(c *Core, t Task) (any, bool, error) { return nil, true, nil })
task := &TaskWithIDImpl{}
taskID := c.PerformAsync(task)
assert.Equal(t, taskID, task.GetTaskID())
}

38
pkg/core/bench_test.go Normal file
View file

@ -0,0 +1,38 @@
package core
import (
"testing"
)
func BenchmarkMessageBus_Action(b *testing.B) {
c, _ := New()
c.RegisterAction(func(c *Core, msg Message) error {
return nil
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = c.ACTION("test")
}
}
func BenchmarkMessageBus_Query(b *testing.B) {
c, _ := New()
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "result", true, nil
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = c.QUERY("test")
}
}
func BenchmarkMessageBus_Perform(b *testing.B) {
c, _ := New()
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
return "result", true, nil
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = c.PERFORM("test")
}
}

402
pkg/core/core.go Normal file
View file

@ -0,0 +1,402 @@
package core
import (
"context"
"embed"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"sync"
)
var (
instance *Core
instanceMu sync.RWMutex
)
// New initialises a Core instance using the provided options and performs the necessary setup.
// It is the primary entry point for creating a new Core application.
//
// Example:
//
// core, err := core.New(
// core.WithService(&MyService{}),
// core.WithAssets(assets),
// )
func New(opts ...Option) (*Core, error) {
c := &Core{
Features: &Features{},
svc: newServiceManager(),
}
c.bus = newMessageBus(c)
for _, o := range opts {
if err := o(c); err != nil {
return nil, err
}
}
c.svc.applyLock()
return c, nil
}
// WithService creates an Option that registers a service. It automatically discovers
// the service name from its package path and registers its IPC handler if it
// implements a method named `HandleIPCEvents`.
//
// Example:
//
// // In myapp/services/calculator.go
// package services
//
// type Calculator struct{}
//
// func (s *Calculator) Add(a, b int) int { return a + b }
//
// // In main.go
// import "myapp/services"
//
// core.New(core.WithService(services.NewCalculator))
func WithService(factory func(*Core) (any, error)) Option {
return func(c *Core) error {
serviceInstance, err := factory(c)
if err != nil {
return E("core.WithService", "failed to create service", err)
}
if serviceInstance == nil {
return E("core.WithService", "service factory returned nil instance", nil)
}
// --- Service Name Discovery ---
typeOfService := reflect.TypeOf(serviceInstance)
if typeOfService.Kind() == reflect.Ptr {
typeOfService = typeOfService.Elem()
}
pkgPath := typeOfService.PkgPath()
parts := strings.Split(pkgPath, "/")
name := strings.ToLower(parts[len(parts)-1])
if name == "" {
return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil)
}
// --- IPC Handler Discovery ---
instanceValue := reflect.ValueOf(serviceInstance)
handlerMethod := instanceValue.MethodByName("HandleIPCEvents")
if handlerMethod.IsValid() {
if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok {
c.RegisterAction(handler)
} else {
return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil)
}
}
return c.RegisterService(name, serviceInstance)
}
}
// WithName creates an option that registers a service with a specific name.
// This is useful when the service name cannot be inferred from the package path,
// such as when using anonymous functions as factories.
// Note: Unlike WithService, this does not automatically discover or register
// IPC handlers. If your service needs IPC handling, implement HandleIPCEvents
// and register it manually.
func WithName(name string, factory func(*Core) (any, error)) Option {
return func(c *Core) error {
serviceInstance, err := factory(c)
if err != nil {
return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err)
}
return c.RegisterService(name, serviceInstance)
}
}
// WithApp creates an Option that injects the GUI runtime (e.g., Wails App) into the Core.
// This is essential for services that need to interact with the GUI runtime.
func WithApp(app any) Option {
return func(c *Core) error {
c.App = app
return nil
}
}
// WithAssets creates an Option that registers the application's embedded assets.
// This is necessary for the application to be able to serve its frontend.
func WithAssets(fs embed.FS) Option {
return func(c *Core) error {
c.assets = fs
return nil
}
}
// WithServiceLock creates an Option that prevents any further services from being
// registered after the Core has been initialized. This is a security measure to
// prevent late-binding of services that could have unintended consequences.
func WithServiceLock() Option {
return func(c *Core) error {
c.svc.enableLock()
return nil
}
}
// --- Core Methods ---
// ServiceStartup is the entry point for the Core service's startup lifecycle.
// It is called by the GUI runtime when the application starts.
func (c *Core) ServiceStartup(ctx context.Context, options any) error {
startables := c.svc.getStartables()
var agg error
for _, s := range startables {
if err := ctx.Err(); err != nil {
return errors.Join(agg, err)
}
if err := s.OnStartup(ctx); err != nil {
agg = errors.Join(agg, err)
}
}
if err := c.ACTION(ActionServiceStartup{}); err != nil {
agg = errors.Join(agg, err)
}
return agg
}
// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
// It is called by the GUI runtime when the application shuts down.
func (c *Core) ServiceShutdown(ctx context.Context) error {
c.shutdown.Store(true)
var agg error
if err := c.ACTION(ActionServiceShutdown{}); err != nil {
agg = errors.Join(agg, err)
}
stoppables := c.svc.getStoppables()
for _, s := range slices.Backward(stoppables) {
if err := ctx.Err(); err != nil {
agg = errors.Join(agg, err)
break // don't return — must still wait for background tasks below
}
if err := s.OnShutdown(ctx); err != nil {
agg = errors.Join(agg, err)
}
}
// Wait for background tasks (PerformAsync), respecting context deadline.
done := make(chan struct{})
go func() {
c.wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
agg = errors.Join(agg, ctx.Err())
}
return agg
}
// ACTION dispatches a message to all registered IPC handlers.
// This is the primary mechanism for services to communicate with each other.
func (c *Core) ACTION(msg Message) error {
return c.bus.action(msg)
}
// RegisterAction adds a new IPC handler to the Core.
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
c.bus.registerAction(handler)
}
// RegisterActions adds multiple IPC handlers to the Core.
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
c.bus.registerActions(handlers...)
}
// QUERY dispatches a query to handlers until one responds.
// Returns (result, handled, error). If no handler responds, handled is false.
func (c *Core) QUERY(q Query) (any, bool, error) {
return c.bus.query(q)
}
// QUERYALL dispatches a query to all handlers and collects all responses.
// Returns all results from handlers that responded.
func (c *Core) QUERYALL(q Query) ([]any, error) {
return c.bus.queryAll(q)
}
// PERFORM dispatches a task to handlers until one executes it.
// Returns (result, handled, error). If no handler responds, handled is false.
func (c *Core) PERFORM(t Task) (any, bool, error) {
return c.bus.perform(t)
}
// PerformAsync dispatches a task to be executed in a background goroutine.
// It returns a unique task ID that can be used to track the task's progress.
// The result of the task will be broadcasted via an ActionTaskCompleted message.
func (c *Core) PerformAsync(t Task) string {
if c.shutdown.Load() {
return ""
}
taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1))
// If the task supports it, inject the ID
if tid, ok := t.(TaskWithID); ok {
tid.SetTaskID(taskID)
}
// Broadcast task started
_ = c.ACTION(ActionTaskStarted{
TaskID: taskID,
Task: t,
})
c.wg.Go(func() {
result, handled, err := c.PERFORM(t)
if !handled && err == nil {
err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil)
}
// Broadcast task completed
_ = c.ACTION(ActionTaskCompleted{
TaskID: taskID,
Task: t,
Result: result,
Error: err,
})
})
return taskID
}
// Progress broadcasts a progress update for a background task.
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
_ = c.ACTION(ActionTaskProgress{
TaskID: taskID,
Task: t,
Progress: progress,
Message: message,
})
}
// RegisterQuery adds a query handler to the Core.
func (c *Core) RegisterQuery(handler QueryHandler) {
c.bus.registerQuery(handler)
}
// RegisterTask adds a task handler to the Core.
func (c *Core) RegisterTask(handler TaskHandler) {
c.bus.registerTask(handler)
}
// RegisterService adds a new service to the Core.
// If the service implements LocaleProvider, its locale FS is collected
// for the i18n service to load during startup.
func (c *Core) RegisterService(name string, api any) error {
// Collect locale filesystems from services that provide them
if lp, ok := api.(LocaleProvider); ok {
c.locales = append(c.locales, lp.Locales())
}
return c.svc.registerService(name, api)
}
// Service retrieves a registered service by name.
// It returns nil if the service is not found.
func (c *Core) Service(name string) any {
return c.svc.service(name)
}
// ServiceFor retrieves a registered service by name and asserts its type to the given interface T.
func ServiceFor[T any](c *Core, name string) (T, error) {
var zero T
raw := c.Service(name)
if raw == nil {
return zero, E("core.ServiceFor", fmt.Sprintf("service %q not found", name), nil)
}
typed, ok := raw.(T)
if !ok {
return zero, E("core.ServiceFor", fmt.Sprintf("service %q is type %T, expected %T", name, raw, zero), nil)
}
return typed, nil
}
// MustServiceFor retrieves a registered service by name and asserts its type to the given interface T.
// It panics if the service is not found or cannot be cast to T.
func MustServiceFor[T any](c *Core, name string) T {
svc, err := ServiceFor[T](c, name)
if err != nil {
panic(err)
}
return svc
}
// App returns the global application instance.
// It panics if the Core has not been initialized via SetInstance.
// This is typically used by GUI runtimes that need global access.
func App() any {
instanceMu.RLock()
inst := instance
instanceMu.RUnlock()
if inst == nil {
panic("core.App() called before core.SetInstance()")
}
return inst.App
}
// SetInstance sets the global Core instance for App() access.
// This is typically called by GUI runtimes during initialization.
func SetInstance(c *Core) {
instanceMu.Lock()
instance = c
instanceMu.Unlock()
}
// GetInstance returns the global Core instance, or nil if not set.
// Use this for non-panicking access to the global instance.
func GetInstance() *Core {
instanceMu.RLock()
inst := instance
instanceMu.RUnlock()
return inst
}
// ClearInstance resets the global Core instance to nil.
// This is primarily useful for testing to ensure a clean state between tests.
func ClearInstance() {
instanceMu.Lock()
instance = nil
instanceMu.Unlock()
}
// Config returns the registered Config service.
func (c *Core) Config() Config {
return MustServiceFor[Config](c, "config")
}
// Display returns the registered Display service.
func (c *Core) Display() Display {
return MustServiceFor[Display](c, "display")
}
// Workspace returns the registered Workspace service.
func (c *Core) Workspace() Workspace {
return MustServiceFor[Workspace](c, "workspace")
}
// Crypt returns the registered Crypt service.
func (c *Core) Crypt() Crypt {
return MustServiceFor[Crypt](c, "crypt")
}
// Core returns self, implementing the CoreProvider interface.
func (c *Core) Core() *Core { return c }
// Assets returns the embedded filesystem containing the application's assets.
func (c *Core) Assets() embed.FS {
return c.assets
}

View file

@ -0,0 +1,43 @@
package core
import (
"testing"
"github.com/stretchr/testify/assert"
)
type MockServiceWithIPC struct {
MockService
handled bool
}
func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
m.handled = true
return nil
}
func TestCore_WithService_IPC(t *testing.T) {
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
factory := func(c *Core) (any, error) {
return svc, nil
}
c, err := New(WithService(factory))
assert.NoError(t, err)
// Trigger ACTION to verify handler was registered
err = c.ACTION(nil)
assert.NoError(t, err)
assert.True(t, svc.handled)
}
func TestCore_ACTION_Bad(t *testing.T) {
c, err := New()
assert.NoError(t, err)
errHandler := func(c *Core, msg Message) error {
return assert.AnError
}
c.RegisterAction(errHandler)
err = c.ACTION(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), assert.AnError.Error())
}

View file

@ -0,0 +1,163 @@
package core
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
type MockStartable struct {
started bool
err error
}
func (m *MockStartable) OnStartup(ctx context.Context) error {
m.started = true
return m.err
}
type MockStoppable struct {
stopped bool
err error
}
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
m.stopped = true
return m.err
}
type MockLifecycle struct {
MockStartable
MockStoppable
}
func TestCore_LifecycleInterfaces(t *testing.T) {
c, err := New()
assert.NoError(t, err)
startable := &MockStartable{}
stoppable := &MockStoppable{}
lifecycle := &MockLifecycle{}
// Register services
err = c.RegisterService("startable", startable)
assert.NoError(t, err)
err = c.RegisterService("stoppable", stoppable)
assert.NoError(t, err)
err = c.RegisterService("lifecycle", lifecycle)
assert.NoError(t, err)
// Startup
err = c.ServiceStartup(context.Background(), nil)
assert.NoError(t, err)
assert.True(t, startable.started)
assert.True(t, lifecycle.started)
assert.False(t, stoppable.stopped)
// Shutdown
err = c.ServiceShutdown(context.Background())
assert.NoError(t, err)
assert.True(t, stoppable.stopped)
assert.True(t, lifecycle.stopped)
}
type MockLifecycleWithLog struct {
id string
log *[]string
}
func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
*m.log = append(*m.log, "start-"+m.id)
return nil
}
func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
*m.log = append(*m.log, "stop-"+m.id)
return nil
}
func TestCore_LifecycleOrder(t *testing.T) {
c, err := New()
assert.NoError(t, err)
var callOrder []string
s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}
err = c.RegisterService("s1", s1)
assert.NoError(t, err)
err = c.RegisterService("s2", s2)
assert.NoError(t, err)
// Startup
err = c.ServiceStartup(context.Background(), nil)
assert.NoError(t, err)
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
// Reset log
callOrder = nil
// Shutdown
err = c.ServiceShutdown(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
}
func TestCore_LifecycleErrors(t *testing.T) {
c, err := New()
assert.NoError(t, err)
s1 := &MockStartable{err: assert.AnError}
s2 := &MockStoppable{err: assert.AnError}
_ = c.RegisterService("s1", s1)
_ = c.RegisterService("s2", s2)
err = c.ServiceStartup(context.Background(), nil)
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
err = c.ServiceShutdown(context.Background())
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
}
func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// Register action that fails
c.RegisterAction(func(c *Core, msg Message) error {
if _, ok := msg.(ActionServiceStartup); ok {
return errors.New("startup action error")
}
if _, ok := msg.(ActionServiceShutdown); ok {
return errors.New("shutdown action error")
}
return nil
})
// Register service that fails
s1 := &MockStartable{err: errors.New("startup service error")}
s2 := &MockStoppable{err: errors.New("shutdown service error")}
err = c.RegisterService("s1", s1)
assert.NoError(t, err)
err = c.RegisterService("s2", s2)
assert.NoError(t, err)
// Startup
err = c.ServiceStartup(context.Background(), nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "startup action error")
assert.Contains(t, err.Error(), "startup service error")
// Shutdown
err = c.ServiceShutdown(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "shutdown action error")
assert.Contains(t, err.Error(), "shutdown service error")
}

354
pkg/core/core_test.go Normal file
View file

@ -0,0 +1,354 @@
package core
import (
"context"
"embed"
"io"
"testing"
"github.com/stretchr/testify/assert"
)
// mockApp is a simple mock for testing app injection
type mockApp struct{}
func TestCore_New_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
assert.NotNil(t, c)
}
// Mock service for testing
type MockService struct {
Name string
}
func (m *MockService) GetName() string {
return m.Name
}
func TestCore_WithService_Good(t *testing.T) {
factory := func(c *Core) (any, error) {
return &MockService{Name: "test"}, nil
}
c, err := New(WithService(factory))
assert.NoError(t, err)
svc := c.Service("core")
assert.NotNil(t, svc)
mockSvc, ok := svc.(*MockService)
assert.True(t, ok)
assert.Equal(t, "test", mockSvc.GetName())
}
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)
}
type MockConfigService struct{}
func (m *MockConfigService) Get(key string, out any) error { return nil }
func (m *MockConfigService) Set(key string, v any) error { return nil }
type MockDisplayService struct{}
func (m *MockDisplayService) OpenWindow(opts ...WindowOption) error { return nil }
func TestCore_Services_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err = c.RegisterService("config", &MockConfigService{})
assert.NoError(t, err)
err = c.RegisterService("display", &MockDisplayService{})
assert.NoError(t, err)
cfg := c.Config()
assert.NotNil(t, cfg)
d := c.Display()
assert.NotNil(t, d)
}
func TestCore_Services_Ugly(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// Config panics when service not registered
assert.Panics(t, func() {
c.Config()
})
// Display panics when service not registered
assert.Panics(t, func() {
c.Display()
})
}
func TestCore_App_Good(t *testing.T) {
app := &mockApp{}
c, err := New(WithApp(app))
assert.NoError(t, err)
// To test the global App() function, we need to set the global instance.
originalInstance := GetInstance()
SetInstance(c)
defer SetInstance(originalInstance)
assert.Equal(t, app, App())
}
func TestCore_App_Ugly(t *testing.T) {
// This test ensures that calling App() before the core is initialized panics.
originalInstance := GetInstance()
ClearInstance()
defer SetInstance(originalInstance)
assert.Panics(t, func() {
App()
})
}
func TestCore_Core_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
assert.Equal(t, c, c.Core())
}
func TestFeatures_IsEnabled_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
c.Features.Flags = []string{"feature1", "feature2"}
assert.True(t, c.Features.IsEnabled("feature1"))
assert.True(t, c.Features.IsEnabled("feature2"))
assert.False(t, c.Features.IsEnabled("feature3"))
assert.False(t, c.Features.IsEnabled(""))
}
func TestFeatures_IsEnabled_Edge(t *testing.T) {
c, _ := New()
c.Features.Flags = []string{" ", "foo"}
assert.True(t, c.Features.IsEnabled(" "))
assert.True(t, c.Features.IsEnabled("foo"))
assert.False(t, c.Features.IsEnabled("FOO")) // Case sensitive check
}
func TestCore_ServiceLifecycle_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
var messageReceived Message
handler := func(c *Core, msg Message) error {
messageReceived = msg
return nil
}
c.RegisterAction(handler)
// Test Startup
_ = c.ServiceStartup(context.TODO(), nil)
_, ok := messageReceived.(ActionServiceStartup)
assert.True(t, ok, "expected ActionServiceStartup message")
// Test Shutdown
_ = c.ServiceShutdown(context.TODO())
_, ok = messageReceived.(ActionServiceShutdown)
assert.True(t, ok, "expected ActionServiceShutdown message")
}
func TestCore_WithApp_Good(t *testing.T) {
app := &mockApp{}
c, err := New(WithApp(app))
assert.NoError(t, err)
assert.Equal(t, app, c.App)
}
//go:embed testdata
var testFS embed.FS
func TestCore_WithAssets_Good(t *testing.T) {
c, err := New(WithAssets(testFS))
assert.NoError(t, err)
assets := c.Assets()
file, err := assets.Open("testdata/test.txt")
assert.NoError(t, err)
defer func() { _ = file.Close() }()
content, err := io.ReadAll(file)
assert.NoError(t, err)
assert.Equal(t, "hello from testdata\n", string(content))
}
func TestCore_WithServiceLock_Good(t *testing.T) {
c, err := New(WithServiceLock())
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{})
assert.Error(t, err)
}
func TestCore_RegisterService_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{Name: "test"})
assert.NoError(t, err)
svc := c.Service("test")
assert.NotNil(t, svc)
mockSvc, ok := svc.(*MockService)
assert.True(t, ok)
assert.Equal(t, "test", mockSvc.GetName())
}
func TestCore_RegisterService_Bad(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{})
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{})
assert.Error(t, err)
err = c.RegisterService("", &MockService{})
assert.Error(t, err)
}
func TestCore_ServiceFor_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{Name: "test"})
assert.NoError(t, err)
svc, err := ServiceFor[*MockService](c, "test")
assert.NoError(t, err)
assert.Equal(t, "test", svc.GetName())
}
func TestCore_ServiceFor_Bad(t *testing.T) {
c, err := New()
assert.NoError(t, err)
_, err = ServiceFor[*MockService](c, "nonexistent")
assert.Error(t, err)
err = c.RegisterService("test", "not a service")
assert.NoError(t, err)
_, err = ServiceFor[*MockService](c, "test")
assert.Error(t, err)
}
func TestCore_MustServiceFor_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err = c.RegisterService("test", &MockService{Name: "test"})
assert.NoError(t, err)
svc := MustServiceFor[*MockService](c, "test")
assert.Equal(t, "test", svc.GetName())
}
func TestCore_MustServiceFor_Ugly(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// MustServiceFor panics on missing service
assert.Panics(t, func() {
MustServiceFor[*MockService](c, "nonexistent")
})
err = c.RegisterService("test", "not a service")
assert.NoError(t, err)
// MustServiceFor panics on type mismatch
assert.Panics(t, func() {
MustServiceFor[*MockService](c, "test")
})
}
type MockAction struct {
handled bool
}
func (a *MockAction) Handle(c *Core, msg Message) error {
a.handled = true
return nil
}
func TestCore_ACTION_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
action := &MockAction{}
c.RegisterAction(action.Handle)
err = c.ACTION(nil)
assert.NoError(t, err)
assert.True(t, action.handled)
}
func TestCore_RegisterActions_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
action1 := &MockAction{}
action2 := &MockAction{}
c.RegisterActions(action1.Handle, action2.Handle)
err = c.ACTION(nil)
assert.NoError(t, err)
assert.True(t, action1.handled)
assert.True(t, action2.handled)
}
func TestCore_WithName_Good(t *testing.T) {
factory := func(c *Core) (any, error) {
return &MockService{Name: "test"}, nil
}
c, err := New(WithName("my-service", factory))
assert.NoError(t, err)
svc := c.Service("my-service")
assert.NotNil(t, svc)
mockSvc, ok := svc.(*MockService)
assert.True(t, ok)
assert.Equal(t, "test", mockSvc.GetName())
}
func TestCore_WithName_Bad(t *testing.T) {
factory := func(c *Core) (any, error) {
return nil, assert.AnError
}
_, err := New(WithName("my-service", factory))
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
}
func TestCore_GlobalInstance_ThreadSafety_Good(t *testing.T) {
// Save original instance
original := GetInstance()
defer SetInstance(original)
// Test SetInstance/GetInstance
c1, _ := New()
SetInstance(c1)
assert.Equal(t, c1, GetInstance())
// Test ClearInstance
ClearInstance()
assert.Nil(t, GetInstance())
// Test concurrent access (race detector should catch issues)
c2, _ := New(WithApp(&mockApp{}))
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
SetInstance(c2)
_ = GetInstance()
done <- true
}()
go func() {
inst := GetInstance()
if inst != nil {
_ = inst.App
}
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 20; i++ {
<-done
}
}

26
pkg/core/e.go Normal file
View file

@ -0,0 +1,26 @@
// Package core re-exports the structured error types from go-log.
//
// All error construction in the framework MUST use E() (or Wrap, WrapCode, etc.)
// rather than fmt.Errorf. This ensures every error carries an operation context
// for structured logging and tracing.
//
// Example:
//
// return core.E("config.Load", "failed to load config file", err)
package core
import (
coreerr "forge.lthn.ai/core/go-log"
)
// Error is the structured error type from go-log.
// It carries Op (operation), Msg (human-readable), Err (underlying), and Code fields.
type Error = coreerr.Err
// E creates a new structured error with operation context.
// This is the primary way to create errors in the Core framework.
//
// The 'op' parameter should be in the format of 'package.function' or 'service.method'.
// The 'msg' parameter should be a human-readable message.
// The 'err' parameter is the underlying error (may be nil).
var E = coreerr.E

29
pkg/core/e_test.go Normal file
View file

@ -0,0 +1,29 @@
package core
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestE_Good(t *testing.T) {
err := E("test.op", "test message", assert.AnError)
assert.Error(t, err)
assert.Equal(t, "test.op: test message: assert.AnError general error for testing", err.Error())
err = E("test.op", "test message", nil)
assert.Error(t, err)
assert.Equal(t, "test.op: test message", err.Error())
}
func TestE_Unwrap(t *testing.T) {
originalErr := errors.New("original error")
err := E("test.op", "test message", originalErr)
assert.True(t, errors.Is(err, originalErr))
var eErr *Error
assert.True(t, errors.As(err, &eErr))
assert.Equal(t, "test.op", eErr.Op)
}

107
pkg/core/fuzz_test.go Normal file
View file

@ -0,0 +1,107 @@
package core
import (
"errors"
"testing"
)
// FuzzE exercises the E() error constructor with arbitrary input.
func FuzzE(f *testing.F) {
f.Add("svc.Method", "something broke", true)
f.Add("", "", false)
f.Add("a.b.c.d.e.f", "unicode: \u00e9\u00e8\u00ea", true)
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
var underlying error
if withErr {
underlying = errors.New("wrapped")
}
e := E(op, msg, underlying)
if e == nil {
t.Fatal("E() returned nil")
}
s := e.Error()
if s == "" && (op != "" || msg != "") {
t.Fatal("Error() returned empty string for non-empty op/msg")
}
// Round-trip: Unwrap should return the underlying error
var coreErr *Error
if !errors.As(e, &coreErr) {
t.Fatal("errors.As failed for *Error")
}
if withErr && coreErr.Unwrap() == nil {
t.Fatal("Unwrap() returned nil with underlying error")
}
if !withErr && coreErr.Unwrap() != nil {
t.Fatal("Unwrap() returned non-nil without underlying error")
}
})
}
// FuzzServiceRegistration exercises service name registration with arbitrary names.
func FuzzServiceRegistration(f *testing.F) {
f.Add("myservice")
f.Add("")
f.Add("a/b/c")
f.Add("service with spaces")
f.Add("service\x00null")
f.Fuzz(func(t *testing.T, name string) {
sm := newServiceManager()
err := sm.registerService(name, struct{}{})
if name == "" {
if err == nil {
t.Fatal("expected error for empty name")
}
return
}
if err != nil {
t.Fatalf("unexpected error for name %q: %v", name, err)
}
// Retrieve should return the same service
got := sm.service(name)
if got == nil {
t.Fatalf("service %q not found after registration", name)
}
// Duplicate registration should fail
err = sm.registerService(name, struct{}{})
if err == nil {
t.Fatalf("expected duplicate error for name %q", name)
}
})
}
// FuzzMessageDispatch exercises action dispatch with concurrent registrations.
func FuzzMessageDispatch(f *testing.F) {
f.Add("hello")
f.Add("")
f.Add("test\nmultiline")
f.Fuzz(func(t *testing.T, payload string) {
c := &Core{
Features: &Features{},
svc: newServiceManager(),
}
c.bus = newMessageBus(c)
var received string
c.bus.registerAction(func(_ *Core, msg Message) error {
received = msg.(string)
return nil
})
err := c.bus.action(payload)
if err != nil {
t.Fatalf("action dispatch failed: %v", err)
}
if received != payload {
t.Fatalf("got %q, want %q", received, payload)
}
})
}

184
pkg/core/interfaces.go Normal file
View file

@ -0,0 +1,184 @@
package core
import (
"context"
"embed"
goio "io"
"io/fs"
"slices"
"sync"
"sync/atomic"
)
// This file defines the public API contracts (interfaces) for the services
// in the Core framework. Services depend on these interfaces, not on
// concrete implementations.
// Contract specifies the operational guarantees that the Core and its services must adhere to.
// This is used for configuring panic handling and other resilience features.
type Contract struct {
// DontPanic, if true, instructs the Core to recover from panics and return an error instead.
DontPanic bool
// DisableLogging, if true, disables all logging from the Core and its services.
DisableLogging bool
}
// Features provides a way to check if a feature is enabled.
// This is used for feature flagging and conditional logic.
type Features struct {
// Flags is a list of enabled feature flags.
Flags []string
}
// IsEnabled returns true if the given feature is enabled.
func (f *Features) IsEnabled(feature string) bool {
return slices.Contains(f.Flags, feature)
}
// Option is a function that configures the Core.
// This is used to apply settings and register services during initialization.
type Option func(*Core) error
// Message is the interface for all messages that can be sent through the Core's IPC system.
// Any struct can be a message, allowing for structured data to be passed between services.
// Used with ACTION for fire-and-forget broadcasts.
type Message any
// Query is the interface for read-only requests that return data.
// Used with QUERY (first responder) or QUERYALL (all responders).
type Query any
// Task is the interface for requests that perform side effects.
// Used with PERFORM (first responder executes).
type Task any
// TaskWithID is an optional interface for tasks that need to know their assigned ID.
// This is useful for tasks that want to report progress back to the frontend.
type TaskWithID interface {
Task
SetTaskID(id string)
GetTaskID() string
}
// QueryHandler handles Query requests. Returns (result, handled, error).
// If handled is false, the query will be passed to the next handler.
type QueryHandler func(*Core, Query) (any, bool, error)
// TaskHandler handles Task requests. Returns (result, handled, error).
// If handled is false, the task will be passed to the next handler.
type TaskHandler func(*Core, Task) (any, bool, error)
// Startable is an interface for services that need to perform initialization.
type Startable interface {
OnStartup(ctx context.Context) error
}
// Stoppable is an interface for services that need to perform cleanup.
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
// LocaleProvider is implemented by services that ship their own translation files.
// Core discovers this interface during service registration and collects the
// locale filesystems. The i18n service loads them during startup.
//
// Usage in a service package:
//
// //go:embed locales
// var localeFS embed.FS
//
// func (s *MyService) Locales() fs.FS { return localeFS }
type LocaleProvider interface {
Locales() fs.FS
}
// Core is the central application object that manages services, assets, and communication.
type Core struct {
App any // GUI runtime (e.g., Wails App) - set by WithApp option
assets embed.FS
Features *Features
svc *serviceManager
bus *messageBus
locales []fs.FS // collected from LocaleProvider services
taskIDCounter atomic.Uint64
wg sync.WaitGroup
shutdown atomic.Bool
}
// Locales returns all locale filesystems collected from registered services.
// The i18n service uses this during startup to load translations.
func (c *Core) Locales() []fs.FS {
return c.locales
}
// Config provides access to application configuration.
type Config interface {
// Get retrieves a configuration value by key and stores it in the 'out' variable.
Get(key string, out any) error
// Set stores a configuration value by key.
Set(key string, v any) error
}
// WindowOption is an interface for applying configuration options to a window.
type WindowOption interface {
Apply(any)
}
// Display provides access to windowing and visual elements.
type Display interface {
// OpenWindow creates a new window with the given options.
OpenWindow(opts ...WindowOption) error
}
// Workspace provides management for encrypted user workspaces.
type Workspace interface {
// CreateWorkspace creates a new encrypted workspace.
CreateWorkspace(identifier, password string) (string, error)
// SwitchWorkspace changes the active workspace.
SwitchWorkspace(name string) error
// WorkspaceFileGet retrieves the content of a file from the active workspace.
WorkspaceFileGet(filename string) (string, error)
// WorkspaceFileSet saves content to a file in the active workspace.
WorkspaceFileSet(filename, content string) error
}
// Crypt provides PGP-based encryption, signing, and key management.
type Crypt interface {
// CreateKeyPair generates a new PGP keypair.
CreateKeyPair(name, passphrase string) (string, error)
// EncryptPGP encrypts data for a recipient.
EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error)
// DecryptPGP decrypts a PGP message.
DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error)
}
// ActionServiceStartup is a message sent when the application's services are starting up.
// This provides a hook for services to perform initialization tasks.
type ActionServiceStartup struct{}
// ActionServiceShutdown is a message sent when the application is shutting down.
// This allows services to perform cleanup tasks, such as saving state or closing resources.
type ActionServiceShutdown struct{}
// ActionTaskStarted is a message sent when a background task has started.
type ActionTaskStarted struct {
TaskID string
Task Task
}
// ActionTaskProgress is a message sent when a task has progress updates.
type ActionTaskProgress struct {
TaskID string
Task Task
Progress float64 // 0.0 to 1.0
Message string
}
// ActionTaskCompleted is a message sent when a task has completed.
type ActionTaskCompleted struct {
TaskID string
Task Task
Result any
Error error
}

119
pkg/core/ipc_test.go Normal file
View file

@ -0,0 +1,119 @@
package core
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
type IPCTestQuery struct{ Value string }
type IPCTestTask struct{ Value string }
func TestIPC_Query(t *testing.T) {
c, _ := New()
// No handler
res, handled, err := c.QUERY(IPCTestQuery{})
assert.False(t, handled)
assert.Nil(t, res)
assert.Nil(t, err)
// With handler
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
if tq, ok := q.(IPCTestQuery); ok {
return tq.Value + "-response", true, nil
}
return nil, false, nil
})
res, handled, err = c.QUERY(IPCTestQuery{Value: "test"})
assert.True(t, handled)
assert.Nil(t, err)
assert.Equal(t, "test-response", res)
}
func TestIPC_QueryAll(t *testing.T) {
c, _ := New()
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "h1", true, nil
})
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "h2", true, nil
})
results, err := c.QUERYALL(IPCTestQuery{})
assert.Nil(t, err)
assert.Len(t, results, 2)
assert.Contains(t, results, "h1")
assert.Contains(t, results, "h2")
}
func TestIPC_Perform(t *testing.T) {
c, _ := New()
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
if tt, ok := task.(IPCTestTask); ok {
if tt.Value == "error" {
return nil, true, errors.New("task error")
}
return "done", true, nil
}
return nil, false, nil
})
// Success
res, handled, err := c.PERFORM(IPCTestTask{Value: "run"})
assert.True(t, handled)
assert.Nil(t, err)
assert.Equal(t, "done", res)
// Error
res, handled, err = c.PERFORM(IPCTestTask{Value: "error"})
assert.True(t, handled)
assert.Error(t, err)
assert.Nil(t, res)
}
func TestIPC_PerformAsync(t *testing.T) {
c, _ := New()
type AsyncResult struct {
TaskID string
Result any
Error error
}
done := make(chan AsyncResult, 1)
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
if tt, ok := task.(IPCTestTask); ok {
return tt.Value + "-done", true, nil
}
return nil, false, nil
})
c.RegisterAction(func(c *Core, msg Message) error {
if m, ok := msg.(ActionTaskCompleted); ok {
done <- AsyncResult{
TaskID: m.TaskID,
Result: m.Result,
Error: m.Error,
}
}
return nil
})
taskID := c.PerformAsync(IPCTestTask{Value: "async"})
assert.NotEmpty(t, taskID)
select {
case res := <-done:
assert.Equal(t, taskID, res.TaskID)
assert.Equal(t, "async-done", res.Result)
assert.Nil(t, res.Error)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task completion")
}
}

120
pkg/core/message_bus.go Normal file
View file

@ -0,0 +1,120 @@
package core
import (
"errors"
"slices"
"sync"
)
// messageBus owns the IPC action, query, and task dispatch.
// It is an unexported component used internally by Core.
type messageBus struct {
core *Core
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) error
queryMu sync.RWMutex
queryHandlers []QueryHandler
taskMu sync.RWMutex
taskHandlers []TaskHandler
}
// newMessageBus creates an empty message bus bound to the given Core.
func newMessageBus(c *Core) *messageBus {
return &messageBus{core: c}
}
// action dispatches a message to all registered IPC handlers.
func (b *messageBus) action(msg Message) error {
b.ipcMu.RLock()
handlers := slices.Clone(b.ipcHandlers)
b.ipcMu.RUnlock()
var agg error
for _, h := range handlers {
if err := h(b.core, msg); err != nil {
agg = errors.Join(agg, err)
}
}
return agg
}
// registerAction adds a single IPC handler.
func (b *messageBus) registerAction(handler func(*Core, Message) error) {
b.ipcMu.Lock()
b.ipcHandlers = append(b.ipcHandlers, handler)
b.ipcMu.Unlock()
}
// registerActions adds multiple IPC handlers.
func (b *messageBus) registerActions(handlers ...func(*Core, Message) error) {
b.ipcMu.Lock()
b.ipcHandlers = append(b.ipcHandlers, handlers...)
b.ipcMu.Unlock()
}
// query dispatches a query to handlers until one responds.
func (b *messageBus) query(q Query) (any, bool, error) {
b.queryMu.RLock()
handlers := slices.Clone(b.queryHandlers)
b.queryMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(b.core, q)
if handled {
return result, true, err
}
}
return nil, false, nil
}
// queryAll dispatches a query to all handlers and collects all responses.
func (b *messageBus) queryAll(q Query) ([]any, error) {
b.queryMu.RLock()
handlers := slices.Clone(b.queryHandlers)
b.queryMu.RUnlock()
var results []any
var agg error
for _, h := range handlers {
result, handled, err := h(b.core, q)
if err != nil {
agg = errors.Join(agg, err)
}
if handled && result != nil {
results = append(results, result)
}
}
return results, agg
}
// registerQuery adds a query handler.
func (b *messageBus) registerQuery(handler QueryHandler) {
b.queryMu.Lock()
b.queryHandlers = append(b.queryHandlers, handler)
b.queryMu.Unlock()
}
// perform dispatches a task to handlers until one executes it.
func (b *messageBus) perform(t Task) (any, bool, error) {
b.taskMu.RLock()
handlers := slices.Clone(b.taskHandlers)
b.taskMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(b.core, t)
if handled {
return result, true, err
}
}
return nil, false, nil
}
// registerTask adds a task handler.
func (b *messageBus) registerTask(handler TaskHandler) {
b.taskMu.Lock()
b.taskHandlers = append(b.taskHandlers, handler)
b.taskMu.Unlock()
}

View file

@ -0,0 +1,176 @@
package core
import (
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMessageBus_Action_Good(t *testing.T) {
c, _ := New()
var received []Message
c.bus.registerAction(func(_ *Core, msg Message) error {
received = append(received, msg)
return nil
})
c.bus.registerAction(func(_ *Core, msg Message) error {
received = append(received, msg)
return nil
})
err := c.bus.action("hello")
assert.NoError(t, err)
assert.Len(t, received, 2)
}
func TestMessageBus_Action_Bad(t *testing.T) {
c, _ := New()
err1 := errors.New("handler1 failed")
err2 := errors.New("handler2 failed")
c.bus.registerAction(func(_ *Core, msg Message) error { return err1 })
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
c.bus.registerAction(func(_ *Core, msg Message) error { return err2 })
err := c.bus.action("test")
assert.Error(t, err)
assert.ErrorIs(t, err, err1)
assert.ErrorIs(t, err, err2)
}
func TestMessageBus_RegisterAction_Good(t *testing.T) {
c, _ := New()
var coreRef *Core
c.bus.registerAction(func(core *Core, msg Message) error {
coreRef = core
return nil
})
_ = c.bus.action(nil)
assert.Same(t, c, coreRef, "handler should receive the Core reference")
}
func TestMessageBus_Query_Good(t *testing.T) {
c, _ := New()
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
return "first", true, nil
})
result, handled, err := c.bus.query(TestQuery{Value: "test"})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "first", result)
}
func TestMessageBus_QueryAll_Good(t *testing.T) {
c, _ := New()
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
return "a", true, nil
})
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
return nil, false, nil // skips
})
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
return "b", true, nil
})
results, err := c.bus.queryAll(TestQuery{})
assert.NoError(t, err)
assert.Equal(t, []any{"a", "b"}, results)
}
func TestMessageBus_Perform_Good(t *testing.T) {
c, _ := New()
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) {
return "done", true, nil
})
result, handled, err := c.bus.perform(TestTask{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "done", result)
}
func TestMessageBus_ConcurrentAccess_Good(t *testing.T) {
c, _ := New()
var wg sync.WaitGroup
const goroutines = 20
// Concurrent register + dispatch
for i := 0; i < goroutines; i++ {
wg.Add(2)
go func() {
defer wg.Done()
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
}()
go func() {
defer wg.Done()
_ = c.bus.action("ping")
}()
}
for i := 0; i < goroutines; i++ {
wg.Add(2)
go func() {
defer wg.Done()
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil })
}()
go func() {
defer wg.Done()
_, _ = c.bus.queryAll(TestQuery{})
}()
}
for i := 0; i < goroutines; i++ {
wg.Add(2)
go func() {
defer wg.Done()
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil })
}()
go func() {
defer wg.Done()
_, _, _ = c.bus.perform(TestTask{})
}()
}
wg.Wait()
}
func TestMessageBus_Action_NoHandlers(t *testing.T) {
c, _ := New()
// Should not error if no handlers are registered
err := c.bus.action("no one listening")
assert.NoError(t, err)
}
func TestMessageBus_Query_NoHandlers(t *testing.T) {
c, _ := New()
result, handled, err := c.bus.query(TestQuery{})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}
func TestMessageBus_QueryAll_NoHandlers(t *testing.T) {
c, _ := New()
results, err := c.bus.queryAll(TestQuery{})
assert.NoError(t, err)
assert.Empty(t, results)
}
func TestMessageBus_Perform_NoHandlers(t *testing.T) {
c, _ := New()
result, handled, err := c.bus.perform(TestTask{})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}

201
pkg/core/query_test.go Normal file
View file

@ -0,0 +1,201 @@
package core
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
type TestQuery struct {
Value string
}
type TestTask struct {
Value string
}
func TestCore_QUERY_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// Register a handler that responds to TestQuery
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
if tq, ok := q.(TestQuery); ok {
return "result-" + tq.Value, true, nil
}
return nil, false, nil
})
result, handled, err := c.QUERY(TestQuery{Value: "test"})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "result-test", result)
}
func TestCore_QUERY_NotHandled(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// No handlers registered
result, handled, err := c.QUERY(TestQuery{Value: "test"})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}
func TestCore_QUERY_FirstResponder(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// First handler responds
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "first", true, nil
})
// Second handler would respond but shouldn't be called
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "second", true, nil
})
result, handled, err := c.QUERY(TestQuery{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "first", result)
}
func TestCore_QUERY_SkipsNonHandlers(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// First handler doesn't handle
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return nil, false, nil
})
// Second handler responds
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "second", true, nil
})
result, handled, err := c.QUERY(TestQuery{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "second", result)
}
func TestCore_QUERYALL_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// Multiple handlers respond
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "first", true, nil
})
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "second", true, nil
})
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return nil, false, nil // Doesn't handle
})
results, err := c.QUERYALL(TestQuery{})
assert.NoError(t, err)
assert.Len(t, results, 2)
assert.Contains(t, results, "first")
assert.Contains(t, results, "second")
}
func TestCore_QUERYALL_AggregatesErrors(t *testing.T) {
c, err := New()
assert.NoError(t, err)
err1 := errors.New("error1")
err2 := errors.New("error2")
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "result1", true, err1
})
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
return "result2", true, err2
})
results, err := c.QUERYALL(TestQuery{})
assert.Error(t, err)
assert.ErrorIs(t, err, err1)
assert.ErrorIs(t, err, err2)
assert.Len(t, results, 2)
}
func TestCore_PERFORM_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
executed := false
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
if tt, ok := t.(TestTask); ok {
executed = true
return "done-" + tt.Value, true, nil
}
return nil, false, nil
})
result, handled, err := c.PERFORM(TestTask{Value: "work"})
assert.NoError(t, err)
assert.True(t, handled)
assert.True(t, executed)
assert.Equal(t, "done-work", result)
}
func TestCore_PERFORM_NotHandled(t *testing.T) {
c, err := New()
assert.NoError(t, err)
// No handlers registered
result, handled, err := c.PERFORM(TestTask{Value: "work"})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}
func TestCore_PERFORM_FirstResponder(t *testing.T) {
c, err := New()
assert.NoError(t, err)
callCount := 0
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
callCount++
return "first", true, nil
})
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
callCount++
return "second", true, nil
})
result, handled, err := c.PERFORM(TestTask{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "first", result)
assert.Equal(t, 1, callCount) // Only first handler called
}
func TestCore_PERFORM_WithError(t *testing.T) {
c, err := New()
assert.NoError(t, err)
expectedErr := errors.New("task failed")
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
return nil, true, expectedErr
})
result, handled, err := c.PERFORM(TestTask{})
assert.Error(t, err)
assert.ErrorIs(t, err, expectedErr)
assert.True(t, handled)
assert.Nil(t, result)
}

113
pkg/core/runtime_pkg.go Normal file
View file

@ -0,0 +1,113 @@
package core
import (
"context"
"fmt"
"maps"
"slices"
)
// ServiceRuntime is a helper struct embedded in services to provide access to the core application.
// It is generic and can be parameterized with a service-specific options struct.
type ServiceRuntime[T any] struct {
core *Core
opts T
}
// NewServiceRuntime creates a new ServiceRuntime instance for a service.
// This is typically called by a service's constructor.
func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
return &ServiceRuntime[T]{
core: c,
opts: opts,
}
}
// Core returns the central core instance, providing access to all registered services.
func (r *ServiceRuntime[T]) Core() *Core {
return r.core
}
// Opts returns the service-specific options.
func (r *ServiceRuntime[T]) Opts() T {
return r.opts
}
// Config returns the registered Config service from the core application.
// This is a convenience method for accessing the application's configuration.
func (r *ServiceRuntime[T]) Config() Config {
return r.core.Config()
}
// Runtime is the container that holds all instantiated services.
// Its fields are the concrete types, allowing GUI runtimes to bind them directly.
// This struct is the primary entry point for the application.
type Runtime struct {
app any // GUI runtime (e.g., Wails App)
Core *Core
}
// ServiceFactory defines a function that creates a service instance.
// This is used to decouple the service creation from the runtime initialization.
type ServiceFactory func() (any, error)
// NewWithFactories creates a new Runtime instance using the provided service factories.
// This is the most flexible way to create a new Runtime, as it allows for
// the registration of any number of services.
func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) {
coreOpts := []Option{
WithApp(app),
}
names := slices.Sorted(maps.Keys(factories))
for _, name := range names {
factory := factories[name]
if factory == nil {
return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil)
}
svc, err := factory()
if err != nil {
return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err)
}
svcCopy := svc
coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil }))
}
coreInstance, err := New(coreOpts...)
if err != nil {
return nil, err
}
return &Runtime{
app: app,
Core: coreInstance,
}, nil
}
// NewRuntime creates and wires together all application services.
// This is the simplest way to create a new Runtime, but it does not allow for
// the registration of any custom services.
func NewRuntime(app any) (*Runtime, error) {
return NewWithFactories(app, map[string]ServiceFactory{})
}
// ServiceName returns the name of the service. This is used by GUI runtimes to identify the service.
func (r *Runtime) ServiceName() string {
return "Core"
}
// ServiceStartup is called by the GUI runtime at application startup.
// This is where the Core's startup lifecycle is initiated.
func (r *Runtime) ServiceStartup(ctx context.Context, options any) error {
return r.Core.ServiceStartup(ctx, options)
}
// ServiceShutdown is called by the GUI runtime at application shutdown.
// This is where the Core's shutdown lifecycle is initiated.
func (r *Runtime) ServiceShutdown(ctx context.Context) error {
if r.Core != nil {
return r.Core.ServiceShutdown(ctx)
}
return nil
}

View file

@ -0,0 +1,18 @@
package core
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewWithFactories_EmptyName(t *testing.T) {
factories := map[string]ServiceFactory{
"": func() (any, error) {
return &MockService{Name: "test"}, nil
},
}
_, err := NewWithFactories(nil, factories)
assert.Error(t, err)
assert.Contains(t, err.Error(), "service name cannot be empty")
}

View file

@ -0,0 +1,128 @@
package core
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewRuntime(t *testing.T) {
testCases := []struct {
name string
app any
factories map[string]ServiceFactory
expectErr bool
expectErrStr string
checkRuntime func(*testing.T, *Runtime)
}{
{
name: "Good path",
app: nil,
factories: map[string]ServiceFactory{},
expectErr: false,
checkRuntime: func(t *testing.T, rt *Runtime) {
assert.NotNil(t, rt)
assert.NotNil(t, rt.Core)
},
},
{
name: "With non-nil app",
app: &mockApp{},
factories: map[string]ServiceFactory{},
expectErr: false,
checkRuntime: func(t *testing.T, rt *Runtime) {
assert.NotNil(t, rt)
assert.NotNil(t, rt.Core)
assert.NotNil(t, rt.Core.App)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rt, err := NewRuntime(tc.app)
if tc.expectErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tc.expectErrStr)
assert.Nil(t, rt)
} else {
assert.NoError(t, err)
if tc.checkRuntime != nil {
tc.checkRuntime(t, rt)
}
}
})
}
}
func TestNewWithFactories_Good(t *testing.T) {
factories := map[string]ServiceFactory{
"test": func() (any, error) {
return &MockService{Name: "test"}, nil
},
}
rt, err := NewWithFactories(nil, factories)
assert.NoError(t, err)
assert.NotNil(t, rt)
svc := rt.Core.Service("test")
assert.NotNil(t, svc)
mockSvc, ok := svc.(*MockService)
assert.True(t, ok)
assert.Equal(t, "test", mockSvc.Name)
}
func TestNewWithFactories_Bad(t *testing.T) {
factories := map[string]ServiceFactory{
"test": func() (any, error) {
return nil, assert.AnError
},
}
_, err := NewWithFactories(nil, factories)
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
}
func TestNewWithFactories_Ugly(t *testing.T) {
factories := map[string]ServiceFactory{
"test": nil,
}
_, err := NewWithFactories(nil, factories)
assert.Error(t, err)
assert.Contains(t, err.Error(), "factory is nil")
}
func TestRuntime_Lifecycle_Good(t *testing.T) {
rt, err := NewRuntime(nil)
assert.NoError(t, err)
assert.NotNil(t, rt)
// ServiceName
assert.Equal(t, "Core", rt.ServiceName())
// ServiceStartup & ServiceShutdown
// These are simple wrappers around the core methods, which are tested in core_test.go.
// We call them here to ensure coverage.
rt.ServiceStartup(context.TODO(), nil)
rt.ServiceShutdown(context.TODO())
// Test shutdown with nil core
rt.Core = nil
rt.ServiceShutdown(context.TODO())
}
func TestNewServiceRuntime_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
sr := NewServiceRuntime(c, "test options")
assert.NotNil(t, sr)
assert.Equal(t, c, sr.Core())
// We can't directly test sr.Config() without a registered config service,
// as it will panic.
assert.Panics(t, func() {
sr.Config()
})
}

Some files were not shown because too many files have changed in this diff Show more