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,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,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,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
|