diff --git a/argoproj/codex-workspace/cron-configmap.yaml b/argoproj/codex-workspace/cron-configmap.yaml new file mode 100644 index 000000000..bb6a19937 --- /dev/null +++ b/argoproj/codex-workspace/cron-configmap.yaml @@ -0,0 +1,242 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: codex-workspace-cron + namespace: codex-workspace +data: + jobs.yaml: | + jobs: + - id: workspace-maintenance + name: Workspace maintenance + enabled: false + schedule: "0 22 * * *" + timeZone: Etc/UTC + session: isolated + promptFile: /opt/codex-cron/prompt-workspace-maintenance.md + workdir: /home/boxp + outputRoot: /home/boxp/.codex-cron/runs + bypassApprovals: true + - id: ticket-review + name: Ticket review + enabled: false + schedule: "30 22 * * *" + timeZone: Etc/UTC + session: isolated + promptFile: /opt/codex-cron/prompt-ticket-review.md + workdir: /home/boxp + outputRoot: /home/boxp/.codex-cron/runs + bypassApprovals: true + run-codex-cron.sh: | + #!/usr/bin/env bash + set -Eeuo pipefail + + job_id="${CODEX_CRON_JOB_ID:?CODEX_CRON_JOB_ID is required}" + jobs_file="${CODEX_CRON_JOBS_FILE:-/opt/codex-cron/jobs.yaml}" + + eval "$( + bb /opt/codex-cron/select-codex-cron-job.bb "${jobs_file}" "${job_id}" + )" + + job_name="${CODEX_CRON_NAME:-${CODEX_CRON_JOB_ID}}" + prompt_file="${CODEX_CRON_PROMPT_FILE:?CODEX_CRON_PROMPT_FILE is required}" + workdir="${CODEX_CRON_WORKDIR:-/home/boxp}" + output_root="${CODEX_CRON_OUTPUT_ROOT:-/home/boxp/.codex-cron/runs}" + lock_root="${CODEX_CRON_LOCK_ROOT:-/home/boxp/.codex-cron/locks}" + run_id="${CODEX_CRON_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" + run_dir="${output_root}/${job_id}/${run_id}" + lock_dir="${lock_root}/${job_id}.lock" + + mkdir -p "${run_dir}" "${lock_root}" + + if ! mkdir "${lock_dir}" 2>/dev/null; then + lock_stale_seconds="${CODEX_CRON_LOCK_STALE_SECONDS:-43200}" + lock_mtime="$(stat -c %Y "${lock_dir}" 2>/dev/null || echo 0)" + now_epoch="$(date -u +%s)" + if (( now_epoch - lock_mtime > lock_stale_seconds )); then + echo "removing stale codex cron lock: ${lock_dir}" >&2 + rm -rf "${lock_dir}" + mkdir "${lock_dir}" + else + echo "codex cron job '${job_name}' is already running: ${lock_dir}" >&2 + exit 75 + fi + fi + cleanup() { + rmdir "${lock_dir}" 2>/dev/null || true + } + trap cleanup EXIT + + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + stdout_log="${run_dir}/events.jsonl" + stderr_log="${run_dir}/stderr.log" + last_message="${run_dir}/last-message.md" + summary_file="${run_dir}/summary.json" + + codex_args=(exec --json --cd "${workdir}" --output-last-message "${last_message}") + + if [[ "${CODEX_CRON_BYPASS_APPROVALS:-true}" == "true" ]]; then + codex_args+=(--dangerously-bypass-approvals-and-sandbox) + else + codex_args+=(--sandbox "${CODEX_CRON_SANDBOX:-workspace-write}") + fi + + if [[ -n "${CODEX_CRON_MODEL:-}" ]]; then + codex_args+=(--model "${CODEX_CRON_MODEL}") + fi + + if [[ -n "${CODEX_CRON_PROFILE:-}" ]]; then + codex_args+=(--profile "${CODEX_CRON_PROFILE}") + fi + + if [[ -n "${CODEX_CRON_EXTRA_ARGS:-}" ]]; then + # Shell-style quoting is intentionally not supported. Keep values simple + # and use dedicated env vars above for options that need arguments. + read -r -a extra_args <<< "${CODEX_CRON_EXTRA_ARGS}" + codex_args+=("${extra_args[@]}") + fi + + { + echo "job=${job_name}" + echo "job_id=${job_id}" + echo "run_id=${run_id}" + echo "started_at=${started_at}" + echo "workdir=${workdir}" + echo "prompt_file=${prompt_file}" + echo "codex_args=${codex_args[*]}" + } > "${run_dir}/metadata.env" + + set +e + codex "${codex_args[@]}" - < "${prompt_file}" > "${stdout_log}" 2> "${stderr_log}" + exit_code=$? + set -e + + finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + status="ok" + if [[ "${exit_code}" -ne 0 ]]; then + status="error" + fi + + cat > "${summary_file}" <= (count value) 2) + (= (first value) (last value)) + (#{\" \'} (first value))) + (subs value 1 (dec (count value))) + + (= "true" (str/lower-case value)) true + (= "false" (str/lower-case value)) false + :else value))) + + (defn parse-jobs [text] + (loop [lines (str/split-lines text) + jobs [] + current nil] + (if-not (seq lines) + (cond-> jobs current (conj current)) + (let [raw (first lines) + stripped (str/trim raw)] + (cond + (or (empty? stripped) (= stripped "jobs:") (str/starts-with? stripped "#")) + (recur (rest lines) jobs current) + + (str/starts-with? stripped "- ") + (let [jobs (cond-> jobs current (conj current)) + stripped (str/trim (subs stripped 2)) + current {}] + (if (empty? stripped) + (recur (rest lines) jobs current) + (recur (cons stripped (rest lines)) jobs current))) + + (nil? current) + (fail (str "invalid jobs file: property outside a job: " raw)) + + (not (str/includes? stripped ":")) + (fail (str "unsupported jobs.yaml line: " raw)) + + :else + (let [[k v] (str/split stripped #":" 2) + k (str/trim k)] + (when-not (re-matches #"[A-Za-z][A-Za-z0-9]*" k) + (fail (str "unsupported key: " k))) + (recur (rest lines) jobs (assoc current k (parse-scalar v))))))))) + + (defn emit [name value] + (when-not (or (nil? value) (= "" value)) + (println (str name "=" (shell-quote value))))) + + (let [[jobs-file job-id] *command-line-args*] + (when-not (and jobs-file job-id) + (fail "usage: select-codex-cron-job.bb ")) + (let [job (first (filter #(= job-id (get % "id")) (parse-jobs (slurp jobs-file))))] + (when-not job + (fail (str "codex cron job not found: " job-id))) + (when-not (true? (get job "enabled")) + (fail (str "codex cron job is disabled in jobs.yaml: " job-id))) + (emit "CODEX_CRON_NAME" (or (get job "name") (get job "id"))) + (emit "CODEX_CRON_PROMPT_FILE" (get job "promptFile")) + (emit "CODEX_CRON_WORKDIR" (get job "workdir")) + (emit "CODEX_CRON_OUTPUT_ROOT" (get job "outputRoot")) + (emit "CODEX_CRON_MODEL" (get job "model")) + (emit "CODEX_CRON_PROFILE" (get job "profile")) + (emit "CODEX_CRON_BYPASS_APPROVALS" (str (get job "bypassApprovals" true))) + (emit "CODEX_CRON_EXTRA_ARGS" (get job "extraArgs")))) + prompt-workspace-maintenance.md: | + あなたは Codex workspace の定期実行ジョブです。 + + 目的: + - Codex workspace の状態を軽く確認する。 + - 変更は加えず、必要な次アクションだけを短く報告する。 + + 確認対象: + - `/home/boxp/Documents/obsidian-headless/BOXP/Tickets/BOXP-16.md` + - `/home/boxp/ghq/github.com/boxp/lolice` + - `/home/boxp/ghq/github.com/boxp/arch` + + 制約: + - ファイル編集、commit、push、PR 作成はしない。 + - 問題があれば、最後の応答に箇条書きで記録する。 + prompt-ticket-review.md: | + あなたは Codex workspace の定期実行ジョブです。 + + 目的: + - Obsidian vault のタスクボードを確認する。 + - stale なチケットや次アクションが曖昧なチケットを短く報告する。 + + 確認対象: + - `/home/boxp/Documents/obsidian-headless/BOXP/Boards/Task Board.md` + - `/home/boxp/Documents/obsidian-headless/BOXP/Tickets` + + 制約: + - ファイル編集、commit、push、PR 作成はしない。 + - 日本語で簡潔に報告する。 diff --git a/argoproj/codex-workspace/cron.md b/argoproj/codex-workspace/cron.md new file mode 100644 index 000000000..58fcdc89a --- /dev/null +++ b/argoproj/codex-workspace/cron.md @@ -0,0 +1,59 @@ +# Codex Workspace Cron + +Codex workspace の prompt 定期実行は `jobs.yaml` と Kubernetes CronJob で管理する。 +OpenClaw の `jobs.json` と同じく、複数のジョブがそれぞれ schedule と prompt を持つ。 + +## 仕組み + +- `cron-configmap.yaml` + - `jobs.yaml`: 登録済み cron job の一覧。 + - `run-codex-cron.sh`: `codex exec` を非対話で起動する runner。 + - `prompt-*.md`: 定期実行したい prompt。 +- `cronjob.yaml` + - `spec.schedule`: Kubernetes が実行する schedule。`jobs.yaml` の同名 entry と合わせる。 + - `spec.suspend`: 誤実行防止。初期値は `true`。 + - `CODEX_CRON_JOB_ID`: `jobs.yaml` の job id。 +- 実行ログ + - `/home/boxp/.codex-cron/runs///events.jsonl` + - `/home/boxp/.codex-cron/runs///stderr.log` + - `/home/boxp/.codex-cron/runs///last-message.md` + - `/home/boxp/.codex-cron/runs///summary.json` + +## 有効化 + +prompt と schedule を確認してから、`jobs.yaml` の対象 job を `enabled: true` にし、対応する CronJob の `suspend` を `false` にする。 + +```yaml +jobs: + - id: workspace-maintenance + enabled: true + schedule: "0 22 * * *" +``` + +```yaml +spec: + suspend: false + schedule: "0 22 * * *" +``` + +## 手動実行 + +```bash +kubectl create job \ + --from=cronjob/codex-cron-workspace-maintenance \ + codex-cron-workspace-maintenance-manual \ + -n codex-workspace +``` + +## ジョブ追加 + +1. `cron-configmap.yaml` の `jobs.yaml` に job entry を追加する。 +2. `cron-configmap.yaml` に対応する `prompt-.md` を追加する。 +3. `cronjob.yaml` をコピーして CronJob 名、`schedule`、`CODEX_CRON_JOB_ID` を変更する。 +4. `jobs.yaml` の `id/schedule/timeZone` と CronJob の `CODEX_CRON_JOB_ID/schedule/timeZone` を揃える。 +5. 多重実行を避けるため `concurrencyPolicy: Forbid` は維持する。 +6. workspace home PVC を mount するため、workspace Pod と同じ node へ寄せる `podAffinity` は維持する。 + +## OpenClaw との差分 + +OpenClaw は Gateway 内蔵 scheduler が `jobs.json` と `jobs-state.json` を管理する。Codex workspace では Gateway 相当の常駐 scheduler を持たないため、scheduler は Kubernetes CronJob に委譲し、ジョブ登録情報は ConfigMap の `jobs.yaml` に集約する。Runner 補助スクリプトは workspace image に入っている Babashka (`bb`) で動かす。 diff --git a/argoproj/codex-workspace/cronjob.yaml b/argoproj/codex-workspace/cronjob.yaml new file mode 100644 index 000000000..8e7e4e224 --- /dev/null +++ b/argoproj/codex-workspace/cronjob.yaml @@ -0,0 +1,177 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: codex-cron-workspace-maintenance + namespace: codex-workspace + labels: + app: codex-workspace-cron + codex-workspace.boxp.io/cron: workspace-maintenance +spec: + # Keep this in sync with jobs.yaml entry schedule/timeZone/enabled. + suspend: true + schedule: "0 22 * * *" + timeZone: Etc/UTC + concurrencyPolicy: Forbid + startingDeadlineSeconds: 900 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 0 + template: + metadata: + labels: + app: codex-workspace-cron + codex-workspace.boxp.io/cron: workspace-maintenance + spec: + restartPolicy: Never + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: codex-workspace + topologyKey: kubernetes.io/hostname + containers: + - name: codex + image: ghcr.io/boxp/arch/codex-workspace:latest + imagePullPolicy: Always + command: + - /opt/codex-cron/run-codex-cron.sh + env: + - name: HOME + value: /home/boxp + - name: CODEX_HOME + value: /home/boxp/.codex + - name: CODEX_CRON_JOB_ID + value: workspace-maintenance + - name: CODEX_CRON_JOBS_FILE + value: /opt/codex-cron/jobs.yaml + - name: GRAFANA_URL + value: http://grafana.monitoring.svc.cluster.local:3000 + - name: GRAFANA_SERVICE_ACCOUNT_TOKEN + valueFrom: + secretKeyRef: + name: codex-workspace-grafana + key: service-account-token + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: false + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "4" + memory: "8Gi" + volumeMounts: + - name: home + mountPath: /home/boxp + - name: cron-config + mountPath: /opt/codex-cron + readOnly: true + volumes: + - name: home + persistentVolumeClaim: + claimName: codex-workspace-home + - name: cron-config + configMap: + name: codex-workspace-cron + defaultMode: 0555 +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: codex-cron-ticket-review + namespace: codex-workspace + labels: + app: codex-workspace-cron + codex-workspace.boxp.io/cron: ticket-review +spec: + # Keep this in sync with jobs.yaml entry schedule/timeZone/enabled. + suspend: true + schedule: "30 22 * * *" + timeZone: Etc/UTC + concurrencyPolicy: Forbid + startingDeadlineSeconds: 900 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 0 + template: + metadata: + labels: + app: codex-workspace-cron + codex-workspace.boxp.io/cron: ticket-review + spec: + restartPolicy: Never + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/arch: amd64 + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: codex-workspace + topologyKey: kubernetes.io/hostname + containers: + - name: codex + image: ghcr.io/boxp/arch/codex-workspace:latest + imagePullPolicy: Always + command: + - /opt/codex-cron/run-codex-cron.sh + env: + - name: HOME + value: /home/boxp + - name: CODEX_HOME + value: /home/boxp/.codex + - name: CODEX_CRON_JOB_ID + value: ticket-review + - name: CODEX_CRON_JOBS_FILE + value: /opt/codex-cron/jobs.yaml + - name: GRAFANA_URL + value: http://grafana.monitoring.svc.cluster.local:3000 + - name: GRAFANA_SERVICE_ACCOUNT_TOKEN + valueFrom: + secretKeyRef: + name: codex-workspace-grafana + key: service-account-token + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: false + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "4" + memory: "8Gi" + volumeMounts: + - name: home + mountPath: /home/boxp + - name: cron-config + mountPath: /opt/codex-cron + readOnly: true + volumes: + - name: home + persistentVolumeClaim: + claimName: codex-workspace-home + - name: cron-config + configMap: + name: codex-workspace-cron + defaultMode: 0555 diff --git a/argoproj/codex-workspace/kustomization.yaml b/argoproj/codex-workspace/kustomization.yaml index 2f7d2fc8d..f20b2c9e5 100644 --- a/argoproj/codex-workspace/kustomization.yaml +++ b/argoproj/codex-workspace/kustomization.yaml @@ -6,7 +6,10 @@ resources: - storageclass.yaml - pvc.yaml - configmap.yaml + - cron-configmap.yaml - external-secret.yaml - deployment.yaml + - cronjob.yaml - service.yaml - networkpolicy.yaml + - networkpolicy-cron.yaml diff --git a/argoproj/codex-workspace/networkpolicy-cron.yaml b/argoproj/codex-workspace/networkpolicy-cron.yaml new file mode 100644 index 000000000..7900df253 --- /dev/null +++ b/argoproj/codex-workspace/networkpolicy-cron.yaml @@ -0,0 +1,40 @@ +apiVersion: projectcalico.org/v3 +kind: NetworkPolicy +metadata: + name: codex-workspace-cron-network-policy + namespace: codex-workspace +spec: + selector: app == 'codex-workspace-cron' + types: + - Ingress + - Egress + ingress: [] + egress: + - action: Allow + protocol: UDP + destination: + selector: k8s-app == 'kube-dns' + namespaceSelector: kubernetes.io/metadata.name == 'kube-system' + ports: + - 53 + - action: Allow + protocol: TCP + destination: + selector: k8s-app == 'kube-dns' + namespaceSelector: kubernetes.io/metadata.name == 'kube-system' + ports: + - 53 + - action: Allow + protocol: TCP + destination: + selector: app.kubernetes.io/component == 'grafana' && app.kubernetes.io/name == 'grafana' && app.kubernetes.io/part-of == 'kube-prometheus' + namespaceSelector: kubernetes.io/metadata.name == 'monitoring' + ports: + - 3000 + - action: Allow + protocol: TCP + destination: + ports: + - 22 + - 80 + - 443 diff --git a/docs/project_docs/BOXP-16-codex-workspace-cron/plan.md b/docs/project_docs/BOXP-16-codex-workspace-cron/plan.md new file mode 100644 index 000000000..4f1b4032f --- /dev/null +++ b/docs/project_docs/BOXP-16-codex-workspace-cron/plan.md @@ -0,0 +1,41 @@ +# BOXP-16: Codex workspace にプロンプト定期実行環境を作る + +## Context + +OpenClaw 公式リポジトリ (`https://github.com/openclaw/openclaw`) の cron 実装を確認した。 + +- CLI は `openclaw cron create ` / `openclaw cron add` を提供する。 +- cron は Gateway 内蔵 scheduler として動く。 +- ジョブ定義は `~/.openclaw/cron/jobs.json` に永続化される。 +- 実行状態は `jobs-state.json`、履歴は `runs/.jsonl` に分離される。 +- isolated 実行ではジョブごとに新しい session を使い、delivery は announce/webhook/none を持つ。 + +Codex workspace には OpenClaw Gateway 相当の常駐 scheduler がないため、Kubernetes CronJob を scheduler とし、Codex CLI の非対話実行 (`codex exec`) を runner として使う。ただし OpenClaw の `jobs.json` に近い登録体験を残すため、複数 job の定義は ConfigMap の `jobs.yaml` に集約する。 + +## Design + +- `argoproj/codex-workspace` に reusable runner ConfigMap を追加する。 +- 複数 job は ConfigMap の `jobs.yaml` に `id/name/enabled/schedule/timeZone/session/promptFile/workdir/model` として登録する。 +- prompt は ConfigMap の `prompt-*.md` として GitOps 管理する。 +- スケジュール実行は Kubernetes `CronJob.spec.schedule` として管理し、CronJob は `CODEX_CRON_JOB_ID` で `jobs.yaml` の entry を選ぶ。 +- Runner の job selector は workspace image に含まれる Babashka (`bb`) で実行する。 +- CronJob は `ghcr.io/boxp/arch/codex-workspace:latest` を使い、既存 workspace と同じ home PVC を mount する。 +- RWO PVC を既存 workspace Pod と同じ node で mount できるよう、workspace Pod への pod affinity を設定する。 +- 実行履歴は `/home/boxp/.codex-cron/runs///` に保存する。 +- 実行中の多重起動は `concurrencyPolicy: Forbid` と runner lock で抑止する。 +- CronJob 用の Calico NetworkPolicy を追加し、DNS、Grafana、外部 22/80/443 のみ許可する。 + +## Acceptance Criteria + +- [x] Codex workspace の manifests に prompt 定期実行用 CronJob を追加する。 +- [x] 複数 job を `jobs.yaml` に登録し、それぞれ別 schedule/prompt で実行できる構造にする。 +- [x] prompt と schedule を GitOps で変更できる。 +- [x] Codex 実行ログ、stderr、最後の応答、summary を PVC 上に保存する。 +- [x] 同一ジョブの多重実行を避ける。 +- [x] 設定・有効化・手動実行手順を manifest 近くに文書化する。 +- [x] `kustomize build argoproj/codex-workspace` が成功する。 + +## Notes + +- 初期 CronJob は誤課金や意図しない自動作業を避けるため `suspend: true` にしておく。 +- 有効化する場合は対象 CronJob の `suspend: false` と prompt 内容を明示的に変更する。