Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,57 @@ caller needs.
Ruby remains a fully supported runtime for [standard
functions](https://volcano.dev/platform/functions/overview).

### Decide durable approvals

A durable function written in JavaScript or Python can pause until a person
approves or denies a request. Ruby lists those requests and decides them:

```ruby
approvals = owner_client.durable.approvals

page = approvals.list(project_id, status: "pending", function: "refunds", limit: 20)
page.approvals.each do |approval|
puts [approval.id, approval.title, approval.details, approval.expires_at]
end

begin
approval = approvals.approve(project_id, page.approvals.first.id, comment: "Matches the invoice")
puts approval.decision.decided_at
rescue Volcano::Error::ConflictError => e
puts e.code # approval_decided, approval_expired, or approval_cancelled
end

approvals.deny(project_id, "00000000-0000-4000-8000-000000000051")

stats = approvals.stats(project_id, from: Time.now - (7 * 86_400))
puts [stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision]
```

`list` returns the newest approvals first. Filter by `status` (`pending`,
`approved`, `denied`, `expired`, or `cancelled`), `function` (a durable
function's id or name), `execution_id`, and a `from`/`to` window given as a
`Time` or an ISO 8601 string with an offset, such as `2026-10-01T00:00:00Z`.
`stats` covers the last 30 days unless you pass a
window, up to 366 days. It counts approvals by status, gives the approval rate
and the median and 90th-percentile time to a decision (nil when nothing in the
window was decided), lists the ten functions that requested the most approvals
with the rest summed in `other_functions`, and returns `daily` counts keyed by
`YYYY-MM-DD` UTC dates, omitting days without approvals.

An approval keeps its function and execution names after they are deleted;
their ids become nil. `decision` is nil unless the approval was approved or
denied, and its `decided_by` is nil once the deciding account is deleted.

Reading approvals accepts the project owner's platform user token or a project
access token. Approving and denying require a person: a project access token
raises `Volcano::Error::PermissionDeniedError` (HTTP 403, a subclass of
`AuthenticationError`, so existing rescues still apply). Repeating the same
decision returns the approval unchanged; a conflicting decision, or deciding an
expired or cancelled approval, raises `Volcano::Error::ConflictError` (HTTP
409). An unknown approval, or one in another project, raises
`Volcano::Error::NotFoundError`. A `comment` is optional, up to 2,000
characters.

### Read project logs

```ruby
Expand Down
11 changes: 11 additions & 0 deletions Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ target :core_facades do
check 'lib/volcano.rb'
check 'lib/volcano/database.rb'
check 'lib/volcano/durable.rb'
check 'lib/volcano/durable_arguments.rb'
check 'lib/volcano/durable_models.rb'
check 'lib/volcano/function_auth.rb'
check 'lib/volcano/functions.rb'
Expand Down Expand Up @@ -170,6 +171,16 @@ target :consumers do
configure_code_diagnostics Steep::Diagnostic::Ruby.all_error
end

target :durable_approvals do
signature 'sig', 'sig_dev'
check 'lib/volcano/durable_approvals.rb'
check 'lib/volcano/durable_approval_models.rb'
check 'lib/volcano/durable_approval_responses.rb'
library 'json'
library 'time'
configure_code_diagnostics Steep::Diagnostic::Ruby.all_error
end

target :sandboxes do
signature 'sig', 'sig_dev'
check 'lib/volcano/sandbox_models.rb'
Expand Down
18 changes: 18 additions & 0 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,21 @@ owner_client.durable.stop(project_id, "charge-order", handle.id)
`list` returns executions and pagination metadata. A successful response must include `data`, `page`, `limit`, `total`, and `has_more` with the API's declared types. Missing or malformed fields raise `TypeError`; a complete response with `data: []` returns an empty execution list.

Ruby can start and manage durable functions written in JavaScript or Python. The Ruby SDK has no durable authoring API.

## Decide durable approvals

A durable function written in JavaScript or Python can wait for a person to approve or deny a request. Ruby reads and decides those approvals:

```ruby
approvals = owner_client.durable.approvals
page = approvals.list(project_id, status: "pending", function: "refunds")
approvals.approve(project_id, page.approvals.first.id, comment: "Matches the invoice")
approvals.deny(project_id, approval_id)
stats = approvals.stats(project_id, from: Time.now - (7 * 86_400))
```

`list` and `get` return `DurableApproval` records; `stats` returns counts by status, the approval rate, and decision times for a window that defaults to the last 30 days. `daily` entries carry `YYYY-MM-DD` UTC dates.

An approval's `decision` is nil unless it was approved or denied. `decision.decided_by` is nil once the deciding account is deleted, so read it with `decided_by&.email`.

Reads accept the project owner's platform user token or a project access token. Decisions require a person, so a project access token raises `Volcano::Error::PermissionDeniedError`, a subclass of `Volcano::Error::AuthenticationError`. A conflicting decision, or deciding an expired or cancelled approval, raises `Volcano::Error::ConflictError` with `code` set to `approval_decided`, `approval_expired`, or `approval_cancelled`; repeating the same decision returns the approval unchanged. An unknown approval raises `Volcano::Error::NotFoundError`. Malformed responses raise `TypeError`.
4 changes: 4 additions & 0 deletions lib/volcano.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative 'volcano/session_credentials'
require_relative 'volcano/function_response'
require_relative 'volcano/durable_models'
require_relative 'volcano/durable_approval_models'
require_relative 'volcano/log_responses'
require_relative 'volcano/storage_models'
require_relative 'volcano/storage_upload_models'
Expand Down Expand Up @@ -44,6 +45,9 @@
require_relative 'volcano/database'
require_relative 'volcano/function_resolution'
require_relative 'volcano/functions'
require_relative 'volcano/durable_arguments'
require_relative 'volcano/durable_approval_responses'
require_relative 'volcano/durable_approvals'
require_relative 'volcano/durable'
require_relative 'volcano/logs'
require_relative 'volcano/storage_session_adapter'
Expand Down
41 changes: 7 additions & 34 deletions lib/volcano/durable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
module Volcano
# Starts and follows executions of deployed durable functions.
class Durable
# What the platform accepts as an execution name, and the page size it
# serves at most. Mirrored from the wire contract so a refusal is the
# facade's rather than the generated client's.
# What the platform accepts as an execution name. Mirrored from the wire
# contract so a refusal is the facade's rather than the generated client's.
EXECUTION_NAME = /\A[A-Za-z0-9._-]{1,255}\z/
MAX_PAGE_SIZE = 100
include DurableArguments
include DurableResponses

# Lists, reads, and decides the approvals durable workflows request.
attr_reader :approvals

def initialize(client, transport)
@client = client
@transport = transport
@approvals = DurableApprovals.new(client, transport)
end

# Starts an execution and returns its handle. A durable function is never
Expand Down Expand Up @@ -99,18 +102,6 @@ def execution_path(project_id, function_name, execution_id)
]
end

# An empty path segment would address the collection instead of the
# execution, which is a different request rather than a failed one.
def identifier(value, field)
raise ArgumentError, "#{field} must be a non-empty String" unless value.is_a?(String) && present_string?(value)

value.strip.freeze
end

# The generated client validates this header against the same pattern and
# raises its own message, naming the operation and the option key it knows
# the header by. Checked here first so the caller reads a message about the
# argument they passed, the way Functions#invoke does for a function name.
def execution_name_argument(value)
name = identifier(value, 'execution_name')
unless EXECUTION_NAME.match?(name)
Expand All @@ -120,23 +111,5 @@ def execution_name_argument(value)

name
end

# Same reason: the generated client enforces the page and limit bounds and
# answers with its own vocabulary.
def validate_paging(page, limit)
raise ArgumentError, 'page must be a positive Integer' unless page.nil? || positive_integer?(page)

validate_page_limit(limit)
end

def validate_page_limit(limit)
return if limit.nil? || (limit.is_a?(Integer) && positive_integer?(limit) && limit <= MAX_PAGE_SIZE)

raise ArgumentError, "limit must be an Integer between 1 and #{MAX_PAGE_SIZE}"
end

def positive_integer?(value)
value.is_a?(Integer) && value.positive?
end
end
end
171 changes: 171 additions & 0 deletions lib/volcano/durable_approval_models.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# frozen_string_literal: true

require_relative 'immutable_request_value'

module Volcano
DURABLE_APPROVAL_ATTRIBUTES = %i[
id status name title description details function execution requested_at expires_at decision
].freeze
DURABLE_APPROVAL_STATS_ATTRIBUTES = %i[
from to counts approval_rate median_seconds_to_decision p90_seconds_to_decision
functions other_functions daily
].freeze
private_constant :DURABLE_APPROVAL_ATTRIBUTES, :DURABLE_APPROVAL_STATS_ATTRIBUTES

# Checks keywords for the approval records with too many fields for an
# explicit keyword list, and owns immutable copies of their values.
module DurableApprovalAttributes
def self.capture(attributes, names, optional:)
unknown = attributes.keys - names
raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?

missing = names - optional - attributes.keys
raise ArgumentError, "missing keywords: #{missing.join(', ')}" unless missing.empty?

names.to_h { |name| [name, immutable_value(attributes[name])] }
end

def self.immutable_value(value)
case value
when Hash, Array, Time, String then ImmutableRequestValue.capture(value)
else value
end
end
end
private_constant :DurableApprovalAttributes

# The durable function that requested an approval. `id` is nil once the
# function has been deleted; `name` is kept.
DurableApprovalFunction = Data.define(:id, :name)

# Reopens the generated record for checked initialization.
class DurableApprovalFunction
# @dynamic id, name, members, with, to_h, deconstruct, deconstruct_keys, self.[]
# @dynamic self.members
def initialize(id:, name:)
id = id&.dup&.freeze
name = name.dup.freeze
super
end
end

# The durable execution that requested an approval. `id` and `status` are
# nil once the execution is no longer retained; `name` is kept.
DurableApprovalExecution = Data.define(:id, :name, :status)

# Reopens the generated record for checked initialization.
class DurableApprovalExecution
# @dynamic id, name, status, members, with, to_h, deconstruct, deconstruct_keys
# @dynamic self.[], self.members
def initialize(id:, name:, status:)
id = id&.dup&.freeze
name = name.dup.freeze
status = status&.dup&.freeze
super
end
end

# The person who decided an approval.
DurableApprovalDecider = Data.define(:id, :email)

# Reopens the generated record for checked initialization.
class DurableApprovalDecider
# @dynamic id, email, members, with, to_h, deconstruct, deconstruct_keys, self.[]
# @dynamic self.members
def initialize(id:, email:)
id = id.dup.freeze
email = email.dup.freeze
super
end
end

# Who decided an approval and when. `decided_by` is nil once that person's
# account is deleted.
DurableApprovalDecision = Data.define(:comment, :decided_by, :decided_at)

# Reopens the generated record for checked initialization.
class DurableApprovalDecision
# @dynamic comment, decided_by, decided_at, members, with, to_h, deconstruct
# @dynamic deconstruct_keys, self.[], self.members
def initialize(comment:, decided_by:, decided_at:)
comment = comment.dup.freeze
decided_at = decided_at.dup.freeze
super
end
end

# An approval a durable workflow requested.
#
# `decision` is nil unless the approval was approved or denied, and
# `expires_at` is nil when the workflow set no timeout. `details` is the
# workflow's own JSON, frozen all the way down.
DurableApproval = Data.define(*DURABLE_APPROVAL_ATTRIBUTES)

# Reopens the generated record for checked initialization.
class DurableApproval
# @dynamic id, status, name, title, description, details, function, execution
# @dynamic requested_at, expires_at, decision, members, with, to_h, deconstruct
# @dynamic deconstruct_keys, self.[], self.members
def initialize(**attributes)
values = DurableApprovalAttributes.capture(
attributes, DURABLE_APPROVAL_ATTRIBUTES, optional: %i[details expires_at decision]
)
attributes = values
super
end
end

# One page of a project's approvals, newest first.
DurableApprovalPage = Data.define(:approvals, :page, :limit, :total, :has_more)

# Reopens the generated record for checked initialization.
class DurableApprovalPage
# @dynamic approvals, page, limit, total, has_more, members, with, to_h, deconstruct
# @dynamic deconstruct_keys, self.[], self.members
def initialize(approvals:, page:, limit:, total:, has_more:)
approvals = approvals.to_a.dup.freeze
super
end
end

# Approvals by status. `requested` counts every approval, whatever its status.
DurableApprovalCounts = Data.define(:requested, :pending, :approved, :denied, :expired, :cancelled)

# One workflow's approval counts.
DurableApprovalFunctionCounts = Data.define(:function, :counts)

# Approval counts for one UTC day, given as `YYYY-MM-DD`.
DurableApprovalDailyCounts = Data.define(:date, :counts)

# Reopens the generated record for checked initialization.
class DurableApprovalDailyCounts
# @dynamic date, counts, members, with, to_h, deconstruct, deconstruct_keys, self.[]
# @dynamic self.members
def initialize(date:, counts:)
date = date.dup.freeze
super
end
end

# Approval outcomes over a time window.
#
# `functions` holds the ten workflows that requested the most approvals and
# `other_functions` sums the rest. `daily` omits days without approvals.
# The rate and decision times are nil when nothing in the window was decided.
DurableApprovalStats = Data.define(*DURABLE_APPROVAL_STATS_ATTRIBUTES)

# Reopens the generated record for checked initialization.
class DurableApprovalStats
# @dynamic from, to, counts, approval_rate, median_seconds_to_decision
# @dynamic p90_seconds_to_decision, functions, other_functions, daily, members, with
# @dynamic to_h, deconstruct, deconstruct_keys, self.[], self.members
def initialize(**attributes)
values = DurableApprovalAttributes.capture(
attributes, DURABLE_APPROVAL_STATS_ATTRIBUTES,
optional: %i[approval_rate median_seconds_to_decision p90_seconds_to_decision]
)
attributes = values
super
end
end
end
Loading
Loading