[codex] Add Codex workspace cron runner#594
Conversation
ArgoCD Diff ResultAuth path: tailscale アプリケーション: codex-workspace の差分パス: argoproj/codex-workspace ===== batch/CronJob /codex-cron-workspace-maintenance ======
0a1,95
> apiVersion: batch/v1
> kind: CronJob
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:batch/CronJob:codex-workspace/codex-cron-workspace-maintenance
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> name: codex-cron-workspace-maintenance
> spec:
> concurrencyPolicy: Forbid
> failedJobsHistoryLimit: 3
> jobTemplate:
> spec:
> backoffLimit: 0
> template:
> metadata:
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> spec:
> affinity:
> podAffinity:
> requiredDuringSchedulingIgnoredDuringExecution:
> - labelSelector:
> matchLabels:
> app: codex-workspace
> topologyKey: kubernetes.io/hostname
> automountServiceAccountToken: false
> containers:
> - command:
> - /opt/codex-cron/run-codex-cron.sh
> env:
> - name: HOME
> value: /home/boxp
> - name: CODEX_HOME
> value: /home/boxp/.codex
> - name: CODEX_CRON_NAME
> value: workspace-maintenance
> - name: CODEX_CRON_PROMPT_FILE
> value: /opt/codex-cron/prompt-workspace-maintenance.md
> - name: CODEX_CRON_WORKDIR
> value: /home/boxp
> - name: CODEX_CRON_OUTPUT_ROOT
> value: /home/boxp/.codex-cron/runs
> - name: CODEX_CRON_BYPASS_APPROVALS
> value: "true"
> - name: GRAFANA_URL
> value: http://grafana.monitoring.svc.cluster.local:3000
> - name: GRAFANA_SERVICE_ACCOUNT_TOKEN
> valueFrom:
> secretKeyRef:
> key: service-account-token
> name: codex-workspace-grafana
> image: ghcr.io/boxp/arch/codex-workspace:latest
> imagePullPolicy: Always
> name: codex
> resources:
> limits:
> cpu: "4"
> memory: 8Gi
> requests:
> cpu: 250m
> memory: 512Mi
> securityContext:
> allowPrivilegeEscalation: false
> capabilities:
> drop:
> - ALL
> readOnlyRootFilesystem: false
> runAsGroup: 1000
> runAsNonRoot: true
> runAsUser: 1000
> volumeMounts:
> - mountPath: /home/boxp
> name: home
> - mountPath: /opt/codex-cron
> name: cron-config
> readOnly: true
> nodeSelector:
> kubernetes.io/arch: amd64
> restartPolicy: Never
> volumes:
> - name: home
> persistentVolumeClaim:
> claimName: codex-workspace-home
> - configMap:
> defaultMode: 365
> name: codex-workspace-cron
> name: cron-config
> schedule: 0 22 * * *
> startingDeadlineSeconds: 900
> successfulJobsHistoryLimit: 3
> suspend: true
> timeZone: Etc/UTC
===== /ConfigMap codex-workspace/codex-workspace-cron ======
0a1,120
> apiVersion: v1
> data:
> 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 作成はしない。
> - 問題があれば、最後の応答に箇条書きで記録する。
> run-codex-cron.sh: |
> #!/usr/bin/env bash
> set -Eeuo pipefail
>
> job_name="${CODEX_CRON_NAME:?CODEX_CRON_NAME is required}"
> 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_name}/${run_id}"
> lock_dir="${lock_root}/${job_name}.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 "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}" <<EOF
> {
> "job": "${job_name}",
> "runId": "${run_id}",
> "status": "${status}",
> "exitCode": ${exit_code},
> "startedAt": "${started_at}",
> "finishedAt": "${finished_at}",
> "workdir": "${workdir}",
> "promptFile": "${prompt_file}"
> }
> EOF
>
> echo "Codex cron ${job_name}/${run_id}: ${status} (exit ${exit_code})"
> exit "${exit_code}"
> kind: ConfigMap
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:/ConfigMap:codex-workspace/codex-workspace-cron
> name: codex-workspace-cron
> namespace: codex-workspace
===== projectcalico.org/NetworkPolicy codex-workspace/codex-workspace-cron-network-policy ======
0a1,43
> apiVersion: projectcalico.org/v3
> kind: NetworkPolicy
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:projectcalico.org/NetworkPolicy:codex-workspace/codex-workspace-cron-network-policy
> name: codex-workspace-cron-network-policy
> namespace: codex-workspace
> spec:
> egress:
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: UDP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: TCP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'monitoring'
> ports:
> - 3000
> selector: app.kubernetes.io/component == 'grafana' && app.kubernetes.io/name
> == 'grafana' && app.kubernetes.io/part-of == 'kube-prometheus'
> protocol: TCP
> - action: Allow
> destination:
> ports:
> - 22
> - 80
> - 443
> protocol: TCP
> ingress: []
> selector: app == 'codex-workspace-cron'
> types:
> - Ingress
> - Egress
ℹ️ 上記の差分が見つかりました |
d78fe1a to
ef0ffb6
Compare
ArgoCD Diff ResultAuth path: tailscale アプリケーション: codex-workspace の差分パス: argoproj/codex-workspace ===== batch/CronJob /codex-cron-ticket-review ======
0a1,89
> apiVersion: batch/v1
> kind: CronJob
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:batch/CronJob:codex-workspace/codex-cron-ticket-review
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: ticket-review
> name: codex-cron-ticket-review
> spec:
> concurrencyPolicy: Forbid
> failedJobsHistoryLimit: 3
> jobTemplate:
> spec:
> backoffLimit: 0
> template:
> metadata:
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: ticket-review
> spec:
> affinity:
> podAffinity:
> requiredDuringSchedulingIgnoredDuringExecution:
> - labelSelector:
> matchLabels:
> app: codex-workspace
> topologyKey: kubernetes.io/hostname
> automountServiceAccountToken: false
> containers:
> - 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:
> key: service-account-token
> name: codex-workspace-grafana
> image: ghcr.io/boxp/arch/codex-workspace:latest
> imagePullPolicy: Always
> name: codex
> resources:
> limits:
> cpu: "4"
> memory: 8Gi
> requests:
> cpu: 250m
> memory: 512Mi
> securityContext:
> allowPrivilegeEscalation: false
> capabilities:
> drop:
> - ALL
> readOnlyRootFilesystem: false
> runAsGroup: 1000
> runAsNonRoot: true
> runAsUser: 1000
> volumeMounts:
> - mountPath: /home/boxp
> name: home
> - mountPath: /opt/codex-cron
> name: cron-config
> readOnly: true
> nodeSelector:
> kubernetes.io/arch: amd64
> restartPolicy: Never
> volumes:
> - name: home
> persistentVolumeClaim:
> claimName: codex-workspace-home
> - configMap:
> defaultMode: 365
> name: codex-workspace-cron
> name: cron-config
> schedule: 30 22 * * *
> startingDeadlineSeconds: 900
> successfulJobsHistoryLimit: 3
> suspend: true
> timeZone: Etc/UTC
===== projectcalico.org/NetworkPolicy codex-workspace/codex-workspace-cron-network-policy ======
0a1,43
> apiVersion: projectcalico.org/v3
> kind: NetworkPolicy
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:projectcalico.org/NetworkPolicy:codex-workspace/codex-workspace-cron-network-policy
> name: codex-workspace-cron-network-policy
> namespace: codex-workspace
> spec:
> egress:
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: UDP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: TCP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'monitoring'
> ports:
> - 3000
> selector: app.kubernetes.io/component == 'grafana' && app.kubernetes.io/name
> == 'grafana' && app.kubernetes.io/part-of == 'kube-prometheus'
> protocol: TCP
> - action: Allow
> destination:
> ports:
> - 22
> - 80
> - 443
> protocol: TCP
> ingress: []
> selector: app == 'codex-workspace-cron'
> types:
> - Ingress
> - Egress
===== /ConfigMap codex-workspace/codex-workspace-cron ======
0a1,234
> apiVersion: v1
> 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
> 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 作成はしない。
> - 日本語で簡潔に報告する。
> 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 作成はしない。
> - 問題があれば、最後の応答に箇条書きで記録する。
> 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 "$(
> python3 /opt/codex-cron/select-codex-cron-job.py "${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}" <<EOF
> {
> "job": "${job_id}",
> "name": "${job_name}",
> "runId": "${run_id}",
> "status": "${status}",
> "exitCode": ${exit_code},
> "startedAt": "${started_at}",
> "finishedAt": "${finished_at}",
> "workdir": "${workdir}",
> "promptFile": "${prompt_file}"
> }
> EOF
>
> echo "Codex cron ${job_id}/${run_id}: ${status} (exit ${exit_code})"
> exit "${exit_code}"
> select-codex-cron-job.py: |
> #!/usr/bin/env python3
> import re
> import shlex
> import sys
>
> SCALAR_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*):(?: +(.*))?$")
>
> def parse_scalar(raw):
> value = (raw or "").strip()
> if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
> value = value[1:-1]
> if value.lower() == "true":
> return True
> if value.lower() == "false":
> return False
> return value
>
> def load_jobs(path):
> jobs = []
> current = None
> with open(path, encoding="utf-8") as f:
> for line in f:
> stripped = line.strip()
> if not stripped or stripped.startswith("#") or stripped == "jobs:":
> continue
> if stripped.startswith("- "):
> if current:
> jobs.append(current)
> current = {}
> stripped = stripped[2:].strip()
> if not stripped:
> continue
> if current is None:
> raise SystemExit(f"invalid jobs file: property outside a job: {line.rstrip()}")
> match = SCALAR_RE.match(stripped)
> if not match:
> raise SystemExit(f"unsupported jobs.yaml line: {line.rstrip()}")
> current[match.group(1)] = parse_scalar(match.group(2))
> if current:
> jobs.append(current)
> return jobs
>
> def emit(name, value):
> if value is None or value == "":
> return
> print(f"{name}={shlex.quote(str(value))}")
>
> def main():
> if len(sys.argv) != 3:
> raise SystemExit("usage: select-codex-cron-job.py <jobs.yaml> <job-id>")
> jobs = load_jobs(sys.argv[1])
> job = next((j for j in jobs if j.get("id") == sys.argv[2]), None)
> if not job:
> raise SystemExit(f"codex cron job not found: {sys.argv[2]}")
> if job.get("enabled") is not True:
> raise SystemExit(f"codex cron job is disabled in jobs.yaml: {sys.argv[2]}")
>
> emit("CODEX_CRON_NAME", job.get("name") or job.get("id"))
> emit("CODEX_CRON_PROMPT_FILE", job.get("promptFile"))
> emit("CODEX_CRON_WORKDIR", job.get("workdir"))
> emit("CODEX_CRON_OUTPUT_ROOT", job.get("outputRoot"))
> emit("CODEX_CRON_MODEL", job.get("model"))
> emit("CODEX_CRON_PROFILE", job.get("profile"))
> emit("CODEX_CRON_BYPASS_APPROVALS", str(job.get("bypassApprovals", True)).lower())
> emit("CODEX_CRON_EXTRA_ARGS", job.get("extraArgs"))
>
> if __name__ == "__main__":
> main()
> kind: ConfigMap
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:/ConfigMap:codex-workspace/codex-workspace-cron
> name: codex-workspace-cron
> namespace: codex-workspace
===== batch/CronJob /codex-cron-workspace-maintenance ======
0a1,89
> apiVersion: batch/v1
> kind: CronJob
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:batch/CronJob:codex-workspace/codex-cron-workspace-maintenance
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> name: codex-cron-workspace-maintenance
> spec:
> concurrencyPolicy: Forbid
> failedJobsHistoryLimit: 3
> jobTemplate:
> spec:
> backoffLimit: 0
> template:
> metadata:
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> spec:
> affinity:
> podAffinity:
> requiredDuringSchedulingIgnoredDuringExecution:
> - labelSelector:
> matchLabels:
> app: codex-workspace
> topologyKey: kubernetes.io/hostname
> automountServiceAccountToken: false
> containers:
> - 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:
> key: service-account-token
> name: codex-workspace-grafana
> image: ghcr.io/boxp/arch/codex-workspace:latest
> imagePullPolicy: Always
> name: codex
> resources:
> limits:
> cpu: "4"
> memory: 8Gi
> requests:
> cpu: 250m
> memory: 512Mi
> securityContext:
> allowPrivilegeEscalation: false
> capabilities:
> drop:
> - ALL
> readOnlyRootFilesystem: false
> runAsGroup: 1000
> runAsNonRoot: true
> runAsUser: 1000
> volumeMounts:
> - mountPath: /home/boxp
> name: home
> - mountPath: /opt/codex-cron
> name: cron-config
> readOnly: true
> nodeSelector:
> kubernetes.io/arch: amd64
> restartPolicy: Never
> volumes:
> - name: home
> persistentVolumeClaim:
> claimName: codex-workspace-home
> - configMap:
> defaultMode: 365
> name: codex-workspace-cron
> name: cron-config
> schedule: 0 22 * * *
> startingDeadlineSeconds: 900
> successfulJobsHistoryLimit: 3
> suspend: true
> timeZone: Etc/UTC
ℹ️ 上記の差分が見つかりました |
ef0ffb6 to
eb1ca30
Compare
ArgoCD Diff ResultAuth path: tailscale アプリケーション: codex-workspace の差分パス: argoproj/codex-workspace ===== projectcalico.org/NetworkPolicy codex-workspace/codex-workspace-cron-network-policy ======
0a1,43
> apiVersion: projectcalico.org/v3
> kind: NetworkPolicy
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:projectcalico.org/NetworkPolicy:codex-workspace/codex-workspace-cron-network-policy
> name: codex-workspace-cron-network-policy
> namespace: codex-workspace
> spec:
> egress:
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: UDP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'kube-system'
> ports:
> - 53
> selector: k8s-app == 'kube-dns'
> protocol: TCP
> - action: Allow
> destination:
> namespaceSelector: kubernetes.io/metadata.name == 'monitoring'
> ports:
> - 3000
> selector: app.kubernetes.io/component == 'grafana' && app.kubernetes.io/name
> == 'grafana' && app.kubernetes.io/part-of == 'kube-prometheus'
> protocol: TCP
> - action: Allow
> destination:
> ports:
> - 22
> - 80
> - 443
> protocol: TCP
> ingress: []
> selector: app == 'codex-workspace-cron'
> types:
> - Ingress
> - Egress
===== /ConfigMap codex-workspace/codex-workspace-cron ======
0a1,244
> apiVersion: v1
> 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
> 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 作成はしない。
> - 日本語で簡潔に報告する。
> 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 作成はしない。
> - 問題があれば、最後の応答に箇条書きで記録する。
> 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}" <<EOF
> {
> "job": "${job_id}",
> "name": "${job_name}",
> "runId": "${run_id}",
> "status": "${status}",
> "exitCode": ${exit_code},
> "startedAt": "${started_at}",
> "finishedAt": "${finished_at}",
> "workdir": "${workdir}",
> "promptFile": "${prompt_file}"
> }
> EOF
>
> echo "Codex cron ${job_id}/${run_id}: ${status} (exit ${exit_code})"
> exit "${exit_code}"
> select-codex-cron-job.bb: |
> #!/usr/bin/env bb
>
> (require '[clojure.string :as str])
>
> (defn fail [message]
> (binding [*out* *err*]
> (println message))
> (System/exit 1))
>
> (defn shell-quote [value]
> (let [s (str value)]
> (str "'" (str/replace s #"'" "'\"'\"'") "'")))
>
> (defn parse-scalar [raw]
> (let [value (str/trim (or raw ""))]
> (cond
> (and (>= (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 <jobs.yaml> <job-id>"))
> (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"))))
> kind: ConfigMap
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:/ConfigMap:codex-workspace/codex-workspace-cron
> name: codex-workspace-cron
> namespace: codex-workspace
===== batch/CronJob /codex-cron-ticket-review ======
0a1,89
> apiVersion: batch/v1
> kind: CronJob
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:batch/CronJob:codex-workspace/codex-cron-ticket-review
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: ticket-review
> name: codex-cron-ticket-review
> spec:
> concurrencyPolicy: Forbid
> failedJobsHistoryLimit: 3
> jobTemplate:
> spec:
> backoffLimit: 0
> template:
> metadata:
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: ticket-review
> spec:
> affinity:
> podAffinity:
> requiredDuringSchedulingIgnoredDuringExecution:
> - labelSelector:
> matchLabels:
> app: codex-workspace
> topologyKey: kubernetes.io/hostname
> automountServiceAccountToken: false
> containers:
> - 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:
> key: service-account-token
> name: codex-workspace-grafana
> image: ghcr.io/boxp/arch/codex-workspace:latest
> imagePullPolicy: Always
> name: codex
> resources:
> limits:
> cpu: "4"
> memory: 8Gi
> requests:
> cpu: 250m
> memory: 512Mi
> securityContext:
> allowPrivilegeEscalation: false
> capabilities:
> drop:
> - ALL
> readOnlyRootFilesystem: false
> runAsGroup: 1000
> runAsNonRoot: true
> runAsUser: 1000
> volumeMounts:
> - mountPath: /home/boxp
> name: home
> - mountPath: /opt/codex-cron
> name: cron-config
> readOnly: true
> nodeSelector:
> kubernetes.io/arch: amd64
> restartPolicy: Never
> volumes:
> - name: home
> persistentVolumeClaim:
> claimName: codex-workspace-home
> - configMap:
> defaultMode: 365
> name: codex-workspace-cron
> name: cron-config
> schedule: 30 22 * * *
> startingDeadlineSeconds: 900
> successfulJobsHistoryLimit: 3
> suspend: true
> timeZone: Etc/UTC
===== batch/CronJob /codex-cron-workspace-maintenance ======
0a1,89
> apiVersion: batch/v1
> kind: CronJob
> metadata:
> annotations:
> argocd.argoproj.io/tracking-id: codex-workspace:batch/CronJob:codex-workspace/codex-cron-workspace-maintenance
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> name: codex-cron-workspace-maintenance
> spec:
> concurrencyPolicy: Forbid
> failedJobsHistoryLimit: 3
> jobTemplate:
> spec:
> backoffLimit: 0
> template:
> metadata:
> labels:
> app: codex-workspace-cron
> codex-workspace.boxp.io/cron: workspace-maintenance
> spec:
> affinity:
> podAffinity:
> requiredDuringSchedulingIgnoredDuringExecution:
> - labelSelector:
> matchLabels:
> app: codex-workspace
> topologyKey: kubernetes.io/hostname
> automountServiceAccountToken: false
> containers:
> - 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:
> key: service-account-token
> name: codex-workspace-grafana
> image: ghcr.io/boxp/arch/codex-workspace:latest
> imagePullPolicy: Always
> name: codex
> resources:
> limits:
> cpu: "4"
> memory: 8Gi
> requests:
> cpu: 250m
> memory: 512Mi
> securityContext:
> allowPrivilegeEscalation: false
> capabilities:
> drop:
> - ALL
> readOnlyRootFilesystem: false
> runAsGroup: 1000
> runAsNonRoot: true
> runAsUser: 1000
> volumeMounts:
> - mountPath: /home/boxp
> name: home
> - mountPath: /opt/codex-cron
> name: cron-config
> readOnly: true
> nodeSelector:
> kubernetes.io/arch: amd64
> restartPolicy: Never
> volumes:
> - name: home
> persistentVolumeClaim:
> claimName: codex-workspace-home
> - configMap:
> defaultMode: 365
> name: codex-workspace-cron
> name: cron-config
> schedule: 0 22 * * *
> startingDeadlineSeconds: 900
> successfulJobsHistoryLimit: 3
> suspend: true
> timeZone: Etc/UTC
ℹ️ 上記の差分が見つかりました |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb1ca30baa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| labels: | ||
| app: codex-workspace-cron |
There was a problem hiding this comment.
Grafana への ingress 許可に合うラベルにしてください
cron Pod には app: codex-workspace-cron だけが付くため、既存の argoproj/prometheus-operator/overlays/network-policy-grafana.yaml で許可されている codex-workspace namespace かつ podSelector app: codex-workspace に一致しません。この状態では、cron 側の egress と GRAFANA_URL/token を追加しても、Grafana 側の ingress NetworkPolicy により TCP 3000 がブロックされるため、CronJob から Grafana/MCP を使う実行だけ失敗します。Grafana 側の policy に app: codex-workspace-cron を追加するか、Pod ラベルを許可済みセレクタにも一致させる必要があります。
Useful? React with 👍 / 👎.
Summary
jobs.yaml, multiple prompt files, and a reusablecodex execrunner.jobs.yamlentry viaCODEX_CRON_JOB_ID.docs/project_docs/BOXP-16-codex-workspace-cron/plan.md.Related PR
codex-workspace-cronCodex skill and Babashka CRUD helper used to manage this PR'sjobs.yamland CronJob manifests from inside the workspace.Notes
OpenClaw's official cron implementation runs inside the Gateway and persists multiple jobs in
~/.openclaw/cron/jobs.json, state injobs-state.json, and run history underruns/*.jsonl. Codex workspace does not have an equivalent resident Gateway scheduler, so this PR maps the multi-job registration model onto ConfigMap-managedjobs.yamlplus Kubernetes CronJobs as the scheduler.Each job has its own
id,schedule,promptFile,workdir, and optional model/profile settings. The initial jobs are disabled with bothenabled: falseandsuspend: trueto avoid unattended model usage until each prompt and schedule is reviewed.Runner helper code uses Babashka (
bb), not Python.Validation
kustomize build argoproj/codex-workspacerun-codex-cron.shfrom the ConfigMap and ranbash -nselect-codex-cron-job.bband verified it selects both registered jobs fromjobs.yamlgit diff --check