chrono_forge 0.9.1 → 0.10.0
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/CHANGELOG.md +22 -0
- data/README.md +305 -44
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
- data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/lib/chrono_forge/branch_merge_job.rb +138 -0
- data/lib/chrono_forge/branch_probe.rb +26 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
- data/lib/chrono_forge/executor/methods/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/durably_execute.rb +21 -19
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +118 -25
- data/lib/chrono_forge/executor/methods/merge_branches.rb +83 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +16 -0
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +216 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- metadata +42 -5
- data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
# ChronoForge Dashboard Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** A mountable, zero-build Rails engine gem `chrono_forge-dashboard` giving full visibility and operational control over ChronoForge workflows.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A namespace-isolated Rails engine in a monorepo subfolder with its own gemspec depending on `chrono_forge`. Reuses the core models read-only via query objects and presenters; server-rendered ERB with one engine-served CSS and one vanilla JS file (no build step); fail-closed pluggable auth.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Rails (railties/actionpack/activerecord), Zeitwerk, Minitest + Combustion (dummy app), standardrb.
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — internal tooling; correctness is covered by controller/query/presenter tests. (Visual polish is iterated separately via the frontend-design skill, not gated here.)
|
|
12
|
+
|
|
13
|
+
**Working directory:** all paths are relative to the repo root; the dashboard gem lives under `chrono_forge-dashboard/`. Tests run from that subdir: `cd chrono_forge-dashboard && bundle exec rake test`.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## File structure
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
chrono_forge.gemspec # MODIFY: exclude chrono_forge-dashboard/ from spec.files
|
|
21
|
+
chrono_forge-dashboard/
|
|
22
|
+
chrono_forge-dashboard.gemspec
|
|
23
|
+
Gemfile # path "..", gemspec
|
|
24
|
+
Rakefile # Minitest test task
|
|
25
|
+
lib/chrono_forge/dashboard.rb # entry: requires + Configuration accessor
|
|
26
|
+
lib/chrono_forge/dashboard/version.rb
|
|
27
|
+
lib/chrono_forge/dashboard/configuration.rb
|
|
28
|
+
lib/chrono_forge/dashboard/engine.rb
|
|
29
|
+
lib/chrono_forge/dashboard/step_name_parser.rb
|
|
30
|
+
config/routes.rb
|
|
31
|
+
app/controllers/chrono_forge/dashboard/base_controller.rb
|
|
32
|
+
app/controllers/chrono_forge/dashboard/workflows_controller.rb
|
|
33
|
+
app/controllers/chrono_forge/dashboard/wait_states_controller.rb
|
|
34
|
+
app/controllers/chrono_forge/dashboard/actions_controller.rb
|
|
35
|
+
app/controllers/chrono_forge/dashboard/assets_controller.rb
|
|
36
|
+
app/queries/chrono_forge/dashboard/workflows_query.rb
|
|
37
|
+
app/queries/chrono_forge/dashboard/stats_query.rb
|
|
38
|
+
app/presenters/chrono_forge/dashboard/timeline_presenter.rb
|
|
39
|
+
app/presenters/chrono_forge/dashboard/context_presenter.rb
|
|
40
|
+
app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb
|
|
41
|
+
app/presenters/chrono_forge/dashboard/wait_state_presenter.rb
|
|
42
|
+
app/assets/chrono_forge/dashboard/dashboard.css
|
|
43
|
+
app/assets/chrono_forge/dashboard/dashboard.js
|
|
44
|
+
app/views/layouts/chrono_forge/dashboard/application.html.erb
|
|
45
|
+
app/views/chrono_forge/dashboard/workflows/{index,show}.html.erb + partials
|
|
46
|
+
app/views/chrono_forge/dashboard/wait_states/index.html.erb
|
|
47
|
+
test/test_helper.rb
|
|
48
|
+
test/internal/ # Combustion dummy app mounting the engine
|
|
49
|
+
test/**/*_test.rb
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each task below produces a committed, independently testable unit.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### Task 1: Engine skeleton, gemspec, core exclusion, test harness
|
|
57
|
+
|
|
58
|
+
**Goal:** A loadable, mountable engine with a Combustion dummy app and a passing smoke test; core gem excludes the dashboard dir.
|
|
59
|
+
|
|
60
|
+
**Files:**
|
|
61
|
+
- Modify: `chrono_forge.gemspec:29`
|
|
62
|
+
- Create: `chrono_forge-dashboard/chrono_forge-dashboard.gemspec`, `Gemfile`, `Rakefile`, `lib/chrono_forge/dashboard.rb`, `lib/chrono_forge/dashboard/version.rb`, `lib/chrono_forge/dashboard/engine.rb`, `config/routes.rb`
|
|
63
|
+
- Create test harness: `chrono_forge-dashboard/test/test_helper.rb`, `test/internal/config/{database.yml,routes.rb}`, `test/internal/db/schema.rb`, `test/smoke_test.rb`
|
|
64
|
+
|
|
65
|
+
**Acceptance Criteria:**
|
|
66
|
+
- [ ] `chrono_forge` gemspec no longer ships `chrono_forge-dashboard/` (excluded from `spec.files`)
|
|
67
|
+
- [ ] `ChronoForge::Dashboard::Engine` loads and isolates the `ChronoForge::Dashboard` namespace
|
|
68
|
+
- [ ] A request to the mounted engine root renders (smoke test green)
|
|
69
|
+
|
|
70
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec rake test` → smoke test passes
|
|
71
|
+
|
|
72
|
+
**Steps:**
|
|
73
|
+
|
|
74
|
+
- [ ] **Step 1: Exclude dashboard dir from the core gem**
|
|
75
|
+
|
|
76
|
+
In `chrono_forge.gemspec`, line 29, add `chrono_forge-dashboard/` to the reject prefixes:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile chrono_forge-dashboard/])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- [ ] **Step 2: Gem metadata files**
|
|
83
|
+
|
|
84
|
+
`chrono_forge-dashboard/lib/chrono_forge/dashboard/version.rb`:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
module ChronoForge
|
|
88
|
+
module Dashboard
|
|
89
|
+
VERSION = "0.1.0"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`chrono_forge-dashboard/chrono_forge-dashboard.gemspec`:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
require_relative "lib/chrono_forge/dashboard/version"
|
|
98
|
+
|
|
99
|
+
Gem::Specification.new do |spec|
|
|
100
|
+
spec.name = "chrono_forge-dashboard"
|
|
101
|
+
spec.version = ChronoForge::Dashboard::VERSION
|
|
102
|
+
spec.authors = ["Stefan Froelich"]
|
|
103
|
+
spec.email = ["sfroelich01@gmail.com"]
|
|
104
|
+
spec.summary = "A mountable Rails dashboard for ChronoForge workflows"
|
|
105
|
+
spec.description = "Visibility and operational controls for ChronoForge: workflow list, step replay timeline, context inspector, periodic-task health, wait-state age, and recovery actions."
|
|
106
|
+
spec.homepage = "https://github.com/radioactive-labs/chrono_forge"
|
|
107
|
+
spec.license = "MIT"
|
|
108
|
+
spec.required_ruby_version = ">= 3.2.2"
|
|
109
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
110
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
111
|
+
|
|
112
|
+
spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "MIT-LICENSE", "README.md"]
|
|
113
|
+
spec.require_paths = ["lib"]
|
|
114
|
+
|
|
115
|
+
spec.add_dependency "chrono_forge"
|
|
116
|
+
spec.add_dependency "railties", ">= 7.1"
|
|
117
|
+
spec.add_dependency "actionpack", ">= 7.1"
|
|
118
|
+
|
|
119
|
+
spec.add_development_dependency "rake"
|
|
120
|
+
spec.add_development_dependency "minitest"
|
|
121
|
+
spec.add_development_dependency "minitest-reporters"
|
|
122
|
+
spec.add_development_dependency "combustion"
|
|
123
|
+
spec.add_development_dependency "rack-test"
|
|
124
|
+
spec.add_development_dependency "sqlite3", "~> 1.4"
|
|
125
|
+
spec.add_development_dependency "standard"
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`chrono_forge-dashboard/Gemfile`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
source "https://rubygems.org"
|
|
133
|
+
gemspec
|
|
134
|
+
gem "chrono_forge", path: ".."
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`chrono_forge-dashboard/Rakefile`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
require "rake/testtask"
|
|
141
|
+
require "standard/rake"
|
|
142
|
+
|
|
143
|
+
Rake::TestTask.new do |t|
|
|
144
|
+
t.libs << "test"
|
|
145
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
146
|
+
t.verbose = true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
task default: %i[test standard]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 3: Engine + entry + routes**
|
|
153
|
+
|
|
154
|
+
`chrono_forge-dashboard/lib/chrono_forge/dashboard/engine.rb`:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
require "rails/engine"
|
|
158
|
+
|
|
159
|
+
module ChronoForge
|
|
160
|
+
module Dashboard
|
|
161
|
+
class Engine < ::Rails::Engine
|
|
162
|
+
isolate_namespace ChronoForge::Dashboard
|
|
163
|
+
|
|
164
|
+
# Engine paths are Zeitwerk-loaded by Rails; nothing else needed here.
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`chrono_forge-dashboard/lib/chrono_forge/dashboard.rb`:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
require "chrono_forge"
|
|
174
|
+
require "chrono_forge/dashboard/version"
|
|
175
|
+
require "chrono_forge/dashboard/engine"
|
|
176
|
+
|
|
177
|
+
module ChronoForge
|
|
178
|
+
module Dashboard
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`chrono_forge-dashboard/config/routes.rb`:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
ChronoForge::Dashboard::Engine.routes.draw do
|
|
187
|
+
root to: "workflows#index"
|
|
188
|
+
resources :workflows, only: %i[index show]
|
|
189
|
+
resources :wait_states, only: :index
|
|
190
|
+
# actions and assets added in later tasks
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/workflows_controller.rb` (stub for the smoke test; fleshed out in Tasks 5/7):
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
module ChronoForge
|
|
198
|
+
module Dashboard
|
|
199
|
+
class WorkflowsController < ActionController::Base
|
|
200
|
+
def index
|
|
201
|
+
render plain: "ChronoForge Dashboard"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 4: Test harness (Combustion dummy mounting the engine)**
|
|
209
|
+
|
|
210
|
+
`chrono_forge-dashboard/test/internal/config/database.yml`:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
test:
|
|
214
|
+
adapter: sqlite3
|
|
215
|
+
database: test/internal/db/combustion_test.sqlite
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`chrono_forge-dashboard/test/internal/config/routes.rb`:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
Rails.application.routes.draw do
|
|
222
|
+
mount ChronoForge::Dashboard::Engine => "/chrono_forge"
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`chrono_forge-dashboard/test/internal/db/schema.rb` — reuse the core's three tables (copy from the core install migration: `chrono_forge_workflows`, `chrono_forge_execution_logs`, `chrono_forge_error_logs` with all columns incl. `locked_by`, `metadata`, `step_name`, `attempt`/`attempts`):
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
ActiveRecord::Schema.define do
|
|
230
|
+
create_table :chrono_forge_workflows do |t|
|
|
231
|
+
t.string :key, null: false
|
|
232
|
+
t.string :job_class, null: false
|
|
233
|
+
t.integer :state, default: 0, null: false
|
|
234
|
+
t.json :context, null: false, default: {}
|
|
235
|
+
t.json :kwargs, null: false, default: {}
|
|
236
|
+
t.json :options, null: false, default: {}
|
|
237
|
+
t.datetime :locked_at
|
|
238
|
+
t.string :locked_by
|
|
239
|
+
t.datetime :started_at
|
|
240
|
+
t.datetime :completed_at
|
|
241
|
+
t.timestamps
|
|
242
|
+
t.index :key, unique: true
|
|
243
|
+
t.index %i[state completed_at]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
create_table :chrono_forge_execution_logs do |t|
|
|
247
|
+
t.references :workflow, null: false
|
|
248
|
+
t.string :step_name, null: false
|
|
249
|
+
t.integer :attempts, default: 0, null: false
|
|
250
|
+
t.integer :state, default: 0, null: false
|
|
251
|
+
t.datetime :started_at
|
|
252
|
+
t.datetime :completed_at
|
|
253
|
+
t.datetime :last_executed_at
|
|
254
|
+
t.string :error_class
|
|
255
|
+
t.text :error_message
|
|
256
|
+
t.json :metadata
|
|
257
|
+
t.timestamps
|
|
258
|
+
t.index %i[workflow_id step_name], unique: true
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
create_table :chrono_forge_error_logs do |t|
|
|
262
|
+
t.references :workflow, null: false
|
|
263
|
+
t.string :step_name
|
|
264
|
+
t.integer :attempt
|
|
265
|
+
t.string :error_class
|
|
266
|
+
t.text :error_message
|
|
267
|
+
t.text :backtrace
|
|
268
|
+
t.json :context
|
|
269
|
+
t.timestamps
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`chrono_forge-dashboard/test/test_helper.rb`:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
require "chrono_forge/dashboard"
|
|
278
|
+
require "minitest/autorun"
|
|
279
|
+
require "minitest/reporters"
|
|
280
|
+
Minitest::Reporters.use!
|
|
281
|
+
|
|
282
|
+
require "combustion"
|
|
283
|
+
Combustion.path = "test/internal"
|
|
284
|
+
Combustion.initialize! :active_record, :action_controller
|
|
285
|
+
|
|
286
|
+
require "rails/test_help"
|
|
287
|
+
require "rack/test"
|
|
288
|
+
|
|
289
|
+
module DashboardTestHelpers
|
|
290
|
+
# Create a workflow row with sensible defaults.
|
|
291
|
+
def create_workflow(key:, state: :idle, job_class: "OrderWorkflow", **attrs)
|
|
292
|
+
ChronoForge::Workflow.create!(
|
|
293
|
+
key: key, job_class: job_class, state: ChronoForge::Workflow.states[state],
|
|
294
|
+
context: {}, kwargs: {}, options: {}, started_at: Time.current, **attrs
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`chrono_forge-dashboard/test/smoke_test.rb`:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
require "test_helper"
|
|
304
|
+
|
|
305
|
+
class SmokeTest < ActionDispatch::IntegrationTest
|
|
306
|
+
test "engine root renders" do
|
|
307
|
+
get "/chrono_forge"
|
|
308
|
+
assert_response :success
|
|
309
|
+
assert_match "ChronoForge Dashboard", response.body
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 5: Install + run**
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
cd chrono_forge-dashboard && bundle install && bundle exec rake test
|
|
318
|
+
```
|
|
319
|
+
Expected: smoke test passes (1 run, 0 failures). If the core gem isn't found, confirm the `gem "chrono_forge", path: ".."` line.
|
|
320
|
+
|
|
321
|
+
- [ ] **Step 6: Commit**
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
git add chrono_forge.gemspec chrono_forge-dashboard
|
|
325
|
+
git commit -m "feat(dashboard): engine skeleton, gemspec, and Combustion test harness"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```json:metadata
|
|
329
|
+
{"files": ["chrono_forge.gemspec", "chrono_forge-dashboard/chrono_forge-dashboard.gemspec", "chrono_forge-dashboard/lib/chrono_forge/dashboard.rb", "chrono_forge-dashboard/lib/chrono_forge/dashboard/engine.rb", "chrono_forge-dashboard/config/routes.rb", "chrono_forge-dashboard/test/test_helper.rb", "chrono_forge-dashboard/test/internal/db/schema.rb", "chrono_forge-dashboard/test/smoke_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec rake test", "acceptanceCriteria": ["core gemspec excludes chrono_forge-dashboard/", "engine loads + isolates namespace", "smoke test green"], "requiresUserVerification": false}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
### Task 2: Configuration + fail-closed auth
|
|
335
|
+
|
|
336
|
+
**Goal:** `ChronoForge::Dashboard` config object and a `BaseController` whose `authenticate!` resolves hook → http_basic → `:none` → raise.
|
|
337
|
+
|
|
338
|
+
**Files:**
|
|
339
|
+
- Create: `lib/chrono_forge/dashboard/configuration.rb`, `app/controllers/chrono_forge/dashboard/base_controller.rb`, `test/auth_test.rb`
|
|
340
|
+
- Modify: `lib/chrono_forge/dashboard.rb` (expose `.configure`/`.config`), `app/controllers/chrono_forge/dashboard/workflows_controller.rb` (inherit `BaseController`)
|
|
341
|
+
|
|
342
|
+
**Acceptance Criteria:**
|
|
343
|
+
- [ ] Mounting + requesting with no auth configured raises `AuthenticationNotConfigured`
|
|
344
|
+
- [ ] `http_basic` accepts correct creds, 401s wrong creds
|
|
345
|
+
- [ ] `authenticate` hook runs and can deny
|
|
346
|
+
- [ ] `authentication = :none` permits
|
|
347
|
+
|
|
348
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/auth_test.rb`
|
|
349
|
+
|
|
350
|
+
**Steps:**
|
|
351
|
+
|
|
352
|
+
- [ ] **Step 1: Write the failing tests** — `chrono_forge-dashboard/test/auth_test.rb`:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
require "test_helper"
|
|
356
|
+
|
|
357
|
+
class AuthTest < ActionDispatch::IntegrationTest
|
|
358
|
+
def teardown
|
|
359
|
+
ChronoForge::Dashboard.reset_configuration!
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
test "raises when nothing is configured" do
|
|
363
|
+
assert_raises(ChronoForge::Dashboard::AuthenticationNotConfigured) { get "/chrono_forge" }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
test "http basic accepts correct credentials" do
|
|
367
|
+
ChronoForge::Dashboard.configure { |c| c.http_basic = { username: "a", password: "b" } }
|
|
368
|
+
get "/chrono_forge", headers: { "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials("a", "b") }
|
|
369
|
+
assert_response :success
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
test "http basic rejects wrong credentials" do
|
|
373
|
+
ChronoForge::Dashboard.configure { |c| c.http_basic = { username: "a", password: "b" } }
|
|
374
|
+
get "/chrono_forge", headers: { "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials("a", "x") }
|
|
375
|
+
assert_response :unauthorized
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
test "hook can deny" do
|
|
379
|
+
ChronoForge::Dashboard.configure { |c| c.authenticate { |ctrl| ctrl.head(:forbidden) } }
|
|
380
|
+
get "/chrono_forge"
|
|
381
|
+
assert_response :forbidden
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
test "authentication :none permits" do
|
|
385
|
+
ChronoForge::Dashboard.configure { |c| c.authentication = :none }
|
|
386
|
+
get "/chrono_forge"
|
|
387
|
+
assert_response :success
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
- [ ] **Step 2: Run — confirm failures** (`AuthenticationNotConfigured` / `reset_configuration!` undefined).
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 3: Configuration object** — `lib/chrono_forge/dashboard/configuration.rb`:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
module ChronoForge
|
|
398
|
+
module Dashboard
|
|
399
|
+
class AuthenticationNotConfigured < StandardError
|
|
400
|
+
MESSAGE = <<~MSG.freeze
|
|
401
|
+
ChronoForge::Dashboard has no authentication configured. Do one of:
|
|
402
|
+
- ChronoForge::Dashboard.configure { |c| c.http_basic = { username:, password: } }
|
|
403
|
+
- ChronoForge::Dashboard.configure { |c| c.authenticate { |controller| ... } }
|
|
404
|
+
- ChronoForge::Dashboard.configure { |c| c.authentication = :none } # then guard the mount with your own routing constraint
|
|
405
|
+
MSG
|
|
406
|
+
def initialize(msg = MESSAGE) = super
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
class Configuration
|
|
410
|
+
attr_accessor :http_basic, :authentication
|
|
411
|
+
attr_reader :auth_hook
|
|
412
|
+
attr_accessor :polling_interval, :page_size, :long_wait_threshold
|
|
413
|
+
|
|
414
|
+
def initialize
|
|
415
|
+
@http_basic = nil
|
|
416
|
+
@authentication = nil # nil = unconfigured (fail closed); :none = explicitly open
|
|
417
|
+
@auth_hook = nil
|
|
418
|
+
@polling_interval = 5
|
|
419
|
+
@page_size = 50
|
|
420
|
+
@long_wait_threshold = 3600 # seconds
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def authenticate(&block) = @auth_hook = block
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- [ ] **Step 4: Entry exposes config** — append to `lib/chrono_forge/dashboard.rb`:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
require "chrono_forge/dashboard/configuration"
|
|
433
|
+
|
|
434
|
+
module ChronoForge
|
|
435
|
+
module Dashboard
|
|
436
|
+
class << self
|
|
437
|
+
def config = (@config ||= Configuration.new)
|
|
438
|
+
def configure = yield(config)
|
|
439
|
+
def reset_configuration! = @config = Configuration.new
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
- [ ] **Step 5: BaseController** — `app/controllers/chrono_forge/dashboard/base_controller.rb`:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
module ChronoForge
|
|
449
|
+
module Dashboard
|
|
450
|
+
class BaseController < ActionController::Base
|
|
451
|
+
protect_from_forgery with: :exception
|
|
452
|
+
before_action :authenticate!
|
|
453
|
+
|
|
454
|
+
private
|
|
455
|
+
|
|
456
|
+
def authenticate!
|
|
457
|
+
config = ChronoForge::Dashboard.config
|
|
458
|
+
if config.auth_hook
|
|
459
|
+
instance_exec(self, &config.auth_hook)
|
|
460
|
+
elsif config.http_basic
|
|
461
|
+
creds = config.http_basic
|
|
462
|
+
authenticate_or_request_with_http_basic("ChronoForge") do |u, p|
|
|
463
|
+
ActiveSupport::SecurityUtils.secure_compare(u, creds[:username]) &
|
|
464
|
+
ActiveSupport::SecurityUtils.secure_compare(p, creds[:password])
|
|
465
|
+
end
|
|
466
|
+
elsif config.authentication == :none
|
|
467
|
+
true
|
|
468
|
+
else
|
|
469
|
+
raise AuthenticationNotConfigured
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Update `WorkflowsController` to inherit it (keep the stub `index` for now):
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
module ChronoForge
|
|
481
|
+
module Dashboard
|
|
482
|
+
class WorkflowsController < BaseController
|
|
483
|
+
def index
|
|
484
|
+
render plain: "ChronoForge Dashboard"
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 6: Run — confirm green**, then full suite (`bundle exec rake test`).
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 7: Commit**
|
|
494
|
+
|
|
495
|
+
```bash
|
|
496
|
+
git add chrono_forge-dashboard
|
|
497
|
+
git commit -m "feat(dashboard): fail-closed pluggable authentication"
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
```json:metadata
|
|
501
|
+
{"files": ["chrono_forge-dashboard/lib/chrono_forge/dashboard/configuration.rb", "chrono_forge-dashboard/lib/chrono_forge/dashboard.rb", "chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/base_controller.rb", "chrono_forge-dashboard/test/auth_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/auth_test.rb", "acceptanceCriteria": ["raises unconfigured", "http_basic accept/reject", "hook denies", ":none permits"], "requiresUserVerification": false}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
### Task 3: Step-name parser
|
|
507
|
+
|
|
508
|
+
**Goal:** Pure parser decoding core step names into a struct.
|
|
509
|
+
|
|
510
|
+
**Files:** Create `lib/chrono_forge/dashboard/step_name_parser.rb`, `test/step_name_parser_test.rb`
|
|
511
|
+
|
|
512
|
+
**Acceptance Criteria:**
|
|
513
|
+
- [ ] Parses `durably_execute$x` → kind `:execute`, name `"x"`
|
|
514
|
+
- [ ] Parses `wait_until$cond` → kind `:wait`, name `"cond"`
|
|
515
|
+
- [ ] Parses `durably_repeat$x` → kind `:repeat_coordination`, name `"x"`, no timestamp
|
|
516
|
+
- [ ] Parses `durably_repeat$x$1717000000` → kind `:repeat_run`, name `"x"`, timestamp Integer
|
|
517
|
+
- [ ] Unrecognized names → kind `:unknown`, raw preserved, never raises
|
|
518
|
+
|
|
519
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/step_name_parser_test.rb`
|
|
520
|
+
|
|
521
|
+
**Steps:**
|
|
522
|
+
|
|
523
|
+
- [ ] **Step 1: Failing test** — `test/step_name_parser_test.rb`:
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
require "test_helper"
|
|
527
|
+
|
|
528
|
+
class StepNameParserTest < ActiveSupport::TestCase
|
|
529
|
+
P = ChronoForge::Dashboard::StepNameParser
|
|
530
|
+
|
|
531
|
+
test "durably_execute" do
|
|
532
|
+
r = P.parse("durably_execute$charge_card")
|
|
533
|
+
assert_equal :execute, r.kind
|
|
534
|
+
assert_equal "charge_card", r.name
|
|
535
|
+
assert_nil r.timestamp
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
test "wait_until" do
|
|
539
|
+
assert_equal :wait, P.parse("wait_until$paid?").kind
|
|
540
|
+
assert_equal "paid?", P.parse("wait_until$paid?").name
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
test "durably_repeat coordination" do
|
|
544
|
+
r = P.parse("durably_repeat$remind")
|
|
545
|
+
assert_equal :repeat_coordination, r.kind
|
|
546
|
+
assert_equal "remind", r.name
|
|
547
|
+
assert_nil r.timestamp
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
test "durably_repeat run" do
|
|
551
|
+
r = P.parse("durably_repeat$remind$1717000000")
|
|
552
|
+
assert_equal :repeat_run, r.kind
|
|
553
|
+
assert_equal "remind", r.name
|
|
554
|
+
assert_equal 1717000000, r.timestamp
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
test "unknown is preserved, never raises" do
|
|
558
|
+
r = P.parse("legacy_thing")
|
|
559
|
+
assert_equal :unknown, r.kind
|
|
560
|
+
assert_equal "legacy_thing", r.raw
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
566
|
+
|
|
567
|
+
- [ ] **Step 3: Implement** — `lib/chrono_forge/dashboard/step_name_parser.rb`:
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
module ChronoForge
|
|
571
|
+
module Dashboard
|
|
572
|
+
module StepNameParser
|
|
573
|
+
Parsed = Struct.new(:kind, :name, :timestamp, :raw, keyword_init: true)
|
|
574
|
+
DELIM = "$"
|
|
575
|
+
|
|
576
|
+
def self.parse(step_name)
|
|
577
|
+
prefix, name, ts = step_name.to_s.split(DELIM, 3)
|
|
578
|
+
case prefix
|
|
579
|
+
when "durably_execute" then Parsed.new(kind: :execute, name: name, raw: step_name)
|
|
580
|
+
when "wait_until" then Parsed.new(kind: :wait, name: name, raw: step_name)
|
|
581
|
+
when "durably_repeat"
|
|
582
|
+
if ts
|
|
583
|
+
Parsed.new(kind: :repeat_run, name: name, timestamp: Integer(ts, exception: false), raw: step_name)
|
|
584
|
+
else
|
|
585
|
+
Parsed.new(kind: :repeat_coordination, name: name, raw: step_name)
|
|
586
|
+
end
|
|
587
|
+
else
|
|
588
|
+
Parsed.new(kind: :unknown, name: step_name, raw: step_name)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
- [ ] **Step 4: Run — green.**
|
|
597
|
+
|
|
598
|
+
- [ ] **Step 5: Commit**
|
|
599
|
+
|
|
600
|
+
```bash
|
|
601
|
+
git add chrono_forge-dashboard
|
|
602
|
+
git commit -m "feat(dashboard): step-name parser"
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
```json:metadata
|
|
606
|
+
{"files": ["chrono_forge-dashboard/lib/chrono_forge/dashboard/step_name_parser.rb", "chrono_forge-dashboard/test/step_name_parser_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/step_name_parser_test.rb", "acceptanceCriteria": ["execute/wait/repeat-coord/repeat-run parsed", "unknown preserved, never raises"], "requiresUserVerification": false}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
### Task 4: Query objects (WorkflowsQuery, StatsQuery)
|
|
612
|
+
|
|
613
|
+
**Goal:** Filter/paginate the workflow list and compute state counts in one grouped query.
|
|
614
|
+
|
|
615
|
+
**Files:** Create `app/queries/chrono_forge/dashboard/workflows_query.rb`, `app/queries/chrono_forge/dashboard/stats_query.rb`, `test/queries_test.rb`
|
|
616
|
+
|
|
617
|
+
**Acceptance Criteria:**
|
|
618
|
+
- [ ] `WorkflowsQuery` filters by `state`, `job_class`, `key` (substring), and `created` date range; paginates by `page`/`per`
|
|
619
|
+
- [ ] Blank filters are ignored (return all)
|
|
620
|
+
- [ ] `StatsQuery#counts` returns a hash of state-name → count for every state (zeros included)
|
|
621
|
+
|
|
622
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/queries_test.rb`
|
|
623
|
+
|
|
624
|
+
**Steps:**
|
|
625
|
+
|
|
626
|
+
- [ ] **Step 1: Failing test** — `test/queries_test.rb`:
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
require "test_helper"
|
|
630
|
+
|
|
631
|
+
class QueriesTest < ActiveSupport::TestCase
|
|
632
|
+
include DashboardTestHelpers
|
|
633
|
+
|
|
634
|
+
setup do
|
|
635
|
+
create_workflow(key: "a", state: :failed, job_class: "OrderWorkflow")
|
|
636
|
+
create_workflow(key: "b", state: :completed, job_class: "OrderWorkflow")
|
|
637
|
+
create_workflow(key: "c", state: :failed, job_class: "PayoutWorkflow")
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
test "filters by state" do
|
|
641
|
+
q = ChronoForge::Dashboard::WorkflowsQuery.new(state: "failed")
|
|
642
|
+
assert_equal %w[a c].sort, q.results.map(&:key).sort
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
test "filters by job_class and key substring" do
|
|
646
|
+
assert_equal ["a"], ChronoForge::Dashboard::WorkflowsQuery.new(job_class: "OrderWorkflow", key: "a").results.map(&:key)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
test "blank filters return all" do
|
|
650
|
+
assert_equal 3, ChronoForge::Dashboard::WorkflowsQuery.new(state: "", job_class: nil).results.count
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
test "paginates" do
|
|
654
|
+
q = ChronoForge::Dashboard::WorkflowsQuery.new(page: 1, per: 2)
|
|
655
|
+
assert_equal 2, q.results.to_a.size
|
|
656
|
+
assert_equal 3, q.total_count
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
test "stats counts every state" do
|
|
660
|
+
counts = ChronoForge::Dashboard::StatsQuery.new.counts
|
|
661
|
+
assert_equal 2, counts["failed"]
|
|
662
|
+
assert_equal 1, counts["completed"]
|
|
663
|
+
assert_equal 0, counts["running"]
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 3: Implement** — `app/queries/chrono_forge/dashboard/workflows_query.rb`:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
module ChronoForge
|
|
674
|
+
module Dashboard
|
|
675
|
+
class WorkflowsQuery
|
|
676
|
+
DEFAULT_PER = 50
|
|
677
|
+
|
|
678
|
+
def initialize(state: nil, job_class: nil, key: nil, created_from: nil, created_to: nil, page: 1, per: DEFAULT_PER)
|
|
679
|
+
@state = state.presence
|
|
680
|
+
@job_class = job_class.presence
|
|
681
|
+
@key = key.presence
|
|
682
|
+
@created_from = created_from.presence
|
|
683
|
+
@created_to = created_to.presence
|
|
684
|
+
@page = [page.to_i, 1].max
|
|
685
|
+
@per = [per.to_i, 1].max
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def results = scope.order(created_at: :desc).limit(@per).offset((@page - 1) * @per)
|
|
689
|
+
|
|
690
|
+
def total_count = scope.count
|
|
691
|
+
|
|
692
|
+
def page = @page
|
|
693
|
+
def per = @per
|
|
694
|
+
|
|
695
|
+
private
|
|
696
|
+
|
|
697
|
+
def scope
|
|
698
|
+
s = ChronoForge::Workflow.all
|
|
699
|
+
s = s.where(state: ChronoForge::Workflow.states[@state]) if @state && ChronoForge::Workflow.states.key?(@state)
|
|
700
|
+
s = s.where(job_class: @job_class) if @job_class
|
|
701
|
+
s = s.where("key LIKE ?", "%#{@key}%") if @key
|
|
702
|
+
s = s.where("created_at >= ?", @created_from) if @created_from
|
|
703
|
+
s = s.where("created_at <= ?", @created_to) if @created_to
|
|
704
|
+
s
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
`app/queries/chrono_forge/dashboard/stats_query.rb`:
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
module ChronoForge
|
|
715
|
+
module Dashboard
|
|
716
|
+
class StatsQuery
|
|
717
|
+
# Hash of state-name => count, zero-filled for every state.
|
|
718
|
+
def counts
|
|
719
|
+
grouped = ChronoForge::Workflow.group(:state).count # {0=>n, ...} keyed by enum int
|
|
720
|
+
by_name = grouped.transform_keys { |i| ChronoForge::Workflow.states.key(i) }
|
|
721
|
+
ChronoForge::Workflow.states.keys.index_with { |name| by_name[name].to_i }
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
- [ ] **Step 4: Run — green.**
|
|
729
|
+
|
|
730
|
+
- [ ] **Step 5: Commit**
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
git add chrono_forge-dashboard
|
|
734
|
+
git commit -m "feat(dashboard): workflows + stats query objects"
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
```json:metadata
|
|
738
|
+
{"files": ["chrono_forge-dashboard/app/queries/chrono_forge/dashboard/workflows_query.rb", "chrono_forge-dashboard/app/queries/chrono_forge/dashboard/stats_query.rb", "chrono_forge-dashboard/test/queries_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/queries_test.rb", "acceptanceCriteria": ["state/job_class/key/date filters", "blank ignored", "pagination + total_count", "stats zero-filled"], "requiresUserVerification": false}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
### Task 5: Workflows#index — list, filters, stats, pagination
|
|
744
|
+
|
|
745
|
+
**Goal:** The list page with state badges, filters, a stats header, and pagination, plus its controller tests.
|
|
746
|
+
|
|
747
|
+
**Files:**
|
|
748
|
+
- Modify: `app/controllers/chrono_forge/dashboard/workflows_controller.rb`
|
|
749
|
+
- Create: layout `app/views/layouts/chrono_forge/dashboard/application.html.erb`; `app/views/chrono_forge/dashboard/workflows/index.html.erb` + `_stats.html.erb`, `_filters.html.erb`, `_workflow_row.html.erb`; helper `app/helpers/chrono_forge/dashboard/dashboard_helper.rb`; `test/workflows_index_test.rb`
|
|
750
|
+
|
|
751
|
+
**Acceptance Criteria:**
|
|
752
|
+
- [ ] `index` lists workflows newest-first with a state badge, key, class, timestamps
|
|
753
|
+
- [ ] Filtering by `state`/`job_class`/`key` narrows the list
|
|
754
|
+
- [ ] Stats header shows per-state counts
|
|
755
|
+
- [ ] Pagination links present when `total_count > per`
|
|
756
|
+
|
|
757
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/workflows_index_test.rb`
|
|
758
|
+
|
|
759
|
+
**Steps:**
|
|
760
|
+
|
|
761
|
+
- [ ] **Step 1: Failing test** — `test/workflows_index_test.rb`:
|
|
762
|
+
|
|
763
|
+
```ruby
|
|
764
|
+
require "test_helper"
|
|
765
|
+
|
|
766
|
+
class WorkflowsIndexTest < ActionDispatch::IntegrationTest
|
|
767
|
+
include DashboardTestHelpers
|
|
768
|
+
|
|
769
|
+
setup do
|
|
770
|
+
ChronoForge::Dashboard.configure { |c| c.authentication = :none }
|
|
771
|
+
create_workflow(key: "ord-1", state: :failed, job_class: "OrderWorkflow")
|
|
772
|
+
create_workflow(key: "pay-1", state: :completed, job_class: "PayoutWorkflow")
|
|
773
|
+
end
|
|
774
|
+
teardown { ChronoForge::Dashboard.reset_configuration! }
|
|
775
|
+
|
|
776
|
+
test "lists workflows with badges" do
|
|
777
|
+
get "/chrono_forge/workflows"
|
|
778
|
+
assert_response :success
|
|
779
|
+
assert_match "ord-1", response.body
|
|
780
|
+
assert_match "pay-1", response.body
|
|
781
|
+
assert_match "cf-badge--failed", response.body
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
test "filters by state" do
|
|
785
|
+
get "/chrono_forge/workflows", params: { state: "failed" }
|
|
786
|
+
assert_match "ord-1", response.body
|
|
787
|
+
refute_match "pay-1", response.body
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
test "stats header shows counts" do
|
|
791
|
+
get "/chrono_forge/workflows"
|
|
792
|
+
assert_match "cf-stat", response.body
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
798
|
+
|
|
799
|
+
- [ ] **Step 3: Controller**:
|
|
800
|
+
|
|
801
|
+
```ruby
|
|
802
|
+
module ChronoForge
|
|
803
|
+
module Dashboard
|
|
804
|
+
class WorkflowsController < BaseController
|
|
805
|
+
def index
|
|
806
|
+
@query = WorkflowsQuery.new(**list_params)
|
|
807
|
+
@workflows = @query.results
|
|
808
|
+
@stats = StatsQuery.new.counts
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
private
|
|
812
|
+
|
|
813
|
+
def list_params
|
|
814
|
+
params.permit(:state, :job_class, :key, :created_from, :created_to, :page)
|
|
815
|
+
.to_h.symbolize_keys.merge(per: ChronoForge::Dashboard.config.page_size)
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
- [ ] **Step 4: Helper** — `app/helpers/chrono_forge/dashboard/dashboard_helper.rb`:
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
module ChronoForge
|
|
826
|
+
module Dashboard
|
|
827
|
+
module DashboardHelper
|
|
828
|
+
def cf_badge(state)
|
|
829
|
+
tag.span(state, class: "cf-badge cf-badge--#{state}")
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def cf_duration(from, to)
|
|
833
|
+
return "—" unless from && to
|
|
834
|
+
secs = (to - from).to_i
|
|
835
|
+
"#{secs}s"
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 5: Layout** — `app/views/layouts/chrono_forge/dashboard/application.html.erb`:
|
|
843
|
+
|
|
844
|
+
```erb
|
|
845
|
+
<!DOCTYPE html>
|
|
846
|
+
<html>
|
|
847
|
+
<head>
|
|
848
|
+
<title>ChronoForge</title>
|
|
849
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
850
|
+
<%= csrf_meta_tags %>
|
|
851
|
+
<link rel="stylesheet" href="<%= main_app.respond_to?(:cf_dashboard_css_path) ? cf_dashboard_css_path : "#{request.script_name}/assets/dashboard.css" %>">
|
|
852
|
+
</head>
|
|
853
|
+
<body data-poll-interval="<%= ChronoForge::Dashboard.config.polling_interval %>">
|
|
854
|
+
<header class="cf-header"><a href="<%= root_path %>">ChronoForge</a></header>
|
|
855
|
+
<main class="cf-main"><%= yield %></main>
|
|
856
|
+
<script src="<%= request.script_name %>/assets/dashboard.js"></script>
|
|
857
|
+
</body>
|
|
858
|
+
</html>
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
(The asset routes are added in Task 10; until then the `<link>`/`<script>` 404 harmlessly in tests, which assert on body text, not assets.)
|
|
862
|
+
|
|
863
|
+
- [ ] **Step 6: Views** — `index.html.erb`:
|
|
864
|
+
|
|
865
|
+
```erb
|
|
866
|
+
<%= render "stats", stats: @stats %>
|
|
867
|
+
<%= render "filters", query: @query %>
|
|
868
|
+
<table class="cf-table">
|
|
869
|
+
<thead><tr><th>State</th><th>Key</th><th>Class</th><th>Started</th><th>Updated</th></tr></thead>
|
|
870
|
+
<tbody>
|
|
871
|
+
<%= render partial: "workflow_row", collection: @workflows, as: :workflow %>
|
|
872
|
+
</tbody>
|
|
873
|
+
</table>
|
|
874
|
+
<nav class="cf-pager">
|
|
875
|
+
<% if @query.page > 1 %><%= link_to "‹ Prev", request.params.merge(page: @query.page - 1) %><% end %>
|
|
876
|
+
<% if @query.total_count > @query.page * @query.per %><%= link_to "Next ›", request.params.merge(page: @query.page + 1) %><% end %>
|
|
877
|
+
</nav>
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
`_stats.html.erb`:
|
|
881
|
+
|
|
882
|
+
```erb
|
|
883
|
+
<div class="cf-stats">
|
|
884
|
+
<% stats.each do |state, count| %>
|
|
885
|
+
<span class="cf-stat cf-stat--<%= state %>"><%= cf_badge(state) %> <%= count %></span>
|
|
886
|
+
<% end %>
|
|
887
|
+
</div>
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
`_filters.html.erb`:
|
|
891
|
+
|
|
892
|
+
```erb
|
|
893
|
+
<%= form_with url: workflows_path, method: :get, class: "cf-filters" do |f| %>
|
|
894
|
+
<%= f.select :state, ["", *ChronoForge::Workflow.states.keys], { selected: params[:state] } %>
|
|
895
|
+
<%= f.text_field :job_class, value: params[:job_class], placeholder: "Job class" %>
|
|
896
|
+
<%= f.text_field :key, value: params[:key], placeholder: "Key" %>
|
|
897
|
+
<%= f.submit "Filter" %>
|
|
898
|
+
<% end %>
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
`_workflow_row.html.erb`:
|
|
902
|
+
|
|
903
|
+
```erb
|
|
904
|
+
<tr>
|
|
905
|
+
<td><%= cf_badge(workflow.state) %></td>
|
|
906
|
+
<td><%= link_to workflow.key, workflow_path(workflow) %></td>
|
|
907
|
+
<td><%= workflow.job_class %></td>
|
|
908
|
+
<td><%= workflow.started_at&.iso8601 %></td>
|
|
909
|
+
<td><%= workflow.updated_at&.iso8601 %></td>
|
|
910
|
+
</tr>
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
- [ ] **Step 7: Run — green**, then full suite.
|
|
914
|
+
|
|
915
|
+
- [ ] **Step 8: Commit**
|
|
916
|
+
|
|
917
|
+
```bash
|
|
918
|
+
git add chrono_forge-dashboard
|
|
919
|
+
git commit -m "feat(dashboard): workflow list with filters, stats, pagination"
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
```json:metadata
|
|
923
|
+
{"files": ["chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/workflows_controller.rb", "chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/index.html.erb", "chrono_forge-dashboard/app/views/layouts/chrono_forge/dashboard/application.html.erb", "chrono_forge-dashboard/app/helpers/chrono_forge/dashboard/dashboard_helper.rb", "chrono_forge-dashboard/test/workflows_index_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/workflows_index_test.rb", "acceptanceCriteria": ["list with badges", "state filter narrows", "stats header", "pager"], "requiresUserVerification": false}
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
### Task 6: Timeline + Context presenters
|
|
929
|
+
|
|
930
|
+
**Goal:** Build the replay timeline from `execution_logs` (repetitions rolled under their coordination log) and render context as a tree model.
|
|
931
|
+
|
|
932
|
+
**Files:** Create `app/presenters/chrono_forge/dashboard/timeline_presenter.rb`, `app/presenters/chrono_forge/dashboard/context_presenter.rb`, `test/presenters_test.rb`
|
|
933
|
+
|
|
934
|
+
**Acceptance Criteria:**
|
|
935
|
+
- [ ] Timeline orders entries by `started_at`; each has `kind`, `status`, `attempts`, `started_at`, `completed_at`, `error`
|
|
936
|
+
- [ ] `durably_repeat` runs are grouped as children of their coordination entry
|
|
937
|
+
- [ ] Current position = last failed/running entry, else active wait, else nil
|
|
938
|
+
- [ ] `ContextPresenter#nodes` yields `{key, value, type}` and a total byte size
|
|
939
|
+
|
|
940
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/presenters_test.rb`
|
|
941
|
+
|
|
942
|
+
**Steps:**
|
|
943
|
+
|
|
944
|
+
- [ ] **Step 1: Failing test** — `test/presenters_test.rb`:
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
require "test_helper"
|
|
948
|
+
|
|
949
|
+
class PresentersTest < ActiveSupport::TestCase
|
|
950
|
+
include DashboardTestHelpers
|
|
951
|
+
|
|
952
|
+
def log(wf, step_name, state:, attempts: 1, started_at: Time.current, completed_at: nil, **attrs)
|
|
953
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: step_name,
|
|
954
|
+
state: ChronoForge::ExecutionLog.states[state], attempts: attempts,
|
|
955
|
+
started_at: started_at, completed_at: completed_at, **attrs)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
test "timeline orders and rolls up repetitions" do
|
|
959
|
+
wf = create_workflow(key: "t1")
|
|
960
|
+
log(wf, "durably_execute$validate", state: :completed, started_at: 3.minutes.ago)
|
|
961
|
+
coord = log(wf, "durably_repeat$remind", state: :pending, started_at: 2.minutes.ago)
|
|
962
|
+
log(wf, "durably_repeat$remind$1717000000", state: :completed, started_at: 1.minute.ago)
|
|
963
|
+
|
|
964
|
+
tl = ChronoForge::Dashboard::TimelinePresenter.new(wf)
|
|
965
|
+
kinds = tl.entries.map(&:kind)
|
|
966
|
+
assert_equal :execute, kinds.first
|
|
967
|
+
repeat = tl.entries.find { |e| e.kind == :repeat_coordination }
|
|
968
|
+
assert_equal 1, repeat.runs.size
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
test "current position is the failed step" do
|
|
972
|
+
wf = create_workflow(key: "t2", state: :failed)
|
|
973
|
+
log(wf, "durably_execute$a", state: :completed, started_at: 2.minutes.ago)
|
|
974
|
+
failed = log(wf, "durably_execute$b", state: :failed, started_at: 1.minute.ago, error_class: "Boom")
|
|
975
|
+
tl = ChronoForge::Dashboard::TimelinePresenter.new(wf)
|
|
976
|
+
assert_equal failed.id, tl.current_position.id
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
test "context presenter exposes typed nodes and size" do
|
|
980
|
+
wf = create_workflow(key: "t3", context: { "amount" => 5, "intl" => true })
|
|
981
|
+
cp = ChronoForge::Dashboard::ContextPresenter.new(wf)
|
|
982
|
+
types = cp.nodes.map { |n| [n[:key], n[:type]] }.to_h
|
|
983
|
+
assert_equal "Integer", types["amount"]
|
|
984
|
+
assert_equal "TrueClass", types["intl"]
|
|
985
|
+
assert_operator cp.byte_size, :>, 0
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
991
|
+
|
|
992
|
+
- [ ] **Step 3: Implement** — `app/presenters/chrono_forge/dashboard/timeline_presenter.rb`:
|
|
993
|
+
|
|
994
|
+
```ruby
|
|
995
|
+
module ChronoForge
|
|
996
|
+
module Dashboard
|
|
997
|
+
class TimelinePresenter
|
|
998
|
+
Entry = Struct.new(:id, :kind, :name, :status, :attempts, :started_at, :completed_at, :error, :runs, keyword_init: true)
|
|
999
|
+
|
|
1000
|
+
def initialize(workflow) = @workflow = workflow
|
|
1001
|
+
|
|
1002
|
+
# Ordered timeline; repeat_run logs nested under their coordination entry.
|
|
1003
|
+
def entries
|
|
1004
|
+
@entries ||= build
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# The log row representing where the workflow currently sits.
|
|
1008
|
+
def current_position
|
|
1009
|
+
logs = ordered_logs
|
|
1010
|
+
logs.reverse.find { |l| l.failed? } ||
|
|
1011
|
+
logs.reverse.find { |l| l.pending? && StepNameParser.parse(l.step_name).kind == :wait } ||
|
|
1012
|
+
logs.last
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
private
|
|
1016
|
+
|
|
1017
|
+
def ordered_logs
|
|
1018
|
+
@ordered_logs ||= @workflow.execution_logs.order(Arel.sql("started_at, id")).to_a
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def build
|
|
1022
|
+
coord_by_name = {}
|
|
1023
|
+
top = []
|
|
1024
|
+
ordered_logs.each do |l|
|
|
1025
|
+
p = StepNameParser.parse(l.step_name)
|
|
1026
|
+
entry = Entry.new(id: l.id, kind: p.kind, name: p.name, status: l.state,
|
|
1027
|
+
attempts: l.attempts, started_at: l.started_at, completed_at: l.completed_at,
|
|
1028
|
+
error: l.error_class, runs: [])
|
|
1029
|
+
if p.kind == :repeat_coordination
|
|
1030
|
+
coord_by_name[p.name] = entry
|
|
1031
|
+
top << entry
|
|
1032
|
+
elsif p.kind == :repeat_run && (parent = coord_by_name[p.name])
|
|
1033
|
+
parent.runs << entry
|
|
1034
|
+
else
|
|
1035
|
+
top << entry
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
top
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
`app/presenters/chrono_forge/dashboard/context_presenter.rb`:
|
|
1046
|
+
|
|
1047
|
+
```ruby
|
|
1048
|
+
module ChronoForge
|
|
1049
|
+
module Dashboard
|
|
1050
|
+
class ContextPresenter
|
|
1051
|
+
MAX_VALUE_BYTES = 16.kilobytes
|
|
1052
|
+
|
|
1053
|
+
def initialize(workflow) = @workflow = workflow
|
|
1054
|
+
|
|
1055
|
+
def nodes
|
|
1056
|
+
context.map { |k, v| { key: k, value: v, type: v.class.name, bytes: v.to_json.bytesize } }
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def byte_size = context.to_json.bytesize
|
|
1060
|
+
|
|
1061
|
+
private
|
|
1062
|
+
|
|
1063
|
+
def context = @workflow.context || {}
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
- [ ] **Step 4: Run — green.**
|
|
1070
|
+
|
|
1071
|
+
- [ ] **Step 5: Commit**
|
|
1072
|
+
|
|
1073
|
+
```bash
|
|
1074
|
+
git add chrono_forge-dashboard
|
|
1075
|
+
git commit -m "feat(dashboard): timeline and context presenters"
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
```json:metadata
|
|
1079
|
+
{"files": ["chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/timeline_presenter.rb", "chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/context_presenter.rb", "chrono_forge-dashboard/test/presenters_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/presenters_test.rb", "acceptanceCriteria": ["timeline order + repetition rollup", "current position", "typed context nodes + size"], "requiresUserVerification": false}
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
---
|
|
1083
|
+
|
|
1084
|
+
### Task 7: Workflows#show — timeline, context tree, errors, wait callout
|
|
1085
|
+
|
|
1086
|
+
**Goal:** The detail page wiring the presenters and error logs into the view.
|
|
1087
|
+
|
|
1088
|
+
**Files:**
|
|
1089
|
+
- Modify: `app/controllers/chrono_forge/dashboard/workflows_controller.rb` (`show`)
|
|
1090
|
+
- Create: `show.html.erb` + `_timeline.html.erb`, `_context_tree.html.erb`, `_errors.html.erb`, `_wait_callout.html.erb`; `test/workflows_show_test.rb`
|
|
1091
|
+
|
|
1092
|
+
**Acceptance Criteria:**
|
|
1093
|
+
- [ ] `show` renders the timeline (one node per step, repetitions nested), context tree, and error log
|
|
1094
|
+
- [ ] An idle workflow waiting on a `wait_until` shows the wait callout with age + timeout
|
|
1095
|
+
- [ ] Missing/unknown step names render without raising
|
|
1096
|
+
|
|
1097
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/workflows_show_test.rb`
|
|
1098
|
+
|
|
1099
|
+
**Steps:**
|
|
1100
|
+
|
|
1101
|
+
- [ ] **Step 1: Failing test** — `test/workflows_show_test.rb`:
|
|
1102
|
+
|
|
1103
|
+
```ruby
|
|
1104
|
+
require "test_helper"
|
|
1105
|
+
|
|
1106
|
+
class WorkflowsShowTest < ActionDispatch::IntegrationTest
|
|
1107
|
+
include DashboardTestHelpers
|
|
1108
|
+
setup { ChronoForge::Dashboard.configure { |c| c.authentication = :none } }
|
|
1109
|
+
teardown { ChronoForge::Dashboard.reset_configuration! }
|
|
1110
|
+
|
|
1111
|
+
test "renders timeline, context, errors" do
|
|
1112
|
+
wf = create_workflow(key: "show-1", state: :failed, context: { "amount" => 10 })
|
|
1113
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "durably_execute$charge",
|
|
1114
|
+
state: ChronoForge::ExecutionLog.states[:failed], attempts: 3, started_at: 1.minute.ago, error_class: "Boom")
|
|
1115
|
+
ChronoForge::ErrorLog.create!(workflow: wf, step_name: "durably_execute$charge", attempt: 3,
|
|
1116
|
+
error_class: "Boom", error_message: "kaboom")
|
|
1117
|
+
|
|
1118
|
+
get "/chrono_forge/workflows/#{wf.id}"
|
|
1119
|
+
assert_response :success
|
|
1120
|
+
assert_match "charge", response.body
|
|
1121
|
+
assert_match "amount", response.body
|
|
1122
|
+
assert_match "kaboom", response.body
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
test "wait callout for idle wait_until" do
|
|
1126
|
+
wf = create_workflow(key: "show-2", state: :idle)
|
|
1127
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "wait_until$paid?",
|
|
1128
|
+
state: ChronoForge::ExecutionLog.states[:pending], attempts: 1,
|
|
1129
|
+
started_at: 2.hours.ago, last_executed_at: 2.hours.ago,
|
|
1130
|
+
metadata: { "timeout_at" => 1.hour.from_now })
|
|
1131
|
+
get "/chrono_forge/workflows/#{wf.id}"
|
|
1132
|
+
assert_match "cf-wait-callout", response.body
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
1138
|
+
|
|
1139
|
+
- [ ] **Step 3: Controller `show`**:
|
|
1140
|
+
|
|
1141
|
+
```ruby
|
|
1142
|
+
def show
|
|
1143
|
+
@workflow = ChronoForge::Workflow.find(params[:id])
|
|
1144
|
+
@timeline = TimelinePresenter.new(@workflow)
|
|
1145
|
+
@context = ContextPresenter.new(@workflow)
|
|
1146
|
+
@errors = @workflow.error_logs.order(created_at: :desc)
|
|
1147
|
+
@wait = WaitStatePresenter.new(@workflow).active # nil unless idle-waiting (Task 8)
|
|
1148
|
+
end
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
(Note: `WaitStatePresenter` arrives in Task 8; Task 7 depends on Task 8 for the wait callout. If implementing 7 before 8, stub `@wait = nil` and add the callout when 8 lands. Sequence 8 before 7 if possible.)
|
|
1152
|
+
|
|
1153
|
+
- [ ] **Step 4: Views** — `show.html.erb`:
|
|
1154
|
+
|
|
1155
|
+
```erb
|
|
1156
|
+
<h1 class="cf-title"><%= cf_badge(@workflow.state) %> <%= @workflow.key %></h1>
|
|
1157
|
+
<p class="cf-meta"><%= @workflow.job_class %> · locked_by=<%= @workflow.locked_by || "—" %></p>
|
|
1158
|
+
<%= render "wait_callout", wait: @wait if @wait %>
|
|
1159
|
+
<section><h2>Timeline</h2><%= render "timeline", timeline: @timeline %></section>
|
|
1160
|
+
<section><h2>Context</h2><%= render "context_tree", context: @context %></section>
|
|
1161
|
+
<section><h2>Errors</h2><%= render "errors", errors: @errors %></section>
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
`_timeline.html.erb`:
|
|
1165
|
+
|
|
1166
|
+
```erb
|
|
1167
|
+
<ol class="cf-timeline">
|
|
1168
|
+
<% timeline.entries.each do |e| %>
|
|
1169
|
+
<li class="cf-step cf-step--<%= e.status %> <%= "cf-step--current" if timeline.current_position&.id == e.id %>">
|
|
1170
|
+
<span class="cf-step__kind"><%= e.kind %></span>
|
|
1171
|
+
<span class="cf-step__name"><%= e.name %></span>
|
|
1172
|
+
<span class="cf-step__status"><%= e.status %></span>
|
|
1173
|
+
<span class="cf-step__attempts">×<%= e.attempts %></span>
|
|
1174
|
+
<% if e.error %><span class="cf-step__error"><%= e.error %></span><% end %>
|
|
1175
|
+
<% if e.runs.any? %>
|
|
1176
|
+
<ol class="cf-timeline cf-timeline--runs">
|
|
1177
|
+
<% e.runs.each do |r| %>
|
|
1178
|
+
<li class="cf-step cf-step--<%= r.status %>"><%= Time.zone.at(0) %><%= r.status %> ×<%= r.attempts %></li>
|
|
1179
|
+
<% end %>
|
|
1180
|
+
</ol>
|
|
1181
|
+
<% end %>
|
|
1182
|
+
</li>
|
|
1183
|
+
<% end %>
|
|
1184
|
+
</ol>
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
`_context_tree.html.erb`:
|
|
1188
|
+
|
|
1189
|
+
```erb
|
|
1190
|
+
<div class="cf-context" data-collapsible>
|
|
1191
|
+
<p class="cf-context__size"><%= number_to_human_size(context.byte_size) %></p>
|
|
1192
|
+
<ul>
|
|
1193
|
+
<% context.nodes.each do |n| %>
|
|
1194
|
+
<li><code class="cf-context__key"><%= n[:key] %></code>
|
|
1195
|
+
<span class="cf-context__type"><%= n[:type] %></span>
|
|
1196
|
+
<span class="cf-context__val"><%= n[:value].inspect.truncate(200) %></span>
|
|
1197
|
+
</li>
|
|
1198
|
+
<% end %>
|
|
1199
|
+
</ul>
|
|
1200
|
+
</div>
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
`_errors.html.erb`:
|
|
1204
|
+
|
|
1205
|
+
```erb
|
|
1206
|
+
<ul class="cf-errors">
|
|
1207
|
+
<% errors.each do |err| %>
|
|
1208
|
+
<li>
|
|
1209
|
+
<strong><%= err.error_class %></strong> (attempt <%= err.attempt %>) — <%= err.error_message %>
|
|
1210
|
+
<% if err.backtrace.present? %>
|
|
1211
|
+
<details><summary>backtrace</summary><pre><%= err.backtrace %></pre></details>
|
|
1212
|
+
<% end %>
|
|
1213
|
+
</li>
|
|
1214
|
+
<% end %>
|
|
1215
|
+
</ul>
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
`_wait_callout.html.erb`:
|
|
1219
|
+
|
|
1220
|
+
```erb
|
|
1221
|
+
<div class="cf-wait-callout">
|
|
1222
|
+
Waiting on <code><%= wait.condition %></code> for <%= distance_of_time_in_words(wait.waiting_since, Time.current) %>
|
|
1223
|
+
(timeout <%= wait.timeout_at&.iso8601 || "—" %>)
|
|
1224
|
+
</div>
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
- [ ] **Step 5: Run — green**, then full suite.
|
|
1228
|
+
|
|
1229
|
+
- [ ] **Step 6: Commit**
|
|
1230
|
+
|
|
1231
|
+
```bash
|
|
1232
|
+
git add chrono_forge-dashboard
|
|
1233
|
+
git commit -m "feat(dashboard): workflow detail with timeline, context tree, errors"
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
```json:metadata
|
|
1237
|
+
{"files": ["chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/workflows_controller.rb", "chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/show.html.erb", "chrono_forge-dashboard/test/workflows_show_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/workflows_show_test.rb", "acceptanceCriteria": ["timeline+context+errors render", "wait callout for idle wait", "unknown steps don't raise"], "requiresUserVerification": false}
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
### Task 8: Periodic health + wait-state presenters and wait-states index
|
|
1243
|
+
|
|
1244
|
+
**Goal:** `durably_repeat` health, wait-state age model, and the wait-states list page.
|
|
1245
|
+
|
|
1246
|
+
**Files:** Create `app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb`, `app/presenters/chrono_forge/dashboard/wait_state_presenter.rb`, `app/controllers/chrono_forge/dashboard/wait_states_controller.rb`, `app/views/chrono_forge/dashboard/wait_states/index.html.erb`, `test/periodic_and_wait_test.rb`
|
|
1247
|
+
|
|
1248
|
+
**Acceptance Criteria:**
|
|
1249
|
+
- [ ] `PeriodicHealthPresenter` reports last run, next scheduled, timed-out count, and per-run latencies for each `durably_repeat` coordination log
|
|
1250
|
+
- [ ] `WaitStatePresenter#active` returns `{condition, waiting_since, timeout_at}` for an idle wait, else nil
|
|
1251
|
+
- [ ] `WaitStatesController#index` lists idle-waiting workflows sorted by wait age, flagging those past `long_wait_threshold`
|
|
1252
|
+
|
|
1253
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/periodic_and_wait_test.rb`
|
|
1254
|
+
|
|
1255
|
+
**Steps:**
|
|
1256
|
+
|
|
1257
|
+
- [ ] **Step 1: Failing test** — `test/periodic_and_wait_test.rb`:
|
|
1258
|
+
|
|
1259
|
+
```ruby
|
|
1260
|
+
require "test_helper"
|
|
1261
|
+
|
|
1262
|
+
class PeriodicAndWaitTest < ActionDispatch::IntegrationTest
|
|
1263
|
+
include DashboardTestHelpers
|
|
1264
|
+
setup { ChronoForge::Dashboard.configure { |c| c.authentication = :none } }
|
|
1265
|
+
teardown { ChronoForge::Dashboard.reset_configuration! }
|
|
1266
|
+
|
|
1267
|
+
test "wait presenter detects active idle wait" do
|
|
1268
|
+
wf = create_workflow(key: "w1", state: :idle)
|
|
1269
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "wait_until$ready?",
|
|
1270
|
+
state: ChronoForge::ExecutionLog.states[:pending], attempts: 1,
|
|
1271
|
+
started_at: 90.minutes.ago, last_executed_at: 90.minutes.ago,
|
|
1272
|
+
metadata: { "timeout_at" => 1.hour.from_now })
|
|
1273
|
+
active = ChronoForge::Dashboard::WaitStatePresenter.new(wf).active
|
|
1274
|
+
assert_equal "ready?", active.condition
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
test "wait-states index flags long waiters" do
|
|
1278
|
+
wf = create_workflow(key: "w2", state: :idle)
|
|
1279
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "wait_until$ready?",
|
|
1280
|
+
state: ChronoForge::ExecutionLog.states[:pending], attempts: 1,
|
|
1281
|
+
started_at: 5.hours.ago, last_executed_at: 5.hours.ago, metadata: {})
|
|
1282
|
+
get "/chrono_forge/wait_states"
|
|
1283
|
+
assert_response :success
|
|
1284
|
+
assert_match "w2", response.body
|
|
1285
|
+
assert_match "cf-wait--long", response.body
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
test "periodic health reports timeouts and latencies" do
|
|
1289
|
+
wf = create_workflow(key: "p1")
|
|
1290
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "durably_repeat$sync",
|
|
1291
|
+
state: ChronoForge::ExecutionLog.states[:pending], attempts: 1, started_at: 1.day.ago,
|
|
1292
|
+
metadata: { "last_execution_at" => 2.hours.ago.iso8601 })
|
|
1293
|
+
ChronoForge::ExecutionLog.create!(workflow: wf, step_name: "durably_repeat$sync$1717000000",
|
|
1294
|
+
state: ChronoForge::ExecutionLog.states[:failed], attempts: 1, error_class: "TimeoutError",
|
|
1295
|
+
started_at: 3.hours.ago, completed_at: 3.hours.ago)
|
|
1296
|
+
health = ChronoForge::Dashboard::PeriodicHealthPresenter.new(wf).tasks
|
|
1297
|
+
assert_equal 1, health.first.timed_out_count
|
|
1298
|
+
end
|
|
1299
|
+
end
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
- [ ] **Step 2: Run — confirm fail.**
|
|
1303
|
+
|
|
1304
|
+
- [ ] **Step 3: Implement** — `app/presenters/chrono_forge/dashboard/wait_state_presenter.rb`:
|
|
1305
|
+
|
|
1306
|
+
```ruby
|
|
1307
|
+
module ChronoForge
|
|
1308
|
+
module Dashboard
|
|
1309
|
+
class WaitStatePresenter
|
|
1310
|
+
Active = Struct.new(:condition, :waiting_since, :timeout_at, keyword_init: true)
|
|
1311
|
+
|
|
1312
|
+
def initialize(workflow) = @workflow = workflow
|
|
1313
|
+
|
|
1314
|
+
# Active wait iff the workflow is idle and its latest log is a pending wait_until.
|
|
1315
|
+
def active
|
|
1316
|
+
return nil unless @workflow.idle?
|
|
1317
|
+
log = @workflow.execution_logs.order(Arel.sql("started_at, id")).last
|
|
1318
|
+
return nil unless log&.pending?
|
|
1319
|
+
p = StepNameParser.parse(log.step_name)
|
|
1320
|
+
return nil unless p.kind == :wait
|
|
1321
|
+
Active.new(condition: p.name,
|
|
1322
|
+
waiting_since: log.last_executed_at || log.started_at,
|
|
1323
|
+
timeout_at: log.metadata&.dig("timeout_at"))
|
|
1324
|
+
end
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
`app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb`:
|
|
1331
|
+
|
|
1332
|
+
```ruby
|
|
1333
|
+
module ChronoForge
|
|
1334
|
+
module Dashboard
|
|
1335
|
+
class PeriodicHealthPresenter
|
|
1336
|
+
Task = Struct.new(:name, :last_execution_at, :timed_out_count, :latencies, keyword_init: true)
|
|
1337
|
+
|
|
1338
|
+
def initialize(workflow) = @workflow = workflow
|
|
1339
|
+
|
|
1340
|
+
def tasks
|
|
1341
|
+
coords = logs.select { |l| StepNameParser.parse(l.step_name).kind == :repeat_coordination }
|
|
1342
|
+
coords.map do |coord|
|
|
1343
|
+
name = StepNameParser.parse(coord.step_name).name
|
|
1344
|
+
runs = logs.select do |l|
|
|
1345
|
+
pp = StepNameParser.parse(l.step_name)
|
|
1346
|
+
pp.kind == :repeat_run && pp.name == name
|
|
1347
|
+
end
|
|
1348
|
+
Task.new(
|
|
1349
|
+
name: name,
|
|
1350
|
+
last_execution_at: coord.metadata&.dig("last_execution_at"),
|
|
1351
|
+
timed_out_count: runs.count { |r| r.error_class == "TimeoutError" },
|
|
1352
|
+
latencies: runs.filter_map { |r| (r.completed_at - r.started_at).to_i if r.completed_at && r.started_at }
|
|
1353
|
+
)
|
|
1354
|
+
end
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
private
|
|
1358
|
+
|
|
1359
|
+
def logs = @logs ||= @workflow.execution_logs.to_a
|
|
1360
|
+
end
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
`app/controllers/chrono_forge/dashboard/wait_states_controller.rb`:
|
|
1366
|
+
|
|
1367
|
+
```ruby
|
|
1368
|
+
module ChronoForge
|
|
1369
|
+
module Dashboard
|
|
1370
|
+
class WaitStatesController < BaseController
|
|
1371
|
+
def index
|
|
1372
|
+
idle = ChronoForge::Workflow.where(state: ChronoForge::Workflow.states[:idle])
|
|
1373
|
+
@waits = idle.filter_map do |wf|
|
|
1374
|
+
a = WaitStatePresenter.new(wf).active
|
|
1375
|
+
{ workflow: wf, wait: a } if a
|
|
1376
|
+
end.sort_by { |h| h[:wait].waiting_since || Time.current }
|
|
1377
|
+
@threshold = ChronoForge::Dashboard.config.long_wait_threshold
|
|
1378
|
+
end
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
`app/views/chrono_forge/dashboard/wait_states/index.html.erb`:
|
|
1385
|
+
|
|
1386
|
+
```erb
|
|
1387
|
+
<h1>Waiting workflows</h1>
|
|
1388
|
+
<table class="cf-table">
|
|
1389
|
+
<thead><tr><th>Key</th><th>Condition</th><th>Waiting</th><th>Timeout</th></tr></thead>
|
|
1390
|
+
<tbody>
|
|
1391
|
+
<% @waits.each do |h| %>
|
|
1392
|
+
<% long = (Time.current - (h[:wait].waiting_since || Time.current)) > @threshold %>
|
|
1393
|
+
<tr class="<%= "cf-wait--long" if long %>">
|
|
1394
|
+
<td><%= link_to h[:workflow].key, workflow_path(h[:workflow]) %></td>
|
|
1395
|
+
<td><code><%= h[:wait].condition %></code></td>
|
|
1396
|
+
<td><%= distance_of_time_in_words(h[:wait].waiting_since, Time.current) %></td>
|
|
1397
|
+
<td><%= h[:wait].timeout_at || "—" %></td>
|
|
1398
|
+
</tr>
|
|
1399
|
+
<% end %>
|
|
1400
|
+
</tbody>
|
|
1401
|
+
</table>
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
- [ ] **Step 4: Run — green.**
|
|
1405
|
+
|
|
1406
|
+
- [ ] **Step 5: Commit**
|
|
1407
|
+
|
|
1408
|
+
```bash
|
|
1409
|
+
git add chrono_forge-dashboard
|
|
1410
|
+
git commit -m "feat(dashboard): periodic-task health and wait-state age view"
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
```json:metadata
|
|
1414
|
+
{"files": ["chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb", "chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb", "chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/wait_states_controller.rb", "chrono_forge-dashboard/app/views/chrono_forge/dashboard/wait_states/index.html.erb", "chrono_forge-dashboard/test/periodic_and_wait_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/periodic_and_wait_test.rb", "acceptanceCriteria": ["periodic health timeouts+latencies", "active wait detection", "wait-states list flags long waiters"], "requiresUserVerification": false}
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
---
|
|
1418
|
+
|
|
1419
|
+
### Task 9: Operational actions (retry, unlock, bulk retry)
|
|
1420
|
+
|
|
1421
|
+
**Goal:** POST endpoints for the three recovery actions, guarded and flashing on failure.
|
|
1422
|
+
|
|
1423
|
+
**Files:** Modify `config/routes.rb`; create `app/controllers/chrono_forge/dashboard/actions_controller.rb`, `test/actions_test.rb`
|
|
1424
|
+
|
|
1425
|
+
**Acceptance Criteria:**
|
|
1426
|
+
- [ ] `POST /workflows/:id/retry` calls `workflow.retry_later`; on a non-retryable workflow it flashes and redirects (no 500)
|
|
1427
|
+
- [ ] `POST /workflows/:id/unlock` clears `locked_at`/`locked_by` and sets state `idle`
|
|
1428
|
+
- [ ] `POST /workflows/bulk_retry` calls `retry_later` on every failed workflow and reports the count
|
|
1429
|
+
- [ ] All three require CSRF + auth
|
|
1430
|
+
|
|
1431
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/actions_test.rb`
|
|
1432
|
+
|
|
1433
|
+
**Steps:**
|
|
1434
|
+
|
|
1435
|
+
- [ ] **Step 1: Routes**:
|
|
1436
|
+
|
|
1437
|
+
```ruby
|
|
1438
|
+
ChronoForge::Dashboard::Engine.routes.draw do
|
|
1439
|
+
root to: "workflows#index"
|
|
1440
|
+
resources :workflows, only: %i[index show] do
|
|
1441
|
+
member do
|
|
1442
|
+
post :retry, to: "actions#retry"
|
|
1443
|
+
post :unlock, to: "actions#unlock"
|
|
1444
|
+
end
|
|
1445
|
+
collection { post :bulk_retry, to: "actions#bulk_retry" }
|
|
1446
|
+
end
|
|
1447
|
+
resources :wait_states, only: :index
|
|
1448
|
+
get "assets/:file", to: "assets#show", constraints: { file: /dashboard\.(css|js)/ }
|
|
1449
|
+
end
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
- [ ] **Step 2: Failing test** — `test/actions_test.rb`:
|
|
1453
|
+
|
|
1454
|
+
```ruby
|
|
1455
|
+
require "test_helper"
|
|
1456
|
+
|
|
1457
|
+
class ActionsTest < ActionDispatch::IntegrationTest
|
|
1458
|
+
include DashboardTestHelpers
|
|
1459
|
+
setup { ChronoForge::Dashboard.configure { |c| c.authentication = :none } }
|
|
1460
|
+
teardown { ChronoForge::Dashboard.reset_configuration! }
|
|
1461
|
+
|
|
1462
|
+
test "retry calls retry_later on a failed workflow" do
|
|
1463
|
+
wf = create_workflow(key: "r1", state: :failed)
|
|
1464
|
+
called = false
|
|
1465
|
+
ChronoForge::Workflow.any_instance.stub(:retry_later, ->(*) { called = true }) do
|
|
1466
|
+
post "/chrono_forge/workflows/#{wf.id}/retry"
|
|
1467
|
+
end
|
|
1468
|
+
assert_response :redirect
|
|
1469
|
+
assert called
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
test "retry on a running workflow flashes instead of 500" do
|
|
1473
|
+
wf = create_workflow(key: "r2", state: :running)
|
|
1474
|
+
post "/chrono_forge/workflows/#{wf.id}/retry"
|
|
1475
|
+
assert_response :redirect
|
|
1476
|
+
follow_redirect!
|
|
1477
|
+
assert_match(/cannot retry|not.*retry/i, response.body)
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
test "unlock clears the lock and idles" do
|
|
1481
|
+
wf = create_workflow(key: "u1", state: :running, locked_at: Time.current, locked_by: "job-1")
|
|
1482
|
+
post "/chrono_forge/workflows/#{wf.id}/unlock"
|
|
1483
|
+
wf.reload
|
|
1484
|
+
assert_nil wf.locked_at
|
|
1485
|
+
assert_nil wf.locked_by
|
|
1486
|
+
assert wf.idle?
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
test "bulk retry hits all failed" do
|
|
1490
|
+
create_workflow(key: "b1", state: :failed)
|
|
1491
|
+
create_workflow(key: "b2", state: :failed)
|
|
1492
|
+
count = 0
|
|
1493
|
+
ChronoForge::Workflow.any_instance.stub(:retry_later, ->(*) { count += 1 }) do
|
|
1494
|
+
post "/chrono_forge/workflows/bulk_retry"
|
|
1495
|
+
end
|
|
1496
|
+
assert_equal 2, count
|
|
1497
|
+
end
|
|
1498
|
+
end
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
(`any_instance.stub` is provided by Minitest's `Object#stub` via `minitest/mock`; add `require "minitest/mock"` in `test_helper.rb` if needed. If `any_instance` is unavailable, assert via job enqueue using `ActiveJob::TestHelper` instead — the workflow's `retry_later` enqueues a job, so `assert_enqueued_jobs` works without stubbing.)
|
|
1502
|
+
|
|
1503
|
+
- [ ] **Step 3: Implement** — `app/controllers/chrono_forge/dashboard/actions_controller.rb`:
|
|
1504
|
+
|
|
1505
|
+
```ruby
|
|
1506
|
+
module ChronoForge
|
|
1507
|
+
module Dashboard
|
|
1508
|
+
class ActionsController < BaseController
|
|
1509
|
+
rescue_from ChronoForge::Executor::WorkflowNotRetryableError do |e|
|
|
1510
|
+
redirect_to workflow_path(params[:id]), alert: e.message
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
def retry
|
|
1514
|
+
workflow.retry_later
|
|
1515
|
+
redirect_to workflow_path(workflow), notice: "Re-enqueued #{workflow.key}."
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
def unlock
|
|
1519
|
+
workflow.update!(locked_at: nil, locked_by: nil, state: :idle)
|
|
1520
|
+
redirect_to workflow_path(workflow), notice: "Unlocked #{workflow.key}."
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
def bulk_retry
|
|
1524
|
+
n = 0
|
|
1525
|
+
ChronoForge::Workflow.where(state: ChronoForge::Workflow.states[:failed]).find_each do |wf|
|
|
1526
|
+
wf.retry_later
|
|
1527
|
+
n += 1
|
|
1528
|
+
end
|
|
1529
|
+
redirect_to workflows_path, notice: "Re-enqueued #{n} failed workflow(s)."
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
private
|
|
1533
|
+
|
|
1534
|
+
def workflow = @workflow ||= ChronoForge::Workflow.find(params[:id])
|
|
1535
|
+
end
|
|
1536
|
+
end
|
|
1537
|
+
end
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
The layout must render flash; add to `application.html.erb` `<main>`:
|
|
1541
|
+
|
|
1542
|
+
```erb
|
|
1543
|
+
<% flash.each do |type, msg| %><div class="cf-flash cf-flash--<%= type %>"><%= msg %></div><% end %>
|
|
1544
|
+
```
|
|
1545
|
+
|
|
1546
|
+
- [ ] **Step 4: Run — green**, then full suite.
|
|
1547
|
+
|
|
1548
|
+
- [ ] **Step 5: Commit**
|
|
1549
|
+
|
|
1550
|
+
```bash
|
|
1551
|
+
git add chrono_forge-dashboard
|
|
1552
|
+
git commit -m "feat(dashboard): retry, unlock, and bulk-retry actions"
|
|
1553
|
+
```
|
|
1554
|
+
|
|
1555
|
+
```json:metadata
|
|
1556
|
+
{"files": ["chrono_forge-dashboard/config/routes.rb", "chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/actions_controller.rb", "chrono_forge-dashboard/test/actions_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/actions_test.rb", "acceptanceCriteria": ["retry calls retry_later", "non-retryable flashes not 500", "unlock clears lock+idles", "bulk retry counts"], "requiresUserVerification": false}
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
---
|
|
1560
|
+
|
|
1561
|
+
### Task 10: Assets controller + CSS/JS (polling, tree, sparklines, confirms)
|
|
1562
|
+
|
|
1563
|
+
**Goal:** Serve the engine's CSS/JS without a host pipeline; wire JS behaviors.
|
|
1564
|
+
|
|
1565
|
+
**Files:** Create `app/controllers/chrono_forge/dashboard/assets_controller.rb`, `app/assets/chrono_forge/dashboard/dashboard.css`, `app/assets/chrono_forge/dashboard/dashboard.js`, `test/assets_test.rb`
|
|
1566
|
+
|
|
1567
|
+
**Acceptance Criteria:**
|
|
1568
|
+
- [ ] `GET /assets/dashboard.css` returns `text/css` with a long-cache header
|
|
1569
|
+
- [ ] `GET /assets/dashboard.js` returns `application/javascript`
|
|
1570
|
+
- [ ] An unknown asset name 404s (route constraint)
|
|
1571
|
+
|
|
1572
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec ruby -Itest test/assets_test.rb`
|
|
1573
|
+
|
|
1574
|
+
**Steps:**
|
|
1575
|
+
|
|
1576
|
+
- [ ] **Step 1: Failing test** — `test/assets_test.rb`:
|
|
1577
|
+
|
|
1578
|
+
```ruby
|
|
1579
|
+
require "test_helper"
|
|
1580
|
+
|
|
1581
|
+
class AssetsTest < ActionDispatch::IntegrationTest
|
|
1582
|
+
setup { ChronoForge::Dashboard.configure { |c| c.authentication = :none } }
|
|
1583
|
+
teardown { ChronoForge::Dashboard.reset_configuration! }
|
|
1584
|
+
|
|
1585
|
+
test "serves css" do
|
|
1586
|
+
get "/chrono_forge/assets/dashboard.css"
|
|
1587
|
+
assert_response :success
|
|
1588
|
+
assert_equal "text/css", response.media_type
|
|
1589
|
+
assert_match "max-age", response.headers["Cache-Control"]
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
test "serves js" do
|
|
1593
|
+
get "/chrono_forge/assets/dashboard.js"
|
|
1594
|
+
assert_response :success
|
|
1595
|
+
assert_includes ["application/javascript", "text/javascript"], response.media_type
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
```
|
|
1599
|
+
|
|
1600
|
+
- [ ] **Step 2: Implement** — `app/controllers/chrono_forge/dashboard/assets_controller.rb`:
|
|
1601
|
+
|
|
1602
|
+
```ruby
|
|
1603
|
+
module ChronoForge
|
|
1604
|
+
module Dashboard
|
|
1605
|
+
class AssetsController < BaseController
|
|
1606
|
+
skip_before_action :authenticate! # static assets are not sensitive
|
|
1607
|
+
|
|
1608
|
+
TYPES = { "dashboard.css" => "text/css", "dashboard.js" => "application/javascript" }.freeze
|
|
1609
|
+
ROOT = ChronoForge::Dashboard::Engine.root.join("app/assets/chrono_forge/dashboard")
|
|
1610
|
+
|
|
1611
|
+
def show
|
|
1612
|
+
file = params[:file]
|
|
1613
|
+
type = TYPES[file] or return head(:not_found)
|
|
1614
|
+
path = ROOT.join(file)
|
|
1615
|
+
return head(:not_found) unless path.file?
|
|
1616
|
+
response.set_header("Cache-Control", "public, max-age=31536000, immutable")
|
|
1617
|
+
send_file path, type: type, disposition: "inline"
|
|
1618
|
+
end
|
|
1619
|
+
end
|
|
1620
|
+
end
|
|
1621
|
+
end
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
- [ ] **Step 3: CSS** — `app/assets/chrono_forge/dashboard/dashboard.css` (self-contained, `cf-` prefixed). Minimum viable, no external fonts:
|
|
1625
|
+
|
|
1626
|
+
```css
|
|
1627
|
+
:root { --cf-fg:#1c1e21; --cf-muted:#6b7280; --cf-line:#e5e7eb; }
|
|
1628
|
+
.cf-main { max-width: 1100px; margin: 0 auto; padding: 1rem; font-family: system-ui, sans-serif; color: var(--cf-fg); }
|
|
1629
|
+
.cf-table { width:100%; border-collapse:collapse; }
|
|
1630
|
+
.cf-table th, .cf-table td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--cf-line); }
|
|
1631
|
+
.cf-badge { padding:.1rem .5rem; border-radius:1rem; font-size:.8rem; }
|
|
1632
|
+
.cf-badge--failed,.cf-badge--stalled { background:#fee2e2; }
|
|
1633
|
+
.cf-badge--completed { background:#dcfce7; }
|
|
1634
|
+
.cf-badge--running { background:#dbeafe; }
|
|
1635
|
+
.cf-badge--idle { background:#f3f4f6; }
|
|
1636
|
+
.cf-timeline { list-style:none; padding-left:0; }
|
|
1637
|
+
.cf-step { padding:.4rem .6rem; border-left:3px solid var(--cf-line); margin:.2rem 0; }
|
|
1638
|
+
.cf-step--failed { border-color:#ef4444; }
|
|
1639
|
+
.cf-step--current { background:#fff7ed; }
|
|
1640
|
+
.cf-wait-callout,.cf-wait--long { background:#fff7ed; }
|
|
1641
|
+
.cf-flash { padding:.5rem .8rem; margin:.5rem 0; border-radius:.3rem; }
|
|
1642
|
+
.cf-flash--alert { background:#fee2e2; } .cf-flash--notice { background:#dcfce7; }
|
|
1643
|
+
.cf-sparkline { height:24px; }
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
- [ ] **Step 4: JS** — `app/assets/chrono_forge/dashboard/dashboard.js` (vanilla; no inline handlers):
|
|
1647
|
+
|
|
1648
|
+
```javascript
|
|
1649
|
+
(function () {
|
|
1650
|
+
"use strict";
|
|
1651
|
+
|
|
1652
|
+
// Collapsible context tree
|
|
1653
|
+
document.querySelectorAll("[data-collapsible] .cf-context__key").forEach(function (el) {
|
|
1654
|
+
el.addEventListener("click", function () { el.closest("li").classList.toggle("cf-collapsed"); });
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// Confirm destructive actions: any form with data-confirm
|
|
1658
|
+
document.querySelectorAll("form[data-confirm]").forEach(function (form) {
|
|
1659
|
+
form.addEventListener("submit", function (e) {
|
|
1660
|
+
if (!window.confirm(form.getAttribute("data-confirm"))) e.preventDefault();
|
|
1661
|
+
});
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
// Render inline-SVG sparklines from data-values="1,2,3"
|
|
1665
|
+
document.querySelectorAll("[data-sparkline]").forEach(function (el) {
|
|
1666
|
+
var vals = (el.getAttribute("data-values") || "").split(",").map(Number).filter(function (n) { return !isNaN(n); });
|
|
1667
|
+
if (!vals.length) return;
|
|
1668
|
+
var max = Math.max.apply(null, vals), w = 100, h = 24, step = w / Math.max(vals.length - 1, 1);
|
|
1669
|
+
var pts = vals.map(function (v, i) { return (i * step) + "," + (h - (max ? v / max * h : 0)); }).join(" ");
|
|
1670
|
+
el.innerHTML = '<svg class="cf-sparkline" viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="' + pts + '"/></svg>';
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// Polling refresh of the list/stats region
|
|
1674
|
+
var body = document.body, interval = parseInt(body.getAttribute("data-poll-interval") || "0", 10) * 1000;
|
|
1675
|
+
var region = document.querySelector("[data-poll-region]");
|
|
1676
|
+
if (interval > 0 && region && !body.hasAttribute("data-poll-paused")) {
|
|
1677
|
+
setInterval(function () {
|
|
1678
|
+
fetch(window.location.href, { headers: { "X-Requested-With": "XMLHttpRequest" } })
|
|
1679
|
+
.then(function (r) { return r.text(); })
|
|
1680
|
+
.then(function (html) {
|
|
1681
|
+
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
1682
|
+
var fresh = doc.querySelector("[data-poll-region]");
|
|
1683
|
+
if (fresh) region.innerHTML = fresh.innerHTML;
|
|
1684
|
+
}).catch(function () {});
|
|
1685
|
+
}, interval);
|
|
1686
|
+
}
|
|
1687
|
+
})();
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
(Wrap the list table/stats in `<div data-poll-region>` in `index.html.erb`; add `data-confirm` to the unlock/bulk-retry forms; add `data-sparkline data-values="..."` where periodic latencies render.)
|
|
1691
|
+
|
|
1692
|
+
- [ ] **Step 5: Run — green**, then full suite + standardrb.
|
|
1693
|
+
|
|
1694
|
+
- [ ] **Step 6: Commit**
|
|
1695
|
+
|
|
1696
|
+
```bash
|
|
1697
|
+
git add chrono_forge-dashboard
|
|
1698
|
+
git commit -m "feat(dashboard): engine-served assets, polling, sparklines, confirms"
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
```json:metadata
|
|
1702
|
+
{"files": ["chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/assets_controller.rb", "chrono_forge-dashboard/app/assets/chrono_forge/dashboard/dashboard.css", "chrono_forge-dashboard/app/assets/chrono_forge/dashboard/dashboard.js", "chrono_forge-dashboard/test/assets_test.rb"], "verifyCommand": "cd chrono_forge-dashboard && bundle exec ruby -Itest test/assets_test.rb", "acceptanceCriteria": ["css served text/css + cache header", "js served", "unknown asset 404s"], "requiresUserVerification": false}
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
### Task 11: README + install docs for the dashboard gem
|
|
1708
|
+
|
|
1709
|
+
**Goal:** A README so users can install, mount, and configure auth.
|
|
1710
|
+
|
|
1711
|
+
**Files:** Create `chrono_forge-dashboard/README.md`, `chrono_forge-dashboard/MIT-LICENSE`; modify the core `README.md` (add a short "Dashboard" section linking to the gem).
|
|
1712
|
+
|
|
1713
|
+
**Acceptance Criteria:**
|
|
1714
|
+
- [ ] README covers install (`gem "chrono_forge-dashboard"`), mounting, and all three auth modes incl. the fail-closed behavior
|
|
1715
|
+
- [ ] Core README has a Dashboard section pointing to the companion gem
|
|
1716
|
+
|
|
1717
|
+
**Verify:** `grep -n "chrono_forge-dashboard" chrono_forge-dashboard/README.md` and `grep -n "Dashboard" README.md`
|
|
1718
|
+
|
|
1719
|
+
**Steps:**
|
|
1720
|
+
|
|
1721
|
+
- [ ] **Step 1: Write `chrono_forge-dashboard/README.md`** covering: what it is, `gem "chrono_forge-dashboard"`, `mount ChronoForge::Dashboard::Engine, at: "/chrono_forge"`, the three auth modes (http_basic / hook / `:none` + routing constraint) and that it raises if unconfigured, plus `polling_interval`/`page_size`/`long_wait_threshold` config. Include the `MIT-LICENSE` file.
|
|
1722
|
+
|
|
1723
|
+
- [ ] **Step 2: Core README Dashboard section** — after the Features list, a short subsection: "ChronoForge has a free, mountable dashboard — see `chrono_forge-dashboard`," with the mount snippet.
|
|
1724
|
+
|
|
1725
|
+
- [ ] **Step 3: Commit**
|
|
1726
|
+
|
|
1727
|
+
```bash
|
|
1728
|
+
git add chrono_forge-dashboard/README.md chrono_forge-dashboard/MIT-LICENSE README.md
|
|
1729
|
+
git commit -m "docs(dashboard): README, install/mount/auth, core README link"
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
```json:metadata
|
|
1733
|
+
{"files": ["chrono_forge-dashboard/README.md", "README.md"], "verifyCommand": "grep -n 'Dashboard' README.md", "acceptanceCriteria": ["install+mount+three auth modes documented", "core README links the gem"], "requiresUserVerification": false}
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
---
|
|
1737
|
+
|
|
1738
|
+
## Self-Review
|
|
1739
|
+
|
|
1740
|
+
**Spec coverage:** packaging/engine/exclusion → T1; auth fail-closed → T2; step-name parsing → T3; queries/stats → T4; list/filters/stats/pagination → T5; timeline + context presenters → T6; detail view → T7; periodic health + wait-state → T8; actions (retry/unlock/bulk) → T9; assets + polling/sparklines/confirms → T10; docs → T11. All spec sections covered. (Real-time push, context editing, context-value search, UI-triggered cleanup are explicitly out of scope in the spec — no tasks, correctly.)
|
|
1741
|
+
|
|
1742
|
+
**Sequencing note:** Task 7's wait callout depends on `WaitStatePresenter` (Task 8). Recommended execution order: 1, 2, 3, 4, 5, 6, **8, 7**, 9, 10, 11 — or implement Task 7 with `@wait = nil` and add the callout line when 8 lands. Captured in the task dependencies.
|
|
1743
|
+
|
|
1744
|
+
**Placeholder scan:** none — every step has concrete code or an explicit, named artifact.
|
|
1745
|
+
|
|
1746
|
+
**Type consistency:** `StepNameParser.parse` → `Parsed(kind, name, timestamp, raw)` used consistently in T6/T8; `WorkflowsQuery#results/#total_count/#page/#per`, `StatsQuery#counts`, `TimelinePresenter#entries/#current_position`, `ContextPresenter#nodes/#byte_size`, `WaitStatePresenter#active`, `PeriodicHealthPresenter#tasks` — signatures match across tasks. Config keys (`http_basic`, `authenticate`, `authentication`, `polling_interval`, `page_size`, `long_wait_threshold`) consistent T2/T5/T8/T10.
|
|
1747
|
+
|
|
1748
|
+
**Verification requirement scan:** NO — the spec/prompt requires no human-in-the-loop verification of outcomes (internal tooling, test-covered). No verification task required.
|