mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 21:30:46 +02:00
Implements the Agentflow feature end-to-end: **Database** (migration 032): - `agentflow` table (title, description/prompt, agent, status, concurrency policy) - `agentflow_trigger` table (schedule/webhook/api with cron + timezone) - `agentflow_run` table (execution history with status tracking) - Extends `agent_task_queue` with nullable `issue_id` and `agentflow_run_id` **Backend**: - CRUD API for agentflows, triggers, and runs - Server-side scheduler goroutine (30s polling, CAS-safe trigger claiming) - Cron expression parsing via robfig/cron/v3 with timezone support - Concurrency policies: skip_if_active, coalesce, always_run - Manual trigger endpoint (POST /agentflows/:id/run) - Task service extended with EnqueueTaskForAgentflow() - Broadcast methods handle nullable issue_id for agentflow tasks **Daemon**: - Task type extended with agentflow context (title, description, run_id) - BuildPrompt generates agentflow-specific prompts - Execution environment writes agentflow context to CLAUDE.md - Claim response includes agentflow data for workspace/repo resolution **Frontend**: - Sidebar entry (Agentflows with Zap icon) - List + detail page with triggers and run history tabs - Create dialog with agent selection, cron, timezone, concurrency policy - Pause/activate toggle and manual run button - Zustand store for agentflow state management - TypeScript types and API client methods Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
5.9 KiB
Go
174 lines
5.9 KiB
Go
// Package execenv manages isolated per-task execution environments for the daemon.
|
|
// Each task gets its own directory with injected context files. Repositories are
|
|
// checked out on demand by the agent via `multica repo checkout`.
|
|
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// RepoContextForEnv describes a workspace repo available for checkout.
|
|
type RepoContextForEnv struct {
|
|
URL string // remote URL
|
|
Description string // human-readable description
|
|
}
|
|
|
|
// PrepareParams holds all inputs needed to set up an execution environment.
|
|
type PrepareParams struct {
|
|
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
|
|
WorkspaceID string // workspace UUID — tasks are grouped under this
|
|
TaskID string // task UUID — used for directory name
|
|
AgentName string // for git branch naming only
|
|
Provider string // agent provider ("claude", "codex") — determines skill injection paths
|
|
Task TaskContextForEnv // context data for writing files
|
|
}
|
|
|
|
// TaskContextForEnv is the subset of task context used for writing context files.
|
|
type TaskContextForEnv struct {
|
|
IssueID string
|
|
TriggerCommentID string // comment that triggered this task (empty for on_assign)
|
|
AgentName string
|
|
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
|
|
AgentSkills []SkillContextForEnv
|
|
Repos []RepoContextForEnv // workspace repos available for checkout
|
|
|
|
// Agentflow fields (set when task is triggered by an agentflow)
|
|
IsAgentflow bool
|
|
AgentflowTitle string
|
|
AgentflowDescription string // prompt template
|
|
}
|
|
|
|
// SkillContextForEnv represents a skill to be written into the execution environment.
|
|
type SkillContextForEnv struct {
|
|
Name string
|
|
Content string
|
|
Files []SkillFileContextForEnv
|
|
}
|
|
|
|
// SkillFileContextForEnv represents a supporting file within a skill.
|
|
type SkillFileContextForEnv struct {
|
|
Path string
|
|
Content string
|
|
}
|
|
|
|
// Environment represents a prepared, isolated execution environment.
|
|
type Environment struct {
|
|
// RootDir is the top-level env directory ({workspacesRoot}/{task_id_short}/).
|
|
RootDir string
|
|
// WorkDir is the directory to pass as Cwd to the agent ({RootDir}/workdir/).
|
|
WorkDir string
|
|
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
|
|
CodexHome string
|
|
|
|
logger *slog.Logger // for cleanup logging
|
|
}
|
|
|
|
// Prepare creates an isolated execution environment for a task.
|
|
// The workdir starts empty (no repo checkouts). The agent checks out repos
|
|
// on demand via `multica repo checkout <url>`.
|
|
func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
|
if params.WorkspacesRoot == "" {
|
|
return nil, fmt.Errorf("execenv: workspaces root is required")
|
|
}
|
|
if params.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("execenv: workspace ID is required")
|
|
}
|
|
if params.TaskID == "" {
|
|
return nil, fmt.Errorf("execenv: task ID is required")
|
|
}
|
|
|
|
envRoot := filepath.Join(params.WorkspacesRoot, params.WorkspaceID, shortID(params.TaskID))
|
|
|
|
// Remove existing env if present (defensive — task IDs are unique).
|
|
if _, err := os.Stat(envRoot); err == nil {
|
|
if err := os.RemoveAll(envRoot); err != nil {
|
|
return nil, fmt.Errorf("execenv: remove existing env: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create directory tree.
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
for _, dir := range []string{workDir, filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("execenv: create directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
env := &Environment{
|
|
RootDir: envRoot,
|
|
WorkDir: workDir,
|
|
logger: logger,
|
|
}
|
|
|
|
// Write context files into workdir (skills go to provider-native paths).
|
|
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
|
|
return nil, fmt.Errorf("execenv: write context files: %w", err)
|
|
}
|
|
|
|
// For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills.
|
|
if params.Provider == "codex" {
|
|
codexHome := filepath.Join(envRoot, "codex-home")
|
|
if err := prepareCodexHome(codexHome, logger); err != nil {
|
|
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
|
|
}
|
|
if len(params.Task.AgentSkills) > 0 {
|
|
if err := writeSkillFiles(filepath.Join(codexHome, "skills"), params.Task.AgentSkills); err != nil {
|
|
return nil, fmt.Errorf("execenv: write codex skills: %w", err)
|
|
}
|
|
}
|
|
env.CodexHome = codexHome
|
|
}
|
|
|
|
logger.Info("execenv: prepared env", "root", envRoot, "repos_available", len(params.Task.Repos))
|
|
return env, nil
|
|
}
|
|
|
|
// Reuse wraps an existing workdir into an Environment and refreshes context files.
|
|
// Returns nil if the workdir does not exist (caller should fall back to Prepare).
|
|
func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger) *Environment {
|
|
if _, err := os.Stat(workDir); err != nil {
|
|
return nil
|
|
}
|
|
|
|
env := &Environment{
|
|
RootDir: filepath.Dir(workDir),
|
|
WorkDir: workDir,
|
|
logger: logger,
|
|
}
|
|
|
|
// Refresh context files (issue_context.md, skills).
|
|
if err := writeContextFiles(workDir, provider, task); err != nil {
|
|
logger.Warn("execenv: refresh context files failed", "error", err)
|
|
}
|
|
|
|
logger.Info("execenv: reusing env", "workdir", workDir)
|
|
return env
|
|
}
|
|
|
|
// Cleanup tears down the execution environment.
|
|
// If removeAll is true, the entire env root is deleted. Otherwise, workdir is
|
|
// removed but output/ and logs/ are preserved for debugging.
|
|
func (env *Environment) Cleanup(removeAll bool) error {
|
|
if env == nil {
|
|
return nil
|
|
}
|
|
|
|
if removeAll {
|
|
if err := os.RemoveAll(env.RootDir); err != nil {
|
|
env.logger.Warn("execenv: cleanup removeAll failed", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Partial cleanup: remove workdir, keep output/ and logs/.
|
|
if err := os.RemoveAll(env.WorkDir); err != nil {
|
|
env.logger.Warn("execenv: cleanup workdir failed", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|