workspaces 0.1.9 → 0.1.11

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: c580535eeb20692a3454144150752141b76eeed504ca7f13d59f2ee498e5e8eb
4
- data.tar.gz: dc5fbe433b8ffd2c485bdbf91bcb2aae72bf4010926f4c0106361cc5a1369ce6
3
+ metadata.gz: fc21704825da052eb9cde1ea64cf3ab4958242f08492cf2a5b538d6fd97ebca3
4
+ data.tar.gz: 46dc25654e97ef6fb461058470dcfeb56e5a82720fa6df875c5b3c34b5d273a6
5
5
  SHA512:
6
- metadata.gz: 3287a5de3b491b3df3573494b43afa9a099aae057e535943fd4fd277b09e4b3755b37bf566894cab253804eb39ae009ed21f887414c4dad51b81f4ce816bbba8
7
- data.tar.gz: 623e7caec5c6bdc116c350efe7ffde325e3cdb66e22c473f69cbe6efecc65de12c1d3e794c8625e9a01c24db6f783e828105e52cfa03b6005f5409fabe5f9131
6
+ metadata.gz: daa43e3151dd5bb2e4239ae61ca9322101eb8793d437a91782dfa71ec078ce42721d384bea49564c0929a087b9476ab8c76d44860ad806ab3dab7e4145352193
7
+ data.tar.gz: 61d43cac06ed0948041320abf6d0a876697241a26063f5aa3f85537580cea660c8e0c7af2e8589fad80a32c6d3ba8432140e959a9fee70ba791ca92f3c81534a
data/DESIGN.md CHANGED
@@ -48,19 +48,24 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
48
48
  - **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
49
49
  - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
50
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
51
- - **Lifecycle state machine**: the workspace detail page is driven by `Presentation::STATES`. Each state (`idle`, `busy`, `ready`, `stopped`, `error`/`interrupted`, `unknown`) declares which sections exist (progress, actions, environment, log, details, danger zone) and the label of the single environment-form button. Sections that are not usable or meaningful in a state are hidden, never shown disabled. The server renders the initial state and the polling script re-applies it from `presentation.sections`, so the page transitions without a reload.
52
- - `idle` (brand new): only the environment editor plus a **Start workspace** button, with a one-line hint. No log, actions, details, or delete.
53
- - `busy` (setup/restart running): only progress and the live log.
54
- - `ready`: everything, with **Save & restart**.
55
- - `stopped`: no progress from the previous run; **Save & start**.
56
- - `error`/`interrupted`: retry action, environment editor, progress, and logs; **Save & retry**.
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
+ 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.
55
+ - **Lead by state**:
56
+ - `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
57
+ - `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
58
+ - `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
59
+ - `stopped`: "Your workspace is paused" with a large **Start workspace** button.
60
+ - `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry** button, the error shown, and technical details open.
61
+ - `unknown`: a minimal view with technical details open.
62
+ - **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
57
63
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
58
- - **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
59
64
  - **Open workspace**: success-styled link available when the server is ready
60
65
  - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
61
66
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
62
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.
63
- - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
68
+ - **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
64
69
  - **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.
65
70
  - **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.
66
71
  - **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
data/README.md CHANGED
@@ -143,6 +143,64 @@ The front door is not an authentication boundary. Its host and same-origin check
143
143
  protect routing and POST actions, but a publicly reachable instance still needs
144
144
  access control such as VPN or SSO. Do not expose workspace backend ports directly.
145
145
 
146
+ ### Proxy middleware
147
+
148
+ The front door can run Rack middleware around traffic that is forwarded to a workspace
149
+ backend. The gem knows nothing about what a middleware does: the container or host
150
+ that runs `serve` registers its own. Typical uses are injecting a script tag, adding a
151
+ header, or logging.
152
+
153
+ ```ruby
154
+ # my_middleware.rb
155
+ Workspaces.configure_proxy do
156
+ use MyMiddleware, option: 'value'
157
+ end
158
+ ```
159
+
160
+ Load it with `serve --require FILE` (repeatable) or by setting `WORKSPACES_REQUIRE` to
161
+ one or more paths separated like `PATH` entries. Environment entries load first, then
162
+ `--require` files in order. A missing file or an error while loading stops `serve` with
163
+ a non-zero exit instead of silently running without it. These files are executed with
164
+ the front door's privileges, so only name files the operator controls. With nothing
165
+ registered, the front door behaves exactly as before.
166
+
167
+ Blocks run in registration order and the first `use` is outermost. The stack wraps only
168
+ the step that forwards a request to a ready workspace backend. The dashboard, redirects
169
+ for workspaces that are not ready, and error pages never pass through it. For each
170
+ proxied request, two Rack env keys are available:
171
+
172
+ | Key | Value |
173
+ | --- | --- |
174
+ | `workspaces.workspace_id` | The workspace ID, such as `main-1ae4815f` |
175
+ | `workspaces.workspace` | The workspace snapshot hash (branch, status, related PR, and so on) |
176
+
177
+ Two details matter when rewriting response bodies. The proxy passes the browser's
178
+ `Accept-Encoding` to the backend, so a compressed response arrives compressed; set
179
+ `env['HTTP_ACCEPT_ENCODING'] = 'identity'` before calling the app, or skip responses
180
+ with a `Content-Encoding` header. If you change the body, drop the stale `Content-Length`
181
+ header. An example that appends a script tag to HTML pages:
182
+
183
+ ```ruby
184
+ class ScriptInjector
185
+ TAG = '<script src="https://example.test/widget.js" data-thread-id="%<id>s"></script>'
186
+
187
+ def initialize(app)
188
+ @app = app
189
+ end
190
+
191
+ def call(env)
192
+ env['HTTP_ACCEPT_ENCODING'] = 'identity'
193
+ status, headers, body = @app.call(env)
194
+ return [status, headers, body] unless headers['content-type'].to_s.start_with?('text/html')
195
+
196
+ html = body.each.to_a.join.sub('</body>', format(TAG, id: env['workspaces.workspace_id']) + '</body>')
197
+ [status, headers.reject { |key, _| key.casecmp?('content-length') }, [html]]
198
+ end
199
+ end
200
+
201
+ Workspaces.configure_proxy { use ScriptInjector }
202
+ ```
203
+
146
204
  ### Local HTTPS
147
205
 
148
206
  To serve TLS directly, install/trust a local CA and create a certificate covering
@@ -198,6 +256,7 @@ and forwarded scheme/host headers; it does not change the internal listener to T
198
256
  | `WORKSPACES_PUBLIC_ORIGIN` | `http://localhost:4747` | Browser-facing dashboard origin and scheme |
199
257
  | `WORKSPACES_BASE_DOMAIN` | `localhost` | Preview hostname suffix |
200
258
  | `WORKSPACES_SUBDOMAIN_PREFIX` | `ws-` | Prefix before the workspace ID on preview hosts |
259
+ | `WORKSPACES_REQUIRE` | none | Ruby files, separated like `PATH`, loaded by `serve` to register proxy middleware |
201
260
 
202
261
  Keep the project root, public origin, preview domain, and storage settings consistent
203
262
  across CLI and server processes. Changing the browser-facing origin requires restarting
@@ -5,6 +5,7 @@ require 'rack/handler/puma'
5
5
  require_relative 'registry'
6
6
  require_relative 'coordinator'
7
7
  require_relative 'front_door'
8
+ require_relative 'proxy_middleware'
8
9
  require_relative 'tls'
9
10
  require_relative 'child_env'
10
11
 
@@ -86,6 +87,7 @@ module Workspaces
86
87
  end
87
88
 
88
89
  def parse_tls_options(parser, options)
90
+ parser.on('--require PATH') { |value| (options[:require] ||= []) << value }
89
91
  parser.on('--secure') { options[:secure] = true }
90
92
  parser.on('--tls-cert PATH') { |value| options[:tls_cert] = value }
91
93
  parser.on('--tls-key PATH') { |value| options[:tls_key] = value }
@@ -93,6 +95,7 @@ module Workspaces
93
95
 
94
96
  def serve(_id, options)
95
97
  tls = Tls.from_serve_options(options)&.validate!
98
+ ProxyMiddleware.load_files(ProxyMiddleware.env_paths + options.fetch(:require, []))
96
99
 
97
100
  if tls
98
101
  tls.apply_env_defaults { serve_with_tls(tls) }
@@ -196,7 +199,7 @@ module Workspaces
196
199
  end
197
200
 
198
201
  def usage
199
- 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
202
+ 'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
200
203
  'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
201
204
  'exec ID -- COMMAND... | remove ID [--force]'
202
205
  end
@@ -14,7 +14,7 @@
14
14
  let actionsSignature = null;
15
15
  function toggleBusyState(nextBusy) {
16
16
  busy = !!nextBusy;
17
- root.querySelectorAll('.workspace-actions button').forEach((button) => {
17
+ root.querySelectorAll('.workspace-actions button, .lead__action button').forEach((button) => {
18
18
  button.disabled = busy;
19
19
  });
20
20
  if (window.WorkspaceEnvironment)
@@ -95,7 +95,7 @@
95
95
  completed = Date.parse(data.completed_at);
96
96
  renderStatus(view);
97
97
  renderSections(view);
98
- node('message').textContent = data.message || '';
98
+ if (node('message')) node('message').textContent = data.message || '';
99
99
  node('source-update-message').textContent = data.source_update_message || '';
100
100
  node('progress').replaceChildren(...view.steps.map(renderStep));
101
101
  renderActions(view.actions);
@@ -109,25 +109,69 @@
109
109
  root.querySelectorAll('[data-section]').forEach((element) => {
110
110
  element.hidden = !sections[element.dataset.section];
111
111
  });
112
- const controls = node('controls');
113
- if (controls) {
114
- controls.hidden = !sections.actions && !sections.environment;
115
- const title = node('actions-settings-title');
116
- if (title && view.controls_title) title.textContent = view.controls_title;
117
- }
118
- const hint = node('hint');
119
- if (hint) {
120
- hint.textContent = view.hint || '';
121
- hint.hidden = !view.hint;
122
- }
123
- node('message').hidden = view.state === 'idle';
112
+ renderLead(view);
113
+ applyDisclosureState(view);
124
114
  if (window.WorkspaceEnvironment)
125
115
  window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
126
116
  }
127
117
 
118
+ function renderLead(view) {
119
+ node('status').textContent = view.headline || view.title;
120
+ 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;
131
+ const title = node('environment-title');
132
+ if (title && view.environment_title) title.textContent = view.environment_title;
133
+ renderLeadAction(view.lead_action);
134
+ }
135
+
136
+ // The lead action is a lifecycle post (start/retry) or, when ready, the preview link
137
+ // rendered separately as #preview. Rebuild only when it actually changes.
138
+ let leadSignature = null;
139
+ function renderLeadAction(action) {
140
+ const holder = node('lead-action');
141
+ if (!holder) return;
142
+ const signature = JSON.stringify(action || null);
143
+ if (signature === leadSignature) return;
144
+ leadSignature = signature;
145
+ holder.replaceChildren();
146
+ if (!action || action.kind !== 'post' || !allowedActions.includes(action.key)) return;
147
+ const form = document.createElement('form');
148
+ form.method = 'post';
149
+ form.action = base + '/' + action.key;
150
+ const button = document.createElement('button');
151
+ button.className = 'button button--primary button--large';
152
+ button.textContent = action.label;
153
+ form.append(button);
154
+ holder.append(form);
155
+ toggleBusyState(busy);
156
+ }
157
+
158
+ // A disclosure opens by itself when its state says so (a failure must never be hidden,
159
+ // a fresh workspace needs its settings), but we never close one the user opened, and
160
+ // we only apply the state's preference when the state changes, not on every poll.
161
+ let lastDisclosureState = root.dataset.disclosureState || null;
162
+ function applyDisclosureState(view) {
163
+ if (view.state === lastDisclosureState) return;
164
+ lastDisclosureState = view.state;
165
+ const open = view.open || {};
166
+ [['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
167
+ ([id, shouldOpen]) => {
168
+ const element = node(id);
169
+ if (element) element.open = !!shouldOpen;
170
+ }
171
+ );
172
+ }
173
+
128
174
  function renderStatus(view) {
129
- node('status').textContent = view.title;
130
- node('activity').hidden = !view.busy;
131
175
  node('preview').hidden = !view.ready;
132
176
  node('error').textContent = view.error || '';
133
177
  node('error-panel').hidden = !view.error;
@@ -148,7 +148,64 @@ h1 {
148
148
  .log-panel,
149
149
  .danger-zone { gap: var(--space-3); }
150
150
 
151
- details.status-panel[open] > summary { margin-block-end: var(--space-4); }
151
+
152
+ /* Lead: a friendly headline, one sentence, and at most one big button. */
153
+ .lead { gap: var(--space-4); padding-block: var(--space-8); }
154
+ .lead__text { display: grid; gap: var(--space-2); }
155
+ .lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
156
+ .lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
157
+ .lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
158
+ .lead__action:empty { display: none; }
159
+ .button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
160
+
161
+ /* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
162
+ /* A disclosure has to read as a control. The native marker is lost once a summary is a flex
163
+ container, so draw our own chevron; the whole header is the click target, with a hover
164
+ fill and a keyboard focus ring. */
165
+ /* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
166
+ .status-panel.disclosure { padding: 0; }
167
+ .disclosure > summary {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ align-items: baseline;
171
+ gap: var(--space-1) var(--space-3);
172
+ padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
173
+ position: relative;
174
+ list-style: none;
175
+ color: var(--text);
176
+ font-weight: 600;
177
+ border-radius: var(--radius);
178
+ cursor: pointer;
179
+ user-select: none;
180
+ transition: background-color 120ms ease;
181
+ }
182
+ .disclosure > summary::-webkit-details-marker { display: none; }
183
+ .disclosure > summary::after {
184
+ content: '';
185
+ position: absolute;
186
+ inset-inline-end: var(--panel-padding);
187
+ inset-block-start: 50%;
188
+ inline-size: 8px;
189
+ block-size: 8px;
190
+ margin-block-start: -6px;
191
+ border-inline-end: 2px solid var(--muted);
192
+ border-block-end: 2px solid var(--muted);
193
+ transform: rotate(45deg);
194
+ transition: transform 160ms ease, border-color 120ms ease;
195
+ }
196
+ .disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
197
+ .disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
198
+ .disclosure > summary:hover::after { border-color: var(--text); }
199
+ .disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
200
+ .disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
201
+ .disclosure > :not(summary) { margin: var(--panel-padding); }
202
+ .disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
203
+ @media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
204
+ .disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
205
+ .disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
206
+ .disclosure .log-panel { padding: 0; }
207
+ .disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
208
+ .disclosure h3 { font-size: 1rem; }
152
209
 
153
210
  .status-row {
154
211
  display: grid;
@@ -1,37 +1,27 @@
1
- <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
1
+ <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>" data-disclosure-state="<%= escape(@presentation['state']) %>">
2
2
  <header class="dashboard__header">
3
- <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
3
+ <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1></div>
4
4
  <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
5
  </header>
6
- <section class="status-panel" aria-labelledby="status">
7
- <div class="status-row">
8
- <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
- <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
6
+ <section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
7
+ <div class="lead__text">
8
+ <h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
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
11
  </div>
11
- <p id="hint" <%= 'hidden' unless @presentation['hint'] %>><%= escape(@presentation['hint']) %></p>
12
- <p id="message" <%= 'hidden' if @presentation['state'] == 'idle' %>><%= field('message') %></p>
13
- <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
14
- <% @presentation['steps'].each do |step| %>
15
- <li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
16
- <% end %>
17
- </ol>
12
+ <div class="lead__actions">
13
+ <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>
14
+ <div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
15
+ </div>
16
+ <p id="message" class="muted" hidden><%= field('message') %></p>
18
17
  <section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
19
18
  <h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
20
19
  </section>
21
20
  <p id="connection" role="status"></p>
22
21
  </section>
23
- <section class="status-panel controls-panel" id="controls" data-section-group aria-labelledby="actions-settings-title" <%= 'hidden' unless section?('actions') || section?('environment') %>>
24
- <h2 id="actions-settings-title"><%= escape(@presentation['controls_title']) %></h2>
25
- <div class="workspace-actions-group" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
26
- <h3 id="actions-heading">Workspace actions</h3>
27
- <div class="workspace-actions">
28
- <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
29
- <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
30
- </div>
31
- <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
- </div>
33
- <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
- <h3 id="environment-overrides">Environment</h3>
22
+ <details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
23
+ <summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
24
+ <div class="workspace-settings" aria-labelledby="environment-title">
35
25
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
26
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
27
  <legend class="env-legend">Workspace settings</legend>
@@ -57,25 +47,45 @@
57
47
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
58
48
  </form>
59
49
  </div>
60
- </section>
61
- <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
62
- <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
63
- <nav class="log-switch" aria-label="Log source">
64
- <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
65
- <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
66
- </nav>
67
- <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
68
- </section>
69
- <details class="status-panel" data-section="details" <%= 'hidden' unless section?('details') %>><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
70
- <section class="status-panel danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
71
- <h2 id="danger-heading">Danger Zone</h2>
72
- <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
73
- <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
74
- <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
75
- <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
76
- <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
77
- <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
78
- </form>
79
- </section>
50
+ </details>
51
+ <details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
52
+ <summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
53
+ <div class="technical stack stack--loose">
54
+ <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
55
+ <% @presentation['steps'].each do |step| %>
56
+ <li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
57
+ <% end %>
58
+ </ol>
59
+ <div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
60
+ <h3 id="actions-heading">Workspace controls</h3>
61
+ <div class="workspace-actions">
62
+ <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
63
+ </div>
64
+ <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
65
+ </div>
66
+ <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
67
+ <div class="log-header"><h3 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h3><time id="updated"><%= field('updated_at') %></time></div>
68
+ <nav class="log-switch" aria-label="Log source">
69
+ <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
70
+ <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
71
+ </nav>
72
+ <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
73
+ </section>
74
+ <section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
75
+ <h3>Workspace details</h3>
76
+ <dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl>
77
+ </section>
78
+ <section class="danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
79
+ <h3 id="danger-heading">Danger Zone</h3>
80
+ <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
81
+ <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
82
+ <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
83
+ <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
84
+ <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
85
+ <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
86
+ </form>
87
+ </section>
88
+ </div>
89
+ </details>
80
90
  <noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
81
91
  </main>
@@ -161,6 +161,15 @@ module Workspaces
161
161
  JSON.generate(environment_presets)
162
162
  end
163
163
 
164
+ # The lead's primary action. `open` is rendered separately as a link (it needs the
165
+ # preview URL and must stay a real anchor), so only lifecycle posts produce a form.
166
+ def lead_action_form(action)
167
+ return '' unless action && action['kind'] == 'post'
168
+
169
+ "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
170
+ "<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
171
+ end
172
+
164
173
  def action_form(action)
165
174
  style = action['primary'] ? 'button--primary' : 'button--subtle'
166
175
  "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
@@ -5,15 +5,22 @@ require 'net/http'
5
5
  require_relative 'coordinator'
6
6
  require_relative 'dashboard_view'
7
7
  require_relative 'environment_overrides'
8
+ require_relative 'proxy_middleware'
8
9
 
9
10
  module Workspaces
10
11
  class FrontDoor
11
12
  WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
12
13
  (?:/(status|start|prepare|restart|stop|remove|update-pr|environment))?\z}x
13
14
 
15
+ # Rack env keys set on requests that reach a ready workspace backend, for use by
16
+ # middleware registered with Workspaces.configure_proxy.
17
+ WORKSPACE_ID_ENV = 'workspaces.workspace_id'.freeze
18
+ WORKSPACE_ENV = 'workspaces.workspace'.freeze
19
+
14
20
  def initialize(coordinator: Coordinator.new, registry: Registry.new)
15
21
  @coordinator = coordinator
16
22
  @registry = registry
23
+ @forwarder = ProxyMiddleware.build(method(:forward_to_backend))
17
24
  end
18
25
 
19
26
  def call(env)
@@ -230,7 +237,9 @@ module Workspaces
230
237
  snapshot = @coordinator.snapshot(id, include_log: false)
231
238
  return unavailable(id, request) unless snapshot['status'] == 'ready' && !snapshot['active']
232
239
 
233
- forward(id, request)
240
+ request.env[WORKSPACE_ID_ENV] = id
241
+ request.env[WORKSPACE_ENV] = snapshot
242
+ @forwarder.call(request.env)
234
243
  rescue IOError, SystemCallError, Timeout::Error => e
235
244
  response(502, "Workspace backend unavailable: #{e.class}")
236
245
  end
@@ -241,6 +250,11 @@ module Workspaces
241
250
  response(503, "Workspace not ready. Visit #{Config.dashboard_url(id)}")
242
251
  end
243
252
 
253
+ # Innermost app of the proxy stack: sends the request to the workspace backend.
254
+ def forward_to_backend(env)
255
+ forward(env.fetch(WORKSPACE_ID_ENV), Rack::Request.new(env))
256
+ end
257
+
244
258
  def forward(id, request)
245
259
  uri = URI("http://127.0.0.1:#{Backend.new(id).port}#{request.fullpath}")
246
260
  upstream = upstream_request(id, request, uri)
@@ -6,8 +6,13 @@ module Workspaces
6
6
  ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
7
7
  'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
8
8
 
9
- SECTIONS = %w(progress actions environment log details danger).freeze
10
- MANAGEABLE = SECTIONS
9
+ # Three layers, from friendly to technical. `lead` is the headline, one sentence and at
10
+ # most one primary action. `environment` (presets and variables) is the area for
11
+ # deliberately changing something. `technical` is one disclosure holding setup steps,
12
+ # logs, workspace details, lifecycle controls and deletion; it is never the first thing
13
+ # a user reads, but it opens by itself when something needs attention.
14
+ SECTIONS = %w(lead progress actions environment technical log details danger).freeze
15
+ TECHNICAL = %w(technical progress actions log details danger).freeze
11
16
 
12
17
  # Workspace lifecycle state machine. Each state declares only what is usable and
13
18
  # meaningful there; everything else is hidden rather than shown disabled.
@@ -20,19 +25,60 @@ module Workspaces
20
25
  #
21
26
  # `environment_submit` labels the single environment-form button, which saves the
22
27
  # settings and (re)starts the workspace, so a brand new workspace needs only that.
28
+ #
29
+ # Per state: `headline` and `summary` are plain language; `open_environment` and
30
+ # `open_technical` say which disclosures start expanded. A failure always opens the
31
+ # technical section so the cause is visible without hunting for it.
23
32
  STATES = {
24
33
  'idle' => {
25
- sections: %w(environment),
26
- environment_submit: 'Start workspace', controls_title: 'Set up your workspace',
27
- hint: 'Choose the environment you want to preview below, then start the workspace. ' \
28
- 'Setup usually takes a few minutes.'
34
+ sections: %w(lead environment),
35
+ headline: "Let's get your workspace running",
36
+ summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
37
+ environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
38
+ open_environment: true, open_technical: false
39
+ },
40
+ 'busy' => {
41
+ sections: %w(lead technical progress log),
42
+ headline: 'Setting things up…',
43
+ summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
44
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
45
+ open_environment: false, open_technical: true
46
+ },
47
+ 'ready' => {
48
+ sections: %w(lead environment) + TECHNICAL,
49
+ headline: 'Your workspace is ready',
50
+ summary: 'Everything is set up and running. Open it to see your changes.',
51
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
52
+ open_environment: false, open_technical: false
53
+ },
54
+ 'stopped' => {
55
+ sections: %w(lead environment) + TECHNICAL,
56
+ headline: 'Your workspace is paused',
57
+ summary: 'Start it again to pick up where you left off.',
58
+ environment_submit: 'Save & start', environment_title: 'Change settings',
59
+ open_environment: false, open_technical: false
60
+ },
61
+ 'error' => {
62
+ sections: %w(lead environment) + TECHNICAL,
63
+ headline: "Something didn't go to plan",
64
+ summary: 'Setup stopped before it finished. You can try again, or change settings first.',
65
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
66
+ open_environment: false, open_technical: true
67
+ },
68
+ 'interrupted' => {
69
+ sections: %w(lead environment) + TECHNICAL,
70
+ headline: 'Setup was interrupted',
71
+ summary: 'It stopped before it finished. Try again to pick up from the start.',
72
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
73
+ open_environment: false, open_technical: true
29
74
  },
30
- 'busy' => { sections: %w(progress log), environment_submit: 'Save & restart' },
31
- 'ready' => { sections: MANAGEABLE, environment_submit: 'Save & restart' },
32
- 'stopped' => { sections: %w(actions environment log details danger), environment_submit: 'Save & start' },
33
- 'error' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
34
- 'interrupted' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
35
- 'unknown' => { sections: %w(log details), environment_submit: 'Save & restart' }
75
+ 'unknown' => {
76
+ sections: %w(lead technical log details),
77
+ headline: 'Workspace status unknown',
78
+ summary: 'Open the technical details to see what the workspace is doing.',
79
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
80
+ open_environment: false, open_technical: true
81
+ }
36
82
  }.freeze
37
83
 
38
84
  def initialize(snapshot)
@@ -42,9 +88,12 @@ module Workspaces
42
88
  def to_h
43
89
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
44
90
  'actions' => actions, 'ready' => status == 'ready' && !busy?,
45
- 'state' => state, 'sections' => sections, 'hint' => definition[:hint],
46
- 'controls_title' => definition.fetch(:controls_title, 'Actions & settings'),
47
- 'environment_submit' => definition[:environment_submit] }
91
+ 'state' => state, 'sections' => sections,
92
+ 'headline' => definition[:headline], 'summary' => definition[:summary],
93
+ 'subtitle' => subtitle, 'lead_action' => lead_action,
94
+ 'environment_title' => definition[:environment_title],
95
+ 'environment_submit' => definition[:environment_submit],
96
+ 'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
48
97
  end
49
98
 
50
99
  private
@@ -73,6 +122,23 @@ module Workspaces
73
122
  SECTIONS.to_h { |name| [name, visible.include?(name)] }
74
123
  end
75
124
 
125
+ # The single most useful thing to do next, or nil when the user should just wait.
126
+ # `open` links straight to the preview; the others are lifecycle form posts.
127
+ def lead_action
128
+ case state
129
+ when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
130
+ when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
131
+ when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
132
+ end
133
+ end
134
+
135
+ # What is happening right now, in the quiet line under the headline.
136
+ def subtitle
137
+ return nil unless state == 'busy'
138
+
139
+ @snapshot['message']
140
+ end
141
+
76
142
  def title
77
143
  return 'Workspace busy' if @snapshot['active'] && !busy_status?
78
144
  return @snapshot['message'] || 'Running setup' if status == 'preparing'
@@ -0,0 +1,77 @@
1
+ require 'rack'
2
+
3
+ module Workspaces
4
+ # Adds a Rack middleware stack around workspace preview traffic.
5
+ #
6
+ # The gem stays unaware of what any middleware does: the process hosting the front
7
+ # door registers its own, for example from a file named by WORKSPACES_REQUIRE:
8
+ #
9
+ # Workspaces.configure_proxy do
10
+ # use MyMiddleware, option: 'value'
11
+ # end
12
+ #
13
+ # Blocks are replayed in registration order into a Rack::Builder, so the first
14
+ # `use` is the outermost middleware. The stack wraps only the step that forwards a
15
+ # request to a ready workspace backend. The dashboard, redirects for workspaces that
16
+ # are not ready, and error responses never pass through it.
17
+ module ProxyMiddleware
18
+ ENV_VAR = 'WORKSPACES_REQUIRE'
19
+
20
+ # Raised for unusable configuration; the CLI reports it and exits non-zero.
21
+ class ConfigurationError < ArgumentError; end
22
+
23
+ @blocks = []
24
+
25
+ def self.register(&block)
26
+ raise ConfigurationError, 'Workspaces.configure_proxy requires a block' unless block
27
+
28
+ @blocks << block
29
+ self
30
+ end
31
+
32
+ def self.any?
33
+ !@blocks.empty?
34
+ end
35
+
36
+ def self.reset!
37
+ @blocks.clear
38
+ end
39
+
40
+ # Returns the endpoint untouched when nothing is registered, so an unconfigured
41
+ # front door behaves exactly as it did before this seam existed.
42
+ def self.build(endpoint)
43
+ blocks = @blocks.dup
44
+ return endpoint if blocks.empty?
45
+
46
+ Rack::Builder.new do
47
+ blocks.each { |block| instance_exec(&block) }
48
+ run endpoint
49
+ end.to_app
50
+ end
51
+
52
+ # Paths from WORKSPACES_REQUIRE, separated like PATH entries.
53
+ def self.env_paths
54
+ ENV.fetch(ENV_VAR, '').split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
55
+ end
56
+
57
+ # Executes the given Ruby files so they can register middleware. These files run
58
+ # with the privileges of the front door; only name files the operator controls.
59
+ def self.load_files(paths)
60
+ paths.each do |path|
61
+ full_path = File.expand_path(path)
62
+ raise ConfigurationError, "Proxy middleware file not found: #{path}" unless File.file?(full_path)
63
+
64
+ begin
65
+ require full_path
66
+ rescue ScriptError, StandardError => e
67
+ raise ConfigurationError, "Could not load proxy middleware #{path}: #{e.class}: #{e.message}"
68
+ end
69
+ end
70
+ paths
71
+ end
72
+ end
73
+
74
+ def self.configure_proxy(&block)
75
+ ProxyMiddleware.register(&block)
76
+ end
77
+ end
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.9'
2
+ VERSION = '0.1.11'
3
3
  end
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.9
4
+ version: 0.1.11
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-12 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -88,6 +88,7 @@ files:
88
88
  - lib/workspaces/paths.rb
89
89
  - lib/workspaces/port_allocator.rb
90
90
  - lib/workspaces/presentation.rb
91
+ - lib/workspaces/proxy_middleware.rb
91
92
  - lib/workspaces/recipe.rb
92
93
  - lib/workspaces/registry.rb
93
94
  - lib/workspaces/state_store.rb