workspaces 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/DESIGN.md +5 -1
- data/README.md +30 -0
- data/lib/workspaces/cli.rb +1 -10
- data/lib/workspaces/coordinator.rb +15 -2
- data/lib/workspaces/dashboard/app.js +22 -3
- data/lib/workspaces/dashboard/styles.css +13 -0
- data/lib/workspaces/dashboard/template.html.erb +13 -2
- data/lib/workspaces/dashboard_view.rb +41 -2
- data/lib/workspaces/front_door.rb +31 -2
- data/lib/workspaces/presentation.rb +9 -3
- data/lib/workspaces/registry.rb +26 -0
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +14 -5
- data/lib/workspaces/worktree.rb +81 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3771d28dd38a7503c4ca8189e4ce6419d8ad5989c8ef8618fe5420b3572909a
|
|
4
|
+
data.tar.gz: 6dda806083d5074afeb3428d64eb9b05d67b0a1b84b5c485156d7d087606db5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bfe8edb9cd5d06f9f10adf028da25cec903b5f3429971ef5521a99dd74142454985da667977d113c50129de27e5086cd040e8a6afaf111fbcfce406cb755634
|
|
7
|
+
data.tar.gz: a278582fd63bfae4d3f0f27afd68d36418854b6513e0fe50bcd96e30c73cacc9d8b3b008770d5c5b32dfd1b6abadb8ace1e60034b12909790b38fa169001adeb
|
data/DESIGN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Workspace list/create screen and per-workspace setup/status screen. Workspace identity is independent of PR provenance.
|
|
6
6
|
|
|
7
|
-
GET is read-only. All creation and lifecycle controls submit same-origin POST forms.
|
|
7
|
+
GET is read-only. All creation and lifecycle controls submit same-origin POST forms. Workspace IDs, branches and filesystem paths wrap on narrow screens. Native labelled inputs and buttons are keyboard accessible; polling announces status without replacing form focus or making the entire log an ARIA live region.
|
|
8
8
|
|
|
9
9
|
## Visual direction
|
|
10
10
|
|
|
@@ -41,9 +41,11 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
41
41
|
## Component primitives
|
|
42
42
|
|
|
43
43
|
- **Creation form**: labelled branch, PR, and new-branch inputs with a primary create action
|
|
44
|
+
- **Workspace inventory**: one row per managed workspace with a status distinct from backend running state, creation and last startup times, and a link to its detail page. Unknown timestamps are shown as unavailable, not inferred from recent setup.
|
|
44
45
|
- **Status pill**: current workspace status as text
|
|
45
46
|
- **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
|
|
46
47
|
- **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
|
|
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.
|
|
47
49
|
- **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
|
|
48
50
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
49
51
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
|
|
@@ -53,6 +55,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
53
55
|
- **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.
|
|
54
56
|
- **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
|
|
55
57
|
- **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.
|
|
58
|
+
- **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.
|
|
59
|
+
- **Workspace favicon**: a small two-pane workspace mark served by the dashboard host. Use `--accent` blue for localhost and `--warning` gold for hosted origins so browser tabs signal the environment without relying on page text. The icon contains no workspace-specific data.
|
|
56
60
|
|
|
57
61
|
## Accessibility constraints
|
|
58
62
|
|
data/README.md
CHANGED
|
@@ -39,6 +39,17 @@ The returned JSON includes the workspace ID, dashboard URL, and preview URL. You
|
|
|
39
39
|
create from a pull request with `create --pr NUMBER`, or create a new branch with
|
|
40
40
|
`create --new-branch NAME --from REF`. Run `help` for lifecycle and other commands.
|
|
41
41
|
|
|
42
|
+
### Step timings
|
|
43
|
+
|
|
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.
|
|
47
|
+
|
|
48
|
+
A background step measures only process launch, not the server's lifetime or
|
|
49
|
+
readiness. The following readiness step measures its own wait separately.
|
|
50
|
+
Interrupted steps have no recorded duration. Step timings are not the total
|
|
51
|
+
workflow wall-clock time; orchestration overhead can fall outside the steps.
|
|
52
|
+
|
|
42
53
|
## Serve the dashboard and previews
|
|
43
54
|
|
|
44
55
|
Run `serve` in a separate terminal using the **same project root and `WORKSPACES_HOME`**
|
|
@@ -67,6 +78,25 @@ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
|
|
|
67
78
|
`WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
|
|
68
79
|
`serve` does **not** start workspace backends; use `start` or `restart` for those.
|
|
69
80
|
|
|
81
|
+
The `/workspaces` dashboard lists each managed workspace's setup status, whether its
|
|
82
|
+
backend is running, creation time, and last backend startup. On a workspace's detail
|
|
83
|
+
page, the Danger Zone lets an operator delete that one workspace after typing its
|
|
84
|
+
exact ID. Deletion stops the backend and force-removes the worktree, including
|
|
85
|
+
uncommitted and untracked files; the branch and committed changes are preserved.
|
|
86
|
+
Busy workspaces cannot be removed until their current operation finishes. This
|
|
87
|
+
action is protected by the dashboard's same-origin POST check, but that check is
|
|
88
|
+
not authentication—keep hosted deployments behind VPN or SSO.
|
|
89
|
+
|
|
90
|
+
The dashboard favicon is blue for localhost and gold for hosted origins, helping
|
|
91
|
+
distinguish local and hosted tabs.
|
|
92
|
+
|
|
93
|
+
PR workspaces also offer **Update from PR** on their detail page. It fetches the
|
|
94
|
+
latest PR head and fast-forwards the workspace branch only when the new head is
|
|
95
|
+
a descendant of its current commit. Local tracked and untracked changes are
|
|
96
|
+
temporarily stashed and restored (including staged state). On a restore conflict,
|
|
97
|
+
the stash is retained for manual recovery. Updating the checkout does not run
|
|
98
|
+
setup or restart the already-running backend; use Rebuild & restart afterward.
|
|
99
|
+
|
|
70
100
|
The front door is not an authentication boundary. Its host and same-origin checks
|
|
71
101
|
protect routing and POST actions, but a publicly reachable instance still needs
|
|
72
102
|
access control such as VPN or SSO. Do not expose workspace backend ports directly.
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -137,16 +137,7 @@ module Workspaces
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def remove(id, options)
|
|
140
|
-
registry.
|
|
141
|
-
if !options[:force] && workspace.dirty?
|
|
142
|
-
raise ArgumentError,
|
|
143
|
-
'Workspace has uncommitted/untracked work; use --force to discard it'
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
Backend.new(id).stop!
|
|
147
|
-
workspace.remove!(force: options[:force])
|
|
148
|
-
StateStore.new(Config.state_file).delete(id)
|
|
149
|
-
end
|
|
140
|
+
registry.remove(id, force: options[:force])
|
|
150
141
|
emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true }, options)
|
|
151
142
|
end
|
|
152
143
|
|
|
@@ -60,6 +60,17 @@ module Workspaces
|
|
|
60
60
|
data.merge('presentation' => Presentation.new(data).to_h)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
def inventory
|
|
64
|
+
@registry.list.map do |workspace|
|
|
65
|
+
id = workspace.fetch('id')
|
|
66
|
+
entry = @state.get(id) || {}
|
|
67
|
+
busy = locked?(id)
|
|
68
|
+
workspace.merge('status' => resolve_status(id, entry.fetch('status', 'idle'), busy),
|
|
69
|
+
'running' => !!Backend.new(id).running?, 'active' => busy,
|
|
70
|
+
'started_at' => entry['started_at'])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
private
|
|
64
75
|
|
|
65
76
|
def resolve_status(id, status, busy)
|
|
@@ -74,7 +85,9 @@ module Workspaces
|
|
|
74
85
|
{
|
|
75
86
|
'workspace_id' => id, 'status' => status, 'active' => busy,
|
|
76
87
|
'message' => status == entry['status'] ? entry['message'] : status.capitalize,
|
|
77
|
-
'prepare_started_at' => entry['prepare_started_at'], '
|
|
88
|
+
'prepare_started_at' => entry['prepare_started_at'], 'started_at' => entry['started_at'],
|
|
89
|
+
'running' => !!Backend.new(id).running?, 'updated_at' => entry['updated_at'],
|
|
90
|
+
'source_update_message' => entry['source_update_message'],
|
|
78
91
|
'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
|
|
79
92
|
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
80
93
|
'steps' => entry.fetch('steps', []),
|
|
@@ -121,7 +134,7 @@ module Workspaces
|
|
|
121
134
|
def publish_starting(id, restart)
|
|
122
135
|
recipe = Recipe.new
|
|
123
136
|
steps = recipe.for_operation(restart: restart).map do |step|
|
|
124
|
-
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending' }
|
|
137
|
+
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
|
|
125
138
|
end
|
|
126
139
|
update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
127
140
|
'steps' => steps,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
|
|
8
8
|
? 'backend'
|
|
9
9
|
: 'setup';
|
|
10
|
-
const allowedActions = ['start', 'prepare', 'restart', 'stop'];
|
|
10
|
+
const allowedActions = ['start', 'prepare', 'restart', 'stop', 'update-pr'];
|
|
11
11
|
let busy = root.dataset.busy === 'true';
|
|
12
12
|
let started = Date.parse(root.dataset.started);
|
|
13
13
|
let completed = Date.parse(root.dataset.completed);
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
});
|
|
19
19
|
if (window.WorkspaceEnvironment)
|
|
20
20
|
window.WorkspaceEnvironment.toggleBusy(nextBusy);
|
|
21
|
+
node('remove-form').querySelectorAll('input, button').forEach((control) => {
|
|
22
|
+
control.disabled = nextBusy;
|
|
23
|
+
});
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function renderActions(actions) {
|
|
@@ -51,11 +54,25 @@
|
|
|
51
54
|
label.textContent = step.label;
|
|
52
55
|
const outcome = document.createElement('span');
|
|
53
56
|
outcome.className = 'step-outcome';
|
|
54
|
-
outcome.textContent = step
|
|
57
|
+
outcome.textContent = stepOutcome(step);
|
|
55
58
|
item.append(bullet, label, outcome);
|
|
56
59
|
return item;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
function stepOutcome(step) {
|
|
63
|
+
const state = step.state;
|
|
64
|
+
const rawDuration = step.duration_seconds;
|
|
65
|
+
if (typeof rawDuration !== 'number') return state;
|
|
66
|
+
const duration = rawDuration;
|
|
67
|
+
if (
|
|
68
|
+
(state === 'complete' || state === 'failed') &&
|
|
69
|
+
Number.isFinite(duration) &&
|
|
70
|
+
duration >= 0
|
|
71
|
+
)
|
|
72
|
+
return state + ' · ' + duration.toFixed(3) + 's';
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
function updateLog(data) {
|
|
60
77
|
if (data.log_source !== selectedLog) return;
|
|
61
78
|
const log = node('log');
|
|
@@ -75,6 +92,7 @@
|
|
|
75
92
|
completed = Date.parse(data.completed_at);
|
|
76
93
|
renderStatus(view);
|
|
77
94
|
node('message').textContent = data.message || '';
|
|
95
|
+
node('source-update-message').textContent = data.source_update_message || '';
|
|
78
96
|
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
79
97
|
renderActions(view.actions);
|
|
80
98
|
updateLog(data);
|
|
@@ -122,7 +140,8 @@
|
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
root.addEventListener('submit', (event) => {
|
|
125
|
-
if (event.target.id !== 'environment-form'
|
|
143
|
+
if (event.target.id !== 'environment-form' && event.target.id !== 'remove-form')
|
|
144
|
+
toggleBusyState(true);
|
|
126
145
|
node('connection').textContent = 'Submitting…';
|
|
127
146
|
});
|
|
128
147
|
|
|
@@ -312,6 +312,19 @@ fieldset:disabled {
|
|
|
312
312
|
border-color: var(--border);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
.button--danger {
|
|
316
|
+
background: var(--danger);
|
|
317
|
+
color: var(--bg);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
|
321
|
+
|
|
322
|
+
.danger-zone { border-color: var(--danger); }
|
|
323
|
+
.danger-zone h2 { margin-block-start: 0; color: var(--danger); }
|
|
324
|
+
.danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
|
|
325
|
+
.workspace-list { list-style: none; padding: 0; display: grid; gap: var(--space-3); }
|
|
326
|
+
.workspace-list__item { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
327
|
+
|
|
315
328
|
.button--success {
|
|
316
329
|
background: color-mix(in oklab, var(--success) 22%, transparent);
|
|
317
330
|
border-color: color-mix(in oklab, var(--success) 55%, var(--border));
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<p id="message"><%= field('message') %></p>
|
|
12
12
|
<ol class="stepper" id="progress" aria-label="Setup progress">
|
|
13
13
|
<% @presentation['steps'].each do |step| %>
|
|
14
|
-
<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"><%= step
|
|
14
|
+
<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>
|
|
15
15
|
<% end %>
|
|
16
16
|
</ol>
|
|
17
17
|
<section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
23
23
|
</div>
|
|
24
24
|
<p id="connection" role="status"></p>
|
|
25
|
+
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
25
26
|
</section>
|
|
26
27
|
<section class="log-panel" aria-labelledby="log-title">
|
|
27
28
|
<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>
|
|
@@ -48,6 +49,16 @@
|
|
|
48
49
|
<p class="muted" id="environment-status" role="status" aria-live="polite"></p>
|
|
49
50
|
</form>
|
|
50
51
|
</section>
|
|
51
|
-
<details class="status-panel"><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></dl></details>
|
|
52
|
+
<details class="status-panel"><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>
|
|
53
|
+
<section class="status-panel danger-zone" aria-labelledby="danger-heading">
|
|
54
|
+
<h2 id="danger-heading">Danger Zone</h2>
|
|
55
|
+
<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>
|
|
56
|
+
<form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
|
|
57
|
+
<label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
|
|
58
|
+
<input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
|
|
59
|
+
<p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
|
|
60
|
+
<button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
|
|
61
|
+
</form>
|
|
62
|
+
</section>
|
|
52
63
|
<noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
|
|
53
64
|
</main>
|
|
@@ -21,8 +21,8 @@ module Workspaces
|
|
|
21
21
|
<p><label>Start new branch from <input name="from" placeholder="HEAD"></label></p>
|
|
22
22
|
<button class="button button--primary">Create workspace</button>
|
|
23
23
|
</form></section>
|
|
24
|
-
<section class="status-panel"><h2>Managed workspaces</h2><ul>
|
|
25
|
-
#{workspaces.map { |workspace|
|
|
24
|
+
<section class="status-panel"><h2>Managed workspaces</h2><ul class="workspace-list">
|
|
25
|
+
#{workspaces.map { |workspace| view.inventory_item(workspace) }.join}
|
|
26
26
|
</ul></section></main>
|
|
27
27
|
HTML
|
|
28
28
|
end
|
|
@@ -35,6 +35,20 @@ module Workspaces
|
|
|
35
35
|
new({}).render_checkout_authentication_error(path)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def self.favicon
|
|
39
|
+
local = Config.public_uri.host.match?(/(?:\A|\.)localhost\z/i)
|
|
40
|
+
color = local ? '#4f8dff' : '#f1bb53'
|
|
41
|
+
<<~SVG
|
|
42
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Workspaces">
|
|
43
|
+
<rect x="3" y="3" width="58" height="58" rx="13" fill="#0f1524"/>
|
|
44
|
+
<rect x="11" y="13" width="42" height="38" rx="5" fill="none" stroke="#{color}" stroke-width="4"/>
|
|
45
|
+
<path d="M11 25h42M32 25v26" fill="none" stroke="#{color}" stroke-width="4"/>
|
|
46
|
+
<circle cx="18" cy="19" r="2" fill="#{color}"/>
|
|
47
|
+
<circle cx="25" cy="19" r="2" fill="#{color}"/>
|
|
48
|
+
</svg>
|
|
49
|
+
SVG
|
|
50
|
+
end
|
|
51
|
+
|
|
38
52
|
def initialize(snapshot)
|
|
39
53
|
@snapshot = snapshot
|
|
40
54
|
@presentation = Presentation.new(snapshot).to_h
|
|
@@ -66,6 +80,7 @@ module Workspaces
|
|
|
66
80
|
File.read(File.join(__dir__, 'dashboard/environment.js'), encoding: 'UTF-8')
|
|
67
81
|
'<!doctype html><html lang="en"><head><meta charset="utf-8">' \
|
|
68
82
|
'<meta name="viewport" content="width=device-width, initial-scale=1">' \
|
|
83
|
+
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">' \
|
|
69
84
|
"<title>#{escape(title)}</title><style>#{css}</style></head>" \
|
|
70
85
|
"<body>#{body}<script>#{script}</script></body></html>"
|
|
71
86
|
end
|
|
@@ -74,6 +89,20 @@ module Workspaces
|
|
|
74
89
|
ERB::Util.html_escape(value.to_s)
|
|
75
90
|
end
|
|
76
91
|
|
|
92
|
+
def inventory_item(workspace)
|
|
93
|
+
id = escape(Config.validate_id!(workspace.fetch('id')))
|
|
94
|
+
running = workspace['running'] ? 'Running' : 'Not running'
|
|
95
|
+
<<~HTML
|
|
96
|
+
<li class="workspace-list__item">
|
|
97
|
+
<a href="/workspaces/#{id}">#{id}</a>
|
|
98
|
+
<span class="muted">#{escape(workspace['branch'])}</span>
|
|
99
|
+
<span>#{running} · #{escape(workspace['status'] || 'idle')}#{workspace['active'] ? ' (busy)' : ''}</span>
|
|
100
|
+
<span class="muted">Created <time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></span>
|
|
101
|
+
<span class="muted">Last startup <time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></span>
|
|
102
|
+
</li>
|
|
103
|
+
HTML
|
|
104
|
+
end
|
|
105
|
+
|
|
77
106
|
private
|
|
78
107
|
|
|
79
108
|
def field(name)
|
|
@@ -99,5 +128,15 @@ module Workspaces
|
|
|
99
128
|
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
100
129
|
"<button class=\"button #{style}\">#{escape(action.fetch('label'))}</button></form>"
|
|
101
130
|
end
|
|
131
|
+
|
|
132
|
+
def step_outcome(step)
|
|
133
|
+
state = step['state'].to_s
|
|
134
|
+
return state unless %w(complete failed).include?(state)
|
|
135
|
+
|
|
136
|
+
duration = step['duration_seconds']
|
|
137
|
+
return state unless duration.is_a?(Numeric) && duration.finite? && duration >= 0
|
|
138
|
+
|
|
139
|
+
"#{state} · #{format('%.3f', duration)}s"
|
|
140
|
+
end
|
|
102
141
|
end
|
|
103
142
|
end
|
|
@@ -9,7 +9,7 @@ require_relative 'environment_overrides'
|
|
|
9
9
|
module Workspaces
|
|
10
10
|
class FrontDoor
|
|
11
11
|
WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
|
|
12
|
-
(?:/(status|start|prepare|restart|stop|environment))?\z}x
|
|
12
|
+
(?:/(status|start|prepare|restart|stop|remove|update-pr|environment))?\z}x
|
|
13
13
|
|
|
14
14
|
def initialize(coordinator: Coordinator.new, registry: Registry.new)
|
|
15
15
|
@coordinator = coordinator
|
|
@@ -35,6 +35,7 @@ module Workspaces
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
def route(request)
|
|
38
|
+
return favicon(request) if request.path_info == '/favicon.svg'
|
|
38
39
|
return index(request) if ['/', '/workspaces'].include?(request.path_info)
|
|
39
40
|
return create(request) if request.path_info == '/workspaces/create'
|
|
40
41
|
pr_match = %r{\A/pr/([1-9][0-9]*)\z}.match(request.path_info)
|
|
@@ -43,6 +44,12 @@ module Workspaces
|
|
|
43
44
|
workspace_route(request)
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
def favicon(request)
|
|
48
|
+
return response(405, 'Use GET or HEAD') unless safe?(request)
|
|
49
|
+
|
|
50
|
+
response(200, DashboardView.favicon, type: 'image/svg+xml', head: request.head?)
|
|
51
|
+
end
|
|
52
|
+
|
|
46
53
|
def workspace_route(request)
|
|
47
54
|
match = WORKSPACE_ROUTE.match(request.path_info)
|
|
48
55
|
return response(404, 'Not found. Visit /workspaces.') unless match
|
|
@@ -69,7 +76,7 @@ module Workspaces
|
|
|
69
76
|
def index(request)
|
|
70
77
|
return response(405, 'Use GET or HEAD') unless safe?(request)
|
|
71
78
|
|
|
72
|
-
response(200, DashboardView.index(@
|
|
79
|
+
response(200, DashboardView.index(@coordinator.inventory), type: 'text/html', head: request.head?)
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
def action_request(request, id, action)
|
|
@@ -77,6 +84,8 @@ module Workspaces
|
|
|
77
84
|
return status_response(request, id, action) if action.nil? || action == 'status'
|
|
78
85
|
return response(405, 'Use POST') unless request.post?
|
|
79
86
|
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
87
|
+
return remove(request, id) if action == 'remove'
|
|
88
|
+
return update_pr(id) if action == 'update-pr'
|
|
80
89
|
|
|
81
90
|
perform_action(id, action)
|
|
82
91
|
redirect(Config.dashboard_url(id))
|
|
@@ -126,6 +135,26 @@ module Workspaces
|
|
|
126
135
|
end
|
|
127
136
|
end
|
|
128
137
|
|
|
138
|
+
def remove(request, id)
|
|
139
|
+
unless request.media_type == 'application/x-www-form-urlencoded'
|
|
140
|
+
return response(415, 'Deletion requires a form-encoded confirmation')
|
|
141
|
+
end
|
|
142
|
+
return response(413, 'Deletion confirmation is too large') if request.content_length.to_i > 512
|
|
143
|
+
return response(422, 'Type the exact workspace ID to confirm deletion') unless request.POST['confirm_id'] == id
|
|
144
|
+
|
|
145
|
+
@registry.remove(id, force: true)
|
|
146
|
+
redirect("#{Config.public_origin}/workspaces")
|
|
147
|
+
rescue ArgumentError, Worktree::CommandFailedError, SystemCallError
|
|
148
|
+
response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update_pr(id)
|
|
152
|
+
@registry.update_pr(id)
|
|
153
|
+
redirect(Config.dashboard_url(id))
|
|
154
|
+
rescue Worktree::UpdateError
|
|
155
|
+
redirect(Config.dashboard_url(id))
|
|
156
|
+
end
|
|
157
|
+
|
|
129
158
|
def create(request)
|
|
130
159
|
return response(405, 'Use POST') unless request.post?
|
|
131
160
|
return response(403, 'Creation requires the configured dashboard origin') unless same_origin?(request)
|
|
@@ -50,10 +50,16 @@ module Workspaces
|
|
|
50
50
|
|
|
51
51
|
def actions
|
|
52
52
|
return [] if busy?
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
available = if status == 'ready'
|
|
54
|
+
%w(prepare restart stop).map { |key| action(key, false) }
|
|
55
|
+
elsif %w(error interrupted).include?(status)
|
|
56
|
+
[retry_action]
|
|
57
|
+
else
|
|
58
|
+
[action('start', true)]
|
|
59
|
+
end
|
|
60
|
+
available << { 'key' => 'update-pr', 'label' => 'Update from PR', 'primary' => false } if @snapshot.dig('source', 'kind') == 'pr'
|
|
61
|
+
available
|
|
55
62
|
|
|
56
|
-
[action('start', true)]
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
def retry_action
|
data/lib/workspaces/registry.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative 'worktree'
|
|
2
2
|
require_relative 'state_store'
|
|
3
|
+
require_relative 'backend'
|
|
3
4
|
|
|
4
5
|
module Workspaces
|
|
5
6
|
class Registry
|
|
@@ -35,6 +36,31 @@ module Workspaces
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
def remove(id, force: false)
|
|
40
|
+
with_lock(id) do |workspace|
|
|
41
|
+
raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
|
|
42
|
+
|
|
43
|
+
Backend.new(id).stop!
|
|
44
|
+
workspace.remove!(force: force)
|
|
45
|
+
StateStore.new(Config.state_file).delete(id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update_pr(id)
|
|
50
|
+
with_lock(id) do |workspace|
|
|
51
|
+
result = workspace.update_from_pr!
|
|
52
|
+
message = result['updated'] ? 'PR checkout updated. Rebuild & restart to run the new code.' : 'PR checkout is already current.'
|
|
53
|
+
StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
|
|
54
|
+
'source_updated_at' => Time.now.utc.iso8601)
|
|
55
|
+
result
|
|
56
|
+
rescue Worktree::UpdateError, Worktree::CommandFailedError => e
|
|
57
|
+
message = e.is_a?(Worktree::CommandFailedError) ? 'Could not fetch or update the PR checkout; local files were not intentionally discarded' : e.message
|
|
58
|
+
StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
|
|
59
|
+
'source_updated_at' => Time.now.utc.iso8601)
|
|
60
|
+
raise Worktree::UpdateError, message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
38
64
|
private
|
|
39
65
|
|
|
40
66
|
def describe_directory(directory, number)
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Workspaces
|
|
|
33
33
|
|
|
34
34
|
def publish_steps(steps)
|
|
35
35
|
@state.set(@workspace.id, 'steps' => steps.map do |step|
|
|
36
|
-
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending' }
|
|
36
|
+
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
|
|
37
37
|
end)
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -48,6 +48,7 @@ module Workspaces
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def execute(step, recipe, context, runner, overrides)
|
|
51
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
52
|
progress(step, 'active')
|
|
52
53
|
@log.puts "==> #{step.fetch('name')}"
|
|
53
54
|
command = recipe.command(step, context)
|
|
@@ -59,16 +60,24 @@ module Workspaces
|
|
|
59
60
|
@backend.ensure_alive! if @server_started
|
|
60
61
|
end
|
|
61
62
|
@server_started = true if step['background']
|
|
62
|
-
progress(step, 'complete')
|
|
63
63
|
rescue StandardError
|
|
64
|
-
|
|
64
|
+
finish_step(step, 'failed', started_at)
|
|
65
65
|
raise
|
|
66
|
+
else
|
|
67
|
+
finish_step(step, 'complete', started_at)
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
def
|
|
70
|
+
def finish_step(step, status, started_at)
|
|
71
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
|
|
72
|
+
progress(step, status, duration_seconds: duration)
|
|
73
|
+
@log.puts format('==> %s %s (%.3fs)', step.fetch('name'), status, duration)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def progress(step, status, duration_seconds: nil)
|
|
69
77
|
@state.transaction do |data|
|
|
70
78
|
entry = data.fetch(@workspace.id)
|
|
71
|
-
entry['steps'].find { |item| item['key'] == step['id'] }
|
|
79
|
+
entry['steps'].find { |item| item['key'] == step['id'] }
|
|
80
|
+
.merge!('state' => status, 'duration_seconds' => duration_seconds)
|
|
72
81
|
entry['message'] = step['name']
|
|
73
82
|
entry['current_step'] = step['id']
|
|
74
83
|
entry['updated_at'] = Time.now.utc.iso8601
|
data/lib/workspaces/worktree.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Workspaces
|
|
|
9
9
|
class Worktree
|
|
10
10
|
class CommandFailedError < StandardError; end
|
|
11
11
|
class AuthenticationError < CommandFailedError; end
|
|
12
|
+
class UpdateError < StandardError; end
|
|
12
13
|
AUTH_FAILURE = Regexp.union(/could not read (?:Username|Password)|terminal prompts disabled|Authentication failed/i,
|
|
13
14
|
/Permission denied \(publickey\)|gh auth login|HTTP 401|HTTP 403/i)
|
|
14
15
|
METADATA = '.workspace.json'.freeze
|
|
@@ -90,6 +91,32 @@ module Workspaces
|
|
|
90
91
|
git!(*args, path.to_s)
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
def update_from_pr!
|
|
95
|
+
require_owned!
|
|
96
|
+
source = metadata.fetch('source')
|
|
97
|
+
raise UpdateError, 'Only a PR workspace can be updated from a PR' unless source['kind'] == 'pr'
|
|
98
|
+
raise UpdateError, 'Resolve the retained workspace update stash before updating again' if update_stash?
|
|
99
|
+
|
|
100
|
+
number = source.fetch('number')
|
|
101
|
+
raise UpdateError, 'Invalid PR number in workspace metadata' unless number.to_s.match?(/\A[1-9][0-9]*\z/)
|
|
102
|
+
|
|
103
|
+
target = fetch_pr_head(number)
|
|
104
|
+
current = git!('rev-parse', 'HEAD', cwd: path)
|
|
105
|
+
raise UpdateError, 'Workspace branch has diverged from the PR; no files were changed' unless ancestor?(current, target)
|
|
106
|
+
return { 'updated' => false, 'head' => current } if current == target
|
|
107
|
+
|
|
108
|
+
stash = save_changes
|
|
109
|
+
begin
|
|
110
|
+
git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path)
|
|
111
|
+
rescue CommandFailedError
|
|
112
|
+
restore_changes(stash) if stash
|
|
113
|
+
raise UpdateError, 'Could not fast-forward the workspace; local changes were restored'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
restore_changes(stash) if stash
|
|
117
|
+
{ 'updated' => true, 'head' => target }
|
|
118
|
+
end
|
|
119
|
+
|
|
93
120
|
def git!(*args, cwd: Config.repo_root)
|
|
94
121
|
output, status = Open3.capture2e(git_environment, 'git', *args, chdir: cwd.to_s)
|
|
95
122
|
raise_authentication_error!(output) unless status.success?
|
|
@@ -100,8 +127,61 @@ module Workspaces
|
|
|
100
127
|
|
|
101
128
|
private
|
|
102
129
|
|
|
130
|
+
def fetch_pr_head(number)
|
|
131
|
+
ref = "refs/workspaces/updates/#{id}"
|
|
132
|
+
git!('fetch', 'origin', "+pull/#{number}/head:#{ref}", cwd: path)
|
|
133
|
+
target = git!('rev-parse', '--verify', "#{ref}^{commit}", cwd: path)
|
|
134
|
+
git!('update-ref', '-d', ref, cwd: path)
|
|
135
|
+
target
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ancestor?(current, target)
|
|
139
|
+
_, status = Open3.capture2e(git_environment, 'git', 'merge-base', '--is-ancestor', current, target,
|
|
140
|
+
chdir: path.to_s)
|
|
141
|
+
return true if status.exitstatus == 0
|
|
142
|
+
return false if status.exitstatus == 1
|
|
143
|
+
|
|
144
|
+
raise CommandFailedError, 'Unable to check PR ancestry'
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def save_changes
|
|
148
|
+
return nil unless dirty?
|
|
149
|
+
|
|
150
|
+
previous = git!('rev-parse', '--verify', '-q', 'refs/stash', cwd: path) if stash_exists?
|
|
151
|
+
git!('stash', 'push', '--include-untracked', '-m', "workspaces update #{id}", cwd: path)
|
|
152
|
+
current = git!('rev-parse', '--verify', 'refs/stash', cwd: path)
|
|
153
|
+
raise UpdateError, 'Unable to save workspace changes; check the worktree and stash before retrying' if current == previous || dirty?
|
|
154
|
+
|
|
155
|
+
current
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def stash_exists?
|
|
159
|
+
_, status = Open3.capture2e(git_environment, 'git', 'rev-parse', '--verify', '-q', 'refs/stash',
|
|
160
|
+
chdir: path.to_s)
|
|
161
|
+
status.success?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def update_stash?
|
|
165
|
+
git!('stash', 'list', '--format=%gs', cwd: path).lines.any? do |line|
|
|
166
|
+
line.chomp.end_with?("workspaces update #{id}")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def restore_changes(stash)
|
|
171
|
+
return unless stash
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
git!('stash', 'apply', '--index', stash, cwd: path)
|
|
175
|
+
rescue CommandFailedError
|
|
176
|
+
raise UpdateError, "Workspace changes could not be reapplied. Stash #{stash} is retained for manual recovery; resolve worktree conflicts before retrying"
|
|
177
|
+
end
|
|
178
|
+
return unless git!('rev-parse', 'refs/stash', cwd: path) == stash
|
|
179
|
+
|
|
180
|
+
git!('stash', 'drop', 'stash@{0}', cwd: path)
|
|
181
|
+
end
|
|
182
|
+
|
|
103
183
|
def git_environment
|
|
104
|
-
{ 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ASKPASS' => '/bin/false', 'SSH_ASKPASS' => '/bin/false',
|
|
184
|
+
{ 'GIT_MASTER' => '1', 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ASKPASS' => '/bin/false', 'SSH_ASKPASS' => '/bin/false',
|
|
105
185
|
'GCM_INTERACTIVE' => 'Never', 'GIT_SSH_COMMAND' => noninteractive_ssh }
|
|
106
186
|
end
|
|
107
187
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: workspaces
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
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-
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|