chrono_forge-dashboard 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +147 -0
  5. data/app/assets/chrono_forge/dashboard/dashboard.css +2 -0
  6. data/app/assets/chrono_forge/dashboard/dashboard.js +89 -0
  7. data/app/assets/chrono_forge/dashboard/tailwind.css +69 -0
  8. data/app/controllers/chrono_forge/dashboard/actions_controller.rb +58 -0
  9. data/app/controllers/chrono_forge/dashboard/analytics_controller.rb +23 -0
  10. data/app/controllers/chrono_forge/dashboard/assets_controller.rb +21 -0
  11. data/app/controllers/chrono_forge/dashboard/base_controller.rb +29 -0
  12. data/app/controllers/chrono_forge/dashboard/branch_children_controller.rb +33 -0
  13. data/app/controllers/chrono_forge/dashboard/repetitions_controller.rb +20 -0
  14. data/app/controllers/chrono_forge/dashboard/wait_states_controller.rb +38 -0
  15. data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +31 -0
  16. data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +153 -0
  17. data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +64 -0
  18. data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +62 -0
  19. data/app/presenters/chrono_forge/dashboard/context_presenter.rb +17 -0
  20. data/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb +77 -0
  21. data/app/presenters/chrono_forge/dashboard/timeline_presenter.rb +90 -0
  22. data/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb +64 -0
  23. data/app/queries/chrono_forge/dashboard/analytics_query.rb +133 -0
  24. data/app/queries/chrono_forge/dashboard/repetitions_query.rb +110 -0
  25. data/app/queries/chrono_forge/dashboard/stats_query.rb +30 -0
  26. data/app/queries/chrono_forge/dashboard/workflows_query.rb +90 -0
  27. data/app/views/chrono_forge/dashboard/analytics/index.html.erb +103 -0
  28. data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +58 -0
  29. data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +69 -0
  30. data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +73 -0
  31. data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +57 -0
  32. data/app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb +16 -0
  33. data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +13 -0
  34. data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +6 -0
  35. data/app/views/chrono_forge/dashboard/workflows/_parent_breadcrumb.html.erb +9 -0
  36. data/app/views/chrono_forge/dashboard/workflows/_periodic.html.erb +24 -0
  37. data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +14 -0
  38. data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +67 -0
  39. data/app/views/chrono_forge/dashboard/workflows/_wait_callout.html.erb +5 -0
  40. data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +8 -0
  41. data/app/views/chrono_forge/dashboard/workflows/index.html.erb +39 -0
  42. data/app/views/chrono_forge/dashboard/workflows/show.html.erb +79 -0
  43. data/app/views/layouts/chrono_forge/dashboard/application.html.erb +50 -0
  44. data/config/routes.rb +20 -0
  45. data/lib/chrono_forge/dashboard/configuration.rb +32 -0
  46. data/lib/chrono_forge/dashboard/engine.rb +9 -0
  47. data/lib/chrono_forge/dashboard/step_name_parser.rb +32 -0
  48. data/lib/chrono_forge/dashboard/version.rb +5 -0
  49. data/lib/chrono_forge/dashboard.rb +30 -0
  50. metadata +237 -0
@@ -0,0 +1,31 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class WorkflowsController < BaseController
4
+ def index
5
+ @query = WorkflowsQuery.new(**list_params)
6
+ @workflows = @query.records
7
+ @waits = WaitStatePresenter.active_map(@workflows)
8
+ stats = StatsQuery.new
9
+ @stats = stats.counts
10
+ @stats_cap = stats.cap
11
+ end
12
+
13
+ def show
14
+ @workflow = ChronoForge::Workflow.find(params[:id])
15
+ @timeline = TimelinePresenter.new(@workflow)
16
+ @context = ContextPresenter.new(@workflow)
17
+ @wait = WaitStatePresenter.new(@workflow).active
18
+ @periodic = PeriodicHealthPresenter.new(@workflow).tasks
19
+ @branches = BranchesPresenter.new(@workflow)
20
+ @parent_log = @workflow.parent_execution_log
21
+ end
22
+
23
+ private
24
+
25
+ def list_params
26
+ params.permit(:state, :job_class, :key, :created_from, :created_to, :before, :after)
27
+ .to_h.symbolize_keys.merge(per: ChronoForge::Dashboard.config.page_size)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,153 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ module DashboardHelper
4
+ # Display order for state counts: active work first, terminal last. Any
5
+ # unknown states are appended so a new core state never silently vanishes.
6
+ STATE_ORDER = %w[running idle stalled failed completed].freeze
7
+
8
+ def cf_state_order(keys)
9
+ (STATE_ORDER & keys) + (keys - STATE_ORDER)
10
+ end
11
+
12
+ def cf_badge(state)
13
+ tag.span(state, class: "cf-pill cf-pill-#{state}")
14
+ end
15
+
16
+ # Shared "chip" treatment for inline nav/action links (metrics, details,
17
+ # repetitions, open, pagination, back) — a subtle bordered button, never an
18
+ # underlined text link. Pass extra utility classes (margins, truncation).
19
+ def cf_chip(extra = nil)
20
+ ["inline-flex items-center rounded-md border border-zinc-200 px-2 py-0.5 text-xs text-zinc-600 hover:bg-zinc-50", extra].compact.join(" ")
21
+ end
22
+
23
+ # State badge, upgraded to "scheduled" for an idle workflow parked on a
24
+ # wait whose wake time is still in the future — so genuinely-scheduled work
25
+ # doesn't read as "stuck idle".
26
+ def cf_state_badge(workflow, wait = nil)
27
+ return cf_badge("scheduled") if workflow.idle? && wait&.scheduled?
28
+ cf_badge(workflow.state)
29
+ end
30
+
31
+ def cf_dot(state)
32
+ tag.span(class: "cf-dot cf-dot-#{state}")
33
+ end
34
+
35
+ def cf_time(t)
36
+ t&.iso8601 || "—"
37
+ end
38
+
39
+ # A capped count: shows "5000+" once the count saturates its cap.
40
+ def cf_capped(count, cap)
41
+ (count >= cap) ? "#{cap}+" : count.to_s
42
+ end
43
+
44
+ # Whether the viewer prefers absolute timestamps (cookie-persisted nav toggle).
45
+ def cf_absolute_time?
46
+ cookies[:cf_time_format] == "absolute"
47
+ end
48
+
49
+ # Auto-refresh interval in seconds (0 = off). A cookie-persisted nav control
50
+ # overrides the configured default per viewer; options come from config.
51
+ def cf_poll_options = ChronoForge::Dashboard.config.polling_interval_options
52
+
53
+ def cf_poll_interval
54
+ raw = cookies[:cf_poll_interval]
55
+ return raw.to_i if raw.present? && raw.match?(/\A\d+\z/)
56
+ ChronoForge::Dashboard.config.polling_interval.to_i
57
+ end
58
+
59
+ def cf_poll_label(secs)
60
+ return "off" if secs.zero?
61
+ (secs % 60 == 0) ? "#{secs / 60}m" : "#{secs}s"
62
+ end
63
+
64
+ # A timestamp shown relative ("3 minutes ago") or absolute (raw ISO8601)
65
+ # per the viewer's preference, with the other form available on hover.
66
+ def cf_ago(t)
67
+ return "—" unless t
68
+ rel = "#{time_ago_in_words(t)} ago"
69
+ abs = t.iso8601
70
+ shown, hover = cf_absolute_time? ? [abs, rel] : [rel, abs]
71
+ tag.span(shown, title: hover, class: "cursor-help")
72
+ end
73
+
74
+ # Human duration between two times (e.g. "1m 04s"); "—" if unfinished.
75
+ def cf_duration(from, to)
76
+ return "—" unless from && to
77
+ cf_secs((to - from).to_i)
78
+ end
79
+
80
+ # Human duration from a number of seconds, scaled to the two most-significant
81
+ # units (e.g. "45s", "1m 04s", "3h 12m", "2d 21h"); "—" if nil.
82
+ def cf_secs(secs)
83
+ return "—" if secs.nil?
84
+ secs = secs.to_i
85
+ return "#{secs}s" if secs < 60
86
+ return "#{secs / 60}m #{(secs % 60).to_s.rjust(2, "0")}s" if secs < 3600
87
+ return "#{secs / 3600}h #{(secs % 3600 / 60).to_s.rjust(2, "0")}m" if secs < 86400
88
+ "#{secs / 86400}d #{(secs % 86400 / 3600).to_s.rjust(2, "0")}h"
89
+ end
90
+
91
+ # Class name for a stacked-bar segment, width quantized to 5% steps so it
92
+ # stays CSP-safe (no inline style — see .cf-bar-{0..100} in tailwind.css).
93
+ def cf_bar_width(value, max)
94
+ pct = (max.to_f.zero? ? 0 : (value / max.to_f * 100))
95
+ "cf-bar-#{(pct / 5).round * 5}"
96
+ end
97
+
98
+ # A rate (0.0–1.0) as a percentage; "—" if nil. Keeps tiny non-zero rates
99
+ # visible (a 0.0008% workflow-failure rate shows "<0.01%", never "0%").
100
+ def cf_pct(rate)
101
+ return "—" if rate.nil?
102
+ pct = rate * 100
103
+ return "0%" if pct.zero?
104
+ return "<0.01%" if pct < 0.01
105
+ (pct < 1) ? "#{pct.round(2)}%" : "#{pct.round}%"
106
+ end
107
+
108
+ # Concise latency summary (avg + most recent) from a list of run seconds.
109
+ def cf_latency_summary(latencies)
110
+ return "—" if latencies.blank?
111
+ avg = (latencies.sum.to_f / latencies.size).round
112
+ "avg #{avg}s · last #{latencies.last}s"
113
+ end
114
+
115
+ # Short, readable label for a parsed step kind.
116
+ KIND_LABELS = {
117
+ execute: "execute", sleep: "wait", wait: "wait until", continue: "continue if",
118
+ repeat_coordination: "repeat", repeat_run: "run", lifecycle: "workflow",
119
+ branch: "branch", merge: "merge", unknown: "step"
120
+ }.freeze
121
+
122
+ def cf_kind_label(kind)
123
+ KIND_LABELS.fetch(kind, kind.to_s)
124
+ end
125
+
126
+ # Human-friendly [label, value] pairs of a step's metadata for the timeline
127
+ # — surfaces things like a wait's resume time, a wait_until timeout, or a
128
+ # durably_repeat's last execution. Keys are humanized; values are stringified
129
+ # (the view truncates). Blank values are dropped.
130
+ # Internal bookkeeping surfaced elsewhere (the linked error is rendered
131
+ # inline; branch poll state + spawn cursors show in the Branches panel), so
132
+ # they'd just be noise in the timeline's metadata line. poll_token is the
133
+ # merge poller's fencing token — pure plumbing, never user-facing.
134
+ META_SKIP = %w[error_log_id poll poll_token cursors].freeze
135
+
136
+ def cf_meta_pairs(metadata)
137
+ return [] unless metadata.is_a?(Hash)
138
+ metadata
139
+ .reject { |k, v| v.nil? || v == "" || META_SKIP.include?(k.to_s) }
140
+ .map { |k, v| [k.to_s.tr("_", " "), v.to_s] }
141
+ end
142
+
143
+ # Text color for an execution-log status (pending/completed/failed).
144
+ def cf_status_color(status)
145
+ case status
146
+ when "completed" then "text-emerald-600"
147
+ when "failed" then "text-rose-600"
148
+ else "text-zinc-500"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,64 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # Health of a single branch (a branch$<name> execution log) for the parent's
4
+ # detail page. Every child count is CAPPED and index-only on
5
+ # (parent_execution_log_id, state) — a branch can hold hundreds of thousands
6
+ # of children, so we never count the full set, only up to CAP (shown "CAP+").
7
+ class BranchPresenter
8
+ CAP = 5000
9
+
10
+ # merge_state: :merged | :merging | nil (not yet merged)
11
+ def initialize(log, merge_state = nil)
12
+ @log = log
13
+ @merge_state = merge_state
14
+ end
15
+
16
+ attr_reader :log, :merge_state
17
+
18
+ def name = StepNameParser.parse(@log.step_name).name
19
+
20
+ # The branch is "sealed" once its block closed (done dispatching children).
21
+ def sealed? = @log.completed?
22
+
23
+ def dispatched = capped(children)
24
+ def pending = capped(children.where.not(state: ChronoForge::Workflow.states[:completed]))
25
+ def blocked = capped(children.where(state: BLOCKED_STATES))
26
+
27
+ def cap = CAP
28
+
29
+ BLOCKED_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
30
+
31
+ # A scheduled next poll this far past due means the BranchMergeJob poller
32
+ # likely never ran (queue latency aside) — a heuristic, hence "potential".
33
+ POLL_OVERDUE_GRACE = 120 # seconds
34
+
35
+ # The BranchMergeJob stamps its poll state onto the branch log's metadata
36
+ # (it can't be queried from the backend; ActiveJob has no such API).
37
+ def polled? = poll.present?
38
+ def last_polled_at = parse_time(poll&.dig("last_polled_at"))
39
+ def next_poll_at = parse_time(poll&.dig("next_poll_at"))
40
+ def polls = poll&.dig("polls").to_i
41
+
42
+ # next_poll_at is nil once the merge completes, so a finished merge never
43
+ # looks overdue; a non-nil time well in the past = the poller is likely dead.
44
+ def poll_overdue?
45
+ t = next_poll_at
46
+ t.present? && t < Time.current - POLL_OVERDUE_GRACE
47
+ end
48
+
49
+ private
50
+
51
+ def children = @log.spawned_workflows
52
+
53
+ def poll = @log.metadata&.dig("poll")
54
+
55
+ def parse_time(value) = value.present? ? Time.zone.parse(value.to_s) : nil
56
+
57
+ # Index-only COUNT over a LIMIT CAP subquery — O(CAP) regardless of how many
58
+ # children match (mirrors StatsQuery).
59
+ def capped(relation)
60
+ ChronoForge::Workflow.from(relation.reorder(nil).select(:id).limit(CAP), :capped).count
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,62 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # A workflow's branches for its detail page. Loads only the coordination logs
4
+ # (branch$<name> and merge$<names>) — a tiny set — and derives each branch's
5
+ # merge state from the merge logs (the core doesn't persist a "merged" flag).
6
+ class BranchesPresenter
7
+ # One merge join (a merge$<names> log = a BranchMergeJob's durable target).
8
+ # state: :merging (pending — a poller is joining) | :merged (completed).
9
+ Merge = Struct.new(:names, :state, :started_at) do
10
+ def merging? = state == :merging
11
+ end
12
+
13
+ def initialize(workflow) = @workflow = workflow
14
+
15
+ def any? = branch_logs.any?
16
+
17
+ def branches
18
+ @branches ||= branch_logs
19
+ .sort_by(&:step_name)
20
+ .map { |log| BranchPresenter.new(log, merge_states[StepNameParser.parse(log.step_name).name]) }
21
+ end
22
+
23
+ # The merge joins on this workflow, in-progress first. A long-pending merge
24
+ # with no blocked children is the sign of a dropped BranchMergeJob poller.
25
+ def merges
26
+ @merges ||= merge_logs
27
+ .map { |log| Merge.new(StepNameParser.parse(log.step_name).name.split(","), log.completed? ? :merged : :merging, log.started_at) }
28
+ .sort_by { |m| [m.merging? ? 0 : 1, m.started_at || Time.current] }
29
+ end
30
+
31
+ private
32
+
33
+ def coordination_logs
34
+ d = StepNameParser::DELIM
35
+ @coordination_logs ||= @workflow.execution_logs
36
+ .where("step_name LIKE ? OR step_name LIKE ?", "branch#{d}%", "merge#{d}%")
37
+ .to_a
38
+ end
39
+
40
+ def branch_logs
41
+ coordination_logs.select { |l| StepNameParser.parse(l.step_name).kind == :branch }
42
+ end
43
+
44
+ def merge_logs
45
+ coordination_logs.select { |l| StepNameParser.parse(l.step_name).kind == :merge }
46
+ end
47
+
48
+ # branch name => :merged (merge log completed) | :merging (pending). A merge
49
+ # log covers one or more comma-joined branch names; "merged" wins if a name
50
+ # appears in both a completed and a pending merge.
51
+ def merge_states
52
+ @merge_states ||= merge_logs
53
+ .each_with_object({}) do |log, map|
54
+ state = log.completed? ? :merged : :merging
55
+ StepNameParser.parse(log.step_name).name.split(",").each do |nm|
56
+ map[nm] = state unless map[nm] == :merged
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,17 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class ContextPresenter
4
+ def initialize(workflow) = @workflow = workflow
5
+
6
+ def nodes
7
+ context.map { |k, v| {key: k, value: v, type: v.class.name, bytes: v.to_json.bytesize} }
8
+ end
9
+
10
+ def byte_size = context.to_json.bytesize
11
+
12
+ private
13
+
14
+ def context = @workflow.context || {}
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,77 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # Health of a workflow's durably_repeat tasks. Never materializes the full run
4
+ # history (which can be huge) — coordination logs are a tiny set, and each
5
+ # task's run aggregates are computed with bounded/scoped queries that ride the
6
+ # [workflow_id, step_name] index as range scans.
7
+ class PeriodicHealthPresenter
8
+ Task = Struct.new(:name, :last_execution_at, :next_scheduled_at, :timed_out_count, :latencies)
9
+
10
+ RECENT = 20
11
+ # Bound the metadata scan used to count missed ticks (see #missed_ticks).
12
+ SCAN_CAP = 1_000
13
+
14
+ def initialize(workflow) = @workflow = workflow
15
+
16
+ def tasks
17
+ coordinations.map do |coord|
18
+ name = StepNameParser.parse(coord.step_name).name
19
+ Task.new(
20
+ name: name,
21
+ last_execution_at: parse_time(coord.metadata&.dig("last_execution_at")),
22
+ next_scheduled_at: next_scheduled(name),
23
+ timed_out_count: missed_ticks(name),
24
+ latencies: recent_latencies(name)
25
+ )
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # The coordination logs (durably_repeat$name, no $ts suffix) — one per task.
32
+ def coordinations
33
+ @workflow.execution_logs
34
+ .where("step_name LIKE ?", "durably_repeat#{d}%")
35
+ .where.not("step_name LIKE ?", "durably_repeat#{d}%#{d}%")
36
+ .to_a
37
+ end
38
+
39
+ def runs(name)
40
+ @workflow.execution_logs.where("step_name LIKE ?", "durably_repeat#{d}#{name}#{d}%")
41
+ end
42
+
43
+ # Missed (timed-out) ticks. A fast-forward catch-up collapses N expired ticks
44
+ # into one TimeoutError row tagged fast_forwarded:N, so count it as N; a plain
45
+ # per-tick timeout counts as 1. Bounded metadata scan.
46
+ def missed_ticks(name)
47
+ runs(name).where(error_class: "TimeoutError")
48
+ .limit(SCAN_CAP).pluck(:metadata)
49
+ .sum { |m| [m&.dig("fast_forwarded").to_i, 1].max }
50
+ end
51
+
52
+ # Next run = the furthest-out not-yet-completed scheduled repetition. Pending
53
+ # runs are few (the future-scheduled ones), so loading them is bounded.
54
+ def next_scheduled(name)
55
+ ts = runs(name).where(state: ChronoForge::ExecutionLog.states[:pending])
56
+ .filter_map { |r| StepNameParser.parse(r.step_name).timestamp }.max
57
+ Time.zone.at(ts) if ts
58
+ end
59
+
60
+ # Durations (seconds) of the most recent completed runs, oldest-first so the
61
+ # summary's "last" is the newest. Bounded to RECENT rows.
62
+ def recent_latencies(name)
63
+ runs(name).where(state: ChronoForge::ExecutionLog.states[:completed])
64
+ .where.not(started_at: nil, completed_at: nil)
65
+ .order(id: :desc).limit(RECENT)
66
+ .map { |r| (r.completed_at - r.started_at).to_i }.reverse
67
+ end
68
+
69
+ def parse_time(value)
70
+ return nil if value.blank?
71
+ value.is_a?(Time) ? value : Time.zone.parse(value.to_s)
72
+ end
73
+
74
+ def d = StepNameParser::DELIM
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,90 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class TimelinePresenter
4
+ Entry = Struct.new(:id, :kind, :name, :step_name, :status, :attempts,
5
+ :started_at, :completed_at, :last_executed_at, :error_class, :error_message,
6
+ :metadata, :errors, :missing_error_id, :iterations, :tombstones, :skipped_ticks, :last_run_at)
7
+
8
+ # Per-iteration run logs of a durably_repeat step are excluded from the
9
+ # timeline (they get their own paginated page) and summarized instead.
10
+ RUN_PATTERN = "durably_repeat#{StepNameParser::DELIM}%#{StepNameParser::DELIM}%".freeze
11
+
12
+ def initialize(workflow) = @workflow = workflow
13
+
14
+ attr_reader :workflow
15
+
16
+ def entries
17
+ @entries ||= build
18
+ end
19
+
20
+ # Error logs not shown on any step — workflow-level failures whose step_name
21
+ # is nil and that aren't linked to a $workflow_failure$ marker. Surfaced so
22
+ # a failure is never invisible. (Repeat-run errors live on the repetitions
23
+ # page, so they're excluded.)
24
+ def orphan_errors
25
+ entries
26
+ @orphan_errors
27
+ end
28
+
29
+ def current_position
30
+ logs = ordered_logs
31
+ logs.reverse.find { |l| l.failed? } ||
32
+ logs.reverse.find { |l| l.pending? && StepNameParser.parse(l.step_name).kind == :wait } ||
33
+ logs.last
34
+ end
35
+
36
+ private
37
+
38
+ def ordered_logs
39
+ @ordered_logs ||= @workflow.execution_logs
40
+ .where.not("step_name LIKE ?", RUN_PATTERN)
41
+ .order(Arel.sql("started_at, id")).to_a
42
+ end
43
+
44
+ def build
45
+ all_errors = @workflow.error_logs.order(:attempt, :created_at).to_a
46
+ by_step = all_errors.group_by(&:step_name)
47
+ by_id = all_errors.index_by(&:id)
48
+ shown = []
49
+
50
+ entries = ordered_logs.map do |l|
51
+ p = StepNameParser.parse(l.step_name)
52
+ errors = (by_step[l.step_name] || []).dup
53
+ # A workflow-level failure ($workflow_failure$<id>) records its error
54
+ # with a nil step_name, so attach it to the marker by id. If that error
55
+ # log is gone (independently pruned), note the id so the marker still
56
+ # says *something* rather than rendering an errorless failure.
57
+ missing_error_id = nil
58
+ if p.kind == :lifecycle && p.name == "failure" && p.timestamp
59
+ if (err = by_id[p.timestamp])
60
+ errors << err unless errors.include?(err)
61
+ else
62
+ missing_error_id = p.timestamp
63
+ end
64
+ end
65
+ shown.concat(errors)
66
+ entry = Entry.new(id: l.id, kind: p.kind, name: p.name, step_name: l.step_name,
67
+ status: l.state, attempts: l.attempts, started_at: l.started_at,
68
+ completed_at: l.completed_at, last_executed_at: l.last_executed_at,
69
+ error_class: l.error_class, error_message: l.error_message,
70
+ metadata: l.metadata, errors: errors, missing_error_id: missing_error_id)
71
+ summarize_repetitions(entry, p.name) if p.kind == :repeat_coordination
72
+ entry
73
+ end
74
+
75
+ @orphan_errors = (all_errors - shown).reject { |e| e.step_name.to_s.match?(RUN_PATTERN_RX) }
76
+ entries
77
+ end
78
+
79
+ RUN_PATTERN_RX = /\Adurably_repeat#{Regexp.escape(StepNameParser::DELIM)}.+#{Regexp.escape(StepNameParser::DELIM)}/
80
+
81
+ def summarize_repetitions(entry, name)
82
+ s = RepetitionsQuery.new(workflow: @workflow, step: name).summary
83
+ entry.iterations = s[:iterations]
84
+ entry.tombstones = s[:tombstones]
85
+ entry.skipped_ticks = s[:skipped_ticks]
86
+ entry.last_run_at = s[:last_run_at]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,64 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class WaitStatePresenter
4
+ # kind: :wait (wait_until — polls, has a timeout) or :continue (continue_if
5
+ # — waits on an external event, NO timeout, never self-resumes). A stuck
6
+ # continue_if is the silent killer: a webhook that never arrives leaves the
7
+ # workflow parked forever with nothing to flag it.
8
+ Active = Struct.new(:kind, :condition, :waiting_since, :timeout_at) do
9
+ # Only a time-based wait with a wake time still in the future is
10
+ # "scheduled" (intentionally parked until then). Event waits never are.
11
+ def scheduled?
12
+ return false unless kind == :wait && timeout_at
13
+ t = timeout_at.is_a?(Time) ? timeout_at : Time.zone.parse(timeout_at.to_s)
14
+ t&.future? || false
15
+ end
16
+
17
+ def event_wait? = kind == :continue
18
+
19
+ # The scheduled wake time as a Time, or nil for event waits / no timeout.
20
+ def next_run_at
21
+ return nil unless kind == :wait && timeout_at
22
+ timeout_at.is_a?(Time) ? timeout_at : Time.zone.parse(timeout_at.to_s)
23
+ end
24
+ end
25
+
26
+ def initialize(workflow) = @workflow = workflow
27
+
28
+ # Reuses the batch resolver so a single workflow and a page of them agree:
29
+ # it looks at the latest *pending wait/continue* log, ignoring durably_repeat
30
+ # run logs (which stamp started_at = now and would otherwise mask the wait).
31
+ def active
32
+ self.class.active_map([@workflow])[@workflow.id]
33
+ end
34
+
35
+ # Active waits for a batch of workflows, in two queries instead of one per
36
+ # row. Returns {workflow_id => Active} for idle workflows currently parked
37
+ # on a pending wait_until or continue_if. Bounded by the caller's set.
38
+ def self.active_map(workflows)
39
+ ids = workflows.select(&:idle?).map(&:id)
40
+ return {} if ids.empty?
41
+
42
+ d = StepNameParser::DELIM
43
+ latest = {}
44
+ ChronoForge::ExecutionLog
45
+ .where(workflow_id: ids, state: ChronoForge::ExecutionLog.states[:pending])
46
+ .where("step_name LIKE ? OR step_name LIKE ?", "wait_until#{d}%", "continue_if#{d}%")
47
+ .order(Arel.sql("started_at, id"))
48
+ .each { |log| latest[log.workflow_id] = log }
49
+
50
+ latest.transform_values { |log| build(log) }
51
+ end
52
+
53
+ def self.build(log)
54
+ p = StepNameParser.parse(log.step_name)
55
+ Active.new(
56
+ kind: p.kind,
57
+ condition: p.name,
58
+ waiting_since: log.last_executed_at || log.started_at,
59
+ timeout_at: log.metadata&.dig("timeout_at")
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end