workspaces 0.1.20 → 0.1.21

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: b62d0530751f975a4e8604a691dce7bc133c3d7bd57a966e08e9b9e24c7088d9
4
- data.tar.gz: 3fb3b5acff924444694d1390b9edd0f4dc2eaf5885da24b938552180b52a0b0e
3
+ metadata.gz: 2aca45b37e744578006f9b80c3b42a8d664d3b989e9443c10505e357b739338d
4
+ data.tar.gz: cdfb7b28c2fb5f66ace3e778cceb9654a07271fbad73321768829a498248feb5
5
5
  SHA512:
6
- metadata.gz: e860375ef8492aba63ef9340ff2ad29e22fc504353546d343b3e2c3dc31833b9df5bc77de4a8b367eeeb8e3423aef5918e0f61a51d4de26dd8ea9d916af7bec3
7
- data.tar.gz: d8521f83fd632951c5ac551f52f1c4a72db8d22afd33dca483bad10e1f82456f01d8fcf4fa0d50fc00e93cb98da32eab34ae6c16454a8cb931e5654c9349ee19
6
+ metadata.gz: 764c88c9ebb620cace6381b7e95b1ba32e2cb6e4e80da503a28f68ec1acb783a84a99bf4eb7213cb97e2d4787ce9117bee0c09504649367517b56bb593821a1d
7
+ data.tar.gz: dab1db619647ee35bdcd731c6072b762e9788a42877eb684411368d1e7461230267adedbf8a875354f9d0ec50f91f3a81e4654b75f6c29e14c6b0a0f7d0b8896
data/README.md CHANGED
@@ -75,6 +75,26 @@ A few things worth knowing:
75
75
  `workspaces exec <id> -- git lfs pull --include='path/**'`.
76
76
  - Workspaces created before this behavior keep their real files; nothing is converted.
77
77
 
78
+ ### Running a command in a workspace
79
+
80
+ `workspaces exec <id> [--timeout SECONDS] -- COMMAND [ARGUMENTS...]` runs a command in the
81
+ workspace's directory with the workspace's environment, and exits with the command's own status.
82
+
83
+ - It holds the workspace's lock while it runs, so a restart, stop or removal is refused until it
84
+ finishes. That is what stops a command racing a rebuild.
85
+ - A command is stopped after **15 minutes** by default, along with everything it started, and
86
+ `exec` exits 124 (what `timeout(1)` uses). Change it with `--timeout SECONDS` or
87
+ `WORKSPACES_EXEC_TIMEOUT_SECONDS`; `0` means no limit. An explicit `--timeout` wins.
88
+ - Anyone refused while it runs is told what holds the lock, for example
89
+ `Workspace is busy: running a command (bin/rails), pid 1365484, for 28m 5s. It will be stopped
90
+ automatically after 15m 0s`. Only the executable is recorded, never its arguments, because
91
+ those can carry secrets.
92
+ - The lock is released by the operating system when its holder exits for any reason, including
93
+ being killed, so a dead command cannot leave a workspace stuck.
94
+ - The preview keeps being served while a command runs, because `exec` leaves the server alone. A
95
+ restart, rebuild, PR update or removal holding the lock still takes the preview offline, since
96
+ those replace the server or its files.
97
+
78
98
  ### Step timings
79
99
 
80
100
  Per-step durations are persisted in seconds in workspace step status JSON, so they
@@ -225,19 +225,75 @@ module Workspaces
225
225
  raise ArgumentError, "Could not remove: #{result['problems'].join('; ')}" unless result['problems'].empty?
226
226
  end
227
227
 
228
+ EXEC_USAGE = 'Usage: workspaces exec ID [--timeout SECONDS] -- COMMAND [ARGUMENTS...]'.freeze
229
+ # What `timeout(1)` exits with, so scripts that already handle that keep working.
230
+ EXEC_TIMED_OUT = 124
231
+
228
232
  def execute(argv)
229
233
  id = argv.shift
230
- unless argv.shift == '--' && !argv.empty?
231
- raise ArgumentError,
232
- 'Usage: workspaces exec ID -- COMMAND [ARGUMENTS...]'
234
+ timeout = nil
235
+ while argv.first && argv.first != '--'
236
+ flag = argv.shift
237
+ raise ArgumentError, EXEC_USAGE unless flag == '--timeout' && !argv.empty?
238
+
239
+ timeout = argv.shift
240
+ end
241
+ raise ArgumentError, EXEC_USAGE unless argv.shift == '--' && !argv.empty?
242
+
243
+ limit = Config.exec_timeout_seconds(timeout)
244
+ # Only the executable goes in the record: arguments can carry secrets, and the record is readable
245
+ # by anyone who can read the lock directory.
246
+ registry.with_lock(id, operation: 'exec', detail: File.basename(argv.first)) do |workspace, lock|
247
+ run_exec(workspace, argv, limit, lock)
248
+ end
249
+ end
250
+
251
+ # Runs the command in its own process group so the limit, or a cancel, can stop it and everything it
252
+ # started, then reports what `timeout(1)` would.
253
+ def run_exec(workspace, argv, limit, lock)
254
+ child_env = ChildEnv.for_workspace(workspace_path: workspace.path)
255
+ pid = Process.spawn(child_env, [argv.first, argv.first], *argv.drop(1),
256
+ chdir: workspace.path.to_s, unsetenv_others: true, pgroup: true)
257
+ lock.update('child_pid' => pid, 'process_group' => pid, 'timeout_seconds' => limit)
258
+ status = wait_for_exec(pid, limit)
259
+ return status.exitstatus || (128 + status.termsig) if status
260
+
261
+ warn "workspaces exec: #{File.basename(argv.first)} timed out after #{DurationFormat.call(limit)} and was stopped"
262
+ EXEC_TIMED_OUT
263
+ end
264
+
265
+ # The exit status, or nil when the limit ran out first and the process group was stopped.
266
+ def wait_for_exec(pid, limit)
267
+ deadline = limit && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + limit)
268
+ loop do
269
+ reaped, status = Process.wait2(pid, Process::WNOHANG)
270
+ return status if reaped
271
+
272
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
273
+ stop_group(pid)
274
+ return nil
275
+ end
276
+ sleep 0.05
233
277
  end
278
+ end
234
279
 
235
- registry.with_lock(id) do |workspace|
236
- child_env = ChildEnv.for_workspace(workspace_path: workspace.path)
237
- pid = Process.spawn(child_env, [argv.first, argv.first], *argv.drop(1),
238
- chdir: workspace.path.to_s, unsetenv_others: true)
239
- _, status = Process.wait2(pid)
240
- status.exitstatus || (128 + status.termsig)
280
+ # TERM first so a command can clean up, KILL for anything still there after a few seconds.
281
+ def stop_group(pid)
282
+ Process.kill('TERM', -pid)
283
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
284
+ until Process.wait2(pid, Process::WNOHANG)
285
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
286
+
287
+ sleep 0.05
288
+ end
289
+ Process.kill('KILL', -pid)
290
+ rescue Errno::ESRCH, Errno::ECHILD
291
+ nil
292
+ ensure
293
+ begin
294
+ Process.wait2(pid)
295
+ rescue Errno::ECHILD
296
+ nil
241
297
  end
242
298
  end
243
299
 
@@ -257,7 +313,7 @@ module Workspaces
257
313
  'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
258
314
  'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
259
315
  'resume ID [--from STEP | --only STEP] [--json] | stop ID | keep-awake ID [--off] | ' \
260
- 'exec ID -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
316
+ 'exec ID [--timeout SECONDS] -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
261
317
  end
262
318
 
263
319
  def help(*)
@@ -20,6 +20,7 @@ module Workspaces
20
20
  DEFAULT_SLEEP_AFTER_MINUTES = 30
21
21
  DEFAULT_WAKE_TIMEOUT_SECONDS = 90
22
22
  DEFAULT_SLEEP_SWEEP_SECONDS = 30
23
+ DEFAULT_EXEC_TIMEOUT_SECONDS = 900
23
24
 
24
25
  # Seconds a ready workspace may go without preview traffic before it is put to sleep, or nil when
25
26
  # sleeping is switched off (WORKSPACES_SLEEP_AFTER_MINUTES=0). Fractions of a minute are allowed.
@@ -28,6 +29,19 @@ module Workspaces
28
29
  minutes.zero? ? nil : minutes * 60
29
30
  end
30
31
 
32
+ # How long `workspaces exec` lets a command run before stopping it, or nil for no limit. An explicit
33
+ # value (the --timeout option) wins over WORKSPACES_EXEC_TIMEOUT_SECONDS, which wins over the default.
34
+ # Zero means no limit.
35
+ def self.exec_timeout_seconds(explicit)
36
+ value = explicit.nil? ? ENV.fetch('WORKSPACES_EXEC_TIMEOUT_SECONDS', DEFAULT_EXEC_TIMEOUT_SECONDS.to_s) : explicit
37
+ seconds = Float(value)
38
+ raise ArgumentError, 'The exec timeout must be a non-negative number of seconds' unless seconds.finite? && seconds >= 0
39
+
40
+ seconds.zero? ? nil : (seconds % 1).zero? ? seconds.to_i : seconds
41
+ rescue ArgumentError, TypeError
42
+ raise ArgumentError, 'The exec timeout must be a non-negative number of seconds'
43
+ end
44
+
31
45
  # How long the first request to a sleeping preview waits for the server to be ready.
32
46
  def self.wake_timeout_seconds
33
47
  positive_number('WORKSPACES_WAKE_TIMEOUT_SECONDS', DEFAULT_WAKE_TIMEOUT_SECONDS)
@@ -42,7 +42,7 @@ module Workspaces
42
42
  # message) when the request cannot be honoured.
43
43
  def rerun(id, mode: :retry, key: nil)
44
44
  workspace = @registry.find(id)
45
- lock = acquire_lock(id)
45
+ lock = acquire_lock(id, 'resume')
46
46
  return false unless lock
47
47
 
48
48
  plan = rerun_plan(id, mode, key)
@@ -96,7 +96,7 @@ module Workspaces
96
96
  # the workspace is busy, is kept awake, is not running, or is already asleep.
97
97
  def sleep!(id)
98
98
  @registry.find(id)
99
- lock = acquire_lock(id)
99
+ lock = acquire_lock(id, 'sleep')
100
100
  return false unless lock
101
101
 
102
102
  begin
@@ -122,7 +122,7 @@ module Workspaces
122
122
  # the one holding the workspace lock gets past the check.
123
123
  def wake(id)
124
124
  workspace = @registry.find(id)
125
- lock = acquire_lock(id)
125
+ lock = acquire_lock(id, 'wake')
126
126
  return false unless lock
127
127
 
128
128
  entry = @state.get(id) || {}
@@ -142,7 +142,7 @@ module Workspaces
142
142
 
143
143
  # Stops the server by hand. A workspace stopped this way is not asleep, so traffic never wakes it.
144
144
  def stop(id)
145
- @registry.with_lock(id) do
145
+ @registry.with_lock(id, operation: 'stop') do
146
146
  Backend.new(id).stop!
147
147
  @state.set(id, 'status' => 'stopped', 'message' => 'Server stopped', 'sleep_state' => nil, 'slept_at' => nil)
148
148
  end
@@ -249,7 +249,7 @@ module Workspaces
249
249
  end
250
250
 
251
251
  def finish_interrupted_sleep(id)
252
- lock = acquire_lock(id)
252
+ lock = acquire_lock(id, 'sleep')
253
253
  return unless lock
254
254
 
255
255
  begin
@@ -262,6 +262,7 @@ module Workspaces
262
262
  def snapshot_fields(id, entry, busy, status, log, include_log)
263
263
  {
264
264
  'workspace_id' => id, 'status' => status, 'active' => busy,
265
+ 'lock_operation' => busy ? WorkspaceLock.holder(id)&.fetch('operation', nil) : nil,
265
266
  'sleeping' => status == 'sleeping', 'slept_at' => entry['slept_at'],
266
267
  'keep_awake' => !!entry['keep_awake'], 'sleep_after_seconds' => Config.sleep_after_seconds,
267
268
  'message' => status == entry['status'] ? entry['message'] : status.capitalize,
@@ -290,7 +291,7 @@ module Workspaces
290
291
 
291
292
  def launch(id, restart:, force:, prelaunch: nil)
292
293
  workspace = @registry.find(id)
293
- lock = acquire_lock(id)
294
+ lock = acquire_lock(id, restart ? 'restart' : 'prepare')
294
295
  return false unless lock
295
296
 
296
297
  if prelaunch && !restart && !force && Backend.new(id).running?
@@ -335,12 +336,8 @@ module Workspaces
335
336
  'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil, 'total_seconds' => nil)
336
337
  end
337
338
 
338
- def acquire_lock(id)
339
- Config.locks_dir.mkpath
340
- # The worker owns this duplicate; closing the request's descriptor must not release its lock.
341
- File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |file|
342
- file.dup if file.flock(File::LOCK_EX | File::LOCK_NB)
343
- end
339
+ def acquire_lock(id, operation)
340
+ WorkspaceLock.acquire(id, operation: operation)
344
341
  end
345
342
 
346
343
  def publish_starting(id, restart, extra: {})
@@ -434,10 +431,7 @@ module Workspaces
434
431
  end
435
432
 
436
433
  def locked?(id)
437
- path = Config.prepare_lock_path(id)
438
- return false unless path.file?
439
-
440
- File.open(path, File::RDWR) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
434
+ WorkspaceLock.held?(id)
441
435
  end
442
436
 
443
437
  def log_tail(id, log)
@@ -165,7 +165,7 @@ module Workspaces
165
165
  else
166
166
  @coordinator.update_environment(id, **parse_environment_changes(params))
167
167
  end
168
- return response(409, 'Workspace is busy preparing, restarting, or running a command') unless updated
168
+ return response(409, WorkspaceLock.busy_message(id)) unless updated
169
169
 
170
170
  redirect(Config.dashboard_url(id))
171
171
  rescue EnvironmentOverrides::Invalid => e
@@ -326,13 +326,23 @@ module Workspaces
326
326
  end
327
327
 
328
328
  snapshot = @coordinator.snapshot(id, include_log: false)
329
- return unavailable(id, request) unless snapshot['status'] == 'ready' && !snapshot['active']
329
+ return unavailable(id, request) unless servable?(snapshot)
330
330
 
331
331
  request.env[WORKSPACE_ID_ENV] = id
332
332
  request.env[WORKSPACE_ENV] = snapshot
333
333
  @forwarder.call(request.env)
334
334
  end
335
335
 
336
+ # A ready workspace is served unless something is changing it. A command run with `exec` leaves the
337
+ # server alone, so a preview keeps working while one runs, and a command that hangs cannot take the
338
+ # preview down with it. Anything else holding the lock (a restart, a rebuild, a PR update, removal) may
339
+ # be replacing the server or its files, and so does an unknown holder: those are still refused.
340
+ def servable?(snapshot)
341
+ return false unless snapshot['status'] == 'ready'
342
+
343
+ !snapshot['active'] || snapshot['lock_operation'] == 'exec'
344
+ end
345
+
336
346
  # A lease keeps the workspace awake for as long as its request is being served. A response body
337
347
  # that is still being produced (anything but a buffered Array) keeps it until the server closes it.
338
348
  def hold_lease(result, lease)
@@ -1,4 +1,5 @@
1
1
  require_relative 'worktree'
2
+ require_relative 'workspace_lock'
2
3
  require_relative 'state_store'
3
4
  require_relative 'backend'
4
5
  require_relative 'recipe'
@@ -28,23 +29,24 @@ module Workspaces
28
29
  workspace
29
30
  end
30
31
 
31
- def with_lock(id)
32
+ # Runs the block holding the workspace's lock, or raises ArgumentError saying who holds it. The block
33
+ # gets the workspace, and the lock when it wants to add to the holder record.
34
+ def with_lock(id, operation: 'busy', detail: nil)
32
35
  workspace = find(id)
33
- Config.locks_dir.mkpath
34
- File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |lock|
35
- unless lock.flock(File::LOCK_EX | File::LOCK_NB)
36
- raise ArgumentError,
37
- 'Workspace is busy preparing, restarting, or running a command'
38
- end
39
-
40
- yield workspace
36
+ lock = WorkspaceLock.acquire(id, operation: operation, detail: detail)
37
+ raise ArgumentError, WorkspaceLock.busy_message(id) unless lock
38
+
39
+ begin
40
+ yield workspace, lock
41
+ ensure
42
+ lock.close
41
43
  end
42
44
  end
43
45
 
44
46
  # Removes the workspace and its saved settings and secrets. Returns the secrets that could not be
45
47
  # removed (empty when all went), so callers can say so rather than report a clean removal.
46
48
  def remove(id, force: false)
47
- with_lock(id) do |workspace|
49
+ with_lock(id, operation: 'remove') do |workspace|
48
50
  raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
49
51
 
50
52
  Backend.new(id).stop!
@@ -78,7 +80,7 @@ module Workspaces
78
80
  end
79
81
 
80
82
  def update_pr(id)
81
- with_lock(id) do |workspace|
83
+ with_lock(id, operation: 'update-pr') do |workspace|
82
84
  result = workspace.update_from_pr!
83
85
  message = result['updated'] ? 'PR checkout updated. Rebuild & restart to run the new code.' : 'PR checkout is already current.'
84
86
  StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.20'
2
+ VERSION = '0.1.21'
3
3
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative 'config'
6
+ require_relative 'duration_format'
7
+
8
+ module Workspaces
9
+ # The per-workspace lock that serializes preparing, restarting, stopping, removing, updating and
10
+ # running commands, plus a record of who holds it.
11
+ #
12
+ # The lock is a non-blocking exclusive flock, so the kernel releases it when the holder exits for
13
+ # any reason, including SIGKILL. A holder that died therefore never leaves the lock stuck; only a
14
+ # holder that is alive and not finishing does. The record in the file is written by the holder and
15
+ # emptied when it lets go. It is only meaningful while the lock is held, so a record left behind by
16
+ # a holder that died is ignored.
17
+ class WorkspaceLock
18
+ GENERIC_BUSY = 'Workspace is busy preparing, restarting, or running a command'
19
+
20
+ OPERATIONS = {
21
+ 'prepare' => 'preparing', 'restart' => 'restarting', 'resume' => 're-running setup steps',
22
+ 'sleep' => 'putting the workspace to sleep', 'wake' => 'waking the workspace',
23
+ 'stop' => 'stopping the server', 'remove' => 'removing the workspace',
24
+ 'update-pr' => 'updating the PR checkout'
25
+ }.freeze
26
+
27
+ # Takes the lock, or returns nil when someone else holds it.
28
+ def self.acquire(id, operation:, detail: nil)
29
+ Config.locks_dir.mkpath
30
+ file = File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600)
31
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
32
+ file.close
33
+ return nil
34
+ end
35
+
36
+ lock = new(file)
37
+ lock.update('operation' => operation, 'pid' => Process.pid, 'started_at' => Time.now.utc.iso8601,
38
+ 'detail' => detail)
39
+ lock
40
+ rescue StandardError
41
+ file&.close unless file&.closed?
42
+ raise
43
+ end
44
+
45
+ # True while some process holds the lock.
46
+ def self.held?(id)
47
+ path = Config.prepare_lock_path(id)
48
+ return false unless path.file?
49
+
50
+ File.open(path, File::RDWR) { |file| !file.flock(File::LOCK_EX | File::LOCK_NB) }
51
+ end
52
+
53
+ # What the current holder wrote, or nil when nothing holds the lock or the record cannot be read.
54
+ def self.holder(id)
55
+ return nil unless held?(id)
56
+
57
+ record = JSON.parse(Config.prepare_lock_path(id).read)
58
+ record.is_a?(Hash) ? record : nil
59
+ rescue JSON::ParserError, SystemCallError
60
+ nil
61
+ end
62
+
63
+ def self.busy_message(id)
64
+ record = holder(id)
65
+ return GENERIC_BUSY unless record
66
+
67
+ "Workspace is busy: #{describe(record)}"
68
+ end
69
+
70
+ def self.describe(record)
71
+ operation = record['operation'].to_s
72
+ return describe_command(record) if operation == 'exec'
73
+
74
+ OPERATIONS.fetch(operation, operation)
75
+ end
76
+
77
+ def self.describe_command(record)
78
+ text = +'running a command'
79
+ text << " (#{record['detail']})" if record['detail']
80
+ pids = [record['child_pid'] && "pid #{record['child_pid']}"].compact
81
+ text << ", #{pids.join(', ')}" unless pids.empty?
82
+ elapsed = elapsed_text(record['started_at'])
83
+ text << ", for #{elapsed}" if elapsed
84
+ limit = DurationFormat.call(record['timeout_seconds'])
85
+ text << ". It will be stopped automatically after #{limit}" if limit
86
+ text
87
+ end
88
+ private_class_method :describe_command
89
+
90
+ def self.elapsed_text(started_at)
91
+ DurationFormat.call(Time.now - Time.iso8601(started_at.to_s))
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+ private_class_method :elapsed_text
96
+
97
+ def initialize(file)
98
+ @file = file
99
+ @record = {}
100
+ end
101
+
102
+ # Adds to what the holder has told the world, keeping what it already said.
103
+ def update(fields)
104
+ @record = @record.merge(fields.compact)
105
+ @file.rewind
106
+ @file.truncate(0)
107
+ @file.write(JSON.generate(@record))
108
+ @file.flush
109
+ end
110
+
111
+ # A descriptor the background worker owns, so the request that took the lock can close its own
112
+ # without releasing it.
113
+ def dup
114
+ self.class.allocate.tap { |copy| copy.send(:adopt, @file.dup, @record) }
115
+ end
116
+
117
+ def close
118
+ return if @file.closed?
119
+
120
+ @file.truncate(0)
121
+ @file.close
122
+ rescue IOError, SystemCallError
123
+ @file.close unless @file.closed?
124
+ end
125
+
126
+ def closed?
127
+ @file.closed?
128
+ end
129
+
130
+ private
131
+
132
+ def adopt(file, record)
133
+ @file = file
134
+ @record = record
135
+ end
136
+ end
137
+ end
data/lib/workspaces.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative 'workspaces/config'
2
+ require_relative 'workspaces/workspace_lock'
2
3
  require_relative 'workspaces/state_store'
3
4
  require_relative 'workspaces/port_allocator'
4
5
  require_relative 'workspaces/child_env'
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.20
4
+ version: 0.1.21
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-30 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -107,6 +107,7 @@ files:
107
107
  - lib/workspaces/vault/file_vault.rb
108
108
  - lib/workspaces/version.rb
109
109
  - lib/workspaces/workflow.rb
110
+ - lib/workspaces/workspace_lock.rb
110
111
  - lib/workspaces/worktree.rb
111
112
  homepage:
112
113
  licenses: