docs(plans): add Core IPC design for CLI commands
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>
This commit is contained in:
parent
f4da42d095
commit
eecf267935
1 changed files with 373 additions and 0 deletions
373
docs/plans/2026-01-30-core-ipc-design.md
Normal file
373
docs/plans/2026-01-30-core-ipc-design.md
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
# 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.
|
||||||
Loading…
Add table
Reference in a new issue