-runtime.go에 에이전트 런 브리지 인터op 기능 추가 - runner.go에 HTTP invoccer와 워커 브리지 로직 추가 - postgres.go에 worker_bridge 관련 migration 및 저장소 계층 구현 - model.go에 WorkerBridge 엔터티 모델 추가 - main.go에 워커 브리지 초기화 및 HTTP 서버 통합 - config.go에 워커 브리지 설정 항목 추가 - migration에 worker_bridge 스키마 추가 - agent-roadmap에 iop-agent-run-bridge 페이즈 및 마일스톤 업데이트 - http_invoker.go 새로 추가하여 외부 HTTP 호출 추상화
589 lines
18 KiB
Go
589 lines
18 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/gito/services/core/internal/config"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/core"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/events"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/gitengine"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/storage"
|
|
)
|
|
|
|
type OperationPicker interface {
|
|
PickQueuedOperation(ctx context.Context, now time.Time) (core.Operation, bool, error)
|
|
}
|
|
|
|
// AgentRunPicker picks only agent_run-typed operations from the queue,
|
|
// preventing non-agent operations from being stranded in running state.
|
|
type AgentRunPicker interface {
|
|
PickQueuedAgentRunOperation(ctx context.Context, now time.Time) (core.Operation, bool, error)
|
|
}
|
|
|
|
type Clock interface {
|
|
Now() time.Time
|
|
}
|
|
|
|
type realClock struct{}
|
|
|
|
func (realClock) Now() time.Time {
|
|
return time.Now()
|
|
}
|
|
|
|
type AgentRunInput struct {
|
|
OperationID string
|
|
RepoID string
|
|
Branch string
|
|
WorkspacePath string
|
|
Instruction string
|
|
PolicyContext map[string]any
|
|
ExpectedRevision string
|
|
CredentialRefs map[string]string
|
|
}
|
|
|
|
type AgentRunState string
|
|
|
|
const (
|
|
AgentRunStateSucceeded AgentRunState = "succeeded"
|
|
AgentRunStateFailed AgentRunState = "failed"
|
|
AgentRunStateCancelled AgentRunState = "cancelled"
|
|
)
|
|
|
|
type AgentRunResult struct {
|
|
State AgentRunState
|
|
Message string
|
|
}
|
|
|
|
type AgentRunGitEvidence struct {
|
|
StatusClean bool
|
|
StatusOutput string
|
|
BeforeRevision string
|
|
AfterRevision string
|
|
ChangedFiles []gitengine.ChangedFile
|
|
}
|
|
|
|
type RevisionScanRequest struct {
|
|
Provider string
|
|
RepoID string
|
|
Branch string
|
|
WorkDir string
|
|
Runner gitengine.CommandRunner
|
|
ObservedAt time.Time
|
|
// BeforeRevision and AfterRevision, when both non-empty, let the scanner
|
|
// emit a branch.updated event without requiring an existing cursor. This
|
|
// covers the first push of a watched branch immediately after an agent_run.
|
|
BeforeRevision string
|
|
AfterRevision string
|
|
}
|
|
|
|
type RevisionScanResult struct {
|
|
Attempted bool
|
|
Matched bool
|
|
EventID string
|
|
}
|
|
|
|
type RevisionScanner interface {
|
|
ScanAgentRunRevision(ctx context.Context, req RevisionScanRequest) (RevisionScanResult, error)
|
|
}
|
|
|
|
type AgentRunInputLoader interface {
|
|
LoadAgentRunInput(ctx context.Context, op core.Operation) (AgentRunInput, error)
|
|
}
|
|
|
|
type AgentRunInvoker interface {
|
|
InvokeAgentRun(ctx context.Context, input AgentRunInput) (AgentRunResult, error)
|
|
}
|
|
|
|
type RunnerDependencies struct {
|
|
OperationStore storage.OperationStore
|
|
OperationEventStore storage.OperationEventStore
|
|
AgentRunInputLoader AgentRunInputLoader
|
|
AgentRunInvoker AgentRunInvoker
|
|
GitRunner gitengine.CommandRunner
|
|
RevisionScanner RevisionScanner
|
|
// AgentRunPicker, when set, is preferred over the generic OperationPicker so
|
|
// that only agent_run operations are transitioned to running. If nil, the
|
|
// caller-supplied generic picker is used instead.
|
|
AgentRunPicker AgentRunPicker
|
|
}
|
|
|
|
type Runner struct {
|
|
cfg config.Config
|
|
logger *slog.Logger
|
|
picker OperationPicker
|
|
agentRunPicker AgentRunPicker
|
|
clock Clock
|
|
operationStore storage.OperationStore
|
|
operationEventStore storage.OperationEventStore
|
|
agentRunInputLoader AgentRunInputLoader
|
|
agentRunInvoker AgentRunInvoker
|
|
gitRunner gitengine.CommandRunner
|
|
revisionScanner RevisionScanner
|
|
}
|
|
|
|
func NewRunner(cfg config.Config, logger *slog.Logger, picker OperationPicker, clock Clock, scanner RevisionScanner) *Runner {
|
|
return NewRunnerWithDependencies(cfg, logger, picker, clock, RunnerDependencies{
|
|
RevisionScanner: scanner,
|
|
})
|
|
}
|
|
|
|
func NewRunnerWithDependencies(cfg config.Config, logger *slog.Logger, picker OperationPicker, clock Clock, deps RunnerDependencies) *Runner {
|
|
if clock == nil {
|
|
clock = realClock{}
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
gitRunner := deps.GitRunner
|
|
if gitRunner == nil {
|
|
gitRunner = gitengine.CLI{}
|
|
}
|
|
return &Runner{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
picker: picker,
|
|
agentRunPicker: deps.AgentRunPicker,
|
|
clock: clock,
|
|
operationStore: deps.OperationStore,
|
|
operationEventStore: deps.OperationEventStore,
|
|
agentRunInputLoader: deps.AgentRunInputLoader,
|
|
agentRunInvoker: deps.AgentRunInvoker,
|
|
gitRunner: gitRunner,
|
|
revisionScanner: deps.RevisionScanner,
|
|
}
|
|
}
|
|
|
|
func (r *Runner) Run() error {
|
|
return r.RunOnce(context.Background())
|
|
}
|
|
|
|
func (r *Runner) RunOnce(ctx context.Context) error {
|
|
if !r.cfg.WorkerEnabled {
|
|
r.logger.Info("worker disabled")
|
|
return nil
|
|
}
|
|
|
|
if r.agentRunPicker == nil && r.picker == nil {
|
|
r.logger.Warn("worker picker not configured")
|
|
return nil
|
|
}
|
|
|
|
var op core.Operation
|
|
var ok bool
|
|
var err error
|
|
if r.agentRunPicker != nil {
|
|
op, ok, err = r.agentRunPicker.PickQueuedAgentRunOperation(ctx, r.clock.Now())
|
|
} else {
|
|
op, ok, err = r.picker.PickQueuedOperation(ctx, r.clock.Now())
|
|
}
|
|
if err != nil {
|
|
r.logger.Error("failed to pick operation", "error", err)
|
|
return fmt.Errorf("pick operation: %w", err)
|
|
}
|
|
|
|
if !ok {
|
|
r.logger.Debug("no pending operation found")
|
|
return nil
|
|
}
|
|
|
|
r.logger.Info("picked operation", "operation_id", op.ID, "type", op.Type)
|
|
if op.Type == core.OperationAgentRun {
|
|
return r.runAgentRunOperation(ctx, op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) error {
|
|
if r.operationStore == nil || r.operationEventStore == nil || r.agentRunInputLoader == nil || r.agentRunInvoker == nil {
|
|
return fmt.Errorf("agent_run handler is not fully configured")
|
|
}
|
|
|
|
startedAt := r.clock.Now().UTC()
|
|
started := storage.OperationEvent{
|
|
OperationID: op.ID,
|
|
Event: events.Event{
|
|
ID: eventID(op.ID, events.OperationStarted, startedAt),
|
|
Type: events.OperationStarted,
|
|
Subject: operationEventSubject(op.ID),
|
|
},
|
|
}
|
|
startPayload, err := agentRunEventPayload(op, core.OperationRunning, startedAt, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
started.Event.Payload = startPayload
|
|
started.Event.CreatedAt = startedAt
|
|
|
|
if err := r.operationEventStore.AppendEvent(ctx, started); err != nil {
|
|
return fmt.Errorf("append agent_run started event: %w", err)
|
|
}
|
|
|
|
agentStarted := storage.OperationEvent{
|
|
OperationID: op.ID,
|
|
Event: events.Event{
|
|
ID: eventID(op.ID, events.AgentRunStarted, startedAt),
|
|
Type: events.AgentRunStarted,
|
|
Subject: operationEventSubject(op.ID),
|
|
},
|
|
}
|
|
agentRunStartedPayload, err := agentRunEventPayload(op, core.OperationRunning, startedAt, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
agentStarted.Event.Payload = agentRunStartedPayload
|
|
agentStarted.Event.CreatedAt = startedAt
|
|
|
|
if err := r.operationEventStore.AppendEvent(ctx, agentStarted); err != nil {
|
|
return fmt.Errorf("append agent_run_started event: %w", err)
|
|
}
|
|
|
|
input, loadErr := r.agentRunInputLoader.LoadAgentRunInput(ctx, op)
|
|
if loadErr != nil {
|
|
r.logger.Warn("agent_run input load failed; failing operation", "operation_id", op.ID)
|
|
failAt := r.clock.Now().UTC()
|
|
failedOp, ferr := r.operationStore.FailOperation(ctx, op.ID, failAt)
|
|
if ferr != nil {
|
|
return fmt.Errorf("fail agent_run operation after input load failure: %w", ferr)
|
|
}
|
|
failMsg := "agent run input load failed"
|
|
failExtra := map[string]any{"message": failMsg}
|
|
for _, evType := range []string{events.OperationFailed, events.AgentRunCompleted} {
|
|
payload, perr := agentRunEventPayload(failedOp, core.OperationFailed, failAt, failExtra)
|
|
if perr != nil {
|
|
return perr
|
|
}
|
|
ev := storage.OperationEvent{
|
|
OperationID: op.ID,
|
|
Event: events.Event{
|
|
ID: eventID(op.ID, evType, failAt),
|
|
Type: evType,
|
|
Subject: operationEventSubject(op.ID),
|
|
Payload: payload,
|
|
CreatedAt: failAt,
|
|
},
|
|
}
|
|
if aerr := r.operationEventStore.AppendEvent(ctx, ev); aerr != nil {
|
|
return fmt.Errorf("append %s event after input load failure: %w", evType, aerr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
result, err := r.agentRunInvoker.InvokeAgentRun(ctx, input)
|
|
terminalState := core.OperationFailed
|
|
completionMessage := strings.TrimSpace(result.Message)
|
|
// If backend invocation fails, fail the operation with a stable, sanitized message.
|
|
if err != nil {
|
|
completionMessage = "agent run invocation failed"
|
|
} else {
|
|
terminalState, err = toTerminalOperationState(result.State)
|
|
if err != nil {
|
|
return fmt.Errorf("unsupported agent_run result state: %w", err)
|
|
}
|
|
}
|
|
if completionMessage == "" {
|
|
completionMessage = string(terminalState)
|
|
}
|
|
|
|
policy := agentRunGitResultPolicyFrom(input.PolicyContext, input.Branch)
|
|
completionExtra := map[string]any{"message": completionMessage}
|
|
if terminalState == core.OperationSucceeded {
|
|
evidence, evidenceErr := r.collectAgentRunGitEvidence(input)
|
|
if evidenceErr != nil {
|
|
r.logger.Warn("agent_run git evidence collection failed", "operation_id", op.ID, "error", evidenceErr)
|
|
terminalState = core.OperationFailed
|
|
completionMessage = "git evidence collection failed"
|
|
completionExtra = map[string]any{"message": completionMessage}
|
|
} else {
|
|
completionExtra, terminalState, completionMessage = r.applyAgentRunGitResultPolicy(input, policy, evidence, completionMessage)
|
|
if terminalState == core.OperationSucceeded && policy.CommitPushAllowed && r.revisionScanner != nil {
|
|
var provider string
|
|
if pVal, ok := input.PolicyContext["provider"]; ok {
|
|
if s, ok := pVal.(string); ok {
|
|
provider = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
scanReq := RevisionScanRequest{
|
|
Provider: provider,
|
|
RepoID: op.RepoID,
|
|
Branch: policy.Branch,
|
|
WorkDir: input.WorkspacePath,
|
|
Runner: r.gitRunner,
|
|
ObservedAt: r.clock.Now().UTC(),
|
|
BeforeRevision: evidence.BeforeRevision,
|
|
AfterRevision: evidence.AfterRevision,
|
|
}
|
|
scanRes, err := r.revisionScanner.ScanAgentRunRevision(ctx, scanReq)
|
|
if err != nil {
|
|
r.logger.Warn("agent_run revision scan failed", "operation_id", op.ID, "error", err)
|
|
terminalState = core.OperationFailed
|
|
completionMessage = "revision scan failed"
|
|
completionExtra["message"] = completionMessage
|
|
completionExtra["revision_scan_attempted"] = true
|
|
completionExtra["revision_event_matched"] = false
|
|
} else {
|
|
completionExtra["revision_scan_attempted"] = scanRes.Attempted
|
|
completionExtra["revision_event_matched"] = scanRes.Matched
|
|
if scanRes.EventID != "" {
|
|
completionExtra["revision_event_id"] = scanRes.EventID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
terminalAt := r.clock.Now().UTC()
|
|
var updatedOperation core.Operation
|
|
switch terminalState {
|
|
case core.OperationSucceeded:
|
|
updatedOperation, err = r.operationStore.SucceedOperation(ctx, op.ID, terminalAt)
|
|
case core.OperationFailed:
|
|
updatedOperation, err = r.operationStore.FailOperation(ctx, op.ID, terminalAt)
|
|
case core.OperationCancelled:
|
|
updatedOperation, err = r.operationStore.CancelOperation(ctx, op.ID, terminalAt)
|
|
default:
|
|
err = fmt.Errorf("unsupported terminal state %q", terminalState)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("mark agent_run operation terminal: %w", err)
|
|
}
|
|
|
|
completionEventType := events.OperationCompleted
|
|
if terminalState == core.OperationFailed {
|
|
completionEventType = events.OperationFailed
|
|
}
|
|
completedAt := storage.OperationEvent{
|
|
OperationID: op.ID,
|
|
Event: events.Event{
|
|
ID: eventID(op.ID, completionEventType, terminalAt),
|
|
Type: completionEventType,
|
|
Subject: operationEventSubject(op.ID),
|
|
},
|
|
}
|
|
completedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, completionExtra)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
completedAt.Event.Payload = completedPayload
|
|
completedAt.Event.CreatedAt = terminalAt
|
|
if err := r.operationEventStore.AppendEvent(ctx, completedAt); err != nil {
|
|
return fmt.Errorf("append agent_run completion event: %w", err)
|
|
}
|
|
|
|
agentCompleted := storage.OperationEvent{
|
|
OperationID: op.ID,
|
|
Event: events.Event{
|
|
ID: eventID(op.ID, events.AgentRunCompleted, terminalAt),
|
|
Type: events.AgentRunCompleted,
|
|
Subject: operationEventSubject(op.ID),
|
|
},
|
|
}
|
|
agentCompletedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, completionExtra)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
agentCompleted.Event.Payload = agentCompletedPayload
|
|
agentCompleted.Event.CreatedAt = terminalAt
|
|
if err := r.operationEventStore.AppendEvent(ctx, agentCompleted); err != nil {
|
|
return fmt.Errorf("append agent_run_completed event: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) collectAgentRunGitEvidence(input AgentRunInput) (AgentRunGitEvidence, error) {
|
|
workdir := strings.TrimSpace(input.WorkspacePath)
|
|
before := strings.TrimSpace(input.ExpectedRevision)
|
|
if workdir == "" || before == "" {
|
|
return AgentRunGitEvidence{}, fmt.Errorf("workspace_path and expected_revision are required")
|
|
}
|
|
|
|
status, err := gitengine.Status(r.gitRunner, workdir)
|
|
if err != nil {
|
|
return AgentRunGitEvidence{}, fmt.Errorf("git status: %w", err)
|
|
}
|
|
after, err := gitengine.HeadRevision(r.gitRunner, workdir)
|
|
if err != nil {
|
|
return AgentRunGitEvidence{}, fmt.Errorf("git head revision: %w", err)
|
|
}
|
|
changedFiles, err := gitengine.ChangedFilesWithStatus(r.gitRunner, workdir, before, after)
|
|
if err != nil {
|
|
return AgentRunGitEvidence{}, fmt.Errorf("git changed files: %w", err)
|
|
}
|
|
|
|
return AgentRunGitEvidence{
|
|
StatusClean: status.Clean,
|
|
StatusOutput: status.Output,
|
|
BeforeRevision: before,
|
|
AfterRevision: after,
|
|
ChangedFiles: changedFiles,
|
|
}, nil
|
|
}
|
|
|
|
func agentRunGitEvidencePayload(evidence AgentRunGitEvidence) map[string]any {
|
|
changedFiles := make([]any, 0, len(evidence.ChangedFiles))
|
|
for _, file := range evidence.ChangedFiles {
|
|
changedFiles = append(changedFiles, map[string]any{
|
|
"path": file.Path,
|
|
"change_type": file.ChangeType,
|
|
})
|
|
}
|
|
return map[string]any{
|
|
"status_clean": evidence.StatusClean,
|
|
"status_output": evidence.StatusOutput,
|
|
"before_revision": evidence.BeforeRevision,
|
|
"after_revision": evidence.AfterRevision,
|
|
"changed_files": changedFiles,
|
|
}
|
|
}
|
|
|
|
func operationEventSubject(operationID string) string {
|
|
return "operation:" + operationID
|
|
}
|
|
|
|
func eventID(operationID, eventType string, at time.Time) string {
|
|
return fmt.Sprintf("agent-run:%s:%s:%d", operationID, eventType, at.UnixNano())
|
|
}
|
|
|
|
func agentRunEventPayload(op core.Operation, state core.OperationState, at time.Time, extra map[string]any) ([]byte, error) {
|
|
payload := map[string]any{
|
|
"operation_id": op.ID,
|
|
"repo_id": op.RepoID,
|
|
"type": op.Type,
|
|
"state": state,
|
|
"at": at.Format(time.RFC3339Nano),
|
|
}
|
|
for key, value := range extra {
|
|
payload[key] = value
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize agent run event payload: %w", err)
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
type agentRunGitResultPolicy struct {
|
|
CommitPushAllowed bool
|
|
CommitMessage string
|
|
Remote string
|
|
Branch string
|
|
}
|
|
|
|
func agentRunGitResultPolicyFrom(ctx map[string]any, branch string) agentRunGitResultPolicy {
|
|
p := agentRunGitResultPolicy{
|
|
Remote: "origin",
|
|
Branch: strings.TrimSpace(branch),
|
|
}
|
|
if ctx == nil {
|
|
return p
|
|
}
|
|
if v, ok := ctx["commit_push"]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
p.CommitPushAllowed = b
|
|
}
|
|
}
|
|
if !p.CommitPushAllowed {
|
|
if v, ok := ctx["allowed_result_actions"]; ok {
|
|
if agentRunAllowsCommitPushAction(v) {
|
|
p.CommitPushAllowed = true
|
|
}
|
|
}
|
|
}
|
|
if msg, ok := ctx["commit_message"]; ok {
|
|
if s, ok := msg.(string); ok {
|
|
p.CommitMessage = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
if remote, ok := ctx["remote"]; ok {
|
|
if s, ok := remote.(string); ok && strings.TrimSpace(s) != "" {
|
|
p.Remote = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
if b, ok := ctx["branch"]; ok {
|
|
if s, ok := b.(string); ok && strings.TrimSpace(s) != "" {
|
|
p.Branch = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func agentRunAllowsCommitPushAction(value any) bool {
|
|
switch actions := value.(type) {
|
|
case []any:
|
|
for _, action := range actions {
|
|
if s, ok := action.(string); ok && strings.TrimSpace(s) == "commit_push" {
|
|
return true
|
|
}
|
|
}
|
|
case []string:
|
|
for _, action := range actions {
|
|
if strings.TrimSpace(action) == "commit_push" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *Runner) applyAgentRunGitResultPolicy(
|
|
input AgentRunInput,
|
|
policy agentRunGitResultPolicy,
|
|
evidence AgentRunGitEvidence,
|
|
completionMessage string,
|
|
) (map[string]any, core.OperationState, string) {
|
|
extra := agentRunGitEvidencePayload(evidence)
|
|
extra["commit_push_allowed"] = policy.CommitPushAllowed
|
|
|
|
if !policy.CommitPushAllowed {
|
|
if !evidence.StatusClean {
|
|
msg := "workspace dirty after agent run"
|
|
extra["message"] = msg
|
|
return extra, core.OperationFailed, msg
|
|
}
|
|
extra["message"] = completionMessage
|
|
return extra, core.OperationSucceeded, completionMessage
|
|
}
|
|
|
|
commitMsg := policy.CommitMessage
|
|
if commitMsg == "" {
|
|
commitMsg = "agent run result: " + input.OperationID
|
|
}
|
|
extra["commit_attempted"] = true
|
|
if err := gitengine.Commit(r.gitRunner, input.WorkspacePath, gitengine.CommitOptions{
|
|
Message: commitMsg,
|
|
All: true,
|
|
}); err != nil {
|
|
msg := "git commit failed"
|
|
extra["message"] = msg
|
|
return extra, core.OperationFailed, msg
|
|
}
|
|
extra["push_attempted"] = true
|
|
extra["push_remote"] = policy.Remote
|
|
extra["push_branch"] = policy.Branch
|
|
if err := gitengine.Push(r.gitRunner, input.WorkspacePath, policy.Remote, policy.Branch); err != nil {
|
|
msg := "git push failed"
|
|
extra["message"] = msg
|
|
return extra, core.OperationFailed, msg
|
|
}
|
|
extra["message"] = completionMessage
|
|
return extra, core.OperationSucceeded, completionMessage
|
|
}
|
|
|
|
func toTerminalOperationState(state AgentRunState) (core.OperationState, error) {
|
|
switch state {
|
|
case AgentRunStateSucceeded:
|
|
return core.OperationSucceeded, nil
|
|
case AgentRunStateFailed:
|
|
return core.OperationFailed, nil
|
|
case AgentRunStateCancelled:
|
|
return core.OperationCancelled, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid agent run state %q", state)
|
|
}
|
|
}
|