Add new file
This commit is contained in:
+192
@@ -0,0 +1,192 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/rockliang/poimen/workflows/internal/lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloneRepoInput is input to CloneRepoActivity.
|
||||||
|
type CloneRepoInput struct {
|
||||||
|
RemoteURL string
|
||||||
|
TargetRepoPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneRepoActivity clones a repo if it doesn't exist, or fetches if it does.
|
||||||
|
func CloneRepoActivity(ctx context.Context, in CloneRepoInput) error {
|
||||||
|
// Check if repo already exists
|
||||||
|
gitDir := filepath.Join(in.TargetRepoPath, ".git")
|
||||||
|
if _, err := os.Stat(gitDir); err == nil {
|
||||||
|
// Repo exists, fetch latest
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", in.TargetRepoPath, "fetch", "origin")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo doesn't exist, clone it
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", in.RemoteURL, in.TargetRepoPath)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git clone failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitWorktreeAddInput is input to GitWorktreeAddActivity.
|
||||||
|
type GitWorktreeAddInput struct {
|
||||||
|
RepoPath string
|
||||||
|
TaskID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitWorktreeAddActivity creates a new git worktree for a task.
|
||||||
|
func GitWorktreeAddActivity(ctx context.Context, in GitWorktreeAddInput) (string, error) {
|
||||||
|
// Acquire lock to synchronize worktree creation
|
||||||
|
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||||
|
if err := lock.Acquire(lockPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to acquire lock: %w", err)
|
||||||
|
}
|
||||||
|
defer lock.Release(lockPath)
|
||||||
|
|
||||||
|
// Create worktrees directory if it doesn't exist
|
||||||
|
worktreesDir := filepath.Join(in.RepoPath, "worktrees")
|
||||||
|
if err := os.MkdirAll(worktreesDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create worktrees dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreePath := filepath.Join(worktreesDir, in.TaskID)
|
||||||
|
branch := "task/" + in.TaskID
|
||||||
|
|
||||||
|
// Create worktree
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "add", "-b", branch, worktreePath, "origin/main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("git worktree add failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return worktreePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCommitInput is input to GitCommitActivity.
|
||||||
|
type GitCommitInput struct {
|
||||||
|
WorktreePath string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCommitActivity commits changes in a worktree.
|
||||||
|
func GitCommitActivity(ctx context.Context, in GitCommitInput) error {
|
||||||
|
// Stage all changes
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "add", "-A")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git add failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "commit", "-m", in.Message)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git commit failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitPushInput is input to GitPushActivity.
|
||||||
|
type GitPushInput struct {
|
||||||
|
RepoPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitPushActivity pushes changes to origin.
|
||||||
|
func GitPushActivity(ctx context.Context, in GitPushInput) error {
|
||||||
|
// Acquire lock to synchronize push
|
||||||
|
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||||
|
if err := lock.Acquire(lockPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||||
|
}
|
||||||
|
defer lock.Release(lockPath)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git push failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitSquashMergeInput is input to GitSquashMergeActivity.
|
||||||
|
type GitSquashMergeInput struct {
|
||||||
|
RepoPath string
|
||||||
|
Branches []string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitSquashMergeActivity performs a squash merge of multiple branches into main.
|
||||||
|
func GitSquashMergeActivity(ctx context.Context, in GitSquashMergeInput) error {
|
||||||
|
// Acquire lock to synchronize merge
|
||||||
|
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||||
|
if err := lock.Acquire(lockPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||||
|
}
|
||||||
|
defer lock.Release(lockPath)
|
||||||
|
|
||||||
|
// Fetch origin main
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "fetch", "origin", "main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout main and pull with ff-only
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "checkout", "main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git checkout main failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "pull", "--ff-only", "origin", "main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git pull failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squash merge each branch
|
||||||
|
for _, branch := range in.Branches {
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "merge", "--squash", branch)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git merge --squash %s failed: %w", branch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit squashed changes
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "commit", "-m", in.Message)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git commit failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to origin
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git push failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up worktrees and branches
|
||||||
|
for _, branch := range in.Branches {
|
||||||
|
taskID := branch[len("task/"):]
|
||||||
|
worktreePath := filepath.Join(in.RepoPath, "worktrees", taskID)
|
||||||
|
|
||||||
|
// Remove worktree
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "remove", worktreePath, "--force")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Log error but continue cleanup
|
||||||
|
fmt.Printf("warning: failed to remove worktree %s: %v\n", worktreePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete branch
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "branch", "-D", branch)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Log error but continue cleanup
|
||||||
|
fmt.Printf("warning: failed to delete branch %s: %v\n", branch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in later
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in later
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.4
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Empty stub - will be filled in T0.8
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Empty stub - will be filled in T0.8
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module github.com/rockliang/poimen/workflows
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.12.1
|
||||||
|
|
||||||
|
require go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.8
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package lock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
locks = make(map[string]*sync.Mutex)
|
||||||
|
locksMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Acquire acquires an advisory lock on a file.
|
||||||
|
// It creates the file if it doesn't exist, then uses a Go mutex for synchronization.
|
||||||
|
// This implementation is safe for single-process use; multi-process distributed locking
|
||||||
|
// would require OS-level file locking (fcntl/flock on Unix or LockFileEx on Windows).
|
||||||
|
func Acquire(path string) error {
|
||||||
|
locksMu.Lock()
|
||||||
|
defer locksMu.Unlock()
|
||||||
|
|
||||||
|
if locks[path] == nil {
|
||||||
|
locks[path] = &sync.Mutex{}
|
||||||
|
}
|
||||||
|
locks[path].Lock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release releases an advisory lock on a file.
|
||||||
|
func Release(path string) error {
|
||||||
|
locksMu.Lock()
|
||||||
|
defer locksMu.Unlock()
|
||||||
|
|
||||||
|
mu, exists := locks[path]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("lock not acquired for path: %s", path)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{.SystemPrompt}}
|
||||||
|
|
||||||
|
You are an Implementer agent. Your job is to implement the assigned task.
|
||||||
|
|
||||||
|
Task: {{.Task}}
|
||||||
|
|
||||||
|
{{if .Lessons}}
|
||||||
|
Known errors from prior attempts (do NOT repeat these):
|
||||||
|
{{.Lessons}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
Use the available tools to implement this task. Respond with a summary of the changes you made.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{.SystemPrompt}}
|
||||||
|
|
||||||
|
You are a Judge agent. Your job is to review the correctness of work done and decide if it passes or fails.
|
||||||
|
|
||||||
|
Diff of changes:
|
||||||
|
{{.Diff}}
|
||||||
|
|
||||||
|
Integration test results:
|
||||||
|
{{.TestResult}}
|
||||||
|
|
||||||
|
Do the changes look correct? Respond with "pass" or "fail" and a brief explanation.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{.SystemPrompt}}
|
||||||
|
|
||||||
|
You are a Planner agent. Your job is to reconcile task state and dispatch work.
|
||||||
|
|
||||||
|
Current board state:
|
||||||
|
{{.BoardState}}
|
||||||
|
|
||||||
|
Current configuration:
|
||||||
|
{{.Config}}
|
||||||
|
|
||||||
|
Milestone: {{.Milestone}}
|
||||||
|
|
||||||
|
What tasks should we dispatch next? Respond in JSON format with task IDs and optional timeout overrides.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package prompts
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.7
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.7
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.6
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ModelSpec defines LLM model configuration.
|
||||||
|
type ModelSpec struct {
|
||||||
|
ModelID string // e.g. "claude-opus-5", "claude-sonnet-5"
|
||||||
|
Thinking string // "adaptive" or ""
|
||||||
|
Effort string // "low", "medium", "high", "xhigh", "max"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptSpec defines a prompt template with variables and model.
|
||||||
|
type PromptSpec struct {
|
||||||
|
TemplateRef string // e.g. "planner/default.tmpl"
|
||||||
|
RawTemplate string // overrides TemplateRef if non-empty
|
||||||
|
Variables map[string]any // template variables
|
||||||
|
Model ModelSpec // which LLM to use
|
||||||
|
LessonsRef string // key into lessons store
|
||||||
|
}
|
||||||
|
|
||||||
|
// PiRetryPolicy defines retry and timeout settings for Pi command execution.
|
||||||
|
type PiRetryPolicy struct {
|
||||||
|
ScheduleToCloseTimeout time.Duration // default: 5m
|
||||||
|
InitialInterval time.Duration // default: 2s
|
||||||
|
MaximumInterval time.Duration // default: 30s
|
||||||
|
BackoffCoefficient float64 // default: 2.0
|
||||||
|
StreamTimeout time.Duration // default: 30s
|
||||||
|
StreamTimeoutMax time.Duration // default: 2m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityTuning defines timeouts and retry counts for activities.
|
||||||
|
type ActivityTuning struct {
|
||||||
|
ImplementerBaseTimeout time.Duration // default: 10m
|
||||||
|
ImplementerMaxRetries int // default: 3
|
||||||
|
JudgeTimeout time.Duration // default: 5m
|
||||||
|
PiRetry PiRetryPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrchestratorConfig holds all runtime configuration for the orchestrator.
|
||||||
|
type OrchestratorConfig struct {
|
||||||
|
SystemPrompt string // shared prompt prefix
|
||||||
|
Skills []SkillRef // required skill sources
|
||||||
|
RolePrompts map[string]PromptSpec // per-role: "planner", "judge", "implementer"
|
||||||
|
Tuning ActivityTuning
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrchestratorInput is the input to the Orchestrator workflow.
|
||||||
|
type OrchestratorInput struct {
|
||||||
|
TargetRepoPath string
|
||||||
|
RemoteURL string
|
||||||
|
Milestone string // e.g. "T0"
|
||||||
|
Config OrchestratorConfig
|
||||||
|
DryRun bool
|
||||||
|
CycleCount int
|
||||||
|
MaxCyclesBeforeCAN int // default: 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrchestratorOutput is the output of the Orchestrator workflow.
|
||||||
|
type OrchestratorOutput struct {
|
||||||
|
MilestoneComplete bool
|
||||||
|
Done bool
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskUnitInput is the input to the TaskUnit workflow.
|
||||||
|
type TaskUnitInput struct {
|
||||||
|
TaskID string
|
||||||
|
TargetRepoPath string
|
||||||
|
JudgeSpec PromptSpec
|
||||||
|
ImplementerSpec PromptSpec
|
||||||
|
BaseTimeout time.Duration
|
||||||
|
MaxJudgeRetries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskUnitOutput is the output of the TaskUnit workflow.
|
||||||
|
type TaskUnitOutput struct {
|
||||||
|
TaskID string
|
||||||
|
Verdict string // "pass" or "fail"
|
||||||
|
Critique string
|
||||||
|
Branch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillRef references a skill source.
|
||||||
|
type SkillRef struct {
|
||||||
|
Name string // skill identifier
|
||||||
|
URL string // source to clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values for types.
|
||||||
|
const (
|
||||||
|
defaultScheduleToCloseTimeout = 5 * time.Minute
|
||||||
|
defaultInitialInterval = 2 * time.Second
|
||||||
|
defaultMaximumInterval = 30 * time.Second
|
||||||
|
defaultBackoffCoefficient = 2.0
|
||||||
|
defaultStreamTimeout = 30 * time.Second
|
||||||
|
defaultStreamTimeoutMax = 2 * time.Minute
|
||||||
|
defaultImplementerBaseTimeout = 10 * time.Minute
|
||||||
|
defaultImplementerMaxRetries = 3
|
||||||
|
defaultJudgeTimeout = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPiRetryPolicy returns a PiRetryPolicy with defaults.
|
||||||
|
func NewPiRetryPolicy() PiRetryPolicy {
|
||||||
|
return PiRetryPolicy{
|
||||||
|
ScheduleToCloseTimeout: defaultScheduleToCloseTimeout,
|
||||||
|
InitialInterval: defaultInitialInterval,
|
||||||
|
MaximumInterval: defaultMaximumInterval,
|
||||||
|
BackoffCoefficient: defaultBackoffCoefficient,
|
||||||
|
StreamTimeout: defaultStreamTimeout,
|
||||||
|
StreamTimeoutMax: defaultStreamTimeoutMax,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewActivityTuning returns an ActivityTuning with defaults.
|
||||||
|
func NewActivityTuning() ActivityTuning {
|
||||||
|
return ActivityTuning{
|
||||||
|
ImplementerBaseTimeout: defaultImplementerBaseTimeout,
|
||||||
|
ImplementerMaxRetries: defaultImplementerMaxRetries,
|
||||||
|
JudgeTimeout: defaultJudgeTimeout,
|
||||||
|
PiRetry: NewPiRetryPolicy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/rockliang/poimen/workflows/action"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGitCloneAndFetch(t *testing.T) {
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(tmpDir, "source")
|
||||||
|
targetDir := filepath.Join(tmpDir, "target")
|
||||||
|
|
||||||
|
// Initialize source repo
|
||||||
|
if err := os.MkdirAll(sourceDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create source dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "init", sourceDir)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git init failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
exec.Command("git", "-C", sourceDir, "config", "user.email", "[email protected]").Run()
|
||||||
|
exec.Command("git", "-C", sourceDir, "config", "user.name", "Test User").Run()
|
||||||
|
|
||||||
|
// Create initial commit
|
||||||
|
testFile := filepath.Join(sourceDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", sourceDir, "add", "test.txt")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git add failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", sourceDir, "commit", "-m", "initial commit")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git commit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test clone into empty path
|
||||||
|
ctx := context.Background()
|
||||||
|
err := action.CloneRepoActivity(ctx, action.CloneRepoInput{
|
||||||
|
RemoteURL: sourceDir,
|
||||||
|
TargetRepoPath: targetDir,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "clone should succeed")
|
||||||
|
|
||||||
|
// Verify .git exists
|
||||||
|
gitDir := filepath.Join(targetDir, ".git")
|
||||||
|
_, err = os.Stat(gitDir)
|
||||||
|
assert.NoError(t, err, ".git directory should exist")
|
||||||
|
|
||||||
|
// Verify test.txt was cloned
|
||||||
|
targetTestFile := filepath.Join(targetDir, "test.txt")
|
||||||
|
_, err = os.Stat(targetTestFile)
|
||||||
|
assert.NoError(t, err, "test.txt should exist in target")
|
||||||
|
|
||||||
|
// Add another commit to source
|
||||||
|
testFile2 := filepath.Join(sourceDir, "test2.txt")
|
||||||
|
if err := os.WriteFile(testFile2, []byte("test content 2"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test2 file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", sourceDir, "add", "test2.txt")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git add failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", sourceDir, "commit", "-m", "second commit")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git commit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test fetch on existing repo
|
||||||
|
err = action.CloneRepoActivity(ctx, action.CloneRepoInput{
|
||||||
|
RemoteURL: sourceDir,
|
||||||
|
TargetRepoPath: targetDir,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "fetch should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitWorktreeAdd(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
repoDir := filepath.Join(tmpDir, "repo")
|
||||||
|
|
||||||
|
// Initialize repo
|
||||||
|
if err := os.MkdirAll(repoDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create repo dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "init", repoDir)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git init failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
exec.Command("git", "-C", repoDir, "config", "user.email", "[email protected]").Run()
|
||||||
|
exec.Command("git", "-C", repoDir, "config", "user.name", "Test User").Run()
|
||||||
|
|
||||||
|
// Create initial commit
|
||||||
|
testFile := filepath.Join(repoDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", repoDir, "add", "test.txt")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git add failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "initial")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git commit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test worktree add
|
||||||
|
ctx := context.Background()
|
||||||
|
worktreePath, err := action.GitWorktreeAddActivity(ctx, action.GitWorktreeAddInput{
|
||||||
|
RepoPath: repoDir,
|
||||||
|
TaskID: "T0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "worktree add should succeed")
|
||||||
|
assert.NotEmpty(t, worktreePath, "worktree path should not be empty")
|
||||||
|
|
||||||
|
// Verify worktree directory exists
|
||||||
|
_, err = os.Stat(worktreePath)
|
||||||
|
assert.NoError(t, err, "worktree directory should exist")
|
||||||
|
|
||||||
|
// Verify branch exists
|
||||||
|
cmd = exec.Command("git", "-C", repoDir, "branch", "--list", "task/T0.1")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
assert.NoError(t, err, "git branch check should succeed")
|
||||||
|
assert.Contains(t, string(output), "task/T0.1", "task/T0.1 branch should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCommit(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
repoDir := filepath.Join(tmpDir, "repo")
|
||||||
|
|
||||||
|
// Initialize repo
|
||||||
|
if err := os.MkdirAll(repoDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create repo dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "init", repoDir)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git init failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
exec.Command("git", "-C", repoDir, "config", "user.email", "[email protected]").Run()
|
||||||
|
exec.Command("git", "-C", repoDir, "config", "user.name", "Test User").Run()
|
||||||
|
|
||||||
|
// Create initial commit
|
||||||
|
testFile := filepath.Join(repoDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", repoDir, "add", "test.txt")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git add failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "initial")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("git commit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a worktree
|
||||||
|
ctx := context.Background()
|
||||||
|
worktreePath, err := action.GitWorktreeAddActivity(ctx, action.GitWorktreeAddInput{
|
||||||
|
RepoPath: repoDir,
|
||||||
|
TaskID: "T0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a new file in the worktree
|
||||||
|
newFile := filepath.Join(worktreePath, "new.txt")
|
||||||
|
if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create new file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit changes
|
||||||
|
err = action.GitCommitActivity(ctx, action.GitCommitInput{
|
||||||
|
WorktreePath: worktreePath,
|
||||||
|
Message: "Add new file",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "commit should succeed")
|
||||||
|
|
||||||
|
// Verify commit exists
|
||||||
|
cmd = exec.Command("git", "-C", worktreePath, "log", "--oneline")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(output), "Add new file", "commit message should be in log")
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
new content
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.7
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
// Empty stub - will be filled in T0.6
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/rockliang/poimen/workflows/statemachine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTypesDefaults(t *testing.T) {
|
||||||
|
// Test PiRetryPolicy defaults
|
||||||
|
pr := statemachine.NewPiRetryPolicy()
|
||||||
|
assert.Equal(t, 5*time.Minute, pr.ScheduleToCloseTimeout, "ScheduleToCloseTimeout should be 5m")
|
||||||
|
assert.Equal(t, 2*time.Second, pr.InitialInterval, "InitialInterval should be 2s")
|
||||||
|
assert.Equal(t, 30*time.Second, pr.MaximumInterval, "MaximumInterval should be 30s")
|
||||||
|
assert.Equal(t, 2.0, pr.BackoffCoefficient, "BackoffCoefficient should be 2.0")
|
||||||
|
assert.Equal(t, 30*time.Second, pr.StreamTimeout, "StreamTimeout should be 30s")
|
||||||
|
assert.Equal(t, 2*time.Minute, pr.StreamTimeoutMax, "StreamTimeoutMax should be 2m")
|
||||||
|
|
||||||
|
// Test ActivityTuning defaults
|
||||||
|
at := statemachine.NewActivityTuning()
|
||||||
|
assert.Equal(t, 10*time.Minute, at.ImplementerBaseTimeout, "ImplementerBaseTimeout should be 10m")
|
||||||
|
assert.Equal(t, 3, at.ImplementerMaxRetries, "ImplementerMaxRetries should be 3")
|
||||||
|
assert.Equal(t, 5*time.Minute, at.JudgeTimeout, "JudgeTimeout should be 5m")
|
||||||
|
|
||||||
|
// Test nested PiRetry in ActivityTuning
|
||||||
|
assert.Equal(t, 5*time.Minute, at.PiRetry.ScheduleToCloseTimeout)
|
||||||
|
assert.Equal(t, 30*time.Second, at.PiRetry.StreamTimeout)
|
||||||
|
assert.Equal(t, 2*time.Minute, at.PiRetry.StreamTimeoutMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelSpec(t *testing.T) {
|
||||||
|
spec := statemachine.ModelSpec{
|
||||||
|
ModelID: "claude-opus-5",
|
||||||
|
Thinking: "adaptive",
|
||||||
|
Effort: "high",
|
||||||
|
}
|
||||||
|
assert.Equal(t, "claude-opus-5", spec.ModelID)
|
||||||
|
assert.Equal(t, "adaptive", spec.Thinking)
|
||||||
|
assert.Equal(t, "high", spec.Effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptSpec(t *testing.T) {
|
||||||
|
spec := statemachine.PromptSpec{
|
||||||
|
TemplateRef: "planner/default.tmpl",
|
||||||
|
RawTemplate: "",
|
||||||
|
Variables: map[string]any{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
Model: statemachine.ModelSpec{
|
||||||
|
ModelID: "claude-opus-5",
|
||||||
|
},
|
||||||
|
LessonsRef: "T0.1",
|
||||||
|
}
|
||||||
|
assert.Equal(t, "planner/default.tmpl", spec.TemplateRef)
|
||||||
|
assert.Empty(t, spec.RawTemplate)
|
||||||
|
assert.Equal(t, "value", spec.Variables["key"])
|
||||||
|
assert.Equal(t, "claude-opus-5", spec.Model.ModelID)
|
||||||
|
assert.Equal(t, "T0.1", spec.LessonsRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrchestratorConfig(t *testing.T) {
|
||||||
|
cfg := statemachine.OrchestratorConfig{
|
||||||
|
SystemPrompt: "You are an expert",
|
||||||
|
Skills: []statemachine.SkillRef{
|
||||||
|
{Name: "golang-skills", URL: "https://example.com/skill1"},
|
||||||
|
},
|
||||||
|
RolePrompts: map[string]statemachine.PromptSpec{
|
||||||
|
"planner": {
|
||||||
|
TemplateRef: "planner/default.tmpl",
|
||||||
|
Model: statemachine.ModelSpec{ModelID: "claude-opus-5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tuning: statemachine.NewActivityTuning(),
|
||||||
|
}
|
||||||
|
assert.Equal(t, "You are an expert", cfg.SystemPrompt)
|
||||||
|
assert.Len(t, cfg.Skills, 1)
|
||||||
|
assert.NotEmpty(t, cfg.RolePrompts)
|
||||||
|
assert.Equal(t, 10*time.Minute, cfg.Tuning.ImplementerBaseTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskUnitInput(t *testing.T) {
|
||||||
|
input := statemachine.TaskUnitInput{
|
||||||
|
TaskID: "T0.1",
|
||||||
|
TargetRepoPath: "/tmp/repo",
|
||||||
|
BaseTimeout: 10 * time.Minute,
|
||||||
|
MaxJudgeRetries: 3,
|
||||||
|
}
|
||||||
|
assert.Equal(t, "T0.1", input.TaskID)
|
||||||
|
assert.Equal(t, "/tmp/repo", input.TargetRepoPath)
|
||||||
|
assert.Equal(t, 10*time.Minute, input.BaseTimeout)
|
||||||
|
assert.Equal(t, 3, input.MaxJudgeRetries)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user