From 107e1055d7262fa37bf5c4a94a87d492189b7721 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 27 Mar 2026 21:31:22 -0400 Subject: [PATCH 01/23] feat: resume coderabbit when claude addresses feedback --- .github/workflows/claude.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bece89a..b35fd7f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -25,7 +25,7 @@ jobs: timeout-minutes: 30 permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write actions: read @@ -42,3 +42,21 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} additional_permissions: | actions: read + + - name: Request CodeRabbit re-review + if: >- + steps.claude.outcome == 'success' && ( + github.event.issue.pull_request || + github.event_name == 'pull_request_review_comment' || + github.event_name == 'pull_request_review' + ) + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: >- + ${{ + github.event.issue.number || + github.event.pull_request.number + }} + run: | + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --body "@coderabbitai review" From ad104e1e7b6d437d6676bf13977d87f28d88cb92 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 31 Mar 2026 23:04:38 -0400 Subject: [PATCH 02/23] docs: track design specs in git, add release pipeline spec (#70) Change gitignore to only exclude superpowers plans (ephemeral) while tracking specs (lasting decision records). Add release pipeline design spec covering changesets, goreleaser, Helm OCI publish, and site deploy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 +- .../2026-03-31-release-pipeline-design.md | 115 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-31-release-pipeline-design.md diff --git a/.gitignore b/.gitignore index 45f8ee1..afa3e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,8 +46,8 @@ coverage.html ai/ .stitch/ -# Internal development process (plans, specs, design exploration) -docs/superpowers/ +# Internal development process (ephemeral implementation plans) +docs/superpowers/plans/ plans/ marketing/variants/ diff --git a/docs/superpowers/specs/2026-03-31-release-pipeline-design.md b/docs/superpowers/specs/2026-03-31-release-pipeline-design.md new file mode 100644 index 0000000..cce6920 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-release-pipeline-design.md @@ -0,0 +1,115 @@ +# Automated Release Pipeline Design + +> **Issue:** [#70 — Automated release pipeline (goreleaser, Helm publish, site deploy)](https://github.com/dvflw/mantle/issues/70) + +## Overview + +Automate the full release lifecycle for all three monorepo packages (engine, helm-chart, site) using changesets for version management, goreleaser for engine builds, OCI registry for Helm chart distribution, and Cloudflare Pages for site deployment. + +## Release Trigger Flow + +Changesets manages versioning across all packages. The flow: + +1. Developer runs `bunx changeset` to describe changes and semver bump level +2. Changeset `.md` file is committed with the PR +3. On merge to main, `release-please.yml` runs `changeset version`, bumps versions, updates changelogs, and opens/updates a "Version Packages" PR +4. Merging the Version PR triggers `release-tags.yml`, which diffs the merge commit to detect version bumps and creates per-package git tags (`engine/v0.4.1`, `helm-chart/v0.2.0`, etc.) +5. Per-package tag creation triggers the corresponding release workflow + +The site is an exception — it deploys on every push to main that touches `packages/site/**`, not gated by version tags. + +## Workflow File Structure + +| Workflow | Trigger | Purpose | +|---|---|---| +| `release-please.yml` | push to main | Run changesets, open/update Version PR | +| `release-tags.yml` | push to main (filters to commits from changesets bot via commit message check) | Detect bumped packages, create per-package git tags, sync `Chart.yaml` `appVersion` | +| `release-engine.yml` | `engine/v*` tag | goreleaser: binaries, Docker image, GitHub Release | +| `release-helm.yml` | `helm-chart/v*` tag | `helm package` + `helm push` to GHCR OCI registry | +| `site-deploy.yml` | push to main (paths: `packages/site/**`) | Build Astro + deploy to Cloudflare Pages | + +Existing `engine-ci.yml` and `helm-ci.yml` remain unchanged for PR checks. The current `release.yml` is deleted — its responsibilities move to `release-engine.yml`. + +## Engine Release (goreleaser) + +A `.goreleaser.yaml` at `packages/engine/.goreleaser.yaml` replaces the hand-rolled build script. + +- **Builds:** Single `mantle` binary, `CGO_ENABLED=0`, 4 targets: `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`. Ldflags inject version, commit, and date. +- **Archives:** Tarballs named `mantle-{os}-{arch}.tar.gz` (matches existing convention) +- **Checksum:** `checksums.txt` (SHA256) +- **Changelog:** Auto-generated from conventional commits, grouped by type +- **Docker:** Multi-platform images (`linux/amd64`, `linux/arm64`) pushed to `ghcr.io/dvflw/mantle`. Tags: `{{.Version}}`, `{{.Major}}.{{.Minor}}`, `{{.Major}}`, `latest`. Uses existing `Dockerfile`. +- **Release:** Creates GitHub Release with changelog and artifacts +- **Trivy:** Runs as a separate workflow step after Docker push (not in goreleaser config) + +The workflow sets `GORELEASER_CURRENT_TAG` from the `engine/v*` tag with the `engine/` prefix stripped. + +## Helm Chart OCI Publish + +When a `helm-chart/v*` tag is created, `release-helm.yml`: + +1. Checks out the repo +2. Sets up Helm +3. Logs in to GHCR via `helm registry login ghcr.io` +4. Runs `helm package packages/helm-chart/` +5. Runs `helm push mantle-*.tgz oci://ghcr.io/dvflw/helm-charts` +6. Creates a lightweight GitHub Release for the tag with a link to the OCI artifact + +Users install with: +```bash +helm install mantle oci://ghcr.io/dvflw/helm-charts/mantle --version 0.2.0 +``` + +### appVersion Sync + +`Chart.yaml` has two version fields: +- `version` — the chart's own version, bumped by changesets when chart templates change +- `appVersion` — informational label for which engine version the chart targets + +`release-tags.yml` auto-syncs `appVersion` whenever an engine tag is created: it updates `Chart.yaml`, commits, and pushes. This eliminates manual tracking of the engine version in two places. + +## Site Deploy + +`site-deploy.yml` triggers on push to main when `packages/site/**` changes. + +1. Checks out the repo +2. Installs bun +3. Runs `bun install` and `bun run build` in `packages/site/` +4. Uses `cloudflare/wrangler-action` to deploy `packages/site/dist/` to Cloudflare Pages + +No version tagging — docs deploy immediately on merge. Requires `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` repository secrets. + +## Changesets Integration + +**Package registration:** Each package needs a `package.json` with `name` and `version` for changesets to track. The site already has one. Lightweight `package.json` files are added to engine and helm-chart (just `name` + `version`, no dependencies). + +**Version script:** The changeset config gets a `versionScript` that runs after bumping `package.json` versions. This syncs the bumped version into `Chart.yaml` for the helm-chart package. The engine doesn't need file syncing — its version comes from the git tag at build time. + +**Tag format:** `engine/v0.4.1`, `helm-chart/v0.2.0`, `site/v1.1.0`. The `release-tags.yml` workflow diffs the merge commit to detect which `package.json` versions changed and creates tags accordingly. + +## File Changes + +### New files +- `.github/workflows/release-please.yml` +- `.github/workflows/release-tags.yml` +- `.github/workflows/release-engine.yml` +- `.github/workflows/release-helm.yml` +- `.github/workflows/site-deploy.yml` +- `packages/engine/.goreleaser.yaml` +- `packages/engine/package.json` (version anchor for changesets) +- `packages/helm-chart/package.json` (version anchor for changesets) + +### Modified files +- `.changeset/config.json` — add version script for `Chart.yaml` sync +- Root workspace config — register engine and helm-chart as workspace packages + +### Deleted files +- `.github/workflows/release.yml` — replaced by `release-engine.yml` + +## Required Secrets + +| Secret | Purpose | +|---|---| +| `GITHUB_TOKEN` | Already available. Used by changesets, goreleaser, Helm push, tag creation | +| `CLOUDFLARE_API_TOKEN` | Site deploy via wrangler | +| `CLOUDFLARE_ACCOUNT_ID` | Site deploy via wrangler | From 003d251c1d7bbc9748f45a1286b4cd0ca214a046 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 31 Mar 2026 23:08:43 -0400 Subject: [PATCH 03/23] build: register bun workspaces, add version anchors for changesets Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 +++ packages/engine/package.json | 5 +++++ packages/helm-chart/package.json | 5 +++++ 3 files changed, 13 insertions(+) create mode 100644 packages/engine/package.json create mode 100644 packages/helm-chart/package.json diff --git a/package.json b/package.json index 91fb547..40d1d78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "mantle", "private": true, + "workspaces": [ + "packages/*" + ], "scripts": { "changeset": "changeset", "version": "changeset version", diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..ded69da --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,5 @@ +{ + "name": "@mantle/engine", + "version": "0.4.0", + "private": true +} diff --git a/packages/helm-chart/package.json b/packages/helm-chart/package.json new file mode 100644 index 0000000..c6f5259 --- /dev/null +++ b/packages/helm-chart/package.json @@ -0,0 +1,5 @@ +{ + "name": "@mantle/helm-chart", + "version": "0.1.0", + "private": true +} From a162aa56420d8273091129deaeee7094812775f2 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 31 Mar 2026 23:36:10 -0400 Subject: [PATCH 04/23] ci: add changesets Version PR workflow --- .github/workflows/release-please.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..90776f4 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,37 @@ +name: Release Please + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + version: + name: Version Packages + runs-on: ubuntu-latest + # Skip if this push is the Version PR merge itself (prevents loop) + if: "!startsWith(github.event.head_commit.message, 'ci: version packages')" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - name: Create or update Version PR + uses: changesets/action@v1 + with: + version: bun run version + commit: "ci: version packages" + title: "ci: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 990d26d40fafe0ba081d7c3c92e9a468d4dac183 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:09:24 -0400 Subject: [PATCH 05/23] ci: add per-package tagging workflow with appVersion sync Co-Authored-By: Claude Sonnet 4.6 --- .github/scripts/sync-chart-appversion.sh | 16 +++++ .github/workflows/release-tags.yml | 75 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100755 .github/scripts/sync-chart-appversion.sh create mode 100644 .github/workflows/release-tags.yml diff --git a/.github/scripts/sync-chart-appversion.sh b/.github/scripts/sync-chart-appversion.sh new file mode 100755 index 0000000..53a8af9 --- /dev/null +++ b/.github/scripts/sync-chart-appversion.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: sync-chart-appversion.sh +# Example: sync-chart-appversion.sh 0.4.1 + +ENGINE_VERSION="$1" +CHART_FILE="packages/helm-chart/Chart.yaml" + +if [[ ! -f "$CHART_FILE" ]]; then + echo "ERROR: $CHART_FILE not found" + exit 1 +fi + +sed -i "s/^appVersion:.*/appVersion: \"${ENGINE_VERSION}\"/" "$CHART_FILE" +echo "Updated $CHART_FILE appVersion to $ENGINE_VERSION" diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml new file mode 100644 index 0000000..2d265dd --- /dev/null +++ b/.github/workflows/release-tags.yml @@ -0,0 +1,75 @@ +name: Release Tags + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + tag: + name: Create Package Tags + runs-on: ubuntu-latest + # Only run on Version PR merges + if: "startsWith(github.event.head_commit.message, 'ci: version packages')" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect version bumps and create tags + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAGGED="" + + # Check engine version bump + ENGINE_OLD=$(git show HEAD~1:packages/engine/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "") + ENGINE_NEW=$(jq -r '.version' packages/engine/package.json) + if [[ -n "$ENGINE_OLD" && "$ENGINE_OLD" != "$ENGINE_NEW" ]]; then + echo "Engine version bumped: $ENGINE_OLD -> $ENGINE_NEW" + git tag "engine/v${ENGINE_NEW}" + TAGGED="$TAGGED engine/v${ENGINE_NEW}" + + # Sync appVersion in Chart.yaml + .github/scripts/sync-chart-appversion.sh "$ENGINE_NEW" + fi + + # Check helm-chart version bump + HELM_OLD=$(git show HEAD~1:packages/helm-chart/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "") + HELM_NEW=$(jq -r '.version' packages/helm-chart/package.json) + if [[ -n "$HELM_OLD" && "$HELM_OLD" != "$HELM_NEW" ]]; then + echo "Helm chart version bumped: $HELM_OLD -> $HELM_NEW" + git tag "helm-chart/v${HELM_NEW}" + TAGGED="$TAGGED helm-chart/v${HELM_NEW}" + fi + + # Check site version bump + SITE_OLD=$(git show HEAD~1:packages/site/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "") + SITE_NEW=$(jq -r '.version' packages/site/package.json) + if [[ -n "$SITE_OLD" && "$SITE_OLD" != "$SITE_NEW" ]]; then + echo "Site version bumped: $SITE_OLD -> $SITE_NEW" + git tag "site/v${SITE_NEW}" + TAGGED="$TAGGED site/v${SITE_NEW}" + fi + + # Push all tags at once + if [[ -n "$TAGGED" ]]; then + echo "Pushing tags:$TAGGED" + git push origin --tags + else + echo "No version bumps detected" + fi + + - name: Commit appVersion sync if changed + run: | + if git diff --quiet packages/helm-chart/Chart.yaml 2>/dev/null; then + echo "No appVersion change" + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add packages/helm-chart/Chart.yaml + git commit -m "ci: sync Chart.yaml appVersion" + git push + fi From 09c92b5236fb2cbea544b3aa0f61198c70a6b685 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:10:14 -0400 Subject: [PATCH 06/23] build: add goreleaser config for engine releases --- packages/engine/.goreleaser.yaml | 86 ++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/engine/.goreleaser.yaml diff --git a/packages/engine/.goreleaser.yaml b/packages/engine/.goreleaser.yaml new file mode 100644 index 0000000..c5edba4 --- /dev/null +++ b/packages/engine/.goreleaser.yaml @@ -0,0 +1,86 @@ +version: 2 + +project_name: mantle + +builds: + - id: mantle + main: ./cmd/mantle/ + binary: mantle + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X github.com/dvflw/mantle/internal/version.Version={{.Version}} + - -X github.com/dvflw/mantle/internal/version.Commit={{.ShortCommit}} + - -X github.com/dvflw/mantle/internal/version.Date={{.Date}} + +archives: + - id: default + format: tar.gz + name_template: "mantle-{{ .Os }}-{{ .Arch }}" + +checksum: + name_template: checksums.txt + algorithm: sha256 + +changelog: + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\(.+\))?\!?:.*$' + - title: Bug Fixes + regexp: '^.*?fix(\(.+\))?\!?:.*$' + - title: Other + order: 999 + +dockers: + - image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + use: buildx + dockerfile: Dockerfile + build_flag_templates: + - "--platform=linux/amd64" + - "--build-arg=VERSION={{.Version}}" + - "--build-arg=COMMIT={{.ShortCommit}}" + - "--build-arg=DATE={{.Date}}" + - image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + use: buildx + goarch: arm64 + dockerfile: Dockerfile + build_flag_templates: + - "--platform=linux/arm64" + - "--build-arg=VERSION={{.Version}}" + - "--build-arg=COMMIT={{.ShortCommit}}" + - "--build-arg=DATE={{.Date}}" + +docker_manifests: + - name_template: "ghcr.io/dvflw/mantle:{{ .Version }}" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + - name_template: "ghcr.io/dvflw/mantle:{{ .Major }}.{{ .Minor }}" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + - name_template: "ghcr.io/dvflw/mantle:{{ .Major }}" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + - name_template: "ghcr.io/dvflw/mantle:latest" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + +release: + github: + owner: dvflw + name: mantle + draft: false + prerelease: auto From eed20116012320ba770b1ba381663daa2f5a6218 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:11:17 -0400 Subject: [PATCH 07/23] ci: replace release.yml with goreleaser-based engine release workflow --- .github/workflows/release-engine.yml | 66 ++++++++++++ .github/workflows/release.yml | 151 --------------------------- 2 files changed, 66 insertions(+), 151 deletions(-) create mode 100644 .github/workflows/release-engine.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml new file mode 100644 index 0000000..e1bec5b --- /dev/null +++ b/.github/workflows/release-engine.yml @@ -0,0 +1,66 @@ +name: Release Engine + +on: + push: + tags: + - 'engine/v*' + +permissions: + contents: write + packages: write + +jobs: + release: + name: Build and Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - name: Set goreleaser tag + run: | + # Strip "engine/" prefix so goreleaser sees a clean semver tag + TAG="${GITHUB_REF#refs/tags/engine/}" + echo "GORELEASER_CURRENT_TAG=${TAG}" >> "$GITHUB_ENV" + + - name: Run goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + workdir: packages/engine + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + trivy: + name: Trivy Scan + runs-on: ubuntu-latest + needs: release + steps: + - name: Extract version + id: vars + run: | + TAG="${GITHUB_REF#refs/tags/engine/v}" + echo "version=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: "ghcr.io/dvflw/mantle:${{ steps.vars.outputs.version }}" + format: table + exit-code: '1' + severity: CRITICAL,HIGH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2081b06..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - packages: write - -jobs: - release: - name: Build and Release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - - name: Set release variables - id: vars - run: | - TAG="${GITHUB_REF#refs/tags/}" - VERSION="${TAG#v}" - COMMIT="$(git rev-parse --short HEAD)" - DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT" - echo "date=${DATE}" >> "$GITHUB_OUTPUT" - - - name: Build binaries - working-directory: packages/engine - env: - CGO_ENABLED: '0' - VERSION: ${{ steps.vars.outputs.version }} - COMMIT: ${{ steps.vars.outputs.commit }} - DATE: ${{ steps.vars.outputs.date }} - run: | - LDFLAGS="-s -w" - LDFLAGS="${LDFLAGS} -X github.com/dvflw/mantle/internal/version.Version=${VERSION}" - LDFLAGS="${LDFLAGS} -X github.com/dvflw/mantle/internal/version.Commit=${COMMIT}" - LDFLAGS="${LDFLAGS} -X github.com/dvflw/mantle/internal/version.Date=${DATE}" - - platforms=( - "linux/amd64" - "linux/arm64" - "darwin/amd64" - "darwin/arm64" - ) - - mkdir -p ../../dist - - for platform in "${platforms[@]}"; do - IFS='/' read -r os arch <<< "${platform}" - output="mantle-${os}-${arch}" - echo "Building ${output}..." - GOOS="${os}" GOARCH="${arch}" go build \ - -ldflags "${LDFLAGS}" \ - -o "../../dist/${output}" \ - ./cmd/mantle/ - done - - - name: Create tarballs and checksums - run: | - cd dist - for binary in mantle-*; do - tar czf "${binary}.tar.gz" "${binary}" - rm "${binary}" - done - sha256sum *.tar.gz > checksums.txt - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.vars.outputs.tag }} - run: | - gh release create "${TAG}" \ - --title "${TAG}" \ - --generate-notes \ - dist/*.tar.gz \ - dist/checksums.txt - - docker: - name: Docker Image - runs-on: ubuntu-latest - needs: release - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set release variables - id: vars - run: | - TAG="${GITHUB_REF#refs/tags/}" - VERSION="${TAG#v}" - COMMIT="$(git rev-parse --short HEAD)" - DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT" - echo "date=${DATE}" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/dvflw/mantle - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: packages/engine - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ steps.vars.outputs.version }} - COMMIT=${{ steps.vars.outputs.commit }} - DATE=${{ steps.vars.outputs.date }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ghcr.io/dvflw/mantle:${{ steps.vars.outputs.version }} - format: table - exit-code: '1' - severity: CRITICAL,HIGH From e2f97a12ca1ac03d16d41f71c09936e0873a1df3 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:11:55 -0400 Subject: [PATCH 08/23] ci: add Cloudflare Pages site deploy workflow --- .github/workflows/site-deploy.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/site-deploy.yml diff --git a/.github/workflows/site-deploy.yml b/.github/workflows/site-deploy.yml new file mode 100644 index 0000000..09ddcb0 --- /dev/null +++ b/.github/workflows/site-deploy.yml @@ -0,0 +1,31 @@ +name: Deploy Site + +on: + push: + branches: [main] + paths: + - 'packages/site/**' + +jobs: + deploy: + name: Build and Deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + working-directory: packages/site + run: bun install --frozen-lockfile + + - name: Build + working-directory: packages/site + run: bun run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy packages/site/dist --project-name=mantle-docs From 68723410822fa295ba5c7555bee89ad6749e9c1c Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:12:04 -0400 Subject: [PATCH 09/23] ci: add Helm chart OCI publish workflow --- .github/workflows/release-helm.yml | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/release-helm.yml diff --git a/.github/workflows/release-helm.yml b/.github/workflows/release-helm.yml new file mode 100644 index 0000000..9ea4f9a --- /dev/null +++ b/.github/workflows/release-helm.yml @@ -0,0 +1,49 @@ +name: Release Helm Chart + +on: + push: + tags: + - 'helm-chart/v*' + +permissions: + contents: write + packages: write + +jobs: + publish: + name: Package and Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin + + - name: Extract version + id: vars + run: | + TAG="${GITHUB_REF#refs/tags/helm-chart/v}" + echo "version=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Package chart + run: helm package packages/helm-chart/ + + - name: Push to OCI registry + run: helm push mantle-${{ steps.vars.outputs.version }}.tgz oci://ghcr.io/dvflw/helm-charts + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.vars.outputs.version }} + run: | + gh release create "${TAG}" \ + --title "Helm Chart ${VERSION}" \ + --notes "Published to \`oci://ghcr.io/dvflw/helm-charts/mantle:${VERSION}\` + + Install with: + \`\`\`bash + helm install mantle oci://ghcr.io/dvflw/helm-charts/mantle --version ${VERSION} + \`\`\`" From 6deaab01f69465d9c41e2460aeb557b8ce48b30c Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Wed, 1 Apr 2026 00:54:38 -0400 Subject: [PATCH 10/23] fix: address code review findings for release pipeline - Fix race condition: sync appVersion and commit before pushing tags - Add Dockerfile.goreleaser to avoid double-compilation in Docker builds - Add concurrency control to release-tags.yml - Add permissions block to site-deploy.yml - Pin Trivy action to v0.28.0 instead of @master - Update spec to reflect appVersion sync approach Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release-engine.yml | 2 +- .github/workflows/release-tags.yml | 74 ++++++++++++------- .github/workflows/site-deploy.yml | 3 + .../2026-03-31-release-pipeline-design.md | 5 +- packages/engine/.goreleaser.yaml | 15 ++-- packages/engine/Dockerfile.goreleaser | 20 +++++ 6 files changed, 81 insertions(+), 38 deletions(-) create mode 100644 packages/engine/Dockerfile.goreleaser diff --git a/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml index e1bec5b..b7ac167 100644 --- a/.github/workflows/release-engine.yml +++ b/.github/workflows/release-engine.yml @@ -58,7 +58,7 @@ jobs: echo "version=${TAG}" >> "$GITHUB_OUTPUT" - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: "ghcr.io/dvflw/mantle:${{ steps.vars.outputs.version }}" format: table diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index 2d265dd..e9c7936 100644 --- a/.github/workflows/release-tags.yml +++ b/.github/workflows/release-tags.yml @@ -4,6 +4,10 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + permissions: contents: write @@ -18,22 +22,19 @@ jobs: with: fetch-depth: 2 - - name: Detect version bumps and create tags - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Detect version bumps + id: bumps run: | - TAGGED="" + ENGINE_BUMPED=false + HELM_BUMPED=false + SITE_BUMPED=false # Check engine version bump ENGINE_OLD=$(git show HEAD~1:packages/engine/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "") ENGINE_NEW=$(jq -r '.version' packages/engine/package.json) if [[ -n "$ENGINE_OLD" && "$ENGINE_OLD" != "$ENGINE_NEW" ]]; then echo "Engine version bumped: $ENGINE_OLD -> $ENGINE_NEW" - git tag "engine/v${ENGINE_NEW}" - TAGGED="$TAGGED engine/v${ENGINE_NEW}" - - # Sync appVersion in Chart.yaml - .github/scripts/sync-chart-appversion.sh "$ENGINE_NEW" + ENGINE_BUMPED=true fi # Check helm-chart version bump @@ -41,8 +42,7 @@ jobs: HELM_NEW=$(jq -r '.version' packages/helm-chart/package.json) if [[ -n "$HELM_OLD" && "$HELM_OLD" != "$HELM_NEW" ]]; then echo "Helm chart version bumped: $HELM_OLD -> $HELM_NEW" - git tag "helm-chart/v${HELM_NEW}" - TAGGED="$TAGGED helm-chart/v${HELM_NEW}" + HELM_BUMPED=true fi # Check site version bump @@ -50,26 +50,48 @@ jobs: SITE_NEW=$(jq -r '.version' packages/site/package.json) if [[ -n "$SITE_OLD" && "$SITE_OLD" != "$SITE_NEW" ]]; then echo "Site version bumped: $SITE_OLD -> $SITE_NEW" - git tag "site/v${SITE_NEW}" - TAGGED="$TAGGED site/v${SITE_NEW}" + SITE_BUMPED=true + fi + + echo "engine_bumped=${ENGINE_BUMPED}" >> "$GITHUB_OUTPUT" + echo "engine_version=${ENGINE_NEW}" >> "$GITHUB_OUTPUT" + echo "helm_bumped=${HELM_BUMPED}" >> "$GITHUB_OUTPUT" + echo "helm_version=${HELM_NEW}" >> "$GITHUB_OUTPUT" + echo "site_bumped=${SITE_BUMPED}" >> "$GITHUB_OUTPUT" + echo "site_version=${SITE_NEW}" >> "$GITHUB_OUTPUT" + + - name: Sync Chart.yaml appVersion + if: steps.bumps.outputs.engine_bumped == 'true' + run: | + .github/scripts/sync-chart-appversion.sh "${{ steps.bumps.outputs.engine_version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add packages/helm-chart/Chart.yaml + git commit -m "ci: sync Chart.yaml appVersion to ${{ steps.bumps.outputs.engine_version }}" + git push + + - name: Create and push tags + run: | + TAGGED="" + + if [[ "${{ steps.bumps.outputs.engine_bumped }}" == "true" ]]; then + git tag "engine/v${{ steps.bumps.outputs.engine_version }}" + TAGGED="$TAGGED engine/v${{ steps.bumps.outputs.engine_version }}" + fi + + if [[ "${{ steps.bumps.outputs.helm_bumped }}" == "true" ]]; then + git tag "helm-chart/v${{ steps.bumps.outputs.helm_version }}" + TAGGED="$TAGGED helm-chart/v${{ steps.bumps.outputs.helm_version }}" + fi + + if [[ "${{ steps.bumps.outputs.site_bumped }}" == "true" ]]; then + git tag "site/v${{ steps.bumps.outputs.site_version }}" + TAGGED="$TAGGED site/v${{ steps.bumps.outputs.site_version }}" fi - # Push all tags at once if [[ -n "$TAGGED" ]]; then echo "Pushing tags:$TAGGED" git push origin --tags else echo "No version bumps detected" fi - - - name: Commit appVersion sync if changed - run: | - if git diff --quiet packages/helm-chart/Chart.yaml 2>/dev/null; then - echo "No appVersion change" - else - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/helm-chart/Chart.yaml - git commit -m "ci: sync Chart.yaml appVersion" - git push - fi diff --git a/.github/workflows/site-deploy.yml b/.github/workflows/site-deploy.yml index 09ddcb0..289e95f 100644 --- a/.github/workflows/site-deploy.yml +++ b/.github/workflows/site-deploy.yml @@ -6,6 +6,9 @@ on: paths: - 'packages/site/**' +permissions: + contents: read + jobs: deploy: name: Build and Deploy diff --git a/docs/superpowers/specs/2026-03-31-release-pipeline-design.md b/docs/superpowers/specs/2026-03-31-release-pipeline-design.md index cce6920..3ae3580 100644 --- a/docs/superpowers/specs/2026-03-31-release-pipeline-design.md +++ b/docs/superpowers/specs/2026-03-31-release-pipeline-design.md @@ -83,7 +83,7 @@ No version tagging — docs deploy immediately on merge. Requires `CLOUDFLARE_AP **Package registration:** Each package needs a `package.json` with `name` and `version` for changesets to track. The site already has one. Lightweight `package.json` files are added to engine and helm-chart (just `name` + `version`, no dependencies). -**Version script:** The changeset config gets a `versionScript` that runs after bumping `package.json` versions. This syncs the bumped version into `Chart.yaml` for the helm-chart package. The engine doesn't need file syncing — its version comes from the git tag at build time. +**Chart.yaml sync:** Rather than using a changesets `versionScript`, the `appVersion` sync is handled at tag-creation time in `release-tags.yml`. When an engine tag is created, the workflow updates `Chart.yaml`'s `appVersion`, commits, and pushes. This is cleaner than syncing during version PR preparation because it's driven by actual engine releases. The engine version at build time comes from the git tag via ldflags. **Tag format:** `engine/v0.4.1`, `helm-chart/v0.2.0`, `site/v1.1.0`. The `release-tags.yml` workflow diffs the merge commit to detect which `package.json` versions changed and creates tags accordingly. @@ -100,8 +100,7 @@ No version tagging — docs deploy immediately on merge. Requires `CLOUDFLARE_AP - `packages/helm-chart/package.json` (version anchor for changesets) ### Modified files -- `.changeset/config.json` — add version script for `Chart.yaml` sync -- Root workspace config — register engine and helm-chart as workspace packages +- Root `package.json` — add `workspaces` field to register all packages ### Deleted files - `.github/workflows/release.yml` — replaced by `release-engine.yml` diff --git a/packages/engine/.goreleaser.yaml b/packages/engine/.goreleaser.yaml index c5edba4..b754f4e 100644 --- a/packages/engine/.goreleaser.yaml +++ b/packages/engine/.goreleaser.yaml @@ -43,22 +43,21 @@ dockers: - image_templates: - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" use: buildx - dockerfile: Dockerfile + ids: [mantle] + goos: linux + goarch: amd64 + dockerfile: Dockerfile.goreleaser build_flag_templates: - "--platform=linux/amd64" - - "--build-arg=VERSION={{.Version}}" - - "--build-arg=COMMIT={{.ShortCommit}}" - - "--build-arg=DATE={{.Date}}" - image_templates: - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" use: buildx + ids: [mantle] + goos: linux goarch: arm64 - dockerfile: Dockerfile + dockerfile: Dockerfile.goreleaser build_flag_templates: - "--platform=linux/arm64" - - "--build-arg=VERSION={{.Version}}" - - "--build-arg=COMMIT={{.ShortCommit}}" - - "--build-arg=DATE={{.Date}}" docker_manifests: - name_template: "ghcr.io/dvflw/mantle:{{ .Version }}" diff --git a/packages/engine/Dockerfile.goreleaser b/packages/engine/Dockerfile.goreleaser new file mode 100644 index 0000000..fe58106 --- /dev/null +++ b/packages/engine/Dockerfile.goreleaser @@ -0,0 +1,20 @@ +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S mantle \ + && adduser -S -G mantle -h /home/mantle -s /bin/sh mantle + +COPY mantle /usr/local/bin/mantle + +ENV MANTLE_LOG_LEVEL=info +ENV MANTLE_API_ADDRESS=:8080 + +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/healthz || exit 1 + +USER mantle +WORKDIR /home/mantle + +ENTRYPOINT ["mantle"] From 6f8aae536f327bcfee6406cfa16fd421acbea749 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 21:50:38 -0400 Subject: [PATCH 11/23] docs: add OpenAPI spec design for issue #55 --- .../specs/2026-04-04-openapi-spec-design.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-openapi-spec-design.md diff --git a/docs/superpowers/specs/2026-04-04-openapi-spec-design.md b/docs/superpowers/specs/2026-04-04-openapi-spec-design.md new file mode 100644 index 0000000..4e2e66e --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-openapi-spec-design.md @@ -0,0 +1,254 @@ +# OpenAPI Specification Design + +**Issue:** [dvflw/mantle#55](https://github.com/dvflw/mantle/issues/55) +**Milestone:** v0.5.0 — The GitOps Update + +## Goal + +Generate an OpenAPI 3.0 spec from Go code using swaggo/swag annotations. The spec becomes the stable contract for SDK generation in v1.0.0 and gives operators a machine-readable description of the API. + +## Approach + +swaggo/swag: annotation comments on existing handlers, `swag init` at build time. No router or handler signature changes. Generates `swagger.json` + `swagger.yaml` into `packages/engine/docs/`, checked into git. Spec served at `GET /api/v1/openapi.json` via `embed.FS`. + +OpenAPI 3.0 (not 3.1) — `oapi-codegen` and all major SDK gen tools handle 3.0 without issue. + +## API Surface Covered + +### REST API — authenticated (Bearer) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/run/{workflow}` | Trigger a workflow execution | +| POST | `/api/v1/cancel/{execution}` | Cancel a running execution | +| GET | `/api/v1/executions` | List executions (workflow, status, since, limit) | +| GET | `/api/v1/executions/{id}` | Get execution detail with steps | +| GET | `/api/v1/workflows` | List workflow definitions | +| GET | `/api/v1/workflows/{name}` | Get latest workflow definition | +| GET | `/api/v1/workflows/{name}/versions` | List all versions of a workflow | +| GET | `/api/v1/workflows/{name}/versions/{version}` | Get a specific workflow version | +| GET | `/api/v1/budgets` | List AI provider budgets | +| PUT | `/api/v1/budgets/{provider}` | Set budget for a provider | +| DELETE | `/api/v1/budgets/{provider}` | Delete budget for a provider | +| GET | `/api/v1/budgets/usage` | Get token usage for the current period | + +### System endpoints — unauthenticated + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/healthz` | Liveness probe | +| GET | `/readyz` | Readiness probe (DB + worker/reaper) | + +### Excluded from spec + +- `GET /metrics` — Prometheus scrape endpoint, not a consumer API +- `POST /hooks/{path}` — Dynamic webhook paths, not a typed contract + +## File Changes + +| Action | File | Purpose | +|--------|------|---------| +| Create | `packages/engine/internal/server/docs.go` | Global swag annotations (title, version, auth schemes) | +| Modify | `packages/engine/internal/server/api.go` | Export response types; add per-handler annotations | +| Modify | `packages/engine/internal/server/server.go` | Add per-handler annotations to handleRun, handleCancel; add `GET /api/v1/openapi.json` route | +| Create | `packages/engine/internal/server/docs/` | swag-generated output (swagger.json, swagger.yaml, docs.go) | +| Modify | `packages/engine/go.mod` | Add swaggo/swag, swaggo/files dependencies | +| Modify | `packages/engine/Makefile` | Add `spec` target | +| Modify | `.github/workflows/engine-ci.yml` | Add spec freshness check step | + +## Response Type Exports + +The following unexported types in `api.go` are public API contracts and must be exported for swag to generate accurate schemas: + +| Before | After | +|--------|-------| +| `executionSummary` | `ExecutionSummary` | +| `executionDetail` | `ExecutionDetail` | +| `stepSummary` | `StepSummary` | + +`TeamBudget` in `internal/budget/store.go` is already exported. + +## Auth Schemes + +Two security schemes documented in the spec: + +- **ApiKeyAuth** — `http` bearer scheme. API key issued by `mantle secrets create` or the auth CLI. Format: `Authorization: Bearer mk_`. +- **OIDCAuth** — `http` bearer scheme. OIDC JWT from a configured identity provider. Format: `Authorization: Bearer `. + +All `/api/v1/` endpoints require one of the two. Health endpoints are unauthenticated. + +## Global Annotations (docs.go) + +```go +// @title Mantle API +// @version 1.0 +// @description Headless AI workflow automation — BYOK, IaC-first, enterprise-grade. +// @contact.name Mantle +// @contact.url https://github.com/dvflw/mantle +// @license.name BSL/SSPL +// @license.url https://github.com/dvflw/mantle/blob/main/LICENSE +// +// @host localhost:8080 +// @basePath / +// +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +// @description Bearer API key. Format: "Bearer mk_..." +// +// @securityDefinitions.apikey OIDCAuth +// @in header +// @name Authorization +// @description Bearer OIDC JWT. Format: "Bearer " +``` + +## Spec Serving + +A new `GET /api/v1/openapi.json` endpoint serves the embedded spec: + +```go +//go:embed docs/swagger.json +var swaggerJSON []byte + +mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(swaggerJSON) +}) +``` + +The embed lives in `packages/engine/internal/server/` alongside the generated `docs/` directory. + +## Makefile Target + +```makefile +.PHONY: spec +spec: ## Regenerate OpenAPI spec + swag init -g internal/server/docs.go \ + -d internal/server \ + --output internal/server/docs \ + --outputTypes json,yaml \ + --parseDependency \ + --parseInternal +``` + +Run from `packages/engine/`. + +## CI Freshness Check + +Added to the engine CI workflow after `go build`: + +```yaml +- name: Check OpenAPI spec is up to date + run: | + cd packages/engine + swag init -g internal/server/docs.go \ + -d internal/server \ + --output internal/server/docs \ + --outputTypes json,yaml \ + --parseDependency \ + --parseInternal + git diff --exit-code internal/server/docs/ +``` + +Fails the build if a handler was changed without regenerating the spec. + +## Example Annotations + +### POST /api/v1/run/{workflow} + +```go +// handleRun triggers a workflow execution via the API. +// +// @Summary Trigger a workflow execution +// @Tags executions +// @Param workflow path string true "Workflow name" +// @Success 202 {object} RunResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/run/{workflow} [post] +``` + +### GET /api/v1/executions + +```go +// handleListExecutions handles GET /api/v1/executions with query param filters. +// +// @Summary List executions +// @Tags executions +// @Param workflow query string false "Filter by workflow name" +// @Param status query string false "Filter by status" Enums(pending,running,completed,failed,cancelled) +// @Param since query string false "Filter by age (e.g. 1h, 7d)" +// @Param limit query integer false "Max results (default 20)" +// @Success 200 {object} ExecutionListResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/executions [get] +``` + +## Additional Response Types + +Inline response wrapper types to define in `api.go`: + +```go +// RunResponse is returned when a workflow execution is accepted. +type RunResponse struct { + ExecutionID string `json:"execution_id"` + Workflow string `json:"workflow"` + Version int `json:"version"` +} + +// CancelResponse is returned when an execution is cancelled. +type CancelResponse struct { + ExecutionID string `json:"execution_id"` + Status string `json:"status"` +} + +// ExecutionListResponse wraps a list of executions. +type ExecutionListResponse struct { + Executions []ExecutionSummary `json:"executions"` +} + +// WorkflowListResponse wraps a list of workflow summaries. +type WorkflowListResponse struct { + Workflows []workflow.WorkflowSummary `json:"workflows"` +} + +// WorkflowDetailResponse is returned for GET /api/v1/workflows/{name}. +type WorkflowDetailResponse struct { + Name string `json:"name"` + Version int `json:"version"` + Definition json.RawMessage `json:"definition"` +} + +// WorkflowVersionListResponse wraps a list of workflow versions. +type WorkflowVersionListResponse struct { + Name string `json:"name"` + Versions []workflow.VersionSummary `json:"versions"` +} + +// ErrorResponse is the standard error envelope. +type ErrorResponse struct { + Error string `json:"error"` +} + +// UsageResponse is returned for GET /api/v1/budgets/usage. +type UsageResponse struct { + PeriodStart string `json:"period_start"` + Provider string `json:"provider"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} +``` + +## Testing + +- `go build ./...` must pass after type renames +- `swag init` must complete without errors +- `GET /api/v1/openapi.json` returns valid JSON containing `"openapi":"3.0.0"` (swag v2 generates OpenAPI 3.0) +- `git diff --exit-code` on generated files in CI From 4e401233bd4bf7e76f0e86de89771391b9cdd335 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:03:04 -0400 Subject: [PATCH 12/23] build: add swaggo/swag dependency for OpenAPI spec generation --- packages/engine/go.mod | 13 +++++++++++++ packages/engine/go.sum | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/engine/go.mod b/packages/engine/go.mod index 0c74e89..924c1f6 100644 --- a/packages/engine/go.mod +++ b/packages/engine/go.mod @@ -42,7 +42,10 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect @@ -81,6 +84,10 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -90,10 +97,12 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect @@ -123,6 +132,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -137,15 +147,18 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/packages/engine/go.sum b/packages/engine/go.sum index d73f3dc..37c0c9c 100644 --- a/packages/engine/go.sum +++ b/packages/engine/go.sum @@ -34,8 +34,14 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= @@ -103,6 +109,7 @@ github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSw github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -146,6 +153,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -177,12 +194,17 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -193,6 +215,10 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -221,6 +247,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -277,11 +304,14 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0= @@ -331,8 +361,11 @@ golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDME golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= @@ -348,6 +381,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -364,6 +398,7 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -375,6 +410,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -392,9 +429,15 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= From 13f653fa718308410355ee2443dc1183bf71ef3c Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:05:38 -0400 Subject: [PATCH 13/23] refactor: export response types, add named API request/response types --- packages/engine/internal/server/api.go | 85 +++++++++++++++---- .../engine/internal/server/server_test.go | 25 ++++++ 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index 4cd166b..25dc48a 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -15,8 +15,8 @@ import ( "github.com/dvflw/mantle/internal/workflow" ) -// executionSummary is the JSON representation of an execution in list responses. -type executionSummary struct { +// ExecutionSummary is the JSON representation of an execution in list responses. +type ExecutionSummary struct { ID string `json:"id"` Workflow string `json:"workflow"` Version int `json:"version"` @@ -25,19 +25,19 @@ type executionSummary struct { CompletedAt *string `json:"completed_at,omitempty"` } -// executionDetail is the JSON representation of a single execution with steps. -type executionDetail struct { +// ExecutionDetail is the JSON representation of a single execution with steps. +type ExecutionDetail struct { ID string `json:"id"` Workflow string `json:"workflow"` Version int `json:"version"` Status string `json:"status"` StartedAt *string `json:"started_at"` CompletedAt *string `json:"completed_at,omitempty"` - Steps []stepSummary `json:"steps"` + Steps []StepSummary `json:"steps"` } -// stepSummary is the JSON representation of a step execution. -type stepSummary struct { +// StepSummary is the JSON representation of a step execution. +type StepSummary struct { Name string `json:"name"` Status string `json:"status"` Error string `json:"error,omitempty"` @@ -118,7 +118,7 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { } defer rows.Close() - executions := []executionSummary{} + executions := []ExecutionSummary{} for rows.Next() { var id, wfName, wfStatus string var version int @@ -129,7 +129,7 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { return } - exec := executionSummary{ + exec := ExecutionSummary{ ID: id, Workflow: wfName, Version: version, @@ -177,12 +177,12 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { return } - detail := executionDetail{ + detail := ExecutionDetail{ ID: execID, Workflow: workflowName, Version: version, Status: status, - Steps: []stepSummary{}, + Steps: []StepSummary{}, } if startedAt != nil { ts := startedAt.Format(time.RFC3339) @@ -216,7 +216,7 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { return } - step := stepSummary{ + step := StepSummary{ Name: stepName, Status: stepStatus, } @@ -266,6 +266,62 @@ func parseSinceDuration(s string) (time.Duration, error) { return d, nil } +// RunResponse is returned when a workflow execution is accepted. +type RunResponse struct { + ExecutionID string `json:"execution_id"` + Workflow string `json:"workflow"` + Version int `json:"version"` +} + +// CancelResponse is returned when an execution is cancelled. +type CancelResponse struct { + ExecutionID string `json:"execution_id"` + Status string `json:"status"` +} + +// ExecutionListResponse wraps a list of executions. +type ExecutionListResponse struct { + Executions []ExecutionSummary `json:"executions"` +} + +// WorkflowListResponse wraps a list of workflow summaries. +type WorkflowListResponse struct { + Workflows []workflow.WorkflowSummary `json:"workflows"` +} + +// WorkflowDetailResponse is returned for GET /api/v1/workflows/{name}. +type WorkflowDetailResponse struct { + Name string `json:"name"` + Version int `json:"version"` + Definition json.RawMessage `json:"definition"` +} + +// WorkflowVersionListResponse wraps a list of workflow versions. +type WorkflowVersionListResponse struct { + Name string `json:"name"` + Versions []workflow.VersionSummary `json:"versions"` +} + +// ErrorResponse is the standard error envelope. +type ErrorResponse struct { + Error string `json:"error"` +} + +// UsageResponse is returned for GET /api/v1/budgets/usage. +type UsageResponse struct { + PeriodStart string `json:"period_start"` + Provider string `json:"provider"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +// SetBudgetRequest is the request body for PUT /api/v1/budgets/{provider}. +type SetBudgetRequest struct { + MonthlyTokenLimit int64 `json:"monthly_token_limit"` + Enforcement string `json:"enforcement"` // "hard" or "warn" +} + func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -382,10 +438,7 @@ func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) provider := r.PathValue("provider") - var body struct { - MonthlyTokenLimit int64 `json:"monthly_token_limit"` - Enforcement string `json:"enforcement"` - } + var body SetBudgetRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return diff --git a/packages/engine/internal/server/server_test.go b/packages/engine/internal/server/server_test.go index 81243dd..8960b1d 100644 --- a/packages/engine/internal/server/server_test.go +++ b/packages/engine/internal/server/server_test.go @@ -49,6 +49,31 @@ func TestWriteJSON(t *testing.T) { } } +func TestResponseTypesExported(t *testing.T) { + ts := "2026-01-01T00:00:00Z" + summary := ExecutionSummary{ + ID: "exec-1", + Workflow: "wf", + Version: 1, + Status: "completed", + StartedAt: &ts, + } + data, err := json.Marshal(summary) + if err != nil { + t.Fatalf("marshal ExecutionSummary: %v", err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["id"] != "exec-1" { + t.Errorf("id = %v, want exec-1", got["id"]) + } + if got["workflow"] != "wf" { + t.Errorf("workflow = %v, want wf", got["workflow"]) + } +} + func TestWriteJSONError(t *testing.T) { rec := httptest.NewRecorder() writeJSONError(rec, "something went wrong", 422) From fce8fae045e987868c182f8ab0d03bb1fc984067 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:06:01 -0400 Subject: [PATCH 14/23] docs: add swag global API annotations and health endpoint stubs --- packages/engine/internal/server/docs.go | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/engine/internal/server/docs.go diff --git a/packages/engine/internal/server/docs.go b/packages/engine/internal/server/docs.go new file mode 100644 index 0000000..8ee4022 --- /dev/null +++ b/packages/engine/internal/server/docs.go @@ -0,0 +1,54 @@ +// Package server provides the Mantle HTTP API server. +// +// @title Mantle API +// @version 1.0 +// @description Headless AI workflow automation — BYOK, IaC-first, enterprise-grade. +// @contact.name Mantle +// @contact.url https://github.com/dvflw/mantle +// @license.name BSL 1.1 +// @license.url https://github.com/dvflw/mantle/blob/main/LICENSE +// +// @host localhost:8080 +// @basePath / +// +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +// @description Bearer API key. Format: "Bearer mk_..." +// +// @securityDefinitions.apikey OIDCAuth +// @in header +// @name Authorization +// @description Bearer OIDC JWT. Format: "Bearer " +package server + +// HealthResponse is the response body for /healthz. +type HealthResponse struct { + Status string `json:"status"` +} + +// ReadyzResponse is the response body for /readyz. +type ReadyzResponse struct { + Status string `json:"status"` + Details map[string]string `json:"details,omitempty"` +} + +// healthzDoc is a swag documentation stub for GET /healthz. +// The actual handler is health.HealthzHandler() registered in Start(). +// +// @Summary Liveness probe +// @Tags system +// @Success 200 {object} HealthResponse +// @Router /healthz [get] +func healthzDoc() {} //nolint:unused,deadcode + +// readyzDoc is a swag documentation stub for GET /readyz. +// The actual handler is health.ReadyzHandler() registered in Start(). +// Returns 503 when the database or worker/reaper components are unhealthy. +// +// @Summary Readiness probe +// @Tags system +// @Success 200 {object} ReadyzResponse +// @Failure 503 {object} ReadyzResponse +// @Router /readyz [get] +func readyzDoc() {} //nolint:unused,deadcode From 4b7174febb588438c554fb78e65f8630ce1aed0a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:07:10 -0400 Subject: [PATCH 15/23] docs: add swag annotations to api.go handlers --- packages/engine/internal/server/api.go | 104 +++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index 25dc48a..d96b03b 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -46,6 +46,19 @@ type StepSummary struct { } // handleListExecutions handles GET /api/v1/executions with query param filters. +// +// @Summary List executions +// @Tags executions +// @Param workflow query string false "Filter by workflow name" +// @Param status query string false "Filter by status" Enums(pending,running,completed,failed,cancelled) +// @Param since query string false "Filter by age (e.g. 1h, 7d)" +// @Param limit query integer false "Max results (default 20)" +// @Success 200 {object} ExecutionListResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/executions [get] func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { workflow := r.URL.Query().Get("workflow") status := r.URL.Query().Get("status") @@ -156,6 +169,17 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { } // handleGetExecution handles GET /api/v1/executions/{id} with step details. +// +// @Summary Get execution detail +// @Tags executions +// @Param id path string true "Execution ID (UUID)" +// @Success 200 {object} ExecutionDetail +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/executions/{id} [get] func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { execID := r.PathValue("id") if execID == "" { @@ -335,6 +359,14 @@ func writeJSONError(w http.ResponseWriter, message string, status int) { } // handleListWorkflows handles GET /api/v1/workflows. +// +// @Summary List workflow definitions +// @Tags workflows +// @Success 200 {object} WorkflowListResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/workflows [get] func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) { workflows, err := workflow.ListWorkflows(r.Context(), s.DB) if err != nil { @@ -349,6 +381,16 @@ func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) { } // handleGetWorkflow handles GET /api/v1/workflows/{name} — returns latest version. +// +// @Summary Get latest workflow definition +// @Tags workflows +// @Param name path string true "Workflow name" +// @Success 200 {object} WorkflowDetailResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/workflows/{name} [get] func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if name == "" { @@ -376,6 +418,16 @@ func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) { } // handleListWorkflowVersions handles GET /api/v1/workflows/{name}/versions. +// +// @Summary List versions of a workflow +// @Tags workflows +// @Param name path string true "Workflow name" +// @Success 200 {object} WorkflowVersionListResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/workflows/{name}/versions [get] func (s *Server) handleListWorkflowVersions(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if name == "" { @@ -396,6 +448,17 @@ func (s *Server) handleListWorkflowVersions(w http.ResponseWriter, r *http.Reque } // handleGetWorkflowVersion handles GET /api/v1/workflows/{name}/versions/{version}. +// +// @Summary Get a specific workflow version +// @Tags workflows +// @Param name path string true "Workflow name" +// @Param version path integer true "Version number" +// @Success 200 {object} WorkflowDetailResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/workflows/{name}/versions/{version} [get] func (s *Server) handleGetWorkflowVersion(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if name == "" { @@ -424,6 +487,15 @@ func (s *Server) handleGetWorkflowVersion(w http.ResponseWriter, r *http.Request }) } +// handleListBudgets lists AI provider budgets for the authenticated team. +// +// @Summary List provider budgets +// @Tags budgets +// @Success 200 {array} budget.TeamBudget +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/budgets [get] func (s *Server) handleListBudgets(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) budgets, err := s.BudgetStore.ListTeamBudgets(r.Context(), teamID) @@ -434,6 +506,18 @@ func (s *Server) handleListBudgets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, budgets) } +// handleSetBudget sets or updates the token budget for a provider. +// +// @Summary Set provider budget +// @Tags budgets +// @Param provider path string true "Provider name (e.g. openai, bedrock)" +// @Param body body SetBudgetRequest true "Budget configuration" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/budgets/{provider} [put] func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) provider := r.PathValue("provider") @@ -477,6 +561,16 @@ func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +// handleDeleteBudget removes the token budget for a provider. +// +// @Summary Delete provider budget +// @Tags budgets +// @Param provider path string true "Provider name" +// @Success 200 {object} map[string]string +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/budgets/{provider} [delete] func (s *Server) handleDeleteBudget(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) provider := r.PathValue("provider") @@ -505,6 +599,16 @@ func (s *Server) handleDeleteBudget(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +// handleGetUsage returns token usage for the current billing period. +// +// @Summary Get token usage +// @Tags budgets +// @Param provider query string false "Provider name; omit for total across all providers" +// @Success 200 {object} UsageResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/budgets/usage [get] func (s *Server) handleGetUsage(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) cfg := config.FromContext(r.Context()) From 6d321a687a67b98c25cd3777c215e2bbc285f69c Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:07:46 -0400 Subject: [PATCH 16/23] docs: add swag annotations to handleRun and handleCancel --- packages/engine/internal/server/server.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/engine/internal/server/server.go b/packages/engine/internal/server/server.go index aa70d2a..9c0eade 100644 --- a/packages/engine/internal/server/server.go +++ b/packages/engine/internal/server/server.go @@ -400,6 +400,17 @@ func (s *Server) waitForExecutions(ctx context.Context) { } // handleRun triggers a workflow execution via the API. +// +// @Summary Trigger a workflow execution +// @Tags executions +// @Param workflow path string true "Workflow name" +// @Success 202 {object} RunResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/run/{workflow} [post] func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit @@ -429,6 +440,16 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { } // handleCancel cancels a running execution via the API. +// +// @Summary Cancel a running execution +// @Tags executions +// @Param execution path string true "Execution ID (UUID)" +// @Success 200 {object} CancelResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security ApiKeyAuth +// @Security OIDCAuth +// @Router /api/v1/cancel/{execution} [post] func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) { execID := r.PathValue("execution") From 8954f8fc658e3d705ed2a1e3fe113246cd31a4d0 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:13:12 -0400 Subject: [PATCH 17/23] feat: generate initial Swagger 2.0 spec via swaggo/swag --- packages/engine/internal/server/api.go | 2 +- .../engine/internal/server/docs/swagger.json | 890 ++++++++++++++++++ .../engine/internal/server/docs/swagger.yaml | 554 +++++++++++ 3 files changed, 1445 insertions(+), 1 deletion(-) create mode 100644 packages/engine/internal/server/docs/swagger.json create mode 100644 packages/engine/internal/server/docs/swagger.yaml diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index d96b03b..f6e2d2c 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -317,7 +317,7 @@ type WorkflowListResponse struct { type WorkflowDetailResponse struct { Name string `json:"name"` Version int `json:"version"` - Definition json.RawMessage `json:"definition"` + Definition json.RawMessage `json:"definition" swaggertype:"object"` } // WorkflowVersionListResponse wraps a list of workflow versions. diff --git a/packages/engine/internal/server/docs/swagger.json b/packages/engine/internal/server/docs/swagger.json new file mode 100644 index 0000000..897b451 --- /dev/null +++ b/packages/engine/internal/server/docs/swagger.json @@ -0,0 +1,890 @@ +{ + "swagger": "2.0", + "info": { + "description": "Headless AI workflow automation — BYOK, IaC-first, enterprise-grade.", + "title": "Mantle API", + "contact": { + "name": "Mantle", + "url": "https://github.com/dvflw/mantle" + }, + "license": { + "name": "BSL 1.1", + "url": "https://github.com/dvflw/mantle/blob/main/LICENSE" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/v1/budgets": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "budgets" + ], + "summary": "List provider budgets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/budget.TeamBudget" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/budgets/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "budgets" + ], + "summary": "Get token usage", + "parameters": [ + { + "type": "string", + "description": "Provider name; omit for total across all providers", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.UsageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/budgets/{provider}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "budgets" + ], + "summary": "Set provider budget", + "parameters": [ + { + "type": "string", + "description": "Provider name (e.g. openai, bedrock)", + "name": "provider", + "in": "path", + "required": true + }, + { + "description": "Budget configuration", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SetBudgetRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "budgets" + ], + "summary": "Delete provider budget", + "parameters": [ + { + "type": "string", + "description": "Provider name", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/cancel/{execution}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "executions" + ], + "summary": "Cancel a running execution", + "parameters": [ + { + "type": "string", + "description": "Execution ID (UUID)", + "name": "execution", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.CancelResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/executions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "executions" + ], + "summary": "List executions", + "parameters": [ + { + "type": "string", + "description": "Filter by workflow name", + "name": "workflow", + "in": "query" + }, + { + "enum": [ + "pending", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by age (e.g. 1h, 7d)", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 20)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ExecutionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/executions/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "executions" + ], + "summary": "Get execution detail", + "parameters": [ + { + "type": "string", + "description": "Execution ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ExecutionDetail" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/run/{workflow}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "executions" + ], + "summary": "Trigger a workflow execution", + "parameters": [ + { + "type": "string", + "description": "Workflow name", + "name": "workflow", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/server.RunResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/workflows": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "workflows" + ], + "summary": "List workflow definitions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WorkflowListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/workflows/{name}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "workflows" + ], + "summary": "Get latest workflow definition", + "parameters": [ + { + "type": "string", + "description": "Workflow name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WorkflowDetailResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/workflows/{name}/versions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "workflows" + ], + "summary": "List versions of a workflow", + "parameters": [ + { + "type": "string", + "description": "Workflow name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WorkflowVersionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/workflows/{name}/versions/{version}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "tags": [ + "workflows" + ], + "summary": "Get a specific workflow version", + "parameters": [ + { + "type": "string", + "description": "Workflow name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WorkflowDetailResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/healthz": { + "get": { + "tags": [ + "system" + ], + "summary": "Liveness probe", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.HealthResponse" + } + } + } + } + }, + "/readyz": { + "get": { + "tags": [ + "system" + ], + "summary": "Readiness probe", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ReadyzResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/server.ReadyzResponse" + } + } + } + } + } + }, + "definitions": { + "budget.TeamBudget": { + "type": "object", + "properties": { + "enforcement": { + "type": "string" + }, + "id": { + "type": "string" + }, + "monthly_token_limit": { + "type": "integer" + }, + "provider": { + "type": "string" + }, + "team_id": { + "type": "string" + } + } + }, + "server.CancelResponse": { + "type": "object", + "properties": { + "execution_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "server.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "server.ExecutionDetail": { + "type": "object", + "properties": { + "completed_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/server.StepSummary" + } + }, + "version": { + "type": "integer" + }, + "workflow": { + "type": "string" + } + } + }, + "server.ExecutionListResponse": { + "type": "object", + "properties": { + "executions": { + "type": "array", + "items": { + "$ref": "#/definitions/server.ExecutionSummary" + } + } + } + }, + "server.ExecutionSummary": { + "type": "object", + "properties": { + "completed_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "workflow": { + "type": "string" + } + } + }, + "server.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "server.ReadyzResponse": { + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "server.RunResponse": { + "type": "object", + "properties": { + "execution_id": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "workflow": { + "type": "string" + } + } + }, + "server.SetBudgetRequest": { + "type": "object", + "properties": { + "enforcement": { + "description": "\"hard\" or \"warn\"", + "type": "string" + }, + "monthly_token_limit": { + "type": "integer" + } + } + }, + "server.StepSummary": { + "type": "object", + "properties": { + "completed_at": { + "type": "string" + }, + "error": { + "type": "string" + }, + "name": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "server.UsageResponse": { + "type": "object", + "properties": { + "completion_tokens": { + "type": "integer" + }, + "period_start": { + "type": "string" + }, + "prompt_tokens": { + "type": "integer" + }, + "provider": { + "type": "string" + }, + "total_tokens": { + "type": "integer" + } + } + }, + "server.WorkflowDetailResponse": { + "type": "object", + "properties": { + "definition": { + "type": "object" + }, + "name": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, + "server.WorkflowListResponse": { + "type": "object", + "properties": { + "workflows": { + "type": "array", + "items": { + "$ref": "#/definitions/workflow.WorkflowSummary" + } + } + } + }, + "server.WorkflowVersionListResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/definitions/workflow.VersionSummary" + } + } + } + }, + "workflow.VersionSummary": { + "type": "object", + "properties": { + "content_hash": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, + "workflow.WorkflowSummary": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "latest_version": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "description": "Bearer API key. Format: \"Bearer mk_...\"", + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "OIDCAuth": { + "description": "Bearer OIDC JWT. Format: \"Bearer \u003cjwt\u003e\"", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/packages/engine/internal/server/docs/swagger.yaml b/packages/engine/internal/server/docs/swagger.yaml new file mode 100644 index 0000000..a86052d --- /dev/null +++ b/packages/engine/internal/server/docs/swagger.yaml @@ -0,0 +1,554 @@ +basePath: / +definitions: + budget.TeamBudget: + properties: + enforcement: + type: string + id: + type: string + monthly_token_limit: + type: integer + provider: + type: string + team_id: + type: string + type: object + server.CancelResponse: + properties: + execution_id: + type: string + status: + type: string + type: object + server.ErrorResponse: + properties: + error: + type: string + type: object + server.ExecutionDetail: + properties: + completed_at: + type: string + id: + type: string + started_at: + type: string + status: + type: string + steps: + items: + $ref: '#/definitions/server.StepSummary' + type: array + version: + type: integer + workflow: + type: string + type: object + server.ExecutionListResponse: + properties: + executions: + items: + $ref: '#/definitions/server.ExecutionSummary' + type: array + type: object + server.ExecutionSummary: + properties: + completed_at: + type: string + id: + type: string + started_at: + type: string + status: + type: string + version: + type: integer + workflow: + type: string + type: object + server.HealthResponse: + properties: + status: + type: string + type: object + server.ReadyzResponse: + properties: + details: + additionalProperties: + type: string + type: object + status: + type: string + type: object + server.RunResponse: + properties: + execution_id: + type: string + version: + type: integer + workflow: + type: string + type: object + server.SetBudgetRequest: + properties: + enforcement: + description: '"hard" or "warn"' + type: string + monthly_token_limit: + type: integer + type: object + server.StepSummary: + properties: + completed_at: + type: string + error: + type: string + name: + type: string + started_at: + type: string + status: + type: string + type: object + server.UsageResponse: + properties: + completion_tokens: + type: integer + period_start: + type: string + prompt_tokens: + type: integer + provider: + type: string + total_tokens: + type: integer + type: object + server.WorkflowDetailResponse: + properties: + definition: + type: object + name: + type: string + version: + type: integer + type: object + server.WorkflowListResponse: + properties: + workflows: + items: + $ref: '#/definitions/workflow.WorkflowSummary' + type: array + type: object + server.WorkflowVersionListResponse: + properties: + name: + type: string + versions: + items: + $ref: '#/definitions/workflow.VersionSummary' + type: array + type: object + workflow.VersionSummary: + properties: + content_hash: + type: string + created_at: + type: string + version: + type: integer + type: object + workflow.WorkflowSummary: + properties: + created_at: + type: string + latest_version: + type: integer + name: + type: string + updated_at: + type: string + type: object +host: localhost:8080 +info: + contact: + name: Mantle + url: https://github.com/dvflw/mantle + description: Headless AI workflow automation — BYOK, IaC-first, enterprise-grade. + license: + name: BSL 1.1 + url: https://github.com/dvflw/mantle/blob/main/LICENSE + title: Mantle API + version: "1.0" +paths: + /api/v1/budgets: + get: + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/budget.TeamBudget' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: List provider budgets + tags: + - budgets + /api/v1/budgets/{provider}: + delete: + parameters: + - description: Provider name + in: path + name: provider + required: true + type: string + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Delete provider budget + tags: + - budgets + put: + parameters: + - description: Provider name (e.g. openai, bedrock) + in: path + name: provider + required: true + type: string + - description: Budget configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.SetBudgetRequest' + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Set provider budget + tags: + - budgets + /api/v1/budgets/usage: + get: + parameters: + - description: Provider name; omit for total across all providers + in: query + name: provider + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.UsageResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Get token usage + tags: + - budgets + /api/v1/cancel/{execution}: + post: + parameters: + - description: Execution ID (UUID) + in: path + name: execution + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.CancelResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Cancel a running execution + tags: + - executions + /api/v1/executions: + get: + parameters: + - description: Filter by workflow name + in: query + name: workflow + type: string + - description: Filter by status + enum: + - pending + - running + - completed + - failed + - cancelled + in: query + name: status + type: string + - description: Filter by age (e.g. 1h, 7d) + in: query + name: since + type: string + - description: Max results (default 20) + in: query + name: limit + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ExecutionListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: List executions + tags: + - executions + /api/v1/executions/{id}: + get: + parameters: + - description: Execution ID (UUID) + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ExecutionDetail' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Get execution detail + tags: + - executions + /api/v1/run/{workflow}: + post: + parameters: + - description: Workflow name + in: path + name: workflow + required: true + type: string + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/server.RunResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Trigger a workflow execution + tags: + - executions + /api/v1/workflows: + get: + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WorkflowListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: List workflow definitions + tags: + - workflows + /api/v1/workflows/{name}: + get: + parameters: + - description: Workflow name + in: path + name: name + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WorkflowDetailResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Get latest workflow definition + tags: + - workflows + /api/v1/workflows/{name}/versions: + get: + parameters: + - description: Workflow name + in: path + name: name + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WorkflowVersionListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: List versions of a workflow + tags: + - workflows + /api/v1/workflows/{name}/versions/{version}: + get: + parameters: + - description: Workflow name + in: path + name: name + required: true + type: string + - description: Version number + in: path + name: version + required: true + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WorkflowDetailResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Get a specific workflow version + tags: + - workflows + /healthz: + get: + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.HealthResponse' + summary: Liveness probe + tags: + - system + /readyz: + get: + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ReadyzResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/server.ReadyzResponse' + summary: Readiness probe + tags: + - system +securityDefinitions: + ApiKeyAuth: + description: 'Bearer API key. Format: "Bearer mk_..."' + in: header + name: Authorization + type: apiKey + OIDCAuth: + description: 'Bearer OIDC JWT. Format: "Bearer "' + in: header + name: Authorization + type: apiKey +swagger: "2.0" From f1c421fb9b810168ef304ec68576ec02ad706c0e Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:13:51 -0400 Subject: [PATCH 18/23] feat: serve OpenAPI spec at GET /api/v1/openapi.json --- packages/engine/internal/server/openapi.go | 15 ++++++++ .../engine/internal/server/openapi_test.go | 35 +++++++++++++++++++ packages/engine/internal/server/server.go | 3 ++ 3 files changed, 53 insertions(+) create mode 100644 packages/engine/internal/server/openapi.go create mode 100644 packages/engine/internal/server/openapi_test.go diff --git a/packages/engine/internal/server/openapi.go b/packages/engine/internal/server/openapi.go new file mode 100644 index 0000000..682f990 --- /dev/null +++ b/packages/engine/internal/server/openapi.go @@ -0,0 +1,15 @@ +package server + +import ( + _ "embed" + "net/http" +) + +//go:embed docs/swagger.json +var swaggerJSON []byte + +// handleOpenAPISpec serves the embedded OpenAPI/Swagger spec as JSON. +func handleOpenAPISpec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(swaggerJSON) //nolint:errcheck +} diff --git a/packages/engine/internal/server/openapi_test.go b/packages/engine/internal/server/openapi_test.go new file mode 100644 index 0000000..b0cc2a6 --- /dev/null +++ b/packages/engine/internal/server/openapi_test.go @@ -0,0 +1,35 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleOpenAPISpec(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil) + handleOpenAPISpec(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var doc map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + // Swagger 2.0 uses "swagger", OpenAPI 3.x uses "openapi" + if doc["swagger"] == nil && doc["openapi"] == nil { + t.Error("response does not look like an OpenAPI/Swagger document (missing 'swagger' or 'openapi' key)") + } + if doc["info"] == nil { + t.Error("response missing 'info' field") + } + if doc["paths"] == nil { + t.Error("response missing 'paths' field") + } +} diff --git a/packages/engine/internal/server/server.go b/packages/engine/internal/server/server.go index 9c0eade..5c087b0 100644 --- a/packages/engine/internal/server/server.go +++ b/packages/engine/internal/server/server.go @@ -148,6 +148,9 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget) mux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage) + // OpenAPI spec endpoint. + mux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec) + // Start distributed engine components (worker + reaper). if cfg := config.FromContext(ctx); cfg != nil { claimer := &engine.Claimer{ From 9781526544611884e19c6e55a01d73860aca60ee Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:29:04 -0400 Subject: [PATCH 19/23] ci: add make spec target and OpenAPI spec freshness check Adds `make spec` to regenerate the OpenAPI spec locally and a GitHub Actions job that re-runs swag on CI and fails if the committed spec has drifted from the annotations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/engine-ci.yml | 21 +++++++++++++++++++++ packages/engine/Makefile | 10 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index cd7db36..b0b9559 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -74,3 +74,24 @@ jobs: run: go install github.com/securego/gosec/v2/cmd/gosec@latest - name: Run gosec run: gosec -exclude=G101,G104,G112,G115,G117,G202,G204,G301,G304,G306,G404,G703,G705,G706 ./... + + spec: + name: Spec Freshness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - name: Install swag + run: go install github.com/swaggo/swag/cmd/swag@latest + - name: Regenerate spec + run: | + swag init \ + -g docs.go \ + --dir internal/server,internal/workflow,internal/budget \ + --output internal/server/docs \ + --outputTypes json,yaml \ + --parseInternal + - name: Check for drift + run: git diff --exit-code internal/server/docs/ diff --git a/packages/engine/Makefile b/packages/engine/Makefile index 96ca78f..d4141d8 100644 --- a/packages/engine/Makefile +++ b/packages/engine/Makefile @@ -5,7 +5,7 @@ LDFLAGS := -X github.com/dvflw/mantle/internal/version.Version=$(VERSION) \ -X github.com/dvflw/mantle/internal/version.Commit=$(COMMIT) \ -X github.com/dvflw/mantle/internal/version.Date=$(DATE) -.PHONY: build test lint clean migrate run dev +.PHONY: build test lint clean migrate run dev spec build: go build -ldflags "$(LDFLAGS)" -o mantle ./cmd/mantle @@ -27,3 +27,11 @@ run: dev: docker-compose up -d + +spec: ## Regenerate OpenAPI spec (requires: go install github.com/swaggo/swag/cmd/swag@latest) + swag init \ + -g docs.go \ + --dir internal/server,internal/workflow,internal/budget \ + --output internal/server/docs \ + --outputTypes json,yaml \ + --parseInternal From 593309f60d3150d4ced7d9d6acbb78ab9b670e7e Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 22:33:42 -0400 Subject: [PATCH 20/23] docs: address pre-push review findings - Add @Description to all 12 API handler annotations - Add StatusResponse named type; replace map[string]string in budget mutation responses - Add w.WriteHeader(http.StatusOK) to handleOpenAPISpec for consistency - Add field-level comments to budget.TeamBudget (enforcement values, token limit units) - Regenerate swagger.json/yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/engine/internal/budget/store.go | 6 +- packages/engine/internal/server/api.go | 65 ++++++++++++------- .../engine/internal/server/docs/swagger.json | 33 +++++++--- .../engine/internal/server/docs/swagger.yaml | 37 +++++++++-- packages/engine/internal/server/openapi.go | 1 + packages/engine/internal/server/server.go | 10 +-- 6 files changed, 106 insertions(+), 46 deletions(-) diff --git a/packages/engine/internal/budget/store.go b/packages/engine/internal/budget/store.go index 75f60da..16cfe46 100644 --- a/packages/engine/internal/budget/store.go +++ b/packages/engine/internal/budget/store.go @@ -18,9 +18,9 @@ type ProviderUsage struct { type TeamBudget struct { ID string `json:"id"` TeamID string `json:"team_id"` - Provider string `json:"provider"` - MonthlyTokenLimit int64 `json:"monthly_token_limit"` - Enforcement string `json:"enforcement"` + Provider string `json:"provider"` // AI provider name (e.g. "openai", "bedrock") + MonthlyTokenLimit int64 `json:"monthly_token_limit"` // Maximum tokens allowed per billing period + Enforcement string `json:"enforcement"` // "hard" blocks execution when exceeded; "warn" logs only } // Store handles budget-related database operations. diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index f6e2d2c..27d5b3d 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -47,8 +47,9 @@ type StepSummary struct { // handleListExecutions handles GET /api/v1/executions with query param filters. // -// @Summary List executions -// @Tags executions +// @Summary List executions +// @Description Returns a paginated list of workflow executions for the authenticated team. Supports filtering by workflow name, status, and age. +// @Tags executions // @Param workflow query string false "Filter by workflow name" // @Param status query string false "Filter by status" Enums(pending,running,completed,failed,cancelled) // @Param since query string false "Filter by age (e.g. 1h, 7d)" @@ -170,8 +171,9 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { // handleGetExecution handles GET /api/v1/executions/{id} with step details. // -// @Summary Get execution detail -// @Tags executions +// @Summary Get execution detail +// @Description Returns full details of a single execution including all step results. +// @Tags executions // @Param id path string true "Execution ID (UUID)" // @Success 200 {object} ExecutionDetail // @Failure 400 {object} ErrorResponse @@ -346,6 +348,11 @@ type SetBudgetRequest struct { Enforcement string `json:"enforcement"` // "hard" or "warn" } +// StatusResponse is returned for mutations that produce no data payload. +type StatusResponse struct { + Status string `json:"status"` +} + func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -360,8 +367,9 @@ func writeJSONError(w http.ResponseWriter, message string, status int) { // handleListWorkflows handles GET /api/v1/workflows. // -// @Summary List workflow definitions -// @Tags workflows +// @Summary List workflow definitions +// @Description Returns all workflow definitions ever applied by the authenticated team. +// @Tags workflows // @Success 200 {object} WorkflowListResponse // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth @@ -382,8 +390,9 @@ func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) { // handleGetWorkflow handles GET /api/v1/workflows/{name} — returns latest version. // -// @Summary Get latest workflow definition -// @Tags workflows +// @Summary Get latest workflow definition +// @Description Returns the latest applied definition for a workflow. +// @Tags workflows // @Param name path string true "Workflow name" // @Success 200 {object} WorkflowDetailResponse // @Failure 404 {object} ErrorResponse @@ -419,8 +428,9 @@ func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) { // handleListWorkflowVersions handles GET /api/v1/workflows/{name}/versions. // -// @Summary List versions of a workflow -// @Tags workflows +// @Summary List versions of a workflow +// @Description Returns all historical versions of a workflow in reverse chronological order. +// @Tags workflows // @Param name path string true "Workflow name" // @Success 200 {object} WorkflowVersionListResponse // @Failure 400 {object} ErrorResponse @@ -449,8 +459,9 @@ func (s *Server) handleListWorkflowVersions(w http.ResponseWriter, r *http.Reque // handleGetWorkflowVersion handles GET /api/v1/workflows/{name}/versions/{version}. // -// @Summary Get a specific workflow version -// @Tags workflows +// @Summary Get a specific workflow version +// @Description Returns a specific historical version of a workflow definition. +// @Tags workflows // @Param name path string true "Workflow name" // @Param version path integer true "Version number" // @Success 200 {object} WorkflowDetailResponse @@ -489,8 +500,9 @@ func (s *Server) handleGetWorkflowVersion(w http.ResponseWriter, r *http.Request // handleListBudgets lists AI provider budgets for the authenticated team. // -// @Summary List provider budgets -// @Tags budgets +// @Summary List provider budgets +// @Description Returns the token budget configuration for all providers configured by the authenticated team. +// @Tags budgets // @Success 200 {array} budget.TeamBudget // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth @@ -508,11 +520,12 @@ func (s *Server) handleListBudgets(w http.ResponseWriter, r *http.Request) { // handleSetBudget sets or updates the token budget for a provider. // -// @Summary Set provider budget -// @Tags budgets -// @Param provider path string true "Provider name (e.g. openai, bedrock)" -// @Param body body SetBudgetRequest true "Budget configuration" -// @Success 200 {object} map[string]string +// @Summary Set provider budget +// @Description Creates or replaces the monthly token budget for a provider. Enforcement "hard" blocks execution when the limit is reached; "warn" logs a warning only. +// @Tags budgets +// @Param provider path string true "Provider name (e.g. openai, bedrock)" +// @Param body body SetBudgetRequest true "Budget configuration" +// @Success 200 {object} StatusResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth @@ -563,10 +576,11 @@ func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { // handleDeleteBudget removes the token budget for a provider. // -// @Summary Delete provider budget -// @Tags budgets -// @Param provider path string true "Provider name" -// @Success 200 {object} map[string]string +// @Summary Delete provider budget +// @Description Removes the budget configuration for a provider. Does not affect in-flight executions. +// @Tags budgets +// @Param provider path string true "Provider name" +// @Success 200 {object} StatusResponse // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth // @Security OIDCAuth @@ -601,8 +615,9 @@ func (s *Server) handleDeleteBudget(w http.ResponseWriter, r *http.Request) { // handleGetUsage returns token usage for the current billing period. // -// @Summary Get token usage -// @Tags budgets +// @Summary Get token usage +// @Description Returns token usage aggregated by provider for the current billing period (calendar month UTC). +// @Tags budgets // @Param provider query string false "Provider name; omit for total across all providers" // @Success 200 {object} UsageResponse // @Failure 500 {object} ErrorResponse diff --git a/packages/engine/internal/server/docs/swagger.json b/packages/engine/internal/server/docs/swagger.json index 897b451..f775e8e 100644 --- a/packages/engine/internal/server/docs/swagger.json +++ b/packages/engine/internal/server/docs/swagger.json @@ -26,6 +26,7 @@ "OIDCAuth": [] } ], + "description": "Returns the token budget configuration for all providers configured by the authenticated team.", "tags": [ "budgets" ], @@ -59,6 +60,7 @@ "OIDCAuth": [] } ], + "description": "Returns token usage aggregated by provider for the current billing period (calendar month UTC).", "tags": [ "budgets" ], @@ -97,6 +99,7 @@ "OIDCAuth": [] } ], + "description": "Creates or replaces the monthly token budget for a provider. Enforcement \"hard\" blocks execution when the limit is reached; \"warn\" logs a warning only.", "tags": [ "budgets" ], @@ -123,10 +126,7 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/server.StatusResponse" } }, "400": { @@ -152,6 +152,7 @@ "OIDCAuth": [] } ], + "description": "Removes the budget configuration for a provider. Does not affect in-flight executions.", "tags": [ "budgets" ], @@ -169,10 +170,7 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/server.StatusResponse" } }, "500": { @@ -194,6 +192,7 @@ "OIDCAuth": [] } ], + "description": "Sends a cancellation signal to a running execution. The execution may not stop immediately; poll the status endpoint to confirm.", "tags": [ "executions" ], @@ -239,6 +238,7 @@ "OIDCAuth": [] } ], + "description": "Returns a paginated list of workflow executions for the authenticated team. Supports filtering by workflow name, status, and age.", "tags": [ "executions" ], @@ -308,6 +308,7 @@ "OIDCAuth": [] } ], + "description": "Returns full details of a single execution including all step results.", "tags": [ "executions" ], @@ -359,6 +360,7 @@ "OIDCAuth": [] } ], + "description": "Triggers the latest applied version of the named workflow. Returns a 202 Accepted response with the new execution ID.", "tags": [ "executions" ], @@ -410,6 +412,7 @@ "OIDCAuth": [] } ], + "description": "Returns all workflow definitions ever applied by the authenticated team.", "tags": [ "workflows" ], @@ -440,6 +443,7 @@ "OIDCAuth": [] } ], + "description": "Returns the latest applied definition for a workflow.", "tags": [ "workflows" ], @@ -485,6 +489,7 @@ "OIDCAuth": [] } ], + "description": "Returns all historical versions of a workflow in reverse chronological order.", "tags": [ "workflows" ], @@ -530,6 +535,7 @@ "OIDCAuth": [] } ], + "description": "Returns a specific historical version of a workflow definition.", "tags": [ "workflows" ], @@ -616,15 +622,18 @@ "type": "object", "properties": { "enforcement": { + "description": "\"hard\" blocks execution when exceeded; \"warn\" logs only", "type": "string" }, "id": { "type": "string" }, "monthly_token_limit": { + "description": "Maximum tokens allowed per billing period", "type": "integer" }, "provider": { + "description": "AI provider name (e.g. \"openai\", \"bedrock\")", "type": "string" }, "team_id": { @@ -762,6 +771,14 @@ } } }, + "server.StatusResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, "server.StepSummary": { "type": "object", "properties": { diff --git a/packages/engine/internal/server/docs/swagger.yaml b/packages/engine/internal/server/docs/swagger.yaml index a86052d..e7fb591 100644 --- a/packages/engine/internal/server/docs/swagger.yaml +++ b/packages/engine/internal/server/docs/swagger.yaml @@ -3,12 +3,15 @@ definitions: budget.TeamBudget: properties: enforcement: + description: '"hard" blocks execution when exceeded; "warn" logs only' type: string id: type: string monthly_token_limit: + description: Maximum tokens allowed per billing period type: integer provider: + description: AI provider name (e.g. "openai", "bedrock") type: string team_id: type: string @@ -97,6 +100,11 @@ definitions: monthly_token_limit: type: integer type: object + server.StatusResponse: + properties: + status: + type: string + type: object server.StepSummary: properties: completed_at: @@ -182,6 +190,8 @@ info: paths: /api/v1/budgets: get: + description: Returns the token budget configuration for all providers configured + by the authenticated team. responses: "200": description: OK @@ -201,6 +211,8 @@ paths: - budgets /api/v1/budgets/{provider}: delete: + description: Removes the budget configuration for a provider. Does not affect + in-flight executions. parameters: - description: Provider name in: path @@ -211,9 +223,7 @@ paths: "200": description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/server.StatusResponse' "500": description: Internal Server Error schema: @@ -225,6 +235,8 @@ paths: tags: - budgets put: + description: Creates or replaces the monthly token budget for a provider. Enforcement + "hard" blocks execution when the limit is reached; "warn" logs a warning only. parameters: - description: Provider name (e.g. openai, bedrock) in: path @@ -241,9 +253,7 @@ paths: "200": description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/server.StatusResponse' "400": description: Bad Request schema: @@ -260,6 +270,8 @@ paths: - budgets /api/v1/budgets/usage: get: + description: Returns token usage aggregated by provider for the current billing + period (calendar month UTC). parameters: - description: Provider name; omit for total across all providers in: query @@ -282,6 +294,8 @@ paths: - budgets /api/v1/cancel/{execution}: post: + description: Sends a cancellation signal to a running execution. The execution + may not stop immediately; poll the status endpoint to confirm. parameters: - description: Execution ID (UUID) in: path @@ -309,6 +323,8 @@ paths: - executions /api/v1/executions: get: + description: Returns a paginated list of workflow executions for the authenticated + team. Supports filtering by workflow name, status, and age. parameters: - description: Filter by workflow name in: query @@ -353,6 +369,7 @@ paths: - executions /api/v1/executions/{id}: get: + description: Returns full details of a single execution including all step results. parameters: - description: Execution ID (UUID) in: path @@ -384,6 +401,8 @@ paths: - executions /api/v1/run/{workflow}: post: + description: Triggers the latest applied version of the named workflow. Returns + a 202 Accepted response with the new execution ID. parameters: - description: Workflow name in: path @@ -415,6 +434,8 @@ paths: - executions /api/v1/workflows: get: + description: Returns all workflow definitions ever applied by the authenticated + team. responses: "200": description: OK @@ -432,6 +453,7 @@ paths: - workflows /api/v1/workflows/{name}: get: + description: Returns the latest applied definition for a workflow. parameters: - description: Workflow name in: path @@ -459,6 +481,8 @@ paths: - workflows /api/v1/workflows/{name}/versions: get: + description: Returns all historical versions of a workflow in reverse chronological + order. parameters: - description: Workflow name in: path @@ -486,6 +510,7 @@ paths: - workflows /api/v1/workflows/{name}/versions/{version}: get: + description: Returns a specific historical version of a workflow definition. parameters: - description: Workflow name in: path diff --git a/packages/engine/internal/server/openapi.go b/packages/engine/internal/server/openapi.go index 682f990..079e72c 100644 --- a/packages/engine/internal/server/openapi.go +++ b/packages/engine/internal/server/openapi.go @@ -11,5 +11,6 @@ var swaggerJSON []byte // handleOpenAPISpec serves the embedded OpenAPI/Swagger spec as JSON. func handleOpenAPISpec(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) w.Write(swaggerJSON) //nolint:errcheck } diff --git a/packages/engine/internal/server/server.go b/packages/engine/internal/server/server.go index 5c087b0..d6767d6 100644 --- a/packages/engine/internal/server/server.go +++ b/packages/engine/internal/server/server.go @@ -404,8 +404,9 @@ func (s *Server) waitForExecutions(ctx context.Context) { // handleRun triggers a workflow execution via the API. // -// @Summary Trigger a workflow execution -// @Tags executions +// @Summary Trigger a workflow execution +// @Description Triggers the latest applied version of the named workflow. Returns a 202 Accepted response with the new execution ID. +// @Tags executions // @Param workflow path string true "Workflow name" // @Success 202 {object} RunResponse // @Failure 400 {object} ErrorResponse @@ -444,8 +445,9 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { // handleCancel cancels a running execution via the API. // -// @Summary Cancel a running execution -// @Tags executions +// @Summary Cancel a running execution +// @Description Sends a cancellation signal to a running execution. The execution may not stop immediately; poll the status endpoint to confirm. +// @Tags executions // @Param execution path string true "Execution ID (UUID)" // @Success 200 {object} CancelResponse // @Failure 404 {object} ErrorResponse From 1cdc2472b16c09753f9740cba2ea7e3d056a4b4a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 23:09:14 -0400 Subject: [PATCH 21/23] fix: address CodeRabbit review findings - Remove hardcoded @host localhost:8080 from global API annotations so the spec is served host-agnostic - Add omitempty to StartedAt on ExecutionSummary, ExecutionDetail, and StepSummary to match CompletedAt behaviour and align wire format with schema - Replace http.Error (text/plain) with writeJSONError in budget/usage handlers so 400/500 responses honour the documented ErrorResponse contract; log internal errors via s.Logger instead of leaking details - Pin swag install in CI spec job to v1.16.6 for reproducible builds - Sync govulncheck step with main's known-unfixable exclusion filter - Regenerate swagger.json/yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/engine-ci.yml | 13 +++++++++-- packages/engine/internal/server/api.go | 22 +++++++++++-------- packages/engine/internal/server/docs.go | 1 - .../engine/internal/server/docs/swagger.json | 1 - .../engine/internal/server/docs/swagger.yaml | 1 - 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index b0b9559..fd519d1 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -60,7 +60,16 @@ jobs: - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck - run: govulncheck ./... + run: | + # GO-2026-4887 and GO-2026-4883 are in docker/docker (testcontainers test dep only). + # Fixed in: N/A — no upstream fix available. Remove exclusions when a patched release ships. + OUTPUT=$(govulncheck ./... 2>&1 || true) + echo "$OUTPUT" + ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883') + if [ -n "$ACTIONABLE" ]; then + echo "FAIL: unfixed actionable vulnerabilities found" + exit 1 + fi gosec: name: Gosec @@ -84,7 +93,7 @@ jobs: with: go-version: '1.25' - name: Install swag - run: go install github.com/swaggo/swag/cmd/swag@latest + run: go install github.com/swaggo/swag/cmd/swag@v1.16.6 - name: Regenerate spec run: | swag init \ diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index 27d5b3d..f605936 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -21,7 +21,7 @@ type ExecutionSummary struct { Workflow string `json:"workflow"` Version int `json:"version"` Status string `json:"status"` - StartedAt *string `json:"started_at"` + StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` } @@ -31,7 +31,7 @@ type ExecutionDetail struct { Workflow string `json:"workflow"` Version int `json:"version"` Status string `json:"status"` - StartedAt *string `json:"started_at"` + StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` Steps []StepSummary `json:"steps"` } @@ -41,7 +41,7 @@ type StepSummary struct { Name string `json:"name"` Status string `json:"status"` Error string `json:"error,omitempty"` - StartedAt *string `json:"started_at"` + StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` } @@ -512,7 +512,8 @@ func (s *Server) handleListBudgets(w http.ResponseWriter, r *http.Request) { teamID := auth.TeamIDFromContext(r.Context()) budgets, err := s.BudgetStore.ListTeamBudgets(r.Context(), teamID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.Logger.Error("listing budgets", "error", err) + writeJSONError(w, "internal server error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, budgets) @@ -537,19 +538,20 @@ func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { var body SetBudgetRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeJSONError(w, "invalid request body", http.StatusBadRequest) return } if body.Enforcement == "" { body.Enforcement = "hard" } if body.Enforcement != "hard" && body.Enforcement != "warn" { - http.Error(w, "enforcement must be 'hard' or 'warn'", http.StatusBadRequest) + writeJSONError(w, "enforcement must be 'hard' or 'warn'", http.StatusBadRequest) return } if err := s.BudgetStore.SetTeamBudget(r.Context(), teamID, provider, body.MonthlyTokenLimit, body.Enforcement); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.Logger.Error("setting budget", "provider", provider, "error", err) + writeJSONError(w, "internal server error", http.StatusInternalServerError) return } @@ -590,7 +592,8 @@ func (s *Server) handleDeleteBudget(w http.ResponseWriter, r *http.Request) { provider := r.PathValue("provider") if err := s.BudgetStore.DeleteTeamBudget(r.Context(), teamID, provider); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.Logger.Error("deleting budget", "provider", provider, "error", err) + writeJSONError(w, "internal server error", http.StatusInternalServerError) return } @@ -639,7 +642,8 @@ func (s *Server) handleGetUsage(w http.ResponseWriter, r *http.Request) { usage, err = s.BudgetStore.GetTotalUsage(r.Context(), teamID, period) } if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.Logger.Error("getting usage", "provider", provider, "error", err) + writeJSONError(w, "internal server error", http.StatusInternalServerError) return } diff --git a/packages/engine/internal/server/docs.go b/packages/engine/internal/server/docs.go index 8ee4022..083b3ae 100644 --- a/packages/engine/internal/server/docs.go +++ b/packages/engine/internal/server/docs.go @@ -8,7 +8,6 @@ // @license.name BSL 1.1 // @license.url https://github.com/dvflw/mantle/blob/main/LICENSE // -// @host localhost:8080 // @basePath / // // @securityDefinitions.apikey ApiKeyAuth diff --git a/packages/engine/internal/server/docs/swagger.json b/packages/engine/internal/server/docs/swagger.json index f775e8e..8b97110 100644 --- a/packages/engine/internal/server/docs/swagger.json +++ b/packages/engine/internal/server/docs/swagger.json @@ -13,7 +13,6 @@ }, "version": "1.0" }, - "host": "localhost:8080", "basePath": "/", "paths": { "/api/v1/budgets": { diff --git a/packages/engine/internal/server/docs/swagger.yaml b/packages/engine/internal/server/docs/swagger.yaml index e7fb591..ab24973 100644 --- a/packages/engine/internal/server/docs/swagger.yaml +++ b/packages/engine/internal/server/docs/swagger.yaml @@ -176,7 +176,6 @@ definitions: updated_at: type: string type: object -host: localhost:8080 info: contact: name: Mantle From df573f73e761850a3eca832e299d1126af8309cf Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 23:16:52 -0400 Subject: [PATCH 22/23] fix: use named response types and fix govulncheck filter exit code - Replace all ad-hoc map[string]any response payloads with their declared named types (ExecutionListResponse, WorkflowListResponse, WorkflowDetailResponse, WorkflowVersionListResponse) so runtime responses match the documented Swagger types and are validated at compile time - Add || true to the ACTIONABLE grep pipeline so grep's non-zero exit when all vulns are excluded doesn't abort the step under set -e - Regenerate swagger.json/yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/engine-ci.yml | 2 +- packages/engine/internal/server/api.go | 24 +++++++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index fd519d1..81faaed 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -65,7 +65,7 @@ jobs: # Fixed in: N/A — no upstream fix available. Remove exclusions when a patched release ships. OUTPUT=$(govulncheck ./... 2>&1 || true) echo "$OUTPUT" - ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883') + ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883' || true) if [ -n "$ACTIONABLE" ]; then echo "FAIL: unfixed actionable vulnerabilities found" exit 1 diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index f605936..897090a 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -166,7 +166,7 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]any{"executions": executions}) + writeJSON(w, http.StatusOK, ExecutionListResponse{Executions: executions}) } // handleGetExecution handles GET /api/v1/executions/{id} with step details. @@ -385,7 +385,7 @@ func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) { if workflows == nil { workflows = []workflow.WorkflowSummary{} } - writeJSON(w, http.StatusOK, map[string]any{"workflows": workflows}) + writeJSON(w, http.StatusOK, WorkflowListResponse{Workflows: workflows}) } // handleGetWorkflow handles GET /api/v1/workflows/{name} — returns latest version. @@ -418,11 +418,10 @@ func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) { return } - var def json.RawMessage = content - writeJSON(w, http.StatusOK, map[string]any{ - "name": name, - "version": version, - "definition": def, + writeJSON(w, http.StatusOK, WorkflowDetailResponse{ + Name: name, + Version: version, + Definition: json.RawMessage(content), }) } @@ -454,7 +453,7 @@ func (s *Server) handleListWorkflowVersions(w http.ResponseWriter, r *http.Reque if versions == nil { versions = []workflow.VersionSummary{} } - writeJSON(w, http.StatusOK, map[string]any{"name": name, "versions": versions}) + writeJSON(w, http.StatusOK, WorkflowVersionListResponse{Name: name, Versions: versions}) } // handleGetWorkflowVersion handles GET /api/v1/workflows/{name}/versions/{version}. @@ -490,11 +489,10 @@ func (s *Server) handleGetWorkflowVersion(w http.ResponseWriter, r *http.Request return } - var def json.RawMessage = content - writeJSON(w, http.StatusOK, map[string]any{ - "name": name, - "version": version, - "definition": def, + writeJSON(w, http.StatusOK, WorkflowDetailResponse{ + Name: name, + Version: version, + Definition: json.RawMessage(content), }) } From ca4f2d3f674a248ee87e9617d4b1e9240fd698dc Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 4 Apr 2026 23:25:48 -0400 Subject: [PATCH 23/23] docs: pin swag version in Makefile spec target comment to v1.16.6 Matches the version pinned in the CI spec freshness job to avoid mismatched generated specs between local and CI runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/engine/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/Makefile b/packages/engine/Makefile index d4141d8..78dd10b 100644 --- a/packages/engine/Makefile +++ b/packages/engine/Makefile @@ -28,7 +28,7 @@ run: dev: docker-compose up -d -spec: ## Regenerate OpenAPI spec (requires: go install github.com/swaggo/swag/cmd/swag@latest) +spec: ## Regenerate OpenAPI spec (requires: go install github.com/swaggo/swag/cmd/swag@v1.16.6) swag init \ -g docs.go \ --dir internal/server,internal/workflow,internal/budget \