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/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" diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index cd7db36..81faaed 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' || true) + if [ -n "$ACTIONABLE" ]; then + echo "FAIL: unfixed actionable vulnerabilities found" + exit 1 + fi gosec: name: Gosec @@ -74,3 +83,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@v1.16.6 + - 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/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml new file mode 100644 index 0000000..b7ac167 --- /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@0.28.0 + 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-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} + \`\`\`" 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 }} diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml new file mode 100644 index 0000000..e9c7936 --- /dev/null +++ b/.github/workflows/release-tags.yml @@ -0,0 +1,97 @@ +name: Release Tags + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +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 + id: bumps + run: | + 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" + ENGINE_BUMPED=true + 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" + HELM_BUMPED=true + 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" + 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 + + if [[ -n "$TAGGED" ]]; then + echo "Pushing tags:$TAGGED" + git push origin --tags + else + echo "No version bumps detected" + fi 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 diff --git a/.github/workflows/site-deploy.yml b/.github/workflows/site-deploy.yml new file mode 100644 index 0000000..289e95f --- /dev/null +++ b/.github/workflows/site-deploy.yml @@ -0,0 +1,34 @@ +name: Deploy Site + +on: + push: + branches: [main] + paths: + - 'packages/site/**' + +permissions: + contents: read + +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 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..3ae3580 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-release-pipeline-design.md @@ -0,0 +1,114 @@ +# 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). + +**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. + +## 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 +- Root `package.json` — add `workspaces` field to register all 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 | 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 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/.goreleaser.yaml b/packages/engine/.goreleaser.yaml new file mode 100644 index 0000000..b754f4e --- /dev/null +++ b/packages/engine/.goreleaser.yaml @@ -0,0 +1,85 @@ +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 + ids: [mantle] + goos: linux + goarch: amd64 + dockerfile: Dockerfile.goreleaser + build_flag_templates: + - "--platform=linux/amd64" + - image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + use: buildx + ids: [mantle] + goos: linux + goarch: arm64 + dockerfile: Dockerfile.goreleaser + build_flag_templates: + - "--platform=linux/arm64" + +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 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"] diff --git a/packages/engine/Makefile b/packages/engine/Makefile index 96ca78f..78dd10b 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@v1.16.6) + swag init \ + -g docs.go \ + --dir internal/server,internal/workflow,internal/budget \ + --output internal/server/docs \ + --outputTypes json,yaml \ + --parseInternal 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= 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 4cd166b..897090a 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -15,37 +15,51 @@ 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"` Status string `json:"status"` - StartedAt *string `json:"started_at"` + StartedAt *string `json:"started_at,omitempty"` 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"` + StartedAt *string `json:"started_at,omitempty"` 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"` - StartedAt *string `json:"started_at"` + StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` } // handleListExecutions handles GET /api/v1/executions with query param filters. +// +// @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)" +// @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") @@ -118,7 +132,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 +143,7 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { return } - exec := executionSummary{ + exec := ExecutionSummary{ ID: id, Workflow: wfName, Version: version, @@ -152,10 +166,22 @@ 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. +// +// @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 +// @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 == "" { @@ -177,12 +203,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 +242,7 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { return } - step := stepSummary{ + step := StepSummary{ Name: stepName, Status: stepStatus, } @@ -266,6 +292,67 @@ 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" swaggertype:"object"` +} + +// 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" +} + +// 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) @@ -279,6 +366,15 @@ func writeJSONError(w http.ResponseWriter, message string, status int) { } // handleListWorkflows handles GET /api/v1/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 +// @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 { @@ -289,10 +385,21 @@ 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. +// +// @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 +// @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 == "" { @@ -311,15 +418,25 @@ 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), }) } // handleListWorkflowVersions handles GET /api/v1/workflows/{name}/versions. +// +// @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 +// @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 == "" { @@ -336,10 +453,22 @@ 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}. +// +// @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 +// @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 == "" { @@ -360,46 +489,67 @@ 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), }) } +// handleListBudgets lists AI provider budgets for the authenticated team. +// +// @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 +// @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) 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) } +// handleSetBudget sets or updates the token budget for a provider. +// +// @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 +// @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") - 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) + 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 } @@ -424,12 +574,24 @@ 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 +// @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 +// @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") 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 } @@ -452,6 +614,17 @@ 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 +// @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 +// @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()) @@ -467,7 +640,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 new file mode 100644 index 0000000..083b3ae --- /dev/null +++ b/packages/engine/internal/server/docs.go @@ -0,0 +1,53 @@ +// 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 +// +// @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 diff --git a/packages/engine/internal/server/docs/swagger.json b/packages/engine/internal/server/docs/swagger.json new file mode 100644 index 0000000..8b97110 --- /dev/null +++ b/packages/engine/internal/server/docs/swagger.json @@ -0,0 +1,906 @@ +{ + "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" + }, + "basePath": "/", + "paths": { + "/api/v1/budgets": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "description": "Returns the token budget configuration for all providers configured by the authenticated team.", + "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": [] + } + ], + "description": "Returns token usage aggregated by provider for the current billing period (calendar month UTC).", + "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": [] + } + ], + "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" + ], + "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": { + "$ref": "#/definitions/server.StatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "description": "Removes the budget configuration for a provider. Does not affect in-flight executions.", + "tags": [ + "budgets" + ], + "summary": "Delete provider budget", + "parameters": [ + { + "type": "string", + "description": "Provider name", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.StatusResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/v1/cancel/{execution}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ], + "description": "Sends a cancellation signal to a running execution. The execution may not stop immediately; poll the status endpoint to confirm.", + "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": [] + } + ], + "description": "Returns a paginated list of workflow executions for the authenticated team. Supports filtering by workflow name, status, and age.", + "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": [] + } + ], + "description": "Returns full details of a single execution including all step results.", + "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": [] + } + ], + "description": "Triggers the latest applied version of the named workflow. Returns a 202 Accepted response with the new execution ID.", + "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": [] + } + ], + "description": "Returns all workflow definitions ever applied by the authenticated team.", + "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": [] + } + ], + "description": "Returns the latest applied definition for a workflow.", + "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": [] + } + ], + "description": "Returns all historical versions of a workflow in reverse chronological order.", + "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": [] + } + ], + "description": "Returns a specific historical version of a workflow definition.", + "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": { + "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" + } + } + }, + "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.StatusResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "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..ab24973 --- /dev/null +++ b/packages/engine/internal/server/docs/swagger.yaml @@ -0,0 +1,578 @@ +basePath: / +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 + 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.StatusResponse: + properties: + status: + type: string + 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 +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: + description: Returns the token budget configuration for all providers configured + by the authenticated team. + 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: + description: Removes the budget configuration for a provider. Does not affect + in-flight executions. + parameters: + - description: Provider name + in: path + name: provider + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.StatusResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + security: + - ApiKeyAuth: [] + - OIDCAuth: [] + summary: Delete provider budget + 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 + 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: + $ref: '#/definitions/server.StatusResponse' + "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: + 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 + 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: + 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 + 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: + 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 + 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: + description: Returns full details of a single execution including all step results. + 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: + 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 + 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: + description: Returns all workflow definitions ever applied by the authenticated + team. + 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: + description: Returns the latest applied definition for a workflow. + 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: + description: Returns all historical versions of a workflow in reverse chronological + order. + 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: + description: Returns a specific historical version of a workflow definition. + 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" diff --git a/packages/engine/internal/server/openapi.go b/packages/engine/internal/server/openapi.go new file mode 100644 index 0000000..079e72c --- /dev/null +++ b/packages/engine/internal/server/openapi.go @@ -0,0 +1,16 @@ +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.WriteHeader(http.StatusOK) + 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 aa70d2a..d6767d6 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{ @@ -400,6 +403,18 @@ func (s *Server) waitForExecutions(ctx context.Context) { } // handleRun triggers a workflow execution via the API. +// +// @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 +// @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 +444,17 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { } // handleCancel cancels a running execution via the API. +// +// @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 +// @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") 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) 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 +}