iop/apps/node/internal/adapters/cli/codex_app_server.go

391 lines
10 KiB
Go

package cli
import (
"context"
"fmt"
"sync"
"time"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
// codexAppServerSession tracks a single long-lived `codex app-server --stdio` process
// bound to a (target, sessionID) key. The process is started lazily on first use and
// reused across Execute calls until it exits or is terminated.
type codexAppServerSession struct {
key sessionKey
threadID string // Codex thread id assigned by thread/start
mu sync.Mutex
proc *codexAppServerProc
closed bool
}
// codexAppServerExecutor is the sessionReporter + executor for modeCodexAppServer profiles.
type codexAppServerExecutor struct {
cli *CLI
mu sync.Mutex
sessions map[sessionKey]*codexAppServerSession
}
type codexAppServerRun struct {
executor *codexAppServerExecutor
ctx context.Context
runID string
threadID string
turnID string
proc *codexAppServerProc
sink runtime.EventSink
}
func (r *codexAppServerRun) drain() error {
for {
select {
case <-r.ctx.Done():
return r.ctx.Err()
case notif, ok := <-r.proc.notifCh:
if !ok {
return fmt.Errorf("codex app-server notification channel closed")
}
done, err := r.handleNotification(notif)
if err != nil {
return err
}
if done {
return nil
}
case <-r.proc.done:
return r.handleProcessExit()
}
}
}
func (r *codexAppServerRun) handleNotification(notif appServerNotification) (bool, error) {
events, done, err := decodeAppServerNotification(notif, r.runID, r.threadID, r.turnID)
for _, ev := range events {
if emitErr := r.sink.Emit(r.ctx, ev); emitErr != nil {
return false, emitErr
}
}
return done, err
}
func (r *codexAppServerRun) handleProcessExit() error {
// Process exited. Drain any notifications already in the buffer before
// declaring an error — the terminal event may have arrived with the exit.
for {
select {
case notif, ok := <-r.proc.notifCh:
if !ok {
return fmt.Errorf("codex app-server process exited during turn")
}
done, err := r.handleNotification(notif)
if err != nil {
return err
}
if done {
return nil
}
default:
return fmt.Errorf("codex app-server process exited during turn")
}
}
}
func (e *codexAppServerExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
sess, err := e.resolveCodexAppServerSession(ctx, spec, profile)
if err != nil {
return emitReturnedError(ctx, sink, spec.RunID, err)
}
sess.mu.Lock()
defer sess.mu.Unlock()
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
}); err != nil {
return err
}
prompt := extractPrompt(spec.Input)
turnID, err := codexAppServerTurnStart(ctx, sess.proc, sess.threadID, prompt)
if err != nil {
return err
}
run := &codexAppServerRun{
executor: e,
ctx: ctx,
runID: spec.RunID,
threadID: sess.threadID,
turnID: turnID,
proc: sess.proc,
sink: sink,
}
return run.drain()
}
// codexAppServerTurnStart sends turn/start with the prompt and returns the turn id.
func codexAppServerTurnStart(ctx context.Context, p *codexAppServerProc, threadID, prompt string) (string, error) {
// TurnStartParams.input is required; UserInput text variant requires text_elements.
params := map[string]any{
"threadId": threadID,
"input": []map[string]any{
{"type": "text", "text": prompt, "text_elements": []any{}},
},
}
resp, err := p.send(ctx, "turn/start", params)
if err != nil {
return "", fmt.Errorf("turn/start: %w", err)
}
// Actual shape: result.turn.id (TurnStartResponse)
turnID := nestedString(resp.Result, "turn", "id")
// Legacy/flat fallbacks.
if turnID == "" {
turnID, _ = resp.Result["turnId"].(string)
}
if turnID == "" {
turnID, _ = resp.Result["id"].(string)
}
if turnID == "" {
return "", fmt.Errorf("turn/start: server did not return a turn id")
}
return turnID, nil
}
func codexAppServerDrainNotifications(ctx context.Context, p *codexAppServerProc, runID, threadID, turnID string, sink runtime.EventSink) error {
run := &codexAppServerRun{
ctx: ctx,
runID: runID,
threadID: threadID,
turnID: turnID,
proc: p,
sink: sink,
}
return run.drain()
}
// decodeAppServerNotification maps a single app-server notification to RuntimeEvents.
// threadID and turnID are the execution-context values used as fallbacks when the
// notification params do not carry them. Returns (events, turnDone, error).
func decodeAppServerNotification(notif appServerNotification, runID, threadID, turnID string) ([]runtime.RuntimeEvent, bool, error) {
// Prefer IDs from the notification params; fall back to execution-context values.
effectiveThread := notif.Params["threadId"]
if s, ok := effectiveThread.(string); ok && s != "" {
threadID = s
}
effectiveTurn := notif.Params["turnId"]
if s, ok := effectiveTurn.(string); ok && s != "" {
turnID = s
}
meta := map[string]string{"source": "codex-app-server"}
if threadID != "" {
meta["thread_id"] = threadID
}
if turnID != "" {
meta["turn_id"] = turnID
}
switch notif.Method {
case "item/agentMessage/delta":
// AgentMessageDeltaNotification: { threadId, turnId, itemId, delta }
delta := nestedString(notif.Params, "delta")
if delta == "" {
return nil, false, nil
}
return []runtime.RuntimeEvent{{
RunID: runID,
Type: runtime.EventTypeDelta,
Delta: delta,
Metadata: meta,
Timestamp: time.Now(),
}}, false, nil
case "item/completed":
// Suppress duplicate final delta — already streamed via delta notifications.
return nil, false, nil
case "turn/completed":
// TurnCompletedNotification: { threadId, turn: { id, status, error, ... } }
// status may be "completed" | "interrupted" | "failed" | "inProgress"
status := nestedString(notif.Params, "turn", "status")
if status == "failed" {
msg := nestedString(notif.Params, "turn", "error", "message")
if msg == "" {
msg = "turn failed"
}
return []runtime.RuntimeEvent{{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Metadata: meta,
Timestamp: time.Now(),
}}, false, fmt.Errorf("codex app-server turn failed: %s", msg)
}
return []runtime.RuntimeEvent{{
RunID: runID,
Type: runtime.EventTypeComplete,
Metadata: meta,
Timestamp: time.Now(),
}}, true, nil
case "error":
// ErrorNotification: { error: { message, ... }, willRetry, threadId, turnId }
msg := nestedString(notif.Params, "error", "message")
if msg == "" {
// flat fallback for non-standard shapes
msg = nestedString(notif.Params, "message")
}
if msg == "" {
msg = "codex app-server error"
}
return []runtime.RuntimeEvent{{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Metadata: meta,
Timestamp: time.Now(),
}}, false, fmt.Errorf("codex app-server: %s", msg)
case "turn/failed":
// Legacy method name kept for compatibility.
msg := nestedString(notif.Params, "error", "message")
if msg == "" {
msg = nestedString(notif.Params, "message")
}
if msg == "" {
msg = "turn failed"
}
return []runtime.RuntimeEvent{{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Metadata: meta,
Timestamp: time.Now(),
}}, false, fmt.Errorf("codex app-server turn failed: %s", msg)
}
return nil, false, nil
}
// nestedString walks a params map by keys and returns the string value at the path.
func nestedString(params map[string]any, keys ...string) string {
var cur any = params
for i, k := range keys {
m, ok := cur.(map[string]any)
if !ok {
return ""
}
cur = m[k]
if i == len(keys)-1 {
s, _ := cur.(string)
return s
}
}
return ""
}
func (e *codexAppServerExecutor) resolveCodexAppServerSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*codexAppServerSession, error) {
target := cliTargetName(spec)
key := newSessionKey(spec)
e.mu.Lock()
if sess, ok := e.sessions[key]; ok {
e.mu.Unlock()
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
e.mu.Unlock()
return nil, fmt.Errorf("cli adapter: no codex app-server session for target %q session %q", target, key.sessionID)
}
e.mu.Unlock()
proc, err := startCodexAppServerProc(ctx, profile, spec.Workspace)
if err != nil {
return nil, fmt.Errorf("cli adapter: start codex app-server: %w", err)
}
threadID, err := codexAppServerInit(ctx, proc)
if err != nil {
proc.close()
return nil, fmt.Errorf("cli adapter: codex app-server init: %w", err)
}
sess := &codexAppServerSession{
key: key,
threadID: threadID,
proc: proc,
}
e.mu.Lock()
if existing, ok := e.sessions[key]; ok {
e.mu.Unlock()
proc.close()
return existing, nil
}
e.sessions[key] = sess
e.mu.Unlock()
return sess, nil
}
func (e *codexAppServerExecutor) 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{modeCodexAppServer, k.target, k.sessionID, k.workspace})
}
return snaps
}
// Terminate closes every workspace variant of the given target/sessionID.
func (e *codexAppServerExecutor) Terminate(_ context.Context, target, sessionID string) (bool, error) {
sid := normalizeSessionID(sessionID)
e.mu.Lock()
var matched []*codexAppServerSession
for k, sess := range e.sessions {
if k.target == target && k.sessionID == sid {
matched = append(matched, sess)
delete(e.sessions, k)
}
}
e.mu.Unlock()
if len(matched) == 0 {
return false, nil
}
for _, sess := range matched {
closeCodexAppServerSession(sess)
}
return true, nil
}
func (e *codexAppServerExecutor) Stop(_ context.Context) error {
e.mu.Lock()
cp := make(map[sessionKey]*codexAppServerSession, len(e.sessions))
for k, v := range e.sessions {
cp[k] = v
}
e.sessions = make(map[sessionKey]*codexAppServerSession)
e.mu.Unlock()
for _, sess := range cp {
closeCodexAppServerSession(sess)
}
return nil
}
func closeCodexAppServerSession(sess *codexAppServerSession) {
if sess == nil {
return
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.closed {
return
}
sess.closed = true
if sess.proc != nil {
sess.proc.close()
}
}