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