diff --git a/app/controllers/api/v1/hooks_controller.rb b/app/controllers/api/v1/hooks_controller.rb index 8f84392e..4c35a186 100644 --- a/app/controllers/api/v1/hooks_controller.rb +++ b/app/controllers/api/v1/hooks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index 6313f1fa..df6b9fbd 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -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 @@ -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 = { diff --git a/app/controllers/boards/roadmaps_controller.rb b/app/controllers/boards/roadmaps_controller.rb new file mode 100644 index 00000000..97e7a525 --- /dev/null +++ b/app/controllers/boards/roadmaps_controller.rb @@ -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 diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 282abce1..24b558f7 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -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 diff --git a/app/controllers/file_viewer_controller.rb b/app/controllers/file_viewer_controller.rb index c5e9f2a1..709a7043 100644 --- a/app/controllers/file_viewer_controller.rb +++ b/app/controllers/file_viewer_controller.rb @@ -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 @@ -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)) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 844182b1..e2a485e7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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) diff --git a/app/jobs/zeroclaw_dispatch_job.rb b/app/jobs/zeroclaw_dispatch_job.rb index 0eb71030..3d78e84c 100644 --- a/app/jobs/zeroclaw_dispatch_job.rb +++ b/app/jobs/zeroclaw_dispatch_job.rb @@ -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) diff --git a/app/models/board.rb b/app/models/board.rb index 4404aefa..ba1053a1 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -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 diff --git a/app/models/board_roadmap.rb b/app/models/board_roadmap.rb new file mode 100644 index 00000000..a4d49df2 --- /dev/null +++ b/app/models/board_roadmap.rb @@ -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 diff --git a/app/models/board_roadmap_task_link.rb b/app/models/board_roadmap_task_link.rb new file mode 100644 index 00000000..a4b1ee36 --- /dev/null +++ b/app/models/board_roadmap_task_link.rb @@ -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 diff --git a/app/models/factory_cycle_log.rb b/app/models/factory_cycle_log.rb index a3585a6a..b764ec1b 100644 --- a/app/models/factory_cycle_log.rb +++ b/app/models/factory_cycle_log.rb @@ -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 diff --git a/app/models/notification.rb b/app/models/notification.rb index 48c8e840..533ea786 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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) } diff --git a/app/models/task.rb b/app/models/task.rb index 885b8e8c..e418a574 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -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", diff --git a/app/models/task/agent_integration.rb b/app/models/task/agent_integration.rb index b2fc551f..b6b6a1ef 100644 --- a/app/models/task/agent_integration.rb +++ b/app/models/task/agent_integration.rb @@ -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? @@ -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? diff --git a/app/models/task_dependency.rb b/app/models/task_dependency.rb index f5f1b181..53ab697e 100644 --- a/app/models/task_dependency.rb +++ b/app/models/task_dependency.rb @@ -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 @@ -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 diff --git a/app/models/task_template.rb b/app/models/task_template.rb index 7db28789..e751ca30 100644 --- a/app/models/task_template.rb +++ b/app/models/task_template.rb @@ -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? } diff --git a/app/models/user.rb b/app/models/user.rb index df44c6c2..b095fadf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 @@ -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? diff --git a/app/services/agent_auto_runner_service.rb b/app/services/agent_auto_runner_service.rb index db180fbd..fff14a01 100644 --- a/app/services/agent_auto_runner_service.rb +++ b/app/services/agent_auto_runner_service.rb @@ -37,10 +37,6 @@ def run! next if agent_currently_working?(user) - # Optional pipeline pre-routing only in pipeline assist mode. - if pipeline_assist_enabled_for?(user) - stats[:pipeline_processed] += process_pipeline_tasks!(user) - end if wake_if_work_available!(user) stats[:users_woken] += 1 @@ -58,10 +54,6 @@ def openclaw_configured?(user) user.openclaw_gateway_url.present? && (hooks_token.present? || user.openclaw_gateway_token.present?) end - def pipeline_assist_enabled_for?(user) - user.respond_to?(:pipeline_assist_mode?) && user.pipeline_assist_mode? - end - def agent_currently_working?(user) RunnerLease.active.joins(:task).where(tasks: { user_id: user.id, status: Task.statuses[:in_progress] }).exists? end @@ -118,11 +110,7 @@ def wake_if_work_available!(user) ) begin - if pipeline_assist_enabled_for?(user) && task.pipeline_ready? - @openclaw_webhook_service.new(user).notify_auto_pull_ready_with_pipeline(task) - else - @openclaw_webhook_service.new(user).notify_auto_pull_ready(task) - end + @openclaw_webhook_service.new(user).notify_auto_pull_ready(task) rescue StandardError => e @logger.warn("[AgentAutoRunner] wake failed user_id=#{user.id} task_id=#{task.id} err=#{e.class}: #{e.message}") end diff --git a/app/services/board_roadmap_task_generator.rb b/app/services/board_roadmap_task_generator.rb new file mode 100644 index 00000000..78f1ce59 --- /dev/null +++ b/app/services/board_roadmap_task_generator.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class BoardRoadmapTaskGenerator + Result = Struct.new(:created_tasks, :created_count, keyword_init: true) + + def initialize(board_roadmap) + @roadmap = board_roadmap + @board = board_roadmap.board + end + + def call + created = [] + + BoardRoadmap.transaction do + @roadmap.unchecked_items.each do |item| + next if @roadmap.task_links.exists?(item_key: item[:key]) + + task = @board.tasks.create!( + name: item[:text], + user: @board.user, + status: :inbox + ) + + @roadmap.task_links.create!( + task: task, + item_key: item[:key], + item_text: item[:text] + ) + + created << task + end + + @roadmap.update!( + last_generated_at: Time.current, + last_generated_count: created.size + ) + end + + Result.new(created_tasks: created, created_count: created.size) + end +end diff --git a/app/services/lobster_runner.rb b/app/services/lobster_runner.rb new file mode 100644 index 00000000..b43eb0cd --- /dev/null +++ b/app/services/lobster_runner.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "yaml" +require "open3" +require "securerandom" + +class LobsterRunner + PIPELINE_DIR = Rails.root.join("lobster") + TIMEOUT_SECONDS = 30 + + Result = Struct.new(:success, :output, :resume_token, :waiting_approval, :error, keyword_init: true) + + def self.run(pipeline_name, task:, args: {}) + new(pipeline_name, task: task, args: args).run + end + + def self.resume(task, approve:) + new(nil, task: task).resume(approve: approve) + end + + def initialize(pipeline_name, task:, args: {}) + @pipeline_name = pipeline_name + @task = task + @args = args + end + + def run + pipeline_file = PIPELINE_DIR.join("#{@pipeline_name}.lobster") + return Result.new(success: false, error: "Pipeline not found: #{@pipeline_name}") unless pipeline_file.exist? + + pipeline = YAML.load_file(pipeline_file) + steps = pipeline["steps"] || [] + outputs = [] + + steps.each do |step| + cmd = interpolate(step["command"], @args.merge("task_id" => @task.id.to_s)) + + if step["approval"] == "required" + token = SecureRandom.hex(16) + @task.update!( + resume_token: token, + lobster_status: "waiting_approval", + lobster_pipeline: @pipeline_name + ) + return Result.new( + success: true, + output: outputs.join("\n"), + resume_token: token, + waiting_approval: true + ) + end + + stdout, stderr, status = Open3.capture3(cmd) + output = stdout.presence || stderr.presence || "(no output)" + outputs << "[#{step["id"]}] #{output.strip}" + + unless status.success? + return Result.new(success: false, output: outputs.join("\n"), error: "Step '#{step["id"]}' failed: #{stderr.strip}") + end + end + + @task.update!(lobster_status: "completed", resume_token: nil) + Result.new(success: true, output: outputs.join("\n")) + rescue => e + Result.new(success: false, error: e.message) + end + + def resume(approve:) + unless approve + @task.update!(lobster_status: "rejected", resume_token: nil) + return Result.new(success: true, output: "Pipeline rejected by user.") + end + + pipeline_name = @task.lobster_pipeline + return Result.new(success: false, error: "No pipeline stored on task") unless pipeline_name.present? + + pipeline_file = PIPELINE_DIR.join("#{pipeline_name}.lobster") + return Result.new(success: false, error: "Pipeline not found: #{pipeline_name}") unless pipeline_file.exist? + + pipeline = YAML.load_file(pipeline_file) + steps = pipeline["steps"] || [] + approval_idx = steps.index { |s| s["approval"] == "required" } + remaining = approval_idx ? steps[(approval_idx + 1)..] : [] + + outputs = [] + remaining.each do |step| + cmd = interpolate(step["command"], { "task_id" => @task.id.to_s }) + stdout, stderr, status = Open3.capture3(cmd) + output = stdout.presence || stderr.presence || "(no output)" + outputs << "[#{step["id"]}] #{output.strip}" + end + + @task.update!(lobster_status: "completed", resume_token: nil) + Result.new(success: true, output: outputs.join("\n")) + rescue => e + Result.new(success: false, error: e.message) + end + + private + + def interpolate(cmd, vars) + cmd.gsub(/\$(\w+)/) { vars[$1] || ENV[$1] || "" } + end +end diff --git a/app/services/openclaw_gateway_client.rb b/app/services/openclaw_gateway_client.rb index 4f41b97e..96693221 100644 --- a/app/services/openclaw_gateway_client.rb +++ b/app/services/openclaw_gateway_client.rb @@ -169,6 +169,72 @@ def node_notify(node_id, title:, body:) # --- Config (read-only, for plugin status) --- + # --- Sessions Chat --- + + # Send a message to an agent session via /hooks/agent. + # /api/sessions/send does NOT exist in OpenClaw β€” this is the correct endpoint. + def sessions_send(session_key, message) + hooks_token = @user.try(:openclaw_hooks_token).presence || @user.openclaw_gateway_token + + uri = base_uri.dup + uri.path = "/hooks/agent" + + req = Net::HTTP::Post.new(uri) + req["Authorization"] = "Bearer #{hooks_token}" + req["Content-Type"] = "application/json" + req.body = { + message: message, + sessionKey: session_key, + deliver: false, + name: "ClawTrol Chat" + }.to_json + + res = http_for(uri).request(req) + JSON.parse(res.body) + rescue StandardError => e + { "ok" => false, "error" => e.message } + end + + # Read session transcript from local JSONL file (no HTTP endpoint exists for this). + def sessions_history(session_key, limit: 20) + sessions_dir = File.expand_path("~/.openclaw/agents/main/sessions") + store_file = File.join(sessions_dir, "sessions.json") + return { "messages" => [], "error" => "sessions.json not found" } unless File.exist?(store_file) + + store = JSON.parse(File.read(store_file)) + entry = store[session_key] + return { "messages" => [], "error" => "session not found" } unless entry + + session_id = entry["sessionId"] + transcript_file = File.join(sessions_dir, "#{session_id}.jsonl") + return { "messages" => [], "error" => "transcript not found" } unless File.exist?(transcript_file) + + messages = [] + File.readlines(transcript_file).last(limit * 3).each do |line| + begin + msg = JSON.parse(line.strip) + next unless %w[user assistant].include?(msg["role"]) + content_text = if msg["content"].is_a?(Array) + msg["content"].select { |c| c["type"] == "text" }.map { |c| c["text"] }.join("\n") + else + msg["content"].to_s + end + next if content_text.blank? + messages << { + "role" => msg["role"], + "content" => content_text, + "timestamp" => msg["timestamp"] + } + rescue JSON::ParserError + next + end + end + + { "messages" => messages.last(limit) } + rescue StandardError => e + { "messages" => [], "error" => e.message } + end + def config_get result = invoke_tool!("gateway", action: "config.get", args: {}) extract_details(result) || {} diff --git a/app/services/openclaw_memory_search_health_service.rb b/app/services/openclaw_memory_search_health_service.rb index 9b4294b2..eb0233fb 100644 --- a/app/services/openclaw_memory_search_health_service.rb +++ b/app/services/openclaw_memory_search_health_service.rb @@ -68,11 +68,12 @@ def check_and_persist! ) end - # 2) Probe memory_search via CLI (OpenClaw has no HTTP API for memory_search) - search = probe_memory_via_cli + # 2) Probe memory_search via HTTP + search = post_json("/api/memory/search", body: { query: "ping", top_k: 1 }) unless search[:ok] + status = classify_memory_error(search[:http_code], search[:error]) return persist!( - status: search[:status] || :degraded, + status: status, last_checked_at: now, error_message: "memory_search: #{search[:error]}", error_at: now diff --git a/app/views/boards/_roadmap.html.erb b/app/views/boards/_roadmap.html.erb new file mode 100644 index 00000000..161a7ae5 --- /dev/null +++ b/app/views/boards/_roadmap.html.erb @@ -0,0 +1,31 @@ +<% return if @board.aggregator? %> + +<% roadmap = @board.roadmap || @board.build_roadmap %> + +
+
+
+
+

πŸ—ΊοΈ Roadmap

+

Write markdown checklist items (- [ ] Task) and generate board tasks.

+
+ <% if roadmap.last_generated_at.present? %> + Last generated: <%= time_ago_in_words(roadmap.last_generated_at) %> ago + <% end %> +
+ + <%= form_with model: roadmap, url: board_roadmap_path(@board), method: :patch, class: "space-y-3" do |form| %> + <%= form.text_area :body, + rows: 8, + placeholder: "- [ ] Ship roadmap MVP\n- [ ] Add controller tests", + class: "w-full rounded-lg border border-border bg-bg-base text-content px-3 py-2 text-sm font-mono" %> + +
+ <%= form.submit "Save Roadmap", class: "px-3 py-2 text-xs font-medium rounded-lg border border-border bg-bg-elevated hover:bg-bg-hover text-content" %> +
+ <% end %> + + <%= button_to "Generate Tasks from Roadmap", generate_tasks_board_roadmap_path(@board), method: :post, + class: "px-3 py-2 text-xs font-medium rounded-lg bg-accent text-white hover:opacity-90" %> +
+
diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index ec5c2d01..95508a69 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -58,6 +58,8 @@ <% end %> + <%= render "boards/roadmap" %> + <%# Mobile Column Tabs - visible only on mobile, sticky so always accessible %>
diff --git a/app/views/boards/tasks/_panel.html.erb b/app/views/boards/tasks/_panel.html.erb index f7f25710..914e58eb 100644 --- a/app/views/boards/tasks/_panel.html.erb +++ b/app/views/boards/tasks/_panel.html.erb @@ -117,6 +117,38 @@ πŸ€– Dispatch to ZeroClaw + <%# Lobster Pipeline & Spawn via Gateway %> +
+ <% if task.resume_token.present? %> + + + <% else %> + + + <% end %> +
+ +