workspaces 0.1.8 → 0.1.10

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: a0102752fa27b9aa9b5fd40ab163411bc9d0411cdfaa00d8a82f6a04088e0099
4
- data.tar.gz: 63704204815a6e183e1e244da898defcf67ff3e55b45e69400e19e9f275abaec
3
+ metadata.gz: 1974af7e7acc6d19fe556efa4af5e2f9e05635d8df564aa6b3e7e7f8a9096370
4
+ data.tar.gz: d819cb05a41259c8f9b0c7e49d42155f7b072daa88bd839b44351ddec7445b51
5
5
  SHA512:
6
- metadata.gz: a6e7e66264833d0b55493fc42203af62be3726ee6d8422d913e00f11afc2b9cf65aaef078e0ac06ecdedbced9c1cd1ff62d37759e1eb7a28295b01908d8bb976
7
- data.tar.gz: 2bac52fea92eee837b049d2ce5b24946c0d4f3e306530dd6f4a8aef3e772495725dcdb1d7eec9c4b0b339f54d12948202d725ef609e6c1a2237c470c474276c8
6
+ metadata.gz: ad1115622e9f3c85fb12c63ba8268161054cca32675a081b6d43b5a11b1b5f1a55c0491a143631277c12aa2d019e10eb228acfcdb26fecce719a45f0336c8f3f
7
+ data.tar.gz: 236ca0df90dc5e0403fca4d494bcbeabf90117aabbd12c12175d3eecf4d2475fc36e432430e3ece7c61c651188c234daeef041ed148812d80ef71e983fda5afe
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
@@ -1,6 +1,6 @@
1
1
  <main class="dashboard">
2
2
  <header class="dashboard__header">
3
- <div><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
3
+ <div class="dashboard__title"><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
4
4
  <a href="/workspaces" class="button button--subtle">All workspaces</a>
5
5
  </header>
6
6
  <% unless @workspaces.empty? %>
@@ -8,7 +8,7 @@
8
8
  <h2 id="existing-heading"><%= @workspaces.one? ? 'Continue in your workspace' : 'Choose a workspace' %></h2>
9
9
  <p>Open an existing workspace to view its status, run it, or continue working.</p>
10
10
  <% @workspaces.each do |workspace| %>
11
- <article>
11
+ <article class="stack stack--tight">
12
12
  <h3><%= escape(workspace.fetch('id')) %></h3>
13
13
  <p><%= escape(workspace['branch']) %></p>
14
14
  <a href="/workspaces/<%= escape(workspace.fetch('id')) %>" class="button button--primary" aria-label="Open workspace <%= escape(workspace.fetch('id')) %>">Open workspace</a>
@@ -24,11 +24,13 @@
24
24
  <details class="status-panel">
25
25
  <summary>Create another workspace</summary>
26
26
  <% end %>
27
+ <div class="stack">
27
28
  <p>Create a fresh workspace from the PR's current head and start its server. Existing workspaces and edits stay untouched.</p>
28
29
  <form method="post" action="/pr/<%= escape(@number) %>">
29
30
  <button class="button <%= @workspaces.empty? ? 'button--primary' : 'button--subtle' %>">Create and start workspace</button>
30
31
  </form>
31
32
  <p class="muted">Creation fetches the PR. Repository setup steps continue on the workspace dashboard.</p>
33
+ </div>
32
34
  <% if @workspaces.empty? %>
33
35
  </section>
34
36
  <% else %>
@@ -17,27 +17,51 @@
17
17
  --space-5: 20px;
18
18
  --space-6: 24px;
19
19
  --space-8: 32px;
20
+ --space-10: 40px;
21
+ --panel-padding: var(--space-6);
20
22
  }
21
23
 
22
24
  * {
23
25
  box-sizing: border-box;
24
26
  }
25
27
 
28
+ /* Vertical rhythm: browsers ship per-element default margins, so spacing depended on
29
+ which tag was used. Reset them once; every gap is declared by a stack rule below
30
+ using the --space-* scale. */
31
+ h1, h2, h3, h4, p, ul, ol, dl, dd, pre, fieldset, figure { margin: 0; }
32
+ ol, ul { padding-inline-start: var(--space-6); }
33
+ legend { padding: 0; }
34
+
35
+ /* A stack separates its direct children by one step of the scale. Four steps only:
36
+ tight 8px label and control, control and its help text
37
+ snug 12px heading and its content, tightly related groups
38
+ block 16px blocks inside a panel (the default)
39
+ loose 24px panels on a page, and panel padding
40
+ Grid gaps skip [hidden] and out-of-flow children, so toggling sections never leaves
41
+ a stray gap behind (margins on siblings would). */
42
+ .stack { display: grid; gap: var(--space-4); align-content: start; }
43
+ .stack--tight { gap: var(--space-2); }
44
+ .stack--snug { gap: var(--space-3); }
45
+ .stack--loose { gap: var(--space-6); }
46
+
26
47
  [hidden] { display: none !important; }
27
48
  input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
28
49
  input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
29
50
  textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
30
51
  summary { cursor: pointer; color: var(--muted); }
31
52
  summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
32
- .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block-start: var(--space-4); }
33
- .setup-error { margin-block-start: var(--space-5); border-color: var(--danger); }
53
+ .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
54
+ /* The server-rendered action forms live in a wrapper; let them flow with the sibling button. */
55
+ #actions { display: contents; }
56
+ .setup-error { --panel-padding: var(--space-4); padding: var(--panel-padding); border-color: var(--danger); box-shadow: none; }
34
57
  .setup-error h3 { color: var(--danger); margin-block-start: 0; }
35
58
  .step-outcome { margin-inline-start: auto; font-size: 12px; }
36
59
  .activity-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: workspace-pulse 1.5s infinite; }
37
60
  @keyframes workspace-pulse { 50% { opacity: 0.3; } }
38
61
  @media (prefers-reduced-motion: reduce) { .activity-dot { animation: none; } }
39
62
  dd, h1, code { overflow-wrap: anywhere; }
40
- dd { margin-inline-start: 0; margin-block-end: var(--space-3); }
63
+ dt + dd { margin-block-start: var(--space-1); }
64
+ dd + dt { margin-block-start: var(--space-3); }
41
65
  #log { max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
42
66
 
43
67
  body {
@@ -69,7 +93,7 @@ a {
69
93
  }
70
94
 
71
95
  .eyebrow {
72
- margin: 0 0 var(--space-1);
96
+ margin: 0;
73
97
  color: var(--muted);
74
98
  font-size: 12px;
75
99
  letter-spacing: 0.08em;
@@ -87,6 +111,11 @@ h1 {
87
111
  color: var(--muted);
88
112
  }
89
113
 
114
+ .dashboard__title {
115
+ display: grid;
116
+ gap: var(--space-1);
117
+ }
118
+
90
119
  .header-actions {
91
120
  display: flex;
92
121
  gap: var(--space-3);
@@ -100,13 +129,30 @@ h1 {
100
129
  background: var(--bg-elevated);
101
130
  border: 1px solid var(--border);
102
131
  border-radius: var(--radius);
103
- padding: var(--space-5);
132
+ padding: var(--panel-padding);
104
133
  box-shadow: var(--shadow);
105
134
  }
106
135
 
107
- .status-row {
136
+ /* Disclosure panels keep their native layout; only the open state adds spacing below. */
137
+ .status-panel:not(details),
138
+ .controls-panel,
139
+ .error-panel,
140
+ .log-panel,
141
+ .noscript-note {
108
142
  display: grid;
109
143
  gap: var(--space-4);
144
+ align-content: start;
145
+ }
146
+
147
+ .error-panel,
148
+ .log-panel,
149
+ .danger-zone { gap: var(--space-3); }
150
+
151
+ details.status-panel[open] > summary { margin-block-end: var(--space-4); }
152
+
153
+ .status-row {
154
+ display: grid;
155
+ gap: var(--space-2) var(--space-4);
110
156
  grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
111
157
  }
112
158
 
@@ -152,7 +198,7 @@ h1 {
152
198
  }
153
199
 
154
200
  .stepper {
155
- margin: var(--space-5) 0 0;
201
+ margin: 0;
156
202
  padding: 0;
157
203
  list-style: none;
158
204
  display: grid;
@@ -217,7 +263,7 @@ h1 {
217
263
  margin: 0;
218
264
  padding: var(--space-4);
219
265
  display: grid;
220
- gap: var(--space-3);
266
+ gap: var(--space-4);
221
267
  }
222
268
 
223
269
  .env-legend {
@@ -226,16 +272,9 @@ h1 {
226
272
  padding-inline: var(--space-2);
227
273
  }
228
274
 
229
- .env-input-label {
230
- display: inline-flex;
231
- align-items: center;
232
- gap: var(--space-2);
233
- font-size: 14px;
234
- }
235
-
236
275
  .env-input-label {
237
276
  display: block;
238
- margin-block-end: var(--space-1);
277
+ font-size: 14px;
239
278
  }
240
279
 
241
280
  .env-fieldset textarea {
@@ -265,31 +304,21 @@ fieldset:disabled {
265
304
  }
266
305
 
267
306
  .controls-panel {
268
- display: grid;
269
- gap: var(--space-4);
270
307
  align-items: start;
271
308
  }
272
309
 
273
- .controls-panel h2,
274
- .controls-panel h3 {
275
- margin: 0;
276
- }
277
-
278
310
  .workspace-actions-group,
279
311
  .workspace-settings {
280
312
  display: grid;
281
313
  gap: var(--space-3);
282
314
  }
283
315
 
284
- .workspace-settings {
316
+ /* The divider only separates two groups: skip it when settings is the only one showing. */
317
+ .workspace-actions-group:not([hidden]) + .workspace-settings {
285
318
  border-top: 1px solid var(--border);
286
319
  padding-top: var(--space-4);
287
320
  }
288
321
 
289
- .workspace-actions {
290
- margin-block-start: 0;
291
- }
292
-
293
322
  .workspace-actions .button,
294
323
  .workspace-actions form {
295
324
  margin: 0;
@@ -312,6 +341,7 @@ fieldset:disabled {
312
341
  }
313
342
 
314
343
  .button {
344
+ justify-self: start;
315
345
  display: inline-flex;
316
346
  justify-content: center;
317
347
  align-items: center;
@@ -361,10 +391,25 @@ fieldset:disabled {
361
391
  .button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
362
392
 
363
393
  .danger-zone { border-color: var(--danger); }
364
- .danger-zone h2 { margin-block-start: 0; color: var(--danger); }
394
+ .danger-zone h2 { color: var(--danger); }
365
395
  .danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
366
- .workspace-list { list-style: none; padding: 0; display: grid; gap: var(--space-3); }
367
- .workspace-list__item { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
396
+
397
+ .workspace-table { inline-size: 100%; border-collapse: collapse; }
398
+ .workspace-table th,
399
+ .workspace-table td { padding: var(--space-3) var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
400
+ .workspace-table th:first-child,
401
+ .workspace-table td:first-child { padding-inline-start: 0; }
402
+ .workspace-table th:last-child,
403
+ .workspace-table td:last-child { padding-inline-end: 0; }
404
+ .workspace-table thead th { padding-block-start: 0; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
405
+ .workspace-table tbody tr:last-child > * { padding-block-end: 0; border-block-end: 0; }
406
+ .workspace-table tbody th { font-weight: 600; }
407
+ .workspace-table__date { color: var(--muted); font-size: 13px; white-space: nowrap; }
408
+
409
+ .field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
410
+ .field input { inline-size: 100%; }
411
+
412
+ ol:not(.stepper) > li + li { margin-block-start: var(--space-2); }
368
413
 
369
414
  .button--success {
370
415
  background: color-mix(in oklab, var(--success) 22%, transparent);
@@ -379,11 +424,12 @@ fieldset:disabled {
379
424
 
380
425
  .error-panel h2,
381
426
  .log-panel h2 {
382
- margin: 0 0 var(--space-3);
427
+ margin: 0;
383
428
  font-size: 1rem;
384
429
  }
385
430
 
386
431
  .error-panel pre,
432
+ .status-panel pre,
387
433
  .log-panel pre {
388
434
  margin: 0;
389
435
  padding: var(--space-3);
@@ -408,14 +454,12 @@ fieldset:disabled {
408
454
  justify-content: space-between;
409
455
  align-items: baseline;
410
456
  gap: var(--space-3);
411
- margin-block-end: var(--space-3);
412
457
  }
413
458
 
414
459
  .log-switch {
415
460
  display: flex;
416
461
  flex-wrap: wrap;
417
462
  gap: var(--space-2);
418
- margin-block-end: var(--space-3);
419
463
  }
420
464
 
421
465
  .log-switch a {
@@ -450,7 +494,9 @@ fieldset:disabled {
450
494
  display: none !important;
451
495
  }
452
496
 
453
- .visually-hidden {
497
+ .visually-hidden,
498
+ [role='status']:empty,
499
+ [aria-live]:empty {
454
500
  position: absolute !important;
455
501
  inline-size: 1px;
456
502
  block-size: 1px;
@@ -461,7 +507,22 @@ fieldset:disabled {
461
507
  border: 0;
462
508
  }
463
509
 
510
+ p:empty:not([role]):not([aria-live]) { display: none; }
511
+
464
512
  @media (max-width: 640px) {
513
+ /* Stack each row: workspace, then status. Dates are desktop-only detail. */
514
+ .workspace-table,
515
+ .workspace-table tbody { display: block; }
516
+ .workspace-table thead,
517
+ .workspace-table th.workspace-table__date,
518
+ .workspace-table td.workspace-table__date { display: none; }
519
+ .workspace-table tr { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); }
520
+ .workspace-table tbody tr:first-child { padding-block-start: 0; }
521
+ .workspace-table tbody tr:last-child { padding-block-end: 0; border-block-end: 0; }
522
+ .workspace-table th,
523
+ .workspace-table td { display: block; padding: 0; border: 0; }
524
+ .workspace-table tbody tr:last-child > * { padding-block-end: 0; }
525
+
465
526
  .dashboard {
466
527
  inline-size: min(100% - var(--space-4), 960px);
467
528
  padding-block: var(--space-6);
@@ -472,7 +533,8 @@ fieldset:disabled {
472
533
  .error-panel,
473
534
  .log-panel,
474
535
  .noscript-note {
475
- padding: var(--space-4);
536
+ --panel-padding: var(--space-4);
537
+ padding: var(--panel-padding);
476
538
  }
477
539
 
478
540
  .log-header {
@@ -1,6 +1,6 @@
1
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'] %>">
2
2
  <header class="dashboard__header">
3
- <div><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p><%= field('branch') %></p></div>
3
+ <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
4
4
  <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
5
  </header>
6
6
  <section class="status-panel" aria-labelledby="status">
@@ -32,11 +32,11 @@
32
32
  </div>
33
33
  <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
34
  <h3 id="environment-overrides">Environment</h3>
35
- <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
35
+ <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
36
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
37
  <legend class="env-legend">Workspace settings</legend>
38
38
  <% unless environment_presets.empty? %>
39
- <div data-preset-controls hidden>
39
+ <div class="stack stack--tight" data-preset-controls hidden>
40
40
  <p class="env-input-label" id="environment-preset-label">Preset</p>
41
41
  <div class="env-preset-buttons" role="group" aria-labelledby="environment-preset-label" aria-describedby="environment-preset-help">
42
42
  <% environment_presets.each_key do |name| %>
@@ -46,8 +46,10 @@
46
46
  <p class="muted" id="environment-preset-help">Presets decide which app this workspace runs. Select one, then use <strong data-submit-label><%= escape(@presentation['environment_submit']) %></strong> to apply it.</p>
47
47
  </div>
48
48
  <% end %>
49
- <label class="env-input-label" for="editable-env">Environment variables</label>
50
- <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
49
+ <div class="stack stack--tight">
50
+ <label class="env-input-label" for="editable-env">Environment variables</label>
51
+ <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
52
+ </div>
51
53
  </fieldset>
52
54
  <div class="env-actions">
53
55
  <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
@@ -15,16 +15,16 @@ module Workspaces
15
15
  <main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1></header>
16
16
  <section class="status-panel"><h2>Create workspace</h2>
17
17
  <p>Choose a branch, PR number, or new branch. Every submission creates a separate managed worktree.</p>
18
- <form method="post" action="/workspaces/create">
19
- <p><label>Existing branch <input name="branch" placeholder="main"></label></p>
20
- <p><label>PR number <input name="pr" inputmode="numeric" placeholder="43828"></label></p>
21
- <p><label>New branch <input name="new_branch" placeholder="agent/fix-checkout"></label></p>
22
- <p><label>Start new branch from <input name="from" placeholder="HEAD"></label></p>
18
+ <form method="post" action="/workspaces/create" class="stack">
19
+ <label class="field">Existing branch <input name="branch" placeholder="main"></label>
20
+ <label class="field">PR number <input name="pr" inputmode="numeric" placeholder="43828"></label>
21
+ <label class="field">New branch <input name="new_branch" placeholder="agent/fix-checkout"></label>
22
+ <label class="field">Start new branch from <input name="from" placeholder="HEAD"></label>
23
23
  <button class="button button--primary">Create workspace</button>
24
24
  </form></section>
25
- <section class="status-panel"><h2>Managed workspaces</h2><ul class="workspace-list">
26
- #{workspaces.map { |workspace| view.inventory_item(workspace) }.join}
27
- </ul></section></main>
25
+ <section class="status-panel"><h2>Managed workspaces</h2>
26
+ #{view.inventory_table(workspaces)}
27
+ </section></main>
28
28
  HTML
29
29
  end
30
30
 
@@ -90,17 +90,33 @@ module Workspaces
90
90
  ERB::Util.html_escape(value.to_s)
91
91
  end
92
92
 
93
- def inventory_item(workspace)
93
+ # Tabular data gets a real table. The workspace ID is both the row header and the
94
+ # link, so no separate name/branch/link columns are needed. Narrow screens hide the
95
+ # date columns and stack each row (see styles.css).
96
+ def inventory_table(workspaces)
97
+ return '<p class="muted">No workspaces yet.</p>' if workspaces.empty?
98
+
99
+ <<~HTML
100
+ <table class="workspace-table">
101
+ <thead><tr><th scope="col">Workspace</th><th scope="col">Status</th><th scope="col" class="workspace-table__date">Created</th><th scope="col" class="workspace-table__date">Last startup</th></tr></thead>
102
+ <tbody>
103
+ #{workspaces.map { |workspace| inventory_row(workspace) }.join}
104
+ </tbody>
105
+ </table>
106
+ HTML
107
+ end
108
+
109
+ def inventory_row(workspace)
94
110
  id = escape(Config.validate_id!(workspace.fetch('id')))
111
+ status = escape(workspace['status'] || 'idle')
95
112
  running = workspace['running'] ? 'Running' : 'Not running'
96
113
  <<~HTML
97
- <li class="workspace-list__item">
98
- <a href="/workspaces/#{id}">#{id}</a>
99
- <span class="muted">#{escape(workspace['branch'])}</span>
100
- <span>#{running} · #{escape(workspace['status'] || 'idle')}#{workspace['active'] ? ' (busy)' : ''}</span>
101
- <span class="muted">Created <time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></span>
102
- <span class="muted">Last startup <time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></span>
103
- </li>
114
+ <tr>
115
+ <th scope="row"><a href="/workspaces/#{id}">#{id}</a></th>
116
+ <td><span class="status-pill" data-status="#{status}"><span class="status-dot" aria-hidden="true"></span>#{running} · #{status}#{workspace['active'] ? ' (busy)' : ''}</span></td>
117
+ <td class="workspace-table__date"><time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></td>
118
+ <td class="workspace-table__date"><time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></td>
119
+ </tr>
104
120
  HTML
105
121
  end
106
122
 
@@ -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)
@@ -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.8'
2
+ VERSION = '0.1.10'
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.8
4
+ version: 0.1.10
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-13 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