forked from Gitlink/gitlink-cli
402 lines
11 KiB
Go
402 lines
11 KiB
Go
package workflow
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// StartDaemon launches a workflow as a background daemon process.
|
||
func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) error {
|
||
bin, err := os.Executable()
|
||
if err != nil {
|
||
return fmt.Errorf("cannot find executable: %w", err)
|
||
}
|
||
|
||
args := buildDaemonArgs(ctx, wf, interval, aiMode)
|
||
|
||
cmd := exec.Command(bin, args...)
|
||
applyDaemonAttrs(cmd)
|
||
|
||
// Redirect output to log file instead of discarding.
|
||
logPath := daemonLogPath(wf.Name)
|
||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||
if err != nil {
|
||
return fmt.Errorf("create log file: %w", err)
|
||
}
|
||
cmd.Stdout = logFile
|
||
cmd.Stderr = logFile
|
||
cmd.Stdin = nil
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
logFile.Close()
|
||
return fmt.Errorf("start daemon: %w", err)
|
||
}
|
||
// logFile is owned by child process; it will be closed when child exits.
|
||
|
||
pid := cmd.Process.Pid
|
||
if err := savePID(wf.Name, pid); err != nil {
|
||
return fmt.Errorf("save pid: %w", err)
|
||
}
|
||
|
||
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
|
||
fmt.Printf("Log: %s\n", logPath)
|
||
return nil
|
||
}
|
||
|
||
func buildDaemonArgs(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) []string {
|
||
args := []string{
|
||
"workflow", "+run", "--name", wf.Name,
|
||
"--format", "json", "--daemon-loop",
|
||
"--interval", interval.String(),
|
||
}
|
||
if !isExplicitMultiRepoRun(ctx, wf) {
|
||
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||
}
|
||
if aiMode == "ai" {
|
||
args = append(args, "--ai")
|
||
} else if aiMode == "no-ai" {
|
||
args = append(args, "--no-ai")
|
||
}
|
||
if repos := ctx.Arg("repos"); repos != "" {
|
||
args = append(args, "--repos", repos)
|
||
}
|
||
if from := ctx.Arg("from"); from != "" {
|
||
args = append(args, "--from", from)
|
||
}
|
||
if release := ctx.Arg("release"); release != "" {
|
||
args = append(args, "--release", release)
|
||
}
|
||
return args
|
||
}
|
||
|
||
// StopDaemon stops a running workflow daemon by name.
|
||
func StopDaemon(name string) error {
|
||
pid, err := readPID(name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
proc, err := os.FindProcess(pid)
|
||
if err != nil {
|
||
cleanPID(name)
|
||
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
|
||
}
|
||
|
||
if err := proc.Signal(os.Interrupt); err != nil {
|
||
// Process might already be dead; clean up pid file anyway.
|
||
cleanPID(name)
|
||
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
|
||
}
|
||
|
||
cleanPID(name)
|
||
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
|
||
return nil
|
||
}
|
||
|
||
// StatusDaemon prints the current daemon status for a workflow.
|
||
func StatusDaemon(name string) error {
|
||
state, err := LoadState(name)
|
||
if err != nil {
|
||
return fmt.Errorf("load state: %w", err)
|
||
}
|
||
|
||
pid, pidErr := readPID(name)
|
||
running := pidErr == nil && processRunning(pid)
|
||
|
||
fmt.Printf("工作流: %s\n", name)
|
||
if running {
|
||
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
|
||
} else {
|
||
fmt.Println("状态: 已停止")
|
||
}
|
||
if state.LastRun != "" {
|
||
t, err := time.Parse(time.RFC3339, state.LastRun)
|
||
if err == nil {
|
||
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||
} else {
|
||
fmt.Printf("上次运行: %s\n", state.LastRun)
|
||
}
|
||
}
|
||
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
|
||
fmt.Printf("快照步骤: %d 个\n", len(state.Snapshots))
|
||
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
|
||
return nil
|
||
}
|
||
|
||
// DaemonLoop runs the workflow repeatedly in a loop (used by the daemon subprocess).
|
||
func DaemonLoop(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
|
||
tick := time.NewTicker(interval)
|
||
defer tick.Stop()
|
||
|
||
// Run immediately on start (dry-run to establish baseline).
|
||
doDaemonCycle(ctx, wf)
|
||
|
||
for {
|
||
select {
|
||
case <-tick.C:
|
||
doDaemonCycle(ctx, wf)
|
||
}
|
||
}
|
||
}
|
||
|
||
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||
state, _ := LoadState(wf.Name)
|
||
|
||
// Phase 1: cheap dry-run — collect data without AI.
|
||
dryResult, err := Run(ctx, wf, true)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
|
||
return
|
||
}
|
||
|
||
changed := state.Diff(dryResult.Steps)
|
||
if len(changed) == 0 {
|
||
if state.TotalRuns > 0 {
|
||
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
|
||
state.TotalRuns++
|
||
state.Save()
|
||
return
|
||
}
|
||
// First run: establish baseline snapshot, then proceed to full run.
|
||
} else {
|
||
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
|
||
}
|
||
|
||
// Phase 2: full run (AI or rules based on context.AIMode).
|
||
fullResult, err := Run(ctx, wf, false)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
|
||
return
|
||
}
|
||
|
||
// Reload state — Run() may have updated ReviewedPRs fingerprints.
|
||
state, _ = LoadState(wf.Name)
|
||
state.TotalRuns++
|
||
state.Diff(fullResult.Steps)
|
||
state.UpdateSnapshots(fullResult.Steps)
|
||
state.Save()
|
||
|
||
ok, total := 0, len(fullResult.Steps)
|
||
for _, sr := range fullResult.Steps {
|
||
if sr.OK {
|
||
ok++
|
||
}
|
||
}
|
||
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||
|
||
// Log step-level details: failures and rule-engine findings.
|
||
for _, sr := range fullResult.Steps {
|
||
if !sr.OK {
|
||
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
|
||
}
|
||
if sr.Type == StepTypeSkill && sr.Data != nil {
|
||
logSkillFindings(sr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// logSkillFindings prints rule-engine/AI analysis findings from a skill step to the daemon log.
|
||
func logSkillFindings(sr StepResult) {
|
||
m, ok := sr.Data.(map[string]interface{})
|
||
if !ok {
|
||
return
|
||
}
|
||
skill, _ := m["_skill"].(string)
|
||
analysis := m["analysis"]
|
||
|
||
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
|
||
|
||
// AI mode returns analysis as a markdown string; print it directly.
|
||
if s, ok := analysis.(string); ok && s != "" {
|
||
for _, line := range strings.Split(s, "\n") {
|
||
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Rule engine returns analysis as a structured map.
|
||
am, _ := analysis.(map[string]interface{})
|
||
if am == nil {
|
||
return
|
||
}
|
||
|
||
if summary, ok := am["summary"].(string); ok && summary != "" {
|
||
fmt.Fprintf(os.Stderr, "[%s] 📋 %s\n", time.Now().Format(time.RFC3339), summary)
|
||
}
|
||
|
||
if findings, ok := am["findings"]; ok && findings != nil {
|
||
raw, _ := json.Marshal(findings)
|
||
var arr []interface{}
|
||
if json.Unmarshal(raw, &arr) == nil {
|
||
for _, f := range arr {
|
||
if fm, ok := f.(map[string]interface{}); ok {
|
||
sev := fm["severity"]
|
||
what := fm["what"]
|
||
fmt.Fprintf(os.Stderr, "[%s] [%v] %v\n", time.Now().Format(time.RFC3339), sev, what)
|
||
if prNum, ok := fm["pr_number"]; ok && prNum != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] PR: #%v\n", time.Now().Format(time.RFC3339), prNum)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if msg, ok := am["message"].(string); ok && msg != "" {
|
||
fmt.Fprintf(os.Stderr, "[%s] ℹ️ %s\n", time.Now().Format(time.RFC3339), msg)
|
||
}
|
||
|
||
if reviewData, ok := am["reviewed_prs"]; ok {
|
||
fmt.Fprintf(os.Stderr, "[%s] 已审查 PR 数: %v\n", time.Now().Format(time.RFC3339), reviewData)
|
||
}
|
||
if totalFindings, ok := am["total_findings"]; ok {
|
||
fmt.Fprintf(os.Stderr, "[%s] 发现问题数: %v\n", time.Now().Format(time.RFC3339), totalFindings)
|
||
}
|
||
}
|
||
|
||
// daemonLogPath returns the log file path for a workflow daemon.
|
||
func daemonLogPath(name string) string {
|
||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
|
||
}
|
||
|
||
// tailDaemonLog reads and optionally follows a daemon log file.
|
||
func tailDaemonLog(name string, follow bool) error {
|
||
path := daemonLogPath(name)
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
|
||
}
|
||
fmt.Print(string(data))
|
||
|
||
if !follow {
|
||
return nil
|
||
}
|
||
|
||
sig := make(chan os.Signal, 1)
|
||
signal.Notify(sig, os.Interrupt)
|
||
ticker := time.NewTicker(1 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
offset := int64(len(data))
|
||
for {
|
||
select {
|
||
case <-sig:
|
||
return nil
|
||
case <-ticker.C:
|
||
fi, err := os.Stat(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if fi.Size() > offset {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
f.Seek(offset, 0)
|
||
buf := make([]byte, fi.Size()-offset)
|
||
n, _ := f.Read(buf)
|
||
if n > 0 {
|
||
fmt.Print(string(buf[:n]))
|
||
}
|
||
offset = fi.Size()
|
||
f.Close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("resolve repo: %w", err)
|
||
}
|
||
|
||
bin, _ := os.Executable()
|
||
extraArgs := ""
|
||
if aiMode == "ai" {
|
||
extraArgs = " --ai"
|
||
} else if aiMode == "no-ai" {
|
||
extraArgs = " --no-ai"
|
||
}
|
||
|
||
unit := fmt.Sprintf(`[Unit]
|
||
Description=GitLink CLI Workflow: %s (%s/%s)
|
||
After=network.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
|
||
Restart=on-failure
|
||
RestartSec=30
|
||
StandardOutput=append:%s
|
||
StandardError=append:%s
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
`,
|
||
wf.Name, ctx.Owner, ctx.Repo,
|
||
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
|
||
)
|
||
|
||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
|
||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||
return fmt.Errorf("写入 unit 文件: %w", err)
|
||
}
|
||
|
||
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
|
||
fmt.Println("安装步骤:")
|
||
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
|
||
fmt.Println(" sudo systemctl daemon-reload")
|
||
fmt.Printf(" sudo systemctl enable workflow-%s\n", wf.Name)
|
||
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
|
||
fmt.Println()
|
||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
|
||
return nil
|
||
}
|
||
|
||
func savePID(name string, pid int) error {
|
||
dir := config.ConfigDir()
|
||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
|
||
}
|
||
|
||
func readPID(name string) (int, error) {
|
||
data, err := os.ReadFile(pidPath(name))
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
|
||
}
|
||
return 0, err
|
||
}
|
||
return strconv.Atoi(string(data))
|
||
}
|
||
|
||
func cleanPID(name string) {
|
||
os.Remove(pidPath(name))
|
||
}
|
||
|
||
func processRunning(pid int) bool {
|
||
proc, err := os.FindProcess(pid)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return proc.Signal(syscall.Signal(0)) == nil
|
||
}
|
||
|
||
func pidPath(name string) string {
|
||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
|
||
}
|