workspaces 0.1.12 → 0.1.13

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: 1425a9356962c4990de3a5a0f5ffb1b8660325bcff165bfb8336fb7c468696ef
4
- data.tar.gz: 878aff1b14d9ec8f13902a9ac3abd35e47cce3536d640d25b15a8e5a50426936
3
+ metadata.gz: 1d730b2e0f349781b10fe24beefc003403f6709460fbe4e0420853988c05f1e5
4
+ data.tar.gz: '069ee33491c24526d1644a8877386a95720e8e61457a15296ba2da604067860a'
5
5
  SHA512:
6
- metadata.gz: '059361fdfac7d7328f4f50d31efeb647656375582a4d178f4f0d6f7c62affae21eaf53f67cf9590da272f29803fdefa904ebba36e18c8b570b97b12ad9c86ca3'
7
- data.tar.gz: c623574a0d2105f00b22ed460c597941e09fead329a335d47f01c9967d65ec84da098e23c08c4fdb0ace7358c6d7c929f4d5fa6a228bf21fb12a960a75d4b8c8
6
+ metadata.gz: b2174d3fa1eeb55ce8fffe1dba27a7eaab267fed9904c9b216f4c256a150006d83dfb2100768dc49e473fd54f7a3e687f2dc9f9f159cb1c1df948d128079d7a3
7
+ data.tar.gz: 32fbfbb3e6c5334fafa03bb2bbc74f6039b4e3d17ff5a19a58422fa3314af9be0ae0eaace1d4579f2926c5a5c156f0efa074c1e9aeb2dae04d6b19f8be08a872
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
@@ -54,6 +54,30 @@ Interrupted steps have no recorded duration. Step timings are not the total
54
54
  workflow wall-clock time; orchestration overhead can fall outside the steps, which is
55
55
  why the total is measured separately and is usually a little larger than their sum.
56
56
 
57
+ ### Build history and the progress bar
58
+
59
+ While a workspace is being prepared the dashboard shows a progress bar with the current step,
60
+ the percentage, the elapsed time and, once there is history, an estimate of what is left.
61
+
62
+ Every finished run is appended to `build-history.jsonl` in the Workspaces home, one JSON
63
+ object per line, shared by all workspaces of the project. It is written quietly and a failure
64
+ to write it never affects the build. Each record has the time, workspace, operation
65
+ (`prepare` or `restart`), result (`complete` or `failed`), `total_seconds`, the Workspaces
66
+ version, and each step's id, state and seconds. To see which step is getting slower:
67
+
68
+ ```sh
69
+ jq -r 'select(.result=="complete") | .at[:10] as $d | .steps[] | [$d, .id, .seconds] | @tsv' \
70
+ "$WORKSPACES_HOME/build-history.jsonl"
71
+ ```
72
+
73
+ Estimates are the mean of each step over its last 10 successful runs. Failed runs are
74
+ recorded for the trend but never count toward "usual". They are read once when a run starts,
75
+ so the bar does not shift while you watch it. Percentages weigh each step by how long it
76
+ usually takes, count the running step by how much of its usual time has passed, never reach
77
+ 100% until the run finishes, and hold a step that overruns below done. A step more than 1.5
78
+ times its usual time is flagged as taking longer than usual. With no history yet, steps weigh
79
+ the same and no time estimate is shown. Delete the file to start the estimates over.
80
+
57
81
  ## Serve the dashboard and previews
58
82
 
59
83
  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' => elapsed_since(started))
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' => started && elapsed_since(started))
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
- const subtitle = node('subtitle');
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
- <p id="subtitle" class="muted" <%= 'hidden' unless @presentation['subtitle'] %>><span id="subtitle-text"><%= escape(@presentation['subtitle']) %></span><span id="activity" <%= 'hidden' unless @presentation['busy'] %>> · <span class="activity-dot" aria-hidden="true"></span> <span id="elapsed">—</span></span></p>
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}" : ''
@@ -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: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
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
- 'subtitle' => subtitle, 'lead_action' => lead_action, 'total' => total,
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
- # What is happening right now, in the quiet line under the headline.
163
- def subtitle
164
- return nil unless state == 'busy'
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['message']
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
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.12'
2
+ VERSION = '0.1.13'
3
3
  end
@@ -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
- .merge!('state' => status, 'duration_seconds' => duration_seconds)
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
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.12
4
+ version: 0.1.13
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-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 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