openclacky 1.3.5 → 1.3.6
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent.rb +0 -3
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/server/http_server.rb +122 -119
- data/lib/clacky/server/session_registry.rb +50 -1
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +0 -9
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +54 -1
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/i18n.js +22 -2
- data/lib/clacky/web/index.html +52 -26
- data/lib/clacky/web/sessions.js +36 -36
- data/lib/clacky/web/ws-dispatcher.js +1 -1
- metadata +1 -1
|
@@ -15,6 +15,7 @@ require "securerandom"
|
|
|
15
15
|
require "timeout"
|
|
16
16
|
require "yaml"
|
|
17
17
|
require "date"
|
|
18
|
+
require "open3"
|
|
18
19
|
require_relative "session_registry"
|
|
19
20
|
require_relative "git_panel"
|
|
20
21
|
require_relative "web_ui_controller"
|
|
@@ -468,8 +469,8 @@ module Clacky
|
|
|
468
469
|
30
|
|
469
470
|
elsif path == "/api/media/video/understand"
|
|
470
471
|
60
|
|
471
|
-
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
|
|
472
|
-
# Building a tar.gz of ~/.clacky
|
|
472
|
+
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
|
|
473
|
+
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
473
474
|
120
|
|
474
475
|
else
|
|
475
476
|
10
|
|
@@ -525,6 +526,8 @@ module Clacky
|
|
|
525
526
|
when ["GET", "/api/backup/status"] then api_backup_status(res)
|
|
526
527
|
when ["POST", "/api/backup/run"] then api_backup_run(res)
|
|
527
528
|
when ["GET", "/api/backup/download"] then api_backup_download(res)
|
|
529
|
+
when ["POST", "/api/backup/restore"] then api_backup_restore(req, res)
|
|
530
|
+
when ["POST", "/api/backup/open-folder"] then api_backup_open_folder(res)
|
|
528
531
|
when ["PATCH", "/api/backup/config"] then api_backup_config(req, res)
|
|
529
532
|
when ["POST", "/api/telemetry"] then api_telemetry(req, res)
|
|
530
533
|
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
@@ -736,30 +739,28 @@ module Clacky
|
|
|
736
739
|
# ── REST API ──────────────────────────────────────────────────────────────
|
|
737
740
|
|
|
738
741
|
def api_list_sessions(req, res)
|
|
739
|
-
query
|
|
740
|
-
limit
|
|
741
|
-
before
|
|
742
|
-
q
|
|
743
|
-
q_scope
|
|
744
|
-
date
|
|
745
|
-
type
|
|
742
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
743
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
|
|
744
|
+
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
745
|
+
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
746
|
+
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
747
|
+
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
748
|
+
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
749
|
+
exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
746
750
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
747
751
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
748
752
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
749
753
|
|
|
750
|
-
|
|
751
|
-
# `registry.list` always returns ALL matching pinned rows first (on the
|
|
752
|
-
# first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
|
|
753
|
-
# So has_more is determined by whether the non-pinned section overflowed.
|
|
754
|
-
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
|
|
754
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
|
|
755
755
|
|
|
756
|
-
# Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
|
|
757
756
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
758
757
|
has_more = non_pinned_part.size > limit
|
|
759
758
|
non_pinned_part = non_pinned_part.first(limit)
|
|
760
759
|
sessions = pinned_part + non_pinned_part
|
|
761
760
|
|
|
762
|
-
|
|
761
|
+
stats = @registry.cron_stats
|
|
762
|
+
json_response(res, 200, { sessions: sessions, has_more: has_more,
|
|
763
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
|
|
763
764
|
end
|
|
764
765
|
|
|
765
766
|
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
@@ -4876,6 +4877,68 @@ module Clacky
|
|
|
4876
4877
|
})
|
|
4877
4878
|
end
|
|
4878
4879
|
|
|
4880
|
+
# POST /api/backup/restore — accept a tar.gz upload, extract over ~/.clacky, hot-restart
|
|
4881
|
+
def api_backup_restore(req, res)
|
|
4882
|
+
body = req.body.to_s
|
|
4883
|
+
return json_response(res, 400, { error: "Empty body" }) if body.empty?
|
|
4884
|
+
|
|
4885
|
+
clacky_dir = File.expand_path("~/.clacky")
|
|
4886
|
+
stamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
4887
|
+
tmp_archive = File.join(Dir.tmpdir, "clacky-restore-#{stamp}.tar.gz")
|
|
4888
|
+
tmp_backup = File.join(Dir.tmpdir, "clacky-pre-restore-#{stamp}")
|
|
4889
|
+
|
|
4890
|
+
File.binwrite(tmp_archive, body)
|
|
4891
|
+
|
|
4892
|
+
FileUtils.cp_r(clacky_dir, tmp_backup)
|
|
4893
|
+
|
|
4894
|
+
result = system("tar", "-xzf", tmp_archive, "-C", clacky_dir)
|
|
4895
|
+
unless result
|
|
4896
|
+
FileUtils.rm_rf(clacky_dir)
|
|
4897
|
+
FileUtils.cp_r(tmp_backup, clacky_dir)
|
|
4898
|
+
return json_response(res, 500, { error: "Failed to extract archive" })
|
|
4899
|
+
end
|
|
4900
|
+
|
|
4901
|
+
json_response(res, 200, { ok: true })
|
|
4902
|
+
|
|
4903
|
+
Thread.new do
|
|
4904
|
+
sleep 0.5
|
|
4905
|
+
if @master_pid
|
|
4906
|
+
begin
|
|
4907
|
+
Process.kill("USR1", @master_pid)
|
|
4908
|
+
rescue Errno::ESRCH
|
|
4909
|
+
standalone_exec_restart
|
|
4910
|
+
end
|
|
4911
|
+
else
|
|
4912
|
+
standalone_exec_restart
|
|
4913
|
+
end
|
|
4914
|
+
end
|
|
4915
|
+
rescue => e
|
|
4916
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4917
|
+
ensure
|
|
4918
|
+
FileUtils.rm_f(tmp_archive) if tmp_archive
|
|
4919
|
+
FileUtils.rm_rf(tmp_backup) if tmp_backup && Dir.exist?(tmp_backup)
|
|
4920
|
+
end
|
|
4921
|
+
|
|
4922
|
+
# POST /api/backup/open-folder — open the backup destination in Finder/Explorer
|
|
4923
|
+
def api_backup_open_folder(res)
|
|
4924
|
+
dest = BackupManager.status["dest_dir"]
|
|
4925
|
+
FileUtils.mkdir_p(dest)
|
|
4926
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
4927
|
+
if host_os =~ /darwin/
|
|
4928
|
+
system("open", dest)
|
|
4929
|
+
elsif host_os =~ /linux/
|
|
4930
|
+
if File.exist?("/proc/version") && File.read("/proc/version").downcase.include?("microsoft")
|
|
4931
|
+
windows_path, = Open3.capture2("wslpath", "-w", dest)
|
|
4932
|
+
system("explorer.exe", windows_path.strip) unless windows_path.strip.empty?
|
|
4933
|
+
else
|
|
4934
|
+
system("xdg-open", dest)
|
|
4935
|
+
end
|
|
4936
|
+
end
|
|
4937
|
+
json_response(res, 200, { ok: true, dest_dir: dest })
|
|
4938
|
+
rescue => e
|
|
4939
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4940
|
+
end
|
|
4941
|
+
|
|
4879
4942
|
# Resolve the AgentConfig for a given session, falling back to nil when
|
|
4880
4943
|
# the session isn't live so callers can use the global config instead.
|
|
4881
4944
|
def config_for_session(session_id)
|
|
@@ -5722,12 +5785,12 @@ module Clacky
|
|
|
5722
5785
|
interrupt_session(session_id)
|
|
5723
5786
|
|
|
5724
5787
|
when "list_sessions"
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
page = @registry.list(limit: 21)
|
|
5788
|
+
stats = @registry.cron_stats
|
|
5789
|
+
page = @registry.list(limit: 21, exclude_type: "cron")
|
|
5728
5790
|
has_more = page.size > 20
|
|
5729
5791
|
all_sessions = page.first(20)
|
|
5730
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5792
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5793
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
|
|
5731
5794
|
|
|
5732
5795
|
when "run_task"
|
|
5733
5796
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -5822,112 +5885,30 @@ module Clacky
|
|
|
5822
5885
|
|
|
5823
5886
|
# Interrupt a running agent session.
|
|
5824
5887
|
#
|
|
5825
|
-
# Thread#raise
|
|
5826
|
-
#
|
|
5827
|
-
#
|
|
5828
|
-
# that stay "running" forever even after multiple interrupt attempts.
|
|
5829
|
-
#
|
|
5830
|
-
# Strategy: three-tier escalation in a background watchdog Thread so
|
|
5831
|
-
# the HTTP handler returns immediately.
|
|
5832
|
-
#
|
|
5833
|
-
# Tier 1 (t=0): Thread#raise(AgentInterrupted).
|
|
5834
|
-
# Unblocks most pure-Ruby waits and Faraday reads.
|
|
5835
|
-
# Handles the common case.
|
|
5836
|
-
# Tier 2 (t=3): force-close this session's WebSocket connections
|
|
5837
|
-
# so any send_raw stuck on socket write wakes up.
|
|
5838
|
-
# Try Thread#raise again (idempotent).
|
|
5839
|
-
# Tier 3 (t=8): Thread#kill — last resort. Leaks any held
|
|
5840
|
-
# resources but frees the session so the user can
|
|
5841
|
-
# move on.
|
|
5888
|
+
# Thread#raise is best-effort: it unblocks most pure-Ruby waits and
|
|
5889
|
+
# Faraday reads, but can't reach a thread stuck in a C-extension syscall
|
|
5890
|
+
# until that syscall returns. We raise once and return immediately.
|
|
5842
5891
|
#
|
|
5843
|
-
#
|
|
5844
|
-
#
|
|
5845
|
-
#
|
|
5892
|
+
# Correctness of the *takeover* does not depend on the old thread dying
|
|
5893
|
+
# promptly: each new task claims a fresh epoch (see run_agent_task), and
|
|
5894
|
+
# any status write or UI broadcast from a superseded thread is fenced off
|
|
5895
|
+
# by that epoch. A stale thread that lingers in a syscall is harmless — it
|
|
5896
|
+
# self-terminates at the next check_stale! checkpoint, or when the syscall
|
|
5897
|
+
# returns; either way it can no longer touch the live session.
|
|
5846
5898
|
def interrupt_session(session_id)
|
|
5847
|
-
thread = nil
|
|
5848
5899
|
@registry.with_session(session_id) do |s|
|
|
5849
5900
|
s[:idle_timer]&.cancel
|
|
5850
5901
|
thread = s[:thread]
|
|
5851
|
-
|
|
5852
5902
|
next unless thread&.alive?
|
|
5853
5903
|
|
|
5854
|
-
Clacky::Logger.info("[interrupt] session=#{session_id}
|
|
5904
|
+
Clacky::Logger.info("[interrupt] session=#{session_id} raise")
|
|
5855
5905
|
begin
|
|
5856
5906
|
thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
|
|
5857
5907
|
rescue ThreadError => e
|
|
5858
|
-
Clacky::Logger.warn("[interrupt]
|
|
5908
|
+
Clacky::Logger.warn("[interrupt] raise failed: #{e.message}")
|
|
5859
5909
|
end
|
|
5860
5910
|
end
|
|
5861
|
-
|
|
5862
|
-
return unless thread&.alive?
|
|
5863
|
-
|
|
5864
|
-
start_interrupt_watchdog(session_id, thread)
|
|
5865
|
-
end
|
|
5866
|
-
|
|
5867
|
-
# Background watchdog: escalates from WebSocket force-close (tier 2)
|
|
5868
|
-
# to Thread#kill (tier 3) if the agent thread refuses to die.
|
|
5869
|
-
private def start_interrupt_watchdog(session_id, thread)
|
|
5870
|
-
Thread.new do
|
|
5871
|
-
Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
|
|
5872
|
-
|
|
5873
|
-
# Give the first Thread#raise a few seconds to unwind.
|
|
5874
|
-
sleep 3
|
|
5875
|
-
next unless thread.alive?
|
|
5876
|
-
|
|
5877
|
-
Clacky::Logger.warn(
|
|
5878
|
-
"[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
|
|
5879
|
-
"force-closing session resources"
|
|
5880
|
-
)
|
|
5881
|
-
force_close_session_sockets(session_id)
|
|
5882
|
-
# Re-raise — sometimes the first raise was swallowed deep in a
|
|
5883
|
-
# C-extension syscall; after we force-close the socket the
|
|
5884
|
-
# syscall returns and the next raise sticks.
|
|
5885
|
-
begin
|
|
5886
|
-
thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
|
|
5887
|
-
rescue ThreadError
|
|
5888
|
-
# already dead between checks — fine
|
|
5889
|
-
end
|
|
5890
|
-
|
|
5891
|
-
sleep 5
|
|
5892
|
-
next unless thread.alive?
|
|
5893
|
-
|
|
5894
|
-
Clacky::Logger.error(
|
|
5895
|
-
"[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
|
|
5896
|
-
)
|
|
5897
|
-
begin
|
|
5898
|
-
thread.kill
|
|
5899
|
-
rescue StandardError => e
|
|
5900
|
-
Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
|
|
5901
|
-
end
|
|
5902
|
-
|
|
5903
|
-
# Record the forced-kill so the UI can show a warning and operators
|
|
5904
|
-
# can correlate with any backtrace dumps. The session is left in
|
|
5905
|
-
# :idle state by run_agent_task's rescue clause; if the kill
|
|
5906
|
-
# happened before the rescue could run, patch the state directly.
|
|
5907
|
-
begin
|
|
5908
|
-
@registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
|
|
5909
|
-
broadcast_session_update(session_id)
|
|
5910
|
-
rescue StandardError
|
|
5911
|
-
# best effort
|
|
5912
|
-
end
|
|
5913
|
-
end
|
|
5914
|
-
end
|
|
5915
|
-
|
|
5916
|
-
# Close every WebSocket connection bound to the given session. Used by
|
|
5917
|
-
# the interrupt watchdog to unblock agent threads stuck in a WS write.
|
|
5918
|
-
private def force_close_session_sockets(session_id)
|
|
5919
|
-
conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
5920
|
-
conns.each do |conn|
|
|
5921
|
-
Clacky::Logger.warn(
|
|
5922
|
-
"[interrupt] session=#{session_id} force-closing WS conn"
|
|
5923
|
-
)
|
|
5924
|
-
conn.force_close!
|
|
5925
|
-
end
|
|
5926
|
-
rescue StandardError => e
|
|
5927
|
-
Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
5928
|
-
end
|
|
5929
|
-
|
|
5930
|
-
# Run a task in a session immediately in the background, without waiting
|
|
5911
|
+
end # Run a task in a session immediately in the background, without waiting
|
|
5931
5912
|
# for the client to subscribe. The user bubble is persisted via
|
|
5932
5913
|
# display_text (Agent#run → history → replay_history), so the frontend
|
|
5933
5914
|
# only needs to navigate over and load history — no realtime broadcast,
|
|
@@ -6008,11 +5989,19 @@ module Clacky
|
|
|
6008
5989
|
# Cancel any pending idle compression before starting a new task
|
|
6009
5990
|
idle_timer&.cancel
|
|
6010
5991
|
|
|
6011
|
-
#
|
|
5992
|
+
# Claim a fresh epoch and mark running atomically-ish. The epoch
|
|
5993
|
+
# fences this task against an interrupted-but-not-yet-dead predecessor:
|
|
5994
|
+
# the old thread compares its epoch before writing status or
|
|
5995
|
+
# broadcasting and silently drops anything once superseded. Without
|
|
5996
|
+
# this a slow old thread could overwrite :running back to :idle (or
|
|
5997
|
+
# close the new task's sockets), leaving the UI stuck "running".
|
|
5998
|
+
#
|
|
5999
|
+
# Marked running BEFORE evict_excess_idle! — otherwise this session
|
|
6012
6000
|
# (still :idle here) can be evicted from the registry along with
|
|
6013
6001
|
# other idle agents, breaking subsequent status updates and any
|
|
6014
6002
|
# follow-up handle_user_message (which would early-return on
|
|
6015
6003
|
# @registry.exist? == false).
|
|
6004
|
+
epoch = @registry.claim_epoch(session_id)
|
|
6016
6005
|
@registry.update(session_id, status: :running)
|
|
6017
6006
|
|
|
6018
6007
|
# evict_excess_idle! serializes + writes 1 file per evicted session
|
|
@@ -6028,8 +6017,9 @@ module Clacky
|
|
|
6028
6017
|
locale = Thread.current[:lang]
|
|
6029
6018
|
thread = Thread.new do
|
|
6030
6019
|
Thread.current[:lang] = locale
|
|
6020
|
+
Thread.current[:task_epoch] = epoch
|
|
6031
6021
|
task.call
|
|
6032
|
-
@registry.
|
|
6022
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle, error: nil)
|
|
6033
6023
|
broadcast_session_update(session_id)
|
|
6034
6024
|
# Transient global signal for the optional task-complete sound. Sent to
|
|
6035
6025
|
# all clients (broadcast_all) so a browser viewing another session — or
|
|
@@ -6040,7 +6030,9 @@ module Clacky
|
|
|
6040
6030
|
# Start idle compression timer now that the agent is idle
|
|
6041
6031
|
idle_timer&.start
|
|
6042
6032
|
rescue Clacky::AgentInterrupted
|
|
6043
|
-
|
|
6033
|
+
# A superseding task already owns the session — do not touch status
|
|
6034
|
+
# or push UI events, they belong to the new epoch now.
|
|
6035
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
|
|
6044
6036
|
broadcast_session_update(session_id)
|
|
6045
6037
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
6046
6038
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
@@ -6056,12 +6048,14 @@ module Clacky
|
|
|
6056
6048
|
end
|
|
6057
6049
|
user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
|
|
6058
6050
|
raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
|
|
6059
|
-
@registry.
|
|
6051
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6060
6052
|
broadcast_session_update(session_id)
|
|
6061
6053
|
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6062
6054
|
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
|
|
6063
6055
|
end
|
|
6064
|
-
|
|
6056
|
+
# Register the thread only if we still own the epoch; a faster
|
|
6057
|
+
# superseding task may have already replaced it.
|
|
6058
|
+
@registry.with_session(session_id) { |s| s[:thread] = thread if s[:epoch].to_i == epoch.to_i }
|
|
6065
6059
|
end
|
|
6066
6060
|
|
|
6067
6061
|
# ── WebSocket subscription management ─────────────────────────────────────
|
|
@@ -6091,6 +6085,15 @@ module Clacky
|
|
|
6091
6085
|
# removed automatically. Connections already marked closed are skipped
|
|
6092
6086
|
# upfront so one sluggish client can't delay delivery to healthy ones.
|
|
6093
6087
|
def broadcast(session_id, event)
|
|
6088
|
+
# Drop events emitted by a superseded agent thread. A thread carrying a
|
|
6089
|
+
# :task_epoch only gets through while it still owns the session; an
|
|
6090
|
+
# interrupted-but-unwinding old thread (e.g. a late show_progress
|
|
6091
|
+
# "done" from an ensure block) is silently dropped so it can't disturb
|
|
6092
|
+
# the new task's UI. Threads without :task_epoch (HTTP handlers, the
|
|
6093
|
+
# task supervisor itself) are never affected.
|
|
6094
|
+
my_epoch = Thread.current[:task_epoch]
|
|
6095
|
+
return if my_epoch && @registry.current_epoch(session_id) != my_epoch
|
|
6096
|
+
|
|
6094
6097
|
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
6095
6098
|
dead = []
|
|
6096
6099
|
clients.each do |conn|
|
|
@@ -49,6 +49,7 @@ module Clacky
|
|
|
49
49
|
agent: nil,
|
|
50
50
|
ui: nil,
|
|
51
51
|
thread: nil,
|
|
52
|
+
epoch: 0,
|
|
52
53
|
idle_timer: nil,
|
|
53
54
|
pending_task: nil,
|
|
54
55
|
pending_working_dir: nil
|
|
@@ -138,6 +139,40 @@ module Clacky
|
|
|
138
139
|
end
|
|
139
140
|
end
|
|
140
141
|
|
|
142
|
+
# Atomically bump the session's task epoch and return the new value.
|
|
143
|
+
# Each user message that starts a task claims a fresh epoch; a stale task
|
|
144
|
+
# thread (interrupted but not yet dead) compares its epoch against the
|
|
145
|
+
# current one and discards any side effects (status writes, broadcasts)
|
|
146
|
+
# once it has been superseded.
|
|
147
|
+
def claim_epoch(session_id)
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
session = @sessions[session_id]
|
|
150
|
+
return nil unless session
|
|
151
|
+
|
|
152
|
+
session[:epoch] = session[:epoch].to_i + 1
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Current task epoch for a session (0 if none / unknown).
|
|
157
|
+
def current_epoch(session_id)
|
|
158
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:epoch, 0).to_i }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Update fields only if the caller still owns the current epoch. Returns
|
|
162
|
+
# true if the update was applied, false if the epoch was stale (a newer
|
|
163
|
+
# task has taken over) or the session is gone.
|
|
164
|
+
def update_if_epoch(session_id, epoch, **fields)
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
session = @sessions[session_id]
|
|
167
|
+
return false unless session
|
|
168
|
+
return false unless session[:epoch].to_i == epoch.to_i
|
|
169
|
+
|
|
170
|
+
fields[:updated_at] = Time.now
|
|
171
|
+
session.merge!(fields)
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
141
176
|
# Return a session list from disk enriched with live registry status.
|
|
142
177
|
# Sorted by created_at descending (newest first).
|
|
143
178
|
#
|
|
@@ -161,7 +196,7 @@ module Clacky
|
|
|
161
196
|
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
162
197
|
#
|
|
163
198
|
# source and profile are orthogonal — either can be nil independently.
|
|
164
|
-
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, include_pinned: true)
|
|
199
|
+
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true)
|
|
165
200
|
return [] unless @session_manager
|
|
166
201
|
|
|
167
202
|
live = @mutex.synchronize do
|
|
@@ -196,6 +231,12 @@ module Clacky
|
|
|
196
231
|
end
|
|
197
232
|
end
|
|
198
233
|
|
|
234
|
+
# ── exclude_type filter ───────────────────────────────────────────────
|
|
235
|
+
if exclude_type
|
|
236
|
+
excluded = Array(exclude_type)
|
|
237
|
+
all = all.reject { |s| excluded.include?(s_source(s)) }
|
|
238
|
+
end
|
|
239
|
+
|
|
199
240
|
# ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
|
|
200
241
|
all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
|
|
201
242
|
|
|
@@ -349,6 +390,14 @@ module Clacky
|
|
|
349
390
|
@session_manager.all_sessions.count { |s| s_source(s) == "cron" }
|
|
350
391
|
end
|
|
351
392
|
|
|
393
|
+
# Count + latest updated_at for cron sessions (used by sidebar virtual entry).
|
|
394
|
+
def cron_stats
|
|
395
|
+
return { count: 0, latest_updated_at: nil } unless @session_manager
|
|
396
|
+
cron_all = @session_manager.all_sessions.select { |s| s_source(s) == "cron" }
|
|
397
|
+
latest = cron_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
|
|
398
|
+
{ count: cron_all.size, latest_updated_at: latest }
|
|
399
|
+
end
|
|
400
|
+
|
|
352
401
|
# Delete a session from registry (and interrupt its thread).
|
|
353
402
|
def delete(session_id)
|
|
354
403
|
@mutex.synchronize do
|
|
@@ -35,7 +35,7 @@ module Clacky
|
|
|
35
35
|
|
|
36
36
|
# Keep only the most recent 200 sessions (best-effort, never block save)
|
|
37
37
|
begin
|
|
38
|
-
cleanup_by_count(keep: 200)
|
|
38
|
+
cleanup_by_count(keep: 200, keep_cron: 200)
|
|
39
39
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
40
40
|
# Cleanup is non-critical; swallow all errors (including AgentInterrupted)
|
|
41
41
|
end
|
|
@@ -163,6 +163,33 @@ module Clacky
|
|
|
163
163
|
chunk_path
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
+
# Read the raw markdown of a chunk file. Returns nil if missing.
|
|
167
|
+
def read_chunk(chunk_path)
|
|
168
|
+
return nil unless chunk_path && File.exist?(chunk_path)
|
|
169
|
+
File.read(chunk_path)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Split raw chunk markdown into [front_matter_hash, body_string].
|
|
173
|
+
# front_matter_hash preserves insertion order; body is everything after
|
|
174
|
+
# the closing "---". Returns [nil, raw] when there is no leading block.
|
|
175
|
+
def split_chunk_md(raw)
|
|
176
|
+
return [nil, raw.to_s] unless raw.to_s.start_with?("---")
|
|
177
|
+
|
|
178
|
+
fm_end = raw.index("\n---\n", 4)
|
|
179
|
+
return [nil, raw] unless fm_end
|
|
180
|
+
|
|
181
|
+
fm_text = raw[4...fm_end]
|
|
182
|
+
body = raw[(fm_end + 5)..] || ""
|
|
183
|
+
|
|
184
|
+
fm = {}
|
|
185
|
+
fm_text.each_line do |line|
|
|
186
|
+
k, _, v = line.chomp.partition(":")
|
|
187
|
+
next if k.strip.empty?
|
|
188
|
+
fm[k.strip] = v.strip
|
|
189
|
+
end
|
|
190
|
+
[fm, body]
|
|
191
|
+
end
|
|
192
|
+
|
|
166
193
|
# All sessions from disk, newest-first (sorted by last activity / updated_at,
|
|
167
194
|
# falling back to created_at for legacy sessions without updated_at).
|
|
168
195
|
# Optional filters:
|
|
@@ -305,11 +332,15 @@ module Clacky
|
|
|
305
332
|
# are soft-deleted (moved to the session trash, recoverable). Pinned
|
|
306
333
|
# sessions are never deleted and do not count toward the cap.
|
|
307
334
|
# Returns count of soft-deleted sessions.
|
|
308
|
-
def cleanup_by_count(keep:)
|
|
335
|
+
def cleanup_by_count(keep:, keep_cron: 200)
|
|
309
336
|
non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
|
|
310
|
-
return 0 if non_pinned.size <= keep
|
|
311
337
|
|
|
312
|
-
|
|
338
|
+
cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
|
|
339
|
+
|
|
340
|
+
victims = []
|
|
341
|
+
victims += regular[keep..] if regular.size > keep
|
|
342
|
+
victims += cron[keep_cron..] if cron.size > keep_cron
|
|
343
|
+
|
|
313
344
|
victims.each { |session| soft_delete(session[:session_id]) }
|
|
314
345
|
victims.size
|
|
315
346
|
end
|
|
@@ -120,28 +120,23 @@ module Clacky
|
|
|
120
120
|
@render_mutex.synchronize do
|
|
121
121
|
entry = @buffer.entry_by_id(id)
|
|
122
122
|
if entry.nil?
|
|
123
|
-
Clacky::Logger.warn("[ph_debug] replace_entry_nil", id: id, content: content.to_s[0, 120])
|
|
124
123
|
return
|
|
125
124
|
end
|
|
126
125
|
if entry.committed
|
|
127
|
-
Clacky::Logger.warn("[ph_debug] replace_entry_committed", id: id, content: content.to_s[0, 120])
|
|
128
126
|
return
|
|
129
127
|
end
|
|
130
128
|
if (entry.committed_line_offset || 0) > 0
|
|
131
|
-
Clacky::Logger.warn("[ph_debug] replace_entry_partial", id: id, offset: entry.committed_line_offset, content: content.to_s[0, 120])
|
|
132
129
|
return
|
|
133
130
|
end
|
|
134
131
|
|
|
135
132
|
old_lines = entry.lines.dup
|
|
136
133
|
new_lines = wrap_content_to_lines(content)
|
|
137
134
|
if old_lines == new_lines
|
|
138
|
-
Clacky::Logger.warn("[ph_debug] replace_entry_same", id: id)
|
|
139
135
|
screen.flush
|
|
140
136
|
return
|
|
141
137
|
end
|
|
142
138
|
@buffer.replace(id, new_lines)
|
|
143
139
|
is_tail = @buffer.live_entries.last&.id == id
|
|
144
|
-
Clacky::Logger.warn("[ph_debug] replace_entry_paint", id: id, is_tail: is_tail, old_n: old_lines.length, new_n: new_lines.length, content: content.to_s[0, 120])
|
|
145
140
|
|
|
146
141
|
unless @fullscreen_mode
|
|
147
142
|
# repaint_entry_in_place relies on the entry being the tail of
|
|
@@ -182,7 +182,6 @@ module Clacky
|
|
|
182
182
|
# @param final_message [String, nil] Optional override for the last
|
|
183
183
|
# frame. If nil, the handle composes "<message>… (<elapsed>s)".
|
|
184
184
|
def finish(final_message: nil)
|
|
185
|
-
Clacky::Logger.warn("[ph_debug] finish_entry", oid: object_id, state: @state, unreg: @unregistered, msg: @message, eid: @entry_id)
|
|
186
185
|
snapshot = @monitor.synchronize do
|
|
187
186
|
return if @unregistered
|
|
188
187
|
first_close = @state == :running
|
|
@@ -201,10 +200,8 @@ module Clacky
|
|
|
201
200
|
else
|
|
202
201
|
compose_final_frame(snapshot[:message], snapshot[:elapsed])
|
|
203
202
|
end
|
|
204
|
-
Clacky::Logger.warn("[ph_debug] finish_unregister", oid: object_id, eid: @entry_id, first_close: snapshot[:first_close], final_frame: final_frame.to_s[0, 200])
|
|
205
203
|
@owner.unregister_progress(self, final_frame: final_frame)
|
|
206
204
|
@monitor.synchronize { @unregistered = true }
|
|
207
|
-
Clacky::Logger.warn("[ph_debug] finish_done", oid: object_id)
|
|
208
205
|
end
|
|
209
206
|
alias_method :cancel, :finish
|
|
210
207
|
|
|
@@ -664,22 +664,13 @@ module Clacky
|
|
|
664
664
|
|
|
665
665
|
# Called by ProgressHandle#finish.
|
|
666
666
|
def unregister_progress(handle, final_frame:)
|
|
667
|
-
Clacky::Logger.warn("[ph_debug] unreg_entry", oid: handle.object_id, eid: handle.entry_id, top: @progress_stack.last == handle, stack_size: @progress_stack.size, ff: final_frame.to_s[0, 200])
|
|
668
667
|
@progress_mutex.synchronize do
|
|
669
|
-
# If this handle still holds its entry (it's currently top), we
|
|
670
|
-
# render one last frame there and release the id. If it was
|
|
671
|
-
# previously detached (someone above is still active), its entry
|
|
672
|
-
# is already gone and the final_frame is simply dropped.
|
|
673
668
|
if handle.entry_id
|
|
674
669
|
if final_frame && !final_frame.to_s.strip.empty?
|
|
675
|
-
Clacky::Logger.warn("[ph_debug] unreg_update_entry", oid: handle.object_id, eid: handle.entry_id)
|
|
676
670
|
update_entry(handle.entry_id, @renderer.render_progress(final_frame))
|
|
677
671
|
else
|
|
678
|
-
Clacky::Logger.warn("[ph_debug] unreg_remove_entry", oid: handle.object_id, eid: handle.entry_id)
|
|
679
672
|
remove_entry(handle.entry_id)
|
|
680
673
|
end
|
|
681
|
-
else
|
|
682
|
-
Clacky::Logger.warn("[ph_debug] unreg_no_entry_id", oid: handle.object_id)
|
|
683
674
|
end
|
|
684
675
|
|
|
685
676
|
@progress_stack.delete(handle)
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -4486,6 +4486,7 @@ body {
|
|
|
4486
4486
|
border-radius: 10px;
|
|
4487
4487
|
padding: 0.625rem 0.875rem;
|
|
4488
4488
|
background: var(--color-bg-secondary);
|
|
4489
|
+
margin-bottom: 0.625rem;
|
|
4489
4490
|
}
|
|
4490
4491
|
.backup-auto-card .backup-auto-row {
|
|
4491
4492
|
margin: 0.25rem 0;
|
|
@@ -4517,6 +4518,43 @@ body {
|
|
|
4517
4518
|
font-size: 0.75rem;
|
|
4518
4519
|
color: var(--color-text-secondary);
|
|
4519
4520
|
}
|
|
4521
|
+
.backup-dest-row {
|
|
4522
|
+
display: flex;
|
|
4523
|
+
align-items: center;
|
|
4524
|
+
gap: 0.5rem;
|
|
4525
|
+
margin: 0.625rem 0 0.25rem;
|
|
4526
|
+
padding-top: 0.625rem;
|
|
4527
|
+
border-top: 1px solid var(--color-border-primary);
|
|
4528
|
+
}
|
|
4529
|
+
.backup-dest-label {
|
|
4530
|
+
font-size: 0.78125rem;
|
|
4531
|
+
color: var(--color-text-secondary);
|
|
4532
|
+
white-space: nowrap;
|
|
4533
|
+
flex-shrink: 0;
|
|
4534
|
+
}
|
|
4535
|
+
.backup-dest-path {
|
|
4536
|
+
font-size: 0.78125rem;
|
|
4537
|
+
color: var(--color-text-primary);
|
|
4538
|
+
font-family: var(--font-mono, monospace);
|
|
4539
|
+
overflow: hidden;
|
|
4540
|
+
text-overflow: ellipsis;
|
|
4541
|
+
white-space: nowrap;
|
|
4542
|
+
}
|
|
4543
|
+
.btn-settings-link {
|
|
4544
|
+
flex-shrink: 0;
|
|
4545
|
+
background: transparent;
|
|
4546
|
+
border: 1px solid var(--color-border-primary);
|
|
4547
|
+
border-radius: 6px;
|
|
4548
|
+
color: var(--color-accent-primary);
|
|
4549
|
+
font-size: 0.75rem;
|
|
4550
|
+
padding: 0.25rem 0.625rem;
|
|
4551
|
+
cursor: pointer;
|
|
4552
|
+
white-space: nowrap;
|
|
4553
|
+
}
|
|
4554
|
+
.btn-settings-link:hover {
|
|
4555
|
+
background: var(--color-bg-hover);
|
|
4556
|
+
border-color: var(--color-accent-primary);
|
|
4557
|
+
}
|
|
4520
4558
|
.backup-actions {
|
|
4521
4559
|
display: flex;
|
|
4522
4560
|
align-items: center;
|
|
@@ -4524,6 +4562,21 @@ body {
|
|
|
4524
4562
|
flex-wrap: wrap;
|
|
4525
4563
|
}
|
|
4526
4564
|
|
|
4565
|
+
.backup-config-row {
|
|
4566
|
+
margin-top: 0.5rem;
|
|
4567
|
+
padding-top: 0.75rem;
|
|
4568
|
+
border-top: 1px solid var(--color-border);
|
|
4569
|
+
}
|
|
4570
|
+
.btn-settings-action--file {
|
|
4571
|
+
cursor: pointer;
|
|
4572
|
+
}
|
|
4573
|
+
.backup-actions-sep {
|
|
4574
|
+
width: 1px;
|
|
4575
|
+
height: 1.25rem;
|
|
4576
|
+
background: var(--color-border);
|
|
4577
|
+
flex-shrink: 0;
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4527
4580
|
.settings-network-url {
|
|
4528
4581
|
display: flex;
|
|
4529
4582
|
flex-direction: column;
|
|
@@ -7548,7 +7601,7 @@ body.setup-mode[data-theme="dark"] {
|
|
|
7548
7601
|
cursor: pointer;
|
|
7549
7602
|
transition: background .15s, opacity .15s;
|
|
7550
7603
|
}
|
|
7551
|
-
.setup-submit-btn:hover { background:
|
|
7604
|
+
.setup-submit-btn:hover { background: var(--color-button-primary-hover); }
|
|
7552
7605
|
.setup-submit-btn:disabled { opacity: .5; cursor: default; }
|
|
7553
7606
|
|
|
7554
7607
|
/* ── Onboard panel ───────────────────────────────────────────────────────── */
|