diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index 817d90d..a4a9c7c 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "os/user" + "sort" "strconv" "strings" "syscall" @@ -60,9 +61,12 @@ func startAsync(ctx context.Context, fn func()) bool { } // runWorkflow executes a workflow script in the background. -func runWorkflow(s *session.Session, workflow string) { +// If variant is non-empty and not "default", the matching conf sidecar is +// sourced as environment variables before the script runs. +func runWorkflow(s *session.Session, workflow, variant string) { startAsync(s.Ctx, func() { - scriptPath := util.CfgDir() + "/workflows/" + workflow + workflowDir := util.CfgDir() + "/workflows/" + scriptPath := workflowDir + workflow if _, err := os.Stat(scriptPath); err != nil { s.SetGoalStatus("error: workflow not found: " + workflow) return @@ -74,6 +78,24 @@ func runWorkflow(s *session.Session, workflow string) { "OLLIE_SESSION_NAME="+s.Name(), "OLLIE_CWD="+cwd, ) + // Source variant conf if specified. + if variant != "" && variant != "default" { + confPath := workflowDir + workflow + "-" + variant + ".conf" + confData, err := os.ReadFile(confPath) + if err != nil { + s.SetGoalStatus("error: variant conf not found: " + workflow + "-" + variant + ".conf") + return + } + for _, line := range strings.Split(string(confData), "\n") { + line = strings.TrimSpace(line) + if line == "" || line[0] == '#' { + continue + } + if _, _, ok := strings.Cut(line, "="); ok { + cmd.Env = append(cmd.Env, line) + } + } + } cmd.Dir = cwd out, err := cmd.CombinedOutput() if err != nil { @@ -146,19 +168,52 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { }), ), virtfs.FileNode("workflows", 0444, - virtfs.Doc("Available workflows, one per line"), + virtfs.Doc("Available workflows with variants. Format: name\\tdefault,variant1,variant2"), virtfs.Read(func() ([]byte, error) { dir := util.CfgDir() + "/workflows" entries, err := os.ReadDir(dir) if err != nil { return nil, nil } - var sb strings.Builder + // Collect executable workflow scripts (no extension). + workflows := make(map[string][]string) for _, e := range entries { if e.IsDir() { continue } - sb.WriteString(e.Name()) + name := e.Name() + if strings.Contains(name, ".") { + continue // skip .conf files + } + workflows[name] = []string{"default"} + } + // Find variant confs: {name}-{variant}.conf + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".conf") { + continue + } + base := strings.TrimSuffix(name, ".conf") + // Find the longest workflow name that is a prefix. + for wf := range workflows { + if strings.HasPrefix(base, wf+"-") { + variant := strings.TrimPrefix(base, wf+"-") + if variant != "" { + workflows[wf] = append(workflows[wf], variant) + } + } + } + } + var sb strings.Builder + names := make([]string, 0, len(workflows)) + for wf := range workflows { + names = append(names, wf) + } + sort.Strings(names) + for _, wf := range names { + sb.WriteString(wf) + sb.WriteByte('\t') + sb.WriteString(strings.Join(workflows[wf], ",")) sb.WriteByte('\n') } return []byte(sb.String()), nil @@ -256,7 +311,7 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { virtfs.Doc("Create session"), virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { args := strings.Fields(string(data)) - name, remote, workflow, cwd := "", "", "", "" + name, remote, workflow, variant, cwd := "", "", "", "", "" yolo := false for _, arg := range args { if k, v, ok := strings.Cut(arg, "="); ok { @@ -267,6 +322,8 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { remote = v case "workflow": workflow = v + case "variant": + variant = v case "cwd": cwd = v case "yolo": @@ -285,6 +342,9 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { if workflow != "" { sess.SetWorkflow(workflow) } + if variant != "" { + sess.SetVariant(variant) + } return []byte(sess.Name() + "\n"), nil }), ), @@ -375,7 +435,7 @@ func buildSessionChildren( s.SetGoal(input) // Trigger workflow only if not already running. if status == "" || status == "complete" || status == "blocked" || strings.HasPrefix(status, "error") { - runWorkflow(s, s.Workflow()) + runWorkflow(s, s.Workflow(), s.Variant()) } return nil }), @@ -454,10 +514,14 @@ func buildSessionChildren( }, "run": func(args []string) ([]byte, error) { workflow := s.Workflow() + variant := s.Variant() if len(args) > 0 { workflow = args[0] } - runWorkflow(s, workflow) + if len(args) > 1 { + variant = args[1] + } + runWorkflow(s, workflow, variant) return []byte("ok\n"), nil }, }, data) diff --git a/cmd/olliesrv/internal/session/persist.go b/cmd/olliesrv/internal/session/persist.go index e055bcd..5659551 100644 --- a/cmd/olliesrv/internal/session/persist.go +++ b/cmd/olliesrv/internal/session/persist.go @@ -24,6 +24,7 @@ type PersistedSession struct { Yolo bool `json:"yolo,omitempty"` Paused bool `json:"paused,omitempty"` Workflow string `json:"workflow,omitempty"` + Variant string `json:"variant,omitempty"` Goal string `json:"goal,omitempty"` GoalStatus string `json:"goalStatus,omitempty"` Agents []PersistedAgent `json:"agents"` @@ -75,6 +76,7 @@ func PersistSession(name string) error { Yolo: sess.Yolo, Paused: sess.IsPaused(), Workflow: sess.Workflow(), + Variant: sess.Variant(), Goal: goalText, GoalStatus: goalStatus, } @@ -206,6 +208,9 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { if ps.Workflow != "" { sess.SetWorkflow(ps.Workflow) } + if ps.Variant != "" { + sess.SetVariant(ps.Variant) + } if ps.Goal != "" { sess.SetGoal(ps.Goal) } diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index 662e2bb..34da95c 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -51,6 +51,7 @@ type Session struct { goalSignalMu sync.Mutex goalSignalCh chan struct{} workflow string // workflow profile name (default: "conductor") + variant string // workflow variant (empty = "default") // Autosave saveMu sync.Mutex @@ -274,6 +275,21 @@ func (s *Session) SetWorkflow(w string) { s.mu.Unlock() } +// Variant returns the workflow variant name (empty means "default"). +func (s *Session) Variant() string { + s.mu.RLock() + v := s.variant + s.mu.RUnlock() + return v +} + +// SetVariant sets the workflow variant name. +func (s *Session) SetVariant(v string) { + s.mu.Lock() + s.variant = v + s.mu.Unlock() +} + // FindAgent returns the agent matching the given name or ID, or nil. func (s *Session) FindAgent(nameOrID string) *agent.Agent { for _, ag := range s.agents { diff --git a/data/workflows/review b/data/workflows/review index 063b7ef..6d80283 100755 --- a/data/workflows/review +++ b/data/workflows/review @@ -8,17 +8,19 @@ set -euo pipefail SESSION_NAME="$OLLIE_SESSION_NAME" CWD="$OLLIE_CWD" MAX_ROUNDS=${OLLIE_REFINE_MAX:-3} +AUTHOR_PROFILE=${AUTHOR_PROFILE:-author} +REVIEWER_PROFILE=${REVIEWER_PROFILE:-reviewer} # Set status to running. echo "running" | ollie-9p write "session/$SESSION_NAME/goalstatus" # Create the author agent. -AUTHOR=$(printf 'profile=author\ncwd=%s\nname=author\n' "$CWD" \ +AUTHOR=$(printf 'profile=%s\ncwd=%s\nname=author\n' "$AUTHOR_PROFILE" "$CWD" \ | ollie-9p rdwr "session/$SESSION_NAME/agent/new") AUTHOR=$(echo "$AUTHOR" | tr -d '\n') # Create the reviewer agent. -REVIEWER=$(printf 'profile=reviewer\ncwd=%s\nname=reviewer\n' "$CWD" \ +REVIEWER=$(printf 'profile=%s\ncwd=%s\nname=reviewer\n' "$REVIEWER_PROFILE" "$CWD" \ | ollie-9p rdwr "session/$SESSION_NAME/agent/new") REVIEWER=$(echo "$REVIEWER" | tr -d '\n') diff --git a/data/workflows/review-code.conf b/data/workflows/review-code.conf new file mode 100644 index 0000000..0fbe1d9 --- /dev/null +++ b/data/workflows/review-code.conf @@ -0,0 +1,5 @@ +# Variant: code +# Optimized for code review cycles — more iterations, code-focused profiles. +OLLIE_REFINE_MAX=5 +AUTHOR_PROFILE=author +REVIEWER_PROFILE=reviewer diff --git a/data/workflows/review-writing.conf b/data/workflows/review-writing.conf new file mode 100644 index 0000000..0a91344 --- /dev/null +++ b/data/workflows/review-writing.conf @@ -0,0 +1,5 @@ +# Variant: writing +# Optimized for document drafting — fewer iterations, writing-focused profiles. +OLLIE_REFINE_MAX=3 +AUTHOR_PROFILE=author +REVIEWER_PROFILE=reviewer