workspaces 0.1.6 → 0.1.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8817ac7615085c4aef7e23b75b8034ac494ad7c2c61b9f53dd14fc79232350cd
4
- data.tar.gz: f670f3dbe8ad41cbcc46e29833bd0be27dcd04fcbe48465a78e80a756fc95764
3
+ metadata.gz: a0102752fa27b9aa9b5fd40ab163411bc9d0411cdfaa00d8a82f6a04088e0099
4
+ data.tar.gz: 63704204815a6e183e1e244da898defcf67ff3e55b45e69400e19e9f275abaec
5
5
  SHA512:
6
- metadata.gz: e672a1e9ca5e3186364077718a1ff69851435de0a69e0deadcdeae1ab5715cc8a5acf4e42b17bc8f2639f58a6d6dcb907d10380cda72b59bbebf29d536846de8
7
- data.tar.gz: dac093c4058c50a6a8abb863bf61156a88f760768ea5709995ffc800d4654b380305fffc09d3bb6466d19f4ca1ac9a0a85f47a09043c3f9902a66e40012db8b3
6
+ metadata.gz: a6e7e66264833d0b55493fc42203af62be3726ee6d8422d913e00f11afc2b9cf65aaef078e0ac06ecdedbced9c1cd1ff62d37759e1eb7a28295b01908d8bb976
7
+ data.tar.gz: 2bac52fea92eee837b049d2ce5b24946c0d4f3e306530dd6f4a8aef3e772495725dcdb1d7eec9c4b0b339f54d12948202d725ef609e6c1a2237c470c474276c8
data/DESIGN.md CHANGED
@@ -48,10 +48,16 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
48
48
  - **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
49
49
  - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
50
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
51
+ - **Lifecycle state machine**: the workspace detail page is driven by `Presentation::STATES`. Each state (`idle`, `busy`, `ready`, `stopped`, `error`/`interrupted`, `unknown`) declares which sections exist (progress, actions, environment, log, details, danger zone) and the label of the single environment-form button. Sections that are not usable or meaningful in a state are hidden, never shown disabled. The server renders the initial state and the polling script re-applies it from `presentation.sections`, so the page transitions without a reload.
52
+ - `idle` (brand new): only the environment editor plus a **Start workspace** button, with a one-line hint. No log, actions, details, or delete.
53
+ - `busy` (setup/restart running): only progress and the live log.
54
+ - `ready`: everything, with **Save & restart**.
55
+ - `stopped`: no progress from the previous run; **Save & start**.
56
+ - `error`/`interrupted`: retry action, environment editor, progress, and logs; **Save & retry**.
51
57
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
52
58
  - **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
53
59
  - **Open workspace**: success-styled link available when the server is ready
54
- - **Lifecycle controls**: disabled while busy; helper text distinguishes rebuilding from server-only restart
60
+ - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
55
61
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
56
62
  - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
57
63
  - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
data/README.md CHANGED
@@ -121,6 +121,24 @@ and defining them requires no JavaScript edits. Preset keys must be declared in
121
121
  `default_editable_env`; recipes reject undeclared keys and secret-like values.
122
122
  Hidden environment values are never exposed in the editor or preset choices.
123
123
 
124
+ Use `create --branch main --preset Demo` to save a preset for a new, still-idle
125
+ workspace. `start ID --preset Demo` applies it before preparing a stopped
126
+ workspace; `restart ID --preset 'Demo off'` switches a running workspace and
127
+ restarts its server. `prepare ID --preset Demo` switches and rebuilds. An
128
+ unknown name is rejected without changing saved settings. Selecting a preset
129
+ replaces the editable settings (including custom editor lines) but preserves
130
+ hidden legacy overrides. A running workspace requires `restart --preset` or
131
+ `prepare --preset`, not `start --preset`.
132
+
133
+ For one-off non-secret tweaks, add repeatable `--set NAME=VALUE` and
134
+ `--unset NAME` flags to `create`, `start`, `prepare`, or `restart`. For example,
135
+ `restart ID --preset Demo --set DEMO_FEATURE=on` applies the preset first,
136
+ then the individual changes, and restarts. Without `--preset`, these flags
137
+ patch current editable settings. Names reserved for the runner and names
138
+ suggesting secrets are rejected; values on the command line may be visible
139
+ in process listings and shell history. Use a secret store, not `--set`, for
140
+ credentials.
141
+
124
142
  The front door is not an authentication boundary. Its host and same-origin checks
125
143
  protect routing and POST actions, but a publicly reachable instance still needs
126
144
  access control such as VPN or SSO. Do not expose workspace backend ports directly.
@@ -49,6 +49,28 @@ module Workspaces
49
49
  options = {}
50
50
  OptionParser.new do |parser|
51
51
  parse_common_options(parser, options)
52
+ parser.on('--preset NAME') { |value| options[:preset] = value } if %w(create start prepare restart).include?(command)
53
+ if %w(create start prepare restart).include?(command)
54
+ parser.on('--set NAME=VALUE') do |value|
55
+ name, separator, setting = value.partition('=')
56
+ raise OptionParser::InvalidArgument, '--set requires NAME=VALUE' if separator.empty?
57
+ EnvironmentOverrides.validate_name!(name)
58
+ EnvironmentOverrides.validate_value!(setting)
59
+ if name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
60
+ raise OptionParser::InvalidArgument, 'Secret-like names cannot be set on the command line'
61
+ end
62
+ options[:set] ||= {}
63
+ raise OptionParser::InvalidArgument, 'duplicate --set name' if options[:set].key?(name)
64
+ options[:set][name] = setting
65
+ end
66
+ parser.on('--unset NAME') do |value|
67
+ EnvironmentOverrides.validate_name!(value)
68
+ if value.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
69
+ raise OptionParser::InvalidArgument, 'Secret-like names cannot be unset on the command line'
70
+ end
71
+ (options[:unset] ||= []) << value
72
+ end
73
+ end
52
74
  parse_tls_options(parser, options) if command == 'serve'
53
75
  end.parse!(argv)
54
76
  options
@@ -102,7 +124,8 @@ module Workspaces
102
124
  end
103
125
 
104
126
  def create(_id, options)
105
- emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from)).describe, options)
127
+ emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from, :preset),
128
+ set: options.fetch(:set, {}), unset: options.fetch(:unset, [])).describe, options)
106
129
  end
107
130
 
108
131
  def list(_id, options)
@@ -114,14 +137,16 @@ module Workspaces
114
137
  end
115
138
 
116
139
  def lifecycle(id, options)
117
- action = proc { run_lifecycle(options.fetch(:command), id) }
140
+ action = proc { run_lifecycle(options.fetch(:command), id, preset: options[:preset],
141
+ set: options.fetch(:set, {}), unset: options.fetch(:unset, [])) }
118
142
  result = options[:json] ? with_stdout_on_stderr(&action) : action.call
119
143
  emit(result, options)
120
144
  raise ArgumentError, result['last_error'] if result['status'] == 'error'
121
145
  end
122
146
 
123
- def run_lifecycle(command, id)
124
- started = command == 'restart' ? coordinator.restart(id) : coordinator.start(id, force: command == 'prepare')
147
+ def run_lifecycle(command, id, preset: nil, set: {}, unset: [])
148
+ started = command == 'restart' ? coordinator.restart(id, preset: preset, set: set, unset: unset) :
149
+ coordinator.start(id, force: command == 'prepare', preset: preset, set: set, unset: unset)
125
150
  if !started && coordinator.snapshot(id)['active']
126
151
  raise ArgumentError, 'Workspace is busy; inspect it with show --json'
127
152
  end
@@ -171,8 +196,8 @@ module Workspaces
171
196
  end
172
197
 
173
198
  def usage
174
- 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--json] | ' \
175
- 'list [--pr NUMBER] [--json] | show/start/prepare/restart/stop ID [--json] | ' \
199
+ 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
200
+ 'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
176
201
  'exec ID -- COMMAND... | remove ID [--force]'
177
202
  end
178
203
 
@@ -18,12 +18,12 @@ module Workspaces
18
18
  @workers_mutex = Mutex.new
19
19
  end
20
20
 
21
- def start(id, force: false)
22
- launch(id, restart: false, force: force)
21
+ def start(id, force: false, preset: nil, set: {}, unset: [])
22
+ launch(id, restart: false, force: force, prelaunch: settings_update(id, preset, set, unset))
23
23
  end
24
24
 
25
- def restart(id)
26
- launch(id, restart: true, force: true)
25
+ def restart(id, preset: nil, set: {}, unset: [])
26
+ launch(id, restart: true, force: true, prelaunch: settings_update(id, preset, set, unset))
27
27
  end
28
28
 
29
29
  def update_environment(id, set:, remove:)
@@ -73,6 +73,14 @@ module Workspaces
73
73
 
74
74
  private
75
75
 
76
+ def settings_update(id, preset, set, unset)
77
+ return if !preset && set.empty? && unset.empty?
78
+
79
+ lambda do |_workspace|
80
+ EnvironmentOverrides.new(id).apply_settings(preset: preset, set: set, unset: unset)
81
+ end
82
+ end
83
+
76
84
  def resolve_status(id, status, busy)
77
85
  return busy ? status : 'interrupted' if ACTIVE.include?(status)
78
86
  return status if status == 'error'
@@ -107,6 +115,9 @@ module Workspaces
107
115
  lock = acquire_lock(id)
108
116
  return false unless lock
109
117
 
118
+ if prelaunch && !restart && !force && Backend.new(id).running?
119
+ raise ArgumentError, 'Workspace is running; use restart --preset to change its environment'
120
+ end
110
121
  return false if backend_already_running?(id, force, lock)
111
122
  prelaunch&.call(workspace)
112
123
  publish_starting(id, restart)
@@ -94,6 +94,7 @@
94
94
  started = Date.parse(data.prepare_started_at);
95
95
  completed = Date.parse(data.completed_at);
96
96
  renderStatus(view);
97
+ renderSections(view);
97
98
  node('message').textContent = data.message || '';
98
99
  node('source-update-message').textContent = data.source_update_message || '';
99
100
  node('progress').replaceChildren(...view.steps.map(renderStep));
@@ -103,6 +104,27 @@
103
104
  updateLog(data);
104
105
  }
105
106
 
107
+ function renderSections(view) {
108
+ const sections = view.sections || {};
109
+ root.querySelectorAll('[data-section]').forEach((element) => {
110
+ element.hidden = !sections[element.dataset.section];
111
+ });
112
+ const controls = node('controls');
113
+ if (controls) {
114
+ controls.hidden = !sections.actions && !sections.environment;
115
+ const title = node('actions-settings-title');
116
+ if (title && view.controls_title) title.textContent = view.controls_title;
117
+ }
118
+ const hint = node('hint');
119
+ if (hint) {
120
+ hint.textContent = view.hint || '';
121
+ hint.hidden = !view.hint;
122
+ }
123
+ node('message').hidden = view.state === 'idle';
124
+ if (window.WorkspaceEnvironment)
125
+ window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
126
+ }
127
+
106
128
  function renderStatus(view) {
107
129
  node('status').textContent = view.title;
108
130
  node('activity').hidden = !view.busy;
@@ -45,6 +45,18 @@
45
45
  });
46
46
  }
47
47
 
48
+ function submitLabel() {
49
+ return (submit && submit.textContent.trim()) || 'Save & restart';
50
+ }
51
+
52
+ function setSubmitLabel(label) {
53
+ if (!label) return;
54
+ if (submit) submit.textContent = label;
55
+ form.querySelectorAll('[data-submit-label]').forEach((element) => {
56
+ element.textContent = label;
57
+ });
58
+ }
59
+
48
60
  function setStatus(message) {
49
61
  if (!message) return;
50
62
  if (lastAnnounced === message) return;
@@ -74,7 +86,7 @@
74
86
 
75
87
  function announceUnsaved(message) {
76
88
  if (activeBusy) return;
77
- const nextMessage = message || 'Draft updated locally and not saved yet. Use Save & restart to apply changes.';
89
+ const nextMessage = message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
78
90
  status.textContent = nextMessage;
79
91
  lastAnnounced = nextMessage;
80
92
  }
@@ -115,7 +127,7 @@
115
127
  if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
116
128
  replaceDraft(
117
129
  presets[nextValue],
118
- 'Preset loaded into draft only. Use Save & restart to apply changes.'
130
+ 'Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.'
119
131
  );
120
132
  });
121
133
  });
@@ -124,6 +136,7 @@
124
136
  toggleBusy,
125
137
  updateStatus,
126
138
  setStatus,
139
+ setSubmitLabel,
127
140
  syncFromSnapshot(snapshot) {
128
141
  if (!snapshot || presetButtons.length === 0 || !editor) return;
129
142
  const rendered = snapshot.editable_environment;
@@ -8,8 +8,9 @@
8
8
  <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
9
  <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
10
10
  </div>
11
- <p id="message"><%= field('message') %></p>
12
- <ol class="stepper" id="progress" aria-label="Setup progress">
11
+ <p id="hint" <%= 'hidden' unless @presentation['hint'] %>><%= escape(@presentation['hint']) %></p>
12
+ <p id="message" <%= 'hidden' if @presentation['state'] == 'idle' %>><%= field('message') %></p>
13
+ <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
13
14
  <% @presentation['steps'].each do |step| %>
14
15
  <li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
15
16
  <% end %>
@@ -19,19 +20,18 @@
19
20
  </section>
20
21
  <p id="connection" role="status"></p>
21
22
  </section>
22
- <section class="status-panel controls-panel" aria-labelledby="actions-settings-title">
23
- <h2 id="actions-settings-title">Actions &amp; settings</h2>
24
- <div class="workspace-actions-group" aria-labelledby="actions-heading">
23
+ <section class="status-panel controls-panel" id="controls" data-section-group aria-labelledby="actions-settings-title" <%= 'hidden' unless section?('actions') || section?('environment') %>>
24
+ <h2 id="actions-settings-title"><%= escape(@presentation['controls_title']) %></h2>
25
+ <div class="workspace-actions-group" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
25
26
  <h3 id="actions-heading">Workspace actions</h3>
26
27
  <div class="workspace-actions">
27
28
  <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
28
29
  <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
29
- <a class="button button--subtle" href="/workspaces/<%= @id %><%= @snapshot['log_source'] == 'backend' ? '?log=backend' : '' %>">Refresh status</a>
30
30
  </div>
31
31
  <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
32
  </div>
33
- <div class="workspace-settings" aria-labelledby="environment-overrides">
34
- <h3 id="environment-overrides">Editable environment</h3>
33
+ <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
+ <h3 id="environment-overrides">Environment</h3>
35
35
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
36
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
37
  <legend class="env-legend">Workspace settings</legend>
@@ -43,20 +43,20 @@
43
43
  <button type="button" class="button button--subtle env-preset-button" data-environment-preset="<%= escape(name) %>" aria-pressed="false"><%= escape(name) %></button>
44
44
  <% end %>
45
45
  </div>
46
- <p class="muted" id="environment-preset-help">Select a preset, then Save &amp; restart to apply.</p>
46
+ <p class="muted" id="environment-preset-help">Presets decide which app this workspace runs. Select one, then use <strong data-submit-label><%= escape(@presentation['environment_submit']) %></strong> to apply it.</p>
47
47
  </div>
48
48
  <% end %>
49
49
  <label class="env-input-label" for="editable-env">Environment variables</label>
50
50
  <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
51
51
  </fieldset>
52
52
  <div class="env-actions">
53
- <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>>Save &amp; restart</button>
53
+ <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
54
54
  </div>
55
55
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
56
56
  </form>
57
57
  </div>
58
58
  </section>
59
- <section class="log-panel" aria-labelledby="log-title">
59
+ <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
60
60
  <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
61
61
  <nav class="log-switch" aria-label="Log source">
62
62
  <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
@@ -64,8 +64,8 @@
64
64
  </nav>
65
65
  <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
66
66
  </section>
67
- <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
68
- <section class="status-panel danger-zone" aria-labelledby="danger-heading">
67
+ <details class="status-panel" data-section="details" <%= 'hidden' unless section?('details') %>><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
68
+ <section class="status-panel danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
69
69
  <h2 id="danger-heading">Danger Zone</h2>
70
70
  <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
71
71
  <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
@@ -106,6 +106,10 @@ module Workspaces
106
106
 
107
107
  private
108
108
 
109
+ def section?(name)
110
+ @presentation.fetch('sections', {})[name]
111
+ end
112
+
109
113
  def field(name)
110
114
  escape(@snapshot[name])
111
115
  end
@@ -75,6 +75,33 @@ module Workspaces
75
75
  load
76
76
  end
77
77
 
78
+ def apply_preset(name, recipe: Recipe.new)
79
+ apply_settings(preset: name, recipe: recipe)
80
+ end
81
+
82
+ def self.settings_for(recipe:, preset: nil, set: {}, unset: [], current: nil)
83
+ defaults = recipe.default_editable_env
84
+ preset_values = recipe.environment_presets.fetch(preset) { raise Invalid, "Unknown environment preset: #{preset}" } if preset
85
+ values = preset ? defaults.merge(preset_values) : (current || defaults).dup
86
+ validate_entries!(set)
87
+ unset.each { |name| validate_name!(name) }
88
+ raise Invalid, 'Cannot set and unset the same name' unless (set.keys & unset).empty?
89
+ names = set.keys + unset
90
+ if names.any? { |name| name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i) }
91
+ raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
92
+ end
93
+ values.merge!(set)
94
+ unset.each { |name| values.delete(name) }
95
+ validate_entries!(values)
96
+ values
97
+ end
98
+
99
+ def apply_settings(preset: nil, set: {}, unset: [], recipe: Recipe.new)
100
+ current = preset ? nil : editable_values(recipe.default_editable_env)
101
+ values = self.class.settings_for(recipe: recipe, preset: preset, set: set, unset: unset, current: current)
102
+ replace_editable(values.sort.map { |key, value| "#{key}=#{value}" }.join("\n"), defaults: recipe.default_editable_env)
103
+ end
104
+
78
105
  def apply_patch(set:, remove:)
79
106
  validate_patch!(set, remove)
80
107
  updated = load_file(file_path).merge(set)
@@ -6,13 +6,45 @@ module Workspaces
6
6
  ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
7
7
  'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
8
8
 
9
+ SECTIONS = %w(progress actions environment log details danger).freeze
10
+ MANAGEABLE = SECTIONS
11
+
12
+ # Workspace lifecycle state machine. Each state declares only what is usable and
13
+ # meaningful there; everything else is hidden rather than shown disabled.
14
+ #
15
+ # idle ──start──▶ busy ──▶ ready ◀──▶ busy
16
+ # │ │
17
+ # ▼ ▼
18
+ # error stopped ──start──▶ busy
19
+ # (interrupted)
20
+ #
21
+ # `environment_submit` labels the single environment-form button, which saves the
22
+ # settings and (re)starts the workspace, so a brand new workspace needs only that.
23
+ STATES = {
24
+ 'idle' => {
25
+ sections: %w(environment),
26
+ environment_submit: 'Start workspace', controls_title: 'Set up your workspace',
27
+ hint: 'Choose the environment you want to preview below, then start the workspace. ' \
28
+ 'Setup usually takes a few minutes.'
29
+ },
30
+ 'busy' => { sections: %w(progress log), environment_submit: 'Save & restart' },
31
+ 'ready' => { sections: MANAGEABLE, environment_submit: 'Save & restart' },
32
+ 'stopped' => { sections: %w(actions environment log details danger), environment_submit: 'Save & start' },
33
+ 'error' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
34
+ 'interrupted' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
35
+ 'unknown' => { sections: %w(log details), environment_submit: 'Save & restart' }
36
+ }.freeze
37
+
9
38
  def initialize(snapshot)
10
39
  @snapshot = snapshot
11
40
  end
12
41
 
13
42
  def to_h
14
43
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
15
- 'actions' => actions, 'ready' => status == 'ready' && !busy? }
44
+ 'actions' => actions, 'ready' => status == 'ready' && !busy?,
45
+ 'state' => state, 'sections' => sections, 'hint' => definition[:hint],
46
+ 'controls_title' => definition.fetch(:controls_title, 'Actions & settings'),
47
+ 'environment_submit' => definition[:environment_submit] }
16
48
  end
17
49
 
18
50
  private
@@ -25,6 +57,22 @@ module Workspaces
25
57
  @snapshot['active'] || status == 'preparing'
26
58
  end
27
59
 
60
+ def state
61
+ return 'busy' if busy?
62
+
63
+ STATES.key?(status) ? status : 'unknown'
64
+ end
65
+
66
+ def definition
67
+ STATES.fetch(state)
68
+ end
69
+
70
+ def sections
71
+ visible = definition[:sections].dup
72
+ visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
73
+ SECTIONS.to_h { |name| [name, visible.include?(name)] }
74
+ end
75
+
28
76
  def title
29
77
  return 'Workspace busy' if @snapshot['active'] && !busy_status?
30
78
  return @snapshot['message'] || 'Running setup' if status == 'preparing'
@@ -1,6 +1,8 @@
1
1
  require_relative 'worktree'
2
2
  require_relative 'state_store'
3
3
  require_relative 'backend'
4
+ require_relative 'recipe'
5
+ require_relative 'environment_overrides'
4
6
 
5
7
  module Workspaces
6
8
  class Registry
@@ -17,9 +19,12 @@ module Workspaces
17
19
  end
18
20
  end
19
21
 
20
- def create(**options)
22
+ def create(preset: nil, set: {}, unset: [], **options)
23
+ recipe = Recipe.new if preset || !set.empty? || !unset.empty?
24
+ EnvironmentOverrides.settings_for(recipe: recipe, preset: preset, set: set, unset: unset) if recipe
21
25
  workspace = Worktree.create(**options)
22
26
  StateStore.new(Config.state_file).set(workspace.id, 'status' => 'idle', 'related_pr' => workspace.related_pr)
27
+ EnvironmentOverrides.new(workspace.id).apply_settings(preset: preset, set: set, unset: unset, recipe: recipe) if recipe
23
28
  workspace
24
29
  end
25
30
 
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.6'
2
+ VERSION = '0.1.8'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack