workspaces 0.1.12 → 0.1.14
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 +2 -0
- data/README.md +60 -0
- data/lib/workspaces/build_history.rb +83 -0
- data/lib/workspaces/coordinator.rb +20 -3
- data/lib/workspaces/dashboard/app.js +24 -10
- data/lib/workspaces/dashboard/styles.css +11 -0
- data/lib/workspaces/dashboard/template.html.erb +7 -1
- data/lib/workspaces/dashboard_view.rb +29 -0
- data/lib/workspaces/paths.rb +5 -0
- data/lib/workspaces/presentation.rb +9 -6
- data/lib/workspaces/progress.rb +122 -0
- data/lib/workspaces/recipe.rb +33 -2
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +3 -2
- data/lib/workspaces/worktree.rb +10 -4
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 160b209187a95a674c6ce4936b4b2863ed5893ac8331f1f0345d550cf09f2f2d
|
|
4
|
+
data.tar.gz: ba7ef0dd3ed8c913ea29683f641c1d4ac314ee15a1c201dd45634d45a8a980bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 978a8f06eda4d6d9c0c443499f1cf28f41c33800b3394b40fb56dd8771e0f1283001a4e24effcb3f7c52563023a18e1f48bfe884f97bd255642ff9379b9ea095
|
|
7
|
+
data.tar.gz: 8162b0c59925b414a1af0ff511fcc6bee2d79f83bb42d452614ef68f79c7ee52e42cb89531c21c5e77b29bc76b76cdfea4f4b5fff73fff2217b49217a0321a2c
|
data/DESIGN.md
CHANGED
|
@@ -67,6 +67,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
67
67
|
- **Capture**: `StepLog` wraps the combined log. Every line still goes to the combined `<id>.setup.log`; while a step is current the same scrubbed text also goes to `logs/<id>.steps/<step-id>.log`. A step's file is truncated when it begins, the files are cleared when a run starts, and a failing step records its error message in its own file.
|
|
68
68
|
- **Read**: the snapshot carries `step_logs` (the tail of each step that has output, with a `truncated` flag) for the setup view only, and `backend_log_available`.
|
|
69
69
|
- **Tabs**: *Setup steps* (the accordions) and *Web server log*. The server tab appears only once the backend log has content. The combined log stays reachable as a final, collapsed *Full setup log* row, which also covers workspaces set up before step files existed.
|
|
70
|
+
- **Build progress**: while a build is being prepared the lead is the headline, a progress bar (`role="progressbar"` with a text value), and one line saying *Step n of m · name*, the percentage, the elapsed time and an estimate. The server computes the numbers from the step states and per-step averages frozen when the run began (`Progress`), and the script only draws them; the elapsed clock ticks locally between polls. A step well past its usual time turns the bar amber and the estimate reads "Taking longer than usual". Without history there is no time estimate. The bar is hidden when no build is running.
|
|
71
|
+
- **Build history**: every finished run is appended to `build-history.jsonl` (`BuildHistory`). Estimates are the mean of a step's last 10 successful completions; failed runs are recorded but never counted. Recording is best-effort and cannot fail a build.
|
|
70
72
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
71
73
|
- **Log source switcher**: two native links inside one log panel (Setup steps, Web server log), with current selection announced through `aria-current`; only the selected view is loaded and refreshed. Setup steps is the default.
|
|
72
74
|
- **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
|
data/README.md
CHANGED
|
@@ -39,6 +39,42 @@ The returned JSON includes the workspace ID, dashboard URL, and preview URL. You
|
|
|
39
39
|
create from a pull request with `create --pr NUMBER`, or create a new branch with
|
|
40
40
|
`create --new-branch NAME --from REF`. Run `help` for lifecycle and other commands.
|
|
41
41
|
|
|
42
|
+
### Git LFS
|
|
43
|
+
|
|
44
|
+
If the project keeps large files in [Git LFS](https://git-lfs.com), a new workspace does not
|
|
45
|
+
copy them. Creating a workspace (and fast-forwarding a PR workspace to a newer head) leaves
|
|
46
|
+
those files as small pointer files, and `git status` stays clean. Without this, every
|
|
47
|
+
workspace would hold a full second copy of every LFS file, even though the source
|
|
48
|
+
checkout's LFS store is already shared.
|
|
49
|
+
|
|
50
|
+
List the paths a workspace actually needs in `.workspaces.yml`:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
lfs:
|
|
54
|
+
fetch:
|
|
55
|
+
- eval/amc/images/golden/**
|
|
56
|
+
- spec/fixtures/images/**
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That adds a first step, **Fetch Git LFS files**, which runs
|
|
60
|
+
`git lfs pull --include=<paths>` in the workspace. It appears in the step list with its own
|
|
61
|
+
log, time and place in the progress estimate like any other step, and it runs on `start` and
|
|
62
|
+
`prepare` but not on `restart`. Paths are repository-relative patterns; absolute paths,
|
|
63
|
+
`..`, a leading `-`, commas and control characters are rejected when the recipe loads.
|
|
64
|
+
Omit `lfs` and nothing changes except that files stay as pointers.
|
|
65
|
+
|
|
66
|
+
A few things worth knowing:
|
|
67
|
+
|
|
68
|
+
- Files are fetched from the objects the source repository already has when it can, and from
|
|
69
|
+
the remote otherwise. If neither works (no network, or no credentials for LFS) the step
|
|
70
|
+
fails and the workspace reports it, rather than silently leaving pointers.
|
|
71
|
+
- A file that is still a pointer reads as a ~130 byte text file starting with
|
|
72
|
+
`version https://git-lfs`. If a test or build reads an LFS file, add its path to
|
|
73
|
+
`lfs.fetch`.
|
|
74
|
+
- To fetch more by hand in an existing workspace:
|
|
75
|
+
`workspaces exec <id> -- git lfs pull --include='path/**'`.
|
|
76
|
+
- Workspaces created before this behavior keep their real files; nothing is converted.
|
|
77
|
+
|
|
42
78
|
### Step timings
|
|
43
79
|
|
|
44
80
|
Per-step durations are persisted in seconds in workspace step status JSON, so they
|
|
@@ -54,6 +90,30 @@ Interrupted steps have no recorded duration. Step timings are not the total
|
|
|
54
90
|
workflow wall-clock time; orchestration overhead can fall outside the steps, which is
|
|
55
91
|
why the total is measured separately and is usually a little larger than their sum.
|
|
56
92
|
|
|
93
|
+
### Build history and the progress bar
|
|
94
|
+
|
|
95
|
+
While a workspace is being prepared the dashboard shows a progress bar with the current step,
|
|
96
|
+
the percentage, the elapsed time and, once there is history, an estimate of what is left.
|
|
97
|
+
|
|
98
|
+
Every finished run is appended to `build-history.jsonl` in the Workspaces home, one JSON
|
|
99
|
+
object per line, shared by all workspaces of the project. It is written quietly and a failure
|
|
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
|
|
102
|
+
version, and each step's id, state and seconds. To see which step is getting slower:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
jq -r 'select(.result=="complete") | .at[:10] as $d | .steps[] | [$d, .id, .seconds] | @tsv' \
|
|
106
|
+
"$WORKSPACES_HOME/build-history.jsonl"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Estimates are the mean of each step over its last 10 successful runs. Failed runs are
|
|
110
|
+
recorded for the trend but never count toward "usual". They are read once when a run starts,
|
|
111
|
+
so the bar does not shift while you watch it. Percentages weigh each step by how long it
|
|
112
|
+
usually takes, count the running step by how much of its usual time has passed, never reach
|
|
113
|
+
100% until the run finishes, and hold a step that overruns below done. A step more than 1.5
|
|
114
|
+
times its usual time is flagged as taking longer than usual. With no history yet, steps weigh
|
|
115
|
+
the same and no time estimate is shown. Delete the file to start the estimates over.
|
|
116
|
+
|
|
57
117
|
## Serve the dashboard and previews
|
|
58
118
|
|
|
59
119
|
Run `serve` in a separate terminal using the **same project root and `WORKSPACES_HOME`**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
|
|
8
|
+
module Workspaces
|
|
9
|
+
# A quiet, append-only record of every finished run, plus the per-step averages derived from it.
|
|
10
|
+
#
|
|
11
|
+
# One JSON object per line in `build-history.jsonl` under the Workspaces home, shared by every
|
|
12
|
+
# workspace of the project, so it can be read with jq or loaded into a spreadsheet to see how
|
|
13
|
+
# setup times trend. Only successful runs feed the progress estimates; failed runs are recorded
|
|
14
|
+
# for the trend but never skew what "usual" means.
|
|
15
|
+
#
|
|
16
|
+
# Nothing here may break a build: recording failures are swallowed with a one-line warning.
|
|
17
|
+
module BuildHistory
|
|
18
|
+
SAMPLE_SIZE = 10
|
|
19
|
+
# The estimates only need recent runs, so a long history is read from its tail.
|
|
20
|
+
READ_BYTES = 512 * 1024
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def record(workspace_id:, operation:, result:, total_seconds:, steps:, now: Time.now.utc)
|
|
25
|
+
line = JSON.generate(
|
|
26
|
+
'at' => now.iso8601, 'workspace_id' => workspace_id, 'operation' => operation, 'result' => result,
|
|
27
|
+
'total_seconds' => total_seconds, 'workspaces_version' => VERSION,
|
|
28
|
+
'steps' => Array(steps).map { |step| { 'id' => step['key'], 'state' => step['state'], 'seconds' => step['duration_seconds'] } }
|
|
29
|
+
)
|
|
30
|
+
path = Config.build_history_path
|
|
31
|
+
FileUtils.mkdir_p(path.dirname)
|
|
32
|
+
File.open(path, 'a') do |file|
|
|
33
|
+
file.flock(File::LOCK_EX)
|
|
34
|
+
file.puts(line)
|
|
35
|
+
end
|
|
36
|
+
true
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
warn "[Workspaces] build history not recorded: #{e.class}"
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Mean seconds for each requested step id, over its last SAMPLE_SIZE completions in
|
|
43
|
+
# successful runs. Steps never seen succeed are omitted.
|
|
44
|
+
def estimates(step_ids)
|
|
45
|
+
wanted = step_ids.to_a
|
|
46
|
+
samples = Hash.new { |hash, key| hash[key] = [] }
|
|
47
|
+
records.reverse_each do |record|
|
|
48
|
+
next unless record['result'] == 'complete'
|
|
49
|
+
|
|
50
|
+
Array(record['steps']).each do |step|
|
|
51
|
+
id = step['id']
|
|
52
|
+
seconds = step['seconds']
|
|
53
|
+
next unless wanted.include?(id) && step['state'] == 'complete' && seconds.is_a?(Numeric) && seconds >= 0
|
|
54
|
+
next if samples[id].length >= SAMPLE_SIZE
|
|
55
|
+
|
|
56
|
+
samples[id] << seconds
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
samples.transform_values { |values| (values.sum / values.length).round(3) }
|
|
60
|
+
rescue StandardError
|
|
61
|
+
{}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Newest last. Unreadable or half-written lines are skipped.
|
|
65
|
+
def records
|
|
66
|
+
path = Config.build_history_path
|
|
67
|
+
return [] unless path.file?
|
|
68
|
+
|
|
69
|
+
text = File.open(path, 'rb') do |file|
|
|
70
|
+
size = file.size
|
|
71
|
+
file.seek(-READ_BYTES, IO::SEEK_END) if size > READ_BYTES
|
|
72
|
+
data = file.read.force_encoding('UTF-8')
|
|
73
|
+
size > READ_BYTES ? data.sub(/\A[^\n]*\n/, '') : data
|
|
74
|
+
end
|
|
75
|
+
text.each_line.filter_map do |line|
|
|
76
|
+
parsed = JSON.parse(line)
|
|
77
|
+
parsed if parsed.is_a?(Hash)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative 'tee_log'
|
|
|
6
6
|
require_relative 'presentation'
|
|
7
7
|
require_relative 'environment_overrides'
|
|
8
8
|
require_relative 'duration_format'
|
|
9
|
+
require_relative 'build_history'
|
|
9
10
|
|
|
10
11
|
module Workspaces
|
|
11
12
|
class Coordinator
|
|
@@ -100,6 +101,7 @@ module Workspaces
|
|
|
100
101
|
'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
|
|
101
102
|
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
102
103
|
'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
|
|
104
|
+
'estimates' => entry.fetch('estimates', {}),
|
|
103
105
|
'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
|
|
104
106
|
'backend_log_available' => backend_log_available?(id),
|
|
105
107
|
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
@@ -151,8 +153,10 @@ module Workspaces
|
|
|
151
153
|
steps = recipe.for_operation(restart: restart).map do |step|
|
|
152
154
|
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
|
|
153
155
|
end
|
|
156
|
+
# Frozen for the whole run, so the bar does not shift under the reader and the history file
|
|
157
|
+
# is read once per run rather than once per poll.
|
|
154
158
|
update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
155
|
-
'steps' => steps,
|
|
159
|
+
'steps' => steps, 'estimates' => BuildHistory.estimates(steps.map { |step| step['key'] }),
|
|
156
160
|
'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
|
|
157
161
|
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
|
|
158
162
|
'total_seconds' => nil)
|
|
@@ -188,17 +192,30 @@ module Workspaces
|
|
|
188
192
|
def prepare_and_boot(workspace, log, restart, started)
|
|
189
193
|
id = workspace.id
|
|
190
194
|
port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
|
|
195
|
+
total = elapsed_since(started)
|
|
191
196
|
update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
|
|
192
|
-
'total_seconds' =>
|
|
197
|
+
'total_seconds' => total)
|
|
198
|
+
record_run(id, 'complete', total)
|
|
193
199
|
end
|
|
194
200
|
|
|
195
201
|
def publish_error(id, exception, started = nil)
|
|
196
202
|
detail = scrub("#{exception.class}: #{exception.message}", id: id)
|
|
197
203
|
$stdout.puts "[Workspace #{id}] ERROR: #{detail}"
|
|
198
204
|
File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
|
|
205
|
+
total = started && elapsed_since(started)
|
|
199
206
|
update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
|
|
200
207
|
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
|
|
201
|
-
'total_seconds' =>
|
|
208
|
+
'total_seconds' => total)
|
|
209
|
+
record_run(id, 'failed', total)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Appends to the build history. A problem here must never change how the run itself ended.
|
|
213
|
+
def record_run(id, result, total_seconds)
|
|
214
|
+
entry = @state.get(id) || {}
|
|
215
|
+
BuildHistory.record(workspace_id: id, operation: entry['operation'], result: result,
|
|
216
|
+
total_seconds: total_seconds, steps: entry['steps'])
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
202
219
|
end
|
|
203
220
|
|
|
204
221
|
def monotonic
|
|
@@ -206,16 +206,7 @@
|
|
|
206
206
|
function renderLead(view) {
|
|
207
207
|
node('status').textContent = view.headline || view.title;
|
|
208
208
|
node('summary').textContent = view.summary || '';
|
|
209
|
-
|
|
210
|
-
const activity = node('activity');
|
|
211
|
-
if (subtitle) {
|
|
212
|
-
const text = view.subtitle || '';
|
|
213
|
-
subtitle.hidden = !text;
|
|
214
|
-
// The text has its own element, so it can be set whether or not the server rendered any.
|
|
215
|
-
const target = node('subtitle-text');
|
|
216
|
-
if (target) target.textContent = text;
|
|
217
|
-
}
|
|
218
|
-
if (activity) activity.hidden = !view.busy;
|
|
209
|
+
renderProgress(view.progress);
|
|
219
210
|
const title = node('environment-title');
|
|
220
211
|
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
221
212
|
const badge = node('environment-preset-badge');
|
|
@@ -229,6 +220,29 @@
|
|
|
229
220
|
// The lead action is a lifecycle post (start/retry) or, when ready, the preview link
|
|
230
221
|
// rendered separately as #preview. Rebuild only when it actually changes.
|
|
231
222
|
let leadSignature = null;
|
|
223
|
+
// The server decides the percentage, the step line and the estimate; this only draws them. The
|
|
224
|
+
// elapsed clock is ticked locally each second (see tick) so it keeps moving between polls.
|
|
225
|
+
function renderProgress(progress) {
|
|
226
|
+
const holder = node('build-progress');
|
|
227
|
+
if (!holder) return;
|
|
228
|
+
holder.hidden = !progress;
|
|
229
|
+
if (!progress) return;
|
|
230
|
+
const percent = progress.percent;
|
|
231
|
+
const bar = node('progress-bar');
|
|
232
|
+
bar.setAttribute('aria-valuenow', String(percent));
|
|
233
|
+
bar.setAttribute(
|
|
234
|
+
'aria-valuetext',
|
|
235
|
+
[progress.step_text + ' · ' + progress.step_label, progress.eta_text].filter(Boolean).join('. ')
|
|
236
|
+
);
|
|
237
|
+
bar.dataset.slow = progress.slow ? 'true' : 'false';
|
|
238
|
+
node('progress-fill').style.inlineSize = percent + '%';
|
|
239
|
+
node('progress-step').textContent = progress.step_text + ' · ' + progress.step_label;
|
|
240
|
+
node('progress-percent').textContent = percent + '%';
|
|
241
|
+
node('progress-eta').textContent = progress.eta_text ? ' · ' + progress.eta_text : '';
|
|
242
|
+
if (!Number.isFinite(started) && Number.isFinite(progress.elapsed_seconds))
|
|
243
|
+
node('elapsed').textContent = formatDuration(progress.elapsed_seconds);
|
|
244
|
+
}
|
|
245
|
+
|
|
232
246
|
function renderLeadAction(action) {
|
|
233
247
|
const holder = node('lead-action');
|
|
234
248
|
if (!holder) return;
|
|
@@ -155,6 +155,17 @@ h1 {
|
|
|
155
155
|
.lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
|
|
156
156
|
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
|
|
157
157
|
.lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
158
|
+
.lead__actions:empty { display: none; }
|
|
159
|
+
|
|
160
|
+
/* The build progress bar: a plain track and fill, then one line saying which step and how long. */
|
|
161
|
+
.build-progress { display: grid; gap: var(--space-3); }
|
|
162
|
+
.progress-bar { block-size: 10px; border-radius: 999px; background: color-mix(in oklab, var(--muted) 22%, transparent); overflow: hidden; }
|
|
163
|
+
.progress-bar__fill { display: block; block-size: 100%; border-radius: inherit; background: var(--accent); transition: inline-size 600ms ease; }
|
|
164
|
+
.progress-bar[data-slow='true'] .progress-bar__fill { background: var(--warning); }
|
|
165
|
+
.progress-meta { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: var(--space-1) var(--space-4); }
|
|
166
|
+
.progress-meta__step { display: flex; align-items: center; gap: var(--space-2); font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
|
|
167
|
+
.progress-meta__time { font-variant-numeric: tabular-nums; }
|
|
168
|
+
@media (prefers-reduced-motion: reduce) { .progress-bar__fill { transition: none; } }
|
|
158
169
|
.lead__action:empty { display: none; }
|
|
159
170
|
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
|
|
160
171
|
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
<div class="lead__text">
|
|
8
8
|
<h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
|
|
9
9
|
<p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
|
|
10
|
-
|
|
10
|
+
</div>
|
|
11
|
+
<div class="build-progress" id="build-progress" <%= 'hidden' unless @presentation['progress'] %>>
|
|
12
|
+
<div class="progress-bar" id="progress-bar" role="progressbar" aria-label="Setup progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= progress_percent %>" aria-valuetext="<%= escape(progress_valuetext) %>" data-slow="<%= @presentation.dig('progress', 'slow') ? 'true' : 'false' %>"><span class="progress-bar__fill" id="progress-fill" style="inline-size: <%= progress_percent %>%"></span></div>
|
|
13
|
+
<div class="progress-meta">
|
|
14
|
+
<p class="progress-meta__step"><span class="activity-dot" aria-hidden="true"></span><span id="progress-step"><%= escape(progress_step_text) %></span></p>
|
|
15
|
+
<p class="progress-meta__time muted"><span id="progress-percent"><%= progress_percent %>%</span><span aria-hidden="true"> · </span><span id="elapsed"><%= escape(progress_elapsed_text) %></span><span id="progress-eta"><%= escape(progress_eta_suffix) %></span></p>
|
|
16
|
+
</div>
|
|
11
17
|
</div>
|
|
12
18
|
<div class="lead__actions">
|
|
13
19
|
<a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
@@ -186,6 +186,35 @@ module Workspaces
|
|
|
186
186
|
text ? "#{total['state']} · #{text}" : 'everything, in order'
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# The bar's text, rendered on the server for the first paint; app.js keeps it current after that.
|
|
190
|
+
def progress
|
|
191
|
+
@presentation['progress'] || {}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def progress_percent
|
|
195
|
+
progress.fetch('percent', 0)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def progress_step_text
|
|
199
|
+
return '' if progress.empty?
|
|
200
|
+
|
|
201
|
+
"#{progress['step_text']} · #{progress['step_label']}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def progress_elapsed_text
|
|
205
|
+
DurationFormat.call(progress['elapsed_seconds']) || '—'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def progress_eta_suffix
|
|
209
|
+
progress['eta_text'] ? " · #{progress['eta_text']}" : ''
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def progress_valuetext
|
|
213
|
+
return '' if progress.empty?
|
|
214
|
+
|
|
215
|
+
[progress_step_text, progress['eta_text']].compact.join('. ')
|
|
216
|
+
end
|
|
217
|
+
|
|
189
218
|
def preset_badge_text
|
|
190
219
|
name = @presentation['current_preset']
|
|
191
220
|
name ? "Preset: #{name}" : ''
|
data/lib/workspaces/paths.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Workspaces
|
|
|
27
27
|
home.join('state.json')
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Every finished run, one JSON object per line, shared by all workspaces of this project.
|
|
31
|
+
def build_history_path
|
|
32
|
+
home.join('build-history.jsonl')
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
def log_path(workspace_id, create: true)
|
|
31
36
|
setup_log_path(workspace_id, create: create)
|
|
32
37
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'progress'
|
|
2
|
+
|
|
1
3
|
module Workspaces
|
|
2
4
|
class Presentation
|
|
3
5
|
TITLES = { 'idle' => 'Ready to start', 'stopped' => 'Server stopped', 'ready' => 'Workspace ready',
|
|
@@ -41,7 +43,7 @@ module Workspaces
|
|
|
41
43
|
'busy' => {
|
|
42
44
|
sections: %w(lead technical log),
|
|
43
45
|
headline: 'Setting things up…',
|
|
44
|
-
summary: "
|
|
46
|
+
summary: "We'll take you to the preview as soon as it's ready.",
|
|
45
47
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
46
48
|
open_environment: false, open_technical: true
|
|
47
49
|
},
|
|
@@ -91,7 +93,7 @@ module Workspaces
|
|
|
91
93
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
92
94
|
'state' => state, 'sections' => sections,
|
|
93
95
|
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
94
|
-
'
|
|
96
|
+
'lead_action' => lead_action, 'total' => total, 'progress' => progress,
|
|
95
97
|
'current_preset' => current_preset,
|
|
96
98
|
'environment_title' => definition[:environment_title],
|
|
97
99
|
'environment_submit' => definition[:environment_submit],
|
|
@@ -159,11 +161,12 @@ module Workspaces
|
|
|
159
161
|
{ 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
|
|
160
162
|
end
|
|
161
163
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
# The progress bar's numbers, only while a build is being prepared (not for a lone command
|
|
165
|
+
# on a ready workspace, which has no steps of its own to measure).
|
|
166
|
+
def progress
|
|
167
|
+
return nil unless status == 'preparing' && !steps.empty?
|
|
165
168
|
|
|
166
|
-
@snapshot['
|
|
169
|
+
Progress.new(steps: steps, estimates: @snapshot['estimates'], started_at: @snapshot['prepare_started_at']).to_h
|
|
167
170
|
end
|
|
168
171
|
|
|
169
172
|
def title
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'duration_format'
|
|
5
|
+
|
|
6
|
+
module Workspaces
|
|
7
|
+
# A deliberately simple read on how far along a run is, for a progress bar.
|
|
8
|
+
#
|
|
9
|
+
# With history: each step carries weight equal to its usual duration, finished steps count in
|
|
10
|
+
# full, and the running step counts by how much of its usual time has passed. Without history
|
|
11
|
+
# every step weighs the same and only finished steps count. The bar never claims 100% while
|
|
12
|
+
# work remains, and a step that overruns its usual time is held back so the bar waits for it.
|
|
13
|
+
class Progress
|
|
14
|
+
MIN_WEIGHT = 1.0
|
|
15
|
+
MAX_PERCENT = 99
|
|
16
|
+
STEP_CAP = 0.95
|
|
17
|
+
SLOW_FACTOR = 1.5
|
|
18
|
+
|
|
19
|
+
def initialize(steps:, estimates:, started_at:, now: Time.now)
|
|
20
|
+
@steps = steps
|
|
21
|
+
@estimates = estimates || {}
|
|
22
|
+
@started_at = parse(started_at)
|
|
23
|
+
@now = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
return nil if @steps.empty?
|
|
28
|
+
|
|
29
|
+
{ 'percent' => percent, 'step_label' => current_step['label'], 'step_text' => "Step #{current_index + 1} of #{@steps.length}",
|
|
30
|
+
'elapsed_seconds' => elapsed, 'eta_text' => eta_text, 'slow' => slow?, 'estimated' => estimated? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def percent
|
|
36
|
+
done = 0.0
|
|
37
|
+
@steps.each_with_index do |step, index|
|
|
38
|
+
case step['state']
|
|
39
|
+
when 'complete' then done += weights[index]
|
|
40
|
+
when 'active' then done += weights[index] * active_fraction(step, weights[index])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
[(done / weights.sum * 100).floor, MAX_PERCENT].min
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def active_fraction(step, weight)
|
|
47
|
+
return 0.0 unless estimated?
|
|
48
|
+
|
|
49
|
+
[step_elapsed(step) / weight, STEP_CAP].min
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def estimated?
|
|
53
|
+
@steps.any? { |step| estimate(step) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def estimate(step)
|
|
57
|
+
value = @estimates[step['key']]
|
|
58
|
+
value.is_a?(Numeric) && value.positive? ? [value, MIN_WEIGHT].max : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A step with no history of its own borrows the average of the ones that have it.
|
|
62
|
+
def weights
|
|
63
|
+
@weights ||= begin
|
|
64
|
+
known = @steps.filter_map { |step| estimate(step) }
|
|
65
|
+
fallback = known.empty? ? 1.0 : known.sum / known.length
|
|
66
|
+
@steps.map { |step| estimate(step) || fallback }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def current_index
|
|
71
|
+
@current_index ||= @steps.index { |step| step['state'] == 'active' } ||
|
|
72
|
+
@steps.index { |step| step['state'] == 'pending' } || (@steps.length - 1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def current_step
|
|
76
|
+
@steps[current_index]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def step_elapsed(step)
|
|
80
|
+
started = parse(step['started_at'])
|
|
81
|
+
started ? [@now - started, 0].max : 0.0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def slow?
|
|
85
|
+
step = @steps.find { |item| item['state'] == 'active' }
|
|
86
|
+
estimated? && !step.nil? && step_elapsed(step) > weights[@steps.index(step)] * SLOW_FACTOR
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remaining
|
|
90
|
+
@steps.each_with_index.sum do |step, index|
|
|
91
|
+
case step['state']
|
|
92
|
+
when 'pending' then weights[index]
|
|
93
|
+
when 'active' then [weights[index] - step_elapsed(step), 0].max
|
|
94
|
+
else 0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def eta_text
|
|
100
|
+
return nil unless estimated?
|
|
101
|
+
return 'Taking longer than usual' if slow?
|
|
102
|
+
return 'Almost done' if remaining < 5
|
|
103
|
+
|
|
104
|
+
"About #{DurationFormat.call(round_eta(remaining))} left"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A countdown that changes by single seconds reads as noise; round to what a person would say.
|
|
108
|
+
def round_eta(seconds)
|
|
109
|
+
seconds >= 60 ? (seconds / 10.0).round * 10 : (seconds / 5.0).round * 5
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def elapsed
|
|
113
|
+
@started_at ? [(@now - @started_at).floor, 0].max : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse(value)
|
|
117
|
+
value && Time.parse(value.to_s)
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/workspaces/recipe.rb
CHANGED
|
@@ -7,7 +7,9 @@ module Workspaces
|
|
|
7
7
|
class Invalid < ArgumentError; end
|
|
8
8
|
RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
|
|
9
9
|
WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
|
|
10
|
-
|
|
10
|
+
LFS_STEP_ID = 'lfs'.freeze
|
|
11
|
+
LFS_STEP_NAME = 'Fetch Git LFS files'.freeze
|
|
12
|
+
attr_reader :steps, :environment, :default_editable_env, :environment_presets, :lfs_fetch
|
|
11
13
|
|
|
12
14
|
def initialize(root: Config.repo_root)
|
|
13
15
|
@root = root
|
|
@@ -16,7 +18,9 @@ module Workspaces
|
|
|
16
18
|
@default_editable_env = validate_editable_env!(document.fetch('default_editable_env', {}))
|
|
17
19
|
@environment_presets = validate_environment_presets!(document.fetch('environment_presets', {}))
|
|
18
20
|
@environment = validate_env!(document.fetch('env', {}))
|
|
21
|
+
@lfs_fetch = validate_lfs!(document.fetch('lfs', {}))
|
|
19
22
|
@steps = document.fetch('steps').map { |step| validate_step!(step) }
|
|
23
|
+
@steps = [lfs_step] + @steps unless lfs_fetch.empty?
|
|
20
24
|
validate_editable_overlap!
|
|
21
25
|
validate_sequence!
|
|
22
26
|
rescue Errno::ENOENT
|
|
@@ -29,6 +33,13 @@ module Workspaces
|
|
|
29
33
|
restart ? steps.drop(steps.index { |step| step['background'] }) : steps
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
# Files fetched into the workspace come from the LFS objects the source repository already
|
|
37
|
+
# has, then are linked in by `git lfs pull`; the source's store is shared by every workspace.
|
|
38
|
+
def lfs_step
|
|
39
|
+
{ 'id' => LFS_STEP_ID, 'name' => LFS_STEP_NAME, 'timeout' => 600, 'env' => {}, 'background' => false,
|
|
40
|
+
'run' => ['git', 'lfs', 'pull', "--include=#{lfs_fetch.join(',')}"] }
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def command(step, context)
|
|
33
44
|
command = step.fetch('run')
|
|
34
45
|
return ['sh', '-c', command] if command.is_a?(String)
|
|
@@ -50,7 +61,7 @@ module Workspaces
|
|
|
50
61
|
raise Invalid,
|
|
51
62
|
'Configuration must be a mapping with version: 1'
|
|
52
63
|
end
|
|
53
|
-
unknown_keys!(document, %w(version env default_editable_env environment_presets steps))
|
|
64
|
+
unknown_keys!(document, %w(version env default_editable_env environment_presets lfs steps))
|
|
54
65
|
raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
|
|
55
66
|
end
|
|
56
67
|
|
|
@@ -100,6 +111,26 @@ module Workspaces
|
|
|
100
111
|
'timeout applies to foreground steps, not the server lifetime'
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
# `lfs: { fetch: [path, ...] }` names the Git LFS files a workspace needs. Everything else
|
|
115
|
+
# stays a pointer. Paths are repository-relative globs handed to `git lfs pull --include`, so
|
|
116
|
+
# they are checked for the things that would make that unsafe or surprising.
|
|
117
|
+
def validate_lfs!(value)
|
|
118
|
+
raise Invalid, 'lfs must be a mapping' unless value.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
unknown_keys!(value, %w(fetch))
|
|
121
|
+
paths = value.fetch('fetch', [])
|
|
122
|
+
raise Invalid, 'lfs.fetch must be an array of paths' unless paths.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
paths.each do |path|
|
|
125
|
+
unless path.is_a?(String) && !path.strip.empty? && path == path.strip
|
|
126
|
+
raise Invalid, 'lfs.fetch paths must be nonempty trimmed strings'
|
|
127
|
+
end
|
|
128
|
+
raise Invalid, 'lfs.fetch paths cannot contain commas or control characters' if path.match?(/[,[:cntrl:]]/)
|
|
129
|
+
raise Invalid, 'lfs.fetch paths must be relative to the repository' if path.start_with?('/', '-') || path.split('/').include?('..')
|
|
130
|
+
end
|
|
131
|
+
paths.uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
103
134
|
def validate_env!(values)
|
|
104
135
|
raise Invalid, 'env must be a mapping' unless values.is_a?(Hash)
|
|
105
136
|
values.each do |key, value|
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -86,8 +86,9 @@ module Workspaces
|
|
|
86
86
|
def progress(step, status, duration_seconds: nil)
|
|
87
87
|
@state.transaction do |data|
|
|
88
88
|
entry = data.fetch(@workspace.id)
|
|
89
|
-
entry['steps'].find { |item| item['key'] == step['id'] }
|
|
90
|
-
|
|
89
|
+
record = entry['steps'].find { |item| item['key'] == step['id'] }
|
|
90
|
+
record.merge!('state' => status, 'duration_seconds' => duration_seconds)
|
|
91
|
+
record['started_at'] = Time.now.utc.iso8601(3) if status == 'active'
|
|
91
92
|
entry['message'] = step['name']
|
|
92
93
|
entry['current_step'] = step['id']
|
|
93
94
|
entry['updated_at'] = Time.now.utc.iso8601
|
data/lib/workspaces/worktree.rb
CHANGED
|
@@ -12,6 +12,12 @@ module Workspaces
|
|
|
12
12
|
class UpdateError < StandardError; end
|
|
13
13
|
AUTH_FAILURE = Regexp.union(/could not read (?:Username|Password)|terminal prompts disabled|Authentication failed/i,
|
|
14
14
|
/Permission denied \(publickey\)|gh auth login|HTTP 401|HTTP 403/i)
|
|
15
|
+
# A project that keeps large files in Git LFS would otherwise copy every one of them into
|
|
16
|
+
# every workspace at checkout. With smudging skipped they arrive as small pointer files and
|
|
17
|
+
# `git status` stays clean, so nothing downloads until a recipe asks for it (`lfs.fetch`).
|
|
18
|
+
# Applied only where the gem writes files from Git itself; it is not applied to stash or
|
|
19
|
+
# apply, which must never swap a real file someone fetched for its pointer.
|
|
20
|
+
LFS_POINTERS_ONLY = { 'GIT_LFS_SKIP_SMUDGE' => '1' }.freeze
|
|
15
21
|
METADATA = '.workspace.json'.freeze
|
|
16
22
|
PR_METADATA = '.related-pr'.freeze
|
|
17
23
|
attr_reader :id, :path
|
|
@@ -107,7 +113,7 @@ module Workspaces
|
|
|
107
113
|
|
|
108
114
|
stash = save_changes
|
|
109
115
|
begin
|
|
110
|
-
git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path)
|
|
116
|
+
git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path, env: LFS_POINTERS_ONLY)
|
|
111
117
|
rescue CommandFailedError
|
|
112
118
|
restore_changes(stash) if stash
|
|
113
119
|
raise UpdateError, 'Could not fast-forward the workspace; local changes were restored'
|
|
@@ -117,8 +123,8 @@ module Workspaces
|
|
|
117
123
|
{ 'updated' => true, 'head' => target }
|
|
118
124
|
end
|
|
119
125
|
|
|
120
|
-
def git!(*args, cwd: Config.repo_root)
|
|
121
|
-
output, status = Open3.capture2e(git_environment, 'git', *args, chdir: cwd.to_s)
|
|
126
|
+
def git!(*args, cwd: Config.repo_root, env: {})
|
|
127
|
+
output, status = Open3.capture2e(git_environment.merge(env), 'git', *args, chdir: cwd.to_s)
|
|
122
128
|
raise_authentication_error!(output) unless status.success?
|
|
123
129
|
raise CommandFailedError, "git #{args.join(' ')} failed:\n#{output}" unless status.success?
|
|
124
130
|
|
|
@@ -203,7 +209,7 @@ module Workspaces
|
|
|
203
209
|
raise ArgumentError, 'Workspace branch must not start with a dash' if target_branch.start_with?('-')
|
|
204
210
|
|
|
205
211
|
Config.worktrees_dir.mkpath
|
|
206
|
-
git!('-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', target_branch, path.to_s, sha)
|
|
212
|
+
git!('-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', target_branch, path.to_s, sha, env: LFS_POINTERS_ONLY)
|
|
207
213
|
end
|
|
208
214
|
|
|
209
215
|
def initialize_worktree!(source, target_branch)
|
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.14
|
|
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-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- exe/workspaces
|
|
72
72
|
- lib/workspaces.rb
|
|
73
73
|
- lib/workspaces/backend.rb
|
|
74
|
+
- lib/workspaces/build_history.rb
|
|
74
75
|
- lib/workspaces/child_env.rb
|
|
75
76
|
- lib/workspaces/cli.rb
|
|
76
77
|
- lib/workspaces/command_runner.rb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/workspaces/paths.rb
|
|
90
91
|
- lib/workspaces/port_allocator.rb
|
|
91
92
|
- lib/workspaces/presentation.rb
|
|
93
|
+
- lib/workspaces/progress.rb
|
|
92
94
|
- lib/workspaces/proxy_middleware.rb
|
|
93
95
|
- lib/workspaces/recipe.rb
|
|
94
96
|
- lib/workspaces/registry.rb
|