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,37 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Versioned event payloads (spec §11, §23.4). Historical events are never rewritten:
|
|
3
|
+
# readers call `EventSchema.upcast` to see old payloads in the current shape.
|
|
4
|
+
#
|
|
5
|
+
# EventSchema.register("agent.execution.usage", from: 1) { |p| … returns v2 payload … }
|
|
6
|
+
module EventSchema
|
|
7
|
+
@current = Hash.new(1)
|
|
8
|
+
@upcasters = {}
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def current_version(type) = @current[type.to_s]
|
|
12
|
+
|
|
13
|
+
def register(type, from:, &block)
|
|
14
|
+
@upcasters[[ type.to_s, from ]] = block
|
|
15
|
+
@current[type.to_s] = [ @current[type.to_s], from + 1 ].max
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Applies successive upcasters from `version` to the current version.
|
|
19
|
+
def upcast(type, version, payload)
|
|
20
|
+
version = version.to_i
|
|
21
|
+
data = payload.is_a?(Hash) ? payload.dup : {}
|
|
22
|
+
while version < current_version(type)
|
|
23
|
+
step = @upcasters[[ type.to_s, version ]] or break
|
|
24
|
+
data = step.call(data)
|
|
25
|
+
version += 1
|
|
26
|
+
end
|
|
27
|
+
data
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# v2 (AgentBoard 0.2): monetary amounts carry an explicit currency; unknown stays nil.
|
|
32
|
+
register("agent.execution.usage", from: 1) do |p|
|
|
33
|
+
usd = p.delete("reported_cost_usd")
|
|
34
|
+
p.merge("reported_cost" => usd, "currency" => usd.nil? ? nil : "USD")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Open-core extension points (spec §16). The MIT core works fully offline for a single
|
|
3
|
+
# local user. Commercial extensions (agentboard-pro) register here and unlock features
|
|
4
|
+
# according to their own licence; the core never phones home or needs an account.
|
|
5
|
+
module Extensions
|
|
6
|
+
FEATURES = {
|
|
7
|
+
team_mode: "Team mode: host-app authentication, roles and separation of duties",
|
|
8
|
+
advanced_workflows: "Advanced workflows: conditions, retries, per-step timeouts, external and pull-request steps",
|
|
9
|
+
governance: "Analytics & governance: operational metrics, support bundle, scheduled retention, audit export",
|
|
10
|
+
capture: "Capture widget beyond development (team use on staging) and region screenshots",
|
|
11
|
+
git_hosting: "Git hosting integrations (GitHub, GitLab, Bitbucket): signed webhooks, merge-aware completion, pull-request creation"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
Extension = Struct.new(:name, :version, :features, :status, keyword_init: true) do
|
|
15
|
+
def enabled_features = Array(features.respond_to?(:call) ? features.call : features).map(&:to_sym)
|
|
16
|
+
def status_info = status.respond_to?(:call) ? status.call : status
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@extensions = {}
|
|
20
|
+
@hooks = Hash.new { |h, k| h[k] = [] }
|
|
21
|
+
@step_handlers = {}
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_reader :extensions, :step_handlers
|
|
25
|
+
|
|
26
|
+
def register(name, version:, features:, status: nil)
|
|
27
|
+
@extensions[name.to_s] = Extension.new(name: name.to_s, version: version, features: features, status: status)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unregister(name) = @extensions.delete(name.to_s)
|
|
31
|
+
|
|
32
|
+
def feature?(key) = @extensions.values.any? { |e| e.enabled_features.include?(key.to_sym) }
|
|
33
|
+
|
|
34
|
+
def require!(key)
|
|
35
|
+
return true if feature?(key)
|
|
36
|
+
|
|
37
|
+
raise Error.policy_denied("#{FEATURES.fetch(key.to_sym)} requires AgentBoard Pro with a valid licence", feature: key.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Hooks: on(:event) { |*args| … }; run(:event, *args) calls every subscriber.
|
|
41
|
+
def on(event, &block) = (@hooks[event.to_sym] << block)
|
|
42
|
+
def run(event, *args, **kwargs) = @hooks[event.to_sym].map { |h| h.call(*args, **kwargs) }
|
|
43
|
+
def hooks?(event) = @hooks[event.to_sym].any?
|
|
44
|
+
|
|
45
|
+
# Workflow step types provided by extensions.
|
|
46
|
+
def register_step_handler(type, &block) = (@step_handlers[type.to_s] = block)
|
|
47
|
+
def step_handler(type) = @step_handlers[type.to_s]
|
|
48
|
+
|
|
49
|
+
def reset_hooks!
|
|
50
|
+
@hooks.clear
|
|
51
|
+
@step_handlers.clear
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.feature?(key) = Extensions.feature?(key)
|
|
57
|
+
def self.require_feature!(key) = Extensions.require!(key)
|
|
58
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Git
|
|
3
|
+
# Thin, argv-only wrapper over the git CLI. Never interpolates into a shell.
|
|
4
|
+
class Repository
|
|
5
|
+
class CommandFailed < StandardError; end
|
|
6
|
+
|
|
7
|
+
attr_reader :root
|
|
8
|
+
|
|
9
|
+
def initialize(root)
|
|
10
|
+
@root = File.expand_path(root.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def git(*args, chdir: root, allow_failure: false, env: {})
|
|
14
|
+
out, err, status = Open3.capture3(safe_env.merge(env), "git", *args.map(&:to_s), chdir: chdir)
|
|
15
|
+
unless status.success? || allow_failure
|
|
16
|
+
raise CommandFailed, "git #{args.first} failed: #{Redactor.redact(err.strip)[0, 500]}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
allow_failure ? [ out, err, status ] : out
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def repository? = git("rev-parse", "--is-inside-work-tree", allow_failure: true)[2].success?
|
|
23
|
+
def toplevel = git("rev-parse", "--show-toplevel").strip
|
|
24
|
+
def head_sha(chdir: root) = git("rev-parse", "HEAD", chdir: chdir).strip
|
|
25
|
+
def current_branch(chdir: root) = git("rev-parse", "--abbrev-ref", "HEAD", chdir: chdir).strip
|
|
26
|
+
|
|
27
|
+
def default_branch
|
|
28
|
+
out, _e, st = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", allow_failure: true)
|
|
29
|
+
return out.strip.sub(%r{\Aorigin/}, "") if st.success? && out.present?
|
|
30
|
+
|
|
31
|
+
%w[main master trunk].find { |b| git("rev-parse", "--verify", "--quiet", "refs/heads/#{b}", allow_failure: true)[2].success? } || current_branch
|
|
32
|
+
rescue CommandFailed
|
|
33
|
+
"main"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clean?(chdir: root) = git("status", "--porcelain", chdir: chdir).strip.empty?
|
|
37
|
+
|
|
38
|
+
# Returns the full SHA if the object exists as a commit in this repository.
|
|
39
|
+
def resolve_commit(sha)
|
|
40
|
+
return nil unless sha.to_s.match?(/\A[0-9a-fA-F]{4,64}\z/)
|
|
41
|
+
|
|
42
|
+
out, _e, st = git("rev-parse", "--verify", "--quiet", "#{sha}^{commit}", allow_failure: true)
|
|
43
|
+
st.success? ? out.strip : nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def commit_subject(sha) = git("log", "-1", "--format=%s", sha).strip
|
|
47
|
+
def ancestor?(ancestor, descendant) = git("merge-base", "--is-ancestor", ancestor, descendant, allow_failure: true)[2].success?
|
|
48
|
+
|
|
49
|
+
def tracked_files(chdir: root) = git("ls-files", "-z", chdir: chdir).split("\0")
|
|
50
|
+
|
|
51
|
+
def changed_files(base, head, chdir: root)
|
|
52
|
+
git("diff", "--name-only", "-z", "#{base}..#{head}", chdir: chdir).split("\0")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def diff_stat(base, head, chdir: root)
|
|
56
|
+
out = git("diff", "--shortstat", "#{base}..#{head}", chdir: chdir)
|
|
57
|
+
{
|
|
58
|
+
files: out[/(\d+) files? changed/, 1].to_i,
|
|
59
|
+
insertions: out[/(\d+) insertions?/, 1].to_i,
|
|
60
|
+
deletions: out[/(\d+) deletions?/, 1].to_i
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def log(limit: 20, chdir: root, range: nil)
|
|
65
|
+
args = [ "log", "-#{limit.to_i}", "--format=%H%x1f%an%x1f%aI%x1f%s" ]
|
|
66
|
+
args << range if range
|
|
67
|
+
git(*args, chdir: chdir).lines.map do |line|
|
|
68
|
+
sha, author, date, subject = line.chomp.split("\x1f", 4)
|
|
69
|
+
{ sha: sha, author: author, date: date, subject: subject }
|
|
70
|
+
end
|
|
71
|
+
rescue CommandFailed
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def worktrees
|
|
76
|
+
git("worktree", "list", "--porcelain").split("\n\n").map do |block|
|
|
77
|
+
block.lines.each_with_object({}) do |line, h|
|
|
78
|
+
k, v = line.chomp.split(" ", 2)
|
|
79
|
+
h[k] = v || true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Prevent user/global hooks or pagers from altering behaviour.
|
|
87
|
+
def safe_env
|
|
88
|
+
{ "GIT_TERMINAL_PROMPT" => "0", "GIT_PAGER" => "cat", "LC_ALL" => "C" }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Git
|
|
3
|
+
# Creates one dedicated branch + worktree per execution (spec §8, §29.2):
|
|
4
|
+
# .agentboard/worktrees/AB-142-exec-001 on branch agentboard/AB-142/exec-001
|
|
5
|
+
# Worktrees are source-control isolation only — they are NOT security sandboxes.
|
|
6
|
+
class WorktreeManager
|
|
7
|
+
Worktree = Struct.new(:path, :branch, :base_sha, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :repository, :workspace
|
|
10
|
+
|
|
11
|
+
def initialize(repository:, workspace:)
|
|
12
|
+
@repository = repository
|
|
13
|
+
@workspace = File.expand_path(workspace.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.name_for(ticket_key, attempt)
|
|
17
|
+
raise Error.validation_failed("Invalid ticket key") unless ticket_key.to_s.match?(/\A[A-Z][A-Z0-9_-]*-\d+\z/)
|
|
18
|
+
|
|
19
|
+
suffix = "exec-#{attempt.to_i.to_s.rjust(3, "0")}"
|
|
20
|
+
[ "#{ticket_key}-#{suffix}", "agentboard/#{ticket_key}/#{suffix}" ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create!(ticket_key:, attempt:, base_ref: "HEAD")
|
|
24
|
+
dir_name, branch = self.class.name_for(ticket_key, attempt)
|
|
25
|
+
FileUtils.mkdir_p(workspace, mode: 0o700)
|
|
26
|
+
ensure_ignored!
|
|
27
|
+
path = PathGuard.within!(workspace, dir_name)
|
|
28
|
+
raise Error.workspace_conflict("Worktree #{dir_name} already exists") if File.exist?(path)
|
|
29
|
+
|
|
30
|
+
base_sha = repository.git("rev-parse", "--verify", "#{base_ref}^{commit}").strip
|
|
31
|
+
repository.git("worktree", "add", "-b", branch, path, base_sha)
|
|
32
|
+
Worktree.new(path: path, branch: branch, base_sha: base_sha)
|
|
33
|
+
rescue Repository::CommandFailed => e
|
|
34
|
+
raise Error.workspace_conflict(e.message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def head_sha(path) = repository.head_sha(chdir: guard(path))
|
|
38
|
+
def clean?(path) = repository.clean?(chdir: guard(path))
|
|
39
|
+
|
|
40
|
+
# Commits any pending work in the worktree on behalf of the execution, so the result is
|
|
41
|
+
# bound to an immutable SHA. Returns the new head.
|
|
42
|
+
def snapshot!(path, message:)
|
|
43
|
+
dir = guard(path)
|
|
44
|
+
return head_sha(dir) if repository.clean?(chdir: dir)
|
|
45
|
+
|
|
46
|
+
repository.git("add", "-A", chdir: dir)
|
|
47
|
+
repository.git("-c", "user.name=AgentBoard", "-c", "user.email=agentboard@localhost", "commit", "--no-verify", "-m", message, chdir: dir)
|
|
48
|
+
head_sha(dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def changes(path, base_sha)
|
|
52
|
+
dir = guard(path)
|
|
53
|
+
head = head_sha(dir)
|
|
54
|
+
{ head_sha: head, files: repository.changed_files(base_sha, head, chdir: dir), stat: repository.diff_stat(base_sha, head, chdir: dir) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Removal requires: no uncommitted changes unless force (explicit confirmation).
|
|
58
|
+
def remove!(path, force: false, delete_branch: false)
|
|
59
|
+
dir = guard(path)
|
|
60
|
+
return false unless File.directory?(dir)
|
|
61
|
+
if !force && !repository.clean?(chdir: dir)
|
|
62
|
+
raise Error.workspace_conflict("Worktree has uncommitted changes; confirm with --force")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
branch = repository.current_branch(chdir: dir)
|
|
66
|
+
args = [ "worktree", "remove" ]
|
|
67
|
+
args << "--force" if force
|
|
68
|
+
repository.git(*args, dir)
|
|
69
|
+
repository.git("branch", "-D", branch, allow_failure: true) if delete_branch && branch.start_with?("agentboard/")
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def prune! = repository.git("worktree", "prune")
|
|
74
|
+
|
|
75
|
+
def list
|
|
76
|
+
# Compare real paths: on macOS /var and /tmp are symlinks into /private.
|
|
77
|
+
real = File.exist?(workspace) ? File.realpath(workspace) : workspace
|
|
78
|
+
repository.worktrees.select { |w| w["worktree"].to_s.start_with?(real) || w["worktree"].to_s.start_with?(workspace) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def guard(path) = PathGuard.within!(workspace, path)
|
|
84
|
+
|
|
85
|
+
def ensure_ignored!
|
|
86
|
+
exclude = File.join(repository.git("rev-parse", "--git-common-dir").strip.then { |d| File.expand_path(d, repository.root) }, "info", "exclude")
|
|
87
|
+
top = File.realpath(repository.toplevel)
|
|
88
|
+
ws = File.realpath(workspace)
|
|
89
|
+
return unless ws.start_with?(top + "/") # workspace outside repo
|
|
90
|
+
rel = ws.delete_prefix(top + "/")
|
|
91
|
+
|
|
92
|
+
# Local AgentBoard state never shows up as untracked files in the main checkout.
|
|
93
|
+
wanted = [ "/#{rel}/" ]
|
|
94
|
+
state_dir = File.dirname(ws)
|
|
95
|
+
if File.basename(state_dir) == ".agentboard"
|
|
96
|
+
base = state_dir.delete_prefix(top + "/")
|
|
97
|
+
wanted += %w[logs cache spool manifests].map { |d| "/#{base}/#{d}/" }
|
|
98
|
+
end
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(exclude))
|
|
100
|
+
lines = File.exist?(exclude) ? File.read(exclude).lines.map(&:strip) : []
|
|
101
|
+
missing = wanted - lines
|
|
102
|
+
File.open(exclude, "a") { |f| missing.each { |l| f.puts(l) } } if missing.any?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
require "fileutils"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Application-generated identifiers. UUIDv7 is time-ordered, portable across
|
|
3
|
+
# PostgreSQL and SQLite, and safe to generate outside the database (spec §23.1).
|
|
4
|
+
module Ids
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def uuid = SecureRandom.uuid_v7
|
|
8
|
+
def correlation = "c-#{SecureRandom.hex(8)}"
|
|
9
|
+
def token(bytes = 32) = SecureRandom.urlsafe_base64(bytes)
|
|
10
|
+
def digest(value) = Digest::SHA256.hexdigest(value.to_s)
|
|
11
|
+
|
|
12
|
+
# Constant-time comparison of a presented secret with a stored digest.
|
|
13
|
+
def secure_match?(presented, stored_digest)
|
|
14
|
+
return false if presented.nil? || stored_digest.nil?
|
|
15
|
+
|
|
16
|
+
a = digest(presented)
|
|
17
|
+
a.bytesize == stored_digest.bytesize && OpenSSL.fixed_length_secure_compare(a, stored_digest)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Canonical JSON (sorted keys) for digests of definitions and approval actions.
|
|
21
|
+
def canonical_json(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then "{" + value.map { |k, v| [ k.to_s, v ] }.sort_by(&:first).map { |k, v| "#{k.to_json}:#{canonical_json(v)}" }.join(",") + "}"
|
|
24
|
+
when Array then "[" + value.map { |v| canonical_json(v) }.join(",") + "]"
|
|
25
|
+
when Symbol then value.to_s.to_json
|
|
26
|
+
else value.to_json
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def canonical_digest(value) = digest(canonical_json(value))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
require "openssl"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module Agentboard
|
|
4
|
+
# Structured log records (spec §32.1): timestamp, level, component, correlation and
|
|
5
|
+
# execution ids, event name. Messages are redacted; payloads are never logged.
|
|
6
|
+
class JsonLogFormatter < ::Logger::Formatter
|
|
7
|
+
def initialize(component: "runtime")
|
|
8
|
+
super()
|
|
9
|
+
@component = component
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(severity, time, _progname, msg)
|
|
13
|
+
record = { ts: time.utc.iso8601(3), level: severity.downcase, component: @component, pid: ::Process.pid }
|
|
14
|
+
if defined?(Agentboard::Current)
|
|
15
|
+
record[:correlation_id] = Agentboard::Current.correlation_id if Agentboard::Current.correlation_id
|
|
16
|
+
end
|
|
17
|
+
record[:thread] = Thread.current.name if Thread.current.name
|
|
18
|
+
text = msg.is_a?(Exception) ? "#{msg.class}: #{msg.message}" : msg.to_s
|
|
19
|
+
if (m = text.match(/\A\[agentboard(?:\.(\w+))?\]\s*/))
|
|
20
|
+
record[:component] = m[1] if m[1]
|
|
21
|
+
text = m.post_match
|
|
22
|
+
end
|
|
23
|
+
record[:execution_id] = text[/\b([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})\b/, 1] if text.include?("execution")
|
|
24
|
+
record[:message] = Redactor.redact(text)[0, 4000]
|
|
25
|
+
JSON.generate(record.compact) + "\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Canonicalizes paths and rejects traversal or symlink escape outside an allowed root
|
|
3
|
+
# (spec §29.2, §30.2). Used for every AgentBoard-managed file operation.
|
|
4
|
+
module PathGuard
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def within!(root, path)
|
|
8
|
+
root_real = File.realpath(root.to_s)
|
|
9
|
+
candidate = File.expand_path(path.to_s, root_real)
|
|
10
|
+
# Resolve the deepest existing ancestor so symlinks anywhere in the chain are followed.
|
|
11
|
+
probe = candidate
|
|
12
|
+
probe = File.dirname(probe) until File.exist?(probe) || probe == "/"
|
|
13
|
+
resolved = File.join(File.realpath(probe), candidate.delete_prefix(probe))
|
|
14
|
+
resolved = File.expand_path(resolved)
|
|
15
|
+
unless resolved == root_real || resolved.start_with?(root_real + File::SEPARATOR)
|
|
16
|
+
raise Error.validation_failed("Path escapes the allowed workspace", path: path.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
resolved
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def safe_relative?(path)
|
|
23
|
+
p = path.to_s
|
|
24
|
+
!p.empty? && !p.start_with?("/") && !p.split(%r{[/\\]}).include?("..") && !p.include?("\0")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Finds the agent process (claude, codex, cursor-agent…) that spawned the current process.
|
|
3
|
+
# MCP servers and hook commands are both children of the same agent process, which lets
|
|
4
|
+
# AgentBoard correlate an MCP session with the provider's hook-reported session without
|
|
5
|
+
# trusting any identifier supplied by the agent. Works on macOS and Linux (`ps`).
|
|
6
|
+
module ProcessAncestry
|
|
7
|
+
AGENT_COMMANDS = /\A(claude|codex|cursor-agent|cursor|gemini|opencode|copilot)\z/i
|
|
8
|
+
MAX_DEPTH = 12
|
|
9
|
+
|
|
10
|
+
Agent = Struct.new(:pid, :command, :fingerprint, keyword_init: true) do
|
|
11
|
+
def to_h = { "agent_pid" => pid, "agent_command" => command, "agent_fingerprint" => fingerprint }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def find_agent(start_pid = ::Process.ppid)
|
|
17
|
+
pid = start_pid
|
|
18
|
+
MAX_DEPTH.times do
|
|
19
|
+
break if pid.nil? || pid <= 1
|
|
20
|
+
|
|
21
|
+
ppid, comm = info(pid)
|
|
22
|
+
name = File.basename(comm.to_s)
|
|
23
|
+
return Agent.new(pid: pid, command: name, fingerprint: Subprocess.fingerprint(pid)) if name.match?(AGENT_COMMANDS)
|
|
24
|
+
|
|
25
|
+
pid = ppid
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def info(pid)
|
|
31
|
+
out, _e, st = Open3.capture3("ps", "-o", "ppid=,comm=", "-p", pid.to_s)
|
|
32
|
+
return [ nil, nil ] unless st.success?
|
|
33
|
+
|
|
34
|
+
ppid, comm = out.strip.split(/\s+/, 2)
|
|
35
|
+
[ ppid.to_i, comm ]
|
|
36
|
+
rescue StandardError
|
|
37
|
+
[ nil, nil ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# Provider adapter contract. Passive (MCP/hooks/event ingestion) and active
|
|
4
|
+
# (launch/stream/cancel/resume) are separate concerns so partial support is explicit.
|
|
5
|
+
# Unsupported features raise UnsupportedCapability (CAPABILITY_UNAVAILABLE).
|
|
6
|
+
class Base
|
|
7
|
+
class << self
|
|
8
|
+
def key = raise(NotImplementedError)
|
|
9
|
+
def display_name = key.to_s.capitalize
|
|
10
|
+
def binary = nil
|
|
11
|
+
|
|
12
|
+
# Compatibility manifest: lib/agentboard/providers/compatibility/<key>.yml
|
|
13
|
+
def compatibility
|
|
14
|
+
@compatibility ||= begin
|
|
15
|
+
path = File.expand_path("compatibility/#{key}.yml", __dir__)
|
|
16
|
+
File.exist?(path) ? YAML.safe_load(File.read(path), aliases: false) : { "provider" => key.to_s, "validated" => [] }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :settings
|
|
22
|
+
|
|
23
|
+
def initialize(settings: nil)
|
|
24
|
+
@settings = settings || Agentboard.config.provider_settings(self.class.key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def key = self.class.key.to_s
|
|
28
|
+
def display_name = self.class.display_name
|
|
29
|
+
|
|
30
|
+
# -- discovery -----------------------------------------------------------------
|
|
31
|
+
def probe(environment: ENV) = raise(NotImplementedError)
|
|
32
|
+
def capabilities(environment: ENV) = probe(environment: environment).supported_capabilities
|
|
33
|
+
def detect = probe.installed
|
|
34
|
+
def supports?(cap) = Array(capabilities).include?(cap.to_sym)
|
|
35
|
+
|
|
36
|
+
# -- passive -------------------------------------------------------------------
|
|
37
|
+
def install_passive_hooks(project:, root:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
|
|
38
|
+
def remove_passive_hooks(project:, root:, manifest:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
|
|
39
|
+
def configure_passive(context:) = install_passive_hooks(project: context[:project], root: context[:root])
|
|
40
|
+
|
|
41
|
+
# Maps a raw hook payload to [NormalizedEvent] (hook ingestion).
|
|
42
|
+
def parse_hook(event_name:, payload:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
|
|
43
|
+
|
|
44
|
+
# -- active --------------------------------------------------------------------
|
|
45
|
+
def launch_spec(execution:, worktree:, context_path:, prompt:) = raise(UnsupportedCapability.new(:active_execution, provider: key))
|
|
46
|
+
|
|
47
|
+
# Parses one line of provider stdout into zero or more NormalizedEvents.
|
|
48
|
+
def parse_event(raw_event:) = raise(UnsupportedCapability.new(:structured_events, provider: key))
|
|
49
|
+
|
|
50
|
+
def resume_spec(execution:, worktree:, input:) = raise(UnsupportedCapability.new(:session_resume, provider: key))
|
|
51
|
+
|
|
52
|
+
# Cancellation is performed by the supervisor via the process group; providers that
|
|
53
|
+
# need protocol-level interruption override this.
|
|
54
|
+
def interrupt(handle:)
|
|
55
|
+
raise UnsupportedCapability.new(:cancellation, provider: key) unless supports?(:cancellation)
|
|
56
|
+
|
|
57
|
+
Subprocess.terminate(handle.fetch(:pgid), grace: Agentboard.config.runtime["cancel_grace_seconds"].to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Back-compat names from spec §5.
|
|
61
|
+
def start(execution:) = raise(UnsupportedCapability.new(:active_execution, provider: key))
|
|
62
|
+
def events(session:, cursor: nil) = raise(UnsupportedCapability.new(:structured_events, provider: key))
|
|
63
|
+
def cancel(session:) = raise(UnsupportedCapability.new(:cancellation, provider: key))
|
|
64
|
+
def resume(handle:, input: nil) = raise(UnsupportedCapability.new(:session_resume, provider: key))
|
|
65
|
+
|
|
66
|
+
protected
|
|
67
|
+
|
|
68
|
+
def run_version(argv)
|
|
69
|
+
out, _err, status = Open3.capture3(Subprocess.scrubbed_env, *argv)
|
|
70
|
+
status.success? ? out.strip : nil
|
|
71
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def which(bin)
|
|
76
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
77
|
+
path = File.join(dir, bin)
|
|
78
|
+
return path if File.file?(path) && File.executable?(path)
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Conservative capability negotiation (AT-14): capabilities listed for a validated
|
|
84
|
+
# version are enabled; unknown versions only get passive, low-risk capabilities unless
|
|
85
|
+
# the operator explicitly opts in with `allow_unvalidated_versions: true`.
|
|
86
|
+
def negotiate(version, all_caps, safe_caps: %i[mcp])
|
|
87
|
+
entry = Array(self.class.compatibility["validated"]).find { |v| version.to_s.include?(v["binary_version"].to_s) }
|
|
88
|
+
if entry
|
|
89
|
+
caps = Array(entry["capabilities"]).map(&:to_sym) & all_caps
|
|
90
|
+
[ caps, entry["status"] || "validated", [ entry["smoke_test"] ].compact ]
|
|
91
|
+
elsif settings["allow_unvalidated_versions"]
|
|
92
|
+
[ all_caps, "unvalidated (operator opt-in)", [ "#{display_name} #{version} is not in the compatibility manifest; running with operator opt-in." ] ]
|
|
93
|
+
else
|
|
94
|
+
[ safe_caps & all_caps, "unknown version",
|
|
95
|
+
[ "#{display_name} #{version || "(unknown)"} is not in the compatibility manifest. Active execution is disabled; " \
|
|
96
|
+
"set providers.#{key}.allow_unvalidated_versions: true to opt in after testing." ] ]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def safe_json(line)
|
|
101
|
+
JSON.parse(line)
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|