Skip to content

Tags: angr1it/optio

Tags

v0.1.2

Toggle v0.1.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: persist deploy/runtime config for agent and redis (#17)

v0.1.1

Toggle v0.1.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Feature/merge with main (#15)

* feat: add GitLab support via git platform abstraction layer

Introduce a GitPlatform interface (strategy pattern) with GitHub and GitLab
implementations, replacing ~25 hardcoded GitHub API fetch calls across 10+
service files. This enables Optio to orchestrate tasks against GitLab repos
with full MR watching, CI status, review, auto-merge, and issue sync support.

Key changes:
- GitPlatform interface with 15 methods (PR/MR, CI, review, issue, repo)
- GitHubPlatform + GitLabPlatform implementations
- parseRepoUrl()/parsePrUrl() for platform-aware URL parsing
- git-token-service with getGitPlatformForRepo() entry point
- DB migration adding git_platform column to repos table
- GitLabTicketProvider for issue sync
- Envoy sidecar proxy support for GitLab API
- repo-init.sh credential setup for GitLab (GITLAB_TOKEN, glab CLI)
- Setup API endpoints for GitLab token validation and project listing
- UI label generalization ("GitHub Issues" → "Issues")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: support for gitlab

* fix: support nested {{#if}} blocks in prompt template renderer

The single-pass regex broke when GitLab support introduced nested
conditionals. Process innermost blocks first, iterating until all
nesting is resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: gitlab support

* fix: extract actual review summary from agent output instead of hardcoded message

The review summary UI showed "Agent completed successfully" instead of the
agent's actual findings. Root causes:

- ClaudeCodeAdapter.parseResult() hardcoded summary text, ignoring the result event
- agent-event-parser truncated tool_result to 300 chars, cutting off review JSON
- parseReviewOutput() fallback was ineffective for generic summaries

Also includes:
- Review draft cleanup on force-redo to prevent stale summaries
- Increase DEFAULT_MAX_TURNS_REVIEW from 10 to 30
- Add turn budget warning to review prompt template
- Rename githubReviewUrl to reviewUrl for platform-neutral naming

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve Jira provider and ticket sync

- Switch to searchForIssuesUsingJqlEnhancedSearch (cursor-based pagination)
- Smart repo URL matching: match ticket repo field against configured repos
- Support full URLs, owner/repo paths, and repo: labels in Jira tickets
- Add/delete ticket provider UI in settings page
- Add deleteTicketProvider API client method

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: derive WebSocket URL from PUBLIC_API_URL for split-origin setups

When web and API run on different origins (e.g. NodePort with separate
ports), WebSocket connections failed because ws-client derived the URL
from the page origin instead of the API origin.

- Inject PUBLIC_API_URL at runtime via window.__OPTIO_CONFIG in layout.tsx
- ws-client reads it to derive the correct ws:// or wss:// URL
- Helm web-deployment auto-sets PUBLIC_API_URL for NodePort configurations
- Fix PGDATA path for postgres to prevent initdb issues on restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: update review max turns test to match new default (30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: as per comments

* fix: harden ws-url-config PR per review feedback

- Escape </script> sequences in runtime config JSON to prevent XSS
- Remove leaked globalThis.__OPTIO_CONFIG in ws-client test cleanup
- Remove unscoped PGDATA change (belongs in its own PR)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename githubReviewUrl to reviewUrl for client compatibility

The API returned `githubReviewUrl` but the web client expected `reviewUrl`,
causing the review URL to always be undefined after submitting a draft.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore PUBLIC_URL env var in web-deployment.yaml (#294)

PR #290 replaced the PUBLIC_URL env var block with PUBLIC_API_URL instead
of adding it alongside. This broke OAuth login for deployments with
publicUrl set, since the auth callback route uses PUBLIC_URL to construct
redirect URLs.

Restore the PUBLIC_URL block before PUBLIC_API_URL so both env vars are
present in the web container.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dynamically import SessionTerminal to prevent SSR "self is not defined" error (#295)

The xterm.js library accesses `self` during module initialization, which
crashes Next.js server-side rendering. Using next/dynamic with ssr: false
ensures the terminal component is only loaded on the client side.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback from PR #288 (Jira provider improvements) (#296)

- Hoist configuredRepos query above the provider loop to avoid redundant
  DB queries when multiple providers are configured
- Add .trim() to Jira repo: label extraction so labels like
  "repo: acme/backend" match correctly
- Move sensitive provider credentials (apiToken, apiKey) from plaintext
  JSONB config to encrypted secrets store (AES-256-GCM)
- Refactor test mock to match on .from() table argument instead of
  brittle call-count ordering
- Use provider config baseUrl for GitLab repo URL construction instead
  of hardcoded https://gitlab.com fallback

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include all workspace members in Dockerfile deps stages (#297)

The Dockerfile.api deps stage was missing apps/web and apps/site
workspace members, and Dockerfile.web was missing apps/site. pnpm
needs the full workspace graph to correctly resolve the lockfile
with --frozen-lockfile, so omitting members causes the install to
fail or produce incorrect workspace links.

Also removed the --prod flag from Dockerfile.api so that workspace
package dependencies are fully resolved, matching the pattern used
in Dockerfile.web.

Added a regression test that validates both Dockerfiles include all
workspace members defined in pnpm-workspace.yaml.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: make Helm chart publicly available (#298)

- Add .helmignore to exclude unnecessary files from chart packages
- Enrich Chart.yaml with home, sources, keywords, and maintainers metadata
- Update README with three installation methods (Helm repo, OCI, source)

Note: Workflow changes (CI helm-lint job, release.yml OCI visibility fix,
and helm-release.yml for chart-releaser) are included in the PR description
and need to be applied separately due to token scope limitations.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(site): serve marketing site from optio.host custom domain

Add CNAME file and drop /optio basePath now that the site is served
from the apex domain instead of jonwiggins.github.io/optio/.

* fix: don't require GITHUB_TOKEN secret when GitHub App auth is configured

GITHUB_TOKEN was unconditionally added to the required secrets list,
causing resolveSecretsForTask() to throw before reaching the
isGitHubAppConfigured() guard. GitHub App users get credentials
dynamically via the credential helper — no static PAT needed.

* fix(helm): enable readOnlyRootFilesystem on web and API pods (#312)

Set readOnlyRootFilesystem: true and add seccompProfile: RuntimeDefault
to both web and API container security contexts. Mount emptyDir volumes
for /tmp (both pods) and /app/.next/cache (web pod) so the containers
can still write to the directories they need while preventing writes to
the rest of the root filesystem.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): add SSRF validation to slackWebhookUrl on PATCH /api/repos/:id (#313)

The updateRepoSchema accepted any string for slackWebhookUrl without
SSRF checks, while POST /api/slack/test already validated with
isSsrfSafeUrl. An admin could store an internal URL that the API pod
would later request when sending Slack notifications.

Apply the same isSsrfSafeUrl Zod refine used in the Slack test route
to the repo update schema. The existing assertSsrfSafe call in
slack-service.ts before fetch provides defense-in-depth against DNS
rebinding.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(web): add security headers to Next.js config (#314)

Add HTTP security headers via the Next.js headers() config function:
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: restrict camera, microphone, geolocation
- Content-Security-Policy-Report-Only: restrictive CSP in report-only mode
- Strict-Transport-Security: HSTS with preload (production only)

CSP is deployed in report-only mode to avoid breaking existing
functionality. Once verified clean, it can be switched to enforcing.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): add rate limiting to auth endpoints (#315)

Auth endpoints under /api/auth/* had no stricter rate limiting than the
global 100 req/min, enabling credential stuffing, account enumeration,
and brute-force attacks on the OAuth state/code namespace. This adds
per-route rate limits of 10 req/min on login, callback, exchange-code,
and logout endpoints. Also configures Redis as the rate-limit store so
limits work correctly across multiple API replicas.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): use constant-time comparison for session tokens and add sliding-window expiry (#316)

- Add timingSafeEqual for session token hash comparison (defense-in-depth)
- Replace fixed 30-day session TTL with 7-day sliding window capped at 30 days
- Revoke all user sessions on workspace role change or member removal
- Move expiry check from DB WHERE clause to application code for proper ordering

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): restrict public route matching for /api/auth/* endpoints (#318)

The PUBLIC_ROUTES list used a prefix match on `/api/auth/`, making every
endpoint under that path (including `/api/auth/claude-token`) accessible
without authentication. Replace the blanket prefix with an explicit
allow-list of OAuth login/callback routes so that sensitive endpoints
like claude-token, status, usage, and me require a valid session.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): use 12-byte IV and AAD binding for AES-256-GCM encryption (#319)

Switch from 16-byte to NIST SP 800-38D recommended 12-byte IV for
AES-256-GCM. Add Additional Authenticated Data (AAD) binding so
encrypted blobs are tied to their identity context (name, scope,
workspace), preventing row-swap attacks where a ciphertext is moved
to a different context and still decrypts.

- encrypt()/decrypt() accept optional AAD parameter
- buildSecretAAD() helper builds context string for secrets table
- storeSecret/retrieveSecret bind AAD to name|scope|workspaceId
- repo-service and webhook-service pass entity-specific AAD
- Legacy 16-byte IV rows decrypt without AAD (backward compat)
- retrieveSecret enforces isNull(workspaceId) for non-global scopes
  when no workspaceId is provided, preventing cross-workspace leakage

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): remove SYS_ADMIN and NET_ADMIN from DinD pods, require workspace opt-in (#321)

Docker-in-Docker pods were granted SYS_ADMIN and NET_ADMIN capabilities,
which effectively gave container-escape potential to untrusted code in
.optio/setup.sh. This replaces them with rootless Docker (SYS_CHROOT
only) and adds an admission-time check requiring explicit workspace
admin opt-in via a new allowDockerInDocker flag.

Changes:
- Replace SYS_ADMIN + NET_ADMIN capabilities with SYS_CHROOT for rootless DinD
- Add allowDockerInDocker boolean to workspaces table (default false)
- Add admission check in createRepoPod that rejects DinD without workspace opt-in
- Pass workspaceId through getOrCreateRepoPod → createRepoPod → admission check
- Update workspace service/routes to support the new field
- Add tests for all admission check scenarios
- Update web UI description to reflect rootless Docker approach

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): replace bearer token with HMAC-SHA256 for internal credential endpoint (#322)

The /api/internal/git-credentials endpoint previously authenticated using
a shared bearer token (OPTIO_CREDENTIAL_SECRET) sent directly over the
wire. A compromised agent pod could harvest this secret and request
credentials for any repository.

Changes:
- Add HMAC-SHA256 request signing: agent pods now sign each request with
  HMAC(secret, "{timestamp}.{path}") and send the signature in the
  X-Optio-Signature header. The raw secret never crosses the wire.
- Add replay protection: signatures expire after 5 minutes.
- Maintain backward compatibility with legacy Bearer token auth for
  pods running older agent images.
- Update optio-git-credential and optio-gh-wrapper scripts to compute
  and send HMAC signatures using openssl.
- Add default-deny NetworkPolicy for repo pods (opt-in via
  networkPolicy.enabled) restricting cluster-internal traffic to only
  the API service.
- Add comprehensive test coverage for the new HMAC auth service.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): stop leaking WebSocket auth tokens in URLs (#323)

WebSocket authentication tokens were being passed as URL query parameters
(`?token=...`), which leaked them into nginx access logs, browser history,
proxy logs, and Referer headers. This changes the auth mechanism to use
the Sec-WebSocket-Protocol header instead.

Changes:
- Remove `req.query.token` fallback from the auth middleware (auth.ts)
- Switch ws-auth.ts to extract upgrade tokens from Sec-WebSocket-Protocol
  header instead of URL query params
- Configure handleProtocols in server.ts to negotiate the `optio-ws-v1`
  protocol without echoing back the auth token
- Update the web client (ws-client.ts) to send tokens via WebSocket
  subprotocol header: ["optio-ws-v1", "optio-auth-<TOKEN>"]
- Fix optio-chat.ts to use extractSessionToken() (cookie only) instead
  of reading from req.query.token for session passthrough
- Add crypto.timingSafeEqual for WS upgrade token hash comparison in
  session-service.ts to prevent timing side-channel attacks

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): replace unsafe req.body/params/query as casts with Zod validation (#330)

Replace all ~105 instances of `req.body as Type`, `req.params as Type`, and
`req.query as Type` casts across route handlers, plugins, and WebSocket
handlers with proper Zod schema validation using `.parse()` / `.safeParse()`.

This prevents mass assignment, type confusion, and downstream crashes from
malformed client input. Invalid requests now return 400 with a descriptive
error instead of silently proceeding with unvalidated data.

Also adds an ESLint `no-restricted-syntax` rule to ban future `as` casts on
`req.body`, `req.params`, and `req.query`, ensuring this class of vulnerability
cannot be reintroduced.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): replace RSA-2048 with Ed25519 for Envoy sidecar CA, cut validity to 30 days (#334)

The Envoy secret-proxy sidecar was generating a 2048-bit RSA self-signed
CA with 365-day validity at pod init time. Since the cert is ephemeral
(never persists beyond pod lifetime) and only terminates intra-pod TLS:

- Switch to Ed25519 which is smaller, faster (~500ms → ~10ms gen time),
  and sufficient for this use case
- Reduce validity from 365 to 30 days (pods rarely live >24h)
- Add OPTIO_ENVOY_CA_ALG env var override for future PQ migration
  (e.g. "mldsa44" when OpenSSL 3.5+ stabilizes ML-DSA)

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(security): document Kubernetes 1.33+ as minimum for post-quantum TLS (#335)

Kubernetes v1.33 is the first release built on Go 1.24, which enables
hybrid X25519MLKEM768 key exchange by default for TLS 1.3. This means
the Optio API pod's communication with kube-apiserver gets post-quantum
TLS automatically on v1.33+ clusters.

Changes:
- Add kubeVersion: ">=1.33.0" to Helm Chart.yaml (fails fast on older clusters)
- Add K8s v1.33+ prerequisite to README.md
- Create docs/pq-readiness.md with PQ status table for all network legs
- Add K8s version warning to setup-local.sh for clusters below v1.33

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(observability): add TLS key-exchange group logging per upstream (#336)

Log Node/OpenSSL version and PQ readiness at API startup, and subscribe
to undici's diagnostics channel to count negotiated key-exchange groups
(e.g. X25519MLKEM768 vs X25519) per upstream host. Counters are flushed
to structured debug logs every 60s. Exposes getTlsGroupCounts() for
future Prometheus integration.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): enforce OpenSSL >= 3.5 for post-quantum TLS across all images (#337)

Node 22+ bundles OpenSSL 3.5.x which negotiates hybrid post-quantum
X25519MLKEM768 key agreement by default in TLS 1.3 handshakes. This
ensures all outbound connections benefit from PQ key exchange.

- Add build-time OpenSSL version assertion to Dockerfile.api, Dockerfile.web,
  Dockerfile.agent, Dockerfile.optio, and images/base.Dockerfile
- Add runtime assertion in server.ts (via openssl-check.ts) that refuses to
  start if OpenSSL < 3.5
- Add unit tests for the version check logic

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(security): introduce Signer/Verifier interface for crypto-agility (#338)

Add a Signer/Verifier abstraction layer to decouple signature algorithm
selection from call sites, enabling future PQ (post-quantum) algorithm
swaps with a single-file change.

- Create Signer interface (Rs256Signer, HmacSha256Signer, MlDsa65Signer stub)
  and Verifier interface (HmacSha256Verifier with timingSafeEqual)
- Refactor github-app-service.ts to use Rs256Signer via loadAppSigner()
  with env-configurable algorithm (GITHUB_APP_JWT_ALG)
- Refactor tickets.ts webhook verification to use HmacSha256Verifier,
  centralizing constant-time comparison
- Refactor webhook-service.ts signPayload to use HmacSha256Signer
- Log active signer configuration on startup
- Add 16 unit tests for all signer/verifier implementations

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): enable TLS for in-cluster Postgres (sslmode=verify-full) (#339)

The in-cluster Postgres deployment was running without TLS, meaning the
Postgres wire protocol (including decrypted secret values from the secrets
table) was transmitted in plaintext on the cluster network.

Changes:
- Add self-signed CA + server cert generation at Helm install time
  (helm/optio/templates/postgres-tls.yaml) with lookup-based reuse on
  upgrades to avoid breaking connections
- Enable ssl=on in the Postgres container with TLS 1.3 minimum, using an
  init container to set correct key file permissions (0600)
- Update DATABASE_URL helper to append sslmode=verify-full&sslrootcert
  when postgresql.tls.enabled is true
- Mount the CA cert into the API deployment at /etc/optio/pg-ca.crt
- Add parseSslConfig() utility (apps/api/src/db/ssl.ts) that reads
  sslmode and sslrootcert from the connection string and configures the
  postgres driver's TLS options accordingly
- TLS is enabled by default (postgresql.tls.enabled: true) but can be
  disabled for local development

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): enable TLS and authentication for in-cluster Redis (#340)

In-cluster Redis was running plaintext, exposing BullMQ job payloads
(rendered prompts, task metadata) and pub/sub log streams (repo content)
to any compromised pod or lateral attacker on the cluster network.

Changes:
- Add redis.tls.enabled (default true) to Helm values with auto-generated
  self-signed CA + server cert (preserved across upgrades via lookup)
- Configure Redis to serve TLSv1.3-only (--tls-port, --port 0)
- Add redis.auth.enabled (default true) with auto-generated password
- Create centralized redis-config.ts that builds TLS options from env
  (REDIS_CA_CERT_PATH, REDIS_TLS_REJECT_UNAUTHORIZED) and injects
  REDIS_PASSWORD into the connection URL
- Update all 10 Redis consumers (event-bus, 6 workers, server rate
  limiter, index.ts) to use the centralized config
- Mount Redis CA cert into the API pod for server verification
- Update _helpers.tpl to emit rediss:// scheme when TLS is enabled

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(security): tag encrypted blobs with algorithm version byte for crypto-agility (#341)

Add an `alg` field to all encryption operations to enable future cipher
migrations (AAD binding V2, ML-KEM, KMS-wrapped keys) without coordinated
rewrites across every call site.

- Add ALG_AES_256_GCM_V1 (0x01) constant and EncryptedBlob interface
- Refactor encrypt() to return { alg, ciphertext, iv, authTag }
- Refactor decrypt() to dispatch on blob.alg with runtime validation
- Add `alg` smallint column to secrets, webhooks, and repos tables
- Update storeSecret/retrieveSecret to persist and read alg
- Update webhook-service and repo-service to use EncryptedBlob API
- Existing rows default to alg=1, null alg falls back to V1

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add OpenCode as 5th agent adapter (experimental) (#350)

Add OpenCode (opencode.ai) as an experimental fifth agent adapter
alongside Claude Code, OpenAI Codex, and GitHub Copilot. Gated behind
OPTIO_OPENCODE_ENABLED=true feature flag.

Key changes:
- OpenCodeAdapter implementing AgentAdapter interface with provider-agnostic
  secret validation, container config, and defensive result parsing
- opencode-event-parser.ts for NDJSON streaming with committed fixture
- buildAgentCommand/inferExitCode/streaming parser dispatch for opencode
- DB migration adding opencodeModel/opencodeAgent/opencodeProvider to repos
- agent-entrypoint.sh and base.Dockerfile updated with opencode support
- All Zod route schemas and web UI selectors updated with opencode option
- Setup status endpoint reports opencodeEnabled/opencodeConfigured
- Comprehensive unit tests for adapter, event parser, and task worker

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add mid-task agent messaging and interject (#352)

Enable users to send messages to a running Claude Code agent mid-task
without cancelling and losing context. Messages are delivered via Claude
Code's --input-format stream-json stdin mechanism.

Key changes:
- New task_messages table with delivery lifecycle (created→delivered→acked)
- POST /api/tasks/:id/message route with soft/interrupt modes
- GET /api/tasks/:id/messages route for message history
- Per-task Redis pub/sub channel bridges API→worker for stdin delivery
- buildAgentCommand adds --input-format stream-json --replay-user-messages
- WebSocket log stream relays task:message events to connected clients
- Activity feed includes messages alongside comments and events
- Task detail UI shows message input bar for running claude-code tasks
- Rate limiting (10 msgs/min/task), permission checks (creator or admin)
- Returns 501 for codex/copilot agents (not yet supported)

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(notifications): add browser push notifications for task lifecycle events (#354)

* feat(notifications): add browser push notifications for task lifecycle events

Implement opt-in Web Push API notifications so users receive OS-level
push alerts for task lifecycle events (PR opened, completed, failed,
needs attention) even when the Optio tab is closed.

Backend:
- New `push_subscriptions` and `notification_preferences` tables
- `notification-service.ts` with VAPID config, subscription CRUD,
  preference management, and push delivery via `web-push` library
- 7 API endpoints at `/api/notifications/*` for subscription management,
  preferences, VAPID key retrieval, and test notifications
- Fire-and-forget push dispatch in `transitionTask()` and `addComment()`
- Auto-cleanup of expired/invalid subscriptions (HTTP 410)
- Per-user, per-event notification preferences with sensible defaults

Frontend:
- Service worker (`sw.js`) handles push events and click-through navigation
- `push-subscription.ts` client helper for registration and subscription
- `NotificationPreferences` component with permission state, device list,
  per-event toggle grid, and test notification button
- Replaced legacy `new Notification()` calls with service worker push
- Service worker registered on authenticated page load via `PushSwRegistrar`

Infrastructure:
- VAPID keys configurable via Helm (`notifications.vapid.*`)
- `scripts/generate-vapid-keys.sh` for key generation
- Graceful degradation when VAPID keys are not configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): pin @github/copilot to 1.0.20 to avoid transient npm 404s

The unpinned `npm install -g @github/copilot` resolves to 1.0.21 which
intermittently returns 404 from the npm registry in CI (no Docker layer
cache). Pin to 1.0.20 which is the last known-good version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Google Gemini CLI as a 4th agent adapter (#351)

* feat: add Google Gemini CLI as a 4th agent adapter

Add GeminiAdapter to @optio/agent-adapters alongside Claude Code, Codex,
and Copilot, enabling Optio to run Google's @google/gemini-cli as a
coding agent with full stream-json event parsing, cost tracking, and
two auth modes (API key + Vertex AI ADC).

Changes:
- New GeminiAdapter class with validateSecrets, buildContainerConfig,
  parseResult (packages/agent-adapters/src/gemini.ts)
- New gemini-event-parser service for NDJSON stream parsing
  (apps/api/src/services/gemini-event-parser.ts)
- Extended AgentTaskInput with GeminiAuthMode and related fields
- Added geminiModel/geminiApprovalMode columns to repos table + migration
- Updated task-worker with Gemini parser dispatch, command builder,
  exit code inference, and secret resolution
- Added POST /api/setup/validate/gemini-key validation endpoint
- Extended PATCH /api/repos/:id with geminiModel, geminiApprovalMode
- Updated base.Dockerfile to install @google/gemini-cli
- Added gemini case to agent-entrypoint.sh
- Setup wizard: Gemini card with API key and Vertex AI auth modes
- Repo edit page: Gemini Settings section (model + approval mode)
- Task creation: gemini option in agent selector
- 40 adapter tests + 17 event parser tests (all passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make @github/copilot install best-effort in base image

The @github/copilot npm package is returning 404 (unpublished),
which breaks the Docker image build for all branches. Make the
install non-fatal so the image can still build without it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stalled task detection with activity heartbeats (#353)

Add soft stall detection for running tasks whose agent has gone silent.
Tasks are flagged as "stalled" when no parsed log events arrive within a
configurable threshold (default 5 min), surfacing a warning in the UI
without prematurely failing the task.

Key changes:
- New `lastActivityAt` column on tasks, updated via debounced (5s) writes
  from the task worker whenever the agent emits parsed events
- New `activitySubstate` enum (active/stalled/recovered) for tracking
  stall transitions without adding a new TaskState value
- Soft stall detection pass in repo-cleanup-worker (30s cadence) that
  emits `task:stalled` and `task:recovered` WebSocket events
- Per-repo `stallThresholdMs` override on repos table, with fallback to
  `OPTIO_STALL_THRESHOLD_MS` env var then 5-min default
- `stallInfo` object in GET /api/tasks/:id response with isStalled,
  silentForMs, thresholdMs, and lastLogSummary
- `isStalled` boolean enrichment on task list responses
- Yellow "Stuck" badge overlay on StateBadge component
- Stall indicator row on task cards and warning banner on task detail page
- WebSocket event forwarding for stall/recovered events on per-task channel
- Pure `isTaskStalled()` / `getSilentDuration()` utilities in shared package

The existing hard stale-task fallback (10 min → fail + retry) is preserved
as a safety net. Both coexist: soft stall warns at 5 min, hard stall kills
at 10 min.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): add SSRF validation to ticket provider config (#317)

* fix(security): add SSRF validation to ticket provider config and outbound calls

The ticket provider create endpoint accepted arbitrary config without
validating URL fields, allowing SSRF attacks via Jira baseUrl and GitLab
host. This fix adds three layers of protection:

1. Zod discriminated union schema at the route level with isSsrfSafeUrl/
   isSsrfSafeHost refinements to reject private/internal addresses on input
2. Runtime assertSsrfSafe() calls in Jira and GitLab providers before
   every outbound request (catches DNS rebinding)
3. redirect: "error" on all GitLab provider fetch() calls to prevent
   302-based SSRF bypasses

The SSRF utilities are moved to @optio/shared so both apps/api and
packages/ticket-providers can use them. The old location re-exports
for backwards compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move SSRF utils to subpath export to avoid bundling node:dns in web build

The barrel export in @optio/shared pulled node:dns/promises into the
Next.js webpack build. Move ssrf to a dedicated subpath export
(@optio/shared/ssrf) so server-only code stays out of the browser bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve postgres and redis TLS connection failures

- Strip libpq-style ssl* query params from DATABASE_URL before passing
  to postgres-js, which otherwise forwards them as server startup
  parameters and triggers "unrecognized configuration parameter" errors.
- Disable Redis client cert auth (--tls-auth-clients no) since the API
  connects with server-only TLS and does not present a client cert.

* fix(worker): deliver initial claude prompt via stdin stream-json

When the mid-task messaging feature added --input-format stream-json,
claude started ignoring the -p "$OPTIO_PROMPT" positional arg and
instead waits for user messages on stdin. The worker only wrote to
stdin on subsequent mid-task interjects, so every fresh task sat
silently at "Running Claude Code..." with zero agent output until the
stale-task detector killed it. This was hard to spot because the unit
tests only asserted the flag strings, never that a real claude
invocation produced any output.

Switch the command to boolean --print and write the initial prompt to
execSession.stdin as a stream-json user message immediately after the
exec session is created. The pipe buffer holds the message until bash
finishes its setup and execs claude, so no synchronisation is needed.

Adds a regression test that fails if the prompt is ever embedded in the
claude command again, plus unit coverage for the new helper.

* fix(web): show token-refresh banner on recent task auth failures

The banner previously only triggered when /api/auth/usage itself returned
401/expired. In practice the Anthropic usage endpoint and messages
endpoint can diverge: usage returns 429 (rate limited by token scope)
while messages returns 401 (token invalid for task execution). The
result was broken tasks with no visible UI path to refresh the token.

Add a hasRecentClaudeAuthFailure detector that scans task_logs for
authentication markers from the last 15 minutes, expose it on
/api/auth/usage as hasRecentAuthFailure, and broaden the banner trigger
so it fires on either signal. The banner now also shows when usage
meters render successfully but tasks are still 401ing — a real
edge case with narrow-scope OAuth tokens.

* fix(auth): use drizzle helpers in auth-failure detector

The raw sql.join approach silently returned zero rows even when matching
logs existed. Switch to and/or/ilike/gt helpers which are the same
pattern used elsewhere in the codebase (e.g. task-service listTasks).

* fix(worker): exit claude cleanly on result event via stdin EOF

After the earlier fix that made the initial prompt arrive over stdin,
claude would process the turn, emit the result event, and then sit
idle waiting for more stream-json input — the task never transitioned
out of running.

Two related changes:

1. Flag claude's result event as terminal in the stream parser and,
   in the task worker, call execSession.stdin.end() when a terminal
   event arrives. Claude closes on EOF, so this lets it exit cleanly
   without affecting mid-task messaging (the subscribe handler still
   writes until the end() is called).

2. The custom Writable wrapping the k8s exec stdin only proxied write;
   calling .end() on it was a no-op because the underlying
   stdinStream was never closed. Implement the `_final` hook in both
   the kubernetes and docker runtimes so .end() propagates as EOF to
   the in-container process.

Verified by running a smoke-test task end-to-end: state=completed,
cost captured, tokens captured, transitioned in ~40s.

* feat(rbac): allow deployment without ClusterRole for namespace-only K8s (#357)

In shared multitenant clusters, users may not have permission to create
ClusterRole/ClusterRoleBinding resources. This change makes cluster-wide
permissions optional so Optio can run with namespace-scoped RBAC only.

- Add `rbac.clusterRole.create` Helm value (default true) to control
  whether ClusterRole/ClusterRoleBinding are created
- Handle 403 Forbidden in ensureNamespace() gracefully — if we can't
  read the namespace at cluster scope, assume it exists (we're running
  in it)
- Make cluster overview's listNode() call fail gracefully — returns
  empty nodes list instead of 500 when ClusterRole is unavailable

Closes #320

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(db): prevent duplicate-numbered migrations from concurrent branches (#358)

Switch new Drizzle migrations to unix-timestamp prefixes to prevent
numeric collisions when concurrent branches each generate the next
sequential number. Add a repair migration that idempotently ensures all
objects from the 6 historical duplicate-prefix pairs exist on any cluster.

- Update drizzle.config.ts with migrations.prefix: "unix"
- Add 1775613995_repair_duplicate_migrations.sql (idempotent repair)
- Add scripts/check-migration-prefixes.sh with historical allowlist
- Wire prefix checker into husky pre-commit hook
- Add apps/api/src/db/migrate.ts standalone migration runner
- Update CLAUDE.md with new migration conventions

Closes #355

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [PQ] Add docs/cryptography.md describing every crypto primitive and its migration plan (#359)

* docs: add cryptography.md with full primitive inventory and PQ migration plan

Consolidates the answer to "which crypto algorithms does Optio use?" into a
single reference document for procurement security reviews and compliance
audits. Covers AES-256-GCM secrets-at-rest, SHA-256 session tokens, HMAC-SHA256
webhook signing, RS256 GitHub App JWTs, Ed25519 Envoy CA, Helm TLS CAs, and
outbound TLS. Includes Mosca shelf-life analysis, key management procedures,
CNSA 2.0 / FIPS 140-3 compliance mapping, and runtime PQ verification commands.

Closes #333

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format cryptography.md with prettier

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add OpenTelemetry export for traces, metrics, and logs (#360)

Add first-class OpenTelemetry support to the Optio API server so operators
can ship traces, metrics, and high-signal logs to any OTLP-compatible backend
(Datadog, Honeycomb, Grafana Cloud, etc.).

The integration is opt-in via OPTIO_OTEL_ENABLED=true. When disabled (default),
no OTel packages are loaded and there is zero runtime cost.

New files:
- telemetry.ts: SDK initialization with dynamic imports
- telemetry/spans.ts: span wrappers, trace context propagation, URL sanitizer
- telemetry/metrics.ts: 12 custom counters, histograms, and gauges
- telemetry/logs.ts: high-signal OTel log emitter for state transitions,
  cost reports, pod health events, auth failures, and webhook failures
- telemetry/instrument-worker.ts: BullMQ worker processor wrapper
- telemetry/attributes.ts: whitelisted attribute constants
- telemetry/*.test.ts: 35 tests including sensitive data enforcement
- docs/observability.md: setup guide for Datadog, Honeycomb, Grafana Cloud

Modified files:
- index.ts: dynamic imports after telemetry init for auto-instrumentation
- health.ts: otelEnabled field in health response
- task-service.ts: state transition metrics and OTel logs
- event-bus.ts: trace ID attached to WebSocket events
- Helm chart: observability.otel.* values and secret env vars

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: per-repo shared persistent cache directories (#361)

Add a first-class "shared directories" feature that lets workspace admins
define cache directories which persist across tasks in the same repository.
These mount into every task pod at a configurable path inside /workspace or
the agent's $HOME, are backed by K8s PVCs, and reduce per-task setup time
for repos with heavy install or model-download steps.

Key changes:
- New `repo_shared_directories` table + migration (0046)
- `cache_pvc_name`/`cache_pvc_state` columns on `repo_pods`
- shared-directory-service: CRUD + PVC orchestration + clear/usage
- REST routes: list/create/update/delete/clear/usage + pod recycle
- repo-pool-service integration: mount cache PVCs via extraVolumes
- repo-service: cleanup cache + home PVCs on repo deletion
- Web UI: SharedDirectoriesSection with 10 preset cache configs
- Helm chart: agent.cache.* values + env vars + PVC RBAC patch/update
- .optio-cache/ added to git exclude in worktree setup
- CLAUDE.md: documented feature, routes, cache recipes

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: kill orphaned agents before stale re-queue and on API restart (#362)

Two interacting bugs caused duplicate agents in the same worktree after
API restarts:

Bug 1 — Stale re-queue spawned new agents without killing orphans:
- Add killOrphanedAgentInPod() that finds processes by OPTIO_TASK_ID
  env var in /proc/*/environ, sends SIGTERM+SIGKILL, and cleans the
  worktree with git worktree remove --force
- Call it in the stale detection path before re-queueing
- Mark worktreeState='removed' so the next agent creates a fresh one
- Escalate to needs_attention when cleanup fails on a repeated stale
  recovery, preventing infinite retry loops

Bug 2 — API restart left orphaned in-pod processes:
- Add orphan kill loop in reconcileOrphanedTasks() startup path
- Make the exec script signal-aware: EXIT trap kills child processes
  (agent + background watchdog)
- Add background heartbeat that detects broken stdout pipe (EPIPE from
  severed SPDY stream) and sends SIGTERM to the main script

Tests added for killOrphanedAgentInPod (7 cases) and stale detection
with orphan cleanup (4 cases in cleanup worker tests).

Closes #356

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: build the optio CLI — terminal-first client for the API (#363)

* feat: build the optio CLI — terminal-first client for the API

Introduces `apps/cli/` as a new workspace package shipping a standalone
`optio` CLI tool (Node >= 20). The CLI talks to any deployed Optio API
instance and gives developers a scriptable, terminal-first way to manage
tasks, stream logs, attach to interactive sessions, and manage repos,
secrets, and workspaces — without opening the web UI.

Server-side changes:
- New `api_keys` table + migration for personal access tokens (PATs)
- `api-key-service.ts` with CRUD + validation for `optio_pat_*` tokens
- Auth plugin extended to validate PAT tokens via Bearer header
- WS auth extended to accept PATs via Sec-WebSocket-Protocol header
- CLI login flow routes: POST /api/auth/cli/start, POST /api/auth/cli/token
- API key management routes: POST/GET/DELETE /api/auth/api-keys
- Fix pre-existing handleProtocols typecheck error in server.ts

CLI features:
- OAuth-over-loopback login flow with PKCE (optio login)
- Token precedence: --api-key flag > OPTIO_TOKEN env > credentials file
- Task commands: new, list, show, logs (--follow via WS), cancel, retry,
  review, message
- Repo commands: list, show, add, remove
- Session commands: new, list, attach (raw PTY passthrough), end
- Secret commands: list, set, rm
- Workspace commands: list, switch
- --json on every command, --no-color, sensible exit codes
- XDG-aware config (~/.config/optio/) with 0600 credentials
- 37KB bundled output (well under 1MB target)

Closes #345

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add apps/cli to Dockerfile COPY stages for workspace graph

The Dockerfile workspace completeness test requires all workspace
members' package.json files to be copied in the deps stage so pnpm
can resolve the full workspace graph during docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: update Helm chart defaults to GHCR images for production readiness (#365)

- Change values.yaml image defaults from local names (optio-api, optio-web,
  optio-base) to GHCR paths (ghcr.io/jonwiggins/optio-*)
- Change all default imagePullPolicy values from Never to IfNotPresent
- Add agent.image.prefix value for configurable preset image resolution,
  with OPTIO_AGENT_IMAGE_PREFIX and OPTIO_AGENT_IMAGE_TAG env vars
- Create values.local.yaml with local dev overrides (short image names,
  pullPolicy: Never, auth disabled, NodePort services)
- Update setup-local.sh and update-local.sh to use -f values.local.yaml
- Remove redundant GHCR image overrides from values.production.yaml
- Update CLAUDE.md Helm chart section and known issues

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: compute pipeline stats server-side to avoid pagination clamp (#366)

The overview dashboard was deriving pipeline stats by filtering a
paginated task list (limit: 100), so counters like "Done" stopped
growing once a workspace exceeded ~100 tasks.

Add GET /api/tasks/stats that runs a single grouped COUNT query,
and update the dashboard hook to consume it instead of filtering
client-side. The recent-tasks widget now fetches only 5 rows.

Closes #364

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing 0046 migration entry to Drizzle journal (#369)

* fix: add missing 0046 migration entry to Drizzle journal

The migration file 0046_repo_shared_directories.sql was present but had
no corresponding entry in _journal.json. Drizzle's migrator uses the
journal as its single source of truth, so the migration was silently
skipped—leaving the cache_pvc_name and cache_pvc_state columns missing
from the repo_pods table and crashing the health-check worker.

Add the missing journal entry (idx 48) and bump the two subsequent
timestamp-prefixed entries. Also add a journal-consistency test that
verifies every .sql file has a journal entry (and vice-versa), so this
class of bug is caught in CI going forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: mock assertSsrfSafe in jira tests to prevent DNS hangs in CI

jira.test.ts was calling the real assertSsrfSafe which does dns.lookup()
for the test hostname. In CI, this can hang until the 5s timeout,
causing flaky test failures. Add the same mock that jira-ssrf.test.ts
already uses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): replace workflow tables with new Workflows data model (#384)

Drop old `workflow_templates` and `workflow_runs` tables and remove all
references (routes, services, tests, UI page, sidebar link, api-client
methods, task-worker/task-service integration).

Create four new tables via migration: `workflows`, `workflow_triggers`,
`workflow_runs`, and `workflow_pods` — designed for parameterized agent
recipes with manual/schedule/webhook triggers.

Add shared types (`WorkflowRunState`, `WorkflowTriggerType`, `Workflow`,
`WorkflowTrigger`, `WorkflowRun`, `WorkflowPod`) and a workflow run
state machine to `packages/shared/`.

Closes #370

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add /api/workflows CRUD routes with aggregate run stats (#386)

Add enriched workflow endpoints at /api/workflows that return runCount,
lastRunAt, and totalCostUsd by joining workflow_runs and tasks tables.

- GET /api/workflows — list workflows with aggregate stats, workspace-scoped
- POST /api/workflows — create a workflow template (Zod validated)
- GET /api/workflows/:id — get workflow with stats
- PATCH /api/workflows/:id — update workflow template
- DELETE /api/workflows/:id — delete workflow template

Closes #371

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): workflow pod management (#388)

* feat(workflows): add workflow-pool-service for workflow environment pods

Implement workflow pod management that provides isolated K8s pods for
workflow runs without git clone overhead. Pods init with a setup script
and sleep, waiting for exec commands from workflow steps.

New components:
- workflow_pods DB table with state tracking and active run counts
- workflow-pool-service.ts: getOrCreateWorkflowPod, createWorkflowPod,
  execRunInPod, releaseRun, cleanupIdleWorkflowPods, listWorkflowPods
- generateWorkflowPodName in shared constants for K8s-safe naming
- Integration with repo-cleanup-worker for idle pod cleanup
- Full test coverage (11 tests)

Closes #374

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve rebase conflicts with new workflow data model

Remove duplicate workflowPods table definition from schema.ts that was
introduced by the `1775791846_replace_workflow_tables` migration on main.
Update the `1775792368_workflow_pods` migration to DROP and recreate the
table instead of CREATE IF NOT EXISTS. Update shared WorkflowPodState enum
values to match the new schema (provisioning/ready/error/terminating).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add workflow detail page with runs, triggers, and stats (#392)

Add /workflows list page and /workflows/:id detail page with:
- Definition summary showing name, description, and enabled state
- Stats bar: total runs, success rate, total cost, last run time
- Tabbed layout: recent runs table, triggers list, configuration panel
- Actions: enable/disable, delete, refresh
- Auto-refresh every 5s while runs are active
- API: add triggers listing endpoint and client methods
- Nav: add Workflows link to sidebar

Closes #382

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): workflow run routes (#389)

* feat(workflows): add workflow run routes for retry, cancel, and logs

Add new API routes and service functions for managing workflow runs:
- POST /api/workflows/:id/runs — short alias for starting a workflow run
- GET /api/workflows/:id/runs — short alias for listing runs
- POST /api/workflow-runs/:id/retry — retry failed/cancelled runs
- POST /api/workflow-runs/:id/cancel — cancel running/paused runs
- GET /api/workflow-runs/:id/logs — aggregated logs across all tasks

Infrastructure additions:
- workflowRunId column on task_logs table with index (migration)
- BullMQ workflow-runs queue with dedicated worker
- Worker registered in API server startup and graceful shutdown

Closes #373

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix prettier formatting after rebase conflict resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): workflows list page + sidebar (#390)

* feat(workflows): add workflows list page and sidebar entry

Add a /workflows page showing all workflow definitions in a table with
columns for name, status, runtime, run count, last run, cost, and
trigger type icons. Includes empty state, loading skeleton, and a
Workflow icon sidebar entry.

Backend: new GET /api/workflows endpoint with workflow-service that
aggregates run stats (count, last run, total cost) and trigger types.

Closes #380

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve rebase conflicts with workflow CRUD routes on main

Deduplicate workflowRoutes import/registration in server.ts, add
workflowTriggers schema mock and trigger query mocking to
workflow-service tests, align frontend with backend field names
(runCount instead of totalRuns).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add BullMQ workflow worker for workflow run execution (#394)

Implement a workflow-runs BullMQ worker that processes workflow runs with:
- Concurrency checks (global OPTIO_MAX_WORKFLOW_CONCURRENT + per-workflow maxConcurrent)
- Serialized claim lock to prevent concurrency storms
- Pod provisioning via workflow-pool-service
- Prompt rendering with {{param}} variable substitution
- Agent execution with multi-runtime support (claude-code, codex, copilot, opencode, gemini)
- NDJSON log streaming with agent-event-parser
- Cost/token/model capture from agent results
- State transitions (queued → running → completed/failed)
- Retry with exponential backoff (5s × 2^attempt)
- Provisioning retry (up to 3x with 30s delay)
- Auth mode support (api-key, oauth-token, max-subscription)

Also adds:
- POST /api/workflows/:id/runs route to create and enqueue workflow runs
- createWorkflowRun() service method
- WorkflowRunStateChangedEvent and WorkflowRunLogEvent WS event types
- Worker registration in index.ts with graceful shutdown

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): manual trigger with params form (#391)

* feat(workflows): manual trigger with params form

Add UI for manually running workflows with a dynamic parameter form.

- WorkflowParamsForm component generates form fields from paramsSchema
  (string, number, integer, boolean, enum) with defaults and required
  indicators; falls back to freeform JSON textarea when no schema exists
- RunWorkflowDialog opens from the detail page Run button, renders the
  params form, and POSTs to the new /api/workflows/:id/runs endpoint
- Workflow list page (/workflows) with per-row Run shortcut
- Workflow detail page (/workflows/[id]) with info cards, schema preview,
  expandable run history, and the Run dialog
- Backend: workflow-service (list, get, createRun, listRuns, listTriggers)
  and workflow routes registered in server.ts
- API client methods: listWorkflows, getWorkflow, runWorkflow,
  listWorkflowRuns, listWorkflowTriggers
- Sidebar navigation updated with Workflows entry
- 19 tests covering schema-driven form generation, freeform JSON mode,
  field interactions, defaults, and required indicators

Closes #377

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(workflows): update tests for merged route/service changes

After rebasing onto main, the workflow routes now include workspace
ownership checks and limit parameters from the feature branch merged
with CRUD routes from main. Updated test mocks to match:
- Add mockGetWorkflow to runs list test (workspace ownership check)
- Add .limit() to mock chain in workflow-service test
- Add missing mock exports (createWorkflowRun, listWorkflowTriggers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(workflows): remove duplicate route and API client entries after rebase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add POST /api/hooks/:webhookPath endpoint for webhook trigger ingress (#395)

Implement incoming webhook endpoint that triggers workflow runs from
external services. The endpoint is exempt from session auth, verifies
HMAC-SHA256 signatures when a secret is configured, maps incoming JSON
payloads to workflow params via JSON-path expressions, and returns 202
with the created run ID. Rate limited to 60 requests/minute.

Closes #379

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add cron-based schedule trigger evaluation (#396)

Add a BullMQ repeating job (every 60s) that evaluates workflow schedule
triggers, creates workflow runs when cron expressions match, and tracks
lastFiredAt/nextFireAt to prevent double-firing.

- Add lastFiredAt and nextFireAt columns to workflowTriggers with index
- Add trigger CRUD functions to workflow-service (create, update, delete)
- Add getDueScheduleTriggers() and markTriggerFired() for evaluation
- Add createWorkflowRun() for creating runs from triggers
- Create workflow-trigger-worker with BullMQ repeating job
- Register worker in API server startup and shutdown
- Add POST/PATCH/DELETE routes for workflow triggers with cron validation
- Update WorkflowTrigger shared type with new fields
- Add comprehensive tests for service, worker, and routes

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): add workflow run detail page (#398)

Add /workflows/:id/runs/:runId page with metadata bar, params panel,
live log streaming via polling, output JSON viewer, error panel with
classifier, and retry/cancel action buttons.

- New page at apps/web/src/app/workflows/[id]/runs/[runId]/page.tsx
- New useWorkflowRunLogs hook for fetching/polling workflow run logs
- Extended LogViewer to accept external log data (externalLogs prop)
- Added retryWorkflowRun, cancelWorkflowRun, getWorkflowRunLogs to API client
- Made run rows clickable in workflow detail page
- Added guard in useLogs for empty taskId
- Tests for new hook and API client methods

Closes #383

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): create/edit workflow form (#393)

* feat(workflows): create/edit workflow form with trigger management

Add /workflows/new and /workflows/[id]/edit pages with a shared form
component covering: basics (name, description, enabled), environment
spec (JSON), agent settings (runtime, model, max turns, budget, and
advanced execution params), prompt template with {{PARAM}} detection
and auto-build for params schema, and inline trigger management
(manual/schedule/webhook CRUD).

Also adds:
- Workflow trigger API routes (GET/POST/PATCH/DELETE /api/workflows/:id/triggers)
- Workflow trigger service methods (list/create/update/delete)
- Workflow CRUD + trigger API client methods in web app
- Workflows list page at /workflows with enable/disable/delete
- Workflows link in sidebar navigation
- Tests for all new routes, service methods, and API client methods

Closes #381

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: format workflows page after rebase conflict resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update workflow trigger tests for merged updateWorkflowTrigger

The rebase merged two versions of updateWorkflowTrigger - incoming's
type-update support and main's getWorkflowTrigger+nextFireAt logic.
Tests for the simpler update path now mock getWorkflowTrigger too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): WebSocket log streaming for workflow runs (#397)

* feat(workflows): add WebSocket log streaming for workflow runs

Add real-time log streaming infrastructure for workflow runs, mirroring
the existing task log streaming pattern:

- Add WorkflowRunStateChangedEvent and WorkflowRunLogEvent shared types
- Add workflow_run_logs table with DB migration
- Add publishWorkflowRunEvent to event-bus (publishes to global and
  per-run Redis channels: optio:workflow-run:{id})
- Add /ws/workflow-runs/:workflowRunId/logs WebSocket handler with
  catch-up support (sends last 50 logs on connect)
- Add transitionWorkflowRunState and appendWorkflowRunLog service
  functions that persist + publish events
- Add GET /api/workflow-runs/:id/logs REST endpoint
- Add createWorkflowRunLogClient factory to web WsClient
- Add workflow_run:state_changed handler to global WebSocket hook
- Full test coverage for all new functionality

Closes #376

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(workflows): resolve type errors and formatting after rebase

- Add workflowId parameter to transitionRun() and all call sites to
  match WorkflowRunStateChangedEvent type requirements
- Map AgentLogEntry fields to flat WorkflowRunLogEvent shape instead
  of passing entry as a property
- Fix workflow-runs logs route tests to mock getWorkflowRun
- Run prettier on modified files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): trigger CRUD routes (#387)

* feat(workflows): add CRUD routes for workflow triggers

Implement workflow trigger management (closes #372):
- New `workflow_triggers` table with at-most-one-per-type constraint
  and webhook path uniqueness index
- GET/POST /api/workflow-templates/:id/triggers
- PATCH/DELETE /api/workflow-templates/:id/triggers/:triggerId
- Per-type config validation (manual, schedule, webhook)
- Workspace-scoped access control
- 32 tests covering routes and service layer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: format workflow-trigger-service.ts with Prettier

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove duplicate trigger routes from workflows.ts

The trigger CRUD routes (GET/POST/PATCH/DELETE /api/workflows/:id/triggers)
are now handled by the dedicated workflow-triggers.ts route module. Remove
them from workflows.ts and its tests to prevent duplicate route registration
when both modules are loaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(workflows): improve UX with WS streaming, structured triggers, clone, and visual editors

- Fix webhook path bug: config.webhookPath → config.path (triggers never matched)
- Switch workflow run logs from 3s REST polling to real-time WebSocket streaming
- Add structured trigger config: cron presets/preview for schedule, path/secret inputs for webhook
- Add clone/duplicate workflow (POST /api/workflows/:id/clone) with frontend button
- Add visual parameters schema builder with auto-detect from prompt
- Add runs table filter pills (All/Running/Completed/Failed)
- Add edit button, delete confirmation, and duplicate button to detail page
- Show full webhook URL with copy button in trigger display
- Navigate to run detail page after starting a run
- Redirect to workflow detail (not list) after create/edit
- Enhance empty states with action buttons (Run Now, Configure Triggers)
- Fix check-migration-prefixes.sh for bash 3.x compatibility (macOS)

* docs: compact CLAUDE.md by removing info derivable from code

Remove directory layout, database schema, API routes catalog, workers
list, and detailed execution flow (all readable from the source).
Merge duplicated auth/concurrency/migration docs into single mentions.
Condense subsystem descriptions into one-liner pointers to code.

669 lines → 217 lines (~67% reduction).

* fix(auth): clear stale auth-failure banner when token is updated (#401)

Generalize the auth failure detector to support both Claude and GitHub
tokens with per-token-type watermarks. When a token is updated via the
secrets page, only failures after the update time are considered,
immediately clearing stale banners.

Key changes:
- auth-failure-detector: new getRecentAuthFailures() returns per-type
  status; uses secrets.updatedAt as watermark to narrow the window
- auth_events table: lightweight store for GitHub auth failures from
  non-task contexts (PR watcher, ticket sync)
- secrets route: validates tokens on save, invalidates credential cache,
  publishes auth:status_changed WebSocket event
- auth routes: /api/auth/usage returns authFailures.{claude,github};
  /api/auth/refresh returns fresh failure status
- GitHub token banner: new GitHubTokenBanner component shown when
  GITHUB_TOKEN is invalid
- WebSocket: auth:status_changed event triggers immediate UI refr…

v0.1.0

Toggle v0.1.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: github app secrets naming