Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ These are well-documented in code; read the relevant service files for details:
- **Reconciliation control plane** (`workers/reconcile-worker.ts`, `services/reconcile-{snapshot,executor,queue}.ts`, `packages/shared/src/reconcile/`): K8s-style reconciler with four `RunKind`s — `repo` (Repo Task runs in `tasks`), `standalone` (Job runs: `tasks` rows with `kind = 'standalone'`, through the `workflow_runs` view), `pr-review` (external PR reviews), and `persistent-agent` (Persistent Agents in `persistent_agents`). Pure decision functions consume a frozen `WorldSnapshot` and return a typed `Action`; the executor applies it under CAS so concurrent passes can't trample each other. Producers — `taskService.transitionTask`, `workflow-service`'s `transitionWorkflowRunCas`, `pr-watcher` poll cycle, `repo-cleanup` pod-health detection, `wakeAgent()` (PA inbox + trigger dispatch) — wake the reconciler via `enqueueReconcile`. Periodic resync (`OPTIO_RECONCILE_RESYNC_INTERVAL`, 5 min) catches anything missed. The reconciler owns: PR-driven transitions (auto-merge, complete-on-merge, fail-on-close), auto-resume on CI/conflict/review (capped by `OPTIO_MAX_AUTO_RESUMES`), review launch, stall + pod-death detection, control-intent (cancel/retry/resume/restart), and the Persistent Agent turn cycle. Schema: `control_intent`, `reconcile_backoff_until`, `reconcile_attempts` columns on `tasks` (every run) and `persistent_agents`. See `docs/reconciliation.md`
- **Optio Local** (`local-host-service.ts`, `local-terminal-service.ts`, `local-blueprint-service.ts`, `local-relay.ts`, ws in `ws/local-daemon.ts` + `ws/local-terminal-stream.ts`, routes in `routes/local.ts`, daemon in `apps/cli/src/local/`): terminals on the **user's own machine**, managed from the web UI at `/local`. The CLI daemon (`optio local up`) makes one outbound WS to `/ws/local/daemon` (PAT auth), advertises a dir allowlist (kept in the machine's `local.json`; the Machines page and the New work form add / remove dirs through the daemon — a `dirs` frame, `local-dirs-service.ts` — unless it runs with `--no-remote-dirs`), and runs node-pty PTYs; the in-process relay (`local-relay.ts`, single API replica) routes frames to browser viewers on `/ws/local/terminals/:id/stream` (binary = terminal bytes, JSON = control); which viewer's screen the PTY is sized for is decided there too (`local-grid.ts`: the screen in use, from the viewers' `view` reports). Attention detection is daemon-side and layered: Claude Code hooks (Stop/Notification/UserPromptSubmit via a localhost hook server) → OSC-aware bell scanner → silence heuristic; persisted as `attention_state` (`working`/`needs_you`/`idle`) driving the cockpit's "needs you" queue. **Local Automations** (`work_definitions` rows with `kind = 'local-blueprint'`, "blueprints" in code) are agent / terminal specs spawned by `workflow_triggers` with `target_type="local_blueprint"`: any of the seven trigger types incl. the events `github` / `slack` / `linear` (signed ingress in `routes/event-ingress.ts` + the existing GitHub receiver; matching in `event-trigger-service.ts`, the same path that fires pod-side Jobs and scheduled Tasks). `sessionMode` picks `interactive` (agent halts at its prompt for chat) or `headless` (`claude -p` etc., exits when done); the daemon reports the agent's `session_id` so an exited run can be resumed (`POST /api/local/terminals/:id/resume`). Trigger params reach a command through `renderCommandTemplate` (shell variables, never spliced in) — payloads can never inject commands. Hosts are per-user (never workspace compute); local agents use the machine's own CLI auth (no server secrets ship to laptops). The daemon launches Claude Code with `--permission-mode auto` unless the spawn's `permissionMode` says otherwise (a headless `claude -p` would otherwise deny everything that needs approval), passes `effort`, and reports the models its Codex offers (`codex debug models`, an `agent-models` frame, merged into `GET /api/agents/openai/options`); see `cli/src/local/agent-command.ts` and `cli-probes.ts`. **Local runs** (`local-run-service.ts`): every Task, Job, and scheduled-Task blueprint has a run location (`run_target` = `cluster` | `local`, plus `local_host_id` / `local_dir` / `local_session_mode`) picked with the shared `components/run-location-picker.tsx`; the task / workflow workers dispatch `local` runs to the daemon as an agent terminal (`spawned_by = "job" | "task"`, `local_terminal_id` on the run) instead of a pod, and the terminal's frames drive the run's state (`syncLinkedRun`: running, usage → cost, PR link → `pr_opened`, exit → completed / failed). The reconciler skips capacity / stall / pod checks for local runs. Schema: `local_hosts`, `local_terminals`, `work_definitions`. See `docs/optio-local.md`
- **Organization and private scope** (`services/ownership.ts`; the work-specific rules in `work-ownership.ts`): every scoped resource — secrets, connections, model providers, MCP servers, skills, prompts, and work of every kind — is the **organization's** (`owner_user_id` null; everyone in the workspace sees it) or one person's **private** one (visible to its owner alone, **read-only to workspace admins**, who see it named with its owner and may delete it; changing / running / using it is a 403 for everyone else, admins included). `visibleOwner` is the SQL term every list uses, `canSee` / `canChange` the row forms, `usableBy` what a piece of work may be given (the organization's rows plus its owner's own). A member fetching someone else's private row gets a 404. Private work is hidden from other members everywhere (Work list, per-kind lists and details, recent runs, the events socket); a private agent is addressable only by its owner's agents. Private secrets (`scope = "user"`) are stored without a workspace (a boot-time heal re-binds older rows). UI vocabulary is fixed: pickers **Organization / Private**, sections **Organization / Private / Other people's**, chip **Private** (`lib/owner.ts`, `hooks/use-current-user.ts`, `components/ui/{owner-segments,scoped-list,owner-picker,owner-chip}.tsx`; the Secrets page is the exemplar). See `docs/scope.md`
- **Config as code** (`services/config/`, `routes/config.ts`, `workers/config-sync-worker.ts`, schemas in `schemas/config.ts`, shared types in `packages/shared/src/config/`): 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`; references by **name**, secrets by name or `${{SECRET_NAME}}`, never values). A cluster mounts a directory of them (`OPTIO_CONFIG_DIR`; Helm `configAsCode.*`) that the sync worker reads every `OPTIO_CONFIG_INTERVAL` and **applies** to one workspace (`OPTIO_CONFIG_WORKSPACE` slug, default the oldest): create / adopt / update (a managed row edited in the UI is **put back** and reported as reverted) / replace / prune (`OPTIO_CONFIG_PRUNE`, default on), each manifest its own error. One handler per kind in `services/config/kinds/*.ts` (desire / find / diff / create / update / remove / export); the engine is `apply.ts`. Managed rows carry `managedBy` (`withManagedBy` / `withManagedWork`, nothing is managed unless `OPTIO_CONFIG_DIR` is set) and show a **Managed** chip and banner; **Detach** stops it. `GET /api/config/schema.json` (public) is the JSON Schema; `GET /api/config/export[.yaml]` exports; `POST /api/config/apply` is what `optio apply -f` posts (a plain upsert: manages nothing, never prunes); `optio export -o DIR` writes one file per resource. Settings → Config as code shows the directory's last sync. See `docs/config-as-code.md`
- **Model providers, owners, pod secrets** (`model-provider-service.ts`, `work-ownership.ts`, routes in `model-providers.ts`, `bedrockRuntime` in `@optio/shared`): Settings → Model providers saves Amazon Bedrock for Claude Code / Codex (region, per-agent model ids, a machine AWS profile, encrypted pod credentials or the pod's IAM role); work picks one with `agentOptions.modelProvider`. Providers, secrets, connections and work have an owner — the organization (`owner_user_id` null) or one person: private work runs with its owner's secrets / providers / connections and only its owner changes or runs it; organization work only uses organization resources. `podSecrets` is the list of secrets a piece of work gives its pod (`GET /api/secrets/pickable`). Workspaces can auto-join people by verified email domain. See `docs/model-providers.md`
- **Task dependencies**: `task_dependencies` table for multi-step pipelines
- **Cost tracking**: `GET /api/analytics/costs` with daily/repo/type breakdowns, UI at `/costs`
Expand Down Expand Up @@ -321,6 +322,7 @@ Key `values.yaml` settings:
6. Enable ingress with TLS
7. Set `GITHUB_TOKEN` secret for PR watching, issue sync, repo detection
8. Install `metrics-server` in cluster
9. Optional: keep the workspace's Jobs, agents, prompts, repos and integrations in a repo as manifests and mount them (`configAsCode.*`; `docs/config-as-code.md`)

## Known Issues

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ API ......... http://localhost:30400

On Docker Desktop Kubernetes these are NodePorts reached via `kubectl port-forward` — the web UI listens on **30310** and the API on **30400**.

Open the web UI and the setup wizard will walk you through configuring sign-in (your organization's Google Workspace — the wizard asks for the one-time setup token the API prints in its log, and the first person to sign in becomes the deployment admin; or any OAuth provider by environment variables), GitHub access, agent credentials (API key, OAuth token, Vertex AI, or a Max/Pro subscription), and adding your first repository. Then hit **New session** and pick a preset — _Open a PR_, _Interactive chat_, _Scheduled run_, or _Persistent agent_ — or compose your own from the five attributes.
Open the web UI and the setup wizard will walk you through configuring sign-in (your organization's Google Workspace — the wizard asks for the one-time setup token the API prints in its log, and the first person to sign in becomes the deployment admin; or any OAuth provider by environment variables), GitHub access, agent credentials (API key, OAuth token, Vertex AI, or a Max/Pro subscription), and adding your first repository. Teams that keep infrastructure in a repo can keep Optio there too: every Job, agent, prompt, repo, MCP server, skill and connection is a YAML manifest a cluster reads from a mounted directory (`docs/config-as-code.md`; `optio export -o optio/` writes what you have). Then hit **New session** and pick a preset — _Open a PR_, _Interactive chat_, _Scheduled run_, or _Persistent agent_ — or compose your own from the five attributes.

### Pair your own machine (optional)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ data class AgentConfig(

// endregion

// region config.ts

/**
* Config as code: what a resource that a configuration directory manages
* carries in every list and detail response (`docs/config-as-code.md`).
* The file is the truth: edits made in the UI are put back at the next sync.
*/
@Serializable
data class ManagedBy(
/** The `config_objects` row — `POST /api/config/objects/:id/detach` takes it. */
val objectId: String,
val sourceId: String,
/** The source's name as Settings shows it ("config directory"). */
val sourceName: String,
/** The manifest's file, relative to the source's directory. */
val path: String,
/** The manifest kind: Work, Prompt, Repo, McpServer, Skill, Connection. */
val kind: String,
)

// endregion

// region connection.ts

@Serializable
Expand Down Expand Up @@ -334,6 +356,8 @@ data class Connection(
val ownerUserId: String? = null,
/** Display name of `ownerUserId`, for a private connection (lists only). */
val ownerName: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
val enabled: Boolean,
val status: ConnectionStatus,
val statusMessage: String? = null,
Expand Down Expand Up @@ -433,6 +457,8 @@ data class RepoConnection(
val ownerUserId: String? = null,
/** Display name of `ownerUserId`, for a private connection (lists only). */
val ownerName: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
val enabled: Boolean,
val status: ConnectionStatus,
val statusMessage: String? = null,
Expand Down Expand Up @@ -2446,6 +2472,8 @@ data class McpServerConfig(
*/
val ownerUserId: String? = null,
val ownerName: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
val enabled: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
Expand Down Expand Up @@ -2514,6 +2542,8 @@ data class CustomSkillConfig(
*/
val ownerUserId: String? = null,
val ownerName: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
val layout: CustomSkillLayout,
/** Extra files for skill-dir layout. Null/empty = none. */
val files: List<CustomSkillFile>? = null,
Expand Down Expand Up @@ -2599,6 +2629,8 @@ data class InstalledSkillConfig(
*/
val ownerUserId: String? = null,
val ownerName: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
val agentTypes: List<String>? = null,
val enabled: Boolean,
val lastSyncedAt: Instant? = null,
Expand Down Expand Up @@ -3015,6 +3047,8 @@ data class PersistentAgent(
* connections, and only they can change it.
*/
val ownerUserId: String? = null,
/** Set when a configuration directory manages it (the file is the truth). */
val managedBy: ManagedBy? = null,
/**
* The secrets (by name) the agent gets in its pod. Null = the workspace's
* legacy behavior (see `Workspace.restrictPodSecrets`).
Expand Down
Loading
Loading