- Add onshot.go and persistent.go for distinct CLI modes - Update cli.go, factory_internal_test.go, cli_test.go - Update configs/edge.yaml and packages/config - Update READMEs for edge and node apps
159 lines
4.4 KiB
Go
159 lines
4.4 KiB
Go
// Package cli provides an Adapter that runs external CLI tools as inference
|
|
// backends. Profiles (claude, gemini, codex, opencode) are configured in
|
|
// configs/node.yaml and select the command + args to execute. Interactive CLIs
|
|
// should be configured with their non-interactive/headless flags for use in the
|
|
// request/response node pipeline.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"sort"
|
|
"sync"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
|
|
const Name = "cli"
|
|
|
|
// known profiles — must match CLI tool names
|
|
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
|
|
|
|
type cliOutput struct {
|
|
line string
|
|
}
|
|
|
|
type profileSession struct {
|
|
name string
|
|
profile config.CLIProfileConf
|
|
cmd *exec.Cmd
|
|
input io.Writer
|
|
output <-chan cliOutput
|
|
done <-chan error
|
|
closeFn func() error
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type CLI struct {
|
|
mu sync.Mutex
|
|
profiles map[string]config.CLIProfileConf
|
|
sessions map[string]*profileSession
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
|
|
return &CLI{
|
|
profiles: cfg.Profiles,
|
|
sessions: make(map[string]*profileSession),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (c *CLI) Name() string { return Name }
|
|
|
|
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
models := make([]string, 0, len(c.profiles))
|
|
for name := range c.profiles {
|
|
models = append(models, name)
|
|
}
|
|
// Always advertise known profiles even if not in config yet.
|
|
for _, p := range knownProfiles {
|
|
if _, ok := c.profiles[p]; !ok {
|
|
models = append(models, p)
|
|
}
|
|
}
|
|
return runtime.Capabilities{
|
|
AdapterName: Name,
|
|
Models: models,
|
|
MaxConcurrency: 4,
|
|
}, nil
|
|
}
|
|
|
|
// Start starts persistent profile sessions in deterministic (sorted) order.
|
|
// On failure, already-started sessions are rolled back under c.mu.
|
|
func (c *CLI) Start(ctx context.Context) error {
|
|
// Sort profile names for deterministic start order.
|
|
names := make([]string, 0, len(c.profiles))
|
|
for name := range c.profiles {
|
|
if c.profiles[name].Persistent {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
|
|
for _, name := range names {
|
|
profile := c.profiles[name]
|
|
sess, err := startProfileSession(ctx, name, profile, c.logger)
|
|
if err != nil {
|
|
// Roll back already-started sessions under the lock.
|
|
c.mu.Lock()
|
|
_ = c.stopAllSessions(context.Background())
|
|
c.sessions = make(map[string]*profileSession)
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
|
|
}
|
|
c.mu.Lock()
|
|
c.sessions[name] = sess
|
|
c.mu.Unlock()
|
|
c.logger.Info("cli adapter: persistent session started", zap.String("profile", name))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stopAllSessions closes all sessions and clears the map.
|
|
// Must be called while c.mu is held.
|
|
func (c *CLI) stopAllSessions(_ context.Context) error {
|
|
var firstErr error
|
|
for name, sess := range c.sessions {
|
|
if err := sess.closeFn(); err != nil && firstErr == nil {
|
|
firstErr = fmt.Errorf("cli adapter: close session %q: %w", name, err)
|
|
}
|
|
if sess.cmd != nil && sess.cmd.Process != nil {
|
|
_ = sess.cmd.Process.Kill()
|
|
}
|
|
}
|
|
c.sessions = make(map[string]*profileSession)
|
|
return firstErr
|
|
}
|
|
|
|
// Stop stops all persistent sessions. Sessions are iterated in map order (non-deterministic),
|
|
// but since Stop is idempotent and all sessions are stopped regardless of individual failures,
|
|
// the order does not affect correctness. Errors are combined by reporting only the first error.
|
|
func (c *CLI) Stop(_ context.Context) error {
|
|
c.mu.Lock()
|
|
// Snapshot sessions under the lock, then release it before doing
|
|
// blocking close/kill so we don't hold c.mu while processes terminate.
|
|
sessionsCopy := make(map[string]*profileSession, len(c.sessions))
|
|
for name, sess := range c.sessions {
|
|
sessionsCopy[name] = sess
|
|
}
|
|
c.sessions = make(map[string]*profileSession)
|
|
c.mu.Unlock()
|
|
|
|
var firstErr error
|
|
for name, sess := range sessionsCopy {
|
|
if err := sess.closeFn(); err != nil && firstErr == nil {
|
|
firstErr = fmt.Errorf("cli adapter: close session %q: %w", name, err)
|
|
}
|
|
if sess.cmd != nil && sess.cmd.Process != nil {
|
|
_ = sess.cmd.Process.Kill()
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
profile, ok := c.profiles[spec.Model]
|
|
if !ok {
|
|
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
|
|
}
|
|
if profile.Persistent {
|
|
return c.executePersistent(ctx, spec, profile, sink)
|
|
}
|
|
return c.executeOneShot(ctx, spec, profile, sink)
|
|
}
|