diff --git a/CHANGELOG.md b/CHANGELOG.md
index d95c385..1dffaf6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.2.0] - 2026-04-16
+
+### Added
+
+- Support for `client.services.session(uuid).logs(recent: true)` for finished-session log retrieval.
+- `OctaSpace::ProvisionRejectedError` for MR create requests that are transport-successful but rejected by the API payload contract.
+- `OctaSpace::PayloadHelpers` with targeted helpers for stringified app port lists and marketplace bandwidth normalization.
+- Diagnostics preset and manual runner support for recent session logs.
+- Live-shaped fixtures for recent sessions and render marketplace responses.
+
+### Changed
+
+- `services.mr.list`, `services.render.list`, and `services.vpn.list` are now documented and tested as marketplace catalog endpoints rather than session collections.
+- `services.mr.create` now forwards optional `organization_id` and `project_id`.
+- Playground Services now presents marketplace-oriented summaries with normalized bandwidth display.
+- Playground and README copy now reflect live API quirks for app ports, recent sessions, and finished-session logs.
+- Publishing guidance now uses an explicit manual release flow, and the local `rake release` helper is disabled to avoid accidental RubyGems publication.
+
+### Fixed
+
+- Corrected the Ruby public API gap for recent finished-session logs.
+- Corrected MR create handling for `HTTP 200` batch-style rejection responses.
+- Corrected fixtures and tests that modeled `/services/mr` and `/services/vpn` as session lists.
+- Corrected logs fixtures and tests to match the live `{system, container}` payload shape.
+- Corrected the playground Apps page so port counts work when the live API returns stringified JSON arrays.
+- Corrected test coverage for `sessions?recent=true` by using live-shaped string telemetry fields.
+
## [0.1.0] - 2026-04-12
### Added
@@ -19,4 +46,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Rails integration with automatic client sharing and graceful shutdown.
- Interactive Playground app for manual testing and diagnostics.
-[0.1.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...HEAD
+[0.2.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...HEAD
+[0.1.0]: https://github.com/octaspace/ruby-sdk/releases/tag/v0.1.0
diff --git a/PUBLISHING.md b/PUBLISHING.md
index 8ef39aa..7b419ec 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -2,9 +2,29 @@
How to validate, build, and publish the `octaspace` gem.
+Important release policy for this repository:
+
+- there is no GitHub Actions workflow that publishes to RubyGems automatically
+- publish only after the feature PR is reviewed, polished, and merged into `main`
+- publish from a clean local checkout of `main`
+- do not use `rake release` in this repository; build, push, tag, and release explicitly as separate manual steps
+
+## 0. Release Sequence
+
+1. Finish the feature branch work and merge the PR into `main`.
+2. Switch to `main` locally and pull the merged commit.
+3. Update `CHANGELOG.md` and bump `lib/octaspace/version.rb` if that was not already done in the merged branch.
+4. Run verification locally.
+5. Build the gem.
+6. Push the gem to RubyGems manually.
+7. Tag the exact published commit and push the tag.
+8. Create the GitHub Release manually from that tag.
+
## 1. Pre-release Checklist
```bash
+git switch main
+git pull --ff-only
bundle exec rake test # all tests pass
bundle exec standardrb # zero linting violations
gem build octaspace.gemspec # builds without warnings
@@ -15,7 +35,7 @@ gem build octaspace.gemspec # builds without warnings
Install the built gem outside the project and confirm it loads correctly:
```bash
-gem install ./octaspace-0.1.0.gem
+gem install ./octaspace-0.2.0.gem
ruby -e "require 'octaspace'; p OctaSpace::Client.new"
```
@@ -35,24 +55,24 @@ Use these alternatives to verify the gem before publishing:
- **Inspect gem contents:**
```bash
- gem unpack octaspace-0.1.0.gem --target=/tmp/octaspace-check
+ gem unpack octaspace-0.2.0.gem --target=/tmp/octaspace-check
find /tmp/octaspace-check -type f | sort
rm -rf /tmp/octaspace-check
```
- **Check metadata that RubyGems.org will display:**
```bash
- gem specification octaspace-0.1.0.gem
+ gem specification octaspace-0.2.0.gem
```
- **Install and test in a clean environment:**
```bash
- gem install ./octaspace-0.1.0.gem
+ gem install ./octaspace-0.2.0.gem
ruby -e "require 'octaspace'; p OctaSpace::Client.new; puts OctaSpace::VERSION"
```
## 4. Publish to RubyGems.org
```bash
-gem push octaspace-0.1.0.gem
+gem push octaspace-0.2.0.gem
```
You will be prompted for credentials on first push. The API key is saved to `~/.gem/credentials`.
@@ -60,5 +80,6 @@ You will be prompted for credentials on first push. The API key is saved to `~/.
## 5. Post-publish
- Verify the gem page at [rubygems.org/gems/octaspace](https://rubygems.org/gems/octaspace).
-- Tag the release: `git tag v0.1.0 && git push --tags`.
-- Bump `lib/octaspace/version.rb` for the next development cycle.
+- Tag the exact published commit: `git tag v0.2.0 && git push origin v0.2.0`.
+- Create the GitHub Release manually from tag `v0.2.0`.
+- Bump `lib/octaspace/version.rb` for the next development cycle in a follow-up commit.
diff --git a/README.md b/README.md
index e137db4..2b73517 100644
--- a/README.md
+++ b/README.md
@@ -62,17 +62,18 @@ client.sessions.list # GET /sessions
session = client.services.session("uuid-here")
session.info # GET /services/:uuid/info
session.logs # GET /services/:uuid/logs
+session.logs(recent: true) # GET /services/:uuid/logs?recent=true for finished sessions
session.stop(score: 5) # POST /services/:uuid/stop
# Services
-client.services.mr.list # GET /services/mr
+client.services.mr.list # GET /services/mr (marketplace machine catalog)
client.services.mr.create(
node_id: 1,
disk_size: 10,
image: "ubuntu:24.04",
app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"
) # POST /services/mr
-client.services.vpn.list # GET /services/vpn
+client.services.vpn.list # GET /services/vpn (VPN relay catalog)
client.services.vpn.create(node_id: 1, subkind: "wg") # POST /services/vpn
client.services.render.list # GET /services/render
client.services.render.create(node_id: 1, disk_size: 100) # POST /services/render
@@ -80,6 +81,9 @@ client.services.render.create(node_id: 1, disk_size: 100) # POST /services/rende
# Apps
client.apps.list # GET /apps
+# Note: live API may serialize app port lists as JSON strings
+# (for example "[]"). The raw response is preserved by the SDK.
+
# Network
client.network.info # GET /network
@@ -269,9 +273,9 @@ Pages:
|---|---|
| `/playground/account` | Profile + balance |
| `/playground/nodes` | Node list with state badges |
-| `/playground/sessions` | Active sessions |
-| `/playground/services` | Machine Rentals + VPN sessions |
-| `/playground/diagnostics` | Transport mode, pool stats, URL rotator state (auto-refresh every 5s) |
+| `/playground/sessions` | Current + recent sessions, including live-format quirks |
+| `/playground/services` | Marketplace catalogs for MR, Render, and VPN |
+| `/playground/diagnostics` | Direct SDK method runner for contracts, payloads, transport mode, and pool stats |
## Development
@@ -288,7 +292,7 @@ bundle exec rake test # tests only
You can verify the gem in a clean Ruby environment without Rails:
1. Build the gem: `gem build octaspace.gemspec`
-2. Install it locally: `gem install ./octaspace-0.1.0.gem`
+2. Install it locally: `gem install ./octaspace-0.2.0.gem`
3. Test in IRB:
```ruby
@@ -310,7 +314,7 @@ bundle exec appraisal rails-8-0 rake test
### Dummy Application (Playground)
-The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`.`.
+The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`.
To run the dummy app:
diff --git a/Rakefile b/Rakefile
index 8100069..ba4d6e3 100644
--- a/Rakefile
+++ b/Rakefile
@@ -20,7 +20,7 @@ if (requested_tasks & %w[rdoc clobber_rdoc]).any?
end
end
-if (requested_tasks & %w[build install release]).any?
+if (requested_tasks & %w[build install]).any?
require "bundler/gem_tasks"
end
diff --git a/lib/octaspace.rb b/lib/octaspace.rb
index f045f3b..ea61c7d 100644
--- a/lib/octaspace.rb
+++ b/lib/octaspace.rb
@@ -2,6 +2,7 @@
require "octaspace/version"
require "octaspace/errors"
+require "octaspace/payload_helpers"
require "octaspace/response"
require "octaspace/configuration"
require "octaspace/middleware/url_rotator"
diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb
index 0eee380..ca8826e 100644
--- a/lib/octaspace/errors.rb
+++ b/lib/octaspace/errors.rb
@@ -24,6 +24,32 @@ class TimeoutError < NetworkError; end
# API-level errors (HTTP response received, but indicates failure)
class ApiError < Error; end
+ # Domain-level rejection where transport succeeded but the provision request
+ # was rejected by the API payload contract.
+ class ProvisionRejectedError < Error
+ attr_reader :rejections
+
+ def initialize(message = nil, response: nil, rejections: [])
+ @rejections = Array(rejections)
+ super(message || build_message(@rejections), response: response)
+ end
+
+ private
+
+ def build_message(rejections)
+ first_reason =
+ rejections.filter_map do |item|
+ next unless item.is_a?(Hash)
+
+ item["reason"] || item[:reason]
+ end.first
+
+ return "Provision request rejected" if first_reason.to_s.empty?
+
+ "Provision request rejected: #{first_reason}"
+ end
+ end
+
# 401 Unauthorized
class AuthenticationError < ApiError; end
diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb
new file mode 100644
index 0000000..7d120bf
--- /dev/null
+++ b/lib/octaspace/payload_helpers.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require "json"
+
+module OctaSpace
+ module PayloadHelpers
+ module_function
+
+ def parse_port_list(value)
+ case value
+ when nil
+ []
+ when Array
+ value
+ when String
+ parse_stringified_port_list(value)
+ else
+ Array(value)
+ end
+ end
+
+ def normalize_marketplace_bandwidth(value)
+ numeric =
+ case value
+ when nil
+ return nil
+ when Numeric
+ value.to_f
+ when String
+ Float(value)
+ else
+ return value
+ end
+
+ return numeric unless numeric > 100_000
+
+ numeric / 125_000.0
+ rescue ArgumentError, TypeError
+ value
+ end
+
+ def parse_stringified_port_list(value)
+ stripped = value.strip
+ return [] if stripped.empty?
+
+ parsed = JSON.parse(stripped)
+ parsed.is_a?(Array) ? parsed : []
+ rescue JSON::ParserError
+ []
+ end
+ private_class_method :parse_stringified_port_list
+ end
+end
diff --git a/lib/octaspace/playground/payload_presets.yml b/lib/octaspace/playground/payload_presets.yml
index 3f24278..f32387b 100644
--- a/lib/octaspace/playground/payload_presets.yml
+++ b/lib/octaspace/playground/payload_presets.yml
@@ -33,6 +33,14 @@ presets:
uuid: sess-abc-123
score: 5
+ services.session.logs:
+ source:
+ live_api:
+ note: Uses the finished-session recent logs branch confirmed against the live API.
+ payload:
+ uuid: sess-abc-123
+ recent: true
+
idle_jobs.find:
source:
fixture: test/fixtures/idle_jobs/show.json
diff --git a/lib/octaspace/resources/services/machine_rental.rb b/lib/octaspace/resources/services/machine_rental.rb
index d2f1b17..261a6cd 100644
--- a/lib/octaspace/resources/services/machine_rental.rb
+++ b/lib/octaspace/resources/services/machine_rental.rb
@@ -14,7 +14,7 @@ class Services
# app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"
# )
class MachineRental < Base
- # List available / active machine rentals
+ # List available marketplace machines for rent
# GET /services/mr
# @param params [Hash] optional filter params
# @return [OctaSpace::Response]
@@ -40,7 +40,37 @@ def create(**attrs)
entrypoint: attrs[:entrypoint].to_s
}
- post("/services/mr", body: [item])
+ item[:organization_id] = attrs[:organization_id] if attrs.key?(:organization_id)
+ item[:project_id] = attrs[:project_id] if attrs.key?(:project_id)
+
+ response = post("/services/mr", body: [item])
+ raise_if_rejected!(response)
+ response
+ end
+
+ private
+
+ def raise_if_rejected!(response)
+ rejections = extract_rejections(response.data)
+ return if rejections.empty?
+
+ raise OctaSpace::ProvisionRejectedError.new(response: response, rejections: rejections)
+ end
+
+ def extract_rejections(data)
+ return [] unless data.is_a?(Array)
+
+ data.filter_map do |item|
+ next unless item.is_a?(Hash)
+
+ reason = item["reason"] || item[:reason]
+ status = item["status"] || item[:status]
+ uuid = item["uuid"] || item[:uuid]
+ next if uuid
+ next unless reason || status.to_i.positive?
+
+ item
+ end
end
end
end
diff --git a/lib/octaspace/resources/services/session_proxy.rb b/lib/octaspace/resources/services/session_proxy.rb
index add9c89..e4c9670 100644
--- a/lib/octaspace/resources/services/session_proxy.rb
+++ b/lib/octaspace/resources/services/session_proxy.rb
@@ -31,9 +31,14 @@ def info
# Fetch session logs
# GET /services/:uuid/logs
+ # @param recent [Boolean, nil] use the recent logs branch for finished sessions
# @return [OctaSpace::Response]
- def logs
- @transport.get("/services/#{@uuid}/logs")
+ def logs(recent: nil)
+ params = {}
+ params[:recent] = true if recent
+ return @transport.get("/services/#{@uuid}/logs") if params.empty?
+
+ @transport.get("/services/#{@uuid}/logs", params:)
end
# Stop the session
diff --git a/lib/octaspace/resources/services/vpn.rb b/lib/octaspace/resources/services/vpn.rb
index 587b234..00d114c 100644
--- a/lib/octaspace/resources/services/vpn.rb
+++ b/lib/octaspace/resources/services/vpn.rb
@@ -9,7 +9,7 @@ class Services
# client.services.vpn.list
# client.services.vpn.create(node_id: 123)
class Vpn < Base
- # List active VPN sessions
+ # List available VPN relay nodes
# GET /services/vpn
# @param params [Hash] optional filter params
# @return [OctaSpace::Response]
diff --git a/lib/octaspace/version.rb b/lib/octaspace/version.rb
index 44347b4..475e29d 100644
--- a/lib/octaspace/version.rb
+++ b/lib/octaspace/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module OctaSpace
- VERSION = "0.1.0"
+ VERSION = "0.2.0"
end
diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb
index fad62ce..b9495db 100644
--- a/test/dummy/app/controllers/application_controller.rb
+++ b/test/dummy/app/controllers/application_controller.rb
@@ -19,6 +19,7 @@ class ApplicationController < ActionController::Base
:playground_format_bytes,
:playground_format_datetime,
:playground_format_duration,
+ :playground_format_mbps,
:playground_format_short_time,
:playground_json,
:playground_json_html,
@@ -190,6 +191,13 @@ def playground_format_bytes(value)
format("%.1f %s", bytes, units[index])
end
+ def playground_format_mbps(value)
+ normalized = OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(value)
+ return "—" if normalized.blank?
+
+ format("%.1f Mbps", normalized)
+ end
+
def playground_format_datetime(value)
return "—" if value.blank?
diff --git a/test/dummy/app/controllers/playground/diagnostics_controller.rb b/test/dummy/app/controllers/playground/diagnostics_controller.rb
index 1afc299..31185e0 100644
--- a/test/dummy/app/controllers/playground/diagnostics_controller.rb
+++ b/test/dummy/app/controllers/playground/diagnostics_controller.rb
@@ -30,7 +30,7 @@ class DiagnosticsController < BaseController
{
id: "apps.list",
label: "octa_client.apps.list",
- description: "GET /apps — requires API key",
+ description: "GET /apps — requires API key; live API may serialize ports/http_ports as JSON strings",
method: "GET",
path: "/apps",
requires_auth: true
@@ -72,15 +72,24 @@ class DiagnosticsController < BaseController
{
id: "sessions.list.recent",
label: "octa_client.sessions.list(recent: true)",
- description: "GET /sessions?recent=true — requires API key",
+ description: "GET /sessions?recent=true — requires API key; live telemetry fields may be serialized as strings",
method: "GET",
path: "/sessions?recent=true",
requires_auth: true
},
+ {
+ id: "services.session.logs",
+ label: "octa_client.services.session(uuid).logs",
+ description: "GET /services/:uuid/logs — for finished sessions use recent=true",
+ method: "GET",
+ path: "/services/:uuid/logs",
+ requires_auth: true,
+ payload: true
+ },
{
id: "services.mr.list",
label: "octa_client.services.mr.list",
- description: "GET /services/mr — requires API key",
+ description: "GET /services/mr — requires API key; returns marketplace machine catalog",
method: "GET",
path: "/services/mr",
requires_auth: true
@@ -88,7 +97,7 @@ class DiagnosticsController < BaseController
{
id: "services.render.list",
label: "octa_client.services.render.list",
- description: "GET /services/render — requires API key",
+ description: "GET /services/render — requires API key; returns render marketplace catalog",
method: "GET",
path: "/services/render",
requires_auth: true
@@ -96,7 +105,7 @@ class DiagnosticsController < BaseController
{
id: "services.vpn.list",
label: "octa_client.services.vpn.list",
- description: "GET /services/vpn — requires API key",
+ description: "GET /services/vpn — requires API key; returns VPN relay catalog",
method: "GET",
path: "/services/vpn",
requires_auth: true
@@ -104,7 +113,7 @@ class DiagnosticsController < BaseController
{
id: "services.mr.create",
label: "octa_client.services.mr.create",
- description: "POST /services/mr — requires API key",
+ description: "POST /services/mr — requires API key; live API uses array payloads and may reject item-level with HTTP 200",
method: "POST",
path: "/services/mr",
requires_auth: true,
@@ -218,6 +227,9 @@ def execute_call(call)
octa_client.sessions.list
when "sessions.list.recent"
octa_client.sessions.list(recent: true)
+ when "services.session.logs"
+ uuid = payload.fetch(:uuid).to_s
+ octa_client.services.session(uuid).logs(recent: payload[:recent])
when "services.mr.list"
octa_client.services.mr.list
when "services.render.list"
@@ -274,7 +286,7 @@ def parse_payload!(call, payload_json)
def validate_mutation_payload!(call, payload)
case call[:id]
- when "services.session.stop"
+ when "services.session.logs", "services.session.stop"
raise OctaSpace::ValidationError.new("Payload must include a non-empty uuid") if payload[:uuid].to_s.strip.empty?
when "idle_jobs.find", "idle_jobs.logs"
raise OctaSpace::ValidationError.new("Payload must include node_id") if payload[:node_id].to_s.strip.empty?
@@ -284,6 +296,9 @@ def validate_mutation_payload!(call, payload)
def resolved_path(call, payload)
case call[:id]
+ when "services.session.logs"
+ path = "/services/#{payload.fetch(:uuid)}/logs"
+ payload[:recent] ? "#{path}?recent=true" : path
when "services.session.stop"
"/services/#{payload.fetch(:uuid)}/stop"
when "idle_jobs.find"
diff --git a/test/dummy/app/controllers/playground/services_controller.rb b/test/dummy/app/controllers/playground/services_controller.rb
index 5325747..f324800 100644
--- a/test/dummy/app/controllers/playground/services_controller.rb
+++ b/test/dummy/app/controllers/playground/services_controller.rb
@@ -4,9 +4,9 @@ module Playground
class ServicesController < BaseController
def show
@active_tab = params[:tab].presence_in(%w[mr render vpn]) || "mr"
- @mr_sessions = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list }
- @render_sessions = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list }
- @vpn_sessions = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list }
+ @mr_catalog = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list }
+ @render_catalog = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list }
+ @vpn_catalog = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list }
end
end
end
diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb
index 9bf1611..62eb156 100644
--- a/test/dummy/app/views/layouts/application.html.erb
+++ b/test/dummy/app/views/layouts/application.html.erb
@@ -116,7 +116,11 @@
+ <% if apps_data.any? %>
+
+ Raw JSON
+
+ <% end %>
<% if playground_mock_active? %>
Mock: <%= playground_mock_scenario %>
<% end %>
@@ -52,7 +62,10 @@
"><%= app["uuid"].presence || "—" %>
<%= app["image"] || app["docker_image"] || "—" %>
- <%= [Array(app["ports"]).length, Array(app["http_ports"]).length].sum %>
+ <%= [
+ OctaSpace::PayloadHelpers.parse_port_list(app["ports"]).length,
+ OctaSpace::PayloadHelpers.parse_port_list(app["http_ports"]).length
+ ].sum %>
<% if app_uuid.present? %>
@@ -70,7 +83,9 @@
-
+
Raw JSON
<%= render "playground/shared/json_viewer", data: apps_data, max_height: "max-h-80" %>
diff --git a/test/dummy/app/views/playground/diagnostics/show.html.erb b/test/dummy/app/views/playground/diagnostics/show.html.erb
index 1d71016..4169332 100644
--- a/test/dummy/app/views/playground/diagnostics/show.html.erb
+++ b/test/dummy/app/views/playground/diagnostics/show.html.erb
@@ -28,10 +28,12 @@
SDK Method
-
+
<% @calls.each do |call| %>
<% selected = call[:id] == @selected_call_id %>
-
+
<%= link_to(playground_diagnostics_path(call: call[:id]), class: "block") do %>
@@ -59,7 +61,7 @@
rows: 8,
spellcheck: false,
class: "w-full rounded-lg border border-slate-800 bg-slate-950/80 px-3 py-2 font-mono text-[11px] leading-5 text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %>
-
Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop, include uuid. For idle_jobs.*, include node_id and job_id.
+
Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop and services.session.logs, include uuid. For services.session.logs, set recent: true when checking finished sessions. For services.mr.create, optional organization_id and project_id are forwarded. For idle_jobs.*, include node_id and job_id.
<% end %>
<%= button_tag "Run Method", type: "submit", class: "w-full rounded border border-cyan-500/20 bg-cyan-500/10 py-1.5 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %>
diff --git a/test/dummy/app/views/playground/services/show.html.erb b/test/dummy/app/views/playground/services/show.html.erb
index d8f63ea..7e91cf3 100644
--- a/test/dummy/app/views/playground/services/show.html.erb
+++ b/test/dummy/app/views/playground/services/show.html.erb
@@ -1,20 +1,49 @@
-<% mr_data = @mr_sessions.respond_to?(:data) ? Array(@mr_sessions.data) : [] %>
-<% render_data = @render_sessions.respond_to?(:data) ? Array(@render_sessions.data) : [] %>
-<% vpn_data = @vpn_sessions.respond_to?(:data) ? Array(@vpn_sessions.data) : [] %>
+<% mr_data = @mr_catalog.respond_to?(:data) ? Array(@mr_catalog.data) : [] %>
+<% render_data = @render_catalog.respond_to?(:data) ? Array(@render_catalog.data) : [] %>
+<% vpn_data = @vpn_catalog.respond_to?(:data) ? Array(@vpn_catalog.data) : [] %>
<% tabs = [
- {id: "mr", label: "Machine Rental", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_sessions if @mr_sessions.is_a?(OctaSpace::Error))},
- {id: "render", label: "Render", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_sessions if @render_sessions.is_a?(OctaSpace::Error))},
- {id: "vpn", label: "VPN", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_sessions if @vpn_sessions.is_a?(OctaSpace::Error))}
+ {id: "mr", label: "Machine Rental Marketplace", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_catalog if @mr_catalog.is_a?(OctaSpace::Error))},
+ {id: "render", label: "Render Marketplace", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_catalog if @render_catalog.is_a?(OctaSpace::Error))},
+ {id: "vpn", label: "VPN Relay Marketplace", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_catalog if @vpn_catalog.is_a?(OctaSpace::Error))}
] %>
<% active = tabs.find { |tab| tab[:id] == @active_tab } || tabs.first %>
<% active_status = active[:error] ? "error" : "success" %>
+<%
+ offer_label = lambda do |tab_id, item|
+ case tab_id
+ when "vpn"
+ item["residential"] ? "Residential" : "Datacenter"
+ else
+ item["gpu"].presence || item["gpus"]&.first&.dig("model") || "CPU"
+ end
+ end
+
+ pricing_label = lambda do |tab_id, item|
+ case tab_id
+ when "vpn"
+ usd = item["traffic_price_usd"]
+ ether = item["traffic_price_ether"]
+ parts = []
+ parts << "$#{usd}/GB" if usd.present?
+ parts << "#{ether} ETH/GB" if ether.present?
+ parts.presence&.join(" · ") || "—"
+ else
+ base = item["base_usd"] || item["base"]
+ traffic = item["traffic_usd"] || item["traffic"]
+ parts = []
+ parts << "$#{base}/hr" if base.present?
+ parts << "$#{traffic}/GB" if traffic.present?
+ parts.presence&.join(" · ") || "—"
+ end
+ end
+%>
Services
- Available resources for each service type. Uses the Ruby SDK client against the currently selected transport.
+ Marketplace catalog endpoints for MR, Render, and VPN. Uses the Ruby SDK client against the currently selected transport.
@@ -29,6 +58,10 @@
Start mutations (mr.create, render.create, vpn.create) exist on octa_client.services.* but are intentionally not wired up here — use Diagnostics for manual request inspection.
+
+ Bandwidth below is normalized for display with OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth when live payload values look like raw bytes-per-second. Raw JSON remains unchanged below.
+
+
<% tabs.each do |tab| %>
@@ -55,6 +88,37 @@
<%= active[:data].is_a?(Array) ? "#{active[:data].length} items" : "#{active[:data].to_h.keys.length} keys" %>
+ <% if active[:data].is_a?(Array) && active[:data].any? %>
+
+
Marketplace Summary
+
+
+
+
+ Node
+ Location
+ Offer
+ Bandwidth ↓/↑
+ Pricing
+
+
+
+ <% active[:data].each do |item| %>
+
+ #<%= item["node_id"] || "—" %>
+ <%= [item["city"], item["country"]].compact_blank.join(", ").presence || "—" %>
+ <%= offer_label.call(active[:id], item) %>
+
+ <%= playground_format_mbps(item["net_down_mbits"]) %> / <%= playground_format_mbps(item["net_up_mbits"]) %>
+
+ <%= pricing_label.call(active[:id], item) %>
+
+ <% end %>
+
+
+
+
+ <% end %>
<%= render "playground/shared/json_viewer", data: active[:data], max_height: "max-h-[32rem]" %>
diff --git a/test/dummy/app/views/playground/sessions/index.html.erb b/test/dummy/app/views/playground/sessions/index.html.erb
index 38399d7..b0e64cf 100644
--- a/test/dummy/app/views/playground/sessions/index.html.erb
+++ b/test/dummy/app/views/playground/sessions/index.html.erb
@@ -59,6 +59,12 @@
<% end %>
+ <% if active_recent %>
+
+ Recent session payloads may serialize telemetry like duration, rx, and tx as strings. For finished-session logs, use Diagnostics with services.session.logs and recent: true.
+
+ <% end %>
+
<% tabs.each do |tab| %>
diff --git a/test/dummy/public/playground.css b/test/dummy/public/playground.css
index a662644..5b28ece 100644
--- a/test/dummy/public/playground.css
+++ b/test/dummy/public/playground.css
@@ -8,3 +8,31 @@
::-webkit-scrollbar-track { background: rgb(15 23 42); }
::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); }
+
+/* Page loader ----------------------------------------------------------------
+ Visibility is controlled from JS after 0.4 s, so fast responses never
+ produce a visible flash. */
+#page-loader {
+ display: none;
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(2, 6, 23, 0.78);
+}
+#page-loader.loader-active {
+ display: flex;
+ opacity: 1;
+}
+
+.loader-spinner {
+ width: 28px;
+ height: 28px;
+ border: 2.5px solid rgba(148, 163, 184, 0.18);
+ border-top-color: rgb(34, 211, 238);
+ border-radius: 50%;
+ animation: loader-spin 0.7s linear infinite;
+}
+@keyframes loader-spin { to { transform: rotate(360deg); } }
diff --git a/test/dummy/public/playground.js b/test/dummy/public/playground.js
index 52c41b1..c5133ce 100644
--- a/test/dummy/public/playground.js
+++ b/test/dummy/public/playground.js
@@ -32,3 +32,114 @@ document.addEventListener("change", (event) => {
field.form.requestSubmit()
})
+
+// Page loader: appears only after 400 ms of waiting, so fast responses do not flash.
+const LOADER_SKIP = ["/playground/settings", "/playground/request-log"]
+const LOADER_DELAY_MS = 400
+let loaderTimer = null
+
+const activateLoader = () => {
+ const element = document.getElementById("page-loader")
+ if (!element) return
+
+ element.classList.add("loader-active")
+ element.setAttribute("aria-hidden", "false")
+}
+
+const scheduleLoader = () => {
+ if (loaderTimer !== null) return
+
+ loaderTimer = window.setTimeout(() => {
+ loaderTimer = null
+ activateLoader()
+ }, LOADER_DELAY_MS)
+}
+
+const hideLoader = () => {
+ if (loaderTimer !== null) {
+ window.clearTimeout(loaderTimer)
+ loaderTimer = null
+ }
+
+ const element = document.getElementById("page-loader")
+ if (!element) return
+
+ element.classList.remove("loader-active")
+ element.setAttribute("aria-hidden", "true")
+}
+
+document.addEventListener("submit", (event) => {
+ const form = event.target
+ if (!form || !form.action) return
+
+ try {
+ const pathname = new URL(form.action, window.location.href).pathname
+ if (LOADER_SKIP.some((value) => pathname === value)) return
+ } catch (_error) {
+ return
+ }
+
+ scheduleLoader()
+})
+
+document.addEventListener("click", (event) => {
+ if (!event.target.closest("[data-scroll-target]")) {
+ if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
+ const link = event.target.closest("a[href]")
+
+ if (link) {
+ const href = link.getAttribute("href")
+
+ if (href && !href.startsWith("#") && !href.startsWith("mailto:") && link.target !== "_blank") {
+ try {
+ const url = new URL(href, window.location.href)
+ if (url.origin === window.location.origin) {
+ scheduleLoader()
+ }
+ } catch (_error) {
+ }
+ }
+ }
+ }
+ }
+
+ const trigger = event.target.closest("[data-scroll-target]")
+ if (!trigger) return
+
+ const targetId = trigger.dataset.scrollTarget
+ if (!targetId) return
+
+ const target = document.getElementById(targetId)
+ if (!target) return
+
+ event.preventDefault()
+ target.scrollIntoView({behavior: "smooth", block: "start"})
+
+ if (typeof target.focus === "function") {
+ target.focus({preventScroll: true})
+ }
+})
+
+const syncDiagnosticsSelection = () => {
+ const list = document.querySelector("[data-diagnostics-list]")
+ const selected = document.querySelector("[data-diagnostics-item][data-selected='true']")
+ if (!list || !selected) return
+
+ window.requestAnimationFrame(() => {
+ selected.scrollIntoView({block: "nearest", inline: "nearest"})
+ })
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", syncDiagnosticsSelection, {once: true})
+} else {
+ syncDiagnosticsSelection()
+}
+
+window.addEventListener("pageshow", (event) => {
+ if (event.persisted) {
+ hideLoader()
+ }
+})
+
+window.addEventListener("pagehide", hideLoader)
diff --git a/test/dummy_diagnostics_test.rb b/test/dummy_diagnostics_test.rb
index 0d12b16..912db61 100644
--- a/test/dummy_diagnostics_test.rb
+++ b/test/dummy_diagnostics_test.rb
@@ -50,6 +50,22 @@ def test_diagnostics_run_executes_session_stop_mutation
assert_includes @session.response.body, "stopped"
end
+ def test_diagnostics_run_executes_recent_session_logs_call
+ payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs")
+ stub_request(:get, "https://api.octa.space/services/sess-abc-123/logs")
+ .with(query: {"recent" => "true"})
+ .to_return(status: 200, body: fixture("services/session/logs.json"), headers: {"Content-Type" => "application/json"})
+
+ @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"})
+ @session.post("/playground/diagnostics/run", params: {call: "services.session.logs", payload_json: JSON.pretty_generate(payload)})
+
+ assert_equal 200, @session.response.status
+ assert_includes @session.response.body, "Success"
+ assert_includes @session.response.body, "octa_client.services.session(uuid).logs"
+ assert_includes @session.response.body, "services/sess-abc-123/logs?recent=true"
+ assert_includes @session.response.body, "Container started"
+ end
+
def test_diagnostics_run_reports_invalid_payload_json
@session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"})
@session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: "{not json}"})
@@ -73,14 +89,14 @@ def test_diagnostics_smoke_runs_read_only_sdk_smoke_and_renders_json
stub_request(:get, "https://api.octa.space/services/mr")
.to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"})
stub_request(:get, "https://api.octa.space/services/render")
- .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"})
+ .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"})
stub_request(:get, "https://api.octa.space/services/vpn")
.to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"})
stub_request(:get, "https://api.octa.space/sessions")
.to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"})
stub_request(:get, "https://api.octa.space/sessions")
.with(query: {"recent" => "true"})
- .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"})
+ .to_return(status: 200, body: fixture("sessions/recent.json"), headers: {"Content-Type" => "application/json"})
@session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"})
@session.post("/playground/diagnostics/smoke", params: {call: "network.info"})
@@ -117,4 +133,14 @@ def test_diagnostics_show_prefills_payload_from_query_string
assert_includes @session.response.body, "249b4cb3-3db1-4c06-98a4-772ba88cd81c"
assert_includes @session.response.body, "ubuntu:24.04"
end
+
+ def test_diagnostics_show_marks_selected_card_for_scroll_restoration
+ @session.get("/playground/diagnostics", params: {call: "services.session.logs"})
+
+ assert_equal 200, @session.response.status
+ assert_includes @session.response.body, "data-diagnostics-list"
+ assert_includes @session.response.body, "data-diagnostics-item"
+ assert_includes @session.response.body, 'data-selected="true"'
+ assert_includes @session.response.body, "octa_client.services.session(uuid).logs"
+ end
end
diff --git a/test/dummy_resources_test.rb b/test/dummy_resources_test.rb
index cfc4323..04330b3 100644
--- a/test/dummy_resources_test.rb
+++ b/test/dummy_resources_test.rb
@@ -27,6 +27,8 @@ def test_apps_page_renders_app_catalog
assert_includes @session.response.body, "Use in MR Create"
assert_includes @session.response.body, CGI.escape("249b4cb3-3db1-4c06-98a4-772ba88cd81c")
assert_includes @session.response.body, CGI.escape("ubuntu:24.04")
+ assert_includes @session.response.body, 'data-scroll-target="apps-raw-json"'
+ assert_includes @session.response.body, 'id="apps-raw-json"'
end
def test_layout_brand_link_points_to_root
@@ -38,6 +40,8 @@ def test_layout_brand_link_points_to_root
assert_equal 200, @session.response.status
assert_includes @session.response.body, 'href="/"'
assert_includes @session.response.body, "SDK Playground"
+ assert_includes @session.response.body, 'id="page-loader"'
+ assert_includes @session.response.body, "Loading…"
end
def test_idle_jobs_page_renders_status_and_logs
@@ -53,4 +57,20 @@ def test_idle_jobs_page_renders_status_and_logs
assert_includes @session.response.body, "Training complete."
assert_includes @session.response.body, "completed"
end
+
+ def test_services_page_renders_marketplace_summary
+ stub_request(:get, "https://api.octa.space/services/mr")
+ .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"})
+ stub_request(:get, "https://api.octa.space/services/render")
+ .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"})
+ stub_request(:get, "https://api.octa.space/services/vpn")
+ .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"})
+
+ @session.get("/playground/services")
+
+ assert_equal 200, @session.response.status
+ assert_includes @session.response.body, "Machine Rental Marketplace"
+ assert_includes @session.response.body, "Marketplace Summary"
+ assert_includes @session.response.body, "6977.8 Mbps"
+ end
end
diff --git a/test/fixtures/apps/index.json b/test/fixtures/apps/index.json
index 76382d9..ab250f0 100644
--- a/test/fixtures/apps/index.json
+++ b/test/fixtures/apps/index.json
@@ -7,7 +7,9 @@
"category": "AI",
"image": "ubuntu:24.04",
"docker_image": "octaspace/stable-diffusion:2.1",
- "min_vram_gb": 8
+ "min_vram_gb": 8,
+ "ports": "[5000,6000]",
+ "http_ports": "[8080]"
},
{
"id": 2,
@@ -17,6 +19,8 @@
"category": "AI",
"image": "ubuntu:22.04",
"docker_image": "octaspace/comfyui:latest",
- "min_vram_gb": 6
+ "min_vram_gb": 6,
+ "ports": "[]",
+ "http_ports": "[]"
}
]
diff --git a/test/fixtures/services/mr/index.json b/test/fixtures/services/mr/index.json
index dc5d335..f129952 100644
--- a/test/fixtures/services/mr/index.json
+++ b/test/fixtures/services/mr/index.json
@@ -1,11 +1,21 @@
[
{
- "uuid": "mr-sess-111",
- "node_id": 5,
- "app_id": 1,
- "app_name": "Stable Diffusion",
- "status": "running",
- "started_at": "2026-04-11T10:00:00Z",
- "urls": {"jupyter": "https://mr-sess-111.octa.space"}
+ "node_id": 9010,
+ "country": "France",
+ "city": "Paris",
+ "net_down_mbits": 872229553,
+ "net_up_mbits": 62488613,
+ "gpu": "NVIDIA GeForce RTX 5090",
+ "base_usd": 3400,
+ "traffic_usd": 1,
+ "reliability": {
+ "uptime": 100,
+ "rating": {"count": 0, "score": 0},
+ "s_avg_d": 10117,
+ "s_failed": 1,
+ "s_max_d": 366353,
+ "s_normal": 381,
+ "stability": 99.74
+ }
}
]
diff --git a/test/fixtures/services/render/index.json b/test/fixtures/services/render/index.json
new file mode 100644
index 0000000..012e2a8
--- /dev/null
+++ b/test/fixtures/services/render/index.json
@@ -0,0 +1,13 @@
+[
+ {
+ "node_id": 9042,
+ "country": "Germany",
+ "city": "Frankfurt",
+ "gpu": "NVIDIA RTX A6000",
+ "net_down_mbits": 125000000,
+ "net_up_mbits": 62500000,
+ "base_usd": 1200,
+ "traffic_usd": 1,
+ "reliability": 99.5
+ }
+]
diff --git a/test/fixtures/services/session/logs.json b/test/fixtures/services/session/logs.json
index 7f0777e..a7c87b9 100644
--- a/test/fixtures/services/session/logs.json
+++ b/test/fixtures/services/session/logs.json
@@ -1,5 +1,13 @@
-[
- {"timestamp": "2026-04-11T08:00:01Z", "level": "info", "message": "Container started"},
- {"timestamp": "2026-04-11T08:00:05Z", "level": "info", "message": "Model loaded"},
- {"timestamp": "2026-04-11T08:01:00Z", "level": "debug", "message": "First inference complete"}
-]
+{
+ "system": [
+ {
+ "ts": 1776161866508,
+ "msg": "24.04: Pulling from library/ubuntu\nDigest: sha256:84e77dee7d1bc93fb029a45e3c6cb9d8aa4831ccfcc7103d36e876938d28895b\nStatus: Image is up to date for ubuntu:24.04\n"
+ },
+ {
+ "ts": 1776161866701,
+ "msg": "Container started\n"
+ }
+ ],
+ "container": ""
+}
diff --git a/test/fixtures/services/vpn/index.json b/test/fixtures/services/vpn/index.json
index 197b0de..d946171 100644
--- a/test/fixtures/services/vpn/index.json
+++ b/test/fixtures/services/vpn/index.json
@@ -1,9 +1,12 @@
[
{
- "uuid": "vpn-sess-222",
- "node_id": 7,
- "status": "active",
- "ip": "10.8.0.2",
- "started_at": "2026-04-11T09:00:00Z"
+ "node_id": 9539,
+ "country": "Poland",
+ "city": "Zakrzew",
+ "residential": false,
+ "net_down_mbits": 0,
+ "net_up_mbits": 0,
+ "traffic_price_usd": 0.005366743801677206,
+ "traffic_price_ether": 0.05
}
]
diff --git a/test/fixtures/sessions/recent.json b/test/fixtures/sessions/recent.json
new file mode 100644
index 0000000..9fcdfcf
--- /dev/null
+++ b/test/fixtures/sessions/recent.json
@@ -0,0 +1,13 @@
+[
+ {
+ "uuid": "5dd2d308-db91-4bda-86ff-e512d711b363",
+ "service": "mr",
+ "duration": "28",
+ "rx": "0",
+ "tx": "0",
+ "started_at": 1776161866303,
+ "finished_at": 1776161893995,
+ "charge_amount": 0,
+ "urls": {"8080": "https://sess-abc-123.octa.space"}
+ }
+]
diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb
new file mode 100644
index 0000000..9699653
--- /dev/null
+++ b/test/octaspace/payload_helpers_test.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class OctaSpace::PayloadHelpersTest < Minitest::Test
+ def test_parse_port_list_accepts_arrays
+ assert_equal [5000, 6000], OctaSpace::PayloadHelpers.parse_port_list([5000, 6000])
+ end
+
+ def test_parse_port_list_parses_json_strings
+ assert_equal [8080, 8888], OctaSpace::PayloadHelpers.parse_port_list("[8080,8888]")
+ end
+
+ def test_parse_port_list_returns_empty_array_for_invalid_string
+ assert_equal [], OctaSpace::PayloadHelpers.parse_port_list("not-json")
+ end
+
+ def test_parse_port_list_returns_empty_array_for_nil
+ assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil)
+ end
+
+ def test_normalize_marketplace_bandwidth_preserves_small_values
+ assert_equal 250.0, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(250)
+ end
+
+ def test_normalize_marketplace_bandwidth_converts_large_values
+ assert_in_delta 6977.836424, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(872_229_553), 0.000001
+ end
+
+ def test_normalize_marketplace_bandwidth_returns_nil_for_nil
+ assert_nil OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(nil)
+ end
+end
diff --git a/test/octaspace/playground/payload_presets_test.rb b/test/octaspace/playground/payload_presets_test.rb
index d476a9b..1753cdb 100644
--- a/test/octaspace/playground/payload_presets_test.rb
+++ b/test/octaspace/playground/payload_presets_test.rb
@@ -23,4 +23,11 @@ def test_payload_json_for_returns_pretty_json_object
assert_includes json, "\"uuid\""
assert_equal({"uuid" => "sess-abc-123", "score" => 5}, JSON.parse(json))
end
+
+ def test_logs_preset_defaults_to_recent_finished_session_flow
+ payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs")
+
+ assert_equal "sess-abc-123", payload[:uuid]
+ assert_equal true, payload[:recent]
+ end
end
diff --git a/test/octaspace/playground/smoke_runner_test.rb b/test/octaspace/playground/smoke_runner_test.rb
index 004c62b..344f223 100644
--- a/test/octaspace/playground/smoke_runner_test.rb
+++ b/test/octaspace/playground/smoke_runner_test.rb
@@ -16,12 +16,12 @@ def test_run_returns_passed_summary_for_read_only_suites
stub_get("/apps", fixture_path: "apps/index.json")
stub_get("/nodes", fixture_path: "nodes/index.json")
stub_get("/services/mr", fixture_path: "services/mr/index.json")
- stub_get("/services/render", fixture_path: "services/mr/index.json")
+ stub_get("/services/render", fixture_path: "services/render/index.json")
stub_get("/services/vpn", fixture_path: "services/vpn/index.json")
stub_get("/sessions", fixture_path: "sessions/index.json")
stub_request(:get, "#{StubHelpers::BASE_URL}/sessions")
.with(query: {"recent" => "true"})
- .to_return(status: 200, body: fixture("sessions/index.json"), headers: json_headers)
+ .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers)
result = OctaSpace::Playground::SmokeRunner.new(client: @client).run
diff --git a/test/octaspace/resources/apps_test.rb b/test/octaspace/resources/apps_test.rb
index cb4de5d..9ed104a 100644
--- a/test/octaspace/resources/apps_test.rb
+++ b/test/octaspace/resources/apps_test.rb
@@ -15,6 +15,8 @@ def test_list_returns_array
assert_kind_of Array, response.data
assert_equal 2, response.data.size
assert_equal "Stable Diffusion", response.data.first["name"]
+ assert_equal "[5000,6000]", response.data.first["ports"]
+ assert_equal "[8080]", response.data.first["http_ports"]
end
def test_list_passes_params
diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb
index 70fb4ee..45fa89e 100644
--- a/test/octaspace/resources/services_test.rb
+++ b/test/octaspace/resources/services_test.rb
@@ -28,11 +28,16 @@ def test_mr_list_returns_array
response = @client.services.mr.list
assert response.success?
assert_kind_of Array, response.data
- assert_equal "mr-sess-111", response.data.first["uuid"]
+ assert_equal 9010, response.data.first["node_id"]
+ assert_equal "France", response.data.first["country"]
+ assert_kind_of Hash, response.data.first["reliability"]
end
def test_mr_create_returns_success
- payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create")
+ payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge(
+ organization_id: 77,
+ project_id: 88
+ )
stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr")
.with(body: [
{
@@ -45,13 +50,38 @@ def test_mr_create_returns_success
ports: [],
http_ports: [],
start_command: "",
- entrypoint: ""
+ entrypoint: "",
+ organization_id: 77,
+ project_id: 88
}
].to_json)
.to_return(status: 201, body: '{"uuid":"new-sess","status":"starting"}', headers: json_headers)
response = @client.services.mr.create(**payload)
assert response.success?
+ assert_equal "new-sess", response.data["uuid"]
+ end
+
+ def test_mr_create_raises_api_error_on_400_object_rejection
+ payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create")
+ stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr")
+ .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers)
+
+ assert_raises(OctaSpace::ApiError) { @client.services.mr.create(**payload) }
+ end
+
+ def test_mr_create_raises_provision_rejected_error_on_batch_rejection
+ payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create")
+ stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr")
+ .to_return(
+ status: 200,
+ body: '[{"id":0,"reason":"Node not found","status":1}]',
+ headers: json_headers
+ )
+
+ error = assert_raises(OctaSpace::ProvisionRejectedError) { @client.services.mr.create(**payload) }
+ assert_includes error.message, "Node not found"
+ assert_equal 1, error.rejections.length
end
def test_mr_list_raises_not_found_on_404
@@ -66,7 +96,8 @@ def test_vpn_list_returns_array
response = @client.services.vpn.list
assert response.success?
assert_kind_of Array, response.data
- assert_equal "vpn-sess-222", response.data.first["uuid"]
+ assert_equal 9539, response.data.first["node_id"]
+ refute response.data.first["residential"]
end
def test_vpn_create_returns_success
@@ -79,9 +110,10 @@ def test_vpn_create_returns_success
# --- Render ---
def test_render_list_returns_success
- stub_get("/services/render", status: 200, fixture_path: "services/mr/index.json")
+ stub_get("/services/render", status: 200, fixture_path: "services/render/index.json")
response = @client.services.render.list
assert response.success?
+ assert_equal "NVIDIA RTX A6000", response.data.first["gpu"]
end
def test_render_create_returns_success
@@ -105,7 +137,18 @@ def test_session_proxy_logs
stub_get("/services/sess-abc-123/logs", fixture_path: "services/session/logs.json")
response = @client.services.session("sess-abc-123").logs
assert response.success?
- assert_kind_of Array, response.data
+ assert_kind_of Hash, response.data
+ assert_kind_of Array, response.data["system"]
+ end
+
+ def test_session_proxy_logs_with_recent_flag
+ stub_request(:get, "#{StubHelpers::BASE_URL}/services/sess-abc-123/logs")
+ .with(query: {"recent" => "true"})
+ .to_return(status: 200, body: fixture("services/session/logs.json"), headers: json_headers)
+
+ response = @client.services.session("sess-abc-123").logs(recent: true)
+ assert response.success?
+ assert_equal "", response.data["container"]
end
def test_session_proxy_stop
diff --git a/test/octaspace/resources/sessions_test.rb b/test/octaspace/resources/sessions_test.rb
index cd5a32c..426c850 100644
--- a/test/octaspace/resources/sessions_test.rb
+++ b/test/octaspace/resources/sessions_test.rb
@@ -19,9 +19,11 @@ def test_list_returns_array
def test_list_passes_params
stub_request(:get, "#{StubHelpers::BASE_URL}/sessions")
.with(query: {"recent" => "true"})
- .to_return(status: 200, body: "[]", headers: json_headers)
+ .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers)
response = @client.sessions.list(recent: true)
assert response.success?
+ assert_equal "28", response.data.first["duration"]
+ assert_equal "0", response.data.first["rx"]
end
def test_list_raises_authentication_error_on_401
diff --git a/test/test_helper.rb b/test/test_helper.rb
index cf3b0dc..bf1c5be 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -4,6 +4,7 @@
require "octaspace"
require "octaspace/transport/mock_transport"
+require "octaspace/playground/payload_presets"
require "minitest/autorun"
require "minitest/mock"
require "webmock/minitest"