pgbus 0.19.4 → 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 +6 -0
- data/README.md +96 -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 +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/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- 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/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 +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 +92 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
],
|
|
5
5
|
"dashboard": {
|
|
6
6
|
"title": "Pgbus",
|
|
7
|
-
"description": "PostgreSQL-native job processing and event bus built on PGMQ.\nMonitor job throughput, queue depth, latency, dead letter queues,\nworker processes, event handlers, and stream broadcasts.\n",
|
|
7
|
+
"description": "PostgreSQL-native job processing and event bus built on PGMQ.\nMonitor job throughput, queue depth, latency, dead letter queues,\nworker processes, event handlers, and stream broadcasts.\nWorkflow step outcomes and run times, and stuck workflows.\n",
|
|
8
8
|
"visuals": [
|
|
9
9
|
{
|
|
10
10
|
"type": "timeseries",
|
|
@@ -393,6 +393,133 @@
|
|
|
393
393
|
"tags": []
|
|
394
394
|
}
|
|
395
395
|
]
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"type": "timeseries",
|
|
399
|
+
"display": "LINE",
|
|
400
|
+
"title": "Workflow steps per outcome",
|
|
401
|
+
"description": "Workflow step attempts by class, step and outcome (completed, reattempted, skipped, paused, cancelled, finished_early).",
|
|
402
|
+
"line_label": "%workflow_class% - %step% - %outcome%",
|
|
403
|
+
"format": "number",
|
|
404
|
+
"draw_null_as_zero": true,
|
|
405
|
+
"metrics": [
|
|
406
|
+
{
|
|
407
|
+
"name": "pgbus_workflow_steps_total",
|
|
408
|
+
"fields": [
|
|
409
|
+
{
|
|
410
|
+
"field": "COUNTER"
|
|
411
|
+
}
|
|
412
|
+
],
|
|
413
|
+
"tags": [
|
|
414
|
+
{
|
|
415
|
+
"key": "workflow_class",
|
|
416
|
+
"value": "*"
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"key": "step",
|
|
420
|
+
"value": "*"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
"key": "outcome",
|
|
424
|
+
"value": "*"
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
"type": "timeseries",
|
|
432
|
+
"display": "LINE",
|
|
433
|
+
"title": "Workflow step run time (seconds)",
|
|
434
|
+
"description": "Mean and p95 run time of each workflow step's body, in seconds.",
|
|
435
|
+
"line_label": "%workflow_class% - %step% - %outcome%",
|
|
436
|
+
"format": "number",
|
|
437
|
+
"draw_null_as_zero": true,
|
|
438
|
+
"metrics": [
|
|
439
|
+
{
|
|
440
|
+
"name": "pgbus_workflow_step_duration_seconds",
|
|
441
|
+
"fields": [
|
|
442
|
+
{
|
|
443
|
+
"field": "MEAN"
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
"field": "P95"
|
|
447
|
+
}
|
|
448
|
+
],
|
|
449
|
+
"tags": [
|
|
450
|
+
{
|
|
451
|
+
"key": "workflow_class",
|
|
452
|
+
"value": "*"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"key": "step",
|
|
456
|
+
"value": "*"
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"key": "outcome",
|
|
460
|
+
"value": "*"
|
|
461
|
+
}
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
]
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"type": "timeseries",
|
|
468
|
+
"display": "LINE",
|
|
469
|
+
"title": "Workflows by status",
|
|
470
|
+
"description": "Workflow runs per class and status, from each dispatcher sweep.",
|
|
471
|
+
"line_label": "%workflow_class% - %status%",
|
|
472
|
+
"format": "number",
|
|
473
|
+
"draw_null_as_zero": true,
|
|
474
|
+
"metrics": [
|
|
475
|
+
{
|
|
476
|
+
"name": "pgbus_workflows",
|
|
477
|
+
"fields": [
|
|
478
|
+
{
|
|
479
|
+
"field": "GAUGE"
|
|
480
|
+
}
|
|
481
|
+
],
|
|
482
|
+
"tags": [
|
|
483
|
+
{
|
|
484
|
+
"key": "workflow_class",
|
|
485
|
+
"value": "*"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
"key": "status",
|
|
489
|
+
"value": "*"
|
|
490
|
+
}
|
|
491
|
+
]
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
"type": "timeseries",
|
|
497
|
+
"display": "LINE",
|
|
498
|
+
"title": "Stuck workflows",
|
|
499
|
+
"description": "Ongoing workflow runs that need a person, by class and reason (paused_error, stalled, paused_manual, overdue).",
|
|
500
|
+
"line_label": "%workflow_class% - %reason%",
|
|
501
|
+
"format": "number",
|
|
502
|
+
"draw_null_as_zero": true,
|
|
503
|
+
"metrics": [
|
|
504
|
+
{
|
|
505
|
+
"name": "pgbus_workflows_stuck",
|
|
506
|
+
"fields": [
|
|
507
|
+
{
|
|
508
|
+
"field": "GAUGE"
|
|
509
|
+
}
|
|
510
|
+
],
|
|
511
|
+
"tags": [
|
|
512
|
+
{
|
|
513
|
+
"key": "workflow_class",
|
|
514
|
+
"value": "*"
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
"key": "reason",
|
|
518
|
+
"value": "*"
|
|
519
|
+
}
|
|
520
|
+
]
|
|
521
|
+
}
|
|
522
|
+
]
|
|
396
523
|
}
|
|
397
524
|
]
|
|
398
525
|
}
|
|
@@ -76,7 +76,8 @@ module Pgbus
|
|
|
76
76
|
subscribe("pgbus.outbox.publish") { |event| on_outbox_publish(event) },
|
|
77
77
|
subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
|
|
78
78
|
subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
|
|
79
|
-
subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) }
|
|
79
|
+
subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
|
|
80
|
+
*Pgbus::Metrics::WorkflowMetrics::EVENTS.map { |name| subscribe(name) { |event| on_workflow(event) } }
|
|
80
81
|
# pgbus.client.pool is deliberately NOT subscribed here — the
|
|
81
82
|
# minutely Probe#track_pool already reports pgbus_pool_size /
|
|
82
83
|
# pgbus_pool_available gauges for AppSignal (see probe.rb).
|
|
@@ -273,6 +274,20 @@ module Pgbus
|
|
|
273
274
|
{ reason: payload[:reason], kind: "consumer" }
|
|
274
275
|
)
|
|
275
276
|
end
|
|
277
|
+
|
|
278
|
+
# ── Workflows (issue #540) ──────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
# The same names and labels as Metrics::Subscriber
|
|
281
|
+
# (Pgbus::Metrics::WorkflowMetrics), as AppSignal custom metrics.
|
|
282
|
+
def on_workflow(event)
|
|
283
|
+
Pgbus::Metrics::WorkflowMetrics.samples(event.name, event.payload).each do |kind, name, value, tags|
|
|
284
|
+
case kind
|
|
285
|
+
when :histogram then ::Appsignal.add_distribution_value(name, value, tags)
|
|
286
|
+
when :counter then ::Appsignal.increment_counter(name, value, tags)
|
|
287
|
+
when :gauge then ::Appsignal.set_gauge(name, value, tags)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
276
291
|
end
|
|
277
292
|
end
|
|
278
293
|
end
|
data/lib/pgbus/mcp/rack_app.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Pgbus
|
|
|
19
19
|
#
|
|
20
20
|
# Security: requests are rejected with 401 unless they carry the configured
|
|
21
21
|
# token (or pass the supplied auth callable). Run it on an internal network
|
|
22
|
-
# / behind your VPN, never internet-exposed.
|
|
22
|
+
# / behind your VPN, never internet-exposed. With config.mcp_write_tools on
|
|
23
|
+
# it also serves the workflow write tools (issue #550): gate it.
|
|
23
24
|
#
|
|
24
25
|
# DNS-rebinding protection: since mcp 0.23 the transport validates the Host
|
|
25
26
|
# header (loopback only, by default) and the Origin header (same-origin
|
|
@@ -123,9 +124,13 @@ module Pgbus
|
|
|
123
124
|
@token.nil? && @auth.nil?
|
|
124
125
|
end
|
|
125
126
|
|
|
127
|
+
# With config.mcp_write_tools on, an unauthenticated mount can change
|
|
128
|
+
# workflow runs. Warned, not refused: a mount inside the app's own
|
|
129
|
+
# authenticated routes is gated where this class cannot see it.
|
|
126
130
|
def warn_unauthenticated!
|
|
127
131
|
Pgbus.logger.warn do
|
|
128
|
-
"
|
|
132
|
+
writes = Server.write_tools? ? " config.mcp_write_tools is on: anyone who reaches it can change workflow runs." : ""
|
|
133
|
+
"[Pgbus::MCP] HTTP diagnostic server mounted without authentication.#{writes} " \
|
|
129
134
|
"Pass token: or auth: to Pgbus::MCP.rack_app, and keep it on an internal network."
|
|
130
135
|
end
|
|
131
136
|
end
|
data/lib/pgbus/mcp/runner.rb
CHANGED
|
@@ -66,7 +66,8 @@ module Pgbus
|
|
|
66
66
|
|
|
67
67
|
def log_start(allow_payloads)
|
|
68
68
|
mode = allow_payloads ? "payloads ALLOWED" : "payloads redacted"
|
|
69
|
-
|
|
69
|
+
kind = Server.write_tools? ? "diagnostic server with the workflow write tools ON (mcp_write_tools)" : "read-only diagnostic server"
|
|
70
|
+
Pgbus.logger.info { "[Pgbus::MCP] starting #{kind} over stdio (#{mode})" }
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_support/core_ext/time"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module MCP
|
|
9
|
+
# How MCP tools write a data-source row as JSON: times as ISO 8601 UTC,
|
|
10
|
+
# symbols as strings, keys as strings, recursively.
|
|
11
|
+
module Serialization
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def serialize(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash then value.to_h { |key, nested| [key.to_s, serialize(nested)] }
|
|
17
|
+
when Array then value.map { |nested| serialize(nested) }
|
|
18
|
+
when Symbol then value.to_s
|
|
19
|
+
when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
|
|
20
|
+
else value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
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
|