workspaces 0.1.16 → 0.1.17
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 +1 -0
- data/README.md +52 -0
- data/lib/workspaces/activity_tracker.rb +107 -0
- data/lib/workspaces/cli.rb +29 -7
- data/lib/workspaces/config.rb +29 -0
- data/lib/workspaces/coordinator.rb +145 -10
- data/lib/workspaces/dashboard/app.js +15 -1
- data/lib/workspaces/dashboard/styles.css +4 -0
- data/lib/workspaces/dashboard/template.html.erb +1 -0
- data/lib/workspaces/dashboard_view.rb +10 -0
- data/lib/workspaces/front_door.rb +111 -12
- data/lib/workspaces/presentation.rb +30 -3
- data/lib/workspaces/recipe.rb +30 -2
- data/lib/workspaces/sleep_policy.rb +39 -0
- data/lib/workspaces/sleeper.rb +48 -0
- data/lib/workspaces/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f662b3baa05bd9cd6a684906e2bf2d75d663dfa493fd684ef1090adb0095276
|
|
4
|
+
data.tar.gz: 2265a4ddeeae587c04b2eae5e8ed0e8a7f5f64988bb9c22c0f1f4c9202c30b9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3cea009527f192e61a8c14a2f9642e7f67d0d58118e2f13884a28c68d5b10c17ca12c98dfba24af32618ebae87e4f3f34645a10048ce110ec395424be1dda5c0
|
|
7
|
+
data.tar.gz: 496afa3b947a7c21a58f9e66f9f542b3146ce327fabad2b5633bd58f63d089a3e8ad965420e251e0589213acffc1219845625c9ba8377d42d6b59e9a5c608d8e
|
data/DESIGN.md
CHANGED
|
@@ -61,6 +61,7 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
61
61
|
- `unknown`: a minimal view with technical details open.
|
|
62
62
|
- **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
|
|
63
63
|
- **Per-step re-run controls**: inside each step's accordion, small subtle **Re-run this step** and **Re-run from here** forms aimed at `/workspaces/ID/resume?only=KEY` and `?from=KEY`. The server decides which appear (`presentation.resume.step_modes`), so the page never offers something that would be refused; they are hidden while a run is in progress and are disabled with the other controls while busy.
|
|
64
|
+
- **Sleeping**: a workspace asleep is its own state (`sleeping`), distinct from stopped and failed. The lead reads "Your workspace is asleep", says files and data are kept and that opening the preview wakes it, and offers one **Wake workspace** button. A ready workspace shows one muted line, "Sleeps after N min without preview traffic", or "Kept awake" when exempt. **Keep awake** / **Allow sleep** are subtle workspace controls. A waking workspace is simply busy.
|
|
64
65
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, retry failed step, and refresh
|
|
65
66
|
- **Open workspace**: success-styled link available when the server is ready
|
|
66
67
|
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
data/README.md
CHANGED
|
@@ -114,6 +114,58 @@ usually takes, count the running step by how much of its usual time has passed,
|
|
|
114
114
|
times its usual time is flagged as taking longer than usual. With no history yet, steps weigh
|
|
115
115
|
the same and no time estimate is shown. Delete the file to start the estimates over.
|
|
116
116
|
|
|
117
|
+
## Sleeping idle workspaces
|
|
118
|
+
|
|
119
|
+
A ready workspace that sees no preview traffic for a while is put to sleep: its server (the
|
|
120
|
+
managed process group, and nothing shared such as PostgreSQL or Redis) is stopped, and the
|
|
121
|
+
front door keeps answering on the same preview URL. The worktree, database, dependencies, built
|
|
122
|
+
assets, environment settings and build history are untouched. The first request to a sleeping
|
|
123
|
+
preview wakes it, waits for readiness and then forwards that request.
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
WORKSPACES_SLEEP_AFTER_MINUTES=30 # default; 15 also works; 0 switches sleeping off
|
|
127
|
+
WORKSPACES_WAKE_TIMEOUT_SECONDS=90 # how long the first request waits for a wake
|
|
128
|
+
WORKSPACES_SLEEP_SWEEP_SECONDS=30 # how often idle workspaces are looked for
|
|
129
|
+
bundle exec workspaces keep-awake <workspace-id> # never sleep this workspace
|
|
130
|
+
bundle exec workspaces keep-awake <workspace-id> --off # allow sleep again
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The dashboard shows when a workspace sleeps, says "asleep" with a **Wake workspace** button, and has
|
|
134
|
+
**Keep awake** / **Allow sleep** under Workspace controls. The setting is stored with the workspace.
|
|
135
|
+
|
|
136
|
+
What counts as activity:
|
|
137
|
+
|
|
138
|
+
- Preview requests do. Dashboard and status polling never reach the preview and do not count.
|
|
139
|
+
- `/health` and `/up` never count and never wake a workspace. List more background polling under
|
|
140
|
+
`sleep.ignore_paths` (globs on the request path) in `.workspaces.yml`:
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
sleep:
|
|
144
|
+
ignore_paths:
|
|
145
|
+
- /api/heartbeat
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- A request in flight holds the workspace awake until its response body is finished. Streamed
|
|
149
|
+
responses hold it until the body is closed. Streaming and upgraded connections are not newly
|
|
150
|
+
supported by the proxy; this only means the ones it already serves are never cut off.
|
|
151
|
+
- Work the proxy cannot see (background jobs, scheduled tasks) is not activity. A workspace that
|
|
152
|
+
runs such work needs `keep-awake`, or `WORKSPACES_SLEEP_AFTER_MINUTES=0`.
|
|
153
|
+
|
|
154
|
+
How waking behaves:
|
|
155
|
+
|
|
156
|
+
- Only an intentionally sleeping workspace wakes. A workspace stopped by hand, failed, or still
|
|
157
|
+
preparing is never started by traffic; a GET still lands on the dashboard as before.
|
|
158
|
+
- Simultaneous first requests share one wake and start one server. A request is sent upstream
|
|
159
|
+
once, never replayed, and keeps its method and body.
|
|
160
|
+
- A wake runs only the server and readiness steps. If a step before the server changed in
|
|
161
|
+
`.workspaces.yml` since the workspace was prepared, it runs the full preparation instead.
|
|
162
|
+
- A wake that fails or runs past the timeout answers `503` with `Retry-After`, a link to the
|
|
163
|
+
dashboard, and the failure in the workspace's status and logs. It is not retried in a loop.
|
|
164
|
+
- Sleeping and waking use the same per-workspace lock as start, stop, restart and rebuild, so
|
|
165
|
+
they cannot overlap. After a front-door restart a sleeping workspace stays asleep, a sleep cut
|
|
166
|
+
short is finished, and a wake cut short shows as interrupted.
|
|
167
|
+
- Idle clocks live in the front door's memory, so a restart gives every workspace a fresh idle period.
|
|
168
|
+
|
|
117
169
|
## Serve the dashboard and previews
|
|
118
170
|
|
|
119
171
|
Run `serve` in a separate terminal using the **same project root and `WORKSPACES_HOME`**
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Remembers when each workspace last saw preview traffic and how many requests are in flight.
|
|
5
|
+
# Only the front door that proxies the traffic can know this, so it lives in memory.
|
|
6
|
+
#
|
|
7
|
+
# A request holds a Lease from the moment it is accepted until its response is finished, and a
|
|
8
|
+
# workspace with any lease out is never put to sleep. The decision to sleep and new requests meet
|
|
9
|
+
# at one mutex: once a workspace is chosen for sleep, `acquire` refuses until the sleep is over,
|
|
10
|
+
# so a request can never slip in between "nothing in flight" and "server stopped".
|
|
11
|
+
class ActivityTracker
|
|
12
|
+
class Lease
|
|
13
|
+
def initialize(tracker, id, counted)
|
|
14
|
+
@tracker = tracker
|
|
15
|
+
@id = id
|
|
16
|
+
@counted = counted
|
|
17
|
+
@released = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def release
|
|
21
|
+
@tracker.send(:release, self, @id, @counted)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def released?
|
|
25
|
+
@released
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mark_released!
|
|
29
|
+
was = @released
|
|
30
|
+
@released = true
|
|
31
|
+
!was
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
36
|
+
@clock = clock
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@last_activity = {}
|
|
39
|
+
@in_flight = Hash.new(0)
|
|
40
|
+
@sleeping = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `counted: false` is for traffic that must not keep a workspace awake (configured background
|
|
44
|
+
# polling): it still holds a lease, so the server is not stopped under it.
|
|
45
|
+
# Returns nil while the workspace is being put to sleep.
|
|
46
|
+
def acquire(id, counted: true)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return nil if @sleeping[id]
|
|
49
|
+
|
|
50
|
+
@in_flight[id] += 1
|
|
51
|
+
@last_activity[id] = @clock.call if counted
|
|
52
|
+
Lease.new(self, id, counted)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def touch(id)
|
|
57
|
+
@mutex.synchronize { @last_activity[id] = @clock.call }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Seconds since the last counted activity. A workspace seen for the first time starts its idle
|
|
61
|
+
# clock now, which is also how the clocks restart after the front door does.
|
|
62
|
+
def idle_for(id)
|
|
63
|
+
@mutex.synchronize { @clock.call - (@last_activity[id] ||= @clock.call) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def in_flight(id)
|
|
67
|
+
@mutex.synchronize { @in_flight[id] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Runs the block only if the workspace has been idle for `threshold` seconds with nothing in
|
|
71
|
+
# flight, and keeps new requests out until it returns. Returns the block's value, or false.
|
|
72
|
+
def sleep_if_idle(id, threshold)
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
return false if @sleeping[id] || @in_flight[id].positive?
|
|
75
|
+
return false if @clock.call - (@last_activity[id] ||= @clock.call) < threshold
|
|
76
|
+
|
|
77
|
+
@sleeping[id] = true
|
|
78
|
+
end
|
|
79
|
+
begin
|
|
80
|
+
yield
|
|
81
|
+
ensure
|
|
82
|
+
@mutex.synchronize { @sleeping.delete(id) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def forget(id)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
@last_activity.delete(id)
|
|
89
|
+
@in_flight.delete(id)
|
|
90
|
+
@sleeping.delete(id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def release(lease, id, counted)
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
return false unless lease.mark_released!
|
|
99
|
+
|
|
100
|
+
@in_flight[id] -= 1
|
|
101
|
+
@in_flight.delete(id) if @in_flight[id] <= 0
|
|
102
|
+
@last_activity[id] = @clock.call if counted
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -8,13 +8,16 @@ require_relative 'front_door'
|
|
|
8
8
|
require_relative 'proxy_middleware'
|
|
9
9
|
require_relative 'tls'
|
|
10
10
|
require_relative 'child_env'
|
|
11
|
+
require_relative 'activity_tracker'
|
|
12
|
+
require_relative 'sleeper'
|
|
13
|
+
require_relative 'duration_format'
|
|
11
14
|
|
|
12
15
|
module Workspaces
|
|
13
16
|
class Cli
|
|
14
17
|
COMMANDS = {
|
|
15
18
|
'serve' => :serve, 'create' => :create, 'list' => :list, 'show' => :show,
|
|
16
19
|
'start' => :lifecycle, 'prepare' => :lifecycle, 'restart' => :lifecycle,
|
|
17
|
-
'stop' => :stop, 'remove' => :remove, 'resume' => :resume, 'help' => :help
|
|
20
|
+
'stop' => :stop, 'remove' => :remove, 'resume' => :resume, 'keep-awake' => :keep_awake, 'help' => :help
|
|
18
21
|
}.freeze
|
|
19
22
|
def self.run(argv)
|
|
20
23
|
new.run(argv.dup)
|
|
@@ -85,6 +88,7 @@ module Workspaces
|
|
|
85
88
|
parser.on('--from REF') { |value| options[:from] = value }
|
|
86
89
|
parser.on('--json') { options[:json] = true }
|
|
87
90
|
parser.on('--force') { options[:force] = true }
|
|
91
|
+
parser.on('--off') { options[:off] = true }
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def parse_tls_options(parser, options)
|
|
@@ -110,7 +114,20 @@ module Workspaces
|
|
|
110
114
|
puts "Dashboard: #{Config.public_origin}/workspaces"
|
|
111
115
|
puts "Managed directory: #{Config.worktrees_dir}"
|
|
112
116
|
print_tls_exports(tls)
|
|
113
|
-
|
|
117
|
+
tracker = ActivityTracker.new
|
|
118
|
+
sleeper = Sleeper.new(coordinator: coordinator, tracker: tracker).start
|
|
119
|
+
puts sleep_banner
|
|
120
|
+
begin
|
|
121
|
+
Rackup::Handler::Puma.run(FrontDoor.new(coordinator: coordinator, registry: registry, tracker: tracker),
|
|
122
|
+
**puma_options(tls))
|
|
123
|
+
ensure
|
|
124
|
+
sleeper.stop
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sleep_banner
|
|
129
|
+
seconds = Config.sleep_after_seconds
|
|
130
|
+
seconds ? "Idle workspaces sleep after #{DurationFormat.call(seconds)} without preview traffic" : 'Idle sleep is off'
|
|
114
131
|
end
|
|
115
132
|
|
|
116
133
|
def print_tls_exports(tls)
|
|
@@ -177,10 +194,15 @@ module Workspaces
|
|
|
177
194
|
end
|
|
178
195
|
|
|
179
196
|
def stop(id, options)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
coordinator.stop(id)
|
|
198
|
+
emit(coordinator.snapshot(id), options)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Exempts a workspace from sleeping (or, with --off, lets it sleep again).
|
|
202
|
+
def keep_awake(id, options)
|
|
203
|
+
raise ArgumentError, 'keep-awake needs a workspace ID' unless id
|
|
204
|
+
|
|
205
|
+
coordinator.keep_awake(id, !options[:off])
|
|
184
206
|
emit(coordinator.snapshot(id), options)
|
|
185
207
|
end
|
|
186
208
|
|
|
@@ -220,7 +242,7 @@ module Workspaces
|
|
|
220
242
|
def usage
|
|
221
243
|
'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
222
244
|
'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
223
|
-
'resume ID [--from STEP | --only STEP] [--json] | stop ID | ' \
|
|
245
|
+
'resume ID [--from STEP | --only STEP] [--json] | stop ID | keep-awake ID [--off] | ' \
|
|
224
246
|
'exec ID -- COMMAND... | remove ID [--force]'
|
|
225
247
|
end
|
|
226
248
|
|
data/lib/workspaces/config.rb
CHANGED
|
@@ -17,6 +17,35 @@ module Workspaces
|
|
|
17
17
|
DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
|
|
18
18
|
DEFAULT_FRONT_DOOR_PORT = 4747
|
|
19
19
|
BACKEND_PORT_RANGE = (30_000..30_999)
|
|
20
|
+
DEFAULT_SLEEP_AFTER_MINUTES = 30
|
|
21
|
+
DEFAULT_WAKE_TIMEOUT_SECONDS = 90
|
|
22
|
+
DEFAULT_SLEEP_SWEEP_SECONDS = 30
|
|
23
|
+
|
|
24
|
+
# Seconds a ready workspace may go without preview traffic before it is put to sleep, or nil when
|
|
25
|
+
# sleeping is switched off (WORKSPACES_SLEEP_AFTER_MINUTES=0). Fractions of a minute are allowed.
|
|
26
|
+
def self.sleep_after_seconds
|
|
27
|
+
minutes = positive_number('WORKSPACES_SLEEP_AFTER_MINUTES', DEFAULT_SLEEP_AFTER_MINUTES, allow_zero: true)
|
|
28
|
+
minutes.zero? ? nil : minutes * 60
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# How long the first request to a sleeping preview waits for the server to be ready.
|
|
32
|
+
def self.wake_timeout_seconds
|
|
33
|
+
positive_number('WORKSPACES_WAKE_TIMEOUT_SECONDS', DEFAULT_WAKE_TIMEOUT_SECONDS)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.sleep_sweep_seconds
|
|
37
|
+
positive_number('WORKSPACES_SLEEP_SWEEP_SECONDS', DEFAULT_SLEEP_SWEEP_SECONDS)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.positive_number(name, default, allow_zero: false)
|
|
41
|
+
value = Float(ENV.fetch(name, default.to_s))
|
|
42
|
+
return value if value.finite? && (value.positive? || (allow_zero && value.zero?))
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "#{name} must be a#{allow_zero ? ' non-negative' : ' positive'} number"
|
|
45
|
+
rescue ArgumentError, TypeError
|
|
46
|
+
raise ArgumentError, "#{name} must be a#{allow_zero ? ' non-negative' : ' positive'} number"
|
|
47
|
+
end
|
|
48
|
+
private_class_method :positive_number
|
|
20
49
|
|
|
21
50
|
def self.front_door_port
|
|
22
51
|
Integer(ENV.fetch('WORKSPACES_PORT', DEFAULT_FRONT_DOOR_PORT))
|
|
@@ -22,6 +22,12 @@ module Workspaces
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def start(id, force: false, preset: nil, set: {}, unset: [])
|
|
25
|
+
if !force && sleeping?(id)
|
|
26
|
+
return wake(id) unless preset || !set.empty? || !unset.empty?
|
|
27
|
+
|
|
28
|
+
return launch(id, restart: true, force: true, prelaunch: settings_update(id, preset, set, unset))
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
launch(id, restart: false, force: force, prelaunch: settings_update(id, preset, set, unset))
|
|
26
32
|
end
|
|
27
33
|
|
|
@@ -49,7 +55,7 @@ module Workspaces
|
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def update_environment(id, set:, remove:)
|
|
52
|
-
restart = Backend.new(id).running?
|
|
58
|
+
restart = Backend.new(id).running? || sleeping?(id)
|
|
53
59
|
launch(id, restart: restart, force: true,
|
|
54
60
|
prelaunch: lambda { |_workspace|
|
|
55
61
|
Recipe.new
|
|
@@ -58,7 +64,7 @@ module Workspaces
|
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
def replace_editable_environment(id, text:)
|
|
61
|
-
restart = Backend.new(id).running?
|
|
67
|
+
restart = Backend.new(id).running? || sleeping?(id)
|
|
62
68
|
launch(id, restart: restart, force: true,
|
|
63
69
|
prelaunch: lambda { |_workspace|
|
|
64
70
|
defaults = Recipe.new.default_editable_env
|
|
@@ -66,6 +72,105 @@ module Workspaces
|
|
|
66
72
|
})
|
|
67
73
|
end
|
|
68
74
|
|
|
75
|
+
# Stops an idle workspace's server and keeps everything else: worktree, database, dependencies,
|
|
76
|
+
# built assets, settings and build history. Returns true when it slept. Does nothing (false) while
|
|
77
|
+
# the workspace is busy, is kept awake, is not running, or is already asleep.
|
|
78
|
+
def sleep!(id)
|
|
79
|
+
@registry.find(id)
|
|
80
|
+
lock = acquire_lock(id)
|
|
81
|
+
return false unless lock
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
entry = @state.get(id) || {}
|
|
85
|
+
return false unless sleepable?(id, entry)
|
|
86
|
+
|
|
87
|
+
update(id, 'sleep_state' => 'sleeping', 'slept_at' => now)
|
|
88
|
+
begin
|
|
89
|
+
Backend.new(id).stop!
|
|
90
|
+
rescue StandardError
|
|
91
|
+
update(id, 'sleep_state' => nil, 'slept_at' => nil)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
true
|
|
95
|
+
ensure
|
|
96
|
+
lock.close
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Starts a sleeping workspace's server again and returns at once; the caller watches for readiness.
|
|
101
|
+
# Only a workspace that is intentionally asleep is woken: one that was stopped by hand, failed,
|
|
102
|
+
# or is already starting is left alone (false). Concurrent callers share one wake because only
|
|
103
|
+
# the one holding the workspace lock gets past the check.
|
|
104
|
+
def wake(id)
|
|
105
|
+
workspace = @registry.find(id)
|
|
106
|
+
lock = acquire_lock(id)
|
|
107
|
+
return false unless lock
|
|
108
|
+
|
|
109
|
+
entry = @state.get(id) || {}
|
|
110
|
+
if entry['sleep_state'] != 'sleeping' || Backend.new(id).running?
|
|
111
|
+
lock.close
|
|
112
|
+
return false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
restart = preparation_current?(entry)
|
|
116
|
+
publish_starting(id, restart, extra: { 'sleep_state' => 'waking', 'message' => 'Waking workspace' })
|
|
117
|
+
spawn_worker(workspace, restart, lock)
|
|
118
|
+
true
|
|
119
|
+
rescue StandardError
|
|
120
|
+
lock&.close unless lock&.closed?
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Stops the server by hand. A workspace stopped this way is not asleep, so traffic never wakes it.
|
|
125
|
+
def stop(id)
|
|
126
|
+
@registry.with_lock(id) do
|
|
127
|
+
Backend.new(id).stop!
|
|
128
|
+
@state.set(id, 'status' => 'stopped', 'message' => 'Server stopped', 'sleep_state' => nil, 'slept_at' => nil)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def keep_awake(id, enabled)
|
|
133
|
+
@registry.find(id)
|
|
134
|
+
@state.set(id, 'keep_awake' => !!enabled)
|
|
135
|
+
!!enabled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sleeping?(id)
|
|
139
|
+
(@state.get(id) || {})['sleep_state'] == 'sleeping'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# :sleeping, :waking (a wake is running), or nil.
|
|
143
|
+
def sleep_phase(id)
|
|
144
|
+
case (@state.get(id) || {})['sleep_state']
|
|
145
|
+
when 'sleeping' then :sleeping
|
|
146
|
+
when 'waking' then locked?(id) ? :waking : nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def status_of(id)
|
|
151
|
+
entry = @state.get(id) || {}
|
|
152
|
+
resolve_status(id, entry, locked?(id))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Workspaces that look ready, are not kept awake and are not asleep. A cheap read of the state
|
|
156
|
+
# file; `sleep!` checks everything again under the workspace lock.
|
|
157
|
+
def sleep_candidates
|
|
158
|
+
@state.all.filter_map do |id, entry|
|
|
159
|
+
id if entry['status'] == 'ready' && !entry['keep_awake'] && entry['sleep_state'].nil?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Run once when a front door starts. A sleep that was cut short is finished, and a wake that was
|
|
164
|
+
# cut short is cleared, so the workspace reads as interrupted rather than as waking forever.
|
|
165
|
+
def reconcile_sleep
|
|
166
|
+
@state.all.each do |id, entry|
|
|
167
|
+
case entry['sleep_state']
|
|
168
|
+
when 'sleeping' then finish_interrupted_sleep(id)
|
|
169
|
+
when 'waking' then @state.set(id, 'sleep_state' => nil) unless locked?(id)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
69
174
|
def wait(id)
|
|
70
175
|
workers_mutex.synchronize { workers[id] }&.value
|
|
71
176
|
snapshot(id)
|
|
@@ -77,7 +182,7 @@ module Workspaces
|
|
|
77
182
|
workspace = @registry.find(id)
|
|
78
183
|
entry = @state.get(id) || {}
|
|
79
184
|
busy = locked?(id)
|
|
80
|
-
status = resolve_status(id, entry
|
|
185
|
+
status = resolve_status(id, entry, busy)
|
|
81
186
|
data = workspace.describe.merge(snapshot_fields(id, entry, busy, status, log, include_log))
|
|
82
187
|
data.merge('presentation' => Presentation.new(data).to_h)
|
|
83
188
|
end
|
|
@@ -87,7 +192,7 @@ module Workspaces
|
|
|
87
192
|
id = workspace.fetch('id')
|
|
88
193
|
entry = @state.get(id) || {}
|
|
89
194
|
busy = locked?(id)
|
|
90
|
-
workspace.merge('status' => resolve_status(id, entry
|
|
195
|
+
workspace.merge('status' => resolve_status(id, entry, busy),
|
|
91
196
|
'running' => !!Backend.new(id).running?, 'active' => busy,
|
|
92
197
|
'started_at' => entry['started_at'])
|
|
93
198
|
end
|
|
@@ -103,17 +208,43 @@ module Workspaces
|
|
|
103
208
|
end
|
|
104
209
|
end
|
|
105
210
|
|
|
106
|
-
def resolve_status(id,
|
|
211
|
+
def resolve_status(id, entry, busy)
|
|
212
|
+
status = entry.fetch('status', 'idle')
|
|
107
213
|
return busy ? status : 'interrupted' if ACTIVE.include?(status)
|
|
108
214
|
return status if status == 'error'
|
|
109
215
|
return 'ready' if Backend.new(id).running?
|
|
216
|
+
return 'sleeping' if entry['sleep_state'] == 'sleeping'
|
|
110
217
|
|
|
111
218
|
status == 'ready' ? 'stopped' : status
|
|
112
219
|
end
|
|
113
220
|
|
|
221
|
+
def sleepable?(id, entry)
|
|
222
|
+
entry['status'] == 'ready' && !entry['keep_awake'] && entry['sleep_state'].nil? && Backend.new(id).running?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# What was prepared still matches the recipe, so waking only needs the server and readiness steps.
|
|
226
|
+
# A workspace prepared before this was recorded is trusted.
|
|
227
|
+
def preparation_current?(entry)
|
|
228
|
+
recorded = entry['prepared_digest']
|
|
229
|
+
recorded.nil? || recorded == Recipe.new.preparation_digest
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def finish_interrupted_sleep(id)
|
|
233
|
+
lock = acquire_lock(id)
|
|
234
|
+
return unless lock
|
|
235
|
+
|
|
236
|
+
begin
|
|
237
|
+
Backend.new(id).stop! if Backend.new(id).running?
|
|
238
|
+
ensure
|
|
239
|
+
lock.close
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
114
243
|
def snapshot_fields(id, entry, busy, status, log, include_log)
|
|
115
244
|
{
|
|
116
245
|
'workspace_id' => id, 'status' => status, 'active' => busy,
|
|
246
|
+
'sleeping' => status == 'sleeping', 'slept_at' => entry['slept_at'],
|
|
247
|
+
'keep_awake' => !!entry['keep_awake'], 'sleep_after_seconds' => Config.sleep_after_seconds,
|
|
117
248
|
'message' => status == entry['status'] ? entry['message'] : status.capitalize,
|
|
118
249
|
'prepare_started_at' => entry['prepare_started_at'], 'started_at' => entry['started_at'],
|
|
119
250
|
'running' => !!Backend.new(id).running?, 'updated_at' => entry['updated_at'],
|
|
@@ -191,18 +322,20 @@ module Workspaces
|
|
|
191
322
|
end
|
|
192
323
|
end
|
|
193
324
|
|
|
194
|
-
def publish_starting(id, restart)
|
|
325
|
+
def publish_starting(id, restart, extra: {})
|
|
195
326
|
recipe = Recipe.new
|
|
196
327
|
steps = recipe.for_operation(restart: restart).map do |step|
|
|
197
328
|
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
|
|
198
329
|
end
|
|
199
330
|
# Frozen for the whole run, so the bar does not shift under the reader and the history file
|
|
200
331
|
# is read once per run rather than once per poll.
|
|
201
|
-
update(id,
|
|
332
|
+
update(id, {
|
|
333
|
+
'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
202
334
|
'steps' => steps, 'estimates' => BuildHistory.estimates(steps.map { |step| step['key'] }),
|
|
203
335
|
'operation' => restart ? 'restart' : 'prepare', 'rerun_keys' => nil, 'failed_phase' => nil,
|
|
204
336
|
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
|
|
205
|
-
'total_seconds' => nil
|
|
337
|
+
'total_seconds' => nil, 'sleep_state' => nil, 'slept_at' => nil
|
|
338
|
+
}.merge(extra))
|
|
206
339
|
end
|
|
207
340
|
|
|
208
341
|
def spawn_worker(workspace, restart, lock, plan: nil)
|
|
@@ -241,8 +374,10 @@ module Workspaces
|
|
|
241
374
|
total = elapsed_since(started)
|
|
242
375
|
# A re-run that left no server running (a step before it on a stopped workspace) keeps the
|
|
243
376
|
# recorded port as it was; the workspace then reads as stopped until it is started.
|
|
244
|
-
attributes = { 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'total_seconds' => total
|
|
377
|
+
attributes = { 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'total_seconds' => total,
|
|
378
|
+
'sleep_state' => nil }
|
|
245
379
|
attributes['port'] = port if port
|
|
380
|
+
attributes['prepared_digest'] = Recipe.new.preparation_digest if plan.nil? && !restart
|
|
246
381
|
update(id, attributes)
|
|
247
382
|
record_run(id, 'complete', total)
|
|
248
383
|
end
|
|
@@ -254,7 +389,7 @@ module Workspaces
|
|
|
254
389
|
total = started && elapsed_since(started)
|
|
255
390
|
update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
|
|
256
391
|
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
|
|
257
|
-
'total_seconds' => total)
|
|
392
|
+
'total_seconds' => total, 'sleep_state' => nil)
|
|
258
393
|
record_run(id, 'failed', total)
|
|
259
394
|
end
|
|
260
395
|
|
|
@@ -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', 'resume', 'stop', 'update-pr'];
|
|
10
|
+
const allowedActions = ['start', 'prepare', 'restart', 'resume', 'stop', 'update-pr', 'keep-awake', 'allow-sleep'];
|
|
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);
|
|
@@ -245,9 +245,23 @@
|
|
|
245
245
|
window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
function sleepNote(view) {
|
|
249
|
+
const sleep = view.sleep;
|
|
250
|
+
if (!sleep) return '';
|
|
251
|
+
if (sleep.keep_awake) return 'Kept awake: this workspace will not sleep when idle.';
|
|
252
|
+
if (view.state !== 'ready') return '';
|
|
253
|
+
const minutes = Math.round(sleep.after_seconds / 60);
|
|
254
|
+
return 'Sleeps after ' + (minutes >= 1 ? minutes + ' min' : Math.round(sleep.after_seconds) + ' s') + ' without preview traffic.';
|
|
255
|
+
}
|
|
256
|
+
|
|
248
257
|
function renderLead(view) {
|
|
249
258
|
node('status').textContent = view.headline || view.title;
|
|
250
259
|
node('summary').textContent = view.summary || '';
|
|
260
|
+
const note = node('sleep-note');
|
|
261
|
+
if (note) {
|
|
262
|
+
note.textContent = sleepNote(view);
|
|
263
|
+
note.hidden = !note.textContent;
|
|
264
|
+
}
|
|
251
265
|
renderProgress(view.progress);
|
|
252
266
|
const title = node('environment-title');
|
|
253
267
|
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
@@ -266,6 +266,10 @@ h1 {
|
|
|
266
266
|
background: var(--success);
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
.status-pill[data-status='sleeping'] .status-dot {
|
|
270
|
+
background: var(--muted);
|
|
271
|
+
}
|
|
272
|
+
|
|
269
273
|
.status-pill[data-status='error'] .status-dot,
|
|
270
274
|
.status-pill[data-status='interrupted'] .status-dot,
|
|
271
275
|
.status-pill[data-status='stopped'] .status-dot {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
20
20
|
<div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
|
|
21
21
|
</div>
|
|
22
|
+
<p id="sleep-note" class="muted" <%= 'hidden' if sleep_note.empty? %>><%= escape(sleep_note) %></p>
|
|
22
23
|
<p id="message" class="muted" hidden><%= field('message') %></p>
|
|
23
24
|
<section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
|
|
24
25
|
<h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
|
|
@@ -215,6 +215,16 @@ module Workspaces
|
|
|
215
215
|
[progress_step_text, progress['eta_text']].compact.join('. ')
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
+
def sleep_note
|
|
219
|
+
sleep = @presentation['sleep']
|
|
220
|
+
return '' unless sleep
|
|
221
|
+
return 'Kept awake: this workspace will not sleep when idle.' if sleep['keep_awake']
|
|
222
|
+
return '' unless @presentation['state'] == 'ready'
|
|
223
|
+
|
|
224
|
+
minutes = (sleep['after_seconds'] / 60.0).round
|
|
225
|
+
"Sleeps after #{minutes >= 1 ? "#{minutes} min" : "#{sleep['after_seconds'].round} s"} without preview traffic."
|
|
226
|
+
end
|
|
227
|
+
|
|
218
228
|
def preset_badge_text
|
|
219
229
|
name = @presentation['current_preset']
|
|
220
230
|
name ? "Preset: #{name}" : ''
|
|
@@ -6,20 +6,31 @@ require_relative 'coordinator'
|
|
|
6
6
|
require_relative 'dashboard_view'
|
|
7
7
|
require_relative 'environment_overrides'
|
|
8
8
|
require_relative 'proxy_middleware'
|
|
9
|
+
require_relative 'activity_tracker'
|
|
10
|
+
require_relative 'sleep_policy'
|
|
9
11
|
|
|
10
12
|
module Workspaces
|
|
11
13
|
class FrontDoor
|
|
12
14
|
WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
|
|
13
|
-
(?:/(status|start|prepare|restart|resume|stop|remove|update-pr|environment))?\z}x
|
|
15
|
+
(?:/(status|start|prepare|restart|resume|stop|remove|update-pr|environment|keep-awake|allow-sleep))?\z}x
|
|
14
16
|
|
|
15
17
|
# Rack env keys set on requests that reach a ready workspace backend, for use by
|
|
16
18
|
# middleware registered with Workspaces.configure_proxy.
|
|
17
19
|
WORKSPACE_ID_ENV = 'workspaces.workspace_id'.freeze
|
|
18
20
|
WORKSPACE_ENV = 'workspaces.workspace'.freeze
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
WAKE_POLL_SECONDS = 0.25
|
|
23
|
+
RETRY_AFTER_SECONDS = 5
|
|
24
|
+
private_constant :WAKE_POLL_SECONDS, :RETRY_AFTER_SECONDS
|
|
25
|
+
|
|
26
|
+
attr_reader :tracker
|
|
27
|
+
|
|
28
|
+
def initialize(coordinator: Coordinator.new, registry: Registry.new, tracker: ActivityTracker.new,
|
|
29
|
+
sleep_policy: SleepPolicy.new)
|
|
21
30
|
@coordinator = coordinator
|
|
22
31
|
@registry = registry
|
|
32
|
+
@tracker = tracker
|
|
33
|
+
@sleep_policy = sleep_policy
|
|
23
34
|
@forwarder = ProxyMiddleware.build(method(:forward_to_backend))
|
|
24
35
|
end
|
|
25
36
|
|
|
@@ -126,7 +137,9 @@ module Workspaces
|
|
|
126
137
|
when 'prepare' then @coordinator.start(id, force: true)
|
|
127
138
|
when 'restart' then @coordinator.restart(id)
|
|
128
139
|
when 'resume' then resume(id, request)
|
|
129
|
-
when 'stop' then stop(id)
|
|
140
|
+
when 'stop' then @coordinator.stop(id)
|
|
141
|
+
when 'keep-awake' then @coordinator.keep_awake(id, true)
|
|
142
|
+
when 'allow-sleep' then @coordinator.keep_awake(id, false)
|
|
130
143
|
end
|
|
131
144
|
end
|
|
132
145
|
|
|
@@ -147,13 +160,6 @@ module Workspaces
|
|
|
147
160
|
response(422, e.message)
|
|
148
161
|
end
|
|
149
162
|
|
|
150
|
-
def stop(id)
|
|
151
|
-
@registry.with_lock(id) do
|
|
152
|
-
Backend.new(id).stop!
|
|
153
|
-
StateStore.new(Config.state_file).set(id, 'status' => 'stopped', 'message' => 'Server stopped')
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
163
|
def remove(request, id)
|
|
158
164
|
unless request.media_type == 'application/x-www-form-urlencoded'
|
|
159
165
|
return response(415, 'Deletion requires a form-encoded confirmation')
|
|
@@ -162,6 +168,7 @@ module Workspaces
|
|
|
162
168
|
return response(422, 'Type the exact workspace ID to confirm deletion') unless request.POST['confirm_id'] == id
|
|
163
169
|
|
|
164
170
|
@registry.remove(id, force: true)
|
|
171
|
+
@tracker.forget(id)
|
|
165
172
|
redirect("#{Config.public_origin}/workspaces")
|
|
166
173
|
rescue ArgumentError, Worktree::CommandFailedError, SystemCallError
|
|
167
174
|
response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
|
|
@@ -246,14 +253,106 @@ module Workspaces
|
|
|
246
253
|
def proxy(id, request)
|
|
247
254
|
return response(404, 'Unknown managed workspace') unless Worktree.new(id).exists?
|
|
248
255
|
|
|
256
|
+
counted = !@sleep_policy.ignored?(request.path_info)
|
|
257
|
+
lease = acquire_lease(id, counted)
|
|
258
|
+
return waking_unavailable(id) unless lease
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
result = serve(id, request, counted)
|
|
262
|
+
rescue StandardError, Timeout::Error
|
|
263
|
+
lease.release
|
|
264
|
+
raise
|
|
265
|
+
end
|
|
266
|
+
hold_lease(result, lease)
|
|
267
|
+
rescue IOError, SystemCallError, Timeout::Error => e
|
|
268
|
+
response(502, "Workspace backend unavailable: #{e.class}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def serve(id, request, counted)
|
|
272
|
+
phase = @coordinator.sleep_phase(id)
|
|
273
|
+
if phase
|
|
274
|
+
return sleeping_unavailable(id) unless counted
|
|
275
|
+
|
|
276
|
+
outcome = await_wake(id, phase)
|
|
277
|
+
return wake_failed(id) if outcome == :failed
|
|
278
|
+
return waking_unavailable(id) unless outcome == :ready
|
|
279
|
+
end
|
|
280
|
+
|
|
249
281
|
snapshot = @coordinator.snapshot(id, include_log: false)
|
|
250
282
|
return unavailable(id, request) unless snapshot['status'] == 'ready' && !snapshot['active']
|
|
251
283
|
|
|
252
284
|
request.env[WORKSPACE_ID_ENV] = id
|
|
253
285
|
request.env[WORKSPACE_ENV] = snapshot
|
|
254
286
|
@forwarder.call(request.env)
|
|
255
|
-
|
|
256
|
-
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A lease keeps the workspace awake for as long as its request is being served. A response body
|
|
290
|
+
# that is still being produced (anything but a buffered Array) keeps it until the server closes it.
|
|
291
|
+
def hold_lease(result, lease)
|
|
292
|
+
status, headers, body = result
|
|
293
|
+
if body.is_a?(Array)
|
|
294
|
+
lease.release
|
|
295
|
+
return result
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
[status, headers, Rack::BodyProxy.new(body) { lease.release }]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Requests are refused only while the workspace is being put to sleep, which takes a moment;
|
|
302
|
+
# they then wait their turn and wake it.
|
|
303
|
+
def acquire_lease(id, counted)
|
|
304
|
+
deadline = monotonic + Config.wake_timeout_seconds
|
|
305
|
+
loop do
|
|
306
|
+
lease = @tracker.acquire(id, counted: counted)
|
|
307
|
+
return lease if lease
|
|
308
|
+
return nil if monotonic >= deadline
|
|
309
|
+
|
|
310
|
+
sleep WAKE_POLL_SECONDS / 5
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Starts the wake (one request wins the workspace lock; the rest see it already running) and
|
|
315
|
+
# waits, within the wake timeout, for the server to be ready. :ready, :failed or :timeout.
|
|
316
|
+
def await_wake(id, phase)
|
|
317
|
+
@coordinator.wake(id) if phase == :sleeping
|
|
318
|
+
deadline = monotonic + Config.wake_timeout_seconds
|
|
319
|
+
loop do
|
|
320
|
+
outcome = wake_outcome(id)
|
|
321
|
+
return outcome if outcome
|
|
322
|
+
return :timeout if monotonic >= deadline
|
|
323
|
+
|
|
324
|
+
sleep WAKE_POLL_SECONDS
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def wake_outcome(id)
|
|
329
|
+
return nil if @coordinator.sleep_phase(id)
|
|
330
|
+
|
|
331
|
+
case @coordinator.status_of(id)
|
|
332
|
+
when 'ready' then :ready
|
|
333
|
+
when 'preparing' then nil
|
|
334
|
+
else :failed
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def monotonic
|
|
339
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def waking_unavailable(id)
|
|
343
|
+
retryable(503, "Workspace is waking up and not ready yet. Try again in a few seconds. Status: #{Config.dashboard_url(id)}")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def sleeping_unavailable(id)
|
|
347
|
+
retryable(503, "Workspace is asleep. Open it to wake it. Status: #{Config.dashboard_url(id)}")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def wake_failed(id)
|
|
351
|
+
retryable(503, "Workspace could not wake up. Details: #{Config.dashboard_url(id)}")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def retryable(status, message)
|
|
355
|
+
[status, headers('text/plain').merge('retry-after' => RETRY_AFTER_SECONDS.to_s), [message]]
|
|
257
356
|
end
|
|
258
357
|
|
|
259
358
|
def unavailable(id, request)
|
|
@@ -3,11 +3,13 @@ require_relative 'progress'
|
|
|
3
3
|
module Workspaces
|
|
4
4
|
class Presentation
|
|
5
5
|
TITLES = { 'idle' => 'Ready to start', 'stopped' => 'Server stopped', 'ready' => 'Workspace ready',
|
|
6
|
+
'sleeping' => 'Workspace asleep',
|
|
6
7
|
'preparing' => 'Running setup', 'error' => 'Setup failed',
|
|
7
8
|
'interrupted' => 'Setup interrupted' }.freeze
|
|
8
9
|
ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
|
|
9
10
|
'restart' => 'Restart server', 'stop' => 'Stop server',
|
|
10
|
-
'resume' => 'Retry failed step'
|
|
11
|
+
'resume' => 'Retry failed step', 'keep-awake' => 'Keep awake',
|
|
12
|
+
'allow-sleep' => 'Allow sleep' }.freeze
|
|
11
13
|
|
|
12
14
|
# Three layers, from friendly to technical. `lead` is the headline, one sentence and at
|
|
13
15
|
# most one primary action. `environment` (presets and variables) is the area for
|
|
@@ -55,6 +57,14 @@ module Workspaces
|
|
|
55
57
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
56
58
|
open_environment: false, open_technical: false
|
|
57
59
|
},
|
|
60
|
+
'sleeping' => {
|
|
61
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
62
|
+
headline: 'Your workspace is asleep',
|
|
63
|
+
summary: 'It went quiet, so its server was stopped to free up resources. Your files and data are kept. ' \
|
|
64
|
+
'Opening the preview wakes it in a few seconds.',
|
|
65
|
+
environment_submit: 'Save & start', environment_title: 'Change settings',
|
|
66
|
+
open_environment: false, open_technical: false
|
|
67
|
+
},
|
|
58
68
|
'stopped' => {
|
|
59
69
|
sections: %w(lead environment) + TECHNICAL,
|
|
60
70
|
headline: 'Your workspace is paused',
|
|
@@ -95,7 +105,7 @@ module Workspaces
|
|
|
95
105
|
'state' => state, 'sections' => sections,
|
|
96
106
|
'headline' => definition[:headline], 'summary' => summary, 'resume' => resume,
|
|
97
107
|
'lead_action' => lead_action, 'total' => total, 'progress' => progress,
|
|
98
|
-
'current_preset' => current_preset,
|
|
108
|
+
'current_preset' => current_preset, 'sleep' => sleep_info,
|
|
99
109
|
'environment_title' => definition[:environment_title],
|
|
100
110
|
'environment_submit' => definition[:environment_submit],
|
|
101
111
|
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
@@ -107,6 +117,14 @@ module Workspaces
|
|
|
107
117
|
@snapshot.fetch('status', 'idle')
|
|
108
118
|
end
|
|
109
119
|
|
|
120
|
+
# What the page needs to explain sleeping: whether this workspace is kept awake, and after how
|
|
121
|
+
# long an idle one sleeps. Nil for states where sleeping is not in play.
|
|
122
|
+
def sleep_info
|
|
123
|
+
return nil unless %w(ready sleeping).include?(status) && !@snapshot['sleep_after_seconds'].nil?
|
|
124
|
+
|
|
125
|
+
{ 'keep_awake' => !!@snapshot['keep_awake'], 'after_seconds' => @snapshot['sleep_after_seconds'] }
|
|
126
|
+
end
|
|
127
|
+
|
|
110
128
|
def busy?
|
|
111
129
|
@snapshot['active'] || status == 'preparing'
|
|
112
130
|
end
|
|
@@ -133,6 +151,7 @@ module Workspaces
|
|
|
133
151
|
case state
|
|
134
152
|
when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
|
|
135
153
|
when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
|
|
154
|
+
when 'sleeping' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Wake workspace' }
|
|
136
155
|
when 'error', 'interrupted' then (retryable? ? resume_action : retry_action).merge('kind' => 'post')
|
|
137
156
|
end
|
|
138
157
|
end
|
|
@@ -228,7 +247,9 @@ module Workspaces
|
|
|
228
247
|
def actions
|
|
229
248
|
return [] if busy?
|
|
230
249
|
available = if status == 'ready'
|
|
231
|
-
%w(prepare restart stop).map { |key| action(key, false) }
|
|
250
|
+
%w(prepare restart stop).map { |key| action(key, false) } + keep_awake_actions
|
|
251
|
+
elsif status == 'sleeping'
|
|
252
|
+
[action('start', true).merge('label' => 'Wake workspace')] + keep_awake_actions
|
|
232
253
|
elsif retryable?
|
|
233
254
|
[resume_action, retry_action.merge('label' => 'Re-run all steps', 'primary' => false)]
|
|
234
255
|
elsif %w(error interrupted).include?(status)
|
|
@@ -241,6 +262,12 @@ module Workspaces
|
|
|
241
262
|
|
|
242
263
|
end
|
|
243
264
|
|
|
265
|
+
def keep_awake_actions
|
|
266
|
+
return [] if @snapshot['sleep_after_seconds'].nil?
|
|
267
|
+
|
|
268
|
+
[action(@snapshot['keep_awake'] ? 'allow-sleep' : 'keep-awake', false)]
|
|
269
|
+
end
|
|
270
|
+
|
|
244
271
|
def retry_action
|
|
245
272
|
restart = @snapshot['operation'] == 'restart'
|
|
246
273
|
action(restart ? 'restart' : 'prepare', true).merge('label' => restart ? 'Retry restart' : 'Retry setup')
|
data/lib/workspaces/recipe.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'digest'
|
|
2
4
|
require_relative 'config'
|
|
3
5
|
require_relative 'environment_overrides'
|
|
4
6
|
|
|
@@ -9,7 +11,7 @@ module Workspaces
|
|
|
9
11
|
WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
|
|
10
12
|
LFS_STEP_ID = 'lfs'.freeze
|
|
11
13
|
LFS_STEP_NAME = 'Fetch Git LFS files'.freeze
|
|
12
|
-
attr_reader :steps, :environment, :default_editable_env, :environment_presets, :lfs_fetch
|
|
14
|
+
attr_reader :steps, :environment, :default_editable_env, :environment_presets, :lfs_fetch, :sleep_ignore_paths
|
|
13
15
|
|
|
14
16
|
def initialize(root: Config.repo_root)
|
|
15
17
|
@root = root
|
|
@@ -19,6 +21,7 @@ module Workspaces
|
|
|
19
21
|
@environment_presets = validate_environment_presets!(document.fetch('environment_presets', {}))
|
|
20
22
|
@environment = validate_env!(document.fetch('env', {}))
|
|
21
23
|
@lfs_fetch = validate_lfs!(document.fetch('lfs', {}))
|
|
24
|
+
@sleep_ignore_paths = validate_sleep!(document.fetch('sleep', {}))
|
|
22
25
|
@steps = document.fetch('steps').map { |step| validate_step!(step) }
|
|
23
26
|
@steps = [lfs_step] + @steps unless lfs_fetch.empty?
|
|
24
27
|
validate_editable_overlap!
|
|
@@ -33,6 +36,14 @@ module Workspaces
|
|
|
33
36
|
restart ? steps.drop(steps.index { |step| step['background'] }) : steps
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
# Changes when a step that runs before the server changes. A sleeping workspace wakes by running
|
|
40
|
+
# only the server and readiness steps, unless this no longer matches what it was prepared with.
|
|
41
|
+
def preparation_digest
|
|
42
|
+
server = steps.index { |step| step['background'] }
|
|
43
|
+
material = { 'env' => environment, 'steps' => steps.first(server).map { |step| step.slice('id', 'run', 'env', 'timeout') } }
|
|
44
|
+
Digest::SHA256.hexdigest(JSON.generate(material))
|
|
45
|
+
end
|
|
46
|
+
|
|
36
47
|
# Files fetched into the workspace come from the LFS objects the source repository already
|
|
37
48
|
# has, then are linked in by `git lfs pull`; the source's store is shared by every workspace.
|
|
38
49
|
def lfs_step
|
|
@@ -61,7 +72,7 @@ module Workspaces
|
|
|
61
72
|
raise Invalid,
|
|
62
73
|
'Configuration must be a mapping with version: 1'
|
|
63
74
|
end
|
|
64
|
-
unknown_keys!(document, %w(version env default_editable_env environment_presets lfs steps))
|
|
75
|
+
unknown_keys!(document, %w(version env default_editable_env environment_presets lfs sleep steps))
|
|
65
76
|
raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
|
|
66
77
|
end
|
|
67
78
|
|
|
@@ -131,6 +142,23 @@ module Workspaces
|
|
|
131
142
|
paths.uniq
|
|
132
143
|
end
|
|
133
144
|
|
|
145
|
+
# `sleep: { ignore_paths: [glob, ...] }` names preview paths that are background polling, so they
|
|
146
|
+
# do not keep an otherwise idle workspace awake. Globs match the request path (without the query).
|
|
147
|
+
def validate_sleep!(value)
|
|
148
|
+
raise Invalid, 'sleep must be a mapping' unless value.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
unknown_keys!(value, %w(ignore_paths))
|
|
151
|
+
paths = value.fetch('ignore_paths', [])
|
|
152
|
+
raise Invalid, 'sleep.ignore_paths must be an array of paths' unless paths.is_a?(Array)
|
|
153
|
+
|
|
154
|
+
paths.each do |path|
|
|
155
|
+
unless path.is_a?(String) && path.start_with?('/') && path == path.strip && !path.match?(/[[:cntrl:]]/)
|
|
156
|
+
raise Invalid, 'sleep.ignore_paths entries must be trimmed strings starting with /'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
paths.uniq
|
|
160
|
+
end
|
|
161
|
+
|
|
134
162
|
def validate_env!(values)
|
|
135
163
|
raise Invalid, 'env must be a mapping' unless values.is_a?(Hash)
|
|
136
164
|
values.each do |key, value|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'config'
|
|
4
|
+
require_relative 'recipe'
|
|
5
|
+
|
|
6
|
+
module Workspaces
|
|
7
|
+
# Which preview requests count as someone using a workspace. Health probes and the paths a recipe
|
|
8
|
+
# lists under `sleep.ignore_paths` are background traffic: they neither keep a workspace awake
|
|
9
|
+
# nor wake a sleeping one.
|
|
10
|
+
class SleepPolicy
|
|
11
|
+
DEFAULT_IGNORED_PATHS = %w(/health /up).freeze
|
|
12
|
+
|
|
13
|
+
def initialize(recipe_path: nil)
|
|
14
|
+
@recipe_path = recipe_path
|
|
15
|
+
@cached = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ignored?(request_path)
|
|
19
|
+
globs.any? { |glob| File.fnmatch?(glob, request_path.to_s, File::FNM_EXTGLOB) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def globs
|
|
25
|
+
path = @recipe_path || Config.repo_root.join('.workspaces.yml')
|
|
26
|
+
stamp = path.file? ? path.mtime : nil
|
|
27
|
+
return @cached[1] if @cached && @cached[0] == [path, stamp]
|
|
28
|
+
|
|
29
|
+
@cached = [[path, stamp], DEFAULT_IGNORED_PATHS + configured_globs(path)]
|
|
30
|
+
@cached[1]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configured_globs(path)
|
|
34
|
+
Recipe.new(root: path.dirname).sleep_ignore_paths
|
|
35
|
+
rescue Recipe::Invalid
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'config'
|
|
4
|
+
|
|
5
|
+
module Workspaces
|
|
6
|
+
# Puts ready workspaces to sleep once they have seen no preview traffic for a while. It only
|
|
7
|
+
# decides when; stopping the server, and every check that must hold while it does, is the
|
|
8
|
+
# coordinator's. The tracker is what keeps a request that is being served from being cut off.
|
|
9
|
+
class Sleeper
|
|
10
|
+
def initialize(coordinator:, tracker:, threshold: Config.sleep_after_seconds, interval: Config.sleep_sweep_seconds)
|
|
11
|
+
@coordinator = coordinator
|
|
12
|
+
@tracker = tracker
|
|
13
|
+
@threshold = threshold
|
|
14
|
+
@interval = interval
|
|
15
|
+
@thread = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Settles anything a previous front door left half done, then sweeps on a timer.
|
|
19
|
+
def start
|
|
20
|
+
return self if @threshold.nil?
|
|
21
|
+
|
|
22
|
+
@coordinator.reconcile_sleep
|
|
23
|
+
@thread = Thread.new do
|
|
24
|
+
loop do
|
|
25
|
+
sleep @interval
|
|
26
|
+
sweep
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stop
|
|
33
|
+
@thread&.kill
|
|
34
|
+
@thread = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def sweep
|
|
38
|
+
return [] if @threshold.nil?
|
|
39
|
+
|
|
40
|
+
@coordinator.sleep_candidates.select do |id|
|
|
41
|
+
@tracker.sleep_if_idle(id, @threshold) { @coordinator.sleep!(id) }
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
warn "[Workspaces] could not put #{id} to sleep: #{e.class}"
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
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.17
|
|
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-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- README.md
|
|
71
71
|
- exe/workspaces
|
|
72
72
|
- lib/workspaces.rb
|
|
73
|
+
- lib/workspaces/activity_tracker.rb
|
|
73
74
|
- lib/workspaces/backend.rb
|
|
74
75
|
- lib/workspaces/build_history.rb
|
|
75
76
|
- lib/workspaces/child_env.rb
|
|
@@ -95,6 +96,8 @@ files:
|
|
|
95
96
|
- lib/workspaces/recipe.rb
|
|
96
97
|
- lib/workspaces/registry.rb
|
|
97
98
|
- lib/workspaces/resume_plan.rb
|
|
99
|
+
- lib/workspaces/sleep_policy.rb
|
|
100
|
+
- lib/workspaces/sleeper.rb
|
|
98
101
|
- lib/workspaces/state_store.rb
|
|
99
102
|
- lib/workspaces/step_log.rb
|
|
100
103
|
- lib/workspaces/tee_log.rb
|