workspaces 0.1.14 → 0.1.15

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: 160b209187a95a674c6ce4936b4b2863ed5893ac8331f1f0345d550cf09f2f2d
4
- data.tar.gz: ba7ef0dd3ed8c913ea29683f641c1d4ac314ee15a1c201dd45634d45a8a980bc
3
+ metadata.gz: a68a635327e6a8f69c8c7d29f881e5bb6eb12e57d66e1dff7ea31d8e3c8dc9ab
4
+ data.tar.gz: 7306dff7993e982666f948a81a2131b7b6f165dfa61b4a729ae093b6feb7583e
5
5
  SHA512:
6
- metadata.gz: 978a8f06eda4d6d9c0c443499f1cf28f41c33800b3394b40fb56dd8771e0f1283001a4e24effcb3f7c52563023a18e1f48bfe884f97bd255642ff9379b9ea095
7
- data.tar.gz: 8162b0c59925b414a1af0ff511fcc6bee2d79f83bb42d452614ef68f79c7ee52e42cb89531c21c5e77b29bc76b76cdfea4f4b5fff73fff2217b49217a0321a2c
6
+ metadata.gz: f2b165a4f127e77c942338f0ae5bcfc5d0f9ad016ea69bac7282dfa0dacc4158c860269d2ccd98eae794a56381d519bc192be6d0d44c2bb03f24060800464ed0
7
+ data.tar.gz: 8e4a2a00f375c70bb10d16512ad11e8463357db5074061e682246ec2972eba108620118b29549534e8636a220efa6bd894b1e9516d4a8ffecf8f18ac960828a4
data/DESIGN.md CHANGED
@@ -57,10 +57,11 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
57
57
  - `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
58
58
  - `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
59
59
  - `stopped`: "Your workspace is paused" with a large **Start workspace** button.
60
- - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry** button, the error shown, and technical details open.
60
+ - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry failed step** button (**Continue setup** after an interruption) that names the step in the summary, a subtle **Re-run all steps** beside it, the error shown, and technical details open. When the recorded steps cannot be resumed, the single **Retry** of the earlier design is shown instead.
61
61
  - `unknown`: a minimal view with technical details open.
62
62
  - **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
63
- - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
63
+ - **Per-step re-run controls**: inside each step's accordion, small subtle **Re-run this step** and **Re-run from here** forms aimed at `/workspaces/ID/resume?only=KEY` and `?from=KEY`. The server decides which appear (`presentation.resume.step_modes`), so the page never offers something that would be refused; they are hidden while a run is in progress and are disabled with the other controls while busy.
64
+ - **Action buttons**: start, prepare/rebuild, restart server, stop server, retry failed step, and refresh
64
65
  - **Open workspace**: success-styled link available when the server is ready
65
66
  - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
66
67
  - **Step logs (GitHub Actions style)**: setup output is split by recipe step. Each step is a `<details>` row with a status icon (ring, pulsing ring, check or cross), its name, how long it took and a chevron; opening it shows only that step's output. A step that is running or has failed starts open and a completed or waiting one starts closed. The script updates rows in place by step id, and applies the open preference only when a step's state changes, so a row the reader toggled is never reopened or reclosed and what they are reading is never rebuilt.
data/README.md CHANGED
@@ -98,7 +98,7 @@ the percentage, the elapsed time and, once there is history, an estimate of what
98
98
  Every finished run is appended to `build-history.jsonl` in the Workspaces home, one JSON
99
99
  object per line, shared by all workspaces of the project. It is written quietly and a failure
100
100
  to write it never affects the build. Each record has the time, workspace, operation
101
- (`prepare` or `restart`), result (`complete` or `failed`), `total_seconds`, the Workspaces
101
+ (`prepare`, `restart` or `resume`), result (`complete` or `failed`), `total_seconds`, the Workspaces
102
102
  version, and each step's id, state and seconds. To see which step is getting slower:
103
103
 
104
104
  ```sh
@@ -142,6 +142,35 @@ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
142
142
  `WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
143
143
  `serve` does **not** start workspace backends; use `start` or `restart` for those.
144
144
 
145
+ ### Re-running part of a run
146
+
147
+ When a run fails, or is interrupted, you do not have to start over. `resume` continues from the
148
+ first step that did not finish and keeps every earlier step's state, time and log:
149
+
150
+ ```sh
151
+ bin/workspaces resume ID # retry from the first unfinished step
152
+ bin/workspaces resume ID --from STEP # re-run STEP and every step after it
153
+ bin/workspaces resume ID --only STEP # re-run exactly one step
154
+ ```
155
+
156
+ The dashboard offers the same: **Retry failed step** (or **Continue setup** after an
157
+ interruption) with **Re-run all steps** beside it, and, inside each step, **Re-run this step**
158
+ and **Re-run from here** where they are allowed. `POST /workspaces/ID/resume` takes optional
159
+ `from=STEP` or `only=STEP` query parameters.
160
+
161
+ A failed run stops the server, so a failure at or after the `background: true` step retries from
162
+ that step, exactly like a restart: the server is started again with a fresh `WORKSPACE_RUN_ID`
163
+ and readiness is checked against it. A failure before the server step retries that step and
164
+ everything after it. Re-running steps *after* the server step, on a workspace whose server is
165
+ still up, reuses that server's port and `WORKSPACE_RUN_ID`, and leaves it running even if
166
+ the step fails.
167
+
168
+ A re-run never leaves an unfinished step behind (`--from` cannot skip one, `--only` needs every
169
+ other step finished), so a successful one always means a complete workspace. It is refused, in
170
+ plain words, when `.workspaces.yml`'s steps no longer match the ones recorded, when the server
171
+ step is asked for on its own, or when a step after the server is asked for and the server is not
172
+ running. The build history records only the steps that re-ran, with operation `resume`.
173
+
145
174
  The `/workspaces` dashboard lists each managed workspace's setup status, whether its
146
175
  backend is running, creation time, and last backend startup. On a workspace's detail
147
176
  page, the Danger Zone lets an operator delete that one workspace after typing its
@@ -25,6 +25,17 @@ module Workspaces
25
25
  @state.get(workspace_id)&.fetch('port', nil)
26
26
  end
27
27
 
28
+ # The WORKSPACE_RUN_ID the current server was started with. A step that runs after the server
29
+ # (readiness) has to see the same value, so it is kept while the server lives.
30
+ def run_id
31
+ @state.get(workspace_id)&.fetch('run_id', nil)
32
+ end
33
+
34
+ # True only for a server that can be checked by a later step on its own: alive, with its token.
35
+ def resumable?
36
+ !!running? && !run_id.to_s.empty?
37
+ end
38
+
28
39
  def reserve!
29
40
  @state.transaction do |data|
30
41
  taken = data.values.filter_map { |entry| entry['port'] }
@@ -54,7 +65,7 @@ module Workspaces
54
65
  entry = @state.get(workspace_id) || {}
55
66
  terminate(entry['pid'], group: entry['process_group']) if entry['pid']
56
67
  @state.transaction do |data|
57
- %w(pid port process_group).each { |key| data[workspace_id]&.delete(key) }
68
+ %w(pid port process_group run_id).each { |key| data[workspace_id]&.delete(key) }
58
69
  end
59
70
  end
60
71
 
@@ -14,7 +14,7 @@ module Workspaces
14
14
  COMMANDS = {
15
15
  'serve' => :serve, 'create' => :create, 'list' => :list, 'show' => :show,
16
16
  'start' => :lifecycle, 'prepare' => :lifecycle, 'restart' => :lifecycle,
17
- 'stop' => :stop, 'remove' => :remove, 'help' => :help
17
+ 'stop' => :stop, 'remove' => :remove, 'resume' => :resume, 'help' => :help
18
18
  }.freeze
19
19
  def self.run(argv)
20
20
  new.run(argv.dup)
@@ -50,6 +50,7 @@ module Workspaces
50
50
  options = {}
51
51
  OptionParser.new do |parser|
52
52
  parse_common_options(parser, options)
53
+ parser.on('--only STEP') { |value| options[:only] = value } if command == 'resume'
53
54
  parser.on('--preset NAME') { |value| options[:preset] = value } if %w(create start prepare restart).include?(command)
54
55
  if %w(create start prepare restart).include?(command)
55
56
  parser.on('--set NAME=VALUE') do |value|
@@ -157,6 +158,24 @@ module Workspaces
157
158
  coordinator.wait(id)
158
159
  end
159
160
 
161
+ # Re-runs part of the last run: from the first unfinished step by default, from a named step
162
+ # onward with --from, or exactly one step with --only. Finished steps keep their time and log.
163
+ def resume(id, options)
164
+ raise ArgumentError, 'Choose either --from or --only, not both' if options[:from] && options[:only]
165
+ raise ArgumentError, 'resume needs a workspace ID' unless id
166
+
167
+ mode = options[:only] ? :only : (options[:from] ? :from : :retry)
168
+ action = proc do
169
+ started = coordinator.rerun(id, mode: mode, key: options[:only] || options[:from])
170
+ raise ArgumentError, 'Workspace is busy; inspect it with show --json' if !started && coordinator.snapshot(id)['active']
171
+
172
+ coordinator.wait(id)
173
+ end
174
+ result = options[:json] ? with_stdout_on_stderr(&action) : action.call
175
+ emit(result, options)
176
+ raise ArgumentError, result['last_error'] if result['status'] == 'error'
177
+ end
178
+
160
179
  def stop(id, options)
161
180
  registry.with_lock(id) do
162
181
  Backend.new(id).stop!
@@ -200,7 +219,8 @@ module Workspaces
200
219
 
201
220
  def usage
202
221
  'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
203
- 'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
222
+ 'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
223
+ 'resume ID [--from STEP | --only STEP] [--json] | stop ID | ' \
204
224
  'exec ID -- COMMAND... | remove ID [--force]'
205
225
  end
206
226
 
@@ -7,6 +7,7 @@ require_relative 'presentation'
7
7
  require_relative 'environment_overrides'
8
8
  require_relative 'duration_format'
9
9
  require_relative 'build_history'
10
+ require_relative 'resume_plan'
10
11
 
11
12
  module Workspaces
12
13
  class Coordinator
@@ -28,6 +29,25 @@ module Workspaces
28
29
  launch(id, restart: true, force: true, prelaunch: settings_update(id, preset, set, unset))
29
30
  end
30
31
 
32
+ # Re-runs part of the last run instead of all of it. `mode` is :retry (continue from the first
33
+ # step that did not finish), :from (that step and everything after it) or :only (exactly that
34
+ # step). Finished steps keep their time and log; see ResumePlan for what each mode allows.
35
+ # Returns false when the workspace is busy and raises ArgumentError (with a plain-language
36
+ # message) when the request cannot be honoured.
37
+ def rerun(id, mode: :retry, key: nil)
38
+ workspace = @registry.find(id)
39
+ lock = acquire_lock(id)
40
+ return false unless lock
41
+
42
+ plan = rerun_plan(id, mode, key)
43
+ publish_rerun(id, plan)
44
+ spawn_worker(workspace, false, lock, plan: plan)
45
+ true
46
+ rescue StandardError
47
+ lock&.close unless lock&.closed?
48
+ raise
49
+ end
50
+
31
51
  def update_environment(id, set:, remove:)
32
52
  restart = Backend.new(id).running?
33
53
  launch(id, restart: restart, force: true,
@@ -101,6 +121,7 @@ module Workspaces
101
121
  'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
102
122
  'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
103
123
  'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
124
+ 'resume' => resume_info(id, entry),
104
125
  'estimates' => entry.fetch('estimates', {}),
105
126
  'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
106
127
  'backend_log_available' => backend_log_available?(id),
@@ -140,6 +161,28 @@ module Workspaces
140
161
  true
141
162
  end
142
163
 
164
+ def resume_info(id, entry)
165
+ steps = entry['steps']
166
+ return nil if steps.nil? || steps.empty?
167
+
168
+ ResumePlan.describe(recipe_steps: Recipe.new.steps, persisted: steps, server_live: Backend.new(id).resumable?)
169
+ rescue Recipe::Invalid
170
+ nil
171
+ end
172
+
173
+ def rerun_plan(id, mode, key)
174
+ entry = @state.get(id) || {}
175
+ ResumePlan.build(recipe_steps: Recipe.new.steps, persisted: entry['steps'], mode: mode.to_sym, key: key,
176
+ server_live: Backend.new(id).resumable?)
177
+ end
178
+
179
+ def publish_rerun(id, plan)
180
+ update(id, 'status' => 'preparing', 'message' => "Re-running #{plan.description}",
181
+ 'steps' => plan.steps, 'estimates' => BuildHistory.estimates(plan.steps.map { |step| step['key'] }),
182
+ 'operation' => 'resume', 'rerun_keys' => plan.run_keys, 'failed_phase' => nil,
183
+ 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil, 'total_seconds' => nil)
184
+ end
185
+
143
186
  def acquire_lock(id)
144
187
  Config.locks_dir.mkpath
145
188
  # The worker owns this duplicate; closing the request's descriptor must not release its lock.
@@ -157,16 +200,16 @@ module Workspaces
157
200
  # is read once per run rather than once per poll.
158
201
  update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
159
202
  'steps' => steps, 'estimates' => BuildHistory.estimates(steps.map { |step| step['key'] }),
160
- 'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
203
+ 'operation' => restart ? 'restart' : 'prepare', 'rerun_keys' => nil, 'failed_phase' => nil,
161
204
  'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
162
205
  'total_seconds' => nil)
163
206
  end
164
207
 
165
- def spawn_worker(workspace, restart, lock)
208
+ def spawn_worker(workspace, restart, lock, plan: nil)
166
209
  id = workspace.id
167
210
  workers_mutex.synchronize do
168
211
  workers[id] = Thread.new do
169
- run(workspace, restart: restart)
212
+ run(workspace, restart: restart, plan: plan)
170
213
  ensure
171
214
  lock.close
172
215
  workers_mutex.synchronize { workers.delete(id) }
@@ -174,14 +217,15 @@ module Workspaces
174
217
  end
175
218
  end
176
219
 
177
- def run(workspace, restart:)
220
+ def run(workspace, restart:, plan: nil)
178
221
  id = workspace.id
179
222
  started = monotonic
180
223
  File.open(Config.setup_log_path(id), 'a') do |file|
181
224
  file.sync = true
182
225
  log = TeeLog.new(id, file)
183
- log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
184
- prepare_and_boot(workspace, log, restart, started)
226
+ verb = plan ? "re-running #{plan.description} of" : (restart ? 'restarting' : 'preparing')
227
+ log.puts "==> #{verb} workspace #{id}; branch and working files unchanged"
228
+ prepare_and_boot(workspace, log, restart, started, plan)
185
229
  end
186
230
  rescue StandardError => e
187
231
  publish_error(id, e, started)
@@ -189,12 +233,17 @@ module Workspaces
189
233
 
190
234
  # The wall-clock time of the whole run, boot and readiness included. Step durations add up to
191
235
  # less than this because orchestration between steps is not inside any step.
192
- def prepare_and_boot(workspace, log, restart, started)
236
+ def prepare_and_boot(workspace, log, restart, started, plan = nil)
193
237
  id = workspace.id
194
- port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
238
+ workflow = Workflow.new(workspace, log, state: @state)
239
+ # Full runs call the workflow exactly as they always have; only a partial re-run passes a plan.
240
+ port = plan ? workflow.run!(restart: restart, plan: plan) : workflow.run!(restart: restart)
195
241
  total = elapsed_since(started)
196
- update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
197
- 'total_seconds' => total)
242
+ # A re-run that left no server running (a step before it on a stopped workspace) keeps the
243
+ # recorded port as it was; the workspace then reads as stopped until it is started.
244
+ attributes = { 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'total_seconds' => total }
245
+ attributes['port'] = port if port
246
+ update(id, attributes)
198
247
  record_run(id, 'complete', total)
199
248
  end
200
249
 
@@ -212,8 +261,10 @@ module Workspaces
212
261
  # Appends to the build history. A problem here must never change how the run itself ended.
213
262
  def record_run(id, result, total_seconds)
214
263
  entry = @state.get(id) || {}
264
+ steps = entry['steps']
265
+ steps = steps.select { |step| entry['rerun_keys'].include?(step['key']) } if entry['rerun_keys'] && steps
215
266
  BuildHistory.record(workspace_id: id, operation: entry['operation'], result: result,
216
- total_seconds: total_seconds, steps: entry['steps'])
267
+ total_seconds: total_seconds, steps: steps)
217
268
  rescue StandardError
218
269
  nil
219
270
  end
@@ -7,14 +7,14 @@
7
7
  const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
8
8
  ? 'backend'
9
9
  : 'setup';
10
- const allowedActions = ['start', 'prepare', 'restart', 'stop', 'update-pr'];
10
+ const allowedActions = ['start', 'prepare', 'restart', 'resume', 'stop', 'update-pr'];
11
11
  let busy = root.dataset.busy === 'true';
12
12
  let started = Date.parse(root.dataset.started);
13
13
  let completed = Date.parse(root.dataset.completed);
14
14
  let actionsSignature = null;
15
15
  function toggleBusyState(nextBusy) {
16
16
  busy = !!nextBusy;
17
- root.querySelectorAll('.workspace-actions button, .lead__action button').forEach((button) => {
17
+ root.querySelectorAll('.workspace-actions button, .lead__action button, .step-log__actions button').forEach((button) => {
18
18
  button.disabled = busy;
19
19
  });
20
20
  if (window.WorkspaceEnvironment)
@@ -68,9 +68,12 @@
68
68
  const outcome = document.createElement('span');
69
69
  outcome.className = 'step-log__outcome step-outcome';
70
70
  summary.append(status, name, outcome);
71
+ const controls = document.createElement('div');
72
+ controls.className = 'step-log__actions';
73
+ controls.hidden = true;
71
74
  const output = document.createElement('pre');
72
75
  output.tabIndex = 0;
73
- details.append(summary, output);
76
+ details.append(summary, controls, output);
74
77
  details.addEventListener('toggle', () => {
75
78
  if (details.dataset.programmatic === 'true') return;
76
79
  userToggled.add(step.key);
@@ -98,6 +101,7 @@
98
101
  const parts = summary.children;
99
102
  parts[1].textContent = step.label;
100
103
  parts[2].textContent = stepOutcome(step);
104
+ renderStepControls(details, step, view.resume);
101
105
  const output = details.querySelector('pre');
102
106
  output.setAttribute('aria-label', step.label + ' output');
103
107
  const entry = stepLogs && stepLogs[step.key];
@@ -106,6 +110,32 @@
106
110
  });
107
111
  }
108
112
 
113
+ // Re-run controls for one step. The server says which of them would be accepted right now (a
114
+ // single step is only offered while the rest of the run is finished, and steps after the server
115
+ // only while it is up), so the page never offers something that would be refused.
116
+ function renderStepControls(details, step, resume) {
117
+ const holder = details.querySelector('.step-log__actions');
118
+ const modes = (resume && resume.step_modes && resume.step_modes[step.key]) || [];
119
+ const labels = { only: 'Re-run this step', from: 'Re-run from here' };
120
+ const signature = modes.join(',');
121
+ if (holder.dataset.signature === signature) return;
122
+ holder.dataset.signature = signature;
123
+ holder.replaceChildren();
124
+ holder.hidden = modes.length === 0;
125
+ modes.forEach((mode) => {
126
+ if (!labels[mode]) return;
127
+ const form = document.createElement('form');
128
+ form.method = 'post';
129
+ form.action = base + '/resume?' + mode + '=' + encodeURIComponent(step.key);
130
+ const button = document.createElement('button');
131
+ button.className = 'button button--subtle button--small';
132
+ button.textContent = labels[mode];
133
+ form.append(button);
134
+ holder.append(form);
135
+ });
136
+ toggleBusyState(busy);
137
+ }
138
+
109
139
  // The Full setup log row reads "in progress" while running, then how the run ended and how
110
140
  // long it took. Without a recorded total it keeps its plain label.
111
141
  function renderFullLogOutcome(view) {
@@ -472,6 +472,12 @@ fieldset:disabled {
472
472
  border-color: var(--border);
473
473
  }
474
474
 
475
+ .button--small { padding: 4px 10px; font-size: 12px; }
476
+
477
+ .step-log__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-2) var(--space-3) 0; }
478
+ .step-log__actions[hidden] { display: none; }
479
+ .step-log__actions form { margin: 0; }
480
+
475
481
  .button--danger {
476
482
  background: var(--danger);
477
483
  color: var(--bg);
@@ -226,6 +226,25 @@ module Workspaces
226
226
 
227
227
  # One GitHub-Actions-style accordion per recipe step. A step that is running or has
228
228
  # failed starts open; a completed or not-yet-started one starts closed.
229
+ # Small forms for re-running just this step or this step and everything after it, shown only
230
+ # for what the workspace can actually do right now (the server decides, not the page).
231
+ def step_controls(step)
232
+ modes = @presentation.dig('resume', 'step_modes')&.fetch(step['key'], nil)
233
+ return '' if modes.nil? || modes.empty?
234
+
235
+ last = @presentation['steps'].last
236
+ key = ERB::Util.url_encode(step['key'].to_s)
237
+ forms = []
238
+ forms << step_control(key, 'only', 'Re-run this step') if modes.include?('only')
239
+ forms << step_control(key, 'from', 'Re-run from here') if modes.include?('from') && !(modes.include?('only') && last && last['key'] == step['key'])
240
+ %(<div class="step-log__actions">#{forms.join}</div>)
241
+ end
242
+
243
+ def step_control(key, mode, label)
244
+ "<form method=\"post\" action=\"/workspaces/#{@id}/resume?#{mode}=#{key}\">" \
245
+ "<button class=\"button button--subtle button--small\">#{label}</button></form>"
246
+ end
247
+
229
248
  def step_accordion(step)
230
249
  key = escape(step['key'])
231
250
  state = escape(step['state'])
@@ -235,6 +254,7 @@ module Workspaces
235
254
  <<~HTML
236
255
  <details class="step-log" data-step-log="#{key}" data-step-state="#{state}"#{open}#{current}>
237
256
  <summary><span class="step-log__status" aria-hidden="true"></span><span class="step-log__name">#{escape(step['label'])}</span><span class="step-log__outcome step-outcome">#{escape(step_outcome(step))}</span></summary>
257
+ #{step_controls(step)}
238
258
  <pre tabindex="0" aria-label="#{escape(step['label'])} output">#{entry ? escape(entry['text']) : ''}</pre>
239
259
  </details>
240
260
  HTML
@@ -10,7 +10,7 @@ require_relative 'proxy_middleware'
10
10
  module Workspaces
11
11
  class FrontDoor
12
12
  WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
13
- (?:/(status|start|prepare|restart|stop|remove|update-pr|environment))?\z}x
13
+ (?:/(status|start|prepare|restart|resume|stop|remove|update-pr|environment))?\z}x
14
14
 
15
15
  # Rack env keys set on requests that reach a ready workspace backend, for use by
16
16
  # middleware registered with Workspaces.configure_proxy.
@@ -94,7 +94,7 @@ module Workspaces
94
94
  return remove(request, id) if action == 'remove'
95
95
  return update_pr(id) if action == 'update-pr'
96
96
 
97
- perform_action(id, action)
97
+ perform_action(id, action, request)
98
98
  redirect(Config.dashboard_url(id))
99
99
  end
100
100
 
@@ -109,11 +109,23 @@ module Workspaces
109
109
  response(200, body, type: action ? 'application/json' : 'text/html', head: request.head?)
110
110
  end
111
111
 
112
- def perform_action(id, action)
112
+ # Retries from the first unfinished step by default; `from=<step>` re-runs that step and every
113
+ # one after it, `only=<step>` re-runs exactly that step.
114
+ def resume(id, request)
115
+ from = request&.params&.fetch('from', nil)
116
+ only = request&.params&.fetch('only', nil)
117
+ raise ArgumentError, 'Choose either from or only, not both' if from && only
118
+
119
+ mode = only ? :only : (from ? :from : :retry)
120
+ @coordinator.rerun(id, mode: mode, key: only || from)
121
+ end
122
+
123
+ def perform_action(id, action, request = nil)
113
124
  case action
114
125
  when 'start' then @coordinator.start(id)
115
126
  when 'prepare' then @coordinator.start(id, force: true)
116
127
  when 'restart' then @coordinator.restart(id)
128
+ when 'resume' then resume(id, request)
117
129
  when 'stop' then stop(id)
118
130
  end
119
131
  end
@@ -6,7 +6,8 @@ module Workspaces
6
6
  'preparing' => 'Running setup', 'error' => 'Setup failed',
7
7
  'interrupted' => 'Setup interrupted' }.freeze
8
8
  ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
9
- 'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
9
+ 'restart' => 'Restart server', 'stop' => 'Stop server',
10
+ 'resume' => 'Retry failed step' }.freeze
10
11
 
11
12
  # Three layers, from friendly to technical. `lead` is the headline, one sentence and at
12
13
  # most one primary action. `environment` (presets and variables) is the area for
@@ -92,7 +93,7 @@ module Workspaces
92
93
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
93
94
  'actions' => actions, 'ready' => status == 'ready' && !busy?,
94
95
  'state' => state, 'sections' => sections,
95
- 'headline' => definition[:headline], 'summary' => definition[:summary],
96
+ 'headline' => definition[:headline], 'summary' => summary, 'resume' => resume,
96
97
  'lead_action' => lead_action, 'total' => total, 'progress' => progress,
97
98
  'current_preset' => current_preset,
98
99
  'environment_title' => definition[:environment_title],
@@ -126,15 +127,47 @@ module Workspaces
126
127
  end
127
128
 
128
129
  # The single most useful thing to do next, or nil when the user should just wait.
129
- # `open` links straight to the preview; the others are lifecycle form posts.
130
+ # `open` links straight to the preview; the others are lifecycle form posts. After a failure the
131
+ # first offer is to carry on from where it stopped; a full re-run stays one click away.
130
132
  def lead_action
131
133
  case state
132
134
  when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
133
135
  when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
134
- when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
136
+ when 'error', 'interrupted' then (retryable? ? resume_action : retry_action).merge('kind' => 'post')
135
137
  end
136
138
  end
137
139
 
140
+ # What a re-run of part of the last run could do, for the per-step controls and the retry
141
+ # wording. Nil while a run is in progress, or when the recorded steps cannot be re-run.
142
+ def resume
143
+ info = @snapshot['resume']
144
+ return nil if busy? || !info.is_a?(Hash)
145
+
146
+ failed = steps.find { |step| step['state'] == 'failed' }
147
+ { 'step_modes' => info['step_modes'] || {}, 'retry_from' => info['retry_from'],
148
+ 'retry_label' => info['retry_label'], 'failed_label' => failed && failed['label'] }
149
+ end
150
+
151
+ def retryable?
152
+ %w(error interrupted).include?(status) && !resume.nil? && !resume['retry_from'].nil?
153
+ end
154
+
155
+ def resume_action
156
+ action('resume', true).merge('label' => status == 'interrupted' ? 'Continue setup' : 'Retry failed step')
157
+ end
158
+
159
+ # Plain words for what a retry will do, naming the step. A failure at or after the server step
160
+ # restarts the server (a failed run stops it), so say where the retry really begins.
161
+ def summary
162
+ return definition[:summary] unless retryable?
163
+
164
+ info = resume
165
+ failed = info['failed_label'] || info['retry_label']
166
+ lead = status == 'interrupted' ? "Setup was interrupted during \"#{failed}\"." : "\"#{failed}\" didn't finish."
167
+ from = info['retry_label'] == failed ? 'it' : "from \"#{info['retry_label']}\""
168
+ "#{lead} Retrying runs #{from} again and keeps everything that already finished."
169
+ end
170
+
138
171
  # The preset whose values exactly equal the workspace's saved editable settings, or nil when
139
172
  # there are no presets or the settings match none (for example after a hand edit). Presets
140
173
  # arrive already completed with the recipe defaults, so equality is a plain comparison.
@@ -196,6 +229,8 @@ module Workspaces
196
229
  return [] if busy?
197
230
  available = if status == 'ready'
198
231
  %w(prepare restart stop).map { |key| action(key, false) }
232
+ elsif retryable?
233
+ [resume_action, retry_action.merge('label' => 'Re-run all steps', 'primary' => false)]
199
234
  elsif %w(error interrupted).include?(status)
200
235
  [retry_action]
201
236
  else
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Workspaces
4
+ # Decides which steps a re-run covers, from what a workspace last recorded and what the recipe
5
+ # says now. Pure: no files, no processes, so every rule can be tested directly.
6
+ #
7
+ # Three ways to ask:
8
+ # :retry continue from the first step that did not finish, but never later than the server
9
+ # step, because a failed run stops the server and everything after it needs a new one
10
+ # :from re-run one step and every step after it
11
+ # :only re-run exactly one step, leaving the rest of the run (and a running server) alone
12
+ #
13
+ # Refusals are plain-language ArgumentErrors, so callers can show them as they are.
14
+ class ResumePlan
15
+ class Refused < ArgumentError; end
16
+
17
+ Plan = Struct.new(:mode, :run_keys, :steps, :touches_server, :after_server, :description, keyword_init: true)
18
+
19
+ MODES = %i(only from).freeze
20
+
21
+ def self.describe(recipe_steps:, persisted:, server_live: false)
22
+ new(recipe_steps, persisted).describe(server_live: server_live)
23
+ end
24
+
25
+ def self.build(recipe_steps:, persisted:, mode:, key: nil, server_live: false)
26
+ new(recipe_steps, persisted).build(mode: mode, key: key, server_live: server_live)
27
+ end
28
+
29
+ def initialize(recipe_steps, persisted)
30
+ @recipe_keys = recipe_steps.map { |step| step['id'] }
31
+ @server_key = recipe_steps.find { |step| step['background'] }&.fetch('id')
32
+ @persisted = Array(persisted).map(&:dup)
33
+ @keys = @persisted.map { |step| step['key'] }
34
+ end
35
+
36
+ # What the dashboard needs to offer the right buttons, or nil when re-running is not possible
37
+ # (no recorded steps, or the recipe's steps have changed since they were recorded). Each step
38
+ # lists the modes that would be accepted for it, by asking `build` rather than repeating its rules.
39
+ def describe(server_live: false)
40
+ return nil unless compatible?
41
+
42
+ index = retry_index
43
+ { 'server_key' => @server_key,
44
+ 'retry_from' => index && @keys[index],
45
+ 'retry_label' => index && @persisted[index]['label'],
46
+ 'step_modes' => @keys.to_h { |key| [key, allowed_modes(key, server_live)] } }
47
+ end
48
+
49
+ def build(mode:, key: nil, server_live: false)
50
+ unless compatible?
51
+ raise Refused, 'The steps recorded for this workspace no longer match .workspaces.yml; re-run all steps instead'
52
+ end
53
+
54
+ from, to = range_for(mode, key)
55
+ run_keys = @keys[from..to]
56
+ touches = (from..to).cover?(server_idx)
57
+ after = from > server_idx
58
+ if after && !server_live
59
+ raise Refused, 'The server is not running, or was started before step re-runs were supported; ' \
60
+ 're-run from the server step instead'
61
+ end
62
+
63
+ Plan.new(mode: mode, run_keys: run_keys, steps: carried_steps(run_keys), touches_server: touches,
64
+ after_server: after, description: describe_range(mode, run_keys))
65
+ end
66
+
67
+ private
68
+
69
+ # The recorded steps are either the whole recipe or, after a restart, everything from the
70
+ # server step on. Anything else means the recipe moved on and the record cannot be trusted.
71
+ def compatible?
72
+ return false if @server_key.nil? || @keys.empty?
73
+
74
+ @keys == @recipe_keys || @keys == @recipe_keys.drop(@recipe_keys.index(@server_key))
75
+ end
76
+
77
+ def server_idx
78
+ @server_idx ||= @keys.index(@server_key)
79
+ end
80
+
81
+ def retry_index
82
+ first = @persisted.index { |step| step['state'] != 'complete' }
83
+ first && [first, server_idx].min
84
+ end
85
+
86
+ def range_for(mode, key)
87
+ last = @keys.length - 1
88
+ case mode
89
+ when :retry
90
+ index = retry_index
91
+ raise Refused, 'Every step already completed, so there is nothing to retry' unless index
92
+
93
+ [index, last]
94
+ when :from
95
+ index = index_of(key)
96
+ require_finished_before!(index)
97
+ [index, last]
98
+ when :only
99
+ index = index_of(key)
100
+ if index == server_idx
101
+ raise Refused, 'Restarting the server means re-running the steps after it; re-run from that step instead'
102
+ end
103
+ require_others_finished!(index)
104
+
105
+ [index, index]
106
+ else
107
+ raise ArgumentError, "Unknown re-run mode: #{mode.inspect}"
108
+ end
109
+ end
110
+
111
+ # A re-run must never leave an unfinished step behind, so that a successful one always means the
112
+ # whole workspace is complete.
113
+ def require_finished_before!(index)
114
+ first = @persisted.index { |step| step['state'] != 'complete' }
115
+ return if first.nil? || first >= index
116
+
117
+ raise Refused, "\"#{label_at(first)}\" has not finished; re-run from that step or an earlier one"
118
+ end
119
+
120
+ def require_others_finished!(index)
121
+ other = @persisted.each_index.find { |i| i != index && @persisted[i]['state'] != 'complete' }
122
+ return if other.nil?
123
+
124
+ raise Refused, "\"#{label_at(other)}\" has not finished; re-run from that step instead of a single step"
125
+ end
126
+
127
+ def label_at(index)
128
+ @persisted[index]['label'] || @keys[index]
129
+ end
130
+
131
+ def allowed_modes(key, server_live)
132
+ MODES.select do |mode|
133
+ build(mode: mode, key: key, server_live: server_live)
134
+ true
135
+ rescue Refused
136
+ false
137
+ end.map(&:to_s)
138
+ end
139
+
140
+ def index_of(key)
141
+ @keys.index(key.to_s) || raise(Refused, "Unknown step: #{key.to_s[0, 60].inspect}")
142
+ end
143
+
144
+ # The step list to publish: finished work stays as it was, the steps about to run start over.
145
+ def carried_steps(run_keys)
146
+ @persisted.map do |step|
147
+ next step unless run_keys.include?(step['key'])
148
+
149
+ step.merge('state' => 'pending', 'duration_seconds' => nil).tap { |reset| reset.delete('started_at') }
150
+ end
151
+ end
152
+
153
+ def describe_range(mode, run_keys)
154
+ names = run_keys.map { |key| @persisted[@keys.index(key)]['label'] || key }
155
+ case mode
156
+ when :only then "only #{names.first}"
157
+ when :from then "from #{names.first}"
158
+ else "from #{names.first}"
159
+ end
160
+ end
161
+ end
162
+ end
@@ -41,9 +41,13 @@ module Workspaces
41
41
  end
42
42
 
43
43
  # Clears every step file; called when a run begins so a restart never shows stale steps.
44
- def self.reset(workspace_id)
44
+ def self.reset(workspace_id, keys: nil)
45
45
  dir = Config.step_logs_dir(workspace_id, create: false)
46
- Dir.glob(dir.join('*.log')).each { |path| File.delete(path) } if dir.directory?
46
+ return unless dir.directory?
47
+
48
+ Dir.glob(dir.join('*.log')).each do |path|
49
+ File.delete(path) if keys.nil? || keys.include?(File.basename(path, '.log'))
50
+ end
47
51
  end
48
52
 
49
53
  private
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.14'
2
+ VERSION = '0.1.15'
3
3
  end
@@ -16,20 +16,28 @@ module Workspaces
16
16
  @backend = Backend.new(workspace.id)
17
17
  end
18
18
 
19
- def run!(restart: false)
19
+ # `plan` (a ResumePlan::Plan) re-runs part of the recipe: finished steps keep their state, time
20
+ # and log, only the planned steps start over, and the server is stopped and restarted only when
21
+ # the planned steps include the one that starts it. Without a plan the whole operation runs.
22
+ #
23
+ # Returns the server's port, or nil when no server is running afterwards (which can only happen
24
+ # when a plan re-ran a step before the server on a workspace whose server was stopped).
25
+ def run!(restart: false, plan: nil)
20
26
  recipe = Recipe.new
21
27
  overrides = EnvironmentOverrides.new(@workspace.id).load
22
- steps = recipe.for_operation(restart: restart)
23
- publish_steps(steps)
24
- StepLog.reset(@workspace.id)
25
- @backend.stop!
26
- context = build_context(@backend.reserve!)
28
+ steps = plan ? recipe.steps.select { |step| plan.run_keys.include?(step['id']) } : recipe.for_operation(restart: restart)
29
+ plan ? @state.set(@workspace.id, 'steps' => plan.steps) : publish_steps(steps)
30
+ StepLog.reset(@workspace.id, keys: plan&.run_keys)
31
+ context = prepare_server(plan)
27
32
  runner = CommandRunner.new(@workspace.path, @log)
28
33
  steps.each { |step| execute(step, recipe, context, runner, overrides) }
29
- @backend.ensure_alive!
30
- @backend.port
34
+ return finish_full_run if plan.nil? || plan.touches_server || plan.after_server
35
+
36
+ @backend.running? ? @backend.port : nil
31
37
  rescue StandardError
32
- @backend.stop!
38
+ # A server this run started (or would have replaced) is not trustworthy after a failure. One
39
+ # this run never touched is left as it was.
40
+ @backend.stop! if plan.nil? || plan.touches_server
33
41
  raise
34
42
  ensure
35
43
  @log.finish_step
@@ -37,19 +45,38 @@ module Workspaces
37
45
 
38
46
  private
39
47
 
48
+ # Stops the old server and reserves a fresh port and token when this run will start the server;
49
+ # otherwise the running server's own port and token are reused, so steps see the same values.
50
+ def prepare_server(plan)
51
+ if plan.nil? || plan.touches_server
52
+ @backend.stop!
53
+ return build_context(@backend.reserve!)
54
+ end
55
+
56
+ @server_started = plan.after_server
57
+ build_context(@backend.port, run_id: @backend.run_id)
58
+ end
59
+
60
+ # A run that covers the server step (or follows it) ends with a liveness check, exactly as a
61
+ # full run always has, and reports the port of the server it leaves running.
62
+ def finish_full_run
63
+ @backend.ensure_alive!
64
+ @backend.port
65
+ end
66
+
40
67
  def publish_steps(steps)
41
68
  @state.set(@workspace.id, 'steps' => steps.map do |step|
42
69
  { 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
43
70
  end)
44
71
  end
45
72
 
46
- def build_context(port)
73
+ def build_context(port, run_id: nil)
47
74
  {
48
75
  'WORKSPACE_ID' => @workspace.id, 'WORKSPACE_PATH' => @workspace.path.to_s,
49
76
  'WORKSPACE_REPO_ROOT' => Config.repo_root.to_s, 'WORKSPACE_PORT' => port.to_s,
50
77
  'WORKSPACE_URL' => Config.preview_url(@workspace.id).delete_suffix('/'),
51
78
  'WORKSPACE_HOST' => Config.preview_host(@workspace.id),
52
- 'WORKSPACE_CACHE_DIR' => Config.home.to_s, 'WORKSPACE_RUN_ID' => SecureRandom.hex(24)
79
+ 'WORKSPACE_CACHE_DIR' => Config.home.to_s, 'WORKSPACE_RUN_ID' => run_id || SecureRandom.hex(24)
53
80
  }
54
81
  end
55
82
 
@@ -62,6 +89,7 @@ module Workspaces
62
89
  env = recipe.env(step, context, overrides: overrides)
63
90
  if step['background']
64
91
  @backend.start!(@workspace, command, env: env)
92
+ @state.set(@workspace.id, 'run_id' => context.fetch('WORKSPACE_RUN_ID'))
65
93
  else
66
94
  runner.run!(command, env: env, timeout: step.fetch('timeout'))
67
95
  @backend.ensure_alive! if @server_started
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.14
4
+ version: 0.1.15
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-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -94,6 +94,7 @@ files:
94
94
  - lib/workspaces/proxy_middleware.rb
95
95
  - lib/workspaces/recipe.rb
96
96
  - lib/workspaces/registry.rb
97
+ - lib/workspaces/resume_plan.rb
97
98
  - lib/workspaces/state_store.rb
98
99
  - lib/workspaces/step_log.rb
99
100
  - lib/workspaces/tee_log.rb