diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index e36efbb..cafd8ea 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 # headroom for the AI wiring step (up to --max-turns 250) plus rye sync env: - V1_SPEC_URL: https://ade-specs.s3.us-east-2.amazonaws.com/v1/staging/openapi.json # staging spec, published to S3 by vision-tools-rest-api CI + V1_SPEC_URL: https://api.va.staging.landing.ai/v1/ade/openapi.json # staging drives the loop SYNC_BRANCH: spec-sync/v1 # fixed branch: reruns update one PR in place, never a pile of them SPEC_LABEL: V1 # used only in Slack/PR-comment copy to disambiguate the two jobs steps: @@ -54,10 +54,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - - name: Fail on fetch error - if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' + # An unavailable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back — an unbooked cluster serves a + # 404 (or the host stops answering entirely). Treat ONLY this as a no-op — log and end the run + # cleanly (no drift, no PR, no Slack); the next hourly run picks up any drift once staging is + # booked. Every other failure (a reachable source returning 401/403/5xx, an empty/invalid spec, + # or a script error) falls through to "Fail on spec error" below and alerts. + - name: Spec source unavailable — skip + if: steps.drift.outputs.code == '20' + run: echo "spec source unavailable (exit 20); staging is likely unbooked — skipping this run." + + # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a + # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. + - name: Fail on spec error + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored." exit 1 # If a sync PR is already open, do nothing: it's awaiting human review, and re-running @@ -216,9 +228,10 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. Tailors the message to the two failure modes worth - # calling out — a spec fetch/normalize error (S3 object missing/unreadable or its publisher failing) and an - # AI-wiring crash (PR left mechanical-only) — and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. An unavailable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable source that errored — an HTTP error + # status / empty / invalid spec, or a script error (exit not 0/10/20) — and an AI-wiring crash + # (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -228,8 +241,8 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10') - && format('Spec fetch/normalize failed (exit {0}). spec-sync reads the V1 spec from , published by vision-tools-rest-api . Check the object is present and readable, and that the publisher last ran green.', steps.drift.outputs.code) + ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored (an unbooked cluster 404s → exit 20, skipped, not alerted). Investigate.', steps.drift.outputs.code) || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' || 'spec-sync run failed. See the workflow run for details.') }} @@ -240,16 +253,15 @@ jobs: # and a separate sync branch, with a V2-tailored wiring prompt. Kept as a distinct job (not a # matrix) so the proven V1 loop is untouched and the two prompts can diverge freely. # - # HOSTS (do not conflate): the V2 *spec* originates in the AIDE gateway, but spec-sync reads it - # from the CI-published S3 copy (V2_SPEC_URL below) — not live from aide — so drift detection does - # not depend on staging being booked. The V2 *API* the SDK and contract tests call is api.ade.[env]; - # the SDK never talks to aide (its paths are staff-SSO-gated). + # HOSTS (do not conflate): the V2 *spec* is published at aide.[env]/openapi.json; the V2 *API* + # (what the SDK and contract tests call) is api.ade.[env]. We fetch drift from aide; the SDK + # never talks to aide (its paths are staff-SSO-gated). spec-sync-v2: if: github.repository == 'landing-ai/ade-python' runs-on: ubuntu-latest timeout-minutes: 60 # headroom for the AI wiring step (up to --max-turns 250) plus rye sync env: - V2_SPEC_URL: https://ade-specs.s3.us-east-2.amazonaws.com/v2/staging/openapi.json # staging spec, published to S3 by aide CI + V2_SPEC_URL: https://aide.staging.landing.ai/openapi.json # staging drives the loop SYNC_BRANCH: spec-sync/v2 # separate branch so V1 and V2 sync PRs never collide SPEC_LABEL: V2 # used only in Slack/PR-comment copy to disambiguate the two jobs steps: @@ -282,10 +294,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - - name: Fail on fetch error - if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' + # An unavailable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back — an unbooked cluster serves a + # 404 (or the host stops answering entirely). Treat ONLY this as a no-op — log and end the run + # cleanly (no drift, no PR, no Slack); the next hourly run picks up any drift once staging is + # booked. Every other failure (a reachable source returning 401/403/5xx, an empty/invalid spec, + # or a script error) falls through to "Fail on spec error" below and alerts. + - name: Spec source unavailable — skip + if: steps.drift.outputs.code == '20' + run: echo "spec source unavailable (exit 20); staging is likely unbooked — skipping this run." + + # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a + # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. + - name: Fail on spec error + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored." exit 1 - name: Check for an open sync PR @@ -520,9 +544,10 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. Tailors the message to the two failure modes worth - # calling out — a spec fetch/normalize error (S3 object missing/unreadable or its publisher failing) and an - # AI-wiring crash (PR left mechanical-only) — and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. An unavailable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable source that errored — an HTTP error + # status / empty / invalid spec, or a script error (exit not 0/10/20) — and an AI-wiring crash + # (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -532,8 +557,8 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10') - && format('Spec fetch/normalize failed (exit {0}). spec-sync reads the V2 spec from , published by aide . Check the object is present and readable, and that the publisher last ran green.', steps.drift.outputs.code) + ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored (an unbooked cluster 404s → exit 20, skipped, not alerted). Investigate.', steps.drift.outputs.code) || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' || 'spec-sync run failed. See the workflow run for details.') }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6449e53..508f7be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,25 +129,23 @@ the environment. ## Spec-sync pipeline -The SDK tracks the ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` +The SDK tracks the live ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` (hourly cron + manual `workflow_dispatch`). It is driven by the **staging** spec; releases gate on the **production** spec ("staging in, production out"). It runs **two independent loops** (one job each): the **V1** loop tracks the V1 spec against -`specs/v1-ade.json`, and the **V2** loop tracks the V2 spec against `specs/v2-aide.json` on a -separate `spec-sync/v2` branch. Both reuse the same scripts. - -Each loop reads the **staging** spec from a **CI-published copy on S3** -(`s3://ade-specs/{v1,v2}/staging/openapi.json`), generated by the upstream repos' -(`vision-tools-rest-api` / `aide`) CI — so spec-sync no longer depends on the staging cluster being -booked. The specs originate in the Vision Tools API and the AIDE gateway respectively; note the V2 -host split — the V2 *API* the SDK calls is `api.ade.[env]`, not the gateway. (The release gate still -live-fetches the **production** spec, which is always online.) - -On each run a loop fetches and normalizes its spec (`scripts/spec-sync/fetch-normalize.sh`) and -diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). On drift it opens one -PR with two attributed commits (paths shown for V1; the V2 loop uses the `v2-aide`/`v2_models` -equivalents): +`specs/v1-ade.json`, and the **V2** loop tracks the V2 spec on the AIDE gateway +(`aide.[env]/openapi.json`) against `specs/v2-aide.json` on a separate `spec-sync/v2` branch. Both +reuse the same scripts. (Note the host split: the V2 *spec* is published at `aide.[env]`, but the +V2 *API* the SDK calls is `api.ade.[env]`.) + +On each run a loop fetches and normalizes its live spec (`scripts/spec-sync/fetch-normalize.sh`) and +diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). Staging auto-reclaims +and must be booked, so an unavailable spec source (an unbooked cluster 404s, or the host stops +answering) is treated as an expected no-op — the run ends cleanly with no PR and no Slack alert, and +the next run picks up drift once staging is booked. A reachable source that returns an error status +(401/403/5xx) or an empty/invalid spec still fails loudly and alerts. On drift it opens one PR with +two attributed commits (paths shown for V1; the V2 loop uses the `v2-aide`/`v2_models` equivalents): 1. **Mechanical** — updated `specs/v1-ade.json` snapshot plus regenerated *reference* models in `specs/_generated/v1_models.py` (`scripts/spec-sync/gen-models.sh`, `datamodel-code-generator`). diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md index b320123..29ce6bc 100644 --- a/docs/design/stainless-exit-and-v2.md +++ b/docs/design/stainless-exit-and-v2.md @@ -54,7 +54,7 @@ It *would* be the right call for a large, fast-moving generated surface or many ### The API contract -Derived from the aide gateway source (`services/gateway/`, `packages/aide_temporal/.../public_workflows/{parse,extract_v2}.py`, `customer_surface.py`). **The exposed `openapi.json` is the source of truth once public — this table must be re-verified against it** (see the aide ask below; the URL currently returns 401). +Derived from the aide gateway source (`services/gateway/`, `packages/aide_temporal/.../public_workflows/{parse,extract_v2}.py`, `customer_surface.py`). **The exposed `openapi.json` is the source of truth — this table must be re-verified against it** (the aide ask below is now DONE — the curated spec is served **unauthenticated**). Auth is unchanged from V1: `Authorization: Bearer `. Hosts differ from V1 (see environment matrix below). @@ -115,9 +115,9 @@ The V2 API lives on different hosts, so the existing 2-entry `environment` map b A new `LANDINGAI_ADE_ENVIRONMENT` env var selects the environment without code changes — that's the QA workflow. Explicit `base_url` / `v2_base_url` overrides (params and env vars) remain for mock servers and proxies; if only `base_url` is set, V2 traffic follows it too, so a mock captures everything. Two implementation subtleties are deferred to the PR, noted here only so they aren't rediscovered: routing is per-resource rather than by path prefix (`POST /v1/files` lives on the aide host), and V1 request paths are untouched, so existing behavior can't change. API keys stay per-environment, passed through as today. Open item: security sign-off on shipping `dev`/`staging` hostnames in public source (not secrets; fallback is env-var-only recognition). -### Required from aide (one change) +### Required from aide (one change) — DONE -Serve the curated, customer-surface-only OpenAPI spec unauthenticated at each environment's gateway host — `https://aide[.env].landing.ai/openapi.json` currently returns 401 (staff SSO). The curation hook (`install_job_openapi()`) already exists; verify staff routes are excluded and multipart bodies render correctly for codegen. This unblocks both the contract verification above and Problem 3. +**DONE (2026-07):** the curated, customer-surface-only OpenAPI spec is now served **unauthenticated** at each environment's gateway host (`https://aide[.env].landing.ai/openapi.json` — was 401/staff SSO). The curation hook (`install_job_openapi()`) excludes staff routes and renders multipart bodies for codegen. This unblocked both the contract verification above and Problem 3, and lets spec-sync live-fetch the V2 staging spec. --- @@ -164,7 +164,7 @@ Two consequences to be explicit about: - **The surface-lock baseline must be the last *release tag*, not the previous commit on `main`.** Merged-but-unreleased surface stays mutable: if a staging feature is reshaped or dropped before it ever reaches production, the next sync PR can amend or remove it without tripping the compat gate. Only *released* surface is locked — which is the actual promise made to users. - **A staging-only feature holds the release train**: nothing releases until it either reaches production or is reverted in staging (at which point the next sync PR removes it and unblocks). Self-correcting in both directions, and acceptable at our deploy cadence. -Verified: the V1 staging spec is already public (`https://api.va.staging.landing.ai/v1/ade/openapi.json` → 200); aide's staging spec is behind the same auth as production, so the single aide ask in Problem 2 (expose the curated spec per environment) covers this. +Verified: both staging specs are now public and fetchable **without auth** — V1 at `https://api.va.staging.landing.ai/v1/ade/openapi.json` and V2 at `https://aide.staging.landing.ai/openapi.json` (after the Problem 2 aide change landed). spec-sync live-fetches both. ### The AI step, concretely: `anthropics/claude-code-action` diff --git a/scripts/spec-sync/check-drift.sh b/scripts/spec-sync/check-drift.sh index 1a788cf..333abf2 100755 --- a/scripts/spec-sync/check-drift.sh +++ b/scripts/spec-sync/check-drift.sh @@ -2,7 +2,9 @@ # Compare a live spec against its committed snapshot. # exit 0 -> no drift # exit 10 -> drift detected; updated in place with the live spec -# other -> operational error (e.g. fetch failure) +# exit 20 -> spec source unavailable: unreachable (transport) or an unbooked staging 404; the +# caller may treat this as an expected no-op +# other -> operational error (reachable but returns an error status / empty / invalid spec, script error, etc.) set -euo pipefail if [ "$#" -ne 2 ]; then diff --git a/scripts/spec-sync/fetch-normalize.sh b/scripts/spec-sync/fetch-normalize.sh index a9415fc..80586b4 100755 --- a/scripts/spec-sync/fetch-normalize.sh +++ b/scripts/spec-sync/fetch-normalize.sh @@ -9,6 +9,24 @@ if [ "$#" -ne 1 ]; then fi url="$1" +body="$(mktemp)" +trap 'rm -f "$body"' EXIT +# Fetch WITHOUT `-f` so we can inspect the HTTP status ourselves and separate "expected on staging" +# from "real problem" — `-f` collapses every 4xx/5xx into one exit code. A transport failure (DNS, +# connection refused, timeout — curl's own non-zero exit) means the source is unreachable, which the +# caller treats as an expected no-op: exit 20. +code="$(curl -sS --max-time 30 --retry 3 --retry-delay 2 -o "$body" -w '%{http_code}' "$url")" || exit 20 +# Map the HTTP status: +# 200 -> normalize below. +# 404 -> an unbooked/torn-down staging cluster serves 404 (the spec route disappears with the +# backend); this is the EXPECTED no-op, so exit 20 as well. +# else -> a reachable source returning 401/403 (auth), 5xx (server error), etc. is a REAL problem; +# exit 1 (operational error) so the workflow's catch-all alert fires. +case "$code" in + 200) : ;; + 404) exit 20 ;; + *) echo "fetch-normalize: unexpected HTTP $code from $url" >&2; exit 1 ;; +esac # `jq -S` sorts object keys only; array element order (e.g. `required`, `enum`, `tags`) is # preserved as emitted by the backend. This assumes the gateway emits arrays deterministically. # If it ever reorders them, drift detection would fire on cosmetic churn (phantom PRs) — start @@ -19,9 +37,18 @@ url="$1" # committed snapshot, so spec-sync's AI wiring never sees them and cannot re-add the hidden # surface. To un-hide, remove this filter and re-wire the resource. NOTE: V1 # `/v1/ade/extract/build-schema` is deliberately NOT stripped — only the V2 surface is hidden. -curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url" | jq -S ' +# +# A 200 with an empty/whitespace body would make `jq` emit nothing and exit 0 — a silent "empty spec" +# that check-drift would treat as drift and commit, clobbering the snapshot. Reject it loudly instead. +# (Malformed JSON already fails via jq's non-zero exit.) +normalized="$(jq -S ' del( .paths["/v2/extract/build-schema"], .paths["/v2/extract/build-schema/jobs"], .paths["/v2/extract/build-schema/jobs/{job_id}"] - )' + )' < "$body")" +if [ -z "$normalized" ]; then + echo "fetch-normalize: empty/blank spec from $url" >&2 + exit 1 +fi +printf '%s\n' "$normalized"