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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5377d0d4a49b5a0b585540ddc02e4eeaaeb3e34a8a26dba5ce4db2107c85334
4
- data.tar.gz: d880d6274c7a79ca45f9fd39232310083c4709d5b3a3bdf630d6eee716563278
3
+ metadata.gz: 9f662b3baa05bd9cd6a684906e2bf2d75d663dfa493fd684ef1090adb0095276
4
+ data.tar.gz: 2265a4ddeeae587c04b2eae5e8ed0e8a7f5f64988bb9c22c0f1f4c9202c30b9f
5
5
  SHA512:
6
- metadata.gz: aeb8a8dbd6e618499c3831d711734df8eb930f88656c0f2f66617f9835ddd1d994ef39c25362d784697cef44478847ed1df9d4ceb382a37066209d2bb427b9aa
7
- data.tar.gz: 6fe026cae101124808e962b0280c64a4d851665b02789dad6b6eceeea7dcf0147d46a11ee21c7df261671c2369c606e01f3baee45ed28efbe47fd13d5ac8fefe
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
@@ -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
- Rackup::Handler::Puma.run(FrontDoor.new, **puma_options(tls))
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
- registry.with_lock(id) do
181
- Backend.new(id).stop!
182
- StateStore.new(Config.state_file).set(id, 'status' => 'stopped', 'message' => 'Server stopped')
183
- end
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
 
@@ -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.fetch('status', 'idle'), busy)
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.fetch('status', 'idle'), busy),
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, status, busy)
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, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
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
- def initialize(coordinator: Coordinator.new, registry: Registry.new)
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
- rescue IOError, SystemCallError, Timeout::Error => e
256
- response(502, "Workspace backend unavailable: #{e.class}")
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' }.freeze
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')
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.16'
2
+ VERSION = '0.1.17'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.16
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-22 00:00:00.000000000 Z
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