pgbus 0.19.3 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +104 -9
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +130 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +142 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +67 -26
- data/lib/pgbus/active_job/batch_id.rb +23 -1
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/batch/builder.rb +1 -0
- data/lib/pgbus/batch.rb +35 -3
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
- data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
- data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +131 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +95 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Skips the step a waiting workflow run waits on (issue #550).
|
|
7
|
+
# DataSource#skip_workflow_step.
|
|
8
|
+
class WorkflowSkipStepTool < WriteTool
|
|
9
|
+
tool_name "pgbus_workflow_skip_step"
|
|
10
|
+
title "Pgbus Workflow: Skip Step"
|
|
11
|
+
description verb_description(:skip, "Skip the step a waiting Pgbus::Workflow run waits on (a signal or a fan-out batch).")
|
|
12
|
+
input_schema(**workflow_input_schema)
|
|
13
|
+
|
|
14
|
+
def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
|
|
15
|
+
perform(:skip, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
16
|
+
server_context: server_context) do |data_source, **who|
|
|
17
|
+
data_source.skip_workflow_step(workflow_id, **who)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# One workflow class's insights over a window (issue #549): its
|
|
7
|
+
# pipeline with live counts and step performance, throughput, durations,
|
|
8
|
+
# funnel and per-revision numbers. Maps to DataSource#workflow_definition,
|
|
9
|
+
# the dashboard's /pgbus/workflows/definitions/:workflow_class.
|
|
10
|
+
class WorkflowStatsTool < BaseTool
|
|
11
|
+
tool_name "pgbus_workflow_stats"
|
|
12
|
+
title "Pgbus Workflow Stats"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
One Pgbus::Workflow class over a window (1h, 24h default, 7d or 30d, capped at workflow_retention). steps, in declaration order (gravestones as kind "removed"; names only the rows hold as declared: false): counts (runs sitting at the step now by run status ready/performing/waiting/paused), run_ms (p50/p95/p99 of finished_at - started_at over completed or failed attempts) and pickup_ms (p50/p95 of scheduled → started), rates (failed: an error was recorded, reattempted, skipped) and outcomes over the sample_size attempts that finished in the window; percentiles over fewer than 20 samples are noisy. Also: started/finished/cancelled, e2e_ms, duration_histogram (finished runs per created → finished range), throughput (runs started/finished/cancelled per bucket_seconds), funnel (of the runs started in the window: reached = runs with a row for the step, stopped = where each run is now by status) and by_revision (the five app revisions that finished an attempt last, newest first, totals and per step).
|
|
15
|
+
DESC
|
|
16
|
+
|
|
17
|
+
input_schema(
|
|
18
|
+
properties: {
|
|
19
|
+
workflow_class: { type: "string", description: "The class name, e.g. OrderWorkflow or Billing::OrderWorkflow." },
|
|
20
|
+
window: { type: "string", enum: Web::WorkflowWindow::CHOICES.keys,
|
|
21
|
+
description: "1h, 24h (default), 7d or 30d; capped at workflow_retention." }
|
|
22
|
+
},
|
|
23
|
+
required: %w[workflow_class]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def self.call(workflow_class:, window: nil, server_context: nil)
|
|
27
|
+
name = workflow_class.to_s
|
|
28
|
+
unless Web::DataSource::WorkflowInsights::CLASS_NAME.match?(name)
|
|
29
|
+
return error_response("#{name.inspect} is not a class name (OrderWorkflow or Billing::OrderWorkflow)")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
detail = data_source_from(server_context).workflow_definition(name, window: Web::WorkflowWindow.parse(window))
|
|
33
|
+
return error_response("No workflow class or run named #{name}") unless detail
|
|
34
|
+
|
|
35
|
+
json_response(Serialization.serialize(workflow: WorkflowInsightsPayload.row(detail)))
|
|
36
|
+
rescue Web::WorkflowWindow::Invalid => e
|
|
37
|
+
error_response(e.message)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# The dashboard's needs-attention view (issue #542): ongoing runs that
|
|
7
|
+
# wait on a person, grouped. Maps to DataSource#workflow_attention_groups.
|
|
8
|
+
class WorkflowsAttentionTool < BaseTool
|
|
9
|
+
tool_name "pgbus_workflows_attention"
|
|
10
|
+
title "Pgbus Workflows Needing Attention"
|
|
11
|
+
description <<~DESC
|
|
12
|
+
Ongoing Pgbus::Workflow runs that need a person, grouped by (workflow_class, step_name, reason, error_class): reason is paused_error, stalled, paused_manual, overdue (due but not picked up past workflow_stall_threshold) or waiting_overdue (waiting past workflow_waiting_visibility_threshold without a wait_timeout). Each group has its run count, first_seen_at/last_seen_at, the status its runs are in and the actions that status allows; total_runs sums the groups (at most 200 groups, biggest first). workflow_class narrows them to one class. The newest error message is redacted unless the server allows payloads and include_payloads is set.
|
|
13
|
+
DESC
|
|
14
|
+
|
|
15
|
+
input_schema(
|
|
16
|
+
properties: {
|
|
17
|
+
workflow_class: { type: "string", description: "Only this class's groups, e.g. OrderWorkflow." },
|
|
18
|
+
include_payloads: {
|
|
19
|
+
type: "boolean", description: "Include error messages. Only honored when the server allows payloads."
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
required: []
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def self.call(workflow_class: nil, include_payloads: false, server_context: nil)
|
|
26
|
+
if workflow_class && !Web::DataSource::WorkflowInsights::CLASS_NAME.match?(workflow_class.to_s)
|
|
27
|
+
return error_response("#{workflow_class.inspect} is not a class name (OrderWorkflow or Billing::OrderWorkflow)")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
groups = data_source_from(server_context).workflow_attention_groups(workflow_class: workflow_class&.to_s)
|
|
31
|
+
allowed = payloads_allowed?(server_context, include_payloads)
|
|
32
|
+
|
|
33
|
+
json_response(
|
|
34
|
+
{ groups: groups.map { |group| WorkflowPayloads.group(group, allowed: allowed) },
|
|
35
|
+
total_runs: groups.sum { |group| group[:count] } },
|
|
36
|
+
server_context: server_context, include_payloads: include_payloads
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Workflow runs, filtered like the dashboard's /pgbus/workflows (issue
|
|
7
|
+
# #542). Maps to DataSource#workflows and #workflows_count; the filters
|
|
8
|
+
# are checked by the same Web::WorkflowQuery the index uses.
|
|
9
|
+
class WorkflowsTool < BaseTool
|
|
10
|
+
tool_name "pgbus_workflows"
|
|
11
|
+
title "Pgbus Workflows"
|
|
12
|
+
description <<~DESC
|
|
13
|
+
List Pgbus::Workflow runs, newest first: class, subject, status, current step and attempt, what a waiting step waits for (signal name, fan-out batch progress), when a scheduled step runs, and the error class of a paused one. status is "ongoing" (default: ready, performing, waiting or paused), one state, or "all"; the other filters combine with AND. created_from/created_to and ended_from/ended_to (when a run finished or was cancelled) take an ISO 8601 date or time. Paginated (default 25, max 100); total is capped at 10000. The error message is redacted unless the server allows payloads and include_payloads is set.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
MAX_PER_PAGE = 100
|
|
17
|
+
MAX_PAGE = 1_000
|
|
18
|
+
TEXT = %w[workflow_class step error_class subject_type subject_id workflow_id].freeze
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
status: { type: "string", enum: Web::DataSource::WorkflowList::STATUSES,
|
|
23
|
+
description: "ongoing (default), ready, performing, waiting, paused, finished, cancelled or all." },
|
|
24
|
+
**TEXT.to_h { |name| [name.to_sym, { type: "string", description: "Exact #{name.tr("_", " ")}." }] },
|
|
25
|
+
created_from: { type: "string", description: "Created at or after this ISO 8601 date or time." },
|
|
26
|
+
created_to: { type: "string", description: "Created at or before this ISO 8601 date (the whole day) or time." },
|
|
27
|
+
ended_from: { type: "string", description: "Finished or cancelled at or after this ISO 8601 date or time." },
|
|
28
|
+
ended_to: {
|
|
29
|
+
type: "string", description: "Finished or cancelled at or before this ISO 8601 date (the whole day) or time."
|
|
30
|
+
},
|
|
31
|
+
page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
|
|
32
|
+
per_page: { type: "integer", description: "Rows per page (default 25, max 100).", minimum: 1 },
|
|
33
|
+
include_payloads: {
|
|
34
|
+
type: "boolean", description: "Include error messages. Only honored when the server allows payloads."
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
required: []
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def self.call(page: 1, per_page: 25, include_payloads: false, server_context: nil, **filters)
|
|
41
|
+
query = Web::WorkflowQuery.new(filters)
|
|
42
|
+
data_source = data_source_from(server_context)
|
|
43
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
44
|
+
page = page.to_i.clamp(1, MAX_PAGE)
|
|
45
|
+
rows = data_source.workflows(status: query.status, page: page, per_page: per_page, **query.filters)
|
|
46
|
+
count = data_source.workflows_count(status: query.status, **query.filters)
|
|
47
|
+
allowed = payloads_allowed?(server_context, include_payloads)
|
|
48
|
+
|
|
49
|
+
json_response(
|
|
50
|
+
{ workflows: rows.map { |row| WorkflowPayloads.row(row, allowed: allowed) }, page: page, per_page: per_page,
|
|
51
|
+
total: count.total, has_more: more_pages?(count, page: page, per_page: per_page, shown: rows.size) },
|
|
52
|
+
server_context: server_context, include_payloads: include_payloads
|
|
53
|
+
)
|
|
54
|
+
rescue Web::WorkflowQuery::Invalid => e
|
|
55
|
+
error_response(e.message)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# The workflow insights' hashes as the MCP tools send them (issue #549).
|
|
6
|
+
# removed_in_flight is keyed by step name, and a step may be named like a
|
|
7
|
+
# payload key (removed_step :payload), which the Redactor would blank:
|
|
8
|
+
# the tools send it as [{ step:, runs: }] rows instead.
|
|
9
|
+
module WorkflowInsightsPayload
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def row(row) = row.merge(removed_in_flight: row.fetch(:removed_in_flight, {}).map { |step, runs| { step: step, runs: runs } })
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# What the workflow tools (issue #542) send back. A run's arguments, its
|
|
6
|
+
# steps' outputs, its signals' payloads and its error text (message,
|
|
7
|
+
# backtrace, cause messages, an event's error_message) can carry
|
|
8
|
+
# application data, and the Redactor only blanks known payload keys
|
|
9
|
+
# holding a leaf value, so these tools gate them explicitly: replaced by
|
|
10
|
+
# Redactor::REDACTED unless BaseTool.payloads_allowed? says otherwise.
|
|
11
|
+
# Written out through MCP::Serialization.
|
|
12
|
+
module WorkflowPayloads
|
|
13
|
+
REDACTED = Redactor::REDACTED
|
|
14
|
+
STEP_SECRETS = %i[output error_message error_backtrace].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def row(row, allowed:)
|
|
19
|
+
serialize(allowed ? row : row.merge(redact(row, :error_message)))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detail(detail, allowed:)
|
|
23
|
+
return serialize(detail.except(:timeline)) if allowed
|
|
24
|
+
|
|
25
|
+
gated = detail.except(:timeline).merge(arguments: REDACTED, **redact(detail, :error_message))
|
|
26
|
+
gated[:steps] = Array(detail[:steps]).map { |step| gate_step(step) }
|
|
27
|
+
gated[:signals] = Array(detail[:signals]).map { |signal| signal.merge(payload: REDACTED) }
|
|
28
|
+
gated[:events] = Array(detail[:events]).map { |event| gate_event(event) }
|
|
29
|
+
serialize(gated)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def group(group, allowed:)
|
|
33
|
+
serialize(allowed ? group : group.merge(redact(group, :error_message)))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def gate_step(step)
|
|
37
|
+
gated = step.merge(STEP_SECRETS.to_h { |key| [key, step[key].nil? ? nil : REDACTED] })
|
|
38
|
+
gated[:error_causes] = Array(step[:error_causes]).map { |cause| cause.slice("class", :class) }
|
|
39
|
+
gated
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def gate_event(event)
|
|
43
|
+
details = event[:details] || {}
|
|
44
|
+
event.merge(details: details.reject { |key, _| key.to_s == "error_message" })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# { key => REDACTED } when the value is there, so a nil stays nil.
|
|
48
|
+
def redact(hash, key) = hash[key].nil? ? {} : { key => REDACTED }
|
|
49
|
+
|
|
50
|
+
def serialize(value) = Serialization.serialize(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# Base class for the opt-in write tools (issue #550). Deliberately not a
|
|
6
|
+
# BaseTool subclass: no write tool can inherit the read-only annotations,
|
|
7
|
+
# and no read tool can become a write tool by changing its superclass.
|
|
8
|
+
# Server registers these only when config.mcp_write_tools is true.
|
|
9
|
+
#
|
|
10
|
+
# Every call (#perform): the run loaded (unknown: ok: false), the tool's
|
|
11
|
+
# inputs and a reason (1-500 characters after trimming) checked,
|
|
12
|
+
# expected_status checked when given, the verb checked against the run's
|
|
13
|
+
# available_actions, then the same Web::DataSource action the dashboard
|
|
14
|
+
# uses, recorded as actor "mcp" (or "mcp:<client name>"). The answer is
|
|
15
|
+
# { ok:, reason:, workflow: } with the run as it is after the call,
|
|
16
|
+
# payloads always redacted; a refusal changes nothing and records nothing.
|
|
17
|
+
#
|
|
18
|
+
# The two checks read the run before acting, so they are advisory: the
|
|
19
|
+
# action's guarded transition under the run's row lock decides, and its
|
|
20
|
+
# code (:not_allowed, with the status the run is in now) is passed on.
|
|
21
|
+
class WriteTool < ::MCP::Tool
|
|
22
|
+
ANNOTATIONS = { read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: false }.freeze
|
|
23
|
+
REASON_LIMIT = 500
|
|
24
|
+
CLIENT_NAME_LIMIT = 100
|
|
25
|
+
ACTOR = "mcp"
|
|
26
|
+
# What an unexpected failure answers: its text can carry internals
|
|
27
|
+
# (hosts, SQL, values), and Web::DataSource has already logged it.
|
|
28
|
+
FAILED = "the action failed unexpectedly; the error is in the pgbus log"
|
|
29
|
+
|
|
30
|
+
annotations(**ANNOTATIONS)
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# MCP::Tool.inherited resets @annotations_value on every subclass, so
|
|
34
|
+
# fall back to the nearest ancestor's (as BaseTool does).
|
|
35
|
+
def annotations_value
|
|
36
|
+
super || (superclass.respond_to?(:annotations_value) ? superclass.annotations_value : nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# For a verb whose repeat can never act again (cancel: a cancelled run
|
|
40
|
+
# stays cancelled). Not skip or resume at: a retry can act again once
|
|
41
|
+
# the run has parked or paused again.
|
|
42
|
+
def idempotent!
|
|
43
|
+
annotations(**ANNOTATIONS, idempotent_hint: true)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The input_schema every write tool takes, plus the tool's own fields.
|
|
47
|
+
def workflow_input_schema(properties: {}, required: [])
|
|
48
|
+
{
|
|
49
|
+
properties: {
|
|
50
|
+
workflow_id: { type: "string", description: "The run's workflow_id (a UUID)." },
|
|
51
|
+
reason: { type: "string", minLength: 1, maxLength: REASON_LIMIT,
|
|
52
|
+
description: "Why, in 1-#{REASON_LIMIT} characters. Recorded on the run's audit trail." },
|
|
53
|
+
expected_status: {
|
|
54
|
+
type: "string", enum: Workflow::States::WORKFLOW,
|
|
55
|
+
description: "The status you read. If the run is in another one when the call starts: ok: false, nothing done."
|
|
56
|
+
},
|
|
57
|
+
**properties
|
|
58
|
+
},
|
|
59
|
+
required: %w[workflow_id reason] + required
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The tool description: what it does, the transition it makes, and the
|
|
64
|
+
# opt-in. Frozen: it is class state (the conformance audit checks).
|
|
65
|
+
def verb_description(verb, summary)
|
|
66
|
+
transition = Workflow::States.fetch(verb)
|
|
67
|
+
<<~DESC.freeze
|
|
68
|
+
#{summary} Allowed from: #{transition.from.join(", ")}. What happens: #{transition.effect}. A write tool: it exists only when the server was built with config.mcp_write_tools = true. reason is required and recorded on the run's audit trail with actor "mcp" (or "mcp:<client name>" when the transport knows the client); pass expected_status (the status you read with pgbus_workflow): a run that has moved on by the time the call starts answers ok: false and nothing is done (the check runs just before the action, whose transition is guarded again under the run's row lock). Answers { ok, reason, workflow } with the run as it is after the call; a refused call changes nothing and records nothing (a transition refused under the lock adds code and status, a disallowed verb available_actions); arguments, outputs, signal payloads and error text are redacted.
|
|
69
|
+
DESC
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Runs the checks, then yields (data_source, actor:, reason:) for the
|
|
73
|
+
# DataSource action, whose { ok:, ... } becomes the answer. +invalid+
|
|
74
|
+
# is the tool's own input refusal (a blank step, an oversized
|
|
75
|
+
# payload), answered like every other refusal: with the run as it is.
|
|
76
|
+
def perform(verb, workflow_id:, reason:, expected_status:, server_context:, invalid: nil)
|
|
77
|
+
data_source = BaseTool.data_source_from(server_context)
|
|
78
|
+
detail = data_source.workflow_detail(workflow_id)
|
|
79
|
+
return answer(ok: false, reason: "workflow #{workflow_id} not found", workflow: nil) unless detail
|
|
80
|
+
|
|
81
|
+
note = reason.to_s.strip
|
|
82
|
+
refusal = input_refusal(note, invalid) || guard_refusal(verb, detail, expected_status)
|
|
83
|
+
return answer(**refusal, workflow: present(detail)) if refusal
|
|
84
|
+
|
|
85
|
+
result = outcome(yield(data_source, actor: actor(server_context), reason: note))
|
|
86
|
+
answer(**result, workflow: present(data_source.workflow_detail(workflow_id)))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def answer(**value) = BaseTool.json_response(value)
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def input_refusal(note, invalid)
|
|
94
|
+
unless note.length.between?(1, REASON_LIMIT)
|
|
95
|
+
return { ok: false, reason: "reason must be 1-#{REASON_LIMIT} characters after trimming whitespace" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
{ ok: false, reason: invalid } if invalid
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The DataSource action's answer, ok and reason first; an unexpected
|
|
102
|
+
# failure's text replaced (FAILED).
|
|
103
|
+
def outcome(result)
|
|
104
|
+
reason = result[:code] == :failed ? FAILED : result[:reason]
|
|
105
|
+
{ ok: result[:ok], reason: reason, **result.except(:ok, :reason) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def guard_refusal(verb, detail, expected_status)
|
|
109
|
+
status = detail[:status].to_s
|
|
110
|
+
if expected_status && expected_status.to_s != status
|
|
111
|
+
return { ok: false, reason: "workflow #{detail[:workflow_id]} is #{status}, not #{expected_status}" }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
allowed = Array(detail[:available_actions]).map(&:to_s)
|
|
115
|
+
return if allowed.include?(verb.to_s)
|
|
116
|
+
|
|
117
|
+
{ ok: false, reason: "cannot #{verb.to_s.tr("_", " ")} a #{status} workflow", available_actions: allowed }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def present(detail) = detail && WorkflowPayloads.detail(detail, allowed: false)
|
|
121
|
+
|
|
122
|
+
# "mcp", or "mcp:<name>" when the transport knows the client (an
|
|
123
|
+
# MCP::ServerContext on mcp >= 1.x; a plain Hash context has none).
|
|
124
|
+
# The name is client-supplied: control characters dropped, length capped.
|
|
125
|
+
def actor(server_context)
|
|
126
|
+
info = server_context.client_info if server_context.respond_to?(:client_info)
|
|
127
|
+
name = info.is_a?(Hash) ? (info[:name] || info["name"]) : nil
|
|
128
|
+
name = name.to_s.gsub(/[^[:print:]]/, "").strip[0, CLIENT_NAME_LIMIT]
|
|
129
|
+
name.empty? ? ACTOR : "#{ACTOR}:#{name}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/pgbus/mcp.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Pgbus
|
|
|
14
14
|
REQUIRES = %w[
|
|
15
15
|
pgbus/mcp/redactor
|
|
16
16
|
pgbus/mcp/base_tool
|
|
17
|
+
pgbus/mcp/serialization
|
|
17
18
|
pgbus/mcp/health_analyzer
|
|
18
19
|
pgbus/mcp/tools/health_tool
|
|
19
20
|
pgbus/mcp/tools/queues_tool
|
|
@@ -29,6 +30,20 @@ module Pgbus
|
|
|
29
30
|
pgbus/mcp/tools/stats_tool
|
|
30
31
|
pgbus/mcp/tools/recurring_tool
|
|
31
32
|
pgbus/mcp/tools/deploy_tasks_tool
|
|
33
|
+
pgbus/mcp/workflow_payloads
|
|
34
|
+
pgbus/mcp/tools/workflows_tool
|
|
35
|
+
pgbus/mcp/tools/workflow_detail_tool
|
|
36
|
+
pgbus/mcp/tools/workflows_attention_tool
|
|
37
|
+
pgbus/mcp/workflow_insights_payload
|
|
38
|
+
pgbus/mcp/tools/workflow_definitions_tool
|
|
39
|
+
pgbus/mcp/tools/workflow_stats_tool
|
|
40
|
+
pgbus/mcp/write_tool
|
|
41
|
+
pgbus/mcp/tools/workflow_resume_tool
|
|
42
|
+
pgbus/mcp/tools/workflow_cancel_tool
|
|
43
|
+
pgbus/mcp/tools/workflow_skip_step_tool
|
|
44
|
+
pgbus/mcp/tools/workflow_resume_at_tool
|
|
45
|
+
pgbus/mcp/tools/workflow_run_now_tool
|
|
46
|
+
pgbus/mcp/tools/workflow_signal_tool
|
|
32
47
|
pgbus/mcp/server
|
|
33
48
|
pgbus/mcp/runner
|
|
34
49
|
pgbus/mcp/rack_app
|
|
@@ -11,6 +11,10 @@ module Pgbus
|
|
|
11
11
|
# gauge -> "name:value|g"
|
|
12
12
|
# histogram -> "name:value|ms"
|
|
13
13
|
#
|
|
14
|
+
# StatsD reads |ms values as milliseconds, so a histogram named
|
|
15
|
+
# *_seconds (the Prometheus convention, e.g.
|
|
16
|
+
# pgbus_workflow_step_duration_seconds) is sent as *_ms, times 1000.
|
|
17
|
+
#
|
|
14
18
|
# Tags are appended as DogStatsD `|#k:v,k:v`. No gem dependency — a plain
|
|
15
19
|
# UDPSocket. UDP send is fire-and-forget; any socket error is logged and
|
|
16
20
|
# swallowed so a metrics hiccup never propagates into the producer thread.
|
|
@@ -32,6 +36,8 @@ module Pgbus
|
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def histogram(name, value, tags = {})
|
|
39
|
+
return emit("#{name.delete_suffix("_seconds")}_ms", value * 1000.0, "ms", tags) if name.end_with?("_seconds")
|
|
40
|
+
|
|
35
41
|
emit(name, value, "ms", tags)
|
|
36
42
|
end
|
|
37
43
|
|
|
@@ -73,7 +73,8 @@ module Pgbus
|
|
|
73
73
|
subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
|
|
74
74
|
subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
|
|
75
75
|
subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
|
|
76
|
-
subscribe("pgbus.client.pool") { |event| on_client_pool(event) }
|
|
76
|
+
subscribe("pgbus.client.pool") { |event| on_client_pool(event) },
|
|
77
|
+
*WorkflowMetrics::EVENTS.map { |name| subscribe(name) { |event| on_workflow(event) } }
|
|
77
78
|
].freeze
|
|
78
79
|
end
|
|
79
80
|
|
|
@@ -251,6 +252,18 @@ module Pgbus
|
|
|
251
252
|
backend.gauge("#{METRIC_PREFIX}pool_available", payload[:available], tags)
|
|
252
253
|
end
|
|
253
254
|
|
|
255
|
+
# ── Workflows (issue #540) ────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def on_workflow(event)
|
|
258
|
+
WorkflowMetrics.samples(event.name, event.payload).each do |kind, name, value, tags|
|
|
259
|
+
case kind
|
|
260
|
+
when :histogram then backend.histogram(name, value, tags)
|
|
261
|
+
when :counter then backend.increment(name, value, tags)
|
|
262
|
+
when :gauge then backend.gauge(name, value, tags)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
254
267
|
def hostname
|
|
255
268
|
Socket.gethostname
|
|
256
269
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Metrics
|
|
5
|
+
# The pgbus.workflow.* event → metric mapping (issue #540), shared by
|
|
6
|
+
# Metrics::Subscriber (Prometheus, StatsD) and the AppSignal subscriber so
|
|
7
|
+
# both backends report the same names and labels:
|
|
8
|
+
#
|
|
9
|
+
# pgbus_workflow_step_duration_seconds{workflow_class, step, outcome} histogram
|
|
10
|
+
# pgbus_workflow_steps_total{workflow_class, step, outcome} counter
|
|
11
|
+
# pgbus_workflows_finished_total{workflow_class} counter
|
|
12
|
+
# pgbus_workflows_paused_total{workflow_class, error_class} counter (error_class absent for a manual pause)
|
|
13
|
+
# pgbus_workflows_cancelled_total{workflow_class} counter
|
|
14
|
+
# pgbus_workflow_stalls_total{workflow_class, step, detection} counter (detection: redelivery | sweep)
|
|
15
|
+
# pgbus_workflows{workflow_class, status} gauge, from each sweep
|
|
16
|
+
# pgbus_workflows_stuck{workflow_class, reason} gauge, from each sweep
|
|
17
|
+
#
|
|
18
|
+
# Label values come from code (class, step and exception names), so
|
|
19
|
+
# their cardinality is bounded; workflow ids never become labels.
|
|
20
|
+
module WorkflowMetrics
|
|
21
|
+
EVENTS = %w[
|
|
22
|
+
pgbus.workflow.step pgbus.workflow_finished pgbus.workflow_paused pgbus.workflow_cancelled
|
|
23
|
+
pgbus.workflow.stall_detected pgbus.workflow.gauge
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# [[kind, name, value, tags], ...] for one event; kind is :histogram,
|
|
29
|
+
# :counter or :gauge.
|
|
30
|
+
def samples(event_name, payload)
|
|
31
|
+
klass = payload[:workflow_class]
|
|
32
|
+
case event_name
|
|
33
|
+
when "pgbus.workflow.step" then step_samples(klass, payload)
|
|
34
|
+
when "pgbus.workflow_finished" then [counter("pgbus_workflows_finished_total", workflow_class: klass)]
|
|
35
|
+
when "pgbus.workflow_paused"
|
|
36
|
+
[counter("pgbus_workflows_paused_total", workflow_class: klass, error_class: payload[:error_class])]
|
|
37
|
+
when "pgbus.workflow_cancelled" then [counter("pgbus_workflows_cancelled_total", workflow_class: klass)]
|
|
38
|
+
when "pgbus.workflow.stall_detected"
|
|
39
|
+
[counter("pgbus_workflow_stalls_total", workflow_class: klass, step: payload[:step_name], detection: payload[:detection])]
|
|
40
|
+
when "pgbus.workflow.gauge" then gauge_samples(payload)
|
|
41
|
+
else []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def step_samples(klass, payload)
|
|
46
|
+
tags = { workflow_class: klass, step: payload[:step_name], outcome: payload[:outcome] }.compact
|
|
47
|
+
samples = [[:counter, "pgbus_workflow_steps_total", 1, tags]]
|
|
48
|
+
samples.unshift([:histogram, "pgbus_workflow_step_duration_seconds", payload[:duration], tags]) if payload[:duration]
|
|
49
|
+
samples
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def gauge_samples(payload)
|
|
53
|
+
statuses = Array(payload[:statuses]).map do |row|
|
|
54
|
+
[:gauge, "pgbus_workflows", row[:count], { workflow_class: row[:workflow_class], status: row[:status] }]
|
|
55
|
+
end
|
|
56
|
+
stuck = Array(payload[:stuck]).map do |row|
|
|
57
|
+
[:gauge, "pgbus_workflows_stuck", row[:count], { workflow_class: row[:workflow_class], reason: row[:reason] }]
|
|
58
|
+
end
|
|
59
|
+
statuses + stuck
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def counter(name, **tags) = [:counter, name, 1, tags.compact]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -19,6 +19,7 @@ module Pgbus
|
|
|
19
19
|
JOB_LOCK_CLEANUP_INTERVAL = 300 # Run job lock cleanup every 5 minutes
|
|
20
20
|
STATS_CLEANUP_INTERVAL = 3600 # Run stats cleanup every hour
|
|
21
21
|
ORPHAN_STREAM_SWEEP_INTERVAL = 3600 # Run orphan stream sweep every hour
|
|
22
|
+
WORKFLOW_CLEANUP_INTERVAL = 3600 # Delete finished/cancelled workflows past workflow_retention every hour
|
|
22
23
|
TABLE_MAINTENANCE_INTERVAL = Pgbus::TableMaintenance::MAINTENANCE_INTERVAL
|
|
23
24
|
|
|
24
25
|
# Page size for archive compaction. Each cycle deletes up to this
|
|
@@ -57,6 +58,7 @@ module Pgbus
|
|
|
57
58
|
@last_stream_archive_compaction_at
|
|
58
59
|
@last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
|
|
59
60
|
@last_orphan_stream_sweep_at @last_table_maintenance_at
|
|
61
|
+
@last_workflow_sweep_at @last_workflow_cleanup_at
|
|
60
62
|
].freeze
|
|
61
63
|
|
|
62
64
|
# Outcome of a single maintenance task in a cycle. Lets run_maintenance
|
|
@@ -247,6 +249,8 @@ module Pgbus
|
|
|
247
249
|
sweep_interval = config.streams_orphan_sweep_interval
|
|
248
250
|
results << run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
|
|
249
251
|
results << run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
|
|
252
|
+
results << run_if_due(now, :@last_workflow_sweep_at, config.workflow_sweep_interval) { sweep_workflows }
|
|
253
|
+
results << run_if_due(now, :@last_workflow_cleanup_at, WORKFLOW_CLEANUP_INTERVAL) { cleanup_workflows }
|
|
250
254
|
results
|
|
251
255
|
end
|
|
252
256
|
|
|
@@ -388,6 +392,28 @@ module Pgbus
|
|
|
388
392
|
e
|
|
389
393
|
end
|
|
390
394
|
|
|
395
|
+
# Stalled and overdue workflow steps, timed-out waits, and the workflow gauges.
|
|
396
|
+
def sweep_workflows
|
|
397
|
+
summary = Workflow::Sweep.repair(config: config)
|
|
398
|
+
return unless summary && summary.values_at(:stalled, :resent, :timed_out, :fan_outs).compact.any?(&:positive?)
|
|
399
|
+
|
|
400
|
+
Pgbus.logger.info do
|
|
401
|
+
"[Pgbus] Workflow sweep: #{summary[:stalled]} stalled step(s) repaired, #{summary[:resent]} overdue step(s) re-sent, " \
|
|
402
|
+
"#{summary[:timed_out].to_i} wait(s) timed out, #{summary[:fan_outs].to_i} fan-out(s) resolved"
|
|
403
|
+
end
|
|
404
|
+
rescue StandardError => e
|
|
405
|
+
log_maintenance_failure("Workflow sweep", e)
|
|
406
|
+
e
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def cleanup_workflows
|
|
410
|
+
deleted = Workflow::Sweep.cleanup(retention: config.workflow_retention)
|
|
411
|
+
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished workflows" } if deleted.positive?
|
|
412
|
+
rescue StandardError => e
|
|
413
|
+
log_maintenance_failure("Workflow cleanup", e)
|
|
414
|
+
e
|
|
415
|
+
end
|
|
416
|
+
|
|
391
417
|
def cleanup_stats
|
|
392
418
|
retention = config.stats_retention
|
|
393
419
|
return unless retention&.positive?
|
data/lib/pgbus/version.rb
CHANGED