feat(config): persist per-project agent config and resolve it at spawn (#154)
* feat(config): persist per-project agent config and resolve it at spawn
Each project can now carry its own agent config (model, permissions,
adapter-specific keys) that survives daemon restart and is resolved into
the launch command when a session spawns.
- storage: add nullable projects.agent_config JSON column (migration 0008);
marshal/unmarshal in the store so the domain carries map[string]any
- resolution: session manager loads the project row and populates
LaunchConfig.Config before GetLaunchCommand
- validation: claude-code declares a ConfigSpec (model, permissions) and
rejects unknown keys / bad types / bad enums at spawn; it applies the
model override and config-driven permission mode (explicit Permissions
still wins)
- surface: PUT /projects/{id}/agent-config + `ao project set-config`
(--set/--config-json/--clear), config shown in `ao project get`
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(claudecode): validate string-list/required config keys and unhandled types
Address review on per-project agent config validation:
- handle ConfigFieldStringList (list of strings) explicitly
- reject unhandled ConfigFieldType via a default case rather than
silently passing
- enforce Required fields are present
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): make per-project agent config a typed struct
Replace the free-form map[string]any agent config with a typed
domain.AgentConfig{Model, Permissions} so values are validated when set
(CLI/API) instead of silently dropped at spawn, and the OpenAPI/TS schema
and UI get real typed fields.
- domain: AgentConfig struct + Validate(); PermissionMode moves to domain
and ports re-exports it as a type alias (zero adapter churn)
- storage: marshal/unmarshal the typed struct (IsZero → SQL NULL)
- service: validate on Add and SetAgentConfig; read-model exposes a typed
*AgentConfig
- claudecode: read typed cfg.Config.Model/.Permissions; drop the
map/spec-based validateConfig in favor of the typed Validate()
- cli: typed `ao project set-config --model/--permission/--clear`
- docs: add docs/design/per-project-config.md blueprint sequencing the
remaining # Projects fields toward fully typed per-project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): full typed per-project ProjectConfig (store, resolve, surface)
Expand per-project config from agentConfig-only to the full legacy
`projects.<id>` surface, modeled as one typed domain.ProjectConfig
persisted in a single projects.config JSON column.
Wired end-to-end at spawn:
- defaultBranch → base branch for the session worktree (ports.WorkspaceConfig.BaseBranch)
- env → merged into the runtime env (AO-internal vars still win)
- symlinks → repo files linked into the workspace
- postCreate → commands run in the workspace (OS-agnostic shell)
- agentRules / agentRulesFile / orchestratorRules → merged into the prompt
- worker/orchestrator role overrides → harness + agent-config resolution
Stored + validated + surfaced now, consumption deferred (no consumer yet):
tracker, scm(+webhook), opencodeIssueSessionStrategy; sessionPrefix feeds
the display prefix only (session-id generation unchanged).
Validation lives on domain.ProjectConfig.Validate() and runs when config is
set (CLI/API). PermissionMode/AgentConfig stay typed; harness names validated
via domain.AgentHarness.IsKnown().
Surface: PUT /projects/{id}/config (replaces /agent-config) + typed
`ao project set-config` flags (--default-branch/--env/--symlink/--post-create/
--agent-rules/--worker-agent/… or --config-json). OpenAPI + TS regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(lint): tighten symlink dir perms to 0o750 (gosec G301)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): centralize default project config + tests
Add domain.DefaultProjectConfig / ProjectConfig.WithDefaults with a single
DefaultBranchName ("main") source of truth, replacing the literal "main"
scattered in the read-model and the gitworktree adapter. Unconfigured
projects now resolve the default branch through one path; every other field
defaults to its zero value.
Tests: defaults present for all fields (DefaultProjectConfig/WithDefaults),
and an unconfigured project reports the default branch + derived session
prefix while omitting the empty config object.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): encode documented defaults (branch=main, tracker=github)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): fail-safe paths for missing/corrupt per-project config
Address review on default-config / fail-safe spawning:
- projectRules: a missing AgentRulesFile is optional context, skipped
rather than aborting every spawn (only a real read error surfaces)
- store: a corrupt config JSON column degrades to a zero config instead
of failing GetProject/ListProjects/FindProjectByPath for that row
- restore: re-apply the project's resolved AgentConfig so a configured
model/permissions carry across a restore (matches fresh spawn)
Tests: missing rules file skips, corrupt config degrades to zero, restore
applies the project agent config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): trim per-project config to consumer-backed fields
Drop config that has no live consumer yet, so this PR lands only the
fields actually read at spawn/display:
- Remove prompt rules (agentRules, agentRulesFile, orchestratorRules)
from ProjectConfig. Project/agent instructions belong on the system
prompt path or repo-local AGENTS.md, not another rules family.
- Remove future-only integration config with no consumer: tracker, scm,
scm.webhook, and opencodeIssueSessionStrategy (plus their types,
constants, the github tracker default, CLI flags, and spec schemas).
These return in focused PRs alongside the code that reads them.
Kept: defaultBranch, sessionPrefix, env, symlinks, postCreate,
agentConfig (model/permissions), and worker/orchestrator role
overrides. Cross-agent model/permissions support stays follow-up (#157).
Regenerated openapi.yaml + frontend schema.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): reject unknown config JSON keys; confine symlink paths
Two review hardenings on the now-trimmed per-project config surface:
- Project add/set-config endpoints decode with DisallowUnknownFields, so
a misspelled or removed config field surfaces as a clear 400 instead
of being silently dropped. Locks the removals from e213b68 (and any
future trims) at the API gate. Covered by new controllers test.
- applySymlinks now refuses absolute paths and any ".." segment via a
safeRelPath guard, so a project config cannot escape the project or
workspace tree via a malicious symlinks entry. Covered by new
session_manager test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(config): reject symlink path traversal at config write time
greptile flagged ProjectConfig.Symlinks as a write-time path-traversal
gap on PR #154 — the runtime guard in applySymlinks catches a malicious
entry on every spawn, but the config itself accepted it. Move the check
into ProjectConfig.Validate so a bad symlinks entry surfaces as
INVALID_PROJECT_CONFIG when set (CLI/API) instead of silently sitting in
the row until the next spawn. The runtime guard stays as
defense-in-depth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
This commit is contained in:
parent
33a1ee6df9
commit
7698c24931
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<branch>, 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 {
|
||||
|
|
|
|||
|
|
@ -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/<path> 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/<path> to the running daemon and decodes a
|
||||
// 2xx response into out.
|
||||
func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package cli
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
|
@ -53,11 +55,57 @@ 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"`
|
||||
AgentConfig agentConfig `json:"agentConfig,omitempty"`
|
||||
Worker roleOverride `json:"worker,omitempty"`
|
||||
Orchestrator roleOverride `json:"orchestrator,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
|
||||
workerAgent string
|
||||
orchestratorAgent string
|
||||
env []string
|
||||
symlink []string
|
||||
postCreate []string
|
||||
configJSON string
|
||||
clear bool
|
||||
json bool
|
||||
}
|
||||
|
||||
type projectListResult struct {
|
||||
Projects []projectSummary `json:"projects"`
|
||||
}
|
||||
|
|
@ -86,6 +134,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 +228,112 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts projectSetConfigOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "set-config <id>",
|
||||
Short: "Set the per-project config",
|
||||
Long: "Replace a project's per-project config (branch, session prefix, env, " +
|
||||
"symlinks, post-create, 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.workerAgent, "worker-agent", "", "Harness override for worker sessions")
|
||||
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions")
|
||||
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,
|
||||
AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission},
|
||||
Worker: roleOverride{Agent: opts.workerAgent},
|
||||
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
|
||||
}
|
||||
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 +425,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 +439,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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProjectConfig is the typed per-project configuration — the SQLite twin of the
|
||||
// legacy agent-orchestrator.yaml `projects.<id>` 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.
|
||||
//
|
||||
// Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks,
|
||||
// PostCreate, AgentConfig, and the role overrides are consumed at spawn;
|
||||
// SessionPrefix feeds the display prefix. Settings whose consumers do not yet
|
||||
// exist (tracker/SCM per-project config, prompt rules) are intentionally absent
|
||||
// and land in focused follow-up PRs alongside the code that reads them.
|
||||
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"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// DefaultBranchName is the base branch used when a project configures none.
|
||||
const DefaultBranchName = "main"
|
||||
|
||||
// DefaultProjectConfig returns the config a project has when it sets nothing:
|
||||
// branch "main". Every other field defaults to its zero value (no
|
||||
// env/symlinks/post-create, agent + role defaults).
|
||||
func DefaultProjectConfig() ProjectConfig {
|
||||
return ProjectConfig{
|
||||
DefaultBranch: DefaultBranchName,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, s := range c.Symlinks {
|
||||
if err := validateRepoRelative(s); err != nil {
|
||||
return fmt.Errorf("symlink %q: %w", s, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRepoRelative refuses paths that would let a project config escape
|
||||
// its repo root: absolute paths and any ".." segment (before or after Clean).
|
||||
// The same guard runs at spawn time as defense-in-depth, but enforcing it here
|
||||
// rejects bad config when it is set rather than at every later spawn.
|
||||
func validateRepoRelative(p string) error {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\`) {
|
||||
return fmt.Errorf("path must be repo-relative and must not escape the project root")
|
||||
}
|
||||
clean := filepath.Clean(trimmed)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("path must be repo-relative and must not escape the project root")
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.ToSlash(clean), "/") {
|
||||
if seg == ".." {
|
||||
return fmt.Errorf("path must be repo-relative and must not escape the project root")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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 symlinks", ProjectConfig{Symlinks: []string{".env", "configs/dev.toml"}}, false},
|
||||
{"symlink absolute path", ProjectConfig{Symlinks: []string{"/etc/passwd"}}, true},
|
||||
{"symlink parent escape", ProjectConfig{Symlinks: []string{"../escape"}}, true},
|
||||
{"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true},
|
||||
{"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, 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 one documented non-empty default.
|
||||
if def.DefaultBranch != "main" {
|
||||
t.Fatalf("default DefaultBranch = %q, want main", def.DefaultBranch)
|
||||
}
|
||||
|
||||
// Every other field defaults to its zero value: clearing the documented
|
||||
// default must leave the config completely empty.
|
||||
def.DefaultBranch = ""
|
||||
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 {
|
||||
t.Fatalf("WithDefaults = %#v, want branch=main", got)
|
||||
}
|
||||
|
||||
// Set fields are preserved, not overwritten.
|
||||
got = (ProjectConfig{
|
||||
DefaultBranch: "develop",
|
||||
AgentConfig: AgentConfig{Model: "m"},
|
||||
}).WithDefaults()
|
||||
if got.DefaultBranch != "develop" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,31 @@ components:
|
|||
- repo
|
||||
- defaultBranch
|
||||
type: object
|
||||
ProjectConfig:
|
||||
properties:
|
||||
agentConfig:
|
||||
$ref: '#/components/schemas/AgentConfig'
|
||||
defaultBranch:
|
||||
type: string
|
||||
env:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
orchestrator:
|
||||
$ref: '#/components/schemas/RoleOverride'
|
||||
postCreate:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
sessionPrefix:
|
||||
type: string
|
||||
symlinks:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
worker:
|
||||
$ref: '#/components/schemas/RoleOverride'
|
||||
type: object
|
||||
ProjectGetResponse:
|
||||
properties:
|
||||
project:
|
||||
|
|
@ -1189,6 +1266,13 @@ components:
|
|||
- sessionId
|
||||
- session
|
||||
type: object
|
||||
RoleOverride:
|
||||
properties:
|
||||
agent:
|
||||
type: string
|
||||
agentConfig:
|
||||
$ref: '#/components/schemas/AgentConfig'
|
||||
type: object
|
||||
RollbackSessionResponse:
|
||||
properties:
|
||||
deleted:
|
||||
|
|
@ -1203,36 +1287,6 @@ components:
|
|||
- ok
|
||||
- sessionId
|
||||
type: object
|
||||
SCMConfig:
|
||||
properties:
|
||||
package:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
plugin:
|
||||
type: string
|
||||
webhook:
|
||||
$ref: '#/components/schemas/SCMWebhookConfig'
|
||||
type: object
|
||||
SCMWebhookConfig:
|
||||
properties:
|
||||
deliveryHeader:
|
||||
type: string
|
||||
enabled:
|
||||
type:
|
||||
- "null"
|
||||
- boolean
|
||||
eventHeader:
|
||||
type: string
|
||||
maxBodyBytes:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
secretEnvVar:
|
||||
type: string
|
||||
signatureHeader:
|
||||
type: string
|
||||
type: object
|
||||
SendSessionMessageRequest:
|
||||
properties:
|
||||
message:
|
||||
|
|
@ -1358,6 +1412,13 @@ components:
|
|||
- sessionId
|
||||
- state
|
||||
type: object
|
||||
SetProjectConfigInput:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/ProjectConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
SpawnOrchestratorRequest:
|
||||
properties:
|
||||
clean:
|
||||
|
|
@ -1421,15 +1482,6 @@ components:
|
|||
required:
|
||||
- projectId
|
||||
type: object
|
||||
TrackerConfig:
|
||||
properties:
|
||||
package:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
plugin:
|
||||
type: string
|
||||
type: object
|
||||
tags:
|
||||
- description: Project registry, configuration, and lifecycle administration
|
||||
name: projects
|
||||
|
|
|
|||
|
|
@ -118,10 +118,13 @@ 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",
|
||||
// httpd/controllers (wire envelopes)
|
||||
"ControllersListProjectsResponse": "ListProjectsResponse",
|
||||
"ControllersProjectResponse": "ProjectResponse",
|
||||
|
|
@ -154,14 +157,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 +298,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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
var in projectsvc.AddInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
if err := decodeJSONStrict(r, &in); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
}
|
||||
|
|
@ -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 := decodeJSONStrict(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}")
|
||||
|
|
@ -102,3 +121,12 @@ func projectID(r *http.Request) domain.ProjectID {
|
|||
func decodeJSON(r *http.Request, out any) error {
|
||||
return json.NewDecoder(r.Body).Decode(out)
|
||||
}
|
||||
|
||||
// decodeJSONStrict rejects request bodies that include keys outside the target
|
||||
// type. Used on project add/set-config so a misspelled or removed config field
|
||||
// surfaces as a 400 instead of being silently dropped.
|
||||
func decodeJSONStrict(r *http.Request, out any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,36 @@ func TestProjectsAPI_Delete(t *testing.T) {
|
|||
|
||||
}
|
||||
|
||||
// TestProjectsAPI_RejectsUnknownConfigKeys locks the strict-decoder gate on the
|
||||
// project config endpoints: a misspelled or removed field surfaces as a clear
|
||||
// 400 instead of being silently dropped, so the API cannot accumulate dead
|
||||
// config the daemon never reads.
|
||||
func TestProjectsAPI_RejectsUnknownConfigKeys(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
repo := gitRepo(t, "rejects-unknown")
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"rej"}`)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
||||
}
|
||||
|
||||
// PUT a config body with an extraneous top-level key.
|
||||
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"defaultBranch":"develop"},"surprise":"!"}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
||||
|
||||
// PUT a config body with a removed field inside the nested config — the
|
||||
// canonical regression: agentRules / tracker are no longer modelled, so
|
||||
// projects can't sneak them back in.
|
||||
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"agentRules":"x"}}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
||||
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"tracker":{"plugin":"github"}}}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
||||
|
||||
// POST /projects gets the same gate, so add-time config rides the same rail.
|
||||
otherRepo := gitRepo(t, "rejects-unknown-add")
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(otherRepo)+`,"projectId":"rej2","config":{"orchestratorRules":"x"}}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
||||
}
|
||||
|
||||
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +127,14 @@ 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) {
|
||||
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)
|
||||
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)
|
||||
|
|
@ -403,6 +488,7 @@ func buildPrompt(cfg ports.SpawnConfig) string {
|
|||
|
||||
func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) {
|
||||
prompt := buildPrompt(cfg)
|
||||
|
||||
switch cfg.Kind {
|
||||
case domain.KindOrchestrator:
|
||||
return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil
|
||||
|
|
@ -465,13 +551,105 @@ 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. Paths must be repo-relative with no
|
||||
// parent traversal (no leading "/", no ".." segment) — a bad path is refused
|
||||
// up front so a project config cannot escape the project or workspace tree.
|
||||
func applySymlinks(projectPath, workspacePath string, symlinks []string) error {
|
||||
for _, rel := range symlinks {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
clean, err := safeRelPath(rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("symlink %q: %w", rel, err)
|
||||
}
|
||||
source := filepath.Join(projectPath, clean)
|
||||
if _, err := os.Stat(source); err != nil {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(workspacePath, clean)
|
||||
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
|
||||
}
|
||||
|
||||
// safeRelPath confines rel to a repo-relative path: no absolute paths and no
|
||||
// ".." segments (before or after Clean). The cleaned form is returned so
|
||||
// callers join it against project/workspace roots safely.
|
||||
func safeRelPath(rel string) (string, error) {
|
||||
if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
|
||||
return "", fmt.Errorf("path must be repo-relative")
|
||||
}
|
||||
clean := filepath.Clean(rel)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == "." || clean == "" {
|
||||
return "", fmt.Errorf("path must be repo-relative")
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.ToSlash(clean), "/") {
|
||||
if seg == ".." {
|
||||
return "", fmt.Errorf("path must be repo-relative")
|
||||
}
|
||||
}
|
||||
return clean, 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 +683,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 +700,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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
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 TestApplySymlinksRejectsParentTraversal(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
// A "..", "/" or "../" segment escapes the project tree and must be refused
|
||||
// before any stat/link runs, so a project config cannot link in arbitrary
|
||||
// host files.
|
||||
for _, bad := range []string{"../escape", "/etc/passwd", "a/../../b", ".."} {
|
||||
if err := applySymlinks(project, workspace, []string{bad}); err == nil {
|
||||
t.Fatalf("applySymlinks(%q) accepted an unsafe path", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,7 @@ type Project struct {
|
|||
DisplayName string
|
||||
RegisteredAt time.Time
|
||||
ArchivedAt sql.NullTime
|
||||
Config sql.NullString
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
# Design: typed per-project configuration
|
||||
|
||||
Status: **partially implemented** — `ProjectConfig` is typed, validated,
|
||||
persisted (one `projects.config` JSON column), and surfaced via
|
||||
`ao project set-config` + `PUT /projects/{id}/config`. The struct deliberately
|
||||
carries only fields with a live consumer: `defaultBranch`, `env`, `symlinks`,
|
||||
`postCreate`, `agentConfig`, and the `worker`/`orchestrator` role overrides are
|
||||
wired at spawn; `sessionPrefix` feeds the display prefix. Settings whose
|
||||
consumers do not yet exist — per-project `tracker`/`scm` config and prompt
|
||||
`rules` — are intentionally **not** modeled yet and land in focused follow-up
|
||||
PRs alongside the code that reads them (see "Sequencing" below). Cross-agent
|
||||
`agentConfig.model`/`permissions` support is tracked in #157.
|
||||
|
||||
## 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.<id>`) 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 { ... }
|
||||
|
||||
// implemented today — only fields with a live consumer are modeled
|
||||
type ProjectConfig struct {
|
||||
DefaultBranch string
|
||||
SessionPrefix string
|
||||
AgentConfig AgentConfig
|
||||
Worker RoleOverride // {Harness, AgentConfig}
|
||||
Orchestrator RoleOverride
|
||||
Env map[string]string
|
||||
Symlinks []string
|
||||
PostCreate []string
|
||||
// future slices add fields here as their consumers land:
|
||||
// AgentRules / AgentRulesFile / OrchestratorRules (prompt rules)
|
||||
// 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.
|
||||
|
|
@ -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,24 @@ export interface components {
|
|||
};
|
||||
Project: {
|
||||
agent?: string;
|
||||
config?: components["schemas"]["ProjectConfig"];
|
||||
defaultBranch: string;
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
repo: string;
|
||||
scm?: components["schemas"]["SCMConfig"];
|
||||
tracker?: components["schemas"]["TrackerConfig"];
|
||||
};
|
||||
ProjectConfig: {
|
||||
agentConfig?: components["schemas"]["AgentConfig"];
|
||||
defaultBranch?: string;
|
||||
env?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
orchestrator?: components["schemas"]["RoleOverride"];
|
||||
postCreate?: string[];
|
||||
sessionPrefix?: string;
|
||||
symlinks?: string[];
|
||||
worker?: components["schemas"]["RoleOverride"];
|
||||
};
|
||||
ProjectGetResponse: {
|
||||
project: components["schemas"]["ProjectOrDegraded"];
|
||||
|
|
@ -413,27 +446,16 @@ export interface components {
|
|||
session: components["schemas"]["Session"];
|
||||
sessionId: string;
|
||||
};
|
||||
RoleOverride: {
|
||||
agent?: string;
|
||||
agentConfig?: components["schemas"]["AgentConfig"];
|
||||
};
|
||||
RollbackSessionResponse: {
|
||||
deleted?: boolean;
|
||||
killed?: boolean;
|
||||
ok: boolean;
|
||||
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;
|
||||
secretEnvVar?: string;
|
||||
signatureHeader?: string;
|
||||
};
|
||||
SendSessionMessageRequest: {
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -484,6 +506,9 @@ export interface components {
|
|||
sessionId: string;
|
||||
state: string;
|
||||
};
|
||||
SetProjectConfigInput: {
|
||||
config: components["schemas"]["ProjectConfig"];
|
||||
};
|
||||
SpawnOrchestratorRequest: {
|
||||
clean?: boolean;
|
||||
projectId: string;
|
||||
|
|
@ -502,11 +527,6 @@ export interface components {
|
|||
projectId: string;
|
||||
prompt?: string;
|
||||
};
|
||||
TrackerConfig: {
|
||||
package?: string;
|
||||
path?: string;
|
||||
plugin?: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
|
|
@ -885,6 +905,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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue