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