samagotchi 0.2.0
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/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "rack"
|
|
9
|
+
require "rack/request"
|
|
10
|
+
|
|
11
|
+
require_relative "../bridge_client"
|
|
12
|
+
require_relative "../bridge/bounded_queue"
|
|
13
|
+
require_relative "../session"
|
|
14
|
+
require_relative "../session_manager"
|
|
15
|
+
require_relative "../session_commands"
|
|
16
|
+
require_relative "../host_registry"
|
|
17
|
+
require_relative "../model_profile"
|
|
18
|
+
require_relative "../project_scope"
|
|
19
|
+
require_relative "../version"
|
|
20
|
+
require_relative "../output_formatter"
|
|
21
|
+
require_relative "../image_store"
|
|
22
|
+
require_relative "../context_note"
|
|
23
|
+
require_relative "../recap_store"
|
|
24
|
+
require_relative "markdown_renderer"
|
|
25
|
+
require_relative "message_parts"
|
|
26
|
+
require_relative "session_summary"
|
|
27
|
+
require_relative "../log"
|
|
28
|
+
|
|
29
|
+
module Samagotchi
|
|
30
|
+
module Web
|
|
31
|
+
# Rack application that serves the Web UI and JSON API.
|
|
32
|
+
#
|
|
33
|
+
# This is the single-port control plane for Chi Web. It never constructs
|
|
34
|
+
# an Engine directly — it talks to SessionManager over the file IPC layer.
|
|
35
|
+
# Live SSE is proxied from each session's Bridge
|
|
36
|
+
# (the single live client transport); history for any session — including
|
|
37
|
+
# dead ones — is served by GET /api/sessions/:id/output from output/ files.
|
|
38
|
+
class App
|
|
39
|
+
DEFAULT_HOST = BridgeClient::HOST
|
|
40
|
+
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
41
|
+
# An uploaded image may be this big before it is downscaled.
|
|
42
|
+
MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
43
|
+
SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
|
|
44
|
+
IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
|
|
45
|
+
IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
|
|
46
|
+
"webp" => "image/webp" }.freeze
|
|
47
|
+
# The cancel reasons a client may give, as the Bridge takes them
|
|
48
|
+
# (Bridge::CANCEL_REASONS); anything else is sent and echoed as manual.
|
|
49
|
+
CANCEL_REASONS = %w[manual user ctrl_c].freeze
|
|
50
|
+
|
|
51
|
+
# POST /stop waits this long for the worker to let go of the session.
|
|
52
|
+
STOP_WAIT_SECONDS = 2.0
|
|
53
|
+
|
|
54
|
+
# GET /api/events: a `: ping` this often while idle, a queue this deep
|
|
55
|
+
# per tab (an overflow ends the connection; the reconnect's snapshot
|
|
56
|
+
# is the recovery), and the loop's longest wait before it looks at
|
|
57
|
+
# whether the hub or the server is shutting down.
|
|
58
|
+
EVENTS_HEARTBEAT = 15.0
|
|
59
|
+
EVENTS_QUEUE = 256
|
|
60
|
+
# Seconds GET /api/models waits for the hosts' lists before answering
|
|
61
|
+
# with what it has (the listing goes on and fills the registry's cache).
|
|
62
|
+
MODELS_WAIT_TIMEOUT = 4.0
|
|
63
|
+
EVENTS_POLL = 1.0
|
|
64
|
+
|
|
65
|
+
# A callable the event loops ask whether the server still runs;
|
|
66
|
+
# Server.start points it at WEBrick's status once it has the server.
|
|
67
|
+
attr_writer :server_running
|
|
68
|
+
|
|
69
|
+
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
70
|
+
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
71
|
+
# @param turn_view [Boolean] the page's per-turn view (web.turn_view,
|
|
72
|
+
# the default); false is the classic row of bubbles; ?view=turn|chat
|
|
73
|
+
# overrides it for one page load
|
|
74
|
+
# @param hub [SessionHub, nil] the session projection GET /api/events
|
|
75
|
+
# streams from; without one the route answers 503
|
|
76
|
+
# @param registry [HostRegistry, nil] the hosts GET /api/models lists
|
|
77
|
+
# (built from the config on first use)
|
|
78
|
+
# @param models_wait_timeout [Float] bounded seconds GET /api/models
|
|
79
|
+
# waits for the hosts' lists
|
|
80
|
+
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
81
|
+
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, turn_view: true, hub: nil,
|
|
82
|
+
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
83
|
+
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
|
|
84
|
+
@manager = manager || SessionManager
|
|
85
|
+
@registry = registry
|
|
86
|
+
@models_wait_timeout = models_wait_timeout
|
|
87
|
+
@models_mutex = Mutex.new
|
|
88
|
+
@models_thread = nil
|
|
89
|
+
@session_class = session_class || Session
|
|
90
|
+
@state_dir = state_dir
|
|
91
|
+
@public_dir = public_dir || File.expand_path("public", __dir__)
|
|
92
|
+
@bridge_wait_timeout = bridge_wait_timeout
|
|
93
|
+
@markdown_renderer = MarkdownRenderer.new(enabled: markdown)
|
|
94
|
+
@turn_view = turn_view
|
|
95
|
+
@hub = hub
|
|
96
|
+
@events_heartbeat = events_heartbeat
|
|
97
|
+
@events_queue = events_queue
|
|
98
|
+
@server_running = -> { true }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def call(env)
|
|
102
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
response = route(env)
|
|
104
|
+
log_request(env, response, started)
|
|
105
|
+
response
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def route(env)
|
|
111
|
+
req = Rack::Request.new(env)
|
|
112
|
+
return forbidden unless localhost?(req)
|
|
113
|
+
|
|
114
|
+
case [req.request_method, req.path_info]
|
|
115
|
+
when ["GET", "/"], ["GET", "/index.html"]
|
|
116
|
+
serve_index(req)
|
|
117
|
+
when ["GET", "/api/sessions"]
|
|
118
|
+
handle_list(req)
|
|
119
|
+
when ["POST", "/api/sessions"]
|
|
120
|
+
handle_create(req)
|
|
121
|
+
when ["GET", "/api/info"]
|
|
122
|
+
handle_info
|
|
123
|
+
when ["GET", "/api/models"]
|
|
124
|
+
handle_models
|
|
125
|
+
when ["GET", "/api/events"]
|
|
126
|
+
handle_events(req)
|
|
127
|
+
else
|
|
128
|
+
# Dynamic routes
|
|
129
|
+
if (m = %r{\A/api/sessions/([^/]+)/stream\z}.match(req.path_info)) && req.get?
|
|
130
|
+
return handle_stream(req, m[1])
|
|
131
|
+
end
|
|
132
|
+
if (m = %r{\A/api/sessions/([^/]+)/output\z}.match(req.path_info)) && req.get?
|
|
133
|
+
return handle_output(req, m[1])
|
|
134
|
+
end
|
|
135
|
+
if (m = %r{\A/api/sessions/([^/]+)/cancel\z}.match(req.path_info)) && req.post?
|
|
136
|
+
return handle_cancel(req, m[1])
|
|
137
|
+
end
|
|
138
|
+
if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
|
|
139
|
+
return handle_stop(req, m[1])
|
|
140
|
+
end
|
|
141
|
+
if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
|
|
142
|
+
return handle_turn(req, m[1])
|
|
143
|
+
end
|
|
144
|
+
if (m = %r{\A/api/sessions/([^/]+)/answer\z}.match(req.path_info)) && req.post?
|
|
145
|
+
return handle_question_answer(req, m[1])
|
|
146
|
+
end
|
|
147
|
+
if (m = %r{\A/api/sessions/([^/]+)/question/dismiss\z}.match(req.path_info)) && req.post?
|
|
148
|
+
return handle_question_dismiss(req, m[1])
|
|
149
|
+
end
|
|
150
|
+
if (m = %r{\A/api/sessions/([^/]+)/command\z}.match(req.path_info)) && req.post?
|
|
151
|
+
return handle_command(req, m[1])
|
|
152
|
+
end
|
|
153
|
+
if (m = %r{\A/api/sessions/([^/]+)/images\z}.match(req.path_info)) && req.post?
|
|
154
|
+
return handle_image_upload(req, m[1])
|
|
155
|
+
end
|
|
156
|
+
if (m = %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}.match(req.path_info)) && req.get?
|
|
157
|
+
return handle_image(req, m[1], m[2])
|
|
158
|
+
end
|
|
159
|
+
if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.get?
|
|
160
|
+
return handle_show(req, m[1])
|
|
161
|
+
end
|
|
162
|
+
if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.delete?
|
|
163
|
+
return handle_delete(req, m[1])
|
|
164
|
+
end
|
|
165
|
+
if req.path_info.start_with?("/assets/") || req.path_info.start_with?("/public/")
|
|
166
|
+
return serve_static(req)
|
|
167
|
+
end
|
|
168
|
+
# Fallback static file serve from public_dir (e.g. /style.css)
|
|
169
|
+
maybe_static = serve_static(req)
|
|
170
|
+
return maybe_static if maybe_static[0] != 404
|
|
171
|
+
|
|
172
|
+
not_found(path: req.path_info)
|
|
173
|
+
end
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
|
|
176
|
+
error_response(500, "internal_error", e.message)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# API requests at debug level (not the page and its assets): method,
|
|
180
|
+
# path, status, time. Never a body or the query; only a tail=1 read
|
|
181
|
+
# (the page's end-of-turn re-read) is marked, to tell it from a full one.
|
|
182
|
+
def log_request(env, response, started)
|
|
183
|
+
path = env["PATH_INFO"].to_s
|
|
184
|
+
return unless path.start_with?("/api/") && Log.level?(:debug)
|
|
185
|
+
|
|
186
|
+
tail = env["QUERY_STRING"].to_s.split("&").include?("tail=1") ? { tail: true } : {}
|
|
187
|
+
Log.debug(:web, "request", sid: path[%r{\A/api/sessions/([^/]+)}, 1], method: env["REQUEST_METHOD"], path: path,
|
|
188
|
+
status: response[0], **tail,
|
|
189
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def localhost?(req)
|
|
193
|
+
# Only 127.0.0.1 / ::1 / localhost are allowed. The socket is bound to
|
|
194
|
+
# 127.0.0.1, but an explicit Host check prevents DNS-rebind tricks.
|
|
195
|
+
host = req.host.to_s.downcase.split(":").first
|
|
196
|
+
%w[127.0.0.1 ::1 localhost].include?(host)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def forbidden
|
|
200
|
+
error_response(403, "forbidden", "only 127.0.0.1 is allowed")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The page's scope: ?dir=<folder> lists that folder's project (every
|
|
204
|
+
# session when the folder is in no repo). A dir that isn't an existing
|
|
205
|
+
# absolute folder answers 400 before anything else runs.
|
|
206
|
+
def handle_list(req)
|
|
207
|
+
dir, error = scope_dir(req.params["dir"])
|
|
208
|
+
return error if error
|
|
209
|
+
|
|
210
|
+
scope = {}
|
|
211
|
+
root = dir && ProjectScope.root_for(dir)
|
|
212
|
+
scope[:project_root] = root if root
|
|
213
|
+
# Lazy retention sweep (once per 24h)
|
|
214
|
+
if @manager.respond_to?(:retention_sweep_if_due)
|
|
215
|
+
begin
|
|
216
|
+
@manager.retention_sweep_if_due(state_dir: @state_dir)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
sort = sanitize_sort(req.params["sort"])
|
|
222
|
+
order = sanitize_order(req.params["order"])
|
|
223
|
+
limit = sanitize_limit(req.params["limit"])
|
|
224
|
+
offset = sanitize_offset(req.params["offset"])
|
|
225
|
+
return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
|
|
226
|
+
|
|
227
|
+
sessions = if @state_dir
|
|
228
|
+
@manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
|
|
229
|
+
**scope)
|
|
230
|
+
else
|
|
231
|
+
@manager.list_sessions(sort: sort, order: order, limit: limit, offset: offset, **scope)
|
|
232
|
+
end
|
|
233
|
+
roots = {}
|
|
234
|
+
payload = sessions.map do |s|
|
|
235
|
+
owner = session_owner(s.id)
|
|
236
|
+
session_to_json(s, status: displayed_status(s, owner: owner), owner: owner, root_cache: roots)
|
|
237
|
+
end
|
|
238
|
+
# Expose total via header for pagination (total unordered count)
|
|
239
|
+
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }
|
|
240
|
+
# Compute total without limit/offset for header
|
|
241
|
+
if limit || offset.positive?
|
|
242
|
+
total = if @state_dir
|
|
243
|
+
@manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, **scope).size
|
|
244
|
+
else
|
|
245
|
+
@manager.list_sessions(sort: sort, order: order, **scope).size
|
|
246
|
+
end
|
|
247
|
+
headers["X-Total-Count"] = total.to_s
|
|
248
|
+
end
|
|
249
|
+
body = JSON.generate(payload)
|
|
250
|
+
headers["Content-Length"] = body.bytesize.to_s
|
|
251
|
+
[200, headers, [body]]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The list from the hub's projection (the page's fallback and the
|
|
255
|
+
# first paint): the same sort, paging and total as from the files.
|
|
256
|
+
def list_from_hub(root, sort:, order:, limit:, offset:)
|
|
257
|
+
all = @hub.snapshot(project_root: root, sort: sort, order: order)
|
|
258
|
+
page = all.drop(offset)
|
|
259
|
+
page = page.first(limit) if limit
|
|
260
|
+
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }
|
|
261
|
+
headers["X-Total-Count"] = all.size.to_s if limit || offset.positive?
|
|
262
|
+
body = JSON.generate(page)
|
|
263
|
+
headers["Content-Length"] = body.bytesize.to_s
|
|
264
|
+
[200, headers, [body]]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A scope folder from the page: [dir, nil] (dir nil when none was
|
|
268
|
+
# given), or [nil, a 400 response] when it isn't an existing absolute
|
|
269
|
+
# folder (a bookmarked worktree deleted since, a typo).
|
|
270
|
+
def scope_dir(value)
|
|
271
|
+
return [nil, nil] if value.nil? || value.to_s.strip.empty?
|
|
272
|
+
|
|
273
|
+
dir = value.to_s
|
|
274
|
+
unless dir.start_with?("/") && File.directory?(dir)
|
|
275
|
+
return [nil, error_response(400, "invalid_dir", "not a folder: #{dir}")]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
[File.expand_path(dir), nil]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# What a second `chi web` probes before starting its own server: this
|
|
282
|
+
# is chi web, and it knows ?dir (features).
|
|
283
|
+
def handle_info
|
|
284
|
+
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
|
|
285
|
+
features: ["dir"] })
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The models a new session can start on, spelled as chi spells them
|
|
289
|
+
# elsewhere: bare for the default host (what default.model holds), and
|
|
290
|
+
# host:model for another host. The default is the configured model, or
|
|
291
|
+
# what spawn_session gives a session today. The hosts' lists come from
|
|
292
|
+
# the registry's cache (60 s; 10 min for a remote host); the route waits
|
|
293
|
+
# a bounded time for a fresh listing and answers with what it has, a
|
|
294
|
+
# host that failed or the wait running out noted in +warning+, never a
|
|
295
|
+
# failure: the default alone is enough for the page.
|
|
296
|
+
def handle_models
|
|
297
|
+
default = begin
|
|
298
|
+
ModelProfile.required_model_name
|
|
299
|
+
rescue StandardError
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
models = []
|
|
303
|
+
warnings = []
|
|
304
|
+
begin
|
|
305
|
+
registry = host_registry
|
|
306
|
+
results = await_model_lists(registry)
|
|
307
|
+
if results.nil?
|
|
308
|
+
warnings << "the hosts are still listing their models"
|
|
309
|
+
else
|
|
310
|
+
default_host = registry.default_entry&.name
|
|
311
|
+
ordered = results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }
|
|
312
|
+
ordered.each do |host|
|
|
313
|
+
data = results[host]
|
|
314
|
+
if data[:error]
|
|
315
|
+
warnings << "#{host}: #{data[:error]}"
|
|
316
|
+
next
|
|
317
|
+
end
|
|
318
|
+
Array(data[:models]).each do |info|
|
|
319
|
+
id = info.id.to_s
|
|
320
|
+
next if id.strip.empty? || id.end_with?(":batch")
|
|
321
|
+
|
|
322
|
+
models << { name: host == default_host ? id : "#{host}:#{id}", host: host, id: id }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
rescue StandardError => e
|
|
327
|
+
warnings << e.message
|
|
328
|
+
end
|
|
329
|
+
if default && models.none? { |m| m[:name].casecmp?(default) }
|
|
330
|
+
models.unshift({ name: default, host: nil, id: default })
|
|
331
|
+
end
|
|
332
|
+
payload = { default: default, models: models }
|
|
333
|
+
payload[:warning] = warnings.join("; ") unless warnings.empty?
|
|
334
|
+
json_response(200, payload)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def host_registry
|
|
338
|
+
@models_mutex.synchronize { @registry ||= HostRegistry.new }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The hosts' model lists (cached ones as they are), or the last complete
|
|
342
|
+
# listing (nil when there is none) when this one is not done within the
|
|
343
|
+
# wait. One listing runs at a time; a request that finds one running
|
|
344
|
+
# waits on it. A listing that raised raises here.
|
|
345
|
+
def await_model_lists(registry)
|
|
346
|
+
thread = @models_mutex.synchronize do
|
|
347
|
+
unless @models_thread&.alive?
|
|
348
|
+
@models_thread = Thread.new { registry.list_all_models(force: false) }
|
|
349
|
+
@models_thread.report_on_exception = false
|
|
350
|
+
end
|
|
351
|
+
@models_thread
|
|
352
|
+
end
|
|
353
|
+
thread.join(@models_wait_timeout) ? thread.value : registry.cached_results
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def sanitize_sort(val)
|
|
357
|
+
%w[created_at updated_at].include?(val.to_s) ? val.to_s : "updated_at"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def sanitize_order(val)
|
|
361
|
+
%w[asc desc].include?(val.to_s) ? val.to_s : "desc"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def sanitize_limit(val)
|
|
365
|
+
return nil if val.nil? || val.to_s.strip.empty?
|
|
366
|
+
n = val.to_i
|
|
367
|
+
return nil if n <= 0
|
|
368
|
+
[n, 1000].min
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def sanitize_offset(val)
|
|
372
|
+
return 0 if val.nil? || val.to_s.strip.empty?
|
|
373
|
+
n = val.to_i
|
|
374
|
+
n.positive? ? n : 0
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def handle_create(req)
|
|
378
|
+
body = parse_json(request_body(req))
|
|
379
|
+
unless body.is_a?(Hash)
|
|
380
|
+
return error_response(400, "invalid_json", "invalid JSON body")
|
|
381
|
+
end
|
|
382
|
+
prompt = body["prompt"] || body[:prompt]
|
|
383
|
+
# idle: a session with no first turn, for a first message with
|
|
384
|
+
# images (the page uploads them into it, then sends the turn).
|
|
385
|
+
idle = body["idle"] == true
|
|
386
|
+
if prompt.to_s.strip.empty? && !idle
|
|
387
|
+
return error_response(400, "missing_fields", "prompt is required")
|
|
388
|
+
end
|
|
389
|
+
# dir: the folder the chat starts in (the page's scope); without it,
|
|
390
|
+
# the server's own cwd.
|
|
391
|
+
dir, error = scope_dir(body["dir"])
|
|
392
|
+
return error if error
|
|
393
|
+
|
|
394
|
+
folder = dir ? { working_directory: dir } : {}
|
|
395
|
+
# model: the model the session starts on (the start page's picker,
|
|
396
|
+
# spelled as GET /api/models lists it); blank means the default.
|
|
397
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
398
|
+
folder[:model_name] = model unless model.empty?
|
|
399
|
+
begin
|
|
400
|
+
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
401
|
+
rescue ArgumentError => e
|
|
402
|
+
return error_response(400, "invalid_model", e.message) if e.message.match?(/SAMAGOTCHI_DEFAULT_MODEL/)
|
|
403
|
+
raise
|
|
404
|
+
end
|
|
405
|
+
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
406
|
+
# it so the client can attach without client-side polling.
|
|
407
|
+
port = await_bridge_port(session.id)
|
|
408
|
+
# The projection holds it before the page hears back (no tick wait).
|
|
409
|
+
@hub&.touch(session.id)
|
|
410
|
+
json_response(201, session_to_json(session).merge(bridge_port: port))
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def handle_show(req, id)
|
|
414
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
415
|
+
# Read-only preview: selecting a session never spawns a worker.
|
|
416
|
+
# The worker is woken only on POST /turn (handle_turn) via
|
|
417
|
+
# SessionManager.resume_session + write_turn_input. This avoids
|
|
418
|
+
# replaying last_prompt and spamming workers on preview scrub.
|
|
419
|
+
# last_event_seq is nil when no live bridge, so the frontend stays
|
|
420
|
+
# silent until the first Send.
|
|
421
|
+
#
|
|
422
|
+
# A live worker's snapshot is the truth (the session file lags its
|
|
423
|
+
# Engine): the messages, the turn in progress and the prompts queued
|
|
424
|
+
# behind it, all at one event_seq the client streams on from.
|
|
425
|
+
live = bridge_get_json(id, "snapshot")
|
|
426
|
+
turn_snapshot = live && live["snapshot"]
|
|
427
|
+
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
428
|
+
# body (the stream carries the card's plain text only).
|
|
429
|
+
if req.params["cards"] == "1"
|
|
430
|
+
return json_response(200, { cards: cards_for_display(turn_snapshot) })
|
|
431
|
+
end
|
|
432
|
+
snapshot = live && live["session_state_snapshot"]
|
|
433
|
+
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
434
|
+
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
435
|
+
pending = if turn_snapshot
|
|
436
|
+
current_turn && current_turn["pending_question"]
|
|
437
|
+
elsif session.respond_to?(:pending_question)
|
|
438
|
+
session.pending_question
|
|
439
|
+
end
|
|
440
|
+
owner = session_owner(id)
|
|
441
|
+
# A /model in the worker changes it before the file catches up.
|
|
442
|
+
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
443
|
+
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
444
|
+
# What the worker's server said it served for that model (after a turn).
|
|
445
|
+
if snapshot
|
|
446
|
+
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
|
|
447
|
+
end
|
|
448
|
+
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
449
|
+
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
|
|
450
|
+
# ?tail=1: the page's re-read at the end of a turn wants the final
|
|
451
|
+
# answer's rendered markdown, the status and the timing, not the
|
|
452
|
+
# whole history again (nor a render of every earlier answer).
|
|
453
|
+
if req.params["tail"] == "1"
|
|
454
|
+
return json_response(200, {
|
|
455
|
+
tail: true,
|
|
456
|
+
session: session_json,
|
|
457
|
+
messages: last_assistant_for_display(raw_messages),
|
|
458
|
+
markdown_warning: @markdown_renderer.warning,
|
|
459
|
+
timing: timing
|
|
460
|
+
})
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
json_response(200, {
|
|
464
|
+
session: session_json,
|
|
465
|
+
history: read_history(id),
|
|
466
|
+
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
|
|
467
|
+
current_turn: current_turn,
|
|
468
|
+
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
469
|
+
recap: turn_snapshot && turn_snapshot["recap"],
|
|
470
|
+
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
471
|
+
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
472
|
+
guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
|
|
473
|
+
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
474
|
+
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
475
|
+
init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
|
|
476
|
+
cards: cards_for_display(turn_snapshot),
|
|
477
|
+
# The composer's / autocomplete; the built-ins until a worker
|
|
478
|
+
# names its plugins' too.
|
|
479
|
+
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
480
|
+
markdown_warning: @markdown_renderer.warning,
|
|
481
|
+
pending_question: pending,
|
|
482
|
+
last_event_seq: last_event_seq,
|
|
483
|
+
# The stream cursor `<seq>-<epoch>`: a later worker resets it
|
|
484
|
+
# instead of taking the seq as its own. Only the snapshot has it.
|
|
485
|
+
last_event_id: turn_snapshot && turn_snapshot["event_id"],
|
|
486
|
+
timing: timing
|
|
487
|
+
})
|
|
488
|
+
rescue ArgumentError => e
|
|
489
|
+
error_response(404, "not_found", e.message)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# The worker's last cards and between-turns notices (Bridge
|
|
493
|
+
# snapshot[:cards]), a card's body as body_html: rendered markdown
|
|
494
|
+
# when the renderer is on, else the escaped text in a <pre>.
|
|
495
|
+
# @return [Array<Hash>] [] without a live worker
|
|
496
|
+
def cards_for_display(turn_snapshot)
|
|
497
|
+
Array(turn_snapshot && turn_snapshot["cards"]).filter_map do |card|
|
|
498
|
+
next unless card.is_a?(Hash)
|
|
499
|
+
next card unless card["type"].to_s == "card"
|
|
500
|
+
|
|
501
|
+
card.merge("body_html" => card_body_html(card["body"].to_s))
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def card_body_html(body)
|
|
506
|
+
return "" if body.strip.empty?
|
|
507
|
+
|
|
508
|
+
html = @markdown_renderer.render(body) if @markdown_renderer.available?
|
|
509
|
+
html || "<pre>#{CGI.escapeHTML(body)}</pre>"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def handle_question_answer(req, id)
|
|
513
|
+
session = @session_class.load(id, state_dir: default_state_dir) rescue nil
|
|
514
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session
|
|
515
|
+
|
|
516
|
+
body = parse_json(request_body(req))
|
|
517
|
+
unless body.is_a?(Hash)
|
|
518
|
+
return error_response(400, "invalid_json", "invalid JSON body")
|
|
519
|
+
end
|
|
520
|
+
qid = body["id"] || body[:id] || body["question_id"] || body[:question_id]
|
|
521
|
+
selected = body["selected"] || body[:selected] || body["selection"] || body[:selection]
|
|
522
|
+
freeform = body["freeform"] || body[:freeform] || body["other"] || body[:other]
|
|
523
|
+
# Allow payload nested under answer
|
|
524
|
+
if body["answer"].is_a?(Hash)
|
|
525
|
+
ans = body["answer"]
|
|
526
|
+
qid ||= ans["id"] || ans[:id]
|
|
527
|
+
selected ||= ans["selected"] || ans[:selected]
|
|
528
|
+
freeform ||= ans["freeform"] || ans[:freeform]
|
|
529
|
+
end
|
|
530
|
+
if qid.to_s.strip.empty?
|
|
531
|
+
return error_response(400, "missing_fields", "id is required")
|
|
532
|
+
end
|
|
533
|
+
# Try live Engine via Bridge first (in-process answer without file IPC)
|
|
534
|
+
client = bridge_client(id)
|
|
535
|
+
if client
|
|
536
|
+
begin
|
|
537
|
+
reply = client.answer(id: qid, selected: selected, freeform: freeform)
|
|
538
|
+
return json_response(200, { status: "answered", session_id: id, id: qid }) if reply.ok?
|
|
539
|
+
# Read after its deadline and dropped (see #worker_timeout).
|
|
540
|
+
return worker_timeout("the answer was not sent") if reply.status == 408
|
|
541
|
+
|
|
542
|
+
# Pass the bridge's verdict through: 409 = another client answered
|
|
543
|
+
# first (or the question was cancelled), 400 = invalid selection.
|
|
544
|
+
if [400, 409].include?(reply.status)
|
|
545
|
+
detail = reply.json&.dig("detail") || "answer rejected"
|
|
546
|
+
return error_response(reply.status, reply.status == 409 ? "question_not_pending" : "invalid_answer", detail)
|
|
547
|
+
end
|
|
548
|
+
rescue Errno::ETIMEDOUT
|
|
549
|
+
return worker_timeout("the answer was not sent")
|
|
550
|
+
rescue StandardError
|
|
551
|
+
nil
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
555
|
+
rescue ArgumentError => e
|
|
556
|
+
error_response(404, "not_found", e.message)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Leave the pending question unanswered (the card's Dismiss). A question
|
|
560
|
+
# only exists while its worker waits on it, so this needs a live bridge.
|
|
561
|
+
def handle_question_dismiss(req, id)
|
|
562
|
+
body = parse_json(request_body(req))
|
|
563
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
564
|
+
|
|
565
|
+
qid = body["id"].to_s
|
|
566
|
+
return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
|
|
567
|
+
|
|
568
|
+
client = bridge_client(id)
|
|
569
|
+
return error_response(503, "not_live", "no live bridge for session #{id}") unless client
|
|
570
|
+
|
|
571
|
+
reply = client.dismiss_question(id: qid)
|
|
572
|
+
case reply.status
|
|
573
|
+
when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
|
|
574
|
+
when 409 then error_response(409, "question_not_pending", reply.json&.dig("detail") || "question not pending")
|
|
575
|
+
when 408 then worker_timeout("the question was not dismissed")
|
|
576
|
+
when 404
|
|
577
|
+
error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "dismiss questions"))
|
|
578
|
+
else error_response(503, "not_live", "no live bridge for session #{id}")
|
|
579
|
+
end
|
|
580
|
+
rescue Errno::ETIMEDOUT
|
|
581
|
+
worker_timeout("the question was not dismissed")
|
|
582
|
+
rescue StandardError
|
|
583
|
+
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# A session command typed in the composer (/model, /models, !rollback,
|
|
587
|
+
# !cmd, /continue): the worker runs it (woken as for a turn) and every
|
|
588
|
+
# UI gets its :command_ran. Needs the worker's Bridge.
|
|
589
|
+
def handle_command(req, id)
|
|
590
|
+
body = parse_json(request_body(req))
|
|
591
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
592
|
+
|
|
593
|
+
line = body["line"].to_s.strip
|
|
594
|
+
return error_response(400, "missing_fields", "line is required") if line.empty?
|
|
595
|
+
|
|
596
|
+
@manager.resume_session(id, state_dir: @state_dir) if @manager.respond_to?(:resume_session)
|
|
597
|
+
client = live_bridge_client(id)
|
|
598
|
+
return error_response(503, "not_live", "no live bridge for session #{id}") unless client
|
|
599
|
+
|
|
600
|
+
reply = client.post_command(line: line, client_id: body["client_id"])
|
|
601
|
+
case reply.status
|
|
602
|
+
when 202 then json_response(202, reply.json || { status: "accepted" })
|
|
603
|
+
when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
|
|
604
|
+
when 408 then worker_timeout("the command was not run")
|
|
605
|
+
when 404
|
|
606
|
+
error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run commands"))
|
|
607
|
+
else error_response(503, "not_live", "no live bridge for session #{id}")
|
|
608
|
+
end
|
|
609
|
+
rescue SessionManager::OwnedByTUI => e
|
|
610
|
+
error_response(409, "owned_by_tui", e.message)
|
|
611
|
+
rescue ArgumentError => e
|
|
612
|
+
error_response(404, "not_found", e.message)
|
|
613
|
+
rescue Errno::ETIMEDOUT
|
|
614
|
+
worker_timeout("the command was not run")
|
|
615
|
+
rescue SystemCallError, IOError
|
|
616
|
+
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# The worker didn't take a request in time: its read timed out, or the
|
|
620
|
+
# Bridge read it after its deadline (408 deadline_passed) and dropped
|
|
621
|
+
# it. Either way +what+ didn't happen, and a frozen worker that wakes
|
|
622
|
+
# won't do it (BridgeClient::DEADLINE_SHARE).
|
|
623
|
+
def worker_timeout(what)
|
|
624
|
+
error_response(504, "worker_timeout", "the session's worker did not answer, so #{what}")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def handle_output(req, id)
|
|
628
|
+
# Query ?since= may be an ISO8601 time or numeric mtime; we treat it as Time parse.
|
|
629
|
+
since_param = req.params["since"]
|
|
630
|
+
since_time = parse_since(since_param)
|
|
631
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
632
|
+
raw = @manager.read_responses(id, since_time: since_time, state_dir: @state_dir)
|
|
633
|
+
# Strip wire tokens server-side so the client never sees <|tool_call> etc.
|
|
634
|
+
rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
635
|
+
json_response(200, { session_id: session.id, output: rendered })
|
|
636
|
+
rescue ArgumentError => e
|
|
637
|
+
error_response(404, "not_found", e.message)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def handle_turn(req, id)
|
|
641
|
+
body = parse_json(request_body(req))
|
|
642
|
+
unless body.is_a?(Hash)
|
|
643
|
+
return error_response(400, "invalid_json", "invalid JSON body")
|
|
644
|
+
end
|
|
645
|
+
prompt = body["prompt"] || body[:prompt]
|
|
646
|
+
client_id = body["client_id"] || body[:client_id]
|
|
647
|
+
if prompt.to_s.strip.empty?
|
|
648
|
+
return error_response(400, "missing_fields", "prompt is required")
|
|
649
|
+
end
|
|
650
|
+
images = turn_images(id, body["images"])
|
|
651
|
+
return error_response(400, "bad_images", images) if images.is_a?(String)
|
|
652
|
+
|
|
653
|
+
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images, state_dir: @state_dir,
|
|
654
|
+
manager: @manager, bridge: -> { live_bridge_client(id) })
|
|
655
|
+
case result[:status]
|
|
656
|
+
when :accepted then json_response(202, result[:ack])
|
|
657
|
+
when :refused then json_response(result[:code], result[:ack])
|
|
658
|
+
when :timeout then error_response(504, "worker_timeout", result[:ack]["detail"])
|
|
659
|
+
else error_response(500, "enqueue_failed", "could not write turn input")
|
|
660
|
+
end
|
|
661
|
+
rescue SessionManager::OwnedByTUI => e
|
|
662
|
+
error_response(409, "owned_by_tui", e.message)
|
|
663
|
+
rescue SessionManager::ImagesUnsupported => e
|
|
664
|
+
error_response(409, "images_unsupported", e.message)
|
|
665
|
+
rescue ArgumentError => e
|
|
666
|
+
error_response(404, "not_found", e.message)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# A turn's images as [{file:, name:}] (refs to uploads already in the
|
|
670
|
+
# session's images/), or a String saying what's wrong. Never a path.
|
|
671
|
+
def turn_images(id, raw)
|
|
672
|
+
return [] if raw.nil?
|
|
673
|
+
return "images must be a list" unless raw.is_a?(Array)
|
|
674
|
+
|
|
675
|
+
session_dir = image_session_dir(id)
|
|
676
|
+
return "unknown session" unless session_dir
|
|
677
|
+
|
|
678
|
+
raw.map do |image|
|
|
679
|
+
return "each image must be {file:, name:}" unless image.is_a?(Hash)
|
|
680
|
+
return "images are refs to uploaded files, not paths" if image.key?("path") || image.key?(:path)
|
|
681
|
+
return "unknown image" unless ImageStore.valid_ref?(session_dir, image)
|
|
682
|
+
|
|
683
|
+
{ file: (image["file"] || image[:file]).to_s, name: File.basename((image["name"] || image[:name]).to_s)[0, 120] }
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# The session's folder, or nil for an id that isn't one.
|
|
688
|
+
def image_session_dir(id)
|
|
689
|
+
return nil unless SESSION_ID_RE.match?(id.to_s)
|
|
690
|
+
|
|
691
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
692
|
+
Session.session_dir(id, state_dir: default_state_dir)
|
|
693
|
+
rescue ArgumentError
|
|
694
|
+
nil
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# POST /api/sessions/:id/images: the raw image as the body (a paste or
|
|
698
|
+
# a drop), ?name= for its file name. Stored like any other image
|
|
699
|
+
# (converted, downscaled); answers the ref the turn then names.
|
|
700
|
+
def handle_image_upload(req, id)
|
|
701
|
+
session_dir = image_session_dir(id)
|
|
702
|
+
return error_response(404, "not_found", "unknown session") unless session_dir
|
|
703
|
+
|
|
704
|
+
length = req.content_length.to_i
|
|
705
|
+
if length > MAX_IMAGE_UPLOAD_BYTES
|
|
706
|
+
return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
bytes = request_body(req, MAX_IMAGE_UPLOAD_BYTES + 1).to_s.b
|
|
710
|
+
if bytes.bytesize > MAX_IMAGE_UPLOAD_BYTES
|
|
711
|
+
return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
name = File.basename(req.params["name"].to_s)[0, 120]
|
|
715
|
+
ref = ImageStore.ingest(session_dir, bytes: bytes, name: name.empty? ? "image" : name, source: "user")
|
|
716
|
+
json_response(201, ref)
|
|
717
|
+
rescue ImageStore::Error => e
|
|
718
|
+
error_response(422, "bad_image", e.message)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# GET /api/sessions/:id/images/<hash>.<ext>: a stored image (only
|
|
722
|
+
# raster types; never svg), for the page's thumbnails.
|
|
723
|
+
def handle_image(_req, id, name)
|
|
724
|
+
session_dir = image_session_dir(id)
|
|
725
|
+
return not_found(path: "images/#{name}") unless session_dir && IMAGE_NAME_RE.match?(name.to_s)
|
|
726
|
+
|
|
727
|
+
path = File.join(session_dir, ImageStore::DIR, name)
|
|
728
|
+
return not_found(path: "images/#{name}") unless File.file?(path) && !File.symlink?(path)
|
|
729
|
+
|
|
730
|
+
body = File.binread(path)
|
|
731
|
+
[200, { "Content-Type" => IMAGE_TYPES.fetch(File.extname(name).delete(".")), "Content-Length" => body.bytesize.to_s,
|
|
732
|
+
"X-Content-Type-Options" => "nosniff", "Cache-Control" => "private, max-age=86400" }, [body]]
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# The session's Bridge, waiting briefly for one a resume just spawned.
|
|
736
|
+
# @return [BridgeClient, nil]
|
|
737
|
+
def live_bridge_client(id)
|
|
738
|
+
port = bridge_sidecar_port(id) || await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
|
|
739
|
+
port && BridgeClient.new(session_id: id, port: port, host: DEFAULT_HOST)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# The process holding the session: {"pid", "kind" => "worker"|"tui"}, or
|
|
743
|
+
# nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
|
|
744
|
+
def session_owner(id)
|
|
745
|
+
return nil unless @manager.respond_to?(:session_owner)
|
|
746
|
+
|
|
747
|
+
@manager.session_owner(id, state_dir: @state_dir)
|
|
748
|
+
rescue StandardError
|
|
749
|
+
nil
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def handle_cancel(req, id)
|
|
753
|
+
# Validate session exists
|
|
754
|
+
begin
|
|
755
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
756
|
+
rescue ArgumentError => e
|
|
757
|
+
return error_response(404, "not_found", e.message)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
body = request_body(req)
|
|
761
|
+
reason = "user"
|
|
762
|
+
unless body.nil? || body.strip.empty?
|
|
763
|
+
parsed = parse_json(body)
|
|
764
|
+
if parsed.is_a?(Hash)
|
|
765
|
+
r = parsed["reason"] || parsed[:reason] || parsed["cancellation_reason"]
|
|
766
|
+
r = r.to_s.strip
|
|
767
|
+
reason = if r.empty? then "user"
|
|
768
|
+
elsif CANCEL_REASONS.include?(r) then r
|
|
769
|
+
else "manual"
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Try direct bridge cancel first (in-process, low latency)
|
|
775
|
+
client = bridge_client(id)
|
|
776
|
+
if client
|
|
777
|
+
begin
|
|
778
|
+
reply = client.cancel(reason: reason)
|
|
779
|
+
if reply.status == 202
|
|
780
|
+
return json_response(202, { status: "cancel_requested", session_id: id, reason: reason, via: "bridge" })
|
|
781
|
+
end
|
|
782
|
+
# The bridge answers 409 when there is no active turn to cancel.
|
|
783
|
+
if reply.status == 409
|
|
784
|
+
return json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
|
|
785
|
+
end
|
|
786
|
+
rescue StandardError
|
|
787
|
+
nil
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
792
|
+
rescue ArgumentError => e
|
|
793
|
+
error_response(404, "not_found", e.message)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def handle_stop(_req, id)
|
|
797
|
+
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
798
|
+
# after it just means an immediate resume finds it (rare).
|
|
799
|
+
@manager.stop_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
|
|
800
|
+
@hub&.touch(id)
|
|
801
|
+
json_response(200, { status: "stopped", session_id: id })
|
|
802
|
+
rescue SessionManager::OwnedByTUI => e
|
|
803
|
+
error_response(409, "owned_by_tui", e.message)
|
|
804
|
+
rescue ArgumentError => e
|
|
805
|
+
error_response(404, "not_found", e.message)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# DELETE /api/sessions/:id: the session for good. A live worker is
|
|
809
|
+
# stopped first (the page's confirm says so); a chi REPL's session is
|
|
810
|
+
# refused.
|
|
811
|
+
def handle_delete(_req, id)
|
|
812
|
+
result = @manager.delete_session(id, state_dir: @state_dir, stop: true, wait: STOP_WAIT_SECONDS)
|
|
813
|
+
@hub&.touch(result[:id])
|
|
814
|
+
json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
|
|
815
|
+
rescue SessionManager::OwnedByTUI
|
|
816
|
+
error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
|
|
817
|
+
rescue SessionManager::DeleteRefused => e
|
|
818
|
+
error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
|
|
819
|
+
rescue ArgumentError => e
|
|
820
|
+
error_response(404, "not_found", e.message)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def handle_stream(req, id)
|
|
824
|
+
# Validate session exists
|
|
825
|
+
begin
|
|
826
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
827
|
+
rescue ArgumentError
|
|
828
|
+
return error_response(404, "not_found", "Session not found: #{id}")
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Single live transport: proxy to the session's Bridge. When no live
|
|
832
|
+
# worker exists there is nothing to stream — history is available via
|
|
833
|
+
# GET /api/sessions/:id/output.
|
|
834
|
+
bridge_port = bridge_sidecar_port(id)
|
|
835
|
+
# A resume just spawned the worker: its bridge may still be binding.
|
|
836
|
+
# Wait briefly for it so the first SSE connect lands on a live bridge
|
|
837
|
+
# instead of an instantly-closed empty 200 / 503 during startup.
|
|
838
|
+
if bridge_port.nil? && @bridge_wait_timeout.to_i.positive?
|
|
839
|
+
bridge_port = await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
|
|
840
|
+
end
|
|
841
|
+
return error_response(503, "not_live", "no live bridge for session #{id}") unless bridge_port
|
|
842
|
+
|
|
843
|
+
headers = {
|
|
844
|
+
"Content-Type" => "text/event-stream",
|
|
845
|
+
"Cache-Control" => "no-cache",
|
|
846
|
+
"Connection" => "keep-alive",
|
|
847
|
+
"X-Accel-Buffering" => "no",
|
|
848
|
+
"Access-Control-Allow-Origin" => "*"
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
# Handlers that buffer enumerable bodies before responding (rackup's
|
|
852
|
+
# WEBrick does `body.each` to completion) hang forever on an unbounded
|
|
853
|
+
# SSE body — the browser never even receives the status line. When the
|
|
854
|
+
# handler supports hijacking, stream frames straight to the socket
|
|
855
|
+
# instead of returning an enumerable.
|
|
856
|
+
if req.env["rack.hijack?"]
|
|
857
|
+
hijack = sse_hijack(id, req, bridge_port)
|
|
858
|
+
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
|
|
862
|
+
[200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
|
|
863
|
+
server_running: @server_running)]
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# GET /api/events: the session list as one SSE stream per tab. The
|
|
867
|
+
# first frame is the hub's snapshot (scoped by ?dir= as the list is),
|
|
868
|
+
# then `session` for an upsert and `session_gone` for a removal, with
|
|
869
|
+
# a `: ping` while idle. Frame ids are the hub's seq, for the log's
|
|
870
|
+
# sake: there is no replay, a reconnect starts with a fresh snapshot.
|
|
871
|
+
def handle_events(req)
|
|
872
|
+
return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
|
|
873
|
+
|
|
874
|
+
dir, error = scope_dir(req.params["dir"])
|
|
875
|
+
return error if error
|
|
876
|
+
|
|
877
|
+
root = dir && ProjectScope.root_for(dir)
|
|
878
|
+
headers = {
|
|
879
|
+
"Content-Type" => "text/event-stream",
|
|
880
|
+
"Cache-Control" => "no-cache",
|
|
881
|
+
"Connection" => "keep-alive",
|
|
882
|
+
"X-Accel-Buffering" => "no",
|
|
883
|
+
"Access-Control-Allow-Origin" => "*"
|
|
884
|
+
}
|
|
885
|
+
body = EventsBody.new(hub: @hub, project_root: root, heartbeat: @events_heartbeat, capacity: @events_queue,
|
|
886
|
+
server_running: @server_running)
|
|
887
|
+
# As handle_stream: hijack when the handler offers it (rackup's
|
|
888
|
+
# WEBrick buffers enumerable bodies), else an enumerable body.
|
|
889
|
+
if req.env["rack.hijack?"]
|
|
890
|
+
hijack = lambda do |io|
|
|
891
|
+
body.each do |chunk|
|
|
892
|
+
io.write(chunk)
|
|
893
|
+
io.flush
|
|
894
|
+
end
|
|
895
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
896
|
+
nil # the tab went away
|
|
897
|
+
end
|
|
898
|
+
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
[200, headers, body]
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Rack hijack lambda (env["rack.hijack?"] truthy): pipes the bridge's SSE
|
|
905
|
+
# frames directly to the client socket until it disconnects. The handler
|
|
906
|
+
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
907
|
+
# only body bytes go to the socket here.
|
|
908
|
+
def sse_hijack(id, req, port)
|
|
909
|
+
lambda do |io|
|
|
910
|
+
query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
|
|
911
|
+
body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
|
|
912
|
+
server_running: @server_running)
|
|
913
|
+
body.each { |chunk| io.write(chunk) }
|
|
914
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
915
|
+
nil # client went away — end the stream quietly
|
|
916
|
+
rescue StandardError => e
|
|
917
|
+
Log.warn(:web, "proxy_failed", sid: id, port: port, error: e.class.name, msg: e.message)
|
|
918
|
+
nil
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# Bounded wait for a freshly-spawned worker's bridge sidecar so the
|
|
923
|
+
# response hands the client a live transport without client-side polling.
|
|
924
|
+
# Returns the bridge port, or nil when it is not up by the deadline (the
|
|
925
|
+
# session still exists; EventSource auto-reconnect covers late binders).
|
|
926
|
+
def await_bridge_port(session_id, timeout: @bridge_wait_timeout)
|
|
927
|
+
return nil if timeout.nil? || timeout <= 0
|
|
928
|
+
|
|
929
|
+
BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar_port(session_id) }
|
|
930
|
+
rescue StandardError
|
|
931
|
+
nil
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# Port of the session's live Bridge (stale sidecars are removed), or nil.
|
|
935
|
+
def bridge_sidecar_port(session_id)
|
|
936
|
+
dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
|
|
937
|
+
BridgeClient.sidecar_port(dir, host: DEFAULT_HOST)
|
|
938
|
+
rescue StandardError
|
|
939
|
+
nil
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# @return [BridgeClient, nil] a client for the session's live Bridge
|
|
943
|
+
def bridge_client(session_id)
|
|
944
|
+
port = bridge_sidecar_port(session_id)
|
|
945
|
+
port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Read the live Engine state over the bridge (raw GET /session/:id/state).
|
|
949
|
+
# @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
|
|
950
|
+
def bridge_get_json(session_id, path)
|
|
951
|
+
bridge_client(session_id)&.get_json(path)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Monotonic SSE cursor for a live bridge session, else nil.
|
|
955
|
+
def bridge_event_seq(session_id)
|
|
956
|
+
bridge_client(session_id)&.event_seq
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def serve_index(req)
|
|
960
|
+
path = File.join(@public_dir, "index.html")
|
|
961
|
+
if File.file?(path)
|
|
962
|
+
body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
|
|
963
|
+
[200, {
|
|
964
|
+
"Content-Type" => "text/html; charset=utf-8",
|
|
965
|
+
"Content-Length" => body.bytesize.to_s,
|
|
966
|
+
"Cache-Control" => "no-store, no-cache, must-revalidate, max-age=0",
|
|
967
|
+
"Pragma" => "no-cache",
|
|
968
|
+
"Expires" => "0"
|
|
969
|
+
}, [body]]
|
|
970
|
+
else
|
|
971
|
+
[200, { "Content-Type" => "text/html" }, ["<h1>Chi Web</h1><p>Public dir missing: #{@public_dir}</p>"]]
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def serve_static(req)
|
|
976
|
+
# Serve files under public_dir for /assets/* and bare paths
|
|
977
|
+
rel = req.path_info.sub(%r{\A/(assets|public)/}, "")
|
|
978
|
+
rel = req.path_info.sub(%r{\A/}, "") if rel == req.path_info
|
|
979
|
+
# Prevent directory traversal
|
|
980
|
+
rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
|
|
981
|
+
full = File.join(@public_dir, rel)
|
|
982
|
+
if File.file?(full) && full.start_with?(@public_dir)
|
|
983
|
+
body = File.binread(full)
|
|
984
|
+
ctype = mime_type(full)
|
|
985
|
+
[200, { "Content-Type" => ctype, "Content-Length" => body.bytesize.to_s, "Cache-Control" => "public, max-age=3600" }, [body]]
|
|
986
|
+
else
|
|
987
|
+
not_found(path: req.path_info)
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def mime_type(path)
|
|
992
|
+
case File.extname(path).downcase
|
|
993
|
+
when ".html" then "text/html; charset=utf-8"
|
|
994
|
+
when ".js" then "application/javascript; charset=utf-8"
|
|
995
|
+
when ".css" then "text/css; charset=utf-8"
|
|
996
|
+
when ".json" then "application/json; charset=utf-8"
|
|
997
|
+
when ".svg" then "image/svg+xml"
|
|
998
|
+
else "application/octet-stream"
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# status is turn state (idle/running): SessionSummary.displayed_status,
|
|
1003
|
+
# unless the manager can't tell who owns a session (then the file's
|
|
1004
|
+
# word stands).
|
|
1005
|
+
def displayed_status(session, snapshot = nil, owner: session_owner(session.id))
|
|
1006
|
+
unless @manager.respond_to?(:session_owner)
|
|
1007
|
+
return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
|
|
1008
|
+
|
|
1009
|
+
return session.status
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
SessionSummary.displayed_status(session, snapshot, owner: owner)
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
# @param owner [Hash, nil] #session_owner; its kind is shown as `owner`
|
|
1016
|
+
def session_to_json(s, status: s.status, owner: nil, root_cache: nil)
|
|
1017
|
+
SessionSummary.build(s, status: status, owner: owner, root_cache: root_cache,
|
|
1018
|
+
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def timing_payload(session_id, live_metrics: nil)
|
|
1022
|
+
persisted = read_analytics(session_id)
|
|
1023
|
+
source = if live_metrics.is_a?(Hash)
|
|
1024
|
+
persisted.merge(live_metrics).merge(
|
|
1025
|
+
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
1026
|
+
"turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
|
|
1027
|
+
"tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
|
|
1028
|
+
)
|
|
1029
|
+
else
|
|
1030
|
+
persisted
|
|
1031
|
+
end
|
|
1032
|
+
started_at = source["started_at"]
|
|
1033
|
+
last_activity_at = source["last_activity_at"]
|
|
1034
|
+
{
|
|
1035
|
+
started_at: started_at,
|
|
1036
|
+
last_activity_at: last_activity_at,
|
|
1037
|
+
session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live_metrics.is_a?(Hash)),
|
|
1038
|
+
turn_records: Array(source["turn_records"]),
|
|
1039
|
+
tool_records: Array(source["tool_records"]),
|
|
1040
|
+
active_turn: source["active_turn"],
|
|
1041
|
+
active_tools: Array(source["active_tools"])
|
|
1042
|
+
}
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
def read_analytics(session_id)
|
|
1046
|
+
path = File.join(@session_class.session_dir(session_id, state_dir: default_state_dir), "analytics.json")
|
|
1047
|
+
return {} unless File.file?(path)
|
|
1048
|
+
|
|
1049
|
+
data = JSON.parse(File.read(path))
|
|
1050
|
+
data.is_a?(Hash) ? data : {}
|
|
1051
|
+
rescue JSON::ParserError, SystemCallError
|
|
1052
|
+
{}
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def merge_timing_records(persisted, live)
|
|
1056
|
+
(Array(persisted) + Array(live)).each_with_object({}) do |record, records|
|
|
1057
|
+
next unless record.is_a?(Hash)
|
|
1058
|
+
|
|
1059
|
+
id = record["id"] || record[:id]
|
|
1060
|
+
records[id] = record if id
|
|
1061
|
+
end.values
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def session_duration_ms(started_at, last_activity_at, active:)
|
|
1065
|
+
started = Time.iso8601(started_at.to_s)
|
|
1066
|
+
finished = active ? Time.now : Time.iso8601(last_activity_at.to_s)
|
|
1067
|
+
[((finished - started) * 1000).round, 0].max
|
|
1068
|
+
rescue ArgumentError
|
|
1069
|
+
nil
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# The recap saved with the session and how many user turns came since:
|
|
1073
|
+
# a live worker's (it counts against its Engine's messages), else
|
|
1074
|
+
# recap.json against the session file. A stopped session gets its
|
|
1075
|
+
# recap without a worker being woken.
|
|
1076
|
+
# @return [Hash, nil] {text:, turns_since:}
|
|
1077
|
+
def saved_recap_for(id, session, turn_snapshot)
|
|
1078
|
+
if turn_snapshot
|
|
1079
|
+
saved = turn_snapshot["saved_recap"]
|
|
1080
|
+
return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i }
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
|
|
1084
|
+
return nil unless saved
|
|
1085
|
+
|
|
1086
|
+
messages = Array(session.messages)
|
|
1087
|
+
since = messages.drop(saved[:covered].to_i).count { |m| (m[:role] || m["role"]).to_s == "user" }
|
|
1088
|
+
{ text: saved[:text], turns_since: since }
|
|
1089
|
+
rescue StandardError
|
|
1090
|
+
nil
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
def read_history(id)
|
|
1094
|
+
raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
|
|
1095
|
+
raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
1096
|
+
rescue StandardError
|
|
1097
|
+
[]
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
# @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
|
|
1101
|
+
# string keys from a Bridge snapshot
|
|
1102
|
+
# @param parts [Boolean] the turn view's reload (?parts=1): each
|
|
1103
|
+
# assistant message also carries what it did (MessageParts: thinking,
|
|
1104
|
+
# tool calls with params and output), and a message with nothing to
|
|
1105
|
+
# show but that (a text-less step) is kept with empty content
|
|
1106
|
+
def messages_for_display(msgs, parts: false)
|
|
1107
|
+
filtered = []
|
|
1108
|
+
list = Array(msgs)
|
|
1109
|
+
list.each_with_index do |m, index|
|
|
1110
|
+
role = (m[:role] || m["role"]).to_s
|
|
1111
|
+
content = (m[:content] || m["content"]).to_s
|
|
1112
|
+
if Samagotchi::ContextNote.note?(m)
|
|
1113
|
+
filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
|
|
1114
|
+
next
|
|
1115
|
+
end
|
|
1116
|
+
next if role == "system"
|
|
1117
|
+
next if role == "tool_response"
|
|
1118
|
+
|
|
1119
|
+
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
1120
|
+
did = parts && %w[model assistant].include?(role) ? message_parts(list, index) : nil
|
|
1121
|
+
next if stripped.empty? && did.nil?
|
|
1122
|
+
|
|
1123
|
+
norm_role = role == "model" ? "assistant" : role
|
|
1124
|
+
# normalize assistant vs model, keep user as is
|
|
1125
|
+
norm_role = "assistant" if norm_role == "assistant" || norm_role == "model"
|
|
1126
|
+
norm_role = "user" if norm_role == "user"
|
|
1127
|
+
next unless %w[user assistant].include?(norm_role)
|
|
1128
|
+
|
|
1129
|
+
message = { role: norm_role, content: stripped }
|
|
1130
|
+
images = m[:images] || m["images"]
|
|
1131
|
+
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1132
|
+
message[:html] = @markdown_renderer.render(stripped) if norm_role == "assistant" && !stripped.empty? && @markdown_renderer.available?
|
|
1133
|
+
message[:parts] = did if did
|
|
1134
|
+
filtered << message
|
|
1135
|
+
end
|
|
1136
|
+
filtered
|
|
1137
|
+
rescue StandardError
|
|
1138
|
+
[]
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# The parts of the assistant message at +index+, with the tool_response
|
|
1142
|
+
# messages right after it.
|
|
1143
|
+
def message_parts(list, index)
|
|
1144
|
+
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
1145
|
+
MessageParts.for_message(list[index], responses)
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# The last message messages_for_display shows as an answer, as a list of
|
|
1149
|
+
# at most one: walked backwards so only that one is rendered.
|
|
1150
|
+
def last_assistant_for_display(msgs)
|
|
1151
|
+
Array(msgs).reverse_each do |m|
|
|
1152
|
+
shown = messages_for_display([m]).first
|
|
1153
|
+
return [shown] if shown && shown[:role] == "assistant"
|
|
1154
|
+
end
|
|
1155
|
+
[]
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def parse_json(str)
|
|
1159
|
+
return nil if str.nil? || str.strip.empty?
|
|
1160
|
+
|
|
1161
|
+
JSON.parse(str)
|
|
1162
|
+
rescue JSON::ParserError
|
|
1163
|
+
nil
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
def parse_since(val)
|
|
1167
|
+
return nil if val.nil? || val.to_s.strip.empty?
|
|
1168
|
+
|
|
1169
|
+
# Try ISO8601, then float timestamp
|
|
1170
|
+
Time.iso8601(val.to_s)
|
|
1171
|
+
rescue ArgumentError
|
|
1172
|
+
begin
|
|
1173
|
+
Time.at(Float(val.to_s))
|
|
1174
|
+
rescue StandardError
|
|
1175
|
+
nil
|
|
1176
|
+
end
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def default_state_dir
|
|
1180
|
+
@state_dir || Session.default_state_dir
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# The sessions folder as the page shows it: ~ for the home folder.
|
|
1184
|
+
def sessions_dir_label
|
|
1185
|
+
home_label(File.expand_path(default_state_dir))
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
def home_label(dir)
|
|
1189
|
+
home = Dir.home
|
|
1190
|
+
return "~" if dir == home
|
|
1191
|
+
|
|
1192
|
+
dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# <body> data for the page: where sessions are stored, where an
|
|
1196
|
+
# all-view new chat starts (the server's cwd), and, when ?dir names a
|
|
1197
|
+
# folder in a repo, that project's name and root. A bad dir adds
|
|
1198
|
+
# nothing here: the list call answers 400 and the page says so.
|
|
1199
|
+
def index_data_attributes(req)
|
|
1200
|
+
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
|
|
1201
|
+
attrs["turn-view"] = "1" if turn_view?(req.params["view"])
|
|
1202
|
+
dir, = scope_dir(req.params["dir"])
|
|
1203
|
+
root = dir && ProjectScope.root_for(dir)
|
|
1204
|
+
if root
|
|
1205
|
+
attrs["project-name"] = File.basename(root)
|
|
1206
|
+
attrs["project-dir"] = home_label(root)
|
|
1207
|
+
elsif dir.nil?
|
|
1208
|
+
# The all view's way back: the project view it came from
|
|
1209
|
+
# (?from=), else the project chi web runs in.
|
|
1210
|
+
back, = scope_dir(req.params["from"])
|
|
1211
|
+
back ||= Dir.pwd
|
|
1212
|
+
if (back_root = ProjectScope.root_for(back))
|
|
1213
|
+
attrs["back-name"] = File.basename(back_root)
|
|
1214
|
+
attrs["back-dir"] = back
|
|
1215
|
+
end
|
|
1216
|
+
end
|
|
1217
|
+
attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
# The turn view for this page load: ?view=turn or ?view=chat wins,
|
|
1221
|
+
# anything else leaves the config's choice.
|
|
1222
|
+
def turn_view?(param)
|
|
1223
|
+
case param
|
|
1224
|
+
when "turn" then true
|
|
1225
|
+
when "chat" then false
|
|
1226
|
+
else @turn_view
|
|
1227
|
+
end
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def json_response(status, payload)
|
|
1231
|
+
body = JSON.generate(payload)
|
|
1232
|
+
[status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }, [body]]
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
# The request body, "" when there is none: WEBrick (through rackup)
|
|
1236
|
+
# raises LengthRequired on reading a POST that has neither a
|
|
1237
|
+
# Content-Length nor a chunked body (plain `curl -X POST`).
|
|
1238
|
+
def request_body(req, limit = nil)
|
|
1239
|
+
return "" if req.get_header("CONTENT_LENGTH").nil? && req.get_header("HTTP_TRANSFER_ENCODING").nil?
|
|
1240
|
+
|
|
1241
|
+
req.body.read(*limit)
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def error_response(status, code, detail)
|
|
1245
|
+
json_response(status, { error: code, detail: detail })
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def not_found(path:)
|
|
1250
|
+
error_response(404, "not_found", "not found: #{path}")
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
# One tab's GET /api/events: a bounded queue behind a lambda sink,
|
|
1254
|
+
# subscribed and snapshotted as one step under the hub's lock, then
|
|
1255
|
+
# drained onto the socket. The loop ends when the hub stops or the
|
|
1256
|
+
# server leaves :Running (rackup's WEBrick joins every request thread
|
|
1257
|
+
# before returning, so a loop that waited on the queue alone would
|
|
1258
|
+
# hang Ctrl-C while a tab is open), and when the queue overflowed:
|
|
1259
|
+
# the reconnect's snapshot is the recovery.
|
|
1260
|
+
class EventsBody
|
|
1261
|
+
# Subscribes now: the snapshot is the list as of the request, and
|
|
1262
|
+
# what changes between here and the socket queues up behind it.
|
|
1263
|
+
def initialize(hub:, project_root:, heartbeat:, capacity:, server_running:)
|
|
1264
|
+
@hub = hub
|
|
1265
|
+
@project_root = project_root
|
|
1266
|
+
@heartbeat = heartbeat
|
|
1267
|
+
@server_running = server_running
|
|
1268
|
+
@queue = Bridge::BoundedQueue.new(capacity: capacity)
|
|
1269
|
+
@handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
def each
|
|
1273
|
+
yield frame(nil, "snapshot", sessions: @snapshot)
|
|
1274
|
+
last_write = monotonic
|
|
1275
|
+
loop do
|
|
1276
|
+
event = @queue.pop([@heartbeat, EVENTS_POLL].min)
|
|
1277
|
+
if event
|
|
1278
|
+
break if @queue.overflow_dropped?
|
|
1279
|
+
|
|
1280
|
+
next unless in_scope?(event)
|
|
1281
|
+
|
|
1282
|
+
yield frame(event.seq, event.type, event.data)
|
|
1283
|
+
last_write = monotonic
|
|
1284
|
+
else
|
|
1285
|
+
# What was queued before the stop still goes out.
|
|
1286
|
+
break if @hub.stopped? || !@server_running.call
|
|
1287
|
+
|
|
1288
|
+
if monotonic - last_write >= @heartbeat
|
|
1289
|
+
yield ": ping\r\n\r\n"
|
|
1290
|
+
last_write = monotonic
|
|
1291
|
+
end
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
ensure
|
|
1295
|
+
close
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
# Rack calls it when the body is done with, served or not.
|
|
1299
|
+
def close
|
|
1300
|
+
@handle.unsubscribe
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
private
|
|
1304
|
+
|
|
1305
|
+
# A session outside ?dir's project is not this tab's (its removal
|
|
1306
|
+
# still goes out: harmless).
|
|
1307
|
+
def in_scope?(event)
|
|
1308
|
+
return true if @project_root.nil? || event.type != "session"
|
|
1309
|
+
|
|
1310
|
+
event.data[:session][:project_root] == @project_root
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def frame(id, type, data)
|
|
1314
|
+
lines = []
|
|
1315
|
+
lines << "id: #{id}" if id
|
|
1316
|
+
lines << "event: #{type}"
|
|
1317
|
+
JSON.generate(data).each_line { |line| lines << "data: #{line.chomp}" }
|
|
1318
|
+
lines.join("\r\n") + "\r\n\r\n"
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
def monotonic
|
|
1322
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1323
|
+
end
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Proxy body that streams from the per-session Bridge TCP server. It
|
|
1327
|
+
# ends when the Bridge closes the stream or, as EventsBody, when the
|
|
1328
|
+
# server leaves :Running: rackup's WEBrick joins every request thread
|
|
1329
|
+
# before returning, and a read blocked on a quiet worker would hang
|
|
1330
|
+
# Ctrl-C of chi web. The bytes go through unchanged.
|
|
1331
|
+
class ProxyStreamBody
|
|
1332
|
+
def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
|
|
1333
|
+
@client = BridgeClient.new(session_id: session_id, port: port, host: host)
|
|
1334
|
+
@query = query
|
|
1335
|
+
@headers = headers
|
|
1336
|
+
@server_running = server_running
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def each(&block)
|
|
1340
|
+
# Forward the browser's auto-reconnect cursor: the bridge prefers the
|
|
1341
|
+
# Last-Event-ID header over ?from_seq, and the reconnect URL carries a
|
|
1342
|
+
# stale initial cursor — without this the bridge would replay content
|
|
1343
|
+
# already delivered (duplicate bubbles).
|
|
1344
|
+
@client.stream(query: @query, last_event_id: @headers["HTTP_LAST_EVENT_ID"], running: @server_running, &block)
|
|
1345
|
+
end
|
|
1346
|
+
end
|
|
1347
|
+
end
|
|
1348
|
+
end
|
|
1349
|
+
end
|