openclacky 1.0.0 → 1.0.2
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 +39 -0
- data/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- data/lib/clacky/deploy_api_client.rb +0 -484
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "webrick"
|
|
4
4
|
require "websocket"
|
|
5
|
+
require "socket"
|
|
5
6
|
require "json"
|
|
6
7
|
require "thread"
|
|
7
8
|
require "fileutils"
|
|
@@ -9,6 +10,8 @@ require "tmpdir"
|
|
|
9
10
|
require "uri"
|
|
10
11
|
require "securerandom"
|
|
11
12
|
require "timeout"
|
|
13
|
+
require "yaml"
|
|
14
|
+
require "date"
|
|
12
15
|
require_relative "session_registry"
|
|
13
16
|
require_relative "web_ui_controller"
|
|
14
17
|
require_relative "scheduler"
|
|
@@ -392,6 +395,13 @@ module Clacky
|
|
|
392
395
|
when ["GET", "/api/brand/skills"] then api_brand_skills(res)
|
|
393
396
|
when ["GET", "/api/brand"] then api_brand_info(res)
|
|
394
397
|
when ["GET", "/api/creator/skills"] then api_creator_skills(res)
|
|
398
|
+
when ["GET", "/api/trash"] then api_trash(req, res)
|
|
399
|
+
when ["POST", "/api/trash/restore"] then api_trash_restore(req, res)
|
|
400
|
+
when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
|
|
401
|
+
when ["GET", "/api/profile"] then api_profile_get(res)
|
|
402
|
+
when ["PUT", "/api/profile"] then api_profile_put(req, res)
|
|
403
|
+
when ["GET", "/api/memories"] then api_memories_list(res)
|
|
404
|
+
when ["POST", "/api/memories"] then api_memories_create(req, res)
|
|
395
405
|
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
396
406
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
397
407
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
@@ -462,6 +472,15 @@ module Clacky
|
|
|
462
472
|
elsif method == "POST" && path.match?(%r{^/api/my-skills/[^/]+/publish$})
|
|
463
473
|
name = URI.decode_www_form_component(path.sub("/api/my-skills/", "").sub("/publish", ""))
|
|
464
474
|
api_publish_my_skill(name, req, res)
|
|
475
|
+
elsif method == "GET" && path.match?(%r{^/api/memories/[^/]+$})
|
|
476
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
477
|
+
api_memories_get(filename, res)
|
|
478
|
+
elsif method == "PUT" && path.match?(%r{^/api/memories/[^/]+$})
|
|
479
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
480
|
+
api_memories_update(filename, req, res)
|
|
481
|
+
elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
|
|
482
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
483
|
+
api_memories_delete(filename, res)
|
|
465
484
|
else
|
|
466
485
|
not_found(res)
|
|
467
486
|
end
|
|
@@ -1449,6 +1468,10 @@ module Clacky
|
|
|
1449
1468
|
path = parse_json_body(req)["path"]
|
|
1450
1469
|
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
1451
1470
|
|
|
1471
|
+
# Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
|
|
1472
|
+
# Ruby's File.exist? does NOT automatically expand ~ — that's a shell feature.
|
|
1473
|
+
path = File.expand_path(path)
|
|
1474
|
+
|
|
1452
1475
|
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1453
1476
|
# Convert it to the Linux-side path so File.exist? works.
|
|
1454
1477
|
linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
@@ -1887,6 +1910,469 @@ module Clacky
|
|
|
1887
1910
|
})
|
|
1888
1911
|
end
|
|
1889
1912
|
|
|
1913
|
+
# GET /api/trash[?project=<path>]
|
|
1914
|
+
# Lists recently deleted files in the AI trash.
|
|
1915
|
+
#
|
|
1916
|
+
# The trash is organized by project_root; each project gets its own
|
|
1917
|
+
# hashed subdirectory under ~/.clacky/trash/ (see TrashDirectory).
|
|
1918
|
+
# Returns ALL projects' deletions by default, with a per-file
|
|
1919
|
+
# project_root field so the UI can group or filter.
|
|
1920
|
+
#
|
|
1921
|
+
# Optional ?project=<absolute-path> restricts to a single project.
|
|
1922
|
+
# Response:
|
|
1923
|
+
# { ok: true,
|
|
1924
|
+
# files: [ { original_path, deleted_at, file_size, file_type,
|
|
1925
|
+
# project_root, project_name, trash_file } ],
|
|
1926
|
+
# projects: [ { project_root, project_name, file_count, total_size } ],
|
|
1927
|
+
# total_count, total_size }
|
|
1928
|
+
private def api_trash(req, res)
|
|
1929
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1930
|
+
filter_project = query["project"].to_s.strip
|
|
1931
|
+
filter_project = nil if filter_project.empty?
|
|
1932
|
+
|
|
1933
|
+
projects =
|
|
1934
|
+
if filter_project
|
|
1935
|
+
[{ project_root: File.expand_path(filter_project),
|
|
1936
|
+
project_name: File.basename(File.expand_path(filter_project)),
|
|
1937
|
+
trash_dir: Clacky::TrashDirectory.new(filter_project).trash_dir }]
|
|
1938
|
+
else
|
|
1939
|
+
Clacky::TrashDirectory.all_projects
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
all_files = []
|
|
1943
|
+
project_rows = []
|
|
1944
|
+
|
|
1945
|
+
projects.each do |p|
|
|
1946
|
+
files = _trash_files_in(p[:trash_dir], p[:project_root])
|
|
1947
|
+
next if files.empty? && filter_project.nil?
|
|
1948
|
+
|
|
1949
|
+
total_size = files.sum { |f| f[:file_size].to_i }
|
|
1950
|
+
project_rows << {
|
|
1951
|
+
project_root: p[:project_root],
|
|
1952
|
+
project_name: p[:project_name],
|
|
1953
|
+
file_count: files.size,
|
|
1954
|
+
total_size: total_size
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
files.each do |f|
|
|
1958
|
+
all_files << f.merge(
|
|
1959
|
+
project_root: p[:project_root],
|
|
1960
|
+
project_name: p[:project_name]
|
|
1961
|
+
)
|
|
1962
|
+
end
|
|
1963
|
+
end
|
|
1964
|
+
|
|
1965
|
+
all_files.sort_by! { |f| f[:deleted_at].to_s }.reverse!
|
|
1966
|
+
|
|
1967
|
+
json_response(res, 200, {
|
|
1968
|
+
ok: true,
|
|
1969
|
+
files: all_files,
|
|
1970
|
+
projects: project_rows,
|
|
1971
|
+
total_count: all_files.size,
|
|
1972
|
+
total_size: all_files.sum { |f| f[:file_size].to_i }
|
|
1973
|
+
})
|
|
1974
|
+
end
|
|
1975
|
+
|
|
1976
|
+
# POST /api/trash/restore
|
|
1977
|
+
# Body: { project_root: "...", original_path: "..." }
|
|
1978
|
+
# Restores a single file from trash back to its original location.
|
|
1979
|
+
# Refuses if the target already exists on disk.
|
|
1980
|
+
private def api_trash_restore(req, res)
|
|
1981
|
+
data = parse_json_body(req)
|
|
1982
|
+
project_root = data["project_root"].to_s.strip
|
|
1983
|
+
original_path = data["original_path"].to_s.strip
|
|
1984
|
+
|
|
1985
|
+
if project_root.empty? || original_path.empty?
|
|
1986
|
+
json_response(res, 400, { ok: false, error: "project_root and original_path are required" })
|
|
1987
|
+
return
|
|
1988
|
+
end
|
|
1989
|
+
|
|
1990
|
+
tool = Clacky::Tools::TrashManager.new
|
|
1991
|
+
result = tool.execute(action: "restore",
|
|
1992
|
+
file_path: original_path,
|
|
1993
|
+
working_dir: project_root)
|
|
1994
|
+
|
|
1995
|
+
if result[:success]
|
|
1996
|
+
json_response(res, 200, { ok: true, restored_file: result[:restored_file], message: result[:message] })
|
|
1997
|
+
else
|
|
1998
|
+
json_response(res, 422, { ok: false, error: result[:message] })
|
|
1999
|
+
end
|
|
2000
|
+
end
|
|
2001
|
+
|
|
2002
|
+
# DELETE /api/trash[?project=<path>][&days_old=<n>][&file=<original_path>]
|
|
2003
|
+
# Three modes:
|
|
2004
|
+
# ?file=<original_path>&project=<root> → permanently delete one file
|
|
2005
|
+
# ?project=<root>[&days_old=0] → empty that project's trash
|
|
2006
|
+
# (no project, days_old required) → empty ALL projects older than N days
|
|
2007
|
+
private def api_trash_delete(req, res)
|
|
2008
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
2009
|
+
project_root = query["project"].to_s.strip
|
|
2010
|
+
days_old = query["days_old"].to_s.strip
|
|
2011
|
+
file_path = query["file"].to_s.strip
|
|
2012
|
+
|
|
2013
|
+
project_root = nil if project_root.empty?
|
|
2014
|
+
file_path = nil if file_path.empty?
|
|
2015
|
+
|
|
2016
|
+
# Mode 1: single-file permanent delete
|
|
2017
|
+
if file_path
|
|
2018
|
+
unless project_root
|
|
2019
|
+
json_response(res, 400, { ok: false, error: "project is required when file is given" })
|
|
2020
|
+
return
|
|
2021
|
+
end
|
|
2022
|
+
deleted = _trash_delete_single(project_root, file_path)
|
|
2023
|
+
if deleted
|
|
2024
|
+
json_response(res, 200, { ok: true, deleted_count: 1, freed_size: deleted[:file_size].to_i })
|
|
2025
|
+
else
|
|
2026
|
+
json_response(res, 404, { ok: false, error: "File not found in trash: #{file_path}" })
|
|
2027
|
+
end
|
|
2028
|
+
return
|
|
2029
|
+
end
|
|
2030
|
+
|
|
2031
|
+
# Mode 2 & 3: bulk empty (optionally scoped to one project, optionally by age)
|
|
2032
|
+
days_i = days_old.empty? ? 0 : days_old.to_i
|
|
2033
|
+
tool = Clacky::Tools::TrashManager.new
|
|
2034
|
+
|
|
2035
|
+
targets =
|
|
2036
|
+
if project_root
|
|
2037
|
+
[project_root]
|
|
2038
|
+
else
|
|
2039
|
+
Clacky::TrashDirectory.all_projects.map { |p| p[:project_root] }
|
|
2040
|
+
end
|
|
2041
|
+
|
|
2042
|
+
total_deleted = 0
|
|
2043
|
+
total_freed = 0
|
|
2044
|
+
targets.each do |root|
|
|
2045
|
+
result = tool.execute(action: "empty", days_old: days_i, working_dir: root)
|
|
2046
|
+
next unless result[:success]
|
|
2047
|
+
total_deleted += result[:deleted_count].to_i
|
|
2048
|
+
total_freed += result[:freed_size].to_i
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
json_response(res, 200, {
|
|
2052
|
+
ok: true,
|
|
2053
|
+
deleted_count: total_deleted,
|
|
2054
|
+
freed_size: total_freed,
|
|
2055
|
+
days_old: days_i
|
|
2056
|
+
})
|
|
2057
|
+
end
|
|
2058
|
+
|
|
2059
|
+
# ── Trash helpers (private) ─────────────────────────────────────
|
|
2060
|
+
# Reads all metadata sidecars in `trash_dir` and returns enriched
|
|
2061
|
+
# file records. Silently skips sidecars whose payload file has
|
|
2062
|
+
# already been purged from disk.
|
|
2063
|
+
private def _trash_files_in(trash_dir, project_root)
|
|
2064
|
+
return [] unless trash_dir && Dir.exist?(trash_dir)
|
|
2065
|
+
|
|
2066
|
+
files = []
|
|
2067
|
+
Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |meta_path|
|
|
2068
|
+
begin
|
|
2069
|
+
meta = JSON.parse(File.read(meta_path))
|
|
2070
|
+
trash = meta_path.sub(/\.metadata\.json\z/, "")
|
|
2071
|
+
next unless File.exist?(trash)
|
|
2072
|
+
files << {
|
|
2073
|
+
original_path: meta["original_path"],
|
|
2074
|
+
deleted_at: meta["deleted_at"],
|
|
2075
|
+
deleted_by: meta["deleted_by"],
|
|
2076
|
+
file_size: meta["file_size"].to_i,
|
|
2077
|
+
file_type: meta["file_type"],
|
|
2078
|
+
file_mode: meta["file_mode"],
|
|
2079
|
+
trash_file: trash
|
|
2080
|
+
}
|
|
2081
|
+
rescue StandardError
|
|
2082
|
+
# Corrupt or partial metadata — skip.
|
|
2083
|
+
end
|
|
2084
|
+
end
|
|
2085
|
+
files
|
|
2086
|
+
end
|
|
2087
|
+
|
|
2088
|
+
# Permanently deletes the single trash entry whose original_path
|
|
2089
|
+
# matches inside `project_root`'s trash. Returns the removed
|
|
2090
|
+
# metadata hash, or nil if not found.
|
|
2091
|
+
private def _trash_delete_single(project_root, original_path)
|
|
2092
|
+
trash_dir = Clacky::TrashDirectory.new(project_root).trash_dir
|
|
2093
|
+
expanded = File.expand_path(original_path, project_root)
|
|
2094
|
+
entry = _trash_files_in(trash_dir, project_root).find do |f|
|
|
2095
|
+
f[:original_path] == expanded
|
|
2096
|
+
end
|
|
2097
|
+
return nil unless entry
|
|
2098
|
+
|
|
2099
|
+
File.delete(entry[:trash_file]) if File.exist?(entry[:trash_file])
|
|
2100
|
+
File.delete("#{entry[:trash_file]}.metadata.json") if File.exist?("#{entry[:trash_file]}.metadata.json")
|
|
2101
|
+
entry
|
|
2102
|
+
rescue StandardError
|
|
2103
|
+
nil
|
|
2104
|
+
end
|
|
2105
|
+
|
|
2106
|
+
# ── Profile API (USER.md / SOUL.md) ──────────────────────────────
|
|
2107
|
+
#
|
|
2108
|
+
# User can override the built-in defaults by writing their own
|
|
2109
|
+
# ~/.clacky/agents/USER.md and ~/.clacky/agents/SOUL.md. These
|
|
2110
|
+
# endpoints let the Web UI read and edit those files.
|
|
2111
|
+
|
|
2112
|
+
PROFILE_USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
|
|
2113
|
+
PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
|
|
2114
|
+
PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
|
|
2115
|
+
|
|
2116
|
+
# GET /api/profile
|
|
2117
|
+
# Returns { ok:, user: { path, content, is_default }, soul: { ... } }
|
|
2118
|
+
private def api_profile_get(res)
|
|
2119
|
+
json_response(res, 200, {
|
|
2120
|
+
ok: true,
|
|
2121
|
+
user: _profile_read_file("USER.md"),
|
|
2122
|
+
soul: _profile_read_file("SOUL.md")
|
|
2123
|
+
})
|
|
2124
|
+
end
|
|
2125
|
+
|
|
2126
|
+
# PUT /api/profile
|
|
2127
|
+
# Body: { kind: "user"|"soul", content: "..." }
|
|
2128
|
+
# Writes the file to ~/.clacky/agents/<KIND>.md. Empty content
|
|
2129
|
+
# deletes the override so the built-in default is used again.
|
|
2130
|
+
private def api_profile_put(req, res)
|
|
2131
|
+
data = parse_json_body(req)
|
|
2132
|
+
kind = data["kind"].to_s.downcase
|
|
2133
|
+
content = data["content"].to_s
|
|
2134
|
+
|
|
2135
|
+
filename = case kind
|
|
2136
|
+
when "user" then "USER.md"
|
|
2137
|
+
when "soul" then "SOUL.md"
|
|
2138
|
+
else
|
|
2139
|
+
json_response(res, 400, { ok: false, error: "kind must be 'user' or 'soul'" })
|
|
2140
|
+
return
|
|
2141
|
+
end
|
|
2142
|
+
|
|
2143
|
+
if content.bytesize > PROFILE_MAX_BYTES
|
|
2144
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{PROFILE_MAX_BYTES} bytes)" })
|
|
2145
|
+
return
|
|
2146
|
+
end
|
|
2147
|
+
|
|
2148
|
+
FileUtils.mkdir_p(PROFILE_USER_AGENTS_DIR)
|
|
2149
|
+
target = File.join(PROFILE_USER_AGENTS_DIR, filename)
|
|
2150
|
+
|
|
2151
|
+
# Treat whitespace-only payload as "reset to built-in default":
|
|
2152
|
+
# delete the override file so AgentProfile falls back to default.
|
|
2153
|
+
if content.strip.empty?
|
|
2154
|
+
File.delete(target) if File.exist?(target)
|
|
2155
|
+
json_response(res, 200, { ok: true, reset: true, file: _profile_read_file(filename) })
|
|
2156
|
+
return
|
|
2157
|
+
end
|
|
2158
|
+
|
|
2159
|
+
File.write(target, content)
|
|
2160
|
+
json_response(res, 200, { ok: true, file: _profile_read_file(filename) })
|
|
2161
|
+
rescue StandardError => e
|
|
2162
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2163
|
+
end
|
|
2164
|
+
|
|
2165
|
+
# Read a profile file — user override if present, else built-in default.
|
|
2166
|
+
# Returns { path:, content:, is_default:, writable: }.
|
|
2167
|
+
private def _profile_read_file(filename)
|
|
2168
|
+
user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
|
|
2169
|
+
default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
|
|
2170
|
+
|
|
2171
|
+
if File.exist?(user_path) && !File.zero?(user_path)
|
|
2172
|
+
{
|
|
2173
|
+
path: user_path,
|
|
2174
|
+
content: File.read(user_path),
|
|
2175
|
+
is_default: false
|
|
2176
|
+
}
|
|
2177
|
+
elsif File.exist?(default_path)
|
|
2178
|
+
{
|
|
2179
|
+
path: default_path,
|
|
2180
|
+
content: File.read(default_path),
|
|
2181
|
+
is_default: true
|
|
2182
|
+
}
|
|
2183
|
+
else
|
|
2184
|
+
{
|
|
2185
|
+
path: user_path, # Where it WILL be written
|
|
2186
|
+
content: "",
|
|
2187
|
+
is_default: true
|
|
2188
|
+
}
|
|
2189
|
+
end
|
|
2190
|
+
rescue StandardError => e
|
|
2191
|
+
{ path: "", content: "", is_default: true, error: e.message }
|
|
2192
|
+
end
|
|
2193
|
+
|
|
2194
|
+
# ── Memories API (~/.clacky/memories/*.md) ───────────────────────
|
|
2195
|
+
#
|
|
2196
|
+
# Long-term memories are plain Markdown files with YAML frontmatter
|
|
2197
|
+
# stored under ~/.clacky/memories/. These endpoints let the user
|
|
2198
|
+
# inspect, edit, create, and delete them from the Web UI.
|
|
2199
|
+
|
|
2200
|
+
MEMORIES_DIR = File.expand_path("~/.clacky/memories").freeze
|
|
2201
|
+
MEMORY_MAX_BYTES = 50_000
|
|
2202
|
+
|
|
2203
|
+
# GET /api/memories
|
|
2204
|
+
# Returns { ok:, dir:, memories: [ { filename, topic, description, updated_at, size, preview } ] }
|
|
2205
|
+
# Sorted by updated_at (YAML frontmatter) descending, falling back to file mtime.
|
|
2206
|
+
private def api_memories_list(res)
|
|
2207
|
+
FileUtils.mkdir_p(MEMORIES_DIR)
|
|
2208
|
+
memories = Dir.glob(File.join(MEMORIES_DIR, "*.md")).map do |path|
|
|
2209
|
+
_memory_summary(path)
|
|
2210
|
+
end.compact
|
|
2211
|
+
|
|
2212
|
+
# Sort key: prefer updated_at string (ISO-ish sorts correctly), fall back to mtime.
|
|
2213
|
+
# `mtime` is always present in the summary (ISO 8601), so we use it as the
|
|
2214
|
+
# ultimate tiebreaker. Negate by reversing after sort for descending order.
|
|
2215
|
+
memories.sort_by! do |m|
|
|
2216
|
+
key = m[:updated_at].to_s
|
|
2217
|
+
key = m[:mtime].to_s if key.empty?
|
|
2218
|
+
key
|
|
2219
|
+
end
|
|
2220
|
+
memories.reverse!
|
|
2221
|
+
|
|
2222
|
+
json_response(res, 200, { ok: true, dir: MEMORIES_DIR, memories: memories })
|
|
2223
|
+
end
|
|
2224
|
+
|
|
2225
|
+
# GET /api/memories/:filename
|
|
2226
|
+
# Returns { ok:, filename:, path:, content: }
|
|
2227
|
+
private def api_memories_get(filename, res)
|
|
2228
|
+
safe = _memory_safe_filename(filename)
|
|
2229
|
+
unless safe
|
|
2230
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
2231
|
+
return
|
|
2232
|
+
end
|
|
2233
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
2234
|
+
unless File.exist?(path)
|
|
2235
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
2236
|
+
return
|
|
2237
|
+
end
|
|
2238
|
+
json_response(res, 200, {
|
|
2239
|
+
ok: true,
|
|
2240
|
+
filename: safe,
|
|
2241
|
+
path: path,
|
|
2242
|
+
content: File.read(path)
|
|
2243
|
+
})
|
|
2244
|
+
end
|
|
2245
|
+
|
|
2246
|
+
# POST /api/memories
|
|
2247
|
+
# Body: { filename: "topic.md", content: "..." }
|
|
2248
|
+
# Create a new memory file. Refuses to overwrite existing.
|
|
2249
|
+
private def api_memories_create(req, res)
|
|
2250
|
+
data = parse_json_body(req)
|
|
2251
|
+
filename = _memory_safe_filename(data["filename"].to_s)
|
|
2252
|
+
content = data["content"].to_s
|
|
2253
|
+
|
|
2254
|
+
unless filename
|
|
2255
|
+
json_response(res, 400, { ok: false, error: "Invalid filename (must end in .md, no path separators)" })
|
|
2256
|
+
return
|
|
2257
|
+
end
|
|
2258
|
+
if content.bytesize > MEMORY_MAX_BYTES
|
|
2259
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
|
|
2260
|
+
return
|
|
2261
|
+
end
|
|
2262
|
+
|
|
2263
|
+
FileUtils.mkdir_p(MEMORIES_DIR)
|
|
2264
|
+
path = File.join(MEMORIES_DIR, filename)
|
|
2265
|
+
if File.exist?(path)
|
|
2266
|
+
json_response(res, 409, { ok: false, error: "Memory '#{filename}' already exists" })
|
|
2267
|
+
return
|
|
2268
|
+
end
|
|
2269
|
+
|
|
2270
|
+
File.write(path, content)
|
|
2271
|
+
json_response(res, 201, { ok: true, memory: _memory_summary(path) })
|
|
2272
|
+
rescue StandardError => e
|
|
2273
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2274
|
+
end
|
|
2275
|
+
|
|
2276
|
+
# PUT /api/memories/:filename
|
|
2277
|
+
# Body: { content: "..." }
|
|
2278
|
+
private def api_memories_update(filename, req, res)
|
|
2279
|
+
safe = _memory_safe_filename(filename)
|
|
2280
|
+
unless safe
|
|
2281
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
2282
|
+
return
|
|
2283
|
+
end
|
|
2284
|
+
data = parse_json_body(req)
|
|
2285
|
+
content = data["content"].to_s
|
|
2286
|
+
if content.bytesize > MEMORY_MAX_BYTES
|
|
2287
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
|
|
2288
|
+
return
|
|
2289
|
+
end
|
|
2290
|
+
|
|
2291
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
2292
|
+
unless File.exist?(path)
|
|
2293
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
2294
|
+
return
|
|
2295
|
+
end
|
|
2296
|
+
|
|
2297
|
+
File.write(path, content)
|
|
2298
|
+
json_response(res, 200, { ok: true, memory: _memory_summary(path) })
|
|
2299
|
+
rescue StandardError => e
|
|
2300
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2301
|
+
end
|
|
2302
|
+
|
|
2303
|
+
# DELETE /api/memories/:filename
|
|
2304
|
+
private def api_memories_delete(filename, res)
|
|
2305
|
+
safe = _memory_safe_filename(filename)
|
|
2306
|
+
unless safe
|
|
2307
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
2308
|
+
return
|
|
2309
|
+
end
|
|
2310
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
2311
|
+
unless File.exist?(path)
|
|
2312
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
2313
|
+
return
|
|
2314
|
+
end
|
|
2315
|
+
File.delete(path)
|
|
2316
|
+
json_response(res, 200, { ok: true, filename: safe })
|
|
2317
|
+
rescue StandardError => e
|
|
2318
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2319
|
+
end
|
|
2320
|
+
|
|
2321
|
+
# Returns nil if the filename is unsafe. Must end in .md, contain
|
|
2322
|
+
# no path separators or shell metacharacters, and be non-empty.
|
|
2323
|
+
private def _memory_safe_filename(name)
|
|
2324
|
+
s = name.to_s.strip
|
|
2325
|
+
return nil if s.empty?
|
|
2326
|
+
return nil if s.include?("/") || s.include?("\\")
|
|
2327
|
+
return nil if s.start_with?(".")
|
|
2328
|
+
return nil unless s.end_with?(".md")
|
|
2329
|
+
return nil unless s.match?(/\A[A-Za-z0-9._\-]+\z/)
|
|
2330
|
+
s
|
|
2331
|
+
end
|
|
2332
|
+
|
|
2333
|
+
# Build a summary record for a memory file. Parses YAML frontmatter
|
|
2334
|
+
# if present; otherwise falls back to filename-derived topic.
|
|
2335
|
+
# Returns nil if the file can't be read.
|
|
2336
|
+
private def _memory_summary(path)
|
|
2337
|
+
content = File.read(path)
|
|
2338
|
+
stat = File.stat(path)
|
|
2339
|
+
|
|
2340
|
+
topic = File.basename(path, ".md")
|
|
2341
|
+
description = ""
|
|
2342
|
+
updated_at = stat.mtime.strftime("%Y-%m-%d")
|
|
2343
|
+
|
|
2344
|
+
# Parse YAML frontmatter: --- ... --- at the top of the file.
|
|
2345
|
+
if content.start_with?("---")
|
|
2346
|
+
if (m = content.match(/\A---\s*\n(.*?)\n---\s*\n/m))
|
|
2347
|
+
begin
|
|
2348
|
+
# permitted_classes: Date so YAML `updated_at: 2026-05-01`
|
|
2349
|
+
# parses to a Date instance instead of raising DisallowedClass.
|
|
2350
|
+
fm = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
|
|
2351
|
+
topic = fm["topic"].to_s unless fm["topic"].to_s.strip.empty?
|
|
2352
|
+
description = fm["description"].to_s
|
|
2353
|
+
updated_at = fm["updated_at"].to_s unless fm["updated_at"].to_s.strip.empty?
|
|
2354
|
+
rescue StandardError
|
|
2355
|
+
# Bad frontmatter — fall back to defaults above.
|
|
2356
|
+
end
|
|
2357
|
+
end
|
|
2358
|
+
end
|
|
2359
|
+
|
|
2360
|
+
preview = content.sub(/\A---.*?---\s*\n/m, "").strip[0, 200]
|
|
2361
|
+
|
|
2362
|
+
{
|
|
2363
|
+
filename: File.basename(path),
|
|
2364
|
+
path: path,
|
|
2365
|
+
topic: topic,
|
|
2366
|
+
description: description,
|
|
2367
|
+
updated_at: updated_at,
|
|
2368
|
+
size: stat.size,
|
|
2369
|
+
mtime: stat.mtime.iso8601,
|
|
2370
|
+
preview: preview
|
|
2371
|
+
}
|
|
2372
|
+
rescue StandardError
|
|
2373
|
+
nil
|
|
2374
|
+
end
|
|
2375
|
+
|
|
1890
2376
|
# Auto-packages the named skill directory into a ZIP and uploads it to the
|
|
1891
2377
|
# OpenClacky cloud. No file picker is required — the server finds the skill
|
|
1892
2378
|
# directory, zips it, and streams the ZIP to the cloud API.
|
|
@@ -2220,12 +2706,16 @@ module Clacky
|
|
|
2220
2706
|
def api_list_providers(res)
|
|
2221
2707
|
providers = Clacky::Providers::PRESETS.map do |id, preset|
|
|
2222
2708
|
{
|
|
2223
|
-
id:
|
|
2224
|
-
name:
|
|
2225
|
-
base_url:
|
|
2226
|
-
default_model:
|
|
2227
|
-
models:
|
|
2228
|
-
|
|
2709
|
+
id: id,
|
|
2710
|
+
name: preset["name"],
|
|
2711
|
+
base_url: preset["base_url"],
|
|
2712
|
+
default_model: preset["default_model"],
|
|
2713
|
+
models: preset["models"] || [],
|
|
2714
|
+
# Frontend uses this to render a Base URL dropdown (regional /
|
|
2715
|
+
# billing-plan variants) when present. Absent for single-endpoint
|
|
2716
|
+
# providers — UI renders a plain text input in that case.
|
|
2717
|
+
endpoint_variants: preset["endpoint_variants"],
|
|
2718
|
+
website_url: preset["website_url"]
|
|
2229
2719
|
}
|
|
2230
2720
|
end
|
|
2231
2721
|
json_response(res, 200, { providers: providers })
|
|
@@ -2622,6 +3112,11 @@ module Clacky
|
|
|
2622
3112
|
conn.session_id = session_id
|
|
2623
3113
|
subscribe(session_id, conn)
|
|
2624
3114
|
conn.send_json(type: "subscribed", session_id: session_id)
|
|
3115
|
+
# Push a fresh snapshot so a reconnecting tab sees the true current
|
|
3116
|
+
# status (it may have missed session_update events while offline).
|
|
3117
|
+
if (snap = @registry.snapshot(session_id))
|
|
3118
|
+
conn.send_json(type: "session_update", session: snap)
|
|
3119
|
+
end
|
|
2625
3120
|
# If a shell command is still running, replay progress + buffered stdout
|
|
2626
3121
|
# to the newly subscribed tab so it sees the live state it may have missed.
|
|
2627
3122
|
@registry.with_session(session_id) { |s| s[:ui]&.replay_live_state }
|
|
@@ -2722,13 +3217,113 @@ module Clacky
|
|
|
2722
3217
|
ui&.deliver_confirmation(conf_id, result)
|
|
2723
3218
|
end
|
|
2724
3219
|
|
|
3220
|
+
# Interrupt a running agent session.
|
|
3221
|
+
#
|
|
3222
|
+
# Thread#raise alone is not reliable enough in practice — it's
|
|
3223
|
+
# best-effort against blocked syscalls (socket writes, OpenSSL read,
|
|
3224
|
+
# ConditionVariable#wait with a held mutex) and we've seen sessions
|
|
3225
|
+
# that stay "running" forever even after multiple interrupt attempts.
|
|
3226
|
+
#
|
|
3227
|
+
# Strategy: three-tier escalation in a background watchdog Thread so
|
|
3228
|
+
# the HTTP handler returns immediately.
|
|
3229
|
+
#
|
|
3230
|
+
# Tier 1 (t=0): Thread#raise(AgentInterrupted).
|
|
3231
|
+
# Unblocks most pure-Ruby waits and Faraday reads.
|
|
3232
|
+
# Handles the common case.
|
|
3233
|
+
# Tier 2 (t=3): force-close this session's WebSocket connections
|
|
3234
|
+
# so any send_raw stuck on socket write wakes up.
|
|
3235
|
+
# Try Thread#raise again (idempotent).
|
|
3236
|
+
# Tier 3 (t=8): Thread#kill — last resort. Leaks any held
|
|
3237
|
+
# resources but frees the session so the user can
|
|
3238
|
+
# move on.
|
|
3239
|
+
#
|
|
3240
|
+
# Each transition is logged so that when users report "stuck
|
|
3241
|
+
# sessions" we can see in the log whether tier 2/3 ever had to
|
|
3242
|
+
# fire — that's our signal to dig deeper on the underlying block.
|
|
2725
3243
|
def interrupt_session(session_id)
|
|
3244
|
+
thread = nil
|
|
2726
3245
|
@registry.with_session(session_id) do |s|
|
|
2727
3246
|
s[:idle_timer]&.cancel
|
|
2728
|
-
s[:thread]
|
|
3247
|
+
thread = s[:thread]
|
|
3248
|
+
|
|
3249
|
+
next unless thread&.alive?
|
|
3250
|
+
|
|
3251
|
+
Clacky::Logger.info("[interrupt] session=#{session_id} tier=1 raise")
|
|
3252
|
+
begin
|
|
3253
|
+
thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
|
|
3254
|
+
rescue ThreadError => e
|
|
3255
|
+
Clacky::Logger.warn("[interrupt] tier=1 raise failed: #{e.message}")
|
|
3256
|
+
end
|
|
3257
|
+
end
|
|
3258
|
+
|
|
3259
|
+
return unless thread&.alive?
|
|
3260
|
+
|
|
3261
|
+
start_interrupt_watchdog(session_id, thread)
|
|
3262
|
+
end
|
|
3263
|
+
|
|
3264
|
+
# Background watchdog: escalates from WebSocket force-close (tier 2)
|
|
3265
|
+
# to Thread#kill (tier 3) if the agent thread refuses to die.
|
|
3266
|
+
private def start_interrupt_watchdog(session_id, thread)
|
|
3267
|
+
Thread.new do
|
|
3268
|
+
Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
|
|
3269
|
+
|
|
3270
|
+
# Give the first Thread#raise a few seconds to unwind.
|
|
3271
|
+
sleep 3
|
|
3272
|
+
next unless thread.alive?
|
|
3273
|
+
|
|
3274
|
+
Clacky::Logger.warn(
|
|
3275
|
+
"[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
|
|
3276
|
+
"force-closing session resources"
|
|
3277
|
+
)
|
|
3278
|
+
force_close_session_sockets(session_id)
|
|
3279
|
+
# Re-raise — sometimes the first raise was swallowed deep in a
|
|
3280
|
+
# C-extension syscall; after we force-close the socket the
|
|
3281
|
+
# syscall returns and the next raise sticks.
|
|
3282
|
+
begin
|
|
3283
|
+
thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
|
|
3284
|
+
rescue ThreadError
|
|
3285
|
+
# already dead between checks — fine
|
|
3286
|
+
end
|
|
3287
|
+
|
|
3288
|
+
sleep 5
|
|
3289
|
+
next unless thread.alive?
|
|
3290
|
+
|
|
3291
|
+
Clacky::Logger.error(
|
|
3292
|
+
"[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
|
|
3293
|
+
)
|
|
3294
|
+
begin
|
|
3295
|
+
thread.kill
|
|
3296
|
+
rescue StandardError => e
|
|
3297
|
+
Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
|
|
3298
|
+
end
|
|
3299
|
+
|
|
3300
|
+
# Record the forced-kill so the UI can show a warning and operators
|
|
3301
|
+
# can correlate with any backtrace dumps. The session is left in
|
|
3302
|
+
# :idle state by run_agent_task's rescue clause; if the kill
|
|
3303
|
+
# happened before the rescue could run, patch the state directly.
|
|
3304
|
+
begin
|
|
3305
|
+
@registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
|
|
3306
|
+
broadcast_session_update(session_id)
|
|
3307
|
+
rescue StandardError
|
|
3308
|
+
# best effort
|
|
3309
|
+
end
|
|
2729
3310
|
end
|
|
2730
3311
|
end
|
|
2731
3312
|
|
|
3313
|
+
# Close every WebSocket connection bound to the given session. Used by
|
|
3314
|
+
# the interrupt watchdog to unblock agent threads stuck in a WS write.
|
|
3315
|
+
private def force_close_session_sockets(session_id)
|
|
3316
|
+
conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
3317
|
+
conns.each do |conn|
|
|
3318
|
+
Clacky::Logger.warn(
|
|
3319
|
+
"[interrupt] session=#{session_id} force-closing WS conn"
|
|
3320
|
+
)
|
|
3321
|
+
conn.force_close!
|
|
3322
|
+
end
|
|
3323
|
+
rescue StandardError => e
|
|
3324
|
+
Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
3325
|
+
end
|
|
3326
|
+
|
|
2732
3327
|
# Start the pending task for a session.
|
|
2733
3328
|
# Called when the client sends "run_task" over WS — by that point the
|
|
2734
3329
|
# client has already subscribed, so every broadcast will be delivered.
|
|
@@ -2810,14 +3405,24 @@ module Clacky
|
|
|
2810
3405
|
end
|
|
2811
3406
|
|
|
2812
3407
|
# Broadcast an event to all clients subscribed to a session.
|
|
2813
|
-
# Dead connections (broken pipe / closed socket) are
|
|
3408
|
+
# Dead connections (broken pipe / closed socket / deadline exceeded) are
|
|
3409
|
+
# removed automatically. Connections already marked closed are skipped
|
|
3410
|
+
# upfront so one sluggish client can't delay delivery to healthy ones.
|
|
2814
3411
|
def broadcast(session_id, event)
|
|
2815
3412
|
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
2816
|
-
dead =
|
|
3413
|
+
dead = []
|
|
3414
|
+
clients.each do |conn|
|
|
3415
|
+
if conn.closed?
|
|
3416
|
+
dead << conn
|
|
3417
|
+
next
|
|
3418
|
+
end
|
|
3419
|
+
dead << conn unless conn.send_json(event)
|
|
3420
|
+
end
|
|
2817
3421
|
return if dead.empty?
|
|
2818
3422
|
|
|
2819
3423
|
@ws_mutex.synchronize do
|
|
2820
3424
|
(@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
|
|
3425
|
+
@all_ws_conns.reject! { |conn| dead.include?(conn) }
|
|
2821
3426
|
end
|
|
2822
3427
|
end
|
|
2823
3428
|
|
|
@@ -2825,7 +3430,14 @@ module Clacky
|
|
|
2825
3430
|
# Dead connections are removed automatically.
|
|
2826
3431
|
def broadcast_all(event)
|
|
2827
3432
|
clients = @ws_mutex.synchronize { @all_ws_conns.dup }
|
|
2828
|
-
dead =
|
|
3433
|
+
dead = []
|
|
3434
|
+
clients.each do |conn|
|
|
3435
|
+
if conn.closed?
|
|
3436
|
+
dead << conn
|
|
3437
|
+
next
|
|
3438
|
+
end
|
|
3439
|
+
dead << conn unless conn.send_json(event)
|
|
3440
|
+
end
|
|
2829
3441
|
return if dead.empty?
|
|
2830
3442
|
|
|
2831
3443
|
@ws_mutex.synchronize do
|
|
@@ -2837,7 +3449,7 @@ module Clacky
|
|
|
2837
3449
|
# Broadcast a session_update event to all clients so they can patch their
|
|
2838
3450
|
# local session list without needing a full session_list refresh.
|
|
2839
3451
|
def broadcast_session_update(session_id)
|
|
2840
|
-
session = @registry.
|
|
3452
|
+
session = @registry.snapshot(session_id)
|
|
2841
3453
|
return unless session
|
|
2842
3454
|
|
|
2843
3455
|
broadcast_all(type: "session_update", session: session)
|
|
@@ -3069,14 +3681,33 @@ module Clacky
|
|
|
3069
3681
|
# ── Inner classes ─────────────────────────────────────────────────────────
|
|
3070
3682
|
|
|
3071
3683
|
# Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
|
|
3684
|
+
#
|
|
3685
|
+
# IMPORTANT: send_raw is called from the Agent thread via broadcast() →
|
|
3686
|
+
# send_json(). A blocking socket write with no deadline can pin the Agent
|
|
3687
|
+
# thread indefinitely when the client's receive buffer fills up (silent
|
|
3688
|
+
# disconnects such as Wi-Fi handoff or NAT timeout, where TCP keepalive
|
|
3689
|
+
# defaults are measured in hours). Thread#raise on blocking native socket
|
|
3690
|
+
# writes is best-effort and unreliable, so instead we bound every write
|
|
3691
|
+
# with an explicit deadline using IO.select + write_nonblock and declare
|
|
3692
|
+
# the connection dead on timeout.
|
|
3072
3693
|
class WebSocketConnection
|
|
3073
3694
|
attr_accessor :session_id
|
|
3074
3695
|
|
|
3696
|
+
# Maximum time a single send_raw call is allowed to spend writing.
|
|
3697
|
+
# 5 seconds is generous for healthy LAN/Internet clients and short
|
|
3698
|
+
# enough that a stuck Agent becomes responsive again quickly.
|
|
3699
|
+
SEND_DEADLINE = 5.0
|
|
3700
|
+
|
|
3701
|
+
# Warn threshold — any individual send_raw that exceeds this is logged
|
|
3702
|
+
# so we can spot sluggish clients before they fully hang.
|
|
3703
|
+
SEND_SLOW_WARN = 1.0
|
|
3704
|
+
|
|
3075
3705
|
def initialize(socket, version)
|
|
3076
3706
|
@socket = socket
|
|
3077
3707
|
@version = version
|
|
3078
3708
|
@send_mutex = Mutex.new
|
|
3079
3709
|
@closed = false
|
|
3710
|
+
WebSocketConnection.apply_keepalive(socket)
|
|
3080
3711
|
end
|
|
3081
3712
|
|
|
3082
3713
|
# Returns true if the underlying socket has been detected as dead.
|
|
@@ -3084,6 +3715,15 @@ module Clacky
|
|
|
3084
3715
|
@closed
|
|
3085
3716
|
end
|
|
3086
3717
|
|
|
3718
|
+
# Force-close the connection (used by the interrupt watchdog when an
|
|
3719
|
+
# Agent thread is stuck on an unresponsive socket write).
|
|
3720
|
+
def force_close!
|
|
3721
|
+
@closed = true
|
|
3722
|
+
@socket.close
|
|
3723
|
+
rescue StandardError
|
|
3724
|
+
# best effort
|
|
3725
|
+
end
|
|
3726
|
+
|
|
3087
3727
|
# Send a JSON-serializable object over the WebSocket.
|
|
3088
3728
|
# Returns true on success, false if the connection is dead.
|
|
3089
3729
|
def send_json(data)
|
|
@@ -3094,8 +3734,14 @@ module Clacky
|
|
|
3094
3734
|
end
|
|
3095
3735
|
|
|
3096
3736
|
# Send a raw WebSocket frame.
|
|
3097
|
-
# Returns true on success, false on broken/closed socket.
|
|
3737
|
+
# Returns true on success, false on broken/closed/sluggish socket.
|
|
3738
|
+
#
|
|
3739
|
+
# Uses write_nonblock with an overall deadline so the caller (typically
|
|
3740
|
+
# the Agent thread) never blocks longer than SEND_DEADLINE, even if the
|
|
3741
|
+
# client silently stopped reading.
|
|
3098
3742
|
def send_raw(type, data)
|
|
3743
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3744
|
+
|
|
3099
3745
|
@send_mutex.synchronize do
|
|
3100
3746
|
return false if @closed
|
|
3101
3747
|
|
|
@@ -3104,7 +3750,30 @@ module Clacky
|
|
|
3104
3750
|
data: data,
|
|
3105
3751
|
type: type
|
|
3106
3752
|
)
|
|
3107
|
-
|
|
3753
|
+
bytes = outgoing.to_s
|
|
3754
|
+
|
|
3755
|
+
unless write_with_deadline(bytes, SEND_DEADLINE)
|
|
3756
|
+
# Deadline exceeded — treat as a dead connection so broadcast
|
|
3757
|
+
# purges it and the Agent thread is freed immediately.
|
|
3758
|
+
@closed = true
|
|
3759
|
+
begin
|
|
3760
|
+
@socket.close
|
|
3761
|
+
rescue StandardError
|
|
3762
|
+
# ignore
|
|
3763
|
+
end
|
|
3764
|
+
Clacky::Logger.warn(
|
|
3765
|
+
"[WS] send_raw deadline exceeded — closing sluggish connection " \
|
|
3766
|
+
"(bytes=#{bytes.bytesize}, deadline=#{SEND_DEADLINE}s)"
|
|
3767
|
+
)
|
|
3768
|
+
return false
|
|
3769
|
+
end
|
|
3770
|
+
end
|
|
3771
|
+
|
|
3772
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
3773
|
+
if elapsed > SEND_SLOW_WARN
|
|
3774
|
+
Clacky::Logger.warn(
|
|
3775
|
+
"[WS] send_raw slow: #{elapsed.round(2)}s (type=#{type})"
|
|
3776
|
+
)
|
|
3108
3777
|
end
|
|
3109
3778
|
true
|
|
3110
3779
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
|
|
@@ -3116,6 +3785,70 @@ module Clacky
|
|
|
3116
3785
|
Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
|
|
3117
3786
|
false
|
|
3118
3787
|
end
|
|
3788
|
+
|
|
3789
|
+
# Write `data` to the underlying socket, bounded by `deadline` seconds
|
|
3790
|
+
# of *total* wall time across partial writes. Returns true on full
|
|
3791
|
+
# success, false on timeout.
|
|
3792
|
+
private def write_with_deadline(data, deadline)
|
|
3793
|
+
remaining = data
|
|
3794
|
+
deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline
|
|
3795
|
+
|
|
3796
|
+
until remaining.empty?
|
|
3797
|
+
time_left = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3798
|
+
return false if time_left <= 0
|
|
3799
|
+
|
|
3800
|
+
begin
|
|
3801
|
+
written = @socket.write_nonblock(remaining, exception: false)
|
|
3802
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF
|
|
3803
|
+
raise
|
|
3804
|
+
end
|
|
3805
|
+
|
|
3806
|
+
case written
|
|
3807
|
+
when :wait_writable
|
|
3808
|
+
ready = IO.select(nil, [@socket], nil, [time_left, 0.25].min)
|
|
3809
|
+
# Not ready → loop and re-check the overall deadline.
|
|
3810
|
+
next unless ready
|
|
3811
|
+
when Integer
|
|
3812
|
+
remaining = remaining.byteslice(written, remaining.bytesize - written)
|
|
3813
|
+
else
|
|
3814
|
+
# Nil or unexpected — treat as dead.
|
|
3815
|
+
return false
|
|
3816
|
+
end
|
|
3817
|
+
end
|
|
3818
|
+
|
|
3819
|
+
true
|
|
3820
|
+
end
|
|
3821
|
+
|
|
3822
|
+
# Enable TCP keepalive on the underlying socket so silently dead
|
|
3823
|
+
# peers are detected in minutes instead of the OS default of hours.
|
|
3824
|
+
# Best-effort: any failure is logged at debug level and ignored.
|
|
3825
|
+
def self.apply_keepalive(socket)
|
|
3826
|
+
return unless socket.respond_to?(:setsockopt)
|
|
3827
|
+
|
|
3828
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
|
3829
|
+
|
|
3830
|
+
# TCP-level keepalive tuning — constants vary by platform and are
|
|
3831
|
+
# only set when available. Values chosen to detect dead peers in
|
|
3832
|
+
# roughly 60-90 seconds total.
|
|
3833
|
+
if defined?(Socket::IPPROTO_TCP)
|
|
3834
|
+
# Idle time before first probe (Linux: TCP_KEEPIDLE, macOS: TCP_KEEPALIVE)
|
|
3835
|
+
idle_const = if Socket.const_defined?(:TCP_KEEPIDLE)
|
|
3836
|
+
Socket::TCP_KEEPIDLE
|
|
3837
|
+
elsif Socket.const_defined?(:TCP_KEEPALIVE)
|
|
3838
|
+
Socket::TCP_KEEPALIVE
|
|
3839
|
+
end
|
|
3840
|
+
socket.setsockopt(Socket::IPPROTO_TCP, idle_const, 60) if idle_const
|
|
3841
|
+
|
|
3842
|
+
if Socket.const_defined?(:TCP_KEEPINTVL)
|
|
3843
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, 10)
|
|
3844
|
+
end
|
|
3845
|
+
if Socket.const_defined?(:TCP_KEEPCNT)
|
|
3846
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, 3)
|
|
3847
|
+
end
|
|
3848
|
+
end
|
|
3849
|
+
rescue StandardError => e
|
|
3850
|
+
Clacky::Logger.debug("[WS] failed to set keepalive: #{e.class}: #{e.message}")
|
|
3851
|
+
end
|
|
3119
3852
|
end
|
|
3120
3853
|
end
|
|
3121
3854
|
end
|