diff --git a/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md b/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md index 881aac61b..497b4cf41 100644 --- a/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md +++ b/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md @@ -30,11 +30,15 @@ Review guardrails for fixture and snapshot contract integrity. - JSON fixtures preserve stable structured output envelopes. - Volatile values are normalized in code, not patched ad hoc in fixtures. - Missing fixtures are generated through the snapshot update flow. - -## Validation - -- `npm run test:snapshots` -- `npm run test:schema-fixtures` -- `npm test -- src/snapshot-tests/__tests__/fixture-io.test.ts` -- `npm test -- src/snapshot-tests/__tests__/json-normalize.test.ts` -- `npx skill-check .agents/skills/xcodebuildmcp-snapshot-fixture-review` +- Verify project-defined normalization sentinels in normalization code and tests before reporting + them as implausible fixture values. In particular, `99999` is an intentional sentinel for + volatile UI capture counts. +- Group every occurrence of the same root cause into one finding and list the affected fixtures. + Do not emit separate findings for equivalent CLI, MCP, text, or JSON drift. +- Do not repeat a finding that is already resolved by the current diff. + +## Review limits + +- Review the changed fixtures and the directly relevant normalization or contract code only. +- Do not run snapshot or smoke suites as part of an automated PR review. +- Prefer one high-confidence cross-fixture finding over multiple speculative discrepancies. diff --git a/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md b/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md index bda14bb89..ee5c9f42a 100644 --- a/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md +++ b/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md @@ -17,6 +17,7 @@ Review guardrails for test isolation, scope, and contract validation. - `src/**/__tests__/**` - `src/test-utils/**` - `src/snapshot-tests/**` +- `scripts/**/__tests__/**` - `package.json` - `xcodebuildmcp.com/app/docs/_content/testing.mdx` - `xcodebuildmcp.com/app/docs/_content/contributing.mdx` diff --git a/.github/workflows/warden-watchdog.yml b/.github/workflows/warden-watchdog.yml new file mode 100644 index 000000000..0431d69af --- /dev/null +++ b/.github/workflows/warden-watchdog.yml @@ -0,0 +1,39 @@ +name: Warden watchdog + +on: + workflow_run: + workflows: [Warden] + # GitHub omits `requested` for reruns, so reruns are caught when they enter `in_progress`. + types: [requested, in_progress] + +permissions: + actions: write + contents: read + +jobs: + enforce-timeout: + if: >- + github.event.workflow_run.event == 'pull_request' && + (github.event.action == 'requested' || github.event.workflow_run.run_attempt > 1) + runs-on: ubuntu-latest + timeout-minutes: 11 + concurrency: + group: warden-watchdog-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + - name: Enforce Warden timeout + env: + GITHUB_TOKEN: ${{ github.token }} + TARGET_RUN_ID: ${{ github.event.workflow_run.id }} + TARGET_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + WARDEN_MAX_RUNTIME_SECONDS: 600 + WARDEN_POLL_SECONDS: 15 + run: node scripts/warden-watchdog.mjs diff --git a/.github/workflows/warden.yml b/.github/workflows/warden.yml deleted file mode 100644 index 916f10660..000000000 --- a/.github/workflows/warden.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Warden - -on: - pull_request: - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -# contents: write required for resolving review threads via GraphQL -# See: https://github.com/orgs/community/discussions/44650 -permissions: - contents: write - pull-requests: write - checks: write - -jobs: - review: - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - WARDEN_MODEL: ${{ secrets.WARDEN_MODEL }} - WARDEN_SENTRY_DSN: ${{ secrets.WARDEN_SENTRY_DSN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: getsentry/warden@2130c979dec0163048d954d9599504e2d9fa2b07 - with: - anthropic-api-key: ${{ secrets.WARDEN_ANTHROPIC_API_KEY }} diff --git a/AGENTS.md b/AGENTS.md index dc471e400..148b3cded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,9 @@ ESM TypeScript project (`type: module`). Key layers: - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Follow TypeScript best practices +- Review the complete merge-base diff and trace changed or reused helper contracts, including error and sentinel returns, through callers, consumers, tests, and operational configuration. +- Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. ## Import Conventions - ESM with explicit `.ts` extensions in `src/` (tsup rewrites to `.js` at build) @@ -92,6 +94,7 @@ When reading issues: - CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface. - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. +- Before handoff, run the matching manual Warden review for high-risk changes: runtime/CLI/daemon boundaries → `xcodebuildmcp-runtime-boundary-review`; test infrastructure or harnesses → `xcodebuildmcp-test-boundary-review`; tool manifests, schemas, or contracts → `xcodebuildmcp-tool-contract-review`. Invoke only applicable skills with `warden --skill `. - ## Multi-process filesystem state - XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. diff --git a/CLAUDE.md b/CLAUDE.md index e37749de9..732288fef 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,9 @@ - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Follow TypeScript best practices +- Review the complete merge-base diff and trace changed or reused helper contracts, including error and sentinel returns, through callers, consumers, tests, and operational configuration. +- Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. ## Test Conventions - Snapshot tests (`*.snapshot.test.ts`) must only assert generated tool output against fixtures. Move helper, parser, schema, setup, or behavior assertions to non-snapshot unit/integration tests. @@ -25,6 +27,7 @@ When reading issues: - CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface. - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. +- Before handoff, run the matching manual Warden review for high-risk changes: runtime/CLI/daemon boundaries → `xcodebuildmcp-runtime-boundary-review`; test infrastructure or harnesses → `xcodebuildmcp-test-boundary-review`; tool manifests, schemas, or contracts → `xcodebuildmcp-tool-contract-review`. Invoke only applicable skills with `warden --skill `. - ## Multi-process filesystem state - XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. diff --git a/package.json b/package.json index 235f8fc3f..ca30ee25a 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "package:macos:universal": "scripts/package-macos-portable.sh --universal", "verify:portable": "scripts/verify-portable-install.sh", "homebrew:formula": "scripts/create-homebrew-formula.sh", - "lint": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts'", - "lint:fix": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' --fix", - "format": "prettier --write 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}'", - "format:check": "prettier --check 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}'", + "lint": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", + "lint:fix": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs --fix", + "format": "prettier --write 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", + "format:check": "prettier --check 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", "typecheck": "npx tsc --noEmit && npx tsc -p tsconfig.test.json && npx tsc -p tsconfig.benchmarks.json", "typecheck:tests": "npx tsc -p tsconfig.test.json", "inspect": "npx @modelcontextprotocol/inspector@latest node build/cli.js mcp", @@ -47,6 +47,8 @@ "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT;BlueOak-1.0.0'", "knip": "knip", "test": "vitest run", + "posttest": "npm run test:warden-watchdog", + "test:warden-watchdog": "node --test scripts/__tests__/warden-watchdog.test.mjs", "test:schema-fixtures": "vitest run src/snapshot-tests/__tests__/json-fixture-schema.test.ts", "test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts", "test:snapshots": "npm run test:snapshot", diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs new file mode 100644 index 000000000..641da7009 --- /dev/null +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { URL } from 'node:url'; + +import { + githubRequest, + isPullRequestRun, + monitorWardenRun, + runStartTimeMs, + runtimeSeconds, +} from '../warden-watchdog.mjs'; + +function wardenRun(overrides = {}) { + return { + id: 1, + name: 'Warden', + event: 'pull_request', + status: 'in_progress', + run_attempt: 1, + created_at: '2026-07-21T09:00:00Z', + run_started_at: '2026-07-21T09:01:00Z', + ...overrides, + }; +} + +test('githubRequest scopes conflict handling to callers that allow it', async (context) => { + const originalFetch = globalThis.fetch; + context.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = async () => ({ ok: false, status: 409 }); + + await assert.rejects(githubRequest('/run'), /GitHub API GET \/run failed: 409/); + assert.equal(await githubRequest('/cancel', { method: 'POST', allowConflict: true }), null); +}); + +test('workflow starts the watchdog for initial runs and reruns', () => { + const workflow = readFileSync( + new URL('../../.github/workflows/warden-watchdog.yml', import.meta.url), + 'utf8', + ); + + assert.match(workflow, /types: \[requested, in_progress\]/); + assert.match(workflow, /github\.event\.action == 'requested'/); + assert.match(workflow, /github\.event\.workflow_run\.run_attempt > 1/); + assert.match( + workflow, + / {4}concurrency:\n {6}group: warden-watchdog-\$\{\{ github\.event\.workflow_run\.id \}\}\n {6}cancel-in-progress: true/, + ); + assert.match( + workflow, + /TARGET_RUN_ATTEMPT: \$\{\{ github\.event\.workflow_run\.run_attempt \}\}/, + ); +}); + +test('isPullRequestRun classifies runs using only the event', () => { + assert.equal(isPullRequestRun(wardenRun()), true); + assert.equal(isPullRequestRun(wardenRun({ name: '' })), true); + assert.equal(isPullRequestRun(wardenRun({ event: 'push' })), false); +}); + +test('runtimeSeconds includes queue time and never returns a negative duration', () => { + const run = wardenRun(); + + assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T09:10:00Z')), 600); + assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T08:59:00Z')), 0); +}); + +test('runStartTimeMs includes queue time from attempt-scoped rerun metadata', () => { + const rerun = wardenRun({ + run_attempt: 3, + created_at: '2026-07-21T09:30:00Z', + run_started_at: '2026-07-21T09:36:28Z', + }); + + assert.equal(runStartTimeMs(rerun), Date.parse('2026-07-21T09:30:00Z')); + assert.equal(runtimeSeconds(rerun, Date.parse('2026-07-21T09:40:00Z')), 600); +}); + +test('runStartTimeMs rejects a rerun without a valid attempt timestamp', () => { + assert.throws( + () => runStartTimeMs(wardenRun({ run_attempt: 2, created_at: null })), + /invalid start timestamp/, + ); +}); + +test('monitorWardenRun ignores non-PR runs', async () => { + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + getRun: async () => wardenRun({ event: 'push' }), + cancelRun: async () => assert.fail('non-PR run must not be cancelled'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: true }); +}); + +test('monitorWardenRun returns when the Warden run completes', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed' })]; + let nowMs = Date.parse('2026-07-21T09:00:00Z'); + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => nowMs, + sleep: async (milliseconds) => { + nowMs += milliseconds; + }, + getRun: async () => runs.shift(), + cancelRun: async () => assert.fail('completed run must not be cancelled'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun cancels a queued Warden run at the runtime limit', async () => { + let cancelled = false; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must be cancelled immediately'), + getRun: async () => wardenRun({ status: 'queued' }), + cancelRun: async () => { + cancelled = true; + return true; + }, + }); + + assert.equal(cancelled, true); + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun calculates each delay from one clock reading', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed' })]; + const times = [Date.parse('2026-07-21T09:09:59.999Z'), Date.parse('2026-07-21T09:10:00.001Z')]; + let sleptMilliseconds = 0; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => times.shift(), + sleep: async (milliseconds) => { + sleptMilliseconds = milliseconds; + }, + getRun: async () => runs.shift(), + cancelRun: async () => assert.fail('completed run must not be cancelled'), + }); + + assert.equal(sleptMilliseconds, 1); + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun never sleeps past the runtime limit', async () => { + let nowMs = Date.parse('2026-07-21T09:09:58Z'); + let sleptMilliseconds = 0; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => nowMs, + sleep: async (milliseconds) => { + sleptMilliseconds += milliseconds; + nowMs += milliseconds; + }, + getRun: async () => wardenRun(), + cancelRun: async () => true, + }); + + assert.equal(sleptMilliseconds, 2_000); + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun does not cancel a newer run attempt', async () => { + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + expectedRunAttempt: 1, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must check the current attempt immediately'), + getRun: async () => wardenRun({ run_attempt: 1 }), + getCurrentRun: async () => wardenRun({ run_attempt: 2 }), + cancelRun: async () => assert.fail('a superseded attempt must not cancel the current run'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: false, superseded: true }); +}); + +test('monitorWardenRun handles a run completing naturally during cancellation', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'success' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), + cancelRun: async () => true, + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun reports a completed cancellation', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'cancelled' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), + cancelRun: async () => true, + }); + + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun handles a run completing after cancellation is rejected', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'success' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), + cancelRun: async () => false, + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun fails if a rejected cancellation leaves the run active', async () => { + await assert.rejects( + monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => wardenRun(), + cancelRun: async () => false, + }), + /remained active/, + ); +}); diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs new file mode 100644 index 000000000..8bcf3e0a1 --- /dev/null +++ b/scripts/warden-watchdog.mjs @@ -0,0 +1,164 @@ +import process from 'node:process'; +import { setTimeout as sleepTimer } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; + +export function isPullRequestRun(run) { + return run.event === 'pull_request'; +} + +export function runStartTimeMs(run) { + const startTimeMs = Date.parse(run.created_at); + if (Number.isNaN(startTimeMs)) { + throw new Error('Warden run has an invalid start timestamp'); + } + return startTimeMs; +} + +export function runtimeSeconds(run, nowMs) { + return Math.max(0, Math.floor((nowMs - runStartTimeMs(run)) / 1000)); +} + +export async function monitorWardenRun({ + getRun, + getCurrentRun = getRun, + cancelRun, + expectedRunAttempt, + maxRuntimeSeconds, + pollSeconds, + now = Date.now, + sleep = sleepTimer, +}) { + let run = await getRun(); + + if (!isPullRequestRun(run)) { + return { cancelled: false, ignored: true }; + } + + const monitoredAttempt = expectedRunAttempt ?? run.run_attempt; + if (run.run_attempt !== monitoredAttempt) { + throw new Error(`Expected Warden run attempt ${monitoredAttempt}, received ${run.run_attempt}`); + } + + const deadlineMs = runStartTimeMs(run) + maxRuntimeSeconds * 1000; + while (run.status !== 'completed') { + const remainingMs = deadlineMs - now(); + if (remainingMs <= 0) { + break; + } + + await sleep(Math.min(pollSeconds * 1000, remainingMs)); + run = await getRun(); + } + + if (run.status === 'completed') { + return { cancelled: false, ignored: false }; + } + + const currentRun = await getCurrentRun(); + if (currentRun.run_attempt !== monitoredAttempt) { + return { cancelled: false, ignored: false, superseded: true }; + } + + if (currentRun.status === 'completed') { + return { cancelled: false, ignored: false }; + } + + const cancellationAccepted = await cancelRun(); + run = await getRun(); + if (run.status === 'completed') { + return { cancelled: cancellationAccepted && run.conclusion === 'cancelled', ignored: false }; + } + + if (cancellationAccepted) { + return { cancelled: true, ignored: false }; + } + + throw new Error('Warden run remained active after cancellation was rejected'); +} + +export async function githubRequest(path, options = {}) { + const { allowConflict = false, ...requestOptions } = options; + const response = await globalThis.fetch(`https://api.github.com${path}`, { + ...requestOptions, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...requestOptions.headers, + }, + signal: globalThis.AbortSignal.timeout(30_000), + }); + + if (!response.ok && !(allowConflict && response.status === 409)) { + throw new Error( + `GitHub API ${requestOptions.method ?? 'GET'} ${path} failed: ${response.status}`, + ); + } + + if (response.status === 202 || response.status === 204) { + return undefined; + } + + if (response.status === 409) { + return null; + } + + return response.json(); +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function positiveIntegerEnvironment(name) { + const value = Number.parseInt(requiredEnvironment(name), 10); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +async function main() { + const repository = requiredEnvironment('GITHUB_REPOSITORY'); + const runId = positiveIntegerEnvironment('TARGET_RUN_ID'); + const runAttempt = positiveIntegerEnvironment('TARGET_RUN_ATTEMPT'); + const maxRuntimeSeconds = positiveIntegerEnvironment('WARDEN_MAX_RUNTIME_SECONDS'); + requiredEnvironment('GITHUB_TOKEN'); + + const result = await monitorWardenRun({ + maxRuntimeSeconds, + pollSeconds: positiveIntegerEnvironment('WARDEN_POLL_SECONDS'), + expectedRunAttempt: runAttempt, + // The generic run endpoint retains attempt 1's created_at across reruns. + getRun: () => + githubRequest(`/repos/${repository}/actions/runs/${runId}/attempts/${runAttempt}`), + getCurrentRun: () => githubRequest(`/repos/${repository}/actions/runs/${runId}`), + cancelRun: async () => { + const response = await githubRequest(`/repos/${repository}/actions/runs/${runId}/cancel`, { + method: 'POST', + allowConflict: true, + }); + return response !== null; + }, + }); + + if (result.cancelled) { + throw new Error(`Cancelled Warden run ${runId} after exceeding ${maxRuntimeSeconds} seconds`); + } + + if (result.superseded) { + globalThis.console.log(`Warden run attempt ${runAttempt} was superseded`); + return; + } + + globalThis.console.log(result.ignored ? 'Ignored non-PR Warden run' : 'Warden run completed'); +} + +const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isMain) { + await main(); +} diff --git a/warden.toml b/warden.toml index 444e77e58..33b9f6892 100644 --- a/warden.toml +++ b/warden.toml @@ -1,28 +1,18 @@ # Warden Configuration # https://github.com/getsentry/warden # -# Warden reviews code using AI-powered skills triggered by GitHub events. -# Skills live in .agents/skills/ or .claude/skills/, or are pulled from GitHub -# via the remote field. -# -# Add skills with: warden add +# PR reviews are executed by the organization-managed Warden workflow. The repository watchdog +# cancels any Warden run that exceeds ten minutes. Keep this PR skill set intentionally small: +# each automatic check must have demonstrated unique, actionable findings. version = 1 -# Default settings inherited by all skills [defaults] -# Severity levels: critical, high, medium, low, info -# failOn: minimum severity that fails the check failOn = "high" -# reportOn: minimum severity that creates PR annotations reportOn = "medium" -# Avoid noisy no-op PR reporting. reportOnSuccess = false -# warden-sweep is a full-repo sweep. PR-trigger is intentionally omitted so -# it does not run on every PR. It runs: -# - On schedule via .github/workflows/warden-sweep.yml (workflow_dispatch + cron) -# - Locally via `warden --skill warden-sweep` +# Full-repository review remains scheduled and never runs on PR events. [[skills]] name = "warden-sweep" paths = ["src/**/*.ts", "scripts/**/*.{js,mjs,sh,ts}"] @@ -41,30 +31,19 @@ issueTitle = "Warden weekly sweep" createFixPR = true fixBranchPrefix = "warden-sweep" +# High-value PR reviews. maxTurns is deliberately capped so a single skill cannot monopolize the +# ten-minute workflow budget. [[skills]] -name = "xcodebuildmcp-docs-release-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "README.md", - "CHANGELOG.md", - "package.json", - "scripts/build-website-manifest.mjs", - "scripts/generate-github-release-notes.mjs", - "scripts/release.sh", - "xcodebuildmcp.com/app/docs/_content/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-docs-command-review" -maxTurns = 8 -maxFindings = 5 +name = "xcodebuildmcp-snapshot-fixture-review" +maxTurns = 3 +maxFindings = 3 paths = [ - "CHANGELOG.md", + "src/snapshot-tests/contracts.ts", + "src/snapshot-tests/fixture-io.ts", + "src/snapshot-tests/__tests__/fixture-io.test.ts", + "src/snapshot-tests/__tests__/json-normalize.test.ts", + "src/snapshot-tests/__tests__/json-fixture-schema.test.ts", + "src/snapshot-tests/__fixtures__/**", ] [[skills.triggers]] @@ -72,43 +51,36 @@ type = "pull_request" actions = ["opened", "synchronize", "reopened"] [[skills]] -name = "xcodebuildmcp-packaging-resource-review" -maxTurns = 10 -maxFindings = 5 +name = "find-bugs" +remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5" +maxTurns = 4 +maxFindings = 3 paths = [ - "package.json", - "scripts/copy-build-assets.js", - "scripts/package-macos-portable.sh", - "scripts/build-website-manifest.mjs", - "schemas/**", - "manifests/**", - "skills/**", - "bundled/**", + "src/cli.ts", + "src/cli/**", + "src/daemon.ts", + "src/daemon/**", + "src/doctor-cli.ts", + "src/integrations/**", + "src/mcp/resources/**", + "src/mcp/tools/**", + "src/runtime/**", + "src/server/**", + "src/utils/execution/**", + "scripts/**/*.{js,mjs,ts}", ] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-rendering-streaming-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/rendering/**", - "src/types/domain-fragments.ts", - "src/types/runtime-status.ts", - "src/runtime/tool-invoker.ts", - "src/runtime/__tests__/tool-invoker.test.ts", - "xcodebuildmcp.com/app/docs/_content/architecture-rendering-output.mdx", - "xcodebuildmcp.com/app/docs/_content/architecture-tool-lifecycle.mdx", - "xcodebuildmcp.com/app/docs/_content/output-formats.mdx", +ignorePaths = [ + "**/*.test.ts", + "**/__tests__/**", + "**/__fixtures__/**", + "**/__snapshots__/**", ] [[skills.triggers]] type = "pull_request" actions = ["opened", "synchronize", "reopened"] +# Targeted domain reviews run only when explicitly selected for high-risk changes. [[skills]] name = "xcodebuildmcp-runtime-boundary-review" maxTurns = 8 @@ -126,64 +98,25 @@ paths = [ ] [[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-snapshot-fixture-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/snapshot-tests/contracts.ts", - "src/snapshot-tests/fixture-io.ts", - "src/snapshot-tests/__tests__/fixture-io.test.ts", - "src/snapshot-tests/__tests__/json-normalize.test.ts", - "src/snapshot-tests/__tests__/json-fixture-schema.test.ts", - "src/snapshot-tests/__fixtures__/**", - "xcodebuildmcp.com/app/docs/_content/testing.mdx", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] +type = "local" [[skills]] -name = "xcodebuildmcp-structured-output-review" +name = "xcodebuildmcp-test-boundary-review" maxTurns = 8 maxFindings = 5 -paths = [ - "schemas/structured-output/**", - "src/core/structured-output-schema.ts", - "src/core/__tests__/structured-output-schema.test.ts", - "src/snapshot-tests/__fixtures__/json/**", - "src/snapshot-tests/__tests__/json-fixture-schema.test.ts", - "xcodebuildmcp.com/app/docs/_content/schema-versioning.mdx", - "xcodebuildmcp.com/app/docs/_content/output-formats.mdx", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-test-boundary-review" -maxTurns = 15 -maxFindings = 10 paths = [ "src/**/__tests__/**", "src/test-utils/**", "src/snapshot-tests/**", + "scripts/**/__tests__/**", "package.json", "xcodebuildmcp.com/app/docs/_content/testing.mdx", "xcodebuildmcp.com/app/docs/_content/contributing.mdx", ] -ignorePaths = [ - "src/snapshot-tests/__fixtures__/**", -] +ignorePaths = ["src/snapshot-tests/__fixtures__/**"] [[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] +type = "local" [[skills]] name = "xcodebuildmcp-tool-contract-review" @@ -201,11 +134,9 @@ paths = [ ] [[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -# Remote security skills from getsentry/warden-skills +type = "local" +# Local and scheduled security reviews are retained outside the PR critical path. [[skills]] name = "wrdn-pii" remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" @@ -226,7 +157,6 @@ ignorePaths = [ "src/snapshot-tests/__fixtures__/**", ] -# Temporarily local-only until the Pi model selector failure seen in PR checks is resolved. [[skills.triggers]] type = "local" @@ -246,36 +176,6 @@ ignorePaths = [ [[skills.triggers]] type = "schedule" -[[skills]] -name = "wrdn-code-execution" -remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/cli.ts", - "src/cli/**", - "src/daemon.ts", - "src/daemon/**", - "src/doctor-cli.ts", - "src/integrations/**", - "src/mcp/resources/**", - "src/mcp/tools/**", - "src/runtime/tool-invoker.ts", - "src/utils/execution/**", - "src/utils/xcodemake/**", - "scripts/**/*.{js,mjs,sh,ts}", -] -ignorePaths = [ - "**/*.test.ts", - "**/__tests__/**", - "**/__fixtures__/**", - "**/__snapshots__/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - [[skills]] name = "wrdn-data-exfil" remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" @@ -292,56 +192,6 @@ ignorePaths = [ [[skills.triggers]] type = "schedule" -[[skills]] -name = "wrdn-gha-workflows" -remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" -maxTurns = 8 -maxFindings = 5 -paths = [ - ".github/workflows/*.yml", - ".github/workflows/*.yaml", - ".github/actions/**/*.yml", - ".github/actions/**/*.yaml", - "action.yml", - "action.yaml", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -# Remote code-quality skills from getsentry/skills - -[[skills]] -name = "find-bugs" -remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/cli.ts", - "src/cli/**", - "src/daemon.ts", - "src/daemon/**", - "src/doctor-cli.ts", - "src/integrations/**", - "src/mcp/resources/**", - "src/mcp/tools/**", - "src/runtime/**", - "src/server/**", - "src/utils/execution/**", - "scripts/**/*.{js,mjs,ts}", -] -ignorePaths = [ - "**/*.test.ts", - "**/__tests__/**", - "**/__fixtures__/**", - "**/__snapshots__/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - [[skills]] name = "code-review" remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5"