cli/docs/plans/2026-01-30-core-ipc-design.md

374 lines
11 KiB
Markdown
Raw Normal View History

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