diff --git a/.github/workflows/runner-tool-versions.yml b/.github/workflows/runner-tool-versions.yml new file mode 100644 index 000000000..8b2045593 --- /dev/null +++ b/.github/workflows/runner-tool-versions.yml @@ -0,0 +1,362 @@ +name: Runner Image Freshness Check + +on: + schedule: + # Run weekly on Monday at 9 AM UTC + - cron: '0 9 * * 1' + + workflow_dispatch: # Allow manual triggering + +permissions: + contents: write + pull-requests: write + +concurrency: + group: runner-tool-versions + cancel-in-progress: false + +env: + DOCKERFILE: components/runners/ambient-runner/Dockerfile + +jobs: + check-versions: + name: Check runner component versions + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + + # ── Collect current versions from Dockerfile ────────────────────── + + - name: Parse current versions + id: current + run: | + parse() { grep -oP "ARG $1=\K[^\s]+" "$DOCKERFILE"; } + echo "base_digest=$(grep -oP 'ubi@sha256:\K[a-f0-9]+' "$DOCKERFILE")" >> "$GITHUB_OUTPUT" + echo "gh=$(parse GH_VERSION)" >> "$GITHUB_OUTPUT" + echo "glab=$(parse GLAB_VERSION)" >> "$GITHUB_OUTPUT" + echo "uv=$(parse UV_VERSION)" >> "$GITHUB_OUTPUT" + echo "pre_commit=$(parse PRE_COMMIT_VERSION)" >> "$GITHUB_OUTPUT" + echo "gemini_cli=$(parse GEMINI_CLI_VERSION)" >> "$GITHUB_OUTPUT" + + # ── Fetch latest upstream versions ──────────────────────────────── + + - name: Install skopeo + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq skopeo > /dev/null + + - name: Fetch latest versions + id: latest + run: | + fetch_version() { + local name="$1" url="$2" jq_path="$3" strip_v="${4:-false}" + local version + version=$(curl -sf --max-time 30 "$url" | jq -r "$jq_path") + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "Failed to fetch latest $name version" + exit 1 + fi + [ "$strip_v" = "true" ] && version="${version#v}" + echo "${name}=${version}" >> "$GITHUB_OUTPUT" + echo "$name: $version" + } + + # Base image digest (via skopeo, not curl) + DIGEST=$(skopeo inspect --no-tags \ + "docker://registry.access.redhat.com/ubi10/ubi:latest" 2>/dev/null \ + | jq -r '.Digest' | sed 's/sha256://') + if [ -z "$DIGEST" ] || [ "$DIGEST" = "null" ]; then + echo "Failed to fetch base image digest" + exit 1 + fi + echo "base_digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "Base image: $DIGEST" + + # Tool versions (fetched concurrently) + pids=() + names=() + + fetch_version gh \ + "https://api.github.com/repos/cli/cli/releases/latest" \ + '.tag_name' true & + pids+=($!); names+=("gh") + fetch_version glab \ + "https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/releases" \ + '.[0].tag_name' true & + pids+=($!); names+=("glab") + fetch_version uv \ + "https://pypi.org/pypi/uv/json" \ + '.info.version' & + pids+=($!); names+=("uv") + fetch_version pre_commit \ + "https://pypi.org/pypi/pre-commit/json" \ + '.info.version' & + pids+=($!); names+=("pre-commit") + fetch_version gemini_cli \ + "https://registry.npmjs.org/@google/gemini-cli/latest" \ + '.version' & + pids+=($!); names+=("gemini-cli") + + failed=false + for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + echo "::error::Failed to fetch ${names[$i]} version" + failed=true + fi + done + [ "$failed" = "true" ] && exit 1 + + # ── Determine what needs updating ───────────────────────────────── + + - name: Compare versions + id: diff + env: + CUR_BASE: ${{ steps.current.outputs.base_digest }} + LAT_BASE: ${{ steps.latest.outputs.base_digest }} + CUR_GH: ${{ steps.current.outputs.gh }} + LAT_GH: ${{ steps.latest.outputs.gh }} + CUR_GLAB: ${{ steps.current.outputs.glab }} + LAT_GLAB: ${{ steps.latest.outputs.glab }} + CUR_UV: ${{ steps.current.outputs.uv }} + LAT_UV: ${{ steps.latest.outputs.uv }} + CUR_PC: ${{ steps.current.outputs.pre_commit }} + LAT_PC: ${{ steps.latest.outputs.pre_commit }} + CUR_GEM: ${{ steps.current.outputs.gemini_cli }} + LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + run: | + needs_update=false + updates="" + + is_newer() { + local cur="$1" lat="$2" + [ "$(printf '%s\n%s' "$cur" "$lat" | sort -V | tail -1)" != "$cur" ] + } + + check_tool() { + local key="$1" cur="$2" lat="$3" + if is_newer "$cur" "$lat"; then + echo "${key}=true" >> "$GITHUB_OUTPUT" + needs_update=true + updates="${updates}${key}," + else + echo "${key}=false" >> "$GITHUB_OUTPUT" + fi + } + + if [ "$CUR_BASE" != "$LAT_BASE" ]; then + echo "base=true" >> "$GITHUB_OUTPUT" + needs_update=true + updates="${updates}base," + else + echo "base=false" >> "$GITHUB_OUTPUT" + fi + + check_tool gh "$CUR_GH" "$LAT_GH" + check_tool glab "$CUR_GLAB" "$LAT_GLAB" + check_tool uv "$CUR_UV" "$LAT_UV" + check_tool pc "$CUR_PC" "$LAT_PC" + check_tool gem "$CUR_GEM" "$LAT_GEM" + + echo "any=$needs_update" >> "$GITHUB_OUTPUT" + echo "updates=$updates" >> "$GITHUB_OUTPUT" + echo "Components to update: ${updates:-none}" + + # ── Apply updates to Dockerfile ─────────────────────────────────── + + - name: Update Dockerfile versions + if: steps.diff.outputs.any == 'true' + env: + CUR_BASE: ${{ steps.current.outputs.base_digest }} + LAT_BASE: ${{ steps.latest.outputs.base_digest }} + CUR_GH: ${{ steps.current.outputs.gh }} + LAT_GH: ${{ steps.latest.outputs.gh }} + CUR_GLAB: ${{ steps.current.outputs.glab }} + LAT_GLAB: ${{ steps.latest.outputs.glab }} + CUR_UV: ${{ steps.current.outputs.uv }} + LAT_UV: ${{ steps.latest.outputs.uv }} + CUR_PC: ${{ steps.current.outputs.pre_commit }} + LAT_PC: ${{ steps.latest.outputs.pre_commit }} + CUR_GEM: ${{ steps.current.outputs.gemini_cli }} + LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + UPD_BASE: ${{ steps.diff.outputs.base }} + UPD_GH: ${{ steps.diff.outputs.gh }} + UPD_GLAB: ${{ steps.diff.outputs.glab }} + UPD_UV: ${{ steps.diff.outputs.uv }} + UPD_PC: ${{ steps.diff.outputs.pc }} + UPD_GEM: ${{ steps.diff.outputs.gem }} + run: | + # Build sed expressions for all components that need updating + SED_ARGS=() + [ "$UPD_BASE" = "true" ] && SED_ARGS+=(-e "s|ubi@sha256:${CUR_BASE}|ubi@sha256:${LAT_BASE}|") + [ "$UPD_GH" = "true" ] && SED_ARGS+=(-e "s/ARG GH_VERSION=${CUR_GH}/ARG GH_VERSION=${LAT_GH}/") + [ "$UPD_GLAB" = "true" ] && SED_ARGS+=(-e "s/ARG GLAB_VERSION=${CUR_GLAB}/ARG GLAB_VERSION=${LAT_GLAB}/") + [ "$UPD_UV" = "true" ] && SED_ARGS+=(-e "s/ARG UV_VERSION=${CUR_UV}/ARG UV_VERSION=${LAT_UV}/") + [ "$UPD_PC" = "true" ] && SED_ARGS+=(-e "s/ARG PRE_COMMIT_VERSION=${CUR_PC}/ARG PRE_COMMIT_VERSION=${LAT_PC}/") + [ "$UPD_GEM" = "true" ] && SED_ARGS+=(-e "s/ARG GEMINI_CLI_VERSION=${CUR_GEM}/ARG GEMINI_CLI_VERSION=${LAT_GEM}/") + + if [ ${#SED_ARGS[@]} -gt 0 ]; then + sed -i "${SED_ARGS[@]}" "$DOCKERFILE" + fi + + # Sanity check: all ARGs still present and non-empty + for arg in GH_VERSION GLAB_VERSION UV_VERSION PRE_COMMIT_VERSION GEMINI_CLI_VERSION; do + if ! grep -qP "ARG ${arg}=\S+" "$DOCKERFILE"; then + echo "ERROR: ${arg} missing or empty after update" + exit 1 + fi + done + + # ── Create PR ───────────────────────────────────────────────────── + + - name: Check for existing PR + if: steps.diff.outputs.any == 'true' + id: existing_pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING=$(gh pr list \ + --head "auto/update-runner-image" \ + --state open \ + --json number \ + --jq 'length') + if [ "$EXISTING" -gt 0 ]; then + echo "pr_exists=true" >> "$GITHUB_OUTPUT" + else + echo "pr_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update branch and open PR if needed + if: steps.diff.outputs.any == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CUR_BASE: ${{ steps.current.outputs.base_digest }} + LAT_BASE: ${{ steps.latest.outputs.base_digest }} + CUR_GH: ${{ steps.current.outputs.gh }} + LAT_GH: ${{ steps.latest.outputs.gh }} + CUR_GLAB: ${{ steps.current.outputs.glab }} + LAT_GLAB: ${{ steps.latest.outputs.glab }} + CUR_UV: ${{ steps.current.outputs.uv }} + LAT_UV: ${{ steps.latest.outputs.uv }} + CUR_PC: ${{ steps.current.outputs.pre_commit }} + LAT_PC: ${{ steps.latest.outputs.pre_commit }} + CUR_GEM: ${{ steps.current.outputs.gemini_cli }} + LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + UPD_BASE: ${{ steps.diff.outputs.base }} + UPD_GH: ${{ steps.diff.outputs.gh }} + UPD_GLAB: ${{ steps.diff.outputs.glab }} + UPD_UV: ${{ steps.diff.outputs.uv }} + UPD_PC: ${{ steps.diff.outputs.pc }} + UPD_GEM: ${{ steps.diff.outputs.gem }} + run: | + BRANCH="auto/update-runner-image" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add "$DOCKERFILE" + + # Build a short summary for the commit title + CHANGED="" + [ "$UPD_BASE" = "true" ] && CHANGED="${CHANGED}base-image " + [ "$UPD_GH" = "true" ] && CHANGED="${CHANGED}gh " + [ "$UPD_GLAB" = "true" ] && CHANGED="${CHANGED}glab " + [ "$UPD_UV" = "true" ] && CHANGED="${CHANGED}uv " + [ "$UPD_PC" = "true" ] && CHANGED="${CHANGED}pre-commit " + [ "$UPD_GEM" = "true" ] && CHANGED="${CHANGED}gemini-cli " + CHANGED=$(echo "$CHANGED" | xargs | tr ' ' ', ') + + git commit -m "chore(runner): update ${CHANGED} + + Automated weekly runner image freshness update." + + git push -u --force-with-lease origin "$BRANCH" + + # Build PR body with version table + status() { + if [ "$1" = "true" ]; then echo "\`$2\` -> \`$3\`"; else echo "\`$2\` (up to date)"; fi + } + + PR_BODY="## Runner Image Freshness Update + + | Component | Status | + |-----------|--------| + | Base image (ubi10/ubi) | $(status "$UPD_BASE" "${CUR_BASE:0:12}…" "${LAT_BASE:0:12}…") | + | gh (GitHub CLI) | $(status "$UPD_GH" "$CUR_GH" "$LAT_GH") | + | glab (GitLab CLI) | $(status "$UPD_GLAB" "$CUR_GLAB" "$LAT_GLAB") | + | uv | $(status "$UPD_UV" "$CUR_UV" "$LAT_UV") | + | pre-commit | $(status "$UPD_PC" "$CUR_PC" "$LAT_PC") | + | gemini-cli | $(status "$UPD_GEM" "$CUR_GEM" "$LAT_GEM") | + + ### Components not version-pinned (updated with base image) + + git, jq, Node.js, Go (go-toolset) — installed via dnf from UBI 10 AppStream. + Their versions advance when the base image digest is updated. + + ## Test Plan + + - [ ] Container image builds successfully + - [ ] Runner starts and all tools are accessible + - [ ] Smoke test: \`gh version\`, \`glab version\`, \`uv --version\`, \`gemini --version\` + + --- + *Auto-generated by runner-tool-versions workflow*" + + if [ "${{ steps.existing_pr.outputs.pr_exists }}" = "false" ]; then + gh pr create \ + --title "chore(runner): freshen runner image (${CHANGED})" \ + --body "$PR_BODY" \ + --base main \ + --head "$BRANCH" + fi + + # ── Summary ─────────────────────────────────────────────────────── + + - name: Summary + if: always() + env: + CUR_BASE: ${{ steps.current.outputs.base_digest }} + LAT_BASE: ${{ steps.latest.outputs.base_digest }} + CUR_GH: ${{ steps.current.outputs.gh }} + LAT_GH: ${{ steps.latest.outputs.gh }} + CUR_GLAB: ${{ steps.current.outputs.glab }} + LAT_GLAB: ${{ steps.latest.outputs.glab }} + CUR_UV: ${{ steps.current.outputs.uv }} + LAT_UV: ${{ steps.latest.outputs.uv }} + CUR_PC: ${{ steps.current.outputs.pre_commit }} + LAT_PC: ${{ steps.latest.outputs.pre_commit }} + CUR_GEM: ${{ steps.current.outputs.gemini_cli }} + LAT_GEM: ${{ steps.latest.outputs.gemini_cli }} + ANY_UPDATE: ${{ steps.diff.outputs.any }} + PR_EXISTS: ${{ steps.existing_pr.outputs.pr_exists || 'false' }} + run: | + icon() { [ "$1" = "$2" ] && echo "✅" || echo "⬆️"; } + + { + echo "## Runner Image Freshness Report" + echo "" + echo "| Component | Current | Latest | |" + echo "|-----------|---------|--------|-|" + echo "| Base image | \`${CUR_BASE:0:12}…\` | \`${LAT_BASE:0:12}…\` | $(icon "$CUR_BASE" "$LAT_BASE") |" + echo "| gh | \`${CUR_GH}\` | \`${LAT_GH}\` | $(icon "$CUR_GH" "$LAT_GH") |" + echo "| glab | \`${CUR_GLAB}\` | \`${LAT_GLAB}\` | $(icon "$CUR_GLAB" "$LAT_GLAB") |" + echo "| uv | \`${CUR_UV}\` | \`${LAT_UV}\` | $(icon "$CUR_UV" "$LAT_UV") |" + echo "| pre-commit | \`${CUR_PC}\` | \`${LAT_PC}\` | $(icon "$CUR_PC" "$LAT_PC") |" + echo "| gemini-cli | \`${CUR_GEM}\` | \`${LAT_GEM}\` | $(icon "$CUR_GEM" "$LAT_GEM") |" + echo "" + if [ "$ANY_UPDATE" = "true" ]; then + if [ "$PR_EXISTS" = "true" ]; then + echo "Update PR already exists." + else + echo "PR created with updates." + fi + else + echo "All components are up to date." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sdd-preflight.yml b/.github/workflows/sdd-preflight.yml new file mode 100644 index 000000000..62630186b --- /dev/null +++ b/.github/workflows/sdd-preflight.yml @@ -0,0 +1,166 @@ +name: SDD Preflight + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + pull-requests: write + contents: read + +jobs: + check-managed-paths: + name: SDD boundary check + runs-on: ubuntu-latest + timeout-minutes: 2 + # Skip entirely if PR has sdd-exempt label + if: ${{ !contains(github.event.pull_request.labels.*.name, 'sdd-exempt') }} + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check SDD boundaries + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + MANIFEST=".specify/sdd-manifest.yaml" + if [ ! -f "$MANIFEST" ]; then + echo "No SDD manifest found, skipping" + echo "violation=false" >> "$GITHUB_OUTPUT" + echo "has_findings=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Get changed files in this PR + CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --name-only) + if [ -z "$CHANGED_FILES" ]; then + echo "No changed files, skipping" + echo "violation=false" >> "$GITHUB_OUTPUT" + echo "has_findings=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Parse all managed components in a single yq call: + # Output format: componentmodepath (one line per path) + DEFAULT_MODE=$(yq '.default-mode // "warn"' "$MANIFEST") + COMPONENT_PATHS=$(yq -r ' + .managed-components | to_entries[] | + .key as $comp | + (.value.mode // "'"$DEFAULT_MODE"'") as $mode | + .value.paths[] | + $comp + "\t" + $mode + "\t" + . + ' "$MANIFEST") + + if [ -z "$COMPONENT_PATHS" ]; then + echo "No managed paths defined, skipping" + echo "violation=false" >> "$GITHUB_OUTPUT" + echo "has_findings=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Convert glob patterns to grep regexes and build a lookup file + # Format: regexcomponentmode + PATTERN_FILE=$(mktemp) + while IFS=$'\t' read -r comp mode pattern; do + # Escape regex special chars in the pattern, then convert globs + regex=$(printf '%s' "$pattern" \ + | sed 's/[.+^${}()|[\]]/\\&/g' \ + | sed 's/\*\*/.*/g' \ + | sed 's/\*/[^\/]*/g') + printf '%s\t%s\t%s\n' "$regex" "$comp" "$mode" >> "$PATTERN_FILE" + done <<< "$COMPONENT_PATHS" + + # Match changed files against patterns + VIOLATIONS="" + WARNINGS="" + + while IFS= read -r changed_file; do + [ -z "$changed_file" ] && continue + while IFS=$'\t' read -r regex comp mode; do + if printf '%s' "$changed_file" | grep -qE "^${regex}$"; then + row="| \`${changed_file}\` | **${comp}** | ${mode} |" + if [ "$mode" = "enforce" ]; then + VIOLATIONS="${VIOLATIONS}${row}"$'\n' + else + WARNINGS="${WARNINGS}${row}"$'\n' + fi + break + fi + done < "$PATTERN_FILE" + done <<< "$CHANGED_FILES" + + rm -f "$PATTERN_FILE" + + # Determine result + if [ -n "$VIOLATIONS" ]; then + echo "violation=true" >> "$GITHUB_OUTPUT" + else + echo "violation=false" >> "$GITHUB_OUTPUT" + fi + + if [ -n "$WARNINGS" ] || [ -n "$VIOLATIONS" ]; then + echo "has_findings=true" >> "$GITHUB_OUTPUT" + else + echo "has_findings=false" >> "$GITHUB_OUTPUT" + fi + + # Build comment body and write to a file (avoids shell injection) + BODY_FILE=$(mktemp) + if [ -n "$VIOLATIONS" ]; then + cat > "$BODY_FILE" < + ## ⛔ SDD Preflight — Boundary Violation + + This PR modifies files in SDD-managed component(s) that require changes to go through the designated agent workflow. + + | File | Component | Mode | + |------|-----------|------| + ${VIOLATIONS} + **Action required**: These components are in \`enforce\` mode. Please use the component's agent workflow to make these changes, or request an exemption by adding the \`sdd-exempt\` label. + + 📖 See [SDD Manifest](.specify/sdd-manifest.yaml) for details. + COMMENTEOF + elif [ -n "$WARNINGS" ]; then + cat > "$BODY_FILE" < + ## ⚠️ SDD Preflight — Managed Paths Modified + + This PR modifies files in SDD-managed component(s). These components are migrating to Spec-Driven Development. + + | File | Component | Mode | + |------|-----------|------| + ${WARNINGS} + **No action required** — these components are in \`warn\` mode. Consider using the component's agent workflow for future changes. + + 📖 Specs: [Runner Spec](.specify/specs/runner.md) · [Runner Constitution](.specify/constitutions/runner.md) + COMMENTEOF + fi + + echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT" + + - name: Comment on PR + if: steps.check.outputs.has_findings == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Delete previous SDD preflight comments (identified by HTML marker) + gh api --paginate "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ + --jq '.[] | select(.body | contains("")) | .id' \ + | while read -r comment_id; do + gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" 2>/dev/null || true + done + + gh pr comment "$PR_NUMBER" --body-file "${{ steps.check.outputs.body_file }}" + + - name: Enforce SDD boundaries + if: steps.check.outputs.violation == 'true' + run: | + echo "::error::SDD boundary violation detected. See PR comment for details." + echo "::error::Add the 'sdd-exempt' label to bypass this check." + exit 1 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 346f14075..12b013e6c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -228,13 +228,13 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e '.[claude]' + pip install -e ".[all]" pip install pytest pytest-asyncio pytest-cov httpx - name: Run unit tests with coverage diff --git a/.specify/constitutions/runner.md b/.specify/constitutions/runner.md new file mode 100644 index 000000000..7a076e566 --- /dev/null +++ b/.specify/constitutions/runner.md @@ -0,0 +1,86 @@ +# Runner Constitution + +**Version**: 1.0.0 +**Ratified**: 2026-03-28 +**Parent**: [ACP Platform Constitution](../memory/constitution.md) + +This constitution governs the `components/runners/ambient-runner/` component and its supporting CI workflows. It inherits all principles from the platform constitution and adds runner-specific constraints. + +--- + +## Principle R-I: Version Pinning + +All external tools installed in the runner image MUST be version-pinned. + +- CLI tools (gh, glab) MUST use `ARG _VERSION=X.Y.Z` in the Dockerfile and be installed via pinned binary downloads — never from unpinned package repos. +- Python packages (uv, pre-commit) MUST use `==X.Y.Z` pins at install time. +- npm packages (gemini-cli) MUST use `@X.Y.Z` pins. +- The base image MUST be pinned by SHA digest. +- Versions MUST be declared as Dockerfile `ARG`s at the top of the file for automated bumping. + +**Rationale**: Unpinned installs cause non-reproducible builds and silent regressions. Pinning enables automated freshness tracking and controlled upgrades. + +## Principle R-II: Automated Freshness + +Runner tool versions MUST be checked for staleness automatically. + +- The `runner-tool-versions.yml` workflow runs weekly and on manual dispatch. +- It checks all pinned components against upstream registries. +- When updates are available, it opens a single PR with a version table. +- The workflow MUST NOT auto-merge; a human or authorized agent reviews. + +**Rationale**: Pinned versions go stale. Automated freshness checks balance reproducibility with security and feature currency. + +## Principle R-III: Dependency Update Procedure + +Dependency updates MUST follow the documented procedure in `docs/UPDATE_PROCEDURE.md`. + +- Python dependencies use `>=X.Y.Z` floor pins in pyproject.toml, resolved by `uv lock`. +- SDK bumps (claude-agent-sdk) MUST trigger a review of the frontend Agent Options schema for drift. +- Base image major version upgrades (e.g., UBI 9 → 10) require manual testing. +- Lock files MUST be regenerated after any pyproject.toml change. + +**Rationale**: A structured procedure prevents partial updates, version conflicts, and schema drift between backend SDK types and frontend forms. + +## Principle R-IV: Image Layer Discipline + +Dockerfile layers MUST be optimized for size and cacheability. + +- System packages (`dnf install`) SHOULD be consolidated into a single `RUN` layer. +- Build-only dependencies (e.g., `python3-devel`) MUST be removed in the same layer where they are last used, not in a separate layer. +- Binary CLI downloads (gh, glab) SHOULD share a single `RUN` layer to avoid redundant arch detection. +- `dnf clean all` and cache removal MUST happen in the same `RUN` as the install. + +**Rationale**: Docker layers are additive. Removing packages in a later layer doesn't reclaim space — it only adds whiteout entries. + +## Principle R-V: Agent Options Schema Sync + +The frontend Agent Options form MUST stay in sync with the claude-agent-sdk types. + +- `schema.ts` defines the Zod schema matching `ClaudeAgentOptions` from the SDK. +- `options-form.tsx` renders the form from the schema. +- Editor components in `_components/` MUST use stable React keys (ref-based IDs) for record/map editors to prevent focus loss on rename. +- Record editors MUST prevent key collisions on add operations. +- The form is gated behind the `advanced-agent-options` Unleash flag. + +**Rationale**: Schema drift between SDK and frontend creates silent data loss or validation errors. Stable keys prevent UX bugs in dynamic form editors. + +## Principle R-VI: Bridge Modularity + +Agent bridges (Claude, Gemini, LangGraph) MUST be isolated modules. + +- Each bridge lives in `ambient_runner/bridges//`. +- Bridges MUST NOT import from each other. +- Shared logic lives in `ambient_runner/` (bridge.py, platform/). +- New bridges follow the same directory structure and registration pattern. + +**Rationale**: Bridge isolation enables independent testing, deployment, and addition of new AI providers without cross-contamination. + +--- + +## Governance + +- This constitution is versioned using semver. +- Amendments require a PR that updates this file and passes the SDD preflight check. +- The platform constitution takes precedence on any conflict. +- Compliance is reviewed as part of runner-related PR reviews. diff --git a/.specify/sdd-manifest.yaml b/.specify/sdd-manifest.yaml new file mode 100644 index 000000000..56c942754 --- /dev/null +++ b/.specify/sdd-manifest.yaml @@ -0,0 +1,47 @@ +# SDD Manifest — Spec-Driven Development Enforcement +# +# Components listed here are governed by their spec-kit constitution and spec. +# Changes to managed paths MUST go through the designated agent workflow. +# The sdd-preflight CI job enforces this boundary. +# +# To add a new component: +# 1. Create its constitution in .specify/constitutions/.md +# 2. Create its spec in .specify/specs/.md +# 3. Add an entry below with paths, spec, constitution, and agent +# 4. The preflight job will begin enforcing on the next PR + +version: 1 + +# Platform-wide constitution (all components inherit from this) +platform-constitution: .specify/memory/constitution.md + +# Enforcement mode for new components during migration +# "warn" = comment on PR but don't block; "enforce" = required check +default-mode: warn + +managed-components: + runner: + description: > + Python runner executing Claude Code CLI in Job pods. + Manages AG-UI adapter, MCP integrations, and agent bridges. + paths: + - components/runners/ambient-runner/** + - components/frontend/src/components/claude-agent-options/** + - .github/workflows/runner-tool-versions.yml + constitution: .specify/constitutions/runner.md + spec: .specify/specs/runner.md + mode: warn + added-in-pr: 1091 + # Future: when a GitHub App or bot account is set up for the agent, + # set agent-login to its GitHub username for authorship checks. + # agent-login: ambient-runner-agent[bot] + + # Uncomment to onboard the next component: + # backend: + # description: Go REST API (Gin), manages K8s Custom Resources + # paths: + # - components/backend/** + # constitution: .specify/constitutions/backend.md + # spec: .specify/specs/backend.md + # mode: warn + # added-in-pr: TBD diff --git a/.specify/specs/runner.md b/.specify/specs/runner.md new file mode 100644 index 000000000..bd47f0847 --- /dev/null +++ b/.specify/specs/runner.md @@ -0,0 +1,104 @@ +# Runner Component Spec + +**Version**: 1.0.0 +**Created**: 2026-03-28 +**Constitution**: [Runner Constitution](../constitutions/runner.md) +**Component**: `components/runners/ambient-runner/` + +--- + +## Overview + +The ambient-runner is a Python application that executes AI agent sessions inside Kubernetes Job pods. It bridges AG-UI protocol events to multiple AI providers (Claude, Gemini, LangGraph) and exposes a FastAPI server on port 8001. + +## Component Boundary + +### Managed Paths + +``` +components/runners/ambient-runner/ +├── Dockerfile # Runner container image +├── main.py # FastAPI entry point +├── pyproject.toml # Python dependencies +├── uv.lock # Resolved dependency lock +├── .mcp.json # MCP server configuration +├── ag_ui_claude_sdk/ # Claude AG-UI adapter +├── ag_ui_gemini_cli/ # Gemini AG-UI adapter +├── ambient_runner/ # Core runner package +│ ├── bridges/ # Provider bridges +│ │ ├── claude/ +│ │ ├── gemini_cli/ +│ │ └── langgraph/ +│ ├── endpoints/ # FastAPI routes +│ ├── middleware/ # Request middleware +│ └── platform/ # Platform integration +├── tests/ # Test suite +└── docs/ + └── UPDATE_PROCEDURE.md # Maintenance procedure + +.github/workflows/ +└── runner-tool-versions.yml # Automated freshness checks +``` + +### Supporting Frontend Paths + +``` +components/frontend/src/components/claude-agent-options/ +├── schema.ts # Zod schema (mirrors SDK types) +├── options-form.tsx # Main form component +├── index.ts # Barrel exports +└── _components/ # Per-section editors +``` + +## Current State (as of PR #1091) + +### Base Image +- **UBI 10** (`registry.access.redhat.com/ubi10/ubi@sha256:...`) +- Python 3.12 (system default), Node.js (AppStream), Go (go-toolset) + +### Pinned Tools +| Tool | Dockerfile ARG | Purpose | +|------|---------------|---------| +| gh | `GH_VERSION` | GitHub CLI for repo operations | +| glab | `GLAB_VERSION` | GitLab CLI for repo operations | +| uv | `UV_VERSION` | Python package management | +| pre-commit | `PRE_COMMIT_VERSION` | Git hook framework | +| gemini-cli | `GEMINI_CLI_VERSION` | Google Gemini CLI | + +### Key Dependencies +| Package | Constraint | Role | +|---------|-----------|------| +| claude-agent-sdk | `>=0.1.50` | Claude Code agent SDK | +| anthropic | `>=0.86.0` | Anthropic API client | +| mcp | `>=1.9.2` | Model Context Protocol | +| ag-ui-protocol | `>=0.6.2` | AG-UI event protocol | + +## Maintenance Workflows + +### Weekly: Tool Freshness (`runner-tool-versions.yml`) +- Checks all pinned tools against upstream registries +- Opens a PR if any component has a newer version +- Does not auto-merge + +### Monthly: Dependency Bump (`UPDATE_PROCEDURE.md`) +- Bumps all Python dependencies to latest stable +- Checks for SDK type changes → syncs Agent Options schema +- Regenerates lock file +- Runs housekeeping (type hints, dead code) + +## Change Protocol + +1. All changes to managed paths MUST go through the SDD workflow when the component is in `enforce` mode, and SHOULD when in `warn` mode (see `sdd-manifest.yaml`). +2. Changes MUST comply with the runner constitution. +3. SDK bumps MUST include a schema sync check. +4. Dockerfile changes MUST maintain version pinning and layer discipline. +5. Test coverage MUST not decrease. + +## Verification Checklist + +- [ ] Container image builds successfully +- [ ] All tests pass (`pytest`) +- [ ] Pre-commit hooks pass +- [ ] `gh version`, `glab version`, `uv --version`, `gemini --version` work in container +- [ ] Agent Options form renders correctly (if schema changed) +- [ ] No `Optional[X]` or `List[X]` style type hints (Python 3.12 uses `X | None`, `list[X]`) diff --git a/components/frontend/src/components/claude-agent-options/_components/agents-editor.tsx b/components/frontend/src/components/claude-agent-options/_components/agents-editor.tsx new file mode 100644 index 000000000..f159eb13a --- /dev/null +++ b/components/frontend/src/components/claude-agent-options/_components/agents-editor.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useRef } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import type { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import type { agentDefinitionSchema } from "../schema"; +import { StringListEditor } from "./string-list-editor"; + +type AgentDef = z.infer; + +export function AgentsEditor({ value, onChange }: { value: Record; onChange: (v: Record) => void }) { + const nextId = useRef(0); + const ids = useRef([]); + + const entries = Object.entries(value); + + // Sync IDs with entries length (handles external resets) + while (ids.current.length < entries.length) { + ids.current.push(nextId.current++); + } + ids.current.length = entries.length; + + const addAgent = () => { + let i = 1; + while (`agent-${i}` in value) i++; + ids.current.push(nextId.current++); + onChange({ ...value, [`agent-${i}`]: { description: "", prompt: "" } }); + }; + const removeAgent = (index: number) => { + const name = entries[index][0]; + ids.current.splice(index, 1); + const next = { ...value }; + delete next[name]; + onChange(next); + }; + const updateAgentName = (index: number, newName: string) => { + const next: Record = {}; + for (let i = 0; i < entries.length; i++) { + next[i === index ? newName : entries[i][0]] = entries[i][1]; + } + onChange(next); + }; + const updateAgent = (name: string, agent: AgentDef) => onChange({ ...value, [name]: agent }); + + return ( +
+

Define custom sub-agents with their own prompt, tools, and model.

+ {entries.map(([name, agent], i) => ( +
+
+ updateAgentName(i, e.target.value)} /> + + +
+ updateAgent(name, { ...agent, description: e.target.value })} /> +