feat(framework): add QUERY/QUERYALL/PERFORM dispatch patterns
Implements the Core IPC design with four dispatch patterns: - ACTION: fire-and-forget broadcast (existing) - QUERY: first responder returns data - QUERYALL: all responders return data - PERFORM: first responder executes task Updates git and agentic services to use Query/Task patterns. Adds dev service for workflow orchestration. Refactors dev work command to use worker bundles. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
eecf267935
commit
6ed025d3e6
10 changed files with 912 additions and 131 deletions
89
cmd/dev/bundles.go
Normal file
89
cmd/dev/bundles.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/agentic"
|
||||||
|
devpkg "github.com/host-uk/core/pkg/dev"
|
||||||
|
"github.com/host-uk/core/pkg/framework"
|
||||||
|
"github.com/host-uk/core/pkg/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkBundle contains the Core instance for dev work operations.
|
||||||
|
type WorkBundle struct {
|
||||||
|
Core *framework.Core
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkBundleOptions configures the work bundle.
|
||||||
|
type WorkBundleOptions struct {
|
||||||
|
RegistryPath string
|
||||||
|
AllowEdit bool // Allow agentic to use Write/Edit tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkBundle creates a bundle for dev work operations.
|
||||||
|
// Includes: dev (orchestration), git, agentic services.
|
||||||
|
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
|
||||||
|
c, err := framework.New(
|
||||||
|
framework.WithService(devpkg.NewService(devpkg.ServiceOptions{
|
||||||
|
RegistryPath: opts.RegistryPath,
|
||||||
|
})),
|
||||||
|
framework.WithService(git.NewService(git.ServiceOptions{})),
|
||||||
|
framework.WithService(agentic.NewService(agentic.ServiceOptions{
|
||||||
|
AllowEdit: opts.AllowEdit,
|
||||||
|
})),
|
||||||
|
framework.WithServiceLock(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WorkBundle{Core: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initialises the bundle services.
|
||||||
|
func (b *WorkBundle) Start(ctx context.Context) error {
|
||||||
|
return b.Core.ServiceStartup(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the bundle services.
|
||||||
|
func (b *WorkBundle) Stop(ctx context.Context) error {
|
||||||
|
return b.Core.ServiceShutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusBundle contains the Core instance for status-only operations.
|
||||||
|
type StatusBundle struct {
|
||||||
|
Core *framework.Core
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusBundleOptions configures the status bundle.
|
||||||
|
type StatusBundleOptions struct {
|
||||||
|
RegistryPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatusBundle creates a bundle for status-only operations.
|
||||||
|
// Includes: dev (orchestration), git services. No agentic - commits not available.
|
||||||
|
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
|
||||||
|
c, err := framework.New(
|
||||||
|
framework.WithService(devpkg.NewService(devpkg.ServiceOptions{
|
||||||
|
RegistryPath: opts.RegistryPath,
|
||||||
|
})),
|
||||||
|
framework.WithService(git.NewService(git.ServiceOptions{})),
|
||||||
|
// No agentic service - TaskCommit will be unhandled
|
||||||
|
framework.WithServiceLock(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StatusBundle{Core: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initialises the bundle services.
|
||||||
|
func (b *StatusBundle) Start(ctx context.Context) error {
|
||||||
|
return b.Core.ServiceStartup(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the bundle services.
|
||||||
|
func (b *StatusBundle) Stop(ctx context.Context) error {
|
||||||
|
return b.Core.ServiceShutdown(ctx)
|
||||||
|
}
|
||||||
|
|
@ -44,44 +44,24 @@ func addWorkCommand(parent *cobra.Command) {
|
||||||
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Find or use provided registry, fall back to directory scan
|
// Build worker bundle with required services
|
||||||
var reg *repos.Registry
|
bundle, err := NewWorkBundle(WorkBundleOptions{
|
||||||
var err error
|
RegistryPath: registryPath,
|
||||||
|
})
|
||||||
if registryPath != "" {
|
|
||||||
reg, err = repos.LoadRegistry(registryPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load registry: %w", err)
|
return err
|
||||||
}
|
|
||||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.registry_label")), registryPath)
|
|
||||||
} else {
|
|
||||||
registryPath, err = repos.FindRegistry()
|
|
||||||
if err == nil {
|
|
||||||
reg, err = repos.LoadRegistry(registryPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load registry: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.registry_label")), registryPath)
|
|
||||||
} else {
|
|
||||||
// Fallback: scan current directory
|
|
||||||
cwd, _ := os.Getwd()
|
|
||||||
reg, err = repos.ScanDirectory(cwd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to scan directory: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.scanning_label")), cwd)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build paths and names for git operations
|
// Start services (registers handlers)
|
||||||
var paths []string
|
if err := bundle.Start(ctx); err != nil {
|
||||||
names := make(map[string]string)
|
return err
|
||||||
|
|
||||||
for _, repo := range reg.List() {
|
|
||||||
if repo.IsGitRepo() {
|
|
||||||
paths = append(paths, repo.Path)
|
|
||||||
names[repo.Path] = repo.Name
|
|
||||||
}
|
}
|
||||||
|
defer bundle.Stop(ctx)
|
||||||
|
|
||||||
|
// Load registry and get paths
|
||||||
|
paths, names, err := loadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
|
|
@ -89,11 +69,18 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get status for all repos
|
// QUERY git status
|
||||||
statuses := git.Status(ctx, git.StatusOptions{
|
result, handled, err := bundle.Core.QUERY(git.QueryStatus{
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
Names: names,
|
Names: names,
|
||||||
})
|
})
|
||||||
|
if !handled {
|
||||||
|
return fmt.Errorf("git service not available")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statuses := result.([]git.RepoStatus)
|
||||||
|
|
||||||
// Sort by repo name for consistent output
|
// Sort by repo name for consistent output
|
||||||
sort.Slice(statuses, func(i, j int) bool {
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
|
|
@ -126,18 +113,28 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
for _, s := range dirtyRepos {
|
for _, s := range dirtyRepos {
|
||||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
// PERFORM commit via agentic service
|
||||||
|
_, handled, err := bundle.Core.PERFORM(agentic.TaskCommit{
|
||||||
|
Path: s.Path,
|
||||||
|
Name: s.Name,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
fmt.Printf(" %s %s: %s\n", warningStyle.Render("!"), s.Name, "agentic service not available")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
|
fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check status after commits
|
// Re-QUERY status after commits
|
||||||
statuses = git.Status(ctx, git.StatusOptions{
|
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
Names: names,
|
Names: names,
|
||||||
})
|
})
|
||||||
|
statuses = result.([]git.RepoStatus)
|
||||||
|
|
||||||
// Rebuild ahead repos list
|
// Rebuild ahead repos list
|
||||||
aheadRepos = nil
|
aheadRepos = nil
|
||||||
|
|
@ -178,27 +175,27 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Push sequentially (SSH passphrase needs interaction)
|
// PERFORM push for each repo
|
||||||
var pushPaths []string
|
var divergedRepos []git.RepoStatus
|
||||||
|
|
||||||
for _, s := range aheadRepos {
|
for _, s := range aheadRepos {
|
||||||
pushPaths = append(pushPaths, s.Path)
|
_, handled, err := bundle.Core.PERFORM(git.TaskPush{
|
||||||
|
Path: s.Path,
|
||||||
|
Name: s.Name,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, "git service not available")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
results := git.PushMultiple(ctx, pushPaths, names)
|
if git.IsNonFastForward(err) {
|
||||||
|
fmt.Printf(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
|
||||||
var divergedRepos []git.PushResult
|
divergedRepos = append(divergedRepos, s)
|
||||||
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Success {
|
|
||||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
|
||||||
} else {
|
} else {
|
||||||
// Check if this is a non-fast-forward error (diverged branch)
|
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
if git.IsNonFastForward(r.Error) {
|
|
||||||
fmt.Printf(" %s %s: %s\n", warningStyle.Render("!"), r.Name, i18n.T("cmd.dev.push.diverged"))
|
|
||||||
divergedRepos = append(divergedRepos, r)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,18 +205,26 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
fmt.Printf("%s\n", i18n.T("cmd.dev.push.diverged_help"))
|
fmt.Printf("%s\n", i18n.T("cmd.dev.push.diverged_help"))
|
||||||
if shared.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
|
if shared.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
for _, r := range divergedRepos {
|
for _, s := range divergedRepos {
|
||||||
fmt.Printf(" %s %s...\n", dimStyle.Render("↓"), r.Name)
|
fmt.Printf(" %s %s...\n", dimStyle.Render("↓"), s.Name)
|
||||||
if err := git.Pull(ctx, r.Path); err != nil {
|
|
||||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
|
// PERFORM pull
|
||||||
|
_, _, err := bundle.Core.PERFORM(git.TaskPull{Path: s.Path, Name: s.Name})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s %s...\n", dimStyle.Render("↑"), r.Name)
|
|
||||||
if err := git.Push(ctx, r.Path); err != nil {
|
fmt.Printf(" %s %s...\n", dimStyle.Render("↑"), s.Name)
|
||||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
|
|
||||||
|
// PERFORM push
|
||||||
|
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
|
||||||
|
fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +306,7 @@ func printStatusTable(statuses []git.RepoStatus) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claudeCommit shells out to claude for committing (legacy helper for other commands)
|
||||||
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
||||||
prompt := agentic.Prompt("commit")
|
prompt := agentic.Prompt("commit")
|
||||||
|
|
||||||
|
|
@ -313,6 +319,7 @@ func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claudeEditCommit shells out to claude with edit permissions (legacy helper)
|
||||||
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
||||||
prompt := agentic.Prompt("commit")
|
prompt := agentic.Prompt("commit")
|
||||||
|
|
||||||
|
|
@ -324,3 +331,45 @@ func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath stri
|
||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadRegistry(registryPath string) ([]string, map[string]string, error) {
|
||||||
|
var reg *repos.Registry
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if registryPath != "" {
|
||||||
|
reg, err = repos.LoadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to load registry: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.registry_label")), registryPath)
|
||||||
|
} else {
|
||||||
|
registryPath, err = repos.FindRegistry()
|
||||||
|
if err == nil {
|
||||||
|
reg, err = repos.LoadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to load registry: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.registry_label")), registryPath)
|
||||||
|
} else {
|
||||||
|
// Fallback: scan current directory
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
reg, err = repos.ScanDirectory(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to scan directory: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.scanning_label")), cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
names := make(map[string]string)
|
||||||
|
|
||||||
|
for _, repo := range reg.List() {
|
||||||
|
if repo.IsGitRepo() {
|
||||||
|
paths = append(paths, repo.Path)
|
||||||
|
names[repo.Path] = repo.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, names, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,22 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/framework"
|
"github.com/host-uk/core/pkg/framework"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions for AI service IPC
|
// Tasks for AI service
|
||||||
|
|
||||||
// ActionCommit requests Claude to create a commit.
|
// TaskCommit requests Claude to create a commit.
|
||||||
type ActionCommit struct {
|
type TaskCommit struct {
|
||||||
Path string
|
Path string
|
||||||
Name string
|
Name string
|
||||||
CanEdit bool // allow Write/Edit tools
|
CanEdit bool // allow Write/Edit tools
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionPrompt sends a custom prompt to Claude.
|
// TaskPrompt sends a custom prompt to Claude.
|
||||||
type ActionPrompt struct {
|
type TaskPrompt struct {
|
||||||
Prompt string
|
Prompt string
|
||||||
WorkDir string
|
WorkDir string
|
||||||
AllowedTools []string
|
AllowedTools []string
|
||||||
|
|
@ -27,12 +28,14 @@ type ActionPrompt struct {
|
||||||
// ServiceOptions for configuring the AI service.
|
// ServiceOptions for configuring the AI service.
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
DefaultTools []string
|
DefaultTools []string
|
||||||
|
AllowEdit bool // global permission for Write/Edit tools
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultServiceOptions returns sensible defaults.
|
// DefaultServiceOptions returns sensible defaults.
|
||||||
func DefaultServiceOptions() ServiceOptions {
|
func DefaultServiceOptions() ServiceOptions {
|
||||||
return ServiceOptions{
|
return ServiceOptions{
|
||||||
DefaultTools: []string{"Bash", "Read", "Glob", "Grep"},
|
DefaultTools: []string{"Bash", "Read", "Glob", "Grep"},
|
||||||
|
AllowEdit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,32 +53,35 @@ func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers action handlers.
|
// OnStartup registers task handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterAction(s.handle)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handle(c *framework.Core, msg framework.Message) error {
|
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||||
switch m := msg.(type) {
|
switch m := t.(type) {
|
||||||
case ActionCommit:
|
case TaskCommit:
|
||||||
return s.handleCommit(m)
|
err := s.doCommit(m)
|
||||||
case ActionPrompt:
|
return nil, true, err
|
||||||
return s.handlePrompt(m)
|
|
||||||
|
case TaskPrompt:
|
||||||
|
err := s.doPrompt(m)
|
||||||
|
return nil, true, err
|
||||||
}
|
}
|
||||||
return nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCommit(action ActionCommit) error {
|
func (s *Service) doCommit(task TaskCommit) error {
|
||||||
prompt := Prompt("commit")
|
prompt := Prompt("commit")
|
||||||
|
|
||||||
tools := "Bash,Read,Glob,Grep"
|
tools := []string{"Bash", "Read", "Glob", "Grep"}
|
||||||
if action.CanEdit {
|
if task.CanEdit {
|
||||||
tools = "Bash,Read,Write,Edit,Glob,Grep"
|
tools = []string{"Bash", "Read", "Write", "Edit", "Glob", "Grep"}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(context.Background(), "claude", "-p", prompt, "--allowedTools", tools)
|
cmd := exec.CommandContext(context.Background(), "claude", "-p", prompt, "--allowedTools", strings.Join(tools, ","))
|
||||||
cmd.Dir = action.Path
|
cmd.Dir = task.Path
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
|
@ -83,21 +89,20 @@ func (s *Service) handleCommit(action ActionCommit) error {
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handlePrompt(action ActionPrompt) error {
|
func (s *Service) doPrompt(task TaskPrompt) error {
|
||||||
tools := "Bash,Read,Glob,Grep"
|
opts := s.Opts()
|
||||||
if len(action.AllowedTools) > 0 {
|
tools := opts.DefaultTools
|
||||||
tools = ""
|
if len(tools) == 0 {
|
||||||
for i, t := range action.AllowedTools {
|
tools = []string{"Bash", "Read", "Glob", "Grep"}
|
||||||
if i > 0 {
|
|
||||||
tools += ","
|
|
||||||
}
|
|
||||||
tools += t
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(context.Background(), "claude", "-p", action.Prompt, "--allowedTools", tools)
|
if len(task.AllowedTools) > 0 {
|
||||||
if action.WorkDir != "" {
|
tools = task.AllowedTools
|
||||||
cmd.Dir = action.WorkDir
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(context.Background(), "claude", "-p", task.Prompt, "--allowedTools", strings.Join(tools, ","))
|
||||||
|
if task.WorkDir != "" {
|
||||||
|
cmd.Dir = task.WorkDir
|
||||||
}
|
}
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
|
||||||
313
pkg/dev/service.go
Normal file
313
pkg/dev/service.go
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/agentic"
|
||||||
|
"github.com/host-uk/core/pkg/framework"
|
||||||
|
"github.com/host-uk/core/pkg/git"
|
||||||
|
"github.com/host-uk/core/pkg/repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tasks for dev service
|
||||||
|
|
||||||
|
// TaskWork runs the full dev workflow: status, commit, push.
|
||||||
|
type TaskWork struct {
|
||||||
|
RegistryPath string
|
||||||
|
StatusOnly bool
|
||||||
|
AutoCommit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatus displays git status for all repos.
|
||||||
|
type TaskStatus struct {
|
||||||
|
RegistryPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceOptions for configuring the dev service.
|
||||||
|
type ServiceOptions struct {
|
||||||
|
RegistryPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides dev workflow orchestration as a Core service.
|
||||||
|
type Service struct {
|
||||||
|
*framework.ServiceRuntime[ServiceOptions]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a dev service factory.
|
||||||
|
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
||||||
|
return func(c *framework.Core) (any, error) {
|
||||||
|
return &Service{
|
||||||
|
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStartup registers task handlers.
|
||||||
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
|
s.Core().RegisterTask(s.handleTask)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||||
|
switch m := t.(type) {
|
||||||
|
case TaskWork:
|
||||||
|
err := s.runWork(m)
|
||||||
|
return nil, true, err
|
||||||
|
|
||||||
|
case TaskStatus:
|
||||||
|
err := s.runStatus(m)
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) runWork(task TaskWork) error {
|
||||||
|
// Load registry
|
||||||
|
paths, names, err := s.loadRegistry(task.RegistryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) == 0 {
|
||||||
|
fmt.Println("No git repositories found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QUERY git status
|
||||||
|
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
||||||
|
Paths: paths,
|
||||||
|
Names: names,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
return fmt.Errorf("git service not available")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statuses := result.([]git.RepoStatus)
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
|
return statuses[i].Name < statuses[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display status table
|
||||||
|
s.printStatusTable(statuses)
|
||||||
|
|
||||||
|
// Collect dirty and ahead repos
|
||||||
|
var dirtyRepos []git.RepoStatus
|
||||||
|
var aheadRepos []git.RepoStatus
|
||||||
|
|
||||||
|
for _, st := range statuses {
|
||||||
|
if st.Error != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st.IsDirty() {
|
||||||
|
dirtyRepos = append(dirtyRepos, st)
|
||||||
|
}
|
||||||
|
if st.HasUnpushed() {
|
||||||
|
aheadRepos = append(aheadRepos, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-commit dirty repos if requested
|
||||||
|
if task.AutoCommit && len(dirtyRepos) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Committing changes...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for _, repo := range dirtyRepos {
|
||||||
|
_, handled, err := s.Core().PERFORM(agentic.TaskCommit{
|
||||||
|
Path: repo.Path,
|
||||||
|
Name: repo.Name,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
// Agentic service not available - skip silently
|
||||||
|
fmt.Printf(" - %s: agentic service not available\n", repo.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" x %s: %s\n", repo.Name, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" v %s\n", repo.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-query status after commits
|
||||||
|
result, _, _ = s.Core().QUERY(git.QueryStatus{
|
||||||
|
Paths: paths,
|
||||||
|
Names: names,
|
||||||
|
})
|
||||||
|
statuses = result.([]git.RepoStatus)
|
||||||
|
|
||||||
|
// Rebuild ahead repos list
|
||||||
|
aheadRepos = nil
|
||||||
|
for _, st := range statuses {
|
||||||
|
if st.Error == nil && st.HasUnpushed() {
|
||||||
|
aheadRepos = append(aheadRepos, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If status only, we're done
|
||||||
|
if task.StatusOnly {
|
||||||
|
if len(dirtyRepos) > 0 && !task.AutoCommit {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Use --commit flag to auto-commit dirty repos")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push repos with unpushed commits
|
||||||
|
if len(aheadRepos) == 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("All repositories are up to date")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("%d repos with unpushed commits:\n", len(aheadRepos))
|
||||||
|
for _, st := range aheadRepos {
|
||||||
|
fmt.Printf(" %s: %d commits\n", st.Name, st.Ahead)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print("Push all? [y/N] ")
|
||||||
|
var answer string
|
||||||
|
fmt.Scanln(&answer)
|
||||||
|
if strings.ToLower(answer) != "y" {
|
||||||
|
fmt.Println("Aborted")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Push each repo
|
||||||
|
for _, st := range aheadRepos {
|
||||||
|
_, handled, err := s.Core().PERFORM(git.TaskPush{
|
||||||
|
Path: st.Path,
|
||||||
|
Name: st.Name,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
fmt.Printf(" x %s: git service not available\n", st.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if git.IsNonFastForward(err) {
|
||||||
|
fmt.Printf(" ! %s: branch has diverged\n", st.Name)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" x %s: %s\n", st.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" v %s\n", st.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) runStatus(task TaskStatus) error {
|
||||||
|
paths, names, err := s.loadRegistry(task.RegistryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) == 0 {
|
||||||
|
fmt.Println("No git repositories found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
||||||
|
Paths: paths,
|
||||||
|
Names: names,
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
return fmt.Errorf("git service not available")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses := result.([]git.RepoStatus)
|
||||||
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
|
return statuses[i].Name < statuses[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
s.printStatusTable(statuses)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRegistry(registryPath string) ([]string, map[string]string, error) {
|
||||||
|
var reg *repos.Registry
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if registryPath != "" {
|
||||||
|
reg, err = repos.LoadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to load registry: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Registry: %s\n\n", registryPath)
|
||||||
|
} else {
|
||||||
|
registryPath, err = repos.FindRegistry()
|
||||||
|
if err == nil {
|
||||||
|
reg, err = repos.LoadRegistry(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to load registry: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Registry: %s\n\n", registryPath)
|
||||||
|
} else {
|
||||||
|
// Fallback: scan current directory
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
reg, err = repos.ScanDirectory(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to scan directory: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Scanning: %s\n\n", cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
names := make(map[string]string)
|
||||||
|
|
||||||
|
for _, repo := range reg.List() {
|
||||||
|
if repo.IsGitRepo() {
|
||||||
|
paths = append(paths, repo.Path)
|
||||||
|
names[repo.Path] = repo.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) printStatusTable(statuses []git.RepoStatus) {
|
||||||
|
// Calculate column widths
|
||||||
|
nameWidth := 4 // "Repo"
|
||||||
|
for _, st := range statuses {
|
||||||
|
if len(st.Name) > nameWidth {
|
||||||
|
nameWidth = len(st.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print header
|
||||||
|
fmt.Printf("%-*s %8s %9s %6s %5s\n",
|
||||||
|
nameWidth, "Repo", "Modified", "Untracked", "Staged", "Ahead")
|
||||||
|
|
||||||
|
// Print separator
|
||||||
|
fmt.Println(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
||||||
|
|
||||||
|
// Print rows
|
||||||
|
for _, st := range statuses {
|
||||||
|
if st.Error != nil {
|
||||||
|
fmt.Printf("%-*s error: %s\n", nameWidth, st.Name, st.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-*s %8d %9d %6d %5d\n",
|
||||||
|
nameWidth, st.Name,
|
||||||
|
st.Modified, st.Untracked, st.Staged, st.Ahead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -200,6 +200,73 @@ func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
|
||||||
c.ipcMu.Unlock()
|
c.ipcMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
c.queryMu.RLock()
|
||||||
|
handlers := append([]QueryHandler(nil), c.queryHandlers...)
|
||||||
|
c.queryMu.RUnlock()
|
||||||
|
|
||||||
|
for _, h := range handlers {
|
||||||
|
result, handled, err := h(c, q)
|
||||||
|
if handled {
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
c.queryMu.RLock()
|
||||||
|
handlers := append([]QueryHandler(nil), c.queryHandlers...)
|
||||||
|
c.queryMu.RUnlock()
|
||||||
|
|
||||||
|
var results []any
|
||||||
|
var agg error
|
||||||
|
for _, h := range handlers {
|
||||||
|
result, handled, err := h(c, q)
|
||||||
|
if err != nil {
|
||||||
|
agg = errors.Join(agg, err)
|
||||||
|
}
|
||||||
|
if handled && result != nil {
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
c.taskMu.RLock()
|
||||||
|
handlers := append([]TaskHandler(nil), c.taskHandlers...)
|
||||||
|
c.taskMu.RUnlock()
|
||||||
|
|
||||||
|
for _, h := range handlers {
|
||||||
|
result, handled, err := h(c, t)
|
||||||
|
if handled {
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterQuery adds a query handler to the Core.
|
||||||
|
func (c *Core) RegisterQuery(handler QueryHandler) {
|
||||||
|
c.queryMu.Lock()
|
||||||
|
c.queryHandlers = append(c.queryHandlers, handler)
|
||||||
|
c.queryMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTask adds a task handler to the Core.
|
||||||
|
func (c *Core) RegisterTask(handler TaskHandler) {
|
||||||
|
c.taskMu.Lock()
|
||||||
|
c.taskHandlers = append(c.taskHandlers, handler)
|
||||||
|
c.taskMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterService adds a new service to the Core.
|
// RegisterService adds a new service to the Core.
|
||||||
func (c *Core) RegisterService(name string, api any) error {
|
func (c *Core) RegisterService(name string, api any) error {
|
||||||
if c.servicesLocked {
|
if c.servicesLocked {
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,25 @@ type Option func(*Core) error
|
||||||
|
|
||||||
// Message is the interface for all messages that can be sent through the Core's IPC system.
|
// 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.
|
// 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 interface{}
|
type Message interface{}
|
||||||
|
|
||||||
|
// Query is the interface for read-only requests that return data.
|
||||||
|
// Used with QUERY (first responder) or QUERYALL (all responders).
|
||||||
|
type Query interface{}
|
||||||
|
|
||||||
|
// Task is the interface for requests that perform side effects.
|
||||||
|
// Used with PERFORM (first responder executes).
|
||||||
|
type Task interface{}
|
||||||
|
|
||||||
|
// 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.
|
// Startable is an interface for services that need to perform initialization.
|
||||||
type Startable interface {
|
type Startable interface {
|
||||||
OnStartup(ctx context.Context) error
|
OnStartup(ctx context.Context) error
|
||||||
|
|
@ -64,6 +81,10 @@ type Core struct {
|
||||||
serviceLock bool
|
serviceLock bool
|
||||||
ipcMu sync.RWMutex
|
ipcMu sync.RWMutex
|
||||||
ipcHandlers []func(*Core, Message) error
|
ipcHandlers []func(*Core, Message) error
|
||||||
|
queryMu sync.RWMutex
|
||||||
|
queryHandlers []QueryHandler
|
||||||
|
taskMu sync.RWMutex
|
||||||
|
taskHandlers []TaskHandler
|
||||||
serviceMu sync.RWMutex
|
serviceMu sync.RWMutex
|
||||||
services map[string]any
|
services map[string]any
|
||||||
servicesLocked bool
|
servicesLocked bool
|
||||||
|
|
|
||||||
201
pkg/framework/core/query_test.go
Normal file
201
pkg/framework/core/query_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,11 @@ func (r *ServiceRuntime[T]) Core() *Core {
|
||||||
return r.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.
|
// Config returns the registered Config service from the core application.
|
||||||
// This is a convenience method for accessing the application's configuration.
|
// This is a convenience method for accessing the application's configuration.
|
||||||
func (r *ServiceRuntime[T]) Config() Config {
|
func (r *ServiceRuntime[T]) Config() Config {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ type (
|
||||||
Core = core.Core
|
Core = core.Core
|
||||||
Option = core.Option
|
Option = core.Option
|
||||||
Message = core.Message
|
Message = core.Message
|
||||||
|
Query = core.Query
|
||||||
|
Task = core.Task
|
||||||
|
QueryHandler = core.QueryHandler
|
||||||
|
TaskHandler = core.TaskHandler
|
||||||
Startable = core.Startable
|
Startable = core.Startable
|
||||||
Stoppable = core.Stoppable
|
Stoppable = core.Stoppable
|
||||||
Config = core.Config
|
Config = core.Config
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,39 @@ import (
|
||||||
"github.com/host-uk/core/pkg/framework"
|
"github.com/host-uk/core/pkg/framework"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions for git service IPC
|
// Queries for git service
|
||||||
|
|
||||||
// ActionStatus requests git status for paths.
|
// QueryStatus requests git status for paths.
|
||||||
type ActionStatus struct {
|
type QueryStatus struct {
|
||||||
Paths []string
|
Paths []string
|
||||||
Names map[string]string
|
Names map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionPush requests git push for a path.
|
// QueryDirtyRepos requests repos with uncommitted changes.
|
||||||
type ActionPush struct{ Path, Name string }
|
type QueryDirtyRepos struct{}
|
||||||
|
|
||||||
// ActionPull requests git pull for a path.
|
// QueryAheadRepos requests repos with unpushed commits.
|
||||||
type ActionPull struct{ Path, Name string }
|
type QueryAheadRepos struct{}
|
||||||
|
|
||||||
|
// Tasks for git service
|
||||||
|
|
||||||
|
// TaskPush requests git push for a path.
|
||||||
|
type TaskPush struct {
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskPull requests git pull for a path.
|
||||||
|
type TaskPull struct {
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskPushMultiple requests git push for multiple paths.
|
||||||
|
type TaskPushMultiple struct {
|
||||||
|
Paths []string
|
||||||
|
Names map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
// ServiceOptions for configuring the git service.
|
// ServiceOptions for configuring the git service.
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
|
|
@ -40,40 +60,47 @@ func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers action handlers.
|
// OnStartup registers query and task handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterAction(s.handle)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handle(c *framework.Core, msg framework.Message) error {
|
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||||
switch m := msg.(type) {
|
switch m := q.(type) {
|
||||||
case ActionStatus:
|
case QueryStatus:
|
||||||
return s.handleStatus(m)
|
statuses := Status(context.Background(), StatusOptions{
|
||||||
case ActionPush:
|
Paths: m.Paths,
|
||||||
return s.handlePush(m)
|
Names: m.Names,
|
||||||
case ActionPull:
|
|
||||||
return s.handlePull(m)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleStatus(action ActionStatus) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
statuses := Status(ctx, StatusOptions{
|
|
||||||
Paths: action.Paths,
|
|
||||||
Names: action.Names,
|
|
||||||
})
|
})
|
||||||
s.lastStatus = statuses
|
s.lastStatus = statuses
|
||||||
return nil
|
return statuses, true, nil
|
||||||
|
|
||||||
|
case QueryDirtyRepos:
|
||||||
|
return s.DirtyRepos(), true, nil
|
||||||
|
|
||||||
|
case QueryAheadRepos:
|
||||||
|
return s.AheadRepos(), true, nil
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handlePush(action ActionPush) error {
|
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||||
return Push(context.Background(), action.Path)
|
switch m := t.(type) {
|
||||||
}
|
case TaskPush:
|
||||||
|
err := Push(context.Background(), m.Path)
|
||||||
|
return nil, true, err
|
||||||
|
|
||||||
func (s *Service) handlePull(action ActionPull) error {
|
case TaskPull:
|
||||||
return Pull(context.Background(), action.Path)
|
err := Pull(context.Background(), m.Path)
|
||||||
|
return nil, true, err
|
||||||
|
|
||||||
|
case TaskPushMultiple:
|
||||||
|
results := PushMultiple(context.Background(), m.Paths, m.Names)
|
||||||
|
return results, true, nil
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns last status result.
|
// Status returns last status result.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue