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,261 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# DAG scheduler with precise semantics (spec §28):
|
|
3
|
+
# - a step is runnable when all dependencies succeeded and its condition is true
|
|
4
|
+
# - failed/skipped dependencies skip downstream steps unless the step has an explicit
|
|
5
|
+
# condition (a failure branch) that evaluates true
|
|
6
|
+
# - bounded retries with backoff create new attempt rows; non-idempotent steps never retry
|
|
7
|
+
# - cancellation propagates to running executions, then finalizes the run
|
|
8
|
+
module WorkflowEngine
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def workflow_dir = Agentboard.config.root_path.join(".agentboard", "workflows")
|
|
12
|
+
|
|
13
|
+
# Registers (or reuses) an immutable version of a workflow definition.
|
|
14
|
+
def register!(project:, yaml:, actor: Authorization.system_actor)
|
|
15
|
+
Authorization.authorize!(actor, "workflow.manage")
|
|
16
|
+
definition = Workflows::Definition.parse_yaml(yaml, enforce_features: true)
|
|
17
|
+
digest = definition.digest
|
|
18
|
+
existing = WorkflowDefinition.where(project: project, name: definition.name).order(version: :desc).first
|
|
19
|
+
return existing if existing&.digest == digest
|
|
20
|
+
|
|
21
|
+
WorkflowDefinition.create!(project: project, name: definition.name, version: (existing&.version || 0) + 1,
|
|
22
|
+
definition: definition.to_h, source_yaml: yaml.to_s[0, 100_000], digest: digest)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sync_from_disk!(project:)
|
|
26
|
+
Dir[workflow_dir.join("*.yml").to_s].sort.map do |path|
|
|
27
|
+
register!(project: project, yaml: File.read(path))
|
|
28
|
+
rescue Agentboard::Error => e
|
|
29
|
+
Agentboard.logger.warn("[agentboard] workflow #{File.basename(path)} invalid: #{e.message}")
|
|
30
|
+
nil
|
|
31
|
+
end.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def latest(project:, name:)
|
|
35
|
+
WorkflowDefinition.where(project: project, name: name).order(version: :desc).first or
|
|
36
|
+
raise Error.not_found("Workflow #{name} is not registered")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start!(definition:, ticket:, actor:)
|
|
40
|
+
Authorization.authorize!(actor, "execution.start")
|
|
41
|
+
# Re-checked at start: a licence may have lapsed since the definition was registered.
|
|
42
|
+
Agentboard.require_feature!(:advanced_workflows) if definition.parsed.advanced_features.any?
|
|
43
|
+
raise Error.workspace_conflict("#{ticket.key} already has an active workflow run") if ticket.workflow_runs.active.exists?
|
|
44
|
+
|
|
45
|
+
parsed = definition.parsed
|
|
46
|
+
WorkflowRun.transaction do
|
|
47
|
+
run = WorkflowRun.create!(workflow_definition: definition, ticket: ticket, status: "running", started_at: Time.current,
|
|
48
|
+
base_sha: ticket.project.repository.head_sha)
|
|
49
|
+
parsed.steps.each do |s|
|
|
50
|
+
run.steps.create!(step_key: s.id, step_type: s.type, status: "pending", attempt: 1, input: { "depends_on" => s.depends_on })
|
|
51
|
+
end
|
|
52
|
+
EventStore.append!(type: "workflow.started", aggregate: run, ticket: ticket, actor: actor,
|
|
53
|
+
payload: { workflow: definition.name, version: definition.version, digest: definition.digest, run_id: run.id })
|
|
54
|
+
run
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cancel!(run:, actor:, reason: "cancelled")
|
|
59
|
+
WorkflowRun.transaction do
|
|
60
|
+
run.lock!
|
|
61
|
+
return run if run.terminal?
|
|
62
|
+
|
|
63
|
+
run.update!(status: "cancelled", cancel_reason: reason, finished_at: Time.current)
|
|
64
|
+
run.steps.where(status: %w[pending waiting running]).find_each do |step|
|
|
65
|
+
step.executions.active.each { |e| Executions.request_cancel!(execution: e, actor: actor, reason: "workflow cancelled") }
|
|
66
|
+
step.update!(status: "cancelled", finished_at: Time.current)
|
|
67
|
+
end
|
|
68
|
+
EventStore.append!(type: "workflow.cancelled", aggregate: run, ticket: run.ticket, actor: actor, payload: { reason: reason })
|
|
69
|
+
end
|
|
70
|
+
run
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tick
|
|
74
|
+
WorkflowRun.where(status: %w[running waiting_for_approval]).find_each { |run| advance(run) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def advance(run)
|
|
78
|
+
definition = run.definition
|
|
79
|
+
if definition.timeout_seconds && run.started_at && Time.current - run.started_at > definition.timeout_seconds
|
|
80
|
+
return cancel!(run: run, actor: Authorization.system_actor, reason: "workflow timeout")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
steps = run.current_steps
|
|
84
|
+
steps.each_value { |step| poll_waiting(run, step, definition) if step.status == "waiting" }
|
|
85
|
+
Extensions.run(:workflow_advance, run: run, steps: steps, definition: definition)
|
|
86
|
+
steps = run.reload.current_steps
|
|
87
|
+
|
|
88
|
+
definition.topological_order.each do |key|
|
|
89
|
+
step = steps[key]
|
|
90
|
+
next unless step&.status == "pending"
|
|
91
|
+
next if step.available_at && step.available_at > Time.current
|
|
92
|
+
|
|
93
|
+
spec = definition.step(key)
|
|
94
|
+
deps = spec.depends_on.map { |d| steps[d] }
|
|
95
|
+
next unless deps.all?(&:terminal?)
|
|
96
|
+
|
|
97
|
+
ctx = condition_context(run, steps)
|
|
98
|
+
all_ok = deps.all? { |d| d.status == "succeeded" }
|
|
99
|
+
runnable =
|
|
100
|
+
if spec.condition
|
|
101
|
+
Workflows::Condition.parse(spec.condition).evaluate(ctx)
|
|
102
|
+
else
|
|
103
|
+
all_ok
|
|
104
|
+
end
|
|
105
|
+
if runnable
|
|
106
|
+
start_step(run, step, spec, deps)
|
|
107
|
+
else
|
|
108
|
+
step.update!(status: "skipped", finished_at: Time.current, output: { "reason" => spec.condition ? "condition false" : "dependency did not succeed" })
|
|
109
|
+
EventStore.append!(type: "workflow.step_skipped", aggregate: run, ticket: run.ticket, payload: { step: key })
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
finalize(run.reload)
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
Agentboard.logger.error("[agentboard] workflow #{run.id} advance failed: #{e.class}: #{e.message}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def condition_context(run, steps)
|
|
118
|
+
{
|
|
119
|
+
"steps" => steps.transform_values { |s| { "status" => s.status, "output" => s.output } },
|
|
120
|
+
"ticket" => { "priority" => run.ticket.priority, "kind" => run.ticket.kind, "status" => run.ticket.status }
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def start_step(run, step, spec, deps)
|
|
125
|
+
handoff = latest_handoff(deps)
|
|
126
|
+
verify_handoff!(run, handoff) if handoff
|
|
127
|
+
step.update!(status: "running", started_at: Time.current, input: step.input.merge("handoff" => handoff).compact)
|
|
128
|
+
EventStore.append!(type: "workflow.step_started", aggregate: run, ticket: run.ticket, payload: { step: spec.id, type: spec.type, attempt: step.attempt })
|
|
129
|
+
|
|
130
|
+
case spec.type
|
|
131
|
+
when "agent"
|
|
132
|
+
Executions.enqueue!(ticket: run.ticket.reload, provider_key: spec.provider, action: spec.action, actor: Authorization.system_actor,
|
|
133
|
+
workflow_step: step, idempotency_key: "wf:#{step.id}",
|
|
134
|
+
metadata: { "handoff" => handoff, "base_ref" => handoff&.dig("head_sha"), "workflow_run_id" => run.id,
|
|
135
|
+
"timeout_seconds" => spec.timeout_seconds }.compact)
|
|
136
|
+
when "command", "verification"
|
|
137
|
+
run_command_step(run, step, spec, handoff)
|
|
138
|
+
when "human_approval"
|
|
139
|
+
head = handoff&.dig("head_sha") || run.base_sha
|
|
140
|
+
approval = Approvals.request!(project: run.ticket.project, ticket: run.ticket, actor: Authorization.system_actor,
|
|
141
|
+
action_kind: "workflow_gate", head_sha: head, workflow_step: step,
|
|
142
|
+
required_role: spec.required_role || "maintainer",
|
|
143
|
+
action_payload: { workflow_run_id: run.id, step: spec.id, head_sha: head },
|
|
144
|
+
reason: "Workflow #{run.workflow_definition.name} requires approval at step #{spec.id}")
|
|
145
|
+
step.update!(status: "waiting", output: { "approval_id" => approval.id, "head_sha" => head })
|
|
146
|
+
run.update!(status: "waiting_for_approval")
|
|
147
|
+
when "condition"
|
|
148
|
+
finish_step(run, step, "succeeded", output: { "result" => true })
|
|
149
|
+
|
|
150
|
+
when "git_commit"
|
|
151
|
+
path = handoff&.dig("worktree_path")
|
|
152
|
+
raise Error.validation_failed("git_commit needs a worktree from a previous step") unless path
|
|
153
|
+
|
|
154
|
+
manager = Git::WorktreeManager.new(repository: run.ticket.project.repository, workspace: Agentboard.config.workspace_path)
|
|
155
|
+
head = manager.snapshot!(path, message: "#{run.ticket.key}: workflow commit")
|
|
156
|
+
finish_step(run, step, "succeeded", output: (handoff || {}).merge("head_sha" => head))
|
|
157
|
+
else
|
|
158
|
+
handler = Extensions.step_handler(spec.type)
|
|
159
|
+
unless handler
|
|
160
|
+
return finish_step(run, step, "failed", error: "Step type #{spec.type} requires AgentBoard Pro", failure_code: "capability_unavailable")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
handler.call(run: run, step: step, spec: spec, handoff: handoff)
|
|
164
|
+
end
|
|
165
|
+
rescue Agentboard::Error => e
|
|
166
|
+
finish_step(run, step, "failed", error: e.message, failure_code: e.code.downcase)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def latest_handoff(deps)
|
|
172
|
+
deps.select { |d| d.status == "succeeded" && d.output["head_sha"] }.max_by { |d| d.finished_at.to_f }&.output
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The receiving step verifies actual repository state rather than trusting the summary.
|
|
176
|
+
def verify_handoff!(run, handoff)
|
|
177
|
+
repo = run.ticket.project.repository
|
|
178
|
+
if handoff["head_sha"] && repo.resolve_commit(handoff["head_sha"]).nil?
|
|
179
|
+
raise Error.validation_failed("Handoff head #{handoff["head_sha"]} does not exist in the repository")
|
|
180
|
+
end
|
|
181
|
+
ids = Array(handoff["artifact_ids"])
|
|
182
|
+
if ids.any? && Artifact.where(id: ids, ticket_id: run.ticket_id).count != ids.size
|
|
183
|
+
raise Error.validation_failed("Handoff references unknown artifacts")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def run_command_step(run, step, spec, handoff)
|
|
188
|
+
dir = handoff&.dig("worktree_path").presence || run.ticket.project.root_path
|
|
189
|
+
PathGuard.within!(Agentboard.config.workspace_path, dir) if handoff&.dig("worktree_path").present?
|
|
190
|
+
Thread.new do
|
|
191
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
192
|
+
execution = Execution.create!(ticket: run.ticket, provider_key: "local", action: "verify", status: "verifying",
|
|
193
|
+
attempt: Execution.next_attempt_for(run.ticket), workflow_step: step, worktree_path: dir,
|
|
194
|
+
base_sha: handoff&.dig("base_sha"), head_sha: Git::Repository.new(dir).head_sha(chdir: dir),
|
|
195
|
+
started_at: Time.current, metadata: { "command" => spec.command })
|
|
196
|
+
results = VerificationService.run_catalog!(execution: execution, chdir: dir, names: [ spec.command ], timeout: spec.timeout_seconds)
|
|
197
|
+
ok = results.all?(&:passed?)
|
|
198
|
+
Executions.transition!(execution, ok ? "completed" : "failed", failure_code: ok ? nil : "verification_failed")
|
|
199
|
+
end
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
Agentboard.logger.error("[agentboard] command step failed: #{e.class}: #{e.message}")
|
|
202
|
+
ActiveRecord::Base.connection_pool.with_connection { finish_step(run, step.reload, "failed", error: e.class.name) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Callback from Executions.transition! when an execution linked to a step finishes.
|
|
207
|
+
def step_execution_finished(execution)
|
|
208
|
+
step = execution.workflow_step
|
|
209
|
+
return unless step && step.status == "running"
|
|
210
|
+
|
|
211
|
+
run = step.workflow_run
|
|
212
|
+
ok = execution.status == "completed"
|
|
213
|
+
output = {
|
|
214
|
+
"schema_version" => 1, "ticket_key" => run.ticket.key, "from_step" => step.step_key,
|
|
215
|
+
"base_sha" => execution.base_sha, "head_sha" => execution.head_sha, "branch" => execution.branch,
|
|
216
|
+
"worktree_path" => execution.worktree_path, "execution_id" => execution.id,
|
|
217
|
+
"artifact_ids" => execution.artifacts.where(kind: %w[commit diff test_report]).pluck(:id),
|
|
218
|
+
"verification_result_ids" => execution.verification_results.pluck(:id),
|
|
219
|
+
"summary" => execution.summary.to_s[0, 2000], "known_issues" => ok ? [] : [ execution.failure_message ].compact
|
|
220
|
+
}
|
|
221
|
+
finish_step(run, step, ok ? "succeeded" : "failed", output: output, error: execution.failure_message,
|
|
222
|
+
failure_code: execution.failure_code)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def poll_waiting(run, step, definition)
|
|
226
|
+
approval = Approval.find_by(id: step.output["approval_id"])
|
|
227
|
+
return finish_step(run, step, "failed", error: "approval missing") unless approval
|
|
228
|
+
|
|
229
|
+
head = step.output["head_sha"]
|
|
230
|
+
digest = approval.action_digest
|
|
231
|
+
if approval.valid_for?(action_digest: digest, head_sha: head)
|
|
232
|
+
finish_step(run, step, "succeeded", output: step.output.merge("approved_by" => approval.decided_by))
|
|
233
|
+
elsif %w[rejected expired invalidated].include?(approval.decision) || approval.expired?
|
|
234
|
+
finish_step(run, step, "failed", error: "approval #{approval.decision}")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def finish_step(run, step, status, output: nil, error: nil, failure_code: nil)
|
|
239
|
+
WorkflowStep.transaction do
|
|
240
|
+
step.update!(status: status, finished_at: Time.current, output: output || step.output, error: error&.to_s&.[](0, 2000))
|
|
241
|
+
EventStore.append!(type: "workflow.step_#{status}", aggregate: run, ticket: run.ticket,
|
|
242
|
+
payload: { step: step.step_key, attempt: step.attempt, error: error }.compact)
|
|
243
|
+
run.update!(status: "running") if run.status == "waiting_for_approval" && run.steps.where(status: "waiting").none?
|
|
244
|
+
Extensions.run(:workflow_step_failed, run: run, step: step, failure_code: failure_code) if status == "failed"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def finalize(run)
|
|
250
|
+
return if run.terminal?
|
|
251
|
+
|
|
252
|
+
steps = run.current_steps.values
|
|
253
|
+
return unless steps.all?(&:terminal?)
|
|
254
|
+
|
|
255
|
+
status = steps.any? { |s| s.status == "failed" } ? "failed" : "succeeded"
|
|
256
|
+
run.update!(status: status, finished_at: Time.current)
|
|
257
|
+
EventStore.append!(type: "workflow.#{status}", aggregate: run, ticket: run.ticket, payload: { run_id: run.id })
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<% content_for :title, "Agents" %>
|
|
2
|
+
<% content_for :crumbs do %><%= icon("bot", size: 16, klass: "muted") %> <span>Agents</span><% end %>
|
|
3
|
+
<div class="page-head"><div><h1>Providers & sessions</h1><p>Capabilities are detected from installed binaries — configuration only expresses intent.</p></div></div>
|
|
4
|
+
<div class="stats mb">
|
|
5
|
+
<% @probes.each do |p, probe| %>
|
|
6
|
+
<div class="stat">
|
|
7
|
+
<div class="row"><%= provider_avatar(p.key, size: 26) %><strong><%= p.display_name %></strong>
|
|
8
|
+
<span style="margin-left:auto"><%= probe.installed ? execution_pill(probe.supports?(:active_execution) ? "completed" : "waiting") : execution_pill("skipped") %></span></div>
|
|
9
|
+
<div class="stat-sub mt-sm"><%= probe.installed ? "v#{probe.version} · #{probe.validation}" : "Not installed" %></div>
|
|
10
|
+
<div class="row row-wrap mt-sm" style="gap:4px">
|
|
11
|
+
<% Agentboard::Providers::CAPABILITIES.each do |cap| %>
|
|
12
|
+
<span class="prov <%= probe.supports?(cap) ? "prov-git-observed" : "prov-inferred" %>" title="<%= cap %>"><%= cap.to_s.tr("_", " ") %></span>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<% probe.diagnostics.first(1).each do |d| %><div class="hint mt-sm"><%= d %></div><% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="card card-body-flush">
|
|
20
|
+
<div class="card-head"><h2><%= icon("user", size: 15) %> Sessions</h2><span class="muted" style="margin-left:auto;font-size:12px">Agent-supplied session ids are labels; trust comes from hooks, the runtime, or the MCP process.</span></div>
|
|
21
|
+
<% if @sessions.empty? %>
|
|
22
|
+
<div class="empty"><%= icon("bot", size: 26) %><strong>No agent sessions yet</strong><span>Run <code>bundle exec agentboard provider setup claude</code>, then start <code>claude</code> in this repository.</span></div>
|
|
23
|
+
<% else %>
|
|
24
|
+
<table class="table table-compact">
|
|
25
|
+
<thead><tr><th>Session</th><th>Trust</th><th>State</th><th>Ticket</th><th>Attach</th><th class="right">Last seen</th></tr></thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @sessions.each do |s| %>
|
|
28
|
+
<% link = s.ticket_links.find { |l| l.ended_at.nil? } %>
|
|
29
|
+
<tr>
|
|
30
|
+
<td><span class="row"><%= provider_avatar(s.provider_key, size: 20) %><span class="truncate"><%= s.display_name %></span></span></td>
|
|
31
|
+
<td><span class="prov <%= { "launched" => "prov-confirmed", "hook" => "prov-provider-reported", "mcp_stdio" => "prov-git-observed" }.fetch(s.trust_level, "prov-inferred") %>"><%= s.trust_level %></span></td>
|
|
32
|
+
<td><%= execution_pill({ "active" => "running", "revoked" => "rejected" }.fetch(s.state, "skipped")) %></td>
|
|
33
|
+
<td><% if link %><%= link_to link.ticket.key, ticket_path(link.ticket), class: "link mono" %> <span class="faint" style="font-size:11px"><%= link.source %><%= " · unconfirmed" if link.confidence == "needs_confirmation" %></span><% else %><span class="faint">—</span><% end %></td>
|
|
34
|
+
<td>
|
|
35
|
+
<%= form_with url: session_path(s), method: :patch, class: "row" do %>
|
|
36
|
+
<select name="ticket_key" class="select" style="height:26px;width:150px;font-size:12px"><option value="">Detach</option><% @open_tickets.each do |t| %><option value="<%= t.key %>" <%= "selected" if link&.ticket_id == t.id %>><%= t.key %></option><% end %></select>
|
|
37
|
+
<button class="btn btn-sm">Set</button>
|
|
38
|
+
<% end %>
|
|
39
|
+
</td>
|
|
40
|
+
<td class="right muted"><%= ago(s.last_seen_at || s.created_at) %>
|
|
41
|
+
<% if s.state == "active" && %w[mcp_stdio hook].include?(s.trust_level) && can?("session.manage") %>
|
|
42
|
+
<%= button_to "Revoke", session_path(s), method: :patch, params: { revoke: "1" }, class: "btn btn-sm btn-danger", form: { style: "display:inline;margin-left:6px" } %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% if s.metadata["correlated_session_id"] %><span class="prov prov-confirmed" title="Same agent process (pid <%= s.metadata["agent_pid"] %>)">correlated</span><% end %>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
<% end %>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<% content_for :title, "Analytics" %>
|
|
2
|
+
<% content_for :crumbs do %><%= icon("chart", size: 16, klass: "muted") %> <span>Analytics</span><% end %>
|
|
3
|
+
<div class="page-head"><div><h1>Delivery analytics</h1><p>Unknown values are shown as “—”, never as zero. Costs are only what providers reported.</p></div></div>
|
|
4
|
+
<div class="stats mb">
|
|
5
|
+
<div class="stat"><div class="stat-label">Executions</div><div class="stat-value"><%= @total %></div><div class="stat-sub"><%= @by_status["completed"].to_i %> completed · <%= @by_status["failed"].to_i %> failed</div></div>
|
|
6
|
+
<div class="stat"><div class="stat-label">First-pass success</div><div class="stat-value"><%= @first_pass_rate ? "#{@first_pass_rate}%" : "—" %></div><div class="stat-sub">implement attempts passing checks on try #1</div></div>
|
|
7
|
+
<div class="stat"><div class="stat-label">Median duration</div><div class="stat-value"><%= duration(@median_duration) %></div><div class="stat-sub"><%= @retries %> retries</div></div>
|
|
8
|
+
<div class="stat"><div class="stat-label">Human interventions</div><div class="stat-value"><%= @human_interventions %></div><div class="stat-sub">approval latency <%= duration(@approval_latency) %></div></div>
|
|
9
|
+
</div>
|
|
10
|
+
<% if @metrics.nil? %>
|
|
11
|
+
<div class="callout mb"><%= icon("chart", size: 16) %><div><strong>Operational metrics</strong> (queue wait p95, event lag, orphan worktrees, provider errors), the support bundle and audit export are part of <strong>AgentBoard Pro</strong>. The statistics below are free.</div></div>
|
|
12
|
+
<% else %>
|
|
13
|
+
<div class="stats mb">
|
|
14
|
+
<div class="stat"><div class="stat-label">Queue wait (median / p95)</div><div class="stat-value" style="font-size:18px"><%= duration(@metrics[:queue_wait_median]) %> <span class="muted" style="font-size:13px">/ <%= duration(@metrics[:queue_wait_p95]) %></span></div></div>
|
|
15
|
+
<div class="stat"><div class="stat-label">Event processing lag</div><div class="stat-value" style="font-size:18px"><%= @metrics[:outbox_lag_median] ? "#{@metrics[:outbox_lag_median].round(2)}s" : "—" %></div><div class="stat-sub"><%= @metrics[:outbox_pending] %> pending · <%= @metrics[:outbox_dead] %> dead</div></div>
|
|
16
|
+
<div class="stat"><div class="stat-label">Provider errors / cancellations</div><div class="stat-value" style="font-size:18px"><%= @metrics[:provider_errors] %> <span class="muted" style="font-size:13px">/ <%= @metrics[:cancellations] %></span></div></div>
|
|
17
|
+
<div class="stat"><div class="stat-label">Sessions · orphan worktrees</div><div class="stat-value" style="font-size:18px"><%= @metrics[:active_sessions] %> <span class="muted" style="font-size:13px">· <%= @metrics[:orphan_worktrees] %></span></div><div class="stat-sub">orphans: run <code>agentboard cleanup</code></div></div>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
<div class="detail" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr)">
|
|
21
|
+
<div class="card">
|
|
22
|
+
<div class="card-head"><h3>Executions · last 14 days</h3></div>
|
|
23
|
+
<div class="card-body">
|
|
24
|
+
<% days = (13.downto(0)).map { |i| i.days.ago.to_date } %>
|
|
25
|
+
<% peak = [@daily.values.max.to_i, 1].max %>
|
|
26
|
+
<div class="bars">
|
|
27
|
+
<% days.each do |d| %>
|
|
28
|
+
<div class="bar" title="<%= d %>: <%= @daily[d].to_i %>"><span style="height:<%= (@daily[d].to_i * 100.0 / peak).round %>%"></span><small><%= d.strftime("%d") %></small></div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="card">
|
|
34
|
+
<div class="card-head"><h3>Outcomes by provider</h3></div>
|
|
35
|
+
<div class="card-body">
|
|
36
|
+
<% if @by_provider.empty? %><div class="empty" style="padding:16px">No data yet.</div><% end %>
|
|
37
|
+
<% @by_provider.each do |provider, statuses| %>
|
|
38
|
+
<% sum = statuses.values.sum.to_f %>
|
|
39
|
+
<div class="hbar">
|
|
40
|
+
<span class="row"><%= provider_avatar(provider, size: 18) %><%= provider %></span>
|
|
41
|
+
<span class="hbar-track">
|
|
42
|
+
<% { "completed" => "seg-completed", "failed" => "seg-failed", "interrupted" => "seg-interrupted", "cancelled" => "seg-cancelled" }.each do |s, cls| %>
|
|
43
|
+
<% if statuses[s].positive? %><span class="<%= cls %>" style="width:<%= (statuses[s] / sum * 100).round(1) %>%" title="<%= s %>: <%= statuses[s] %>"></span><% end %>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% active = statuses.sum { |s, c| Agentboard::StateMachines::Execution::ACTIVE.include?(s) ? c : 0 } %>
|
|
46
|
+
<% if active.positive? %><span class="seg-active" style="width:<%= (active / sum * 100).round(1) %>%"></span><% end %>
|
|
47
|
+
</span>
|
|
48
|
+
<span class="right muted"><%= sum.to_i %></span>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="card">
|
|
54
|
+
<div class="card-head"><h3>Token usage (provider-reported)</h3></div>
|
|
55
|
+
<div class="card-body-flush">
|
|
56
|
+
<table class="table table-compact"><thead><tr><th>Provider</th><th class="right">Input</th><th class="right">Output</th><th class="right">Reported cost</th></tr></thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
<% if @tokens.empty? %><tr><td colspan="4" class="muted">No usage reported.</td></tr><% end %>
|
|
59
|
+
<% @tokens.each do |provider, tin, tout, _n| %>
|
|
60
|
+
<% costs = @reported_cost.select { |(p, _c), _| p == provider } %>
|
|
61
|
+
<tr><td><%= provider %></td><td class="right mono"><%= tin ? number_with_delimiter(tin) : "—" %></td><td class="right mono"><%= tout ? number_with_delimiter(tout) : "—" %></td>
|
|
62
|
+
<td class="right mono"><%= costs.any? ? costs.map { |(_, cur), v| "#{number_with_precision(v, precision: 4)} #{cur}" }.join(", ") : "—" %></td></tr>
|
|
63
|
+
<% end %>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
<p class="hint" style="padding:10px 14px;margin:0">Subscription-based CLI usage is not billed per token; reported cost is the provider's own estimate.</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="card">
|
|
70
|
+
<div class="card-head"><h3>Verification outcomes</h3></div>
|
|
71
|
+
<div class="card-body">
|
|
72
|
+
<% vsum = [@verification.values.sum, 1].max.to_f %>
|
|
73
|
+
<% %w[passed failed timeout error].each do |s| %>
|
|
74
|
+
<div class="hbar"><span><%= s.humanize %></span><span class="hbar-track"><span class="<%= s == "passed" ? "seg-completed" : "seg-failed" %>" style="width:<%= (@verification[s].to_i / vsum * 100).round(1) %>%"></span></span><span class="right muted"><%= @verification[s].to_i %></span></div>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<% ticket = approval.ticket %>
|
|
2
|
+
<div class="stack-sm" style="padding:10px 12px;border:1px solid var(--border);border-radius:11px;background:var(--surface)">
|
|
3
|
+
<div class="row">
|
|
4
|
+
<span class="pill pill-review"><span class="pill-dot"></span><%= approval.action_kind.humanize %></span>
|
|
5
|
+
<% if ticket %><%= link_to ticket.key, ticket_path(ticket), class: "link mono", style: "font-size:12px" %><% end %>
|
|
6
|
+
<span class="faint" style="margin-left:auto;font-size:11.5px"><%= ago(approval.created_at) %></span>
|
|
7
|
+
</div>
|
|
8
|
+
<% if approval.reason.present? %><div style="font-size:13px;white-space:pre-wrap"><%= approval.reason.truncate(600) %></div><% end %>
|
|
9
|
+
<% if (summary = approval.action_payload["summary"]).present? %><pre class="code-block" style="padding:8px 10px;font-size:11.5px"><%= summary.truncate(800) %></pre><% end %>
|
|
10
|
+
<% if approval.execution_id %><div class="hint"><%= link_to "Open execution →", execution_path(approval.execution_id), class: "link" %></div><% end %>
|
|
11
|
+
<div class="row muted" style="font-size:11.5px;flex-wrap:wrap">
|
|
12
|
+
<span>by <%= approval.requested_by %></span><span>·</span><span>head <%= sha_tag(approval.head_sha) %></span><span>·</span>
|
|
13
|
+
<span title="<%= approval.action_digest %>">digest <code><%= approval.action_digest[0, 10] %></code></span>
|
|
14
|
+
<% if approval.expires_at %><span>·</span><span>expires <%= ago(approval.expires_at).to_s.sub(" ago", "").html_safe %></span><% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% if !can?("approval.decide") %>
|
|
17
|
+
<div class="hint">Waiting for a <%= approval.required_role %>.</div>
|
|
18
|
+
<% elsif approval.requested_by == current_actor.label && Agentboard.feature?(:team_mode) && Agentboard.config.authenticate_with %>
|
|
19
|
+
<div class="hint">You requested this — another <%= approval.required_role %> must decide (separation of duties).</div>
|
|
20
|
+
<% else %>
|
|
21
|
+
<%= form_with url: approval_path(approval), method: :patch, class: "row" do %>
|
|
22
|
+
<input class="input" name="reason" placeholder="Note (optional)" style="height:28px">
|
|
23
|
+
<button name="decision" value="rejected" class="btn btn-sm btn-danger"><%= icon("x", size: 12) %> Reject</button>
|
|
24
|
+
<button name="decision" value="approved" class="btn btn-sm btn-success"><%= icon("check", size: 12) %> Approve</button>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<% content_for :title, "Approvals" %>
|
|
2
|
+
<% content_for :crumbs do %><%= icon("shield", size: 16, klass: "muted") %> <span>Approvals</span><% end %>
|
|
3
|
+
<div class="content-narrow">
|
|
4
|
+
<div class="page-head"><div><h1>Approvals</h1><p>Each approval is bound to an exact action digest and commit. Any code change invalidates it.</p></div></div>
|
|
5
|
+
<div class="section-title">Pending · <%= @pending.size %></div>
|
|
6
|
+
<% if @pending.empty? %>
|
|
7
|
+
<div class="card"><div class="empty"><%= icon("shield", size: 26) %><strong>Nothing waiting for you</strong><span>Privileged actions and reviews that need a human will show up here.</span></div></div>
|
|
8
|
+
<% else %>
|
|
9
|
+
<div class="stack"><% @pending.each do |a| %><%= render "approval", approval: a %><% end %></div>
|
|
10
|
+
<% end %>
|
|
11
|
+
<div class="section-title mt">History</div>
|
|
12
|
+
<div class="card card-body-flush">
|
|
13
|
+
<% if @decided.empty? %><div class="empty" style="padding:20px">No decisions yet.</div><% else %>
|
|
14
|
+
<table class="table table-compact">
|
|
15
|
+
<thead><tr><th>Action</th><th>Ticket</th><th>Decision</th><th>Head</th><th>By</th><th class="right">When</th></tr></thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<% @decided.each do |a| %>
|
|
18
|
+
<tr>
|
|
19
|
+
<td><%= a.action_kind.humanize %></td>
|
|
20
|
+
<td><% if (tk = a.ticket) %><%= link_to tk.key, ticket_path(tk), class: "link mono" %><% end %></td>
|
|
21
|
+
<td><%= execution_pill(a.decision) %><% if a.decision_reason.present? %> <span class="muted" style="font-size:12px"><%= a.decision_reason.truncate(60) %></span><% end %></td>
|
|
22
|
+
<td><%= sha_tag(a.head_sha) %></td>
|
|
23
|
+
<td class="muted"><%= a.decided_by || "system" %></td>
|
|
24
|
+
<td class="right muted"><%= ago(a.decided_at || a.updated_at) %></td>
|
|
25
|
+
</tr>
|
|
26
|
+
<% end %>
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<% lease = ticket.leases.find { |l| l.released_at.nil? && l.expires_at > Time.current } %>
|
|
2
|
+
<% running = ticket.executions.find { |e| Agentboard::StateMachines::Execution::ACTIVE.include?(e.status) } %>
|
|
3
|
+
<% done, total = ticket.criteria_progress %>
|
|
4
|
+
<% allowed = Agentboard::StateMachines::Ticket.next_states(ticket.status) %>
|
|
5
|
+
<%= link_to ticket_path(ticket), class: "tcard#{" is-blocked" if ticket.status == "blocked"}#{" is-running" if running}",
|
|
6
|
+
draggable: "true", id: "ticket-#{ticket.key}",
|
|
7
|
+
data: { key: ticket.key, status: ticket.status, allowed: allowed.join(" "), position: ticket.position, move_url: move_ticket_path(ticket), turbo_frame: "_top" } do %>
|
|
8
|
+
<div class="tcard-top">
|
|
9
|
+
<%= priority_icon(ticket.priority) %>
|
|
10
|
+
<span class="tcard-key"><%= ticket.key %></span>
|
|
11
|
+
<% if %w[blocked reopened].include?(ticket.status) %>
|
|
12
|
+
<%= status_pill(ticket.status) %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<span class="tcard-foot">
|
|
15
|
+
<% if running %><%= provider_avatar(running.provider_key, size: 18) %><% end %>
|
|
16
|
+
<% if lease %><span title="Leased by <%= lease.holder_label %> until <%= lease.expires_at.strftime("%H:%M") %>"><%= avatar(lease.holder_label, kind: lease.agent_session_id ? "agent" : "human", size: 18) %></span><% end %>
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="tcard-title"><%= ticket.title %></div>
|
|
20
|
+
<% if ticket.status == "blocked" && ticket.blocked_reason.present? %>
|
|
21
|
+
<div class="tcard-blocker"><%= icon("ban", size: 13) %><span class="truncate"><%= ticket.blocked_reason %></span></div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<div class="tcard-meta">
|
|
24
|
+
<%= kind_badge(ticket.kind) %>
|
|
25
|
+
<% ticket.labels.first(2).each do |l| %><span class="label-chip" style="--h:<%= Digest::MD5.hexdigest(l)[0, 2].to_i(16) * 360 / 255 %>"><%= l %></span><% end %>
|
|
26
|
+
<% if total.positive? %>
|
|
27
|
+
<span class="row" style="gap:5px" title="Acceptance criteria"><%= meter(done, total) %><span><%= done %>/<%= total %></span></span>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% if ticket.depends_on.any? { |d| d.status != "done" } %>
|
|
30
|
+
<span class="row" style="gap:3px;color:var(--c-orange)" title="Waiting on dependencies"><%= icon("link", size: 12) %><%= ticket.depends_on.count { |d| d.status != "done" } %></span>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if running %>
|
|
33
|
+
<span class="row" style="gap:4px;color:var(--accent-text)"><%= icon("pulse", size: 12) %><%= running.status.humanize.downcase %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<% content_for :title, "Board" %>
|
|
2
|
+
<% content_for :bare, "1" %>
|
|
3
|
+
<% content_for :crumbs do %>
|
|
4
|
+
<%= icon("kanban", size: 16, klass: "muted") %> <span>Board</span>
|
|
5
|
+
<span class="faint" style="font-weight:500"><%= @columns.values.sum(&:size) %> tickets</span>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% content_for :actions do %>
|
|
8
|
+
<%= form_with url: board_path, method: :get, class: "search", data: { controller: "autosubmit", turbo_action: "replace" } do %>
|
|
9
|
+
<%= icon("search", size: 14) %>
|
|
10
|
+
<input class="input" type="search" name="q" value="<%= @filters["q"] %>" placeholder="Search or jump to <%= current_project.key %>-…" data-hotkey="search" data-action="input->autosubmit#submit" autocomplete="off">
|
|
11
|
+
<% end %>
|
|
12
|
+
<%= link_to new_ticket_path, class: "btn btn-primary" do %><%= icon("plus", size: 14) %> New ticket<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% content_for :toolbar do %>
|
|
15
|
+
<div class="board-toolbar">
|
|
16
|
+
<span class="muted" style="font-size:12px;margin-right:2px">Filter</span>
|
|
17
|
+
<% %w[feature bug task chore spike].each do |k| %>
|
|
18
|
+
<%= link_to k.capitalize, board_path(@filters.merge("kind" => (@filters["kind"] == k ? nil : k)).compact), class: "filter-chip #{"is-on" if @filters["kind"] == k}" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<span class="faint">·</span>
|
|
21
|
+
<% Agentboard::Ticket::PRIORITIES.each do |p, label| %>
|
|
22
|
+
<%= link_to board_path(@filters.merge("priority" => (@filters["priority"] == p.to_s ? nil : p.to_s)).compact), class: "filter-chip #{"is-on" if @filters["priority"] == p.to_s}" do %><%= priority_icon(p) %> <%= label %><% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% if @labels.any? %>
|
|
25
|
+
<span class="faint">·</span>
|
|
26
|
+
<% @labels.first(8).each do |l| %>
|
|
27
|
+
<%= link_to l, board_path(@filters.merge("label" => (@filters["label"] == l ? nil : l)).compact), class: "filter-chip #{"is-on" if @filters["label"] == l}" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% if @milestones.any? %>
|
|
31
|
+
<span class="faint">·</span>
|
|
32
|
+
<% @milestones.each do |m| %>
|
|
33
|
+
<%= link_to board_path(@filters.merge("milestone" => (@filters["milestone"] == m.id ? nil : m.id)).compact), class: "filter-chip #{"is-on" if @filters["milestone"] == m.id}" do %><%= icon("flag", size: 12) %> <%= m.name %><% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if @filters.any? %>
|
|
37
|
+
<%= link_to board_path, class: "btn btn-ghost btn-sm" do %><%= icon("x", size: 12) %> Clear<% end %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
<% if @active_executions.any? %>
|
|
41
|
+
<div class="live-strip">
|
|
42
|
+
<% @active_executions.each do |e| %>
|
|
43
|
+
<%= link_to execution_path(e), class: "live-item" do %>
|
|
44
|
+
<%= provider_avatar(e.provider_key, size: 20) %>
|
|
45
|
+
<strong class="mono" style="font-size:11.5px"><%= e.ticket.key %></strong>
|
|
46
|
+
<%= execution_pill(e.status) %>
|
|
47
|
+
<span class="muted"><%= e.action %> · <%= duration(e.duration) %></span>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<div class="board" data-controller="board">
|
|
55
|
+
<% @columns.each do |status, tickets| %>
|
|
56
|
+
<section class="column" data-column="<%= status %>">
|
|
57
|
+
<header class="column-head">
|
|
58
|
+
<%= status_glyph(status) %>
|
|
59
|
+
<h3><%= Agentboard::StateMachines::Ticket::LABELS[status] %></h3>
|
|
60
|
+
<span class="column-count"><%= tickets.size %></span>
|
|
61
|
+
<% if %w[backlog ready].include?(status) %>
|
|
62
|
+
<%= link_to new_ticket_path(status: status), class: "btn btn-ghost btn-sm btn-icon", title: "New ticket in #{status}" do %><%= icon("plus", size: 14) %><% end %>
|
|
63
|
+
<% end %>
|
|
64
|
+
</header>
|
|
65
|
+
<div class="column-body">
|
|
66
|
+
<% if tickets.empty? %>
|
|
67
|
+
<div class="column-empty"><%= status == "ready" ? "Tickets with acceptance criteria land here" : "Drop tickets here" %></div>
|
|
68
|
+
<% end %>
|
|
69
|
+
<%= render partial: "agentboard/board/card", collection: tickets, as: :ticket %>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% content_for :title, "Executions" %>
|
|
2
|
+
<% content_for :crumbs do %><%= icon("terminal", size: 16, klass: "muted") %> <span>Executions</span><% end %>
|
|
3
|
+
<% unless @daemon[:running] %>
|
|
4
|
+
<div class="callout callout-warn mb"><%= icon("alert", size: 16) %><div><strong>The runtime daemon is not running.</strong> Queued executions wait until you start it:<br><code>bundle exec agentboard daemon start</code> — agents run in that separate process, so restarting the web server never kills them.</div></div>
|
|
5
|
+
<% end %>
|
|
6
|
+
<div class="tabs">
|
|
7
|
+
<%= link_to "All", executions_path, class: "tab #{"is-active" unless @status}" %>
|
|
8
|
+
<% %w[queued running verifying completed failed cancelled interrupted].each do |s| %>
|
|
9
|
+
<%= link_to s.humanize, executions_path(status: s), class: "tab #{"is-active" if @status == s}" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="card card-body-flush">
|
|
13
|
+
<% if @executions.empty? %>
|
|
14
|
+
<div class="empty"><%= icon("terminal", size: 28) %><strong>No executions</strong><span>Open a ticket and start an agent, or run <code>bundle exec agentboard run <%= current_project.key %>-1 --provider fake</code>.</span></div>
|
|
15
|
+
<% else %>
|
|
16
|
+
<table class="table">
|
|
17
|
+
<thead><tr><th>Execution</th><th>Ticket</th><th>Status</th><th>Checks</th><th>Head</th><th>Duration</th><th class="right">Started</th></tr></thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% @executions.each do |e| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%= link_to execution_path(e), class: "row-link" do %><%= provider_avatar(e.provider_key, size: 20) %><span><strong style="font-weight:550"><%= e.action.humanize %></strong> <span class="muted">#<%= e.attempt %></span></span><% end %></td>
|
|
22
|
+
<td><%= link_to e.ticket.key, ticket_path(e.ticket), class: "link mono", style: "font-size:12px" %> <span class="muted truncate" style="max-width:240px;display:inline-block;vertical-align:bottom"><%= e.ticket.title %></span></td>
|
|
23
|
+
<td><%= execution_pill(e.status) %><% if e.failure_code %> <span class="faint" style="font-size:11.5px"><%= e.failure_code %></span><% end %></td>
|
|
24
|
+
<td class="muted"><%= e.verification_results.any? ? "#{e.verification_results.count(&:passed?)}/#{e.verification_results.size}" : "—" %></td>
|
|
25
|
+
<td><%= sha_tag(e.head_sha) %></td>
|
|
26
|
+
<td class="muted"><%= duration(e.duration) %></td>
|
|
27
|
+
<td class="right muted"><%= ago(e.started_at || e.created_at) %></td>
|
|
28
|
+
</tr>
|
|
29
|
+
<% end %>
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|