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 +7 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/libtmux-workspace +6 -0
- data/lib/libtmux/workspace/apply.rb +235 -0
- data/lib/libtmux/workspace/cli.rb +185 -0
- data/lib/libtmux/workspace/document.rb +195 -0
- data/lib/libtmux/workspace/normalizer.rb +281 -0
- data/lib/libtmux/workspace/plan.rb +123 -0
- data/lib/libtmux/workspace/version.rb +7 -0
- data/lib/libtmux/workspace.rb +83 -0
- data/sig/libtmux-workspace.rbs +79 -0
- metadata +94 -0
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,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
|