pgbus 0.19.4 → 0.20.1
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 +9 -0
- data/README.md +97 -7
- 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 +148 -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/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- 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 +149 -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 +49 -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 +445 -0
- data/config/locales/de.yml +445 -0
- data/config/locales/en.yml +445 -0
- data/config/locales/es.yml +445 -0
- data/config/locales/fi.yml +445 -0
- data/config/locales/fr.yml +445 -0
- data/config/locales/it.yml +445 -0
- data/config/locales/ja.yml +445 -0
- data/config/locales/nb.yml +445 -0
- data/config/locales/nl.yml +445 -0
- data/config/locales/pt.yml +445 -0
- data/config/locales/sv.yml +445 -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 +81 -3
- data/lib/pgbus/active_job/delivery.rb +63 -0
- data/lib/pgbus/active_job/executor.rb +20 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +64 -4
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +33 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- 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 +75 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +39 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- 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 +291 -0
- data/lib/pgbus/web/data_source.rb +126 -15
- 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 +89 -0
- data/lib/pgbus/web/workflow_timeline.rb +130 -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 +133 -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 +94 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +23 -0
- metadata +92 -1
data/lib/pgbus/mcp/server.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Pgbus
|
|
|
5
5
|
# Assembles the read-only pgbus diagnostic MCP server: registers every
|
|
6
6
|
# diagnostic tool and wires a DataSource (plus the payload gate) into the
|
|
7
7
|
# server context so tools never touch the database directly or leak
|
|
8
|
-
# payloads by default.
|
|
8
|
+
# payloads by default. The workflow write tools (WRITE_TOOLS, issue #550)
|
|
9
|
+
# join only when config.mcp_write_tools is true when the server is built.
|
|
9
10
|
#
|
|
10
11
|
# This class only builds the +MCP::Server+ instance — transport is the
|
|
11
12
|
# caller's concern (Runner drives stdio). Keeping them separate means the
|
|
@@ -27,38 +28,80 @@ module Pgbus
|
|
|
27
28
|
Tools::ThroughputTool,
|
|
28
29
|
Tools::StatsTool,
|
|
29
30
|
Tools::RecurringTool,
|
|
30
|
-
Tools::DeployTasksTool
|
|
31
|
+
Tools::DeployTasksTool,
|
|
32
|
+
Tools::WorkflowsTool,
|
|
33
|
+
Tools::WorkflowDetailTool,
|
|
34
|
+
Tools::WorkflowsAttentionTool,
|
|
35
|
+
Tools::WorkflowDefinitionsTool,
|
|
36
|
+
Tools::WorkflowStatsTool
|
|
31
37
|
].freeze
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
# The opt-in write surface (issue #180's rule: separate, explicitly
|
|
40
|
+
# opt-in, off by default). Never merged into TOOLS: with
|
|
41
|
+
# config.mcp_write_tools false these are not constructed at all.
|
|
42
|
+
WRITE_TOOLS = [
|
|
43
|
+
Tools::WorkflowResumeTool,
|
|
44
|
+
Tools::WorkflowCancelTool,
|
|
45
|
+
Tools::WorkflowSkipStepTool,
|
|
46
|
+
Tools::WorkflowResumeAtTool,
|
|
47
|
+
Tools::WorkflowRunNowTool,
|
|
48
|
+
Tools::WorkflowSignalTool
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
DIAGNOSTICS = <<~TEXT
|
|
52
|
+
Diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
|
|
35
53
|
Start with pgbus_health for a one-call OK/DEGRADED/STALLED verdict,
|
|
36
54
|
then drill in with pgbus_queues, pgbus_processes, pgbus_jobs,
|
|
37
|
-
pgbus_dlq, pgbus_locks, and pgbus_concurrency
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
pgbus_dlq, pgbus_locks, and pgbus_concurrency; workflow runs with
|
|
56
|
+
pgbus_workflows_attention, pgbus_workflows and pgbus_workflow, and how
|
|
57
|
+
each workflow class performs with pgbus_workflow_definitions and
|
|
58
|
+
pgbus_workflow_stats. Message payloads are redacted unless the server
|
|
59
|
+
was started with payloads explicitly allowed.
|
|
60
|
+
TEXT
|
|
61
|
+
|
|
62
|
+
INSTRUCTIONS = "#{DIAGNOSTICS}No tool mutates state.\n".freeze
|
|
63
|
+
|
|
64
|
+
WRITE_INSTRUCTIONS = <<~TEXT
|
|
65
|
+
The tools above do not mutate state. This server was also built with
|
|
66
|
+
config.mcp_write_tools, so pgbus_workflow_resume, pgbus_workflow_cancel,
|
|
67
|
+
pgbus_workflow_skip_step, pgbus_workflow_resume_at,
|
|
68
|
+
pgbus_workflow_run_now and pgbus_workflow_signal change one workflow
|
|
69
|
+
run each. Read the run with pgbus_workflow first, then pass a reason
|
|
70
|
+
(recorded on the run's audit trail) and expected_status (the status
|
|
71
|
+
you read): a run that has moved on by the time the call starts
|
|
72
|
+
answers ok: false and nothing is done (the check runs just before the
|
|
73
|
+
action, whose transition is guarded again under the run's row lock).
|
|
74
|
+
There are no bulk write tools: pgbus_workflows_attention groups the
|
|
75
|
+
stuck runs; list a group's runs with pgbus_workflows (status,
|
|
76
|
+
workflow_class, step, error_class) and call once per run.
|
|
40
77
|
TEXT
|
|
41
78
|
|
|
42
79
|
module_function
|
|
43
80
|
|
|
44
|
-
# Build an MCP::Server with all diagnostic tools registered
|
|
81
|
+
# Build an MCP::Server with all diagnostic tools registered, plus the
|
|
82
|
+
# write tools when config.mcp_write_tools is true (read here, once).
|
|
45
83
|
#
|
|
46
84
|
# @param data_source [Pgbus::Web::DataSource] read layer the tools query.
|
|
47
85
|
# @param allow_payloads [Boolean] when true, tools honor a per-call
|
|
48
86
|
# include_payloads flag; otherwise payloads are always redacted.
|
|
49
87
|
def build(data_source: Pgbus::Web::DataSource.new, allow_payloads: false)
|
|
88
|
+
write = write_tools?
|
|
50
89
|
::MCP::Server.new(
|
|
51
90
|
name: "pgbus",
|
|
52
91
|
title: "Pgbus Diagnostics",
|
|
53
92
|
version: Pgbus::VERSION,
|
|
54
|
-
instructions: INSTRUCTIONS,
|
|
55
|
-
tools: TOOLS.dup,
|
|
93
|
+
instructions: write ? DIAGNOSTICS + WRITE_INSTRUCTIONS : INSTRUCTIONS,
|
|
94
|
+
tools: write ? TOOLS + WRITE_TOOLS : TOOLS.dup,
|
|
56
95
|
server_context: {
|
|
57
96
|
data_source: data_source,
|
|
58
97
|
allow_payloads: allow_payloads
|
|
59
98
|
}
|
|
60
99
|
)
|
|
61
100
|
end
|
|
101
|
+
|
|
102
|
+
# Only a literal true turns the write tools on (validate! rejects
|
|
103
|
+
# anything else, but an app may skip validation).
|
|
104
|
+
def write_tools? = Pgbus.configuration.mcp_write_tools == true
|
|
62
105
|
end
|
|
63
106
|
end
|
|
64
107
|
end
|
|
@@ -69,16 +69,7 @@ module Pgbus
|
|
|
69
69
|
end
|
|
70
70
|
private_class_method :task_hash
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
def self.serialize(value)
|
|
74
|
-
case value
|
|
75
|
-
when Hash then value.to_h { |key, val| [key.to_s, serialize(val)] }
|
|
76
|
-
when Array then value.map { |val| serialize(val) }
|
|
77
|
-
when Symbol then value.to_s
|
|
78
|
-
when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
|
|
79
|
-
else value
|
|
80
|
-
end
|
|
81
|
-
end
|
|
72
|
+
def self.serialize(value) = Serialization.serialize(value)
|
|
82
73
|
private_class_method :serialize
|
|
83
74
|
end
|
|
84
75
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Cancels an ongoing workflow run (issue #550). DataSource#cancel_workflow.
|
|
7
|
+
class WorkflowCancelTool < WriteTool
|
|
8
|
+
tool_name "pgbus_workflow_cancel"
|
|
9
|
+
title "Pgbus Workflow: Cancel"
|
|
10
|
+
description verb_description(:cancel, "Cancel an ongoing Pgbus::Workflow run. A cancelled run does not come back.")
|
|
11
|
+
idempotent!
|
|
12
|
+
input_schema(**workflow_input_schema)
|
|
13
|
+
|
|
14
|
+
def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
|
|
15
|
+
perform(:cancel, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
16
|
+
server_context: server_context) do |data_source, **who|
|
|
17
|
+
data_source.cancel_workflow(workflow_id, **who)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Every workflow class with its runs going on now, the runs that ended
|
|
7
|
+
# in a window and the end-to-end percentiles (issue #549). Maps to
|
|
8
|
+
# DataSource#workflow_definitions, the dashboard's
|
|
9
|
+
# /pgbus/workflows/definitions.
|
|
10
|
+
class WorkflowDefinitionsTool < BaseTool
|
|
11
|
+
tool_name "pgbus_workflow_definitions"
|
|
12
|
+
title "Pgbus Workflow Definitions"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
One row per Pgbus::Workflow class (every registered subclass and every class name still in pgbus_workflows): loaded (false when the class no longer loads in this app), ongoing and paused runs now, attention (runs that need a person, as pgbus_workflows_attention groups them), finished and cancelled runs that ended in the window, e2e_ms (p50/p95 of created → finished over the finished ones, with samples) and removed_in_flight ([{step, runs}] of ongoing runs still on a removed_step). window is 1h, 24h (default), 7d or 30d, capped at workflow_retention; the answer says what it read. Drill into one class with pgbus_workflow_stats.
|
|
15
|
+
DESC
|
|
16
|
+
|
|
17
|
+
input_schema(
|
|
18
|
+
properties: {
|
|
19
|
+
window: { type: "string", enum: Web::WorkflowWindow::CHOICES.keys,
|
|
20
|
+
description: "1h, 24h (default), 7d or 30d; capped at workflow_retention." }
|
|
21
|
+
},
|
|
22
|
+
required: []
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def self.call(window: nil, server_context: nil)
|
|
26
|
+
parsed = Web::WorkflowWindow.parse(window)
|
|
27
|
+
definitions = data_source_from(server_context).workflow_definitions(window: parsed)
|
|
28
|
+
rows = definitions.map { |row| WorkflowInsightsPayload.row(row) }
|
|
29
|
+
json_response(Serialization.serialize(window: parsed.to_h, definitions: rows))
|
|
30
|
+
rescue Web::WorkflowWindow::Invalid => e
|
|
31
|
+
error_response(e.message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# One workflow run (issue #542): every attempt with its durations, the
|
|
7
|
+
# signals, the audit trail and the outside verbs its status allows.
|
|
8
|
+
# Maps to DataSource#workflow_detail.
|
|
9
|
+
class WorkflowDetailTool < BaseTool
|
|
10
|
+
tool_name "pgbus_workflow"
|
|
11
|
+
title "Pgbus Workflow"
|
|
12
|
+
description <<~DESC
|
|
13
|
+
One Pgbus::Workflow run by workflow_id: status, current step, every step attempt (newest first, at most 100) with status, outcome, scheduled/started/finished times, the waited/queued/ran/waiting durations in seconds, worker, app revision and error class; the signals delivered to it; the audit trail (event, actor, reason; newest first); available_actions (the outside verbs its status allows) and resume_action (what resume would do). Arguments, step outputs, signal payloads and error messages/backtraces are redacted unless the server allows payloads and include_payloads is set.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
input_schema(
|
|
17
|
+
properties: {
|
|
18
|
+
workflow_id: { type: "string", description: "The run's workflow_id (a UUID)." },
|
|
19
|
+
include_payloads: {
|
|
20
|
+
type: "boolean",
|
|
21
|
+
description: "Include arguments, outputs, signal payloads and error text. Only honored when the server allows payloads."
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
required: %w[workflow_id]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def self.call(workflow_id:, include_payloads: false, server_context: nil)
|
|
28
|
+
detail = data_source_from(server_context).workflow_detail(workflow_id)
|
|
29
|
+
return error_response("Workflow #{workflow_id} not found") unless detail
|
|
30
|
+
|
|
31
|
+
allowed = payloads_allowed?(server_context, include_payloads)
|
|
32
|
+
json_response({ workflow: WorkflowPayloads.detail(detail, allowed: allowed) },
|
|
33
|
+
server_context: server_context, include_payloads: include_payloads)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Resumes a paused workflow run at a named step (issue #550).
|
|
7
|
+
# DataSource#resume_workflow_at.
|
|
8
|
+
class WorkflowResumeAtTool < WriteTool
|
|
9
|
+
tool_name "pgbus_workflow_resume_at"
|
|
10
|
+
title "Pgbus Workflow: Resume At"
|
|
11
|
+
description verb_description(
|
|
12
|
+
:resume_at, "Resume a paused Pgbus::Workflow run at the named step (pgbus_workflow lists its step_names)."
|
|
13
|
+
)
|
|
14
|
+
input_schema(
|
|
15
|
+
**workflow_input_schema(
|
|
16
|
+
properties: { step: { type: "string", minLength: 1, description: "The step to run next, e.g. capture." } },
|
|
17
|
+
required: %w[step]
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def self.call(workflow_id:, reason:, step:, expected_status: nil, server_context: nil)
|
|
22
|
+
step = step.to_s.strip
|
|
23
|
+
perform(:resume_at, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
24
|
+
server_context: server_context,
|
|
25
|
+
invalid: step.empty? ? "step must name a step" : nil) do |data_source, **who|
|
|
26
|
+
data_source.resume_workflow_at(workflow_id, step: step, **who)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Resumes a paused workflow run (issue #550). DataSource#resume_workflow.
|
|
7
|
+
class WorkflowResumeTool < WriteTool
|
|
8
|
+
tool_name "pgbus_workflow_resume"
|
|
9
|
+
title "Pgbus Workflow: Resume"
|
|
10
|
+
description verb_description(
|
|
11
|
+
:resume, "Resume a paused Pgbus::Workflow run (pgbus_workflow's resume_action says what runs next)."
|
|
12
|
+
)
|
|
13
|
+
input_schema(**workflow_input_schema)
|
|
14
|
+
|
|
15
|
+
def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
|
|
16
|
+
perform(:resume, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
17
|
+
server_context: server_context) do |data_source, **who|
|
|
18
|
+
data_source.resume_workflow(workflow_id, **who)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Sends a ready run's scheduled step now (issue #550).
|
|
7
|
+
# DataSource#run_workflow_step_now.
|
|
8
|
+
class WorkflowRunNowTool < WriteTool
|
|
9
|
+
tool_name "pgbus_workflow_run_now"
|
|
10
|
+
title "Pgbus Workflow: Run Now"
|
|
11
|
+
description verb_description(:run_now, "Run a ready Pgbus::Workflow run's scheduled step now instead of after its wait.")
|
|
12
|
+
input_schema(**workflow_input_schema)
|
|
13
|
+
|
|
14
|
+
def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
|
|
15
|
+
perform(:run_now, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
16
|
+
server_context: server_context) do |data_source, **who|
|
|
17
|
+
data_source.run_workflow_step_now(workflow_id, **who)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
# Delivers a signal to an ongoing workflow run (issue #550).
|
|
9
|
+
# DataSource#signal_workflow.
|
|
10
|
+
class WorkflowSignalTool < WriteTool
|
|
11
|
+
# Both columns are indexed; an index entry has a size limit.
|
|
12
|
+
KEY_LIMIT = 255
|
|
13
|
+
|
|
14
|
+
tool_name "pgbus_workflow_signal"
|
|
15
|
+
title "Pgbus Workflow: Signal"
|
|
16
|
+
description verb_description(
|
|
17
|
+
:signal, "Send a named signal, with an optional JSON object payload, to an ongoing Pgbus::Workflow run. " \
|
|
18
|
+
"Pass an idempotency_key when the call may be retried: a redelivery with the same key stores " \
|
|
19
|
+
"nothing and answers duplicate: true, but only while the run is ongoing (once it finished or " \
|
|
20
|
+
"was cancelled, a retry answers ok: false: signal is not allowed)."
|
|
21
|
+
)
|
|
22
|
+
input_schema(
|
|
23
|
+
**workflow_input_schema(
|
|
24
|
+
properties: {
|
|
25
|
+
name: { type: "string", minLength: 1, maxLength: KEY_LIMIT,
|
|
26
|
+
description: "The signal's name, e.g. payment_confirmed." },
|
|
27
|
+
payload: {
|
|
28
|
+
type: "object",
|
|
29
|
+
description: "The signal's payload, a JSON object of at most workflow_max_payload_size bytes."
|
|
30
|
+
},
|
|
31
|
+
idempotency_key: { type: "string", maxLength: KEY_LIMIT,
|
|
32
|
+
description: "Makes a retried call deliver the signal once. Blank means none." }
|
|
33
|
+
},
|
|
34
|
+
required: %w[name]
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def self.call(workflow_id:, reason:, name:, payload: nil, idempotency_key: nil, expected_status: nil,
|
|
39
|
+
server_context: nil)
|
|
40
|
+
name = name.to_s.strip
|
|
41
|
+
idempotency_key = idempotency_key.to_s.presence # blank is no key, as in Signals.deliver
|
|
42
|
+
payload = payload_object(payload)
|
|
43
|
+
perform(:signal, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
|
|
44
|
+
server_context: server_context,
|
|
45
|
+
invalid: signal_refusal(name, idempotency_key, payload)) do |data_source, **who|
|
|
46
|
+
data_source.signal_workflow(workflow_id, name: name, payload: payload, idempotency_key: idempotency_key, **who)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Why the signal cannot be sent as given, or nil.
|
|
51
|
+
def self.signal_refusal(name, idempotency_key, payload)
|
|
52
|
+
return "name must name a signal" if name.empty?
|
|
53
|
+
return "name must be at most #{KEY_LIMIT} characters" if name.length > KEY_LIMIT
|
|
54
|
+
return "idempotency_key must be at most #{KEY_LIMIT} characters" if idempotency_key && idempotency_key.length > KEY_LIMIT
|
|
55
|
+
|
|
56
|
+
payload if payload.is_a?(String)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :signal_refusal
|
|
59
|
+
|
|
60
|
+
# The payload with string keys (as the dashboard's form sends it), or
|
|
61
|
+
# why it cannot be sent. Workflow::Payload enforces the cap again on
|
|
62
|
+
# the serialized form.
|
|
63
|
+
def self.payload_object(payload)
|
|
64
|
+
return {} if payload.nil?
|
|
65
|
+
return "payload must be a JSON object" unless payload.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
json = JSON.generate(payload)
|
|
68
|
+
limit = Pgbus.configuration.workflow_max_payload_size
|
|
69
|
+
return "payload is #{json.bytesize} bytes, over workflow_max_payload_size (#{limit})" if json.bytesize > limit
|
|
70
|
+
|
|
71
|
+
JSON.parse(json)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :payload_object
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -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
|