diff --git a/apps/api/src/schemas/task.ts b/apps/api/src/schemas/task.ts index d55349b6..c857d130 100644 --- a/apps/api/src/schemas/task.ts +++ b/apps/api/src/schemas/task.ts @@ -35,7 +35,7 @@ export const TaskActivitySubstateSchema = z .describe("Activity sub-state for running tasks"); export const AgentTypeSchema = z - .enum(["claude-code", "codex", "copilot", "opencode"]) + .enum(["claude-code", "codex", "copilot", "opencode", "gemini", "openclaw"]) .describe("Agent runtime that executes the task"); export const WorktreeStateSchema = z diff --git a/apps/api/src/services/gemini-event-parser.ts b/apps/api/src/services/gemini-event-parser.ts index 6a6db769..e9c13929 100644 --- a/apps/api/src/services/gemini-event-parser.ts +++ b/apps/api/src/services/gemini-event-parser.ts @@ -115,7 +115,8 @@ export function parseGeminiEvent( // Tool result if (event.type === "tool_result") { - const output = typeof event.output === "string" ? event.output : JSON.stringify(event.output); + const output = + typeof event.output === "string" ? event.output : (JSON.stringify(event.output) ?? ""); const trimmed = output.length > 300 ? output.slice(0, 300) + "\u2026" : output; if (trimmed.trim()) { entries.push({ diff --git a/apps/api/src/workers/task-worker.ts b/apps/api/src/workers/task-worker.ts index 0f23edc8..628c585a 100644 --- a/apps/api/src/workers/task-worker.ts +++ b/apps/api/src/workers/task-worker.ts @@ -1749,7 +1749,7 @@ export function buildAgentCommand( ? ` -m ${JSON.stringify(env.OPTIO_GEMINI_MODEL)}` : ""; return [ - `echo "[optio] Running Google Gemini${opts?.isReview ? " (review)" : ""}..."`, + `echo "[optio] Running Gemini..."`, `gemini -p "$OPTIO_PROMPT" \\`, ` --output-format stream-json \\`, ` --approval-mode yolo${geminiModelFlag}`, @@ -1842,12 +1842,15 @@ export function inferExitCode(agentType: string, logs: string): number { } case "gemini": { const hasErrorEvent = logs.includes('"type":"error"') || logs.includes('"type": "error"'); - const hasAuthError = /GEMINI_API_KEY|GOOGLE_API_KEY|permission denied|unauthorized/i.test( - logs, - ); + // Match auth errors by error-descriptive patterns only (not bare env var names which + // could appear in diagnostic output and cause false positives). + const hasAuthError = + /api.?key.*(?:invalid|not valid|missing)|invalid.*api.?key|api_key_invalid|permission denied|unauthorized/i.test( + logs, + ); const hasQuotaError = /quota|resource.?exhausted|rate.?limit/i.test(logs); const hasModelError = /model.*not found|model_not_found|does not exist.*model/i.test(logs); - const hasTurnLimit = /turn.?limit|exit code 53/i.test(logs); + const hasTurnLimit = /turn.?limit|exit:?\s*53\b/i.test(logs); return hasErrorEvent || hasAuthError || hasQuotaError || hasModelError || hasTurnLimit ? 1 : 0; diff --git a/helm/optio/Chart.yaml b/helm/optio/Chart.yaml index 084e86d4..318ed306 100644 --- a/helm/optio/Chart.yaml +++ b/helm/optio/Chart.yaml @@ -1,6 +1,7 @@ apiVersion: v2 name: optio description: AI Agent Workflow Orchestration +kubeVersion: ">=1.33.0-0" type: application version: 0.1.0 appVersion: "0.1.0" diff --git a/helm/optio/templates/api-deployment.yaml b/helm/optio/templates/api-deployment.yaml index 7b42c9a3..4eb7da93 100644 --- a/helm/optio/templates/api-deployment.yaml +++ b/helm/optio/templates/api-deployment.yaml @@ -195,6 +195,7 @@ spec: ports: - port: {{ .Values.api.port }} targetPort: {{ .Values.api.port }} + appProtocol: kubernetes.io/ws # WebSocket support for /ws/* endpoints {{- if and (eq .Values.api.service.type "NodePort") .Values.api.service.nodePort }} nodePort: {{ .Values.api.service.nodePort }} {{- end }} diff --git a/helm/optio/templates/gateway.yaml b/helm/optio/templates/gateway.yaml index 58c938c9..9253e3d0 100644 --- a/helm/optio/templates/gateway.yaml +++ b/helm/optio/templates/gateway.yaml @@ -1,5 +1,5 @@ {{- include "optio.validateNetworking" . }} -{{- if and .Values.gatewayAPI.enabled (.Capabilities.APIVersions.Has "gateway.networking.k8s.io/v1") }} +{{- if and .Values.gatewayAPI.enabled (.Capabilities.APIVersions.Has "gateway.networking.k8s.io/v1") (not .Values.gatewayAPI.parentRefs) }} apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: diff --git a/helm/optio/templates/httproute.yaml b/helm/optio/templates/httproute.yaml index b2760fe8..221f5978 100644 --- a/helm/optio/templates/httproute.yaml +++ b/helm/optio/templates/httproute.yaml @@ -13,8 +13,12 @@ metadata: {{- end }} spec: parentRefs: + {{- if .Values.gatewayAPI.parentRefs }} + {{- toYaml .Values.gatewayAPI.parentRefs | nindent 4 }} + {{- else }} - name: {{ .Release.Name }}-gateway namespace: {{ .Values.namespace }} + {{- end }} {{- if .Values.gatewayAPI.hostname }} hostnames: - {{ .Values.gatewayAPI.hostname | quote }} diff --git a/images/build.sh b/images/build.sh index a6230bac..a3065d5b 100755 --- a/images/build.sh +++ b/images/build.sh @@ -3,29 +3,55 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Parse arguments TAG="${1:-latest}" +PLATFORM="" + +# Check for --platform flag in any position +i=0 +next_is_platform=false +for arg in "$@"; do + i=$((i + 1)) + if [[ "$next_is_platform" == true ]]; then + PLATFORM="$arg" + next_is_platform=false + elif [[ "$arg" == --platform=* ]]; then + PLATFORM="${arg#*=}" + elif [[ "$arg" == "--platform" ]]; then + next_is_platform=true + fi +done + +# Build platform flag if specified +PLATFORM_FLAG="" +if [ -n "${PLATFORM}" ]; then + PLATFORM_FLAG="--platform ${PLATFORM}" + echo "Building for platform: ${PLATFORM}" +fi echo "=== Building Optio Agent Images ===" +echo "Tag: ${TAG}" # Base image (all others depend on this) echo "[1/10] Building optio-base..." -docker build -t "optio-base:${TAG}" -f "${SCRIPT_DIR}/base.Dockerfile" "${ROOT_DIR}" +docker build ${PLATFORM_FLAG} -t "optio-base:${TAG}" -f "${SCRIPT_DIR}/base.Dockerfile" "${ROOT_DIR}" echo "Using base internal image: optio-base:${TAG}" BASE_IMAGE="optio-base:${TAG}" # Language-specific images (can be built in parallel) echo "[2/10] Building optio-node..." -docker build -t "optio-node:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/node.Dockerfile" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-node:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/node.Dockerfile" "${ROOT_DIR}" & echo "[3/10] Building optio-python..." -docker build -t "optio-python:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/python.Dockerfile" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-python:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/python.Dockerfile" "${ROOT_DIR}" & echo "[4/10] Building optio-go..." -docker build -t "optio-go:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/go.Dockerfile" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-go:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/go.Dockerfile" "${ROOT_DIR}" & echo "[5/10] Building optio-rust..." -docker build -t "optio-rust:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/rust.Dockerfile" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-rust:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/rust.Dockerfile" "${ROOT_DIR}" & echo "[6/10] Building optio-ruby..." docker build -t "optio-ruby:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/ruby.Dockerfile" "${ROOT_DIR}" & @@ -34,15 +60,15 @@ echo "[7/10] Building optio-dart..." docker build -t "optio-dart:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/dart.Dockerfile" "${ROOT_DIR}" & echo "[8/10] Building optio-dind..." -docker build -t "optio-dind:${TAG}" -f "${SCRIPT_DIR}/dind.Dockerfile" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-dind:${TAG}" -f "${SCRIPT_DIR}/dind.Dockerfile" "${ROOT_DIR}" & echo "[9/10] Building optio-optio (operations assistant)..." -docker build -t "optio-optio:${TAG}" -f "${ROOT_DIR}/Dockerfile.optio" "${ROOT_DIR}" & +docker build ${PLATFORM_FLAG} -t "optio-optio:${TAG}" -f "${ROOT_DIR}/Dockerfile.optio" "${ROOT_DIR}" & wait echo "[10/10] Building optio-full..." -docker build -t "optio-full:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/full.Dockerfile" "${ROOT_DIR}" +docker build ${PLATFORM_FLAG} -t "optio-full:${TAG}" --build-arg BASE_IMAGE="${BASE_IMAGE}" -f "${SCRIPT_DIR}/full.Dockerfile" "${ROOT_DIR}" # Tag optio-base as the default docker tag "optio-base:${TAG}" "optio-agent:${TAG}" diff --git a/packages/agent-adapters/src/gemini.test.ts b/packages/agent-adapters/src/gemini.test.ts index ccbd42f3..6ebd568c 100644 --- a/packages/agent-adapters/src/gemini.test.ts +++ b/packages/agent-adapters/src/gemini.test.ts @@ -96,7 +96,9 @@ describe("GeminiAdapter", () => { expect(settingsFile).toBeDefined(); const settings = JSON.parse(settingsFile!.content); expect(settings.security.auth.selectedType).toBe("gemini-api-key"); - expect(settings.general.defaultApprovalMode).toBe("yolo"); + // "yolo" is not a valid settings.json enum; it's applied via the CLI --approval-mode flag. + // Settings file uses "auto_edit" as the nearest valid equivalent. + expect(settings.general.defaultApprovalMode).toBe("auto_edit"); expect(settings.telemetry.enabled).toBe(false); }); diff --git a/packages/agent-adapters/src/gemini.ts b/packages/agent-adapters/src/gemini.ts index 36bdc952..22b0e22b 100644 --- a/packages/agent-adapters/src/gemini.ts +++ b/packages/agent-adapters/src/gemini.ts @@ -100,10 +100,13 @@ export class GeminiAdapter implements AgentAdapter { const approvalMode = input.geminiApprovalMode ?? "yolo"; const authType = input.geminiAuthMode === "vertex-ai" ? "vertex-ai" : "gemini-api-key"; const maxSessionTurns = input.maxTurnsCoding ?? 250; + // "yolo" is not a valid settings.json enum (valid: 'default' | 'auto_edit' | 'plan'). + // Yolo mode is applied via the --approval-mode CLI flag; settings.json uses "auto_edit". + const settingsApprovalMode = approvalMode === "yolo" ? "auto_edit" : approvalMode; const geminiSettings = { security: { auth: { selectedType: authType } }, model: { maxSessionTurns }, - general: { defaultApprovalMode: approvalMode }, + general: { defaultApprovalMode: settingsApprovalMode }, telemetry: { enabled: false }, }; setupFiles.push({ diff --git a/packages/shared/src/utils/off-peak.ts b/packages/shared/src/utils/off-peak.ts index e383ac2d..284e3388 100644 --- a/packages/shared/src/utils/off-peak.ts +++ b/packages/shared/src/utils/off-peak.ts @@ -135,10 +135,11 @@ function getETDate(ref: Date, targetHourET: number): Date { `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(targetHourET).padStart(2, "0")}:00:00`, ); - // Get what hour ET thinks this is - const { hour: guessHour } = getETComponents(guess); - const drift = guessHour - targetHourET; - guess.setHours(guess.getHours() - drift); + // Get what ET thinks this is, including minutes (sub-hour timezone offsets like IST = UTC+5:30 + // would otherwise introduce a 30-minute drift when using setHours in local time). + const { hour: guessHour, minute: guessMinute } = getETComponents(guess); + const driftMs = (guessHour - targetHourET) * 60 * 60 * 1000 + guessMinute * 60 * 1000; + guess.setTime(guess.getTime() - driftMs); return guess; }