agentboard 0.2.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 +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- metadata +285 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Dashboard authentication (spec §14, open decision #1 — recommended default):
|
|
3
|
+
# 1. host-auth adapter: `config.authenticate_with = ->(controller) { actor_or_nil }`
|
|
4
|
+
# 2. otherwise local-only access with an optional dashboard token
|
|
5
|
+
# The engine refuses to serve in production unless explicitly enabled.
|
|
6
|
+
class ApplicationController < ActionController::Base
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
layout "agentboard/application"
|
|
9
|
+
|
|
10
|
+
before_action :refuse_production!
|
|
11
|
+
before_action :authenticate_agentboard!
|
|
12
|
+
around_action :with_correlation
|
|
13
|
+
|
|
14
|
+
helper_method :current_actor, :current_project, :pending_approvals_count, :can?, :edition_notices
|
|
15
|
+
|
|
16
|
+
rescue_from Agentboard::Error, with: :render_domain_error
|
|
17
|
+
rescue_from ActiveRecord::StaleObjectError do
|
|
18
|
+
render_domain_error(Agentboard::Error.version_conflict("Someone else changed this record; reload and try again"))
|
|
19
|
+
end
|
|
20
|
+
rescue_from ActiveRecord::RecordInvalid do |e|
|
|
21
|
+
render_domain_error(Agentboard::Error.validation_failed(e.record.errors.full_messages.to_sentence))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def current_actor = @current_actor
|
|
27
|
+
def can?(permission) = Authorization.allowed?(current_actor, permission)
|
|
28
|
+
def edition_notices = @edition_notices ||= Extensions.run(:dashboard_notices).flatten.compact.uniq
|
|
29
|
+
def current_project = @current_project ||= Project.current
|
|
30
|
+
|
|
31
|
+
def pending_approvals_count
|
|
32
|
+
@pending_approvals_count ||= Approval.pending.where(project_id: current_project.id).count
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def refuse_production!
|
|
36
|
+
return unless Rails.env.production? && !Agentboard.config.mount_in_production
|
|
37
|
+
|
|
38
|
+
head :not_found
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Host-app authentication with roles is team mode (AgentBoard Pro). Without a licence
|
|
42
|
+
# the dashboard falls back to local single-user access instead of failing open.
|
|
43
|
+
def authenticate_agentboard!
|
|
44
|
+
hook = Agentboard.config.authenticate_with
|
|
45
|
+
if hook && Agentboard.feature?(:team_mode)
|
|
46
|
+
@current_actor = hook.call(self)
|
|
47
|
+
return head(:unauthorized) unless @current_actor
|
|
48
|
+
else
|
|
49
|
+
unless request.local? || Agentboard.config.allow_non_local_requests
|
|
50
|
+
return render plain: "AgentBoard only accepts local requests by default.", status: :forbidden
|
|
51
|
+
end
|
|
52
|
+
if (token = Agentboard.config.dashboard_token).present?
|
|
53
|
+
if params[:token].present? && ActiveSupport::SecurityUtils.secure_compare(params[:token].to_s, token)
|
|
54
|
+
session[:agentboard_authenticated] = true
|
|
55
|
+
end
|
|
56
|
+
return render(plain: "Dashboard token required (?token=…)", status: :unauthorized) unless session[:agentboard_authenticated]
|
|
57
|
+
end
|
|
58
|
+
@current_actor = Authorization.local_human
|
|
59
|
+
end
|
|
60
|
+
Current.actor = @current_actor
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def with_correlation
|
|
64
|
+
Current.correlation_id = request.request_id.presence || Ids.correlation
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_domain_error(error)
|
|
69
|
+
respond_to do |format|
|
|
70
|
+
format.json { render json: Result.failure(error, correlation_id: Current.correlation_id).to_h, status: http_status(error) }
|
|
71
|
+
format.html do
|
|
72
|
+
flash[:alert] = error.message
|
|
73
|
+
redirect_back fallback_location: root_path, status: :see_other
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def http_status(error)
|
|
79
|
+
{
|
|
80
|
+
"NOT_FOUND" => :not_found, "UNAUTHORIZED" => :unauthorized, "FORBIDDEN" => :forbidden, "VALIDATION_FAILED" => :unprocessable_entity,
|
|
81
|
+
"VERSION_CONFLICT" => :conflict, "LEASE_CONFLICT" => :conflict, "LEASE_EXPIRED" => :conflict, "INVALID_TRANSITION" => :unprocessable_entity,
|
|
82
|
+
"POLICY_DENIED" => :forbidden, "APPROVAL_REQUIRED" => :forbidden, "RATE_LIMITED" => :too_many_requests
|
|
83
|
+
}.fetch(error.code, :unprocessable_entity)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_ticket(key = params[:ticket_key] || params[:key])
|
|
87
|
+
Ticket.find_by_key!(key, project: current_project)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class ApprovalsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
Approvals.expire_due!
|
|
5
|
+
@pending = Approval.pending.where(project_id: current_project.id).order(:created_at)
|
|
6
|
+
@decided = Approval.where(project_id: current_project.id).where.not(decision: "pending").order(updated_at: :desc).limit(50)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def update
|
|
10
|
+
approval = Approval.where(project_id: current_project.id).find(params[:id])
|
|
11
|
+
Approvals.decide!(approval: approval, actor: current_actor, decision: params.require(:decision), reason: params[:reason].presence)
|
|
12
|
+
redirect_back fallback_location: approvals_path, notice: "Approval #{params[:decision]}", status: :see_other
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Serves the engine's precompiled CSS/JS and the Turbo/Stimulus builds shipped inside their
|
|
3
|
+
# gems, so the dashboard works with any (or no) host asset pipeline and without Node.
|
|
4
|
+
class AssetsController < ActionController::Base
|
|
5
|
+
skip_forgery_protection
|
|
6
|
+
|
|
7
|
+
def self.asset_paths
|
|
8
|
+
@asset_paths ||= {
|
|
9
|
+
"agentboard.css" => Agentboard::Engine.root.join("app/assets/stylesheets/agentboard/application.css"),
|
|
10
|
+
"agentboard.js" => Agentboard::Engine.root.join("app/javascript/agentboard/application.js"),
|
|
11
|
+
"capture.js" => Agentboard::Engine.root.join("app/javascript/agentboard/capture.js"),
|
|
12
|
+
"turbo.js" => Pathname.new(Gem.loaded_specs.fetch("turbo-rails").full_gem_path).join("app/assets/javascripts/turbo.min.js"),
|
|
13
|
+
"stimulus.js" => Pathname.new(Gem.loaded_specs.fetch("stimulus-rails").full_gem_path).join("app/assets/javascripts/stimulus.min.js")
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.fingerprint(name)
|
|
18
|
+
@fingerprints ||= {}
|
|
19
|
+
@fingerprints[name] = nil if Rails.env.development?
|
|
20
|
+
@fingerprints[name] ||= Digest::SHA256.file(asset_paths.fetch(name)).hexdigest[0, 12]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
path = self.class.asset_paths[params[:name]] or return head(:not_found)
|
|
25
|
+
type = params[:name].end_with?(".css") ? "text/css" : "text/javascript"
|
|
26
|
+
if params[:v].present? && !Rails.env.development?
|
|
27
|
+
expires_in 1.year, public: true
|
|
28
|
+
end
|
|
29
|
+
send_file path, type: "#{type}; charset=utf-8", disposition: "inline"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class BoardController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
@filters = params.permit(:q, :kind, :priority, :label, :agent, :milestone).to_h.compact_blank
|
|
5
|
+
scope = current_project.tickets.where(status: StateMachines::Ticket::BOARD_COLUMNS + %w[blocked reopened])
|
|
6
|
+
.includes(:project, :acceptance_criteria, :leases, :executions, :depends_on)
|
|
7
|
+
scope = apply_filters(scope)
|
|
8
|
+
tickets = scope.ordered.to_a
|
|
9
|
+
done_cutoff = Agentboard.config.settings.dig("board", "done_visible_days").to_i.days.ago
|
|
10
|
+
@columns = Agentboard.config.board_columns.index_with { [] }
|
|
11
|
+
tickets.each do |t|
|
|
12
|
+
col = if t.status == "blocked" && t.leases.any? { |l| l.released_at.nil? }
|
|
13
|
+
Agentboard.config.board_column_for("in_progress")
|
|
14
|
+
else
|
|
15
|
+
Agentboard.config.board_column_for(t.status)
|
|
16
|
+
end
|
|
17
|
+
next if t.status == "done" && t.completed_at && t.completed_at < done_cutoff
|
|
18
|
+
|
|
19
|
+
@columns[col] << t
|
|
20
|
+
end
|
|
21
|
+
@labels = current_project.tickets.pluck(:labels).flatten.uniq.sort
|
|
22
|
+
@milestones = current_project.milestones.where(status: "open").order(:target_date, :name)
|
|
23
|
+
@active_executions = Execution.active.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).includes(:ticket).limit(6)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def apply_filters(scope)
|
|
29
|
+
if (q = @filters["q"]).present?
|
|
30
|
+
if (m = q.strip.upcase.match(/\A#{Regexp.escape(current_project.key)}-(\d+)\z/))
|
|
31
|
+
scope = scope.where(number: m[1].to_i)
|
|
32
|
+
else
|
|
33
|
+
scope = scope.where("LOWER(title) LIKE :q OR LOWER(description) LIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(q.downcase)}%")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
scope = scope.where(kind: @filters["kind"]) if @filters["kind"].present?
|
|
37
|
+
scope = scope.where(milestone_id: @filters["milestone"]) if @filters["milestone"].present?
|
|
38
|
+
scope = scope.where(priority: @filters["priority"].to_i) if @filters["priority"].present?
|
|
39
|
+
if @filters["label"].present?
|
|
40
|
+
ids = scope.select { |t| t.labels.include?(@filters["label"]) }.map(&:id)
|
|
41
|
+
scope = scope.where(id: ids)
|
|
42
|
+
end
|
|
43
|
+
scope
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class CapturesController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
return head(:not_found) unless Capture.enabled_for_env?
|
|
5
|
+
|
|
6
|
+
ticket = Captures.create!(project: current_project, actor: current_actor, params: params.require(:capture).to_unsafe_h)
|
|
7
|
+
render json: { ok: true, key: ticket.key, url: ticket_url(ticket) }, status: :created
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def screenshot
|
|
11
|
+
artifact = Artifact.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }, kind: "screenshot").find(params[:id])
|
|
12
|
+
path = artifact.uri.delete_prefix("file:")
|
|
13
|
+
PathGuard.within!(Agentboard.config.root_path.join(".agentboard", "captures"), path)
|
|
14
|
+
send_file path, type: "image/png", disposition: "inline"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class CommentsController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
ticket = find_ticket
|
|
5
|
+
Tickets.add_comment!(ticket: ticket, actor: current_actor, body: params.require(:comment).require(:body))
|
|
6
|
+
redirect_to ticket_path(ticket, anchor: "activity"), status: :see_other
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class CriteriaController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
ticket = find_ticket
|
|
5
|
+
Tickets.add_criterion!(ticket: ticket, actor: current_actor, description: params.require(:criterion).require(:description),
|
|
6
|
+
verification_kind: params.dig(:criterion, :verification_kind).presence || "manual")
|
|
7
|
+
redirect_to ticket_path(ticket, anchor: "criteria"), status: :see_other
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def update
|
|
11
|
+
ticket = find_ticket
|
|
12
|
+
criterion = ticket.acceptance_criteria.find(params[:id])
|
|
13
|
+
Tickets.set_criterion_state!(criterion: criterion, actor: current_actor, state: params.require(:state), evidence_id: params[:evidence_id].presence)
|
|
14
|
+
redirect_to ticket_path(ticket, anchor: "criteria"), status: :see_other
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class DependenciesController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
ticket = find_ticket
|
|
5
|
+
other = Ticket.find_by_key!(params.require(:depends_on), project: current_project)
|
|
6
|
+
Tickets.add_dependency!(ticket: ticket, depends_on: other, actor: current_actor)
|
|
7
|
+
redirect_to ticket_path(ticket), notice: "#{ticket.key} now depends on #{other.key}", status: :see_other
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def destroy
|
|
11
|
+
ticket = find_ticket
|
|
12
|
+
Authorization.authorize!(current_actor, "ticket.write")
|
|
13
|
+
dep = ticket.dependencies.find(params[:id])
|
|
14
|
+
dep.destroy!
|
|
15
|
+
EventStore.append!(type: "ticket.dependency_removed", aggregate: ticket, ticket: ticket, actor: current_actor,
|
|
16
|
+
payload: { depends_on: dep.depends_on_ticket.key })
|
|
17
|
+
redirect_to ticket_path(ticket), status: :see_other
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class ExecutionsController < ApplicationController
|
|
3
|
+
before_action :load_execution, except: :index
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@status = params[:status].presence
|
|
7
|
+
scope = Execution.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).includes(:ticket, :verification_results).recent
|
|
8
|
+
scope = scope.where(status: @status) if @status
|
|
9
|
+
@executions = scope.limit(200)
|
|
10
|
+
@daemon = Runtime::DaemonControl.status
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
@events = Event.where(execution_id: @execution.id).order(:received_at, :id).last(400)
|
|
15
|
+
@results = @execution.verification_results
|
|
16
|
+
@usage = @execution.usage_records
|
|
17
|
+
@artifacts = @execution.artifacts.order(:created_at)
|
|
18
|
+
@daemon = Runtime::DaemonControl.status
|
|
19
|
+
@pending_approvals = Approval.pending.where(execution_id: @execution.id).order(:created_at)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cancel
|
|
23
|
+
Executions.request_cancel!(execution: @execution, actor: current_actor, reason: params[:reason].presence || "cancelled from dashboard")
|
|
24
|
+
redirect_to execution_path(@execution), notice: "Cancellation requested", status: :see_other
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def retry
|
|
28
|
+
execution = Executions.retry!(execution: @execution, actor: current_actor)
|
|
29
|
+
redirect_to execution_path(execution), notice: "Retry queued as attempt #{execution.attempt}", status: :see_other
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resume
|
|
33
|
+
execution = Executions.resume!(execution: @execution, actor: current_actor, message: params[:message].presence || "Continue the task.")
|
|
34
|
+
redirect_to execution_path(execution), notice: "Resuming the provider session as attempt #{execution.attempt}", status: :see_other
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Redacted log tail for the console (polled).
|
|
38
|
+
def log
|
|
39
|
+
render plain: Redactor.redact(@execution.log_tail(bytes: 48 * 1024)), content_type: "text/plain"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def load_execution
|
|
45
|
+
@execution = Execution.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).find(params[:id])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class MilestonesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@milestones = current_project.milestones.order(Arel.sql("CASE WHEN status = 'open' THEN 0 ELSE 1 END"), :target_date, :name)
|
|
5
|
+
@progress = Ticket.where(milestone_id: @milestones.map(&:id)).group(:milestone_id, :status).count
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def create
|
|
9
|
+
Authorization.authorize!(current_actor, "ticket.write")
|
|
10
|
+
p = params.require(:milestone).permit(:name, :target_date)
|
|
11
|
+
m = current_project.milestones.create!(name: p[:name], target_date: p[:target_date].presence)
|
|
12
|
+
EventStore.append!(type: "milestone.created", aggregate: m, project: current_project, actor: current_actor, payload: { name: m.name, target_date: m.target_date })
|
|
13
|
+
redirect_to milestones_path, notice: "Milestone created", status: :see_other
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def update
|
|
17
|
+
Authorization.authorize!(current_actor, "ticket.write")
|
|
18
|
+
m = current_project.milestones.find(params[:id])
|
|
19
|
+
m.update!(status: params.require(:status).presence_in(%w[open closed]) || m.status)
|
|
20
|
+
redirect_to milestones_path, status: :see_other
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Lightweight change feed for live updates without ActionCable: clients poll the latest
|
|
3
|
+
# event cursor and morph-refresh the page when it moves.
|
|
4
|
+
class PulseController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
latest = Event.where(project_id: [ current_project.id, nil ]).order(received_at: :desc, id: :desc).pick(:id, :received_at)
|
|
7
|
+
active = Execution.active.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).count
|
|
8
|
+
render json: { cursor: latest&.first, at: latest&.last&.utc&.iso8601(3), active_executions: active,
|
|
9
|
+
pending_approvals: pending_approvals_count }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Dashboard association (precedence 3) and confirmation of heuristic links.
|
|
3
|
+
class SessionsController < ApplicationController
|
|
4
|
+
def update
|
|
5
|
+
session_record = AgentSession.where(project_id: current_project.id).find(params[:id])
|
|
6
|
+
if params[:revoke] == "1"
|
|
7
|
+
SessionCredential.revoke!(session_record, actor: current_actor, reason: "revoked from dashboard")
|
|
8
|
+
return redirect_back(fallback_location: agents_path, notice: "Session revoked; its MCP tools now fail with UNAUTHORIZED", status: :see_other)
|
|
9
|
+
end
|
|
10
|
+
if params[:ticket_key].present?
|
|
11
|
+
ticket = Ticket.find_by_key!(params[:ticket_key], project: current_project)
|
|
12
|
+
SessionAssociation.link!(session: session_record, ticket: ticket, source: "dashboard", actor: current_actor)
|
|
13
|
+
else
|
|
14
|
+
SessionAssociation.unlink!(session: session_record, actor: current_actor)
|
|
15
|
+
end
|
|
16
|
+
redirect_back fallback_location: agents_path, status: :see_other
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class TicketsController < ApplicationController
|
|
3
|
+
before_action :load_ticket, except: %i[index new create]
|
|
4
|
+
|
|
5
|
+
# Backlog: dense, filterable list of every ticket.
|
|
6
|
+
def index
|
|
7
|
+
@status = params[:status].presence
|
|
8
|
+
scope = current_project.tickets.includes(:project, :acceptance_criteria, :leases, :executions).ordered
|
|
9
|
+
scope = scope.where(status: @status) if @status
|
|
10
|
+
scope = scope.where(kind: params[:kind]) if params[:kind].present?
|
|
11
|
+
if params[:q].present?
|
|
12
|
+
scope = scope.where("LOWER(title) LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:q].downcase)}%")
|
|
13
|
+
end
|
|
14
|
+
@tickets = scope.limit(500)
|
|
15
|
+
@counts = current_project.tickets.group(:status).count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@events = @ticket.events.last(200).reverse
|
|
20
|
+
@executions = @ticket.executions.includes(:verification_results).limit(20)
|
|
21
|
+
@artifacts = @ticket.artifacts.limit(50)
|
|
22
|
+
@approvals = Approval.where(ticket_id: @ticket.id).order(created_at: :desc).limit(20)
|
|
23
|
+
@head_sha = Evidence.head_sha(@ticket)
|
|
24
|
+
@checks = CompletionPolicy.checks_report(@ticket, head_sha: @head_sha)
|
|
25
|
+
@completion = CompletionPolicy.evaluate(@ticket)
|
|
26
|
+
@sessions = @ticket.session_links.includes(:agent_session).order(started_at: :desc).limit(10)
|
|
27
|
+
@providers = Providers.registry.keys.select { |k| Agentboard.config.provider_enabled?(k) }
|
|
28
|
+
@workflows = WorkflowDefinition.where(project: current_project).group_by(&:name).map { |_, v| v.max_by(&:version) }
|
|
29
|
+
@other_tickets = current_project.tickets.where.not(id: @ticket.id).open.ordered.limit(200)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def new
|
|
33
|
+
@ticket = current_project.tickets.new(kind: "task", priority: 2, status: params[:status].presence_in(%w[backlog ready]) || "backlog")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create
|
|
37
|
+
p = ticket_params
|
|
38
|
+
@ticket = Tickets.create!(project: current_project, actor: current_actor, title: p[:title], description: p[:description].to_s,
|
|
39
|
+
kind: p[:kind].presence || "task", priority: p[:priority].presence || 2,
|
|
40
|
+
labels: p[:labels].to_s.split(","), criteria: p[:criteria].to_s.lines,
|
|
41
|
+
status: (p[:status].presence_in(%w[backlog ready]) || "backlog"),
|
|
42
|
+
parent: p[:parent_id].present? ? current_project.tickets.find(p[:parent_id]) : nil,
|
|
43
|
+
milestone: p[:milestone_id].present? ? current_project.milestones.find(p[:milestone_id]) : nil)
|
|
44
|
+
redirect_to ticket_path(@ticket), notice: "#{@ticket.key} created"
|
|
45
|
+
rescue Agentboard::Error => e
|
|
46
|
+
@ticket = current_project.tickets.new(title: p[:title], description: p[:description], kind: p[:kind], priority: p[:priority])
|
|
47
|
+
flash.now[:alert] = e.message
|
|
48
|
+
render :new, status: :unprocessable_entity
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def edit; end
|
|
52
|
+
|
|
53
|
+
def update
|
|
54
|
+
Tickets.update!(ticket: @ticket, actor: current_actor, attributes: ticket_params.except(:criteria, :status), expected_version: params[:lock_version])
|
|
55
|
+
redirect_to ticket_path(@ticket), notice: "Saved"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def transition
|
|
59
|
+
to = params.require(:to)
|
|
60
|
+
Tickets.transition!(ticket: @ticket, to: to, actor: current_actor, reason: params[:reason].presence,
|
|
61
|
+
override_criteria: params[:override_criteria] == "1", verification_requested: params[:verification_requested] == "1",
|
|
62
|
+
expected_version: params[:lock_version].presence)
|
|
63
|
+
respond_to do |format|
|
|
64
|
+
format.json { render json: Result.success({ key: @ticket.key, status: @ticket.reload.status, version: @ticket.lock_version }).to_h }
|
|
65
|
+
format.html { redirect_back fallback_location: ticket_path(@ticket), notice: "#{@ticket.key} → #{StateMachines::Ticket::LABELS[to]}", status: :see_other }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Board drag & drop: server-validated transition plus ordering.
|
|
70
|
+
def move
|
|
71
|
+
to = params.require(:to)
|
|
72
|
+
Tickets.transition!(ticket: @ticket, to: to, actor: current_actor, reason: params[:reason].presence) if to != @ticket.status
|
|
73
|
+
Tickets.reorder!(ticket: @ticket, actor: current_actor, position: params[:position]) if params[:position].present?
|
|
74
|
+
render json: Result.success({ key: @ticket.key, status: @ticket.reload.status }).to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def verify
|
|
78
|
+
results = VerificationService.verify_repository!(ticket: @ticket, actor: current_actor)
|
|
79
|
+
passed = results.count(&:passed?)
|
|
80
|
+
redirect_to ticket_path(@ticket), notice: "Verification finished: #{passed}/#{results.size} checks passed", status: :see_other
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run
|
|
84
|
+
if params[:workflow].present?
|
|
85
|
+
definition = WorkflowEngine.latest(project: current_project, name: params[:workflow])
|
|
86
|
+
run = WorkflowEngine.start!(definition: definition, ticket: @ticket, actor: current_actor)
|
|
87
|
+
redirect_to workflow_run_path(run), notice: "Workflow #{definition.name} started", status: :see_other
|
|
88
|
+
else
|
|
89
|
+
execution = Executions.enqueue!(ticket: @ticket, provider_key: params.require(:provider), actor: current_actor,
|
|
90
|
+
action: params[:action_name].presence || "implement",
|
|
91
|
+
idempotency_key: params[:idempotency_key].presence,
|
|
92
|
+
metadata: params[:scenario].present? ? { "scenario" => params[:scenario] } : {})
|
|
93
|
+
notice = Runtime::DaemonControl.status[:running] ? "Execution queued" : "Execution queued — start the runtime with `bundle exec agentboard daemon start`"
|
|
94
|
+
redirect_to execution_path(execution), notice: notice, status: :see_other
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def complete
|
|
99
|
+
CompletionPolicy.complete!(ticket: @ticket, actor: current_actor, expected_version: params[:lock_version].presence)
|
|
100
|
+
redirect_to ticket_path(@ticket), notice: "#{@ticket.key} is done", status: :see_other
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def suggestions
|
|
104
|
+
@suggestions = TicketAdvisor.suggest(@ticket)
|
|
105
|
+
render layout: false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A human accepts one suggestion; it goes through the normal domain services.
|
|
109
|
+
def accept_suggestion
|
|
110
|
+
value = params.require(:value).to_s
|
|
111
|
+
case params.require(:kind)
|
|
112
|
+
when "criterion" then Tickets.add_criterion!(ticket: @ticket, actor: current_actor, description: value)
|
|
113
|
+
when "subtask"
|
|
114
|
+
Tickets.create!(project: current_project, actor: current_actor, title: value, parent: @ticket, kind: "task", priority: @ticket.priority)
|
|
115
|
+
when "dependency"
|
|
116
|
+
Tickets.add_dependency!(ticket: @ticket, depends_on: Ticket.find_by_key!(value, project: current_project), actor: current_actor)
|
|
117
|
+
when "complexity"
|
|
118
|
+
Tickets.update!(ticket: @ticket, actor: current_actor, attributes: { labels: (@ticket.labels.reject { |l| l.start_with?("size:") } + [ "size:#{value}" ]) })
|
|
119
|
+
else
|
|
120
|
+
raise Error.validation_failed("Unknown suggestion kind")
|
|
121
|
+
end
|
|
122
|
+
redirect_to ticket_path(@ticket), notice: "Suggestion accepted", status: :see_other
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def link_pr
|
|
126
|
+
PullRequests.link_manually!(ticket: @ticket, actor: current_actor, url: params.require(:url), state: params.require(:state),
|
|
127
|
+
merge_commit_sha: params[:merge_commit_sha].presence)
|
|
128
|
+
redirect_to ticket_path(@ticket), notice: "Pull request linked", status: :see_other
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def load_ticket = @ticket = find_ticket(params[:key])
|
|
134
|
+
|
|
135
|
+
def ticket_params
|
|
136
|
+
params.require(:ticket).permit(:title, :description, :kind, :priority, :labels, :criteria, :status, :milestone_id, :parent_id)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class WorkflowRunsController < ApplicationController
|
|
3
|
+
before_action { @run = WorkflowRun.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).find(params[:id]) }
|
|
4
|
+
|
|
5
|
+
def show
|
|
6
|
+
@definition = @run.definition
|
|
7
|
+
@steps = @run.current_steps
|
|
8
|
+
@all_steps = @run.steps.includes(:executions).order(:created_at)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def cancel
|
|
12
|
+
WorkflowEngine.cancel!(run: @run, actor: current_actor, reason: "cancelled from dashboard")
|
|
13
|
+
redirect_to workflow_run_path(@run), status: :see_other
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
class WorkflowsController < ApplicationController
|
|
3
|
+
SAMPLE = <<~YAML
|
|
4
|
+
version: 1
|
|
5
|
+
name: feature_delivery
|
|
6
|
+
steps:
|
|
7
|
+
- id: plan
|
|
8
|
+
type: agent
|
|
9
|
+
provider: fake
|
|
10
|
+
action: plan
|
|
11
|
+
- id: implement
|
|
12
|
+
type: agent
|
|
13
|
+
provider: fake
|
|
14
|
+
action: implement
|
|
15
|
+
depends_on: [plan]
|
|
16
|
+
- id: test
|
|
17
|
+
type: command
|
|
18
|
+
command: rails_test
|
|
19
|
+
depends_on: [implement]
|
|
20
|
+
- id: review
|
|
21
|
+
type: agent
|
|
22
|
+
provider: fake
|
|
23
|
+
action: review
|
|
24
|
+
depends_on: [test]
|
|
25
|
+
- id: approval
|
|
26
|
+
type: human_approval
|
|
27
|
+
depends_on: [review]
|
|
28
|
+
YAML
|
|
29
|
+
|
|
30
|
+
def index
|
|
31
|
+
WorkflowEngine.sync_from_disk!(project: current_project) if params[:sync]
|
|
32
|
+
@definitions = WorkflowDefinition.where(project: current_project).order(:name, version: :desc).group_by(&:name)
|
|
33
|
+
@runs = WorkflowRun.joins(:ticket).where(agentboard_tickets: { project_id: current_project.id }).includes(:ticket, :workflow_definition, :steps)
|
|
34
|
+
.order(created_at: :desc).limit(30)
|
|
35
|
+
@yaml = params[:yaml].presence || SAMPLE
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show
|
|
39
|
+
@definition = WorkflowDefinition.where(project: current_project).find(params[:id])
|
|
40
|
+
@parsed = @definition.parsed
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate
|
|
44
|
+
definition = Workflows::Definition.parse_yaml(params.require(:yaml), enforce_features: true)
|
|
45
|
+
render json: { ok: true, name: definition.name, steps: definition.topological_order, digest: definition.digest }
|
|
46
|
+
rescue Agentboard::Error => e
|
|
47
|
+
render json: { ok: false, errors: e.details[:errors] || [ e.message ] }, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create
|
|
51
|
+
definition = WorkflowEngine.register!(project: current_project, yaml: params.require(:yaml), actor: current_actor)
|
|
52
|
+
redirect_to workflow_path(definition), notice: "Registered #{definition.name} v#{definition.version}", status: :see_other
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start
|
|
56
|
+
definition = WorkflowDefinition.where(project: current_project).find(params[:id])
|
|
57
|
+
ticket = Ticket.find_by_key!(params.require(:ticket_key), project: current_project)
|
|
58
|
+
run = WorkflowEngine.start!(definition: definition, ticket: ticket, actor: current_actor)
|
|
59
|
+
redirect_to workflow_run_path(run), status: :see_other
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|