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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +305 -44
  4. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  5. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  6. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  7. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  8. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  9. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  10. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  11. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  12. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  13. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  14. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  15. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  16. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  17. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  18. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  19. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  20. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  21. data/lib/chrono_forge/branch_merge_job.rb +138 -0
  22. data/lib/chrono_forge/branch_probe.rb +26 -0
  23. data/lib/chrono_forge/cleanup.rb +6 -0
  24. data/lib/chrono_forge/execution_log.rb +6 -0
  25. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  26. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  27. data/lib/chrono_forge/executor/methods/durably_execute.rb +21 -19
  28. data/lib/chrono_forge/executor/methods/durably_repeat.rb +118 -25
  29. data/lib/chrono_forge/executor/methods/merge_branches.rb +83 -0
  30. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  31. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  32. data/lib/chrono_forge/executor/methods/workflow_states.rb +16 -0
  33. data/lib/chrono_forge/executor/methods.rb +2 -0
  34. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  35. data/lib/chrono_forge/executor.rb +216 -28
  36. data/lib/chrono_forge/version.rb +1 -1
  37. data/lib/chrono_forge/workflow.rb +10 -1
  38. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  39. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  40. metadata +42 -5
  41. 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.