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,81 @@
1
+ module Agentboard
2
+ class Ticket < ApplicationRecord
3
+ KINDS = %w[feature bug task chore spike].freeze
4
+ PRIORITIES = { 0 => "Urgent", 1 => "High", 2 => "Medium", 3 => "Low" }.freeze
5
+ KEY_FORMAT = /\A([A-Z][A-Z0-9_-]*)-(\d+)\z/
6
+
7
+ belongs_to :project
8
+ belongs_to :milestone, optional: true
9
+ belongs_to :parent, class_name: "Agentboard::Ticket", optional: true
10
+ has_many :subtasks, class_name: "Agentboard::Ticket", foreign_key: :parent_id, dependent: :nullify
11
+ has_many :acceptance_criteria, -> { order(:position) }, class_name: "Agentboard::AcceptanceCriterion", dependent: :destroy
12
+ has_many :comments, -> { order(:created_at) }, dependent: :destroy
13
+ has_many :dependencies, class_name: "Agentboard::TicketDependency", dependent: :destroy
14
+ has_many :depends_on, through: :dependencies, source: :depends_on_ticket
15
+ has_many :inverse_dependencies, class_name: "Agentboard::TicketDependency", foreign_key: :depends_on_ticket_id, dependent: :destroy
16
+ has_many :dependents, through: :inverse_dependencies, source: :ticket
17
+ has_many :leases, class_name: "Agentboard::TicketLease", dependent: :destroy
18
+ has_many :session_links, class_name: "Agentboard::SessionTicketLink", dependent: :destroy
19
+ has_many :agent_sessions, through: :session_links # no DISTINCT: PostgreSQL has no equality for json columns
20
+ has_many :executions, -> { order(created_at: :desc) }, dependent: :restrict_with_exception
21
+ has_many :artifacts, -> { order(created_at: :desc) }, dependent: :destroy
22
+ has_many :workflow_runs, dependent: :restrict_with_exception
23
+ belongs_to :assignee_profile, class_name: "Agentboard::AgentProfile", optional: true
24
+
25
+ validates :title, presence: true, length: { maximum: 255 }
26
+ validates :number, presence: true, uniqueness: { scope: :project_id }
27
+ validates :kind, inclusion: { in: KINDS }
28
+ validates :status, inclusion: { in: StateMachines::Ticket::STATES }
29
+ validates :priority, inclusion: { in: PRIORITIES.keys }
30
+
31
+ scope :open, -> { where.not(status: StateMachines::Ticket::TERMINAL) }
32
+ scope :ordered, -> { order(:priority, :position, :number) }
33
+
34
+ def self.find_by_key!(key, project: nil)
35
+ match = KEY_FORMAT.match(key.to_s.strip.upcase)
36
+ raise Agentboard::Error.not_found("Ticket #{key} not found") unless match
37
+
38
+ scope = joins(:project).where(agentboard_projects: { key: match[1] }, number: match[2].to_i)
39
+ scope = scope.where(project_id: project.id) if project
40
+ scope.first or raise Agentboard::Error.not_found("Ticket #{key} not found")
41
+ end
42
+
43
+ def self.find_by_key(key, project: nil)
44
+ find_by_key!(key, project: project)
45
+ rescue Agentboard::Error
46
+ nil
47
+ end
48
+
49
+ def key = "#{project.key}-#{number}"
50
+ def to_param = key
51
+ def priority_label = PRIORITIES[priority]
52
+ def status_label = StateMachines::Ticket::LABELS[status]
53
+ def terminal? = StateMachines::Ticket::TERMINAL.include?(status)
54
+
55
+ def active_lease
56
+ leases.where(released_at: nil).where("expires_at > ?", Time.current).first
57
+ end
58
+
59
+ def active_execution = executions.where(status: StateMachines::Execution::ACTIVE).first
60
+
61
+ def dependencies_satisfied?
62
+ depends_on.where.not(status: "done").none?
63
+ end
64
+
65
+ def unsatisfied_dependencies = depends_on.where.not(status: "done")
66
+
67
+ def criteria_progress
68
+ total = acceptance_criteria.size
69
+ done = acceptance_criteria.count { |c| %w[verified waived].include?(c.state) }
70
+ [ done, total ]
71
+ end
72
+
73
+ def events
74
+ Agentboard::Event.where(ticket_id: id).order(:occurred_at, :sequence)
75
+ end
76
+
77
+ def latest_head_sha
78
+ executions.where.not(head_sha: nil).pick(:head_sha)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,34 @@
1
+ module Agentboard
2
+ class TicketDependency < ApplicationRecord
3
+ KINDS = %w[blocks relates].freeze
4
+
5
+ belongs_to :ticket
6
+ belongs_to :depends_on_ticket, class_name: "Agentboard::Ticket"
7
+
8
+ validates :kind, inclusion: { in: KINDS }
9
+ validates :depends_on_ticket_id, uniqueness: { scope: [ :ticket_id, :kind ] }
10
+ validate :not_self
11
+ validate :acyclic, if: -> { kind == "blocks" }
12
+
13
+ private
14
+
15
+ def not_self
16
+ errors.add(:depends_on_ticket, "cannot be the ticket itself") if ticket_id == depends_on_ticket_id
17
+ end
18
+
19
+ # DAG validation: adding ticket -> dep must not create a path dep ->* ticket.
20
+ def acyclic
21
+ seen = Set.new
22
+ frontier = [ depends_on_ticket_id ]
23
+ while (current = frontier.shift)
24
+ if current == ticket_id
25
+ errors.add(:depends_on_ticket, "would create a dependency cycle")
26
+ return
27
+ end
28
+ next unless seen.add?(current)
29
+
30
+ frontier.concat(self.class.where(ticket_id: current, kind: "blocks").pluck(:depends_on_ticket_id))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ module Agentboard
2
+ class TicketLease < ApplicationRecord
3
+ belongs_to :ticket
4
+ belongs_to :agent_session, optional: true
5
+
6
+ scope :live, -> { where(released_at: nil) }
7
+
8
+ def expired?(now = Time.current) = expires_at <= now
9
+ def active?(now = Time.current) = released_at.nil? && !expired?(now)
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ module Agentboard
2
+ # Token/cost metering. Unknown cost is nil, never zero; monetary amounts require
3
+ # provenance. `metering_kind` distinguishes API-billed from subscription CLI usage.
4
+ class UsageRecord < ApplicationRecord
5
+ METERING_KINDS = %w[api_billed subscription unknown].freeze
6
+ belongs_to :execution
7
+ validates :metering_kind, inclusion: { in: METERING_KINDS }
8
+ validates :source, presence: true
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ module Agentboard
2
+ # A check bound to a specific commit. A result for SHA A never authorizes SHA B (AT-08).
3
+ class VerificationResult < ApplicationRecord
4
+ STATUSES = %w[running passed failed error timeout skipped].freeze
5
+ belongs_to :execution
6
+ validates :status, inclusion: { in: STATUSES }
7
+ validates :commit_sha, :command_digest, :name, presence: true
8
+
9
+ def passed? = status == "passed"
10
+ def stale_for?(head_sha) = commit_sha != head_sha
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ module Agentboard
2
+ class WebhookDelivery < ApplicationRecord
3
+ STATUSES = %w[received processed ignored rejected failed].freeze
4
+ validates :provider, :delivery_id, :event, :payload_digest, presence: true
5
+ validates :status, inclusion: { in: STATUSES }
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ module Agentboard
2
+ # Immutable once used: a changed definition is registered as a new version.
3
+ class WorkflowDefinition < ApplicationRecord
4
+ belongs_to :project
5
+ has_many :workflow_runs, dependent: :restrict_with_exception
6
+ validates :name, :version, :digest, presence: true
7
+
8
+ before_update :prevent_mutation_when_used
9
+
10
+ def parsed = Agentboard::Workflows::Definition.from_h(definition)
11
+
12
+ private
13
+
14
+ def prevent_mutation_when_used
15
+ return unless workflow_runs.exists? && (definition_changed? || digest_changed?)
16
+
17
+ raise Agentboard::Error.validation_failed("Workflow #{name} v#{version} is immutable once used")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ module Agentboard
2
+ class WorkflowRun < ApplicationRecord
3
+ STATUSES = %w[pending running waiting_for_approval succeeded failed cancelled].freeze
4
+ TERMINAL = %w[succeeded failed cancelled].freeze
5
+
6
+ belongs_to :workflow_definition
7
+ belongs_to :ticket
8
+ has_many :steps, -> { order(:created_at) }, class_name: "Agentboard::WorkflowStep", dependent: :destroy
9
+
10
+ validates :status, inclusion: { in: STATUSES }
11
+ scope :active, -> { where.not(status: TERMINAL) }
12
+
13
+ def terminal? = TERMINAL.include?(status)
14
+ def definition = workflow_definition.parsed
15
+
16
+ # Latest attempt per step key.
17
+ def current_steps
18
+ steps.group_by(&:step_key).transform_values { |list| list.max_by(&:attempt) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ module Agentboard
2
+ class WorkflowStep < ApplicationRecord
3
+ STATUSES = %w[pending running waiting succeeded failed skipped cancelled].freeze
4
+ TERMINAL = %w[succeeded failed skipped cancelled].freeze
5
+
6
+ belongs_to :workflow_run
7
+ has_many :executions, dependent: :nullify
8
+ validates :status, inclusion: { in: STATUSES }
9
+
10
+ def terminal? = TERMINAL.include?(status)
11
+ end
12
+ end
@@ -0,0 +1,76 @@
1
+ module Agentboard
2
+ # Approval requests bound to an exact action digest and code state (spec §28.5, AT-10).
3
+ module Approvals
4
+ DEFAULT_TTL = 72.hours
5
+
6
+ module_function
7
+
8
+ def request!(project:, action_kind:, actor:, action_payload: {}, reason: nil, ticket: nil, execution: nil,
9
+ head_sha: nil, workflow_step: nil, required_role: "maintainer", ttl: DEFAULT_TTL, action_digest: nil)
10
+ Authorization.authorize!(actor, "approval.request")
11
+ digest = action_digest || Ids.canonical_digest(action_payload.to_h.merge(action_kind: action_kind, head_sha: head_sha, ticket_id: ticket&.id))
12
+ Approval.transaction do
13
+ existing = Approval.pending.find_by(project_id: project.id, ticket_id: ticket&.id, action_kind: action_kind, action_digest: digest)
14
+ return existing if existing && !existing.expired?
15
+
16
+ approval = Approval.create!(
17
+ project: project, ticket_id: ticket&.id, execution: execution, workflow_step_id: workflow_step&.id,
18
+ action_kind: action_kind, action_digest: digest, head_sha: head_sha, action_payload: Redactor.redact_value(action_payload.to_h),
19
+ reason: reason, required_role: required_role, requested_by: actor.label, expires_at: Time.current + ttl
20
+ )
21
+ EventStore.append!(type: "approval.requested", aggregate: approval, project: project, ticket: ticket, execution: execution, actor: actor,
22
+ payload: { approval_id: approval.id, action_kind: action_kind, head_sha: head_sha, reason: reason })
23
+ approval
24
+ end
25
+ end
26
+
27
+ def request_review!(ticket:, actor:, head_sha:, summary: nil)
28
+ request!(project: ticket.project, ticket: ticket, actor: actor, action_kind: "review_completion", head_sha: head_sha,
29
+ action_digest: CompletionPolicy.review_action_digest(ticket, head_sha),
30
+ action_payload: { summary: summary.to_s.truncate(2000) }, reason: summary)
31
+ end
32
+
33
+ def decide!(approval:, actor:, decision:, reason: nil)
34
+ raise Error.forbidden("Only humans can decide approvals") unless actor.human?
35
+ Authorization.authorize!(actor, "approval.decide")
36
+ unless Authorization::ROLES.index(actor.role.to_s).to_i >= Authorization::ROLES.index(approval.required_role).to_i
37
+ raise Error.forbidden("Approval requires role #{approval.required_role}")
38
+ end
39
+ raise Error.validation_failed("Decision must be approved or rejected") unless %w[approved rejected].include?(decision)
40
+
41
+ Extensions.run(:before_approval_decision, approval: approval, actor: actor)
42
+
43
+ Approval.transaction do
44
+ approval.lock!
45
+ raise Error.invalid_transition("Approval is already #{approval.decision}") unless approval.pending?
46
+ if approval.expired?
47
+ approval.update!(decision: "expired")
48
+ raise Error.approval_required("Approval request expired; request a new one")
49
+ end
50
+ if approval.action_kind == "review_completion" && approval.ticket && approval.head_sha != Evidence.head_sha(approval.ticket)
51
+ approval.update!(decision: "invalidated", decision_reason: "head changed before decision")
52
+ raise Error.approval_required("Code changed since the review was requested; a new review is required")
53
+ end
54
+
55
+ approval.update!(decision: decision, decided_by: actor.label, decision_reason: reason, decided_at: Time.current)
56
+ EventStore.append!(type: "approval.#{decision}", aggregate: approval, project: approval.project, ticket: approval.ticket,
57
+ actor: actor, payload: { approval_id: approval.id, action_kind: approval.action_kind, reason: reason })
58
+ approval
59
+ end
60
+ end
61
+
62
+ # Any relevant code change invalidates approvals bound to the old head (AT: "Agent edits code after approval").
63
+ def invalidate_stale!(ticket:, head_sha:)
64
+ stale = Approval.where(ticket_id: ticket.id, decision: %w[pending approved]).where.not(head_sha: [ nil, head_sha ])
65
+ stale.find_each do |approval|
66
+ approval.update!(decision: "invalidated", decision_reason: "head moved to #{head_sha.to_s[0, 10]}")
67
+ EventStore.append!(type: "approval.invalidated", aggregate: approval, project: approval.project, ticket: ticket,
68
+ actor: Authorization.system_actor, payload: { approval_id: approval.id, new_head_sha: head_sha })
69
+ end
70
+ end
71
+
72
+ def expire_due!
73
+ Approval.pending.where("expires_at <= ?", Time.current).update_all(decision: "expired", updated_at: Time.current)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,113 @@
1
+ module Agentboard
2
+ # Turns a capture from the in-app widget into a ticket plus a `capture` artifact (and an
3
+ # optional `screenshot` artifact). Everything coming from the browser is untrusted: it is
4
+ # bounded, validated and redacted before it is stored or shown to an agent.
5
+ module Captures
6
+ MAX_ELEMENTS = 10
7
+ MAX_SCREENSHOT_BYTES = 3 * 1024 * 1024
8
+ SAFE_PATH = %r{\A[\w./-]{1,300}\z}
9
+
10
+ module_function
11
+
12
+ def create!(project:, actor:, params:)
13
+ Authorization.authorize!(actor, "ticket.write")
14
+ p = params.to_h.deep_stringify_keys
15
+ title = p["title"].to_s.strip[0, 200]
16
+ raise Error.validation_failed("A title is required") if title.blank?
17
+
18
+ kind = p["kind"].to_s.presence_in(Ticket::KINDS) || "bug"
19
+ priority = p["priority"].to_i.clamp(0, 3)
20
+ url = sanitize_url(p["url"])
21
+ elements = Array(p["elements"]).first(MAX_ELEMENTS).map { |e| sanitize_element(e) }
22
+ templates = Array(p["templates"]).map(&:to_s).select { |t| safe_path?(t) }.uniq.first(50)
23
+ console = Array(p["console_errors"]).first(20).map { |c| Redactor.redact(c.to_s)[0, 500] }
24
+ meta = {
25
+ "url" => url, "route" => p["route"].to_s[0, 120].presence, "viewport" => sanitize_viewport(p["viewport"]),
26
+ "user_agent" => p["user_agent"].to_s[0, 300], "elements" => elements, "templates" => templates,
27
+ "console_errors" => console, "captured_by" => actor.label
28
+ }
29
+ expected = p["expected"].to_s.strip[0, 500]
30
+ ticket = nil
31
+ Ticket.transaction do
32
+ ticket = Tickets.create!(project: project, actor: actor, title: title, kind: kind, priority: priority,
33
+ description: description_for(p["description"], meta), labels: [ "captured" ],
34
+ criteria: expected.present? ? [ expected ] : [], status: expected.present? ? "ready" : "backlog")
35
+ Artifact.create!(ticket: ticket, kind: "capture", uri: url.presence || "capture:#{ticket.key}", title: "Captured from #{meta["route"] || url}",
36
+ provenance: "confirmed", metadata: meta)
37
+ attach_screenshot!(ticket, p["screenshot"]) if p["screenshot"].present?
38
+ EventStore.append!(type: "ticket.captured", aggregate: ticket, ticket: ticket, actor: actor,
39
+ payload: { url: url, route: meta["route"], elements: elements.size, templates: templates.size })
40
+ end
41
+ ticket
42
+ end
43
+
44
+ def safe_path?(path) = path.match?(SAFE_PATH) && PathGuard.safe_relative?(path)
45
+
46
+ def sanitize_url(value)
47
+ uri = URI.parse(value.to_s)
48
+ return nil unless %w[http https].include?(uri.scheme)
49
+
50
+ # Query strings often carry tokens; keep only the path for traceability.
51
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port && ![ 80, 443 ].include?(uri.port)}#{uri.path}"[0, 500]
52
+ rescue URI::InvalidURIError
53
+ nil
54
+ end
55
+
56
+ def sanitize_viewport(v)
57
+ v = v.is_a?(Hash) ? v.transform_keys(&:to_s) : {}
58
+ { "width" => v["width"].to_i.clamp(0, 10_000), "height" => v["height"].to_i.clamp(0, 10_000) }
59
+ end
60
+
61
+ def sanitize_element(e)
62
+ e = e.is_a?(Hash) ? e.transform_keys(&:to_s) : {}
63
+ rect = e["rect"].is_a?(Hash) ? e["rect"].transform_keys(&:to_s).slice("x", "y", "width", "height").transform_values { |n| n.to_f.round(1) } : {}
64
+ template = e["template"].to_s
65
+ {
66
+ "selector" => e["selector"].to_s[0, 500], "text" => Redactor.redact(e["text"].to_s.squish[0, 300]),
67
+ "html" => Redactor.redact(e["html"].to_s[0, 4000]), "template" => safe_path?(template) ? template : nil, "rect" => rect
68
+ }.compact
69
+ end
70
+
71
+ def description_for(text, meta)
72
+ parts = [ Redactor.redact(text.to_s.strip[0, 10_000]).presence ]
73
+ parts << "### Captured from\n- Page: #{meta["url"] || "unknown"}\n- Route: `#{meta["route"] || "unknown"}`\n" \
74
+ "- Viewport: #{meta.dig("viewport", "width")}×#{meta.dig("viewport", "height")}"
75
+ if meta["elements"].any?
76
+ parts << "### Selected elements\n" + meta["elements"].map.with_index(1) do |el, i|
77
+ line = "#{i}. `#{el["selector"]}`"
78
+ line += " — rendered by `#{el["template"]}`" if el["template"]
79
+ line += "\n “#{el["text"].truncate(160)}”" if el["text"].present?
80
+ line
81
+ end.join("\n")
82
+ end
83
+ parts << "### Templates rendered on this page\n" + meta["templates"].first(20).map { |t| "- `#{t}`" }.join("\n") if meta["templates"].any?
84
+ parts << "### Console errors\n" + meta["console_errors"].map { |c| "- #{c}" }.join("\n") if meta["console_errors"].any?
85
+ parts.compact.join("\n\n")
86
+ end
87
+
88
+ def attach_screenshot!(ticket, data_url)
89
+ Agentboard.require_feature!(:capture)
90
+ match = data_url.to_s.match(%r{\Adata:image/png;base64,([A-Za-z0-9+/=]+)\z})
91
+ raise Error.validation_failed("Screenshot must be a PNG data URL") unless match
92
+
93
+ bytes = Base64.strict_decode64(match[1])
94
+ raise Error.validation_failed("Screenshot is too large") if bytes.bytesize > MAX_SCREENSHOT_BYTES
95
+ raise Error.validation_failed("Screenshot is not a PNG") unless bytes.start_with?("\x89PNG".b)
96
+
97
+ dir = Agentboard.config.root_path.join(".agentboard", "captures")
98
+ FileUtils.mkdir_p(dir, mode: 0o700)
99
+ path = dir.join("#{ticket.key}-#{SecureRandom.hex(4)}.png")
100
+ File.binwrite(path, bytes)
101
+ File.chmod(0o600, path)
102
+ Artifact.create!(ticket: ticket, kind: "screenshot", uri: "file:#{path}", title: "Screenshot of the selected area",
103
+ sha256: Digest::SHA256.hexdigest(bytes), provenance: "confirmed", metadata: { "bytes" => bytes.bytesize })
104
+ end
105
+
106
+ # Paths an agent should look at first for a captured ticket.
107
+ def focus_paths(ticket)
108
+ ticket.artifacts.where(kind: "capture").flat_map do |a|
109
+ Array(a.metadata["elements"]).filter_map { |e| e["template"] } + Array(a.metadata["templates"])
110
+ end.uniq.first(20)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,117 @@
1
+ module Agentboard
2
+ # Evidence-based completion (spec §15, §29.4). Agents may *request* completion but every
3
+ # gate is evaluated against persisted records — never against agent-supplied JSON.
4
+ module CompletionPolicy
5
+ module_function
6
+
7
+ def settings = Agentboard.config.completion
8
+
9
+ # require_merged_pr: "when_linked" (default) | "always" | "never" (true/false accepted).
10
+ # "when_linked": a ticket with an open or merged PR must have that PR merged on the
11
+ # reviewed head; tickets without a PR (or only abandoned ones) complete on other evidence.
12
+ def merged_pr_mode
13
+ case settings["require_merged_pr"]
14
+ when true, "always" then "always"
15
+ when false, nil, "never" then "never"
16
+ else "when_linked"
17
+ end
18
+ end
19
+
20
+ def merged_pr_required?(ticket)
21
+ case merged_pr_mode
22
+ when "always" then true
23
+ when "never" then false
24
+ else ticket.artifacts.where(kind: "pull_request").any? { |a| %w[open merged].include?(a.metadata["state"]) }
25
+ end
26
+ end
27
+
28
+ # Required checks must have passed for exactly `head_sha` (AT-08).
29
+ def checks_report(ticket, head_sha:)
30
+ missing = []
31
+ required = Agentboard.config.verification_catalog.required_names
32
+ if head_sha.blank?
33
+ missing << "no verified head commit is linked to the ticket"
34
+ return { ok: false, head_sha: nil, missing: missing, checks: [] }
35
+ end
36
+
37
+ checks = required.map do |name|
38
+ result = VerificationResult.where(ticket_id: ticket.id, name: name).order(started_at: :desc).first
39
+ latest_for_head = VerificationResult.where(ticket_id: ticket.id, name: name, commit_sha: head_sha).order(started_at: :desc).first
40
+ status =
41
+ if latest_for_head&.passed? then "passed"
42
+ elsif latest_for_head then latest_for_head.status
43
+ elsif result then "stale"
44
+ else "missing"
45
+ end
46
+ missing << "#{name} #{status == "stale" ? "passed only for an older commit" : status} at #{head_sha[0, 10]}" unless status == "passed"
47
+ { name: name, status: status, commit_sha: latest_for_head&.commit_sha || result&.commit_sha, result_id: latest_for_head&.id }
48
+ end
49
+ { ok: missing.empty?, head_sha: head_sha, missing: missing, checks: checks }
50
+ end
51
+
52
+ def review_action_digest(ticket, head_sha)
53
+ Ids.canonical_digest(action: "review_completion", ticket_id: ticket.id, head_sha: head_sha)
54
+ end
55
+
56
+ def evaluate(ticket, actor: nil)
57
+ head = Evidence.head_sha(ticket)
58
+ missing = []
59
+ report = { ticket_key: ticket.key, head_sha: head }
60
+
61
+ if settings["require_criteria_verified"]
62
+ crit = ticket.acceptance_criteria.map { |c| { id: c.id, status: c.state, evidence_id: c.evidence_id } }
63
+ unsatisfied = ticket.acceptance_criteria.reject(&:satisfied?)
64
+ missing << "#{unsatisfied.size} acceptance criteria not verified" if unsatisfied.any?
65
+ missing << "ticket has no acceptance criteria" if crit.empty?
66
+ report[:criteria] = crit
67
+ end
68
+
69
+ if settings["require_checks"]
70
+ checks = checks_report(ticket, head_sha: head)
71
+ missing.concat(checks[:missing])
72
+ report[:required_checks] = checks[:checks]
73
+ end
74
+
75
+ if settings["require_approval"] || Agentboard.config.security["require_review"]
76
+ digest = head && review_action_digest(ticket, head)
77
+ approval = head && Approval.where(ticket_id: ticket.id, action_kind: "review_completion", decision: "approved").order(decided_at: :desc)
78
+ .find { |a| a.valid_for?(action_digest: digest, head_sha: head) }
79
+ missing << "human review approval for #{head ? head[0, 10] : "the current head"} is missing" unless approval
80
+ report[:review] = approval ? { status: "approved", approval_id: approval.id } : { status: "pending" }
81
+ end
82
+
83
+ if merged_pr_required?(ticket)
84
+ # The merged PR must carry exactly the head that passed checks and review.
85
+ pr = ticket.artifacts.where(kind: "pull_request").find do |a|
86
+ a.metadata["state"] == "merged" && a.metadata["merge_verified"] && head.present? && a.metadata["head_sha"] == head
87
+ end
88
+ missing << "merged pull request (verified by the Git provider) for #{head ? head[0, 10] : "the current head"}" unless pr
89
+ report[:pull_request] = pr ? { state: "merged", merge_commit_sha: pr.metadata["merge_commit_sha"] } : { state: "missing" }
90
+ end
91
+
92
+ report.merge(ok: missing.empty?, missing: missing)
93
+ end
94
+
95
+ # Policy-controlled completion used by the UI and the MCP `complete_ticket` tool.
96
+ def complete!(ticket:, actor:, expected_version: nil, lease_token: nil, session: nil)
97
+ report = evaluate(ticket, actor: actor)
98
+ raise Error.policy_denied("Completion policy not satisfied", missing: report[:missing]) unless report[:ok]
99
+
100
+ if actor.agent?
101
+ # Agents need a valid lease; the human approval gate above has already been met.
102
+ LeaseService.validate!(ticket: ticket, token: lease_token, session: session)
103
+ ticket.transaction do
104
+ ticket.lock!
105
+ StateMachines::Ticket.assert!(ticket.status, "done")
106
+ ticket.update!(status: "done", completed_at: Time.current)
107
+ LeaseService.release_all!(ticket: ticket, reason: "ticket_done")
108
+ EventStore.append!(type: "ticket.transitioned", aggregate: ticket, ticket: ticket, actor: actor, session: session,
109
+ payload: { from: "review", to: "done", evidence: report.except(:missing, :ok) })
110
+ end
111
+ ticket
112
+ else
113
+ Tickets.transition!(ticket: ticket, to: "done", actor: actor, expected_version: expected_version)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ module Agentboard
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :correlation_id, :actor
4
+ end
5
+ end
@@ -0,0 +1,68 @@
1
+ module Agentboard
2
+ # Retention and deletion (spec §32.3). Ticket and audit metadata are kept; raw logs,
3
+ # provider output events, delivered outbox rows and expired idempotency keys age out.
4
+ # Purging a project is a separate, explicitly confirmed operation.
5
+ module DataGovernance
6
+ PROVIDER_OUTPUT_EVENTS = %w[agent.execution.output agent.execution.message agent.provider.unknown agent.provider.system].freeze
7
+
8
+ module_function
9
+
10
+ def apply_retention!(project:, dry_run: false, now: Time.current)
11
+ r = Agentboard.config.settings["retention"] || {}
12
+ log_cutoff = now - r.fetch("raw_log_days", 30).to_i.days
13
+ event_cutoff = now - r.fetch("provider_output_event_days", 30).to_i.days
14
+ logs_root = Agentboard.config.root_path.join(".agentboard", "logs")
15
+ old_logs = Dir[logs_root.join("*").to_s].select { |d| File.mtime(d) < log_cutoff }
16
+ events = Event.where(project_id: project.id, event_type: PROVIDER_OUTPUT_EVENTS).where("received_at < ?", event_cutoff)
17
+ outbox = OutboxMessage.where(state: "delivered").where("delivered_at < ?", now - 7.days)
18
+ keys = IdempotencyKey.where("expires_at < ?", now)
19
+ deliveries = WebhookDelivery.where("created_at < ?", now - 90.days)
20
+ report = { log_dirs: old_logs.size, provider_output_events: events.count, delivered_outbox: outbox.count,
21
+ idempotency_keys: keys.count, webhook_deliveries: deliveries.count, dry_run: dry_run }
22
+ return report if dry_run
23
+
24
+ FileUtils.rm_rf(old_logs)
25
+ OutboxMessage.where(event_id: events.select(:id)).delete_all
26
+ events.delete_all
27
+ outbox.delete_all
28
+ keys.delete_all
29
+ deliveries.delete_all
30
+ EventStore.append!(type: "security.retention_applied", aggregate: project, project: project, actor: Authorization.system_actor,
31
+ payload: report.except(:dry_run))
32
+ report
33
+ end
34
+
35
+ # Deletes every AgentBoard record of a project. Requires typing the project key.
36
+ def purge_project!(project:, confirm:, actor:)
37
+ raise Error.forbidden("Only administrators can purge") unless actor.role == "administrator"
38
+ raise Error.validation_failed("Confirmation must equal the project key (#{project.key})") unless confirm.to_s == project.key
39
+
40
+ counts = {}
41
+ ActiveRecord::Base.transaction do
42
+ tickets = Ticket.where(project_id: project.id)
43
+ executions = Execution.where(ticket_id: tickets.select(:id))
44
+ runs = WorkflowRun.where(ticket_id: tickets.select(:id))
45
+ steps = WorkflowStep.where(workflow_run_id: runs.select(:id))
46
+ sessions = AgentSession.where(project_id: project.id)
47
+ events = Event.where(project_id: project.id)
48
+ {
49
+ outbox: OutboxMessage.where(event_id: events.select(:id)), events: events,
50
+ usage: UsageRecord.where(execution_id: executions.select(:id)), verification: VerificationResult.where(execution_id: executions.select(:id)),
51
+ approvals: Approval.where(project_id: project.id), artifacts: Artifact.where(ticket_id: tickets.select(:id)),
52
+ executions: executions, steps: steps, runs: runs, workflows: WorkflowDefinition.where(project_id: project.id),
53
+ leases: TicketLease.where(ticket_id: tickets.select(:id)), links: SessionTicketLink.where(agent_session_id: sessions.select(:id)),
54
+ sessions: sessions, profiles: AgentProfile.where(project_id: project.id), comments: Comment.where(ticket_id: tickets.select(:id)),
55
+ criteria: AcceptanceCriterion.where(ticket_id: tickets.select(:id)),
56
+ dependencies: TicketDependency.where(ticket_id: tickets.select(:id)).or(TicketDependency.where(depends_on_ticket_id: tickets.select(:id))),
57
+ snapshots: RepositorySnapshot.where(project_id: project.id)
58
+ }.each { |name, scope| counts[name] = scope.delete_all }
59
+ tickets.update_all(parent_id: nil)
60
+ counts[:tickets] = tickets.delete_all
61
+ counts[:milestones] = Milestone.where(project_id: project.id).delete_all
62
+ project.delete
63
+ end
64
+ Agentboard.logger.warn("[agentboard] project #{project.key} purged by #{actor.label}: #{counts}")
65
+ counts
66
+ end
67
+ end
68
+ end