504 lines
13 KiB
Go
504 lines
13 KiB
Go
package orch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"ai-workflow-skill/internal/protocol"
|
|
"ai-workflow-skill/internal/store"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) store.DispatchWorkspacePreparer {
|
|
ctx := cmd.Context()
|
|
|
|
return func(task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
|
effectiveOpts, useWorktree := resolveDispatchWorktreeOptions(task, opts)
|
|
if !useWorktree {
|
|
return store.DispatchWorkspace{}, func() {}, nil
|
|
}
|
|
return provisionDispatchWorkspace(ctx, effectiveOpts, task, attemptNo)
|
|
}
|
|
}
|
|
|
|
func newAttemptReuseWorkspacePreparer(cmd *cobra.Command, task store.Task, attempt *store.TaskAttempt) store.DispatchWorkspacePreparer {
|
|
if attempt == nil || attempt.WorktreePath == "" {
|
|
return nil
|
|
}
|
|
|
|
workspaceRoot, ok := deriveWorkspaceRootFromAttempt(task.RunID, task.TaskID, attempt.WorktreePath)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
baseRef := attempt.BaseRef
|
|
if strings.TrimSpace(baseRef) == "" {
|
|
baseRef = attempt.BaseCommit
|
|
}
|
|
|
|
opts := dispatchOptions{
|
|
repoPath: attempt.WorktreePath,
|
|
workspaceRoot: workspaceRoot,
|
|
strictWorktree: true,
|
|
baseRef: baseRef,
|
|
}
|
|
|
|
return newDispatchWorkspacePreparer(cmd, opts)
|
|
}
|
|
|
|
func dispatchUsesWorktree(opts dispatchOptions) bool {
|
|
return strings.TrimSpace(opts.workspaceRoot) != "" ||
|
|
opts.strictWorktree
|
|
}
|
|
|
|
func resolveDispatchWorktreeOptions(task store.Task, opts dispatchOptions) (dispatchOptions, bool) {
|
|
if dispatchUsesWorktree(opts) {
|
|
return opts, true
|
|
}
|
|
if !taskLooksLikeCodeWork(task) {
|
|
return opts, false
|
|
}
|
|
|
|
auto := opts
|
|
auto.strictWorktree = true
|
|
return auto, true
|
|
}
|
|
|
|
func taskLooksLikeCodeWork(task store.Task) bool {
|
|
if acceptanceJSONLooksCodeLike(task.AcceptanceJSON) {
|
|
return true
|
|
}
|
|
return roleLooksCodeLike(task.DefaultTo)
|
|
}
|
|
|
|
func acceptanceJSONLooksCodeLike(raw json.RawMessage) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return false
|
|
}
|
|
return acceptanceValueLooksCodeLike(value)
|
|
}
|
|
|
|
func acceptanceValueLooksCodeLike(value any) bool {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, raw := range typed {
|
|
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
|
switch lowerKey {
|
|
case "code", "code_task", "writes_code", "worktree":
|
|
if boolValue, ok := raw.(bool); ok && boolValue {
|
|
return true
|
|
}
|
|
case "kind", "task_type", "mode", "type":
|
|
if stringValue, ok := raw.(string); ok && isCodeLikeMarker(stringValue) {
|
|
return true
|
|
}
|
|
}
|
|
if acceptanceValueLooksCodeLike(raw) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range typed {
|
|
if acceptanceValueLooksCodeLike(item) {
|
|
return true
|
|
}
|
|
}
|
|
case string:
|
|
return isCodeLikeMarker(typed)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func roleLooksCodeLike(role string) bool {
|
|
role = strings.ToLower(strings.TrimSpace(role))
|
|
if role == "" {
|
|
return false
|
|
}
|
|
|
|
for _, token := range splitIdentifierTokens(role) {
|
|
switch token {
|
|
case "backend", "frontend", "front", "admin", "ui", "fullstack", "foundation", "db", "database", "mobile", "ios", "android", "web", "platform", "infra", "api":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isCodeLikeMarker(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
switch value {
|
|
case "code", "code_task", "code-task", "code-change", "code_change", "implementation", "patch", "diff", "repo":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func splitIdentifierTokens(value string) []string {
|
|
return strings.FieldsFunc(value, func(r rune) bool {
|
|
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
|
})
|
|
}
|
|
|
|
func provisionDispatchWorkspace(ctx context.Context, opts dispatchOptions, task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
|
repoRoot, err := resolveRepoRoot(ctx, opts.repoPath)
|
|
if err != nil {
|
|
return store.DispatchWorkspace{}, nil, err
|
|
}
|
|
|
|
workspaceRoot := resolveWorkspaceRoot(repoRoot, opts.workspaceRoot)
|
|
if err := ensureWorkspaceRootIgnored(repoRoot, workspaceRoot); err != nil {
|
|
return store.DispatchWorkspace{}, nil, err
|
|
}
|
|
|
|
baseRef, baseCommit, err := resolveDispatchBase(ctx, repoRoot, workspaceRoot, opts.baseRef, opts.strictWorktree)
|
|
if err != nil {
|
|
return store.DispatchWorkspace{}, nil, err
|
|
}
|
|
|
|
branchName := buildAttemptBranchName(task.RunID, task.TaskID, attemptNo)
|
|
worktreePath := buildAttemptWorktreePath(workspaceRoot, task.RunID, task.TaskID, attemptNo)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
|
|
return store.DispatchWorkspace{}, nil, fmt.Errorf("create worktree parent dir: %w", err)
|
|
}
|
|
if _, err := os.Stat(worktreePath); err == nil {
|
|
return store.DispatchWorkspace{}, nil, fmt.Errorf("%w: worktree path already exists: %s", store.ErrInvalidState, worktreePath)
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return store.DispatchWorkspace{}, nil, fmt.Errorf("stat worktree path: %w", err)
|
|
}
|
|
|
|
if _, _, err := runGit(ctx, repoRoot, "worktree", "add", "-b", branchName, worktreePath, baseCommit); err != nil {
|
|
return store.DispatchWorkspace{}, nil, err
|
|
}
|
|
|
|
cleanup := func() {
|
|
_, _, _ = runGit(context.Background(), repoRoot, "worktree", "remove", "--force", worktreePath)
|
|
_, _, _ = runGit(context.Background(), repoRoot, "branch", "-D", branchName)
|
|
_ = os.RemoveAll(worktreePath)
|
|
}
|
|
|
|
return store.DispatchWorkspace{
|
|
BaseRef: baseRef,
|
|
BaseCommit: baseCommit,
|
|
BranchName: branchName,
|
|
WorktreePath: worktreePath,
|
|
WorkspaceStatus: "created",
|
|
}, cleanup, nil
|
|
}
|
|
|
|
func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) {
|
|
startPath := strings.TrimSpace(repoPath)
|
|
if startPath == "" {
|
|
var err error
|
|
startPath, err = os.Getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get current working directory: %w", err)
|
|
}
|
|
}
|
|
|
|
absPath, err := filepath.Abs(startPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve repo path: %w", err)
|
|
}
|
|
|
|
if _, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel"); err != nil {
|
|
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
|
|
}
|
|
|
|
commonDir, err := resolveCommonGitDir(ctx, absPath)
|
|
if err != nil {
|
|
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
|
|
}
|
|
return filepath.Dir(commonDir), nil
|
|
}
|
|
|
|
func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) {
|
|
baseRef := strings.TrimSpace(requestedBaseRef)
|
|
if baseRef != "" {
|
|
baseCommit, err := resolveCommit(ctx, repoRoot, baseRef)
|
|
if err != nil {
|
|
return "", "", protocol.InvalidInput("base-ref must resolve to a commit", err)
|
|
}
|
|
return baseRef, baseCommit, nil
|
|
}
|
|
|
|
if strict {
|
|
dirty, err := repoHasUncommittedChanges(ctx, repoRoot, workspaceRoot)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if dirty {
|
|
return "", "", fmt.Errorf("%w: repository has uncommitted changes; specify --base-ref or clean the repo", store.ErrInvalidState)
|
|
}
|
|
}
|
|
|
|
baseCommit, err := resolveCommit(ctx, repoRoot, "HEAD")
|
|
if err != nil {
|
|
return "", "", protocol.InvalidInput("failed to resolve HEAD commit", err)
|
|
}
|
|
return "HEAD", baseCommit, nil
|
|
}
|
|
|
|
func resolveCommit(ctx context.Context, repoRoot, ref string) (string, error) {
|
|
stdout, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(stdout), nil
|
|
}
|
|
|
|
func repoHasUncommittedChanges(ctx context.Context, repoRoot, workspaceRoot string) (bool, error) {
|
|
stdout, _, err := runGit(ctx, repoRoot, "status", "--porcelain")
|
|
if err != nil {
|
|
return false, fmt.Errorf("check repository status: %w", err)
|
|
}
|
|
|
|
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if len(line) >= 3 {
|
|
path := strings.TrimSpace(line[3:])
|
|
if shouldIgnoreStatusPath(repoRoot, workspaceRoot, path) {
|
|
continue
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func resolveWorkspaceRoot(repoRoot, configuredRoot string) string {
|
|
root := strings.TrimSpace(configuredRoot)
|
|
if root == "" {
|
|
return filepath.Join(repoRoot, ".orch", "worktrees")
|
|
}
|
|
if filepath.IsAbs(root) {
|
|
return root
|
|
}
|
|
return filepath.Join(repoRoot, root)
|
|
}
|
|
|
|
func ensureWorkspaceRootIgnored(repoRoot, workspaceRoot string) error {
|
|
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve workspace root exclude path: %w", err)
|
|
}
|
|
if relative == "." || strings.HasPrefix(relative, "..") {
|
|
return nil
|
|
}
|
|
|
|
pattern := filepath.ToSlash(relative)
|
|
if !strings.HasSuffix(pattern, "/") {
|
|
pattern += "/"
|
|
}
|
|
|
|
excludePath := filepath.Join(repoRoot, ".git", "info", "exclude")
|
|
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
|
return fmt.Errorf("create git info dir: %w", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(excludePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("read git exclude file: %w", err)
|
|
}
|
|
if strings.Contains(string(content), pattern) {
|
|
return nil
|
|
}
|
|
|
|
appendContent := pattern + "\n"
|
|
if len(content) > 0 && !strings.HasSuffix(string(content), "\n") {
|
|
appendContent = "\n" + appendContent
|
|
}
|
|
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("open git exclude file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
if _, err := file.WriteString(appendContent); err != nil {
|
|
return fmt.Errorf("append git exclude file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func shouldIgnoreStatusPath(repoRoot, workspaceRoot, statusPath string) bool {
|
|
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
|
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
|
|
return false
|
|
}
|
|
|
|
relative = filepath.ToSlash(relative)
|
|
statusPath = filepath.ToSlash(strings.Trim(statusPath, `"`))
|
|
return statusPath == relative || strings.HasPrefix(statusPath, relative+"/")
|
|
}
|
|
|
|
func buildAttemptBranchName(runID, taskID string, attemptNo int) string {
|
|
return fmt.Sprintf(
|
|
"orch/%s/%s/attempt-%d",
|
|
sanitizeGitSegment(runID),
|
|
sanitizeGitSegment(taskID),
|
|
attemptNo,
|
|
)
|
|
}
|
|
|
|
func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int) string {
|
|
return filepath.Join(
|
|
workspaceRoot,
|
|
sanitizePathSegment(runID),
|
|
sanitizePathSegment(taskID),
|
|
fmt.Sprintf("attempt-%d", attemptNo),
|
|
)
|
|
}
|
|
|
|
func deriveWorkspaceRootFromAttempt(runID, taskID, worktreePath string) (string, bool) {
|
|
suffix := filepath.Join(
|
|
sanitizePathSegment(runID),
|
|
sanitizePathSegment(taskID),
|
|
filepath.Base(worktreePath),
|
|
)
|
|
parent := filepath.Dir(worktreePath)
|
|
if filepath.Base(parent) != sanitizePathSegment(taskID) {
|
|
return "", false
|
|
}
|
|
runDir := filepath.Dir(parent)
|
|
if filepath.Base(runDir) != sanitizePathSegment(runID) {
|
|
return "", false
|
|
}
|
|
root := filepath.Dir(runDir)
|
|
if filepath.Clean(filepath.Join(root, suffix)) != filepath.Clean(worktreePath) {
|
|
return "", false
|
|
}
|
|
return root, true
|
|
}
|
|
|
|
func sanitizeGitSegment(value string) string {
|
|
return sanitizeSegment(value)
|
|
}
|
|
|
|
func sanitizePathSegment(value string) string {
|
|
return sanitizeSegment(value)
|
|
}
|
|
|
|
func sanitizeSegment(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "item"
|
|
}
|
|
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
continue
|
|
}
|
|
if r == '-' || r == '_' || r == '.' {
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
continue
|
|
}
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
|
|
result := strings.Trim(b.String(), "-.")
|
|
if result == "" {
|
|
return "item"
|
|
}
|
|
if strings.HasSuffix(result, ".lock") {
|
|
result = strings.TrimSuffix(result, ".lock") + "-lock"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func runGit(ctx context.Context, repoRoot string, args ...string) (string, string, error) {
|
|
cmdArgs := append([]string{"-C", repoRoot}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
return string(output), "", nil
|
|
}
|
|
|
|
message := strings.TrimSpace(string(output))
|
|
if message == "" {
|
|
message = err.Error()
|
|
}
|
|
return "", message, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
|
|
}
|
|
|
|
func cleanupAttemptWorktree(ctx context.Context, attempt store.TaskAttempt, force bool) error {
|
|
if strings.TrimSpace(attempt.WorktreePath) == "" {
|
|
return nil
|
|
}
|
|
|
|
if _, err := os.Stat(attempt.WorktreePath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("stat worktree path: %w", err)
|
|
}
|
|
|
|
repoRoot, err := resolveRepoRootFromExistingWorktree(ctx, attempt.WorktreePath)
|
|
if err != nil {
|
|
if force {
|
|
return os.RemoveAll(attempt.WorktreePath)
|
|
}
|
|
return err
|
|
}
|
|
|
|
args := []string{"worktree", "remove"}
|
|
if force {
|
|
args = append(args, "--force")
|
|
}
|
|
args = append(args, attempt.WorktreePath)
|
|
if _, _, err := runGit(ctx, repoRoot, args...); err != nil {
|
|
if force {
|
|
return os.RemoveAll(attempt.WorktreePath)
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func resolveRepoRootFromExistingWorktree(ctx context.Context, worktreePath string) (string, error) {
|
|
commonDir, err := resolveCommonGitDir(ctx, worktreePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Dir(commonDir), nil
|
|
}
|
|
|
|
func resolveCommonGitDir(ctx context.Context, repoPath string) (string, error) {
|
|
stdout, _, err := runGit(ctx, repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
commonDir := strings.TrimSpace(stdout)
|
|
if !filepath.IsAbs(commonDir) {
|
|
commonDir = filepath.Join(repoPath, commonDir)
|
|
}
|
|
return filepath.Clean(commonDir), nil
|
|
}
|