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,168 @@
1
+ module Agentboard
2
+ # Safe subprocess spawning shared by verification and provider launches.
3
+ #
4
+ # - argv arrays only (no shell, no interpolation of ticket text)
5
+ # - own process group so cancellation reaches every child
6
+ # - scrubbed environment (allowlist) to avoid leaking credentials
7
+ # - continuous draining with a byte cap; truncation is marked, never silent
8
+ module Subprocess
9
+ ENV_ALLOWLIST = %w[PATH HOME USER LOGNAME SHELL LANG LC_ALL LC_CTYPE TERM TMPDIR TZ
10
+ RBENV_VERSION RBENV_ROOT MISE_DATA_DIR MISE_CONFIG_DIR ASDF_DIR ASDF_DATA_DIR
11
+ GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_GEMFILE RUBYOPT RUBYLIB
12
+ NODE_PATH XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME SSL_CERT_FILE].freeze
13
+
14
+ Result = Struct.new(:exit_code, :signal, :timed_out, :cancelled, :duration, :output, :truncated, keyword_init: true) do
15
+ def success? = exit_code == 0 && !timed_out && !cancelled
16
+ end
17
+
18
+ module_function
19
+
20
+ def scrubbed_env(extra = {}, passthrough: [])
21
+ base = ENV.to_h.slice(*(ENV_ALLOWLIST + Array(passthrough)))
22
+ base.merge(extra.transform_keys(&:to_s).transform_values(&:to_s))
23
+ end
24
+
25
+ # Spawns argv; yields each output chunk (already redacted) if a block is given.
26
+ # Returns a handle hash { pid:, pgid:, reader: }.
27
+ def spawn(argv, chdir:, env: {}, passthrough_env: [], stdin_data: nil)
28
+ raise ArgumentError, "argv must be a non-empty Array" unless argv.is_a?(Array) && argv.any?
29
+ raise ArgumentError, "argv elements must be strings" unless argv.all? { |a| a.is_a?(String) }
30
+
31
+ reader, writer = IO.pipe
32
+ in_r, in_w = IO.pipe
33
+ pid = ::Process.spawn(scrubbed_env(env, passthrough: passthrough_env), *argv,
34
+ chdir: chdir.to_s, pgroup: true, in: in_r, out: writer, err: writer,
35
+ unsetenv_others: true, close_others: true)
36
+ writer.close
37
+ in_r.close
38
+ if stdin_data
39
+ Thread.new do
40
+ in_w.write(stdin_data)
41
+ rescue IOError, Errno::EPIPE
42
+ nil
43
+ ensure
44
+ in_w.close unless in_w.closed?
45
+ end
46
+ else
47
+ in_w.close
48
+ end
49
+ { pid: pid, pgid: pid, reader: reader }
50
+ end
51
+
52
+ # Runs to completion with timeout/cancellation. `cancel_check` is polled.
53
+ def run(argv, chdir:, timeout:, env: {}, max_bytes: 1_000_000, grace: 5, cancel_check: nil, log_io: nil, passthrough_env: [], stdin_data: nil, &on_chunk)
54
+ started = monotonic
55
+ handle = spawn(argv, chdir: chdir, env: env, passthrough_env: passthrough_env, stdin_data: stdin_data)
56
+ buffer = +""
57
+ truncated = false
58
+ timed_out = false
59
+ cancelled = false
60
+ reader = handle[:reader]
61
+
62
+ loop do
63
+ if timeout && monotonic - started > timeout
64
+ timed_out = true
65
+ terminate(handle[:pgid], grace: grace)
66
+ break
67
+ end
68
+ if cancel_check&.call
69
+ cancelled = true
70
+ terminate(handle[:pgid], grace: grace)
71
+ break
72
+ end
73
+ ready = IO.select([ reader ], nil, nil, 0.2)
74
+ next unless ready
75
+
76
+ begin
77
+ chunk = reader.read_nonblock(16_384)
78
+ rescue IO::WaitReadable
79
+ next
80
+ rescue EOFError
81
+ break
82
+ end
83
+ chunk = Redactor.redact(chunk.force_encoding("UTF-8").scrub)
84
+ log_io&.write(chunk)
85
+ on_chunk&.call(chunk)
86
+ if buffer.bytesize < max_bytes
87
+ buffer << chunk.byteslice(0, max_bytes - buffer.bytesize)
88
+ else
89
+ truncated = true
90
+ end
91
+ end
92
+ reader.close unless reader.closed?
93
+ _, status = ::Process.wait2(handle[:pid])
94
+ Result.new(exit_code: status.exitstatus, signal: status.termsig, timed_out: timed_out, cancelled: cancelled,
95
+ duration: monotonic - started, output: truncated ? buffer + "\n[agentboard: output truncated at #{max_bytes} bytes]\n" : buffer,
96
+ truncated: truncated)
97
+ rescue Errno::ENOENT => e
98
+ Result.new(exit_code: 127, signal: nil, timed_out: false, cancelled: false, duration: 0, output: "command not found: #{argv.first} (#{e.class})", truncated: false)
99
+ end
100
+
101
+ # Graceful SIGTERM to the process group, SIGKILL after grace period.
102
+ def terminate(pgid, grace: 5)
103
+ signal_group("TERM", pgid)
104
+ deadline = monotonic + grace
105
+ while monotonic < deadline
106
+ return true unless group_alive?(pgid)
107
+
108
+ sleep 0.05
109
+ end
110
+ signal_group("KILL", pgid)
111
+ true
112
+ end
113
+
114
+ def signal_group(sig, pgid)
115
+ ::Process.kill(sig, -pgid)
116
+ rescue Errno::ESRCH, Errno::EPERM
117
+ nil
118
+ end
119
+
120
+ def group_alive?(pgid)
121
+ ::Process.kill(0, -pgid)
122
+ true
123
+ rescue Errno::ESRCH
124
+ false
125
+ rescue Errno::EPERM
126
+ true
127
+ end
128
+
129
+ def alive?(pid)
130
+ ::Process.kill(0, pid)
131
+ true
132
+ rescue Errno::ESRCH
133
+ false
134
+ rescue Errno::EPERM
135
+ true
136
+ end
137
+
138
+ # Best-effort process identity: start time + command, used to avoid trusting reused PIDs.
139
+ def fingerprint(pid)
140
+ out, _e, st = Open3.capture3("ps", "-o", "lstart=,command=", "-p", pid.to_s)
141
+ st.success? ? Digest::SHA256.hexdigest(out.strip)[0, 32] : nil
142
+ rescue StandardError
143
+ nil
144
+ end
145
+
146
+ # Resource usage of a whole process group: { rss_kb:, cpu_seconds:, processes: }.
147
+ # `ps -A -o pid,pgid,rss,time` works on both macOS (BSD ps) and Linux (procps).
148
+ def group_usage(pgid)
149
+ out, _e, st = Open3.capture3("ps", "-A", "-o", "pgid=,rss=,time=")
150
+ return nil unless st.success?
151
+
152
+ rows = out.lines.map(&:split).select { |r| r[0].to_i == pgid.to_i }
153
+ { rss_kb: rows.sum { |r| r[1].to_i }, cpu_seconds: rows.sum { |r| parse_cpu_time(r[2]) }.round(2), processes: rows.size }
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ # Accepts [[dd-]hh:]mm:ss[.xx]
159
+ def parse_cpu_time(str)
160
+ days, rest = str.to_s.include?("-") ? str.split("-", 2) : [ 0, str.to_s ]
161
+ parts = rest.split(":").map(&:to_f)
162
+ parts.unshift(0) while parts.size < 3
163
+ days.to_i * 86_400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
164
+ end
165
+
166
+ def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
167
+ end
168
+ end
@@ -0,0 +1,21 @@
1
+ # AgentBoard agent sandbox image. Build with: bundle exec agentboard sandbox build
2
+ # Contains the project's Ruby toolchain and gems plus the agent CLIs. It runs as your UID
3
+ # with a read-only root filesystem; network egress goes only through the allowlisting proxy.
4
+ ARG RUBY_VERSION=__RUBY_VERSION__
5
+ FROM ruby:${RUBY_VERSION}-slim
6
+
7
+ RUN apt-get update \
8
+ && apt-get install -y --no-install-recommends git ca-certificates curl build-essential libyaml-dev libpq-dev pkg-config nodejs npm \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Agent CLIs (pin versions to the ones validated in AgentBoard's compatibility manifest).
12
+ RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm cache clean --force
13
+
14
+ # Project gems, so agents can run the test suite inside the sandbox. The development group
15
+ # is skipped: it usually holds tooling (and local path gems) the test suite does not need.
16
+ ENV BUNDLE_PATH=/usr/local/bundle BUNDLE_WITHOUT=development
17
+ COPY Gemfile Gemfile.lock* /tmp/app/
18
+ RUN cd /tmp/app && bundle install --jobs 4 && rm -rf /tmp/app && chmod -R a+rX /usr/local/bundle
19
+
20
+ RUN git config --system --add safe.directory '*'
21
+ WORKDIR /workspace
@@ -0,0 +1,79 @@
1
+ require "agentboard/subprocess"
2
+
3
+ module Agentboard
4
+ module Verification
5
+ # Administrator-defined allowlist of verification commands (spec §29.1).
6
+ # Workflows and agents can reference commands only by name.
7
+ class Catalog
8
+ Command = Struct.new(:name, :argv, :timeout_seconds, :required, :working_directory, :env, keyword_init: true) do
9
+ def digest = Ids.canonical_digest(name: name, argv: argv, cwd: working_directory, env: env)
10
+ end
11
+
12
+ attr_reader :commands
13
+
14
+ def initialize(hash)
15
+ @commands = hash.to_h.to_h do |name, spec|
16
+ spec = spec.to_h.transform_keys(&:to_s)
17
+ argv = spec["argv"]
18
+ unless argv.is_a?(Array) && argv.any? && argv.all? { |a| a.is_a?(String) && !a.empty? }
19
+ raise Error.validation_failed("verification command #{name} needs a non-empty argv array of strings")
20
+ end
21
+
22
+ [ name.to_s, Command.new(name: name.to_s, argv: argv.freeze, timeout_seconds: (spec["timeout_seconds"] || 600).to_i.clamp(1, 86_400),
23
+ required: spec.fetch("required", true) != false, working_directory: spec["working_directory"] || "worktree",
24
+ env: (spec["env"] || {}).to_h.transform_keys(&:to_s)) ]
25
+ end
26
+ end
27
+
28
+ def fetch(name)
29
+ commands.fetch(name.to_s) { raise Error.policy_denied("Command #{name} is not in the approved catalog") }
30
+ end
31
+
32
+ def names = commands.keys
33
+ def required_names = commands.values.select(&:required).map(&:name)
34
+ def each(&) = commands.values.each(&)
35
+ end
36
+
37
+ # Executes catalog commands directly (no shell) inside a directory and binds the result
38
+ # to the exact head SHA. A dirty tree is never bound to a commit.
39
+ class Runner
40
+ Outcome = Struct.new(:name, :status, :exit_code, :commit_sha, :command_digest, :output, :duration, :required, keyword_init: true)
41
+
42
+ def initialize(catalog:, max_bytes: 512 * 1024)
43
+ @catalog = catalog
44
+ @max_bytes = max_bytes
45
+ end
46
+
47
+ def run(name, chdir:, cancel_check: nil, timeout: nil, &on_chunk)
48
+ cmd = @catalog.fetch(name)
49
+ repo = Git::Repository.new(chdir)
50
+ head = repo.head_sha(chdir: chdir)
51
+ dirty = !repo.clean?(chdir: chdir)
52
+ limit = [ cmd.timeout_seconds, timeout ].compact.min
53
+ env = cmd.env.merge("CI" => "1", "AGENTBOARD_VERIFICATION" => "1")
54
+ argv = cmd.argv
55
+ container = nil
56
+ if ContainerSandbox.enabled?
57
+ raise Error.capability_unavailable("Container sandbox requested but #{ContainerSandbox.settings["runtime"]} is not running") unless ContainerSandbox.available?
58
+
59
+ container = ContainerSandbox.container_name
60
+ argv = ContainerSandbox.wrap(cmd.argv, workdir: chdir, env: env, name: container)
61
+ end
62
+ result = Subprocess.run(argv, chdir: chdir, timeout: limit, env: env, max_bytes: @max_bytes, cancel_check: cancel_check, &on_chunk)
63
+ ContainerSandbox.kill(container) if container && (result.timed_out || result.cancelled)
64
+ # If the tree changed during the run, the result cannot be attributed to `head`.
65
+ head_after = repo.head_sha(chdir: chdir)
66
+ bound_sha = (dirty || head_after != head) ? "unbound:#{head}" : head
67
+ status =
68
+ if result.timed_out then "timeout"
69
+ elsif result.cancelled then "error"
70
+ elsif result.exit_code == 127 && result.output.start_with?("command not found") then "error"
71
+ elsif result.success? then "passed"
72
+ else "failed"
73
+ end
74
+ Outcome.new(name: cmd.name, status: status, exit_code: result.exit_code, commit_sha: bound_sha, command_digest: cmd.digest,
75
+ output: result.output, duration: result.duration, required: cmd.required)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module Agentboard
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,166 @@
1
+ module Agentboard
2
+ module Workflows
3
+ # Restricted condition grammar (spec §28.2) — never `eval`.
4
+ #
5
+ # expr := or
6
+ # or := and ("or" and)*
7
+ # and := unary ("and" unary)*
8
+ # unary := "not" unary | primary
9
+ # primary := "(" expr ")" | ref op literal | "true" | "false"
10
+ # ref := steps.<id>.(status|output.<key>) | ticket.(priority|kind|status)
11
+ # op := == | != | < | <= | > | >=
12
+ # literal := 'string' | "string" | integer | true | false
13
+ class Condition
14
+ class SyntaxError < StandardError; end
15
+
16
+ TOKEN = /\s*(?:(\(|\))|(==|!=|<=|>=|<|>)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(-?\d+)|([A-Za-z_][\w.]*))/
17
+ REF = /\A(?:steps\.[a-z][a-z0-9_]*\.(?:status|output\.[a-z][a-z0-9_]*)|ticket\.(?:priority|kind|status))\z/
18
+
19
+ attr_reader :source
20
+
21
+ def self.parse(source) = new(source).tap(&:ast)
22
+
23
+ def initialize(source)
24
+ @source = source.to_s
25
+ raise SyntaxError, "condition too long" if @source.length > 500
26
+ end
27
+
28
+ def ast = (@ast ||= build)
29
+
30
+ def step_refs
31
+ refs = []
32
+ walk = ->(node) do
33
+ case node
34
+ in [ :cmp, ref, _, _ ] then refs << ref.split(".")[1] if ref.start_with?("steps.")
35
+ in [ :and | :or, l, r ] then walk.(l); walk.(r)
36
+ in [ :not, x ] then walk.(x)
37
+ else nil
38
+ end
39
+ end
40
+ walk.(ast)
41
+ refs.uniq
42
+ end
43
+
44
+ # context: { "steps" => { "test" => { "status" => "succeeded", "output" => {...} } }, "ticket" => {...} }
45
+ def evaluate(context) = eval_node(ast, context)
46
+
47
+ private
48
+
49
+ def build
50
+ @tokens = tokenize(@source)
51
+ @pos = 0
52
+ node = parse_or
53
+ raise SyntaxError, "unexpected token #{@tokens[@pos].inspect}" if @pos < @tokens.size
54
+
55
+ node
56
+ end
57
+
58
+ def tokenize(src)
59
+ tokens = []
60
+ rest = src.strip
61
+ until rest.empty?
62
+ m = TOKEN.match(rest)
63
+ raise SyntaxError, "invalid condition near #{rest[0, 20].inspect}" unless m && m.begin(0).zero?
64
+
65
+ tokens << if m[1] then [ :paren, m[1] ]
66
+ elsif m[2] then [ :op, m[2] ]
67
+ elsif m[3] then [ :lit, m[3][1..-2].gsub(/\\(.)/, '\1') ]
68
+ elsif m[4] then [ :lit, m[4].to_i ]
69
+ else
70
+ word = m[5]
71
+ case word
72
+ when "and", "or", "not" then [ :kw, word ]
73
+ when "true" then [ :lit, true ]
74
+ when "false" then [ :lit, false ]
75
+ else
76
+ raise SyntaxError, "unknown reference #{word}" unless word.match?(REF)
77
+
78
+ [ :ref, word ]
79
+ end
80
+ end
81
+ rest = m.post_match.lstrip
82
+ end
83
+ tokens
84
+ end
85
+
86
+ def peek = @tokens[@pos]
87
+ def take = @tokens[@pos].tap { @pos += 1 }
88
+
89
+ def parse_or
90
+ left = parse_and
91
+ while peek == [ :kw, "or" ]
92
+ take
93
+ left = [ :or, left, parse_and ]
94
+ end
95
+ left
96
+ end
97
+
98
+ def parse_and
99
+ left = parse_unary
100
+ while peek == [ :kw, "and" ]
101
+ take
102
+ left = [ :and, left, parse_unary ]
103
+ end
104
+ left
105
+ end
106
+
107
+ def parse_unary
108
+ if peek == [ :kw, "not" ]
109
+ take
110
+ return [ :not, parse_unary ]
111
+ end
112
+ parse_primary
113
+ end
114
+
115
+ def parse_primary
116
+ tok = take or raise SyntaxError, "unexpected end of condition"
117
+ case tok
118
+ in [ :paren, "(" ]
119
+ node = parse_or
120
+ raise SyntaxError, "missing )" unless take == [ :paren, ")" ]
121
+
122
+ node
123
+ in [ :lit, true | false => v ] then [ :lit, v ]
124
+ in [ :ref, ref ]
125
+ op = take
126
+ raise SyntaxError, "expected comparison operator after #{ref}" unless op&.first == :op
127
+
128
+ lit = take
129
+ raise SyntaxError, "expected literal after #{op.last}" unless lit&.first == :lit
130
+
131
+ [ :cmp, ref, op.last, lit.last ]
132
+ else
133
+ raise SyntaxError, "unexpected token #{tok.last.inspect}"
134
+ end
135
+ end
136
+
137
+ def eval_node(node, ctx)
138
+ case node
139
+ in [ :lit, v ] then v
140
+ in [ :not, x ] then !eval_node(x, ctx)
141
+ in [ :and, l, r ] then eval_node(l, ctx) && eval_node(r, ctx)
142
+ in [ :or, l, r ] then eval_node(l, ctx) || eval_node(r, ctx)
143
+ in [ :cmp, ref, op, lit ]
144
+ value = resolve(ref, ctx)
145
+ compare(value, op, lit)
146
+ end
147
+ end
148
+
149
+ def resolve(ref, ctx)
150
+ parts = ref.split(".")
151
+ parts.reduce(ctx) { |acc, key| acc.is_a?(Hash) ? (acc[key] || acc[key.to_sym]) : nil }
152
+ end
153
+
154
+ def compare(value, op, lit)
155
+ case op
156
+ when "==" then value == lit || value.to_s == lit.to_s
157
+ when "!=" then !(value == lit || value.to_s == lit.to_s)
158
+ else
159
+ return false unless value.is_a?(Numeric) && lit.is_a?(Numeric)
160
+
161
+ value.public_send(op, lit)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,208 @@
1
+ module Agentboard
2
+ module Workflows
3
+ # Versioned, validated DAG workflow definitions (spec §9, §28.1).
4
+ class Definition
5
+ SCHEMA_VERSION = 1
6
+ STEP_TYPES = %w[agent command verification human_approval git_commit git_pull_request external condition].freeze
7
+ AGENT_ACTIONS = %w[plan implement review fix_tests summarize].freeze
8
+ RETRYABLE = %w[provider_unavailable transient_io execution_timeout].freeze
9
+ MAX_STEPS = 50
10
+ MAX_ATTEMPTS = 5
11
+ MAX_TIMEOUT = 24 * 3600
12
+
13
+ Step = Struct.new(:id, :type, :provider, :action, :command, :depends_on, :condition, :retry, :timeout_seconds,
14
+ :required_role, :config, :integration, keyword_init: true) do
15
+ def to_h = super.compact.transform_keys(&:to_s)
16
+ def max_attempts = (retry_policy["max_attempts"] || 1).to_i
17
+ def retry_policy = (self.retry || {})
18
+ def retry_on = Array(retry_policy["retry_on"])
19
+
20
+ def backoff_for(attempt)
21
+ list = Array(retry_policy["backoff_seconds"])
22
+ (list[attempt - 1] || list.last || 0).to_i
23
+ end
24
+ end
25
+
26
+ attr_reader :name, :version, :steps, :timeout_seconds, :raw
27
+
28
+ ADVANCED_STEP_TYPES = %w[external git_pull_request].freeze
29
+
30
+ def self.parse_yaml(text, catalog: Agentboard.config.verification_catalog, providers: Providers.registry.keys, enforce_features: false)
31
+ data = YAML.safe_load(text.to_s, permitted_classes: [], aliases: false)
32
+ definition = from_h(data, catalog: catalog, providers: providers)
33
+ if enforce_features && definition.advanced_features.any? && !Agentboard.feature?(:advanced_workflows)
34
+ raise Error.policy_denied("This workflow uses #{definition.advanced_features.join(", ")}, which require AgentBoard Pro " \
35
+ "(advanced workflows). Sequential agent/command/approval steps are free.",
36
+ errors: [ "requires AgentBoard Pro: #{definition.advanced_features.join(", ")}" ])
37
+ end
38
+ definition
39
+ rescue Psych::Exception => e
40
+ raise Error.validation_failed("Invalid workflow YAML: #{e.message[0, 200]}")
41
+ end
42
+
43
+ def self.from_h(data, catalog: Agentboard.config.verification_catalog, providers: Providers.registry.keys)
44
+ new(data, catalog: catalog, providers: providers)
45
+ end
46
+
47
+ def initialize(data, catalog:, providers:)
48
+ @catalog = catalog
49
+ @providers = providers.map(&:to_s)
50
+ @errors = []
51
+ @raw = data
52
+ validate_top!(data)
53
+ raise Error.validation_failed("Invalid workflow: #{@errors.join("; ")}", errors: @errors) if @errors.any?
54
+ end
55
+
56
+ def to_h
57
+ { "version" => version, "name" => name, "timeout_seconds" => timeout_seconds, "steps" => steps.map(&:to_h) }.compact
58
+ end
59
+
60
+ def digest = Ids.canonical_digest(to_h)
61
+
62
+ # Constructs that need AgentBoard Pro (advanced workflows).
63
+ def advanced_features
64
+ used = []
65
+ used << "conditions" if steps.any?(&:condition)
66
+ used << "retries" if steps.any? { |s| s.max_attempts > 1 }
67
+ used << "per-step timeouts" if steps.any?(&:timeout_seconds)
68
+ (steps.map(&:type) & ADVANCED_STEP_TYPES).each { |t| used << "#{t} steps" }
69
+ used
70
+ end
71
+ def step(id) = steps.find { |s| s.id == id }
72
+ def roots = steps.select { |s| s.depends_on.empty? }
73
+
74
+ def topological_order
75
+ indeg = steps.to_h { |s| [ s.id, s.depends_on.size ] }
76
+ queue = steps.select { |s| s.depends_on.empty? }.map(&:id)
77
+ order = []
78
+ until queue.empty?
79
+ id = queue.shift
80
+ order << id
81
+ steps.each do |s|
82
+ next unless s.depends_on.include?(id)
83
+
84
+ indeg[s.id] -= 1
85
+ queue << s.id if indeg[s.id].zero?
86
+ end
87
+ end
88
+ order
89
+ end
90
+
91
+ private
92
+
93
+ def validate_top!(data)
94
+ unless data.is_a?(Hash)
95
+ @errors << "definition must be a mapping"
96
+ return
97
+ end
98
+ data = data.transform_keys(&:to_s)
99
+ unknown = data.keys - %w[version name steps timeout_seconds description]
100
+ @errors << "unknown top-level keys: #{unknown.join(", ")}" if unknown.any?
101
+ @version = data["version"]
102
+ @errors << "version must be #{SCHEMA_VERSION}" unless @version == SCHEMA_VERSION
103
+ @name = data["name"].to_s
104
+ @errors << "name must match /\\A[a-z][a-z0-9_]{0,63}\\z/" unless @name.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
105
+ @timeout_seconds = data["timeout_seconds"]&.to_i
106
+ @errors << "timeout_seconds out of range" if @timeout_seconds && !@timeout_seconds.between?(1, 7 * 24 * 3600)
107
+
108
+ raw_steps = data["steps"]
109
+ unless raw_steps.is_a?(Array) && raw_steps.any?
110
+ @errors << "steps must be a non-empty list"
111
+ @steps = []
112
+ return
113
+ end
114
+ @errors << "too many steps (max #{MAX_STEPS})" if raw_steps.size > MAX_STEPS
115
+ @steps = raw_steps.each_with_index.filter_map { |s, i| build_step(s, i) }
116
+ ids = @steps.map(&:id)
117
+ dupes = ids.tally.select { |_, c| c > 1 }.keys
118
+ @errors << "duplicate step ids: #{dupes.join(", ")}" if dupes.any?
119
+ @steps.each do |s|
120
+ (s.depends_on - ids).each { |d| @errors << "step #{s.id} depends on unknown step #{d}" }
121
+ @errors << "step #{s.id} depends on itself" if s.depends_on.include?(s.id)
122
+ next unless s.condition
123
+
124
+ refs = Condition.parse(s.condition).step_refs
125
+ (refs - ids).each { |r| @errors << "step #{s.id} condition references unknown step #{r}" }
126
+ (refs - ancestors(s.id)).each { |r| @errors << "step #{s.id} condition references #{r}, which is not an ancestor" } if @errors.empty?
127
+ rescue Condition::SyntaxError => e
128
+ @errors << "step #{s.id} condition: #{e.message}"
129
+ end
130
+ @errors << "workflow contains a dependency cycle" if @errors.empty? && topological_order.size != @steps.size
131
+ end
132
+
133
+ def ancestors(id, seen = Set.new)
134
+ step = @steps.find { |s| s.id == id }
135
+ return seen unless step
136
+
137
+ step.depends_on.each { |d| ancestors(d, seen << d) unless seen.include?(d) }
138
+ seen.to_a
139
+ end
140
+
141
+ def build_step(raw, index)
142
+ unless raw.is_a?(Hash)
143
+ @errors << "step ##{index + 1} must be a mapping"
144
+ return nil
145
+ end
146
+ raw = raw.transform_keys(&:to_s)
147
+ allowed = %w[id type provider action command depends_on condition retry timeout_seconds required_role config integration]
148
+ unknown = raw.keys - allowed
149
+ @errors << "step #{raw["id"] || index + 1}: unknown keys #{unknown.join(", ")}" if unknown.any?
150
+
151
+ id = raw["id"].to_s
152
+ @errors << "step ##{index + 1}: id must match /\\A[a-z][a-z0-9_]{0,63}\\z/" unless id.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
153
+ type = raw["type"].to_s
154
+ @errors << "step #{id}: unknown type #{type}" unless STEP_TYPES.include?(type)
155
+
156
+ case type
157
+ when "agent"
158
+ @errors << "step #{id}: unknown provider #{raw["provider"]}" unless @providers.include?(raw["provider"].to_s)
159
+ @errors << "step #{id}: action must be one of #{AGENT_ACTIONS.join(", ")}" unless AGENT_ACTIONS.include?(raw["action"].to_s)
160
+ when "command", "verification"
161
+ if raw["command"].is_a?(Array) || raw["command"].to_s.include?(" ")
162
+ @errors << "step #{id}: command must reference a catalog entry by name, not a shell string"
163
+ elsif !@catalog.names.include?(raw["command"].to_s)
164
+ @errors << "step #{id}: command #{raw["command"]} is not in the approved catalog"
165
+ end
166
+ when "external"
167
+ name = raw["integration"].to_s
168
+ unless (Agentboard.config.settings["integrations"] || {}).key?(name)
169
+ @errors << "step #{id}: integration #{name.inspect} is not configured under `integrations`"
170
+ end
171
+ if retry_policy = raw["retry"]
172
+ @errors << "step #{id}: external steps cannot be retried automatically" if retry_policy.is_a?(Hash) && retry_policy["max_attempts"].to_i > 1
173
+ end
174
+ when "human_approval"
175
+ role = (raw["required_role"] || "maintainer").to_s
176
+ @errors << "step #{id}: required_role must be maintainer or administrator" unless %w[maintainer administrator].include?(role)
177
+ end
178
+
179
+ retry_policy = raw["retry"]
180
+ if retry_policy
181
+ if retry_policy.is_a?(Hash)
182
+ retry_policy = retry_policy.transform_keys(&:to_s)
183
+ attempts = retry_policy["max_attempts"].to_i
184
+ @errors << "step #{id}: retry.max_attempts must be 1..#{MAX_ATTEMPTS}" unless attempts.between?(1, MAX_ATTEMPTS)
185
+ backoff = Array(retry_policy["backoff_seconds"])
186
+ @errors << "step #{id}: retry.backoff_seconds must be integers 0..3600" unless backoff.all? { |b| b.is_a?(Integer) && b.between?(0, 3600) }
187
+ bad = Array(retry_policy["retry_on"]).map(&:to_s) - RETRYABLE
188
+ @errors << "step #{id}: retry_on contains unsupported reasons #{bad.join(", ")}" if bad.any?
189
+ if %w[git_commit git_pull_request command].include?(type) && attempts > 1
190
+ @errors << "step #{id}: non-idempotent #{type} steps cannot be retried automatically"
191
+ end
192
+ else
193
+ @errors << "step #{id}: retry must be a mapping"
194
+ end
195
+ end
196
+
197
+ timeout = raw["timeout_seconds"]&.to_i
198
+ @errors << "step #{id}: timeout_seconds must be 1..#{MAX_TIMEOUT}" if timeout && !timeout.between?(1, MAX_TIMEOUT)
199
+ depends = Array(raw["depends_on"]).map(&:to_s)
200
+
201
+ Step.new(id: id, type: type, provider: raw["provider"]&.to_s, action: raw["action"]&.to_s, command: raw["command"]&.to_s,
202
+ depends_on: depends, condition: raw["condition"]&.to_s, retry: retry_policy.is_a?(Hash) ? retry_policy : nil,
203
+ timeout_seconds: timeout, required_role: raw["required_role"]&.to_s, config: raw["config"].is_a?(Hash) ? raw["config"] : nil,
204
+ integration: raw["integration"]&.to_s)
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,2 @@
1
+ require "agentboard/workflows/condition"
2
+ require "agentboard/workflows/definition"