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,144 @@
1
+ # AgentBoard core schema (spec §23.1).
2
+ #
3
+ # Primary keys are application-generated UUIDv7 strings so the same schema works on
4
+ # PostgreSQL and SQLite without extensions. Every table is prefixed `agentboard_`.
5
+ class CreateAgentboardCore < ActiveRecord::Migration[7.2]
6
+ def change
7
+ create_table :agentboard_projects, id: :string, limit: 36 do |t|
8
+ t.string :key, null: false, limit: 16
9
+ t.string :name, null: false
10
+ t.string :root_path, null: false
11
+ t.string :repository_url
12
+ t.string :default_branch, null: false, default: "main"
13
+ t.json :settings, null: false, default: {}
14
+ t.integer :ticket_counter, null: false, default: 0
15
+ t.timestamps
16
+ end
17
+ add_index :agentboard_projects, :key, unique: true
18
+ add_index :agentboard_projects, :root_path, unique: true
19
+
20
+ create_table :agentboard_milestones, id: :string, limit: 36 do |t|
21
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
22
+ t.string :name, null: false
23
+ t.date :target_date
24
+ t.string :status, null: false, default: "open"
25
+ t.timestamps
26
+ end
27
+
28
+ create_table :agentboard_tickets, id: :string, limit: 36 do |t|
29
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
30
+ t.references :milestone, type: :string, foreign_key: { to_table: :agentboard_milestones }
31
+ t.references :parent, type: :string, foreign_key: { to_table: :agentboard_tickets }
32
+ t.integer :number, null: false
33
+ t.string :title, null: false, limit: 255
34
+ t.text :description, null: false, default: ""
35
+ t.string :kind, null: false, default: "task"
36
+ t.string :status, null: false, default: "backlog"
37
+ t.integer :priority, null: false, default: 2
38
+ t.float :position, null: false, default: 0
39
+ t.json :labels, null: false, default: []
40
+ t.string :assignee_profile_id, limit: 36
41
+ t.string :created_by_id
42
+ t.text :blocked_reason
43
+ t.bigint :fencing_counter, null: false, default: 0
44
+ t.integer :lock_version, null: false, default: 0
45
+ t.datetime :completed_at
46
+ t.timestamps
47
+ end
48
+ add_index :agentboard_tickets, [ :project_id, :number ], unique: true
49
+ add_index :agentboard_tickets, [ :project_id, :status, :priority ]
50
+
51
+ create_table :agentboard_ticket_dependencies, id: :string, limit: 36 do |t|
52
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
53
+ t.references :depends_on_ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
54
+ t.string :kind, null: false, default: "blocks"
55
+ t.timestamps
56
+ end
57
+ add_index :agentboard_ticket_dependencies, [ :ticket_id, :depends_on_ticket_id, :kind ], unique: true, name: "index_agentboard_ticket_deps_unique"
58
+ add_check_constraint :agentboard_ticket_dependencies, "ticket_id <> depends_on_ticket_id", name: "agentboard_no_self_dependency"
59
+
60
+ create_table :agentboard_acceptance_criteria, id: :string, limit: 36 do |t|
61
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
62
+ t.integer :position, null: false
63
+ t.text :description, null: false
64
+ t.string :verification_kind, null: false, default: "manual"
65
+ t.string :state, null: false, default: "pending"
66
+ t.string :evidence_id, limit: 36
67
+ t.string :verified_by
68
+ t.datetime :verified_at
69
+ t.timestamps
70
+ end
71
+ add_index :agentboard_acceptance_criteria, [ :ticket_id, :position ], unique: true
72
+
73
+ create_table :agentboard_comments, id: :string, limit: 36 do |t|
74
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
75
+ t.string :actor_type, null: false
76
+ t.string :actor_id, null: false
77
+ t.string :actor_name
78
+ t.string :visibility, null: false, default: "team"
79
+ t.text :body, null: false
80
+ t.timestamps
81
+ end
82
+ add_index :agentboard_comments, [ :ticket_id, :created_at ]
83
+
84
+ create_table :agentboard_agent_profiles, id: :string, limit: 36 do |t|
85
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
86
+ t.string :provider_key, null: false
87
+ t.string :name, null: false
88
+ t.string :model
89
+ t.json :config, null: false, default: {}
90
+ t.json :permissions, null: false, default: {}
91
+ t.boolean :enabled, null: false, default: true
92
+ t.timestamps
93
+ end
94
+ add_index :agentboard_agent_profiles, [ :project_id, :name ], unique: true
95
+
96
+ create_table :agentboard_agent_sessions, id: :string, limit: 36 do |t|
97
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
98
+ t.string :provider_key, null: false
99
+ t.string :external_session_id
100
+ t.string :state, null: false, default: "active"
101
+ t.string :trust_level, null: false, default: "untrusted"
102
+ t.string :label
103
+ t.string :credential_digest
104
+ t.string :cwd
105
+ t.json :metadata, null: false, default: {}
106
+ t.datetime :last_seen_at
107
+ t.datetime :ended_at
108
+ t.timestamps
109
+ end
110
+ add_index :agentboard_agent_sessions, [ :project_id, :provider_key, :external_session_id ], unique: true,
111
+ where: "external_session_id IS NOT NULL", name: "index_agentboard_sessions_external_unique"
112
+ add_index :agentboard_agent_sessions, :credential_digest, unique: true, where: "credential_digest IS NOT NULL"
113
+
114
+ create_table :agentboard_session_ticket_links, id: :string, limit: 36 do |t|
115
+ t.references :agent_session, null: false, type: :string, foreign_key: { to_table: :agentboard_agent_sessions }
116
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
117
+ t.datetime :started_at, null: false
118
+ t.datetime :ended_at
119
+ t.string :source, null: false
120
+ t.string :confidence, null: false, default: "confirmed"
121
+ t.timestamps
122
+ end
123
+ add_index :agentboard_session_ticket_links, [ :agent_session_id, :ended_at ]
124
+ add_index :agentboard_session_ticket_links, :agent_session_id, unique: true, where: "ended_at IS NULL",
125
+ name: "index_agentboard_session_links_one_active"
126
+
127
+ create_table :agentboard_ticket_leases, id: :string, limit: 36 do |t|
128
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
129
+ t.references :agent_session, type: :string, foreign_key: { to_table: :agentboard_agent_sessions }
130
+ t.string :holder_label
131
+ t.string :token_digest, null: false
132
+ t.bigint :fencing_token, null: false
133
+ t.datetime :expires_at, null: false
134
+ t.datetime :released_at
135
+ t.string :release_reason
136
+ t.timestamps
137
+ end
138
+ # Database-enforced exclusivity: at most one unreleased lease per ticket.
139
+ add_index :agentboard_ticket_leases, :ticket_id, unique: true, where: "released_at IS NULL",
140
+ name: "index_agentboard_leases_one_live_per_ticket"
141
+ add_index :agentboard_ticket_leases, :expires_at
142
+ add_index :agentboard_ticket_leases, :token_digest, unique: true
143
+ end
144
+ end
@@ -0,0 +1,213 @@
1
+ # Executions, workflows, verification, approvals and evidence (spec §23.1).
2
+ class CreateAgentboardRuntime < ActiveRecord::Migration[7.2]
3
+ def change
4
+ create_table :agentboard_workflow_definitions, id: :string, limit: 36 do |t|
5
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
6
+ t.string :name, null: false
7
+ t.integer :version, null: false
8
+ t.json :definition, null: false
9
+ t.text :source_yaml
10
+ t.string :digest, null: false
11
+ t.timestamps
12
+ end
13
+ add_index :agentboard_workflow_definitions, [ :project_id, :name, :version ], unique: true, name: "index_agentboard_workflow_defs_unique"
14
+ add_index :agentboard_workflow_definitions, [ :project_id, :digest ]
15
+
16
+ create_table :agentboard_workflow_runs, id: :string, limit: 36 do |t|
17
+ t.references :workflow_definition, null: false, type: :string, foreign_key: { to_table: :agentboard_workflow_definitions }
18
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
19
+ t.string :status, null: false, default: "pending"
20
+ t.string :base_sha
21
+ t.string :worktree_path
22
+ t.string :branch
23
+ t.string :cancel_reason
24
+ t.datetime :started_at
25
+ t.datetime :finished_at
26
+ t.integer :lock_version, null: false, default: 0
27
+ t.timestamps
28
+ end
29
+ add_index :agentboard_workflow_runs, :status
30
+
31
+ create_table :agentboard_workflow_steps, id: :string, limit: 36 do |t|
32
+ t.references :workflow_run, null: false, type: :string, foreign_key: { to_table: :agentboard_workflow_runs }
33
+ t.string :step_key, null: false
34
+ t.string :step_type, null: false
35
+ t.string :status, null: false, default: "pending"
36
+ t.integer :attempt, null: false, default: 1
37
+ t.json :input, null: false, default: {}
38
+ t.json :output, null: false, default: {}
39
+ t.text :error
40
+ t.datetime :available_at
41
+ t.datetime :started_at
42
+ t.datetime :finished_at
43
+ t.timestamps
44
+ end
45
+ add_index :agentboard_workflow_steps, [ :workflow_run_id, :step_key, :attempt ], unique: true, name: "index_agentboard_workflow_steps_unique"
46
+
47
+ create_table :agentboard_executions, id: :string, limit: 36 do |t|
48
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
49
+ t.references :agent_profile, type: :string, foreign_key: { to_table: :agentboard_agent_profiles }
50
+ t.references :agent_session, type: :string, foreign_key: { to_table: :agentboard_agent_sessions }
51
+ t.references :workflow_step, type: :string, foreign_key: { to_table: :agentboard_workflow_steps }
52
+ t.string :provider_key, null: false
53
+ t.string :action, null: false, default: "implement"
54
+ t.string :status, null: false, default: "queued"
55
+ t.integer :attempt, null: false, default: 1
56
+ t.string :base_sha
57
+ t.string :head_sha
58
+ t.string :branch
59
+ t.string :worktree_path
60
+ t.string :context_path
61
+ t.string :context_digest
62
+ t.string :log_path
63
+ t.integer :pid
64
+ t.integer :pgid
65
+ t.string :process_fingerprint
66
+ t.string :idempotency_key
67
+ t.integer :exit_code
68
+ t.string :failure_code
69
+ t.text :failure_message
70
+ t.text :summary
71
+ t.string :cancel_reason
72
+ t.datetime :cancel_requested_at
73
+ t.datetime :heartbeat_at
74
+ t.datetime :started_at
75
+ t.datetime :finished_at
76
+ t.bigint :log_bytes, null: false, default: 0
77
+ t.boolean :log_truncated, null: false, default: false
78
+ t.json :metadata, null: false, default: {}
79
+ t.integer :lock_version, null: false, default: 0
80
+ t.timestamps
81
+ end
82
+ add_index :agentboard_executions, [ :ticket_id, :created_at ]
83
+ add_index :agentboard_executions, [ :status, :created_at ]
84
+ add_index :agentboard_executions, [ :ticket_id, :attempt ], unique: true
85
+ add_index :agentboard_executions, :idempotency_key, unique: true, where: "idempotency_key IS NOT NULL"
86
+
87
+ create_table :agentboard_events, id: :string, limit: 36 do |t|
88
+ t.string :project_id, limit: 36
89
+ t.string :aggregate_type, null: false
90
+ t.string :aggregate_id, null: false
91
+ t.bigint :sequence, null: false
92
+ t.string :event_type, null: false
93
+ t.integer :schema_version, null: false, default: 1
94
+ t.string :source, null: false
95
+ t.string :provenance, null: false, default: "confirmed"
96
+ t.string :ticket_id, limit: 36
97
+ t.string :execution_id, limit: 36
98
+ t.string :session_id, limit: 36
99
+ t.string :actor_type
100
+ t.string :actor_id
101
+ t.string :dedupe_key
102
+ t.datetime :occurred_at, null: false
103
+ t.datetime :received_at, null: false
104
+ t.json :payload, null: false, default: {}
105
+ t.string :correlation_id, null: false
106
+ t.string :causation_id
107
+ t.timestamps
108
+ end
109
+ add_index :agentboard_events, [ :aggregate_type, :aggregate_id, :sequence ], unique: true, name: "index_agentboard_events_aggregate_seq"
110
+ add_index :agentboard_events, [ :project_id, :received_at ]
111
+ add_index :agentboard_events, :correlation_id
112
+ add_index :agentboard_events, [ :ticket_id, :occurred_at ]
113
+ add_index :agentboard_events, [ :execution_id, :sequence ]
114
+ add_index :agentboard_events, :dedupe_key, unique: true, where: "dedupe_key IS NOT NULL"
115
+
116
+ create_table :agentboard_outbox_messages, id: :string, limit: 36 do |t|
117
+ t.string :event_id, null: false, limit: 36
118
+ t.string :topic, null: false
119
+ t.string :state, null: false, default: "pending"
120
+ t.integer :attempts, null: false, default: 0
121
+ t.datetime :available_at, null: false
122
+ t.datetime :delivered_at
123
+ t.text :last_error
124
+ t.timestamps
125
+ end
126
+ add_index :agentboard_outbox_messages, [ :event_id, :topic ], unique: true
127
+ add_index :agentboard_outbox_messages, [ :state, :available_at ]
128
+
129
+ create_table :agentboard_artifacts, id: :string, limit: 36 do |t|
130
+ t.references :ticket, null: false, type: :string, foreign_key: { to_table: :agentboard_tickets }
131
+ t.references :execution, type: :string, foreign_key: { to_table: :agentboard_executions }
132
+ t.string :kind, null: false
133
+ t.string :uri, null: false
134
+ t.string :title
135
+ t.string :sha256
136
+ t.string :commit_sha
137
+ t.string :provenance, null: false, default: "confirmed"
138
+ t.json :metadata, null: false, default: {}
139
+ t.timestamps
140
+ end
141
+
142
+ create_table :agentboard_verification_results, id: :string, limit: 36 do |t|
143
+ t.references :execution, null: false, type: :string, foreign_key: { to_table: :agentboard_executions }
144
+ t.string :ticket_id, limit: 36
145
+ t.string :name, null: false
146
+ t.string :status, null: false, default: "running"
147
+ t.boolean :required, null: false, default: true
148
+ t.string :command_digest, null: false
149
+ t.string :commit_sha, null: false
150
+ t.integer :exit_code
151
+ t.string :log_artifact_id, limit: 36
152
+ t.text :output_excerpt
153
+ t.float :duration_seconds
154
+ t.datetime :started_at, null: false
155
+ t.datetime :finished_at
156
+ t.timestamps
157
+ end
158
+ add_index :agentboard_verification_results, [ :execution_id, :commit_sha ]
159
+ add_index :agentboard_verification_results, [ :ticket_id, :commit_sha ]
160
+
161
+ create_table :agentboard_approvals, id: :string, limit: 36 do |t|
162
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
163
+ t.string :ticket_id, limit: 36
164
+ t.references :execution, type: :string, foreign_key: { to_table: :agentboard_executions }
165
+ t.string :workflow_step_id, limit: 36
166
+ t.string :action_kind, null: false
167
+ t.string :action_digest, null: false
168
+ t.string :head_sha
169
+ t.json :action_payload, null: false, default: {}
170
+ t.text :reason
171
+ t.string :required_role, null: false, default: "maintainer"
172
+ t.string :requested_by, null: false
173
+ t.string :decision, null: false, default: "pending"
174
+ t.string :decided_by
175
+ t.text :decision_reason
176
+ t.datetime :expires_at
177
+ t.datetime :decided_at
178
+ t.timestamps
179
+ end
180
+ add_index :agentboard_approvals, [ :decision, :expires_at ]
181
+ add_index :agentboard_approvals, :ticket_id
182
+
183
+ create_table :agentboard_usage_records, id: :string, limit: 36 do |t|
184
+ t.references :execution, null: false, type: :string, foreign_key: { to_table: :agentboard_executions }
185
+ t.string :provider_key, null: false
186
+ t.string :metering_kind, null: false, default: "unknown"
187
+ t.bigint :input_tokens
188
+ t.bigint :output_tokens
189
+ t.decimal :reported_cost, precision: 12, scale: 6
190
+ t.string :currency
191
+ t.string :source, null: false
192
+ t.timestamps
193
+ end
194
+
195
+ create_table :agentboard_idempotency_keys, id: :string, limit: 36 do |t|
196
+ t.string :scope, null: false
197
+ t.string :key, null: false
198
+ t.string :request_digest, null: false
199
+ t.json :response, null: false, default: {}
200
+ t.datetime :expires_at, null: false
201
+ t.timestamps
202
+ end
203
+ add_index :agentboard_idempotency_keys, [ :scope, :key ], unique: true
204
+
205
+ create_table :agentboard_repository_snapshots, id: :string, limit: 36 do |t|
206
+ t.references :project, null: false, type: :string, foreign_key: { to_table: :agentboard_projects }
207
+ t.string :commit_sha, null: false
208
+ t.json :metadata, null: false, default: {}
209
+ t.timestamps
210
+ end
211
+ add_index :agentboard_repository_snapshots, [ :project_id, :commit_sha ], unique: true
212
+ end
213
+ end
@@ -0,0 +1,18 @@
1
+ # v0.2: Git provider webhooks (spec §29.3). Deliveries are recorded for idempotency and audit,
2
+ # including rejected (forged) ones. Raw payloads are never stored — only a digest.
3
+ class CreateAgentboardWebhookDeliveries < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :agentboard_webhook_deliveries, id: :string, limit: 36 do |t|
6
+ t.string :provider, null: false
7
+ t.string :delivery_id, null: false
8
+ t.string :event, null: false
9
+ t.boolean :signature_valid, null: false, default: false
10
+ t.string :payload_digest, null: false
11
+ t.string :status, null: false, default: "received"
12
+ t.text :detail
13
+ t.timestamps
14
+ end
15
+ add_index :agentboard_webhook_deliveries, [ :provider, :delivery_id ], unique: true, name: "index_agentboard_webhook_deliveries_unique"
16
+ add_index :agentboard_webhook_deliveries, :created_at
17
+ end
18
+ end
data/exe/agentboard ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+ require "agentboard/cli"
7
+ Agentboard::CLI.start(ARGV)
@@ -0,0 +1,124 @@
1
+ require "agentboard/container_sandbox"
2
+
3
+ module Agentboard
4
+ # Runs provider agents inside OCI containers (security.sandbox: container, sandbox.agents: true).
5
+ #
6
+ # - The agent container is attached only to an *internal* network (no direct internet).
7
+ # Its single route out is the egress proxy container, which allowlists provider APIs.
8
+ # - Mounts: the execution worktree and the repository's git directory, at their real host
9
+ # paths (so `git commit` works). Nothing else from the host is visible.
10
+ # - Runs as the invoking UID, all capabilities dropped, no-new-privileges, CPU/memory/PID
11
+ # limits, read-only root filesystem with a tmpfs home.
12
+ # - Credentials: only allowlisted variable *names* are forwarded (`-e NAME`), so values
13
+ # never appear in process listings.
14
+ module AgentContainer
15
+ DEFAULT_EGRESS = {
16
+ "claude" => %w[api.anthropic.com:443 statsig.anthropic.com:443 sentry.io:443],
17
+ "codex" => %w[api.openai.com:443 chatgpt.com:443 auth.openai.com:443],
18
+ "fake" => []
19
+ }.freeze
20
+ PROXY_ALIAS = "agentboard-egress".freeze
21
+ PROXY_PORT = 3128
22
+
23
+ module_function
24
+
25
+ def settings = ContainerSandbox.settings
26
+ def runtime = settings["runtime"]
27
+ def enabled? = ContainerSandbox.enabled? && settings["agents"] == true
28
+
29
+ def image
30
+ settings["agent_image"].presence or raise Error.validation_failed("sandbox.agent_image must be set to run agents in containers (see `agentboard sandbox build`)")
31
+ end
32
+
33
+ def project_id = Digest::SHA256.hexdigest(Agentboard.config.root_path.to_s)[0, 10]
34
+ def network_name = "agentboard-#{project_id}-internal"
35
+ def proxy_name = "agentboard-#{project_id}-egress"
36
+
37
+ def egress_allowlist
38
+ (DEFAULT_EGRESS.values.flatten + Array(settings["egress_allow"])).map(&:to_s).uniq.sort
39
+ end
40
+
41
+ # Builds the docker argv that wraps a provider LaunchSpec.
42
+ def wrap(spec, execution:, name:)
43
+ worktree = File.realpath(spec.chdir.to_s)
44
+ git_common = git_common_dir(worktree)
45
+ argv = [ runtime, "run", "--rm", "-i", "--init", "--name", name,
46
+ "--label", "agentboard.execution=#{execution.id}",
47
+ "--network", network_name,
48
+ "--memory", settings["memory"].to_s, "--cpus", settings["cpus"].to_s, "--pids-limit", settings["pids_limit"].to_i.to_s,
49
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only",
50
+ "--tmpfs", "/tmp:rw,exec,size=1g", "--user", "#{::Process.uid}:#{::Process.gid}",
51
+ "-v", "#{worktree}:#{worktree}:rw", "-v", "#{git_common}:#{git_common}:rw", "-w", worktree,
52
+ "-e", "HOME=/tmp/agent-home", "-e", "AGENTBOARD_EXECUTION_ID=#{execution.id}", "-e", "AGENTBOARD_ISOLATION=container",
53
+ "-e", "HTTPS_PROXY=http://#{PROXY_ALIAS}:#{PROXY_PORT}", "-e", "HTTP_PROXY=http://#{PROXY_ALIAS}:#{PROXY_PORT}",
54
+ "-e", "NO_PROXY=localhost,127.0.0.1" ]
55
+ if (dir = settings["claude_config_dir"].presence)
56
+ FileUtils.mkdir_p(dir, mode: 0o700)
57
+ argv += [ "-v", "#{File.realpath(dir)}:/tmp/agent-home/.claude:rw", "-e", "CLAUDE_CONFIG_DIR=/tmp/agent-home/.claude" ]
58
+ end
59
+ Array(spec.mounts).each { |m| real = File.realpath(m); argv += [ "-v", "#{real}:#{real}:ro" ] }
60
+ spec.env.to_h.each { |k, v| argv += [ "-e", "#{k}=#{v}" ] if k.to_s.match?(/\A[A-Z_][A-Z0-9_]*\z/) && !secret_name?(k) }
61
+ forwarded_credentials(spec).each { |k| argv += [ "-e", k ] }
62
+ cli_env(spec).each_key { |k| argv += [ "-e", k ] unless argv.include?(k) }
63
+ argv + [ image ] + spec.argv
64
+ end
65
+
66
+ # Names only; docker reads the values from its own environment.
67
+ def forwarded_credentials(spec)
68
+ allowed = Array(settings["credential_env"]).map(&:to_s)
69
+ (Array(spec.passthrough_env).map(&:to_s) & (allowed.presence || Array(spec.passthrough_env).map(&:to_s))).select { |k| ENV[k].present? }
70
+ end
71
+
72
+ # Values for aliased credentials, placed in the docker CLI's own environment (never argv).
73
+ def cli_env(spec)
74
+ allowed = Array(settings["credential_env"]).map(&:to_s)
75
+ Array(spec.credential_aliases).each_with_object({}) do |(name, source), env|
76
+ next if allowed.any? && !allowed.include?(source.to_s)
77
+
78
+ env[name.to_s] = ENV[source.to_s] if ENV[source.to_s].present?
79
+ end
80
+ end
81
+
82
+ def secret_name?(name) = name.to_s.match?(/KEY|TOKEN|SECRET|PASSWORD/i)
83
+
84
+ def git_common_dir(worktree)
85
+ out, _e, st = Open3.capture3("git", "rev-parse", "--git-common-dir", chdir: worktree)
86
+ raise Error.workspace_conflict("worktree has no git directory") unless st.success?
87
+
88
+ File.realpath(File.expand_path(out.strip, worktree))
89
+ end
90
+
91
+ # Idempotently creates the internal network and the egress proxy container.
92
+ def ensure_network!
93
+ unless system(runtime, "network", "inspect", network_name, out: File::NULL, err: File::NULL)
94
+ run!("network", "create", "--internal", "--label", "agentboard.project=#{project_id}", network_name)
95
+ end
96
+ allow = egress_allowlist.join(",")
97
+ digest = Digest::SHA256.hexdigest(allow)[0, 12]
98
+ out, _e, st = Open3.capture3(runtime, "inspect", "--format", "{{.State.Running}} {{index .Config.Labels \"agentboard.allow\"}}", proxy_name)
99
+ running, label = out.split
100
+ return if st.success? && running == "true" && label == digest
101
+
102
+ Open3.capture3(runtime, "rm", "-f", proxy_name)
103
+ proxy_script = File.expand_path("egress_proxy.rb", __dir__)
104
+ run!("run", "-d", "--name", proxy_name, "--restart", "unless-stopped", "--label", "agentboard.allow=#{digest}",
105
+ "--network", network_name, "--network-alias", PROXY_ALIAS, "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
106
+ "--read-only", "--memory", "128m", "-e", "EGRESS_ALLOW=#{allow}",
107
+ "-v", "#{proxy_script}:/egress_proxy.rb:ro", settings["proxy_image"].presence || "ruby:3.4-alpine", "ruby", "/egress_proxy.rb", PROXY_PORT.to_s)
108
+ run!("network", "connect", "bridge", proxy_name)
109
+ end
110
+
111
+ def remove(name)
112
+ Open3.capture3(runtime, "rm", "-f", name) if name.present?
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ def run!(*args)
118
+ out, err, st = Open3.capture3(runtime, *args)
119
+ raise Error.provider_unavailable("#{runtime} #{args.first} failed: #{Redactor.redact(err.presence || out)[0, 300]}") unless st.success?
120
+
121
+ out
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,58 @@
1
+ module Agentboard
2
+ # Role-based permissions (spec §14). Agents receive narrowly scoped permissions and
3
+ # can never satisfy human-only guards (approvals, reopen, completion override).
4
+ module Authorization
5
+ ROLES = %w[viewer developer maintainer administrator].freeze
6
+
7
+ PERMISSIONS = {
8
+ "ticket.read" => %w[viewer developer maintainer administrator agent],
9
+ "ticket.write" => %w[developer maintainer administrator],
10
+ "ticket.transition" => %w[developer maintainer administrator agent],
11
+ "ticket.comment" => %w[developer maintainer administrator agent],
12
+ "ticket.claim" => %w[developer maintainer administrator agent],
13
+ "ticket.complete" => %w[maintainer administrator],
14
+ "ticket.reopen" => %w[maintainer administrator],
15
+ "ticket.cancel" => %w[developer maintainer administrator],
16
+ "execution.start" => %w[developer maintainer administrator],
17
+ "execution.cancel" => %w[developer maintainer administrator],
18
+ "approval.request" => %w[developer maintainer administrator agent],
19
+ "approval.decide" => %w[maintainer administrator],
20
+ "workflow.manage" => %w[maintainer administrator],
21
+ "session.manage" => %w[maintainer administrator],
22
+ "settings.manage" => %w[administrator]
23
+ }.freeze
24
+
25
+ # Transitions an agent identity may never perform on its own.
26
+ HUMAN_ONLY_TRANSITIONS = %w[done reopened cancelled backlog].freeze
27
+
28
+ # An actor is anything responding to #role, #type ("human"/"agent"/"system") and #id.
29
+ Actor = Struct.new(:type, :id, :role, :name, keyword_init: true) do
30
+ def agent? = type == "agent"
31
+ def human? = type == "human"
32
+ def system? = type == "system"
33
+ def label = name || "#{type}:#{id}"
34
+ end
35
+
36
+ module_function
37
+
38
+ def system_actor = Actor.new(type: "system", id: "agentboard", role: "administrator", name: "AgentBoard")
39
+ def local_human(name = ENV["USER"] || "local") = Actor.new(type: "human", id: name, role: "administrator", name: name)
40
+
41
+ def agent_actor(session_id, name: nil)
42
+ Actor.new(type: "agent", id: session_id.to_s, role: "agent", name: name || "agent:#{session_id.to_s[0, 8]}")
43
+ end
44
+
45
+ def allowed?(actor, permission)
46
+ return false unless actor
47
+ return true if actor.system?
48
+
49
+ PERMISSIONS.fetch(permission) { raise ArgumentError, "unknown permission #{permission}" }.include?(actor.role.to_s)
50
+ end
51
+
52
+ def authorize!(actor, permission)
53
+ return true if allowed?(actor, permission)
54
+
55
+ raise Error.forbidden("#{actor&.label || "anonymous"} is not allowed to #{permission}", permission: permission)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ module Agentboard
2
+ module Capture
3
+ # Injects the capture widget into the host app's HTML pages (never into AgentBoard's own
4
+ # dashboard) and records which templates rendered the page.
5
+ class Middleware
6
+ MAX_BODY = 5 * 1024 * 1024
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless Capture.enabled_for_env?
14
+ return @app.call(env) if env["PATH_INFO"].to_s.start_with?(Capture.mount_path + "/") || env["PATH_INFO"] == Capture.mount_path
15
+
16
+ Thread.current[:agentboard_capture_collector] = Collector.new
17
+ status, headers, body = @app.call(env)
18
+ inject(env, status, headers, body)
19
+ ensure
20
+ Thread.current[:agentboard_capture_collector] = nil
21
+ end
22
+
23
+ private
24
+
25
+ def inject(env, status, headers, body)
26
+ type = headers["content-type"] || headers["Content-Type"]
27
+ return [ status, headers, body ] unless status == 200 && type.to_s.include?("text/html")
28
+ return [ status, headers, body ] if env["HTTP_TURBO_FRAME"] || headers["content-disposition"].to_s.include?("attachment")
29
+
30
+ html = +""
31
+ body.each { |part| html << part }
32
+ body.close if body.respond_to?(:close)
33
+ index = html.bytesize <= MAX_BODY ? html.rindex("</body>") : nil
34
+ return [ status, headers, [ html ] ] unless index
35
+
36
+ html.insert(index, tag(env))
37
+ headers.delete("content-length")
38
+ headers.delete("Content-Length")
39
+ [ status, headers, [ html ] ]
40
+ end
41
+
42
+ def tag(env)
43
+ params = env["action_dispatch.request.path_parameters"] || {}
44
+ data = {
45
+ endpoint: "#{Capture.mount_path}/captures", dashboard: Capture.mount_path,
46
+ route: [ params[:controller], params[:action] ].compact.join("#").presence,
47
+ templates: Capture.collector&.templates || [], screenshots: Capture.screenshots?, version: Agentboard::VERSION
48
+ }
49
+ src = "#{Capture.mount_path}/assets/capture.js?v=#{AssetsController.fingerprint("capture.js")}"
50
+ %(<script src="#{src}" data-agentboard-capture="#{ERB::Util.html_escape(JSON.generate(data))}" defer></script>)
51
+ end
52
+ end
53
+ end
54
+ end