iop/apps/node/internal/node/node.go
toki d764adb390 chore: agent-task 변경 사항 반영
- edge node store 및 console 업데이트
- node adapters(cli, ollama, vllm, mock) 리팩토링
- node router, run_manager, store 개선
- proto 파일(control.proto, runtime.proto) 및 생성 코드 업데이트
- config 업데이트
2026-05-10 20:41:33 +09:00

410 lines
11 KiB
Go

// Package node is the core IOP Node service. It implements
// transport.Handler and orchestrates routing → adapter execution.
package node
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
iop "iop/proto/gen/iop"
)
// Node implements transport.Handler and coordinates the full execution pipeline.
type Node struct {
nodeID string
router runtime.Router
store *store.Store
runs *runManager
out io.Writer
logger *zap.Logger
}
// New creates a Node. It satisfies transport.Handler.
// out receives console debug output; pass os.Stdout for production, io.Discard in tests.
func New(
nodeID string,
router runtime.Router,
st *store.Store,
out io.Writer,
logger *zap.Logger,
) *Node {
if out == nil {
out = os.Stdout
}
return &Node{
nodeID: nodeID,
router: router,
store: st,
runs: newRunManager(),
out: out,
logger: logger,
}
}
// OnRunRequest handles an incoming RunRequest from a transport Session.
func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *iop.RunRequest) error {
n.logger.Info("run request received",
zap.String("run_id", req.GetRunId()),
zap.String("adapter", req.GetAdapter()),
zap.String("target", req.GetTarget()),
)
rr := runtime.RunRequest{
RunID: req.GetRunId(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionID: req.GetSessionId(),
SessionMode: sessionModeFromProto(req.GetSessionMode()),
Background: req.GetBackground(),
Workspace: req.GetWorkspace(),
Policy: structAsMap(req.GetPolicy()),
Input: structAsMap(req.GetInput()),
TimeoutSec: int(req.GetTimeoutSec()),
Metadata: req.GetMetadata(),
}
printEdgeMessage(n.out, rr.Input)
spec, adapter, err := n.router.ResolveAdapter(ctx, rr)
if err != nil {
return fmt.Errorf("node: resolve: %w", err)
}
if err := n.store.InsertRun(ctx, store.RunRecord{
RunID: spec.RunID,
Adapter: spec.Adapter,
Target: spec.Target,
SessionID: normalizeSessionID(spec.SessionID),
Background: spec.Background,
Status: "running",
CreatedAt: time.Now(),
}); err != nil {
n.logger.Warn("store: insert run", zap.String("run_id", spec.RunID), zap.Error(err))
}
execCtx, cancel := context.WithCancel(ctx)
if spec.TimeoutSec > 0 {
execCtx, cancel = context.WithTimeout(ctx, time.Duration(spec.TimeoutSec)*time.Second)
}
h := &runHandle{
runID: spec.RunID,
adapter: spec.Adapter,
target: spec.Target,
sessionID: normalizeSessionID(spec.SessionID),
cancel: cancel,
done: make(chan struct{}),
}
n.runs.register(h)
sink := &sessionSink{
sess: sess,
out: n.out,
nodeID: n.nodeID,
sessionID: normalizeSessionID(spec.SessionID),
background: spec.Background,
}
run := func() error {
defer cancel()
defer n.runs.deregister(spec.RunID)
defer close(h.done)
execErr := adapter.Execute(execCtx, spec, sink)
n.completeRun(spec, execErr)
return execErr
}
if spec.Background {
go func() { _ = run() }()
return nil
}
return run()
}
func (n *Node) completeRun(spec runtime.ExecutionSpec, execErr error) {
status := "completed"
errMsg := ""
if execErr != nil {
if errors.Is(execErr, runtime.ErrRunCancelled) {
status = "cancelled"
} else {
status = "failed"
errMsg = execErr.Error()
}
n.logger.Warn("run ended", zap.String("run_id", spec.RunID), zap.String("status", status), zap.Error(execErr))
}
if err := n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg); err != nil {
n.logger.Warn("store: complete run", zap.String("run_id", spec.RunID), zap.Error(err))
}
}
// OnCancel cancels a running execution or terminates an adapter session.
func (n *Node) OnCancel(_ context.Context, _ *transport.Session, req *iop.CancelRequest) error {
n.logger.Info("cancel request", zap.String("run_id", req.GetRunId()), zap.String("action", req.GetAction().String()))
switch cancelActionFromProto(req.GetAction()) {
case runtime.CancelActionTerminateSession:
adapter, ok := n.router.GetAdapter(req.GetAdapter())
if !ok {
return fmt.Errorf("node: adapter %q not found", req.GetAdapter())
}
terminator, ok := adapter.(runtime.SessionTerminator)
if !ok {
return fmt.Errorf("node: adapter %q does not support session termination", req.GetAdapter())
}
return terminator.TerminateSession(context.Background(), req.GetTarget(), normalizeSessionID(req.GetSessionId()))
default:
n.runs.cancelRun(req.GetRunId())
return nil
}
}
// OnCommandRequest handles an incoming node command from a transport Session.
func (n *Node) OnCommandRequest(ctx context.Context, sess *transport.Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
n.logger.Info("command request",
zap.String("request_id", req.GetRequestId()),
zap.String("type", req.GetType().String()),
zap.String("adapter", req.GetAdapter()),
zap.String("target", req.GetTarget()),
)
adapter, ok := n.router.GetAdapter(req.GetAdapter())
if !ok {
return &iop.NodeCommandResponse{
RequestId: req.GetRequestId(),
Type: req.GetType(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionId: req.GetSessionId(),
Error: fmt.Sprintf("node: adapter %q not found", req.GetAdapter()),
}, nil
}
handler, ok := adapter.(runtime.CommandHandler)
if !ok {
return &iop.NodeCommandResponse{
RequestId: req.GetRequestId(),
Type: req.GetType(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionId: req.GetSessionId(),
Error: fmt.Sprintf("node: adapter %q does not support commands", req.GetAdapter()),
}, nil
}
execCtx := ctx
if req.GetTimeoutSec() > 0 {
var cancel context.CancelFunc
execCtx, cancel = context.WithTimeout(ctx, time.Duration(req.GetTimeoutSec())*time.Second)
defer cancel()
}
domainReq := toDomainCommandRequest(req)
domainResp, err := handler.HandleCommand(execCtx, domainReq)
if err != nil {
return &iop.NodeCommandResponse{
RequestId: req.GetRequestId(),
Type: req.GetType(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionId: req.GetSessionId(),
Error: err.Error(),
}, nil
}
return toProtoCommandResponse(domainResp), nil
}
func toDomainCommandRequest(req *iop.NodeCommandRequest) runtime.CommandRequest {
var cmdType runtime.CommandType
if req.GetType() == iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS {
cmdType = runtime.CommandTypeUsageStatus
}
return runtime.CommandRequest{
RequestID: req.GetRequestId(),
Type: cmdType,
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionID: normalizeSessionID(req.GetSessionId()),
TimeoutSec: int(req.GetTimeoutSec()),
Metadata: req.GetMetadata(),
}
}
func toProtoCommandResponse(resp runtime.CommandResponse) *iop.NodeCommandResponse {
var cmdType iop.NodeCommandType
if resp.Type == runtime.CommandTypeUsageStatus {
cmdType = iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS
}
out := &iop.NodeCommandResponse{
RequestId: resp.RequestID,
Type: cmdType,
Adapter: resp.Adapter,
Target: resp.Target,
SessionId: resp.SessionID,
}
if resp.UsageStatus != nil {
out.UsageStatus = &iop.AgentUsageStatus{
RawOutput: resp.UsageStatus.RawOutput,
DailyLimit: resp.UsageStatus.DailyLimit,
DailyResetTime: resp.UsageStatus.DailyResetTime,
WeeklyLimit: resp.UsageStatus.WeeklyLimit,
WeeklyResetTime: resp.UsageStatus.WeeklyResetTime,
Metadata: resp.UsageStatus.Metadata,
}
}
return out
}
type protoSender interface {
Send(m proto.Message) error
}
// sessionSink wraps a transport.Session to implement runtime.EventSink.
type sessionSink struct {
sess protoSender
out io.Writer
nodeID string
sessionID string
background bool
streaming bool
lineEnded bool
}
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
s.printEvent(event)
re := &iop.RunEvent{
RunId: event.RunID,
Type: string(event.Type),
Delta: event.Delta,
Message: event.Message,
Error: event.Error,
Metadata: event.Metadata,
Timestamp: event.Timestamp.UnixNano(),
SessionId: s.sessionID,
Background: s.background,
NodeId: s.nodeID,
}
if event.Usage != nil {
re.Usage = &iop.Usage{
InputTokens: int32(event.Usage.InputTokens),
OutputTokens: int32(event.Usage.OutputTokens),
}
}
return s.sess.Send(re)
}
func (s *sessionSink) printEvent(event runtime.RuntimeEvent) {
if s.out == nil {
return
}
switch event.Type {
case runtime.EventTypeStart:
s.streaming = false
s.lineEnded = true
fmt.Fprintf(s.out, "[node-event] start run_id=%s\n", event.RunID)
case runtime.EventTypeDelta:
if event.Delta == "" {
return
}
if !s.streaming {
fmt.Fprint(s.out, "[node-message] ")
s.streaming = true
}
fmt.Fprint(s.out, event.Delta)
s.lineEnded = strings.HasSuffix(event.Delta, "\n")
case runtime.EventTypeComplete:
if s.streaming && !s.lineEnded {
fmt.Fprintln(s.out)
}
s.streaming = false
s.lineEnded = true
fmt.Fprintf(s.out, "[node-event] complete run_id=%s detail=%q\n", event.RunID, event.Message)
case runtime.EventTypeError:
if s.streaming && !s.lineEnded {
fmt.Fprintln(s.out)
}
s.streaming = false
s.lineEnded = true
fmt.Fprintf(s.out, "[node-event] error run_id=%s detail=%q\n", event.RunID, event.Error)
case runtime.EventTypeCancelled:
if s.streaming && !s.lineEnded {
fmt.Fprintln(s.out)
}
s.streaming = false
s.lineEnded = true
fmt.Fprintf(s.out, "[node-event] cancelled run_id=%s\n", event.RunID)
default:
fmt.Fprintf(s.out, "[node-event] %s run_id=%s detail=%q\n", event.Type, event.RunID, event.Message)
}
}
func printEdgeMessage(out io.Writer, input map[string]any) {
if out == nil {
return
}
if input == nil {
printTaggedMessage(out, "edge-message", "")
return
}
if prompt, ok := input["prompt"].(string); ok {
printTaggedMessage(out, "edge-message", prompt)
return
}
b, err := json.MarshalIndent(input, "", " ")
if err != nil {
printTaggedMessage(out, "edge-message", fmt.Sprintf("%v", input))
return
}
fmt.Fprintf(out, "[edge-message]\n%s\n", b)
}
func printTaggedMessage(out io.Writer, tag, message string) {
message = strings.TrimSpace(message)
if message == "" {
fmt.Fprintf(out, "[%s] <empty>\n", tag)
return
}
fmt.Fprintf(out, "[%s] %s\n", tag, message)
}
func structAsMap(s *structpb.Struct) map[string]any {
if s == nil {
return nil
}
return s.AsMap()
}
func sessionModeFromProto(m iop.RunSessionMode) runtime.SessionMode {
if m == iop.RunSessionMode_RUN_SESSION_MODE_REQUIRE_EXISTING {
return runtime.SessionModeRequireExisting
}
return runtime.SessionModeCreateIfMissing
}
func cancelActionFromProto(a iop.CancelAction) runtime.CancelAction {
if a == iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION {
return runtime.CancelActionTerminateSession
}
return runtime.CancelActionCancelRun
}
func normalizeSessionID(id string) string {
if id == "" {
return runtime.DefaultSessionID
}
return id
}