workspaces 0.1.10 → 0.1.12

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.
@@ -6,8 +6,14 @@ module Workspaces
6
6
  ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
7
7
  'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
8
8
 
9
- SECTIONS = %w(progress actions environment log details danger).freeze
10
- MANAGEABLE = SECTIONS
9
+ # Three layers, from friendly to technical. `lead` is the headline, one sentence and at
10
+ # most one primary action. `environment` (presets and variables) is the area for
11
+ # deliberately changing something. `technical` is one disclosure holding setup steps,
12
+ # logs, workspace details, lifecycle controls and deletion; it is never the first thing
13
+ # a user reads, but it opens by itself when something needs attention. Setup steps are
14
+ # not a section of their own: each is an accordion with its own log inside `log`.
15
+ SECTIONS = %w(lead actions environment technical log details danger).freeze
16
+ TECHNICAL = %w(technical actions log details danger).freeze
11
17
 
12
18
  # Workspace lifecycle state machine. Each state declares only what is usable and
13
19
  # meaningful there; everything else is hidden rather than shown disabled.
@@ -20,19 +26,60 @@ module Workspaces
20
26
  #
21
27
  # `environment_submit` labels the single environment-form button, which saves the
22
28
  # settings and (re)starts the workspace, so a brand new workspace needs only that.
29
+ #
30
+ # Per state: `headline` and `summary` are plain language; `open_environment` and
31
+ # `open_technical` say which disclosures start expanded. A failure always opens the
32
+ # technical section so the cause is visible without hunting for it.
23
33
  STATES = {
24
34
  'idle' => {
25
- sections: %w(environment),
26
- environment_submit: 'Start workspace', controls_title: 'Set up your workspace',
27
- hint: 'Choose the environment you want to preview below, then start the workspace. ' \
28
- 'Setup usually takes a few minutes.'
35
+ sections: %w(lead environment),
36
+ headline: "Let's get your workspace running",
37
+ summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
38
+ environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
39
+ open_environment: true, open_technical: false
40
+ },
41
+ 'busy' => {
42
+ sections: %w(lead technical log),
43
+ headline: 'Setting things up…',
44
+ summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
45
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
46
+ open_environment: false, open_technical: true
47
+ },
48
+ 'ready' => {
49
+ sections: %w(lead environment) + TECHNICAL,
50
+ headline: 'Your workspace is ready',
51
+ summary: 'Everything is set up and running. Open it to see your changes.',
52
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
53
+ open_environment: false, open_technical: false
54
+ },
55
+ 'stopped' => {
56
+ sections: %w(lead environment) + TECHNICAL,
57
+ headline: 'Your workspace is paused',
58
+ summary: 'Start it again to pick up where you left off.',
59
+ environment_submit: 'Save & start', environment_title: 'Change settings',
60
+ open_environment: false, open_technical: false
61
+ },
62
+ 'error' => {
63
+ sections: %w(lead environment) + TECHNICAL,
64
+ headline: "Something didn't go to plan",
65
+ summary: 'Setup stopped before it finished. You can try again, or change settings first.',
66
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
67
+ open_environment: false, open_technical: true
29
68
  },
30
- 'busy' => { sections: %w(progress log), environment_submit: 'Save & restart' },
31
- 'ready' => { sections: MANAGEABLE, environment_submit: 'Save & restart' },
32
- 'stopped' => { sections: %w(actions environment log details danger), environment_submit: 'Save & start' },
33
- 'error' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
34
- 'interrupted' => { sections: MANAGEABLE, environment_submit: 'Save & retry' },
35
- 'unknown' => { sections: %w(log details), environment_submit: 'Save & restart' }
69
+ 'interrupted' => {
70
+ sections: %w(lead environment) + TECHNICAL,
71
+ headline: 'Setup was interrupted',
72
+ summary: 'It stopped before it finished. Try again to pick up from the start.',
73
+ environment_submit: 'Save & retry', environment_title: 'Change settings',
74
+ open_environment: false, open_technical: true
75
+ },
76
+ 'unknown' => {
77
+ sections: %w(lead technical log details),
78
+ headline: 'Workspace status unknown',
79
+ summary: 'Open the technical details to see what the workspace is doing.',
80
+ environment_submit: 'Save & restart', environment_title: 'Change settings',
81
+ open_environment: false, open_technical: true
82
+ }
36
83
  }.freeze
37
84
 
38
85
  def initialize(snapshot)
@@ -42,9 +89,13 @@ module Workspaces
42
89
  def to_h
43
90
  { 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
44
91
  'actions' => actions, 'ready' => status == 'ready' && !busy?,
45
- 'state' => state, 'sections' => sections, 'hint' => definition[:hint],
46
- 'controls_title' => definition.fetch(:controls_title, 'Actions & settings'),
47
- 'environment_submit' => definition[:environment_submit] }
92
+ 'state' => state, 'sections' => sections,
93
+ 'headline' => definition[:headline], 'summary' => definition[:summary],
94
+ 'subtitle' => subtitle, 'lead_action' => lead_action, 'total' => total,
95
+ 'current_preset' => current_preset,
96
+ 'environment_title' => definition[:environment_title],
97
+ 'environment_submit' => definition[:environment_submit],
98
+ 'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
48
99
  end
49
100
 
50
101
  private
@@ -68,11 +119,53 @@ module Workspaces
68
119
  end
69
120
 
70
121
  def sections
71
- visible = definition[:sections].dup
72
- visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
122
+ visible = definition[:sections]
73
123
  SECTIONS.to_h { |name| [name, visible.include?(name)] }
74
124
  end
75
125
 
126
+ # The single most useful thing to do next, or nil when the user should just wait.
127
+ # `open` links straight to the preview; the others are lifecycle form posts.
128
+ def lead_action
129
+ case state
130
+ when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
131
+ when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
132
+ when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
133
+ end
134
+ end
135
+
136
+ # The preset whose values exactly equal the workspace's saved editable settings, or nil when
137
+ # there are no presets or the settings match none (for example after a hand edit). Presets
138
+ # arrive already completed with the recipe defaults, so equality is a plain comparison.
139
+ def current_preset
140
+ presets = @snapshot['environment_presets']
141
+ current = @snapshot['editable_environment']
142
+ return nil unless presets.is_a?(Hash) && current.is_a?(Hash) && !current.empty?
143
+
144
+ normalized = stringify(current)
145
+ presets.find { |_name, values| values.is_a?(Hash) && stringify(values) == normalized }&.first&.to_s
146
+ end
147
+
148
+ def stringify(values)
149
+ values.to_h { |name, value| [name.to_s, value.to_s] }
150
+ end
151
+
152
+ # How long the last run took and how it ended, or nil while one is in progress or when no
153
+ # total was recorded (interrupted runs, and workspaces set up before totals were persisted).
154
+ def total
155
+ seconds = @snapshot['total_seconds']
156
+ return nil if busy? || !seconds.is_a?(Numeric)
157
+
158
+ failed = status == 'error' || steps.any? { |step| step['state'] == 'failed' }
159
+ { 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
160
+ end
161
+
162
+ # What is happening right now, in the quiet line under the headline.
163
+ def subtitle
164
+ return nil unless state == 'busy'
165
+
166
+ @snapshot['message']
167
+ end
168
+
76
169
  def title
77
170
  return 'Workspace busy' if @snapshot['active'] && !busy_status?
78
171
  return @snapshot['message'] || 'Running setup' if status == 'preparing'
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Workspaces
4
+ # Splits a run's output by recipe step.
5
+ #
6
+ # Wraps the combined log (a TeeLog, or anything with puts/print). Everything still goes to
7
+ # the combined log exactly as before; while a step is current, the same text is also written
8
+ # to that step's own file so the dashboard can show each step under its own heading.
9
+ #
10
+ # Text arriving through this object has already been scrubbed by the wrapped log, so we hand
11
+ # it the raw text and copy what it actually wrote, rather than scrubbing twice.
12
+ class StepLog
13
+ def initialize(log, workspace_id)
14
+ @log = log
15
+ @workspace_id = workspace_id
16
+ @file = nil
17
+ end
18
+
19
+ # Begins a step's file, truncating any output from an earlier run of the same step.
20
+ def begin_step(step_id)
21
+ finish_step
22
+ @file = File.open(Config.step_log_path(@workspace_id, step_id), 'w')
23
+ @file.sync = true
24
+ end
25
+
26
+ def finish_step
27
+ @file&.close
28
+ @file = nil
29
+ end
30
+
31
+ def puts(message = '')
32
+ text = scrubbed(message)
33
+ @log.puts(message)
34
+ @file&.puts(text)
35
+ end
36
+
37
+ def print(message)
38
+ text = scrubbed(message)
39
+ @log.print(message)
40
+ @file&.print(text)
41
+ end
42
+
43
+ # Clears every step file; called when a run begins so a restart never shows stale steps.
44
+ def self.reset(workspace_id)
45
+ dir = Config.step_logs_dir(workspace_id, create: false)
46
+ Dir.glob(dir.join('*.log')).each { |path| File.delete(path) } if dir.directory?
47
+ end
48
+
49
+ private
50
+
51
+ def scrubbed(message)
52
+ @log.respond_to?(:scrub_text) ? @log.scrub_text(message) : message.to_s
53
+ end
54
+ end
55
+ end
@@ -26,10 +26,16 @@ module Workspaces
26
26
  $stdout.print(prefixed(scrubbed))
27
27
  end
28
28
 
29
+ # The filtering applied to everything written. Public so StepLog can copy exactly what the
30
+ # combined log received, instead of reimplementing the rules.
31
+ def scrub_text(message)
32
+ @overrides.scrub(message)
33
+ end
34
+
29
35
  private
30
36
 
31
37
  def scrub(message)
32
- @overrides.scrub(message)
38
+ scrub_text(message)
33
39
  end
34
40
 
35
41
  def prefixed(message)
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.10'
2
+ VERSION = '0.1.12'
3
3
  end
@@ -3,12 +3,15 @@ require_relative 'recipe'
3
3
  require_relative 'command_runner'
4
4
  require_relative 'backend'
5
5
  require_relative 'environment_overrides'
6
+ require_relative 'step_log'
7
+ require_relative 'duration_format'
6
8
 
7
9
  module Workspaces
8
10
  class Workflow
9
11
  def initialize(workspace, log, state: StateStore.new(Config.state_file))
10
12
  @workspace = workspace
11
- @log = log
13
+ @combined_log = log
14
+ @log = StepLog.new(log, workspace.id)
12
15
  @state = state
13
16
  @backend = Backend.new(workspace.id)
14
17
  end
@@ -18,6 +21,7 @@ module Workspaces
18
21
  overrides = EnvironmentOverrides.new(@workspace.id).load
19
22
  steps = recipe.for_operation(restart: restart)
20
23
  publish_steps(steps)
24
+ StepLog.reset(@workspace.id)
21
25
  @backend.stop!
22
26
  context = build_context(@backend.reserve!)
23
27
  runner = CommandRunner.new(@workspace.path, @log)
@@ -27,6 +31,8 @@ module Workspaces
27
31
  rescue StandardError
28
32
  @backend.stop!
29
33
  raise
34
+ ensure
35
+ @log.finish_step
30
36
  end
31
37
 
32
38
  private
@@ -50,6 +56,7 @@ module Workspaces
50
56
  def execute(step, recipe, context, runner, overrides)
51
57
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
52
58
  progress(step, 'active')
59
+ @log.begin_step(step.fetch('id'))
53
60
  @log.puts "==> #{step.fetch('name')}"
54
61
  command = recipe.command(step, context)
55
62
  env = recipe.env(step, context, overrides: overrides)
@@ -60,7 +67,10 @@ module Workspaces
60
67
  @backend.ensure_alive! if @server_started
61
68
  end
62
69
  @server_started = true if step['background']
63
- rescue StandardError
70
+ rescue StandardError => e
71
+ # The reason a step failed is not command output, so the step file would otherwise end
72
+ # without it. Record it where the step's own accordion can show it.
73
+ @log.puts "ERROR: #{e.message}"
64
74
  finish_step(step, 'failed', started_at)
65
75
  raise
66
76
  else
@@ -70,7 +80,7 @@ module Workspaces
70
80
  def finish_step(step, status, started_at)
71
81
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
72
82
  progress(step, status, duration_seconds: duration)
73
- @log.puts format('==> %s %s (%.3fs)', step.fetch('name'), status, duration)
83
+ @log.puts format('==> %s %s (%s)', step.fetch('name'), status, DurationFormat.call(duration))
74
84
  end
75
85
 
76
86
  def progress(step, status, duration_seconds: nil)
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.10
4
+ version: 0.1.12
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-13 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -83,6 +83,7 @@ files:
83
83
  - lib/workspaces/dashboard/styles.css
84
84
  - lib/workspaces/dashboard/template.html.erb
85
85
  - lib/workspaces/dashboard_view.rb
86
+ - lib/workspaces/duration_format.rb
86
87
  - lib/workspaces/environment_overrides.rb
87
88
  - lib/workspaces/front_door.rb
88
89
  - lib/workspaces/paths.rb
@@ -92,6 +93,7 @@ files:
92
93
  - lib/workspaces/recipe.rb
93
94
  - lib/workspaces/registry.rb
94
95
  - lib/workspaces/state_store.rb
96
+ - lib/workspaces/step_log.rb
95
97
  - lib/workspaces/tee_log.rb
96
98
  - lib/workspaces/tls.rb
97
99
  - lib/workspaces/version.rb