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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. 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,9 @@
1
+ module Agentboard
2
+ class SettingsController < ApplicationController
3
+ def show
4
+ @config = Agentboard.config
5
+ @doctor = Doctor.new(root: Agentboard.config.root_path).checks
6
+ @outbox = Outbox.stats
7
+ end
8
+ end
9
+ 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