feat: migrate dispatch, agent, and taskgit commands from CLI
Move business logic from core/cli cmd/ai into proper homes: - cmd/dispatch: ticket processing, multi-runner exec, forge reporting - cmd/agent: fleet management, SSH keys, add/remove/setup - cmd/taskgit: task-linked git (task:commit, task:pr) Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
61e01bfdf1
commit
272864cbb8
6 changed files with 1570 additions and 0 deletions
436
cmd/agent/cmd.go
Normal file
436
cmd/agent/cmd.go
Normal file
|
|
@ -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 <name> <user@host>",
|
||||
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 <name>",
|
||||
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 <name>",
|
||||
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 <name>",
|
||||
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 <name>",
|
||||
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 ""
|
||||
}
|
||||
679
cmd/dispatch/cmd.go
Normal file
679
cmd/dispatch/cmd.go
Normal file
|
|
@ -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
|
||||
}
|
||||
49
cmd/dispatch/ratelimit.go
Normal file
49
cmd/dispatch/ratelimit.go
Normal file
|
|
@ -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
|
||||
}
|
||||
256
cmd/taskgit/cmd.go
Normal file
256
cmd/taskgit/cmd.go
Normal file
|
|
@ -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
|
||||
}
|
||||
34
go.mod
34
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 (
|
||||
|
|
|
|||
116
go.sum
116
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue