| title | Architecture |
|---|
AWF follows Hexagonal (Ports and Adapters) / Clean Architecture with strict dependency inversion.
┌─────────────────────────────────────────────────────────────┐
│ INTERFACES LAYER │
│ CLI (current) │ API (future) │ MQ (future) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ APPLICATION LAYER │
│ WorkflowService │ ExecutionService │ PluginService │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ DOMAIN LAYER │
│ Workflow │ Step │ Plugin │ Operation │ Ports (Interfaces) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ YAMLRepository │ JSONStateStore │ AgentProviders │ GitHub │ Notify │
└─────────────────────────────────────────────────────────────┘
Domain layer depends on nothing. All other layers depend inward toward domain.
Interfaces → Application → Domain ← Infrastructure
- Domain defines interfaces (Ports)
- Infrastructure implements those interfaces (Adapters)
- Application orchestrates domain logic
- Interfaces handle external communication
The core business logic. No external dependencies.
Location: internal/domain/
Components:
workflow/- Workflow, Step, State, Context entitiesplugin/- Plugin manifest, operation schema, state entitiesoperation/- Operation interface, OperationRegistry, input validation (ValidateInputs)ports/- Repository, StateStore, Executor, PluginManager, OperationProvider interfaces
Key Entities:
type Workflow struct {
ID string
Name string
Version string
Description string
Inputs []InputDef
States map[string]State
Initial string
}
type State interface {
GetName() string
GetType() StateType
}
type ExecutionContext struct {
WorkflowID string
Inputs map[string]interface{}
States map[string]StateResult
}Operation Interface and Registry (F057):
// Operation defines the contract for executable operations.
type Operation interface {
Name() string
Execute(ctx context.Context, inputs map[string]any) (*plugin.OperationResult, error)
Schema() *plugin.OperationSchema
}
// OperationRegistry manages operation lifecycle with thread-safe access.
// Implements ports.OperationProvider for seamless ExecutionService integration.
type OperationRegistry struct {
operations map[string]Operation
mu sync.RWMutex
}The Operation interface is distinct from OperationProvider: an Operation is a single executable operation (e.g., http.get), while OperationProvider manages a collection. The registry bridges the two by implementing OperationProvider using registered Operation instances.
ValidateInputs(schema, inputs) is a standalone function that checks required fields, type correctness (string, integer, boolean, array, object), and applies defaults for optional inputs. It handles JSON float64→int coercion.
Ports (Interfaces):
type Repository interface {
Load(name string) (*Workflow, error)
List() ([]WorkflowInfo, error)
}
type StateStore interface {
Save(ctx *ExecutionContext) error
Load(id string) (*ExecutionContext, error)
Delete(id string) error
}
type Executor interface {
Execute(ctx context.Context, cmd Command) (Result, error)
}
type PluginManager interface {
// Discover finds plugins in the plugins directory.
Discover(ctx context.Context) ([]*plugin.PluginInfo, error)
// Load loads a plugin by name.
Load(ctx context.Context, name string) error
// Init initializes a loaded plugin.
Init(ctx context.Context, name string, config map[string]any) error
// Shutdown stops a running plugin.
Shutdown(ctx context.Context, name string) error
// ShutdownAll stops all running plugins.
ShutdownAll(ctx context.Context) error
// Get returns plugin info by name.
Get(name string) (*plugin.PluginInfo, bool)
// List returns all known plugins.
List() []*plugin.PluginInfo
}
// Interactive mode ports follow ISP with focused interfaces:
type StepPresenter interface {
ShowHeader(workflowName string)
ShowStepDetails(info *workflow.InteractiveStepInfo)
ShowExecuting(stepName string)
ShowStepResult(state *workflow.StepState, nextStep string)
}
type StatusPresenter interface {
ShowAborted()
ShowSkipped(stepName string, nextStep string)
ShowCompleted(status workflow.ExecutionStatus)
ShowError(err error)
}
type UserInteraction interface {
PromptAction(hasRetry bool) (workflow.InteractiveAction, error)
EditInput(name string, current any) (any, error)
ShowContext(ctx *workflow.RuntimeContext)
}
// Composite for backward compatibility (io.ReadWriter pattern):
type InteractivePrompt interface {
StepPresenter
StatusPresenter
UserInteraction
}Orchestrates use cases using domain entities and ports.
Location: internal/application/
Services:
WorkflowService- Workflow loading, validation, listingExecutionService- Workflow execution engineStateManager- State persistence managementTemplateService- Template loading and resolutionPluginService- Plugin lifecycle orchestration
Key Responsibilities:
- Coordinate domain operations
- Handle transactions/rollbacks
- Implement use case logic
- No direct infrastructure access (only through ports)
Implements domain ports with concrete technologies.
Location: internal/infrastructure/
Adapters:
agents/- AI agent providers (Claude, Gemini, Codex, OpenCode, OpenAI-Compatible) implementingAgentProvider; CLI-based providers delegate to a sharedbaseCLIProviderwith per-provider hooks (arg building, session ID extraction, output transformation, option validation)config/- Configuration file loadingdiagram/- Workflow diagram generation (DOT/Graphviz)errors/- Error formatting adapters implementingErrorFormatterexecutor/- Shell executor implementingExecutorexpression/- Expression evaluator implementingExpressionEvaluatorandExpressionValidatorgithub/- Built-in GitHub operation provider implementingOperationProvider(issue/PR/label/project operations, batch executor, auth fallback)logger/- Zap logger implementation (console, JSON, multi-logger, secret masking)notify/- Built-in notification operation provider implementingOperationProvider(desktop, webhook backends)pluginmgr/- Plugin lifecycle (manifest, state, gRPC connections); delegates transport topkg/registry/repository/- YAML file loader implementingRepositorystore/- JSON state store implementingStateStore, SQLite history storagetokenizer/- Token counting for conversation context managementxdg/- XDG directory discovery
Shared Packages (pkg/):
pkg/registry/- Shared GitHub Releases transport (versioning, downloads, checksum verification) used by plugin system and workflow pack systempkg/httpx/- HTTP client abstractions (HTTPDoerinterface, size-limited reads)pkg/plugin/sdk/- Plugin author SDK (Serve(),BasePlugin, input helpers)
Implementation Details:
// YAML Repository
type YAMLRepository struct {
paths []string // Search paths for workflows
}
func (r *YAMLRepository) Load(name string) (*Workflow, error) {
// Find and parse YAML file
}
// JSON State Store
type JSONStateStore struct {
dir string // Storage directory
}
func (s *JSONStateStore) Save(ctx *ExecutionContext) error {
// Atomic write via temp file + rename
}
// Shell Executor
type ShellExecutor struct{}
func (e *ShellExecutor) Execute(ctx context.Context, cmd Command) (Result, error) {
// Execute via detected shell ($SHELL or /bin/sh fallback)
}
// RPC Plugin Manager (gRPC transport via HashiCorp go-plugin, C067)
type RPCPluginManager struct {
pluginsDir string
plugins map[string]*PluginInfo
connections map[string]*pluginConnection // gRPC connections protected by mu RWMutex
mu sync.RWMutex
}
func (m *RPCPluginManager) Init(ctx context.Context, name string) error {
// 1. Discover binary at ~/.local/share/awf/plugins/<name>/awf-plugin-<name>
// 2. Verify version compatibility from plugin.yaml
// 3. Start plugin process via HashiCorp go-plugin
// 4. Establish gRPC connection with 5s timeout (NFR-002)
// 5. Call PluginService.Init RPC
}
func (m *RPCPluginManager) Execute(ctx context.Context, op string, inputs map[string]any) (*OperationResult, error) {
// 1. Route operation to correct plugin based on op name
// 2. Call OperationService.Execute via gRPC
// 3. Convert proto OperationResult to domain type
// 4. Inject PluginName from connection context
}
func (m *RPCPluginManager) Shutdown(ctx context.Context, name string) error {
// 1. Call PluginService.Shutdown RPC
// 2. Kill process via go-plugin
// 3. Clean up connection from map
}
func (m *RPCPluginManager) ShutdownAll(ctx context.Context) error {
// 1. For each running plugin, set 5s deadline
// 2. Call Shutdown on each
// 3. Kill all processes unconditionally
// 4. Accumulate errors with errors.Join()
}Plugin System Architecture:
AWF uses HashiCorp go-plugin with gRPC for external plugin transport (ADR 015). Plugins are separate processes that communicate with the host via typed gRPC services:
// PluginService: plugin lifecycle management
service PluginService {
rpc GetInfo(GetInfoRequest) returns (PluginInfo) {}
rpc Init(InitRequest) returns (InitResponse) {}
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse) {}
}
// OperationService: operation dispatch
service OperationService {
rpc ListOperations(ListRequest) returns (ListResponse) {}
rpc GetOperation(GetRequest) returns (OperationSchema) {}
rpc Execute(ExecuteRequest) returns (ExecuteResponse) {}
}Key Features:
- Process Isolation — Plugin crashes don't affect host workflow execution
- Version Compatibility — Manifest version checked before Init; incompatible plugins fail with exit code 1
- Thread-Safe — connections map protected by RWMutex; concurrent Execute calls validated with
-race - Timeout Protection — Plugin startup enforced with 5s deadline via goroutine+select pattern
- Error Handling — Binary not found (exit 4), version mismatch (exit 1), plugin crash (exit 3)
Plugin Discovery:
Plugins are discovered from $XDG_DATA_HOME/awf/plugins/ (default: ~/.local/share/awf/plugins/). Each plugin requires:
- Directory named
awf-plugin-<name> - Executable binary
awf-plugin-<name>in that directory - Manifest
plugin.yamlwith version and capability declarations
SDK for Plugin Authors:
Plugin authors implement the SDK interface and call sdk.Serve() from main:
type MyPlugin struct {
sdk.BasePlugin
}
func (p *MyPlugin) Operations() []string {
return []string{"my_op"}
}
func (p *MyPlugin) HandleOperation(ctx context.Context, name string, inputs map[string]any) (*sdk.OperationResult, error) {
return sdk.NewSuccessResult(output, data), nil
}
func main() {
sdk.Serve(&MyPlugin{
BasePlugin: sdk.BasePlugin{
PluginName: "awf-plugin-myplugin",
PluginVersion: "1.0.0",
},
})
}The SDK handles:
- go-plugin handshake and lifecycle
- gRPC server setup and method delegation
- JSON serialization for
map[string]anyfields - Input validation helpers (GetStringDefault, GetIntDefault, etc.)
- Result construction (NewSuccessResult, NewErrorResult)
Built-in Providers:
AWF ships with 3 built-in operation providers:
github.*— Issue/PR operations (in-process, zero IPC overhead)http.request— REST API calls (in-process)notify.send— Workflow completion alerts (in-process)
Built-in providers are synthesized as PluginInfo entries and appear in awf plugin list with type builtin. They can be disabled/enabled like external plugins.
Error Handling:
Plugin errors map to domain error codes:
- Binary not found → SYSTEM.IO (exit 4)
- Version incompatible → USER.VALIDATION (exit 1)
- Plugin crash during Execute → EXECUTION.PLUGIN (exit 3)
- Connection timeout → EXECUTION.TIMEOUT (exit 3)
See ADR-015 for design rationale and trade-offs.
### Interfaces Layer
External communication adapters.
**Location:** `internal/interfaces/`
**Components:**
- `cli/` - Cobra command handlers
- `cli/ui/` - Terminal UI components (colors, progress bars)
**Future:**
- REST API adapter
- gRPC adapter
- Message queue adapter
### Public Packages
Reusable utilities safe for external consumption.
**Location:** `pkg/`
**Packages:**
- `interpolation/` - Template variable substitution and shell escaping
- `registry/` - Package/plugin registry transport layer (C070) — GitHub Releases API client, semantic versioning, download & extraction utilities
- `validation/` - Input validation rule evaluation
- `retry/` - Backoff strategies (exponential, linear, constant)
These packages have no domain/infrastructure dependencies and can be imported by external projects.
## Key Patterns
### Dependency Injection
Services receive dependencies through constructors:
```go
func NewWorkflowService(
repo ports.Repository,
store ports.StateStore,
executor ports.Executor,
logger ports.Logger,
validator ports.ExpressionValidator,
) *WorkflowService {
return &WorkflowService{
repo: repo,
store: store,
executor: executor,
logger: logger,
validator: validator,
}
}
All dependencies are domain port interfaces, never infrastructure implementations. The interfaces layer (CLI) wires concrete adapters at the composition root.
Workflow execution follows a state machine pattern:
- Load initial state
- Execute state (step/parallel/loop)
- Evaluate transitions
- Move to next state
- Repeat until terminal state
State persistence uses atomic writes:
// Write to temp file
tmpFile := fmt.Sprintf("%s.%d.%d.tmp", path, os.Getpid(), time.Now().UnixNano())
// Write content
// Rename to target (atomic on POSIX)
os.Rename(tmpFile, path)Go contexts are used for cancellation and timeouts:
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()
result, err := executor.Execute(ctx, cmd)Uses errgroup with semaphore for controlled concurrency:
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, maxConcurrent)
for _, step := range steps {
g.Go(func() error {
sem <- struct{}{}
defer func() { <-sem }()
return executeStep(ctx, step)
})
}
return g.Wait()CLI Command
│
▼
WorkflowService.Load()
│
▼
ExecutionService.Execute()
│
├──► StateStore.Save() (persist state)
│
├──► Executor.Execute() (run commands)
│
└──► StateStore.Load() (resume if interrupted)
SIGINT/SIGTERM
│
▼
Context Cancellation
│
├──► Executor stops command
│
├──► StateStore saves checkpoint
│
└──► Clean exit with state preserved
- Domain: Pure unit tests, no mocks needed
- Application: Mock ports for isolation
- Infrastructure: Integration tests with real resources
- Interfaces: End-to-end CLI tests
See Testing for details.
The WorkflowService previously instantiated expression.NewExprValidator() directly, violating the Dependency Inversion Principle by importing infrastructure from the application layer. This was fixed by:
- Adding
validator ports.ExpressionValidatoras a constructor parameter - Removing the
internal/infrastructure/expressionimport fromservice.go - Wiring the concrete
ExpressionValidatoradapter at CLI call sites (composition root)
The ExpressionValidator port interface was already defined in internal/domain/ports/expression_validator.go. The fix makes the application layer depend only on the port abstraction.
Architecture constraints are enforced automatically using go-arch-lint, configured in .go-arch-lint.yml. This provides AST-based validation of the full dependency graph across all layers.
Usage:
make lint-arch # Check architecture constraints
make lint-arch-map # Show component-to-package mappingIntegration:
lint-archtarget is integrated intomake qualitygate- Runs in CI via
.github/workflows/quality.ymlon every push - Blocks merge if violations detected
- All dependencies checked via AST analysis (deepScan mode)
Configuration Structure (.go-arch-lint.yml):
| Section | Purpose |
|---|---|
version: 3 |
Configuration version |
workdir: internal |
Scope for component paths |
commonVendors |
Shared libraries (all components) |
commonComponents |
Shared packages (all components) |
vendors |
Vendor library definitions |
components |
20 components across 4 layers |
deps |
Dependency rules per component |
Validation Rules by Layer:
| Layer | Count | Max Dependencies | Rationale |
|---|---|---|---|
| Domain | 5 | stdlib + sync only | Pure business logic |
| Application | 1 | domain only + stdlib | Orchestration, no infra |
| Infrastructure | 12 | domain + vendors + stdlib | Concrete implementations |
| Interfaces | 2 | all layers + stdlib | Delivery/wiring |
| Testutil | 1 | domain + selected infra | Test helpers |
Example: Application Layer Rule:
application:
mayDependOn:
- domain-workflow
- domain-ports
- domain-errors
- domain-plugin
canUse:
- go-stdlib
- go-syncThis means the application layer can depend on domain components and stdlib only — any attempt to import from infrastructure, vendor libraries, or other layers will fail the build.
Debugging Component Mapping:
$ make lint-arch-map
Component mapping:
domain-workflow → internal/domain/workflow
domain-ports → internal/domain/ports
application → internal/application
infra-expression → internal/infrastructure/expression
interfaces-cli → internal/interfaces/cli
... (14 more)C042 + C051 Alignment:
- C042 tests verify DIP compliance at runtime via integration tests
- C051 enforces DIP compliance at build time via static analysis
- Together they provide defense-in-depth: testing catches logic errors, linting catches architectural violations
The PluginManager interface (7 methods) was evaluated for ISP compliance and kept unified.
Rationale:
- Single consumer: Only PluginService uses PluginManager, calling all 7 methods
- Cross-concern coupling: DisablePlugin uses both Get() (query) and Shutdown() (lifecycle) together
- 7 methods is within acceptable threshold (Go stdlib net.Conn has 8)
- Plugin subsystem already has 8 focused interfaces averaging 3.75 methods each
Contrast with C049: InteractivePrompt (11 methods, 3 consumers) was split into focused interfaces because different consumers needed distinct method subsets.
- Project Structure - Directory organization
- Testing - Testing conventions
- Contributing - Development workflow