octo-agent 0.11.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 +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,3554 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "websocket"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "json"
|
|
7
|
+
require "thread"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
require "tmpdir"
|
|
10
|
+
require "uri"
|
|
11
|
+
require "securerandom"
|
|
12
|
+
require "timeout"
|
|
13
|
+
require "yaml"
|
|
14
|
+
require "date"
|
|
15
|
+
require_relative "session_registry"
|
|
16
|
+
require_relative "web_ui_controller"
|
|
17
|
+
require_relative "scheduler"
|
|
18
|
+
|
|
19
|
+
require_relative "channel"
|
|
20
|
+
require_relative "../banner"
|
|
21
|
+
require_relative "../utils/file_processor"
|
|
22
|
+
require_relative "../background_task_registry"
|
|
23
|
+
|
|
24
|
+
module Octo
|
|
25
|
+
module Server
|
|
26
|
+
# Lightweight UI collector used by api_session_messages to capture events
|
|
27
|
+
# emitted by Agent#replay_history without broadcasting over WebSocket.
|
|
28
|
+
# Implements the same show_* interface as WebUIController.
|
|
29
|
+
class HistoryCollector
|
|
30
|
+
def initialize(session_id, events)
|
|
31
|
+
@session_id = session_id
|
|
32
|
+
@events = events
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def show_user_message(content, created_at: nil, files: [])
|
|
36
|
+
images = []
|
|
37
|
+
if content.is_a?(Array)
|
|
38
|
+
text_parts = []
|
|
39
|
+
content.each do |block|
|
|
40
|
+
next unless block.is_a?(Hash)
|
|
41
|
+
case block[:type]
|
|
42
|
+
when "text"
|
|
43
|
+
text_parts << block[:text]
|
|
44
|
+
when "image_url"
|
|
45
|
+
url = block.dig(:image_url, :url)
|
|
46
|
+
images << url if url
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
content = text_parts.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ev = { type: "history_user_message", session_id: @session_id, content: content }
|
|
53
|
+
ev[:created_at] = created_at if created_at
|
|
54
|
+
rendered = Array(files).filter_map do |f|
|
|
55
|
+
url = f[:data_url] || f["data_url"]
|
|
56
|
+
name = f[:name] || f["name"]
|
|
57
|
+
path = f[:path] || f["path"]
|
|
58
|
+
|
|
59
|
+
if url
|
|
60
|
+
url
|
|
61
|
+
elsif path && File.exist?(path.to_s)
|
|
62
|
+
# Reconstruct data_url from the tmp file (still present on disk)
|
|
63
|
+
Utils::FileProcessor.image_path_to_data_url(path) rescue "expired:#{name}"
|
|
64
|
+
elsif name
|
|
65
|
+
# File badge for non-image disk files, or image whose tmp file is gone
|
|
66
|
+
type = f[:type] || f["type"] || ""
|
|
67
|
+
type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
images.concat(rendered)
|
|
71
|
+
ev[:images] = images unless images.empty?
|
|
72
|
+
@events << ev
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def show_assistant_message(content, files:)
|
|
76
|
+
return if content.nil? || content.to_s.strip.empty?
|
|
77
|
+
|
|
78
|
+
# Rewrite local image paths to /api/local-image proxy URLs for browser rendering
|
|
79
|
+
rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
80
|
+
@events << { type: "assistant_message", session_id: @session_id, content: rewritten }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def show_tool_call(name, args)
|
|
84
|
+
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
85
|
+
summary = tool_call_summary(name, args_data)
|
|
86
|
+
@events << { type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def tool_call_summary(name, args)
|
|
90
|
+
class_name = name.to_s.split("_").map(&:capitalize).join
|
|
91
|
+
return nil unless Octo::Tools.const_defined?(class_name)
|
|
92
|
+
|
|
93
|
+
tool = Octo::Tools.const_get(class_name).new
|
|
94
|
+
args_sym = args.is_a?(Hash) ? args.transform_keys(&:to_sym) : {}
|
|
95
|
+
tool.format_call(args_sym)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show_tool_result(result, ui_payload: nil)
|
|
101
|
+
ev = { type: "tool_result", session_id: @session_id, result: result }
|
|
102
|
+
ev[:ui_payload] = ui_payload if ui_payload
|
|
103
|
+
@events << ev
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def show_token_usage(token_data)
|
|
107
|
+
return unless token_data.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
@events << { type: "token_usage", session_id: @session_id }.merge(token_data)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Ignore all other UI methods (progress, errors, etc.) during history replay
|
|
113
|
+
def method_missing(name, *args, **kwargs); end
|
|
114
|
+
def respond_to_missing?(name, include_private = false); true; end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# HttpServer runs an embedded WEBrick HTTP server with WebSocket support.
|
|
118
|
+
#
|
|
119
|
+
# Routes:
|
|
120
|
+
# GET /ws → WebSocket upgrade (all real-time communication)
|
|
121
|
+
# * /api/* → JSON REST API (sessions, tasks, schedules)
|
|
122
|
+
# GET /** → static files served from lib/octo/web/ directory
|
|
123
|
+
class HttpServer
|
|
124
|
+
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
125
|
+
|
|
126
|
+
# Default SOUL.md written when the user skips the onboard conversation.
|
|
127
|
+
# A richer version is created by the Agent during the soul_setup phase.
|
|
128
|
+
DEFAULT_SOUL_MD = <<~MD.freeze
|
|
129
|
+
# Octo — Agent Soul
|
|
130
|
+
|
|
131
|
+
You are Octo, a friendly and capable AI coding assistant and technical
|
|
132
|
+
co-founder. You are sharp, concise, and proactive. You speak plainly and
|
|
133
|
+
avoid unnecessary formality. You love helping people ship great software.
|
|
134
|
+
|
|
135
|
+
## Personality
|
|
136
|
+
- Warm and encouraging, but direct and honest
|
|
137
|
+
- Think step-by-step before acting; explain your reasoning briefly
|
|
138
|
+
- Prefer doing over talking — use tools, write code, ship results
|
|
139
|
+
- Adapt your language and tone to match the user's style
|
|
140
|
+
|
|
141
|
+
## Strengths
|
|
142
|
+
- Full-stack software development (Ruby, Python, JS, and more)
|
|
143
|
+
- Architectural thinking and code review
|
|
144
|
+
- Debugging tricky problems with patience and creativity
|
|
145
|
+
- Breaking big goals into small, executable steps
|
|
146
|
+
MD
|
|
147
|
+
|
|
148
|
+
# Default SOUL.md for Chinese-language users.
|
|
149
|
+
DEFAULT_SOUL_MD_ZH = <<~MD.freeze
|
|
150
|
+
# Octo — 助手灵魂
|
|
151
|
+
|
|
152
|
+
你是 Octo,一位友好、能干的 AI 编程助手和技术联合创始人。
|
|
153
|
+
你思维敏锐、言简意赅、主动积极。你说话直接,不喜欢过度客套。
|
|
154
|
+
你热爱帮助用户打造优秀的软件产品。
|
|
155
|
+
|
|
156
|
+
**重要:始终用中文回复用户。**
|
|
157
|
+
|
|
158
|
+
## 性格特点
|
|
159
|
+
- 热情鼓励,但直接诚实
|
|
160
|
+
- 行动前先思考;简要说明你的推理过程
|
|
161
|
+
- 重行动而非空谈 —— 善用工具,写代码,交付结果
|
|
162
|
+
- 根据用户的风格调整语气和表达方式
|
|
163
|
+
|
|
164
|
+
## 核心能力
|
|
165
|
+
- 全栈软件开发(Ruby、Python、JS 等)
|
|
166
|
+
- 架构设计与代码审查
|
|
167
|
+
- 耐心细致地调试复杂问题
|
|
168
|
+
- 将大目标拆解为可执行的小步骤
|
|
169
|
+
MD
|
|
170
|
+
|
|
171
|
+
def initialize(host: "127.0.0.1", port: 8888, agent_config:, client_factory:, sessions_dir: nil, socket: nil, master_pid: nil)
|
|
172
|
+
@host = host
|
|
173
|
+
@port = port
|
|
174
|
+
@agent_config = agent_config
|
|
175
|
+
@client_factory = client_factory # callable: -> { Octo::Client.new(...) }
|
|
176
|
+
@inherited_socket = socket # TCPServer socket passed from Master (nil = standalone mode)
|
|
177
|
+
@master_pid = master_pid # Master PID so we can send USR1 on upgrade/restart
|
|
178
|
+
# Capture the absolute path of the entry script and original ARGV at startup,
|
|
179
|
+
# so api_restart can re-exec the correct binary even if cwd changes later.
|
|
180
|
+
@restart_script = File.expand_path($0)
|
|
181
|
+
@restart_argv = ARGV.dup
|
|
182
|
+
@session_manager = Octo::SessionManager.new(sessions_dir: sessions_dir)
|
|
183
|
+
@registry = SessionRegistry.new(
|
|
184
|
+
session_manager: @session_manager,
|
|
185
|
+
session_restorer: method(:build_session_from_data),
|
|
186
|
+
agent_config: @agent_config
|
|
187
|
+
)
|
|
188
|
+
@ws_clients = {} # session_id => [WebSocketConnection, ...]
|
|
189
|
+
@all_ws_conns = [] # every connected WS client, regardless of session subscription
|
|
190
|
+
@ws_mutex = Mutex.new
|
|
191
|
+
# Version cache: { latest: "x.y.z", checked_at: Time }
|
|
192
|
+
@version_cache = nil
|
|
193
|
+
@version_mutex = Mutex.new
|
|
194
|
+
@scheduler = Scheduler.new(
|
|
195
|
+
session_registry: @registry,
|
|
196
|
+
session_builder: method(:build_session),
|
|
197
|
+
task_runner: method(:run_agent_task)
|
|
198
|
+
)
|
|
199
|
+
@channel_manager = Octo::Channel::ChannelManager.new(
|
|
200
|
+
session_registry: @registry,
|
|
201
|
+
session_builder: method(:build_session),
|
|
202
|
+
run_agent_task: method(:run_agent_task),
|
|
203
|
+
interrupt_session: method(:interrupt_session),
|
|
204
|
+
channel_config: Octo::ChannelConfig.load
|
|
205
|
+
)
|
|
206
|
+
@browser_manager = Octo::BrowserManager.instance
|
|
207
|
+
@skill_loader = Octo::SkillLoader.new(working_dir: nil)
|
|
208
|
+
# Access key authentication:
|
|
209
|
+
# - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
|
|
210
|
+
# - Any other bind address requires OCTO_ACCESS_KEY env var.
|
|
211
|
+
@localhost_only = local_host?(@host)
|
|
212
|
+
@access_key = @localhost_only ? nil : resolve_access_key
|
|
213
|
+
@auth_failures = {}
|
|
214
|
+
@auth_failures_mutex = Mutex.new
|
|
215
|
+
if @localhost_only
|
|
216
|
+
Octo::Logger.info("[HttpServer] Localhost mode — authentication disabled")
|
|
217
|
+
else
|
|
218
|
+
Octo::Logger.info("[HttpServer] Public mode — access key authentication ENABLED")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def start
|
|
223
|
+
# Enable console logging for the server process so log lines are visible in the terminal.
|
|
224
|
+
Octo::Logger.console = true
|
|
225
|
+
|
|
226
|
+
Octo::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
|
|
227
|
+
|
|
228
|
+
# Expose server address and product name to all child processes (skill scripts, shell commands, etc.)
|
|
229
|
+
# so they can call back into the server without hardcoding the port.
|
|
230
|
+
ENV["OCTO_SERVER_PORT"] = @port.to_s
|
|
231
|
+
ENV["OCTO_SERVER_HOST"] = (@host == "0.0.0.0" ? "127.0.0.1" : @host)
|
|
232
|
+
ENV["OCTO_PRODUCT_NAME"] = "Octo"
|
|
233
|
+
|
|
234
|
+
# Override WEBrick's built-in signal traps via StartCallback,
|
|
235
|
+
# which fires after WEBrick sets its own INT/TERM handlers.
|
|
236
|
+
# This ensures Ctrl-C always exits immediately.
|
|
237
|
+
#
|
|
238
|
+
# When running as a worker under Master, DoNotListen: true prevents WEBrick
|
|
239
|
+
# from calling bind() on its own — we inject the inherited socket instead.
|
|
240
|
+
webrick_opts = {
|
|
241
|
+
BindAddress: @host,
|
|
242
|
+
Port: @port,
|
|
243
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
244
|
+
AccessLog: [],
|
|
245
|
+
StartCallback: proc { } # signal traps set below, after `server` is created
|
|
246
|
+
}
|
|
247
|
+
webrick_opts[:DoNotListen] = true if @inherited_socket
|
|
248
|
+
Octo::Logger.info("[HttpServer PID=#{Process.pid}] WEBrick DoNotListen=#{webrick_opts[:DoNotListen].inspect}")
|
|
249
|
+
|
|
250
|
+
server = WEBrick::HTTPServer.new(**webrick_opts)
|
|
251
|
+
|
|
252
|
+
# Override WEBrick's signal traps now that `server` is available.
|
|
253
|
+
# On INT/TERM: call server.shutdown (graceful), with a 1s hard-kill fallback.
|
|
254
|
+
# Also stop BrowserManager so the chrome-devtools-mcp node process is killed
|
|
255
|
+
# before this worker exits — otherwise it becomes an orphan and holds port 8888.
|
|
256
|
+
shutdown_once = false
|
|
257
|
+
shutdown_proc = proc do
|
|
258
|
+
next if shutdown_once
|
|
259
|
+
shutdown_once = true
|
|
260
|
+
# Persist in-flight agent sessions BEFORE starting the forced-exit
|
|
261
|
+
# timer, so any new messages added to @history since the last save
|
|
262
|
+
# are on disk before the new worker reads them after a hot restart.
|
|
263
|
+
interrupt_all_agents
|
|
264
|
+
|
|
265
|
+
# Detach the inherited (shared) listen socket BEFORE WEBrick.shutdown
|
|
266
|
+
# so that cleanup_listener does not call shutdown(SHUT_RDWR)+close on
|
|
267
|
+
# it — that would propagate to every process sharing the underlying
|
|
268
|
+
# kernel socket (Master + new worker), breaking subsequent accept()
|
|
269
|
+
# on Linux. macOS's BSD stack tolerates this; Linux does not.
|
|
270
|
+
if @inherited_socket && server.listeners.include?(@inherited_socket)
|
|
271
|
+
server.listeners.delete(@inherited_socket)
|
|
272
|
+
Octo::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
|
|
273
|
+
end
|
|
274
|
+
t1 = Thread.new { @channel_manager.stop rescue nil }
|
|
275
|
+
t2 = Thread.new { Octo::BrowserManager.instance.stop rescue nil }
|
|
276
|
+
t1.join(1.5)
|
|
277
|
+
t2.join(1.5)
|
|
278
|
+
server.shutdown rescue nil
|
|
279
|
+
end
|
|
280
|
+
trap("INT") { shutdown_proc.call }
|
|
281
|
+
trap("TERM") { shutdown_proc.call }
|
|
282
|
+
|
|
283
|
+
if @inherited_socket
|
|
284
|
+
server.listeners << @inherited_socket
|
|
285
|
+
Octo::Logger.info("[HttpServer PID=#{Process.pid}] injected inherited fd=#{@inherited_socket.fileno} listeners=#{server.listeners.map(&:fileno).inspect}")
|
|
286
|
+
else
|
|
287
|
+
Octo::Logger.info("[HttpServer PID=#{Process.pid}] standalone, WEBrick listeners=#{server.listeners.map(&:fileno).inspect}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Mount API + WebSocket handler (takes priority).
|
|
291
|
+
# Use a custom Servlet so that DELETE/PUT/PATCH requests are not rejected
|
|
292
|
+
# by WEBrick's default method whitelist before reaching our dispatcher.
|
|
293
|
+
dispatcher = self
|
|
294
|
+
servlet_class = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
|
|
295
|
+
define_method(:do_GET) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
296
|
+
define_method(:do_POST) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
297
|
+
define_method(:do_PUT) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
298
|
+
define_method(:do_DELETE) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
299
|
+
define_method(:do_PATCH) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
300
|
+
define_method(:do_OPTIONS) { |req, res| dispatcher.send(:dispatch, req, res) }
|
|
301
|
+
end
|
|
302
|
+
server.mount("/api", servlet_class)
|
|
303
|
+
server.mount("/ws", servlet_class)
|
|
304
|
+
|
|
305
|
+
# Mount static file handler for the entire web directory.
|
|
306
|
+
# Use mount_proc so we can inject no-cache headers on every response,
|
|
307
|
+
# preventing stale JS/CSS from being served after a gem update.
|
|
308
|
+
#
|
|
309
|
+
file_handler = WEBrick::HTTPServlet::FileHandler.new(server, WEB_ROOT,
|
|
310
|
+
FancyIndexing: false)
|
|
311
|
+
|
|
312
|
+
server.mount_proc("/") do |req, res|
|
|
313
|
+
file_handler.service(req, res)
|
|
314
|
+
res["Cache-Control"] = "no-store"
|
|
315
|
+
res["Pragma"] = "no-cache"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Auto-create a default session on startup
|
|
319
|
+
create_default_session
|
|
320
|
+
|
|
321
|
+
# Start the background scheduler
|
|
322
|
+
@scheduler.start
|
|
323
|
+
puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
|
|
324
|
+
|
|
325
|
+
# Start IM channel adapters (non-blocking — each platform runs in its own thread)
|
|
326
|
+
@channel_manager.start
|
|
327
|
+
|
|
328
|
+
# Start browser MCP daemon if browser.yml is configured (non-blocking)
|
|
329
|
+
@browser_manager.start
|
|
330
|
+
|
|
331
|
+
server.start
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── Router ────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
def dispatch(req, res)
|
|
338
|
+
path = req.path
|
|
339
|
+
method = req.request_method
|
|
340
|
+
|
|
341
|
+
# Access key guard (skip for WebSocket upgrades)
|
|
342
|
+
return unless check_access_key(req, res)
|
|
343
|
+
|
|
344
|
+
# WebSocket upgrade — no timeout applied (long-lived connection)
|
|
345
|
+
if websocket_upgrade?(req)
|
|
346
|
+
handle_websocket(req, res)
|
|
347
|
+
return
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Wrap all REST handlers in a timeout so a hung handler (e.g. infinite
|
|
351
|
+
# recursion in chunk parsing) returns a proper 503 instead of an empty 200.
|
|
352
|
+
timeout_sec = if path == "/api/tool/browser"
|
|
353
|
+
30
|
|
354
|
+
else
|
|
355
|
+
10
|
|
356
|
+
end
|
|
357
|
+
Timeout.timeout(timeout_sec) do
|
|
358
|
+
_dispatch_rest(req, res)
|
|
359
|
+
end
|
|
360
|
+
rescue Timeout::Error
|
|
361
|
+
Octo::Logger.warn("[HTTP 503] #{method} #{path} timed out after #{timeout_sec}s")
|
|
362
|
+
json_response(res, 503, { error: "Request timed out" })
|
|
363
|
+
rescue => e
|
|
364
|
+
Octo::Logger.warn("[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
365
|
+
json_response(res, 500, { error: e.message })
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def _dispatch_rest(req, res)
|
|
369
|
+
path = req.path
|
|
370
|
+
method = req.request_method
|
|
371
|
+
|
|
372
|
+
case [method, path]
|
|
373
|
+
when ["GET", "/api/sessions"] then api_list_sessions(req, res)
|
|
374
|
+
when ["POST", "/api/sessions"] then api_create_session(req, res)
|
|
375
|
+
when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
|
|
376
|
+
when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
|
|
377
|
+
when ["GET", "/api/skills"] then api_list_skills(res)
|
|
378
|
+
when ["GET", "/api/config"] then api_get_config(res)
|
|
379
|
+
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
380
|
+
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
381
|
+
when ["GET", "/api/providers"] then api_list_providers(res)
|
|
382
|
+
when ["GET", "/api/onboard/status"] then api_onboard_status(res)
|
|
383
|
+
when ["GET", "/api/browser/status"] then api_browser_status(res)
|
|
384
|
+
when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
|
|
385
|
+
when ["POST", "/api/browser/reload"] then api_browser_reload(res)
|
|
386
|
+
when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
|
|
387
|
+
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
388
|
+
when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
|
|
389
|
+
|
|
390
|
+
when ["GET", "/api/trash"] then api_trash(req, res)
|
|
391
|
+
when ["POST", "/api/trash/restore"] then api_trash_restore(req, res)
|
|
392
|
+
when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
|
|
393
|
+
when ["GET", "/api/profile"] then api_profile_get(res)
|
|
394
|
+
when ["PUT", "/api/profile"] then api_profile_put(req, res)
|
|
395
|
+
when ["GET", "/api/memories"] then api_memories_list(res)
|
|
396
|
+
when ["POST", "/api/memories"] then api_memories_create(req, res)
|
|
397
|
+
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
398
|
+
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
399
|
+
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
400
|
+
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
401
|
+
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
402
|
+
when ["GET", "/api/version"] then api_get_version(res)
|
|
403
|
+
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
404
|
+
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
405
|
+
|
|
406
|
+
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
407
|
+
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
408
|
+
else
|
|
409
|
+
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
410
|
+
platform = path.sub("/api/channels/", "").sub("/send", "")
|
|
411
|
+
api_send_channel_message(platform, req, res)
|
|
412
|
+
elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
|
|
413
|
+
platform = path.sub("/api/channels/", "").sub("/users", "")
|
|
414
|
+
api_list_channel_users(platform, res)
|
|
415
|
+
elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
|
|
416
|
+
platform = path.sub("/api/channels/", "").sub("/test", "")
|
|
417
|
+
api_test_channel(platform, req, res)
|
|
418
|
+
elsif method == "PATCH" && path.match?(%r{^/api/channels/[^/]+/enabled$})
|
|
419
|
+
platform = path.sub("/api/channels/", "").sub("/enabled", "")
|
|
420
|
+
api_toggle_channel(platform, req, res)
|
|
421
|
+
elsif method == "POST" && path.start_with?("/api/channels/")
|
|
422
|
+
platform = path.sub("/api/channels/", "")
|
|
423
|
+
api_save_channel(platform, req, res)
|
|
424
|
+
elsif method == "DELETE" && path.start_with?("/api/channels/")
|
|
425
|
+
platform = path.sub("/api/channels/", "")
|
|
426
|
+
api_delete_channel(platform, res)
|
|
427
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
428
|
+
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
429
|
+
api_session_skills(session_id, res)
|
|
430
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
|
|
431
|
+
session_id = path.sub("/api/sessions/", "").sub("/export", "")
|
|
432
|
+
api_export_session(session_id, res)
|
|
433
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
|
|
434
|
+
session_id = path.sub("/api/sessions/", "").sub("/messages", "")
|
|
435
|
+
api_session_messages(session_id, req, res)
|
|
436
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
|
|
437
|
+
session_id = path.sub("/api/sessions/", "")
|
|
438
|
+
api_rename_session(session_id, req, res)
|
|
439
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
440
|
+
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
441
|
+
api_switch_session_model(session_id, req, res)
|
|
442
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
443
|
+
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
444
|
+
api_switch_session_reasoning_effort(session_id, req, res)
|
|
445
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
446
|
+
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
447
|
+
api_benchmark_session_models(session_id, req, res)
|
|
448
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/working_dir$})
|
|
449
|
+
session_id = path.sub("/api/sessions/", "").sub("/working_dir", "")
|
|
450
|
+
api_change_session_working_dir(session_id, req, res)
|
|
451
|
+
elsif method == "DELETE" && path.start_with?("/api/sessions/")
|
|
452
|
+
session_id = path.sub("/api/sessions/", "")
|
|
453
|
+
api_delete_session(session_id, res)
|
|
454
|
+
elsif method == "POST" && path.match?(%r{^/api/config/models/[^/]+/default$})
|
|
455
|
+
id = path.sub("/api/config/models/", "").sub("/default", "")
|
|
456
|
+
api_set_default_model(id, res)
|
|
457
|
+
elsif method == "PATCH" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
458
|
+
id = path.sub("/api/config/models/", "")
|
|
459
|
+
api_update_model(id, req, res)
|
|
460
|
+
elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
461
|
+
id = path.sub("/api/config/models/", "")
|
|
462
|
+
api_delete_model(id, res)
|
|
463
|
+
elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
|
|
464
|
+
name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
|
|
465
|
+
api_run_cron_task(name, res)
|
|
466
|
+
elsif method == "PATCH" && path.match?(%r{^/api/cron-tasks/[^/]+$})
|
|
467
|
+
name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
|
|
468
|
+
api_update_cron_task(name, req, res)
|
|
469
|
+
elsif method == "DELETE" && path.match?(%r{^/api/cron-tasks/[^/]+$})
|
|
470
|
+
name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
|
|
471
|
+
api_delete_cron_task(name, res)
|
|
472
|
+
elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
|
|
473
|
+
name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
|
|
474
|
+
api_toggle_skill(name, req, res)
|
|
475
|
+
|
|
476
|
+
elsif method == "GET" && path.match?(%r{^/api/memories/[^/]+$})
|
|
477
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
478
|
+
api_memories_get(filename, res)
|
|
479
|
+
elsif method == "PUT" && path.match?(%r{^/api/memories/[^/]+$})
|
|
480
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
481
|
+
api_memories_update(filename, req, res)
|
|
482
|
+
elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
|
|
483
|
+
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
484
|
+
api_memories_delete(filename, res)
|
|
485
|
+
else
|
|
486
|
+
not_found(res)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# ── REST API ──────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
def api_list_sessions(req, res)
|
|
494
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
495
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
|
|
496
|
+
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
497
|
+
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
498
|
+
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
499
|
+
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
500
|
+
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
501
|
+
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
502
|
+
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
503
|
+
|
|
504
|
+
# Fetch one extra NON-PINNED row to detect has_more without a separate count query.
|
|
505
|
+
# `registry.list` always returns ALL matching pinned rows first (on the
|
|
506
|
+
# first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
|
|
507
|
+
# So has_more is determined by whether the non-pinned section overflowed.
|
|
508
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
|
|
509
|
+
|
|
510
|
+
# Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
|
|
511
|
+
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
512
|
+
has_more = non_pinned_part.size > limit
|
|
513
|
+
non_pinned_part = non_pinned_part.first(limit)
|
|
514
|
+
sessions = pinned_part + non_pinned_part
|
|
515
|
+
|
|
516
|
+
json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def api_create_session(req, res)
|
|
520
|
+
body = parse_json_body(req)
|
|
521
|
+
name = body["name"]
|
|
522
|
+
return json_response(res, 400, { error: "name is required" }) if name.nil? || name.strip.empty?
|
|
523
|
+
|
|
524
|
+
# Optional agent_profile; defaults to "general" if omitted or invalid
|
|
525
|
+
profile = body["agent_profile"].to_s.strip
|
|
526
|
+
profile = "general" if profile.empty?
|
|
527
|
+
|
|
528
|
+
# Optional source; defaults to :manual. Accept "system" for skill-launched sessions
|
|
529
|
+
# (e.g. /onboard, /browser-setup, /channel-manager).
|
|
530
|
+
raw_source = body["source"].to_s.strip
|
|
531
|
+
source = %w[manual cron channel setup].include?(raw_source) ? raw_source.to_sym : :manual
|
|
532
|
+
|
|
533
|
+
raw_dir = body["working_dir"].to_s.strip
|
|
534
|
+
working_dir = raw_dir.empty? ? default_working_dir : File.expand_path(raw_dir)
|
|
535
|
+
|
|
536
|
+
# Optional model override — passed as a stable model id (matches the
|
|
537
|
+
# id returned by GET /api/config). Name-based override was removed:
|
|
538
|
+
# a bare model name can't disambiguate between entries from different
|
|
539
|
+
# providers (e.g. "deepseek-v4-pro" on DeepSeek direct vs its dsk-*
|
|
540
|
+
# alias on Octo/Bedrock), and mutating current_model["model"]
|
|
541
|
+
# kept the wrong api_key / base_url / api format, producing
|
|
542
|
+
# "unknown model" errors at the provider.
|
|
543
|
+
model_id_override = body["model_id"].to_s.strip
|
|
544
|
+
model_id_override = nil if model_id_override.empty?
|
|
545
|
+
|
|
546
|
+
if model_id_override && !@agent_config.models.any? { |m| m["id"] == model_id_override }
|
|
547
|
+
return json_response(res, 400, { error: "Model not found in configuration" })
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Create working directory if it doesn't exist
|
|
551
|
+
# Allow multiple sessions in the same directory
|
|
552
|
+
FileUtils.mkdir_p(working_dir)
|
|
553
|
+
|
|
554
|
+
session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source, model_id: model_id_override)
|
|
555
|
+
broadcast_session_update(session_id)
|
|
556
|
+
json_response(res, 201, { session: @registry.session_summary(session_id) })
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Auto-restore persisted sessions (or create a fresh default) when the server starts.
|
|
560
|
+
# Skipped when no API key is configured (onboard flow will handle it).
|
|
561
|
+
#
|
|
562
|
+
# Strategy: load the most recent sessions from ~/.octo/sessions/ for the
|
|
563
|
+
# current working directory and restore them into @registry so their IDs are
|
|
564
|
+
# stable across restarts (frontend hash stays valid). If no persisted sessions
|
|
565
|
+
# exist, fall back to creating a new default session.
|
|
566
|
+
def create_default_session
|
|
567
|
+
return unless @agent_config.models_configured?
|
|
568
|
+
|
|
569
|
+
# Restore up to 5 sessions per source type from disk into the registry.
|
|
570
|
+
@registry.restore_from_disk(n: 5)
|
|
571
|
+
|
|
572
|
+
# Recover any orphaned .jsonl incremental logs from crashed sessions
|
|
573
|
+
# and merge them back into their parent session .json files.
|
|
574
|
+
recovered = @session_manager.recover_jsonl_sessions
|
|
575
|
+
Octo::Logger.info("http_server.recovered_jsonl_sessions", count: recovered) if recovered > 0
|
|
576
|
+
|
|
577
|
+
# If nothing was restored (no persisted sessions), create a fresh default.
|
|
578
|
+
unless @registry.list(limit: 1).any?
|
|
579
|
+
working_dir = default_working_dir
|
|
580
|
+
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
581
|
+
build_session(name: "Session 1", working_dir: working_dir)
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# ── Onboard API ───────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
# GET /api/onboard/status
|
|
588
|
+
# Phase "key_setup" → no API key configured yet
|
|
589
|
+
# Phase "soul_setup" → key configured, but ~/.octo/agents/SOUL.md missing
|
|
590
|
+
# needs_onboard: false → fully set up
|
|
591
|
+
def api_onboard_status(res)
|
|
592
|
+
if !@agent_config.models_configured?
|
|
593
|
+
json_response(res, 200, { needs_onboard: true, phase: "key_setup" })
|
|
594
|
+
else
|
|
595
|
+
json_response(res, 200, { needs_onboard: false })
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# GET /api/browser/status
|
|
600
|
+
# Returns real daemon liveness from BrowserManager (not just yml read).
|
|
601
|
+
def api_browser_status(res)
|
|
602
|
+
json_response(res, 200, @browser_manager.status)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# POST /api/browser/configure
|
|
606
|
+
# Called by browser-setup skill to write browser.yml and hot-reload the daemon.
|
|
607
|
+
# Body: { chrome_version: "146" }
|
|
608
|
+
def api_browser_configure(req, res)
|
|
609
|
+
body = JSON.parse(req.body.to_s) rescue {}
|
|
610
|
+
chrome_version = body["chrome_version"].to_s.strip
|
|
611
|
+
return json_response(res, 422, { ok: false, error: "chrome_version is required" }) if chrome_version.empty?
|
|
612
|
+
|
|
613
|
+
@browser_manager.configure(chrome_version: chrome_version)
|
|
614
|
+
json_response(res, 200, { ok: true })
|
|
615
|
+
rescue StandardError => e
|
|
616
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# POST /api/browser/reload
|
|
620
|
+
# Called by browser-setup skill after writing browser.yml.
|
|
621
|
+
# Hot-reloads the MCP daemon with the new configuration.
|
|
622
|
+
def api_browser_reload(res)
|
|
623
|
+
@browser_manager.reload
|
|
624
|
+
json_response(res, 200, { ok: true })
|
|
625
|
+
rescue StandardError => e
|
|
626
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# POST /api/browser/toggle
|
|
630
|
+
def api_browser_toggle(res)
|
|
631
|
+
enabled = @browser_manager.toggle
|
|
632
|
+
json_response(res, 200, { ok: true, enabled: enabled })
|
|
633
|
+
rescue StandardError => e
|
|
634
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# POST /api/onboard/complete
|
|
638
|
+
# Called after key setup is done (soul_setup is optional/skipped).
|
|
639
|
+
# Creates the default session if none exists yet, returns it.
|
|
640
|
+
def api_onboard_complete(req, res)
|
|
641
|
+
create_default_session if @registry.list(limit: 1).empty?
|
|
642
|
+
first_session = @registry.list(limit: 1).first
|
|
643
|
+
json_response(res, 200, { ok: true, session: first_session })
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# POST /api/onboard/skip-soul
|
|
647
|
+
# Writes a minimal SOUL.md so the soul_setup phase is not re-triggered
|
|
648
|
+
# on the next server start when the user chooses to skip the conversation.
|
|
649
|
+
def api_onboard_skip_soul(req, res)
|
|
650
|
+
body = parse_json_body(req)
|
|
651
|
+
lang = body["lang"].to_s.strip
|
|
652
|
+
soul_content = lang == "zh" ? DEFAULT_SOUL_MD_ZH : DEFAULT_SOUL_MD
|
|
653
|
+
|
|
654
|
+
agents_dir = File.expand_path("~/.octo/agents")
|
|
655
|
+
FileUtils.mkdir_p(agents_dir)
|
|
656
|
+
soul_path = File.join(agents_dir, "SOUL.md")
|
|
657
|
+
unless File.exist?(soul_path)
|
|
658
|
+
File.write(soul_path, soul_content)
|
|
659
|
+
end
|
|
660
|
+
json_response(res, 200, { ok: true })
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# GET /api/version
|
|
664
|
+
# Returns current version and latest version from RubyGems (cached for 1 hour).
|
|
665
|
+
def api_get_version(res)
|
|
666
|
+
current = Octo::VERSION
|
|
667
|
+
latest = fetch_latest_version_cached
|
|
668
|
+
json_response(res, 200, {
|
|
669
|
+
current: current,
|
|
670
|
+
latest: latest,
|
|
671
|
+
needs_update: latest ? version_older?(current, latest) : false,
|
|
672
|
+
launcher: ENV["OCTO_LAUNCHER"] || "cli",
|
|
673
|
+
cli_command: "octo"
|
|
674
|
+
})
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# POST /api/version/upgrade
|
|
678
|
+
# Upgrades octo in a background thread, streaming output via WebSocket broadcast.
|
|
679
|
+
# If the user's gem source is the official RubyGems, use `gem update`.
|
|
680
|
+
# Otherwise (e.g. Aliyun mirror) download the .gem from OSS CDN to bypass mirror lag.
|
|
681
|
+
def api_upgrade_version(req, res)
|
|
682
|
+
json_response(res, 202, { ok: true, message: "Upgrade started" })
|
|
683
|
+
|
|
684
|
+
Thread.new do
|
|
685
|
+
begin
|
|
686
|
+
if official_gem_source?
|
|
687
|
+
upgrade_via_gem_update
|
|
688
|
+
else
|
|
689
|
+
upgrade_via_oss_cdn
|
|
690
|
+
end
|
|
691
|
+
rescue StandardError => e
|
|
692
|
+
Octo::Logger.error("[Upgrade] Exception: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
693
|
+
broadcast_all(type: "upgrade_log", line: "\n✗ Error during upgrade: #{e.message}\n")
|
|
694
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Returns true when the bind host is loopback-only.
|
|
700
|
+
private def local_host?(host)
|
|
701
|
+
["127.0.0.1", "::1", "localhost"].include?(host.to_s.strip)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Resolve access key from OCTO_ACCESS_KEY env var only.
|
|
705
|
+
private def resolve_access_key
|
|
706
|
+
key = ENV.fetch("OCTO_ACCESS_KEY", "").strip
|
|
707
|
+
key.empty? ? nil : key
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Extract bearer token or query param from a WEBrick request.
|
|
711
|
+
# Priority: Authorization: Bearer > ?access_key=
|
|
712
|
+
# The query string form is only used by WebSocket connections, which
|
|
713
|
+
# cannot set custom headers from the browser. All HTTP clients —
|
|
714
|
+
# including the web UI (via a fetch interceptor in auth.js) — use the
|
|
715
|
+
# Authorization header.
|
|
716
|
+
private def extract_key(req)
|
|
717
|
+
auth = req["Authorization"].to_s.strip
|
|
718
|
+
if auth.start_with?("Bearer ")
|
|
719
|
+
token = auth.sub(/\ABearer\s+/i, "").strip
|
|
720
|
+
return token unless token.empty?
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
724
|
+
token = query["access_key"].to_s.strip
|
|
725
|
+
return token unless token.empty?
|
|
726
|
+
|
|
727
|
+
req.cookies.each do |c|
|
|
728
|
+
return c.value if c.name == "octo_access_key" && !c.value.to_s.empty?
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
nil
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Constant-time string comparison to prevent timing attacks.
|
|
735
|
+
private def secure_compare(a, b)
|
|
736
|
+
return false unless a.bytesize == b.bytesize
|
|
737
|
+
|
|
738
|
+
result = 0
|
|
739
|
+
a.unpack("C*").zip(b.unpack("C*")) { |x, y| result |= x ^ y }
|
|
740
|
+
result.zero?
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Returns true if the request is authenticated or auth is disabled.
|
|
744
|
+
# Writes 401/429 to res and returns false on failure.
|
|
745
|
+
private def check_access_key(req, res)
|
|
746
|
+
# Localhost binding — always trusted, no auth needed.
|
|
747
|
+
return true if @localhost_only
|
|
748
|
+
return true unless @access_key # public but no key configured (cli already blocked this)
|
|
749
|
+
|
|
750
|
+
ip = req.peeraddr.last rescue "unknown"
|
|
751
|
+
candidate = extract_key(req)
|
|
752
|
+
|
|
753
|
+
# Lazily evict expired lockout entries to prevent unbounded memory growth.
|
|
754
|
+
@auth_failures_mutex.synchronize do
|
|
755
|
+
@auth_failures.delete_if { |_, e| Time.now >= e[:reset_at] }
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# No key provided — reject immediately without counting as a failure.
|
|
759
|
+
if candidate.nil? || candidate.empty?
|
|
760
|
+
json_response(res, 401, {
|
|
761
|
+
error: "Unauthorized: access key required",
|
|
762
|
+
hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
|
|
763
|
+
})
|
|
764
|
+
return false
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Check if IP is currently locked out.
|
|
768
|
+
blocked, wait_secs = @auth_failures_mutex.synchronize do
|
|
769
|
+
entry = @auth_failures[ip]
|
|
770
|
+
if entry && entry[:count] >= 10 && Time.now < entry[:reset_at]
|
|
771
|
+
[true, (entry[:reset_at] - Time.now).ceil]
|
|
772
|
+
else
|
|
773
|
+
[false, 0]
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
if blocked
|
|
778
|
+
json_response(res, 429, { error: "Too many failed attempts", retry_after: wait_secs })
|
|
779
|
+
return false
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
if secure_compare(@access_key, candidate)
|
|
783
|
+
@auth_failures_mutex.synchronize { @auth_failures.delete(ip) }
|
|
784
|
+
return true
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
@auth_failures_mutex.synchronize do
|
|
788
|
+
entry = @auth_failures[ip] ||= { count: 0, reset_at: Time.now + 300 }
|
|
789
|
+
entry[:count] += 1
|
|
790
|
+
Octo::Logger.warn("[Auth] Failed attempt #{entry[:count]}/10 from #{ip}")
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
json_response(res, 401, {
|
|
794
|
+
error: "Unauthorized: invalid access key",
|
|
795
|
+
hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
|
|
796
|
+
})
|
|
797
|
+
false
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Returns true when the configured gem source is the official RubyGems.org.
|
|
801
|
+
# Raises on error — caller's rescue will handle it.
|
|
802
|
+
private def official_gem_source?
|
|
803
|
+
output, exit_code = run_shell("gem sources -l")
|
|
804
|
+
raise "gem sources -l failed (exit #{exit_code}): #{output}" unless exit_code&.zero?
|
|
805
|
+
|
|
806
|
+
Octo::Logger.info("[Upgrade] gem sources: #{output.strip}")
|
|
807
|
+
output.include?("https://rubygems.org") &&
|
|
808
|
+
!output.match?(%r{mirrors\.|aliyun|tuna|ustc|ruby-china})
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# Upgrade via `gem update octo --no-document` (official RubyGems source).
|
|
812
|
+
private def upgrade_via_gem_update
|
|
813
|
+
cmd = "gem update octo --no-document"
|
|
814
|
+
Octo::Logger.info("[Upgrade] Official source — running: #{cmd}")
|
|
815
|
+
broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
|
|
816
|
+
|
|
817
|
+
output, exit_code = run_shell(cmd, timeout: 600)
|
|
818
|
+
|
|
819
|
+
Octo::Logger.info("[Upgrade] exit_code=#{exit_code}")
|
|
820
|
+
Octo::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
|
|
821
|
+
|
|
822
|
+
success = exit_code&.zero? || false
|
|
823
|
+
|
|
824
|
+
broadcast_all(type: "upgrade_log", line: output)
|
|
825
|
+
finish_upgrade(success, fallback_hint: "gem update octo")
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Upgrade via OSS CDN: fetch latest.txt → download .gem → gem install (bypasses mirror lag).
|
|
829
|
+
private def upgrade_via_oss_cdn
|
|
830
|
+
require "net/http"
|
|
831
|
+
require "uri"
|
|
832
|
+
|
|
833
|
+
oss_base = "https://oss.1024code.com/octo"
|
|
834
|
+
latest_url = "#{oss_base}/latest.txt"
|
|
835
|
+
|
|
836
|
+
Octo::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
|
|
837
|
+
broadcast_all(type: "upgrade_log", line: "Non-official gem source detected — fetching latest version from OSS CDN...\n")
|
|
838
|
+
|
|
839
|
+
# Step 1: fetch latest version from OSS
|
|
840
|
+
latest_version = fetch_oss_latest_version(latest_url)
|
|
841
|
+
unless latest_version
|
|
842
|
+
broadcast_all(type: "upgrade_log", line: "✗ Failed to fetch latest version from OSS CDN\n")
|
|
843
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
844
|
+
return
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
broadcast_all(type: "upgrade_log", line: "Latest version: #{latest_version}\n")
|
|
848
|
+
|
|
849
|
+
# Already up to date?
|
|
850
|
+
unless version_older?(Octo::VERSION, latest_version)
|
|
851
|
+
broadcast_all(type: "upgrade_log", line: "✓ Already at latest version (#{Octo::VERSION})\n")
|
|
852
|
+
broadcast_all(type: "upgrade_complete", success: true)
|
|
853
|
+
return
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Step 2: download .gem file from OSS
|
|
857
|
+
gem_url = "#{oss_base}/octo-#{latest_version}.gem"
|
|
858
|
+
gem_file = "/tmp/octo-#{latest_version}.gem"
|
|
859
|
+
broadcast_all(type: "upgrade_log", line: "Downloading octo-#{latest_version}.gem from OSS...\n")
|
|
860
|
+
Octo::Logger.info("[Upgrade] Downloading #{gem_url}")
|
|
861
|
+
|
|
862
|
+
shell_cmd = "curl -fsSL '#{gem_url}' -o '#{gem_file}'"
|
|
863
|
+
dl_out, dl_exit = run_shell(shell_cmd, timeout: 300)
|
|
864
|
+
unless dl_exit&.zero?
|
|
865
|
+
broadcast_all(type: "upgrade_log", line: "✗ Download failed: #{dl_out}\n")
|
|
866
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
867
|
+
return
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Step 3: install the downloaded .gem (dependencies resolved via configured gem source)
|
|
871
|
+
cmd = "gem install '#{gem_file}' --no-document"
|
|
872
|
+
broadcast_all(type: "upgrade_log", line: "Installing...\n")
|
|
873
|
+
Octo::Logger.info("[Upgrade] Running: #{cmd}")
|
|
874
|
+
|
|
875
|
+
output, exit_code = run_shell(cmd, timeout: 600)
|
|
876
|
+
success = exit_code&.zero? || false
|
|
877
|
+
|
|
878
|
+
broadcast_all(type: "upgrade_log", line: output)
|
|
879
|
+
finish_upgrade(success, fallback_hint: "gem install #{gem_url}")
|
|
880
|
+
ensure
|
|
881
|
+
File.delete(gem_file) if gem_file && File.exist?(gem_file) rescue nil
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# Fetch the latest version string from OSS latest.txt.
|
|
885
|
+
private def fetch_oss_latest_version(url)
|
|
886
|
+
require "net/http"
|
|
887
|
+
uri = URI(url)
|
|
888
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
889
|
+
http.use_ssl = uri.scheme == "https"
|
|
890
|
+
http.open_timeout = 10
|
|
891
|
+
http.read_timeout = 10
|
|
892
|
+
res = http.get(uri.request_uri)
|
|
893
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
894
|
+
|
|
895
|
+
version = res.body.to_s.strip
|
|
896
|
+
version.empty? ? nil : version
|
|
897
|
+
rescue StandardError => e
|
|
898
|
+
Octo::Logger.warn("[Upgrade] fetch_oss_latest_version error: #{e.message}")
|
|
899
|
+
nil
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# Broadcast final upgrade result with appropriate log message.
|
|
903
|
+
#
|
|
904
|
+
# Defensive post-check: if `run_shell` reported failure but the gem
|
|
905
|
+
# is in fact now installed at the latest version, reverse the verdict.
|
|
906
|
+
# This guards against false negatives from the Terminal idle-poll
|
|
907
|
+
# mechanism (see: 0.9.36 upgrade failure bug).
|
|
908
|
+
private def finish_upgrade(success, fallback_hint: "gem update octo")
|
|
909
|
+
if !success && gem_actually_upgraded?
|
|
910
|
+
Octo::Logger.warn("[Upgrade] run_shell reported failure, but installed version matches latest — treating as success.")
|
|
911
|
+
broadcast_all(type: "upgrade_log", line: "\n(Verified: the new version is installed — reclassifying as success.)\n")
|
|
912
|
+
success = true
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
if success
|
|
916
|
+
Octo::Logger.info("[Upgrade] Success!")
|
|
917
|
+
broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
|
|
918
|
+
broadcast_all(type: "upgrade_complete", success: true)
|
|
919
|
+
else
|
|
920
|
+
Octo::Logger.warn("[Upgrade] Failed.")
|
|
921
|
+
broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: #{fallback_hint}\n")
|
|
922
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Check whether the latest published version of octo is already
|
|
927
|
+
# installed locally. Used as a post-upgrade sanity check so a flaky
|
|
928
|
+
# run_shell result doesn't mask a successful install.
|
|
929
|
+
# Returns false on any error (conservative — don't fabricate success).
|
|
930
|
+
private def gem_actually_upgraded?
|
|
931
|
+
latest = fetch_latest_version_from_rubygems_api
|
|
932
|
+
return false unless latest
|
|
933
|
+
|
|
934
|
+
out, exit_code = run_shell("gem list octo -i -v #{latest}", timeout: 30)
|
|
935
|
+
return false unless exit_code&.zero?
|
|
936
|
+
out.to_s.strip.downcase == "true"
|
|
937
|
+
rescue StandardError => e
|
|
938
|
+
Octo::Logger.warn("[Upgrade] gem_actually_upgraded? error: #{e.message}")
|
|
939
|
+
false
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# POST /api/restart
|
|
943
|
+
# Re-execs the current process so the newly installed gem version is loaded.
|
|
944
|
+
# Uses the absolute script path captured at startup to avoid relative-path issues.
|
|
945
|
+
# Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
|
|
946
|
+
def api_restart(req, res)
|
|
947
|
+
json_response(res, 200, { ok: true, message: "Restarting…" })
|
|
948
|
+
|
|
949
|
+
Thread.new do
|
|
950
|
+
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
951
|
+
|
|
952
|
+
if @master_pid
|
|
953
|
+
# Worker mode: tell master to hot-restart. Master will TERM us after the
|
|
954
|
+
# new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
|
|
955
|
+
# the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
|
|
956
|
+
# that bypasses trap handlers and lets the OS close(fd) on a socket shared
|
|
957
|
+
# with master+new worker, corrupting the listener on Linux/WSL.
|
|
958
|
+
Octo::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
|
|
959
|
+
begin
|
|
960
|
+
Process.kill("USR1", @master_pid)
|
|
961
|
+
rescue Errno::ESRCH
|
|
962
|
+
Octo::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
|
|
963
|
+
standalone_exec_restart
|
|
964
|
+
end
|
|
965
|
+
else
|
|
966
|
+
# Standalone mode (no master): fall back to the original exec approach.
|
|
967
|
+
standalone_exec_restart
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Re-exec the current process via a login shell (rbenv/mise shim compatible).
|
|
973
|
+
private def standalone_exec_restart
|
|
974
|
+
script = @restart_script
|
|
975
|
+
argv = @restart_argv
|
|
976
|
+
shell = ENV["SHELL"].to_s
|
|
977
|
+
shell = "/bin/bash" if shell.empty?
|
|
978
|
+
cmd_parts = [Shellwords.escape(script), *argv.map { |a| Shellwords.escape(a) }]
|
|
979
|
+
cmd_string = cmd_parts.join(" ")
|
|
980
|
+
Octo::Logger.info("[Restart] exec: #{shell} -l -c #{cmd_string}")
|
|
981
|
+
exec(shell, "-l", "-c", cmd_string)
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
|
|
985
|
+
# Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
986
|
+
private def fetch_latest_version_cached
|
|
987
|
+
@version_mutex.synchronize do
|
|
988
|
+
now = Time.now
|
|
989
|
+
if @version_cache && (now - @version_cache[:checked_at]) < 3600
|
|
990
|
+
return @version_cache[:latest]
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Fetch outside the mutex to avoid blocking other requests
|
|
995
|
+
latest = fetch_latest_version_from_gem
|
|
996
|
+
|
|
997
|
+
@version_mutex.synchronize do
|
|
998
|
+
@version_cache = { latest: latest, checked_at: Time.now }
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
latest
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Query the latest octo version.
|
|
1005
|
+
# Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
|
|
1006
|
+
# then fall back to `gem list -r` (respects user's configured gem source).
|
|
1007
|
+
# Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
1008
|
+
private def fetch_latest_version_from_gem
|
|
1009
|
+
fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# Try RubyGems official REST API — fast and always up-to-date.
|
|
1013
|
+
# Returns nil if the request fails or times out.
|
|
1014
|
+
private def fetch_latest_version_from_rubygems_api
|
|
1015
|
+
require "net/http"
|
|
1016
|
+
require "json"
|
|
1017
|
+
|
|
1018
|
+
uri = URI("https://rubygems.org/api/v1/gems/octo.json")
|
|
1019
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1020
|
+
http.use_ssl = true
|
|
1021
|
+
http.open_timeout = 5
|
|
1022
|
+
http.read_timeout = 8
|
|
1023
|
+
|
|
1024
|
+
res = http.get(uri.request_uri)
|
|
1025
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
1026
|
+
|
|
1027
|
+
data = JSON.parse(res.body)
|
|
1028
|
+
data["version"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
1029
|
+
rescue StandardError
|
|
1030
|
+
nil
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# Fall back to `gem list -r octo` via login shell.
|
|
1034
|
+
# Respects the user's configured gem source (rbenv/mise mirrors, etc.).
|
|
1035
|
+
# Output format: "octo (0.9.0)"
|
|
1036
|
+
private def fetch_latest_version_from_gem_command
|
|
1037
|
+
out, exit_code = run_shell("gem list -r octo", timeout: 30)
|
|
1038
|
+
return nil unless exit_code&.zero?
|
|
1039
|
+
|
|
1040
|
+
match = out.match(/^octo\s+\(([^)]+)\)/)
|
|
1041
|
+
match ? match[1].strip : nil
|
|
1042
|
+
rescue StandardError
|
|
1043
|
+
nil
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Returns true if version string `a` is strictly older than `b`.
|
|
1047
|
+
private def version_older?(a, b)
|
|
1048
|
+
Gem::Version.new(a) < Gem::Version.new(b)
|
|
1049
|
+
rescue ArgumentError
|
|
1050
|
+
false
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Run a shell command via the unified Terminal tool and return
|
|
1054
|
+
# [output, exit_code] — drop-in replacement for Open3.capture2e.
|
|
1055
|
+
#
|
|
1056
|
+
# Delegates to Terminal.run_sync which handles the idle-poll loop
|
|
1057
|
+
# internally (see its docs for why that's needed — this wrapper used
|
|
1058
|
+
# to re-implement it wrong and caused the 0.9.36 upgrade bug).
|
|
1059
|
+
private def run_shell(command, timeout: 120)
|
|
1060
|
+
Octo::Tools::Terminal.run_sync(command, timeout: timeout)
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# ── Channel API ───────────────────────────────────────────────────────────
|
|
1064
|
+
|
|
1065
|
+
# GET /api/channels
|
|
1066
|
+
# Returns current config and running status for all supported platforms.
|
|
1067
|
+
# POST /api/tool/browser
|
|
1068
|
+
# Executes a browser tool action via the shared BrowserManager daemon.
|
|
1069
|
+
# Used by skill scripts (e.g. feishu_setup.rb) to reuse the server's
|
|
1070
|
+
# existing Chrome connection without spawning a second MCP daemon.
|
|
1071
|
+
#
|
|
1072
|
+
# Request body: JSON with same params as the browser tool
|
|
1073
|
+
# { "action": "snapshot", "interactive": true, ... }
|
|
1074
|
+
#
|
|
1075
|
+
# Response: JSON result from the browser tool
|
|
1076
|
+
def api_tool_browser(req, res)
|
|
1077
|
+
params = parse_json_body(req)
|
|
1078
|
+
action = params["action"]
|
|
1079
|
+
return json_response(res, 400, { error: "action is required" }) if action.nil? || action.empty?
|
|
1080
|
+
|
|
1081
|
+
tool = Octo::Tools::Browser.new
|
|
1082
|
+
result = tool.execute(**params.transform_keys(&:to_sym))
|
|
1083
|
+
|
|
1084
|
+
json_response(res, 200, result)
|
|
1085
|
+
rescue StandardError => e
|
|
1086
|
+
json_response(res, 500, { error: e.message })
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def api_list_channels(res)
|
|
1090
|
+
config = Octo::ChannelConfig.load
|
|
1091
|
+
running = @channel_manager.running_platforms
|
|
1092
|
+
|
|
1093
|
+
platforms = Octo::Channel::Adapters.all.map do |klass|
|
|
1094
|
+
platform = klass.platform_id
|
|
1095
|
+
raw = config.instance_variable_get(:@channels)[platform.to_s] || {}
|
|
1096
|
+
{
|
|
1097
|
+
platform: platform,
|
|
1098
|
+
enabled: !!raw["enabled"],
|
|
1099
|
+
running: running.include?(platform),
|
|
1100
|
+
has_config: !config.platform_config(platform).nil?
|
|
1101
|
+
}.merge(platform_safe_fields(platform, config))
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
json_response(res, 200, { channels: platforms })
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
# POST /api/channels/:platform/send
|
|
1108
|
+
# Proactively send a message to a user via the given IM platform.
|
|
1109
|
+
#
|
|
1110
|
+
# Body:
|
|
1111
|
+
# { "message": "hello", # required
|
|
1112
|
+
# "user_id": "some_user_id" } # optional — defaults to most-recently active user
|
|
1113
|
+
#
|
|
1114
|
+
# Response:
|
|
1115
|
+
# 200 { ok: true }
|
|
1116
|
+
# 400 { ok: false, error: "..." } — missing/invalid params or platform not running
|
|
1117
|
+
# 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
|
|
1118
|
+
#
|
|
1119
|
+
# Constraints:
|
|
1120
|
+
# - The platform adapter must be running (channel must be enabled + connected).
|
|
1121
|
+
# - For Weixin (iLink protocol), a context_token is required per message. This is
|
|
1122
|
+
# automatically looked up from the in-memory cache populated by inbound messages.
|
|
1123
|
+
# If no token exists for the target user (i.e. the user has never messaged the bot
|
|
1124
|
+
# in this server session), the message cannot be delivered.
|
|
1125
|
+
def api_send_channel_message(platform, req, res)
|
|
1126
|
+
platform = platform.to_sym
|
|
1127
|
+
body = parse_json_body(req)
|
|
1128
|
+
message = body["message"].to_s.strip
|
|
1129
|
+
|
|
1130
|
+
if message.empty?
|
|
1131
|
+
json_response(res, 400, { ok: false, error: "message is required" })
|
|
1132
|
+
return
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Resolve target user_id
|
|
1136
|
+
user_id = body["user_id"].to_s.strip
|
|
1137
|
+
if user_id.empty?
|
|
1138
|
+
# Default to the most-recently active user for this platform
|
|
1139
|
+
known = @channel_manager.known_users(platform)
|
|
1140
|
+
if known.empty?
|
|
1141
|
+
json_response(res, 503, {
|
|
1142
|
+
ok: false,
|
|
1143
|
+
error: "No known users for :#{platform}. The user must send a message to the bot first."
|
|
1144
|
+
})
|
|
1145
|
+
return
|
|
1146
|
+
end
|
|
1147
|
+
user_id = known.last
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
result = @channel_manager.send_to_user(platform, user_id, message)
|
|
1151
|
+
if result.nil?
|
|
1152
|
+
json_response(res, 400, {
|
|
1153
|
+
ok: false,
|
|
1154
|
+
error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
|
|
1155
|
+
})
|
|
1156
|
+
else
|
|
1157
|
+
json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
|
|
1158
|
+
end
|
|
1159
|
+
rescue StandardError => e
|
|
1160
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
# GET /api/channels/:platform/users
|
|
1164
|
+
# Returns the list of known user IDs for the given platform.
|
|
1165
|
+
# These are users who have sent at least one message to the bot in this server session.
|
|
1166
|
+
#
|
|
1167
|
+
# For Weixin: returns users with a cached context_token (required for proactive messaging).
|
|
1168
|
+
# For Feishu / WeCom: returns user IDs extracted from channel session bindings.
|
|
1169
|
+
#
|
|
1170
|
+
# Response:
|
|
1171
|
+
# 200 { users: ["uid1", "uid2", ...] }
|
|
1172
|
+
def api_list_channel_users(platform, res)
|
|
1173
|
+
platform = platform.to_sym
|
|
1174
|
+
users = @channel_manager.known_users(platform)
|
|
1175
|
+
json_response(res, 200, { platform: platform, users: users })
|
|
1176
|
+
rescue StandardError => e
|
|
1177
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# POST /api/upload
|
|
1181
|
+
# Accepts a multipart/form-data file upload (field name: "file").
|
|
1182
|
+
# Runs the file through FileProcessor: saves original + generates structured
|
|
1183
|
+
# preview (Markdown) for Office/ZIP files so the agent can read them directly.
|
|
1184
|
+
def api_upload_file(req, res)
|
|
1185
|
+
upload = parse_multipart_upload(req, "file")
|
|
1186
|
+
unless upload
|
|
1187
|
+
json_response(res, 400, { ok: false, error: "No file field found in multipart body" })
|
|
1188
|
+
return
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
saved = Octo::Utils::FileProcessor.save(
|
|
1192
|
+
body: upload[:data],
|
|
1193
|
+
filename: upload[:filename].to_s
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
json_response(res, 200, { ok: true, name: saved[:name], path: saved[:path] })
|
|
1197
|
+
rescue => e
|
|
1198
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
# POST /api/file-action
|
|
1202
|
+
# Unified file action endpoint — open locally or download.
|
|
1203
|
+
# Body: { path: String, action: "open" | "download" }
|
|
1204
|
+
# open: opens the file with the OS default handler (local deployments).
|
|
1205
|
+
# download: returns the file as a download (remote deployments).
|
|
1206
|
+
def api_file_action(req, res)
|
|
1207
|
+
body = parse_json_body(req)
|
|
1208
|
+
path = body["path"]
|
|
1209
|
+
action = body["action"] || "open"
|
|
1210
|
+
|
|
1211
|
+
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
1212
|
+
|
|
1213
|
+
# Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
|
|
1214
|
+
# Ruby's File.exist? does NOT automatically expand ~ — that's a shell feature.
|
|
1215
|
+
path = File.expand_path(path)
|
|
1216
|
+
|
|
1217
|
+
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1218
|
+
# Convert it to the Linux-side path so File.exist? works.
|
|
1219
|
+
linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
1220
|
+
|
|
1221
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
1222
|
+
|
|
1223
|
+
case action
|
|
1224
|
+
when "open"
|
|
1225
|
+
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
1226
|
+
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
1227
|
+
json_response(res, 200, { ok: true })
|
|
1228
|
+
when "download"
|
|
1229
|
+
serve_file_download(res, linux_path)
|
|
1230
|
+
else
|
|
1231
|
+
json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
|
|
1232
|
+
end
|
|
1233
|
+
rescue => e
|
|
1234
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
# Stream a file to the client as a download.
|
|
1238
|
+
# Content-Type is always application/octet-stream — the browser determines
|
|
1239
|
+
# file type and handling from the filename extension in Content-Disposition.
|
|
1240
|
+
def serve_file_download(res, path)
|
|
1241
|
+
filename = File.basename(path)
|
|
1242
|
+
|
|
1243
|
+
res.status = 200
|
|
1244
|
+
res["Content-Type"] = "application/octet-stream"
|
|
1245
|
+
res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
|
|
1246
|
+
res["Content-Length"] = File.size(path).to_s
|
|
1247
|
+
res.body = File.binread(path)
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# GET /api/local-image?path=file:///path/to/image.png
|
|
1251
|
+
# GET /api/local-image?path=/path/to/image.png
|
|
1252
|
+
#
|
|
1253
|
+
# Serves a local image file with the correct Content-Type.
|
|
1254
|
+
# Used by the Web UI to render local images that would otherwise be blocked
|
|
1255
|
+
# by the browser's security policy (file:// from http:// origin).
|
|
1256
|
+
#
|
|
1257
|
+
def api_serve_local_image(req, res)
|
|
1258
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
1259
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
1260
|
+
|
|
1261
|
+
# Strip file:// prefix if present
|
|
1262
|
+
path = raw_path.sub(%r{\Afile://}, "")
|
|
1263
|
+
path = CGI.unescape(path)
|
|
1264
|
+
path = File.expand_path(path)
|
|
1265
|
+
|
|
1266
|
+
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1267
|
+
# Convert it to the Linux-side path so File.exist? works.
|
|
1268
|
+
path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
1269
|
+
|
|
1270
|
+
# Security: only serve image files
|
|
1271
|
+
ext = File.extname(path).downcase
|
|
1272
|
+
unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
|
|
1273
|
+
return json_response(res, 403, { error: "not an image file" })
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
|
|
1277
|
+
|
|
1278
|
+
mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
|
|
1279
|
+
res.status = 200
|
|
1280
|
+
res["Content-Type"] = mime
|
|
1281
|
+
res["Cache-Control"] = "private, max-age=3600"
|
|
1282
|
+
res.body = File.binread(path)
|
|
1283
|
+
rescue => e
|
|
1284
|
+
json_response(res, 500, { error: e.message })
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# POST /api/channels/:platform
|
|
1288
|
+
# Body: { fields... } (platform-specific credential fields)
|
|
1289
|
+
# Saves credentials and optionally (re)starts the adapter.
|
|
1290
|
+
def api_save_channel(platform, req, res)
|
|
1291
|
+
platform = platform.to_sym
|
|
1292
|
+
body = parse_json_body(req)
|
|
1293
|
+
config = Octo::ChannelConfig.load
|
|
1294
|
+
|
|
1295
|
+
fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
|
|
1296
|
+
fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
|
|
1297
|
+
|
|
1298
|
+
# Record when the token was last updated so clients can detect re-login
|
|
1299
|
+
fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
|
|
1300
|
+
fields[:token_updated_at] = Time.now.to_i if platform == :discord && fields.key?(:bot_token)
|
|
1301
|
+
|
|
1302
|
+
# Validate credentials against live API before persisting.
|
|
1303
|
+
# Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
|
|
1304
|
+
klass = Octo::Channel::Adapters.find(platform)
|
|
1305
|
+
if klass && klass.respond_to?(:test_connection)
|
|
1306
|
+
existing = config.platform_config(platform) || {}
|
|
1307
|
+
merged = existing.merge(fields)
|
|
1308
|
+
result = klass.test_connection(merged)
|
|
1309
|
+
unless result[:ok]
|
|
1310
|
+
json_response(res, 422, { ok: false, error: result[:error] || "Credential validation failed" })
|
|
1311
|
+
return
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
config.set_platform(platform, **fields)
|
|
1316
|
+
config.save
|
|
1317
|
+
|
|
1318
|
+
# Hot-reload: stop existing adapter for this platform (if running) and restart
|
|
1319
|
+
@channel_manager.reload_platform(platform, config)
|
|
1320
|
+
|
|
1321
|
+
json_response(res, 200, { ok: true })
|
|
1322
|
+
rescue StandardError => e
|
|
1323
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# DELETE /api/channels/:platform
|
|
1327
|
+
# Disables the platform (keeps credentials, sets enabled: false).
|
|
1328
|
+
def api_delete_channel(platform, res)
|
|
1329
|
+
platform = platform.to_sym
|
|
1330
|
+
config = Octo::ChannelConfig.load
|
|
1331
|
+
config.disable_platform(platform)
|
|
1332
|
+
config.save
|
|
1333
|
+
|
|
1334
|
+
@channel_manager.reload_platform(platform, config)
|
|
1335
|
+
|
|
1336
|
+
json_response(res, 200, { ok: true })
|
|
1337
|
+
rescue StandardError => e
|
|
1338
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
# PATCH /api/channels/:platform/enabled
|
|
1342
|
+
# Body: { enabled: true|false }
|
|
1343
|
+
# Toggles the platform on/off without touching credentials.
|
|
1344
|
+
# Enabling requires the platform to already be configured.
|
|
1345
|
+
def api_toggle_channel(platform, req, res)
|
|
1346
|
+
platform = platform.to_sym
|
|
1347
|
+
enabled = parse_json_body(req)["enabled"] == true
|
|
1348
|
+
|
|
1349
|
+
config = Octo::ChannelConfig.load
|
|
1350
|
+
|
|
1351
|
+
if enabled
|
|
1352
|
+
unless config.platform_config(platform)
|
|
1353
|
+
json_response(res, 422, { ok: false, error: "Platform is not configured yet" })
|
|
1354
|
+
return
|
|
1355
|
+
end
|
|
1356
|
+
config.enable_platform(platform)
|
|
1357
|
+
else
|
|
1358
|
+
config.disable_platform(platform)
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
config.save
|
|
1362
|
+
@channel_manager.reload_platform(platform, config)
|
|
1363
|
+
|
|
1364
|
+
json_response(res, 200, { ok: true, enabled: config.enabled?(platform) })
|
|
1365
|
+
rescue StandardError => e
|
|
1366
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
1367
|
+
end
|
|
1368
|
+
|
|
1369
|
+
# POST /api/channels/:platform/test
|
|
1370
|
+
# Body: { fields... } (credentials to test — NOT saved)
|
|
1371
|
+
# Tests connectivity using the provided credentials without persisting.
|
|
1372
|
+
def api_test_channel(platform, req, res)
|
|
1373
|
+
platform = platform.to_sym
|
|
1374
|
+
body = parse_json_body(req)
|
|
1375
|
+
fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
|
|
1376
|
+
|
|
1377
|
+
klass = Octo::Channel::Adapters.find(platform)
|
|
1378
|
+
unless klass
|
|
1379
|
+
json_response(res, 404, { ok: false, error: "Unknown platform: #{platform}" })
|
|
1380
|
+
return
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
result = klass.test_connection(fields)
|
|
1384
|
+
json_response(res, 200, result)
|
|
1385
|
+
rescue StandardError => e
|
|
1386
|
+
json_response(res, 200, { ok: false, error: e.message })
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
# Returns non-secret fields for a platform (masked secrets).
|
|
1390
|
+
private def platform_safe_fields(platform, config)
|
|
1391
|
+
raw = config.instance_variable_get(:@channels)[platform.to_s] || {}
|
|
1392
|
+
case platform.to_sym
|
|
1393
|
+
when :feishu
|
|
1394
|
+
{
|
|
1395
|
+
app_id: raw["app_id"] || "",
|
|
1396
|
+
domain: raw["domain"] || Octo::Channel::Adapters::Feishu::DEFAULT_DOMAIN,
|
|
1397
|
+
allowed_users: raw["allowed_users"] || []
|
|
1398
|
+
}
|
|
1399
|
+
when :wecom
|
|
1400
|
+
{
|
|
1401
|
+
bot_id: raw["bot_id"] || ""
|
|
1402
|
+
}
|
|
1403
|
+
when :weixin
|
|
1404
|
+
{
|
|
1405
|
+
base_url: raw["base_url"] || Octo::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
|
|
1406
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1407
|
+
has_token: !raw["token"].to_s.strip.empty?,
|
|
1408
|
+
token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
|
|
1409
|
+
}
|
|
1410
|
+
when :discord
|
|
1411
|
+
{
|
|
1412
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1413
|
+
has_token: !raw["bot_token"].to_s.strip.empty?,
|
|
1414
|
+
token_updated_at: raw["token_updated_at"]
|
|
1415
|
+
}
|
|
1416
|
+
when :telegram
|
|
1417
|
+
{
|
|
1418
|
+
base_url: raw["base_url"] || Octo::Channel::Adapters::Telegram::ApiClient::DEFAULT_BASE_URL,
|
|
1419
|
+
parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
|
|
1420
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1421
|
+
has_token: !raw["bot_token"].to_s.strip.empty?
|
|
1422
|
+
}
|
|
1423
|
+
when :dingtalk
|
|
1424
|
+
{
|
|
1425
|
+
client_id: raw["client_id"] || "",
|
|
1426
|
+
allowed_users: raw["allowed_users"] || []
|
|
1427
|
+
}
|
|
1428
|
+
else
|
|
1429
|
+
{}
|
|
1430
|
+
end
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
# ── Cron-Tasks API ───────────────────────────────────────────────────────
|
|
1435
|
+
# Unified API that manages task file + schedule as a single resource.
|
|
1436
|
+
|
|
1437
|
+
# GET /api/cron-tasks
|
|
1438
|
+
def api_list_cron_tasks(res)
|
|
1439
|
+
json_response(res, 200, { cron_tasks: @scheduler.list_cron_tasks })
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
# POST /api/cron-tasks — create task file + schedule in one step
|
|
1443
|
+
# Body: { name, content, cron, enabled? }
|
|
1444
|
+
def api_create_cron_task(req, res)
|
|
1445
|
+
body = parse_json_body(req)
|
|
1446
|
+
name = body["name"].to_s.strip
|
|
1447
|
+
content = body["content"].to_s
|
|
1448
|
+
cron = body["cron"].to_s.strip
|
|
1449
|
+
enabled = body.key?("enabled") ? body["enabled"] : true
|
|
1450
|
+
|
|
1451
|
+
return json_response(res, 422, { error: "name is required" }) if name.empty?
|
|
1452
|
+
return json_response(res, 422, { error: "content is required" }) if content.empty?
|
|
1453
|
+
return json_response(res, 422, { error: "cron is required" }) if cron.empty?
|
|
1454
|
+
|
|
1455
|
+
fields = cron.strip.split(/\s+/)
|
|
1456
|
+
unless fields.size == 5
|
|
1457
|
+
return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
@scheduler.create_cron_task(name: name, content: content, cron: cron, enabled: enabled)
|
|
1461
|
+
json_response(res, 201, { ok: true, name: name })
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
# PATCH /api/cron-tasks/:name — update content and/or cron/enabled
|
|
1465
|
+
# Body: { content?, cron?, enabled? }
|
|
1466
|
+
def api_update_cron_task(name, req, res)
|
|
1467
|
+
body = parse_json_body(req)
|
|
1468
|
+
content = body["content"]
|
|
1469
|
+
cron = body["cron"]&.to_s&.strip
|
|
1470
|
+
enabled = body["enabled"]
|
|
1471
|
+
|
|
1472
|
+
if cron && cron.split(/\s+/).size != 5
|
|
1473
|
+
return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
@scheduler.update_cron_task(name, content: content, cron: cron, enabled: enabled)
|
|
1477
|
+
json_response(res, 200, { ok: true, name: name })
|
|
1478
|
+
rescue => e
|
|
1479
|
+
json_response(res, 404, { error: e.message })
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
# DELETE /api/cron-tasks/:name — remove task file + schedule
|
|
1483
|
+
def api_delete_cron_task(name, res)
|
|
1484
|
+
if @scheduler.delete_cron_task(name)
|
|
1485
|
+
json_response(res, 200, { ok: true })
|
|
1486
|
+
else
|
|
1487
|
+
json_response(res, 404, { error: "Cron task not found: #{name}" })
|
|
1488
|
+
end
|
|
1489
|
+
end
|
|
1490
|
+
|
|
1491
|
+
# POST /api/cron-tasks/:name/run — execute immediately
|
|
1492
|
+
def api_run_cron_task(name, res)
|
|
1493
|
+
unless @scheduler.list_tasks.include?(name)
|
|
1494
|
+
return json_response(res, 404, { error: "Cron task not found: #{name}" })
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
prompt = @scheduler.read_task(name)
|
|
1498
|
+
session_name = "▶ #{name} #{Time.now.strftime("%H:%M")}"
|
|
1499
|
+
working_dir = File.expand_path("~/octo_workspace")
|
|
1500
|
+
FileUtils.mkdir_p(working_dir)
|
|
1501
|
+
|
|
1502
|
+
session_id = build_session(name: session_name, working_dir: working_dir, permission_mode: :auto_approve)
|
|
1503
|
+
@registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
|
|
1504
|
+
|
|
1505
|
+
json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
|
|
1506
|
+
rescue => e
|
|
1507
|
+
json_response(res, 422, { error: e.message })
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
# ── Skills API ────────────────────────────────────────────────────────────
|
|
1511
|
+
|
|
1512
|
+
# GET /api/skills — list all loaded skills with metadata
|
|
1513
|
+
def api_list_skills(res)
|
|
1514
|
+
@skill_loader.load_all # refresh from disk on each request
|
|
1515
|
+
|
|
1516
|
+
skills = @skill_loader.all_skills.map do |skill|
|
|
1517
|
+
source = @skill_loader.loaded_from[skill.identifier]
|
|
1518
|
+
|
|
1519
|
+
# Compute local modification time of SKILL.md for "has local changes" indicator
|
|
1520
|
+
skill_md_path = File.join(skill.directory.to_s, "SKILL.md")
|
|
1521
|
+
local_modified_at = File.exist?(skill_md_path) ? File.mtime(skill_md_path).utc.iso8601 : nil
|
|
1522
|
+
|
|
1523
|
+
entry = {
|
|
1524
|
+
name: skill.identifier,
|
|
1525
|
+
name_zh: skill.name_zh,
|
|
1526
|
+
description: skill.context_description,
|
|
1527
|
+
description_zh: skill.description_zh,
|
|
1528
|
+
source: source,
|
|
1529
|
+
enabled: !skill.disabled?,
|
|
1530
|
+
invalid: skill.invalid?,
|
|
1531
|
+
warnings: skill.warnings,
|
|
1532
|
+
local_modified_at: local_modified_at
|
|
1533
|
+
}
|
|
1534
|
+
entry[:invalid_reason] = skill.invalid_reason if skill.invalid?
|
|
1535
|
+
entry
|
|
1536
|
+
end
|
|
1537
|
+
json_response(res, 200, { skills: skills })
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
# GET /api/sessions/:id/skills — list user-invocable skills for a session,
|
|
1541
|
+
# filtered by the session's agent profile. Used by the frontend slash-command
|
|
1542
|
+
# autocomplete so only skills valid for the current profile are suggested.
|
|
1543
|
+
def api_session_skills(session_id, res)
|
|
1544
|
+
unless @registry.ensure(session_id)
|
|
1545
|
+
json_response(res, 404, { error: "Session not found" })
|
|
1546
|
+
return
|
|
1547
|
+
end
|
|
1548
|
+
session = @registry.get(session_id)
|
|
1549
|
+
unless session
|
|
1550
|
+
json_response(res, 404, { error: "Session not found" })
|
|
1551
|
+
return
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
agent = session[:agent]
|
|
1555
|
+
unless agent
|
|
1556
|
+
json_response(res, 404, { error: "Agent not found" })
|
|
1557
|
+
return
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
agent.skill_loader.load_all
|
|
1561
|
+
profile = agent.agent_profile
|
|
1562
|
+
|
|
1563
|
+
skills = agent.skill_loader.user_invocable_skills
|
|
1564
|
+
skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
|
|
1565
|
+
|
|
1566
|
+
loader = agent.skill_loader
|
|
1567
|
+
loaded_from = loader.loaded_from
|
|
1568
|
+
|
|
1569
|
+
skill_data = skills.map do |skill|
|
|
1570
|
+
source_type = loaded_from[skill.identifier]
|
|
1571
|
+
{
|
|
1572
|
+
name: skill.identifier,
|
|
1573
|
+
name_zh: skill.name_zh,
|
|
1574
|
+
description: skill.description || skill.context_description,
|
|
1575
|
+
description_zh: skill.description_zh,
|
|
1576
|
+
source_type: source_type
|
|
1577
|
+
}
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
json_response(res, 200, { skills: skill_data })
|
|
1581
|
+
end
|
|
1582
|
+
|
|
1583
|
+
# PATCH /api/skills/:name/toggle — enable or disable a skill
|
|
1584
|
+
# Body: { enabled: true/false }
|
|
1585
|
+
def api_toggle_skill(name, req, res)
|
|
1586
|
+
body = parse_json_body(req)
|
|
1587
|
+
enabled = body["enabled"]
|
|
1588
|
+
|
|
1589
|
+
if enabled.nil?
|
|
1590
|
+
json_response(res, 422, { error: "enabled field required" })
|
|
1591
|
+
return
|
|
1592
|
+
end
|
|
1593
|
+
|
|
1594
|
+
skill = @skill_loader.toggle_skill(name, enabled: enabled)
|
|
1595
|
+
json_response(res, 200, { ok: true, name: skill.identifier, enabled: !skill.disabled? })
|
|
1596
|
+
rescue Octo::AgentError => e
|
|
1597
|
+
json_response(res, 422, { error: e.message })
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
# POST /api/my-skills/:name/publish
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
# GET /api/trash[?project=<path>]
|
|
1604
|
+
# Lists recently deleted files in the AI trash.
|
|
1605
|
+
#
|
|
1606
|
+
# The trash is organized by project_root; each project gets its own
|
|
1607
|
+
# hashed subdirectory under ~/.octo/trash/ (see TrashDirectory).
|
|
1608
|
+
# Returns ALL projects' deletions by default, with a per-file
|
|
1609
|
+
# project_root field so the UI can group or filter.
|
|
1610
|
+
#
|
|
1611
|
+
# Optional ?project=<absolute-path> restricts to a single project.
|
|
1612
|
+
# Response:
|
|
1613
|
+
# { ok: true,
|
|
1614
|
+
# files: [ { original_path, deleted_at, file_size, file_type,
|
|
1615
|
+
# project_root, project_name, trash_file } ],
|
|
1616
|
+
# projects: [ { project_root, project_name, file_count, total_size } ],
|
|
1617
|
+
# total_count, total_size }
|
|
1618
|
+
private def api_trash(req, res)
|
|
1619
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1620
|
+
filter_project = query["project"].to_s.strip
|
|
1621
|
+
filter_project = nil if filter_project.empty?
|
|
1622
|
+
|
|
1623
|
+
projects =
|
|
1624
|
+
if filter_project
|
|
1625
|
+
[{ project_root: File.expand_path(filter_project),
|
|
1626
|
+
project_name: File.basename(File.expand_path(filter_project)),
|
|
1627
|
+
trash_dir: Octo::TrashDirectory.new(filter_project).trash_dir }]
|
|
1628
|
+
else
|
|
1629
|
+
Octo::TrashDirectory.all_projects
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
all_files = []
|
|
1633
|
+
project_rows = []
|
|
1634
|
+
|
|
1635
|
+
projects.each do |p|
|
|
1636
|
+
files = _trash_files_in(p[:trash_dir], p[:project_root])
|
|
1637
|
+
next if files.empty? && filter_project.nil?
|
|
1638
|
+
|
|
1639
|
+
total_size = files.sum { |f| f[:file_size].to_i }
|
|
1640
|
+
project_rows << {
|
|
1641
|
+
project_root: p[:project_root],
|
|
1642
|
+
project_name: p[:project_name],
|
|
1643
|
+
file_count: files.size,
|
|
1644
|
+
total_size: total_size
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
files.each do |f|
|
|
1648
|
+
all_files << f.merge(
|
|
1649
|
+
project_root: p[:project_root],
|
|
1650
|
+
project_name: p[:project_name]
|
|
1651
|
+
)
|
|
1652
|
+
end
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
all_files.sort_by! { |f| f[:deleted_at].to_s }.reverse!
|
|
1656
|
+
|
|
1657
|
+
json_response(res, 200, {
|
|
1658
|
+
ok: true,
|
|
1659
|
+
files: all_files,
|
|
1660
|
+
projects: project_rows,
|
|
1661
|
+
total_count: all_files.size,
|
|
1662
|
+
total_size: all_files.sum { |f| f[:file_size].to_i }
|
|
1663
|
+
})
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1666
|
+
# POST /api/trash/restore
|
|
1667
|
+
# Body: { project_root: "...", original_path: "..." }
|
|
1668
|
+
# Restores a single file from trash back to its original location.
|
|
1669
|
+
# Refuses if the target already exists on disk.
|
|
1670
|
+
private def api_trash_restore(req, res)
|
|
1671
|
+
data = parse_json_body(req)
|
|
1672
|
+
project_root = data["project_root"].to_s.strip
|
|
1673
|
+
original_path = data["original_path"].to_s.strip
|
|
1674
|
+
|
|
1675
|
+
if project_root.empty? || original_path.empty?
|
|
1676
|
+
json_response(res, 400, { ok: false, error: "project_root and original_path are required" })
|
|
1677
|
+
return
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
tool = Octo::Tools::TrashManager.new
|
|
1681
|
+
result = tool.execute(action: "restore",
|
|
1682
|
+
file_path: original_path,
|
|
1683
|
+
working_dir: project_root)
|
|
1684
|
+
|
|
1685
|
+
if result[:success]
|
|
1686
|
+
json_response(res, 200, { ok: true, restored_file: result[:restored_file], message: result[:message] })
|
|
1687
|
+
else
|
|
1688
|
+
json_response(res, 422, { ok: false, error: result[:message] })
|
|
1689
|
+
end
|
|
1690
|
+
end
|
|
1691
|
+
|
|
1692
|
+
# DELETE /api/trash[?project=<path>][&days_old=<n>][&file=<original_path>]
|
|
1693
|
+
# Three modes:
|
|
1694
|
+
# ?file=<original_path>&project=<root> → permanently delete one file
|
|
1695
|
+
# ?project=<root>[&days_old=0] → empty that project's trash
|
|
1696
|
+
# (no project, days_old required) → empty ALL projects older than N days
|
|
1697
|
+
private def api_trash_delete(req, res)
|
|
1698
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1699
|
+
project_root = query["project"].to_s.strip
|
|
1700
|
+
days_old = query["days_old"].to_s.strip
|
|
1701
|
+
file_path = query["file"].to_s.strip
|
|
1702
|
+
|
|
1703
|
+
project_root = nil if project_root.empty?
|
|
1704
|
+
file_path = nil if file_path.empty?
|
|
1705
|
+
|
|
1706
|
+
# Mode 1: single-file permanent delete
|
|
1707
|
+
if file_path
|
|
1708
|
+
unless project_root
|
|
1709
|
+
json_response(res, 400, { ok: false, error: "project is required when file is given" })
|
|
1710
|
+
return
|
|
1711
|
+
end
|
|
1712
|
+
deleted = _trash_delete_single(project_root, file_path)
|
|
1713
|
+
if deleted
|
|
1714
|
+
json_response(res, 200, { ok: true, deleted_count: 1, freed_size: deleted[:file_size].to_i })
|
|
1715
|
+
else
|
|
1716
|
+
json_response(res, 404, { ok: false, error: "File not found in trash: #{file_path}" })
|
|
1717
|
+
end
|
|
1718
|
+
return
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
# Mode 2 & 3: bulk empty (optionally scoped to one project, optionally by age)
|
|
1722
|
+
days_i = days_old.empty? ? 0 : days_old.to_i
|
|
1723
|
+
tool = Octo::Tools::TrashManager.new
|
|
1724
|
+
|
|
1725
|
+
targets =
|
|
1726
|
+
if project_root
|
|
1727
|
+
[project_root]
|
|
1728
|
+
else
|
|
1729
|
+
Octo::TrashDirectory.all_projects.map { |p| p[:project_root] }
|
|
1730
|
+
end
|
|
1731
|
+
|
|
1732
|
+
total_deleted = 0
|
|
1733
|
+
total_freed = 0
|
|
1734
|
+
targets.each do |root|
|
|
1735
|
+
result = tool.execute(action: "empty", days_old: days_i, working_dir: root)
|
|
1736
|
+
next unless result[:success]
|
|
1737
|
+
total_deleted += result[:deleted_count].to_i
|
|
1738
|
+
total_freed += result[:freed_size].to_i
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1741
|
+
json_response(res, 200, {
|
|
1742
|
+
ok: true,
|
|
1743
|
+
deleted_count: total_deleted,
|
|
1744
|
+
freed_size: total_freed,
|
|
1745
|
+
days_old: days_i
|
|
1746
|
+
})
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
# ── Trash helpers (private) ─────────────────────────────────────
|
|
1750
|
+
# Reads all metadata sidecars in `trash_dir` and returns enriched
|
|
1751
|
+
# file records. Silently skips sidecars whose payload file has
|
|
1752
|
+
# already been purged from disk.
|
|
1753
|
+
private def _trash_files_in(trash_dir, project_root)
|
|
1754
|
+
return [] unless trash_dir && Dir.exist?(trash_dir)
|
|
1755
|
+
|
|
1756
|
+
files = []
|
|
1757
|
+
Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |meta_path|
|
|
1758
|
+
begin
|
|
1759
|
+
meta = JSON.parse(File.read(meta_path))
|
|
1760
|
+
trash = meta_path.sub(/\.metadata\.json\z/, "")
|
|
1761
|
+
next unless File.exist?(trash)
|
|
1762
|
+
files << {
|
|
1763
|
+
original_path: meta["original_path"],
|
|
1764
|
+
deleted_at: meta["deleted_at"],
|
|
1765
|
+
deleted_by: meta["deleted_by"],
|
|
1766
|
+
file_size: meta["file_size"].to_i,
|
|
1767
|
+
file_type: meta["file_type"],
|
|
1768
|
+
file_mode: meta["file_mode"],
|
|
1769
|
+
trash_file: trash
|
|
1770
|
+
}
|
|
1771
|
+
rescue StandardError
|
|
1772
|
+
# Corrupt or partial metadata — skip.
|
|
1773
|
+
end
|
|
1774
|
+
end
|
|
1775
|
+
files
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
# Permanently deletes the single trash entry whose original_path
|
|
1779
|
+
# matches inside `project_root`'s trash. Returns the removed
|
|
1780
|
+
# metadata hash, or nil if not found.
|
|
1781
|
+
private def _trash_delete_single(project_root, original_path)
|
|
1782
|
+
trash_dir = Octo::TrashDirectory.new(project_root).trash_dir
|
|
1783
|
+
expanded = File.expand_path(original_path, project_root)
|
|
1784
|
+
entry = _trash_files_in(trash_dir, project_root).find do |f|
|
|
1785
|
+
f[:original_path] == expanded
|
|
1786
|
+
end
|
|
1787
|
+
return nil unless entry
|
|
1788
|
+
|
|
1789
|
+
File.delete(entry[:trash_file]) if File.exist?(entry[:trash_file])
|
|
1790
|
+
File.delete("#{entry[:trash_file]}.metadata.json") if File.exist?("#{entry[:trash_file]}.metadata.json")
|
|
1791
|
+
entry
|
|
1792
|
+
rescue StandardError
|
|
1793
|
+
nil
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
# ── Profile API (USER.md / SOUL.md) ──────────────────────────────
|
|
1797
|
+
#
|
|
1798
|
+
# User can override the built-in defaults by writing their own
|
|
1799
|
+
# ~/.octo/agents/USER.md and ~/.octo/agents/SOUL.md. These
|
|
1800
|
+
# endpoints let the Web UI read and edit those files.
|
|
1801
|
+
|
|
1802
|
+
PROFILE_USER_AGENTS_DIR = File.expand_path("~/.octo/agents").freeze
|
|
1803
|
+
PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
|
|
1804
|
+
PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
|
|
1805
|
+
|
|
1806
|
+
# GET /api/profile
|
|
1807
|
+
# Returns { ok:, user: { path, content, is_default }, soul: { ... } }
|
|
1808
|
+
private def api_profile_get(res)
|
|
1809
|
+
json_response(res, 200, {
|
|
1810
|
+
ok: true,
|
|
1811
|
+
user: _profile_read_file("USER.md"),
|
|
1812
|
+
soul: _profile_read_file("SOUL.md")
|
|
1813
|
+
})
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
# PUT /api/profile
|
|
1817
|
+
# Body: { kind: "user"|"soul", content: "..." }
|
|
1818
|
+
# Writes the file to ~/.octo/agents/<KIND>.md. Empty content
|
|
1819
|
+
# deletes the override so the built-in default is used again.
|
|
1820
|
+
private def api_profile_put(req, res)
|
|
1821
|
+
data = parse_json_body(req)
|
|
1822
|
+
kind = data["kind"].to_s.downcase
|
|
1823
|
+
content = data["content"].to_s
|
|
1824
|
+
|
|
1825
|
+
filename = case kind
|
|
1826
|
+
when "user" then "USER.md"
|
|
1827
|
+
when "soul" then "SOUL.md"
|
|
1828
|
+
else
|
|
1829
|
+
json_response(res, 400, { ok: false, error: "kind must be 'user' or 'soul'" })
|
|
1830
|
+
return
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
if content.bytesize > PROFILE_MAX_BYTES
|
|
1834
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{PROFILE_MAX_BYTES} bytes)" })
|
|
1835
|
+
return
|
|
1836
|
+
end
|
|
1837
|
+
|
|
1838
|
+
FileUtils.mkdir_p(PROFILE_USER_AGENTS_DIR)
|
|
1839
|
+
target = File.join(PROFILE_USER_AGENTS_DIR, filename)
|
|
1840
|
+
|
|
1841
|
+
# Treat whitespace-only payload as "reset to built-in default":
|
|
1842
|
+
# delete the override file so AgentProfile falls back to default.
|
|
1843
|
+
if content.strip.empty?
|
|
1844
|
+
File.delete(target) if File.exist?(target)
|
|
1845
|
+
json_response(res, 200, { ok: true, reset: true, file: _profile_read_file(filename) })
|
|
1846
|
+
return
|
|
1847
|
+
end
|
|
1848
|
+
|
|
1849
|
+
File.write(target, content)
|
|
1850
|
+
json_response(res, 200, { ok: true, file: _profile_read_file(filename) })
|
|
1851
|
+
rescue StandardError => e
|
|
1852
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1853
|
+
end
|
|
1854
|
+
|
|
1855
|
+
# Read a profile file — user override if present, else built-in default.
|
|
1856
|
+
# Returns { path:, content:, is_default:, writable: }.
|
|
1857
|
+
private def _profile_read_file(filename)
|
|
1858
|
+
user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
|
|
1859
|
+
default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
|
|
1860
|
+
|
|
1861
|
+
if File.exist?(user_path) && !File.zero?(user_path)
|
|
1862
|
+
{
|
|
1863
|
+
path: user_path,
|
|
1864
|
+
content: File.read(user_path),
|
|
1865
|
+
is_default: false
|
|
1866
|
+
}
|
|
1867
|
+
elsif File.exist?(default_path)
|
|
1868
|
+
{
|
|
1869
|
+
path: default_path,
|
|
1870
|
+
content: File.read(default_path),
|
|
1871
|
+
is_default: true
|
|
1872
|
+
}
|
|
1873
|
+
else
|
|
1874
|
+
{
|
|
1875
|
+
path: user_path, # Where it WILL be written
|
|
1876
|
+
content: "",
|
|
1877
|
+
is_default: true
|
|
1878
|
+
}
|
|
1879
|
+
end
|
|
1880
|
+
rescue StandardError => e
|
|
1881
|
+
{ path: "", content: "", is_default: true, error: e.message }
|
|
1882
|
+
end
|
|
1883
|
+
|
|
1884
|
+
# ── Memories API (~/.octo/memories/*.md) ───────────────────────
|
|
1885
|
+
#
|
|
1886
|
+
# Long-term memories are plain Markdown files with YAML frontmatter
|
|
1887
|
+
# stored under ~/.octo/memories/. These endpoints let the user
|
|
1888
|
+
# inspect, edit, create, and delete them from the Web UI.
|
|
1889
|
+
|
|
1890
|
+
MEMORIES_DIR = File.expand_path("~/.octo/memories").freeze
|
|
1891
|
+
MEMORY_MAX_BYTES = 50_000
|
|
1892
|
+
|
|
1893
|
+
# GET /api/memories
|
|
1894
|
+
# Returns { ok:, dir:, memories: [ { filename, topic, description, updated_at, size, preview } ] }
|
|
1895
|
+
# Sorted by updated_at (YAML frontmatter) descending, falling back to file mtime.
|
|
1896
|
+
private def api_memories_list(res)
|
|
1897
|
+
FileUtils.mkdir_p(MEMORIES_DIR)
|
|
1898
|
+
memories = Dir.glob(File.join(MEMORIES_DIR, "*.md")).map do |path|
|
|
1899
|
+
_memory_summary(path)
|
|
1900
|
+
end.compact
|
|
1901
|
+
|
|
1902
|
+
# Sort key: prefer updated_at string (ISO-ish sorts correctly), fall back to mtime.
|
|
1903
|
+
# `mtime` is always present in the summary (ISO 8601), so we use it as the
|
|
1904
|
+
# ultimate tiebreaker. Negate by reversing after sort for descending order.
|
|
1905
|
+
memories.sort_by! do |m|
|
|
1906
|
+
key = m[:updated_at].to_s
|
|
1907
|
+
key = m[:mtime].to_s if key.empty?
|
|
1908
|
+
key
|
|
1909
|
+
end
|
|
1910
|
+
memories.reverse!
|
|
1911
|
+
|
|
1912
|
+
json_response(res, 200, { ok: true, dir: MEMORIES_DIR, memories: memories })
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
# GET /api/memories/:filename
|
|
1916
|
+
# Returns { ok:, filename:, path:, content: }
|
|
1917
|
+
private def api_memories_get(filename, res)
|
|
1918
|
+
safe = _memory_safe_filename(filename)
|
|
1919
|
+
unless safe
|
|
1920
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
1921
|
+
return
|
|
1922
|
+
end
|
|
1923
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
1924
|
+
unless File.exist?(path)
|
|
1925
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
1926
|
+
return
|
|
1927
|
+
end
|
|
1928
|
+
json_response(res, 200, {
|
|
1929
|
+
ok: true,
|
|
1930
|
+
filename: safe,
|
|
1931
|
+
path: path,
|
|
1932
|
+
content: File.read(path)
|
|
1933
|
+
})
|
|
1934
|
+
end
|
|
1935
|
+
|
|
1936
|
+
# POST /api/memories
|
|
1937
|
+
# Body: { filename: "topic.md", content: "..." }
|
|
1938
|
+
# Create a new memory file. Refuses to overwrite existing.
|
|
1939
|
+
private def api_memories_create(req, res)
|
|
1940
|
+
data = parse_json_body(req)
|
|
1941
|
+
filename = _memory_safe_filename(data["filename"].to_s)
|
|
1942
|
+
content = data["content"].to_s
|
|
1943
|
+
|
|
1944
|
+
unless filename
|
|
1945
|
+
json_response(res, 400, { ok: false, error: "Invalid filename (must end in .md, no path separators)" })
|
|
1946
|
+
return
|
|
1947
|
+
end
|
|
1948
|
+
if content.bytesize > MEMORY_MAX_BYTES
|
|
1949
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
|
|
1950
|
+
return
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
FileUtils.mkdir_p(MEMORIES_DIR)
|
|
1954
|
+
path = File.join(MEMORIES_DIR, filename)
|
|
1955
|
+
if File.exist?(path)
|
|
1956
|
+
json_response(res, 409, { ok: false, error: "Memory '#{filename}' already exists" })
|
|
1957
|
+
return
|
|
1958
|
+
end
|
|
1959
|
+
|
|
1960
|
+
File.write(path, content)
|
|
1961
|
+
json_response(res, 201, { ok: true, memory: _memory_summary(path) })
|
|
1962
|
+
rescue StandardError => e
|
|
1963
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
# PUT /api/memories/:filename
|
|
1967
|
+
# Body: { content: "..." }
|
|
1968
|
+
private def api_memories_update(filename, req, res)
|
|
1969
|
+
safe = _memory_safe_filename(filename)
|
|
1970
|
+
unless safe
|
|
1971
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
1972
|
+
return
|
|
1973
|
+
end
|
|
1974
|
+
data = parse_json_body(req)
|
|
1975
|
+
content = data["content"].to_s
|
|
1976
|
+
if content.bytesize > MEMORY_MAX_BYTES
|
|
1977
|
+
json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
|
|
1978
|
+
return
|
|
1979
|
+
end
|
|
1980
|
+
|
|
1981
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
1982
|
+
unless File.exist?(path)
|
|
1983
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
1984
|
+
return
|
|
1985
|
+
end
|
|
1986
|
+
|
|
1987
|
+
File.write(path, content)
|
|
1988
|
+
json_response(res, 200, { ok: true, memory: _memory_summary(path) })
|
|
1989
|
+
rescue StandardError => e
|
|
1990
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1991
|
+
end
|
|
1992
|
+
|
|
1993
|
+
# DELETE /api/memories/:filename
|
|
1994
|
+
private def api_memories_delete(filename, res)
|
|
1995
|
+
safe = _memory_safe_filename(filename)
|
|
1996
|
+
unless safe
|
|
1997
|
+
json_response(res, 400, { ok: false, error: "Invalid filename" })
|
|
1998
|
+
return
|
|
1999
|
+
end
|
|
2000
|
+
path = File.join(MEMORIES_DIR, safe)
|
|
2001
|
+
unless File.exist?(path)
|
|
2002
|
+
json_response(res, 404, { ok: false, error: "Memory not found" })
|
|
2003
|
+
return
|
|
2004
|
+
end
|
|
2005
|
+
File.delete(path)
|
|
2006
|
+
json_response(res, 200, { ok: true, filename: safe })
|
|
2007
|
+
rescue StandardError => e
|
|
2008
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
# Returns nil if the filename is unsafe. Must end in .md, contain
|
|
2012
|
+
# no path separators or shell metacharacters, and be non-empty.
|
|
2013
|
+
private def _memory_safe_filename(name)
|
|
2014
|
+
s = name.to_s.strip
|
|
2015
|
+
return nil if s.empty?
|
|
2016
|
+
return nil if s.include?("/") || s.include?("\\")
|
|
2017
|
+
return nil if s.start_with?(".")
|
|
2018
|
+
return nil unless s.end_with?(".md")
|
|
2019
|
+
return nil unless s.match?(/\A[A-Za-z0-9._\-]+\z/)
|
|
2020
|
+
s
|
|
2021
|
+
end
|
|
2022
|
+
|
|
2023
|
+
# Build a summary record for a memory file. Parses YAML frontmatter
|
|
2024
|
+
# if present; otherwise falls back to filename-derived topic.
|
|
2025
|
+
# Returns nil if the file can't be read.
|
|
2026
|
+
private def _memory_summary(path)
|
|
2027
|
+
content = File.read(path)
|
|
2028
|
+
stat = File.stat(path)
|
|
2029
|
+
|
|
2030
|
+
topic = File.basename(path, ".md")
|
|
2031
|
+
description = ""
|
|
2032
|
+
updated_at = stat.mtime.strftime("%Y-%m-%d")
|
|
2033
|
+
|
|
2034
|
+
# Parse YAML frontmatter: --- ... --- at the top of the file.
|
|
2035
|
+
if content.start_with?("---")
|
|
2036
|
+
if (m = content.match(/\A---\s*\n(.*?)\n---\s*\n/m))
|
|
2037
|
+
begin
|
|
2038
|
+
# permitted_classes: Date so YAML `updated_at: 2026-05-01`
|
|
2039
|
+
# parses to a Date instance instead of raising DisallowedClass.
|
|
2040
|
+
fm = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
|
|
2041
|
+
topic = fm["topic"].to_s unless fm["topic"].to_s.strip.empty?
|
|
2042
|
+
description = fm["description"].to_s
|
|
2043
|
+
updated_at = fm["updated_at"].to_s unless fm["updated_at"].to_s.strip.empty?
|
|
2044
|
+
rescue StandardError
|
|
2045
|
+
# Bad frontmatter — fall back to defaults above.
|
|
2046
|
+
end
|
|
2047
|
+
end
|
|
2048
|
+
end
|
|
2049
|
+
|
|
2050
|
+
preview = content.sub(/\A---.*?---\s*\n/m, "").strip[0, 200]
|
|
2051
|
+
|
|
2052
|
+
{
|
|
2053
|
+
filename: File.basename(path),
|
|
2054
|
+
path: path,
|
|
2055
|
+
topic: topic,
|
|
2056
|
+
description: description,
|
|
2057
|
+
updated_at: updated_at,
|
|
2058
|
+
size: stat.size,
|
|
2059
|
+
mtime: stat.mtime.iso8601,
|
|
2060
|
+
preview: preview
|
|
2061
|
+
}
|
|
2062
|
+
rescue StandardError
|
|
2063
|
+
nil
|
|
2064
|
+
end
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
# ── Config API ────────────────────────────────────────────────────────────
|
|
2069
|
+
|
|
2070
|
+
# GET /api/config — return current model configurations
|
|
2071
|
+
def api_get_config(res)
|
|
2072
|
+
models = @agent_config.models.map.with_index do |m, i|
|
|
2073
|
+
{
|
|
2074
|
+
id: m["id"], # Stable runtime id — use this for switching
|
|
2075
|
+
index: i,
|
|
2076
|
+
model: m["model"],
|
|
2077
|
+
base_url: m["base_url"],
|
|
2078
|
+
api_key_masked: mask_api_key(m["api_key"]),
|
|
2079
|
+
anthropic_format: m["anthropic_format"] || false,
|
|
2080
|
+
type: m["type"]
|
|
2081
|
+
}
|
|
2082
|
+
end
|
|
2083
|
+
# Filter out auto-injected models (like lite) from UI display
|
|
2084
|
+
models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
|
|
2085
|
+
json_response(res, 200, {
|
|
2086
|
+
models: models,
|
|
2087
|
+
current_index: @agent_config.current_model_index,
|
|
2088
|
+
current_id: @agent_config.current_model&.dig("id")
|
|
2089
|
+
})
|
|
2090
|
+
end
|
|
2091
|
+
|
|
2092
|
+
# POST /api/config — save updated model list
|
|
2093
|
+
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
2094
|
+
# and replaced @models in place. That design was fragile — any missing
|
|
2095
|
+
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
2096
|
+
# been removed in favour of single-item RESTful endpoints below:
|
|
2097
|
+
# POST /api/config/models — add one model
|
|
2098
|
+
# PATCH /api/config/models/:id — update one model
|
|
2099
|
+
# DELETE /api/config/models/:id — remove one model
|
|
2100
|
+
# POST /api/config/models/:id/default — set one model as default
|
|
2101
|
+
#
|
|
2102
|
+
# Each handler only touches the single targeted entry, so a bug in any
|
|
2103
|
+
# one call can never corrupt unrelated models. Front-end code must
|
|
2104
|
+
# never send "the whole list" anymore.
|
|
2105
|
+
|
|
2106
|
+
# POST /api/config/models
|
|
2107
|
+
# Body: { model, base_url, api_key, anthropic_format, type? }
|
|
2108
|
+
# Creates a new model entry, returns { ok:true, id, index } so the
|
|
2109
|
+
# frontend can record the new id without reloading the whole list.
|
|
2110
|
+
def api_add_model(req, res)
|
|
2111
|
+
body = parse_json_body(req)
|
|
2112
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2113
|
+
|
|
2114
|
+
model = body["model"].to_s.strip
|
|
2115
|
+
base_url = body["base_url"].to_s.strip
|
|
2116
|
+
api_key = body["api_key"].to_s
|
|
2117
|
+
# Masked placeholders are never a valid api_key on creation —
|
|
2118
|
+
# a new model MUST come with a real key.
|
|
2119
|
+
if api_key.empty? || api_key.include?("****")
|
|
2120
|
+
return json_response(res, 422, { error: "api_key is required" })
|
|
2121
|
+
end
|
|
2122
|
+
|
|
2123
|
+
entry = {
|
|
2124
|
+
"id" => SecureRandom.uuid,
|
|
2125
|
+
"model" => model,
|
|
2126
|
+
"base_url" => base_url,
|
|
2127
|
+
"api_key" => api_key,
|
|
2128
|
+
"anthropic_format" => body["anthropic_format"] || false
|
|
2129
|
+
}
|
|
2130
|
+
type = body["type"].to_s
|
|
2131
|
+
unless type.empty?
|
|
2132
|
+
# Preserve the single-slot "default" invariant.
|
|
2133
|
+
if type == "default"
|
|
2134
|
+
@agent_config.models.each { |m| m.delete("type") if m["type"] == "default" }
|
|
2135
|
+
end
|
|
2136
|
+
entry["type"] = type
|
|
2137
|
+
end
|
|
2138
|
+
|
|
2139
|
+
@agent_config.models << entry
|
|
2140
|
+
# If this is the only model and no default marker exists yet,
|
|
2141
|
+
# adopt it as the default so downstream lookups resolve cleanly.
|
|
2142
|
+
if @agent_config.models.none? { |m| m["type"] == "default" }
|
|
2143
|
+
entry["type"] = "default"
|
|
2144
|
+
@agent_config.current_model_id = entry["id"]
|
|
2145
|
+
@agent_config.current_model_index = @agent_config.models.length - 1
|
|
2146
|
+
elsif type == "default"
|
|
2147
|
+
# Re-anchor current_* to the newly-defaulted entry.
|
|
2148
|
+
@agent_config.current_model_id = entry["id"]
|
|
2149
|
+
@agent_config.current_model_index = @agent_config.models.length - 1
|
|
2150
|
+
end
|
|
2151
|
+
|
|
2152
|
+
@agent_config.save
|
|
2153
|
+
json_response(res, 200, {
|
|
2154
|
+
ok: true,
|
|
2155
|
+
id: entry["id"],
|
|
2156
|
+
index: @agent_config.models.length - 1
|
|
2157
|
+
})
|
|
2158
|
+
rescue => e
|
|
2159
|
+
json_response(res, 422, { error: e.message })
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
# PATCH /api/config/models/:id
|
|
2163
|
+
# Body: any subset of { model, base_url, api_key, anthropic_format, type }
|
|
2164
|
+
# Rules (the whole reason we moved off bulk save):
|
|
2165
|
+
# - Missing key → field untouched
|
|
2166
|
+
# - api_key with "****" (masked display value) → IGNORED (never overwrites)
|
|
2167
|
+
# - api_key empty string → IGNORED (defensive; treat as "not changed")
|
|
2168
|
+
# - api_key real non-masked value → stored
|
|
2169
|
+
# - type="default" transparently clears the marker on other models
|
|
2170
|
+
# - Unknown id → 404
|
|
2171
|
+
def api_update_model(id, req, res)
|
|
2172
|
+
body = parse_json_body(req)
|
|
2173
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2174
|
+
|
|
2175
|
+
target = @agent_config.models.find { |m| m["id"] == id }
|
|
2176
|
+
return json_response(res, 404, { error: "model not found" }) unless target
|
|
2177
|
+
|
|
2178
|
+
if body.key?("model")
|
|
2179
|
+
v = body["model"].to_s.strip
|
|
2180
|
+
target["model"] = v unless v.empty?
|
|
2181
|
+
end
|
|
2182
|
+
if body.key?("base_url")
|
|
2183
|
+
v = body["base_url"].to_s.strip
|
|
2184
|
+
target["base_url"] = v unless v.empty?
|
|
2185
|
+
end
|
|
2186
|
+
if body.key?("anthropic_format")
|
|
2187
|
+
target["anthropic_format"] = !!body["anthropic_format"]
|
|
2188
|
+
end
|
|
2189
|
+
if body.key?("api_key")
|
|
2190
|
+
new_key = body["api_key"].to_s
|
|
2191
|
+
# Only store a real, unmasked, non-empty value. This is the
|
|
2192
|
+
# single place the "api_key disappeared" bug can no longer
|
|
2193
|
+
# happen — there is no path that writes "" into api_key.
|
|
2194
|
+
if !new_key.empty? && !new_key.include?("****")
|
|
2195
|
+
target["api_key"] = new_key
|
|
2196
|
+
end
|
|
2197
|
+
end
|
|
2198
|
+
if body.key?("type")
|
|
2199
|
+
new_type = body["type"]
|
|
2200
|
+
new_type = nil if new_type.is_a?(String) && new_type.strip.empty?
|
|
2201
|
+
if new_type == "default"
|
|
2202
|
+
@agent_config.models.each do |m|
|
|
2203
|
+
next if m["id"] == id
|
|
2204
|
+
m.delete("type") if m["type"] == "default"
|
|
2205
|
+
end
|
|
2206
|
+
target["type"] = "default"
|
|
2207
|
+
@agent_config.current_model_id = target["id"]
|
|
2208
|
+
@agent_config.current_model_index = @agent_config.models.find_index { |m| m["id"] == id } || 0
|
|
2209
|
+
elsif new_type.nil?
|
|
2210
|
+
target.delete("type")
|
|
2211
|
+
else
|
|
2212
|
+
target["type"] = new_type
|
|
2213
|
+
end
|
|
2214
|
+
end
|
|
2215
|
+
|
|
2216
|
+
@agent_config.save
|
|
2217
|
+
json_response(res, 200, { ok: true })
|
|
2218
|
+
rescue => e
|
|
2219
|
+
json_response(res, 422, { error: e.message })
|
|
2220
|
+
end
|
|
2221
|
+
|
|
2222
|
+
# DELETE /api/config/models/:id
|
|
2223
|
+
def api_delete_model(id, res)
|
|
2224
|
+
models = @agent_config.models
|
|
2225
|
+
return json_response(res, 404, { error: "model not found" }) unless models.any? { |m| m["id"] == id }
|
|
2226
|
+
return json_response(res, 422, { error: "cannot delete the last model" }) if models.length <= 1
|
|
2227
|
+
|
|
2228
|
+
index = models.find_index { |m| m["id"] == id }
|
|
2229
|
+
removed = models.delete_at(index)
|
|
2230
|
+
|
|
2231
|
+
# Re-anchor current_* if we just deleted the active model.
|
|
2232
|
+
if @agent_config.current_model_id == removed["id"]
|
|
2233
|
+
new_default = models.find { |m| m["type"] == "default" } || models.first
|
|
2234
|
+
# If the removed model was the default, promote the new current
|
|
2235
|
+
# model so the config always has exactly one default entry.
|
|
2236
|
+
if removed["type"] == "default" && new_default && new_default["type"] != "default"
|
|
2237
|
+
new_default["type"] = "default"
|
|
2238
|
+
end
|
|
2239
|
+
@agent_config.current_model_id = new_default["id"]
|
|
2240
|
+
@agent_config.current_model_index = models.find_index { |m| m["id"] == new_default["id"] } || 0
|
|
2241
|
+
elsif @agent_config.current_model_index >= models.length
|
|
2242
|
+
@agent_config.current_model_index = models.length - 1
|
|
2243
|
+
end
|
|
2244
|
+
|
|
2245
|
+
@agent_config.save
|
|
2246
|
+
json_response(res, 200, { ok: true })
|
|
2247
|
+
rescue => e
|
|
2248
|
+
json_response(res, 422, { error: e.message })
|
|
2249
|
+
end
|
|
2250
|
+
|
|
2251
|
+
# POST /api/config/models/:id/default
|
|
2252
|
+
# Makes the identified model the new "default" (global initial model
|
|
2253
|
+
# for new sessions AND current model for this server instance).
|
|
2254
|
+
def api_set_default_model(id, res)
|
|
2255
|
+
ok = @agent_config.set_default_model_by_id(id)
|
|
2256
|
+
return json_response(res, 404, { error: "model not found" }) unless ok
|
|
2257
|
+
|
|
2258
|
+
@agent_config.current_model_id = id
|
|
2259
|
+
@agent_config.current_model_index = @agent_config.models.find_index { |m| m["id"] == id } || 0
|
|
2260
|
+
@agent_config.save
|
|
2261
|
+
json_response(res, 200, { ok: true })
|
|
2262
|
+
rescue => e
|
|
2263
|
+
json_response(res, 422, { error: e.message })
|
|
2264
|
+
end
|
|
2265
|
+
|
|
2266
|
+
# POST /api/config/test — test connection for a single model config
|
|
2267
|
+
# Body: { model, base_url, api_key, anthropic_format }
|
|
2268
|
+
def api_test_config(req, res)
|
|
2269
|
+
body = parse_json_body(req)
|
|
2270
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2271
|
+
|
|
2272
|
+
api_key = body["api_key"].to_s
|
|
2273
|
+
# If masked, use the stored key from the matching model (by index or current)
|
|
2274
|
+
if api_key.include?("****")
|
|
2275
|
+
idx = body["index"]&.to_i || @agent_config.current_model_index
|
|
2276
|
+
api_key = @agent_config.models.dig(idx, "api_key").to_s
|
|
2277
|
+
end
|
|
2278
|
+
|
|
2279
|
+
begin
|
|
2280
|
+
model = body["model"].to_s
|
|
2281
|
+
test_client = Octo::Client.new(
|
|
2282
|
+
api_key,
|
|
2283
|
+
base_url: body["base_url"].to_s,
|
|
2284
|
+
model: model,
|
|
2285
|
+
anthropic_format: body["anthropic_format"] || false
|
|
2286
|
+
)
|
|
2287
|
+
result = test_client.test_connection(model: model)
|
|
2288
|
+
if result[:success]
|
|
2289
|
+
json_response(res, 200, { ok: true, message: "Connected successfully" })
|
|
2290
|
+
else
|
|
2291
|
+
json_response(res, 200, { ok: false, message: result[:error].to_s })
|
|
2292
|
+
end
|
|
2293
|
+
rescue => e
|
|
2294
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
2295
|
+
end
|
|
2296
|
+
end
|
|
2297
|
+
|
|
2298
|
+
# GET /api/providers — return built-in provider presets for quick setup
|
|
2299
|
+
def api_list_providers(res)
|
|
2300
|
+
providers = Octo::Providers::PRESETS.map do |id, preset|
|
|
2301
|
+
{
|
|
2302
|
+
id: id,
|
|
2303
|
+
name: preset["name"],
|
|
2304
|
+
base_url: preset["base_url"],
|
|
2305
|
+
default_model: preset["default_model"],
|
|
2306
|
+
models: preset["models"] || [],
|
|
2307
|
+
# Frontend uses this to render a Base URL dropdown (regional /
|
|
2308
|
+
# provider variants) when present. Absent for single-endpoint
|
|
2309
|
+
# providers — UI renders a plain text input in that case.
|
|
2310
|
+
endpoint_variants: preset["endpoint_variants"],
|
|
2311
|
+
website_url: preset["website_url"]
|
|
2312
|
+
}
|
|
2313
|
+
end
|
|
2314
|
+
json_response(res, 200, { providers: providers })
|
|
2315
|
+
end
|
|
2316
|
+
|
|
2317
|
+
# GET /api/sessions/:id/messages?limit=20&before=1709123456.789
|
|
2318
|
+
# Replays conversation history for a session via the agent's replay_history method.
|
|
2319
|
+
# Returns a list of UI events (same format as WS events) for the frontend to render.
|
|
2320
|
+
def api_session_messages(session_id, req, res)
|
|
2321
|
+
unless @registry.ensure(session_id)
|
|
2322
|
+
Octo::Logger.warn("[messages] registry.ensure failed", session_id: session_id)
|
|
2323
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2324
|
+
end
|
|
2325
|
+
|
|
2326
|
+
# Parse query params
|
|
2327
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
2328
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 100].min
|
|
2329
|
+
before = query["before"]&.to_f
|
|
2330
|
+
|
|
2331
|
+
agent = nil
|
|
2332
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2333
|
+
|
|
2334
|
+
unless agent
|
|
2335
|
+
Octo::Logger.warn("[messages] agent is nil", session_id: session_id)
|
|
2336
|
+
return json_response(res, 200, { events: [], has_more: false })
|
|
2337
|
+
end
|
|
2338
|
+
|
|
2339
|
+
# Collect events emitted by replay_history via a lightweight collector UI
|
|
2340
|
+
collected = []
|
|
2341
|
+
collector = HistoryCollector.new(session_id, collected)
|
|
2342
|
+
result = agent.replay_history(collector, limit: limit, before: before)
|
|
2343
|
+
|
|
2344
|
+
json_response(res, 200, { events: collected, has_more: result[:has_more] })
|
|
2345
|
+
end
|
|
2346
|
+
|
|
2347
|
+
def api_rename_session(session_id, req, res)
|
|
2348
|
+
body = parse_json_body(req)
|
|
2349
|
+
new_name = body["name"]&.to_s&.strip
|
|
2350
|
+
pinned = body["pinned"]
|
|
2351
|
+
|
|
2352
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2353
|
+
|
|
2354
|
+
agent = nil
|
|
2355
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2356
|
+
|
|
2357
|
+
# Update name if provided
|
|
2358
|
+
if new_name && !new_name.empty?
|
|
2359
|
+
agent.rename(new_name)
|
|
2360
|
+
end
|
|
2361
|
+
|
|
2362
|
+
# Update pinned status if provided
|
|
2363
|
+
if !pinned.nil?
|
|
2364
|
+
agent.pinned = pinned
|
|
2365
|
+
end
|
|
2366
|
+
|
|
2367
|
+
# Save session data
|
|
2368
|
+
@session_manager.save(agent.to_session_data)
|
|
2369
|
+
|
|
2370
|
+
# Broadcast update event
|
|
2371
|
+
update_data = { type: "session_updated", session_id: session_id }
|
|
2372
|
+
update_data[:name] = new_name if new_name && !new_name.empty?
|
|
2373
|
+
update_data[:pinned] = pinned unless pinned.nil?
|
|
2374
|
+
broadcast(session_id, update_data)
|
|
2375
|
+
|
|
2376
|
+
response_data = { ok: true }
|
|
2377
|
+
response_data[:name] = new_name if new_name && !new_name.empty?
|
|
2378
|
+
response_data[:pinned] = pinned unless pinned.nil?
|
|
2379
|
+
json_response(res, 200, response_data)
|
|
2380
|
+
rescue => e
|
|
2381
|
+
json_response(res, 500, { error: e.message })
|
|
2382
|
+
end
|
|
2383
|
+
|
|
2384
|
+
def api_switch_session_model(session_id, req, res)
|
|
2385
|
+
body = parse_json_body(req)
|
|
2386
|
+
model_id = body["model_id"].to_s.strip
|
|
2387
|
+
|
|
2388
|
+
return json_response(res, 400, { error: "model_id is required" }) if model_id.empty?
|
|
2389
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2390
|
+
|
|
2391
|
+
agent = nil
|
|
2392
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2393
|
+
|
|
2394
|
+
# With Plan B (shared @models reference), every session's AgentConfig
|
|
2395
|
+
# points at the same @models array as the global @agent_config. So
|
|
2396
|
+
# resolving the model by stable id here and in agent.switch_model_by_id
|
|
2397
|
+
# will always agree — no more index divergence after add/delete.
|
|
2398
|
+
target_model = @agent_config.models.find { |m| m["id"] == model_id }
|
|
2399
|
+
if target_model.nil?
|
|
2400
|
+
return json_response(res, 400, { error: "Model not found in configuration" })
|
|
2401
|
+
end
|
|
2402
|
+
|
|
2403
|
+
# Switch to the model by id (unified interface with CLI)
|
|
2404
|
+
# Handles: config.switch_model_by_id + client rebuild + message_compressor rebuild
|
|
2405
|
+
success = agent.switch_model_by_id(model_id)
|
|
2406
|
+
|
|
2407
|
+
unless success
|
|
2408
|
+
return json_response(res, 500, { error: "Failed to switch model" })
|
|
2409
|
+
end
|
|
2410
|
+
|
|
2411
|
+
# Persist the change (saves to session file, NOT global config.yml)
|
|
2412
|
+
@session_manager.save(agent.to_session_data)
|
|
2413
|
+
|
|
2414
|
+
# Broadcast update to all clients
|
|
2415
|
+
broadcast_session_update(session_id)
|
|
2416
|
+
|
|
2417
|
+
json_response(res, 200, { ok: true, model_id: model_id, model: target_model["model"] })
|
|
2418
|
+
rescue => e
|
|
2419
|
+
json_response(res, 500, { error: e.message })
|
|
2420
|
+
end
|
|
2421
|
+
|
|
2422
|
+
# PATCH /api/sessions/:id/reasoning_effort
|
|
2423
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
|
|
2424
|
+
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
2425
|
+
body = parse_json_body(req)
|
|
2426
|
+
raw = body["reasoning_effort"]
|
|
2427
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2428
|
+
|
|
2429
|
+
agent = nil
|
|
2430
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2431
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
2432
|
+
|
|
2433
|
+
agent.reasoning_effort = raw
|
|
2434
|
+
@session_manager.save(agent.to_session_data)
|
|
2435
|
+
broadcast_session_update(session_id)
|
|
2436
|
+
|
|
2437
|
+
json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
|
|
2438
|
+
rescue => e
|
|
2439
|
+
json_response(res, 500, { error: e.message })
|
|
2440
|
+
end
|
|
2441
|
+
|
|
2442
|
+
# POST /api/sessions/:id/benchmark
|
|
2443
|
+
#
|
|
2444
|
+
# Speed-test every configured model in one shot so the user can pick the
|
|
2445
|
+
# fastest available model for this session. We send a minimal one-token
|
|
2446
|
+
# request to each model *in parallel* (one thread per model) and measure
|
|
2447
|
+
# total HTTP duration — for non-streaming calls this equals the user's
|
|
2448
|
+
# perceived time-to-first-token, so the field is named `ttft_ms` for
|
|
2449
|
+
# forward-compatibility with a future streaming implementation.
|
|
2450
|
+
#
|
|
2451
|
+
# Cost note: each request is `max_tokens: 1` + a 2-byte prompt, so the
|
|
2452
|
+
# total cost across a dozen models is well under one cent.
|
|
2453
|
+
#
|
|
2454
|
+
# Response shape:
|
|
2455
|
+
# {
|
|
2456
|
+
# ok: true,
|
|
2457
|
+
# results: [
|
|
2458
|
+
# { model_id: "...", model: "...", ttft_ms: 812, ok: true },
|
|
2459
|
+
# { model_id: "...", model: "...", ok: false, error: "timeout" },
|
|
2460
|
+
# ...
|
|
2461
|
+
# ]
|
|
2462
|
+
# }
|
|
2463
|
+
def api_benchmark_session_models(session_id, _req, res)
|
|
2464
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2465
|
+
|
|
2466
|
+
# Snapshot the models list — @agent_config.models is a shared reference
|
|
2467
|
+
# that the user might mutate from the settings panel during the test;
|
|
2468
|
+
# a shallow dup is enough since we only read string fields below.
|
|
2469
|
+
models = Array(@agent_config.models).dup
|
|
2470
|
+
return json_response(res, 200, { ok: true, results: [] }) if models.empty?
|
|
2471
|
+
|
|
2472
|
+
# Kick off one thread per model. We deliberately cap per-request wall
|
|
2473
|
+
# time inside each thread via a Faraday timeout so a single dead model
|
|
2474
|
+
# can't block the response. The outer join uses a generous ceiling
|
|
2475
|
+
# (timeout + small buffer) as a last-resort safety net.
|
|
2476
|
+
per_model_timeout = 15
|
|
2477
|
+
threads = models.map do |m|
|
|
2478
|
+
Thread.new do
|
|
2479
|
+
Thread.current.report_on_exception = false
|
|
2480
|
+
benchmark_single_model(m, per_model_timeout)
|
|
2481
|
+
end
|
|
2482
|
+
end
|
|
2483
|
+
|
|
2484
|
+
results = threads.map do |t|
|
|
2485
|
+
t.join(per_model_timeout + 3)
|
|
2486
|
+
t.value rescue { ok: false, error: "thread failed" }
|
|
2487
|
+
end
|
|
2488
|
+
|
|
2489
|
+
json_response(res, 200, { ok: true, results: results })
|
|
2490
|
+
rescue => e
|
|
2491
|
+
Octo::Logger.error("[benchmark] #{e.class}: #{e.message}", error: e)
|
|
2492
|
+
json_response(res, 500, { error: e.message })
|
|
2493
|
+
end
|
|
2494
|
+
|
|
2495
|
+
# Runs one speed-test request against a single model config hash and
|
|
2496
|
+
# returns a result row for api_benchmark_session_models. Pure function —
|
|
2497
|
+
# no shared state — so it's safe to call from worker threads.
|
|
2498
|
+
private def benchmark_single_model(model_cfg, timeout_sec)
|
|
2499
|
+
model_id = model_cfg["id"].to_s
|
|
2500
|
+
model_name = model_cfg["model"].to_s
|
|
2501
|
+
base = { model_id: model_id, model: model_name }
|
|
2502
|
+
|
|
2503
|
+
client = Octo::Client.new(
|
|
2504
|
+
model_cfg["api_key"].to_s,
|
|
2505
|
+
base_url: model_cfg["base_url"].to_s,
|
|
2506
|
+
model: model_name,
|
|
2507
|
+
anthropic_format: model_cfg["anthropic_format"] || false
|
|
2508
|
+
)
|
|
2509
|
+
|
|
2510
|
+
# Override Faraday timeouts via a short-lived env var isn't ideal;
|
|
2511
|
+
# instead we rely on test_connection's own network path and wrap
|
|
2512
|
+
# the call in Timeout as a last line of defence. Most providers
|
|
2513
|
+
# respond within 2-3s for a 16-token reply.
|
|
2514
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2515
|
+
result = nil
|
|
2516
|
+
begin
|
|
2517
|
+
Timeout.timeout(timeout_sec) { result = client.test_connection(model: model_name) }
|
|
2518
|
+
rescue Timeout::Error
|
|
2519
|
+
return base.merge(ok: false, error: "timeout after #{timeout_sec}s")
|
|
2520
|
+
end
|
|
2521
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2522
|
+
|
|
2523
|
+
if result && result[:success]
|
|
2524
|
+
base.merge(ok: true, ttft_ms: ((t1 - t0) * 1000).round)
|
|
2525
|
+
else
|
|
2526
|
+
base.merge(ok: false, error: (result && result[:error]).to_s[0, 200])
|
|
2527
|
+
end
|
|
2528
|
+
rescue => e
|
|
2529
|
+
base.merge(ok: false, error: "#{e.class}: #{e.message}"[0, 200])
|
|
2530
|
+
end
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def api_change_session_working_dir(session_id, req, res)
|
|
2534
|
+
body = parse_json_body(req)
|
|
2535
|
+
new_dir = body["working_dir"].to_s.strip
|
|
2536
|
+
|
|
2537
|
+
return json_response(res, 400, { error: "working_dir is required" }) if new_dir.empty?
|
|
2538
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2539
|
+
|
|
2540
|
+
# Expand ~ to home directory
|
|
2541
|
+
expanded_dir = File.expand_path(new_dir)
|
|
2542
|
+
|
|
2543
|
+
# Validate directory exists
|
|
2544
|
+
unless Dir.exist?(expanded_dir)
|
|
2545
|
+
return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
|
|
2546
|
+
end
|
|
2547
|
+
|
|
2548
|
+
agent = nil
|
|
2549
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2550
|
+
|
|
2551
|
+
# Change the agent's working directory
|
|
2552
|
+
agent.change_working_dir(expanded_dir)
|
|
2553
|
+
|
|
2554
|
+
# Persist the change
|
|
2555
|
+
@session_manager.save(agent.to_session_data)
|
|
2556
|
+
|
|
2557
|
+
# Broadcast update to all clients
|
|
2558
|
+
broadcast_session_update(session_id)
|
|
2559
|
+
|
|
2560
|
+
json_response(res, 200, { ok: true, working_dir: expanded_dir })
|
|
2561
|
+
rescue => e
|
|
2562
|
+
json_response(res, 500, { error: e.message })
|
|
2563
|
+
end
|
|
2564
|
+
|
|
2565
|
+
def api_delete_session(session_id, res)
|
|
2566
|
+
# A session exists if it's either in the runtime registry OR on disk.
|
|
2567
|
+
# Old sessions that were never restored into memory this server run
|
|
2568
|
+
# (e.g. shown via "load more" in the WebUI list) are disk-only — we
|
|
2569
|
+
# must still be able to delete them. Previously this endpoint only
|
|
2570
|
+
# consulted @registry and returned 404 for disk-only sessions,
|
|
2571
|
+
# causing the "can't delete old sessions" bug.
|
|
2572
|
+
in_registry = @registry.exist?(session_id)
|
|
2573
|
+
on_disk = !@session_manager.load(session_id).nil?
|
|
2574
|
+
|
|
2575
|
+
unless in_registry || on_disk
|
|
2576
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2577
|
+
end
|
|
2578
|
+
|
|
2579
|
+
# Registry delete is best-effort — only meaningful when the session
|
|
2580
|
+
# is actually live (cancels idle timer, interrupts the agent thread).
|
|
2581
|
+
# For disk-only sessions this is a no-op and returns false, which is
|
|
2582
|
+
# fine and no longer blocks the disk cleanup below.
|
|
2583
|
+
@registry.delete(session_id) if in_registry
|
|
2584
|
+
|
|
2585
|
+
# Always physically remove the persisted session file (+ chunks).
|
|
2586
|
+
@session_manager.delete(session_id) if on_disk
|
|
2587
|
+
|
|
2588
|
+
# Notify any still-connected clients (mainly matters when the
|
|
2589
|
+
# session was live, but harmless otherwise).
|
|
2590
|
+
broadcast(session_id, { type: "session_deleted", session_id: session_id })
|
|
2591
|
+
unsubscribe_all(session_id)
|
|
2592
|
+
|
|
2593
|
+
json_response(res, 200, { ok: true })
|
|
2594
|
+
end
|
|
2595
|
+
|
|
2596
|
+
# Export a session bundle as a .zip download containing:
|
|
2597
|
+
# - session.json (always)
|
|
2598
|
+
# - chunk-*.md (0..N archived conversation chunks)
|
|
2599
|
+
# - logs/octo-YYYY-MM-DD.log (today's logger file, if present)
|
|
2600
|
+
# Useful for debugging — user clicks "download" in the WebUI status bar
|
|
2601
|
+
# and we can ask them to attach the zip to a bug report.
|
|
2602
|
+
def api_export_session(session_id, res)
|
|
2603
|
+
bundle = @session_manager.files_for(session_id)
|
|
2604
|
+
unless bundle
|
|
2605
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2606
|
+
end
|
|
2607
|
+
|
|
2608
|
+
require "zip"
|
|
2609
|
+
|
|
2610
|
+
short_id = bundle[:session][:session_id].to_s[0..7]
|
|
2611
|
+
# Build the zip entirely in memory — session files are small (< few MB).
|
|
2612
|
+
buffer = Zip::OutputStream.write_buffer do |zos|
|
|
2613
|
+
zos.put_next_entry("session.json")
|
|
2614
|
+
zos.write(File.binread(bundle[:json_path]))
|
|
2615
|
+
|
|
2616
|
+
bundle[:chunks].each do |chunk_path|
|
|
2617
|
+
# Preserve original chunk filename so the ordering (chunk-1.md, chunk-2.md, ...) is clear.
|
|
2618
|
+
zos.put_next_entry(File.basename(chunk_path))
|
|
2619
|
+
zos.write(File.binread(chunk_path))
|
|
2620
|
+
end
|
|
2621
|
+
|
|
2622
|
+
log_path = Octo::Logger.current_log_file
|
|
2623
|
+
if log_path && File.exist?(log_path)
|
|
2624
|
+
zos.put_next_entry("logs/#{File.basename(log_path)}")
|
|
2625
|
+
zos.write(File.binread(log_path))
|
|
2626
|
+
end
|
|
2627
|
+
end
|
|
2628
|
+
buffer.rewind
|
|
2629
|
+
data = buffer.read
|
|
2630
|
+
|
|
2631
|
+
filename = "octo-session-#{short_id}.zip"
|
|
2632
|
+
res.status = 200
|
|
2633
|
+
res.content_type = "application/zip"
|
|
2634
|
+
res["Content-Disposition"] = %(attachment; filename="#{filename}")
|
|
2635
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
2636
|
+
# Force a fresh copy each time — debugging sessions get new chunks over time.
|
|
2637
|
+
res["Cache-Control"] = "no-store"
|
|
2638
|
+
res.body = data
|
|
2639
|
+
rescue => e
|
|
2640
|
+
Octo::Logger.error("Session export failed: #{e.message}") if defined?(Octo::Logger)
|
|
2641
|
+
json_response(res, 500, { error: "Export failed: #{e.message}" })
|
|
2642
|
+
end
|
|
2643
|
+
|
|
2644
|
+
# ── WebSocket ─────────────────────────────────────────────────────────────
|
|
2645
|
+
|
|
2646
|
+
def websocket_upgrade?(req)
|
|
2647
|
+
req["Upgrade"]&.downcase == "websocket"
|
|
2648
|
+
end
|
|
2649
|
+
|
|
2650
|
+
# Hijacks the TCP socket from WEBrick and upgrades it to WebSocket.
|
|
2651
|
+
def handle_websocket(req, res)
|
|
2652
|
+
socket = req.instance_variable_get(:@socket)
|
|
2653
|
+
|
|
2654
|
+
# Server handshake — parse the upgrade request
|
|
2655
|
+
handshake = WebSocket::Handshake::Server.new
|
|
2656
|
+
handshake << build_handshake_request(req)
|
|
2657
|
+
unless handshake.finished? && handshake.valid?
|
|
2658
|
+
Octo::Logger.warn("WebSocket handshake invalid")
|
|
2659
|
+
return
|
|
2660
|
+
end
|
|
2661
|
+
|
|
2662
|
+
# Send the 101 Switching Protocols response
|
|
2663
|
+
socket.write(handshake.to_s)
|
|
2664
|
+
|
|
2665
|
+
version = handshake.version
|
|
2666
|
+
incoming = WebSocket::Frame::Incoming::Server.new(version: version)
|
|
2667
|
+
conn = WebSocketConnection.new(socket, version)
|
|
2668
|
+
|
|
2669
|
+
on_ws_open(conn)
|
|
2670
|
+
|
|
2671
|
+
begin
|
|
2672
|
+
buf = String.new("", encoding: "BINARY")
|
|
2673
|
+
loop do
|
|
2674
|
+
chunk = socket.read_nonblock(4096, buf, exception: false)
|
|
2675
|
+
case chunk
|
|
2676
|
+
when :wait_readable
|
|
2677
|
+
IO.select([socket], nil, nil, 30)
|
|
2678
|
+
when nil
|
|
2679
|
+
break # EOF
|
|
2680
|
+
else
|
|
2681
|
+
incoming << chunk.dup
|
|
2682
|
+
while (frame = incoming.next)
|
|
2683
|
+
case frame.type
|
|
2684
|
+
when :text
|
|
2685
|
+
on_ws_message(conn, frame.data)
|
|
2686
|
+
when :binary
|
|
2687
|
+
on_ws_message(conn, frame.data)
|
|
2688
|
+
when :ping
|
|
2689
|
+
conn.send_raw(:pong, frame.data)
|
|
2690
|
+
when :close
|
|
2691
|
+
conn.send_raw(:close, "")
|
|
2692
|
+
break
|
|
2693
|
+
end
|
|
2694
|
+
end
|
|
2695
|
+
end
|
|
2696
|
+
end
|
|
2697
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
|
|
2698
|
+
# Client disconnected or socket became invalid
|
|
2699
|
+
ensure
|
|
2700
|
+
on_ws_close(conn)
|
|
2701
|
+
socket.close rescue nil
|
|
2702
|
+
end
|
|
2703
|
+
|
|
2704
|
+
# Tell WEBrick not to send any response (we handled everything)
|
|
2705
|
+
res.instance_variable_set(:@header, {})
|
|
2706
|
+
res.status = -1
|
|
2707
|
+
rescue => e
|
|
2708
|
+
Octo::Logger.error("WebSocket handler error: #{e.class}: #{e.message}")
|
|
2709
|
+
end
|
|
2710
|
+
|
|
2711
|
+
# Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
|
|
2712
|
+
private def build_handshake_request(req)
|
|
2713
|
+
lines = ["#{req.request_method} #{req.request_uri.request_uri} HTTP/1.1\r\n"]
|
|
2714
|
+
req.each { |k, v| lines << "#{k}: #{v}\r\n" }
|
|
2715
|
+
lines << "\r\n"
|
|
2716
|
+
lines.join
|
|
2717
|
+
end
|
|
2718
|
+
|
|
2719
|
+
def on_ws_open(conn)
|
|
2720
|
+
@ws_mutex.synchronize { @all_ws_conns << conn }
|
|
2721
|
+
# Client will send a "subscribe" message to bind to a session
|
|
2722
|
+
end
|
|
2723
|
+
|
|
2724
|
+
def on_ws_message(conn, raw)
|
|
2725
|
+
msg = JSON.parse(raw)
|
|
2726
|
+
unless msg.is_a?(Hash)
|
|
2727
|
+
Octo::Logger.warn("[on_ws_message] Ignoring non-Hash message: #{raw[0,200].inspect}")
|
|
2728
|
+
return
|
|
2729
|
+
end
|
|
2730
|
+
type = msg["type"]
|
|
2731
|
+
|
|
2732
|
+
case type
|
|
2733
|
+
when "subscribe"
|
|
2734
|
+
session_id = msg["session_id"]
|
|
2735
|
+
if @registry.ensure(session_id)
|
|
2736
|
+
conn.session_id = session_id
|
|
2737
|
+
subscribe(session_id, conn)
|
|
2738
|
+
conn.send_json(type: "subscribed", session_id: session_id)
|
|
2739
|
+
# Push a fresh snapshot so a reconnecting tab sees the true current
|
|
2740
|
+
# status (it may have missed session_update events while offline).
|
|
2741
|
+
if (snap = @registry.snapshot(session_id))
|
|
2742
|
+
conn.send_json(type: "session_update", session: snap)
|
|
2743
|
+
end
|
|
2744
|
+
# If a shell command is still running, replay progress + buffered stdout
|
|
2745
|
+
# to the newly subscribed tab so it sees the live state it may have missed.
|
|
2746
|
+
@registry.with_session(session_id) { |s| s[:ui]&.replay_live_state }
|
|
2747
|
+
# Replay inbox queue status AND pending message content so the
|
|
2748
|
+
# reconnected tab sees both the count hint AND the pending
|
|
2749
|
+
# ghost bubbles for messages still waiting to be drained.
|
|
2750
|
+
@registry.with_session(session_id) do |s|
|
|
2751
|
+
if (agent = s[:agent])
|
|
2752
|
+
pending = agent.inbox_user_message_count
|
|
2753
|
+
if pending > 0
|
|
2754
|
+
s[:ui]&.update_user_message_queue_status(pending: pending)
|
|
2755
|
+
conn.send_json({
|
|
2756
|
+
type: "pending_user_messages",
|
|
2757
|
+
session_id: session_id,
|
|
2758
|
+
messages: agent.inbox_user_messages_snapshot
|
|
2759
|
+
})
|
|
2760
|
+
end
|
|
2761
|
+
end
|
|
2762
|
+
end
|
|
2763
|
+
# Push the current background-task badge so a refreshed tab doesn't
|
|
2764
|
+
# lose track of in-flight async tasks.
|
|
2765
|
+
_push_background_tasks_snapshot(session_id, conn)
|
|
2766
|
+
else
|
|
2767
|
+
conn.send_json(type: "error", message: "Session not found: #{session_id}")
|
|
2768
|
+
end
|
|
2769
|
+
|
|
2770
|
+
when "message"
|
|
2771
|
+
session_id = msg["session_id"] || conn.session_id
|
|
2772
|
+
# Merge legacy images array into files as { data_url:, name:, mime_type: } entries
|
|
2773
|
+
raw_images = (msg["images"] || []).map do |data_url|
|
|
2774
|
+
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
2775
|
+
end
|
|
2776
|
+
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images)
|
|
2777
|
+
|
|
2778
|
+
when "confirmation"
|
|
2779
|
+
session_id = msg["session_id"] || conn.session_id
|
|
2780
|
+
deliver_confirmation(session_id, msg["id"], msg["result"])
|
|
2781
|
+
|
|
2782
|
+
when "interrupt"
|
|
2783
|
+
session_id = msg["session_id"] || conn.session_id
|
|
2784
|
+
interrupt_session(session_id)
|
|
2785
|
+
|
|
2786
|
+
when "list_sessions"
|
|
2787
|
+
# Initial load: newest 20 sessions regardless of source/profile.
|
|
2788
|
+
# Single unified query — frontend shows all in one time-sorted list.
|
|
2789
|
+
page = @registry.list(limit: 21)
|
|
2790
|
+
has_more = page.size > 20
|
|
2791
|
+
all_sessions = page.first(20)
|
|
2792
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, cron_count: @registry.cron_count)
|
|
2793
|
+
|
|
2794
|
+
when "run_task"
|
|
2795
|
+
# Client sends this after subscribing to guarantee it's ready to receive
|
|
2796
|
+
# broadcasts before the agent starts executing.
|
|
2797
|
+
session_id = msg["session_id"] || conn.session_id
|
|
2798
|
+
start_pending_task(session_id)
|
|
2799
|
+
|
|
2800
|
+
when "ping"
|
|
2801
|
+
conn.send_json(type: "pong")
|
|
2802
|
+
|
|
2803
|
+
else
|
|
2804
|
+
conn.send_json(type: "error", message: "Unknown message type: #{type}")
|
|
2805
|
+
end
|
|
2806
|
+
rescue JSON::ParserError => e
|
|
2807
|
+
conn.send_json(type: "error", message: "Invalid JSON: #{e.message}")
|
|
2808
|
+
rescue => e
|
|
2809
|
+
Octo::Logger.error("[on_ws_message] #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
|
|
2810
|
+
conn.send_json(type: "error", message: e.message)
|
|
2811
|
+
end
|
|
2812
|
+
|
|
2813
|
+
def on_ws_close(conn)
|
|
2814
|
+
@ws_mutex.synchronize { @all_ws_conns.delete(conn) }
|
|
2815
|
+
unsubscribe(conn)
|
|
2816
|
+
end
|
|
2817
|
+
|
|
2818
|
+
# ── Session actions ───────────────────────────────────────────────────────
|
|
2819
|
+
|
|
2820
|
+
def handle_user_message(session_id, content, files = [])
|
|
2821
|
+
return unless @registry.exist?(session_id)
|
|
2822
|
+
|
|
2823
|
+
agent = nil
|
|
2824
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2825
|
+
return unless agent
|
|
2826
|
+
|
|
2827
|
+
# Auto-name the session from the first user message (before agent starts running).
|
|
2828
|
+
# Skip if the name looks like it was set by the user (not a system-generated "Session N").
|
|
2829
|
+
if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
|
|
2830
|
+
auto_name = content.gsub(/\s+/, " ").strip[0, 30]
|
|
2831
|
+
auto_name += "…" if content.strip.length > 30
|
|
2832
|
+
agent.rename(auto_name)
|
|
2833
|
+
broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
|
|
2834
|
+
end
|
|
2835
|
+
|
|
2836
|
+
# The frontend always renders a ghost bubble on send. The real
|
|
2837
|
+
# bubble is rendered by the agent when it drains the inbox —
|
|
2838
|
+
# this avoids duplicate bubbles for idle agents.
|
|
2839
|
+
|
|
2840
|
+
# Enqueue — files (if any) are processed eagerly on this HTTP thread
|
|
2841
|
+
# inside enqueue_user_message, so the inbox carries a fully-formed
|
|
2842
|
+
# payload by the time it lands. The agent decides whether to drain
|
|
2843
|
+
# in an in-flight run or spawn a fresh drain-only one.
|
|
2844
|
+
decision = agent.enqueue_user_message(content, files: files)
|
|
2845
|
+
case decision
|
|
2846
|
+
when :running, :spawn_pending
|
|
2847
|
+
# Existing or imminent run will drain the inbox at its next
|
|
2848
|
+
# iteration. Nothing more to do here.
|
|
2849
|
+
return
|
|
2850
|
+
when :spawn
|
|
2851
|
+
# Agent was idle and we won the right to spawn. Kick off a
|
|
2852
|
+
# drain-only run; it will pick up our queued message (and any
|
|
2853
|
+
# other items queued concurrently) at iteration top.
|
|
2854
|
+
run_agent_task(session_id, agent) { agent.run }
|
|
2855
|
+
end
|
|
2856
|
+
end
|
|
2857
|
+
|
|
2858
|
+
def deliver_confirmation(session_id, conf_id, result)
|
|
2859
|
+
ui = nil
|
|
2860
|
+
@registry.with_session(session_id) { |s| ui = s[:ui] }
|
|
2861
|
+
ui&.deliver_confirmation(conf_id, result)
|
|
2862
|
+
end
|
|
2863
|
+
|
|
2864
|
+
# Push a fresh background-tasks snapshot to a newly subscribed WS
|
|
2865
|
+
# client so the badge survives page refresh. Mirrors the format
|
|
2866
|
+
# produced by WebUiController#update_background_tasks.
|
|
2867
|
+
private def _push_background_tasks_snapshot(session_id, conn)
|
|
2868
|
+
now = Time.now
|
|
2869
|
+
tasks = BackgroundTaskRegistry.list_running(agent_session_id: session_id).map do |t|
|
|
2870
|
+
cmd = (t[:command] || "").to_s
|
|
2871
|
+
cmd = "#{cmd[0, 80]}…" if cmd.length > 80
|
|
2872
|
+
elapsed = t[:started_at] ? (now - t[:started_at]).round : 0
|
|
2873
|
+
{ handle_id: t[:handle_id].to_s, command: cmd, elapsed: elapsed }
|
|
2874
|
+
end
|
|
2875
|
+
conn.send_json(type: "background_tasks_update", session_id: session_id, running: tasks.size, tasks: tasks)
|
|
2876
|
+
rescue => e
|
|
2877
|
+
Octo::Logger.warn("[WS] background_tasks_snapshot error: #{e.message}")
|
|
2878
|
+
end
|
|
2879
|
+
|
|
2880
|
+
# Interrupt a running agent session.
|
|
2881
|
+
#
|
|
2882
|
+
# Thread#raise alone is not reliable enough in practice — it's
|
|
2883
|
+
# best-effort against blocked syscalls (socket writes, OpenSSL read,
|
|
2884
|
+
# ConditionVariable#wait with a held mutex) and we've seen sessions
|
|
2885
|
+
# that stay "running" forever even after multiple interrupt attempts.
|
|
2886
|
+
#
|
|
2887
|
+
# Strategy: three-tier escalation in a background watchdog Thread so
|
|
2888
|
+
# the HTTP handler returns immediately.
|
|
2889
|
+
#
|
|
2890
|
+
# Tier 1 (t=0): Thread#raise(AgentInterrupted).
|
|
2891
|
+
# Unblocks most pure-Ruby waits and Faraday reads.
|
|
2892
|
+
# Handles the common case.
|
|
2893
|
+
# Tier 2 (t=3): force-close this session's WebSocket connections
|
|
2894
|
+
# so any send_raw stuck on socket write wakes up.
|
|
2895
|
+
# Try Thread#raise again (idempotent).
|
|
2896
|
+
# Tier 3 (t=8): Thread#kill — last resort. Leaks any held
|
|
2897
|
+
# resources but frees the session so the user can
|
|
2898
|
+
# move on.
|
|
2899
|
+
#
|
|
2900
|
+
# Each transition is logged so that when users report "stuck
|
|
2901
|
+
# sessions" we can see in the log whether tier 2/3 ever had to
|
|
2902
|
+
# fire — that's our signal to dig deeper on the underlying block.
|
|
2903
|
+
def interrupt_session(session_id)
|
|
2904
|
+
thread = nil
|
|
2905
|
+
agent = nil
|
|
2906
|
+
@registry.with_session(session_id) do |s|
|
|
2907
|
+
s[:idle_timer]&.cancel
|
|
2908
|
+
thread = s[:thread]
|
|
2909
|
+
agent = s[:agent]
|
|
2910
|
+
|
|
2911
|
+
if thread&.alive?
|
|
2912
|
+
Octo::Logger.info("[interrupt] session=#{session_id} tier=1 raise")
|
|
2913
|
+
begin
|
|
2914
|
+
thread.raise(Octo::AgentInterrupted, "Interrupted by user")
|
|
2915
|
+
rescue ThreadError => e
|
|
2916
|
+
Octo::Logger.warn("[interrupt] tier=1 raise failed: #{e.message}")
|
|
2917
|
+
end
|
|
2918
|
+
end
|
|
2919
|
+
end
|
|
2920
|
+
|
|
2921
|
+
# Also set @discard_threshold + raise into Agent's tracked run thread.
|
|
2922
|
+
# This covers drain-only inbox runs that may bypass session[:thread].
|
|
2923
|
+
agent&.interrupt_current_run!
|
|
2924
|
+
|
|
2925
|
+
return unless thread&.alive?
|
|
2926
|
+
|
|
2927
|
+
start_interrupt_watchdog(session_id, thread)
|
|
2928
|
+
end
|
|
2929
|
+
|
|
2930
|
+
# Background watchdog: escalates from WebSocket force-close (tier 2)
|
|
2931
|
+
# to Thread#kill (tier 3) if the agent thread refuses to die.
|
|
2932
|
+
private def start_interrupt_watchdog(session_id, thread)
|
|
2933
|
+
Thread.new do
|
|
2934
|
+
Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
|
|
2935
|
+
|
|
2936
|
+
# Give the first Thread#raise a few seconds to unwind.
|
|
2937
|
+
sleep 3
|
|
2938
|
+
next unless thread.alive?
|
|
2939
|
+
|
|
2940
|
+
Octo::Logger.warn(
|
|
2941
|
+
"[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
|
|
2942
|
+
"force-closing session resources"
|
|
2943
|
+
)
|
|
2944
|
+
force_close_session_sockets(session_id)
|
|
2945
|
+
# Re-raise — sometimes the first raise was swallowed deep in a
|
|
2946
|
+
# C-extension syscall; after we force-close the socket the
|
|
2947
|
+
# syscall returns and the next raise sticks.
|
|
2948
|
+
begin
|
|
2949
|
+
thread.raise(Octo::AgentInterrupted, "Interrupted by user (escalated)")
|
|
2950
|
+
rescue ThreadError
|
|
2951
|
+
# already dead between checks — fine
|
|
2952
|
+
end
|
|
2953
|
+
|
|
2954
|
+
sleep 5
|
|
2955
|
+
next unless thread.alive?
|
|
2956
|
+
|
|
2957
|
+
Octo::Logger.error(
|
|
2958
|
+
"[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
|
|
2959
|
+
)
|
|
2960
|
+
begin
|
|
2961
|
+
thread.kill
|
|
2962
|
+
rescue StandardError => e
|
|
2963
|
+
Octo::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
|
|
2964
|
+
end
|
|
2965
|
+
|
|
2966
|
+
# Record the forced-kill so the UI can show a warning and operators
|
|
2967
|
+
# can correlate with any backtrace dumps. The session is left in
|
|
2968
|
+
# :idle state by run_agent_task's rescue clause; if the kill
|
|
2969
|
+
# happened before the rescue could run, patch the state directly.
|
|
2970
|
+
begin
|
|
2971
|
+
@registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
|
|
2972
|
+
broadcast_session_update(session_id)
|
|
2973
|
+
rescue StandardError
|
|
2974
|
+
# best effort
|
|
2975
|
+
end
|
|
2976
|
+
end
|
|
2977
|
+
end
|
|
2978
|
+
|
|
2979
|
+
# Close every WebSocket connection bound to the given session. Used by
|
|
2980
|
+
# the interrupt watchdog to unblock agent threads stuck in a WS write.
|
|
2981
|
+
private def force_close_session_sockets(session_id)
|
|
2982
|
+
conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
2983
|
+
conns.each do |conn|
|
|
2984
|
+
Octo::Logger.warn(
|
|
2985
|
+
"[interrupt] session=#{session_id} force-closing WS conn"
|
|
2986
|
+
)
|
|
2987
|
+
conn.force_close!
|
|
2988
|
+
end
|
|
2989
|
+
rescue StandardError => e
|
|
2990
|
+
Octo::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
2991
|
+
end
|
|
2992
|
+
|
|
2993
|
+
# Start the pending task for a session.
|
|
2994
|
+
# Called when the client sends "run_task" over WS — by that point the
|
|
2995
|
+
# client has already subscribed, so every broadcast will be delivered.
|
|
2996
|
+
def start_pending_task(session_id)
|
|
2997
|
+
return unless @registry.exist?(session_id)
|
|
2998
|
+
|
|
2999
|
+
session = @registry.get(session_id)
|
|
3000
|
+
prompt = session[:pending_task]
|
|
3001
|
+
working_dir = session[:pending_working_dir]
|
|
3002
|
+
return unless prompt # nothing pending
|
|
3003
|
+
|
|
3004
|
+
# Clear the pending fields so a re-connect doesn't re-run
|
|
3005
|
+
@registry.update(session_id, pending_task: nil, pending_working_dir: nil)
|
|
3006
|
+
|
|
3007
|
+
agent = nil
|
|
3008
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
3009
|
+
return unless agent
|
|
3010
|
+
|
|
3011
|
+
run_agent_task(session_id, agent) { agent.run(prompt) }
|
|
3012
|
+
end
|
|
3013
|
+
|
|
3014
|
+
# Interrupt every running agent thread and persist its session state.
|
|
3015
|
+
private def interrupt_all_agents
|
|
3016
|
+
return unless @registry && @session_manager
|
|
3017
|
+
|
|
3018
|
+
@registry.each_live_agent do |id, agent, thread|
|
|
3019
|
+
next unless thread&.alive?
|
|
3020
|
+
begin
|
|
3021
|
+
thread.raise(Octo::AgentInterrupted, "Worker shutting down")
|
|
3022
|
+
Octo::Logger.info("[shutdown] interrupted session=#{id}")
|
|
3023
|
+
rescue => e
|
|
3024
|
+
Octo::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
|
|
3025
|
+
end
|
|
3026
|
+
thread.join(2)
|
|
3027
|
+
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
3028
|
+
end
|
|
3029
|
+
end
|
|
3030
|
+
|
|
3031
|
+
# Run an agent task in a background thread, handling status updates,
|
|
3032
|
+
# session persistence, and idle compression timer lifecycle.
|
|
3033
|
+
# Yields to the caller to perform the actual agent.run call.
|
|
3034
|
+
private def run_agent_task(session_id, agent, &task)
|
|
3035
|
+
if @registry.running_full?
|
|
3036
|
+
broadcast(session_id, { type: "error", session_id: session_id,
|
|
3037
|
+
message: "Too many concurrent tasks (max #{@registry.max_running_agents}), please try again later" })
|
|
3038
|
+
return
|
|
3039
|
+
end
|
|
3040
|
+
|
|
3041
|
+
@registry.evict_excess_idle!
|
|
3042
|
+
|
|
3043
|
+
idle_timer = nil
|
|
3044
|
+
@registry.with_session(session_id) { |s| idle_timer = s[:idle_timer] }
|
|
3045
|
+
|
|
3046
|
+
# Cancel any pending idle compression before starting a new task
|
|
3047
|
+
idle_timer&.cancel
|
|
3048
|
+
|
|
3049
|
+
@registry.update(session_id, status: :running)
|
|
3050
|
+
broadcast_session_update(session_id)
|
|
3051
|
+
|
|
3052
|
+
# Wire up incremental checkpoint callback before the thread starts.
|
|
3053
|
+
# Each completed iteration (think + act + observe) appends new messages
|
|
3054
|
+
# to a .jsonl file so crash recovery can restore them on restart.
|
|
3055
|
+
agent.reset_checkpoint!
|
|
3056
|
+
agent.on_checkpoint do |msgs|
|
|
3057
|
+
@session_manager.append_message_log(session_id, msgs)
|
|
3058
|
+
end
|
|
3059
|
+
|
|
3060
|
+
thread = Thread.new do
|
|
3061
|
+
begin
|
|
3062
|
+
result = task.call
|
|
3063
|
+
@registry.update(session_id, status: :idle, error: nil)
|
|
3064
|
+
broadcast_session_update(session_id)
|
|
3065
|
+
@session_manager.save(agent.to_session_data(status: :success))
|
|
3066
|
+
|
|
3067
|
+
# If the agent run left user messages in the inbox, spawn a
|
|
3068
|
+
# registered drain-only run so they are processed under the same
|
|
3069
|
+
# interrupt / idle-timer lifecycle as any other task.
|
|
3070
|
+
if agent.inbox_user_message_count > 0
|
|
3071
|
+
run_agent_task(session_id, agent) { agent.run }
|
|
3072
|
+
end
|
|
3073
|
+
|
|
3074
|
+
# Start idle compression timer now that the agent is idle
|
|
3075
|
+
idle_timer&.start
|
|
3076
|
+
rescue Octo::AgentInterrupted
|
|
3077
|
+
@registry.update(session_id, status: :idle)
|
|
3078
|
+
broadcast_session_update(session_id)
|
|
3079
|
+
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
3080
|
+
# Re-broadcast inbox queue status so the frontend shows the
|
|
3081
|
+
# accurate pending count after interruption (messages queued
|
|
3082
|
+
# during the run are preserved, not discarded).
|
|
3083
|
+
pending = agent.inbox_user_message_count
|
|
3084
|
+
s = nil
|
|
3085
|
+
@registry.with_session(session_id) { |sess| s = sess }
|
|
3086
|
+
s[:ui]&.update_user_message_queue_status(pending: pending)
|
|
3087
|
+
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
3088
|
+
|
|
3089
|
+
# If the inbox still has queued messages after interruption,
|
|
3090
|
+
# immediately resume draining them — don't go idle.
|
|
3091
|
+
if pending > 0
|
|
3092
|
+
run_agent_task(session_id, agent) { agent.run }
|
|
3093
|
+
end
|
|
3094
|
+
rescue => e
|
|
3095
|
+
@registry.update(session_id, status: :error, error: e.message)
|
|
3096
|
+
broadcast_session_update(session_id)
|
|
3097
|
+
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
3098
|
+
web_ui = nil
|
|
3099
|
+
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
3100
|
+
web_ui&.show_error(e.message)
|
|
3101
|
+
@session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
3102
|
+
ensure
|
|
3103
|
+
# Normal completion (success / interrupted / error) means the
|
|
3104
|
+
# full session has been saved to .json — the .jsonl is now
|
|
3105
|
+
# redundant. On server crash (kill -9) this ensure does NOT run,
|
|
3106
|
+
# leaving the .jsonl behind for recover_jsonl_sessions on restart.
|
|
3107
|
+
@session_manager.delete_message_log(session_id)
|
|
3108
|
+
agent.on_checkpoint(nil)
|
|
3109
|
+
end
|
|
3110
|
+
end
|
|
3111
|
+
@registry.with_session(session_id) { |s| s[:thread] = thread }
|
|
3112
|
+
end
|
|
3113
|
+
|
|
3114
|
+
# ── WebSocket subscription management ─────────────────────────────────────
|
|
3115
|
+
|
|
3116
|
+
def subscribe(session_id, conn)
|
|
3117
|
+
@ws_mutex.synchronize do
|
|
3118
|
+
# Remove conn from any previous session subscription first,
|
|
3119
|
+
# so switching sessions never results in duplicate delivery.
|
|
3120
|
+
@ws_clients.each_value { |list| list.delete(conn) }
|
|
3121
|
+
@ws_clients[session_id] ||= []
|
|
3122
|
+
@ws_clients[session_id] << conn unless @ws_clients[session_id].include?(conn)
|
|
3123
|
+
end
|
|
3124
|
+
end
|
|
3125
|
+
|
|
3126
|
+
def unsubscribe(conn)
|
|
3127
|
+
@ws_mutex.synchronize do
|
|
3128
|
+
@ws_clients.each_value { |list| list.delete(conn) }
|
|
3129
|
+
end
|
|
3130
|
+
end
|
|
3131
|
+
|
|
3132
|
+
def unsubscribe_all(session_id)
|
|
3133
|
+
@ws_mutex.synchronize { @ws_clients.delete(session_id) }
|
|
3134
|
+
end
|
|
3135
|
+
|
|
3136
|
+
# Broadcast an event to all clients subscribed to a session.
|
|
3137
|
+
# Dead connections (broken pipe / closed socket / deadline exceeded) are
|
|
3138
|
+
# removed automatically. Connections already marked closed are skipped
|
|
3139
|
+
# upfront so one sluggish client can't delay delivery to healthy ones.
|
|
3140
|
+
def broadcast(session_id, event)
|
|
3141
|
+
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
3142
|
+
dead = []
|
|
3143
|
+
clients.each do |conn|
|
|
3144
|
+
if conn.closed?
|
|
3145
|
+
dead << conn
|
|
3146
|
+
next
|
|
3147
|
+
end
|
|
3148
|
+
dead << conn unless conn.send_json(event)
|
|
3149
|
+
end
|
|
3150
|
+
return if dead.empty?
|
|
3151
|
+
|
|
3152
|
+
@ws_mutex.synchronize do
|
|
3153
|
+
(@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
|
|
3154
|
+
@all_ws_conns.reject! { |conn| dead.include?(conn) }
|
|
3155
|
+
end
|
|
3156
|
+
end
|
|
3157
|
+
|
|
3158
|
+
# Broadcast an event to every connected client (regardless of session subscription).
|
|
3159
|
+
# Dead connections are removed automatically.
|
|
3160
|
+
def broadcast_all(event)
|
|
3161
|
+
clients = @ws_mutex.synchronize { @all_ws_conns.dup }
|
|
3162
|
+
dead = []
|
|
3163
|
+
clients.each do |conn|
|
|
3164
|
+
if conn.closed?
|
|
3165
|
+
dead << conn
|
|
3166
|
+
next
|
|
3167
|
+
end
|
|
3168
|
+
dead << conn unless conn.send_json(event)
|
|
3169
|
+
end
|
|
3170
|
+
return if dead.empty?
|
|
3171
|
+
|
|
3172
|
+
@ws_mutex.synchronize do
|
|
3173
|
+
@all_ws_conns.reject! { |conn| dead.include?(conn) }
|
|
3174
|
+
@ws_clients.each_value { |list| list.reject! { |conn| dead.include?(conn) } }
|
|
3175
|
+
end
|
|
3176
|
+
end
|
|
3177
|
+
|
|
3178
|
+
# Broadcast a session_update event to all clients so they can patch their
|
|
3179
|
+
# local session list without needing a full session_list refresh.
|
|
3180
|
+
def broadcast_session_update(session_id)
|
|
3181
|
+
session = @registry.snapshot(session_id)
|
|
3182
|
+
return unless session
|
|
3183
|
+
|
|
3184
|
+
broadcast_all(type: "session_update", session: session)
|
|
3185
|
+
end
|
|
3186
|
+
|
|
3187
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
3188
|
+
|
|
3189
|
+
def default_working_dir
|
|
3190
|
+
@agent_config&.default_working_dir || File.expand_path("~/octo_workspace")
|
|
3191
|
+
end
|
|
3192
|
+
|
|
3193
|
+
# Create a session in the registry and wire up Agent + WebUIController.
|
|
3194
|
+
# Returns the new session_id.
|
|
3195
|
+
# Build a new agent session.
|
|
3196
|
+
# @param name [String] display name for the session
|
|
3197
|
+
# @param working_dir [String] working directory for the agent
|
|
3198
|
+
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
3199
|
+
# :auto_approve (unattended — suppresses request_user_feedback waits)
|
|
3200
|
+
def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
|
|
3201
|
+
session_id = Octo::SessionManager.generate_id
|
|
3202
|
+
@registry.create(session_id: session_id)
|
|
3203
|
+
|
|
3204
|
+
config = @agent_config.deep_copy
|
|
3205
|
+
config.permission_mode = permission_mode
|
|
3206
|
+
|
|
3207
|
+
# Apply model override BEFORE creating the client — otherwise the
|
|
3208
|
+
# client is built from the default model entry and may route through
|
|
3209
|
+
# the wrong provider (e.g. sending a deepseek-v4-pro request to the
|
|
3210
|
+
# Bedrock-format Octo endpoint, which replies "unknown model").
|
|
3211
|
+
#
|
|
3212
|
+
# We use switch_model_by_id (not a name-based rewrite of
|
|
3213
|
+
# current_model["model"]) because:
|
|
3214
|
+
# 1. Ids uniquely identify an entry across providers; names can
|
|
3215
|
+
# collide between entries (deepseek vs dsk-deepseek aliases).
|
|
3216
|
+
# 2. switch_model_by_id only flips per-session @current_model_id
|
|
3217
|
+
# in the dup'd config — it never mutates the shared @models
|
|
3218
|
+
# array (see AgentConfig#deep_copy's shared-ref contract).
|
|
3219
|
+
# A name rewrite would have leaked into every live session
|
|
3220
|
+
# AND corrupted the on-disk config at next save.
|
|
3221
|
+
config.switch_model_by_id(model_id) if model_id
|
|
3222
|
+
|
|
3223
|
+
# Build client from the (possibly overridden) config so api format
|
|
3224
|
+
# detection (Bedrock vs OpenAI vs Anthropic) uses the correct model.
|
|
3225
|
+
client = Octo::Client.new(
|
|
3226
|
+
config.api_key,
|
|
3227
|
+
base_url: config.base_url,
|
|
3228
|
+
model: config.model_name,
|
|
3229
|
+
anthropic_format: config.anthropic_format?
|
|
3230
|
+
)
|
|
3231
|
+
|
|
3232
|
+
broadcaster = method(:broadcast)
|
|
3233
|
+
ui = WebUIController.new(session_id, broadcaster)
|
|
3234
|
+
agent = Octo::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
|
|
3235
|
+
session_id: session_id, source: source)
|
|
3236
|
+
agent.rename(name) unless name.nil? || name.empty?
|
|
3237
|
+
idle_timer = build_idle_timer(session_id, agent)
|
|
3238
|
+
|
|
3239
|
+
@registry.with_session(session_id) do |s|
|
|
3240
|
+
s[:agent] = agent
|
|
3241
|
+
s[:ui] = ui
|
|
3242
|
+
s[:idle_timer] = idle_timer
|
|
3243
|
+
end
|
|
3244
|
+
|
|
3245
|
+
# Persist an initial snapshot so the session is immediately visible in registry.list
|
|
3246
|
+
# (which reads from disk). Without this, new sessions only appear after their first task.
|
|
3247
|
+
@session_manager.save(agent.to_session_data)
|
|
3248
|
+
|
|
3249
|
+
session_id
|
|
3250
|
+
end
|
|
3251
|
+
|
|
3252
|
+
# Restore a persisted session from saved session_data (from SessionManager).
|
|
3253
|
+
# The agent keeps its original session_id so the frontend URL hash stays valid
|
|
3254
|
+
# across server restarts.
|
|
3255
|
+
def build_session_from_data(session_data, permission_mode: :confirm_all)
|
|
3256
|
+
original_id = session_data[:session_id]
|
|
3257
|
+
|
|
3258
|
+
client = @client_factory.call
|
|
3259
|
+
config = @agent_config.deep_copy
|
|
3260
|
+
config.permission_mode = permission_mode
|
|
3261
|
+
broadcaster = method(:broadcast)
|
|
3262
|
+
ui = WebUIController.new(original_id, broadcaster)
|
|
3263
|
+
# Restore the agent profile from the persisted session; fall back to "general"
|
|
3264
|
+
# for sessions saved before the agent_profile field was introduced.
|
|
3265
|
+
profile = session_data[:agent_profile].to_s
|
|
3266
|
+
profile = "general" if profile.empty?
|
|
3267
|
+
agent = Octo::Agent.from_session(client, config, session_data, ui: ui, profile: profile)
|
|
3268
|
+
idle_timer = build_idle_timer(original_id, agent)
|
|
3269
|
+
|
|
3270
|
+
# Register session atomically with a fully-built agent so no concurrent
|
|
3271
|
+
# caller ever sees agent=nil for this session. The duplicate-restore guard
|
|
3272
|
+
# is handled upstream by SessionRegistry#ensure via @restoring.
|
|
3273
|
+
@registry.create(session_id: original_id)
|
|
3274
|
+
@registry.with_session(original_id) do |s|
|
|
3275
|
+
s[:agent] = agent
|
|
3276
|
+
s[:ui] = ui
|
|
3277
|
+
s[:idle_timer] = idle_timer
|
|
3278
|
+
end
|
|
3279
|
+
|
|
3280
|
+
original_id
|
|
3281
|
+
end
|
|
3282
|
+
|
|
3283
|
+
# Build an IdleCompressionTimer for a session.
|
|
3284
|
+
# Broadcasts session_update after successful compression so clients see the new cost.
|
|
3285
|
+
private def build_idle_timer(session_id, agent)
|
|
3286
|
+
Octo::IdleCompressionTimer.new(
|
|
3287
|
+
agent: agent,
|
|
3288
|
+
session_manager: @session_manager
|
|
3289
|
+
) do |_success|
|
|
3290
|
+
broadcast_session_update(session_id)
|
|
3291
|
+
end
|
|
3292
|
+
end
|
|
3293
|
+
|
|
3294
|
+
# Mask API key for display: show first 8 + last 4 chars, middle replaced with ****
|
|
3295
|
+
# Mask an api_key for safe display / transport to the browser.
|
|
3296
|
+
#
|
|
3297
|
+
# Contract: the returned string MUST contain "****" so callers (incl.
|
|
3298
|
+
# the frontend) can reliably detect "this is a display placeholder,
|
|
3299
|
+
# not a real key" and refuse to treat it as input. The old behaviour
|
|
3300
|
+
# of returning the raw value for short keys was a correctness bug —
|
|
3301
|
+
# it leaked short keys in plaintext to GET /api/config, and it let
|
|
3302
|
+
# short masked values slip past the frontend's mask-detection.
|
|
3303
|
+
def mask_api_key(key)
|
|
3304
|
+
return "" if key.nil? || key.empty?
|
|
3305
|
+
if key.length <= 12
|
|
3306
|
+
# Very short key — show the first char only, redact the rest.
|
|
3307
|
+
return "#{key[0]}****"
|
|
3308
|
+
end
|
|
3309
|
+
"#{key[0..7]}****#{key[-4..]}"
|
|
3310
|
+
end
|
|
3311
|
+
|
|
3312
|
+
def json_response(res, status, data)
|
|
3313
|
+
res.status = status
|
|
3314
|
+
res.content_type = "application/json; charset=utf-8"
|
|
3315
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
3316
|
+
res.body = JSON.generate(data)
|
|
3317
|
+
end
|
|
3318
|
+
|
|
3319
|
+
def parse_json_body(req)
|
|
3320
|
+
return {} if req.body.nil? || req.body.empty?
|
|
3321
|
+
|
|
3322
|
+
JSON.parse(req.body)
|
|
3323
|
+
rescue JSON::ParserError
|
|
3324
|
+
{}
|
|
3325
|
+
end
|
|
3326
|
+
|
|
3327
|
+
# Parse a multipart/form-data request body to extract a single file upload.
|
|
3328
|
+
# Returns { filename:, data: } or nil when the field is not found.
|
|
3329
|
+
# This is a lightweight parser that handles the standard WEBrick multipart format.
|
|
3330
|
+
#
|
|
3331
|
+
# @param req [WEBrick::HTTPRequest]
|
|
3332
|
+
# @param field_name [String] The form field name to look for
|
|
3333
|
+
# @return [Hash, nil] { filename: String, data: String (binary) }
|
|
3334
|
+
private def parse_multipart_upload(req, field_name)
|
|
3335
|
+
content_type = req["Content-Type"].to_s
|
|
3336
|
+
return nil unless content_type.include?("multipart/form-data")
|
|
3337
|
+
|
|
3338
|
+
# Extract boundary from Content-Type header
|
|
3339
|
+
boundary_match = content_type.match(/boundary=([^\s;]+)/)
|
|
3340
|
+
return nil unless boundary_match
|
|
3341
|
+
|
|
3342
|
+
boundary = "--" + boundary_match[1].strip.gsub(/^"(.*)"$/, '')
|
|
3343
|
+
body = req.body.to_s.b # treat as binary
|
|
3344
|
+
|
|
3345
|
+
# Split body by boundary and find the target field
|
|
3346
|
+
parts = body.split(Regexp.new(Regexp.escape(boundary)))
|
|
3347
|
+
parts.each do |part|
|
|
3348
|
+
# Each part has headers, then blank line, then body
|
|
3349
|
+
# Use \r\n\r\n or \n\n as separator between headers and body
|
|
3350
|
+
header_body_sep = part.index("\r\n\r\n") || part.index("\n\n")
|
|
3351
|
+
next unless header_body_sep
|
|
3352
|
+
|
|
3353
|
+
sep_len = part[header_body_sep, 4] == "\r\n\r\n" ? 4 : 2
|
|
3354
|
+
raw_headers = part[0, header_body_sep]
|
|
3355
|
+
raw_body = part[(header_body_sep + sep_len)..]
|
|
3356
|
+
|
|
3357
|
+
# Remove trailing CRLF from part body
|
|
3358
|
+
raw_body = raw_body.sub(/\r\n\z/, "").sub(/\n\z/, "")
|
|
3359
|
+
|
|
3360
|
+
# Check Content-Disposition for our field name
|
|
3361
|
+
next unless raw_headers.include?("Content-Disposition")
|
|
3362
|
+
|
|
3363
|
+
name_match = raw_headers.match(/name="([^"]+)"/)
|
|
3364
|
+
next unless name_match && name_match[1] == field_name
|
|
3365
|
+
|
|
3366
|
+
file_match = raw_headers.match(/filename="([^"]*)"/)
|
|
3367
|
+
filename = file_match ? file_match[1] : field_name
|
|
3368
|
+
|
|
3369
|
+
return { filename: filename, data: raw_body }
|
|
3370
|
+
end
|
|
3371
|
+
|
|
3372
|
+
nil
|
|
3373
|
+
end
|
|
3374
|
+
|
|
3375
|
+
def not_found(res)
|
|
3376
|
+
res.status = 404
|
|
3377
|
+
res.body = "Not Found"
|
|
3378
|
+
end
|
|
3379
|
+
|
|
3380
|
+
# ── Inner classes ─────────────────────────────────────────────────────────
|
|
3381
|
+
|
|
3382
|
+
# Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
|
|
3383
|
+
#
|
|
3384
|
+
# IMPORTANT: send_raw is called from the Agent thread via broadcast() →
|
|
3385
|
+
# send_json(). A blocking socket write with no deadline can pin the Agent
|
|
3386
|
+
# thread indefinitely when the client's receive buffer fills up (silent
|
|
3387
|
+
# disconnects such as Wi-Fi handoff or NAT timeout, where TCP keepalive
|
|
3388
|
+
# defaults are measured in hours). Thread#raise on blocking native socket
|
|
3389
|
+
# writes is best-effort and unreliable, so instead we bound every write
|
|
3390
|
+
# with an explicit deadline using IO.select + write_nonblock and declare
|
|
3391
|
+
# the connection dead on timeout.
|
|
3392
|
+
class WebSocketConnection
|
|
3393
|
+
attr_accessor :session_id
|
|
3394
|
+
|
|
3395
|
+
# Maximum time a single send_raw call is allowed to spend writing.
|
|
3396
|
+
# 5 seconds is generous for healthy LAN/Internet clients and short
|
|
3397
|
+
# enough that a stuck Agent becomes responsive again quickly.
|
|
3398
|
+
SEND_DEADLINE = 5.0
|
|
3399
|
+
|
|
3400
|
+
# Warn threshold — any individual send_raw that exceeds this is logged
|
|
3401
|
+
# so we can spot sluggish clients before they fully hang.
|
|
3402
|
+
SEND_SLOW_WARN = 1.0
|
|
3403
|
+
|
|
3404
|
+
def initialize(socket, version)
|
|
3405
|
+
@socket = socket
|
|
3406
|
+
@version = version
|
|
3407
|
+
@send_mutex = Mutex.new
|
|
3408
|
+
@closed = false
|
|
3409
|
+
WebSocketConnection.apply_keepalive(socket)
|
|
3410
|
+
end
|
|
3411
|
+
|
|
3412
|
+
# Returns true if the underlying socket has been detected as dead.
|
|
3413
|
+
def closed?
|
|
3414
|
+
@closed
|
|
3415
|
+
end
|
|
3416
|
+
|
|
3417
|
+
# Force-close the connection (used by the interrupt watchdog when an
|
|
3418
|
+
# Agent thread is stuck on an unresponsive socket write).
|
|
3419
|
+
def force_close!
|
|
3420
|
+
@closed = true
|
|
3421
|
+
@socket.close
|
|
3422
|
+
rescue StandardError
|
|
3423
|
+
# best effort
|
|
3424
|
+
end
|
|
3425
|
+
|
|
3426
|
+
# Send a JSON-serializable object over the WebSocket.
|
|
3427
|
+
# Returns true on success, false if the connection is dead.
|
|
3428
|
+
def send_json(data)
|
|
3429
|
+
send_raw(:text, JSON.generate(data))
|
|
3430
|
+
rescue => e
|
|
3431
|
+
Octo::Logger.debug("WS send error (connection dead): #{e.message}")
|
|
3432
|
+
false
|
|
3433
|
+
end
|
|
3434
|
+
|
|
3435
|
+
# Send a raw WebSocket frame.
|
|
3436
|
+
# Returns true on success, false on broken/closed/sluggish socket.
|
|
3437
|
+
#
|
|
3438
|
+
# Uses write_nonblock with an overall deadline so the caller (typically
|
|
3439
|
+
# the Agent thread) never blocks longer than SEND_DEADLINE, even if the
|
|
3440
|
+
# client silently stopped reading.
|
|
3441
|
+
def send_raw(type, data)
|
|
3442
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3443
|
+
|
|
3444
|
+
@send_mutex.synchronize do
|
|
3445
|
+
return false if @closed
|
|
3446
|
+
|
|
3447
|
+
outgoing = WebSocket::Frame::Outgoing::Server.new(
|
|
3448
|
+
version: @version,
|
|
3449
|
+
data: data,
|
|
3450
|
+
type: type
|
|
3451
|
+
)
|
|
3452
|
+
bytes = outgoing.to_s
|
|
3453
|
+
|
|
3454
|
+
unless write_with_deadline(bytes, SEND_DEADLINE)
|
|
3455
|
+
# Deadline exceeded — treat as a dead connection so broadcast
|
|
3456
|
+
# purges it and the Agent thread is freed immediately.
|
|
3457
|
+
@closed = true
|
|
3458
|
+
begin
|
|
3459
|
+
@socket.close
|
|
3460
|
+
rescue StandardError
|
|
3461
|
+
# ignore
|
|
3462
|
+
end
|
|
3463
|
+
Octo::Logger.warn(
|
|
3464
|
+
"[WS] send_raw deadline exceeded — closing sluggish connection " \
|
|
3465
|
+
"(bytes=#{bytes.bytesize}, deadline=#{SEND_DEADLINE}s)"
|
|
3466
|
+
)
|
|
3467
|
+
return false
|
|
3468
|
+
end
|
|
3469
|
+
end
|
|
3470
|
+
|
|
3471
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
3472
|
+
if elapsed > SEND_SLOW_WARN
|
|
3473
|
+
Octo::Logger.warn(
|
|
3474
|
+
"[WS] send_raw slow: #{elapsed.round(2)}s (type=#{type})"
|
|
3475
|
+
)
|
|
3476
|
+
end
|
|
3477
|
+
true
|
|
3478
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
|
|
3479
|
+
@closed = true
|
|
3480
|
+
Octo::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
|
|
3481
|
+
false
|
|
3482
|
+
rescue => e
|
|
3483
|
+
@closed = true
|
|
3484
|
+
Octo::Logger.debug("WS send_raw unexpected error: #{e.message}")
|
|
3485
|
+
false
|
|
3486
|
+
end
|
|
3487
|
+
|
|
3488
|
+
# Write `data` to the underlying socket, bounded by `deadline` seconds
|
|
3489
|
+
# of *total* wall time across partial writes. Returns true on full
|
|
3490
|
+
# success, false on timeout.
|
|
3491
|
+
private def write_with_deadline(data, deadline)
|
|
3492
|
+
remaining = data
|
|
3493
|
+
deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline
|
|
3494
|
+
|
|
3495
|
+
until remaining.empty?
|
|
3496
|
+
time_left = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3497
|
+
return false if time_left <= 0
|
|
3498
|
+
|
|
3499
|
+
begin
|
|
3500
|
+
written = @socket.write_nonblock(remaining, exception: false)
|
|
3501
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF
|
|
3502
|
+
raise
|
|
3503
|
+
end
|
|
3504
|
+
|
|
3505
|
+
case written
|
|
3506
|
+
when :wait_writable
|
|
3507
|
+
ready = IO.select(nil, [@socket], nil, [time_left, 0.25].min)
|
|
3508
|
+
# Not ready → loop and re-check the overall deadline.
|
|
3509
|
+
next unless ready
|
|
3510
|
+
when Integer
|
|
3511
|
+
remaining = remaining.byteslice(written, remaining.bytesize - written)
|
|
3512
|
+
else
|
|
3513
|
+
# Nil or unexpected — treat as dead.
|
|
3514
|
+
return false
|
|
3515
|
+
end
|
|
3516
|
+
end
|
|
3517
|
+
|
|
3518
|
+
true
|
|
3519
|
+
end
|
|
3520
|
+
|
|
3521
|
+
# Enable TCP keepalive on the underlying socket so silently dead
|
|
3522
|
+
# peers are detected in minutes instead of the OS default of hours.
|
|
3523
|
+
# Best-effort: any failure is logged at debug level and ignored.
|
|
3524
|
+
def self.apply_keepalive(socket)
|
|
3525
|
+
return unless socket.respond_to?(:setsockopt)
|
|
3526
|
+
|
|
3527
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
|
3528
|
+
|
|
3529
|
+
# TCP-level keepalive tuning — constants vary by platform and are
|
|
3530
|
+
# only set when available. Values chosen to detect dead peers in
|
|
3531
|
+
# roughly 60-90 seconds total.
|
|
3532
|
+
if defined?(Socket::IPPROTO_TCP)
|
|
3533
|
+
# Idle time before first probe (Linux: TCP_KEEPIDLE, macOS: TCP_KEEPALIVE)
|
|
3534
|
+
idle_const = if Socket.const_defined?(:TCP_KEEPIDLE)
|
|
3535
|
+
Socket::TCP_KEEPIDLE
|
|
3536
|
+
elsif Socket.const_defined?(:TCP_KEEPALIVE)
|
|
3537
|
+
Socket::TCP_KEEPALIVE
|
|
3538
|
+
end
|
|
3539
|
+
socket.setsockopt(Socket::IPPROTO_TCP, idle_const, 60) if idle_const
|
|
3540
|
+
|
|
3541
|
+
if Socket.const_defined?(:TCP_KEEPINTVL)
|
|
3542
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, 10)
|
|
3543
|
+
end
|
|
3544
|
+
if Socket.const_defined?(:TCP_KEEPCNT)
|
|
3545
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, 3)
|
|
3546
|
+
end
|
|
3547
|
+
end
|
|
3548
|
+
rescue StandardError => e
|
|
3549
|
+
Octo::Logger.debug("[WS] failed to set keepalive: #{e.class}: #{e.message}")
|
|
3550
|
+
end
|
|
3551
|
+
end
|
|
3552
|
+
end
|
|
3553
|
+
end
|
|
3554
|
+
end
|