workspaces 0.1.14 → 0.1.16
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 +4 -4
- data/DESIGN.md +3 -2
- data/README.md +30 -1
- data/lib/workspaces/backend.rb +12 -1
- data/lib/workspaces/cli.rb +22 -2
- data/lib/workspaces/coordinator.rb +62 -11
- data/lib/workspaces/dashboard/app.js +45 -3
- data/lib/workspaces/dashboard/styles.css +6 -0
- data/lib/workspaces/dashboard_view.rb +20 -0
- data/lib/workspaces/front_door.rb +15 -3
- data/lib/workspaces/presentation.rb +39 -4
- data/lib/workspaces/resume_plan.rb +162 -0
- data/lib/workspaces/step_log.rb +6 -2
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +39 -11
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5377d0d4a49b5a0b585540ddc02e4eeaaeb3e34a8a26dba5ce4db2107c85334
|
|
4
|
+
data.tar.gz: d880d6274c7a79ca45f9fd39232310083c4709d5b3a3bdf630d6eee716563278
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aeb8a8dbd6e618499c3831d711734df8eb930f88656c0f2f66617f9835ddd1d994ef39c25362d784697cef44478847ed1df9d4ceb382a37066209d2bb427b9aa
|
|
7
|
+
data.tar.gz: 6fe026cae101124808e962b0280c64a4d851665b02789dad6b6eceeea7dcf0147d46a11ee21c7df261671c2369c606e01f3baee45ed28efbe47fd13d5ac8fefe
|
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
|
-
- **
|
|
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 `
|
|
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
|
data/lib/workspaces/backend.rb
CHANGED
|
@@ -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
|
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -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] |
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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:
|
|
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,44 @@
|
|
|
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
|
+
// The server-rendered first paint only includes this container when a step has controls, so the
|
|
117
|
+
// script creates it when it is missing instead of assuming it exists.
|
|
118
|
+
function stepControlsHolder(details) {
|
|
119
|
+
const existing = details.querySelector('.step-log__actions');
|
|
120
|
+
if (existing) return existing;
|
|
121
|
+
const holder = document.createElement('div');
|
|
122
|
+
holder.className = 'step-log__actions';
|
|
123
|
+
holder.hidden = true;
|
|
124
|
+
details.insertBefore(holder, details.querySelector('pre'));
|
|
125
|
+
return holder;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderStepControls(details, step, resume) {
|
|
129
|
+
const holder = stepControlsHolder(details);
|
|
130
|
+
const modes = (resume && resume.step_modes && resume.step_modes[step.key]) || [];
|
|
131
|
+
const labels = { only: 'Re-run this step', from: 'Re-run from here' };
|
|
132
|
+
const signature = modes.join(',');
|
|
133
|
+
if (holder.dataset.signature === signature) return;
|
|
134
|
+
holder.dataset.signature = signature;
|
|
135
|
+
holder.replaceChildren();
|
|
136
|
+
holder.hidden = modes.length === 0;
|
|
137
|
+
modes.forEach((mode) => {
|
|
138
|
+
if (!labels[mode]) return;
|
|
139
|
+
const form = document.createElement('form');
|
|
140
|
+
form.method = 'post';
|
|
141
|
+
form.action = base + '/resume?' + mode + '=' + encodeURIComponent(step.key);
|
|
142
|
+
const button = document.createElement('button');
|
|
143
|
+
button.className = 'button button--subtle button--small';
|
|
144
|
+
button.textContent = labels[mode];
|
|
145
|
+
form.append(button);
|
|
146
|
+
holder.append(form);
|
|
147
|
+
});
|
|
148
|
+
toggleBusyState(busy);
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
// The Full setup log row reads "in progress" while running, then how the run ended and how
|
|
110
152
|
// long it took. Without a recorded total it keeps its plain label.
|
|
111
153
|
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
|
-
|
|
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'
|
|
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' =>
|
|
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
|
data/lib/workspaces/step_log.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -16,20 +16,28 @@ module Workspaces
|
|
|
16
16
|
@backend = Backend.new(workspace.id)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.1.16
|
|
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-
|
|
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
|