diff --git a/cmd/agent/cmd.go b/cmd/agent/cmd.go new file mode 100644 index 0000000..6f98a7b --- /dev/null +++ b/cmd/agent/cmd.go @@ -0,0 +1,436 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + agentic "forge.lthn.ai/core/go-agentic" + "forge.lthn.ai/core/go-scm/agentci" + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/config" +) + +func init() { + cli.RegisterCommands(AddAgentCommands) +} + +// Style aliases from shared package. +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + dimStyle = cli.DimStyle + taskPriorityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500) +) + +const defaultWorkDir = "ai-work" + +// AddAgentCommands registers the 'agent' subcommand group under 'ai'. +func AddAgentCommands(parent *cli.Command) { + agentCmd := &cli.Command{ + Use: "agent", + Short: "Manage AgentCI dispatch targets", + } + + agentCmd.AddCommand(agentAddCmd()) + agentCmd.AddCommand(agentListCmd()) + agentCmd.AddCommand(agentStatusCmd()) + agentCmd.AddCommand(agentLogsCmd()) + agentCmd.AddCommand(agentSetupCmd()) + agentCmd.AddCommand(agentRemoveCmd()) + agentCmd.AddCommand(agentFleetCmd()) + + parent.AddCommand(agentCmd) +} + +func loadConfig() (*config.Config, error) { + return config.New() +} + +func agentAddCmd() *cli.Command { + cmd := &cli.Command{ + Use: "add ", + Short: "Add an agent to the config and verify SSH", + Args: cli.ExactArgs(2), + RunE: func(cmd *cli.Command, args []string) error { + name := args[0] + host := args[1] + + forgejoUser, _ := cmd.Flags().GetString("forgejo-user") + if forgejoUser == "" { + forgejoUser = name + } + queueDir, _ := cmd.Flags().GetString("queue-dir") + if queueDir == "" { + queueDir = "/home/claude/ai-work/queue" + } + model, _ := cmd.Flags().GetString("model") + dualRun, _ := cmd.Flags().GetBool("dual-run") + + // Scan and add host key to known_hosts. + parts := strings.Split(host, "@") + hostname := parts[len(parts)-1] + + fmt.Printf("Scanning host key for %s... ", hostname) + scanCmd := exec.Command("ssh-keyscan", "-H", hostname) + keys, err := scanCmd.Output() + if err != nil { + fmt.Println(errorStyle.Render("FAILED")) + return fmt.Errorf("failed to scan host keys: %w", err) + } + + home, _ := os.UserHomeDir() + knownHostsPath := filepath.Join(home, ".ssh", "known_hosts") + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open known_hosts: %w", err) + } + if _, err := f.Write(keys); err != nil { + f.Close() + return fmt.Errorf("failed to write known_hosts: %w", err) + } + f.Close() + fmt.Println(successStyle.Render("OK")) + + // Test SSH with strict host key checking. + fmt.Printf("Testing SSH to %s... ", host) + testCmd := agentci.SecureSSHCommand(host, "echo ok") + out, err := testCmd.CombinedOutput() + if err != nil { + fmt.Println(errorStyle.Render("FAILED")) + return fmt.Errorf("SSH failed: %s", strings.TrimSpace(string(out))) + } + fmt.Println(successStyle.Render("OK")) + + cfg, err := loadConfig() + if err != nil { + return err + } + + ac := agentci.AgentConfig{ + Host: host, + QueueDir: queueDir, + ForgejoUser: forgejoUser, + Model: model, + DualRun: dualRun, + Active: true, + } + if err := agentci.SaveAgent(cfg, name, ac); err != nil { + return err + } + + fmt.Printf("Agent %s added (%s)\n", successStyle.Render(name), host) + return nil + }, + } + cmd.Flags().String("forgejo-user", "", "Forgejo username (defaults to agent name)") + cmd.Flags().String("queue-dir", "", "Remote queue directory (default: /home/claude/ai-work/queue)") + cmd.Flags().String("model", "sonnet", "Primary AI model") + cmd.Flags().Bool("dual-run", false, "Enable Clotho dual-run verification") + return cmd +} + +func agentListCmd() *cli.Command { + return &cli.Command{ + Use: "list", + Short: "List configured agents", + RunE: func(cmd *cli.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + agents, err := agentci.ListAgents(cfg) + if err != nil { + return err + } + + if len(agents) == 0 { + fmt.Println(dimStyle.Render("No agents configured. Use 'core ai agent add' to add one.")) + return nil + } + + table := cli.NewTable("NAME", "HOST", "MODEL", "DUAL", "ACTIVE", "QUEUE") + for name, ac := range agents { + active := dimStyle.Render("no") + if ac.Active { + active = successStyle.Render("yes") + } + dual := dimStyle.Render("no") + if ac.DualRun { + dual = successStyle.Render("yes") + } + + // Quick SSH check for queue depth. + queue := dimStyle.Render("-") + checkCmd := agentci.SecureSSHCommand(ac.Host, fmt.Sprintf("ls %s/ticket-*.json 2>/dev/null | wc -l", ac.QueueDir)) + out, err := checkCmd.Output() + if err == nil { + n := strings.TrimSpace(string(out)) + if n != "0" { + queue = n + } else { + queue = "0" + } + } + + table.AddRow(name, ac.Host, ac.Model, dual, active, queue) + } + table.Render() + return nil + }, + } +} + +func agentStatusCmd() *cli.Command { + return &cli.Command{ + Use: "status ", + Short: "Check agent status via SSH", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + name := args[0] + cfg, err := loadConfig() + if err != nil { + return err + } + + agents, err := agentci.ListAgents(cfg) + if err != nil { + return err + } + ac, ok := agents[name] + if !ok { + return fmt.Errorf("agent %q not found", name) + } + + script := ` + echo "=== Queue ===" + ls ~/ai-work/queue/ticket-*.json 2>/dev/null | wc -l + echo "=== Active ===" + ls ~/ai-work/active/ticket-*.json 2>/dev/null || echo "none" + echo "=== Done ===" + ls ~/ai-work/done/ticket-*.json 2>/dev/null | wc -l + echo "=== Lock ===" + if [ -f ~/ai-work/.runner.lock ]; then + PID=$(cat ~/ai-work/.runner.lock) + if kill -0 "$PID" 2>/dev/null; then + echo "RUNNING (PID $PID)" + else + echo "STALE (PID $PID)" + fi + else + echo "IDLE" + fi + ` + + sshCmd := agentci.SecureSSHCommand(ac.Host, script) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + return sshCmd.Run() + }, + } +} + +func agentLogsCmd() *cli.Command { + cmd := &cli.Command{ + Use: "logs ", + Short: "Stream agent runner logs", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + name := args[0] + follow, _ := cmd.Flags().GetBool("follow") + lines, _ := cmd.Flags().GetInt("lines") + + cfg, err := loadConfig() + if err != nil { + return err + } + + agents, err := agentci.ListAgents(cfg) + if err != nil { + return err + } + ac, ok := agents[name] + if !ok { + return fmt.Errorf("agent %q not found", name) + } + + remoteCmd := fmt.Sprintf("tail -n %d ~/ai-work/logs/runner.log", lines) + if follow { + remoteCmd = fmt.Sprintf("tail -f -n %d ~/ai-work/logs/runner.log", lines) + } + + sshCmd := agentci.SecureSSHCommand(ac.Host, remoteCmd) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + sshCmd.Stdin = os.Stdin + return sshCmd.Run() + }, + } + cmd.Flags().BoolP("follow", "f", false, "Follow log output") + cmd.Flags().IntP("lines", "n", 50, "Number of lines to show") + return cmd +} + +func agentSetupCmd() *cli.Command { + return &cli.Command{ + Use: "setup ", + Short: "Bootstrap agent machine (create dirs, copy runner, install cron)", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + name := args[0] + cfg, err := loadConfig() + if err != nil { + return err + } + + agents, err := agentci.ListAgents(cfg) + if err != nil { + return err + } + ac, ok := agents[name] + if !ok { + return fmt.Errorf("agent %q not found — use 'core ai agent add' first", name) + } + + // Find the setup script relative to the binary or in known locations. + scriptPath := findSetupScript() + if scriptPath == "" { + return fmt.Errorf("agent-setup.sh not found — expected in scripts/ directory") + } + + fmt.Printf("Setting up %s on %s...\n", name, ac.Host) + setupCmd := exec.Command("bash", scriptPath, ac.Host) + setupCmd.Stdout = os.Stdout + setupCmd.Stderr = os.Stderr + if err := setupCmd.Run(); err != nil { + return fmt.Errorf("setup failed: %w", err) + } + + fmt.Println(successStyle.Render("Setup complete!")) + return nil + }, + } +} + +func agentRemoveCmd() *cli.Command { + return &cli.Command{ + Use: "remove ", + Short: "Remove an agent from config", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + name := args[0] + cfg, err := loadConfig() + if err != nil { + return err + } + + if err := agentci.RemoveAgent(cfg, name); err != nil { + return err + } + + fmt.Printf("Agent %s removed.\n", name) + return nil + }, + } +} + +func agentFleetCmd() *cli.Command { + cmd := &cli.Command{ + Use: "fleet", + Short: "Show fleet status from the go-agentic registry", + RunE: func(cmd *cli.Command, args []string) error { + workDir, _ := cmd.Flags().GetString("work-dir") + if workDir == "" { + home, _ := os.UserHomeDir() + workDir = filepath.Join(home, defaultWorkDir) + } + dbPath := filepath.Join(workDir, "registry.db") + + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + fmt.Println(dimStyle.Render("No registry found. Start a dispatch watcher first: core ai dispatch watch")) + return nil + } + + registry, err := agentic.NewSQLiteRegistry(dbPath) + if err != nil { + return fmt.Errorf("failed to open registry: %w", err) + } + defer registry.Close() + + // Reap stale agents (no heartbeat for 10 minutes). + reaped := registry.Reap(10 * time.Minute) + if len(reaped) > 0 { + for _, id := range reaped { + fmt.Printf(" Reaped stale agent: %s\n", dimStyle.Render(id)) + } + fmt.Println() + } + + agents := registry.List() + if len(agents) == 0 { + fmt.Println(dimStyle.Render("No agents registered.")) + return nil + } + + table := cli.NewTable("ID", "STATUS", "LOAD", "LAST HEARTBEAT", "CAPABILITIES") + for _, a := range agents { + status := dimStyle.Render(string(a.Status)) + switch a.Status { + case agentic.AgentAvailable: + status = successStyle.Render("available") + case agentic.AgentBusy: + status = taskPriorityMediumStyle.Render("busy") + case agentic.AgentOffline: + status = errorStyle.Render("offline") + } + + load := fmt.Sprintf("%d/%d", a.CurrentLoad, a.MaxLoad) + hb := a.LastHeartbeat.Format("15:04:05") + ago := time.Since(a.LastHeartbeat).Truncate(time.Second) + hbStr := fmt.Sprintf("%s (%s ago)", hb, ago) + + caps := "-" + if len(a.Capabilities) > 0 { + caps = strings.Join(a.Capabilities, ", ") + } + + table.AddRow(a.ID, status, load, hbStr, caps) + } + table.Render() + return nil + }, + } + cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)") + return cmd +} + +// findSetupScript looks for agent-setup.sh in common locations. +func findSetupScript() string { + exe, _ := os.Executable() + if exe != "" { + dir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(dir, "scripts", "agent-setup.sh"), + filepath.Join(dir, "..", "scripts", "agent-setup.sh"), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + } + + cwd, _ := os.Getwd() + if cwd != "" { + p := filepath.Join(cwd, "scripts", "agent-setup.sh") + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} diff --git a/cmd/dispatch/cmd.go b/cmd/dispatch/cmd.go new file mode 100644 index 0000000..9d1101f --- /dev/null +++ b/cmd/dispatch/cmd.go @@ -0,0 +1,679 @@ +package dispatch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/log" + + agentic "forge.lthn.ai/core/go-agentic" +) + +func init() { + cli.RegisterCommands(AddDispatchCommands) +} + +// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'. +// These commands run ON the agent machine to process the work queue. +func AddDispatchCommands(parent *cli.Command) { + dispatchCmd := &cli.Command{ + Use: "dispatch", + Short: "Agent work queue processor (runs on agent machine)", + } + + dispatchCmd.AddCommand(dispatchRunCmd()) + dispatchCmd.AddCommand(dispatchWatchCmd()) + dispatchCmd.AddCommand(dispatchStatusCmd()) + + parent.AddCommand(dispatchCmd) +} + +// dispatchTicket represents the work item JSON structure. +type dispatchTicket struct { + ID string `json:"id"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + IssueNumber int `json:"issue_number"` + IssueTitle string `json:"issue_title"` + IssueBody string `json:"issue_body"` + TargetBranch string `json:"target_branch"` + EpicNumber int `json:"epic_number"` + ForgeURL string `json:"forge_url"` + ForgeToken string `json:"forge_token"` + ForgeUser string `json:"forgejo_user"` + Model string `json:"model"` + Runner string `json:"runner"` + Timeout string `json:"timeout"` + CreatedAt string `json:"created_at"` +} + +const ( + defaultWorkDir = "ai-work" + lockFileName = ".runner.lock" +) + +type runnerPaths struct { + root string + queue string + active string + done string + logs string + jobs string + lock string +} + +func getPaths(baseDir string) runnerPaths { + if baseDir == "" { + home, _ := os.UserHomeDir() + baseDir = filepath.Join(home, defaultWorkDir) + } + return runnerPaths{ + root: baseDir, + queue: filepath.Join(baseDir, "queue"), + active: filepath.Join(baseDir, "active"), + done: filepath.Join(baseDir, "done"), + logs: filepath.Join(baseDir, "logs"), + jobs: filepath.Join(baseDir, "jobs"), + lock: filepath.Join(baseDir, lockFileName), + } +} + +func dispatchRunCmd() *cli.Command { + cmd := &cli.Command{ + Use: "run", + Short: "Process a single ticket from the queue", + RunE: func(cmd *cli.Command, args []string) error { + workDir, _ := cmd.Flags().GetString("work-dir") + paths := getPaths(workDir) + + if err := ensureDispatchDirs(paths); err != nil { + return err + } + + if err := acquireLock(paths.lock); err != nil { + log.Info("Runner locked, skipping run", "lock", paths.lock) + return nil + } + defer releaseLock(paths.lock) + + ticketFile, err := pickOldestTicket(paths.queue) + if err != nil { + return err + } + if ticketFile == "" { + return nil + } + + _, err = processTicket(paths, ticketFile) + return err + }, + } + cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)") + return cmd +} + +// fastFailThreshold is how quickly a job must fail to be considered rate-limited. +// Real work always takes longer than 30 seconds; a 3-second exit means the CLI +// was rejected before it could start (rate limit, auth error, etc.). +const fastFailThreshold = 30 * time.Second + +// maxBackoffMultiplier caps the exponential backoff at 8x the base interval. +const maxBackoffMultiplier = 8 + +func dispatchWatchCmd() *cli.Command { + cmd := &cli.Command{ + Use: "watch", + Short: "Run as a daemon, polling the queue", + RunE: func(cmd *cli.Command, args []string) error { + workDir, _ := cmd.Flags().GetString("work-dir") + interval, _ := cmd.Flags().GetDuration("interval") + agentID, _ := cmd.Flags().GetString("agent-id") + paths := getPaths(workDir) + + if err := ensureDispatchDirs(paths); err != nil { + return err + } + + // Register this agent in the go-agentic registry. + registry, events, cleanup := registerAgent(agentID, paths) + if cleanup != nil { + defer cleanup() + } + + log.Info("Starting dispatch watcher", "dir", paths.root, "interval", interval, "agent", agentID) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Heartbeat loop — keeps agent status fresh. + if registry != nil { + go heartbeatLoop(ctx, registry, agentID, interval/2) + } + + // Backoff state: consecutive fast failures increase the poll delay. + backoffMultiplier := 1 + currentInterval := interval + + ticker := time.NewTicker(currentInterval) + defer ticker.Stop() + + adjustTicker := func(fastFail bool) { + if fastFail { + if backoffMultiplier < maxBackoffMultiplier { + backoffMultiplier *= 2 + } + currentInterval = interval * time.Duration(backoffMultiplier) + log.Warn("Fast failure detected, backing off", + "multiplier", backoffMultiplier, "next_poll", currentInterval) + } else { + if backoffMultiplier > 1 { + log.Info("Job succeeded, resetting backoff") + } + backoffMultiplier = 1 + currentInterval = interval + } + ticker.Reset(currentInterval) + } + + fastFail := runCycleWithEvents(paths, registry, events, agentID) + adjustTicker(fastFail) + + for { + select { + case <-ticker.C: + ff := runCycleWithEvents(paths, registry, events, agentID) + adjustTicker(ff) + case <-sigChan: + log.Info("Shutting down watcher...") + if registry != nil { + _ = registry.Deregister(agentID) + } + return nil + case <-ctx.Done(): + return nil + } + } + }, + } + cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)") + cmd.Flags().Duration("interval", 5*time.Minute, "Polling interval") + cmd.Flags().String("agent-id", defaultAgentID(), "Agent identifier for registry") + return cmd +} + +// defaultAgentID returns a sensible agent ID from hostname. +func defaultAgentID() string { + host, _ := os.Hostname() + if host == "" { + return "unknown" + } + return host +} + +// registerAgent creates a SQLite registry and registers this agent. +// Returns the registry, event emitter, and a cleanup function. +func registerAgent(agentID string, paths runnerPaths) (agentic.AgentRegistry, agentic.EventEmitter, func()) { + dbPath := filepath.Join(paths.root, "registry.db") + registry, err := agentic.NewSQLiteRegistry(dbPath) + if err != nil { + log.Warn("Failed to create agent registry", "error", err, "path", dbPath) + return nil, nil, nil + } + + info := agentic.AgentInfo{ + ID: agentID, + Name: agentID, + Status: agentic.AgentAvailable, + LastHeartbeat: time.Now().UTC(), + MaxLoad: 1, + } + if err := registry.Register(info); err != nil { + log.Warn("Failed to register agent", "error", err) + } else { + log.Info("Agent registered", "id", agentID) + } + + events := agentic.NewChannelEmitter(64) + + // Drain events to log. + go func() { + for ev := range events.Events() { + log.Debug("Event", "type", string(ev.Type), "task", ev.TaskID, "agent", ev.AgentID) + } + }() + + return registry, events, func() { + events.Close() + } +} + +// heartbeatLoop sends periodic heartbeats to keep the agent status fresh. +func heartbeatLoop(ctx context.Context, registry agentic.AgentRegistry, agentID string, interval time.Duration) { + if interval < 30*time.Second { + interval = 30 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = registry.Heartbeat(agentID) + } + } +} + +// runCycleWithEvents wraps runCycle with registry status updates and event emission. +// Returns true if the cycle resulted in a fast failure (likely rate-limited). +func runCycleWithEvents(paths runnerPaths, registry agentic.AgentRegistry, events agentic.EventEmitter, agentID string) bool { + if registry != nil { + if agent, err := registry.Get(agentID); err == nil { + agent.Status = agentic.AgentBusy + _ = registry.Register(agent) + } + } + + fastFail := runCycle(paths) + + if registry != nil { + if agent, err := registry.Get(agentID); err == nil { + agent.Status = agentic.AgentAvailable + agent.LastHeartbeat = time.Now().UTC() + _ = registry.Register(agent) + } + } + return fastFail +} + +func dispatchStatusCmd() *cli.Command { + cmd := &cli.Command{ + Use: "status", + Short: "Show runner status", + RunE: func(cmd *cli.Command, args []string) error { + workDir, _ := cmd.Flags().GetString("work-dir") + paths := getPaths(workDir) + + lockStatus := "IDLE" + if data, err := os.ReadFile(paths.lock); err == nil { + pidStr := strings.TrimSpace(string(data)) + pid, _ := strconv.Atoi(pidStr) + if isProcessAlive(pid) { + lockStatus = fmt.Sprintf("RUNNING (PID %d)", pid) + } else { + lockStatus = fmt.Sprintf("STALE (PID %d)", pid) + } + } + + countFiles := func(dir string) int { + entries, _ := os.ReadDir(dir) + count := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") { + count++ + } + } + return count + } + + fmt.Println("=== Agent Dispatch Status ===") + fmt.Printf("Work Dir: %s\n", paths.root) + fmt.Printf("Status: %s\n", lockStatus) + fmt.Printf("Queue: %d\n", countFiles(paths.queue)) + fmt.Printf("Active: %d\n", countFiles(paths.active)) + fmt.Printf("Done: %d\n", countFiles(paths.done)) + + return nil + }, + } + cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)") + return cmd +} + +// runCycle picks and processes one ticket. Returns true if the job fast-failed +// (likely rate-limited), signalling the caller to back off. +func runCycle(paths runnerPaths) bool { + if err := acquireLock(paths.lock); err != nil { + log.Debug("Runner locked, skipping cycle") + return false + } + defer releaseLock(paths.lock) + + ticketFile, err := pickOldestTicket(paths.queue) + if err != nil { + log.Error("Failed to pick ticket", "error", err) + return false + } + if ticketFile == "" { + return false // empty queue, no backoff needed + } + + start := time.Now() + success, err := processTicket(paths, ticketFile) + elapsed := time.Since(start) + + if err != nil { + log.Error("Failed to process ticket", "file", ticketFile, "error", err) + } + + // Detect fast failure: job failed in under 30s → likely rate-limited. + if !success && elapsed < fastFailThreshold { + log.Warn("Job finished too fast, likely rate-limited", + "elapsed", elapsed.Round(time.Second), "file", filepath.Base(ticketFile)) + return true + } + + return false +} + +// processTicket processes a single ticket. Returns (success, error). +// On fast failure the caller is responsible for detecting the timing and backing off. +// The ticket is moved active→done on completion, or active→queue on fast failure. +func processTicket(paths runnerPaths, ticketPath string) (bool, error) { + fileName := filepath.Base(ticketPath) + log.Info("Processing ticket", "file", fileName) + + activePath := filepath.Join(paths.active, fileName) + if err := os.Rename(ticketPath, activePath); err != nil { + return false, fmt.Errorf("failed to move ticket to active: %w", err) + } + + data, err := os.ReadFile(activePath) + if err != nil { + return false, fmt.Errorf("failed to read ticket: %w", err) + } + var t dispatchTicket + if err := json.Unmarshal(data, &t); err != nil { + return false, fmt.Errorf("failed to unmarshal ticket: %w", err) + } + + jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber)) + repoDir := filepath.Join(jobDir, t.RepoName) + if err := os.MkdirAll(jobDir, 0755); err != nil { + return false, err + } + + if err := prepareRepo(t, repoDir); err != nil { + reportToForge(t, false, fmt.Sprintf("Git setup failed: %v", err)) + moveToDone(paths, activePath, fileName) + return false, err + } + + prompt := buildPrompt(t) + + logFile := filepath.Join(paths.logs, fmt.Sprintf("%s-%s-%d.log", t.RepoOwner, t.RepoName, t.IssueNumber)) + start := time.Now() + success, exitCode, runErr := runAgent(t, prompt, repoDir, logFile) + elapsed := time.Since(start) + + // Fast failure: agent exited in <30s without success → likely rate-limited. + // Requeue the ticket so it's retried after the backoff period. + if !success && elapsed < fastFailThreshold { + log.Warn("Agent rejected fast, requeuing ticket", "elapsed", elapsed.Round(time.Second), "file", fileName) + requeuePath := filepath.Join(paths.queue, fileName) + if err := os.Rename(activePath, requeuePath); err != nil { + // Fallback: move to done if requeue fails. + moveToDone(paths, activePath, fileName) + } + return false, runErr + } + + msg := fmt.Sprintf("Agent completed work on #%d. Exit code: %d.", t.IssueNumber, exitCode) + if !success { + msg = fmt.Sprintf("Agent failed on #%d (exit code: %d). Check logs on agent machine.", t.IssueNumber, exitCode) + if runErr != nil { + msg += fmt.Sprintf(" Error: %v", runErr) + } + } + reportToForge(t, success, msg) + + moveToDone(paths, activePath, fileName) + log.Info("Ticket complete", "id", t.ID, "success", success, "elapsed", elapsed.Round(time.Second)) + return success, nil +} + +func prepareRepo(t dispatchTicket, repoDir string) error { + user := t.ForgeUser + if user == "" { + host, _ := os.Hostname() + user = fmt.Sprintf("%s-%s", host, os.Getenv("USER")) + } + + cleanURL := strings.TrimPrefix(t.ForgeURL, "https://") + cleanURL = strings.TrimPrefix(cleanURL, "http://") + cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s.git", user, t.ForgeToken, cleanURL, t.RepoOwner, t.RepoName) + + if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil { + log.Info("Updating existing repo", "dir", repoDir) + cmds := [][]string{ + {"git", "fetch", "origin"}, + {"git", "checkout", t.TargetBranch}, + {"git", "pull", "origin", t.TargetBranch}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + if args[1] == "checkout" { + createCmd := exec.Command("git", "checkout", "-b", t.TargetBranch, "origin/"+t.TargetBranch) + createCmd.Dir = repoDir + if _, err2 := createCmd.CombinedOutput(); err2 == nil { + continue + } + } + return fmt.Errorf("git command %v failed: %s", args, string(out)) + } + } + } else { + log.Info("Cloning repo", "url", t.RepoOwner+"/"+t.RepoName) + cmd := exec.Command("git", "clone", "-b", t.TargetBranch, cloneURL, repoDir) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %s", string(out)) + } + } + return nil +} + +func buildPrompt(t dispatchTicket) string { + return fmt.Sprintf(`You are working on issue #%d in %s/%s. + +Title: %s + +Description: +%s + +The repo is cloned at the current directory on branch '%s'. +Create a feature branch from '%s', make minimal targeted changes, commit referencing #%d, and push. +Then create a PR targeting '%s' using the forgejo MCP tools or git push.`, + t.IssueNumber, t.RepoOwner, t.RepoName, + t.IssueTitle, + t.IssueBody, + t.TargetBranch, + t.TargetBranch, t.IssueNumber, + t.TargetBranch, + ) +} + +func runAgent(t dispatchTicket, prompt, dir, logPath string) (bool, int, error) { + timeout := 30 * time.Minute + if t.Timeout != "" { + if d, err := time.ParseDuration(t.Timeout); err == nil { + timeout = d + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + model := t.Model + if model == "" { + model = "sonnet" + } + + log.Info("Running agent", "runner", t.Runner, "model", model) + + // For Gemini runner, wrap with rate limiting. + if t.Runner == "gemini" { + return executeWithRateLimit(ctx, model, prompt, func() (bool, int, error) { + return execAgent(ctx, t.Runner, model, prompt, dir, logPath) + }) + } + + return execAgent(ctx, t.Runner, model, prompt, dir, logPath) +} + +func execAgent(ctx context.Context, runner, model, prompt, dir, logPath string) (bool, int, error) { + var cmd *exec.Cmd + + switch runner { + case "codex": + cmd = exec.CommandContext(ctx, "codex", "exec", "--full-auto", prompt) + case "gemini": + args := []string{"-p", "-", "-y", "-m", model} + cmd = exec.CommandContext(ctx, "gemini", args...) + cmd.Stdin = strings.NewReader(prompt) + default: // claude + cmd = exec.CommandContext(ctx, "claude", "-p", "--model", model, "--dangerously-skip-permissions", "--output-format", "text") + cmd.Stdin = strings.NewReader(prompt) + } + + cmd.Dir = dir + + f, err := os.Create(logPath) + if err != nil { + return false, -1, err + } + defer f.Close() + + cmd.Stdout = f + cmd.Stderr = f + + if err := cmd.Run(); err != nil { + exitCode := -1 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + return false, exitCode, err + } + + return true, 0, nil +} + +func reportToForge(t dispatchTicket, success bool, body string) { + token := t.ForgeToken + if token == "" { + token = os.Getenv("FORGE_TOKEN") + } + if token == "" { + log.Warn("No forge token available, skipping report") + return + } + + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", + strings.TrimSuffix(t.ForgeURL, "/"), t.RepoOwner, t.RepoName, t.IssueNumber) + + payload := map[string]string{"body": body} + jsonBody, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + log.Error("Failed to create request", "err", err) + return + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Error("Failed to report to Forge", "err", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + log.Warn("Forge reported error", "status", resp.Status) + } +} + +func moveToDone(paths runnerPaths, activePath, fileName string) { + donePath := filepath.Join(paths.done, fileName) + if err := os.Rename(activePath, donePath); err != nil { + log.Error("Failed to move ticket to done", "err", err) + } +} + +func ensureDispatchDirs(p runnerPaths) error { + dirs := []string{p.queue, p.active, p.done, p.logs, p.jobs} + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("mkdir %s failed: %w", d, err) + } + } + return nil +} + +func acquireLock(lockPath string) error { + if data, err := os.ReadFile(lockPath); err == nil { + pidStr := strings.TrimSpace(string(data)) + pid, _ := strconv.Atoi(pidStr) + if isProcessAlive(pid) { + return fmt.Errorf("locked by PID %d", pid) + } + log.Info("Removing stale lock", "pid", pid) + _ = os.Remove(lockPath) + } + + return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) +} + +func releaseLock(lockPath string) { + _ = os.Remove(lockPath) +} + +func isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +func pickOldestTicket(queueDir string) (string, error) { + entries, err := os.ReadDir(queueDir) + if err != nil { + return "", err + } + + var tickets []string + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") && strings.HasSuffix(e.Name(), ".json") { + tickets = append(tickets, filepath.Join(queueDir, e.Name())) + } + } + + if len(tickets) == 0 { + return "", nil + } + + sort.Strings(tickets) + return tickets[0], nil +} diff --git a/cmd/dispatch/ratelimit.go b/cmd/dispatch/ratelimit.go new file mode 100644 index 0000000..6ceeb8c --- /dev/null +++ b/cmd/dispatch/ratelimit.go @@ -0,0 +1,49 @@ +package dispatch + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/log" + "forge.lthn.ai/core/go/pkg/ratelimit" +) + +// executeWithRateLimit wraps an agent execution with rate limiting logic. +// It estimates token usage, waits for capacity, executes the runner, and records usage. +func executeWithRateLimit(ctx context.Context, model, prompt string, runner func() (bool, int, error)) (bool, int, error) { + rl, err := ratelimit.New() + if err != nil { + log.Warn("Failed to initialize rate limiter, proceeding without limits", "error", err) + return runner() + } + + if err := rl.Load(); err != nil { + log.Warn("Failed to load rate limit state", "error", err) + } + + // Estimate tokens from prompt length (1 token ≈ 4 chars) + estTokens := len(prompt) / 4 + if estTokens == 0 { + estTokens = 1 + } + + log.Info("Checking rate limits", "model", model, "est_tokens", estTokens) + + if err := rl.WaitForCapacity(ctx, model, estTokens); err != nil { + return false, -1, err + } + + success, exitCode, runErr := runner() + + // Record usage with conservative output estimate (actual tokens unknown from shell runner). + outputEst := estTokens / 10 + if outputEst < 50 { + outputEst = 50 + } + rl.RecordUsage(model, estTokens, outputEst) + + if err := rl.Persist(); err != nil { + log.Warn("Failed to persist rate limit state", "error", err) + } + + return success, exitCode, runErr +} diff --git a/cmd/taskgit/cmd.go b/cmd/taskgit/cmd.go new file mode 100644 index 0000000..e8c7434 --- /dev/null +++ b/cmd/taskgit/cmd.go @@ -0,0 +1,256 @@ +// Package taskgit implements git integration commands for task commits and PRs. + +package taskgit + +import ( + "bytes" + "context" + "os" + "os/exec" + "strings" + "time" + + "forge.lthn.ai/core/go-agentic" + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +func init() { + cli.RegisterCommands(AddTaskGitCommands) +} + +// Style aliases from shared package. +var ( + successStyle = cli.SuccessStyle + dimStyle = cli.DimStyle +) + +// task:commit command flags +var ( + taskCommitMessage string + taskCommitScope string + taskCommitPush bool +) + +// task:pr command flags +var ( + taskPRTitle string + taskPRDraft bool + taskPRLabels string + taskPRBase string +) + +var taskCommitCmd = &cli.Command{ + Use: "task:commit [task-id]", + Short: i18n.T("cmd.ai.task_commit.short"), + Long: i18n.T("cmd.ai.task_commit.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + taskID := args[0] + + if taskCommitMessage == "" { + return cli.Err("commit message required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return cli.WrapVerb(err, "load", "config") + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return cli.WrapVerb(err, "get", "task") + } + + // Build commit message with optional scope + commitType := inferCommitType(task.Labels) + var fullMessage string + if taskCommitScope != "" { + fullMessage = cli.Sprintf("%s(%s): %s", commitType, taskCommitScope, taskCommitMessage) + } else { + fullMessage = cli.Sprintf("%s: %s", commitType, taskCommitMessage) + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + // Check for uncommitted changes + hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd) + if err != nil { + return cli.WrapVerb(err, "check", "git status") + } + + if !hasChanges { + cli.Println("No changes to commit") + return nil + } + + // Create commit + cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "commit for "+taskID)) + if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil { + return cli.WrapAction(err, "commit") + } + + cli.Print("%s %s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.commit")+":", fullMessage) + + // Push if requested + if taskCommitPush { + cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.Progress("push")) + if err := agentic.PushChanges(ctx, cwd); err != nil { + return cli.WrapAction(err, "push") + } + cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.push", "changes")) + } + + return nil + }, +} + +var taskPRCmd = &cli.Command{ + Use: "task:pr [task-id]", + Short: i18n.T("cmd.ai.task_pr.short"), + Long: i18n.T("cmd.ai.task_pr.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + taskID := args[0] + + cfg, err := agentic.LoadConfig("") + if err != nil { + return cli.WrapVerb(err, "load", "config") + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return cli.WrapVerb(err, "get", "task") + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + // Check current branch + branch, err := agentic.GetCurrentBranch(ctx, cwd) + if err != nil { + return cli.WrapVerb(err, "get", "branch") + } + + if branch == "main" || branch == "master" { + return cli.Err("cannot create PR from %s branch", branch) + } + + // Push current branch + cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("push", branch)) + if err := agentic.PushChanges(ctx, cwd); err != nil { + // Try setting upstream + if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil { + return cli.WrapVerb(err, "push", "branch") + } + } + + // Build PR options + opts := agentic.PROptions{ + Title: taskPRTitle, + Draft: taskPRDraft, + Base: taskPRBase, + } + + if taskPRLabels != "" { + opts.Labels = strings.Split(taskPRLabels, ",") + } + + // Create PR + cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "PR")) + prURL, err := agentic.CreatePR(ctx, task, cwd, opts) + if err != nil { + return cli.WrapVerb(err, "create", "PR") + } + + cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.create", "PR")) + cli.Print(" %s %s\n", i18n.Label("url"), prURL) + + return nil + }, +} + +func initGitFlags() { + // task:commit command flags + taskCommitCmd.Flags().StringVarP(&taskCommitMessage, "message", "m", "", i18n.T("cmd.ai.task_commit.flag.message")) + taskCommitCmd.Flags().StringVar(&taskCommitScope, "scope", "", i18n.T("cmd.ai.task_commit.flag.scope")) + taskCommitCmd.Flags().BoolVar(&taskCommitPush, "push", false, i18n.T("cmd.ai.task_commit.flag.push")) + + // task:pr command flags + taskPRCmd.Flags().StringVar(&taskPRTitle, "title", "", i18n.T("cmd.ai.task_pr.flag.title")) + taskPRCmd.Flags().BoolVar(&taskPRDraft, "draft", false, i18n.T("cmd.ai.task_pr.flag.draft")) + taskPRCmd.Flags().StringVar(&taskPRLabels, "labels", "", i18n.T("cmd.ai.task_pr.flag.labels")) + taskPRCmd.Flags().StringVar(&taskPRBase, "base", "", i18n.T("cmd.ai.task_pr.flag.base")) +} + +// AddTaskGitCommands registers the task:commit and task:pr commands under a parent. +func AddTaskGitCommands(parent *cli.Command) { + initGitFlags() + parent.AddCommand(taskCommitCmd) + parent.AddCommand(taskPRCmd) +} + +// inferCommitType infers the commit type from task labels. +func inferCommitType(labels []string) string { + for _, label := range labels { + switch strings.ToLower(label) { + case "bug", "bugfix", "fix": + return "fix" + case "docs", "documentation": + return "docs" + case "refactor", "refactoring": + return "refactor" + case "test", "tests", "testing": + return "test" + case "chore": + return "chore" + case "style": + return "style" + case "perf", "performance": + return "perf" + case "ci": + return "ci" + case "build": + return "build" + } + } + return "feat" +} + +// runGitCommand runs a git command in the specified directory. +func runGitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", cli.Wrap(err, stderr.String()) + } + return "", err + } + + return stdout.String(), nil +} diff --git a/go.mod b/go.mod index 62ad02a..3b9826d 100644 --- a/go.mod +++ b/go.mod @@ -5,39 +5,73 @@ go 1.25.5 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 forge.lthn.ai/core/go v0.0.0 + forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3 forge.lthn.ai/core/go-scm v0.0.0 github.com/mark3labs/mcp-go v0.43.2 github.com/stretchr/testify v1.11.1 ) require ( + forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf // indirect github.com/42wim/httpsig v1.2.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) replace ( diff --git a/go.sum b/go.sum index ec6c5cd..5a717b8 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,52 @@ codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= +forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3 h1:6H3hjqHY0loJJe9iCofFzw6x5JDIbi6JNSL0oW2TKFE= +forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3/go.mod h1:2WCSLupRyAeSpmFWM5+OPG0/wa4KMQCO8gA0hM9cUq8= +forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA= +forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk= +forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf h1:EDKI+OM0M+l4+VclG5XuUDoYAM8yu8uleFYReeEYwHY= +forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf/go.mod h1:FpUlLEX/ebyoxpk96F7ktr0vYvmFtC5Rpi9fi88UVqw= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -20,33 +57,68 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= @@ -57,8 +129,14 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -66,11 +144,19 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -81,8 +167,38 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= +modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=