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,818 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
# Load Rails' engine base class first so the AgentBoard engine registers itself before the
|
|
3
|
+
# host application boots (the CLI requires agentboard before config/environment.rb).
|
|
4
|
+
require "rails"
|
|
5
|
+
require "rails/engine"
|
|
6
|
+
require "agentboard"
|
|
7
|
+
|
|
8
|
+
module Agentboard
|
|
9
|
+
# Command-line contract (spec §12). All commands are non-interactive, accept --help,
|
|
10
|
+
# and most accept --json for automation.
|
|
11
|
+
#
|
|
12
|
+
# Exit codes:
|
|
13
|
+
# 0 success 4 forbidden / policy denied / unauthorized
|
|
14
|
+
# 1 internal error 5 conflict (version, lease, workspace)
|
|
15
|
+
# 2 usage / validation error 6 provider or capability unavailable
|
|
16
|
+
# 3 not found 7 runtime daemon not running
|
|
17
|
+
module CLIHelpers
|
|
18
|
+
EXIT_CODES = {
|
|
19
|
+
"NOT_FOUND" => 3, "VALIDATION_FAILED" => 2, "INVALID_TRANSITION" => 2, "UNAUTHORIZED" => 4, "FORBIDDEN" => 4,
|
|
20
|
+
"POLICY_DENIED" => 4, "APPROVAL_REQUIRED" => 4, "VERSION_CONFLICT" => 5, "LEASE_CONFLICT" => 5, "LEASE_EXPIRED" => 5,
|
|
21
|
+
"WORKSPACE_CONFLICT" => 5, "CAPABILITY_UNAVAILABLE" => 6, "PROVIDER_UNAVAILABLE" => 6, "RATE_LIMITED" => 5
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.included(base)
|
|
25
|
+
base.class_option :root, type: :string, desc: "Project root (defaults to the Git toplevel of the current directory)"
|
|
26
|
+
# No default: Thor merges parent options with subcommand defaults, which would reset --json.
|
|
27
|
+
base.class_option :json, type: :boolean, desc: "Machine-readable JSON output"
|
|
28
|
+
base.class_eval do
|
|
29
|
+
def self.exit_on_failure? = true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def root
|
|
36
|
+
@root ||= begin
|
|
37
|
+
dir = options[:root] || ENV["AGENTBOARD_ROOT"]
|
|
38
|
+
unless dir
|
|
39
|
+
out, _e, st = Open3.capture3("git", "rev-parse", "--show-toplevel")
|
|
40
|
+
dir = st.success? ? out.strip : Dir.pwd
|
|
41
|
+
# Prefer the nearest Rails app (config/environment.rb) between cwd and the git root.
|
|
42
|
+
cur = Dir.pwd
|
|
43
|
+
while cur.start_with?(dir)
|
|
44
|
+
(dir = cur) && break if File.exist?(File.join(cur, "config", "environment.rb"))
|
|
45
|
+
cur = File.dirname(cur)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
File.expand_path(dir)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Boots the host Rails application. stdout is redirected to stderr while booting so that
|
|
53
|
+
# commands like `mcp serve` keep stdout clean for protocol frames.
|
|
54
|
+
def boot!(quiet_stdout: false)
|
|
55
|
+
return if @booted
|
|
56
|
+
|
|
57
|
+
env_file = File.join(root, "config", "environment.rb")
|
|
58
|
+
raise Error.not_found("No Rails application found at #{root} (config/environment.rb). Use --root.") unless File.exist?(env_file)
|
|
59
|
+
|
|
60
|
+
ENV["RAILS_ENV"] ||= "development"
|
|
61
|
+
original = $stdout
|
|
62
|
+
$stdout = $stderr if quiet_stdout
|
|
63
|
+
Dir.chdir(root) { require env_file }
|
|
64
|
+
Agentboard.config.root = root if Agentboard.config.root.nil?
|
|
65
|
+
@booted = true
|
|
66
|
+
ensure
|
|
67
|
+
$stdout = original if quiet_stdout && original
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def actor = Authorization.local_human
|
|
71
|
+
|
|
72
|
+
def project = @project ||= Project.current(root: Pathname.new(Agentboard.config.root_path))
|
|
73
|
+
|
|
74
|
+
def emit(data, human = nil)
|
|
75
|
+
if options[:json]
|
|
76
|
+
puts JSON.pretty_generate(data.is_a?(Struct) ? data.to_h : data)
|
|
77
|
+
elsif human
|
|
78
|
+
human.respond_to?(:call) ? human.call : puts(human)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def guard
|
|
83
|
+
yield
|
|
84
|
+
rescue Agentboard::Error => e
|
|
85
|
+
if options[:json]
|
|
86
|
+
puts JSON.pretty_generate(Result.failure(e).to_h)
|
|
87
|
+
else
|
|
88
|
+
warn "#{e.code}: #{e.message}"
|
|
89
|
+
Array(e.details[:missing]).each { |m| warn " • #{m}" }
|
|
90
|
+
end
|
|
91
|
+
exit(EXIT_CODES.fetch(e.code, 1))
|
|
92
|
+
rescue Thor::Error
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
warn "INTERNAL_ERROR: #{e.class}: #{Redactor.redact(e.message)}"
|
|
96
|
+
warn e.backtrace.first(5).join("\n") if ENV["AGENTBOARD_DEBUG"]
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ticket!(key) = Ticket.find_by_key!(key.to_s.upcase, project: project)
|
|
101
|
+
|
|
102
|
+
def table(rows, headers)
|
|
103
|
+
widths = headers.each_index.map { |i| ([ headers[i].to_s.size ] + rows.map { |r| r[i].to_s.size }).max.clamp(0, 60) }
|
|
104
|
+
line = ->(cols) { cols.each_with_index.map { |c, i| c.to_s[0, 60].ljust(widths[i]) }.join(" ") }
|
|
105
|
+
puts line.(headers)
|
|
106
|
+
puts widths.map { |w| "─" * w }.join(" ")
|
|
107
|
+
rows.each { |r| puts line.(r) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class DaemonCLI < Thor
|
|
112
|
+
include CLIHelpers
|
|
113
|
+
namespace :daemon
|
|
114
|
+
|
|
115
|
+
desc "start", "Start the runtime daemon (separate from the web server)"
|
|
116
|
+
method_option :foreground, type: :boolean, default: false, aliases: "-f", desc: "Run in the foreground"
|
|
117
|
+
def start
|
|
118
|
+
guard do
|
|
119
|
+
boot!
|
|
120
|
+
status = Runtime::DaemonControl.status
|
|
121
|
+
if status[:running]
|
|
122
|
+
emit({ running: true, pid: status[:pid] }, "AgentBoard daemon already running (pid #{status[:pid]})")
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
if options[:foreground]
|
|
126
|
+
run_foreground
|
|
127
|
+
else
|
|
128
|
+
paths = Runtime::IPC.paths
|
|
129
|
+
Runtime::IPC.ensure_private_dir!(paths[:dir])
|
|
130
|
+
log = File.open(paths[:log], "a", 0o600)
|
|
131
|
+
pid = ::Process.spawn(RbConfig.ruby, File.expand_path("../../exe/agentboard", __dir__), "daemon", "start", "--foreground", "--root", root,
|
|
132
|
+
out: log, err: log, in: File::NULL, pgroup: true, chdir: root)
|
|
133
|
+
::Process.detach(pid)
|
|
134
|
+
deadline = Time.now + 20
|
|
135
|
+
sleep 0.2 until Runtime::DaemonControl.status[:running] || Time.now > deadline
|
|
136
|
+
st = Runtime::DaemonControl.status
|
|
137
|
+
raise Error.provider_unavailable("Daemon failed to start; see #{paths[:log]}") unless st[:running]
|
|
138
|
+
|
|
139
|
+
emit({ running: true, pid: st[:pid], log: paths[:log].to_s }, "AgentBoard daemon started (pid #{st[:pid]}) · logs: #{paths[:log]}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
desc "stop", "Stop the runtime daemon gracefully"
|
|
145
|
+
def stop
|
|
146
|
+
guard do
|
|
147
|
+
boot!
|
|
148
|
+
stopped = Runtime::DaemonControl.stop
|
|
149
|
+
emit({ stopped: stopped }, stopped ? "AgentBoard daemon stopped" : "Daemon was not running")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
desc "restart", "Restart the runtime daemon (running attempts are reconciled as interrupted)"
|
|
154
|
+
def restart
|
|
155
|
+
stop
|
|
156
|
+
@booted = true
|
|
157
|
+
invoke :start, [], options.merge(foreground: false)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
desc "status", "Show runtime daemon status"
|
|
161
|
+
def status
|
|
162
|
+
guard do
|
|
163
|
+
boot!
|
|
164
|
+
st = Runtime::DaemonControl.status
|
|
165
|
+
emit(st, -> {
|
|
166
|
+
puts st[:running] ? "running · pid #{st[:pid]} · active #{st.dig(:health, "active")} · queued #{st.dig(:health, "queued")}" : "not running#{" (stale pid file)" if st[:stale_pid_file]}"
|
|
167
|
+
})
|
|
168
|
+
exit 7 unless st[:running]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def run_foreground
|
|
175
|
+
Extensions.run(:runtime_start)
|
|
176
|
+
lock = Runtime::DaemonControl.write_pid!
|
|
177
|
+
logger = Logger.new($stdout, progname: "agentboard")
|
|
178
|
+
logger.formatter = JsonLogFormatter.new(component: "runtime") unless ENV["AGENTBOARD_LOG_FORMAT"] == "text"
|
|
179
|
+
Agentboard.logger = logger
|
|
180
|
+
supervisor = Runtime::Supervisor.new(logger: logger)
|
|
181
|
+
server = Runtime::IPC::Server.new(handler: Runtime::IpcHandler.new(supervisor), logger: logger).start
|
|
182
|
+
%w[TERM INT].each { |sig| trap(sig) { supervisor.stop! } }
|
|
183
|
+
logger.info("AgentBoard runtime #{Agentboard::VERSION} started (pid #{::Process.pid}, root #{root})")
|
|
184
|
+
HookIngestion.replay_spool!(root: root) rescue nil
|
|
185
|
+
supervisor.run
|
|
186
|
+
ensure
|
|
187
|
+
server&.stop
|
|
188
|
+
if lock
|
|
189
|
+
File.delete(Runtime::IPC.paths[:pid]) rescue nil
|
|
190
|
+
lock.close
|
|
191
|
+
end
|
|
192
|
+
logger&.info("AgentBoard runtime stopped")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
class ProviderCLI < Thor
|
|
197
|
+
include CLIHelpers
|
|
198
|
+
namespace :provider
|
|
199
|
+
|
|
200
|
+
desc "list", "List providers and detected capabilities"
|
|
201
|
+
def list
|
|
202
|
+
guard do
|
|
203
|
+
boot!
|
|
204
|
+
probes = Providers.registry.all.map(&:probe)
|
|
205
|
+
emit(probes.map(&:to_h), -> {
|
|
206
|
+
table(probes.map { |p| [ p.provider, p.installed ? (p.version || "?") : "—", p.validation, p.supported_capabilities.join(",") ] },
|
|
207
|
+
%w[PROVIDER VERSION STATUS CAPABILITIES])
|
|
208
|
+
})
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
desc "setup NAME", "Configure passive integration (MCP + hooks) for claude, codex or cursor"
|
|
213
|
+
method_option :notify, type: :boolean, default: false, desc: "Codex: also install the global notify hook (opt-in)"
|
|
214
|
+
def setup(name)
|
|
215
|
+
guard do
|
|
216
|
+
boot!
|
|
217
|
+
provider = Providers.registry.fetch(name)
|
|
218
|
+
probe = provider.probe
|
|
219
|
+
raise Error.provider_unavailable("#{provider.display_name} is not installed") unless probe.installed || name == "cursor"
|
|
220
|
+
|
|
221
|
+
result = name.to_s == "codex" ? provider.install_passive_hooks(project: project, root: root, notify: options[:notify]) :
|
|
222
|
+
provider.install_passive_hooks(project: project, root: root)
|
|
223
|
+
emit(result.to_h, -> {
|
|
224
|
+
puts "#{provider.display_name} #{probe.version} — passive integration"
|
|
225
|
+
result.changes.each { |c| puts " #{c[:action].to_s.ljust(9)} #{c[:path]} #{Array(c[:keys]).join(".")} #{Array(c[:events]).join(", ")}".rstrip }
|
|
226
|
+
result.backups.each { |b| puts " backup #{b}" }
|
|
227
|
+
result.warnings.each { |w| puts " note #{w}" }
|
|
228
|
+
puts " manifest #{ConfigEditor.manifest_path(root, provider.key)}" rescue nil
|
|
229
|
+
})
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
desc "remove NAME", "Remove AgentBoard-owned MCP/hook entries for a provider"
|
|
234
|
+
def remove(name)
|
|
235
|
+
guard do
|
|
236
|
+
boot!
|
|
237
|
+
provider = Providers.registry.fetch(name)
|
|
238
|
+
removed = provider.remove_passive_hooks(project: project, root: root, manifest: Providers::ConfigEditor.read_manifest(root, provider.key))
|
|
239
|
+
emit({ removed: removed }, removed.any? ? "Removed AgentBoard entries from: #{removed.join(", ")}" : "Nothing to remove")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
desc "doctor NAME", "Probe one provider in detail"
|
|
244
|
+
def doctor(name)
|
|
245
|
+
guard do
|
|
246
|
+
boot!
|
|
247
|
+
probe = Providers.registry.fetch(name).probe
|
|
248
|
+
emit(probe.to_h, -> {
|
|
249
|
+
puts "#{name}: #{probe.installed ? "installed #{probe.version}" : "not installed"} (#{probe.validation})"
|
|
250
|
+
puts "capabilities: #{probe.supported_capabilities.join(", ").presence || "none"}"
|
|
251
|
+
probe.diagnostics.each { |d| puts " - #{d}" }
|
|
252
|
+
})
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
class TicketCLI < Thor
|
|
258
|
+
include CLIHelpers
|
|
259
|
+
namespace :ticket
|
|
260
|
+
|
|
261
|
+
desc "list", "List tickets"
|
|
262
|
+
method_option :status, type: :string
|
|
263
|
+
def list
|
|
264
|
+
guard do
|
|
265
|
+
boot!
|
|
266
|
+
scope = project.tickets.includes(:project).ordered
|
|
267
|
+
scope = scope.where(status: options[:status]) if options[:status]
|
|
268
|
+
rows = scope.limit(200).map { |t| { key: t.key, status: t.status, priority: t.priority, title: t.title } }
|
|
269
|
+
emit(rows, -> { table(rows.map(&:values), %w[KEY STATUS P TITLE]) })
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
desc "show KEY", "Show a ticket"
|
|
274
|
+
def show(key)
|
|
275
|
+
guard do
|
|
276
|
+
boot!
|
|
277
|
+
t = ticket!(key)
|
|
278
|
+
data = { key: t.key, title: t.title, status: t.status, priority: t.priority, kind: t.kind, version: t.lock_version,
|
|
279
|
+
description: t.description, criteria: t.acceptance_criteria.map { |c| { description: c.description, state: c.state } },
|
|
280
|
+
completion: CompletionPolicy.evaluate(t) }
|
|
281
|
+
emit(data, -> {
|
|
282
|
+
puts "#{t.key} #{t.title}"
|
|
283
|
+
puts "status #{t.status} · #{t.priority_label} · #{t.kind} · v#{t.lock_version}"
|
|
284
|
+
puts "", t.description unless t.description.blank?
|
|
285
|
+
puts "", "Acceptance criteria:"
|
|
286
|
+
t.acceptance_criteria.each { |c| puts " [#{c.satisfied? ? "x" : " "}] #{c.description}" }
|
|
287
|
+
puts "", "Completion: #{data[:completion][:ok] ? "ready" : "blocked"}"
|
|
288
|
+
data[:completion][:missing].each { |m| puts " • #{m}" }
|
|
289
|
+
})
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
desc "create TITLE", "Create a ticket"
|
|
294
|
+
method_option :description, type: :string, default: ""
|
|
295
|
+
method_option :criteria, type: :array, default: [], desc: "Acceptance criteria"
|
|
296
|
+
method_option :kind, type: :string, default: "task"
|
|
297
|
+
method_option :priority, type: :numeric, default: 2
|
|
298
|
+
method_option :ready, type: :boolean, default: false
|
|
299
|
+
def create(title)
|
|
300
|
+
guard do
|
|
301
|
+
boot!
|
|
302
|
+
t = Tickets.create!(project: project, actor: actor, title: title, description: options[:description], kind: options[:kind],
|
|
303
|
+
priority: options[:priority], criteria: options[:criteria], status: options[:ready] ? "ready" : "backlog")
|
|
304
|
+
emit({ key: t.key, status: t.status }, "Created #{t.key} (#{t.status})")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
desc "move KEY STATUS", "Transition a ticket"
|
|
309
|
+
method_option :reason, type: :string
|
|
310
|
+
def move(key, status)
|
|
311
|
+
guard do
|
|
312
|
+
boot!
|
|
313
|
+
t = Tickets.transition!(ticket: ticket!(key), to: status, actor: actor, reason: options[:reason])
|
|
314
|
+
emit({ key: t.key, status: t.status }, "#{t.key} → #{t.status}")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
class WorkflowCLI < Thor
|
|
320
|
+
include CLIHelpers
|
|
321
|
+
namespace :workflow
|
|
322
|
+
|
|
323
|
+
desc "validate FILE", "Validate a workflow YAML file"
|
|
324
|
+
def validate(file)
|
|
325
|
+
guard do
|
|
326
|
+
boot!
|
|
327
|
+
d = Workflows::Definition.parse_yaml(File.read(file))
|
|
328
|
+
emit({ ok: true, name: d.name, order: d.topological_order, digest: d.digest }, "✓ #{d.name}: #{d.topological_order.join(" → ")}")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
desc "list", "List registered workflows (syncs .agentboard/workflows first)"
|
|
333
|
+
def list
|
|
334
|
+
guard do
|
|
335
|
+
boot!
|
|
336
|
+
WorkflowEngine.sync_from_disk!(project: project)
|
|
337
|
+
rows = WorkflowDefinition.where(project: project).order(:name, :version).map { |w| [ w.name, w.version, w.digest[0, 12] ] }
|
|
338
|
+
emit(rows, -> { table(rows, %w[NAME VERSION DIGEST]) })
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
map "run" => :run_workflow
|
|
343
|
+
desc "run NAME KEY", "Start a workflow run for a ticket"
|
|
344
|
+
def run_workflow(name, key)
|
|
345
|
+
guard do
|
|
346
|
+
boot!
|
|
347
|
+
WorkflowEngine.sync_from_disk!(project: project)
|
|
348
|
+
r = WorkflowEngine.start!(definition: WorkflowEngine.latest(project: project, name: name), ticket: ticket!(key), actor: actor)
|
|
349
|
+
emit({ run_id: r.id, status: r.status }, "Started #{name} for #{key} (run #{r.id}). The daemon advances it.")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
class McpCLI < Thor
|
|
355
|
+
include CLIHelpers
|
|
356
|
+
namespace :mcp
|
|
357
|
+
|
|
358
|
+
desc "serve", "Run the MCP stdio server (stdout carries protocol frames only)"
|
|
359
|
+
method_option :provider, type: :string, default: nil, desc: "Provider label for this session (claude, codex, …)"
|
|
360
|
+
method_option :execution, type: :string, desc: "Bind to an AgentBoard-launched execution (permission bridge)"
|
|
361
|
+
def serve
|
|
362
|
+
boot!(quiet_stdout: true)
|
|
363
|
+
Agentboard.logger = Logger.new($stderr, level: Logger::WARN)
|
|
364
|
+
ActiveRecord::Base.logger = nil
|
|
365
|
+
provider = options[:provider] || ENV["AGENTBOARD_PROVIDER"] || detect_provider
|
|
366
|
+
execution = options[:execution] && Execution.find_by(id: options[:execution])
|
|
367
|
+
raise Error.not_found("Execution #{options[:execution]} not found") if options[:execution] && execution.nil?
|
|
368
|
+
|
|
369
|
+
McpServer.new(project: project, provider_key: provider, execution: execution).serve
|
|
370
|
+
rescue Agentboard::Error => e
|
|
371
|
+
warn "agentboard mcp: #{e.code}: #{e.message}"
|
|
372
|
+
exit(CLIHelpers::EXIT_CODES.fetch(e.code, 1))
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
private
|
|
376
|
+
|
|
377
|
+
def detect_provider
|
|
378
|
+
return "claude" if ENV["CLAUDECODE"] || ENV["CLAUDE_CODE_ENTRYPOINT"]
|
|
379
|
+
return "codex" if ENV.keys.any? { |k| k.start_with?("CODEX_") }
|
|
380
|
+
|
|
381
|
+
"mcp"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
class HookCLI < Thor
|
|
386
|
+
include CLIHelpers
|
|
387
|
+
namespace :hook
|
|
388
|
+
|
|
389
|
+
desc "ingest", "Ingest a provider hook payload from stdin (bounded, redacted, deduplicated)"
|
|
390
|
+
method_option :provider, type: :string, required: true
|
|
391
|
+
method_option :event, type: :string, required: true
|
|
392
|
+
def ingest(*payload_args)
|
|
393
|
+
# Read before booting Rails (app constants are not loaded yet). Bounded read.
|
|
394
|
+
# Codex `notify` passes the JSON payload as the last argument instead of stdin.
|
|
395
|
+
raw = payload_args.last.to_s.start_with?("{") ? payload_args.last.to_s[0, 256 * 1024] : ($stdin.tty? ? "" : $stdin.read(256 * 1024 + 1).to_s)
|
|
396
|
+
begin
|
|
397
|
+
Timeout.timeout(8) do
|
|
398
|
+
boot!(quiet_stdout: true)
|
|
399
|
+
ActiveRecord::Base.logger = nil
|
|
400
|
+
HookIngestion.ingest!(provider_key: options[:provider], event_name: options[:event], raw: raw, project: project)
|
|
401
|
+
end
|
|
402
|
+
rescue Agentboard::Error => e
|
|
403
|
+
warn "agentboard hook: #{e.code}: #{e.message}" if ENV["AGENTBOARD_DEBUG"]
|
|
404
|
+
rescue StandardError, Timeout::Error => e
|
|
405
|
+
warn "agentboard hook: spooling (#{e.class}: #{e.message})" if ENV["AGENTBOARD_DEBUG"]
|
|
406
|
+
# App/DB unavailable: spool locally and never block the agent.
|
|
407
|
+
HookIngestion.spool!(root: root, provider_key: options[:provider], event_name: options[:event], raw: raw) rescue nil
|
|
408
|
+
end
|
|
409
|
+
exit 0 # hooks must never fail the provider's own workflow
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
desc "replay", "Replay spooled hook payloads"
|
|
413
|
+
def replay
|
|
414
|
+
guard do
|
|
415
|
+
boot!
|
|
416
|
+
n = HookIngestion.replay_spool!(root: root)
|
|
417
|
+
emit({ replayed: n }, "Replayed #{n} spooled hook payload(s)")
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
class SandboxCLI < Thor
|
|
423
|
+
include CLIHelpers
|
|
424
|
+
namespace :sandbox
|
|
425
|
+
|
|
426
|
+
desc "init", "Write .agentboard/sandbox/Dockerfile for the agent image"
|
|
427
|
+
def init
|
|
428
|
+
guard do
|
|
429
|
+
path = File.join(root, ".agentboard", "sandbox", "Dockerfile")
|
|
430
|
+
if File.exist?(path)
|
|
431
|
+
emit({ exists: path }, "#{path} already exists (left unchanged)")
|
|
432
|
+
else
|
|
433
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
434
|
+
File.write(path, File.read(File.expand_path("templates/Dockerfile.agent", __dir__)).sub("__RUBY_VERSION__", RUBY_VERSION))
|
|
435
|
+
emit({ written: path }, "Wrote #{path}. Next: agentboard sandbox build")
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
desc "build", "Build the agent image and print the config to enable container isolation"
|
|
441
|
+
method_option :tag, type: :string
|
|
442
|
+
def build
|
|
443
|
+
guard do
|
|
444
|
+
boot!
|
|
445
|
+
dockerfile = File.join(root, ".agentboard", "sandbox", "Dockerfile")
|
|
446
|
+
raise Error.not_found("Run `agentboard sandbox init` first") unless File.exist?(dockerfile)
|
|
447
|
+
raise Error.provider_unavailable("#{AgentContainer.runtime} is not running") unless ContainerSandbox.available?
|
|
448
|
+
|
|
449
|
+
tag = options[:tag] || "agentboard-agent:#{AgentContainer.project_id}"
|
|
450
|
+
ok = system(AgentContainer.runtime, "build", "-t", tag, "-f", dockerfile, root)
|
|
451
|
+
raise Error.provider_unavailable("image build failed") unless ok
|
|
452
|
+
|
|
453
|
+
emit({ image: tag }, -> {
|
|
454
|
+
puts "Built #{tag}. Enable it in .agentboard/config.yml:"
|
|
455
|
+
puts " security:\n sandbox: container\n sandbox:\n agents: true\n agent_image: #{tag}\n image: #{tag}\n credential_env: [ANTHROPIC_API_KEY, OPENAI_API_KEY]"
|
|
456
|
+
})
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
desc "status", "Show container isolation status (runtime, network, egress allowlist)"
|
|
461
|
+
def status
|
|
462
|
+
guard do
|
|
463
|
+
boot!
|
|
464
|
+
data = { runtime: AgentContainer.runtime, available: ContainerSandbox.available?, verification: ContainerSandbox.enabled?,
|
|
465
|
+
agents: AgentContainer.enabled?, agent_image: ContainerSandbox.settings["agent_image"], network: AgentContainer.network_name,
|
|
466
|
+
proxy: AgentContainer.proxy_name, egress_allowlist: AgentContainer.egress_allowlist }
|
|
467
|
+
emit(data, -> { data.each { |k, v| puts "#{k.to_s.ljust(18)} #{v.is_a?(Array) ? v.join(", ") : v}" } })
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
desc "down", "Remove the egress proxy and internal network of this project"
|
|
472
|
+
def down
|
|
473
|
+
guard do
|
|
474
|
+
boot!
|
|
475
|
+
Open3.capture3(AgentContainer.runtime, "rm", "-f", AgentContainer.proxy_name)
|
|
476
|
+
Open3.capture3(AgentContainer.runtime, "network", "rm", AgentContainer.network_name)
|
|
477
|
+
emit({ removed: [ AgentContainer.proxy_name, AgentContainer.network_name ] }, "Removed egress proxy and network")
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
class CLI < Thor
|
|
483
|
+
include CLIHelpers
|
|
484
|
+
package_name "agentboard"
|
|
485
|
+
|
|
486
|
+
map %w[--version -v] => :version
|
|
487
|
+
desc "version", "Print version"
|
|
488
|
+
def version = puts("agentboard #{Agentboard::VERSION}")
|
|
489
|
+
|
|
490
|
+
desc "daemon SUBCOMMAND", "Runtime daemon lifecycle (start/stop/status/restart)"
|
|
491
|
+
subcommand "daemon", DaemonCLI
|
|
492
|
+
desc "provider SUBCOMMAND", "Provider management (list/setup/remove/doctor)"
|
|
493
|
+
subcommand "provider", ProviderCLI
|
|
494
|
+
desc "ticket SUBCOMMAND", "Ticket management (list/show/create/move)"
|
|
495
|
+
subcommand "ticket", TicketCLI
|
|
496
|
+
desc "workflow SUBCOMMAND", "Workflows (validate/list/run)"
|
|
497
|
+
subcommand "workflow", WorkflowCLI
|
|
498
|
+
desc "mcp SUBCOMMAND", "MCP server"
|
|
499
|
+
subcommand "mcp", McpCLI
|
|
500
|
+
desc "hook SUBCOMMAND", "Provider hook ingestion"
|
|
501
|
+
subcommand "hook", HookCLI
|
|
502
|
+
desc "sandbox SUBCOMMAND", "Container isolation for agents (init/build/status/down)"
|
|
503
|
+
subcommand "sandbox", SandboxCLI
|
|
504
|
+
|
|
505
|
+
desc "init", "Set up .agentboard/ in this repository (idempotent, preserves existing settings)"
|
|
506
|
+
def init
|
|
507
|
+
guard do
|
|
508
|
+
out, _e, st = Open3.capture3("git", "rev-parse", "--show-toplevel", chdir: root)
|
|
509
|
+
raise Error.validation_failed("#{root} is not inside a Git repository") unless st.success?
|
|
510
|
+
|
|
511
|
+
created = []
|
|
512
|
+
%w[workflows instructions policies cache manifests].each do |d|
|
|
513
|
+
path = File.join(root, ".agentboard", d)
|
|
514
|
+
next if File.directory?(path)
|
|
515
|
+
|
|
516
|
+
FileUtils.mkdir_p(path)
|
|
517
|
+
created << ".agentboard/#{d}/"
|
|
518
|
+
end
|
|
519
|
+
config = File.join(root, ".agentboard", "config.yml")
|
|
520
|
+
unless File.exist?(config)
|
|
521
|
+
File.write(config, File.read(File.expand_path("../generators/agentboard/install/templates/config.yml", __dir__)))
|
|
522
|
+
created << ".agentboard/config.yml"
|
|
523
|
+
end
|
|
524
|
+
wf = File.join(root, ".agentboard", "workflows", "feature_delivery.yml")
|
|
525
|
+
unless File.exist?(wf)
|
|
526
|
+
File.write(wf, File.read(File.expand_path("../generators/agentboard/install/templates/feature_delivery.yml", __dir__)))
|
|
527
|
+
created << ".agentboard/workflows/feature_delivery.yml"
|
|
528
|
+
end
|
|
529
|
+
gitignore = File.join(root, ".gitignore")
|
|
530
|
+
lines = File.exist?(gitignore) ? File.read(gitignore).lines.map(&:strip) : []
|
|
531
|
+
wanted = [ "/.agentboard/worktrees/", "/.agentboard/logs/", "/.agentboard/cache/", "/.agentboard/spool/", "/.agentboard/manifests/", "*.agentboard-backup-*" ]
|
|
532
|
+
missing = wanted - lines
|
|
533
|
+
if missing.any?
|
|
534
|
+
File.open(gitignore, "a") { |f| f.puts("", "# AgentBoard local state", *missing) }
|
|
535
|
+
created << ".gitignore (#{missing.size} entries)"
|
|
536
|
+
end
|
|
537
|
+
providers = Providers.registry.all.reject { |p| p.key == "fake" }.map { |p| pr = p.probe; [ p.key, pr.installed, pr.version ] }
|
|
538
|
+
emit({ git_root: out.strip, created: created, providers: providers.map { |k, i, v| { provider: k, installed: i, version: v } } }, -> {
|
|
539
|
+
puts "AgentBoard initialised in #{root}"
|
|
540
|
+
created.each { |c| puts " created #{c}" }
|
|
541
|
+
puts " (nothing to create — already initialised)" if created.empty?
|
|
542
|
+
puts "", "Detected providers:"
|
|
543
|
+
providers.each { |k, i, v| puts " #{i ? "✓" : "·"} #{k} #{v}" }
|
|
544
|
+
puts "", "Next: bundle exec agentboard provider setup claude · bundle exec agentboard daemon start"
|
|
545
|
+
})
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
desc "doctor", "Diagnose the installation"
|
|
550
|
+
def doctor
|
|
551
|
+
guard do
|
|
552
|
+
boot!
|
|
553
|
+
checks = Doctor.new(root: root).checks
|
|
554
|
+
emit(checks.map(&:to_h), -> {
|
|
555
|
+
checks.each { |c| puts "#{{ "ok" => "✓", "warn" => "!", "fail" => "✗" }[c.status]} #{c.name.ljust(26)} #{c.detail}" }
|
|
556
|
+
})
|
|
557
|
+
exit 1 if checks.any? { |c| c.status == "fail" }
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
desc "attach KEY", "Associate the active provider session in this directory with a ticket"
|
|
562
|
+
method_option :session, type: :string, desc: "AgentBoard session id or provider session id"
|
|
563
|
+
method_option :provider, type: :string, default: "claude"
|
|
564
|
+
def attach(key)
|
|
565
|
+
guard do
|
|
566
|
+
boot!
|
|
567
|
+
t = ticket!(key)
|
|
568
|
+
session = if options[:session]
|
|
569
|
+
AgentSession.where(project: project).where("id = :s OR external_session_id = :s", s: options[:session]).first
|
|
570
|
+
else
|
|
571
|
+
AgentSession.where(project: project, provider_key: options[:provider], state: "active").where.not(trust_level: "untrusted")
|
|
572
|
+
.order(Arel.sql("COALESCE(last_seen_at, created_at) DESC")).first
|
|
573
|
+
end
|
|
574
|
+
raise Error.not_found("No active #{options[:provider]} session found; start the agent with hooks configured, or pass --session") unless session
|
|
575
|
+
|
|
576
|
+
SessionAssociation.link!(session: session, ticket: t, source: "cli_attach", actor: actor)
|
|
577
|
+
emit({ session_id: session.id, ticket: t.key }, "Attached #{session.display_name} → #{t.key}")
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
map "run" => :run_ticket
|
|
582
|
+
desc "run KEY", "Queue a managed execution for a ticket"
|
|
583
|
+
method_option :provider, type: :string, default: "fake"
|
|
584
|
+
method_option :action, type: :string, default: "implement"
|
|
585
|
+
method_option :workflow, type: :string
|
|
586
|
+
method_option :wait, type: :boolean, default: false, desc: "Wait for the execution to finish"
|
|
587
|
+
method_option :scenario, type: :string, desc: "Fake provider scenario"
|
|
588
|
+
def run_ticket(key)
|
|
589
|
+
guard do
|
|
590
|
+
boot!
|
|
591
|
+
t = ticket!(key)
|
|
592
|
+
if options[:workflow]
|
|
593
|
+
WorkflowEngine.sync_from_disk!(project: project)
|
|
594
|
+
r = WorkflowEngine.start!(definition: WorkflowEngine.latest(project: project, name: options[:workflow]), ticket: t, actor: actor)
|
|
595
|
+
emit({ workflow_run_id: r.id }, "Started workflow #{options[:workflow]} (#{r.id})")
|
|
596
|
+
return
|
|
597
|
+
end
|
|
598
|
+
e = Executions.enqueue!(ticket: t, provider_key: options[:provider], action: options[:action], actor: actor,
|
|
599
|
+
idempotency_key: SecureRandom.uuid, metadata: options[:scenario] ? { "scenario" => options[:scenario] } : {})
|
|
600
|
+
warn "Runtime daemon is not running; the execution stays queued until `agentboard daemon start`." unless Runtime::DaemonControl.status[:running]
|
|
601
|
+
unless options[:wait]
|
|
602
|
+
emit({ execution_id: e.id, status: e.status, attempt: e.attempt }, "Queued #{e.label} (#{e.id})")
|
|
603
|
+
return
|
|
604
|
+
end
|
|
605
|
+
sleep 0.5 until e.reload.terminal?
|
|
606
|
+
emit({ execution_id: e.id, status: e.status, head_sha: e.head_sha, failure_code: e.failure_code }, "#{e.label}: #{e.status}#{" (#{e.failure_code})" if e.failure_code}")
|
|
607
|
+
exit(e.status == "completed" ? 0 : 1)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
desc "verify KEY", "Run the approved verification catalog against the repository HEAD for a ticket"
|
|
612
|
+
def verify(key)
|
|
613
|
+
guard do
|
|
614
|
+
boot!
|
|
615
|
+
results = VerificationService.verify_repository!(ticket: ticket!(key), actor: actor)
|
|
616
|
+
emit(results.map { |r| r.slice(:name, :status, :commit_sha, :exit_code) }, -> {
|
|
617
|
+
results.each { |r| puts "#{r.passed? ? "✓" : "✗"} #{r.name.ljust(14)} #{r.status.ljust(8)} #{r.commit_sha[0, 12]}" }
|
|
618
|
+
})
|
|
619
|
+
exit 1 unless results.select(&:required).all?(&:passed?)
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
desc "executions", "List recent executions"
|
|
624
|
+
method_option :limit, type: :numeric, default: 20
|
|
625
|
+
def executions
|
|
626
|
+
guard do
|
|
627
|
+
boot!
|
|
628
|
+
rows = Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).includes(:ticket).recent.limit(options[:limit])
|
|
629
|
+
.map { |e| { id: e.id, ticket: e.ticket.key, provider: e.provider_key, action: e.action, attempt: e.attempt, status: e.status, head_sha: e.head_sha } }
|
|
630
|
+
emit(rows, -> { table(rows.map { |r| [ r[:id][0, 13], r[:ticket], r[:provider], r[:action], r[:attempt], r[:status] ] }, %w[ID TICKET PROVIDER ACTION ATTEMPT STATUS]) })
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
desc "logs EXECUTION_ID", "Print (and optionally follow) the redacted execution log"
|
|
635
|
+
method_option :follow, type: :boolean, aliases: "-f", default: false
|
|
636
|
+
def logs(id)
|
|
637
|
+
guard do
|
|
638
|
+
boot!
|
|
639
|
+
e = find_execution(id)
|
|
640
|
+
offset = 0
|
|
641
|
+
loop do
|
|
642
|
+
if e.log_path && File.exist?(e.log_path)
|
|
643
|
+
File.open(e.log_path, "rb") do |f|
|
|
644
|
+
f.seek(offset)
|
|
645
|
+
chunk = f.read
|
|
646
|
+
offset += chunk.bytesize
|
|
647
|
+
$stdout.write(Redactor.redact(chunk.force_encoding("UTF-8").scrub))
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
break unless options[:follow] && !e.reload.terminal?
|
|
651
|
+
|
|
652
|
+
sleep 0.5
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
desc "cancel EXECUTION_ID", "Cancel an execution (process group gets SIGTERM, then SIGKILL)"
|
|
658
|
+
method_option :reason, type: :string, default: "cancelled from CLI"
|
|
659
|
+
def cancel(id)
|
|
660
|
+
guard do
|
|
661
|
+
boot!
|
|
662
|
+
e = Executions.request_cancel!(execution: find_execution(id), actor: actor, reason: options[:reason])
|
|
663
|
+
emit({ execution_id: e.id, status: e.status }, "Cancellation requested for #{e.label} (#{e.status})")
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
desc "resume EXECUTION_ID", "Resume a provider session as a new attempt, if the provider supports it"
|
|
668
|
+
method_option :message, type: :string, default: "Continue the task."
|
|
669
|
+
def resume(id)
|
|
670
|
+
guard do
|
|
671
|
+
boot!
|
|
672
|
+
e = find_execution(id)
|
|
673
|
+
provider = Providers.registry.fetch(e.provider_key)
|
|
674
|
+
raise UnsupportedCapability.new(:session_resume, provider: e.provider_key) unless provider.supports?(:session_resume) && e.metadata["provider_session_id"]
|
|
675
|
+
|
|
676
|
+
n = Executions.resume!(execution: e, actor: actor, message: options[:message])
|
|
677
|
+
emit({ execution_id: n.id }, "Queued resume as #{n.label}")
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
desc "approvals", "List pending approvals"
|
|
682
|
+
def approvals
|
|
683
|
+
guard do
|
|
684
|
+
boot!
|
|
685
|
+
rows = Approval.pending.where(project: project).order(:created_at).map { |a| { id: a.id, kind: a.action_kind, ticket: a.ticket&.key, head: a.head_sha&.slice(0, 10), by: a.requested_by } }
|
|
686
|
+
emit(rows, -> { table(rows.map(&:values), %w[ID KIND TICKET HEAD REQUESTED_BY]) })
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
desc "approve APPROVAL_ID", "Approve (or --reject) a pending approval as the local human"
|
|
691
|
+
method_option :reject, type: :boolean, default: false
|
|
692
|
+
method_option :reason, type: :string
|
|
693
|
+
def approve(id)
|
|
694
|
+
guard do
|
|
695
|
+
boot!
|
|
696
|
+
a = Approvals.decide!(approval: Approval.where(project: project).find(id), actor: actor, decision: options[:reject] ? "rejected" : "approved", reason: options[:reason])
|
|
697
|
+
emit({ id: a.id, decision: a.decision }, "Approval #{a.id[0, 8]}: #{a.decision}")
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
desc "cleanup", "Remove eligible worktrees and expired logs (never uncommitted work without --force)"
|
|
702
|
+
method_option :dry_run, type: :boolean, default: false
|
|
703
|
+
method_option :force, type: :boolean, default: false
|
|
704
|
+
def cleanup
|
|
705
|
+
guard do
|
|
706
|
+
boot!
|
|
707
|
+
days = Agentboard.config.settings.dig("retention", "worktree_days").to_i
|
|
708
|
+
manager = Git::WorktreeManager.new(repository: project.repository, workspace: Agentboard.config.workspace_path)
|
|
709
|
+
removed = []
|
|
710
|
+
skipped = []
|
|
711
|
+
Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).terminal.where.not(worktree_path: nil)
|
|
712
|
+
.where("finished_at < ?", days.days.ago).find_each do |e|
|
|
713
|
+
next unless e.worktree_path.start_with?(Agentboard.config.workspace_path.to_s) && File.directory?(e.worktree_path)
|
|
714
|
+
if e.ticket.status == "review" && !options[:force]
|
|
715
|
+
skipped << [ e.label, "ticket in review" ]
|
|
716
|
+
next
|
|
717
|
+
end
|
|
718
|
+
if options[:dry_run]
|
|
719
|
+
removed << e.label
|
|
720
|
+
next
|
|
721
|
+
end
|
|
722
|
+
manager.remove!(e.worktree_path, force: options[:force])
|
|
723
|
+
removed << e.label
|
|
724
|
+
rescue Agentboard::Error => err
|
|
725
|
+
skipped << [ e.label, err.message ]
|
|
726
|
+
end
|
|
727
|
+
manager.prune! unless options[:dry_run]
|
|
728
|
+
log_days = Agentboard.config.settings.dig("retention", "raw_log_days").to_i
|
|
729
|
+
logs = Dir[File.join(root, ".agentboard", "logs", "*")].select { |d| File.mtime(d) < log_days.days.ago }
|
|
730
|
+
FileUtils.rm_rf(logs) unless options[:dry_run]
|
|
731
|
+
emit({ removed: removed, skipped: skipped, logs_removed: logs.size, dry_run: options[:dry_run] }, -> {
|
|
732
|
+
puts "#{options[:dry_run] ? "Would remove" : "Removed"} #{removed.size} worktree(s), #{logs.size} log dir(s)"
|
|
733
|
+
skipped.each { |l, why| puts " skipped #{l}: #{why}" }
|
|
734
|
+
})
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
desc "export", "Export tickets, events and artifact manifest as JSON (redacted)"
|
|
739
|
+
method_option :output, type: :string, aliases: "-o"
|
|
740
|
+
def export
|
|
741
|
+
guard do
|
|
742
|
+
boot!
|
|
743
|
+
data = {
|
|
744
|
+
exported_at: Time.now.utc.iso8601, agentboard_version: Agentboard::VERSION, project: project.slice(:key, :name, :default_branch),
|
|
745
|
+
tickets: project.tickets.includes(:acceptance_criteria, :comments).map { |t|
|
|
746
|
+
t.slice(:number, :title, :kind, :status, :priority, :labels, :created_at, :completed_at).merge(
|
|
747
|
+
key: t.key, description: Redactor.redact(t.description),
|
|
748
|
+
criteria: t.acceptance_criteria.map { |c| c.slice(:description, :verification_kind, :state) },
|
|
749
|
+
comments: t.comments.map { |c| { author: c.actor_name, body: Redactor.redact(c.body), at: c.created_at } })
|
|
750
|
+
},
|
|
751
|
+
events: Event.where(project_id: project.id).order(:received_at).map { |e| Redactor.redact_value(e.envelope.deep_stringify_keys) },
|
|
752
|
+
artifacts: Artifact.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).map { |a| a.slice(:id, :kind, :uri, :sha256, :commit_sha, :provenance) }
|
|
753
|
+
}
|
|
754
|
+
json = JSON.pretty_generate(data)
|
|
755
|
+
options[:output] ? File.write(options[:output], json) : puts(json)
|
|
756
|
+
warn "Exported #{data[:tickets].size} tickets, #{data[:events].size} events" if options[:output]
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
desc "retention", "Apply data retention (old raw logs, provider output events, delivered outbox)"
|
|
762
|
+
method_option :dry_run, type: :boolean, default: false
|
|
763
|
+
def retention
|
|
764
|
+
guard do
|
|
765
|
+
boot!
|
|
766
|
+
report = DataGovernance.apply_retention!(project: project, dry_run: options[:dry_run])
|
|
767
|
+
emit(report, -> { report.each { |k, v| puts "#{k.to_s.ljust(24)} #{v}" } })
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
desc "purge", "Permanently delete ALL AgentBoard data of this project (requires --confirm KEY)"
|
|
772
|
+
method_option :confirm, type: :string, required: true, desc: "Type the project key to confirm"
|
|
773
|
+
def purge
|
|
774
|
+
guard do
|
|
775
|
+
boot!
|
|
776
|
+
counts = DataGovernance.purge_project!(project: project, confirm: options[:confirm], actor: actor)
|
|
777
|
+
emit(counts, -> { puts "Purged project data: #{counts.map { |k, v| "#{k}=#{v}" }.join(" ")}" })
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
desc "uninstall", "Remove AgentBoard-owned provider config and hooks (never deletes ticket data)"
|
|
782
|
+
def uninstall
|
|
783
|
+
guard do
|
|
784
|
+
boot!
|
|
785
|
+
removed = Providers.registry.all.flat_map do |p|
|
|
786
|
+
manifest = Providers::ConfigEditor.read_manifest(root, p.key)
|
|
787
|
+
manifest ? p.remove_passive_hooks(project: project, root: root, manifest: manifest) : []
|
|
788
|
+
rescue Agentboard::Error
|
|
789
|
+
[]
|
|
790
|
+
end
|
|
791
|
+
emit({ removed: removed }, -> {
|
|
792
|
+
puts removed.any? ? "Removed: #{removed.join(", ")}" : "No provider integrations installed"
|
|
793
|
+
puts "Ticket data, events and tables were kept. Drop them with a separate, explicit migration if you really want to."
|
|
794
|
+
})
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
private
|
|
799
|
+
|
|
800
|
+
def find_execution(id)
|
|
801
|
+
scope = Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id })
|
|
802
|
+
scope.find_by(id: id) || scope.where("agentboard_executions.id LIKE ?", "#{id}%").first || raise(Error.not_found("Execution #{id} not found"))
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
require "timeout"
|
|
807
|
+
require "logger"
|
|
808
|
+
require "open3"
|
|
809
|
+
require "rbconfig"
|
|
810
|
+
require "fileutils"
|
|
811
|
+
|
|
812
|
+
# Pro commands (support-bundle, audit-export, license) when agentboard-pro is installed.
|
|
813
|
+
begin
|
|
814
|
+
require "agentboard/pro"
|
|
815
|
+
require "agentboard/pro/cli"
|
|
816
|
+
rescue LoadError
|
|
817
|
+
nil
|
|
818
|
+
end
|