Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Piped, CI, and `NO_COLOR` output remains plain. Machine output is unchanged.
| Access tokens | create, list, get, revoke project credentials | `cloud access-tokens …` | [access-tokens.md](access-tokens.md) |
| Project | create, list, get, rename, delete, select, get keys and usage | `projects …`, `use` | below |
| Functions | deploy, invoke, inspect, schedule, alias | `functions …` | [functions.md](functions.md) |
| Durable functions | deploy, start, inspect executions, read logs, schedule | `durable …`, `cloud durable …` | [durable-functions.md](durable-functions.md) |
| Durable functions | deploy, start, inspect executions, read logs, schedule, decide approvals | `durable …`, `cloud durable …` | [durable-functions.md](durable-functions.md) |
| Databases | create, inspect, delete, migrate | `databases …`, `migrations …` | [databases.md](databases.md) |
| Storage | manage buckets, objects, policies | `storage …` | [storage.md](storage.md) |
| Variables | deploy, list, get, delete | `variables …` | [variables.md](variables.md) |
Expand Down
80 changes: 79 additions & 1 deletion docs/durable-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,15 @@ existing function's scope alone.
| List schedulers | `volcano cloud durable schedulers list <function>` |
| Pause or resume one | `volcano cloud durable schedulers disable\|enable <function> <scheduler-id>` |
| Delete one | `volcano cloud durable schedulers delete <function> <scheduler-id> [--yes]` |
| List approvals | `volcano cloud durable approvals list [--function …] [--status pending] [--execution …] [--since …] [--page 1] [--limit 100]` |
| Get one approval | `volcano cloud durable approvals get <approval-id>` |
| Approve or deny one | `volcano cloud durable approvals approve\|deny <approval-id> [--comment …] [--yes]` |
| Approval stats | `volcano cloud durable approvals stats [--function …] [--since 30d]` |

Wherever a command takes a function, it takes the name or the id. `deploy` needs
exactly one of `--all` and `-f`. `--yes` skips the confirmation prompt the three
destructive commands ask for. Schedulers require `SUPERAGENT` and are capped at
destructive commands and the two approval decisions ask for. Every `approvals`
command takes `--json`. Schedulers require `SUPERAGENT` and are capped at
5 per project across standard and durable functions together; a create beyond
that answers `403`.

Expand Down Expand Up @@ -163,6 +168,79 @@ executions it already started keep running. `schedulers delete` removes the
scheduler and its run history, and also leaves running executions alone — stop
those with `executions stop`.

## Approvals

A workflow that calls `ctx.waitForApproval` pauses until a person approves or
denies it, then resumes with the decision. See
[durable functions in the JavaScript SDK](/sdk/js/durable-functions) for the
authoring side. The `approvals` commands are the deciding side:

```bash
# What is waiting on someone
volcano cloud durable approvals list

# Read what the workflow is asking, with the details it attached
volcano cloud durable approvals get 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20

# Decide it; the comment reaches the workflow and stays in the history
volcano cloud durable approvals approve 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 --comment "Checked stock"
volcano cloud durable approvals deny 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20
```

```text
ID: 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20
Title: Ship order 4417?
Name: ship-order
Status: approved
Workflow: order-pipeline
Execution: order-4417 (66666666-6666-4666-8666-666666666666, running)
Requested: 2026-10-06T11:00:00Z
Expires: when its execution ends
Details: {
"order": 4417
}
Decided By: owner@example.com
Decided At: 2026-10-06T11:04:12Z
Comment: Checked stock
✓ Approved "Ship order 4417?"; the workflow resumes with the decision
```

`list` shows pending approvals unless `--status` says otherwise. It takes
`pending`, `approved`, `denied`, `expired`, `cancelled`, or `all`. An approval
ends in one of four ways:

| Status | What happened | What the workflow gets |
|---|---|---|
| `approved` | A person approved it | `approved: true` and the comment |
| `denied` | A person denied it | `approved: false` and the comment |
| `expired` | Its timeout passed with nobody deciding | `status: 'expired'`; the wait resolves rather than failing |
| `cancelled` | Its execution ended first | Nothing; the execution is already over |

`approve` and `deny` show the title and ask before deciding, because a decision
cannot be changed. Repeating the decision an approval already has changes
nothing and succeeds, so a retried command is safe. A different decision, or
any decision on an expired or cancelled approval, fails with what happened, for
example `approval … was already approved by owner@example.com at …`. An approval
past its timeout counts as expired, even while it still reads `pending`.

Approvals are decided by a person. A command run with a project access token is
refused; run `volcano login`, or decide in the dashboard.

`stats` counts the approvals requested in a window, 30 days unless `--since`
says otherwise, up to 366 days:

```bash
volcano cloud durable approvals stats --since 7d
```

It prints the count in each status, the approval rate (approved out of
approved and denied), the median time from request to decision, and the
workflows that request the most.

In local mode, drop `cloud`: `volcano durable approvals approve …` decides as the
local user. An approval timeout runs in real time locally, even though plain
waits resolve immediately.

## Starting is asynchronous

`start` returns as soon as the execution is accepted, with the execution's id
Expand Down
113 changes: 113 additions & 0 deletions internal/api/durable_approvals.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package api

import (
"context"
"time"

"github.com/google/uuid"

"github.com/Kong/volcano-cli/internal/apiclient"
)

// DurableApprovalListInput filters one approval page. Empty and nil fields
// apply no filter, so an empty Status lists approvals in every status.
type DurableApprovalListInput struct {
Function string
Status string
ExecutionID *uuid.UUID
From *time.Time
To *time.Time
Page int
Limit int
}

// ListDurableApprovals lists one approval page for a project, newest first.
func (c *Client) ListDurableApprovals(
ctx context.Context, projectID uuid.UUID, input DurableApprovalListInput,
) (*apiclient.PaginatedDurableApprovals, error) {
params := &apiclient.ListDurableApprovalsParams{
Page: &input.Page,
Limit: &input.Limit,
ExecutionId: input.ExecutionID,
From: input.From,
To: input.To,
}
if input.Function != "" {
function := input.Function
params.Function = &function
}
if input.Status != "" {
status := apiclient.DurableApprovalStatus(input.Status)
params.Status = &status
}

resp, err := c.client.ListDurableApprovalsWithResponse(ctx, projectID, params)
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400)
}

// GetDurableApproval returns one approval, including its decision once a
// person has made one.
func (c *Client) GetDurableApproval(
ctx context.Context, projectID, approvalID uuid.UUID,
) (*apiclient.DurableApproval, error) {
resp, err := c.client.GetDurableApprovalWithResponse(ctx, projectID, approvalID)
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON404)
}

// GetDurableApprovalStats counts a project's approvals by outcome from `from`
// until `to`. A nil to is the API's now, and a nil from 30 days before to.
func (c *Client) GetDurableApprovalStats(
ctx context.Context, projectID uuid.UUID, function string, from, to *time.Time,
) (*apiclient.DurableApprovalStats, error) {
params := &apiclient.GetDurableApprovalStatsParams{From: from, To: to}
if function != "" {
params.Function = &function
}

resp, err := c.client.GetDurableApprovalStatsWithResponse(ctx, projectID, params)
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400)
}

// ApproveDurableApproval approves a pending approval, and the workflow
// resumes with the decision. Only a person's credential may decide.
func (c *Client) ApproveDurableApproval(
ctx context.Context, projectID, approvalID uuid.UUID, comment string,
) (*apiclient.DurableApproval, error) {
resp, err := c.client.ApproveDurableApprovalWithResponse(
ctx, projectID, approvalID, durableApprovalDecisionBody(comment))
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200,
resp.JSON400, resp.JSON403, resp.JSON404, resp.JSON409)
}

// DenyDurableApproval denies a pending approval. A denial resumes the workflow
// too: it is a decision, not a failure.
func (c *Client) DenyDurableApproval(
ctx context.Context, projectID, approvalID uuid.UUID, comment string,
) (*apiclient.DurableApproval, error) {
resp, err := c.client.DenyDurableApprovalWithResponse(
ctx, projectID, approvalID, durableApprovalDecisionBody(comment))
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200,
resp.JSON400, resp.JSON403, resp.JSON404, resp.JSON409)
}

func durableApprovalDecisionBody(comment string) apiclient.DurableApprovalDecisionRequest {
if comment == "" {
return apiclient.DurableApprovalDecisionRequest{}
}
return apiclient.DurableApprovalDecisionRequest{Comment: &comment}
}
152 changes: 152 additions & 0 deletions internal/api/durable_approvals_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var durableTestApprovalID = uuid.MustParse("77777777-7777-4777-8777-777777777777")

// The decision body is optional on the wire. A comment travels in it, and no
// comment is an empty object rather than an empty string, which the history
// would record as a comment someone left blank.
func TestDecideDurableApprovalSendsTheComment(t *testing.T) {
for _, tc := range []struct {
name string
approve bool
comment string
want map[string]any
}{
{name: "approve with a comment", approve: true, comment: "Checked stock", want: map[string]any{"comment": "Checked stock"}},
{name: "deny without one", approve: false, comment: "", want: map[string]any{}},
} {
t.Run(tc.name, func(t *testing.T) {
var path string
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
path = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
writeAPIJSON(t, w, http.StatusOK, durableApprovalAPIPayload())
}))
defer server.Close()

client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client()))
require.NoError(t, err)

decide, action := client.DenyDurableApproval, "deny"
if tc.approve {
decide, action = client.ApproveDurableApproval, "approve"
}
approval, err := decide(context.Background(), durableTestProjectID, durableTestApprovalID, tc.comment)
require.NoError(t, err)
assert.Equal(t, durableTestApprovalID, approval.Id)
assert.Equal(t, "/projects/"+durableTestProjectID.String()+"/durable-approvals/"+
durableTestApprovalID.String()+"/"+action, path)
assert.Equal(t, tc.want, body)
})
}
}

// A 409 carries the API's reason and code, which callers need to tell a
// conflicting decision from any other failure, and one conflict from another.
func TestDecideDurableApprovalSurfacesTheConflict(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeAPIJSON(t, w, http.StatusConflict, map[string]any{
"error": "approval already decided", "code": "approval_decided",
})
}))
defer server.Close()

client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client()))
require.NoError(t, err)

_, err = client.ApproveDurableApproval(context.Background(), durableTestProjectID, durableTestApprovalID, "")
require.Error(t, err)
assert.Equal(t, http.StatusConflict, Status(err))
assert.Equal(t, "approval already decided", Message(err))
assert.Equal(t, "approval_decided", Code(err))
}

// An unset filter is no filter: sending an empty status or function would ask
// the API for approvals matching the empty string.
func TestListDurableApprovalsSendsOnlyTheFiltersSet(t *testing.T) {
var query map[string][]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
writeAPIJSON(t, w, http.StatusOK, map[string]any{
"data": []any{}, "page": 1, "limit": 20, "total": 0, "has_more": false,
})
}))
defer server.Close()

client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client()))
require.NoError(t, err)

_, err = client.ListDurableApprovals(context.Background(), durableTestProjectID,
DurableApprovalListInput{Page: 1, Limit: 20})
require.NoError(t, err)
assert.Equal(t, map[string][]string{"page": {"1"}, "limit": {"20"}}, query)

from := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
_, err = client.ListDurableApprovals(context.Background(), durableTestProjectID, DurableApprovalListInput{
Function: "order-pipeline", Status: "pending", From: &from, Page: 1, Limit: 20,
})
require.NoError(t, err)
assert.Equal(t, []string{"order-pipeline"}, query["function"])
assert.Equal(t, []string{"pending"}, query["status"])
assert.Equal(t, []string{"2026-10-05T12:00:00Z"}, query["from"])
}

// The API measures a missing `to` from its own clock, so a caller that names
// both ends has to get both onto the wire.
func TestGetDurableApprovalStatsSendsTheWindow(t *testing.T) {
var query map[string][]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
writeAPIJSON(t, w, http.StatusOK, map[string]any{
"from": "2026-09-06T12:00:00Z", "to": "2026-10-06T12:00:00Z",
})
}))
defer server.Close()

client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client()))
require.NoError(t, err)

_, err = client.GetDurableApprovalStats(context.Background(), durableTestProjectID, "", nil, nil)
require.NoError(t, err)
assert.Empty(t, query)

from := time.Date(2025, 10, 5, 12, 0, 0, 0, time.UTC)
to := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
_, err = client.GetDurableApprovalStats(context.Background(), durableTestProjectID, "order-pipeline", &from, &to)
require.NoError(t, err)
assert.Equal(t, map[string][]string{
"function": {"order-pipeline"},
"from": {"2025-10-05T12:00:00Z"},
"to": {"2026-10-06T12:00:00Z"},
}, query)
}

func durableApprovalAPIPayload() map[string]any {
return map[string]any{
"id": durableTestApprovalID.String(),
"status": "approved",
"name": "ship-order",
"title": "Ship order 4417?",
"description": "",
"function": map[string]any{"id": "55555555-5555-4555-8555-555555555555", "name": "order-pipeline"},
"execution": map[string]any{"id": durableTestExecutionID.String(), "name": "order-4417", "status": "running"},
"requested_at": "2026-10-06T11:00:00Z",
"expires_at": nil,
"decision": nil,
}
}
Loading
Loading