libtmux-workspace 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9514613d11f2325d349731f229675510da8a0e11a1c053fae88f198464e38d84
4
+ data.tar.gz: a367212c8b49b482cfe9bca60323f8360d38814af096f0f306ac49f642727450
5
+ SHA512:
6
+ metadata.gz: cbce1a3ca65ec06fd22647ee9e2ff4af2bd85c488e1a9466c88d5dfe091cdacf7585ae9ad8310d995e1c87a033bca7869d1cf1face0f8041fd812a9531fd62e2
7
+ data.tar.gz: a972900033105a2cc722f20811a72cd065d124e026eee5f55ed65ae7fb5d9dcde6a64e9b2730932542eded315946f31cada5dd557835ab662c22aafc4a639239
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026- libtmux contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # libtmux-workspace
2
+
3
+ Load bounded YAML or JSON, inspect an immutable creation plan, then explicitly
4
+ apply it to an open libtmux server. The library
5
+ and installed command-line executable share the core
6
+ [compatibility matrix](https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml);
7
+ consult its exact per-revision results.
8
+
9
+ The gem declares Ruby 3.3 or newer and depends on the same-version `libtmux`
10
+ gem, JSON 3.0 and Psych 5.5. See the repository's
11
+ [contribution guide](../../.github/CONTRIBUTING.md) for local builds and checks.
12
+ Imports and planning do not start tmux or run commands.
13
+
14
+ Install the alpha and its `libtmux-workspace` executable:
15
+
16
+ ```console
17
+ $ gem install libtmux-workspace --pre
18
+ ```
19
+
20
+ ## Example
21
+
22
+ Save this declared subset of tmuxp-style data as `workspace.yaml`. Relative
23
+ directories resolve against the configuration file's directory.
24
+
25
+ <!-- example: workspace/config -->
26
+ ```yaml
27
+ session_name: work
28
+ environment:
29
+ PROJECT_MODE: development
30
+ windows:
31
+ - window_name: editor
32
+ window_index: 1
33
+ layout: tiled
34
+ panes:
35
+ - shell_command: printf 'editor ready\n'
36
+ - {}
37
+ ```
38
+ <!-- /example -->
39
+
40
+ The [complete workspace program](../../examples/workspace_apply.rb) creates an
41
+ isolated server, applies this document and checks compensation after a later
42
+ failure. Calling `apply` authorizes the configuration's shell commands. This
43
+ excerpt runs inside that program's cleanup wrapper:
44
+
45
+ <!-- example: workspace_apply/main -->
46
+ ```ruby
47
+ workspace = LibTmux::Workspace.load(File.join(__dir__, "workspace.yaml"))
48
+ plan = workspace.plan(snapshot: server.snapshot)
49
+ Example.check(server.list_sessions.size == 1, "planning changed tmux")
50
+ result = plan.apply(server: server)
51
+ Example.check(result.success?, "workspace apply failed")
52
+ Example.check(result.effects.any? { |effect| effect.outcome == :dispatch_only }, "shell dispatch overclaims completion")
53
+ crowded = LibTmux::Workspace.parse(JSON.generate({
54
+ session_name: "crowded", windows: [{window_name: "small", panes: Array.new(40) { {} }}]
55
+ }), format: :json, base_directory: __dir__)
56
+ error = Example.raises(LibTmux::Workspace::ApplyError) do
57
+ crowded.plan.apply(server: server, compensate: true)
58
+ end
59
+ Example.check(!error.result.created_refs.empty?, "failure lost partial creation ledger")
60
+ Example.check(error.result.compensation == :completed, "owned compensation failed")
61
+ Example.check(server.list_sessions.map(&:ref).include?(borrowed.ref), "borrowed session was removed")
62
+ ```
63
+ <!-- /example -->
64
+
65
+ ## Configuration
66
+
67
+ The plain declared subset has effective version 1. Optional `profile` and
68
+ `version` must appear together as `libtmux-ruby.workspace` and `1`.
69
+ `workspace.to_h` exports an immutable, normalized, reloadable configuration.
70
+ It contains expanded values and absolute directories; callers control its
71
+ storage and disclosure.
72
+
73
+ - Root: `session_name`, nonempty `windows`, `options`, `window_options`.
74
+ - Window: `window_name`, nonempty `panes`, `window_index`, `focus`, `layout`,
75
+ `options`. Indexes are unique nonnegative integers; unspecified indexes use
76
+ the lowest available value starting at the declared `base-index` or zero.
77
+ - Pane: a command string or a mapping with `focus`, `split`, `size`. Split is
78
+ `horizontal` or `vertical`. Size is positive cells or `1%` through `99%`;
79
+ neither applies to the initial pane. Explicit sizes cannot accompany a
80
+ final named layout.
81
+ - Root, windows and pane mappings accept `start_directory`, `environment`,
82
+ `shell_command` and `shell_command_before`. Environment maps merge from
83
+ parent to child. Commands inherit unless overridden; before-commands append
84
+ in parent-to-child order. Command values accept a string or string array.
85
+
86
+ At most one window and one pane per window can declare focus; each defaults
87
+ to the first. Layouts are `even-horizontal`, `even-vertical`, `main-horizontal`,
88
+ `main-vertical` and `tiled`. Unknown fields and unsupported features fail with
89
+ a `ConfigError` identifying their configuration position.
90
+
91
+ Session options accept boolean `status`, `mouse`, `renumber-windows`;
92
+ nonnegative `base-index`, `history-limit`, `status-interval`; and enumerated
93
+ `status-position` and `status-justify`. Window options accept boolean
94
+ `automatic-rename`, `allow-rename`, `remain-on-exit`, `synchronize-panes`,
95
+ `aggressive-resize`; nonnegative `pane-base-index`, `main-pane-width`,
96
+ `main-pane-height`; text `window-status-format`, `window-status-current-format`;
97
+ and enumerated `pane-border-status`. Option text remains tmux option text,
98
+ including any formats that tmux evaluates.
99
+ `pane-base-index` cannot exceed 65535; the other numeric options accept
100
+ integers through 2147483647.
101
+
102
+ YAML tags, anchors, aliases, duplicate keys, multiple documents and complex
103
+ mapping keys are rejected. JSON duplicate keys are rejected too. No Ruby,
104
+ ERB, plugins or callbacks are evaluated. Defaults bound source and canonical
105
+ bytes to 1 MiB, strings to 64 KiB, nesting to 32, nodes to 10,000, windows to
106
+ 128 and panes to 1,024. The corresponding `max_*` parse/load keywords can
107
+ adjust these positive limits. Configuration files must be regular files.
108
+
109
+ `${NAME}` substitution is opt-in for directories and environment values:
110
+ pass `expand_environment: true, environment: {"NAME" => "value"}` to load or
111
+ parse. Only the supplied bounded mapping is consulted. Shell text and option
112
+ values are unchanged; `~` has no special path meaning. Missing variables fail
113
+ validation. Parsing checks path syntax; apply checks current accessibility.
114
+ Concurrent filesystem changes can still trigger tmux's cwd fallback.
115
+
116
+ ## Apply and failure results
117
+
118
+ `workspace.plan(snapshot: snapshot)` retains that capture's binding identity
119
+ and rejects a captured name conflict. Every apply takes a fresh snapshot and
120
+ rechecks identity and name absence. The plan creates a new session; it does
121
+ not reconcile, replace or remove a preexisting workspace. The first window
122
+ and pane from each creation command are reused.
123
+
124
+ Apply is synchronous, uses one monotonic timeout across its core operations,
125
+ and accepts a cancellation token. Panes run `/bin/sh`; explicit initial pane
126
+ environment overrides do not modify the session environment. Window indexes,
127
+ splits, options, layout and focus follow the plan's order. Temporary local
128
+ option overrides disable renumbering and pane synchronization during setup;
129
+ the plan then restores declared values or inheritance.
130
+
131
+ Session options apply before subsequent windows and split panes are created.
132
+ On tmux 3.2a–3.6, the reused initial pane retains the global `history-limit`
133
+ inherited at session creation. Later panes use the configured session value.
134
+ For uniform history on these versions, configure the server's global value
135
+ before applying the workspace. Apply does not change global options or replace
136
+ the initial pane. On tmux 3.7+, setting the option also updates existing grids.
137
+
138
+ Shell commands are sent as literal text followed by Enter. Both insertion
139
+ and Enter are dispatch effects: embedded newlines can execute during text
140
+ insertion. Success proves tmux accepted the dispatch, not that a shell
141
+ command finished or succeeded. Shell commands can leave effects beyond tmux.
142
+
143
+ An `ApplyError` exposes an immutable `result`: completed step IDs, positively
144
+ identified `created_refs`, observed or dispatch-only effects, failed action,
145
+ uncertainty and cleanup diagnostics. Diagnostics omit command payloads and
146
+ paths. Lost creation replies stay uncertain; names are never used to guess
147
+ ownership. A caller may pass `compensate: true` to kill only the positively
148
+ returned new session on failure, after an atomic tmux guard proves that every
149
+ current window and pane has a positively identified created reference. Unknown
150
+ initial entities or borrowed entities moved into that session cause refusal.
151
+ Cleanup uses a separate 0.5-second budget. Compensation status is explicit and
152
+ cannot undo shell effects. The default
153
+ preserves partial state for inspection. Applying or compensating does not
154
+ close the supplied server binding.
155
+
156
+ ## Command-line interface
157
+
158
+ The gem installs `libtmux-workspace`. `validate` and offline
159
+ `plan` do not contact tmux. Without a filename, discovery requires exactly one
160
+ `.tmuxp.yaml`, `.tmuxp.yml` or `.tmuxp.json` in the current directory.
161
+
162
+ Check the installed gem version without reading a configuration or contacting
163
+ tmux:
164
+
165
+ ```console
166
+ $ libtmux-workspace --version
167
+ ```
168
+
169
+ ```console
170
+ $ libtmux-workspace validate workspace.yaml
171
+ ```
172
+
173
+ Human plans list ordered operations and their effects. `--json` prints the
174
+ same plan data as `Plan#to_h`, including configured command text and paths.
175
+
176
+ ```console
177
+ $ libtmux-workspace plan \
178
+ --json \
179
+ workspace.yaml
180
+ ```
181
+
182
+ `load` and `plan --live` require `--socket` for an existing server. This
183
+ detached creation example uses an explicitly supplied `TMUX_SOCKET` value:
184
+
185
+ ```console
186
+ $ libtmux-workspace load \
187
+ --socket "$TMUX_SOCKET" \
188
+ --json \
189
+ workspace.yaml
190
+ ```
191
+
192
+ `--timeout` bounds each apply, live capture or subsequent switch operation
193
+ and defaults to 5 seconds.
194
+ `--compensate` enables guarded cleanup after apply failure. Environment
195
+ expansion requires both `--expand-environment` and explicit `--env NAME=VALUE`
196
+ arguments; ambient environment variables are not copied into that mapping.
197
+
198
+ `load --attach` opens the CLI's `/dev/tty` after creation and runs an owned
199
+ terminal client until the user detaches. It requires a valid `TERM`. Failure
200
+ to open or attach the terminal retains the successful apply ledger and
201
+ returns status 3. `load --switch CLIENT` switches the explicit current tmux
202
+ client selector to the created session after apply, preserving the session's
203
+ environment. It accepts a current client name, full TTY path or TTY path
204
+ without `/dev/`; native first-match behavior applies. A missing client fails
205
+ without fallback and retains the created session and ledger with status 3.
206
+ Missing or invalid selector arguments fail before creation. Use
207
+ `--switch=VALUE` for a selector beginning with `-`.
208
+
209
+ The selector is resolved at dispatch; a reconnect matching it is eligible.
210
+ It is not a captured client reference or proof of terminal ownership. Attach
211
+ and switch are mutually exclusive. Neither operation infers a latest client,
212
+ and library `Plan#apply` performs neither operation.
213
+
214
+ | Exit status | Meaning |
215
+ | --- | --- |
216
+ | 0 | Validation, planning or apply succeeded; requested attach/switch succeeded |
217
+ | 1 | Execution failed before known application effects |
218
+ | 2 | Configuration or arguments are invalid |
219
+ | 3 | Application was partial or uncertain, or a later attach/switch/cleanup failed |
220
+ | 130 | Interrupted; available effect ledger is retained |
221
+
222
+ JSON mode writes one result or error object to stdout. Apply errors include
223
+ `ApplyResult#to_h`; human errors write the diagnostic and any ledger to stderr.
224
+ Diagnostics omit configuration payloads. Explicit plan rendering and canonical
225
+ configuration export contain those values by design.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "libtmux/workspace/cli"
5
+
6
+ exit LibTmux::Workspace::CLI.run(ARGV)
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Workspace
5
+ class ApplyResult
6
+ Effect = Data.define(:step_id, :action, :outcome)
7
+ attr_reader :completed_steps, :created_refs, :effects, :failed_step, :failed_action,
8
+ :compensation, :cleanup_errors
9
+
10
+ def initialize(completed_steps:, created_refs:, effects:, failed_step:, failed_action:,
11
+ uncertain:, compensation:, cleanup_errors:)
12
+ @completed_steps, @created_refs, @effects = completed_steps.dup.freeze, created_refs.dup.freeze, effects.dup.freeze
13
+ @failed_step, @failed_action, @uncertain = failed_step, failed_action, uncertain
14
+ @compensation, @cleanup_errors = compensation, cleanup_errors.map { |error| error.dup.freeze }.freeze
15
+ freeze
16
+ end
17
+ private_class_method :new
18
+
19
+ def success?
20
+ failed_action.nil?
21
+ end
22
+
23
+ def uncertain?
24
+ @uncertain
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class} success=#{success?} completed_steps=#{completed_steps.length} created_refs=#{created_refs.length} uncertain=#{uncertain?} compensation=#{compensation}>"
29
+ end
30
+
31
+ def to_h
32
+ {"success" => success?, "completed_steps" => completed_steps,
33
+ "created_refs" => created_refs.transform_values do |ref|
34
+ {"binding_key" => ref.binding_key, "kind" => ref.kind.to_s, "id" => ref.id}
35
+ end,
36
+ "effects" => effects.map { |effect| {"step_id" => effect.step_id, "action" => effect.action.to_s, "outcome" => effect.outcome.to_s} },
37
+ "failed_step" => failed_step, "failed_action" => failed_action&.to_s,
38
+ "uncertain" => uncertain?, "compensation" => compensation.to_s, "cleanup_errors" => cleanup_errors}
39
+ end
40
+ end
41
+
42
+ class ApplyError < LibTmux::Error
43
+ attr_reader :result, :failure_class
44
+
45
+ def initialize(result, failure)
46
+ @result, @failure_class = result, failure.class.name.freeze
47
+ delivery = result.uncertain? ? :possibly_sent : (result.effects.empty? ? :not_sent : :observed)
48
+ super("workspace apply failed during #{result.failed_action} (#{failure_class})",
49
+ phase: :apply, delivery: delivery, cleanup_errors: result.cleanup_errors)
50
+ end
51
+ end
52
+
53
+ class ApplyExecution
54
+ def initialize(plan, server, timeout, cancel, compensate)
55
+ raise ArgumentError, "workspace apply requires a Server" unless server.is_a?(LibTmux::Server)
56
+ raise ArgumentError, "workspace timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
57
+ raise ArgumentError, "compensate must be boolean" unless [true, false].include?(compensate)
58
+ if cancel && !(cancel.respond_to?(:reader) && cancel.respond_to?(:cancelled?))
59
+ raise ArgumentError, "cancel must provide a cancellation reader and state"
60
+ end
61
+ @plan, @server, @cancel, @compensate = plan, server, cancel, compensate
62
+ @deadline = clock + timeout
63
+ @entities, @created, @completed, @effects, @cleanup_errors = {}, {}, [], [], []
64
+ @compensation = :not_requested
65
+ @action, @pending, @mutation = :preflight, false, false
66
+ end
67
+
68
+ def call
69
+ failure = nil
70
+ begin
71
+ Thread.handle_interrupt(Exception => :never) do
72
+ begin
73
+ preflight
74
+ @plan.steps.each do |step|
75
+ @step = step
76
+ perform(step)
77
+ @completed << step.id
78
+ end
79
+ checkpoint
80
+ rescue Exception => error
81
+ failure = error
82
+ @uncertain = @pending && @mutation && (!error.is_a?(LibTmux::Error) || error.delivery != :not_sent)
83
+ ensure
84
+ compensate if failure && @compensate
85
+ end
86
+ end
87
+ rescue Exception => deferred
88
+ failure ||= deferred
89
+ end
90
+ result = ApplyResult.__send__(:new, completed_steps: @completed, created_refs: @created,
91
+ effects: @effects, failed_step: failure && @step&.id, failed_action: failure && @action,
92
+ uncertain: !!@uncertain, compensation: @compensation, cleanup_errors: @cleanup_errors)
93
+ raise ApplyError.new(result, failure), cause: nil if failure
94
+
95
+ result
96
+ end
97
+
98
+ private
99
+
100
+ def preflight
101
+ snapshot = action(:capture_preconditions, mutation: false) { |options| @server.snapshot(**options) }
102
+ expected = @plan.preconditions.fetch("binding_key")
103
+ if expected && expected != snapshot.binding_key
104
+ raise ConflictError.new("workspace plan belongs to another server binding", phase: :apply)
105
+ end
106
+ if snapshot.sessions.where(name: @plan.preconditions.fetch("session_name_absent")).exists?
107
+ raise ConflictError.new("workspace creation requires an absent session name", phase: :apply)
108
+ end
109
+ @plan.steps.filter_map { |step| step.arguments["cwd"] }.uniq.each do |directory|
110
+ unless File.directory?(directory) && File.executable?(directory)
111
+ raise ConfigError.new("workspace directory is unavailable", expected: "existing accessible directory")
112
+ end
113
+ end
114
+ end
115
+
116
+ def perform(step)
117
+ args = step.arguments
118
+ target = @entities[step.target]
119
+ case step.operation
120
+ when :create_session
121
+ action(:create_session) do |options|
122
+ assignments = args.fetch("pane_environment").map { |name, value| "#{name}=#{value}" }
123
+ receipt = @server.new_session(name: args.fetch("name"), window_name: args.fetch("window_name"),
124
+ cwd: args.fetch("cwd"), environment: args.fetch("environment"),
125
+ command: ["/usr/bin/env", "--", *assignments, "/bin/sh"], receipt: true, **options)
126
+ remember("session", receipt.entity)
127
+ remember(step.produces[1], receipt.window)
128
+ remember(step.produces[2], receipt.pane)
129
+ end
130
+ when :create_window
131
+ action(:create_window) do |options|
132
+ receipt = target.new_window(name: args.fetch("name"), index: args.fetch("index"), command: ["/bin/sh"],
133
+ cwd: args.fetch("cwd"), environment: args.fetch("environment"), focus: false, receipt: true, **options)
134
+ remember(step.produces.first, receipt.window)
135
+ remember(step.produces[1], receipt.pane)
136
+ end
137
+ when :split_pane
138
+ action(:split_pane) do |options|
139
+ pane = target.split(direction: args.fetch("direction").to_sym, size: args["size"], command: ["/bin/sh"],
140
+ cwd: args.fetch("cwd"), environment: args.fetch("environment"), focus: false, **options)
141
+ remember(step.produces.first, pane)
142
+ end
143
+ when :move_initial_window
144
+ link = link_for(target)
145
+ if link.index != args.fetch("index")
146
+ action(:move_initial_window) { |options| link.move(session: @entities.fetch("session").ref, index: args.fetch("index"), **options) }
147
+ end
148
+ when :set_session_option, :set_window_option
149
+ action(step.operation) { |options| target.options.set(args.fetch("name"), args.fetch("value"), **options) }
150
+ when :unset_session_option, :unset_window_option
151
+ action(step.operation) { |options| target.options.unset(args.fetch("name"), **options) }
152
+ when :select_layout
153
+ action(:select_layout) { |options| target.select_layout(args.fetch("layout"), **options) }
154
+ when :send_command
155
+ action(:insert_command_text, outcome: :dispatch_only) { |options| target.send_text(args.fetch("command"), **options) }
156
+ action(:dispatch_command, outcome: :dispatch_only) { |options| target.send_keys("Enter", **options) }
157
+ when :select_pane
158
+ action(:select_pane) { |options| target.select(**options) }
159
+ when :select_window
160
+ link = link_for(target)
161
+ action(:select_window) { |options| link.select(**options) }
162
+ else
163
+ raise UnsupportedFeatureError.new("workspace plan operation is unsupported", phase: :apply)
164
+ end
165
+ end
166
+
167
+ def link_for(window)
168
+ links = action(:acquire_window_link, mutation: false) { |options| @entities.fetch("session").list_window_links(**options) }
169
+ only(links.select { |link| link.id == window.id })
170
+ end
171
+
172
+ def only(values)
173
+ unless values.length == 1
174
+ raise ConflictError.new("created workspace topology changed during apply", phase: :apply, delivery: :observed)
175
+ end
176
+ values.first
177
+ end
178
+
179
+ def remember(name, entity)
180
+ @entities[name] = entity
181
+ @created[name] = entity.ref
182
+ entity
183
+ end
184
+
185
+ def action(name, mutation: true, outcome: :observed)
186
+ @action, @pending, @mutation = name, false, mutation
187
+ checkpoint
188
+ raise Cancelled.new("workspace apply was cancelled", phase: :apply) if @cancel&.cancelled?
189
+ remaining = @deadline - clock
190
+ raise DeadlineExceeded.new("workspace apply deadline elapsed", phase: :apply) unless remaining.positive?
191
+
192
+ @pending = true
193
+ result = Thread.handle_interrupt(Exception => :on_blocking) { yield(timeout: remaining, cancel: @cancel) }
194
+ @effects << ApplyResult::Effect.new(step_id: @step&.id, action: name, outcome: outcome) if mutation
195
+ @pending = false
196
+ checkpoint
197
+ result
198
+ end
199
+
200
+ def compensate
201
+ session = @entities["session"]
202
+ @compensation = :nothing_owned
203
+ return unless session
204
+
205
+ deadline = clock + 0.5
206
+ # A deferred interruption can be delivered by a core command checkpoint.
207
+ # Retry only the positively owned session, within the same cleanup budget.
208
+ 2.times do
209
+ begin
210
+ remaining = deadline - clock
211
+ raise DeadlineExceeded.new("workspace compensation deadline elapsed") unless remaining.positive?
212
+
213
+ windows = @created.values.select { |ref| ref.kind == :window }
214
+ panes = @created.values.select { |ref| ref.kind == :pane }
215
+ session.kill(expected_windows: windows, expected_panes: panes, timeout: remaining)
216
+ @compensation = :completed
217
+ return
218
+ rescue Exception => cleanup
219
+ @compensation = :failed
220
+ @cleanup_errors << "workspace compensation failed (#{cleanup.class})"
221
+ end
222
+ end
223
+ end
224
+
225
+ def checkpoint
226
+ Thread.handle_interrupt(Exception => :immediate) { nil }
227
+ end
228
+
229
+ def clock
230
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
231
+ end
232
+ end
233
+ private_constant :ApplyExecution
234
+ end
235
+ end