From 687491835ee9cc8a48ec75a466fa5ecdd91f5d23 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 17:55:07 +0530 Subject: [PATCH 1/3] docs: sync per-project-config.md with shipped single-blob implementation Rewrite the Storage strategy, Surface, Sequencing sections and the Field catalog table so they match what shipped: the whole ProjectConfig is one projects.config JSON blob, set via a single PUT /projects/{id}/config and ao project set-config. The originally proposed per-field columns, env project_env child table, and per-group routes (PUT .../agent-config, .../env) are marked superseded/future. Correct the CLI surface (no ao project env set; env is --env KEY=VALUE on set-config). Typed model block and the typed-over-map principle preserved. Closes #199 Co-Authored-By: Claude Opus 4.8 --- docs/design/per-project-config.md | 135 +++++++++++++++++++----------- 1 file changed, 86 insertions(+), 49 deletions(-) diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md index 05af2850..8fc2e84a 100644 --- a/docs/design/per-project-config.md +++ b/docs/design/per-project-config.md @@ -38,26 +38,30 @@ struct** with a `Validate()` method, so: Adapter-specific keys, if ever needed, become typed fields owned by `domain` rather than an escape-hatch map. -## Field catalog (legacy `projects.`) and target home - -| YAML field | Type | Storage today | Target | -| --------------------------------- | ---------------------- | ----------------------------------- | ---------------------------------------------------- | -| `name` | string | `projects.display_name` | done | -| `repo` | string | `projects.repo_origin_url` | done | -| `path` | string | `projects.path` | done | -| `defaultBranch` | string | hardcoded `"main"` | `projects.default_branch` | -| `sessionPrefix` | string | derived | `projects.session_prefix` | -| `agentConfig` | `{model, permissions}` | **`projects.agent_config` (typed)** | **done (this PR)** | -| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | — | typed role-override columns/blob | -| `env` | `map[string]string` | — | `project_env` table (key/value rows) | -| `symlinks` | `[]string` | — | `projects.symlinks` (JSON) | -| `postCreate` | `[]string` | — | `projects.post_create` (JSON) | -| `agentRules` / `agentRulesFile` | string | partial (`SpawnConfig.AgentRules`) | `projects.agent_rules*` | -| `orchestratorRules` | string | — | `projects.orchestrator_rules` | -| `tracker` | `{plugin, …}` | DTO stub only | `projects.tracker` (typed blob) + adapter validation | -| `scm` | `{plugin, webhook{…}}` | DTO stub only | `projects.scm` (typed blob) + adapter validation | -| `opencodeIssueSessionStrategy` | enum | — | `projects.opencode_session_strategy` | -| `reactions` | per-project overrides | — | `project_reactions` (own slice) | +## Field catalog (legacy `projects.`) and home + +`name`, `repo`, and `path` are first-class columns on `projects`. Every other +shipped setting lives as a key inside the single `projects.config` JSON blob; +settings without a live consumer are not modeled yet (see "Sequencing"). + +| YAML field | Type | Home | Status | +| --------------------------------- | ---------------------- | ------------------------------------------ | ---------------------------------------------- | +| `name` | string | `projects.display_name` (column) | done | +| `repo` | string | `projects.repo_origin_url` (column) | done | +| `path` | string | `projects.path` (column) | done | +| `defaultBranch` | string | `config.defaultBranch` | done | +| `sessionPrefix` | string | `config.sessionPrefix` | done | +| `agentConfig` | `{model, permissions}` | `config.agentConfig` | done | +| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | `config.orchestrator` / `config.worker` | done | +| `env` | `map[string]string` | `config.env` | done | +| `symlinks` | `[]string` | `config.symlinks` | done | +| `postCreate` | `[]string` | `config.postCreate` | done | +| `agentRules` / `agentRulesFile` | string | future `config.agentRules*` | not modeled (partial `SpawnConfig.AgentRules`) | +| `orchestratorRules` | string | future `config.orchestratorRules` | not modeled | +| `tracker` | `{plugin, …}` | future `config.tracker` (adapter-validated) | not modeled | +| `scm` | `{plugin, webhook{…}}` | future `config.scm` (adapter-validated) | not modeled | +| `opencodeIssueSessionStrategy` | enum | future `config.*` | not modeled | +| `reactions` | per-project overrides | future (own slice) | not modeled | ## Typed model @@ -92,38 +96,71 @@ 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. +The whole `ProjectConfig` is persisted as **one nullable JSON blob** — the +`projects.config` `TEXT` column (migration `0008_add_project_config.sql`). The +store marshals `ProjectConfig` to JSON on write and unmarshals on read; an empty +config (`IsZero`) persists SQL `NULL`. There are no per-field columns and no +child tables for any config setting: + +- A single column keeps the schema stable as new typed fields are added — a new + setting is a struct field plus a JSON key, never a migration. +- Validation lives in the domain type (`ProjectConfig.Validate` and each leaf's + `Validate`), not in column constraints, so bad values are refused at set time. +- `env` is a plain `map[string]string` key in the blob, not a `project_env` + child table. + +> The originally proposed split — scalars in typed columns, small blobs in +> per-field JSON columns, `env` in a `project_env` child table — was +> **superseded**. The migration comment records the decision: a single JSON +> column persists the "shape of the YAML config" rather than splitting config +> into many columns. If an individual field ever needs its own column (e.g. to +> index or query on it), that becomes a future, field-specific migration. + +## Surface + +A project's config is set as a whole object through a single route, not via +per-group endpoints: + +- **API** — `PUT /api/v1/projects/{id}/config` with body `{ "config": { … } }` + replaces the project's config. The config may also be supplied at registration + via `POST /api/v1/projects`. The daemon validates the typed config and rejects + unknown fields. +- **CLI** — `ao project set-config ` with typed flags: + - `--default-branch`, `--session-prefix` + - `--model`, `--permission` (the `agentConfig` fields) + - `--worker-agent`, `--orchestrator-agent` (role harness overrides) + - `--env KEY=VALUE` (repeatable), `--symlink` (repeatable), + `--post-create` (repeatable) + - `--config-json '{…}'` to pass the whole object, `--clear` to remove all + config, `--json` to print the updated project + + `set-config` replaces the config; there are no per-field subcommands such as + `ao project env set`. `ao project get ` prints the resolved config. +- **UI** — a generated typed form, driven by the OpenAPI schema for the config + object. ## 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 +Shipped slices (all landed inside the single `projects.config` blob, so identity +scalars and workspace provisioning were not separate column/table migrations): + +1. **agentConfig (typed)** — established the typed+validated+surfaced pattern end + to end. +2. **Project identity scalars** — `defaultBranch`, `sessionPrefix` (stop hardcoding/deriving them). -3. **Workspace provisioning** — `env`, `symlinks`, `postCreate` (these change - spawn/workspace wiring, so grouped). -4. **Rules** — `agentRules`, `agentRulesFile`, `orchestratorRules` (consolidate +3. **Workspace provisioning** — `env`, `symlinks`, `postCreate`. +4. **Role overrides** — `worker` / `orchestrator` `{agent, agentConfig}`. + +Remaining (future) slices, each adding a typed field to `ProjectConfig` (plus +validation, CLI flags, and UI) as its consumer lands — no schema migration +required: + +5. **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. +6. **Tracker / SCM per-project** — typed config with adapter-owned validation. +7. **Per-project reactions** — integrate with the reaction engine; may warrant + its own slice/storage rather than the config blob. -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. +Each slice follows the same shape: domain field + `Validate()` → JSON key in the +config blob → service set/get → the single config route → CLI flags → UI form → +tests. From 96872333cdb9d65d959244e61d3d75c7d4f123b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Jun 2026 12:25:24 +0000 Subject: [PATCH 2/3] chore: format with prettier [skip ci] --- docs/design/per-project-config.md | 39 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md index 8fc2e84a..b674668c 100644 --- a/docs/design/per-project-config.md +++ b/docs/design/per-project-config.md @@ -44,24 +44,24 @@ rather than an escape-hatch map. shipped setting lives as a key inside the single `projects.config` JSON blob; settings without a live consumer are not modeled yet (see "Sequencing"). -| YAML field | Type | Home | Status | -| --------------------------------- | ---------------------- | ------------------------------------------ | ---------------------------------------------- | -| `name` | string | `projects.display_name` (column) | done | -| `repo` | string | `projects.repo_origin_url` (column) | done | -| `path` | string | `projects.path` (column) | done | -| `defaultBranch` | string | `config.defaultBranch` | done | -| `sessionPrefix` | string | `config.sessionPrefix` | done | -| `agentConfig` | `{model, permissions}` | `config.agentConfig` | done | -| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | `config.orchestrator` / `config.worker` | done | -| `env` | `map[string]string` | `config.env` | done | -| `symlinks` | `[]string` | `config.symlinks` | done | -| `postCreate` | `[]string` | `config.postCreate` | done | -| `agentRules` / `agentRulesFile` | string | future `config.agentRules*` | not modeled (partial `SpawnConfig.AgentRules`) | -| `orchestratorRules` | string | future `config.orchestratorRules` | not modeled | -| `tracker` | `{plugin, …}` | future `config.tracker` (adapter-validated) | not modeled | -| `scm` | `{plugin, webhook{…}}` | future `config.scm` (adapter-validated) | not modeled | -| `opencodeIssueSessionStrategy` | enum | future `config.*` | not modeled | -| `reactions` | per-project overrides | future (own slice) | not modeled | +| YAML field | Type | Home | Status | +| --------------------------------- | ---------------------- | ------------------------------------------- | ---------------------------------------------- | +| `name` | string | `projects.display_name` (column) | done | +| `repo` | string | `projects.repo_origin_url` (column) | done | +| `path` | string | `projects.path` (column) | done | +| `defaultBranch` | string | `config.defaultBranch` | done | +| `sessionPrefix` | string | `config.sessionPrefix` | done | +| `agentConfig` | `{model, permissions}` | `config.agentConfig` | done | +| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | `config.orchestrator` / `config.worker` | done | +| `env` | `map[string]string` | `config.env` | done | +| `symlinks` | `[]string` | `config.symlinks` | done | +| `postCreate` | `[]string` | `config.postCreate` | done | +| `agentRules` / `agentRulesFile` | string | future `config.agentRules*` | not modeled (partial `SpawnConfig.AgentRules`) | +| `orchestratorRules` | string | future `config.orchestratorRules` | not modeled | +| `tracker` | `{plugin, …}` | future `config.tracker` (adapter-validated) | not modeled | +| `scm` | `{plugin, webhook{…}}` | future `config.scm` (adapter-validated) | not modeled | +| `opencodeIssueSessionStrategy` | enum | future `config.*` | not modeled | +| `reactions` | per-project overrides | future (own slice) | not modeled | ## Typed model @@ -133,9 +133,10 @@ per-group endpoints: `--post-create` (repeatable) - `--config-json '{…}'` to pass the whole object, `--clear` to remove all config, `--json` to print the updated project - + `set-config` replaces the config; there are no per-field subcommands such as `ao project env set`. `ao project get ` prints the resolved config. + - **UI** — a generated typed form, driven by the OpenAPI schema for the config object. From 21538098c682dd96fe47c35328221e5b66f90a99 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Thu, 18 Jun 2026 15:23:44 +0530 Subject: [PATCH 3/3] remove unwanted docs --- docs/design/per-project-config.md | 167 ------------------------------ 1 file changed, 167 deletions(-) delete mode 100644 docs/design/per-project-config.md diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md deleted file mode 100644 index b674668c..00000000 --- a/docs/design/per-project-config.md +++ /dev/null @@ -1,167 +0,0 @@ -# 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.`) and home - -`name`, `repo`, and `path` are first-class columns on `projects`. Every other -shipped setting lives as a key inside the single `projects.config` JSON blob; -settings without a live consumer are not modeled yet (see "Sequencing"). - -| YAML field | Type | Home | Status | -| --------------------------------- | ---------------------- | ------------------------------------------- | ---------------------------------------------- | -| `name` | string | `projects.display_name` (column) | done | -| `repo` | string | `projects.repo_origin_url` (column) | done | -| `path` | string | `projects.path` (column) | done | -| `defaultBranch` | string | `config.defaultBranch` | done | -| `sessionPrefix` | string | `config.sessionPrefix` | done | -| `agentConfig` | `{model, permissions}` | `config.agentConfig` | done | -| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | `config.orchestrator` / `config.worker` | done | -| `env` | `map[string]string` | `config.env` | done | -| `symlinks` | `[]string` | `config.symlinks` | done | -| `postCreate` | `[]string` | `config.postCreate` | done | -| `agentRules` / `agentRulesFile` | string | future `config.agentRules*` | not modeled (partial `SpawnConfig.AgentRules`) | -| `orchestratorRules` | string | future `config.orchestratorRules` | not modeled | -| `tracker` | `{plugin, …}` | future `config.tracker` (adapter-validated) | not modeled | -| `scm` | `{plugin, webhook{…}}` | future `config.scm` (adapter-validated) | not modeled | -| `opencodeIssueSessionStrategy` | enum | future `config.*` | not modeled | -| `reactions` | per-project overrides | future (own slice) | not modeled | - -## 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 - -The whole `ProjectConfig` is persisted as **one nullable JSON blob** — the -`projects.config` `TEXT` column (migration `0008_add_project_config.sql`). The -store marshals `ProjectConfig` to JSON on write and unmarshals on read; an empty -config (`IsZero`) persists SQL `NULL`. There are no per-field columns and no -child tables for any config setting: - -- A single column keeps the schema stable as new typed fields are added — a new - setting is a struct field plus a JSON key, never a migration. -- Validation lives in the domain type (`ProjectConfig.Validate` and each leaf's - `Validate`), not in column constraints, so bad values are refused at set time. -- `env` is a plain `map[string]string` key in the blob, not a `project_env` - child table. - -> The originally proposed split — scalars in typed columns, small blobs in -> per-field JSON columns, `env` in a `project_env` child table — was -> **superseded**. The migration comment records the decision: a single JSON -> column persists the "shape of the YAML config" rather than splitting config -> into many columns. If an individual field ever needs its own column (e.g. to -> index or query on it), that becomes a future, field-specific migration. - -## Surface - -A project's config is set as a whole object through a single route, not via -per-group endpoints: - -- **API** — `PUT /api/v1/projects/{id}/config` with body `{ "config": { … } }` - replaces the project's config. The config may also be supplied at registration - via `POST /api/v1/projects`. The daemon validates the typed config and rejects - unknown fields. -- **CLI** — `ao project set-config ` with typed flags: - - `--default-branch`, `--session-prefix` - - `--model`, `--permission` (the `agentConfig` fields) - - `--worker-agent`, `--orchestrator-agent` (role harness overrides) - - `--env KEY=VALUE` (repeatable), `--symlink` (repeatable), - `--post-create` (repeatable) - - `--config-json '{…}'` to pass the whole object, `--clear` to remove all - config, `--json` to print the updated project - - `set-config` replaces the config; there are no per-field subcommands such as - `ao project env set`. `ao project get ` prints the resolved config. - -- **UI** — a generated typed form, driven by the OpenAPI schema for the config - object. - -## Sequencing (one slice per PR) - -Shipped slices (all landed inside the single `projects.config` blob, so identity -scalars and workspace provisioning were not separate column/table migrations): - -1. **agentConfig (typed)** — established the typed+validated+surfaced pattern end - to end. -2. **Project identity scalars** — `defaultBranch`, `sessionPrefix` (stop - hardcoding/deriving them). -3. **Workspace provisioning** — `env`, `symlinks`, `postCreate`. -4. **Role overrides** — `worker` / `orchestrator` `{agent, agentConfig}`. - -Remaining (future) slices, each adding a typed field to `ProjectConfig` (plus -validation, CLI flags, and UI) as its consumer lands — no schema migration -required: - -5. **Rules** — `agentRules`, `agentRulesFile`, `orchestratorRules` (consolidate - the partial `SpawnConfig.AgentRules` path). -6. **Tracker / SCM per-project** — typed config with adapter-owned validation. -7. **Per-project reactions** — integrate with the reaction engine; may warrant - its own slice/storage rather than the config blob. - -Each slice follows the same shape: domain field + `Validate()` → JSON key in the -config blob → service set/get → the single config route → CLI flags → UI form → -tests.