Skip to content

feat(config): config as code — manifests, a mounted directory the API applies, export/apply - #634

Merged
jonwiggins merged 4 commits into
mainfrom
feat/config-as-code
Oct 4, 2026
Merged

jonwiggins merged 4 commits into
mainfrom
feat/config-as-code

Conversation

@jonwiggins

Copy link
Copy Markdown
Owner

Config as code

Teams that keep infrastructure in a repository can keep Optio there too. Every Job, scheduled Task, persistent agent, prompt, repo, MCP server, skill and connection can be a YAML manifest; a cluster mounts a directory of them and keeps the workspace matching the files. Design and the decisions behind it: docs/plans/config-as-code.md; behavior: docs/config-as-code.md.

The format

apiVersion: optio/v1
kind: Work
metadata: { name: nightly-dependency-bump }
spec:
  when: { schedule: "0 3 * * 1-5" }
  where: { repo: https://github.com/acme/api, branch: main }
  who: { runtime: claude-code }
  what: { promptFile: ./prompts/dependency-bump.md }
  then: until-merged
  secrets: [GITHUB_TOKEN]
  environment: { connections: { add: [Linear] } }

Six kinds (Work, Prompt, Repo, McpServer, Skill, Connection); metadata.name is the identity; references are names (repos by URL), secrets by name or ${{SECRET_NAME}} and never values (a credential written in the clear is refused; exports write the reference instead). *File fields pull long prompts and skill directories from next to the manifest. GET /api/config/schema.json is the JSON Schema for editors and CI.

The directory

OPTIO_CONFIG_DIR (Helm configAsCode.*: inline files, an existing configMap, or anything mounted through the new api.extraVolumes / extraVolumeMounts) turns it on for one workspace (OPTIO_CONFIG_WORKSPACE slug; default the oldest, or the no-workspace tenant when auth is disabled). A worker reads it every OPTIO_CONFIG_INTERVAL and applies: create / adopt / update / replace / prune (OPTIO_CONFIG_PRUNE, default on), each manifest its own error item. A managed row someone edits in the UI is put back on the next sync and reported as reverted (the decision: rows stay editable, the file wins). The apply is idempotent: a quiet tick writes nothing.

What people see

  • managedBy on every list and detail response of the six kinds; a Managed chip next to the Private chip in lists; a banner on detail and edit pages (the file, "edits here are put back at the next sync", YAML, and Detach for admins); Download YAML per resource.
  • Settings → Config as code: the directory, its workspace, the last sync (counts and every error with its file), Sync now, Preview (dry run), Export YAML.
  • CLI: optio export [-o DIR], optio apply -f FILE|DIR [--dry-run], optio diff -f, optio schema. A CLI apply is a plain upsert: it manages nothing and never prunes.

Code

apps/api/src/services/config/ — apply.ts (the engine), kinds/*.ts (one handler per kind: desire / find / diff / create / update / remove / export), context.ts (name resolution), files.ts, source.ts, managed.ts, export.ts; schemas/config.ts, routes/config.ts, workers/config-sync-worker.ts; shared types and *File inlining in packages/shared/src/config/. updateWork now saves persistent agents too; createWork can skip a Job's first run. Migration 1791970000_config_as_code adds config_sources and config_objects.

Tests

  • Unit: manifest schema, *File inlining, compare, directory reading, when mapping, CLI reader.
  • Integration (config-apply.int.test.ts, 11): every kind created with names resolved, no-op re-apply, drift reverted, a changed file incl. trigger, replace on kind change, prune and prune-off, per-manifest errors, adopt, detach, export round trip.
  • Pipeline e2e (config-as-code.e2e.test.ts, 6): boot with OPTIO_CONFIG_DIR, managedBy through the strict response schemas, revert via sync, public schema + CLI-style apply, export, detach.
  • Playwright (config-as-code.spec.ts): the Settings card and Sync now, the Managed chip on Prompts.
  • Live: deployed to the local cluster with a ConfigMap-mounted manifest; the Job appeared, the Settings card showed the sync, pruning removed it when the file went.

Not in this PR

Git repositories polled by the API or CI-push sources (the tables keep a kind); a manifest field for trigger paramMapping; webhook secrets by reference.

…apply in the CLI and UI

Config as code (docs/config-as-code.md): every Job, scheduled Task,
persistent agent, prompt, repo, MCP server, skill and connection can be a
YAML manifest (apiVersion optio/v1, kind, metadata.name = identity, spec),
with references by name and secrets by name or ${{SECRET_NAME}}, never values.

- A cluster mounts a directory of them (OPTIO_CONFIG_DIR; Helm configAsCode.*,
  plus api.extraVolumes / extraVolumeMounts) that a worker reads every
  interval and applies to one workspace: create / adopt / update (a managed
  row edited in the UI is put back and reported as reverted) / replace /
  prune, each manifest its own error. Tables config_sources, config_objects.
- One handler per kind (services/config/kinds), the engine in apply.ts, name
  resolution in context.ts, reading a directory in files.ts; dry runs plan
  without writing and register would-be rows so later manifests resolve.
- Managed rows carry managedBy in every list and detail response; the web
  shows a Managed chip next to the Private one, a banner on detail and edit
  pages with the YAML and Detach, Download YAML, and Settings → Config as
  code (directory, last sync with per-file errors, Sync now, Preview, Export).
- GET /api/config/schema.json (public JSON Schema), status, source/sync,
  apply, export[.yaml], objects/:id/detach. CLI: optio export [-o DIR],
  optio apply -f, optio diff -f, optio schema.
- updateWork saves persistent agents too; createWork can skip a Job's first run.

Tests: unit (schema, inlining, compare, files, when mapping, CLI reader),
integration (apply: every kind, drift, replace, prune, adopt, errors, detach,
export round trip), pipeline e2e (OPTIO_CONFIG_DIR at boot over HTTP),
Playwright (Settings card, Managed chip). Swift/Kotlin types regenerated.
…values, one spec locator

- The directory walker follows symlinks: kubelet lays a ConfigMap out as
  links into a ..data snapshot, so Dirent.isFile() alone saw an empty
  directory (the live check synced nothing). Covered in files.test.ts.
- configAsCode.mountPath and intervalMs fall back to the chart defaults in
  the templates: an upgrade with --reuse-values doesn't pick up new defaults
  and rendered an empty mountPath.
- The Playwright Settings assertion picks the first of the two count labels.
# Conflicts:
#	apps/web/src/app/connections/page.tsx
#	apps/web/src/app/jobs/[id]/page.tsx
#	apps/web/src/app/repos/[id]/page.tsx
#	apps/web/src/app/templates/page.tsx
#	apps/web/src/components/ui/README.md
#	apps/web/src/components/work-form/work-form.tsx
A key dropped from a Secret's stringData lingers in its data, so disabling
configAsCode left the pod with the old directory and the sync running (seen
on the local cluster). An explicit empty value turns it off.
@jonwiggins
jonwiggins merged commit bfe577e into main Oct 4, 2026
13 checks passed
@jonwiggins
jonwiggins deleted the feat/config-as-code branch October 4, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant