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
28 changes: 19 additions & 9 deletions .circleci/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ unauthenticated rate limit. With it, the limit is 5,000 req/hour per token.

[mise-tokens]: https://mise.en.dev/dev-tools/github-tokens.html

| Workflow | Job | Requires `github` context? |
|---------------|--------------------|----------------------------|
| `ci` | `check` | yes |
| `ci` | `main-pipeline` | yes |
| `release` | `release` | yes |
| `release-cli` | `build-cli` | yes |
| `release-cli` | `publish-platform` | yes |
| `release-cli` | `finalize-cli` | yes |
| Workflow | Job | Requires `github` context? |
|---------------|--------------------------|----------------------------|
| `ci` | `check` | yes |
| `ci` | `registry-compatibility` | yes |
| `ci` | `main-pipeline` | yes |
| `release` | `release` | yes |
| `release-cli` | `build-cli` | yes |
| `release-cli` | `publish-platform` | yes |
| `release-cli` | `finalize-cli` | yes |

If a future job adds `setup-mise` without attaching the `github` context, it will fail loudly
on the `Install tools` step with `mise WARN GitHub rate limit exceeded` once CircleCI's IP
Expand Down Expand Up @@ -94,7 +95,16 @@ Two packed CircleCI configs, one per pipeline dir.

### `development/` — CI

PR-time checks. Workflows: `ci` (runs `check` on non-main branches, runs `main-pipeline` on main).
PR-time checks. Workflows: `ci` (runs `check` and `registry-compatibility` on non-main branches, runs `main-pipeline` on main).

`registry-compatibility` is the live-registry type-compatibility job: it
fetches the deployed registry's OpenAPI spec (network dependency), regenerates
the engine's registry types in the ephemeral checkout, and runs
`bun turbo types` across the whole monorepo (hence the `turbo-cache` context
in addition to `github`). It fails when the live schema is unevaluable
(fetch/validate/codegen error) or when any type check fails — never on
snapshot age or diffs against the committed generated files, which are
discarded with the checkout.

### `release/` — CD

Expand Down
14 changes: 9 additions & 5 deletions .circleci/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,18 @@ jobs:
command: bun scripts/release/tag.ts
name: Tag Release
- notify-failure
openapi-freshness:
registry-compatibility:
docker:
- image: cimg/base:current
resource_class: small
resource_class: medium
steps:
- setup-mise
- run:
command: bun run --cwd packages/engine codegen:registry --check --strict
name: OpenAPI snapshot freshness
command: bun run --cwd packages/engine codegen:registry
name: Sync live registry OpenAPI spec
- run:
command: bun turbo types
name: Type-check against live registry types
release:
docker:
- image: cimg/base:current
Expand Down Expand Up @@ -129,8 +132,9 @@ workflows:
branches:
ignore:
- main
- openapi-freshness:
- registry-compatibility:
context:
- turbo-cache
- github
filters:
branches:
Expand Down
17 changes: 0 additions & 17 deletions .circleci/development/jobs/openapi-freshness.yml

This file was deleted.

21 changes: 21 additions & 0 deletions .circleci/development/jobs/registry-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
docker:
- image: cimg/base:current
resource_class: medium
steps:
- setup-mise
# Live-registry compatibility check. Fetches the registry's current
# OpenAPI spec, regenerates the engine's registry types in this
# ephemeral checkout, and type-checks the full monorepo against
# them. Fails when the live schema can't be fetched/validated/
# generated (unevaluable) or when any type check fails.
#
# Snapshot age and working-tree diffs are deliberately NOT failure
# conditions — the regenerated files are thrown away with the job's
# checkout. The committed snapshot stays authoritative for
# deterministic offline builds.
- run:
name: Sync live registry OpenAPI spec
command: bun run --cwd packages/engine codegen:registry
- run:
name: Type-check against live registry types
command: bun turbo types
12 changes: 7 additions & 5 deletions .circleci/development/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ jobs:
branches:
ignore:
- main
- openapi-freshness:
# Advisory job: exits 1 when the vendored OpenAPI snapshot is
# older than STALENESS_THRESHOLD_DAYS (default 7). Produces a
# failed CircleCI status → red X on the PR. Not a blocker
# unless added to GitHub's required-checks list.
- registry-compatibility:
# Compatibility job: regenerates registry types from the LIVE
# registry OpenAPI spec and type-checks the monorepo against
# them. Fails only when the live schema is unevaluable or the
# type check fails — never on snapshot age or generated diffs.
# Not a blocker unless added to GitHub's required-checks list.
context:
- turbo-cache
- github
filters:
branches:
Expand Down
19 changes: 12 additions & 7 deletions packages/engine/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,18 @@ Wire errors become structured `RegistryError` values via
`NOT_FOUND`, `NETWORK_ERROR` (with `attempts` count),
`REGISTRY_NOT_AVAILABLE`, `UNEXPECTED_ERROR`.

A CircleCI advisory job (`openapi-snapshot-freshness` in
`.circleci/development/jobs/`) verifies the snapshot's
`Generated-At` is no more than `STALENESS_THRESHOLD_DAYS` (default
`7`) old. Stale snapshots produce a failed CircleCI status and a
red X on the PR; the check is advisory and does not block merge by
default. Add the job to GitHub branch protection if you want
hard-block behavior.
A CircleCI job (`registry-compatibility` in
`.circleci/development/jobs/`) checks compatibility against the
**live** registry: it runs `codegen:registry` against the deployed
registry's OpenAPI spec (a network dependency), regenerates the
types in the ephemeral CI checkout, and runs `bun turbo types`
across the full monorepo. It fails when the live schema can't be
fetched/validated/generated or when any type check fails. Snapshot
age and diffs against the committed generated files are not failure
conditions — the regenerated output is discarded with the checkout,
and the committed snapshot remains authoritative for deterministic
offline builds. The check does not block merge by default; add the
job to GitHub branch protection if you want hard-block behavior.

## Bun runtime

Expand Down
86 changes: 8 additions & 78 deletions packages/engine/scripts/sync-registry-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,40 @@
* package and regenerate the TypeScript types the registry client
* imports from it.
*
* Two modes:
* Usage:
*
* bun run codegen:registry
* Sync mode. Fetches the OpenAPI YAML from
* `FACET_REGISTRY_OPENAPI_URL` (default: the live cafe registry),
* validates it parses as OpenAPI 3.x, atomically writes it to
* Fetches the OpenAPI YAML from `FACET_REGISTRY_OPENAPI_URL`
* (default: the live cafe registry), validates it parses as
* OpenAPI 3.x, atomically writes it to
* `src/registry/openapi.snapshot.yaml` with a leading metadata
* header, then runs `openapi-typescript` to emit
* `src/registry/generated/registry-api.ts`. Idempotent: re-running
* against an unchanged registry produces no diff. On any failure
* (network, parse, codegen), exits non-zero with a clear message
* and leaves on-disk state untouched.
*
* bun run codegen:registry --check [--strict]
* Check mode. Reads `Generated-At` from the on-disk snapshot,
* compares to `now`, prints a one-line freshness report. Pure
* offline read; never touches the network. Threshold from
* `STALENESS_THRESHOLD_DAYS` (default: 7). Exits 0 by default;
* with `--strict`, exits 1 when stale.
*
* The script lives outside `src/` because it is build/dev tooling,
* not engine runtime code. It is the only writer of
* `src/registry/openapi.snapshot.yaml` and `src/registry/generated/*`;
* neither file should ever be hand-edited.
*/

import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { atomicWriteFileSync } from '@agent-facets/common'
import { parse as parseYaml } from 'yaml'

const DEFAULT_OPENAPI_URL = 'https://api.agentfacets.io/v0/openapi.yaml'
const DEFAULT_STALENESS_THRESHOLD_DAYS = 7

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ENGINE_ROOT = resolve(SCRIPT_DIR, '..')
const SNAPSHOT_PATH = resolve(ENGINE_ROOT, 'src/registry/openapi.snapshot.yaml')
const GENERATED_PATH = resolve(ENGINE_ROOT, 'src/registry/generated/registry-api.ts')

const GENERATED_AT_HEADER_PATTERN = /^# Generated-At:\s*(\S+)/m

/**
* Two-mode entry point. Argument parsing is intentionally trivial —
* this script is invoked from `package.json`, not from a user shell,
* so we don't need a full flags library.
*/
async function main(argv: ReadonlyArray<string>): Promise<number> {
const args = new Set(argv)
if (args.has('--check')) {
return runCheck({ strict: args.has('--strict') })
}
return runSync()
}

/**
* Sync mode. Fetch → validate → atomically write snapshot → invoke
* Fetch → validate → atomically write snapshot → invoke
* codegen → atomically write generated module → print summary.
*
* Every failure path leaves on-disk state untouched; we only commit
Expand Down Expand Up @@ -127,45 +103,11 @@ async function runSync(): Promise<number> {
return 0
}

/**
* Check mode. Pure offline read of the on-disk snapshot's
* `Generated-At` header; compares to `now`; prints freshness.
*/
function runCheck({ strict }: { strict: boolean }): number {
if (!existsSync(SNAPSHOT_PATH)) {
process.stderr.write(`error: snapshot missing at ${SNAPSHOT_PATH}\n`)
process.stderr.write(`run \`bun run codegen:registry\` from packages/engine to generate it.\n`)
return 1
}

const contents = readFileSync(SNAPSHOT_PATH, 'utf8')
const match = GENERATED_AT_HEADER_PATTERN.exec(contents)
if (match === null) {
process.stderr.write(
`error: snapshot is missing the Generated-At header — likely corrupt; regenerate with \`bun run codegen:registry\`.\n`,
)
return 1
}
const generatedAt = new Date(match[1])
if (Number.isNaN(generatedAt.getTime())) {
process.stderr.write(`error: snapshot Generated-At header is not a valid ISO 8601 timestamp: ${match[1]}\n`)
return 1
}

const thresholdDays = parseThreshold(process.env.STALENESS_THRESHOLD_DAYS)
const ageDays = (Date.now() - generatedAt.getTime()) / (1000 * 60 * 60 * 24)
const ageDaysRounded = Math.round(ageDays * 10) / 10
const stale = ageDays > thresholdDays
const verdict = stale ? 'STALE' : 'fresh'
process.stdout.write(`snapshot is ${ageDaysRounded} days old (threshold: ${thresholdDays}d) — ${verdict}\n`)
return stale && strict ? 1 : 0
}

/**
* Compose the on-disk snapshot: leading metadata comment block, then
* upstream YAML body verbatim. The header is intentionally one
* `# Key: value` line per piece of metadata so the staleness check
* can target `Generated-At` with a one-line regex regardless of what
* `# Key: value` line per piece of metadata so any tooling can
* target a single line with a one-line regex regardless of what
* other lines we add later.
*/
function composeSnapshot({ url, generatedAt, body }: { url: string; generatedAt: string; body: string }): string {
Expand All @@ -182,21 +124,9 @@ function composeSnapshot({ url, generatedAt, body }: { url: string; generatedAt:
return header + body.replace(/^\n+/, '')
}

/**
* Parse `STALENESS_THRESHOLD_DAYS`. Falls back to the default on
* absent/invalid values rather than failing — the staleness check is
* advisory, not load-bearing, and a typo'd env shouldn't break CI.
*/
function parseThreshold(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_STALENESS_THRESHOLD_DAYS
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_STALENESS_THRESHOLD_DAYS
return parsed
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

const exitCode = await main(process.argv.slice(2))
const exitCode = await runSync()
process.exit(exitCode)