diff --git a/docs/sandboxes.md b/docs/sandboxes.md index c79b1907..d3484760 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -34,6 +34,8 @@ when supplied by the server. ## Keep a session +`session.expires_at` is the time the session ends, or `nil` when it has no lifetime limit (a local-mode session), so check for `nil` before using it as a time. + ```ruby client.sandboxes.create( project_id, region: 'us-east-1', preset: 'python3.12', max_duration_seconds: 300 @@ -83,7 +85,7 @@ template's configured memory. Only trusted backend code should call `client.sandboxes.grant(session_id, auth_user_id, expires_at: expiry)` or `client.sandboxes.revoke(session_id, auth_user_id)`. Supply a `Time` or ISO8601 -string for `expiry`; returned session and access expiries are `Time` values. Project users can access only +string for `expiry`; a returned access expiry is a `Time`, and a session expiry is a `Time` or `nil` when the session has no lifetime limit. Project users can access only sessions explicitly granted to them; they cannot create or manage sessions. Service keys stay on the backend. The preset catalog is public; anonymous keys cannot create or manage sessions. diff --git a/lib/volcano/sandbox_response.rb b/lib/volcano/sandbox_response.rb index 81f7a24b..0a593916 100644 --- a/lib/volcano/sandbox_response.rb +++ b/lib/volcano/sandbox_response.rb @@ -18,6 +18,11 @@ def self.timestamp(value) raise TypeError, 'Invalid Sandbox timestamp' end + # A session with no lifetime limit, such as a local-mode session, has no expiry. + def self.nullable_timestamp(value) + value.nil? ? nil : timestamp(value) + end + def self.integer(value) return value if value.is_a?(Integer) diff --git a/lib/volcano/sandbox_session.rb b/lib/volcano/sandbox_session.rb index 2575b353..c6e6efd4 100644 --- a/lib/volcano/sandbox_session.rb +++ b/lib/volcano/sandbox_session.rb @@ -70,7 +70,7 @@ def update(operation, status: 200) def assign_state(data) next_state = SandboxResponse.state(data['state']) - expiry = SandboxResponse.timestamp(data['expires_at']) + expiry = SandboxResponse.nullable_timestamp(data['expires_at']) @state = next_state @expires_at = expiry end diff --git a/lib/volcano/sandboxes.rb b/lib/volcano/sandboxes.rb index 5fa0779a..712bd25d 100644 --- a/lib/volcano/sandboxes.rb +++ b/lib/volcano/sandboxes.rb @@ -18,12 +18,11 @@ def inspect = '#' def create( # rubocop:disable Metrics/ParameterLists -- Preserve explicit typed facade keywords. project_id, region:, preset: nil, sandbox_id: nil, memory_mb: nil, - max_duration_seconds: nil, idle_timeout_seconds: nil, request_id: nil + max_duration_seconds: nil, request_id: nil ) options = { region: region, preset: preset, sandbox_id: sandbox_id, memory_mb: memory_mb, - max_duration_seconds: max_duration_seconds, idle_timeout_seconds: idle_timeout_seconds, - request_id: request_id }.compact - body = SandboxRequests.selector(options).merge(options.slice(:max_duration_seconds, :idle_timeout_seconds)) + max_duration_seconds: max_duration_seconds, request_id: request_id }.compact + body = SandboxRequests.selector(options).merge(options.slice(:max_duration_seconds)) request = SandboxRequest.new(operation: :create_sandbox_session, resource_id: SandboxRequests.identifier(project_id), body: body, request_id: SandboxRequests.request_id(options)) diff --git a/sig/sandboxes.rbs b/sig/sandboxes.rbs index 910502ae..06d272fe 100644 --- a/sig/sandboxes.rbs +++ b/sig/sandboxes.rbs @@ -1,6 +1,6 @@ module Volcano type sandbox_selector = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer } - type sandbox_create_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?max_duration_seconds: Integer, ?idle_timeout_seconds: Integer, ?request_id: String } + type sandbox_create_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?max_duration_seconds: Integer, ?request_id: String } type sandbox_exec_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?timeout_seconds: Integer, ?environment: Hash[String, String], ?request_id: String } type sandbox_command_options = { ?timeout_seconds: Integer, ?environment: Hash[String, String], ?request_id: String } @@ -93,7 +93,7 @@ module Volcano include SandboxDeployments def inspect: () -> String def presets: () -> Array[SandboxPreset] - def create: (String project_id, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?max_duration_seconds: Integer?, ?idle_timeout_seconds: Integer?, ?request_id: String?) -> SandboxSession + def create: (String project_id, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?max_duration_seconds: Integer?, ?request_id: String?) -> SandboxSession def get: (String session_id) -> SandboxSession def exec: (String project_id, String command, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?timeout_seconds: Integer, ?environment: Hash[String, String]?, ?request_id: String?) -> SandboxExecutionResult def grant: (String session_id, String auth_user_id, expires_at: (Time | String)) -> nil @@ -106,7 +106,7 @@ module Volcano attr_reader project_id: String attr_reader region: String attr_reader state: String - attr_reader expires_at: Time + attr_reader expires_at: Time? attr_reader files: SandboxFiles def refresh: () -> SandboxSession def suspend: () -> SandboxSession diff --git a/sig_dev/sandboxes.rbs b/sig_dev/sandboxes.rbs index afe24982..d692fac6 100644 --- a/sig_dev/sandboxes.rbs +++ b/sig_dev/sandboxes.rbs @@ -55,6 +55,7 @@ module Volcano module SandboxResponse STATES: Array[String] def self.timestamp: (Object? value) -> Time + def self.nullable_timestamp: (Object? value) -> Time? def self.text: (Object? value) -> String def self.integer: (Object? value) -> Integer def self.flag: (Object? value) -> bool diff --git a/spec/volcano/sandboxes_spec.rb b/spec/volcano/sandboxes_spec.rb index 7482ca75..3c333861 100644 --- a/spec/volcano/sandboxes_spec.rb +++ b/spec/volcano/sandboxes_spec.rb @@ -49,6 +49,57 @@ def mutated_command_response(request, input, environment, responses) expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10)) end + it 'sends the session lifetime and no idle timeout' do + reply(state, status: 201) + facade.create(project, region: 'aws-us-east-1', preset: 'python3.12', max_duration_seconds: 300) + body = JSON.parse(requests.last.options[:body]) + expect(body).to eq('preset' => 'python3.12', 'region' => 'aws-us-east-1', 'max_duration_seconds' => 300) + end + + it 'rejects the idle timeout the API removed instead of sending it' do + reply(state, status: 201) + expect { facade.create(project, region: 'aws-us-east-1', preset: 'python3.12', idle_timeout_seconds: 60) } + .to raise_error(ArgumentError, /unknown keyword: :idle_timeout_seconds/) + expect(requests).to be_empty + end + + it 'creates and reads a session with no expiry when it has no lifetime limit' do + # A local-mode session has no lifetime limit, so the API returns expires_at: null. + unlimited = state.merge(expires_at: nil) + reply(unlimited, status: 201) + expect(facade.create(project, region: 'aws-us-east-1', preset: 'python3.12').expires_at).to be_nil + reply(unlimited) + expect(facade.get(session_id).expires_at).to be_nil + end + + it 'refreshes between a time and no expiry in both directions' do + reply(state) + handle = facade.get(session_id) + expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10)) + reply(state.merge(expires_at: nil)) + expect(handle.refresh).to be(handle) + expect(handle.expires_at).to be_nil + reply(state) + handle.refresh + expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10)) + end + + it 'still rejects a session expiry that is neither a time nor null' do + reply(state) + handle = facade.get(session_id) + reply(state('terminated').merge(expires_at: 3600)) + expect { handle.refresh }.to raise_error(TypeError, 'Invalid Sandbox text field') + expect(handle.state).to eq('running') + expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10)) + end + + it 'keeps the expiry of an access credential required' do + reply(state) + handle = facade.get(session_id) + reply({ url: 'https://access.test', token: 'secret', expires_at: nil }) + expect { handle.access(8080) }.to raise_error(TypeError, 'Invalid Sandbox text field') + end + it 'returns nonzero command exits as one-shot data' do reply(command.merge(session_id: session_id, region: 'aws-us-east-1', duration_ms: 42)) result = facade.exec(project, 'exit 7', region: 'aws-us-east-1', preset: 'python3.12')