Skip to content
Open
38 changes: 38 additions & 0 deletions app/controllers/api/v1/hooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,44 @@ def agent_complete
}
end

# POST /api/v1/hooks/agent_done
#
# Generic webhook receiver for OpenClaw cron job completions.
# Used by Factory cron jobs (delivery.mode: "webhook") to push results
# directly to ClawTrol instead of relying on announce.
#
# Payload (from OpenClaw cron webhook delivery):
# jobId, runId, status, output, startedAt, endedAt, error
def agent_done
data = request.body.read
parsed = JSON.parse(data) rescue {}

job_id = parsed["jobId"] || params[:job_id]
run_id = parsed["runId"] || params[:run_id]
status = parsed["status"] || "unknown"
output = parsed["output"] || parsed["result"] || ""
error = parsed["error"]

Rails.logger.info("[HooksController#agent_done] job=#{job_id} run=#{run_id} status=#{status}")

# If it's a Factory job, create a FactoryLog or just log it
# Future: map job_id to a Task and update accordingly
if error.present?
Rails.logger.warn("[HooksController#agent_done] Error from job #{job_id}: #{error}")
end

render json: {
ok: true,
job_id: job_id,
run_id: run_id,
status: status,
received_at: Time.current.iso8601
}
rescue => e
Rails.logger.error("[HooksController#agent_done] #{e.message}")
render json: { ok: false, error: e.message }, status: :internal_server_error
end

# POST /api/v1/hooks/task_outcome
#
# OpenClaw completion hook (OutcomeContract v1). This is intentionally
Expand Down
76 changes: 59 additions & 17 deletions app/controllers/api/v1/tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class TasksController < BaseController
include Api::TaskPipelineManagement
include Api::TaskAgentLifecycle
include Api::TaskValidationManagement
before_action :set_task, only: [ :show, :update, :destroy, :complete, :agent_complete, :claim, :unclaim, :assign, :unassign, :generate_followup, :create_followup, :move, :enhance_followup, :handoff, :link_session, :report_rate_limit, :revalidate, :start_validation, :run_debate, :complete_review, :recover_output, :dispatch_zeroclaw, :file, :add_dependency, :remove_dependency, :dependencies, :agent_log, :session_health ]
before_action :set_task, only: [ :show, :update, :destroy, :complete, :agent_complete, :claim, :unclaim, :assign, :unassign, :generate_followup, :create_followup, :move, :enhance_followup, :handoff, :link_session, :report_rate_limit, :revalidate, :start_validation, :run_debate, :complete_review, :recover_output, :dispatch_zeroclaw, :file, :add_dependency, :remove_dependency, :dependencies, :agent_log, :session_health, :run_lobster, :resume_lobster, :spawn_via_gateway ]

# GET /api/v1/tasks/:id/agent_log - get agent transcript for this task
# Returns parsed messages from the OpenClaw session transcript
Expand All @@ -35,27 +35,69 @@ def dispatch_zeroclaw
agent = ZeroclawAgent.next_available
return render json: { error: "No available ZeroClaw agents" }, status: :service_unavailable unless agent

message = @task.description.to_s.presence || @task.name.to_s
# Use job async to avoid request timeout (GLM-4.7 can take 30s+)
ZeroclawDispatchJob.perform_later(@task.id, agent.id)

result = agent.dispatch(message)
agent.update_column(:last_seen_at, Time.current)
render json: { success: true, task_id: @task.id, agent_name: agent.name, queued: true }
rescue => e
render json: { error: "Dispatch failed: #{e.message}" }, status: :unprocessable_entity
end

dispatch_data = {
"agent_name" => agent.name,
"agent_url" => agent.url,
"model" => result["model"],
"response" => result["response"],
"dispatched_at" => Time.current.iso8601
}
# POST /api/v1/tasks/:id/run_lobster
def run_lobster
pipeline = params[:pipeline].presence || "code-review"
args = params[:args]&.permit!&.to_h || {}
args["task_id"] = @task.id.to_s

new_state = @task.state_data.merge("zeroclaw_dispatch" => dispatch_data)
append = "\n\n---\n\n## ZeroClaw Response (#{agent.name})\n\n#{result['response']}"
new_desc = @task.description.to_s + append
@task.update_columns(state_data: new_state, description: new_desc)
result = LobsterRunner.run(pipeline, task: @task, args: args)

render json: { success: true, task_id: @task.id }.merge(dispatch_data)
if result.waiting_approval
render json: { success: true, status: "waiting_approval", resume_token: result.resume_token, output: result.output }
elsif result.success
render json: { success: true, status: "completed", output: result.output }
else
render json: { success: false, error: result.error, output: result.output }, status: :unprocessable_entity
end
rescue => e
render json: { error: "Dispatch failed: #{e.message}" }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_entity
end

# POST /api/v1/tasks/:id/resume_lobster
def resume_lobster
return render json: { error: "No resume token on task" }, status: :bad_request unless @task.resume_token.present?

approve = params[:approve] != "false"
result = LobsterRunner.resume(@task, approve: approve)

if result.success
render json: { success: true, output: result.output, approved: approve }
else
render json: { success: false, error: result.error }, status: :unprocessable_entity
end
rescue => e
render json: { error: e.message }, status: :unprocessable_entity
end

# POST /api/v1/tasks/:id/spawn_via_gateway
def spawn_via_gateway
client = OpenclawGatewayClient.new(current_user)
prompt = @task.compiled_prompt.presence || @task.description.presence || @task.name
model = @task.model.presence || Task::DEFAULT_MODEL

result = client.spawn_session!(model: model, prompt: prompt)
child_key = result[:child_session_key]
session_id = result[:session_id]

if child_key.present?
updates = { agent_session_key: child_key, status: "in_progress", assigned_to_agent: true, assigned_at: Time.current }
updates[:agent_session_id] = session_id if session_id.present?
@task.update!(updates)
render json: { success: true, session_key: child_key, session_id: session_id }
else
render json: { error: "Spawn failed — no child_session_key returned", result: result }, status: :unprocessable_entity
end
rescue => e
render json: { error: e.message }, status: :unprocessable_entity
end

TASK_JSON_INCLUDES = {
Expand Down
40 changes: 40 additions & 0 deletions app/controllers/boards/roadmaps_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module Boards
class RoadmapsController < ApplicationController
before_action :set_board

def update
roadmap = @board.roadmap || @board.build_roadmap
roadmap.assign_attributes(roadmap_params)

if roadmap.save
redirect_to board_path(@board), notice: "Roadmap saved."
else
redirect_to board_path(@board), alert: roadmap.errors.full_messages.join(", ")
end
end

def generate_tasks
roadmap = @board.roadmap

if roadmap.blank?
redirect_to board_path(@board), alert: "Add a roadmap first."
return
end

result = BoardRoadmapTaskGenerator.new(roadmap).call
redirect_to board_path(@board), notice: "Generated #{result.created_count} task(s) from roadmap."
end

private

def set_board
@board = current_user.boards.find(params[:board_id])
end

def roadmap_params
params.require(:board_roadmap).permit(:body)
end
end
end
1 change: 1 addition & 0 deletions app/controllers/boards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def show
@running_oldest_hb_at = active_leases.minimum(:last_heartbeat_at)

@queue_count = @tasks.where(status: :up_next, assigned_to_agent: true, blocked: false).count
@roadmap = @board.roadmap unless @board.aggregator?
end

def archived
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/file_viewer_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def show
end

unless resolved.file?
render inline: error_page("File not found: #{relative}"), status: :not_found, content_type: "text/html"
render inline: error_page("Access denied"), status: :forbidden, content_type: "text/html"
return
end

Expand Down Expand Up @@ -189,7 +189,8 @@ def resolve_safe_path(relative)
return nil unless candidate

# File/dir must exist for realpath to work
return nil unless candidate.exist?
# Return the candidate with a :not_found marker if it doesn't exist yet
return candidate unless candidate.exist?

# Resolve symlinks and verify the real path is still inside an allowed directory
real = Pathname.new(File.realpath(candidate.to_s))
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def file_viewer_url(relative_path)
"#{app_base_url}/view?file=#{relative_path}"
end

def pipeline_ui_enabled?(user = current_user)
user.present? && user.respond_to?(:pipeline_assist_mode?) && user.pipeline_assist_mode?
def pipeline_ui_enabled?(_user = current_user)
false
end

def model_select_options(user = current_user, include_default: true)
Expand Down
3 changes: 2 additions & 1 deletion app/jobs/zeroclaw_dispatch_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ def perform(task_id, agent_id)
task = Task.find(task_id)
agent = ZeroclawAgent.find(agent_id)

message = task.description.to_s.presence || task.name.to_s
# Use task name only — description can be 500KB+ (agent logs) and causes LLM timeouts
message = task.name.to_s

result = agent.dispatch(message)
agent.update_column(:last_seen_at, Time.current)
Expand Down
1 change: 1 addition & 0 deletions app/models/board.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class Board < ApplicationRecord
belongs_to :user, inverse_of: :boards
has_many :tasks, dependent: :destroy, inverse_of: :board
has_one :roadmap, class_name: "BoardRoadmap", dependent: :destroy, inverse_of: :board
has_many :agent_personas, dependent: :nullify, inverse_of: :board
has_many :swarm_ideas, dependent: :nullify, inverse_of: :board

Expand Down
27 changes: 27 additions & 0 deletions app/models/board_roadmap.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

class BoardRoadmap < ApplicationRecord
CHECKLIST_REGEX = /^\s*-\s\[\s\]\s+(.+?)\s*$/.freeze

belongs_to :board
has_many :task_links, class_name: "BoardRoadmapTaskLink", dependent: :destroy, inverse_of: :board_roadmap

validates :body, length: { maximum: 500_000 }

def unchecked_items
body.to_s.each_line.filter_map do |line|
match = line.match(CHECKLIST_REGEX)
next unless match

text = match[1].to_s.strip
next if text.blank?

{ text: text, key: item_key_for(text) }
end.uniq { |item| item[:key] }
end

def item_key_for(text)
normalized = text.to_s.strip.downcase.gsub(/\s+/, " ")
Digest::SHA256.hexdigest(normalized)
end
end
10 changes: 10 additions & 0 deletions app/models/board_roadmap_task_link.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class BoardRoadmapTaskLink < ApplicationRecord
belongs_to :board_roadmap, inverse_of: :task_links
belongs_to :task

validates :item_key, presence: true, uniqueness: { scope: :board_roadmap_id }
validates :item_text, presence: true
validates :task_id, uniqueness: { scope: :board_roadmap_id }
end
2 changes: 1 addition & 1 deletion app/models/factory_cycle_log.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class FactoryCycleLog < ApplicationRecord
# The 'errors' column conflicts with ActiveRecord::Base#errors in Rails 8.1+
self.ignored_columns += ["errors"]

belongs_to :factory_loop, inverse_of: :factory_cycle_logs
belongs_to :factory_loop, optional: true, inverse_of: :factory_cycle_logs
belongs_to :user, optional: true, inverse_of: :factory_cycle_logs
has_many :factory_agent_runs, dependent: :nullify, inverse_of: :factory_cycle_log

Expand Down
2 changes: 2 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class Notification < ApplicationRecord
validates :message, presence: true, length: { maximum: 10_000 }
validates :read_at, presence: true, if: -> { persisted? && read_at.present? }

after_create :enforce_cap_for_user

# Scopes
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
Expand Down
7 changes: 6 additions & 1 deletion app/models/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ class Task < ApplicationRecord
# Pipeline stages - production pipeline services use these values.
PIPELINE_STAGES = %w[unstarted triaged context_ready routed executing verifying completed failed].freeze

# NOTE: pipeline_stage is a string column in production (not integer).
# NOTE: Some deployments may lag migrations; declare explicit attribute types so model boot
# remains safe even when pipeline columns are absent.
attribute :pipeline_enabled, :boolean, default: false
attribute :pipeline_type, :string
attribute :pipeline_log, :json, default: []
attribute :pipeline_stage, :string
# Use string-backed enum to match the DB schema.
enum :pipeline_stage, {
unstarted: "unstarted",
Expand Down
6 changes: 4 additions & 2 deletions app/models/task/agent_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ def has_agent_output_marker?
end

def requires_agent_output_for_done?
assigned_to_agent? || agent_session_id.present? || assigned_at.present?
# Only require agent output if an agent actually ran (claimed the task)
(agent_claimed_at.present? || agent_session_id.present?)
end

def missing_agent_output?
Expand Down Expand Up @@ -311,7 +312,8 @@ def agent_output_required_for_done_transition
# We enforce this only for agent-assigned tasks to avoid breaking
# human-only workflows.
def in_progress_requires_active_lease
return unless assigned_to_agent?
# Only enforce for tasks that an agent has actively claimed (not just queued)
return unless assigned_to_agent? && agent_claimed_at.present?

# Accept a linked session as legacy/equivalent evidence.
return if runner_lease_active? || agent_session_id.present?
Expand Down
7 changes: 4 additions & 3 deletions app/models/task_dependency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

class TaskDependency < ApplicationRecord
# Use strict_loading_mode :strict to raise on N+1, :n_plus_one to only warn
strict_loading :n_plus_one
self.strict_loading_mode = :n_plus_one

belongs_to :task, inverse_of: :task_dependencies
belongs_to :depends_on, class_name: "Task", inverse_of: :dependents
Expand All @@ -20,17 +20,18 @@ class TaskDependency < ApplicationRecord

def no_self_dependency
if task_id == depends_on_id
errors.add(:base, "A task cannot depend on itself")
errors.add(:base, "cannot depend on itself")
end
end

def no_circular_dependency
return if depends_on_id.nil? || task_id.nil?
return if task_id == depends_on_id # self-dependency already caught by no_self_dependency

# Check if adding this dependency would create a cycle
# (i.e., if depends_on already depends on task, directly or indirectly)
if would_create_cycle?
errors.add(:base, "This dependency would create a circular dependency")
errors.add(:base, "circular dependency")
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/models/task_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class TaskTemplate < ApplicationRecord
validates :name, presence: true
validates :slug, presence: true, format: { with: /\A[a-z0-9_-]+\z/, message: "only allows lowercase letters, numbers, hyphens, and underscores" }
validates :slug, uniqueness: { scope: :user_id }, if: -> { user_id.present? }
validates :slug, uniqueness: true, if: -> { global? }
validates :slug, uniqueness: { conditions: -> { where(global: true) } }, if: -> { global? }
validates :model, inclusion: { in: MODELS }, allow_nil: true, allow_blank: true
validates :priority, inclusion: { in: 0..3 }, allow_nil: true
validate :validation_command_is_safe, if: -> { validation_command.present? }
Expand Down
8 changes: 2 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class User < ApplicationRecord
strict_loading :n_plus_one

THEMES = %w[default vaporwave].freeze
ORCHESTRATION_MODES = %w[openclaw_only pipeline_assist].freeze
ORCHESTRATION_MODES = %w[openclaw_only].freeze

has_many :sessions, dependent: :destroy, inverse_of: :user
has_many :boards, dependent: :destroy, inverse_of: :user
Expand Down Expand Up @@ -138,11 +138,7 @@ def has_avatar?


def openclaw_only_mode?
orchestration_mode.to_s != "pipeline_assist"
end

def pipeline_assist_mode?
orchestration_mode.to_s == "pipeline_assist"
true
end
# Check if user signed up via OAuth
def oauth_user?
Expand Down
Loading
Loading