agentboard 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. metadata +285 -0
@@ -0,0 +1,47 @@
1
+ # Deterministic fake coding agent used by AgentBoard's Fake provider.
2
+ # Reads the prompt from stdin, emits JSONL on stdout and edits the current directory.
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ $stdout.sync = true
7
+ args = ARGV.each_slice(2).to_h
8
+ scenario = args.fetch("--scenario", "success")
9
+ prompt = $stdin.read.to_s
10
+ ticket = ENV.fetch("AGENTBOARD_TICKET", "UNKNOWN")
11
+
12
+ emit = ->(h) { puts(JSON.generate(h)) }
13
+ trap("TERM") { emit.call(type: "message", text: "received SIGTERM, stopping"); exit 143 }
14
+
15
+ emit.call(type: "session", session_id: args["--session"] || "fake-#{Process.pid}", model: "fake-1")
16
+ emit.call(type: "message", text: "Read #{prompt.bytesize} bytes of context for #{ticket}.")
17
+
18
+ case scenario
19
+ when "malformed"
20
+ puts "this is not json {"
21
+ emit.call(type: "mystery", foo: 1)
22
+ when "secret"
23
+ emit.call(type: "message", text: "Found key sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 in env")
24
+ when "permission"
25
+ emit.call(type: "permission", tool: "Bash", reason: "wants to run rm -rf tmp/")
26
+ when "env"
27
+ # Simulates an agent that obeys a prompt-injected instruction to dump its environment.
28
+ ENV.sort.each { |k, v| emit.call(type: "message", text: "#{k}=#{v}") }
29
+ when "resume"
30
+ emit.call(type: "message", text: "Resumed session #{args["--session"]}: #{prompt.strip[0, 80]}")
31
+ when "slow"
32
+ 20.times { |i| emit.call(type: "message", text: "working… #{i}"); sleep 0.5 }
33
+ end
34
+
35
+ unless scenario == "noop"
36
+ emit.call(type: "tool_start", tool: "Edit", target: "AGENTBOARD_NOTES.md")
37
+ File.open("AGENTBOARD_NOTES.md", "a") { |f| f.puts("- #{ticket}: change by fake agent (#{scenario})") }
38
+ emit.call(type: "tool_end", tool: "Edit", target: "AGENTBOARD_NOTES.md", ok: true)
39
+ end
40
+ emit.call(type: "usage", input_tokens: prompt.bytesize / 4, output_tokens: 42)
41
+
42
+ if scenario == "fail"
43
+ emit.call(type: "result", ok: false, summary: "Could not complete the task")
44
+ exit 1
45
+ end
46
+ emit.call(type: "result", ok: true, summary: "Implemented #{ticket} (fake)")
47
+ exit 0
@@ -0,0 +1,35 @@
1
+ module Agentboard
2
+ module Providers
3
+ # Placeholder adapters for P2 providers (Gemini CLI, OpenCode, Copilot). They detect the
4
+ # binary and report MCP-only capability; active execution is unavailable until a
5
+ # fixture-backed adapter is written. This keeps the UI honest about support levels.
6
+ class GenericCli < Base
7
+ class << self
8
+ attr_reader :provider_key, :binary_name, :display
9
+
10
+ def for(key:, binary:, display:)
11
+ Class.new(self) do
12
+ @provider_key = key
13
+ @binary_name = binary
14
+ @display = display
15
+ end
16
+ end
17
+
18
+ def key = provider_key.to_sym
19
+ def display_name = display
20
+ def binary = binary_name
21
+ end
22
+
23
+ def probe(environment: ENV)
24
+ bin = which(self.class.binary)
25
+ version = bin && run_version([ bin, "--version" ]).to_s[/\d+\.\d+(?:\.\d+)?/]
26
+ ProbeResult.new(provider: key, installed: !bin.nil?, version: version, authenticated: nil,
27
+ supported_capabilities: bin ? %i[mcp] : [], validation: bin ? "detected (P2, MCP only)" : "not installed",
28
+ diagnostics: [ "#{display_name}: only MCP configuration is supported in this release." ])
29
+ end
30
+
31
+ def launch_spec(**) = raise(UnsupportedCapability.new(:active_execution, provider: key))
32
+ def parse_event(raw_event:) = [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s[0, 4000] }) ]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ module Agentboard
2
+ module Providers
3
+ class Registry
4
+ REQUIRED_METHODS = %i[probe parse_event launch_spec].freeze
5
+
6
+ def initialize
7
+ @adapters = {}
8
+ end
9
+
10
+ def register(key, adapter)
11
+ key = key.to_s
12
+ raise ArgumentError, "provider key must match /\\A[a-z][a-z0-9_]*\\z/" unless key.match?(/\A[a-z][a-z0-9_]*\z/)
13
+ missing = REQUIRED_METHODS.reject { |m| adapter.method_defined?(m) }
14
+ raise ArgumentError, "#{adapter} must implement #{missing.join(", ")}" if missing.any?
15
+
16
+ @adapters[key] = adapter
17
+ end
18
+
19
+ def keys = @adapters.keys
20
+ def registered?(key) = @adapters.key?(key.to_s)
21
+
22
+ def adapter_class(key)
23
+ @adapters.fetch(key.to_s) { raise Error.not_found("Unknown provider #{key}") }
24
+ end
25
+
26
+ def fetch(key, settings: nil) = adapter_class(key).new(settings: settings)
27
+ def each(&) = @adapters.each_key.map { |k| fetch(k) }.each(&)
28
+ def all = @adapters.keys.map { |k| fetch(k) }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ module Agentboard
2
+ module Providers
3
+ CAPABILITIES = %i[mcp passive_hooks active_execution structured_events streaming cancellation session_resume permission_requests].freeze
4
+
5
+ # Typed results (spec §26.1). Handles never carry provider secrets.
6
+ ProbeResult = Struct.new(:provider, :installed, :version, :authenticated, :supported_capabilities, :validation, :diagnostics, keyword_init: true) do
7
+ def supports?(cap) = Array(supported_capabilities).include?(cap.to_sym)
8
+
9
+ def to_h
10
+ { provider: provider, installed: installed, version: version, authenticated: authenticated,
11
+ supported_capabilities: Array(supported_capabilities).map(&:to_s), validation: validation, diagnostics: diagnostics }
12
+ end
13
+ end
14
+
15
+ # What the supervisor needs to spawn a provider process safely.
16
+ # mounts: extra host paths mounted read-only when the agent runs in a container.
17
+ # credential_aliases: { "NAME_IN_CONTAINER" => "HOST_VARIABLE" } — forwarded by name only.
18
+ LaunchSpec = Struct.new(:argv, :env, :stdin_data, :chdir, :passthrough_env, :mounts, :credential_aliases, keyword_init: true)
19
+
20
+ # Normalized event produced by `parse_event`.
21
+ NormalizedEvent = Struct.new(:type, :payload, :external_session_id, :usage, :terminal, :status, :provenance, keyword_init: true) do
22
+ def self.build(type, payload = {}, **opts)
23
+ new(type: type, payload: payload, provenance: "provider_reported", terminal: false, **opts)
24
+ end
25
+ end
26
+
27
+ Usage = Struct.new(:input_tokens, :output_tokens, :reported_cost, :currency, :metering_kind, keyword_init: true)
28
+
29
+ # Passive setup outcome: exact changes for display and the reversal manifest.
30
+ SetupResult = Struct.new(:provider, :changes, :manifest, :backups, :warnings, keyword_init: true)
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ require "agentboard/subprocess"
2
+ require "agentboard/providers/types"
3
+ require "agentboard/providers/base"
4
+ require "agentboard/providers/registry"
5
+ require "agentboard/providers/config_editor"
6
+ require "agentboard/providers/fake"
7
+ require "agentboard/providers/claude_code"
8
+ require "agentboard/providers/codex"
9
+ require "agentboard/providers/cursor"
10
+ require "agentboard/providers/generic_cli"
11
+
12
+ module Agentboard
13
+ module Providers
14
+ def self.registry
15
+ @registry ||= Registry.new.tap do |r|
16
+ r.register(:fake, Fake)
17
+ r.register(:claude, ClaudeCode)
18
+ r.register(:codex, Codex)
19
+ r.register(:cursor, Cursor)
20
+ r.register(:gemini, GenericCli.for(key: "gemini", binary: "gemini", display: "Gemini CLI"))
21
+ r.register(:opencode, GenericCli.for(key: "opencode", binary: "opencode", display: "OpenCode"))
22
+ r.register(:copilot, GenericCli.for(key: "copilot", binary: "copilot", display: "GitHub Copilot CLI"))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,49 @@
1
+ module Agentboard
2
+ # Static Rails metadata via Prism — never boots or executes repository code (spec §10).
3
+ module RailsIntrospection
4
+ HTTP_VERBS = %w[get post put patch delete match].freeze
5
+
6
+ module_function
7
+
8
+ # Returns [{ verb:, path:, kind: }] from config/routes.rb.
9
+ def routes(root)
10
+ path = File.join(root.to_s, "config", "routes.rb")
11
+ return [] unless File.file?(path)
12
+
13
+ require "prism"
14
+ found = []
15
+ walk = ->(node) do
16
+ if node.is_a?(Prism::CallNode) && node.receiver.nil?
17
+ name = node.name.to_s
18
+ first = node.arguments&.arguments&.first
19
+ label = case first
20
+ when Prism::SymbolNode then first.unescaped
21
+ when Prism::StringNode then first.unescaped
22
+ when Prism::KeywordHashNode then first.elements.first&.key&.slice
23
+ end
24
+ if %w[resources resource namespace scope].include?(name) && label
25
+ found << { verb: name, path: label.to_s, kind: "resource" }
26
+ elsif HTTP_VERBS.include?(name) && label
27
+ found << { verb: name.upcase, path: label.to_s, kind: "route" }
28
+ elsif name == "root"
29
+ found << { verb: "GET", path: "/", kind: "root" }
30
+ end
31
+ end
32
+ node.compact_child_nodes.each { |c| walk.(c) }
33
+ end
34
+ walk.(Prism.parse_file(path).value)
35
+ found.first(300)
36
+ rescue StandardError, LoadError
37
+ []
38
+ end
39
+
40
+ # Table names created by migrations (static scan).
41
+ def migration_tables(root)
42
+ Dir[File.join(root.to_s, "db", "migrate", "*.rb")].sort.last(200).flat_map do |f|
43
+ File.read(f, 200_000).scan(/create_table\s+[:"']([a-z0-9_]+)/).flatten
44
+ end.uniq
45
+ rescue StandardError
46
+ []
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,62 @@
1
+ module Agentboard
2
+ # Redacts secrets from logs, hook payloads, event fields and exports (spec §14, AT-13).
3
+ # Patterns are intentionally conservative; projects can add their own via configuration.
4
+ module Redactor
5
+ MASK = "[REDACTED]".freeze
6
+
7
+ DEFAULT_PATTERNS = [
8
+ /\bsk-ant-[A-Za-z0-9_\-]{16,}/, # Anthropic
9
+ /\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}/, # OpenAI
10
+ /\bgh[pousr]_[A-Za-z0-9]{30,}/, # GitHub tokens
11
+ /\bgithub_pat_[A-Za-z0-9_]{30,}/,
12
+ /\bxox[abposr]-[A-Za-z0-9\-]{10,}/, # Slack
13
+ /\bAKIA[0-9A-Z]{16}\b/, # AWS access key id
14
+ /\bAIza[0-9A-Za-z_\-]{35}\b/, # Google API key
15
+ /\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}/, # JWT
16
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/,
17
+ /(?<=:\/\/)[^\/\s:@]+:[^\/\s@]+(?=@)/ # credentials in URLs
18
+ ].freeze
19
+
20
+ # key=value / "key": "value" assignments for sensitive-looking keys
21
+ ASSIGNMENT = /((?:api[_-]?key|secret|token|password|passwd|authorization|bearer|private[_-]?key|access[_-]?key|lease_token)["']?\s*[:=]\s*["']?)([^\s"',;}]{6,})/i
22
+
23
+ SENSITIVE_KEYS = /\A(?:.*(?:password|passwd|secret|token|api[_-]?key|authorization|cookie|credential|private[_-]?key).*)\z/i
24
+
25
+ module_function
26
+
27
+ def patterns
28
+ DEFAULT_PATTERNS + Array(Agentboard.config.redaction_patterns)
29
+ end
30
+
31
+ # Returns [redacted_string, count_of_redactions]
32
+ def redact_with_count(text)
33
+ return [ text, 0 ] unless text.is_a?(String)
34
+
35
+ count = 0
36
+ out = text.dup
37
+ patterns.each do |re|
38
+ out = out.gsub(re) { count += 1; MASK }
39
+ end
40
+ out = out.gsub(ASSIGNMENT) { count += 1; "#{$1}#{MASK}" }
41
+ [ out, count ]
42
+ end
43
+
44
+ def redact(text) = redact_with_count(text).first
45
+
46
+ def secret?(text) = redact_with_count(text).last.positive?
47
+
48
+ # Deep redaction of hashes/arrays: sensitive keys are masked entirely,
49
+ # strings are pattern-scanned.
50
+ def redact_value(value)
51
+ case value
52
+ when Hash
53
+ value.each_with_object({}) do |(k, v), h|
54
+ h[k] = (k.to_s.match?(SENSITIVE_KEYS) && !v.is_a?(Hash) && !v.is_a?(Array) && !v.nil?) ? MASK : redact_value(v)
55
+ end
56
+ when Array then value.map { |v| redact_value(v) }
57
+ when String then redact(value)
58
+ else value
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,46 @@
1
+ module Agentboard
2
+ # Command result envelope (spec §24.3):
3
+ # { ok: true, data: {...}, correlation_id: "c-123" }
4
+ # { ok: false, error: { code:, message:, retryable:, details: }, correlation_id: "c-123" }
5
+ class Result
6
+ attr_reader :data, :error, :correlation_id
7
+
8
+ def self.success(data = {}, correlation_id: Ids.correlation) = new(data: data, correlation_id: correlation_id)
9
+ def self.failure(error, correlation_id: Ids.correlation) = new(error: error, correlation_id: correlation_id)
10
+
11
+ # Runs the block, converting Agentboard::Error into a failure envelope and any other
12
+ # exception into an opaque INTERNAL_ERROR (never leaking the original message).
13
+ def self.capture(correlation_id: Ids.correlation)
14
+ success(yield, correlation_id: correlation_id)
15
+ rescue Agentboard::Error => e
16
+ failure(e, correlation_id: correlation_id)
17
+ rescue ActiveRecord::StaleObjectError => e
18
+ failure(Error.version_conflict("Record was modified concurrently; reload and retry"), correlation_id: correlation_id)
19
+ rescue ActiveRecord::RecordNotFound
20
+ failure(Error.not_found("Record not found"), correlation_id: correlation_id)
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ failure(Error.validation_failed(e.record.errors.full_messages.to_sentence), correlation_id: correlation_id)
23
+ rescue StandardError => e
24
+ Agentboard.logger.error("[agentboard] #{correlation_id} #{e.class}: #{Redactor.redact(e.message)}")
25
+ failure(Error.internal_error("Internal error (correlation #{correlation_id})"), correlation_id: correlation_id)
26
+ end
27
+
28
+ def initialize(data: nil, error: nil, correlation_id:)
29
+ @data = data
30
+ @error = error
31
+ @correlation_id = correlation_id
32
+ end
33
+
34
+ def ok? = error.nil?
35
+
36
+ def to_h
37
+ if ok?
38
+ { ok: true, data: data, correlation_id: correlation_id }
39
+ else
40
+ { ok: false, error: error.to_h, correlation_id: correlation_id }
41
+ end
42
+ end
43
+
44
+ def to_json(*args) = to_h.to_json(*args)
45
+ end
46
+ end
@@ -0,0 +1,193 @@
1
+ require "socket"
2
+
3
+ module Agentboard
4
+ module Runtime
5
+ # Local runtime IPC (spec §27.2): Unix domain socket in a user-private directory (0700),
6
+ # socket file 0600, plus a per-daemon bearer token (0600) checked on every request.
7
+ # Frames are 4-byte big-endian length + JSON, with a protocol version and size limit.
8
+ module IPC
9
+ PROTOCOL_VERSION = 1
10
+ MAX_MESSAGE_BYTES = 1024 * 1024
11
+
12
+ module_function
13
+
14
+ def paths(config = Agentboard.config)
15
+ dir = config.runtime_dir
16
+ id = Digest::SHA256.hexdigest(config.root_path.to_s)[0, 12]
17
+ { dir: dir, socket: dir.join("#{id}.sock"), token: dir.join("#{id}.token"), pid: dir.join("#{id}.pid"), log: dir.join("#{id}.log") }
18
+ end
19
+
20
+ def ensure_private_dir!(dir)
21
+ FileUtils.mkdir_p(dir, mode: 0o700)
22
+ stat = File.stat(dir)
23
+ raise Error.forbidden("Runtime directory #{dir} is not owned by the current user") unless stat.owned?
24
+
25
+ File.chmod(0o700, dir)
26
+ end
27
+
28
+ def write_frame(io, obj)
29
+ data = JSON.generate(obj)
30
+ raise Error.validation_failed("IPC message too large") if data.bytesize > MAX_MESSAGE_BYTES
31
+
32
+ io.write([ data.bytesize ].pack("N") + data)
33
+ end
34
+
35
+ def read_frame(io, timeout: 10)
36
+ header = read_exact(io, 4, timeout)
37
+ return nil unless header
38
+
39
+ size = header.unpack1("N")
40
+ raise Error.validation_failed("IPC message too large") if size > MAX_MESSAGE_BYTES
41
+
42
+ JSON.parse(read_exact(io, size, timeout) || raise(Error.validation_failed("truncated IPC frame")))
43
+ end
44
+
45
+ def read_exact(io, n, timeout)
46
+ buf = +""
47
+ while buf.bytesize < n
48
+ return nil unless IO.select([ io ], nil, nil, timeout)
49
+
50
+ chunk = io.read_nonblock(n - buf.bytesize, exception: false)
51
+ return nil if chunk.nil?
52
+ next if chunk == :wait_readable
53
+
54
+ buf << chunk
55
+ end
56
+ buf
57
+ end
58
+
59
+ class Server
60
+ def initialize(handler:, config: Agentboard.config, logger: Agentboard.logger)
61
+ @handler = handler
62
+ @paths = IPC.paths(config)
63
+ @logger = logger
64
+ @running = false
65
+ end
66
+
67
+ def start
68
+ IPC.ensure_private_dir!(@paths[:dir])
69
+ File.delete(@paths[:socket]) if File.socket?(@paths[:socket])
70
+ @token = Ids.token
71
+ File.write(@paths[:token], @token, perm: 0o600)
72
+ File.chmod(0o600, @paths[:token])
73
+ old = File.umask(0o177)
74
+ @server = UNIXServer.new(@paths[:socket].to_s)
75
+ File.umask(old)
76
+ File.chmod(0o600, @paths[:socket])
77
+ @running = true
78
+ @thread = Thread.new { accept_loop }
79
+ self
80
+ end
81
+
82
+ def stop
83
+ @running = false
84
+ @server&.close
85
+ File.delete(@paths[:socket]) if File.socket?(@paths[:socket])
86
+ File.delete(@paths[:token]) if File.exist?(@paths[:token])
87
+ rescue SystemCallError, IOError
88
+ nil
89
+ end
90
+
91
+ private
92
+
93
+ def accept_loop
94
+ while @running
95
+ client = @server.accept
96
+ Thread.new(client) { |c| serve(c) }
97
+ end
98
+ rescue IOError, Errno::EBADF
99
+ nil
100
+ end
101
+
102
+ def serve(client)
103
+ request = IPC.read_frame(client)
104
+ return unless request
105
+
106
+ response = dispatch(request)
107
+ IPC.write_frame(client, response)
108
+ rescue StandardError => e
109
+ IPC.write_frame(client, { "ok" => false, "error" => { "code" => "INTERNAL_ERROR", "message" => e.class.name } }) rescue nil
110
+ ensure
111
+ client.close
112
+ end
113
+
114
+ def dispatch(request)
115
+ unless request["protocol"] == PROTOCOL_VERSION
116
+ return { "ok" => false, "error" => { "code" => "VALIDATION_FAILED", "message" => "unsupported protocol #{request["protocol"].inspect}" } }
117
+ end
118
+ presented = request["token"].to_s
119
+ unless presented.bytesize == @token.bytesize && OpenSSL.fixed_length_secure_compare(presented, @token)
120
+ @logger.warn("[agentboard] rejected unauthenticated IPC request")
121
+ return { "ok" => false, "error" => { "code" => "UNAUTHORIZED", "message" => "invalid runtime credential" } }
122
+ end
123
+
124
+ result = Result.capture(correlation_id: request["correlation_id"].presence || Ids.correlation) do
125
+ @handler.call(request["op"].to_s, request["params"] || {})
126
+ end
127
+ JSON.parse(result.to_json)
128
+ end
129
+ end
130
+
131
+ class Client
132
+ def initialize(config: Agentboard.config)
133
+ @paths = IPC.paths(config)
134
+ end
135
+
136
+ def available? = File.socket?(@paths[:socket]) && File.exist?(@paths[:token])
137
+
138
+ def call(op, params = {}, timeout: 30)
139
+ raise Error.provider_unavailable("Runtime daemon is not running (agentboard daemon start)") unless available?
140
+
141
+ token = File.read(@paths[:token]).strip
142
+ UNIXSocket.open(@paths[:socket].to_s) do |sock|
143
+ IPC.write_frame(sock, { "protocol" => PROTOCOL_VERSION, "op" => op, "params" => params, "token" => token, "correlation_id" => Ids.correlation })
144
+ IPC.read_frame(sock, timeout: timeout) || raise(Error.provider_unavailable("No response from runtime"))
145
+ end
146
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
147
+ raise Error.provider_unavailable("Runtime daemon is not reachable")
148
+ end
149
+ end
150
+ end
151
+
152
+ # Daemon lifecycle helpers: PID record with liveness validation (never trust a PID file alone).
153
+ module DaemonControl
154
+ module_function
155
+
156
+ def status(config = Agentboard.config)
157
+ paths = IPC.paths(config)
158
+ pid = File.exist?(paths[:pid]) ? File.read(paths[:pid]).to_i : nil
159
+ alive = pid && pid.positive? && Subprocess.alive?(pid)
160
+ health = nil
161
+ if alive && IPC::Client.new(config: config).available?
162
+ response = IPC::Client.new(config: config).call("runtime.health", {}, timeout: 3) rescue nil
163
+ health = response && response["ok"] ? response["data"] : nil
164
+ end
165
+ { running: !!(alive && health), pid: alive ? pid : nil, stale_pid_file: pid && !alive, health: health, socket: paths[:socket].to_s, log: paths[:log].to_s }
166
+ end
167
+
168
+ def write_pid!(config = Agentboard.config)
169
+ paths = IPC.paths(config)
170
+ IPC.ensure_private_dir!(paths[:dir])
171
+ lock = File.open(paths[:pid], File::RDWR | File::CREAT, 0o600)
172
+ raise Error.workspace_conflict("Another AgentBoard daemon is running for this project") unless lock.flock(File::LOCK_EX | File::LOCK_NB)
173
+
174
+ lock.truncate(0)
175
+ lock.write(::Process.pid.to_s)
176
+ lock.flush
177
+ lock # keep open to hold the lock for the daemon lifetime
178
+ end
179
+
180
+ def stop(config = Agentboard.config, timeout: 20)
181
+ st = status(config)
182
+ return false unless st[:pid]
183
+
184
+ ::Process.kill("TERM", st[:pid])
185
+ deadline = Subprocess.monotonic + timeout
186
+ sleep 0.1 while Subprocess.alive?(st[:pid]) && Subprocess.monotonic < deadline
187
+ !Subprocess.alive?(st[:pid])
188
+ rescue Errno::ESRCH
189
+ true
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1 @@
1
+ require "agentboard/runtime/ipc"
@@ -0,0 +1,71 @@
1
+ module Agentboard
2
+ # Pure transition tables. Guards that need persistence (leases, evidence, approvals)
3
+ # are enforced by the domain services in app/services/agentboard.
4
+ module StateMachines
5
+ module Ticket
6
+ STATES = %w[backlog ready claimed in_progress testing review done blocked cancelled reopened].freeze
7
+ TERMINAL = %w[done cancelled].freeze
8
+ NONTERMINAL = (STATES - TERMINAL).freeze
9
+ BOARD_COLUMNS = %w[backlog ready claimed in_progress testing review done].freeze
10
+
11
+ # spec §24.1 — anything not listed is INVALID_TRANSITION
12
+ TRANSITIONS = {
13
+ "backlog" => %w[ready blocked cancelled],
14
+ "ready" => %w[claimed blocked cancelled backlog],
15
+ "claimed" => %w[in_progress ready blocked cancelled],
16
+ "in_progress" => %w[testing blocked cancelled],
17
+ "testing" => %w[review in_progress blocked cancelled],
18
+ "review" => %w[done in_progress blocked cancelled],
19
+ "blocked" => %w[ready cancelled],
20
+ "reopened" => %w[ready blocked cancelled],
21
+ "done" => %w[reopened],
22
+ "cancelled" => []
23
+ }.freeze
24
+
25
+ LABELS = {
26
+ "backlog" => "Backlog", "ready" => "Ready", "claimed" => "Claimed", "in_progress" => "In progress",
27
+ "testing" => "Testing", "review" => "Review", "done" => "Done", "blocked" => "Blocked",
28
+ "cancelled" => "Cancelled", "reopened" => "Reopened"
29
+ }.freeze
30
+
31
+ module_function
32
+
33
+ def allowed?(from, to) = TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
34
+ def next_states(from) = TRANSITIONS.fetch(from.to_s, [])
35
+
36
+ def assert!(from, to)
37
+ return if allowed?(from, to)
38
+
39
+ raise Error.invalid_transition("Ticket cannot move from #{from} to #{to}", from: from, to: to)
40
+ end
41
+ end
42
+
43
+ module Execution
44
+ STATES = %w[queued preparing running waiting_for_input waiting_for_permission verifying completed failed cancelled interrupted].freeze
45
+ TERMINAL = %w[completed failed cancelled interrupted].freeze
46
+ ACTIVE = (STATES - TERMINAL).freeze
47
+
48
+ # spec §24.2
49
+ TRANSITIONS = {
50
+ "queued" => %w[preparing cancelled failed],
51
+ "preparing" => %w[running failed cancelled interrupted],
52
+ "running" => %w[verifying waiting_for_input waiting_for_permission failed cancelled interrupted],
53
+ "waiting_for_input" => %w[running failed cancelled interrupted],
54
+ "waiting_for_permission" => %w[running failed cancelled interrupted],
55
+ "verifying" => %w[completed failed cancelled interrupted],
56
+ "completed" => [], "failed" => [], "cancelled" => [], "interrupted" => []
57
+ }.freeze
58
+
59
+ module_function
60
+
61
+ def allowed?(from, to) = TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
62
+ def terminal?(state) = TERMINAL.include?(state.to_s)
63
+
64
+ def assert!(from, to)
65
+ return if allowed?(from, to)
66
+
67
+ raise Error.invalid_transition("Execution cannot move from #{from} to #{to}", from: from, to: to)
68
+ end
69
+ end
70
+ end
71
+ end