Documents the four dispatch patterns (ACTION, QUERY, QUERYALL, PERFORM), worker bundle architecture, and permissions-through-presence model. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
373 lines
11 KiB
Markdown
373 lines
11 KiB
Markdown
# Core Framework IPC Design
|
|
|
|
> Design document for refactoring CLI commands to use the Core framework's IPC system.
|
|
|
|
## Overview
|
|
|
|
The Core framework provides a dependency injection and inter-process communication (IPC) system originally designed for orchestrating services. This design extends the framework with request/response patterns and applies it to CLI commands.
|
|
|
|
Commands build "worker bundles" - sandboxed Core instances with specific services. The bundle configuration acts as a permissions layer: if a service isn't registered, that capability isn't available.
|
|
|
|
## Dispatch Patterns
|
|
|
|
Four patterns for service communication:
|
|
|
|
| Method | Behaviour | Returns | Use Case |
|
|
|--------|-----------|---------|----------|
|
|
| `ACTION` | Broadcast to all handlers | `error` | Events, notifications |
|
|
| `QUERY` | First responder wins | `(any, bool, error)` | Get data |
|
|
| `QUERYALL` | Broadcast, collect all | `([]any, error)` | Aggregate from multiple services |
|
|
| `PERFORM` | First responder executes | `(any, bool, error)` | Execute a task with side effects |
|
|
|
|
### ACTION (existing)
|
|
|
|
Fire-and-forget broadcast. All registered handlers receive the message. Errors are aggregated.
|
|
|
|
```go
|
|
c.ACTION(ActionServiceStartup{})
|
|
```
|
|
|
|
### QUERY (new)
|
|
|
|
Request data from services. Stops at first handler that returns `handled=true`.
|
|
|
|
```go
|
|
result, handled, err := c.QUERY(git.QueryStatus{Paths: paths})
|
|
if !handled {
|
|
// No service registered to handle this query
|
|
}
|
|
statuses := result.([]git.RepoStatus)
|
|
```
|
|
|
|
### QUERYALL (new)
|
|
|
|
Broadcast query to all handlers, collect all responses. Useful for aggregating results from multiple services (e.g., multiple QA/lint tools).
|
|
|
|
```go
|
|
results, err := c.QUERYALL(qa.QueryLint{Paths: paths})
|
|
for _, r := range results {
|
|
lint := r.(qa.LintResult)
|
|
fmt.Printf("%s found %d issues\n", lint.Tool, len(lint.Issues))
|
|
}
|
|
```
|
|
|
|
### PERFORM (new)
|
|
|
|
Execute a task with side effects. Stops at first handler that returns `handled=true`.
|
|
|
|
```go
|
|
result, handled, err := c.PERFORM(agentic.TaskCommit{
|
|
Path: repo.Path,
|
|
Name: repo.Name,
|
|
})
|
|
if !handled {
|
|
// Agentic service not in bundle - commits not available
|
|
}
|
|
```
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ cmd/dev/dev_work.go │
|
|
│ - Builds worker bundle │
|
|
│ - Triggers PERFORM(TaskWork{}) │
|
|
└─────────────────────┬───────────────────────────────────────┘
|
|
│
|
|
┌─────────────────────▼───────────────────────────────────────┐
|
|
│ cmd/dev/bundles.go │
|
|
│ - NewWorkBundle() - git + agentic + dev │
|
|
│ - NewStatusBundle() - git + dev only │
|
|
│ - Bundle config = permissions │
|
|
└─────────────────────┬───────────────────────────────────────┘
|
|
│
|
|
┌─────────────────────▼───────────────────────────────────────┐
|
|
│ pkg/dev/service.go │
|
|
│ - Orchestrates workflow │
|
|
│ - QUERY(git.QueryStatus{}) │
|
|
│ - PERFORM(agentic.TaskCommit{}) │
|
|
│ - PERFORM(git.TaskPush{}) │
|
|
└─────────────────────┬───────────────────────────────────────┘
|
|
│
|
|
┌─────────────┴─────────────┐
|
|
▼ ▼
|
|
┌───────────────────┐ ┌───────────────────┐
|
|
│ pkg/git/service │ │ pkg/agentic/svc │
|
|
│ │ │ │
|
|
│ Queries: │ │ Tasks: │
|
|
│ - QueryStatus │ │ - TaskCommit │
|
|
│ - QueryDirtyRepos │ │ - TaskPrompt │
|
|
│ - QueryAheadRepos │ │ │
|
|
│ │ │ │
|
|
│ Tasks: │ │ │
|
|
│ - TaskPush │ │ │
|
|
│ - TaskPull │ │ │
|
|
└───────────────────┘ └───────────────────┘
|
|
```
|
|
|
|
## Permissions Model
|
|
|
|
Permissions are implicit through bundle configuration:
|
|
|
|
```go
|
|
// Full capabilities - can commit and push
|
|
func NewWorkBundle(opts WorkBundleOptions) (*framework.Runtime, error) {
|
|
return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
|
|
"dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) },
|
|
"git": func() (any, error) { return git.NewService(opts.Git)(nil) },
|
|
"agentic": func() (any, error) { return agentic.NewService(opts.Agentic)(nil) },
|
|
})
|
|
}
|
|
|
|
// Read-only - status queries only, no commits
|
|
func NewStatusBundle(opts StatusBundleOptions) (*framework.Runtime, error) {
|
|
return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
|
|
"dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) },
|
|
"git": func() (any, error) { return git.NewService(opts.Git)(nil) },
|
|
// No agentic service - TaskCommit will be unhandled
|
|
})
|
|
}
|
|
```
|
|
|
|
Service options provide fine-grained control:
|
|
|
|
```go
|
|
agentic.NewService(agentic.ServiceOptions{
|
|
AllowEdit: false, // Claude can only use read-only tools
|
|
})
|
|
|
|
agentic.NewService(agentic.ServiceOptions{
|
|
AllowEdit: true, // Claude can use Write/Edit tools
|
|
})
|
|
```
|
|
|
|
**Key principle**: Code never checks permissions explicitly. It dispatches actions and either they're handled or they're not. The bundle configuration is the single source of truth for what's allowed.
|
|
|
|
## Framework Changes
|
|
|
|
### New Types (interfaces.go)
|
|
|
|
```go
|
|
type Query interface{}
|
|
type Task interface{}
|
|
|
|
type QueryHandler func(*Core, Query) (any, bool, error)
|
|
type TaskHandler func(*Core, Task) (any, bool, error)
|
|
```
|
|
|
|
### Core Struct Additions (interfaces.go)
|
|
|
|
```go
|
|
type Core struct {
|
|
// ... existing fields
|
|
|
|
queryMu sync.RWMutex
|
|
queryHandlers []QueryHandler
|
|
|
|
taskMu sync.RWMutex
|
|
taskHandlers []TaskHandler
|
|
}
|
|
```
|
|
|
|
### New Methods (core.go)
|
|
|
|
```go
|
|
// QUERY - first responder wins
|
|
func (c *Core) QUERY(q Query) (any, bool, error)
|
|
|
|
// QUERYALL - broadcast, collect all responses
|
|
func (c *Core) QUERYALL(q Query) ([]any, error)
|
|
|
|
// PERFORM - first responder executes
|
|
func (c *Core) PERFORM(t Task) (any, bool, error)
|
|
|
|
// Registration
|
|
func (c *Core) RegisterQuery(h QueryHandler)
|
|
func (c *Core) RegisterTask(h TaskHandler)
|
|
```
|
|
|
|
### Re-exports (framework.go)
|
|
|
|
```go
|
|
type Query = core.Query
|
|
type Task = core.Task
|
|
type QueryHandler = core.QueryHandler
|
|
type TaskHandler = core.TaskHandler
|
|
```
|
|
|
|
## Service Implementation Pattern
|
|
|
|
Services register handlers during startup:
|
|
|
|
```go
|
|
func (s *Service) OnStartup(ctx context.Context) error {
|
|
s.Core().RegisterAction(s.handleAction)
|
|
s.Core().RegisterQuery(s.handleQuery)
|
|
s.Core().RegisterTask(s.handleTask)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
|
switch m := q.(type) {
|
|
case QueryStatus:
|
|
result := s.getStatus(m.Paths, m.Names)
|
|
return result, true, nil
|
|
case QueryDirtyRepos:
|
|
return s.DirtyRepos(), true, nil
|
|
}
|
|
return nil, false, nil // Not handled
|
|
}
|
|
|
|
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
|
switch m := t.(type) {
|
|
case TaskPush:
|
|
err := s.push(m.Path)
|
|
return nil, true, err
|
|
case TaskPull:
|
|
err := s.pull(m.Path)
|
|
return nil, true, err
|
|
}
|
|
return nil, false, nil // Not handled
|
|
}
|
|
```
|
|
|
|
## Git Service Queries & Tasks
|
|
|
|
```go
|
|
// pkg/git/queries.go
|
|
type QueryStatus struct {
|
|
Paths []string
|
|
Names map[string]string
|
|
}
|
|
|
|
type QueryDirtyRepos struct{}
|
|
type QueryAheadRepos struct{}
|
|
|
|
// pkg/git/tasks.go
|
|
type TaskPush struct {
|
|
Path string
|
|
Name string
|
|
}
|
|
|
|
type TaskPull struct {
|
|
Path string
|
|
Name string
|
|
}
|
|
|
|
type TaskPushMultiple struct {
|
|
Paths []string
|
|
Names map[string]string
|
|
}
|
|
```
|
|
|
|
## Agentic Service Tasks
|
|
|
|
```go
|
|
// pkg/agentic/tasks.go
|
|
type TaskCommit struct {
|
|
Path string
|
|
Name string
|
|
CanEdit bool
|
|
}
|
|
|
|
type TaskPrompt struct {
|
|
Prompt string
|
|
WorkDir string
|
|
AllowedTools []string
|
|
}
|
|
```
|
|
|
|
## Dev Workflow Service
|
|
|
|
```go
|
|
// pkg/dev/tasks.go
|
|
type TaskWork struct {
|
|
RegistryPath string
|
|
StatusOnly bool
|
|
AutoCommit bool
|
|
}
|
|
|
|
type TaskCommitAll struct {
|
|
RegistryPath string
|
|
}
|
|
|
|
type TaskPushAll struct {
|
|
RegistryPath string
|
|
Force bool
|
|
}
|
|
```
|
|
|
|
## Command Simplification
|
|
|
|
Before (dev_work.go - 327 lines of orchestration):
|
|
|
|
```go
|
|
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|
// Load registry
|
|
// Get git status
|
|
// Display table
|
|
// Loop dirty repos, shell out to claude
|
|
// Re-check status
|
|
// Confirm push
|
|
// Push repos
|
|
// Handle diverged branches
|
|
// ...
|
|
}
|
|
```
|
|
|
|
After (dev_work.go - minimal):
|
|
|
|
```go
|
|
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|
bundle, err := NewWorkBundle(WorkBundleOptions{
|
|
RegistryPath: registryPath,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
bundle.Core.ServiceStartup(ctx, nil)
|
|
defer bundle.Core.ServiceShutdown(ctx)
|
|
|
|
_, _, err = bundle.Core.PERFORM(dev.TaskWork{
|
|
StatusOnly: statusOnly,
|
|
AutoCommit: autoCommit,
|
|
})
|
|
return err
|
|
}
|
|
```
|
|
|
|
All orchestration logic moves to `pkg/dev/service.go` where it can be tested independently and reused.
|
|
|
|
## Implementation Tasks
|
|
|
|
1. **Framework Core** - Add Query, Task types and QUERY/QUERYALL/PERFORM methods
|
|
2. **Framework Re-exports** - Update framework.go with new types
|
|
3. **Git Service** - Add query and task handlers
|
|
4. **Agentic Service** - Add task handlers
|
|
5. **Dev Service** - Create workflow orchestration service
|
|
6. **Bundles** - Create bundle factories in cmd/dev/
|
|
7. **Commands** - Simplify cmd/dev/*.go to use bundles
|
|
|
|
## Future: CLI-Wide Runtime
|
|
|
|
Phase 2 will add a CLI-wide Core instance that:
|
|
|
|
- Handles signals (SIGINT, SIGTERM)
|
|
- Manages UI state
|
|
- Spawns worker bundles as "interactable elements"
|
|
- Provides cross-bundle communication
|
|
|
|
Worker bundles become sandboxed children of the CLI runtime, with the runtime controlling what capabilities each bundle receives.
|
|
|
|
## Testing
|
|
|
|
Each layer is independently testable:
|
|
|
|
- **Framework**: Unit tests for QUERY/QUERYALL/PERFORM dispatch
|
|
- **Services**: Unit tests with mock Core instances
|
|
- **Bundles**: Integration tests with real services
|
|
- **Commands**: E2E tests via CLI invocation
|
|
|
|
The permission model is testable by creating bundles with/without specific services and verifying behaviour.
|