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,56 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Links sessions that belong to the same agent process (see ProcessAncestry). A hook
|
|
3
|
+
# session corroborates an MCP session and vice versa; links propagate ticket context.
|
|
4
|
+
module SessionCorrelation
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def record_agent!(session, agent)
|
|
8
|
+
return unless agent
|
|
9
|
+
|
|
10
|
+
session.update_columns(metadata: session.metadata.merge(agent.to_h))
|
|
11
|
+
correlate!(session)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def peers(session)
|
|
15
|
+
pid = session.metadata["agent_pid"]
|
|
16
|
+
fp = session.metadata["agent_fingerprint"]
|
|
17
|
+
return AgentSession.none unless pid && fp
|
|
18
|
+
|
|
19
|
+
AgentSession.where(project_id: session.project_id, state: "active").where.not(id: session.id).select do |s|
|
|
20
|
+
s.metadata["agent_pid"] == pid && s.metadata["agent_fingerprint"] == fp
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def correlate!(session)
|
|
25
|
+
peers(session).each do |peer|
|
|
26
|
+
next if peer.metadata["correlated_session_id"] == session.id
|
|
27
|
+
|
|
28
|
+
peer.update_columns(metadata: peer.metadata.merge("correlated_session_id" => session.id))
|
|
29
|
+
session.update_columns(metadata: session.metadata.merge("correlated_session_id" => peer.id))
|
|
30
|
+
EventStore.append!(type: "agent.session.correlated", aggregate: session, project: session.project, session: session,
|
|
31
|
+
payload: { peer_session_id: peer.id, peer_trust: peer.trust_level, agent_pid: session.metadata["agent_pid"] })
|
|
32
|
+
# Propagate an explicit ticket link to the peer that has none.
|
|
33
|
+
[ [ peer, session ], [ session, peer ] ].each do |src, dst|
|
|
34
|
+
link = src.active_link
|
|
35
|
+
next unless link&.explicit? && dst.active_link.nil?
|
|
36
|
+
|
|
37
|
+
SessionAssociation.link!(session: dst, ticket: link.ticket, source: link.source == "runtime" ? "runtime" : "mcp",
|
|
38
|
+
actor: Authorization.system_actor)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# After an explicit MCP link, pull the correlated hook session along.
|
|
44
|
+
def propagate_link!(session)
|
|
45
|
+
peer_id = session.metadata["correlated_session_id"]
|
|
46
|
+
peer = peer_id && AgentSession.find_by(id: peer_id, state: "active")
|
|
47
|
+
link = session.active_link
|
|
48
|
+
return unless peer && link
|
|
49
|
+
return if peer.active_link&.ticket_id == link.ticket_id
|
|
50
|
+
|
|
51
|
+
SessionAssociation.link!(session: peer, ticket: link.ticket, source: "mcp", actor: Authorization.system_actor)
|
|
52
|
+
rescue Agentboard::Error
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Short-lived local capability credential for MCP sessions (spec §25.4). The MCP process
|
|
3
|
+
# holds the secret in memory; only its digest is stored. Every tool call verifies it, the
|
|
4
|
+
# credential rotates before expiry, and a human can revoke the session at any time.
|
|
5
|
+
module SessionCredential
|
|
6
|
+
TTL = 15.minutes
|
|
7
|
+
ROTATE_BEFORE = 2.minutes
|
|
8
|
+
|
|
9
|
+
Credential = Struct.new(:token, :expires_at, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def issue!(session)
|
|
14
|
+
token = Ids.token
|
|
15
|
+
expires = Time.current + TTL
|
|
16
|
+
session.update_columns(credential_digest: Ids.digest(token),
|
|
17
|
+
metadata: session.metadata.merge("credential_expires_at" => expires.utc.iso8601))
|
|
18
|
+
Credential.new(token: token, expires_at: expires)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the (possibly rotated) credential or raises UNAUTHORIZED.
|
|
22
|
+
def verify!(session, credential)
|
|
23
|
+
session.reload
|
|
24
|
+
raise Error.unauthorized("Session was revoked by a human") if session.state == "revoked"
|
|
25
|
+
raise Error.unauthorized("Session has ended") if session.state == "ended"
|
|
26
|
+
raise Error.unauthorized("Missing session credential") unless credential
|
|
27
|
+
raise Error.unauthorized("Invalid session credential") unless Ids.secure_match?(credential.token, session.credential_digest)
|
|
28
|
+
raise Error.unauthorized("Session credential expired") if credential.expires_at <= Time.current
|
|
29
|
+
|
|
30
|
+
credential.expires_at - Time.current < ROTATE_BEFORE ? issue!(session) : credential
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def revoke!(session, actor:, reason: nil)
|
|
34
|
+
raise Error.forbidden("Only humans can revoke sessions") unless actor.human?
|
|
35
|
+
Authorization.authorize!(actor, "session.manage")
|
|
36
|
+
|
|
37
|
+
session.update_columns(state: "revoked", credential_digest: nil, ended_at: Time.current)
|
|
38
|
+
session.leases.live.update_all(released_at: Time.current, release_reason: "session_revoked", updated_at: Time.current)
|
|
39
|
+
SessionTicketLink.active.where(agent_session_id: session.id).update_all(ended_at: Time.current, updated_at: Time.current)
|
|
40
|
+
EventStore.append!(type: "security.session_revoked", aggregate: session, project: session.project, session: session, actor: actor,
|
|
41
|
+
payload: { reason: reason })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Drafting assistance (spec §10): deterministic suggestions that a human must accept.
|
|
3
|
+
# Nothing here mutates a ticket; accepting goes through the normal Tickets services.
|
|
4
|
+
module TicketAdvisor
|
|
5
|
+
Suggestion = Struct.new(:kind, :text, :value, :reason, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
CRITERIA_TEMPLATES = {
|
|
8
|
+
"bug" => [ "A regression test reproduces the bug and passes with the fix", "Root cause is described in the PR or a comment" ],
|
|
9
|
+
"feature" => [ "Automated tests cover the new behaviour", "User-facing changes are documented" ],
|
|
10
|
+
"task" => [ "The change is covered by existing or new tests" ],
|
|
11
|
+
"chore" => [ "The full test suite passes" ],
|
|
12
|
+
"spike" => [ "Findings and a recommendation are written up as a comment" ]
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def suggest(ticket)
|
|
18
|
+
builder = ContextBuilder.new(ticket: ticket, repository: ticket.project.repository, max_tokens: 2_000, chdir: nil, action: "advise", handoff: nil)
|
|
19
|
+
files = builder.relevant_files(limit: 8)
|
|
20
|
+
suggestions = []
|
|
21
|
+
files.each { |path, score| suggestions << Suggestion.new(kind: "file", text: path, value: path, reason: "keyword match score #{score}") }
|
|
22
|
+
suggestions << complexity(ticket, files)
|
|
23
|
+
existing = ticket.acceptance_criteria.map { |c| c.description.downcase }
|
|
24
|
+
CRITERIA_TEMPLATES.fetch(ticket.kind, []).reject { |c| existing.include?(c.downcase) }.each do |c|
|
|
25
|
+
suggestions << Suggestion.new(kind: "criterion", text: c, value: c, reason: "common for #{ticket.kind} tickets")
|
|
26
|
+
end
|
|
27
|
+
subtasks(ticket).each { |s| suggestions << Suggestion.new(kind: "subtask", text: s, value: s, reason: "listed in the description") }
|
|
28
|
+
dependencies(ticket, builder, files).each { |t, why| suggestions << Suggestion.new(kind: "dependency", text: "#{t.key} — #{t.title}", value: t.key, reason: why) }
|
|
29
|
+
suggestions
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def complexity(ticket, files)
|
|
33
|
+
score = files.size + ticket.description.to_s.scan(/\S+/).size / 80 + ticket.acceptance_criteria.size
|
|
34
|
+
size = score <= 3 ? "S" : (score <= 7 ? "M" : "L")
|
|
35
|
+
Suggestion.new(kind: "complexity", text: size, value: size, reason: "#{files.size} relevant files, #{ticket.acceptance_criteria.size} criteria")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Bullet or numbered lines in the description become subtask candidates.
|
|
39
|
+
def subtasks(ticket)
|
|
40
|
+
existing = ticket.subtasks.pluck(:title).map(&:downcase)
|
|
41
|
+
ticket.description.to_s.lines.map(&:chomp).filter_map { |l| l[/\A\s*(?:[-*•]|\d+[.)])\s+(.{6,140})\z/, 1]&.strip }
|
|
42
|
+
.reject { |s| existing.include?(s.downcase) }.uniq.first(8)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Open tickets touching the same files or sharing many keywords.
|
|
46
|
+
def dependencies(ticket, builder, files)
|
|
47
|
+
mine = builder.ticket_keywords.to_set
|
|
48
|
+
file_set = files.map(&:first).to_set
|
|
49
|
+
candidates = ticket.project.tickets.open.where.not(id: [ ticket.id ] + ticket.depends_on.pluck(:id) + ticket.dependents.pluck(:id)).limit(200)
|
|
50
|
+
candidates.filter_map do |other|
|
|
51
|
+
ob = ContextBuilder.new(ticket: other, repository: ticket.project.repository, max_tokens: 1_000, chdir: nil, action: "advise", handoff: nil)
|
|
52
|
+
overlap = (ob.ticket_keywords.to_set & mine).size
|
|
53
|
+
shared = (ob.relevant_files(limit: 8).map(&:first).to_set & file_set).size
|
|
54
|
+
next unless shared.positive? || overlap >= 4
|
|
55
|
+
|
|
56
|
+
[ other, shared.positive? ? "touches #{shared} of the same files" : "#{overlap} shared keywords" ]
|
|
57
|
+
end.first(3)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Ticket domain operations. Every mutation: authorizes the actor, uses optimistic
|
|
3
|
+
# locking, emits an event in the same transaction, and never trusts agent claims.
|
|
4
|
+
module Tickets
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def create!(project:, actor:, title:, description: "", kind: "task", priority: 2, criteria: [], labels: [],
|
|
8
|
+
parent: nil, milestone: nil, status: "backlog", correlation_id: nil)
|
|
9
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
10
|
+
raise Error.validation_failed("New tickets start in backlog or ready") unless %w[backlog ready].include?(status)
|
|
11
|
+
|
|
12
|
+
Ticket.transaction do
|
|
13
|
+
number = project.next_ticket_number!
|
|
14
|
+
ticket = project.tickets.create!(
|
|
15
|
+
number: number, title: title.to_s.strip, description: description.to_s, kind: kind, priority: priority.to_i,
|
|
16
|
+
labels: Array(labels).map(&:to_s).reject(&:blank?).uniq, parent: parent, milestone: milestone,
|
|
17
|
+
created_by_id: actor.id, status: "backlog", position: Time.current.to_f
|
|
18
|
+
)
|
|
19
|
+
Array(criteria).map(&:to_s).map(&:strip).reject(&:blank?).each do |text|
|
|
20
|
+
ticket.acceptance_criteria.create!(description: text)
|
|
21
|
+
end
|
|
22
|
+
EventStore.append!(type: "ticket.created", aggregate: ticket, ticket: ticket, actor: actor, correlation_id: correlation_id,
|
|
23
|
+
payload: { key: ticket.key, title: ticket.title, kind: kind, priority: ticket.priority })
|
|
24
|
+
transition!(ticket: ticket, to: "ready", actor: actor) if status == "ready"
|
|
25
|
+
ticket
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update!(ticket:, actor:, attributes:, expected_version: nil, correlation_id: nil)
|
|
30
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
31
|
+
allowed = attributes.to_h.symbolize_keys.slice(:title, :description, :kind, :priority, :labels, :milestone_id, :parent_id, :assignee_profile_id)
|
|
32
|
+
allowed[:milestone_id] = allowed[:milestone_id].presence if allowed.key?(:milestone_id)
|
|
33
|
+
if allowed[:milestone_id] && !ticket.project.milestones.exists?(allowed[:milestone_id])
|
|
34
|
+
raise Error.validation_failed("Milestone belongs to another project")
|
|
35
|
+
end
|
|
36
|
+
allowed[:labels] = Array(allowed[:labels]).flat_map { |l| l.to_s.split(",") }.map(&:strip).reject(&:blank?).uniq if allowed.key?(:labels)
|
|
37
|
+
Ticket.transaction do
|
|
38
|
+
ticket.lock_version = expected_version.to_i if expected_version
|
|
39
|
+
before = ticket.attributes.slice(*allowed.keys.map(&:to_s))
|
|
40
|
+
ticket.update!(allowed)
|
|
41
|
+
changes = ticket.saved_changes.except("updated_at", "lock_version")
|
|
42
|
+
if changes.any?
|
|
43
|
+
EventStore.append!(type: "ticket.updated", aggregate: ticket, ticket: ticket, actor: actor, correlation_id: correlation_id,
|
|
44
|
+
payload: { changes: changes.transform_values { |(a, b)| { from: a, to: b } }, before: before.keys })
|
|
45
|
+
end
|
|
46
|
+
ticket
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Moves a ticket through the state machine (spec §24.1) enforcing guards.
|
|
51
|
+
#
|
|
52
|
+
# options:
|
|
53
|
+
# reason: required for blocked / cancelled / reopened / criteria override
|
|
54
|
+
# lease_token: required when an agent actor mutates a claimed ticket
|
|
55
|
+
# session: trusted AgentSession for agent actors
|
|
56
|
+
# override_criteria: human-only, backlog -> ready without criteria
|
|
57
|
+
# verification_requested: in_progress -> testing without an implementation artifact
|
|
58
|
+
# head_sha: commit under review for testing -> review
|
|
59
|
+
def transition!(ticket:, to:, actor:, reason: nil, lease_token: nil, session: nil, expected_version: nil,
|
|
60
|
+
override_criteria: false, verification_requested: false, head_sha: nil, correlation_id: nil)
|
|
61
|
+
to = to.to_s
|
|
62
|
+
Authorization.authorize!(actor, permission_for(to))
|
|
63
|
+
if actor.agent? && Authorization::HUMAN_ONLY_TRANSITIONS.include?(to)
|
|
64
|
+
raise Error.forbidden("Agents cannot move tickets to #{to}; a human must do this")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ready -> claimed always goes through the lease service.
|
|
68
|
+
if to == "claimed"
|
|
69
|
+
claim = LeaseService.claim!(ticket: ticket, actor: actor, session: session, expected_version: expected_version, correlation_id: correlation_id)
|
|
70
|
+
return ticket.reload.tap { |t| t.instance_variable_set(:@issued_lease, claim) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Ticket.transaction do
|
|
74
|
+
ticket.lock!
|
|
75
|
+
if expected_version && ticket.lock_version != expected_version.to_i
|
|
76
|
+
raise Error.version_conflict("Ticket #{ticket.key} changed (version #{ticket.lock_version})", current_version: ticket.lock_version)
|
|
77
|
+
end
|
|
78
|
+
from = ticket.status
|
|
79
|
+
return ticket if from == to
|
|
80
|
+
|
|
81
|
+
StateMachines::Ticket.assert!(from, to)
|
|
82
|
+
lease = enforce_lease!(ticket, actor, lease_token, session)
|
|
83
|
+
guard!(ticket, from, to, actor: actor, reason: reason, override_criteria: override_criteria,
|
|
84
|
+
verification_requested: verification_requested, head_sha: head_sha, lease: lease)
|
|
85
|
+
|
|
86
|
+
attrs = { status: to }
|
|
87
|
+
attrs[:blocked_reason] = (to == "blocked" ? reason : nil)
|
|
88
|
+
attrs[:completed_at] = (to == "done" ? Time.current : nil)
|
|
89
|
+
ticket.update!(attrs)
|
|
90
|
+
|
|
91
|
+
if %w[ready done cancelled backlog].include?(to)
|
|
92
|
+
LeaseService.release_all!(ticket: ticket, reason: "ticket_#{to}")
|
|
93
|
+
end
|
|
94
|
+
if %w[done cancelled].include?(to)
|
|
95
|
+
SessionTicketLink.active.where(ticket_id: ticket.id).update_all(ended_at: Time.current, updated_at: Time.current)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
EventStore.append!(type: "ticket.transitioned", aggregate: ticket, ticket: ticket, actor: actor, session: session,
|
|
99
|
+
correlation_id: correlation_id,
|
|
100
|
+
payload: { from: from, to: to, reason: reason, head_sha: head_sha, override_criteria: override_criteria.presence }.compact)
|
|
101
|
+
ticket
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def permission_for(to)
|
|
106
|
+
case to
|
|
107
|
+
when "done" then "ticket.transition" # gated by CompletionPolicy
|
|
108
|
+
when "reopened" then "ticket.reopen"
|
|
109
|
+
when "cancelled" then "ticket.cancel"
|
|
110
|
+
when "claimed" then "ticket.claim"
|
|
111
|
+
else "ticket.transition"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def enforce_lease!(ticket, actor, token, session)
|
|
116
|
+
live = ticket.leases.live.where("expires_at > ?", Time.current).first
|
|
117
|
+
if actor.agent?
|
|
118
|
+
# Agents may only mutate tickets they hold a valid, current lease for.
|
|
119
|
+
return LeaseService.validate!(ticket: ticket, token: token, session: session)
|
|
120
|
+
end
|
|
121
|
+
live
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def guard!(ticket, from, to, actor:, reason:, override_criteria:, verification_requested:, head_sha:, lease:)
|
|
125
|
+
case [ from, to ]
|
|
126
|
+
in [ "backlog", "ready" ]
|
|
127
|
+
if ticket.acceptance_criteria.none?
|
|
128
|
+
unless override_criteria && actor.human? && reason.present?
|
|
129
|
+
raise Error.policy_denied("Add acceptance criteria before marking #{ticket.key} ready (or override with a reason)")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
in [ "claimed", "in_progress" ]
|
|
133
|
+
if actor.agent?
|
|
134
|
+
link = SessionTicketLink.active.find_by(ticket_id: ticket.id, agent_session_id: lease&.agent_session_id)
|
|
135
|
+
raise Error.policy_denied("Session must be bound to the ticket (start_work) before work begins") unless link
|
|
136
|
+
elsif lease.nil?
|
|
137
|
+
raise Error.policy_denied("#{ticket.key} has no active lease; claim it first")
|
|
138
|
+
end
|
|
139
|
+
in [ "in_progress", "testing" ]
|
|
140
|
+
has_impl = ticket.artifacts.where(kind: %w[commit diff pull_request]).exists? || ticket.executions.where.not(head_sha: nil).exists?
|
|
141
|
+
unless has_impl || verification_requested
|
|
142
|
+
raise Error.policy_denied("Attach an implementation artifact (commit/diff) or explicitly request verification")
|
|
143
|
+
end
|
|
144
|
+
in [ "testing", "review" ]
|
|
145
|
+
report = CompletionPolicy.checks_report(ticket, head_sha: head_sha || Evidence.head_sha(ticket))
|
|
146
|
+
raise Error.policy_denied("Required checks have not passed for the current head: #{report[:missing].join("; ")}", **report) unless report[:ok]
|
|
147
|
+
in [ "review", "done" ]
|
|
148
|
+
report = CompletionPolicy.evaluate(ticket, actor: actor)
|
|
149
|
+
raise Error.policy_denied("Completion policy not satisfied: #{report[:missing].join("; ")}", missing: report[:missing]) unless report[:ok]
|
|
150
|
+
in [ _, "blocked" ]
|
|
151
|
+
raise Error.validation_failed("A blocker reason is required") if reason.blank?
|
|
152
|
+
in [ "blocked", "ready" ]
|
|
153
|
+
raise Error.policy_denied("Dependencies are not finished: #{ticket.unsatisfied_dependencies.map(&:key).join(", ")}") unless ticket.dependencies_satisfied?
|
|
154
|
+
in [ "done", "reopened" ] | [ _, "cancelled" ]
|
|
155
|
+
raise Error.validation_failed("A reason is required") if reason.blank?
|
|
156
|
+
else
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def add_comment!(ticket:, actor:, body:, visibility: "team", correlation_id: nil)
|
|
162
|
+
Authorization.authorize!(actor, "ticket.comment")
|
|
163
|
+
redacted, count = Redactor.redact_with_count(body.to_s)
|
|
164
|
+
Comment.transaction do
|
|
165
|
+
comment = ticket.comments.create!(body: redacted, actor_type: actor.type, actor_id: actor.id, actor_name: actor.label, visibility: visibility)
|
|
166
|
+
EventStore.append!(type: "ticket.commented", aggregate: ticket, ticket: ticket, actor: actor, correlation_id: correlation_id,
|
|
167
|
+
payload: { comment_id: comment.id, excerpt: redacted.truncate(160) })
|
|
168
|
+
if count.positive?
|
|
169
|
+
EventStore.append!(type: "security.secret_redacted", aggregate: ticket, ticket: ticket, actor: actor,
|
|
170
|
+
payload: { where: "comment", redactions: count })
|
|
171
|
+
end
|
|
172
|
+
comment
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def add_criterion!(ticket:, actor:, description:, verification_kind: "manual")
|
|
177
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
178
|
+
AcceptanceCriterion.transaction do
|
|
179
|
+
c = ticket.acceptance_criteria.create!(description: description, verification_kind: verification_kind)
|
|
180
|
+
EventStore.append!(type: "ticket.criterion_added", aggregate: ticket, ticket: ticket, actor: actor, payload: { criterion_id: c.id, description: description })
|
|
181
|
+
c
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Humans verify/waive criteria; agents may only *propose* via comments.
|
|
186
|
+
def set_criterion_state!(criterion:, actor:, state:, evidence_id: nil)
|
|
187
|
+
raise Error.forbidden("Only humans can verify acceptance criteria") unless actor.human? || actor.system?
|
|
188
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
189
|
+
raise Error.validation_failed("Unknown criterion state") unless AcceptanceCriterion::STATES.include?(state)
|
|
190
|
+
|
|
191
|
+
ticket = criterion.ticket
|
|
192
|
+
if evidence_id.present? && !Artifact.where(id: evidence_id, ticket_id: ticket.id).exists? && !VerificationResult.where(id: evidence_id).exists?
|
|
193
|
+
raise Error.validation_failed("Evidence #{evidence_id} is not linked to #{ticket.key}")
|
|
194
|
+
end
|
|
195
|
+
AcceptanceCriterion.transaction do
|
|
196
|
+
criterion.update!(state: state, evidence_id: evidence_id, verified_by: actor.label, verified_at: (state == "pending" ? nil : Time.current))
|
|
197
|
+
EventStore.append!(type: "ticket.criterion_#{state}", aggregate: ticket, ticket: ticket, actor: actor,
|
|
198
|
+
payload: { criterion_id: criterion.id, evidence_id: evidence_id })
|
|
199
|
+
criterion
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def add_dependency!(ticket:, depends_on:, actor:, kind: "blocks")
|
|
204
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
205
|
+
TicketDependency.transaction do
|
|
206
|
+
dep = ticket.dependencies.create!(depends_on_ticket: depends_on, kind: kind)
|
|
207
|
+
EventStore.append!(type: "ticket.dependency_added", aggregate: ticket, ticket: ticket, actor: actor,
|
|
208
|
+
payload: { depends_on: depends_on.key, kind: kind })
|
|
209
|
+
dep
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def reorder!(ticket:, actor:, position:)
|
|
214
|
+
Authorization.authorize!(actor, "ticket.write")
|
|
215
|
+
ticket.update_columns(position: position.to_f, updated_at: Time.current)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Runs catalog commands and persists results bound to the tested commit (spec §29.1).
|
|
3
|
+
module VerificationService
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def run_catalog!(execution:, chdir:, names: nil, cancel_check: nil, timeout: nil)
|
|
7
|
+
catalog = Agentboard.config.verification_catalog
|
|
8
|
+
runner = Verification::Runner.new(catalog: catalog)
|
|
9
|
+
(names || catalog.names).map do |name|
|
|
10
|
+
cmd = catalog.fetch(name)
|
|
11
|
+
record = VerificationResult.create!(execution: execution, ticket_id: execution.ticket_id, name: name, status: "running",
|
|
12
|
+
required: cmd.required, command_digest: cmd.digest,
|
|
13
|
+
commit_sha: Git::Repository.new(chdir).head_sha(chdir: chdir), started_at: Time.current)
|
|
14
|
+
EventStore.append!(type: "verification.started", aggregate: execution, ticket: execution.ticket, execution: execution,
|
|
15
|
+
payload: { name: name, result_id: record.id, commit_sha: record.commit_sha })
|
|
16
|
+
outcome = runner.run(name, chdir: chdir, cancel_check: cancel_check, timeout: timeout)
|
|
17
|
+
log = write_log(execution, name, outcome.output)
|
|
18
|
+
record.update!(status: outcome.status, exit_code: outcome.exit_code, commit_sha: outcome.commit_sha,
|
|
19
|
+
output_excerpt: outcome.output.to_s.last(4000), duration_seconds: outcome.duration,
|
|
20
|
+
finished_at: Time.current, log_artifact_id: log&.id)
|
|
21
|
+
EventStore.append!(type: "verification.#{outcome.status == "passed" ? "passed" : "failed"}", aggregate: execution,
|
|
22
|
+
ticket: execution.ticket, execution: execution,
|
|
23
|
+
payload: { name: name, result_id: record.id, status: outcome.status, exit_code: outcome.exit_code,
|
|
24
|
+
commit_sha: outcome.commit_sha, duration: outcome.duration.round(2) })
|
|
25
|
+
record
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Passive/manual flow: verify the project's checked-out HEAD (e.g. after a developer's
|
|
30
|
+
# own Claude/Codex session). Creates a `verify` execution so results have an owner.
|
|
31
|
+
def verify_repository!(ticket:, actor:, names: nil)
|
|
32
|
+
Authorization.authorize!(actor, "execution.start")
|
|
33
|
+
root = ticket.project.root_path
|
|
34
|
+
repo = Git::Repository.new(root)
|
|
35
|
+
head = repo.head_sha
|
|
36
|
+
execution = Execution.create!(ticket: ticket, provider_key: "local", action: "verify", status: "verifying",
|
|
37
|
+
attempt: Execution.next_attempt_for(ticket), base_sha: head, head_sha: head,
|
|
38
|
+
worktree_path: root, started_at: Time.current,
|
|
39
|
+
metadata: { "requested_by" => actor.label, "dirty" => !repo.clean? })
|
|
40
|
+
EventStore.append!(type: "verification.requested", aggregate: execution, ticket: ticket, execution: execution, actor: actor,
|
|
41
|
+
payload: { head_sha: head, dirty: !repo.clean? })
|
|
42
|
+
results = run_catalog!(execution: execution, chdir: root, names: names)
|
|
43
|
+
required_ok = results.select(&:required).all?(&:passed?)
|
|
44
|
+
Executions.transition!(execution, required_ok ? "completed" : "failed", failure_code: (required_ok ? nil : "verification_failed"))
|
|
45
|
+
results
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def write_log(execution, name, output)
|
|
49
|
+
dir = Agentboard.config.root_path.join(".agentboard", "logs", execution.id)
|
|
50
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
51
|
+
path = dir.join("verify-#{name}.log")
|
|
52
|
+
File.write(path, output.to_s)
|
|
53
|
+
Artifact.create!(ticket: execution.ticket, execution: execution, kind: "test_report", uri: "file:#{path}",
|
|
54
|
+
title: "#{name} output", sha256: Digest::SHA256.hexdigest(output.to_s), provenance: "confirmed",
|
|
55
|
+
metadata: { name: name })
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|