From e8dd0eb057ebdbc2fb7b6cee836a229da5ea0c97 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:08:17 -0700 Subject: [PATCH 1/9] Add workflow intent types --- workflow_types.go | 142 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 workflow_types.go diff --git a/workflow_types.go b/workflow_types.go new file mode 100644 index 0000000..5758d4d --- /dev/null +++ b/workflow_types.go @@ -0,0 +1,142 @@ +package keel + +import "time" + +// WorkflowDecision is the declaration decision returned by the workflow API. +type WorkflowDecision string + +const ( + WorkflowDecisionAccepted WorkflowDecision = "accepted" + WorkflowDecisionRejected WorkflowDecision = "rejected" +) + +// WorkflowStatus is the lifecycle status of a workflow declaration. +type WorkflowStatus string + +const ( + WorkflowStatusActive WorkflowStatus = "active" + WorkflowStatusCompleted WorkflowStatus = "completed" + WorkflowStatusExpired WorkflowStatus = "expired" + WorkflowStatusRejected WorkflowStatus = "rejected" +) + +// WorkflowIntent describes the caller-declared expected workflow shape. +type WorkflowIntent struct { + ExpectedCalls *int `json:"expected_calls,omitempty"` + MaxCalls *int `json:"max_calls,omitempty"` + ExpectedModel *string `json:"expected_model,omitempty"` + ExpectedInputTokensPerCall *int `json:"expected_input_tokens_per_call,omitempty"` + ExpectedOutputTokensPerCall *int `json:"expected_output_tokens_per_call,omitempty"` + MaxDurationSeconds *int `json:"max_duration_seconds,omitempty"` +} + +// WorkflowDeclareRequest is the request body for declaring a workflow. +type WorkflowDeclareRequest struct { + WorkflowID string `json:"workflow_id"` + Intent WorkflowIntent `json:"intent"` + BudgetEnvelopeID *string `json:"budget_envelope_id,omitempty"` +} + +// ProjectedCostMethodology records how projected workflow cost was computed. +type ProjectedCostMethodology struct { + Basis string `json:"basis,omitempty"` + Provenance string `json:"provenance,omitempty"` + ExpectedCalls *int `json:"expected_calls,omitempty"` + InputTokensPerCallEstimated *int `json:"input_tokens_per_call_estimated,omitempty"` + OutputTokensPerCallEstimated *int `json:"output_tokens_per_call_estimated,omitempty"` + PricingTableID *string `json:"pricing_table_id,omitempty"` + Tokenizer *string `json:"tokenizer,omitempty"` + Quality *string `json:"quality,omitempty"` +} + +// ProjectedCost is the projected total workflow cost. +type ProjectedCost struct { + AmountMicros *int64 `json:"amount_micros,omitempty"` + Currency string `json:"currency,omitempty"` + Methodology *ProjectedCostMethodology `json:"methodology,omitempty"` +} + +// WorkflowPrincipal identifies who declared a workflow. +type WorkflowPrincipal struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// WorkflowDeclaredVia identifies the SDK metadata claimed by the declarer. +type WorkflowDeclaredVia struct { + SDK *string `json:"sdk,omitempty"` + SDKVersion *string `json:"sdk_version,omitempty"` +} + +// WorkflowAmendRequest is the request body for amending a workflow declaration. +type WorkflowAmendRequest struct { + IfMatchVersion int `json:"if_match_version"` + NewMaxCalls *int `json:"new_max_calls,omitempty"` + NewExpectedCalls *int `json:"new_expected_calls,omitempty"` + ReasonProvided *string `json:"reason_provided,omitempty"` +} + +// WorkflowAmendmentResponse is a signed workflow amendment record. +type WorkflowAmendmentResponse struct { + ID string `json:"id"` + WorkflowDeclarationID string `json:"workflow_declaration_id"` + AppliedAgainstVersion int `json:"applied_against_version"` + PreviousMaxCalls *int `json:"previous_max_calls,omitempty"` + NewMaxCalls *int `json:"new_max_calls,omitempty"` + PreviousExpectedCalls *int `json:"previous_expected_calls,omitempty"` + NewExpectedCalls *int `json:"new_expected_calls,omitempty"` + ReasonProvided *string `json:"reason_provided,omitempty"` + AmendmentCanonicalHash *string `json:"amendment_canonical_hash,omitempty"` + AmendmentSignatureB64 *string `json:"amendment_signature_b64,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// WorkflowDeclarationResponse is returned by declare, amend, get, and list APIs. +type WorkflowDeclarationResponse struct { + WorkflowID string `json:"workflow_id"` + Decision WorkflowDecision `json:"decision"` + Status *WorkflowStatus `json:"status,omitempty"` + ID *string `json:"id,omitempty"` + Version *int `json:"version,omitempty"` + ExpectedCalls *int `json:"expected_calls,omitempty"` + MaxCalls *int `json:"max_calls,omitempty"` + CachedActualCalls *int `json:"cached_actual_calls,omitempty"` + ProjectedCost *ProjectedCost `json:"projected_cost,omitempty"` + ReasonCode *string `json:"reason_code,omitempty"` + DecisionDetails map[string]any `json:"decision_details,omitempty"` + DeclaredBy *WorkflowPrincipal `json:"declared_by,omitempty"` + DeclaredVia *WorkflowDeclaredVia `json:"declared_via,omitempty"` + DeclarationCanonicalHash *string `json:"declaration_canonical_hash,omitempty"` + DeclarationSignatureB64 *string `json:"declaration_signature_b64,omitempty"` + DeclaredAt *time.Time `json:"declared_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Amendments []WorkflowAmendmentResponse `json:"amendments,omitempty"` +} + +// WorkflowCompleteResponse is returned after explicitly completing a workflow. +type WorkflowCompleteResponse struct { + WorkflowID string `json:"workflow_id"` + Status WorkflowStatus `json:"status"` + CachedActualCalls int `json:"cached_actual_calls"` + AuthoritativeActualCalls int `json:"authoritative_actual_calls"` + CounterReconciled bool `json:"counter_reconciled"` + CompletedAt time.Time `json:"completed_at"` +} + +// WorkflowListParams holds query parameters for listing workflow declarations. +type WorkflowListParams struct { + Status *string `json:"status,omitempty"` + CreatedAtFrom *time.Time `json:"created_at_from,omitempty"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` +} + +// WorkflowsListResponse is the paginated list of workflow declarations. +type WorkflowsListResponse struct { + Items []WorkflowDeclarationResponse `json:"items"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` +} From 64ed5d52d9af03e08dd70aa3019c1a7512e4e656 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:09:16 -0700 Subject: [PATCH 2/9] Add workflows client --- keel.go | 4 ++ workflows.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 workflows.go diff --git a/keel.go b/keel.go index e768441..1a4fbc2 100644 --- a/keel.go +++ b/keel.go @@ -35,6 +35,9 @@ type Client struct { // Requests provides request timeline inspection. Requests *RequestsClient + + // Workflows manages caller-declared workflow intent. + Workflows *WorkflowsClient } // NewClient creates a new Keel client with the given configuration. @@ -92,6 +95,7 @@ func NewClient(config ClientConfig) *Client { c.Jobs = &JobsClient{t: t} c.ApiKeys = &ApiKeysClient{t: t} c.Requests = &RequestsClient{t: t} + c.Workflows = &WorkflowsClient{t: t} return c } diff --git a/workflows.go b/workflows.go new file mode 100644 index 0000000..36de40e --- /dev/null +++ b/workflows.go @@ -0,0 +1,105 @@ +package keel + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "time" +) + +// WorkflowsClient provides access to the workflow intent API. +type WorkflowsClient struct { + t *httpTransport +} + +// Declare declares a workflow intent before the workflow runs. +func (c *WorkflowsClient) Declare(ctx context.Context, req WorkflowDeclareRequest) (*WorkflowDeclarationResponse, error) { + body, err := c.t.post(ctx, "/v1/workflows", req, nil) + if err != nil { + return nil, err + } + var resp WorkflowDeclarationResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("keel: decode response: %w", err) + } + return &resp, nil +} + +// Amend appends an amendment to a workflow declaration. +func (c *WorkflowsClient) Amend(ctx context.Context, workflowID string, req WorkflowAmendRequest) (*WorkflowDeclarationResponse, error) { + path := "/v1/workflows/" + url.PathEscape(workflowID) + "/amend" + body, err := c.t.post(ctx, path, req, nil) + if err != nil { + return nil, err + } + var resp WorkflowDeclarationResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("keel: decode response: %w", err) + } + return &resp, nil +} + +// Complete marks a workflow as complete and reconciles its final call count. +func (c *WorkflowsClient) Complete(ctx context.Context, workflowID string) (*WorkflowCompleteResponse, error) { + path := "/v1/workflows/" + url.PathEscape(workflowID) + "/complete" + body, err := c.t.post(ctx, path, struct{}{}, nil) + if err != nil { + return nil, err + } + var resp WorkflowCompleteResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("keel: decode response: %w", err) + } + return &resp, nil +} + +// Get retrieves a workflow declaration by caller-supplied workflow ID. +func (c *WorkflowsClient) Get(ctx context.Context, workflowID string) (*WorkflowDeclarationResponse, error) { + path := "/v1/workflows/" + url.PathEscape(workflowID) + body, err := c.t.get(ctx, path, nil) + if err != nil { + return nil, err + } + var resp WorkflowDeclarationResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("keel: decode response: %w", err) + } + return &resp, nil +} + +// List returns workflow declarations using offset pagination. +func (c *WorkflowsClient) List(ctx context.Context, params WorkflowListParams) (*WorkflowsListResponse, error) { + q := url.Values{} + if params.Status != nil { + q.Set("status", *params.Status) + } + if params.CreatedAtFrom != nil { + q.Set("created_at_from", params.CreatedAtFrom.Format(time.RFC3339)) + } + if params.CreatedAtTo != nil { + q.Set("created_at_to", params.CreatedAtTo.Format(time.RFC3339)) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(*params.Limit)) + } + if params.Offset != nil { + q.Set("offset", strconv.Itoa(*params.Offset)) + } + + path := "/v1/workflows" + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + + body, err := c.t.get(ctx, path, nil) + if err != nil { + return nil, err + } + var resp WorkflowsListResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("keel: decode response: %w", err) + } + return &resp, nil +} From 9211681961f7c69e20bf7011cacdf5725b0be360 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:09:47 -0700 Subject: [PATCH 3/9] Add workflow context helpers --- workflows.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/workflows.go b/workflows.go index 36de40e..42d500b 100644 --- a/workflows.go +++ b/workflows.go @@ -6,14 +6,49 @@ import ( "fmt" "net/url" "strconv" + "strings" "time" ) +type workflowIDContextKey struct{} + // WorkflowsClient provides access to the workflow intent API. type WorkflowsClient struct { t *httpTransport } +// WithWorkflow returns a child context carrying the workflow ID. +func WithWorkflow(ctx context.Context, workflowID string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, workflowIDContextKey{}, strings.TrimSpace(workflowID)) +} + +// WorkflowFromContext returns the workflow ID carried by ctx, if present. +func WorkflowFromContext(ctx context.Context) (string, bool) { + if ctx == nil { + return "", false + } + workflowID, ok := ctx.Value(workflowIDContextKey{}).(string) + if !ok { + return "", false + } + workflowID = strings.TrimSpace(workflowID) + if workflowID == "" { + return "", false + } + return workflowID, true +} + +// RunInWorkflow invokes fn with a child context carrying the workflow ID. +func RunInWorkflow(ctx context.Context, workflowID string, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("keel: workflow function is nil") + } + return fn(WithWorkflow(ctx, workflowID)) +} + // Declare declares a workflow intent before the workflow runs. func (c *WorkflowsClient) Declare(ctx context.Context, req WorkflowDeclareRequest) (*WorkflowDeclarationResponse, error) { body, err := c.t.post(ctx, "/v1/workflows", req, nil) From 8a12ec010e1bba87580ee86242125daf4eef0309 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:11:17 -0700 Subject: [PATCH 4/9] Inject workflow header from context --- http.go | 4 ++++ workflows.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/http.go b/http.go index 1f260bd..4d0c04f 100644 --- a/http.go +++ b/http.go @@ -139,6 +139,10 @@ func (t *httpTransport) newRequest(ctx context.Context, method, path string, bod req.Header.Set("Authorization", "Bearer "+t.apiKey) req.Header.Set("Content-Type", "application/json") + if workflowID, ok := WorkflowFromContext(ctx); ok { + req.Header.Set(workflowIDHeader, workflowID) + } + if t.freshness { req.Header.Set("X-Keel-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) nonce := make([]byte, 16) diff --git a/workflows.go b/workflows.go index 42d500b..24608b5 100644 --- a/workflows.go +++ b/workflows.go @@ -12,6 +12,8 @@ import ( type workflowIDContextKey struct{} +const workflowIDHeader = "X-Keel-Workflow-Id" + // WorkflowsClient provides access to the workflow intent API. type WorkflowsClient struct { t *httpTransport From 0b4d9203bc6b409054d5c1378298d6a33b424e96 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:12:32 -0700 Subject: [PATCH 5/9] Add workflow reason errors --- errors.go | 65 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/errors.go b/errors.go index bea8841..f9edcfe 100644 --- a/errors.go +++ b/errors.go @@ -7,19 +7,52 @@ import ( // Reason code constants for permit denials and throttles (Shape D). const ( - ReasonBudgetRequestCapExceeded = "budget.request_cap_exceeded" - ReasonBudgetDailyCapExceeded = "budget.daily_cap_exceeded" - ReasonBudgetMonthlyCapExceeded = "budget.monthly_cap_exceeded" - ReasonBudgetMonthlyThresholdExceeded = "budget.monthly_threshold_exceeded" - ReasonBudgetDailySpikeDetected = "budget.daily_spike_detected" - ReasonBudgetRateLimitExceeded = "budget.rate_limit_exceeded" - ReasonBudgetRateLimitThrottled = "budget.rate_limit_throttled" - ReasonBudgetPricingUnavailable = "budget.pricing_unavailable" - ReasonPolicyModelNotAllowed = "policy.model_not_allowed" - ReasonPolicyRuleDenied = "policy.rule_denied" - ReasonPolicyReviewRequired = "policy.review_required" + ReasonBudgetRequestCapExceeded = "budget.request_cap_exceeded" + ReasonBudgetDailyCapExceeded = "budget.daily_cap_exceeded" + ReasonBudgetMonthlyCapExceeded = "budget.monthly_cap_exceeded" + ReasonBudgetMonthlyThresholdExceeded = "budget.monthly_threshold_exceeded" + ReasonBudgetDailySpikeDetected = "budget.daily_spike_detected" + ReasonBudgetRateLimitExceeded = "budget.rate_limit_exceeded" + ReasonBudgetRateLimitThrottled = "budget.rate_limit_throttled" + ReasonBudgetPricingUnavailable = "budget.pricing_unavailable" + ReasonPolicyModelNotAllowed = "policy.model_not_allowed" + ReasonPolicyRuleDenied = "policy.rule_denied" + ReasonPolicyReviewRequired = "policy.review_required" + ReasonWorkflowDeclarationExceedsBudgetCap = "workflow_intent.declaration_exceeds_budget_cap" + ReasonWorkflowMaxCallsExceeded = "workflow_intent.max_calls_exceeded" + ReasonWorkflowExpectedCallsExceeded = "workflow_intent.expected_calls_exceeded" + ReasonWorkflowUnknownOrInactive = "workflow_intent.unknown_or_inactive" + ReasonWorkflowIdempotencyConflict = "workflow_intent.idempotency_conflict" + ReasonWorkflowAmendmentVersionConflict = "workflow_intent.amendment_version_conflict" ) +type reasonCodeError string + +func (e reasonCodeError) Error() string { + return string(e) +} + +func (e reasonCodeError) reasonCode() string { + return string(e) +} + +type reasonCodeMatcher interface { + reasonCode() string +} + +var ( + ErrWorkflowMaxCallsExceeded = reasonCodeError(ReasonWorkflowMaxCallsExceeded) + ErrWorkflowUnknownOrInactive = reasonCodeError(ReasonWorkflowUnknownOrInactive) + ErrWorkflowDeclarationExceedsBudgetCap = reasonCodeError(ReasonWorkflowDeclarationExceedsBudgetCap) + ErrWorkflowIdempotencyConflict = reasonCodeError(ReasonWorkflowIdempotencyConflict) + ErrWorkflowAmendmentVersionConflict = reasonCodeError(ReasonWorkflowAmendmentVersionConflict) +) + +func matchesReasonCode(reasonCode string, target error) bool { + matcher, ok := target.(reasonCodeMatcher) + return ok && reasonCode != "" && reasonCode == matcher.reasonCode() +} + // KeelError represents an error response from the Keel API. type KeelError struct { Status int `json:"status"` @@ -37,6 +70,11 @@ func (e *KeelError) Error() string { return fmt.Sprintf("keel: %d %s: %s", e.Status, e.Code, e.Message) } +// Is allows workflow reason-code sentinels to match Keel API errors. +func (e *KeelError) Is(target error) bool { + return matchesReasonCode(e.Code, target) +} + // IsRetryable returns true if the error status code indicates the request can be retried. func (e *KeelError) IsRetryable() bool { switch e.Status { @@ -65,6 +103,11 @@ func (e *ThrottledError) Error() string { return fmt.Sprintf("keel: 429 throttled: %s (retry after %ds)", msg, e.RetryAfterSeconds) } +// Is allows workflow reason-code sentinels to match throttled API errors. +func (e *ThrottledError) Is(target error) bool { + return matchesReasonCode(e.ReasonCode, target) +} + // IsRetryable returns true. A throttled error is always retryable after the indicated delay. func (e *ThrottledError) IsRetryable() bool { return true From a455c820b3581f91677d5e54461a5e717f949e22 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:15:32 -0700 Subject: [PATCH 6/9] Document workflow usage --- README.md | 39 ++++++++++++++ examples/workflows/main.go | 102 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 examples/workflows/main.go diff --git a/README.md b/README.md index bd07c4b..17f99a2 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,44 @@ client.Permits.Lineage(ctx, id) // Get lineage client.Permits.Bundle(ctx, id) // Full audit bundle ``` +### Workflows + +Declare workflow intent before a multi-call run, then carry the workflow ID through `context.Context`. The SDK automatically injects `X-Keel-Workflow-Id` from the context on outbound Keel API requests. + +```go +expectedCalls := 10000 +maxCalls := 12000 + +workflow, err := client.Workflows.Declare(ctx, keel.WorkflowDeclareRequest{ + WorkflowID: "invoice-batch-2027-01-05", + Intent: keel.WorkflowIntent{ + ExpectedCalls: &expectedCalls, + MaxCalls: &maxCalls, + }, +}) +if err != nil { + log.Fatal(err) +} + +workflowCtx := keel.WithWorkflow(ctx, workflow.WorkflowID) +_, err = client.Permits.Create(workflowCtx, permitReq) +if err != nil { + log.Fatal(err) +} +``` + +`RunInWorkflow` is a convenience wrapper when you want to scope several calls to the same workflow context. + +Workflow APIs mirror the rest of the SDK: + +```go +client.Workflows.Declare(ctx, req) +client.Workflows.Amend(ctx, workflowID, req) +client.Workflows.Complete(ctx, workflowID) +client.Workflows.Get(ctx, workflowID) +client.Workflows.List(ctx, params) +``` + ### Executions ```go @@ -225,6 +263,7 @@ source .env && export KEEL_BASE_URL KEEL_API_KEY KEEL_PROJECT_ID go run ./examples/quickstart go run ./examples/provider-swap go run ./examples/end-to-end +go run ./examples/workflows ``` ## Error Handling diff --git a/examples/workflows/main.go b/examples/workflows/main.go new file mode 100644 index 0000000..713fc1c --- /dev/null +++ b/examples/workflows/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + keel "github.com/keelapi/keel-go" +) + +func intPtr(v int) *int { + return &v +} + +func stringPtr(v string) *string { + return &v +} + +func main() { + client := keel.NewClient(keel.ClientConfig{ + BaseURL: os.Getenv("KEEL_BASE_URL"), + APIKey: os.Getenv("KEEL_API_KEY"), + }) + + ctx := context.Background() + workflowID := fmt.Sprintf("invoice-batch-%d", time.Now().Unix()) + + declaration, err := client.Workflows.Declare(ctx, keel.WorkflowDeclareRequest{ + WorkflowID: workflowID, + Intent: keel.WorkflowIntent{ + ExpectedCalls: intPtr(2), + MaxCalls: intPtr(4), + ExpectedModel: stringPtr("gpt-4o-mini"), + ExpectedInputTokensPerCall: intPtr(500), + ExpectedOutputTokensPerCall: intPtr(200), + MaxDurationSeconds: intPtr(3600), + }, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Workflow %s declared: %s\n", declaration.WorkflowID, declaration.Decision) + + err = keel.RunInWorkflow(ctx, workflowID, func(ctx context.Context) error { + for i := 0; i < 2; i++ { + permit, err := client.Permits.Create(ctx, keel.PermitRequest{ + ProjectID: os.Getenv("KEEL_PROJECT_ID"), + IdempotencyKey: fmt.Sprintf("%s-permit-%d", workflowID, i), + Subject: keel.Subject{Type: "service", ID: "invoice-worker"}, + Action: keel.Action{Name: string(keel.OpGenerateText)}, + Resource: keel.Resource{ + Type: "ai_model", + ID: "gpt-4o-mini", + Attributes: keel.ResourceAttributes{ + Provider: string(keel.ProviderOpenAI), + Model: "gpt-4o-mini", + Operation: keel.OpGenerateText, + EstimatedInputTokens: 500, + EstimatedOutputTokens: 200, + }, + }, + }) + if err != nil { + return err + } + fmt.Printf("Permit %s: %s\n", permit.PermitID, permit.Decision) + } + return nil + }) + if err != nil { + log.Fatal(err) + } + + version := 1 + if declaration.Version != nil { + version = *declaration.Version + } + amended, err := client.Workflows.Amend(ctx, workflowID, keel.WorkflowAmendRequest{ + IfMatchVersion: version, + NewMaxCalls: intPtr(6), + ReasonProvided: stringPtr("invoice volume increased"), + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Workflow %s amended to version %d\n", amended.WorkflowID, valueOrZero(amended.Version)) + + completed, err := client.Workflows.Complete(ctx, workflowID) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Workflow %s completed with %d calls\n", completed.WorkflowID, completed.AuthoritativeActualCalls) +} + +func valueOrZero(v *int) int { + if v == nil { + return 0 + } + return *v +} From cd5ec29adfc24bfba5872657edd4fb11cb0c793c Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:19:12 -0700 Subject: [PATCH 7/9] Test workflow intent support --- workflows_test.go | 309 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 workflows_test.go diff --git a/workflows_test.go b/workflows_test.go new file mode 100644 index 0000000..011fc6c --- /dev/null +++ b/workflows_test.go @@ -0,0 +1,309 @@ +package keel + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestNewClient_WorkflowsClient(t *testing.T) { + c := NewClient(ClientConfig{ + BaseURL: "https://api.keelapi.com", + APIKey: "test", + }) + if c.Workflows == nil { + t.Fatal("Workflows client is nil") + } +} + +func TestWorkflowsClient_Declare(t *testing.T) { + now := time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC) + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/workflows" { + t.Errorf("expected /v1/workflows, got %s", r.URL.Path) + } + + var req WorkflowDeclareRequest + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.WorkflowID != "wf_123" { + t.Errorf("expected workflow_id wf_123, got %s", req.WorkflowID) + } + if req.Intent.ExpectedCalls == nil || *req.Intent.ExpectedCalls != 2 { + t.Errorf("expected expected_calls 2, got %v", req.Intent.ExpectedCalls) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "workflow_id": "wf_123", + "decision": "accepted", + "status": "active", + "id": "4be0df33-9aa3-4786-ab27-56d7ba3f4db6", + "version": 1, + "expected_calls": 2, + "max_calls": 4, + "cached_actual_calls": 0, + "declaration_canonical_hash": "hash_123", + "declaration_signature_b64": "sig_123", + "declared_at": now.Format(time.RFC3339), + "projected_cost": map[string]any{ + "amount_micros": 22500000, + "currency": "USD", + "methodology": map[string]any{ + "basis": "caller_declared_workflow_x_point_pricing", + "provenance": "caller_declared_workflow", + "quality": "authoritative", + }, + }, + }) + }) + + expectedCalls := 2 + maxCalls := 4 + resp, err := c.Workflows.Declare(context.Background(), WorkflowDeclareRequest{ + WorkflowID: "wf_123", + Intent: WorkflowIntent{ + ExpectedCalls: &expectedCalls, + MaxCalls: &maxCalls, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.WorkflowID != "wf_123" { + t.Errorf("expected wf_123, got %s", resp.WorkflowID) + } + if resp.DeclarationSignatureB64 == nil || *resp.DeclarationSignatureB64 != "sig_123" { + t.Fatalf("expected signed declaration, got %v", resp.DeclarationSignatureB64) + } + if resp.DeclarationCanonicalHash == nil || *resp.DeclarationCanonicalHash != "hash_123" { + t.Fatalf("expected canonical hash, got %v", resp.DeclarationCanonicalHash) + } +} + +func TestWorkflowsClient_Amend_VersionConflict(t *testing.T) { + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/workflows/wf_123/amend" { + t.Errorf("expected amend path, got %s", r.URL.Path) + } + + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": ReasonWorkflowAmendmentVersionConflict, + "message": "workflow declaration version does not match", + "current_version": 2, + }, + }) + }) + + _, err := c.Workflows.Amend(context.Background(), "wf_123", WorkflowAmendRequest{ + IfMatchVersion: 1, + NewMaxCalls: intPtrForTest(6), + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrWorkflowAmendmentVersionConflict) { + t.Fatalf("expected amendment version conflict, got %v", err) + } + var ke *KeelError + if !errors.As(err, &ke) { + t.Fatalf("expected KeelError, got %T", err) + } + if ke.Code != ReasonWorkflowAmendmentVersionConflict { + t.Errorf("expected code %s, got %s", ReasonWorkflowAmendmentVersionConflict, ke.Code) + } +} + +func TestWorkflowsClient_Complete(t *testing.T) { + completedAt := time.Date(2026, 5, 12, 11, 0, 0, 0, time.UTC) + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/workflows/wf_123/complete" { + t.Errorf("expected complete path, got %s", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "workflow_id": "wf_123", + "status": "completed", + "cached_actual_calls": 3, + "authoritative_actual_calls": 3, + "counter_reconciled": true, + "completed_at": completedAt.Format(time.RFC3339), + }) + }) + + resp, err := c.Workflows.Complete(context.Background(), "wf_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.WorkflowID != "wf_123" { + t.Errorf("expected wf_123, got %s", resp.WorkflowID) + } + if resp.Status != WorkflowStatusCompleted { + t.Errorf("expected completed, got %s", resp.Status) + } + if resp.AuthoritativeActualCalls != 3 { + t.Errorf("expected 3 calls, got %d", resp.AuthoritativeActualCalls) + } +} + +func TestWithWorkflow_PropagatesViaContext(t *testing.T) { + ctx := WithWorkflow(context.Background(), " wf_123 ") + workflowID, ok := WorkflowFromContext(ctx) + if !ok { + t.Fatal("expected workflow ID in context") + } + if workflowID != "wf_123" { + t.Errorf("expected wf_123, got %s", workflowID) + } +} + +func TestRunInWorkflow_AutoInjectsHeader(t *testing.T) { + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get(workflowIDHeader); got != "wf_123" { + t.Errorf("expected workflow header wf_123, got %q", got) + } + json.NewEncoder(w).Encode(PermitAuditItem{PermitID: "pmt_123"}) + }) + + err := RunInWorkflow(context.Background(), "wf_123", func(ctx context.Context) error { + _, err := c.Permits.Get(ctx, "pmt_123") + return err + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunInWorkflow_HeaderAbsentOutside(t *testing.T) { + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get(workflowIDHeader); got != "" { + t.Errorf("expected no workflow header, got %q", got) + } + json.NewEncoder(w).Encode(PermitAuditItem{PermitID: "pmt_123"}) + }) + + _, err := c.Permits.Get(context.Background(), "pmt_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunInWorkflow_PropagatesAcrossGoroutines(t *testing.T) { + err := RunInWorkflow(context.Background(), "wf_goroutine", func(ctx context.Context) error { + result := make(chan string, 1) + go func(ctx context.Context) { + workflowID, _ := WorkflowFromContext(ctx) + result <- workflowID + }(ctx) + + select { + case workflowID := <-result: + if workflowID != "wf_goroutine" { + t.Errorf("expected wf_goroutine, got %s", workflowID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for goroutine") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWorkflowMaxCallsExceeded_TypedError(t *testing.T) { + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": ReasonWorkflowMaxCallsExceeded, + "message": "Workflow max_calls has been exceeded.", + }, + }) + }) + + _, err := c.Permits.Get(context.Background(), "pmt_123") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrWorkflowMaxCallsExceeded) { + t.Fatalf("expected max calls typed error, got %v", err) + } +} + +func TestHTTPHeader_NotForwardedToProvider(t *testing.T) { + c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/proxy/openai" { + t.Errorf("expected /v1/proxy/openai, got %s", r.URL.Path) + } + if got := r.Header.Get(workflowIDHeader); got != "wf_proxy" { + t.Errorf("expected SDK-to-Keel workflow header wf_proxy, got %q", got) + } + + var payload map[string]any + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if containsWorkflowHeaderKey(payload) { + t.Fatalf("workflow header leaked into provider payload: %v", payload) + } + + json.NewEncoder(w).Encode(map[string]any{"id": "chatcmpl_123"}) + }) + + err := RunInWorkflow(context.Background(), "wf_proxy", func(ctx context.Context) error { + _, err := c.Proxy.OpenAI(ctx, map[string]any{ + "model": "gpt-4o-mini", + "messages": []map[string]string{ + {"role": "user", "content": "hello"}, + }, + }) + return err + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func intPtrForTest(v int) *int { + return &v +} + +func containsWorkflowHeaderKey(value any) bool { + switch v := value.(type) { + case map[string]any: + for key, item := range v { + if strings.EqualFold(key, workflowIDHeader) { + return true + } + if containsWorkflowHeaderKey(item) { + return true + } + } + case []any: + for _, item := range v { + if containsWorkflowHeaderKey(item) { + return true + } + } + } + return false +} From 35e093baa1f53a2950f89401a3a109c40fafb1f1 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:20:20 -0700 Subject: [PATCH 8/9] Run gofmt across existing sources --- apikeys.go | 1 + keel_test.go | 1 - providers/anthropic/anthropic.go | 24 ++++---- providers/google/google.go | 15 +++-- providers/meta/meta.go | 14 ++--- providers/openai/openai.go | 10 ++-- providers/xai/xai.go | 15 +++-- types.go | 96 ++++++++++++++++---------------- 8 files changed, 87 insertions(+), 89 deletions(-) diff --git a/apikeys.go b/apikeys.go index 13f8471..ee9f1a6 100644 --- a/apikeys.go +++ b/apikeys.go @@ -54,6 +54,7 @@ func (c *ApiKeysClient) List(ctx context.Context, params ApiKeyListParams) (*Api } return &resp, nil } + // Revoke revokes an API key. func (c *ApiKeysClient) Revoke(ctx context.Context, keyID string) error { _, err := c.t.post(ctx, "/v1/api-keys/"+keyID+"/revoke", nil, nil) diff --git a/keel_test.go b/keel_test.go index d295588..bc8ba85 100644 --- a/keel_test.go +++ b/keel_test.go @@ -280,7 +280,6 @@ func TestJobsCreateAndGet(t *testing.T) { } } - func TestRequestsTimeline(t *testing.T) { c, _ := testServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/requests/req_123/timeline" { diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index f352ce8..54360c6 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -13,13 +13,13 @@ import ( // Config configures the Anthropic-compatible Keel client. type Config struct { - APIKey string - KeelBaseURL string - KeelAPIKey string - KeelProjectID string - KeelSubject *keel.PermitSubject - Timeout time.Duration - MaxRetries int + APIKey string + KeelBaseURL string + KeelAPIKey string + KeelProjectID string + KeelSubject *keel.PermitSubject + Timeout time.Duration + MaxRetries int } func (c *Config) resolve() { @@ -81,11 +81,11 @@ func NewClient(cfg Config) *Client { // MessageCreateParams are the parameters for creating a message. type MessageCreateParams struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - Messages []MessageParam `json:"messages"` - System *string `json:"system,omitempty"` - Extra map[string]any `json:"extra,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []MessageParam `json:"messages"` + System *string `json:"system,omitempty"` + Extra map[string]any `json:"extra,omitempty"` } // MessageParam represents a message in the conversation. diff --git a/providers/google/google.go b/providers/google/google.go index 0c39404..4f6dfd6 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -8,19 +8,18 @@ import ( "os" "time" - keel "github.com/keelapi/keel-go" ) // Config configures the Google-compatible Keel client. type Config struct { - APIKey string - KeelBaseURL string - KeelAPIKey string - KeelProjectID string - KeelSubject *keel.PermitSubject - Timeout time.Duration - MaxRetries int + APIKey string + KeelBaseURL string + KeelAPIKey string + KeelProjectID string + KeelSubject *keel.PermitSubject + Timeout time.Duration + MaxRetries int } func (c *Config) resolve() { diff --git a/providers/meta/meta.go b/providers/meta/meta.go index 9041584..d2b1d6f 100644 --- a/providers/meta/meta.go +++ b/providers/meta/meta.go @@ -14,13 +14,13 @@ import ( // Config configures the Meta-compatible Keel client. type Config struct { - APIKey string - KeelBaseURL string - KeelAPIKey string - KeelProjectID string - KeelSubject *keel.PermitSubject - Timeout time.Duration - MaxRetries int + APIKey string + KeelBaseURL string + KeelAPIKey string + KeelProjectID string + KeelSubject *keel.PermitSubject + Timeout time.Duration + MaxRetries int } func (c *Config) resolve() { diff --git a/providers/openai/openai.go b/providers/openai/openai.go index 3c1a9d9..492424e 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -49,9 +49,9 @@ func (c *Config) resolve() { // Client is an OpenAI-compatible client that routes through Keel. type Client struct { - Chat ChatNamespace - cfg Config - keel *keel.Client + Chat ChatNamespace + cfg Config + keel *keel.Client } // ChatNamespace groups chat-related resources. @@ -145,9 +145,9 @@ type ChatCompletionChunk struct { Created int64 `json:"created"` Model string `json:"model"` Choices []struct { - Index int `json:"index"` + Index int `json:"index"` Delta ChatCompletionMessage `json:"delta"` - FinishReason *string `json:"finish_reason"` + FinishReason *string `json:"finish_reason"` } `json:"choices"` } diff --git a/providers/xai/xai.go b/providers/xai/xai.go index c351d7b..e06d4c3 100644 --- a/providers/xai/xai.go +++ b/providers/xai/xai.go @@ -9,19 +9,18 @@ import ( "os" "time" - keel "github.com/keelapi/keel-go" ) // Config configures the xAI-compatible Keel client. type Config struct { - APIKey string - KeelBaseURL string - KeelAPIKey string - KeelProjectID string - KeelSubject *keel.PermitSubject - Timeout time.Duration - MaxRetries int + APIKey string + KeelBaseURL string + KeelAPIKey string + KeelProjectID string + KeelSubject *keel.PermitSubject + Timeout time.Duration + MaxRetries int } func (c *Config) resolve() { diff --git a/types.go b/types.go index 7b68452..3a30779 100644 --- a/types.go +++ b/types.go @@ -9,10 +9,10 @@ import ( type Decision string const ( - DecisionAllow Decision = "allow" - DecisionDeny Decision = "deny" - DecisionChallenge Decision = "challenge" - DecisionThrottled Decision = "throttled" + DecisionAllow Decision = "allow" + DecisionDeny Decision = "deny" + DecisionChallenge Decision = "challenge" + DecisionThrottled Decision = "throttled" ) // RoutingProvider represents a supported AI provider. @@ -96,17 +96,17 @@ type PermitInput struct { // ResourceAttributes describes the attributes of a resource in a permit request. type ResourceAttributes struct { - Provider string `json:"provider"` - Model string `json:"model"` - Operation CapabilityOperation `json:"operation"` - Modality string `json:"modality,omitempty"` - ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` - EstimatedInputTokens int `json:"estimated_input_tokens"` - EstimatedOutputTokens int `json:"estimated_output_tokens"` - MaxOutputTokensRequested *int `json:"max_output_tokens_requested,omitempty"` - Inputs []PermitInput `json:"inputs,omitempty"` - Routing *RoutingPreferences `json:"routing,omitempty"` - CallbackURL *string `json:"callback_url,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Operation CapabilityOperation `json:"operation"` + Modality string `json:"modality,omitempty"` + ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` + EstimatedInputTokens int `json:"estimated_input_tokens"` + EstimatedOutputTokens int `json:"estimated_output_tokens"` + MaxOutputTokensRequested *int `json:"max_output_tokens_requested,omitempty"` + Inputs []PermitInput `json:"inputs,omitempty"` + Routing *RoutingPreferences `json:"routing,omitempty"` + CallbackURL *string `json:"callback_url,omitempty"` } // Resource describes the target resource. @@ -300,11 +300,11 @@ type PermitEvidenceCreateRequest struct { // PermitEvidenceOut represents a piece of evidence attached to a permit. type PermitEvidenceOut struct { - EvidenceID string `json:"evidence_id"` - PermitID string `json:"permit_id"` - Type string `json:"type"` + EvidenceID string `json:"evidence_id"` + PermitID string `json:"permit_id"` + Type string `json:"type"` Content map[string]any `json:"content"` - CreatedAt string `json:"created_at"` + CreatedAt string `json:"created_at"` } // PermitEvidenceListResponse is the response from listing evidence. @@ -396,21 +396,21 @@ type RoutingInput struct { // ParametersInput represents parameters for an execution request. type ParametersInput struct { - MaxTokens *int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Extra map[string]any `json:"extra,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Extra map[string]any `json:"extra,omitempty"` } // ExecutionCreateRequest is the request body for creating an execution. type ExecutionCreateRequest struct { - Operation string `json:"operation"` - Mode ExecutionMode `json:"mode,omitempty"` - Messages []MessageInput `json:"messages,omitempty"` - Inputs []InputPart `json:"inputs,omitempty"` - Routing *RoutingInput `json:"routing,omitempty"` - Parameters *ParametersInput `json:"parameters,omitempty"` - ProviderOptions map[string]map[string]any `json:"provider_options,omitempty"` + Operation string `json:"operation"` + Mode ExecutionMode `json:"mode,omitempty"` + Messages []MessageInput `json:"messages,omitempty"` + Inputs []InputPart `json:"inputs,omitempty"` + Routing *RoutingInput `json:"routing,omitempty"` + Parameters *ParametersInput `json:"parameters,omitempty"` + ProviderOptions map[string]map[string]any `json:"provider_options,omitempty"` } // ExecutionMessage is an alias for MessageInput for backward compatibility. @@ -418,17 +418,17 @@ type ExecutionMessage = MessageInput // ExecutionResponse is the response from a synchronous execution. type ExecutionResponse struct { - RequestID string `json:"request_id"` - PermitID string `json:"permit_id"` - Status string `json:"status"` - Provider RoutingProvider `json:"provider"` - Model string `json:"model"` - Messages []MessageInput `json:"messages,omitempty"` - Usage *ExecutionUsage `json:"usage,omitempty"` - Timing *ExecutionTiming `json:"timing,omitempty"` + RequestID string `json:"request_id"` + PermitID string `json:"permit_id"` + Status string `json:"status"` + Provider RoutingProvider `json:"provider"` + Model string `json:"model"` + Messages []MessageInput `json:"messages,omitempty"` + Usage *ExecutionUsage `json:"usage,omitempty"` + Timing *ExecutionTiming `json:"timing,omitempty"` Routing *ExecutionRoutingResult `json:"routing,omitempty"` - Governance *ExecutionGovernance `json:"governance,omitempty"` - Raw map[string]any `json:"raw,omitempty"` + Governance *ExecutionGovernance `json:"governance,omitempty"` + Raw map[string]any `json:"raw,omitempty"` } // ExecutionUsage records token usage for an execution. @@ -468,14 +468,14 @@ type ExecutionStreamEvent struct { // ExecuteRequest is the request body for the combined execute endpoint. type ExecuteRequest struct { - Subject Subject `json:"subject"` - Action Action `json:"action"` - Resource Resource `json:"resource"` - Context *Context `json:"context,omitempty"` - Conditions *PermitConditions `json:"conditions,omitempty"` - Messages []MessageInput `json:"messages,omitempty"` - Parameters *ParametersInput `json:"parameters,omitempty"` - CustomMetadata map[string]any `json:"custom_metadata,omitempty"` + Subject Subject `json:"subject"` + Action Action `json:"action"` + Resource Resource `json:"resource"` + Context *Context `json:"context,omitempty"` + Conditions *PermitConditions `json:"conditions,omitempty"` + Messages []MessageInput `json:"messages,omitempty"` + Parameters *ParametersInput `json:"parameters,omitempty"` + CustomMetadata map[string]any `json:"custom_metadata,omitempty"` } // --- Job types --- From af479799f10277e0510fde86d5121800d11d61d3 Mon Sep 17 00:00:00 2001 From: sftimeless <37782990+sftimeless@users.noreply.github.com> Date: Wed, 13 May 2026 07:42:35 -0700 Subject: [PATCH 9/9] ci: add minimal Go CI for build/test/fmt/vet on 1.21+1.22 --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f577c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ['1.21', '1.22'] + steps: + - uses: actions/checkout@v4 + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Build + run: go build ./... + - name: Test + run: go test ./... -race -count=1 + - name: Format check + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "::error::gofmt found unformatted files:" + gofmt -l . + exit 1 + fi + - name: Vet + run: go vet ./...