iop/apps/node/internal/adapters/cli/persistent.go
toki 24789c398b feat: CLI adapter with onshot/persistent mode support
- 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
2026-05-03 18:09:56 +09:00

233 lines
5.4 KiB
Go

package cli
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"time"
"github.com/creack/pty"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
"iop/packages/config"
)
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: %s", msg)
}
func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
c.mu.Lock()
sess, ok := c.sessions[spec.Model]
c.mu.Unlock()
if !ok {
return fmt.Errorf("cli adapter: no persistent session for profile %q", spec.Model)
}
sess.mu.Lock()
defer sess.mu.Unlock()
prompt := extractPrompt(spec.Input)
idleTimeout := time.Duration(profile.ResponseIdleTimeoutMS) * time.Millisecond
if idleTimeout <= 0 {
idleTimeout = 1500 * time.Millisecond
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
if _, err := fmt.Fprintf(sess.input, "%s\n", prompt); err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
}
var idleTimer *time.Timer
var idleC <-chan time.Time
defer func() {
if idleTimer != nil {
idleTimer.Stop()
}
}()
outputTokens := 0
for {
select {
case <-ctx.Done():
c.mu.Lock()
if s, ok := c.sessions[spec.Model]; ok && s == sess {
_ = s.closeFn()
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
delete(c.sessions, spec.Model)
}
c.mu.Unlock()
return ctx.Err()
case out, ok := <-sess.output:
if !ok {
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
}
outputTokens += len(strings.Fields(out.line))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: out.line + "\n",
Timestamp: time.Now(),
})
if idleTimer == nil {
idleTimer = time.NewTimer(idleTimeout)
idleC = idleTimer.C
} else {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(idleTimeout)
}
case <-idleC:
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "cli execution complete",
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
case err := <-sess.done:
msg := "persistent session process exited"
if err != nil {
msg = fmt.Sprintf("persistent session process exited: %v", err)
}
return emitRuntimeError(ctx, sink, spec.RunID, msg)
}
}
}
func startProfileSession(_ context.Context, name string, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) {
if profile.Command == "" {
return nil, fmt.Errorf("profile %q has no command", name)
}
outputCh := make(chan cliOutput, 1024)
doneCh := make(chan error, 1)
var input io.Writer
var closeFn func() error
var cmd *exec.Cmd
if profile.Terminal {
cmd = exec.Command(profile.Command, profile.Args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
input = ptmx
closeFn = ptmx.Close
go func() {
scanner := bufio.NewScanner(ptmx)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
outputCh <- cliOutput{line: line}
}
doneCh <- cmd.Wait()
close(outputCh)
}()
} else {
cmd = exec.Command(profile.Command, profile.Args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = stdin.Close()
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return nil, fmt.Errorf("start: %w", err)
}
input = stdin
closeFn = stdin.Close
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
outputCh <- cliOutput{line: scanner.Text()}
}
doneCh <- cmd.Wait()
close(outputCh)
}()
}
if profile.StartupIdleTimeoutMS > 0 {
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, name)
}
select {
case err := <-doneCh:
_ = closeFn()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
if err == nil {
return nil, fmt.Errorf("process exited during startup")
}
return nil, fmt.Errorf("process exited during startup: %w", err)
default:
}
return &profileSession{
name: name,
profile: profile,
cmd: cmd,
input: input,
output: outputCh,
done: doneCh,
closeFn: closeFn,
}, nil
}
func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, name string) {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case _, ok := <-outputCh:
if !ok {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
logger.Debug("cli adapter: startup drain", zap.String("profile", name))
case <-timer.C:
return
}
}
}