workspaces 0.1.1 → 0.1.3
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 +19 -0
- data/lib/workspaces/cli.rb +1 -10
- data/lib/workspaces/coordinator.rb +14 -1
- data/lib/workspaces/dashboard/app.js +7 -2
- data/lib/workspaces/dashboard/styles.css +13 -0
- data/lib/workspaces/dashboard/template.html.erb +12 -1
- data/lib/workspaces/dashboard_view.rb +31 -2
- data/lib/workspaces/environment_overrides.rb +3 -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/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: 501f96e0f82b4dc9f30ec6682feb64329301b3ee888561e84e65941cff084ae7
|
|
4
|
+
data.tar.gz: 4ae2d5e24099d7638b349a332daaadf89813a96e37996c4f03c41c7baf2f7636
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67865a47386639bfcff1218218bcf8f055c0b563d295d355d9667e1d8410da7a4981def707bc0f219f4179215b4839a689ec36bfa8bba90738ea92d7771b8ad7
|
|
7
|
+
data.tar.gz: 4be62e33d0f29d0c19751b61bd3b9d0dab8cf334314ce62bc5bcfe5f59569235c960b10647cf5bb7060705068db6c1489052b59a97b543c662750f66df3e6f4d
|
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
|
@@ -67,6 +67,25 @@ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
|
|
|
67
67
|
`WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
|
|
68
68
|
`serve` does **not** start workspace backends; use `start` or `restart` for those.
|
|
69
69
|
|
|
70
|
+
The `/workspaces` dashboard lists each managed workspace's setup status, whether its
|
|
71
|
+
backend is running, creation time, and last backend startup. On a workspace's detail
|
|
72
|
+
page, the Danger Zone lets an operator delete that one workspace after typing its
|
|
73
|
+
exact ID. Deletion stops the backend and force-removes the worktree, including
|
|
74
|
+
uncommitted and untracked files; the branch and committed changes are preserved.
|
|
75
|
+
Busy workspaces cannot be removed until their current operation finishes. This
|
|
76
|
+
action is protected by the dashboard's same-origin POST check, but that check is
|
|
77
|
+
not authentication—keep hosted deployments behind VPN or SSO.
|
|
78
|
+
|
|
79
|
+
The dashboard favicon is blue for localhost and gold for hosted origins, helping
|
|
80
|
+
distinguish local and hosted tabs.
|
|
81
|
+
|
|
82
|
+
PR workspaces also offer **Update from PR** on their detail page. It fetches the
|
|
83
|
+
latest PR head and fast-forwards the workspace branch only when the new head is
|
|
84
|
+
a descendant of its current commit. Local tracked and untracked changes are
|
|
85
|
+
temporarily stashed and restored (including staged state). On a restore conflict,
|
|
86
|
+
the stash is retained for manual recovery. Updating the checkout does not run
|
|
87
|
+
setup or restart the already-running backend; use Rebuild & restart afterward.
|
|
88
|
+
|
|
70
89
|
The front door is not an authentication boundary. Its host and same-origin checks
|
|
71
90
|
protect routing and POST actions, but a publicly reachable instance still needs
|
|
72
91
|
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', []),
|
|
@@ -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) {
|
|
@@ -75,6 +78,7 @@
|
|
|
75
78
|
completed = Date.parse(data.completed_at);
|
|
76
79
|
renderStatus(view);
|
|
77
80
|
node('message').textContent = data.message || '';
|
|
81
|
+
node('source-update-message').textContent = data.source_update_message || '';
|
|
78
82
|
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
79
83
|
renderActions(view.actions);
|
|
80
84
|
updateLog(data);
|
|
@@ -122,7 +126,8 @@
|
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
root.addEventListener('submit', (event) => {
|
|
125
|
-
if (event.target.id !== 'environment-form'
|
|
129
|
+
if (event.target.id !== 'environment-form' && event.target.id !== 'remove-form')
|
|
130
|
+
toggleBusyState(true);
|
|
126
131
|
node('connection').textContent = 'Submitting…';
|
|
127
132
|
});
|
|
128
133
|
|
|
@@ -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));
|
|
@@ -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)
|
|
@@ -54,8 +54,9 @@ module Workspaces
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def editable_values(defaults)
|
|
57
|
-
load_file(file_path)
|
|
58
|
-
|
|
57
|
+
legacy = load_file(file_path).slice(*defaults.keys)
|
|
58
|
+
editable = load_file(editable_file_path).reject { |name, _| sensitive_name?(name) }
|
|
59
|
+
defaults.merge(legacy).merge(editable)
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def replace_editable(text, defaults:)
|
|
@@ -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/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.3
|
|
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
|