workspaces 0.1.6 → 0.1.8
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 +7 -1
- data/README.md +18 -0
- data/lib/workspaces/cli.rb +31 -6
- data/lib/workspaces/coordinator.rb +15 -4
- data/lib/workspaces/dashboard/app.js +22 -0
- data/lib/workspaces/dashboard/environment.js +15 -2
- data/lib/workspaces/dashboard/template.html.erb +13 -13
- data/lib/workspaces/dashboard_view.rb +4 -0
- data/lib/workspaces/environment_overrides.rb +27 -0
- data/lib/workspaces/presentation.rb +49 -1
- data/lib/workspaces/registry.rb +6 -1
- data/lib/workspaces/version.rb +1 -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: a0102752fa27b9aa9b5fd40ab163411bc9d0411cdfaa00d8a82f6a04088e0099
|
|
4
|
+
data.tar.gz: 63704204815a6e183e1e244da898defcf67ff3e55b45e69400e19e9f275abaec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6e7e66264833d0b55493fc42203af62be3726ee6d8422d913e00f11afc2b9cf65aaef078e0ac06ecdedbced9c1cd1ff62d37759e1eb7a28295b01908d8bb976
|
|
7
|
+
data.tar.gz: 2bac52fea92eee837b049d2ce5b24946c0d4f3e306530dd6f4a8aef3e772495725dcdb1d7eec9c4b0b339f54d12948202d725ef609e6c1a2237c470c474276c8
|
data/DESIGN.md
CHANGED
|
@@ -48,10 +48,16 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
48
48
|
- **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
|
|
49
49
|
- **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
|
|
50
50
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
51
|
+
- **Lifecycle state machine**: the workspace detail page is driven by `Presentation::STATES`. Each state (`idle`, `busy`, `ready`, `stopped`, `error`/`interrupted`, `unknown`) declares which sections exist (progress, actions, environment, log, details, danger zone) and the label of the single environment-form button. Sections that are not usable or meaningful in a state are hidden, never shown disabled. The server renders the initial state and the polling script re-applies it from `presentation.sections`, so the page transitions without a reload.
|
|
52
|
+
- `idle` (brand new): only the environment editor plus a **Start workspace** button, with a one-line hint. No log, actions, details, or delete.
|
|
53
|
+
- `busy` (setup/restart running): only progress and the live log.
|
|
54
|
+
- `ready`: everything, with **Save & restart**.
|
|
55
|
+
- `stopped`: no progress from the previous run; **Save & start**.
|
|
56
|
+
- `error`/`interrupted`: retry action, environment editor, progress, and logs; **Save & retry**.
|
|
51
57
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
|
|
52
58
|
- **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
|
|
53
59
|
- **Open workspace**: success-styled link available when the server is ready
|
|
54
|
-
- **Lifecycle controls**:
|
|
60
|
+
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
|
55
61
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
56
62
|
- **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.
|
|
57
63
|
- **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
|
data/README.md
CHANGED
|
@@ -121,6 +121,24 @@ and defining them requires no JavaScript edits. Preset keys must be declared in
|
|
|
121
121
|
`default_editable_env`; recipes reject undeclared keys and secret-like values.
|
|
122
122
|
Hidden environment values are never exposed in the editor or preset choices.
|
|
123
123
|
|
|
124
|
+
Use `create --branch main --preset Demo` to save a preset for a new, still-idle
|
|
125
|
+
workspace. `start ID --preset Demo` applies it before preparing a stopped
|
|
126
|
+
workspace; `restart ID --preset 'Demo off'` switches a running workspace and
|
|
127
|
+
restarts its server. `prepare ID --preset Demo` switches and rebuilds. An
|
|
128
|
+
unknown name is rejected without changing saved settings. Selecting a preset
|
|
129
|
+
replaces the editable settings (including custom editor lines) but preserves
|
|
130
|
+
hidden legacy overrides. A running workspace requires `restart --preset` or
|
|
131
|
+
`prepare --preset`, not `start --preset`.
|
|
132
|
+
|
|
133
|
+
For one-off non-secret tweaks, add repeatable `--set NAME=VALUE` and
|
|
134
|
+
`--unset NAME` flags to `create`, `start`, `prepare`, or `restart`. For example,
|
|
135
|
+
`restart ID --preset Demo --set DEMO_FEATURE=on` applies the preset first,
|
|
136
|
+
then the individual changes, and restarts. Without `--preset`, these flags
|
|
137
|
+
patch current editable settings. Names reserved for the runner and names
|
|
138
|
+
suggesting secrets are rejected; values on the command line may be visible
|
|
139
|
+
in process listings and shell history. Use a secret store, not `--set`, for
|
|
140
|
+
credentials.
|
|
141
|
+
|
|
124
142
|
The front door is not an authentication boundary. Its host and same-origin checks
|
|
125
143
|
protect routing and POST actions, but a publicly reachable instance still needs
|
|
126
144
|
access control such as VPN or SSO. Do not expose workspace backend ports directly.
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -49,6 +49,28 @@ module Workspaces
|
|
|
49
49
|
options = {}
|
|
50
50
|
OptionParser.new do |parser|
|
|
51
51
|
parse_common_options(parser, options)
|
|
52
|
+
parser.on('--preset NAME') { |value| options[:preset] = value } if %w(create start prepare restart).include?(command)
|
|
53
|
+
if %w(create start prepare restart).include?(command)
|
|
54
|
+
parser.on('--set NAME=VALUE') do |value|
|
|
55
|
+
name, separator, setting = value.partition('=')
|
|
56
|
+
raise OptionParser::InvalidArgument, '--set requires NAME=VALUE' if separator.empty?
|
|
57
|
+
EnvironmentOverrides.validate_name!(name)
|
|
58
|
+
EnvironmentOverrides.validate_value!(setting)
|
|
59
|
+
if name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
|
|
60
|
+
raise OptionParser::InvalidArgument, 'Secret-like names cannot be set on the command line'
|
|
61
|
+
end
|
|
62
|
+
options[:set] ||= {}
|
|
63
|
+
raise OptionParser::InvalidArgument, 'duplicate --set name' if options[:set].key?(name)
|
|
64
|
+
options[:set][name] = setting
|
|
65
|
+
end
|
|
66
|
+
parser.on('--unset NAME') do |value|
|
|
67
|
+
EnvironmentOverrides.validate_name!(value)
|
|
68
|
+
if value.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
|
|
69
|
+
raise OptionParser::InvalidArgument, 'Secret-like names cannot be unset on the command line'
|
|
70
|
+
end
|
|
71
|
+
(options[:unset] ||= []) << value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
52
74
|
parse_tls_options(parser, options) if command == 'serve'
|
|
53
75
|
end.parse!(argv)
|
|
54
76
|
options
|
|
@@ -102,7 +124,8 @@ module Workspaces
|
|
|
102
124
|
end
|
|
103
125
|
|
|
104
126
|
def create(_id, options)
|
|
105
|
-
emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from
|
|
127
|
+
emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from, :preset),
|
|
128
|
+
set: options.fetch(:set, {}), unset: options.fetch(:unset, [])).describe, options)
|
|
106
129
|
end
|
|
107
130
|
|
|
108
131
|
def list(_id, options)
|
|
@@ -114,14 +137,16 @@ module Workspaces
|
|
|
114
137
|
end
|
|
115
138
|
|
|
116
139
|
def lifecycle(id, options)
|
|
117
|
-
action = proc { run_lifecycle(options.fetch(:command), id
|
|
140
|
+
action = proc { run_lifecycle(options.fetch(:command), id, preset: options[:preset],
|
|
141
|
+
set: options.fetch(:set, {}), unset: options.fetch(:unset, [])) }
|
|
118
142
|
result = options[:json] ? with_stdout_on_stderr(&action) : action.call
|
|
119
143
|
emit(result, options)
|
|
120
144
|
raise ArgumentError, result['last_error'] if result['status'] == 'error'
|
|
121
145
|
end
|
|
122
146
|
|
|
123
|
-
def run_lifecycle(command, id)
|
|
124
|
-
started = command == 'restart' ? coordinator.restart(id
|
|
147
|
+
def run_lifecycle(command, id, preset: nil, set: {}, unset: [])
|
|
148
|
+
started = command == 'restart' ? coordinator.restart(id, preset: preset, set: set, unset: unset) :
|
|
149
|
+
coordinator.start(id, force: command == 'prepare', preset: preset, set: set, unset: unset)
|
|
125
150
|
if !started && coordinator.snapshot(id)['active']
|
|
126
151
|
raise ArgumentError, 'Workspace is busy; inspect it with show --json'
|
|
127
152
|
end
|
|
@@ -171,8 +196,8 @@ module Workspaces
|
|
|
171
196
|
end
|
|
172
197
|
|
|
173
198
|
def usage
|
|
174
|
-
'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--json] | ' \
|
|
175
|
-
'list [--pr NUMBER] [--json] | show/start/prepare/restart
|
|
199
|
+
'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
200
|
+
'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
|
|
176
201
|
'exec ID -- COMMAND... | remove ID [--force]'
|
|
177
202
|
end
|
|
178
203
|
|
|
@@ -18,12 +18,12 @@ module Workspaces
|
|
|
18
18
|
@workers_mutex = Mutex.new
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def start(id, force: false)
|
|
22
|
-
launch(id, restart: false, force: force)
|
|
21
|
+
def start(id, force: false, preset: nil, set: {}, unset: [])
|
|
22
|
+
launch(id, restart: false, force: force, prelaunch: settings_update(id, preset, set, unset))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def restart(id)
|
|
26
|
-
launch(id, restart: true, force: true)
|
|
25
|
+
def restart(id, preset: nil, set: {}, unset: [])
|
|
26
|
+
launch(id, restart: true, force: true, prelaunch: settings_update(id, preset, set, unset))
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def update_environment(id, set:, remove:)
|
|
@@ -73,6 +73,14 @@ module Workspaces
|
|
|
73
73
|
|
|
74
74
|
private
|
|
75
75
|
|
|
76
|
+
def settings_update(id, preset, set, unset)
|
|
77
|
+
return if !preset && set.empty? && unset.empty?
|
|
78
|
+
|
|
79
|
+
lambda do |_workspace|
|
|
80
|
+
EnvironmentOverrides.new(id).apply_settings(preset: preset, set: set, unset: unset)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
76
84
|
def resolve_status(id, status, busy)
|
|
77
85
|
return busy ? status : 'interrupted' if ACTIVE.include?(status)
|
|
78
86
|
return status if status == 'error'
|
|
@@ -107,6 +115,9 @@ module Workspaces
|
|
|
107
115
|
lock = acquire_lock(id)
|
|
108
116
|
return false unless lock
|
|
109
117
|
|
|
118
|
+
if prelaunch && !restart && !force && Backend.new(id).running?
|
|
119
|
+
raise ArgumentError, 'Workspace is running; use restart --preset to change its environment'
|
|
120
|
+
end
|
|
110
121
|
return false if backend_already_running?(id, force, lock)
|
|
111
122
|
prelaunch&.call(workspace)
|
|
112
123
|
publish_starting(id, restart)
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
started = Date.parse(data.prepare_started_at);
|
|
95
95
|
completed = Date.parse(data.completed_at);
|
|
96
96
|
renderStatus(view);
|
|
97
|
+
renderSections(view);
|
|
97
98
|
node('message').textContent = data.message || '';
|
|
98
99
|
node('source-update-message').textContent = data.source_update_message || '';
|
|
99
100
|
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
@@ -103,6 +104,27 @@
|
|
|
103
104
|
updateLog(data);
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
function renderSections(view) {
|
|
108
|
+
const sections = view.sections || {};
|
|
109
|
+
root.querySelectorAll('[data-section]').forEach((element) => {
|
|
110
|
+
element.hidden = !sections[element.dataset.section];
|
|
111
|
+
});
|
|
112
|
+
const controls = node('controls');
|
|
113
|
+
if (controls) {
|
|
114
|
+
controls.hidden = !sections.actions && !sections.environment;
|
|
115
|
+
const title = node('actions-settings-title');
|
|
116
|
+
if (title && view.controls_title) title.textContent = view.controls_title;
|
|
117
|
+
}
|
|
118
|
+
const hint = node('hint');
|
|
119
|
+
if (hint) {
|
|
120
|
+
hint.textContent = view.hint || '';
|
|
121
|
+
hint.hidden = !view.hint;
|
|
122
|
+
}
|
|
123
|
+
node('message').hidden = view.state === 'idle';
|
|
124
|
+
if (window.WorkspaceEnvironment)
|
|
125
|
+
window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
function renderStatus(view) {
|
|
107
129
|
node('status').textContent = view.title;
|
|
108
130
|
node('activity').hidden = !view.busy;
|
|
@@ -45,6 +45,18 @@
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function submitLabel() {
|
|
49
|
+
return (submit && submit.textContent.trim()) || 'Save & restart';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setSubmitLabel(label) {
|
|
53
|
+
if (!label) return;
|
|
54
|
+
if (submit) submit.textContent = label;
|
|
55
|
+
form.querySelectorAll('[data-submit-label]').forEach((element) => {
|
|
56
|
+
element.textContent = label;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
function setStatus(message) {
|
|
49
61
|
if (!message) return;
|
|
50
62
|
if (lastAnnounced === message) return;
|
|
@@ -74,7 +86,7 @@
|
|
|
74
86
|
|
|
75
87
|
function announceUnsaved(message) {
|
|
76
88
|
if (activeBusy) return;
|
|
77
|
-
const nextMessage = message || 'Draft updated locally and not saved yet. Use
|
|
89
|
+
const nextMessage = message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
|
|
78
90
|
status.textContent = nextMessage;
|
|
79
91
|
lastAnnounced = nextMessage;
|
|
80
92
|
}
|
|
@@ -115,7 +127,7 @@
|
|
|
115
127
|
if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
|
|
116
128
|
replaceDraft(
|
|
117
129
|
presets[nextValue],
|
|
118
|
-
'Preset loaded into draft only. Use
|
|
130
|
+
'Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.'
|
|
119
131
|
);
|
|
120
132
|
});
|
|
121
133
|
});
|
|
@@ -124,6 +136,7 @@
|
|
|
124
136
|
toggleBusy,
|
|
125
137
|
updateStatus,
|
|
126
138
|
setStatus,
|
|
139
|
+
setSubmitLabel,
|
|
127
140
|
syncFromSnapshot(snapshot) {
|
|
128
141
|
if (!snapshot || presetButtons.length === 0 || !editor) return;
|
|
129
142
|
const rendered = snapshot.editable_environment;
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
<h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
|
|
9
9
|
<span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
|
|
10
10
|
</div>
|
|
11
|
-
<p id="
|
|
12
|
-
<
|
|
11
|
+
<p id="hint" <%= 'hidden' unless @presentation['hint'] %>><%= escape(@presentation['hint']) %></p>
|
|
12
|
+
<p id="message" <%= 'hidden' if @presentation['state'] == 'idle' %>><%= field('message') %></p>
|
|
13
|
+
<ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
|
|
13
14
|
<% @presentation['steps'].each do |step| %>
|
|
14
15
|
<li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
|
|
15
16
|
<% end %>
|
|
@@ -19,19 +20,18 @@
|
|
|
19
20
|
</section>
|
|
20
21
|
<p id="connection" role="status"></p>
|
|
21
22
|
</section>
|
|
22
|
-
<section class="status-panel controls-panel" aria-labelledby="actions-settings-title"
|
|
23
|
-
<h2 id="actions-settings-title"
|
|
24
|
-
<div class="workspace-actions-group" aria-labelledby="actions-heading"
|
|
23
|
+
<section class="status-panel controls-panel" id="controls" data-section-group aria-labelledby="actions-settings-title" <%= 'hidden' unless section?('actions') || section?('environment') %>>
|
|
24
|
+
<h2 id="actions-settings-title"><%= escape(@presentation['controls_title']) %></h2>
|
|
25
|
+
<div class="workspace-actions-group" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
|
|
25
26
|
<h3 id="actions-heading">Workspace actions</h3>
|
|
26
27
|
<div class="workspace-actions">
|
|
27
28
|
<a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
28
29
|
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
29
|
-
<a class="button button--subtle" href="/workspaces/<%= @id %><%= @snapshot['log_source'] == 'backend' ? '?log=backend' : '' %>">Refresh status</a>
|
|
30
30
|
</div>
|
|
31
31
|
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
32
32
|
</div>
|
|
33
|
-
<div class="workspace-settings" aria-labelledby="environment-overrides"
|
|
34
|
-
<h3 id="environment-overrides">
|
|
33
|
+
<div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
|
|
34
|
+
<h3 id="environment-overrides">Environment</h3>
|
|
35
35
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" 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>
|
|
@@ -43,20 +43,20 @@
|
|
|
43
43
|
<button type="button" class="button button--subtle env-preset-button" data-environment-preset="<%= escape(name) %>" aria-pressed="false"><%= escape(name) %></button>
|
|
44
44
|
<% end %>
|
|
45
45
|
</div>
|
|
46
|
-
<p class="muted" id="environment-preset-help">Select
|
|
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
49
|
<label class="env-input-label" for="editable-env">Environment variables</label>
|
|
50
50
|
<textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
|
|
51
51
|
</fieldset>
|
|
52
52
|
<div class="env-actions">
|
|
53
|
-
<button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy']
|
|
53
|
+
<button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
|
|
54
54
|
</div>
|
|
55
55
|
<p class="muted" id="environment-status" role="status" aria-live="polite"></p>
|
|
56
56
|
</form>
|
|
57
57
|
</div>
|
|
58
58
|
</section>
|
|
59
|
-
<section class="log-panel" aria-labelledby="log-title"
|
|
59
|
+
<section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
|
|
60
60
|
<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>
|
|
61
61
|
<nav class="log-switch" aria-label="Log source">
|
|
62
62
|
<a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
</nav>
|
|
65
65
|
<pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
|
|
66
66
|
</section>
|
|
67
|
-
<details class="status-panel"
|
|
68
|
-
<section class="status-panel danger-zone" aria-labelledby="danger-heading"
|
|
67
|
+
<details class="status-panel" data-section="details" <%= 'hidden' unless section?('details') %>><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
|
|
68
|
+
<section class="status-panel danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
|
|
69
69
|
<h2 id="danger-heading">Danger Zone</h2>
|
|
70
70
|
<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>
|
|
71
71
|
<form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
|
|
@@ -75,6 +75,33 @@ module Workspaces
|
|
|
75
75
|
load
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
def apply_preset(name, recipe: Recipe.new)
|
|
79
|
+
apply_settings(preset: name, recipe: recipe)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.settings_for(recipe:, preset: nil, set: {}, unset: [], current: nil)
|
|
83
|
+
defaults = recipe.default_editable_env
|
|
84
|
+
preset_values = recipe.environment_presets.fetch(preset) { raise Invalid, "Unknown environment preset: #{preset}" } if preset
|
|
85
|
+
values = preset ? defaults.merge(preset_values) : (current || defaults).dup
|
|
86
|
+
validate_entries!(set)
|
|
87
|
+
unset.each { |name| validate_name!(name) }
|
|
88
|
+
raise Invalid, 'Cannot set and unset the same name' unless (set.keys & unset).empty?
|
|
89
|
+
names = set.keys + unset
|
|
90
|
+
if names.any? { |name| name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i) }
|
|
91
|
+
raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
|
|
92
|
+
end
|
|
93
|
+
values.merge!(set)
|
|
94
|
+
unset.each { |name| values.delete(name) }
|
|
95
|
+
validate_entries!(values)
|
|
96
|
+
values
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apply_settings(preset: nil, set: {}, unset: [], recipe: Recipe.new)
|
|
100
|
+
current = preset ? nil : editable_values(recipe.default_editable_env)
|
|
101
|
+
values = self.class.settings_for(recipe: recipe, preset: preset, set: set, unset: unset, current: current)
|
|
102
|
+
replace_editable(values.sort.map { |key, value| "#{key}=#{value}" }.join("\n"), defaults: recipe.default_editable_env)
|
|
103
|
+
end
|
|
104
|
+
|
|
78
105
|
def apply_patch(set:, remove:)
|
|
79
106
|
validate_patch!(set, remove)
|
|
80
107
|
updated = load_file(file_path).merge(set)
|
|
@@ -6,13 +6,45 @@ module Workspaces
|
|
|
6
6
|
ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
|
|
7
7
|
'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
|
|
8
8
|
|
|
9
|
+
SECTIONS = %w(progress actions environment log details danger).freeze
|
|
10
|
+
MANAGEABLE = SECTIONS
|
|
11
|
+
|
|
12
|
+
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
13
|
+
# meaningful there; everything else is hidden rather than shown disabled.
|
|
14
|
+
#
|
|
15
|
+
# idle ──start──▶ busy ──▶ ready ◀──▶ busy
|
|
16
|
+
# │ │
|
|
17
|
+
# ▼ ▼
|
|
18
|
+
# error stopped ──start──▶ busy
|
|
19
|
+
# (interrupted)
|
|
20
|
+
#
|
|
21
|
+
# `environment_submit` labels the single environment-form button, which saves the
|
|
22
|
+
# settings and (re)starts the workspace, so a brand new workspace needs only that.
|
|
23
|
+
STATES = {
|
|
24
|
+
'idle' => {
|
|
25
|
+
sections: %w(environment),
|
|
26
|
+
environment_submit: 'Start workspace', controls_title: 'Set up your workspace',
|
|
27
|
+
hint: 'Choose the environment you want to preview below, then start the workspace. ' \
|
|
28
|
+
'Setup usually takes a few minutes.'
|
|
29
|
+
},
|
|
30
|
+
'busy' => { sections: %w(progress log), environment_submit: 'Save & restart' },
|
|
31
|
+
'ready' => { sections: MANAGEABLE, environment_submit: 'Save & restart' },
|
|
32
|
+
'stopped' => { sections: %w(actions environment log details danger), environment_submit: 'Save & start' },
|
|
33
|
+
'error' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
|
|
34
|
+
'interrupted' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
|
|
35
|
+
'unknown' => { sections: %w(log details), environment_submit: 'Save & restart' }
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
9
38
|
def initialize(snapshot)
|
|
10
39
|
@snapshot = snapshot
|
|
11
40
|
end
|
|
12
41
|
|
|
13
42
|
def to_h
|
|
14
43
|
{ 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
|
|
15
|
-
'actions' => actions, 'ready' => status == 'ready' && !busy
|
|
44
|
+
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
45
|
+
'state' => state, 'sections' => sections, 'hint' => definition[:hint],
|
|
46
|
+
'controls_title' => definition.fetch(:controls_title, 'Actions & settings'),
|
|
47
|
+
'environment_submit' => definition[:environment_submit] }
|
|
16
48
|
end
|
|
17
49
|
|
|
18
50
|
private
|
|
@@ -25,6 +57,22 @@ module Workspaces
|
|
|
25
57
|
@snapshot['active'] || status == 'preparing'
|
|
26
58
|
end
|
|
27
59
|
|
|
60
|
+
def state
|
|
61
|
+
return 'busy' if busy?
|
|
62
|
+
|
|
63
|
+
STATES.key?(status) ? status : 'unknown'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def definition
|
|
67
|
+
STATES.fetch(state)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sections
|
|
71
|
+
visible = definition[:sections].dup
|
|
72
|
+
visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
|
|
73
|
+
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
74
|
+
end
|
|
75
|
+
|
|
28
76
|
def title
|
|
29
77
|
return 'Workspace busy' if @snapshot['active'] && !busy_status?
|
|
30
78
|
return @snapshot['message'] || 'Running setup' if status == 'preparing'
|
data/lib/workspaces/registry.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require_relative 'worktree'
|
|
2
2
|
require_relative 'state_store'
|
|
3
3
|
require_relative 'backend'
|
|
4
|
+
require_relative 'recipe'
|
|
5
|
+
require_relative 'environment_overrides'
|
|
4
6
|
|
|
5
7
|
module Workspaces
|
|
6
8
|
class Registry
|
|
@@ -17,9 +19,12 @@ module Workspaces
|
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
def create(**options)
|
|
22
|
+
def create(preset: nil, set: {}, unset: [], **options)
|
|
23
|
+
recipe = Recipe.new if preset || !set.empty? || !unset.empty?
|
|
24
|
+
EnvironmentOverrides.settings_for(recipe: recipe, preset: preset, set: set, unset: unset) if recipe
|
|
21
25
|
workspace = Worktree.create(**options)
|
|
22
26
|
StateStore.new(Config.state_file).set(workspace.id, 'status' => 'idle', 'related_pr' => workspace.related_pr)
|
|
27
|
+
EnvironmentOverrides.new(workspace.id).apply_settings(preset: preset, set: set, unset: unset, recipe: recipe) if recipe
|
|
23
28
|
workspace
|
|
24
29
|
end
|
|
25
30
|
|
data/lib/workspaces/version.rb
CHANGED
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.8
|
|
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-
|
|
11
|
+
date: 2026-10-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|