diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index e16b073..c107c62 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -74,13 +74,37 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Claude Code exposes -// none yet. +// permissionConfigEnum lists the permission modes the "permissions" config key +// accepts. It mirrors the ports.PermissionMode constants so a project's stored +// config validates against the same vocabulary the launch command maps. +var permissionConfigEnum = []string{ + string(ports.PermissionModeDefault), + string(ports.PermissionModeAcceptEdits), + string(ports.PermissionModeAuto), + string(ports.PermissionModeBypassPermissions), +} + +// GetConfigSpec reports the per-project agent config keys Claude Code +// understands: a model override and a starting permission mode. func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { if err := ctx.Err(); err != nil { return ports.ConfigSpec{}, err } - return ports.ConfigSpec{}, nil + return ports.ConfigSpec{ + Fields: []ports.ConfigField{ + { + Key: "model", + Type: ports.ConfigFieldString, + Description: "Model override passed to `claude --model` (e.g. claude-opus-4-5).", + }, + { + Key: "permissions", + Type: ports.ConfigFieldEnum, + Description: "Starting permission mode.", + Enum: permissionConfigEnum, + }, + }, + }, nil } // GetLaunchCommand builds the argv to start an interactive Claude Code @@ -103,6 +127,12 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // The prompt is passed after `--` so a prompt beginning with "-" is not // mistaken for a flag. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + // Defense-in-depth: the project service validates on write, but re-check + // here so a config written by any other path can't launch a bad command. + if err := cfg.Config.Validate(); err != nil { + return nil, fmt.Errorf("claude-code: %w", err) + } + binary, err := p.claudeBinary(ctx) if err != nil { return nil, err @@ -112,7 +142,18 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( if cfg.SessionID != "" { cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID)) } - appendPermissionFlags(&cmd, cfg.Permissions) + // A project's configured permissions drive the starting mode; the explicit + // LaunchConfig.Permissions wins when set so a per-spawn override still takes + // precedence over the stored project default. + permissions := cfg.Permissions + if permissions == "" { + permissions = cfg.Config.Permissions + } + appendPermissionFlags(&cmd, permissions) + + if model := strings.TrimSpace(cfg.Config.Model); model != "" { + cmd = append(cmd, "--model", model) + } systemPrompt, err := resolveSystemPrompt(cfg) if err != nil { diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index bdaf346..6cddab2 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -422,6 +422,48 @@ func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) { } } +func TestGetLaunchCommandAppliesAgentConfig(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Config: ports.AgentConfig{ + Model: "claude-opus-4-5", + Permissions: ports.PermissionModeAcceptEdits, + }, + }) + if err != nil { + t.Fatal(err) + } + if !containsSubsequence(cmd, []string{"--model", "claude-opus-4-5"}) { + t.Fatalf("command %#v missing --model flag", cmd) + } + if !containsSubsequence(cmd, []string{"--permission-mode", "acceptEdits"}) { + t.Fatalf("command %#v missing config-driven permission mode", cmd) + } +} + +func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Config: ports.AgentConfig{Permissions: ports.PermissionModeAcceptEdits}, + }) + if err != nil { + t.Fatal(err) + } + if !containsSubsequence(cmd, []string{"--permission-mode", "bypassPermissions"}) { + t.Fatalf("explicit Permissions should win; got %#v", cmd) + } +} + +func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Config: ports.AgentConfig{Permissions: "yolo"}, + }); err == nil { + t.Fatal("expected error for invalid permission mode") + } +} + func TestManifestID(t *testing.T) { if got := New().Manifest().ID; got != "claude-code" { t.Fatalf("manifest id = %q, want claude-code", got) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 1e8e6c6..2ec0130 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -15,7 +15,9 @@ import ( const ( defaultGitBinary = "git" - defaultBranch = "main" + // defaultBranch is the base branch used when neither the per-project config + // nor the adapter options name one. It shares domain's single source of truth. + defaultBranch = domain.DefaultBranchName ) // ErrUnsafePath is returned when a resolved worktree path escapes the managed @@ -122,7 +124,7 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port if err != nil { return ports.WorkspaceInfo{}, err } - if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil { return ports.WorkspaceInfo{}, err } return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil @@ -198,13 +200,13 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { return ports.WorkspaceInfo{}, err } - if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil { return ports.WorkspaceInfo{}, err } return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } -func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) error { +func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error { // Refuse early if the branch is already checked out in another worktree: // `git worktree add` will fail, but its stderr leaks through as an opaque // 500. A typed sentinel lets the HTTP layer surface a 409. @@ -233,7 +235,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) // neither origin/, the default branch, nor any tag is reachable, // the branch genuinely has no base — surface ErrBranchNotFetched so callers // can suggest `git fetch`. - baseRef, err := w.resolveBaseRef(ctx, repo, branch) + baseRef, err := w.resolveBaseRef(ctx, repo, branch, baseBranch) if err != nil { if errors.Is(err, errNoBaseRef) { return fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch) @@ -257,8 +259,15 @@ func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) err // addWorktree translates it into ErrBranchNotFetched. var errNoBaseRef = errors.New("gitworktree: no base ref found") -func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch string) (string, error) { - candidates := baseRefCandidates(branch, w.defaultBranch) +func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch, baseBranch string) (string, error) { + // A per-project base branch (cfg.BaseBranch) overrides the adapter default, + // so a project that branches off e.g. "develop" materialises worktrees from + // there. Empty falls back to the adapter's configured default. + defaultBranch := w.defaultBranch + if strings.TrimSpace(baseBranch) != "" { + defaultBranch = baseBranch + } + candidates := baseRefCandidates(branch, defaultBranch) for _, ref := range candidates { exists, err := w.refExists(ctx, repo, ref) if err != nil { diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index 92eafd4..4c8ddff 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -62,6 +62,12 @@ func (c *commandContext) patchJSON(ctx context.Context, path string, body, out a return c.doJSON(ctx, http.MethodPatch, path, body, out) } +// putJSON sends body as JSON to PUT /api/v1/ on the running daemon and +// decodes a 2xx response into out. +func (c *commandContext) putJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPut, path, body, out) +} + // deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a // 2xx response into out. func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 4681717..216e7c5 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -128,6 +128,11 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro return projectsvc.Project{ID: id, Path: in.Path}, nil } +func (f *fakeProjectManager) SetConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetConfigInput) (projectsvc.Project, error) { + cfg := in.Config + return projectsvc.Project{ID: id, Config: &cfg}, nil +} + func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) { return projectsvc.RemoveResult{}, nil } diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index 6a5c1fb..68fa94a 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -2,9 +2,11 @@ package cli import ( "bufio" + "encoding/json" "errors" "fmt" "net/url" + "reflect" "sort" "strings" "text/tabwriter" @@ -53,11 +55,65 @@ type projectDetails struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` DefaultHarness string `json:"agent,omitempty"` - Tracker map[string]any `json:"tracker,omitempty"` - SCM map[string]any `json:"scm,omitempty"` + Config *projectConfig `json:"config,omitempty"` ResolveError string `json:"resolveError,omitempty"` } +// agentConfig mirrors the daemon's typed domain.AgentConfig for the CLI client. +type agentConfig struct { + Model string `json:"model,omitempty"` + Permissions string `json:"permissions,omitempty"` +} + +// roleOverride mirrors domain.RoleOverride. +type roleOverride struct { + Agent string `json:"agent,omitempty"` + AgentConfig agentConfig `json:"agentConfig,omitempty"` +} + +// projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI +// client. The CLI sets common fields via flags and the whole object via +// --config-json. +type projectConfig struct { + DefaultBranch string `json:"defaultBranch,omitempty"` + SessionPrefix string `json:"sessionPrefix,omitempty"` + Env map[string]string `json:"env,omitempty"` + Symlinks []string `json:"symlinks,omitempty"` + PostCreate []string `json:"postCreate,omitempty"` + AgentRules string `json:"agentRules,omitempty"` + AgentRulesFile string `json:"agentRulesFile,omitempty"` + OrchestratorRules string `json:"orchestratorRules,omitempty"` + AgentConfig agentConfig `json:"agentConfig,omitempty"` + Worker roleOverride `json:"worker,omitempty"` + Orchestrator roleOverride `json:"orchestrator,omitempty"` + OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,omitempty"` +} + +// setConfigRequest mirrors the daemon's SetConfigInput body for +// PUT /api/v1/projects/{id}/config. +type setConfigRequest struct { + Config projectConfig `json:"config"` +} + +type projectSetConfigOptions struct { + defaultBranch string + sessionPrefix string + model string + permission string + agentRules string + agentRulesFile string + orchestratorRules string + workerAgent string + orchestratorAgent string + opencodeStrategy string + env []string + symlink []string + postCreate []string + configJSON string + clear bool + json bool +} + type projectListResult struct { Projects []projectSummary `json:"projects"` } @@ -86,6 +142,7 @@ func newProjectCommand(ctx *commandContext) *cobra.Command { cmd.AddCommand(newProjectListCommand(ctx)) cmd.AddCommand(newProjectGetCommand(ctx)) cmd.AddCommand(newProjectAddCommand(ctx)) + cmd.AddCommand(newProjectSetConfigCommand(ctx)) cmd.AddCommand(newProjectRemoveCommand(ctx)) return cmd } @@ -179,6 +236,120 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { return cmd } +func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { + var opts projectSetConfigOptions + cmd := &cobra.Command{ + Use: "set-config ", + Short: "Set the per-project config", + Long: "Replace a project's per-project config (branch, env, symlinks, " + + "post-create, rules, agent model/permissions, role overrides). The config " + + "is resolved when a session spawns.\n\n" + + "Set fields via flags, pass the whole object with --config-json, or --clear " + + "to remove all config.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: project id is required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + config, err := buildProjectConfig(opts) + if err != nil { + return err + } + req := setConfigRequest{Config: config} + var res projectResult + if err := ctx.putJSON(cmd.Context(), "projects/"+url.PathEscape(id)+"/config", req, &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "updated config for project %s\n", res.Project.ID) + return err + }, + } + f := cmd.Flags() + f.StringVar(&opts.defaultBranch, "default-branch", "", "Base branch new session worktrees are created from") + f.StringVar(&opts.sessionPrefix, "session-prefix", "", "Displayed session-id prefix") + f.StringVar(&opts.model, "model", "", "Agent model override (e.g. claude-opus-4-5)") + f.StringVar(&opts.permission, "permission", "", "Permission mode: default, accept-edits, auto, bypass-permissions") + f.StringVar(&opts.agentRules, "agent-rules", "", "Inline rules appended to every agent prompt") + f.StringVar(&opts.agentRulesFile, "agent-rules-file", "", "Path (relative to the project) to a rules file") + f.StringVar(&opts.orchestratorRules, "orchestrator-rules", "", "Inline rules appended to orchestrator prompts") + f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions") + f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions") + f.StringVar(&opts.opencodeStrategy, "opencode-strategy", "", "OpenCode issue-session strategy: reuse, delete, ignore") + f.StringArrayVar(&opts.env, "env", nil, "Env var KEY=VALUE forwarded into sessions (repeatable)") + f.StringArrayVar(&opts.symlink, "symlink", nil, "Repo-relative path to symlink into workspaces (repeatable)") + f.StringArrayVar(&opts.postCreate, "post-create", nil, "Command to run after workspace creation (repeatable)") + f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object (overrides field flags)") + f.BoolVar(&opts.clear, "clear", false, "Clear all config") + f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON") + return cmd +} + +// buildProjectConfig turns the set-config flags into the typed config sent to +// the daemon. --clear empties the config; --config-json supplies the whole +// object; otherwise the field flags form the config. The daemon validates the +// values. +func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { + if opts.clear { + return projectConfig{}, nil + } + if opts.configJSON != "" { + var cfg projectConfig + if err := json.Unmarshal([]byte(opts.configJSON), &cfg); err != nil { + return projectConfig{}, usageError{fmt.Errorf("--config-json is not a valid JSON object: %w", err)} + } + return cfg, nil + } + + env, err := parseEnvPairs(opts.env) + if err != nil { + return projectConfig{}, err + } + cfg := projectConfig{ + DefaultBranch: opts.defaultBranch, + SessionPrefix: opts.sessionPrefix, + Env: env, + Symlinks: opts.symlink, + PostCreate: opts.postCreate, + AgentRules: opts.agentRules, + AgentRulesFile: opts.agentRulesFile, + OrchestratorRules: opts.orchestratorRules, + AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission}, + Worker: roleOverride{Agent: opts.workerAgent}, + Orchestrator: roleOverride{Agent: opts.orchestratorAgent}, + OpencodeIssueSessionStrategy: opts.opencodeStrategy, + } + if reflect.DeepEqual(cfg, projectConfig{}) { + return projectConfig{}, usageError{errors.New("usage: provide at least one config flag, --config-json, or --clear")} + } + return cfg, nil +} + +// parseEnvPairs turns repeated KEY=VALUE flags into a map. +func parseEnvPairs(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + env := make(map[string]string, len(pairs)) + for _, pair := range pairs { + key, value, ok := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, usageError{fmt.Errorf("invalid --env %q: expected KEY=VALUE", pair)} + } + env[key] = value + } + return env, nil +} + func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { var opts projectRemoveOptions cmd := &cobra.Command{ @@ -270,6 +441,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { {label: "repo", value: p.Repo}, {label: "default branch", value: p.DefaultBranch}, {label: "default harness", value: p.DefaultHarness}, + {label: "config", value: formatProjectConfig(p.Config)}, {label: "resolve error", value: p.ResolveError}, } for _, f := range fields { @@ -283,6 +455,19 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { return nil } +// formatProjectConfig renders the per-project config as compact JSON for the +// `project get` text view. A nil config returns "" so the row is skipped. +func formatProjectConfig(config *projectConfig) string { + if config == nil { + return "" + } + data, err := json.Marshal(config) + if err != nil { + return "" + } + return string(data) +} + func confirmProjectRemoval(cmd *cobra.Command, id string) (bool, error) { if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Remove project %q? Type the project id to confirm: ", id); err != nil { return false, err diff --git a/backend/internal/domain/agentconfig.go b/backend/internal/domain/agentconfig.go new file mode 100644 index 0000000..19fe10a --- /dev/null +++ b/backend/internal/domain/agentconfig.go @@ -0,0 +1,48 @@ +package domain + +import "fmt" + +// PermissionMode controls how much review an agent requires before acting. It +// lives in domain (not ports) so the typed AgentConfig can carry it; ports +// re-exports it as a type alias so agent adapters keep referring to +// ports.PermissionMode unchanged. +type PermissionMode string + +// The permission modes adapters map onto their agent's native approval flags. +const ( + // PermissionModeDefault is special: adapters emit no flag for it so the + // agent resolves its starting mode from the user's own config (e.g. + // Claude's TUI reading ~/.claude/settings.json defaultMode). + PermissionModeDefault PermissionMode = "default" + PermissionModeAcceptEdits PermissionMode = "accept-edits" + PermissionModeAuto PermissionMode = "auto" + PermissionModeBypassPermissions PermissionMode = "bypass-permissions" +) + +// AgentConfig is the typed per-project agent configuration. It replaces the +// former free-form map so the fields are validated and the API/UI render a +// real form rather than arbitrary JSON. An empty value (IsZero) means unset. +type AgentConfig struct { + // Model overrides the agent's default model (e.g. claude-opus-4-5). + Model string `json:"model,omitempty"` + // Permissions sets the agent's starting permission mode. Empty defers to + // the agent's own configuration. + Permissions PermissionMode `json:"permissions,omitempty"` +} + +// IsZero reports whether the config carries no settings, so storage can persist +// SQL NULL and resolution can skip an empty config. +func (c AgentConfig) IsZero() bool { + return c == AgentConfig{} +} + +// Validate rejects values outside the typed vocabulary so a bad config is +// refused when it is set (CLI/API) rather than silently dropped at spawn. +func (c AgentConfig) Validate() error { + switch c.Permissions { + case "", PermissionModeDefault, PermissionModeAcceptEdits, PermissionModeAuto, PermissionModeBypassPermissions: + return nil + default: + return fmt.Errorf("invalid permissions %q: want one of default, accept-edits, auto, bypass-permissions", c.Permissions) + } +} diff --git a/backend/internal/domain/harness.go b/backend/internal/domain/harness.go index 41a8474..97babe6 100644 --- a/backend/internal/domain/harness.go +++ b/backend/internal/domain/harness.go @@ -29,3 +29,23 @@ const ( HarnessPi AgentHarness = "pi" HarnessAutohand AgentHarness = "autohand" ) + +// AllHarnesses lists every supported harness. It is the canonical set used to +// validate user-supplied harness names (e.g. per-project role overrides). +var AllHarnesses = []AgentHarness{ + HarnessClaudeCode, HarnessCodex, HarnessAider, HarnessOpenCode, HarnessGrok, + HarnessDroid, HarnessAmp, HarnessAgy, HarnessCrush, HarnessCursor, HarnessQwen, + HarnessCopilot, HarnessGoose, HarnessAuggie, HarnessContinue, HarnessDevin, + HarnessCline, HarnessKimi, HarnessKiro, HarnessKilocode, HarnessVibe, HarnessPi, + HarnessAutohand, +} + +// IsKnown reports whether h is one of the supported harnesses. +func (h AgentHarness) IsKnown() bool { + for _, k := range AllHarnesses { + if h == k { + return true + } + } + return false +} diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index b00e65c..28906cd 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -10,4 +10,7 @@ type ProjectRecord struct { DisplayName string RegisteredAt time.Time ArchivedAt time.Time + // Config holds the typed per-project configuration AO resolves at spawn. An + // IsZero value means unset. + Config ProjectConfig } diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go new file mode 100644 index 0000000..ecea54e --- /dev/null +++ b/backend/internal/domain/projectconfig.go @@ -0,0 +1,146 @@ +package domain + +import ( + "fmt" + "reflect" +) + +// ProjectConfig is the typed per-project configuration — the SQLite twin of the +// legacy agent-orchestrator.yaml `projects.` block. It is persisted as one +// JSON blob per project and resolved at spawn. Each field is typed and +// validated; there is no free-form map. +// +// Some fields are consumed at spawn today (DefaultBranch, Env, Symlinks, +// PostCreate, the rules, AgentConfig, and the role overrides). Others are +// persisted and validated but not yet consumed — Tracker, SCM, and +// OpencodeIssueSessionStrategy await the infrastructure that will read them, and +// SessionPrefix currently feeds only the display prefix (session-id generation +// is unchanged). +type ProjectConfig struct { + // DefaultBranch is the base branch new session worktrees are created from. + DefaultBranch string `json:"defaultBranch,omitempty"` + // SessionPrefix overrides the displayed session-id prefix. + SessionPrefix string `json:"sessionPrefix,omitempty"` + + // Env are extra environment variables forwarded into worker session + // runtimes. AO-internal vars (AO_SESSION, AO_PROJECT_ID, …) always win. + Env map[string]string `json:"env,omitempty"` + // Symlinks are repo-relative paths symlinked into each session workspace. + Symlinks []string `json:"symlinks,omitempty"` + // PostCreate are shell commands run in the workspace after it is created. + PostCreate []string `json:"postCreate,omitempty"` + + // AgentRules are inline rules appended to every agent prompt for the project. + AgentRules string `json:"agentRules,omitempty"` + // AgentRulesFile is a path (relative to the project) whose contents are + // appended to every agent prompt. + AgentRulesFile string `json:"agentRulesFile,omitempty"` + // OrchestratorRules are inline rules appended to orchestrator prompts. + OrchestratorRules string `json:"orchestratorRules,omitempty"` + + // AgentConfig is the default agent config for the project. + AgentConfig AgentConfig `json:"agentConfig,omitempty"` + // Worker and Orchestrator are role-specific harness/agent-config overrides. + Worker RoleOverride `json:"worker,omitempty"` + Orchestrator RoleOverride `json:"orchestrator,omitempty"` + + // Tracker selects and configures the project's issue tracker (not yet consumed). + Tracker TrackerConfig `json:"tracker,omitempty"` + // SCM selects and configures the project's source-control integration (not yet consumed). + SCM SCMConfig `json:"scm,omitempty"` + // OpencodeIssueSessionStrategy controls OpenCode issue-session reuse (not yet consumed). + OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,omitempty"` +} + +// RoleOverride overrides the harness and/or agent config for a session role. +type RoleOverride struct { + Harness AgentHarness `json:"agent,omitempty"` + AgentConfig AgentConfig `json:"agentConfig,omitempty"` +} + +// TrackerConfig selects and configures a project's issue tracker. +type TrackerConfig struct { + Plugin string `json:"plugin,omitempty"` + TeamID string `json:"teamId,omitempty"` +} + +// SCMConfig selects and configures a project's source-control integration. +type SCMConfig struct { + Plugin string `json:"plugin,omitempty"` + Webhook *SCMWebhookConfig `json:"webhook,omitempty"` +} + +// SCMWebhookConfig describes SCM webhook acceleration settings. +type SCMWebhookConfig struct { + Path string `json:"path,omitempty"` + SecretEnvVar string `json:"secretEnvVar,omitempty"` + SignatureHeader string `json:"signatureHeader,omitempty"` + EventHeader string `json:"eventHeader,omitempty"` + DeliveryHeader string `json:"deliveryHeader,omitempty"` + MaxBodyBytes int `json:"maxBodyBytes,omitempty"` +} + +// The OpenCode issue-session strategies. +const ( + OpencodeSessionReuse = "reuse" + OpencodeSessionDelete = "delete" + OpencodeSessionIgnore = "ignore" +) + +// Documented per-project defaults (mirrors the legacy agent-orchestrator.yaml). +const ( + DefaultBranchName = "main" // base branch when none is configured + DefaultTrackerName = "github" // issue tracker defaults to GitHub issues +) + +// DefaultProjectConfig returns the config a project has when it sets nothing: +// branch "main" and the GitHub issue tracker. Every other field defaults to its +// zero value (no env/symlinks/post-create/rules, agent + role defaults, no SCM +// webhook, no OpenCode strategy override). +func DefaultProjectConfig() ProjectConfig { + return ProjectConfig{ + DefaultBranch: DefaultBranchName, + Tracker: TrackerConfig{Plugin: DefaultTrackerName}, + } +} + +// WithDefaults overlays DefaultProjectConfig onto c, filling only fields the +// project left unset. A set field is always preserved. +func (c ProjectConfig) WithDefaults() ProjectConfig { + def := DefaultProjectConfig() + if c.DefaultBranch == "" { + c.DefaultBranch = def.DefaultBranch + } + if c.Tracker.Plugin == "" { + c.Tracker.Plugin = def.Tracker.Plugin + } + return c +} + +// IsZero reports whether the config carries no settings, so storage can persist +// SQL NULL and resolution can skip an empty config. +func (c ProjectConfig) IsZero() bool { + return reflect.DeepEqual(c, ProjectConfig{}) +} + +// Validate rejects values outside the typed vocabulary so a bad config is +// refused when it is set (CLI/API) rather than surfacing at spawn. +func (c ProjectConfig) Validate() error { + if err := c.AgentConfig.Validate(); err != nil { + return err + } + for role, ro := range map[string]RoleOverride{"worker": c.Worker, "orchestrator": c.Orchestrator} { + if ro.Harness != "" && !ro.Harness.IsKnown() { + return fmt.Errorf("%s.agent: unknown harness %q", role, ro.Harness) + } + if err := ro.AgentConfig.Validate(); err != nil { + return fmt.Errorf("%s.%w", role, err) + } + } + switch c.OpencodeIssueSessionStrategy { + case "", OpencodeSessionReuse, OpencodeSessionDelete, OpencodeSessionIgnore: + default: + return fmt.Errorf("opencodeIssueSessionStrategy: want one of reuse, delete, ignore") + } + return nil +} diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go new file mode 100644 index 0000000..df7249b --- /dev/null +++ b/backend/internal/domain/projectconfig_test.go @@ -0,0 +1,80 @@ +package domain + +import "testing" + +func TestProjectConfigValidate(t *testing.T) { + tests := []struct { + name string + cfg ProjectConfig + wantErr bool + }{ + {"empty ok", ProjectConfig{}, false}, + {"good agent config", ProjectConfig{AgentConfig: AgentConfig{Model: "m", Permissions: PermissionModeAuto}}, false}, + {"bad permission", ProjectConfig{AgentConfig: AgentConfig{Permissions: "yolo"}}, true}, + {"good role override", ProjectConfig{Worker: RoleOverride{Harness: HarnessCodex}}, false}, + {"unknown role harness", ProjectConfig{Orchestrator: RoleOverride{Harness: "nope"}}, true}, + {"bad role agent config", ProjectConfig{Worker: RoleOverride{AgentConfig: AgentConfig{Permissions: "nope"}}}, true}, + {"good opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: OpencodeSessionReuse}, false}, + {"bad opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: "sometimes"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.cfg.Validate(); (err != nil) != tt.wantErr { + t.Fatalf("Validate() err = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestDefaultProjectConfig(t *testing.T) { + def := DefaultProjectConfig() + + // The two documented non-empty defaults. + if def.DefaultBranch != "main" { + t.Fatalf("default DefaultBranch = %q, want main", def.DefaultBranch) + } + if def.Tracker.Plugin != "github" { + t.Fatalf("default tracker = %q, want github", def.Tracker.Plugin) + } + + // Every other field defaults to its zero value: clearing the two documented + // defaults must leave the config completely empty. + def.DefaultBranch = "" + def.Tracker = TrackerConfig{} + if !def.IsZero() { + t.Fatalf("default config has unexpected non-zero fields: %#v", def) + } +} + +func TestProjectConfigWithDefaults(t *testing.T) { + // An unset config gets the documented defaults. + got := (ProjectConfig{}).WithDefaults() + if got.DefaultBranch != DefaultBranchName || got.Tracker.Plugin != DefaultTrackerName { + t.Fatalf("WithDefaults = %#v, want branch=main tracker=github", got) + } + + // Set fields are preserved, not overwritten. + got = (ProjectConfig{ + DefaultBranch: "develop", + Tracker: TrackerConfig{Plugin: "linear"}, + AgentConfig: AgentConfig{Model: "m"}, + }).WithDefaults() + if got.DefaultBranch != "develop" || got.Tracker.Plugin != "linear" { + t.Fatalf("WithDefaults overwrote set fields: %#v", got) + } + if got.AgentConfig.Model != "m" { + t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig) + } +} + +func TestProjectConfigIsZero(t *testing.T) { + if !(ProjectConfig{}).IsZero() { + t.Fatal("empty config should be zero") + } + if (ProjectConfig{DefaultBranch: "main"}).IsZero() { + t.Fatal("populated config should not be zero") + } + if (ProjectConfig{Env: map[string]string{"A": "b"}}).IsZero() { + t.Fatal("config with env should not be zero") + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 12d0cde..bddd703 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -283,6 +283,51 @@ paths: summary: Fetch one project; discriminates ok vs degraded tags: - projects + /api/v1/projects/{id}/config: + put: + operationId: setProjectConfig + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SetProjectConfigInput' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Replace a project's per-project config + tags: + - projects /api/v1/prs/{id}/merge: post: operationId: mergePR @@ -916,6 +961,8 @@ components: type: object AddProjectInput: properties: + config: + $ref: '#/components/schemas/ProjectConfig' name: type: - "null" @@ -929,6 +976,13 @@ components: required: - path type: object + AgentConfig: + properties: + model: + type: string + permissions: + type: string + type: object ClaimPRRequest: properties: allowTakeover: @@ -1074,6 +1128,8 @@ components: properties: agent: type: string + config: + $ref: '#/components/schemas/ProjectConfig' defaultBranch: type: string id: @@ -1084,10 +1140,6 @@ components: type: string repo: type: string - scm: - $ref: '#/components/schemas/SCMConfig' - tracker: - $ref: '#/components/schemas/TrackerConfig' required: - id - name @@ -1095,6 +1147,43 @@ components: - repo - defaultBranch type: object + ProjectConfig: + properties: + agentConfig: + $ref: '#/components/schemas/AgentConfig' + agentRules: + type: string + agentRulesFile: + type: string + defaultBranch: + type: string + env: + additionalProperties: + type: string + type: object + opencodeIssueSessionStrategy: + type: string + orchestrator: + $ref: '#/components/schemas/RoleOverride' + orchestratorRules: + type: string + postCreate: + items: + type: string + type: array + scm: + $ref: '#/components/schemas/SCMConfig' + sessionPrefix: + type: string + symlinks: + items: + type: string + type: array + tracker: + $ref: '#/components/schemas/TrackerConfig' + worker: + $ref: '#/components/schemas/RoleOverride' + type: object ProjectGetResponse: properties: project: @@ -1189,6 +1278,13 @@ components: - sessionId - session type: object + RoleOverride: + properties: + agent: + type: string + agentConfig: + $ref: '#/components/schemas/AgentConfig' + type: object RollbackSessionResponse: properties: deleted: @@ -1205,10 +1301,6 @@ components: type: object SCMConfig: properties: - package: - type: string - path: - type: string plugin: type: string webhook: @@ -1218,10 +1310,6 @@ components: properties: deliveryHeader: type: string - enabled: - type: - - "null" - - boolean eventHeader: type: string maxBodyBytes: @@ -1358,6 +1446,13 @@ components: - sessionId - state type: object + SetProjectConfigInput: + properties: + config: + $ref: '#/components/schemas/ProjectConfig' + required: + - config + type: object SpawnOrchestratorRequest: properties: clean: @@ -1423,12 +1518,10 @@ components: type: object TrackerConfig: properties: - package: - type: string - path: - type: string plugin: type: string + teamId: + type: string type: object tags: - description: Project registry, configuration, and lifecycle administration diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index cb83ecd..40e5402 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -118,10 +118,16 @@ var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain - "DomainProjectID": "ProjectID", - "DomainSessionID": "SessionID", - "DomainIssueID": "IssueID", - "DomainSession": "Session", + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + "DomainProjectConfig": "ProjectConfig", + "DomainAgentConfig": "AgentConfig", + "DomainRoleOverride": "RoleOverride", + "DomainTrackerConfig": "TrackerConfig", + "DomainSCMConfig": "SCMConfig", + "DomainSCMWebhookConfig": "SCMWebhookConfig", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", @@ -154,14 +160,12 @@ var schemaNames = map[string]string{ "ControllersResolveCommentsRequest": "ResolveCommentsRequest", "ControllersResolveCommentsResponse": "ResolveCommentsResponse", // service/project entities + DTOs - "ProjectProject": "Project", - "ProjectSummary": "ProjectSummary", - "ProjectDegraded": "DegradedProject", - "ProjectAddInput": "AddProjectInput", - "ProjectRemoveResult": "RemoveProjectResult", - "ProjectTrackerConfig": "TrackerConfig", - "ProjectSCMConfig": "SCMConfig", - "ProjectSCMWebhookConfig": "SCMWebhookConfig", + "ProjectProject": "Project", + "ProjectSummary": "ProjectSummary", + "ProjectDegraded": "DegradedProject", + "ProjectAddInput": "AddProjectInput", + "ProjectRemoveResult": "RemoveProjectResult", + "ProjectSetConfigInput": "SetProjectConfigInput", } // markRequestBodyRequired sets requestBody.required: true on the operation's @@ -297,6 +301,18 @@ func projectOperations() []operation { {http.StatusInternalServerError, envelope.APIError{}}, }, }, + { + method: http.MethodPut, path: "/api/v1/projects/{id}/config", id: "setProjectConfig", tag: "projects", + summary: "Replace a project's per-project config", + pathParams: []any{controllers.ProjectIDParam{}}, + reqBody: projectsvc.SetConfigInput{}, + resps: []respUnit{ + {http.StatusOK, controllers.ProjectResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, { method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects", summary: "Remove a project; stops sessions, cleans workspaces, unregisters", diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index ba877f9..3db688b 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -27,6 +27,7 @@ func (c *ProjectsController) Register(r chi.Router) { r.Get("/projects", c.list) r.Post("/projects", c.add) r.Get("/projects/{id}", c.get) + r.Put("/projects/{id}/config", c.setConfig) r.Delete("/projects/{id}", c.remove) } @@ -82,6 +83,24 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, resp) } +func (c *ProjectsController) setConfig(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/config") + return + } + var in projectsvc.SetConfigInput + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.SetConfig(r.Context(), projectID(r), in) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ProjectResponse{Project: p}) +} + func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}") diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index a00e634..b2f56e9 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -66,9 +66,10 @@ const ( MetadataKeySummary = "summary" ) -// AgentConfig holds values loaded from the selected agent's config section. -// Agent adapters own validation for their custom keys. -type AgentConfig map[string]any +// AgentConfig is the typed per-project agent config handed to adapters at +// launch. It aliases domain.AgentConfig so storage, services, and adapters +// share one definition without a translation layer. +type AgentConfig = domain.AgentConfig // ConfigSpec describes the agent-specific config keys AO can expose to users. type ConfigSpec struct { @@ -139,18 +140,19 @@ type SessionInfo struct { Summary string } -// PermissionMode controls how much review an agent requires before acting. -type PermissionMode string +// PermissionMode controls how much review an agent requires before acting. It +// is a type alias for domain.PermissionMode so adapters keep using +// ports.PermissionMode while the typed AgentConfig (in domain) reuses the same +// type. +type PermissionMode = domain.PermissionMode // The permission modes adapters map onto their agent's native approval flags. +// These re-export the domain constants so existing adapter code is unchanged. const ( - // PermissionModeDefault is special: adapters emit no flag for it so the - // agent resolves its starting mode from the user's own config (e.g. - // Claude's TUI reading ~/.claude/settings.json defaultMode). - PermissionModeDefault PermissionMode = "default" - PermissionModeAcceptEdits PermissionMode = "accept-edits" - PermissionModeAuto PermissionMode = "auto" - PermissionModeBypassPermissions PermissionMode = "bypass-permissions" + PermissionModeDefault = domain.PermissionModeDefault + PermissionModeAcceptEdits = domain.PermissionModeAcceptEdits + PermissionModeAuto = domain.PermissionModeAuto + PermissionModeBypassPermissions = domain.PermissionModeBypassPermissions ) // PromptDeliveryStrategy describes how AO should deliver the initial prompt. diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 720cb16..d10fdff 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -125,6 +125,9 @@ type WorkspaceConfig struct { ProjectID domain.ProjectID SessionID domain.SessionID Branch string + // BaseBranch is the per-project default branch new session branches are + // created from. Empty falls back to the workspace adapter's own default. + BaseBranch string } // WorkspaceInfo describes a created workspace — where it lives and its branch. diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go index 3d53293..fd750fe 100644 --- a/backend/internal/service/project/dto.go +++ b/backend/internal/service/project/dto.go @@ -11,9 +11,16 @@ type GetResult struct { // AddInput is the body shape for POST /api/v1/projects. type AddInput struct { - Path string `json:"path"` - ProjectID *string `json:"projectId,omitempty"` - Name *string `json:"name,omitempty"` + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` + Config *domain.ProjectConfig `json:"config,omitempty"` +} + +// SetConfigInput is the body shape for PUT /api/v1/projects/{id}/config. Config +// replaces the project's stored config wholesale; a zero-value config clears it. +type SetConfigInput struct { + Config domain.ProjectConfig `json:"config"` } // RemoveResult reports what DELETE /api/v1/projects/{id} actually did. diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 966f9bb..46c6142 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -26,6 +26,10 @@ type Manager interface { // Add registers a new project from a git repository path. Add(ctx context.Context, in AddInput) (Project, error) + // SetConfig replaces a project's per-project config, returning the updated + // read-model. + SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error) + // Remove unregisters a project, stopping its sessions and reclaiming // managed workspaces. Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) @@ -54,7 +58,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) { out = append(out, Summary{ ID: domain.ProjectID(row.ID), Name: displayName(row), - SessionPrefix: sessionPrefix(row.ID), + SessionPrefix: resolveSessionPrefix(row), }) } return out, nil @@ -119,12 +123,21 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } + var config domain.ProjectConfig + if in.Config != nil { + if err := in.Config.Validate(); err != nil { + return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) + } + config = *in.Config + } + row := domain.ProjectRecord{ ID: string(id), Path: path, RepoOriginURL: resolveGitOriginURL(path), DisplayName: name, RegisteredAt: time.Now(), + Config: config, } if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") @@ -132,6 +145,29 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return projectFromRow(row), nil } +// SetConfig replaces the project's stored config. The typed config is validated +// here so a bad value is rejected when set rather than surfacing at spawn. +func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + if err := in.Config.Validate(); err != nil { + return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) + } + row, ok, err := m.store.GetProject(ctx, string(id)) + if err != nil { + return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok || !row.ArchivedAt.IsZero() { + return Project{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") + } + row.Config = in.Config + if err := m.store.UpsertProject(ctx, row); err != nil { + return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") + } + return projectFromRow(row), nil +} + // resolveGitOriginURL returns the project's `origin` remote URL via // `git -C path remote get-url origin`. A missing remote, missing repo, or any // other git error returns an empty string — `project add` must not fail just @@ -169,13 +205,18 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } func projectFromRow(row domain.ProjectRecord) Project { - return Project{ + p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), Path: row.Path, Repo: row.RepoOriginURL, - DefaultBranch: "main", + DefaultBranch: row.Config.WithDefaults().DefaultBranch, + } + if !row.Config.IsZero() { + cfg := row.Config + p.Config = &cfg } + return p } func displayName(row domain.ProjectRecord) string { @@ -251,6 +292,16 @@ func validateProjectID(id domain.ProjectID) error { return nil } +// resolveSessionPrefix prefers an explicit per-project SessionPrefix and falls +// back to the id-derived prefix. (Display only; session-id generation is +// unchanged.) +func resolveSessionPrefix(row domain.ProjectRecord) string { + if p := strings.TrimSpace(row.Config.SessionPrefix); p != "" { + return p + } + return sessionPrefix(row.ID) +} + func sessionPrefix(id string) string { if id == "" { return "ao" diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index e67f526..4457e7e 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -95,6 +95,87 @@ func TestManager_AddListGetRemove(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } +func TestManager_DefaultsWhenUnconfigured(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Get on a project that set no config still reports the default branch and a + // derived session prefix, and omits the (empty) config object. + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil { + t.Fatalf("Get returned no project: %#v", got) + } + if got.Project.DefaultBranch != domain.DefaultBranchName { + t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName) + } + if got.Project.Config != nil { + t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config) + } + + list, err := m.List(ctx) + if err != nil || len(list) != 1 { + t.Fatalf("List = %v, %v", list, err) + } + if list[0].SessionPrefix != "ao" { + t.Fatalf("default session prefix = %q, want derived 'ao'", list[0].SessionPrefix) + } +} + +func TestManager_SetConfig(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + cfg := domain.ProjectConfig{ + DefaultBranch: "develop", + Env: map[string]string{"FOO": "bar"}, + AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"}, + } + proj, err := m.SetConfig(ctx, "ao", project.SetConfigInput{Config: cfg}) + if err != nil { + t.Fatalf("SetConfig: %v", err) + } + if proj.Config == nil || proj.Config.AgentConfig.Model != "claude-opus-4-5" { + t.Fatalf("returned config = %#v", proj.Config) + } + if proj.DefaultBranch != "develop" { + t.Fatalf("DefaultBranch = %q, want develop", proj.DefaultBranch) + } + + // The config persists and shows up on a fresh Get. + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil || got.Project.Config == nil || got.Project.Config.Env["FOO"] != "bar" { + t.Fatalf("Get config = %#v", got.Project) + } + + // An invalid permission value is rejected when set. + _, err = m.SetConfig(ctx, "ao", project.SetConfigInput{Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Permissions: "yolo"}}}) + wantCode(t, err, "INVALID_PROJECT_CONFIG") + + // An unknown role-override harness is rejected too. + _, err = m.SetConfig(ctx, "ao", project.SetConfigInput{Config: domain.ProjectConfig{Worker: domain.RoleOverride{Harness: "nope"}}}) + wantCode(t, err, "INVALID_PROJECT_CONFIG") + + // Setting on an unknown project is a clean not-found. + _, err = m.SetConfig(ctx, "ghost", project.SetConfigInput{Config: cfg}) + wantCode(t, err, "PROJECT_NOT_FOUND") +} + func TestManager_ReaddAfterRemove(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index 618500b..af3739f 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -12,14 +12,13 @@ type Summary struct { // Project is the full read-model returned by GET /api/v1/projects/{id}. type Project struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` - DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent,omitempty"` - Tracker *TrackerConfig `json:"tracker,omitempty"` - SCM *SCMConfig `json:"scm,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent,omitempty"` + Config *domain.ProjectConfig `json:"config,omitempty"` } // Degraded is returned in place of Project when project config failed to load. @@ -29,29 +28,3 @@ type Degraded struct { Path string `json:"path"` ResolveError string `json:"resolveError"` } - -// TrackerConfig mirrors tracker behaviour config exposed by the projects API. -type TrackerConfig struct { - Plugin string `json:"plugin,omitempty"` - Package string `json:"package,omitempty"` - Path string `json:"path,omitempty"` -} - -// SCMConfig mirrors SCM behaviour config exposed by the projects API. -type SCMConfig struct { - Plugin string `json:"plugin,omitempty"` - Package string `json:"package,omitempty"` - Path string `json:"path,omitempty"` - Webhook *SCMWebhookConfig `json:"webhook,omitempty"` -} - -// SCMWebhookConfig describes SCM webhook settings. -type SCMWebhookConfig struct { - Enabled *bool `json:"enabled,omitempty"` - Path string `json:"path,omitempty"` - SecretEnvVar string `json:"secretEnvVar,omitempty"` - SignatureHeader string `json:"signatureHeader,omitempty"` - EventHeader string `json:"eventHeader,omitempty"` - DeliveryHeader string `json:"deliveryHeader,omitempty"` - MaxBodyBytes int `json:"maxBodyBytes,omitempty"` -} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 66a5272..b1a4695 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -6,7 +6,11 @@ import ( "context" "errors" "fmt" + "os" "os/exec" + "path/filepath" + "runtime" + "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -46,6 +50,9 @@ type runtimeController interface { // Store is the persistence surface needed by the internal session Manager. type Store interface { + // GetProject loads a project row so spawn can resolve its per-project agent + // config into the launch command. ok=false means the project is unknown. + GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) @@ -120,7 +127,15 @@ func New(d Deps) *Manager { // workspace and runtime, then reports completion to the LCM. A failure after the // row exists parks it as terminated and rolls back what was built. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { - prompt, err := m.buildSpawnPrompt(ctx, cfg) + project, err := m.loadProject(ctx, cfg.ProjectID) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err) + } + // A per-project role override picks the harness when the spawn names none, + // so a project can default workers to one agent and orchestrators to another. + cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config) + + prompt, err := m.buildSpawnPrompt(ctx, cfg, project) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) } @@ -138,12 +153,25 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // derived from the assigned session id. branch = "ao/" + string(id) } - ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: branch}) + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: id, + Branch: branch, + BaseBranch: project.Config.WithDefaults().DefaultBranch, + }) if err != nil { m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } + // Per-project workspace provisioning: symlink shared files, then run any + // post-create commands (e.g. `pnpm install`) before the agent launches. + if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil { + _ = m.workspace.Destroy(ctx, ws) + m.markSpawnFailedTerminated(ctx, id) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err) + } + agent, ok := m.agents.Agent(cfg.Harness) if !ok { _ = m.workspace.Destroy(ctx, ws) @@ -160,6 +188,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess WorkspacePath: ws.Path, Prompt: prompt, IssueID: string(cfg.IssueID), + Config: effectiveAgentConfig(cfg.Kind, project.Config), }) if err != nil { _ = m.workspace.Destroy(ctx, ws) @@ -179,7 +208,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess SessionID: id, WorkspacePath: ws.Path, Argv: argv, - Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir), + Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir, project.Config.Env), }) if err != nil { _ = m.workspace.Destroy(ctx, ws) @@ -197,6 +226,56 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return m.getRecord(ctx, id) } +// loadProject loads the project record so spawn can resolve its per-project +// config (harness/agent overrides, env, branch, rules, provisioning). A missing +// project yields a zero record rather than an error: the project may be +// unregistered yet still have live sessions, and an empty config simply means +// every field falls back to its default. +func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (domain.ProjectRecord, error) { + row, ok, err := m.store.GetProject(ctx, string(projectID)) + if err != nil { + return domain.ProjectRecord{}, fmt.Errorf("load project: %w", err) + } + if !ok { + return domain.ProjectRecord{}, nil + } + return row, nil +} + +// effectiveHarness resolves the harness for a spawn: an explicit harness wins; +// otherwise the project's role override for the session kind applies; otherwise +// it stays empty so the daemon's global default (AO_AGENT) is used downstream. +func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness { + if explicit != "" { + return explicit + } + if role := roleOverride(kind, cfg).Harness; role != "" { + return role + } + return "" +} + +// effectiveAgentConfig merges the role override's agent config over the +// project's base agent config; set override fields win. +func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig { + merged := cfg.AgentConfig + override := roleOverride(kind, cfg).AgentConfig + if override.Model != "" { + merged.Model = override.Model + } + if override.Permissions != "" { + merged.Permissions = override.Permissions + } + return merged +} + +func roleOverride(kind domain.SessionKind, cfg domain.ProjectConfig) domain.RoleOverride { + if kind == domain.KindOrchestrator { + return cfg.Orchestrator + } + return cfg.Worker +} + // markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated. // A phantom half-spawned row is worse than a terminal one; we only delete the // row when nothing observable has landed yet (seed state) via rollbackSpawn. @@ -300,7 +379,13 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } - argv, err := restoreArgv(ctx, agent, id, ws.Path, meta) + project, err := m.loadProject(ctx, rec.ProjectID) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + } + // Restore re-applies the project's resolved agent config so a configured + // model/permissions carry across a restore, matching fresh spawn. + argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, effectiveAgentConfig(rec.Kind, project.Config)) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } @@ -308,7 +393,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess SessionID: id, WorkspacePath: ws.Path, Argv: argv, - Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir), + Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir, project.Config.Env), }) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err) @@ -401,10 +486,20 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } -func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) { +func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig, project domain.ProjectRecord) (string, error) { prompt := buildPrompt(cfg) + + // Project-level rules apply to every agent prompt; OrchestratorRules layer + // on top for orchestrator sessions only. + rules, err := projectRules(project) + if err != nil { + return "", err + } + prompt = appendPromptSection(prompt, rules) + switch cfg.Kind { case domain.KindOrchestrator: + prompt = appendPromptSection(prompt, project.Config.OrchestratorRules) return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil case domain.KindWorker: orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID) @@ -418,6 +513,28 @@ func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) ( return prompt, nil } +// projectRules assembles the project's inline AgentRules and the contents of its +// AgentRulesFile (read relative to the project path). A missing rules file is +// treated as absent optional context — not a hard dependency — so a deleted, +// renamed, or never-created file does not fail every spawn for the project. Only +// a real read error (e.g. permissions) surfaces. +func projectRules(project domain.ProjectRecord) (string, error) { + rules := project.Config.AgentRules + if file := strings.TrimSpace(project.Config.AgentRulesFile); file != "" { + path := file + if !filepath.IsAbs(path) { + path = filepath.Join(project.Path, file) + } + switch data, err := os.ReadFile(path); { + case err == nil: + rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n")) + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("agent rules file: %w", err) + } + } + return rules, nil +} + func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { @@ -465,13 +582,80 @@ func appendPromptSection(prompt, section string) string { } } -func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string { - return map[string]string{ - EnvSessionID: string(id), - EnvProjectID: string(project), - EnvIssueID: string(issue), - EnvDataDir: dataDir, +// spawnEnv builds the runtime environment: the per-project env vars first, then +// the AO-internal vars last so they always win (a project cannot override +// AO_SESSION_ID and friends). +func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string, projectEnv map[string]string) map[string]string { + env := make(map[string]string, len(projectEnv)+4) + for k, v := range projectEnv { + env[k] = v + } + env[EnvSessionID] = string(id) + env[EnvProjectID] = string(project) + env[EnvIssueID] = string(issue) + env[EnvDataDir] = dataDir + return env +} + +// provisionWorkspace applies the project's per-workspace setup after the +// worktree exists: symlink shared files from the project repo, then run any +// post-create commands. Either failing aborts the spawn so a half-provisioned +// workspace never launches an agent. +func (m *Manager) provisionWorkspace(ctx context.Context, project domain.ProjectRecord, workspacePath string) error { + if err := applySymlinks(project.Path, workspacePath, project.Config.Symlinks); err != nil { + return err } + return runPostCreate(ctx, workspacePath, project.Config.PostCreate) +} + +// applySymlinks links each repo-relative path into the workspace. A source that +// does not exist is skipped (symlinks are a convenience for optional files like +// .env); a real link failure aborts. +func applySymlinks(projectPath, workspacePath string, symlinks []string) error { + for _, rel := range symlinks { + rel = strings.TrimSpace(rel) + if rel == "" { + continue + } + source := filepath.Join(projectPath, rel) + if _, err := os.Stat(source); err != nil { + continue + } + target := filepath.Join(workspacePath, rel) + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return fmt.Errorf("symlink %q: %w", rel, err) + } + if _, err := os.Lstat(target); err == nil { + continue + } + if err := os.Symlink(source, target); err != nil { + return fmt.Errorf("symlink %q: %w", rel, err) + } + } + return nil +} + +// runPostCreate runs each post-create command in the workspace via the platform +// shell, so OS-agnostic commands like "pnpm install" work. A non-zero exit +// aborts the spawn with the command output. +func runPostCreate(ctx context.Context, workspacePath string, commands []string) error { + for _, command := range commands { + command = strings.TrimSpace(command) + if command == "" { + continue + } + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "cmd", "/c", command) + } else { + cmd = exec.CommandContext(ctx, "sh", "-c", command) + } + cmd.Dir = workspacePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("postCreate %q: %w: %s", command, err, strings.TrimSpace(string(out))) + } + } + return nil } // preLauncher is an optional Agent capability: a step the manager runs before @@ -505,13 +689,13 @@ func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id do // restoreArgv builds the argv to relaunch a torn-down session: the agent's // native resume command when it can continue the session, else a fresh launch. // The agent signals via ok=false (e.g. no native session id captured yet). -func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata) ([]string, error) { +func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, agentConfig ports.AgentConfig) ([]string, error) { ref := ports.SessionRef{ ID: string(id), WorkspacePath: workspacePath, Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID}, } - cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref}) + cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Config: agentConfig}) if err != nil { return nil, fmt.Errorf("restore command: %w", err) } @@ -522,6 +706,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo SessionID: string(id), WorkspacePath: workspacePath, Prompt: meta.Prompt, + Config: agentConfig, }) if err != nil { return nil, fmt.Errorf("launch command: %w", err) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index be12527..13aca20 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -17,11 +17,16 @@ var ctx = context.Background() type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts + projects map[string]domain.ProjectRecord num int } func newFakeStore() *fakeStore { - return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} + return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}} +} +func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { + r, ok := f.projects[id] + return r, ok, nil } func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { f.num++ @@ -97,12 +102,14 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { type fakeRuntime struct { createErr error created, destroyed int + lastCfg ports.RuntimeConfig } -func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { +func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { if r.createErr != nil { return ports.RuntimeHandle{}, r.createErr } + r.lastCfg = cfg r.created++ return ports.RuntimeHandle{ID: "h1"}, nil } @@ -135,13 +142,43 @@ type fakeAgents struct{} func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true } +// recordingAgent captures the LaunchConfig it is handed so a test can assert the +// session manager resolved and forwarded a project's agent config. +type recordingAgent struct { + fakeAgent + lastConfig ports.AgentConfig +} + +func (a *recordingAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) { + a.lastConfig = cfg.Config + return []string{"launch"}, nil +} + +func (a *recordingAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { + a.lastConfig = cfg.Config + return []string{"resume"}, true, nil +} + +type singleAgent struct{ agent ports.Agent } + +func (s singleAgent) Agent(domain.AgentHarness) (ports.Agent, bool) { return s.agent, true } + type fakeWorkspace struct { destroyErr error destroyed int + lastCfg ports.WorkspaceConfig + // path, when set, is returned as the workspace path so provisioning tests + // can point at a real temp directory. + path string } func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { - return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + w.lastCfg = cfg + path := w.path + if path == "" { + path = "/ws/" + string(cfg.SessionID) + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { w.destroyed++ @@ -175,6 +212,52 @@ func mkLive(id domain.SessionID) domain.SessionRecord { return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.Activity{State: domain.ActivityActive}} } +func TestSpawn_ResolvesProjectConfig(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{ + DefaultBranch: "develop", + Env: map[string]string{"FOO": "bar"}, + AgentConfig: domain.AgentConfig{Model: "base-model"}, + // A worker role override wins over the base agent config for workers. + Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker-model"}}, + }} + agent := &recordingAgent{} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if err != nil { + t.Fatal(err) + } + if agent.lastConfig.Model != "worker-model" { + t.Fatalf("launch model = %q, want role override worker-model", agent.lastConfig.Model) + } + if rec.Harness != domain.HarnessCodex { + t.Fatalf("harness = %q, want codex from role override", rec.Harness) + } + if ws.lastCfg.BaseBranch != "develop" { + t.Fatalf("workspace base branch = %q, want develop", ws.lastCfg.BaseBranch) + } + if rt.lastCfg.Env["FOO"] != "bar" { + t.Fatalf("runtime env FOO = %q, want bar", rt.lastCfg.Env["FOO"]) + } + if rt.lastCfg.Env[EnvSessionID] == "" { + t.Fatal("runtime env missing AO_SESSION_ID") + } + + // A project with no stored config yields a zero AgentConfig (adapter defaults). + st.projects["bare"] = domain.ProjectRecord{ID: "bare"} + agent.lastConfig = ports.AgentConfig{Model: "stale"} + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil { + t.Fatal(err) + } + if !agent.lastConfig.IsZero() { + t.Fatalf("launch config = %#v, want zero for project without config", agent.lastConfig) + } +} + func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { m, st, rt, _ := newManager() s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) @@ -239,6 +322,22 @@ func TestRestore_ReopensTerminal(t *testing.T) { t.Fatal("restore should relaunch") } } +func TestRestore_AppliesProjectAgentConfig(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Model: "restore-model"}}} + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) + agent := &recordingAgent{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatal(err) + } + if agent.lastConfig.Model != "restore-model" { + t.Fatalf("restore config model = %q, want restore-model (config must carry across restore)", agent.lastConfig.Model) + } +} + func TestRestore_RefusesLiveSession(t *testing.T) { m, st, _, _ := newManager() st.sessions["mer-1"] = mkLive("mer-1") diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go new file mode 100644 index 0000000..c6e55c0 --- /dev/null +++ b/backend/internal/session_manager/provision_test.go @@ -0,0 +1,120 @@ +package sessionmanager + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) { + env := spawnEnv("mer-1", "mer", "issue-9", "/data", map[string]string{ + "FOO": "bar", + EnvSessionID: "hacked", // a project must not override AO-internal vars + EnvProjectID: "hacked", + }) + if env["FOO"] != "bar" { + t.Fatalf("FOO = %q, want bar", env["FOO"]) + } + if env[EnvSessionID] != "mer-1" { + t.Fatalf("AO_SESSION_ID = %q, want mer-1 (internal wins)", env[EnvSessionID]) + } + if env[EnvProjectID] != "mer" { + t.Fatalf("AO_PROJECT_ID = %q, want mer (internal wins)", env[EnvProjectID]) + } +} + +func TestEffectiveHarnessAndAgentConfig(t *testing.T) { + cfg := domain.ProjectConfig{ + AgentConfig: domain.AgentConfig{Model: "base", Permissions: domain.PermissionModeAuto}, + Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker"}}, + Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode}, + } + + // Explicit harness always wins. + if h := effectiveHarness(domain.HarnessAider, domain.KindWorker, cfg); h != domain.HarnessAider { + t.Fatalf("explicit harness = %q, want aider", h) + } + // Empty harness falls back to the role override per kind. + if h := effectiveHarness("", domain.KindWorker, cfg); h != domain.HarnessCodex { + t.Fatalf("worker harness = %q, want codex", h) + } + if h := effectiveHarness("", domain.KindOrchestrator, cfg); h != domain.HarnessClaudeCode { + t.Fatalf("orchestrator harness = %q, want claude-code", h) + } + + // Role override merges over the base agent config (set fields win; unset keep base). + got := effectiveAgentConfig(domain.KindWorker, cfg) + if got.Model != "worker" || got.Permissions != domain.PermissionModeAuto { + t.Fatalf("merged worker config = %#v, want model=worker permissions=auto", got) + } + // Orchestrator has no agent-config override, so the base config is used as-is. + if got := effectiveAgentConfig(domain.KindOrchestrator, cfg); got.Model != "base" { + t.Fatalf("orchestrator config = %#v, want base", got) + } +} + +func TestApplySymlinks(t *testing.T) { + project := t.TempDir() + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(project, ".env"), []byte("X=1"), 0o644); err != nil { + t.Fatal(err) + } + + // A present source is linked; a missing source is skipped, not an error. + if err := applySymlinks(project, workspace, []string{".env", "missing.txt"}); err != nil { + t.Fatalf("applySymlinks: %v", err) + } + target := filepath.Join(workspace, ".env") + if data, err := os.ReadFile(target); err != nil || string(data) != "X=1" { + t.Fatalf("symlinked .env = %q err=%v", data, err) + } + if _, err := os.Lstat(filepath.Join(workspace, "missing.txt")); !os.IsNotExist(err) { + t.Fatal("missing source should not have been linked") + } +} + +func TestRunPostCreate(t *testing.T) { + workspace := t.TempDir() + if err := runPostCreate(context.Background(), workspace, []string{"echo hi > out.txt"}); err != nil { + t.Fatalf("runPostCreate: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "out.txt")); err != nil { + t.Fatalf("post-create command did not run in workspace: %v", err) + } + // A failing command surfaces an error. + if err := runPostCreate(context.Background(), workspace, []string{"exit 3"}); err == nil { + t.Fatal("expected error from failing post-create command") + } +} + +func TestProjectRulesReadsFile(t *testing.T) { + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, ".rules.md"), []byte("run tests\n"), 0o644); err != nil { + t.Fatal(err) + } + rec := domain.ProjectRecord{Path: project, Config: domain.ProjectConfig{ + AgentRules: "use conventional commits", + AgentRulesFile: ".rules.md", + }} + rules, err := projectRules(rec) + if err != nil { + t.Fatalf("projectRules: %v", err) + } + if want := "use conventional commits\n\nrun tests"; rules != want { + t.Fatalf("rules = %q, want %q", rules, want) + } + + // A missing rules file is optional context, not a hard failure: it returns + // the inline rules without aborting the spawn. + rec.Config.AgentRulesFile = "nope.md" + got, err := projectRules(rec) + if err != nil { + t.Fatalf("missing rules file should not error: %v", err) + } + if got != "use conventional commits" { + t.Fatalf("rules with missing file = %q, want inline rules only", got) + } +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 824825a..d718e8b 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -107,6 +107,7 @@ type Project struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime + Config sql.NullString } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index dea720c..4ab0398 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -31,7 +31,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) } const findProjectByPath = `-- name: FindProjectByPath :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE path = ? AND archived_at IS NULL ` @@ -45,12 +45,13 @@ func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, + &i.Config, ) return i, err } const getProject = `-- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE id = ? ` @@ -64,12 +65,13 @@ func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, + &i.Config, ) return i, err } const listProjects = `-- name: ListProjects :many -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE archived_at IS NULL ORDER BY id ` @@ -89,6 +91,7 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, + &i.Config, ); err != nil { return nil, err } @@ -104,13 +107,14 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { } const upsertProject = `-- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) +VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, repo_origin_url = excluded.repo_origin_url, display_name = excluded.display_name, - archived_at = excluded.archived_at + archived_at = excluded.archived_at, + config = excluded.config ` type UpsertProjectParams struct { @@ -120,6 +124,7 @@ type UpsertProjectParams struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime + Config sql.NullString } func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { @@ -130,6 +135,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er arg.DisplayName, arg.RegisteredAt, arg.ArchivedAt, + arg.Config, ) return err } diff --git a/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql b/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql new file mode 100644 index 0000000..e8f987c --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql @@ -0,0 +1,15 @@ +-- Per-project configuration. A single nullable JSON column on projects holds the +-- typed ProjectConfig (agent settings, env, symlinks, post-create, rules, role +-- overrides, tracker/scm, …) AO resolves at spawn. NULL means unset; a non-NULL +-- value is a JSON object. One blob per project keeps the registry's "SQLite twin +-- of the YAML config" shape rather than splitting config into many columns. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE projects ADD COLUMN config TEXT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE projects DROP COLUMN config; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 3d41d0d..61c04b8 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -1,22 +1,23 @@ -- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) +VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, repo_origin_url = excluded.repo_origin_url, display_name = excluded.display_name, - archived_at = excluded.archived_at; + archived_at = excluded.archived_at, + config = excluded.config; -- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE id = ?; -- name: ListProjects :many -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: FindProjectByPath :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE path = ? AND archived_at IS NULL; -- name: ArchiveProject :execrows diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 932205a..06a3bd9 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "time" @@ -13,6 +14,10 @@ import ( // UpsertProject inserts or replaces a registered project row. func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error { + config, err := marshalProjectConfig(r.Config) + if err != nil { + return err + } s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ @@ -22,6 +27,7 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error DisplayName: r.DisplayName, RegisteredAt: r.RegisteredAt, ArchivedAt: nullTime(r.ArchivedAt), + Config: config, }) } @@ -83,6 +89,7 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord { RepoOriginURL: p.RepoOriginURL, DisplayName: p.DisplayName, RegisteredAt: p.RegisteredAt, + Config: unmarshalProjectConfig(p.Config), } if p.ArchivedAt.Valid { r.ArchivedAt = p.ArchivedAt.Time @@ -90,6 +97,36 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord { return r } +// marshalProjectConfig encodes the typed per-project config into the nullable +// JSON column. An IsZero config stores SQL NULL so an unset config round-trips +// back to a zero value rather than an empty object. +func marshalProjectConfig(cfg domain.ProjectConfig) (sql.NullString, error) { + if cfg.IsZero() { + return sql.NullString{}, nil + } + data, err := json.Marshal(cfg) + if err != nil { + return sql.NullString{}, fmt.Errorf("marshal project config: %w", err) + } + return sql.NullString{String: string(data), Valid: true}, nil +} + +// unmarshalProjectConfig decodes the nullable JSON column back into the typed +// struct. SQL NULL (an unset config) decodes to a zero value. A damaged config +// (invalid JSON from a direct DB edit or migration bug) also degrades to a zero +// config rather than erroring — a corrupt config must never block access to the +// project row, nor fail an entire ListProjects. +func unmarshalProjectConfig(s sql.NullString) domain.ProjectConfig { + if !s.Valid || s.String == "" { + return domain.ProjectConfig{} + } + var cfg domain.ProjectConfig + if err := json.Unmarshal([]byte(s.String), &cfg); err != nil { + return domain.ProjectConfig{} + } + return cfg +} + func nullTime(t time.Time) sql.NullTime { if t.IsZero() { return sql.NullTime{} diff --git a/backend/internal/storage/sqlite/store/project_store_internal_test.go b/backend/internal/storage/sqlite/store/project_store_internal_test.go new file mode 100644 index 0000000..60b518b --- /dev/null +++ b/backend/internal/storage/sqlite/store/project_store_internal_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "database/sql" + "testing" +) + +func TestUnmarshalProjectConfigDegradesGracefully(t *testing.T) { + // SQL NULL / empty → zero config. + if got := unmarshalProjectConfig(sql.NullString{}); !got.IsZero() { + t.Fatalf("NULL config = %#v, want zero", got) + } + + // Valid JSON decodes. + if got := unmarshalProjectConfig(sql.NullString{String: `{"defaultBranch":"develop"}`, Valid: true}); got.DefaultBranch != "develop" { + t.Fatalf("valid config DefaultBranch = %q, want develop", got.DefaultBranch) + } + + // Corrupt JSON must NOT error — it degrades to a zero config so the project + // row (and ListProjects) stay accessible. + if got := unmarshalProjectConfig(sql.NullString{String: `{not json`, Valid: true}); !got.IsZero() { + t.Fatalf("corrupt config = %#v, want zero (degraded)", got) + } +} diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index a03c1e9..31a2af3 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "encoding/json" + "reflect" "sync" "testing" "time" @@ -71,6 +72,52 @@ func TestProjectCRUDAndArchive(t *testing.T) { } } +func TestProjectConfigRoundTrips(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + // A config with mixed field kinds (scalar, map, list, nested) survives the + // JSON round trip. + cfg := domain.ProjectConfig{ + DefaultBranch: "develop", + Env: map[string]string{"FOO": "bar"}, + Symlinks: []string{".env"}, + PostCreate: []string{"echo hi"}, + AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits}, + Worker: domain.RoleOverride{Harness: domain.HarnessCodex}, + } + if err := s.UpsertProject(ctx, domain.ProjectRecord{ + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg, + }); err != nil { + t.Fatalf("upsert with config: %v", err) + } + got, ok, err := s.GetProject(ctx, "cfg") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if !reflect.DeepEqual(got.Config, cfg) { + t.Fatalf("config = %#v, want %#v", got.Config, cfg) + } + + // An unset config round-trips back to a zero value rather than an empty object. + seedProject(t, s, "nocfg") + got, _, _ = s.GetProject(ctx, "nocfg") + if !got.Config.IsZero() { + t.Fatalf("unset config = %#v, want zero", got.Config) + } + + // Clearing replaces a previously-set config with a zero value. + if err := s.UpsertProject(ctx, domain.ProjectRecord{ + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: domain.ProjectConfig{}, + }); err != nil { + t.Fatalf("clear config: %v", err) + } + if got, _, _ := s.GetProject(ctx, "cfg"); !got.Config.IsZero() { + t.Fatalf("cleared config = %#v, want zero", got.Config) + } +} + func TestSessionCreateAssignsPerProjectID(t *testing.T) { s := newTestStore(t) ctx := context.Background() diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md new file mode 100644 index 0000000..d774dac --- /dev/null +++ b/docs/design/per-project-config.md @@ -0,0 +1,126 @@ +# Design: typed per-project configuration + +Status: **implemented** — the full per-project `ProjectConfig` is typed, +validated, persisted (one `projects.config` JSON column), and surfaced via +`ao project set-config` + `PUT /projects/{id}/config`. Consumption is wired at +spawn for `defaultBranch`, `env`, `symlinks`, `postCreate`, the rules, +`agentConfig`, and the `worker`/`orchestrator` role overrides. `tracker`, `scm`, +`opencodeIssueSessionStrategy`, and `sessionPrefix`→id are stored + validated but +not yet consumed (their consumers do not exist — see "deferred" below). + +## Goal + +Every per-project setting the legacy `agent-orchestrator.yaml` carried under +`projects:` should live as **typed, validated state** in SQLite, reachable +through exactly two entry points: + +1. **CLI** — `ao project ...` (thin client → daemon HTTP) +2. **UI** — the dashboard project settings form + +There is no YAML loader in the Go rewrite, so this is not about parsing a file — +it is about giving each former YAML field a typed home, a validation owner, and a +CLI/API/UI surface. No setting should be a free-form `map[string]any`. + +## Principle: typed over map + +The legacy `agentConfig` was an open `map` (`.passthrough()`), which is why early +storage modeled it as `map[string]any`. That defers validation to spawn time and +forces the UI to render raw JSON. We instead model each setting as a **typed Go +struct** with a `Validate()` method, so: + +- bad values are rejected when **set** (CLI/API), not silently dropped at spawn; +- the OpenAPI spec and frontend TS types are generated with real fields; +- the UI renders a typed form instead of a JSON textarea. + +Adapter-specific keys, if ever needed, become typed fields owned by `domain` +rather than an escape-hatch map. + +## Field catalog (legacy `projects.`) and target home + +| YAML field | Type | Storage today | Target | +|---|---|---|---| +| `name` | string | `projects.display_name` | done | +| `repo` | string | `projects.repo_origin_url` | done | +| `path` | string | `projects.path` | done | +| `defaultBranch` | string | hardcoded `"main"` | `projects.default_branch` | +| `sessionPrefix` | string | derived | `projects.session_prefix` | +| `agentConfig` | `{model, permissions}` | **`projects.agent_config` (typed)** | **done (this PR)** | +| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | — | typed role-override columns/blob | +| `env` | `map[string]string` | — | `project_env` table (key/value rows) | +| `symlinks` | `[]string` | — | `projects.symlinks` (JSON) | +| `postCreate` | `[]string` | — | `projects.post_create` (JSON) | +| `agentRules` / `agentRulesFile` | string | partial (`SpawnConfig.AgentRules`) | `projects.agent_rules*` | +| `orchestratorRules` | string | — | `projects.orchestrator_rules` | +| `tracker` | `{plugin, …}` | DTO stub only | `projects.tracker` (typed blob) + adapter validation | +| `scm` | `{plugin, webhook{…}}` | DTO stub only | `projects.scm` (typed blob) + adapter validation | +| `opencodeIssueSessionStrategy` | enum | — | `projects.opencode_session_strategy` | +| `reactions` | per-project overrides | — | `project_reactions` (own slice) | + +## Typed model + +```go +// domain +type AgentConfig struct { // implemented + Model string `json:"model,omitempty"` + Permissions PermissionMode `json:"permissions,omitempty"` +} +func (c AgentConfig) Validate() error { ... } + +// target — assembled incrementally, one slice per PR +type ProjectConfig struct { + DefaultBranch string + SessionPrefix string + AgentConfig AgentConfig + Worker RoleOverride // {Harness, AgentConfig} + Orchestrator RoleOverride + Env map[string]string + Symlinks []string + PostCreate []string + AgentRules string + Tracker TrackerConfig // adapter-validated + SCM SCMConfig // adapter-validated + // ... +} +``` + +Each leaf type owns a `Validate()`. Plugin-shaped settings (`tracker`, `scm`) +delegate to the selected adapter, mirroring how `agentConfig` is consumed by the +agent adapter. + +## Storage strategy + +- **Scalar fields** (`default_branch`, `session_prefix`, `agent_rules`, enums) → + their own typed columns on `projects`. +- **Small structured blobs** (`agent_config`, `tracker`, `scm`, `symlinks`, + `post_create`) → nullable JSON columns, marshaled/unmarshaled in the store + (the pattern this PR established for `agent_config`). +- **Unbounded key/value sets** (`env`) → a child table keyed by `project_id`. +- **Its own domain** (`reactions`) → a separate slice; reactions already have a + reaction engine to integrate with. + +## Surface (per field) + +- **API** — extend the projects controller. Field groups get focused routes + (e.g. `PUT /projects/{id}/agent-config`, `PUT /projects/{id}/env`) rather than + one mega-PUT, so partial updates are clean and the OpenAPI stays legible. +- **CLI** — typed flags on `ao project` subcommands (e.g. + `ao project set-config --model --permission`, `ao project env set KEY=VAL`). +- **UI** — a generated typed form per group, driven by the OpenAPI schema. + +## Sequencing (one slice per PR) + +1. **agentConfig (typed)** — *this PR*. Establishes the typed+validated+surfaced + pattern end to end. +2. **Project identity scalars** — `default_branch`, `session_prefix` (stop + hardcoding/deriving them). +3. **Workspace provisioning** — `env`, `symlinks`, `postCreate` (these change + spawn/workspace wiring, so grouped). +4. **Rules** — `agentRules`, `agentRulesFile`, `orchestratorRules` (consolidate + the partial `SpawnConfig.AgentRules` path). +5. **Role overrides** — `worker` / `orchestrator` `{agent, agentConfig}`. +6. **Tracker / SCM per-project** — typed blobs with adapter-owned validation. +7. **Per-project reactions** — integrate with the reaction engine. + +Each slice is independently shippable and follows the same shape: domain type + +`Validate()` → storage (column or blob or table) → service set/get → API route → +CLI flags → UI form → tests. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b23fb27..2aca9df 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -92,6 +92,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/projects/{id}/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace a project's per-project config */ + put: operations["setProjectConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/prs/{id}/merge": { parameters: { query?: never; @@ -312,10 +329,15 @@ export interface components { requestId?: string; }; AddProjectInput: { + config?: components["schemas"]["ProjectConfig"]; name?: null | string; path: string; projectId?: null | string; }; + AgentConfig: { + model?: string; + permissions?: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -369,13 +391,30 @@ export interface components { }; Project: { agent?: string; + config?: components["schemas"]["ProjectConfig"]; defaultBranch: string; id: string; name: string; path: string; repo: string; + }; + ProjectConfig: { + agentConfig?: components["schemas"]["AgentConfig"]; + agentRules?: string; + agentRulesFile?: string; + defaultBranch?: string; + env?: { + [key: string]: string; + }; + opencodeIssueSessionStrategy?: string; + orchestrator?: components["schemas"]["RoleOverride"]; + orchestratorRules?: string; + postCreate?: string[]; scm?: components["schemas"]["SCMConfig"]; + sessionPrefix?: string; + symlinks?: string[]; tracker?: components["schemas"]["TrackerConfig"]; + worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { project: components["schemas"]["ProjectOrDegraded"]; @@ -413,6 +452,10 @@ export interface components { session: components["schemas"]["Session"]; sessionId: string; }; + RoleOverride: { + agent?: string; + agentConfig?: components["schemas"]["AgentConfig"]; + }; RollbackSessionResponse: { deleted?: boolean; killed?: boolean; @@ -420,14 +463,11 @@ export interface components { sessionId: string; }; SCMConfig: { - package?: string; - path?: string; plugin?: string; webhook?: components["schemas"]["SCMWebhookConfig"]; }; SCMWebhookConfig: { deliveryHeader?: string; - enabled?: null | boolean; eventHeader?: string; maxBodyBytes?: number; path?: string; @@ -484,6 +524,9 @@ export interface components { sessionId: string; state: string; }; + SetProjectConfigInput: { + config: components["schemas"]["ProjectConfig"]; + }; SpawnOrchestratorRequest: { clean?: boolean; projectId: string; @@ -503,9 +546,8 @@ export interface components { prompt?: string; }; TrackerConfig: { - package?: string; - path?: string; plugin?: string; + teamId?: string; }; }; responses: never; @@ -885,6 +927,60 @@ export interface operations { }; }; }; + setProjectConfig: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetProjectConfigInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; mergePR: { parameters: { query?: never;