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