iop/apps/node/internal/adapters/cli/codex_exec.go
toki f01c7e5ecd refactor(node): CLI 실행 경계를 정리한다
터미널 세션 core와 CLI mode executor 경계를 분리해 후속 remote terminal bridge가 재사용할 내부 실행 기반을 만든다.

adapter 기본값과 vLLM experimental surface도 명시해 production 실행 경로가 mock fallback이나 미구현 실행으로 숨지 않게 한다.
2026-06-06 14:57:49 +09:00

116 lines
3.1 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"regexp"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
var (
codexSessionIDPattern = regexp.MustCompile(`(?mi)^session id:\s*(\S+)\s*$`)
codexThreadStartedRegex = regexp.MustCompile(`\{[^{}]*"type"\s*:\s*"thread\.started"[^{}]*\}`)
)
func (e *codexExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
if profile.ResumeArgs == nil {
return fmt.Errorf("cli adapter: codex-exec mode requires resume_args in profile %q", spec.Target)
}
sess, err := e.resolveCodexExecSession(spec)
if err != nil {
return err
}
sess.mu.Lock()
defer sess.mu.Unlock()
prompt := extractPrompt(spec.Input)
args := codexExecArgs(profile, sess.externalID, prompt)
output, err := e.cli.executeCommand(ctx, spec, profile, args, prompt, sink)
if err != nil {
return err
}
if externalID := parseCodexSessionID(output); externalID != "" {
sess.externalID = externalID
}
if sess.externalID == "" {
return fmt.Errorf("cli adapter: codex exec did not report a session id")
}
return nil
}
func (e *codexExecutor) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSession, error) {
target := cliTargetName(spec)
key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)}
e.mu.Lock()
defer e.mu.Unlock()
if sess, ok := e.sessions[key]; ok {
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID)
}
sess := &codexExecSession{key: key}
e.sessions[key] = sess
return sess, nil
}
func (e *codexExecutor) Sessions() []sessionListEntry {
e.mu.Lock()
defer e.mu.Unlock()
snaps := make([]sessionListEntry, 0, len(e.sessions))
for k := range e.sessions {
snaps = append(snaps, sessionListEntry{"codex-exec", k.target, k.sessionID})
}
return snaps
}
func (e *codexExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) {
key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)}
e.mu.Lock()
_, ok := e.sessions[key]
if ok {
delete(e.sessions, key)
}
e.mu.Unlock()
return ok, nil
}
func (e *codexExecutor) Stop(ctx context.Context) error {
e.mu.Lock()
e.sessions = make(map[sessionKey]*codexExecSession)
e.mu.Unlock()
return nil
}
func codexExecArgs(profile config.CLIProfileConf, externalSessionID, prompt string) []string {
if externalSessionID == "" {
args := append([]string{}, profile.Args...)
return append(args, prompt)
}
args := append([]string{}, profile.ResumeArgs...)
return append(args, externalSessionID, prompt)
}
func parseCodexSessionID(output string) string {
if matches := codexSessionIDPattern.FindStringSubmatch(output); len(matches) >= 2 {
return matches[1]
}
for _, raw := range codexThreadStartedRegex.FindAllString(output, -1) {
var ev struct {
Type string `json:"type"`
ThreadID string `json:"thread_id"`
}
if err := json.Unmarshal([]byte(raw), &ev); err == nil && ev.Type == "thread.started" && ev.ThreadID != "" {
return ev.ThreadID
}
}
return ""
}