Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 47 additions & 22 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <https://ade-specs.s3.us-east-2.amazonaws.com/v1/staging/openapi.json|the S3 object>, published by vision-tools-rest-api <https://github.com/landing-ai/vision-tools-rest-api/actions/workflows/publish-openapi.yml|Publish OpenAPI>. 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.') }}
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <https://ade-specs.s3.us-east-2.amazonaws.com/v2/staging/openapi.json|the S3 object>, published by aide <https://github.com/landing-ai/aide/actions/workflows/publish-openapi.yml|Publish OpenAPI>. 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.') }}
Expand Down
28 changes: 13 additions & 15 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
8 changes: 4 additions & 4 deletions docs/design/stainless-exit-and-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <apikey>`. Hosts differ from V1 (see environment matrix below).

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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`

Expand Down
4 changes: 3 additions & 1 deletion scripts/spec-sync/check-drift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# Compare a live spec against its committed snapshot.
# exit 0 -> no drift
# exit 10 -> drift detected; <committed-path> 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
Expand Down
31 changes: 29 additions & 2 deletions scripts/spec-sync/fetch-normalize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"