workspaces 0.1.11 → 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: fc21704825da052eb9cde1ea64cf3ab4958242f08492cf2a5b538d6fd97ebca3
4
- data.tar.gz: 46dc25654e97ef6fb461058470dcfeb56e5a82720fa6df875c5b3c34b5d273a6
3
+ metadata.gz: 1d730b2e0f349781b10fe24beefc003403f6709460fbe4e0420853988c05f1e5
4
+ data.tar.gz: '069ee33491c24526d1644a8877386a95720e8e61457a15296ba2da604067860a'
5
5
  SHA512:
6
- metadata.gz: daa43e3151dd5bb2e4239ae61ca9322101eb8793d437a91782dfa71ec078ce42721d384bea49564c0929a087b9476ab8c76d44860ad806ab3dab7e4145352193
7
- data.tar.gz: 61d43cac06ed0948041320abf6d0a876697241a26063f5aa3f85537580cea660c8e0c7af2e8589fad80a32c6d3ba8432140e959a9fee70ba791ca92f3c81534a
6
+ metadata.gz: b2174d3fa1eeb55ce8fffe1dba27a7eaab267fed9904c9b216f4c256a150006d83dfb2100768dc49e473fd54f7a3e687f2dc9f9f159cb1c1df948d128079d7a3
7
+ data.tar.gz: 32fbfbb3e6c5334fafa03bb2bbc74f6039b4e3d17ff5a19a58422fa3314af9be0ae0eaace1d4579f2926c5a5c156f0efa074c1e9aeb2dae04d6b19f8be08a872
data/DESIGN.md CHANGED
@@ -50,8 +50,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
50
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
51
51
  - **Three layers**: the workspace page leads with the friendly, the deliberate, then the technical. Each layer is shown or collapsed by lifecycle state, driven by `Presentation::STATES`:
52
52
  1. **Lead** (always visible): a plain-language headline, one sentence, and at most one primary action (a large button). The setup steps never appear here.
53
- 2. **Settings** (a `<details>` titled *Change settings*, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
54
- 3. **Technical details** (one `<details>`): setup steps with timings, the live log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
53
+ 2. **Settings** (a `<details>` titled *Change settings*, with a *Preset: name* badge in its header whenever the saved settings exactly match a preset, so the choice is visible while it is closed, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
54
+ 3. **Technical details** (one `<details>`): setup step logs with timings, the web server log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
55
55
  - **Lead by state**:
56
56
  - `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
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.
@@ -63,8 +63,14 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
63
63
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
64
64
  - **Open workspace**: success-styled link available when the server is ready
65
65
  - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
66
+ - **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.
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
+ - **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
+ - **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.
66
72
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
67
- - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
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.
68
74
  - **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
69
75
  - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
70
76
  - **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
data/README.md CHANGED
@@ -42,13 +42,41 @@ create from a pull request with `create --pr NUMBER`, or create a new branch wit
42
42
  ### Step timings
43
43
 
44
44
  Per-step durations are persisted in seconds in workspace step status JSON, so they
45
- remain available after the command exits. Timings also appear in CLI progress,
46
- the workspace setup log, and the dashboard's step list.
45
+ remain available after the command exits, along with the wall-clock `total_seconds` of
46
+ the last run. Timings also appear in CLI progress, the workspace setup log, and the
47
+ dashboard. Everywhere they are shown as `0.5s`, `12.3s`, `1m 10s` or `1h 5m 3s`, never
48
+ as a large bare seconds count. The dashboard's Full setup log row shows the total
49
+ (`complete · 2m 17s`).
47
50
 
48
51
  A background step measures only process launch, not the server's lifetime or
49
52
  readiness. The following readiness step measures its own wait separately.
50
53
  Interrupted steps have no recorded duration. Step timings are not the total
51
- workflow wall-clock time; orchestration overhead can fall outside the steps.
54
+ workflow wall-clock time; orchestration overhead can fall outside the steps, which is
55
+ why the total is measured separately and is usually a little larger than their sum.
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.
52
80
 
53
81
  ## Serve the dashboard and previews
54
82
 
@@ -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
@@ -10,6 +10,9 @@ module Workspaces
10
10
 
11
11
  SAFE_LOG_BYTES = 8 * 1024
12
12
  SAFE_LOG_LINES = 100
13
+ # Each step shows its own tail, so the per-step bounds are smaller than the combined log's.
14
+ STEP_LOG_BYTES = 6 * 1024
15
+ STEP_LOG_LINES = 80
13
16
  DEFAULT_HOME = File.expand_path('~/.workspaces')
14
17
  DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
15
18
  DEFAULT_FRONT_DOOR_PORT = 4747
@@ -5,6 +5,8 @@ require_relative 'workflow'
5
5
  require_relative 'tee_log'
6
6
  require_relative 'presentation'
7
7
  require_relative 'environment_overrides'
8
+ require_relative 'duration_format'
9
+ require_relative 'build_history'
8
10
 
9
11
  module Workspaces
10
12
  class Coordinator
@@ -98,7 +100,10 @@ module Workspaces
98
100
  'source_update_message' => entry['source_update_message'],
99
101
  'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
100
102
  'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
101
- 'steps' => entry.fetch('steps', []),
103
+ 'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
104
+ 'estimates' => entry.fetch('estimates', {}),
105
+ 'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
106
+ 'backend_log_available' => backend_log_available?(id),
102
107
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
103
108
  'environment_keys' => environment_keys(id),
104
109
  'editable_environment' => editable_environment(id),
@@ -148,10 +153,13 @@ module Workspaces
148
153
  steps = recipe.for_operation(restart: restart).map do |step|
149
154
  { 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
150
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.
151
158
  update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
152
- 'steps' => steps,
159
+ 'steps' => steps, 'estimates' => BuildHistory.estimates(steps.map { |step| step['key'] }),
153
160
  'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
154
- 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil)
161
+ 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
162
+ 'total_seconds' => nil)
155
163
  end
156
164
 
157
165
  def spawn_worker(workspace, restart, lock)
@@ -168,28 +176,54 @@ module Workspaces
168
176
 
169
177
  def run(workspace, restart:)
170
178
  id = workspace.id
179
+ started = monotonic
171
180
  File.open(Config.setup_log_path(id), 'a') do |file|
172
181
  file.sync = true
173
182
  log = TeeLog.new(id, file)
174
183
  log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
175
- prepare_and_boot(workspace, log, restart)
184
+ prepare_and_boot(workspace, log, restart, started)
176
185
  end
177
186
  rescue StandardError => e
178
- publish_error(id, e)
187
+ publish_error(id, e, started)
179
188
  end
180
189
 
181
- def prepare_and_boot(workspace, log, restart)
190
+ # The wall-clock time of the whole run, boot and readiness included. Step durations add up to
191
+ # less than this because orchestration between steps is not inside any step.
192
+ def prepare_and_boot(workspace, log, restart, started)
182
193
  id = workspace.id
183
194
  port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
184
- update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port)
195
+ total = elapsed_since(started)
196
+ update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
197
+ 'total_seconds' => total)
198
+ record_run(id, 'complete', total)
185
199
  end
186
200
 
187
- def publish_error(id, exception)
201
+ def publish_error(id, exception, started = nil)
188
202
  detail = scrub("#{exception.class}: #{exception.message}", id: id)
189
203
  $stdout.puts "[Workspace #{id}] ERROR: #{detail}"
190
204
  File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
205
+ total = started && elapsed_since(started)
191
206
  update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
192
- 'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now)
207
+ 'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
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
219
+ end
220
+
221
+ def monotonic
222
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
+ end
224
+
225
+ def elapsed_since(started)
226
+ (monotonic - started).round(3)
193
227
  end
194
228
 
195
229
  def locked?(id)
@@ -210,6 +244,35 @@ module Workspaces
210
244
  scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
211
245
  end
212
246
 
247
+ # Output for each recipe step, keyed by step id. Only steps that have written something
248
+ # appear, so a pending step has no entry and the page can show it as not started.
249
+ def step_logs(id, steps)
250
+ steps.each_with_object({}) do |step, logs|
251
+ path = Config.step_log_path(id, step['key'], create: false)
252
+ next unless path.file?
253
+
254
+ logs[step['key']] = tail_file(path, id, bytes: Config::STEP_LOG_BYTES, lines: Config::STEP_LOG_LINES)
255
+ end
256
+ rescue ArgumentError
257
+ {}
258
+ end
259
+
260
+ def backend_log_available?(id)
261
+ path = Config.backend_log_path(id, create: false)
262
+ path.file? && !path.zero?
263
+ end
264
+
265
+ # The tail of a log file, scrubbed, with whether earlier output was cut off.
266
+ def tail_file(path, id, bytes:, lines:)
267
+ size = path.size
268
+ raw = File.open(path, 'rb') do |file|
269
+ file.seek(-bytes, IO::SEEK_END) if size > bytes
270
+ file.read
271
+ end
272
+ kept = scrub(raw, id: id).lines
273
+ { 'text' => kept.last(lines).join, 'truncated' => size > bytes || kept.length > lines }
274
+ end
275
+
213
276
  def scrub(value, id:)
214
277
  scrubbed = value.to_s
215
278
  scrubbed = EnvironmentOverrides.new(id).scrub(scrubbed)
@@ -46,43 +46,131 @@
46
46
  toggleBusyState(busy);
47
47
  }
48
48
 
49
- function renderStep(step) {
50
- const item = document.createElement('li');
51
- item.className = 'step';
52
- item.dataset.stepState = step.state;
53
- if (step.state === 'active') item.setAttribute('aria-current', 'step');
54
- const bullet = document.createElement('span');
55
- bullet.className = 'step-bullet';
56
- bullet.setAttribute('aria-hidden', 'true');
57
- const label = document.createElement('span');
58
- label.textContent = step.label;
49
+ // Each recipe step is a disclosure element. The server renders the first paint; after that the
50
+ // poll updates these nodes in place, keyed by step id, so nothing the user is reading
51
+ // is rebuilt. A step opens by itself when it starts running or fails, and closes when it
52
+ // completes, but only on a state change, and never after the user toggled it by hand.
53
+ const userToggled = new Set();
54
+ const stepState = {};
55
+
56
+ function ensureStep(container, step, before) {
57
+ const existing = container.querySelector('[data-step-log="' + step.key + '"]');
58
+ if (existing) return existing;
59
+ const details = document.createElement('details');
60
+ details.className = 'step-log';
61
+ details.dataset.stepLog = step.key;
62
+ const summary = document.createElement('summary');
63
+ const status = document.createElement('span');
64
+ status.className = 'step-log__status';
65
+ status.setAttribute('aria-hidden', 'true');
66
+ const name = document.createElement('span');
67
+ name.className = 'step-log__name';
59
68
  const outcome = document.createElement('span');
60
- outcome.className = 'step-outcome';
61
- outcome.textContent = stepOutcome(step);
62
- item.append(bullet, label, outcome);
63
- return item;
69
+ outcome.className = 'step-log__outcome step-outcome';
70
+ summary.append(status, name, outcome);
71
+ const output = document.createElement('pre');
72
+ output.tabIndex = 0;
73
+ details.append(summary, output);
74
+ details.addEventListener('toggle', () => {
75
+ if (details.dataset.programmatic === 'true') return;
76
+ userToggled.add(step.key);
77
+ });
78
+ container.insertBefore(details, before || null);
79
+ return details;
80
+ }
81
+
82
+ function renderSteps(view, stepLogs) {
83
+ const container = node('steps');
84
+ if (!container) return;
85
+ const steps = view.steps || [];
86
+ const full = node('full-log');
87
+ const keep = new Set(steps.map((step) => step.key).concat(['full']));
88
+ container.querySelectorAll('[data-step-log]').forEach((element) => {
89
+ if (!keep.has(element.dataset.stepLog)) container.removeChild(element);
90
+ });
91
+ renderFullLogOutcome(view);
92
+ steps.forEach((step) => {
93
+ const details = ensureStep(container, step, full);
94
+ details.dataset.stepState = step.state;
95
+ if (step.state === 'active') details.setAttribute('aria-current', 'step');
96
+ else details.removeAttribute('aria-current');
97
+ const summary = details.querySelector('summary');
98
+ const parts = summary.children;
99
+ parts[1].textContent = step.label;
100
+ parts[2].textContent = stepOutcome(step);
101
+ const output = details.querySelector('pre');
102
+ output.setAttribute('aria-label', step.label + ' output');
103
+ const entry = stepLogs && stepLogs[step.key];
104
+ if (entry && output.textContent !== entry.text) writeLog(output, entry.text);
105
+ applyStepOpenState(details, step);
106
+ });
107
+ }
108
+
109
+ // The Full setup log row reads "in progress" while running, then how the run ended and how
110
+ // long it took. Without a recorded total it keeps its plain label.
111
+ function renderFullLogOutcome(view) {
112
+ const label = node('full-log-outcome');
113
+ if (!label) return;
114
+ const total = view.total;
115
+ const text = total && formatDuration(total.seconds);
116
+ label.textContent = view.busy
117
+ ? 'in progress'
118
+ : text
119
+ ? total.state + ' · ' + text
120
+ : 'everything, in order';
121
+ }
122
+
123
+ function applyStepOpenState(details, step) {
124
+ const previous = stepState[step.key];
125
+ stepState[step.key] = step.state;
126
+ if (previous === step.state || userToggled.has(step.key)) return;
127
+ const shouldOpen = step.state === 'active' || step.state === 'failed';
128
+ if (details.open === shouldOpen) return;
129
+ details.dataset.programmatic = 'true';
130
+ details.open = shouldOpen;
131
+ // The toggle event fires asynchronously; clear the guard after it has been delivered.
132
+ setTimeout(() => { details.dataset.programmatic = 'false'; }, 0);
133
+ }
134
+
135
+ // Writing a log must not yank a reader who scrolled up; follow the tail only if already there.
136
+ function writeLog(pre, text) {
137
+ const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 18;
138
+ pre.textContent = text || '';
139
+ if (atBottom) pre.scrollTop = pre.scrollHeight;
140
+ }
141
+
142
+ // Durations read the way people say them: 0.5s, 12.3s, 1m 10s, 1h 5m 3s. This mirrors
143
+ // Workspaces::DurationFormat in Ruby; a spec runs both over the same inputs.
144
+ function formatDuration(seconds) {
145
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return null;
146
+ if (seconds < 60) {
147
+ const rounded = Math.round(seconds * 10) / 10;
148
+ if (rounded >= 60) return '1m 0s';
149
+ return rounded + 's';
150
+ }
151
+ const whole = Math.round(seconds);
152
+ const hours = Math.floor(whole / 3600);
153
+ const minutes = Math.floor((whole % 3600) / 60);
154
+ const secs = whole % 60;
155
+ return hours > 0 ? hours + 'h ' + minutes + 'm ' + secs + 's' : minutes + 'm ' + secs + 's';
64
156
  }
65
157
 
66
158
  function stepOutcome(step) {
67
159
  const state = step.state;
68
- const rawDuration = step.duration_seconds;
69
- if (typeof rawDuration !== 'number') return state;
70
- const duration = rawDuration;
71
- if (
72
- (state === 'complete' || state === 'failed') &&
73
- Number.isFinite(duration) &&
74
- duration >= 0
75
- )
76
- return state + ' · ' + duration.toFixed(3) + 's';
77
- return state;
160
+ if (state !== 'complete' && state !== 'failed') return state;
161
+ const text = formatDuration(step.duration_seconds);
162
+ return text ? state + ' · ' + text : state;
78
163
  }
79
164
 
80
165
  function updateLog(data) {
81
166
  if (data.log_source !== selectedLog) return;
82
- const log = node('log');
83
- const bottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 18;
84
- log.textContent = data.log_tail || '';
85
- if (bottom) log.scrollTop = log.scrollHeight;
167
+ const tab = node('backend-tab');
168
+ if (tab && data.backend_log_available) tab.hidden = false;
169
+ if (selectedLog === 'backend') writeLog(node('log'), data.log_tail);
170
+ else {
171
+ const full = node('full-log');
172
+ if (full) writeLog(full.querySelector('pre'), data.log_tail);
173
+ }
86
174
  node('updated').textContent = data.updated_at || '';
87
175
  }
88
176
 
@@ -97,7 +185,7 @@
97
185
  renderSections(view);
98
186
  if (node('message')) node('message').textContent = data.message || '';
99
187
  node('source-update-message').textContent = data.source_update_message || '';
100
- node('progress').replaceChildren(...view.steps.map(renderStep));
188
+ renderSteps(view, data.step_logs);
101
189
  renderActions(view.actions);
102
190
  if (window.WorkspaceEnvironment)
103
191
  window.WorkspaceEnvironment.syncFromSnapshot(data);
@@ -118,24 +206,43 @@
118
206
  function renderLead(view) {
119
207
  node('status').textContent = view.headline || view.title;
120
208
  node('summary').textContent = view.summary || '';
121
- const subtitle = node('subtitle');
122
- const activity = node('activity');
123
- if (subtitle) {
124
- const text = view.subtitle || '';
125
- subtitle.hidden = !text;
126
- // The text has its own element, so it can be set whether or not the server rendered any.
127
- const target = node('subtitle-text');
128
- if (target) target.textContent = text;
129
- }
130
- if (activity) activity.hidden = !view.busy;
209
+ renderProgress(view.progress);
131
210
  const title = node('environment-title');
132
211
  if (title && view.environment_title) title.textContent = view.environment_title;
212
+ const badge = node('environment-preset-badge');
213
+ if (badge) {
214
+ badge.hidden = !view.current_preset;
215
+ badge.textContent = view.current_preset ? 'Preset: ' + view.current_preset : '';
216
+ }
133
217
  renderLeadAction(view.lead_action);
134
218
  }
135
219
 
136
220
  // The lead action is a lifecycle post (start/retry) or, when ready, the preview link
137
221
  // rendered separately as #preview. Rebuild only when it actually changes.
138
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
+
139
246
  function renderLeadAction(action) {
140
247
  const holder = node('lead-action');
141
248
  if (!holder) return;
@@ -203,9 +310,8 @@
203
310
  if (Number.isFinite(started)) {
204
311
  const end = busy ? Date.now() : completed;
205
312
  const seconds = Math.max(0, Math.floor((end - started) / 1000));
206
- if (Number.isFinite(seconds))
207
- node('elapsed').textContent =
208
- Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
313
+ // The running clock counts whole seconds, so format it as one (no decimals).
314
+ if (Number.isFinite(seconds)) node('elapsed').textContent = formatDuration(seconds);
209
315
  }
210
316
  setTimeout(tick, 1000);
211
317
  }