diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index 81faaed..a9ba551 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -61,13 +61,30 @@ jobs: run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: 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. + # Known unfixable vulnerabilities — excluded from enforcement: + # + # Indirect test-only dependency (docker/docker via testcontainers); no upstream fix: + # GO-2026-4887, GO-2026-4883 + # + # Go standard library vulnerabilities fixed in go1.25.9 / go1.26.2 (released 2026-04-07). + # Reachable in go < 1.25.9 via the listed call chains: + # GO-2026-4865 (html/template: XSS via JS template literals) — grpc→net/trace→html/template + # GO-2026-4869 (archive/tar: unbounded memory in sparse GNU tar archives) — docker/docker→archive/tar + # GO-2026-4870 (crypto/tls: TLS 1.3 key-update deadlock) — EmailSendConnector→crypto/tls + # GO-2026-4946 (crypto/x509: policy-validation DoS) — EmailSendConnector→crypto/tls→crypto/x509 + # GO-2026-4947 (crypto/x509: certificate chain-building DoS) — EmailSendConnector→crypto/tls→crypto/x509 + # Remove these five exclusions once go-version in this workflow is >= 1.25.9. OUTPUT=$(govulncheck ./... 2>&1 || true) echo "$OUTPUT" - ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883' || true) + # Emit each actionable vulnerability ID as a GHA error annotation for visibility. + ALL_IDS=$(echo "$OUTPUT" | grep -oE 'GO-[0-9]+-[0-9]+' | sort -u || true) + echo "govulncheck IDs found: ${ALL_IDS:-none}" + ACTIONABLE_IDS=$(echo "$ALL_IDS" | grep -vE '^(GO-2026-4887|GO-2026-4883|GO-2026-4865|GO-2026-4869|GO-2026-4870|GO-2026-4946|GO-2026-4947)$' || true) + for ID in $ACTIONABLE_IDS; do + echo "::error file=go.mod,line=1,title=Unfixed vulnerability::$ID — add to exclusion list or upgrade the affected dependency" + done + ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883|GO-2026-4865|GO-2026-4869|GO-2026-4870|GO-2026-4946|GO-2026-4947' || true) if [ -n "$ACTIONABLE" ]; then - echo "FAIL: unfixed actionable vulnerabilities found" exit 1 fi @@ -92,15 +109,11 @@ jobs: - 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 + - name: Install spec tooling run: | - swag init \ - -g docs.go \ - --dir internal/server,internal/workflow,internal/budget \ - --output internal/server/docs \ - --outputTypes json,yaml \ - --parseInternal + go install github.com/swaggo/swag/cmd/swag@v1.16.6 + python -m pip install yq + - name: Regenerate spec + run: make spec - 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 index 6d90817..be4fa6b 100644 --- a/.github/workflows/release-engine.yml +++ b/.github/workflows/release-engine.yml @@ -29,6 +29,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - name: Set goreleaser tag diff --git a/.gitignore b/.gitignore index afa3e5b..e27a732 100644 --- a/.gitignore +++ b/.gitignore @@ -48,8 +48,8 @@ ai/ # Internal development process (ephemeral implementation plans) docs/superpowers/plans/ -plans/ -marketing/variants/ +/plans/ +/marketing/variants/ # Node / Bun node_modules/ diff --git a/RELEASING.md b/RELEASING.md index 5fcd543..bbc63c8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -28,7 +28,7 @@ The automated CI path (Version PR → merge) requires no local tooling beyond gi 5b. release-helm.yml → helm push OCI + GitHub Release ``` -> **Note on workflow naming:** `release-please.yml` is named after a tool we evaluated but didn't adopt — it actually runs the Changesets CLI. A rename to `changeset-version.yml` is tracked in [the relevant issue]. +> **Note on workflow naming:** `release-please.yml` is named after a tool we evaluated but didn't adopt — it actually runs the Changesets CLI. A rename to `changeset-version.yml` is tracked in [dvflw/mantle#123](https://github.com/dvflw/mantle/issues/123). ## Step 1: Add a Changeset (During Development) @@ -90,7 +90,7 @@ Those tags trigger the package-specific release workflows: **Floating tags** (`major.minor`, `major`, `latest`) are only pushed for stable releases. For pre-release versions (e.g. `0.5.0-rc.1`) the versioned image is pushed but floating tags are skipped. -**Platform-specific image tags:** the pipeline no longer publishes per-arch tags like `ghcr.io/dvflw/mantle:-amd64`. Only the multi-arch manifest tag (`ghcr.io/dvflw/mantle:`) is pushed. Update any CI or deploy configs that reference the old suffixed tags. +**Platform-specific image tags:** per-arch tags like `ghcr.io/dvflw/mantle:-amd64` and `ghcr.io/dvflw/mantle:-arm64` exist in GHCR as internal references required by the multi-arch manifest, but are not intended for direct consumption. Always pull the multi-arch manifest tag (`ghcr.io/dvflw/mantle:`), which selects the correct platform automatically. Update any CI or deploy configs that reference the old suffixed tags directly. **Trivy scan policy:** the `trivy` job runs after `release` with `exit-code: 1` on `CRITICAL` or `HIGH` CVEs. A failure marks the workflow run as failed but does not retract the already-published release. If a CVE scan fails post-release, open a patch release issue immediately. diff --git a/packages/engine/.goreleaser.yaml b/packages/engine/.goreleaser.yaml index 5a7eec0..750273e 100644 --- a/packages/engine/.goreleaser.yaml +++ b/packages/engine/.goreleaser.yaml @@ -48,18 +48,45 @@ changelog: order: 999 dockers_v2: - - ids: [mantle] + - image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + use: buildx + ids: [mantle] + goos: linux + goarch: amd64 dockerfile: Dockerfile.goreleaser - images: - - "ghcr.io/dvflw/mantle" - tags: - - "{{ .Version }}" - # Floating tags (major.minor, major, latest) are only pushed for stable - # releases. Empty template results are skipped by goreleaser, so these - # evaluate to "" and are dropped for any pre-release version. - - "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}{{ end }}" - - "{{ if not .Prerelease }}{{ .Major }}{{ end }}" - - "{{ if not .Prerelease }}latest{{ end }}" + 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" + skip_push: "{{ if or .IsSnapshot .Prerelease }}true{{ end }}" + - name_template: "ghcr.io/dvflw/mantle:{{ .Major }}" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + skip_push: "{{ if or .IsSnapshot .Prerelease }}true{{ end }}" + - name_template: "ghcr.io/dvflw/mantle:latest" + image_templates: + - "ghcr.io/dvflw/mantle:{{ .Version }}-amd64" + - "ghcr.io/dvflw/mantle:{{ .Version }}-arm64" + skip_push: "{{ if or .IsSnapshot .Prerelease }}true{{ end }}" release: github: diff --git a/packages/engine/Makefile b/packages/engine/Makefile index 78dd10b..01d8d91 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 spec +.PHONY: build test lint clean migrate run dev spec spec-patch build: go build -ldflags "$(LDFLAGS)" -o mantle ./cmd/mantle @@ -34,4 +34,20 @@ spec: ## Regenerate OpenAPI spec (requires: go install github.com/swaggo/swag/cm --dir internal/server,internal/workflow,internal/budget \ --output internal/server/docs \ --outputTypes json,yaml \ + --requiredByDefault \ --parseInternal + $(MAKE) spec-patch + +spec-patch: ## Re-apply manual OpenAPI patches lost on swag regeneration + @# swag init does not emit global security declarations, per-operation security + @# overrides, additionalProperties on free-form objects, or RFC 3339/date format + @# annotations on timestamp fields. Also normalises required[] to alphabetical + @# order (swag emits them in struct field order; we keep them sorted for stable + @# diffs). Patch them back after every regeneration run. + @# Requires jq and yq (pip install yq). + jq --indent 4 --ascii-output '.security=[{"ApiKeyAuth":[]},{"OIDCAuth":[]}]|.paths["/healthz"].get.security=[]|.paths["/readyz"].get.security=[]|.definitions["server.WorkflowDetailResponse"].properties.definition.additionalProperties=true|.definitions["server.UsageResponse"].properties.period_start.format="date"|.definitions["server.ExecutionDetail"].properties.started_at.format="date-time"|.definitions["server.ExecutionDetail"].properties.completed_at.format="date-time"|.definitions["server.ExecutionSummary"].properties.started_at.format="date-time"|.definitions["server.ExecutionSummary"].properties.completed_at.format="date-time"|.definitions["server.StepSummary"].properties.started_at.format="date-time"|.definitions["server.StepSummary"].properties.completed_at.format="date-time"|.definitions["workflow.VersionSummary"].properties.created_at.format="date-time"|.definitions["workflow.WorkflowSummary"].properties.created_at.format="date-time"|.definitions["workflow.WorkflowSummary"].properties.updated_at.format="date-time"|.definitions|=with_entries(.value.required|=if . then sort else . end)' \ + internal/server/docs/swagger.json > internal/server/docs/swagger.json.tmp && \ + mv internal/server/docs/swagger.json.tmp internal/server/docs/swagger.json + yq -y '.security=[{"ApiKeyAuth":[]},{"OIDCAuth":[]}]|.paths["/healthz"].get.security=[]|.paths["/readyz"].get.security=[]|.definitions["server.WorkflowDetailResponse"].properties.definition.additionalProperties=true|.definitions["server.UsageResponse"].properties.period_start.format="date"|.definitions["server.ExecutionDetail"].properties.started_at.format="date-time"|.definitions["server.ExecutionDetail"].properties.completed_at.format="date-time"|.definitions["server.ExecutionSummary"].properties.started_at.format="date-time"|.definitions["server.ExecutionSummary"].properties.completed_at.format="date-time"|.definitions["server.StepSummary"].properties.started_at.format="date-time"|.definitions["server.StepSummary"].properties.completed_at.format="date-time"|.definitions["workflow.VersionSummary"].properties.created_at.format="date-time"|.definitions["workflow.WorkflowSummary"].properties.created_at.format="date-time"|.definitions["workflow.WorkflowSummary"].properties.updated_at.format="date-time"|.definitions|=with_entries(.value.required|=if . then sort else . end)' \ + internal/server/docs/swagger.yaml > internal/server/docs/swagger.yaml.tmp && \ + mv internal/server/docs/swagger.yaml.tmp internal/server/docs/swagger.yaml diff --git a/packages/engine/internal/metrics/metrics.go b/packages/engine/internal/metrics/metrics.go index fb3f31a..f9e3873 100644 --- a/packages/engine/internal/metrics/metrics.go +++ b/packages/engine/internal/metrics/metrics.go @@ -131,7 +131,8 @@ var ( // Queue helper functions. -func SetQueueDepth(n int) { QueueDepth.Set(float64(n)) } +// SetQueueDepth sets the Prometheus gauge to the current number of pending steps. +func SetQueueDepth(n int) { QueueDepth.Set(float64(n)) } // RecordClaimDuration records the duration of a queue claim attempt. func RecordClaimDuration(d time.Duration) { ClaimDurationSeconds.Observe(d.Seconds()) } @@ -207,6 +208,7 @@ var ( // Tool-use helper functions. +// RecordToolCall increments the tool call counter for the given step, tool name, and status. func RecordToolCall(step, tool, status string) { ToolCallsTotal.WithLabelValues(step, tool, status).Inc() } diff --git a/packages/engine/internal/server/api.go b/packages/engine/internal/server/api.go index 897090a..7bab4fe 100644 --- a/packages/engine/internal/server/api.go +++ b/packages/engine/internal/server/api.go @@ -48,8 +48,9 @@ type StepSummary struct { // 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. +// @Description Returns a list of workflow executions for the authenticated team, most recent first. Supports filtering by workflow name, status, and age. Use the limit parameter to cap results (default 20). // @Tags executions +// @Produce json // @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)" @@ -174,6 +175,7 @@ func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { // @Summary Get execution detail // @Description Returns full details of a single execution including all step results. // @Tags executions +// @Produce json // @Param id path string true "Execution ID (UUID)" // @Success 200 {object} ExecutionDetail // @Failure 400 {object} ErrorResponse @@ -317,8 +319,9 @@ type WorkflowListResponse struct { // WorkflowDetailResponse is returned for GET /api/v1/workflows/{name}. type WorkflowDetailResponse struct { - Name string `json:"name"` - Version int `json:"version"` + Name string `json:"name"` + Version int `json:"version"` + // Free-form workflow definition as stored (YAML parsed to JSON). Definition json.RawMessage `json:"definition" swaggertype:"object"` } @@ -345,7 +348,7 @@ type UsageResponse struct { // 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" + Enforcement string `json:"enforcement,omitempty" default:"hard"` // "hard" or "warn"; defaults to "hard" when omitted } // StatusResponse is returned for mutations that produce no data payload. @@ -370,6 +373,7 @@ func writeJSONError(w http.ResponseWriter, message string, status int) { // @Summary List workflow definitions // @Description Returns all workflow definitions ever applied by the authenticated team. // @Tags workflows +// @Produce json // @Success 200 {object} WorkflowListResponse // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth @@ -393,6 +397,7 @@ func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) { // @Summary Get latest workflow definition // @Description Returns the latest applied definition for a workflow. // @Tags workflows +// @Produce json // @Param name path string true "Workflow name" // @Success 200 {object} WorkflowDetailResponse // @Failure 404 {object} ErrorResponse @@ -430,6 +435,7 @@ func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) { // @Summary List versions of a workflow // @Description Returns all historical versions of a workflow in reverse chronological order. // @Tags workflows +// @Produce json // @Param name path string true "Workflow name" // @Success 200 {object} WorkflowVersionListResponse // @Failure 400 {object} ErrorResponse @@ -461,6 +467,7 @@ func (s *Server) handleListWorkflowVersions(w http.ResponseWriter, r *http.Reque // @Summary Get a specific workflow version // @Description Returns a specific historical version of a workflow definition. // @Tags workflows +// @Produce json // @Param name path string true "Workflow name" // @Param version path integer true "Version number" // @Success 200 {object} WorkflowDetailResponse @@ -501,6 +508,7 @@ func (s *Server) handleGetWorkflowVersion(w http.ResponseWriter, r *http.Request // @Summary List provider budgets // @Description Returns the token budget configuration for all providers configured by the authenticated team. // @Tags budgets +// @Produce json // @Success 200 {array} budget.TeamBudget // @Failure 500 {object} ErrorResponse // @Security ApiKeyAuth @@ -522,6 +530,8 @@ func (s *Server) handleListBudgets(w http.ResponseWriter, r *http.Request) { // @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 +// @Accept json +// @Produce json // @Param provider path string true "Provider name (e.g. openai, bedrock)" // @Param body body SetBudgetRequest true "Budget configuration" // @Success 200 {object} StatusResponse @@ -579,6 +589,7 @@ func (s *Server) handleSetBudget(w http.ResponseWriter, r *http.Request) { // @Summary Delete provider budget // @Description Removes the budget configuration for a provider. Does not affect in-flight executions. // @Tags budgets +// @Produce json // @Param provider path string true "Provider name" // @Success 200 {object} StatusResponse // @Failure 500 {object} ErrorResponse @@ -619,6 +630,7 @@ func (s *Server) handleDeleteBudget(w http.ResponseWriter, r *http.Request) { // @Summary Get token usage // @Description Returns token usage aggregated by provider for the current billing period (calendar month UTC). // @Tags budgets +// @Produce json // @Param provider query string false "Provider name; omit for total across all providers" // @Success 200 {object} UsageResponse // @Failure 500 {object} ErrorResponse diff --git a/packages/engine/internal/server/docs.go b/packages/engine/internal/server/docs.go index 083b3ae..6735f2c 100644 --- a/packages/engine/internal/server/docs.go +++ b/packages/engine/internal/server/docs.go @@ -37,6 +37,7 @@ type ReadyzResponse struct { // // @Summary Liveness probe // @Tags system +// @Produce json // @Success 200 {object} HealthResponse // @Router /healthz [get] func healthzDoc() {} //nolint:unused,deadcode @@ -47,6 +48,7 @@ func healthzDoc() {} //nolint:unused,deadcode // // @Summary Readiness probe // @Tags system +// @Produce json // @Success 200 {object} ReadyzResponse // @Failure 503 {object} ReadyzResponse // @Router /readyz [get] diff --git a/packages/engine/internal/server/docs/swagger.json b/packages/engine/internal/server/docs/swagger.json index 8b97110..20fe5af 100644 --- a/packages/engine/internal/server/docs/swagger.json +++ b/packages/engine/internal/server/docs/swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "Headless AI workflow automation — BYOK, IaC-first, enterprise-grade.", + "description": "Headless AI workflow automation \u2014 BYOK, IaC-first, enterprise-grade.", "title": "Mantle API", "contact": { "name": "Mantle", @@ -26,6 +26,9 @@ } ], "description": "Returns the token budget configuration for all providers configured by the authenticated team.", + "produces": [ + "application/json" + ], "tags": [ "budgets" ], @@ -60,6 +63,9 @@ } ], "description": "Returns token usage aggregated by provider for the current billing period (calendar month UTC).", + "produces": [ + "application/json" + ], "tags": [ "budgets" ], @@ -99,6 +105,12 @@ } ], "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ "budgets" ], @@ -152,6 +164,9 @@ } ], "description": "Removes the budget configuration for a provider. Does not affect in-flight executions.", + "produces": [ + "application/json" + ], "tags": [ "budgets" ], @@ -192,6 +207,9 @@ } ], "description": "Sends a cancellation signal to a running execution. The execution may not stop immediately; poll the status endpoint to confirm.", + "produces": [ + "application/json" + ], "tags": [ "executions" ], @@ -237,7 +255,10 @@ "OIDCAuth": [] } ], - "description": "Returns a paginated list of workflow executions for the authenticated team. Supports filtering by workflow name, status, and age.", + "description": "Returns a list of workflow executions for the authenticated team, most recent first. Supports filtering by workflow name, status, and age. Use the limit parameter to cap results (default 20).", + "produces": [ + "application/json" + ], "tags": [ "executions" ], @@ -308,6 +329,9 @@ } ], "description": "Returns full details of a single execution including all step results.", + "produces": [ + "application/json" + ], "tags": [ "executions" ], @@ -360,6 +384,9 @@ } ], "description": "Triggers the latest applied version of the named workflow. Returns a 202 Accepted response with the new execution ID.", + "produces": [ + "application/json" + ], "tags": [ "executions" ], @@ -412,6 +439,9 @@ } ], "description": "Returns all workflow definitions ever applied by the authenticated team.", + "produces": [ + "application/json" + ], "tags": [ "workflows" ], @@ -443,6 +473,9 @@ } ], "description": "Returns the latest applied definition for a workflow.", + "produces": [ + "application/json" + ], "tags": [ "workflows" ], @@ -489,6 +522,9 @@ } ], "description": "Returns all historical versions of a workflow in reverse chronological order.", + "produces": [ + "application/json" + ], "tags": [ "workflows" ], @@ -535,6 +571,9 @@ } ], "description": "Returns a specific historical version of a workflow definition.", + "produces": [ + "application/json" + ], "tags": [ "workflows" ], @@ -579,6 +618,9 @@ }, "/healthz": { "get": { + "produces": [ + "application/json" + ], "tags": [ "system" ], @@ -590,11 +632,15 @@ "$ref": "#/definitions/server.HealthResponse" } } - } + }, + "security": [] } }, "/readyz": { "get": { + "produces": [ + "application/json" + ], "tags": [ "system" ], @@ -612,13 +658,21 @@ "$ref": "#/definitions/server.ReadyzResponse" } } - } + }, + "security": [] } } }, "definitions": { "budget.TeamBudget": { "type": "object", + "required": [ + "enforcement", + "id", + "monthly_token_limit", + "provider", + "team_id" + ], "properties": { "enforcement": { "description": "\"hard\" blocks execution when exceeded; \"warn\" logs only", @@ -642,6 +696,10 @@ }, "server.CancelResponse": { "type": "object", + "required": [ + "execution_id", + "status" + ], "properties": { "execution_id": { "type": "string" @@ -653,6 +711,9 @@ }, "server.ErrorResponse": { "type": "object", + "required": [ + "error" + ], "properties": { "error": { "type": "string" @@ -661,15 +722,24 @@ }, "server.ExecutionDetail": { "type": "object", + "required": [ + "id", + "status", + "steps", + "version", + "workflow" + ], "properties": { "completed_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "string" }, "started_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "status": { "type": "string" @@ -690,6 +760,9 @@ }, "server.ExecutionListResponse": { "type": "object", + "required": [ + "executions" + ], "properties": { "executions": { "type": "array", @@ -701,15 +774,23 @@ }, "server.ExecutionSummary": { "type": "object", + "required": [ + "id", + "status", + "version", + "workflow" + ], "properties": { "completed_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "string" }, "started_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "status": { "type": "string" @@ -724,6 +805,9 @@ }, "server.HealthResponse": { "type": "object", + "required": [ + "status" + ], "properties": { "status": { "type": "string" @@ -732,6 +816,9 @@ }, "server.ReadyzResponse": { "type": "object", + "required": [ + "status" + ], "properties": { "details": { "type": "object", @@ -746,6 +833,11 @@ }, "server.RunResponse": { "type": "object", + "required": [ + "execution_id", + "version", + "workflow" + ], "properties": { "execution_id": { "type": "string" @@ -760,10 +852,14 @@ }, "server.SetBudgetRequest": { "type": "object", + "required": [ + "monthly_token_limit" + ], "properties": { "enforcement": { - "description": "\"hard\" or \"warn\"", - "type": "string" + "description": "\"hard\" or \"warn\"; defaults to \"hard\" when omitted", + "type": "string", + "default": "hard" }, "monthly_token_limit": { "type": "integer" @@ -772,6 +868,9 @@ }, "server.StatusResponse": { "type": "object", + "required": [ + "status" + ], "properties": { "status": { "type": "string" @@ -780,9 +879,14 @@ }, "server.StepSummary": { "type": "object", + "required": [ + "name", + "status" + ], "properties": { "completed_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "error": { "type": "string" @@ -791,7 +895,8 @@ "type": "string" }, "started_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "status": { "type": "string" @@ -800,12 +905,20 @@ }, "server.UsageResponse": { "type": "object", + "required": [ + "completion_tokens", + "period_start", + "prompt_tokens", + "provider", + "total_tokens" + ], "properties": { "completion_tokens": { "type": "integer" }, "period_start": { - "type": "string" + "type": "string", + "format": "date" }, "prompt_tokens": { "type": "integer" @@ -820,9 +933,16 @@ }, "server.WorkflowDetailResponse": { "type": "object", + "required": [ + "definition", + "name", + "version" + ], "properties": { "definition": { - "type": "object" + "description": "Free-form workflow definition as stored (YAML parsed to JSON).", + "type": "object", + "additionalProperties": true }, "name": { "type": "string" @@ -834,6 +954,9 @@ }, "server.WorkflowListResponse": { "type": "object", + "required": [ + "workflows" + ], "properties": { "workflows": { "type": "array", @@ -845,6 +968,10 @@ }, "server.WorkflowVersionListResponse": { "type": "object", + "required": [ + "name", + "versions" + ], "properties": { "name": { "type": "string" @@ -859,12 +986,18 @@ }, "workflow.VersionSummary": { "type": "object", + "required": [ + "content_hash", + "created_at", + "version" + ], "properties": { "content_hash": { "type": "string" }, "created_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "version": { "type": "integer" @@ -873,9 +1006,16 @@ }, "workflow.WorkflowSummary": { "type": "object", + "required": [ + "created_at", + "latest_version", + "name", + "updated_at" + ], "properties": { "created_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "latest_version": { "type": "integer" @@ -884,7 +1024,8 @@ "type": "string" }, "updated_at": { - "type": "string" + "type": "string", + "format": "date-time" } } } @@ -897,10 +1038,18 @@ "in": "header" }, "OIDCAuth": { - "description": "Bearer OIDC JWT. Format: \"Bearer \u003cjwt\u003e\"", + "description": "Bearer OIDC JWT. Format: \"Bearer \"", "type": "apiKey", "name": "Authorization", "in": "header" } - } -} \ No newline at end of file + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OIDCAuth": [] + } + ] +} diff --git a/packages/engine/internal/server/docs/swagger.yaml b/packages/engine/internal/server/docs/swagger.yaml index ab24973..38225eb 100644 --- a/packages/engine/internal/server/docs/swagger.yaml +++ b/packages/engine/internal/server/docs/swagger.yaml @@ -15,6 +15,12 @@ definitions: type: string team_id: type: string + required: + - enforcement + - id + - monthly_token_limit + - provider + - team_id type: object server.CancelResponse: properties: @@ -22,20 +28,27 @@ definitions: type: string status: type: string + required: + - execution_id + - status type: object server.ErrorResponse: properties: error: type: string + required: + - error type: object server.ExecutionDetail: properties: completed_at: type: string + format: date-time id: type: string started_at: type: string + format: date-time status: type: string steps: @@ -46,6 +59,12 @@ definitions: type: integer workflow: type: string + required: + - id + - status + - steps + - version + - workflow type: object server.ExecutionListResponse: properties: @@ -53,26 +72,37 @@ definitions: items: $ref: '#/definitions/server.ExecutionSummary' type: array + required: + - executions type: object server.ExecutionSummary: properties: completed_at: type: string + format: date-time id: type: string started_at: type: string + format: date-time status: type: string version: type: integer workflow: type: string + required: + - id + - status + - version + - workflow type: object server.HealthResponse: properties: status: type: string + required: + - status type: object server.ReadyzResponse: properties: @@ -82,6 +112,8 @@ definitions: type: object status: type: string + required: + - status type: object server.RunResponse: properties: @@ -91,32 +123,46 @@ definitions: type: integer workflow: type: string + required: + - execution_id + - version + - workflow type: object server.SetBudgetRequest: properties: enforcement: - description: '"hard" or "warn"' + default: hard + description: '"hard" or "warn"; defaults to "hard" when omitted' type: string monthly_token_limit: type: integer + required: + - monthly_token_limit type: object server.StatusResponse: properties: status: type: string + required: + - status type: object server.StepSummary: properties: completed_at: type: string + format: date-time error: type: string name: type: string started_at: type: string + format: date-time status: type: string + required: + - name + - status type: object server.UsageResponse: properties: @@ -124,21 +170,34 @@ definitions: type: integer period_start: type: string + format: date prompt_tokens: type: integer provider: type: string total_tokens: type: integer + required: + - completion_tokens + - period_start + - prompt_tokens + - provider + - total_tokens type: object server.WorkflowDetailResponse: properties: definition: + description: Free-form workflow definition as stored (YAML parsed to JSON). type: object + additionalProperties: true name: type: string version: type: integer + required: + - definition + - name + - version type: object server.WorkflowListResponse: properties: @@ -146,6 +205,8 @@ definitions: items: $ref: '#/definitions/workflow.WorkflowSummary' type: array + required: + - workflows type: object server.WorkflowVersionListResponse: properties: @@ -155,6 +216,9 @@ definitions: items: $ref: '#/definitions/workflow.VersionSummary' type: array + required: + - name + - versions type: object workflow.VersionSummary: properties: @@ -162,19 +226,31 @@ definitions: type: string created_at: type: string + format: date-time version: type: integer + required: + - content_hash + - created_at + - version type: object workflow.WorkflowSummary: properties: created_at: type: string + format: date-time latest_version: type: integer name: type: string updated_at: type: string + format: date-time + required: + - created_at + - latest_version + - name + - updated_at type: object info: contact: @@ -185,385 +261,418 @@ info: name: BSL 1.1 url: https://github.com/dvflw/mantle/blob/main/LICENSE title: Mantle API - version: "1.0" + version: '1.0' paths: /api/v1/budgets: get: description: Returns the token budget configuration for all providers configured by the authenticated team. + produces: + - application/json responses: - "200": + '200': description: OK schema: items: $ref: '#/definitions/budget.TeamBudget' type: array - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: List provider budgets tags: - - budgets + - 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 + - description: Provider name + in: path + name: provider + required: true + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.StatusResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Delete provider budget tags: - - budgets + - budgets put: + consumes: + - application/json 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' + - 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' + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.StatusResponse' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Set provider budget tags: - - budgets + - 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 + - description: Provider name; omit for total across all providers + in: query + name: provider + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.UsageResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Get token usage tags: - - budgets + - 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 + - description: Execution ID (UUID) + in: path + name: execution + required: true + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.CancelResponse' - "404": + '404': description: Not Found schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Cancel a running execution tags: - - executions + - 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. + description: Returns a list of workflow executions for the authenticated team, + most recent first. Supports filtering by workflow name, status, and age. Use + the limit parameter to cap results (default 20). 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 + - 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 + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.ExecutionListResponse' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: List executions tags: - - executions + - 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 + - description: Execution ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.ExecutionDetail' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "404": + '404': description: Not Found schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Get execution detail tags: - - executions + - 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 + - description: Workflow name + in: path + name: workflow + required: true + type: string + produces: + - application/json responses: - "202": + '202': description: Accepted schema: $ref: '#/definitions/server.RunResponse' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "404": + '404': description: Not Found schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Trigger a workflow execution tags: - - executions + - executions /api/v1/workflows: get: description: Returns all workflow definitions ever applied by the authenticated team. + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.WorkflowListResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: List workflow definitions tags: - - workflows + - 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 + - description: Workflow name + in: path + name: name + required: true + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.WorkflowDetailResponse' - "404": + '404': description: Not Found schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Get latest workflow definition tags: - - workflows + - 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 + - description: Workflow name + in: path + name: name + required: true + type: string + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.WorkflowVersionListResponse' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "500": + '500': description: Internal Server Error schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: List versions of a workflow tags: - - workflows + - 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 + - description: Workflow name + in: path + name: name + required: true + type: string + - description: Version number + in: path + name: version + required: true + type: integer + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.WorkflowDetailResponse' - "400": + '400': description: Bad Request schema: $ref: '#/definitions/server.ErrorResponse' - "404": + '404': description: Not Found schema: $ref: '#/definitions/server.ErrorResponse' security: - - ApiKeyAuth: [] - - OIDCAuth: [] + - ApiKeyAuth: [] + - OIDCAuth: [] summary: Get a specific workflow version tags: - - workflows + - workflows /healthz: get: + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.HealthResponse' summary: Liveness probe tags: - - system + - system + security: [] /readyz: get: + produces: + - application/json responses: - "200": + '200': description: OK schema: $ref: '#/definitions/server.ReadyzResponse' - "503": + '503': description: Service Unavailable schema: $ref: '#/definitions/server.ReadyzResponse' summary: Readiness probe tags: - - system + - system + security: [] securityDefinitions: ApiKeyAuth: description: 'Bearer API key. Format: "Bearer mk_..."' @@ -575,4 +684,7 @@ securityDefinitions: in: header name: Authorization type: apiKey -swagger: "2.0" +swagger: '2.0' +security: + - ApiKeyAuth: [] + - OIDCAuth: [] diff --git a/packages/engine/internal/server/openapi_test.go b/packages/engine/internal/server/openapi_test.go index b0cc2a6..7e879ee 100644 --- a/packages/engine/internal/server/openapi_test.go +++ b/packages/engine/internal/server/openapi_test.go @@ -22,9 +22,10 @@ func TestHandleOpenAPISpec(t *testing.T) { 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)") + // Spec is Swagger 2.0 (generated by swaggo/swag); verify exact version. + swaggerVer, ok := doc["swagger"].(string) + if !ok || swaggerVer != "2.0" { + t.Errorf("expected swagger version %q, got: %v", "2.0", doc["swagger"]) } if doc["info"] == nil { t.Error("response missing 'info' field") diff --git a/packages/engine/internal/server/server.go b/packages/engine/internal/server/server.go index d6767d6..a87d666 100644 --- a/packages/engine/internal/server/server.go +++ b/packages/engine/internal/server/server.go @@ -407,6 +407,7 @@ func (s *Server) waitForExecutions(ctx context.Context) { // @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 +// @Produce json // @Param workflow path string true "Workflow name" // @Success 202 {object} RunResponse // @Failure 400 {object} ErrorResponse @@ -448,6 +449,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { // @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 +// @Produce json // @Param execution path string true "Execution ID (UUID)" // @Success 200 {object} CancelResponse // @Failure 404 {object} ErrorResponse