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,993 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
require_relative "bridge/bounded_queue"
|
|
11
|
+
require_relative "bridge/card_store"
|
|
12
|
+
require_relative "bridge/event_id"
|
|
13
|
+
require_relative "bridge/ring_buffer"
|
|
14
|
+
require_relative "bridge/sse_writer"
|
|
15
|
+
require_relative "bridge/turn_accumulator"
|
|
16
|
+
require_relative "session"
|
|
17
|
+
require_relative "engine"
|
|
18
|
+
require_relative "session_commands"
|
|
19
|
+
require_relative "image_store"
|
|
20
|
+
require_relative "log"
|
|
21
|
+
|
|
22
|
+
module Samagotchi
|
|
23
|
+
# Bridge is an optional HTTP transport that lets an external web / desktop
|
|
24
|
+
# client reach the owner process (the forked session-worker) and receive the
|
|
25
|
+
# Engine's live event stream via Server-Sent Events, and optionally create a
|
|
26
|
+
# turn via HTTP POST.
|
|
27
|
+
#
|
|
28
|
+
# Design invariants (see multi_ui_architecture):
|
|
29
|
+
# * Reuses `Engine#subscribe` for SSE fan-out — never a second Engine, never
|
|
30
|
+
# `run_turn` across the thread/process boundary. POST reuses the
|
|
31
|
+
# `SessionManager` file-IPC input path.
|
|
32
|
+
# * One capture observer fills a shared ring buffer; each connection gets a
|
|
33
|
+
# per-connection live queue (see SSEWriter).
|
|
34
|
+
# * Binds 127.0.0.1 only. There is NO auth: binding 0.0.0.0 exposes remote
|
|
35
|
+
# turn-execution (RCE). Documented, not fixed.
|
|
36
|
+
# * Optional at runtime: this module is never loaded unless explicitly
|
|
37
|
+
# engaged as a transport.
|
|
38
|
+
class Bridge
|
|
39
|
+
DEFAULT_BIND = "127.0.0.1"
|
|
40
|
+
SIDECAR_FILE = "bridge.json"
|
|
41
|
+
DEFAULT_RING_CAPACITY = 256
|
|
42
|
+
DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
|
43
|
+
# Cancel reasons a client may name (the web sends user, an attached TUI
|
|
44
|
+
# ctrl_c); anything else is :manual, so client input never mints symbols.
|
|
45
|
+
CANCEL_REASONS = %w[manual user ctrl_c].freeze
|
|
46
|
+
# How long #stop waits for requests it is answering (not open streams).
|
|
47
|
+
REQUEST_GRACE_SECONDS = 1.0
|
|
48
|
+
# The largest request body read (images travel as refs, never bytes).
|
|
49
|
+
MAX_BODY_BYTES = 1_000_000
|
|
50
|
+
MAX_TURN_IMAGES = 20
|
|
51
|
+
# A request's deadline (see #handle_post_turn) that isn't epoch seconds.
|
|
52
|
+
BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
|
|
53
|
+
|
|
54
|
+
# @param engine [Samagotchi::Engine] the owning engine (must already live
|
|
55
|
+
# in this process)
|
|
56
|
+
# @param state_dir [String] the session state directory root
|
|
57
|
+
# @param session_id [String] this bridge's session id
|
|
58
|
+
# @param bind [String] bind address (127.0.0.1 only)
|
|
59
|
+
# @param port [Integer] port to bind (0 → OS-assigned, read back)
|
|
60
|
+
# @param ring_capacity [Integer] shared ring-buffer capacity
|
|
61
|
+
# @param heartbeat_interval [Float] idle `: ping` seconds
|
|
62
|
+
# @param input_format [Integer, nil] the input-file format the owning
|
|
63
|
+
# worker reads, advertised in the sidecar for writers (see
|
|
64
|
+
# SessionManager.write_turn_input); nil advertises none (plain text)
|
|
65
|
+
# @param on_input [#call, nil] called once a turn for this session is
|
|
66
|
+
# queued, to wake the worker loop (Worker::Waker#wake)
|
|
67
|
+
# @param on_command [#call, nil] takes a session command
|
|
68
|
+
# ({command_id:, client_id:, line:}) for the worker loop to run; without
|
|
69
|
+
# one, POST /command answers 501
|
|
70
|
+
# @param on_exit_request [#call, nil] a client asks the worker to exit
|
|
71
|
+
# now: takes the client_id and delete: (the exit is to delete the
|
|
72
|
+
# session, /exit --delete), returns nil when the worker will leave or
|
|
73
|
+
# the Symbol that keeps it up (WorkerIdleExit#hold_for_request); called
|
|
74
|
+
# with the event log held. Without one, POST /exit answers 501
|
|
75
|
+
# @param exit_discards [#call, nil] after an exit the worker agreed to:
|
|
76
|
+
# whether it will delete the session as empty (the 200 says
|
|
77
|
+
# +discard+); without one the reply leaves the field out
|
|
78
|
+
def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
|
|
79
|
+
port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
|
|
80
|
+
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
|
|
81
|
+
on_command: nil, on_exit_request: nil, exit_discards: nil)
|
|
82
|
+
@engine = engine
|
|
83
|
+
@on_input = on_input
|
|
84
|
+
@on_command = on_command
|
|
85
|
+
@on_exit_request = on_exit_request
|
|
86
|
+
@exit_discards = exit_discards
|
|
87
|
+
@input_format = input_format
|
|
88
|
+
@state_dir = state_dir
|
|
89
|
+
@session_id = session_id
|
|
90
|
+
@bind = bind
|
|
91
|
+
@port = port
|
|
92
|
+
@ring = RingBuffer.new(capacity: ring_capacity)
|
|
93
|
+
@accumulator = TurnAccumulator.new
|
|
94
|
+
@cards = CardStore.new
|
|
95
|
+
@heartbeat_interval = heartbeat_interval
|
|
96
|
+
@epoch = SecureRandom.hex(4)
|
|
97
|
+
|
|
98
|
+
@capture_handle = nil
|
|
99
|
+
@server = nil
|
|
100
|
+
@accept_thread = nil
|
|
101
|
+
@connection_threads = []
|
|
102
|
+
@stopped = false
|
|
103
|
+
@mutex = Monitor.new
|
|
104
|
+
@open_streams = 0
|
|
105
|
+
# Open streams by the client_id a stream named (`?client_id=`); web
|
|
106
|
+
# tabs, through the `chi web` proxy, name none.
|
|
107
|
+
@streams_by_client = Hash.new(0)
|
|
108
|
+
@last_client_activity_at = monotonic_now
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Boolean] whether the server has been stopped.
|
|
112
|
+
def stopped?
|
|
113
|
+
@mutex.synchronize { @stopped }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# This worker's epoch: event_seq starts over in each worker, so every
|
|
117
|
+
# event id and snapshot carries it (see EventId).
|
|
118
|
+
# @return [String]
|
|
119
|
+
attr_reader :epoch
|
|
120
|
+
|
|
121
|
+
# @return [Integer] SSE streams open now: one per attached TUI or web tab
|
|
122
|
+
def open_streams
|
|
123
|
+
@mutex.synchronize { @open_streams }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Streams held by anyone but +client_id+: a client's own stream (or two,
|
|
127
|
+
# while it reconnects and the old one isn't noticed dead yet) doesn't
|
|
128
|
+
# count. A closed stream counts until its next write fails (heartbeat).
|
|
129
|
+
# @return [Integer]
|
|
130
|
+
def open_streams_except(client_id)
|
|
131
|
+
@mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Monotonic time of the last request, stream open or stream close. The
|
|
135
|
+
# worker's idle exit counts from it. A connect that sends no request (the
|
|
136
|
+
# sidecar liveness probe) doesn't count.
|
|
137
|
+
# @return [Float]
|
|
138
|
+
def last_client_activity_at
|
|
139
|
+
@mutex.synchronize { @last_client_activity_at }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Bind the listen socket (OS-assigned when port == 0), register the shared
|
|
143
|
+
# capture observer, start the acceptor thread, and write the port sidecar.
|
|
144
|
+
# Non-blocking: returns once the socket is listening.
|
|
145
|
+
# @return [self]
|
|
146
|
+
def start
|
|
147
|
+
@server = TCPServer.new(@bind, @port)
|
|
148
|
+
@port = @server.local_address.ip_port
|
|
149
|
+
@capture_handle = @engine.subscribe(observer: capture_observer)
|
|
150
|
+
@accumulator_handle = @engine.subscribe(observer: @accumulator)
|
|
151
|
+
# A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
|
|
152
|
+
@engine.running_turn_messages = -> { @accumulator.current_messages } if @engine.respond_to?(:running_turn_messages=)
|
|
153
|
+
@cards_handle = @engine.subscribe(observer: @cards)
|
|
154
|
+
@accept_thread = Thread.new { accept_loop }
|
|
155
|
+
@accept_thread.report_on_exception = false
|
|
156
|
+
write_sidecar
|
|
157
|
+
self
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
stop
|
|
160
|
+
raise e
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Stop the acceptor, release the socket and remove the sidecar, so clients
|
|
164
|
+
# don't have to find it stale. Does not stop the owning turn. Joins the
|
|
165
|
+
# acceptor thread so the process can exit cleanly (Ruby waits for a thread
|
|
166
|
+
# blocked in IO.select at VM shutdown). Safe to call again.
|
|
167
|
+
def stop
|
|
168
|
+
@mutex.synchronize { @stopped = true }
|
|
169
|
+
begin
|
|
170
|
+
@server&.close
|
|
171
|
+
rescue StandardError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
@capture_handle&.unsubscribe
|
|
175
|
+
@accumulator_handle&.unsubscribe
|
|
176
|
+
@engine.running_turn_messages = nil if @engine.respond_to?(:running_turn_messages=)
|
|
177
|
+
@cards_handle&.unsubscribe
|
|
178
|
+
# A turn post killed between its enqueue and its reply looks failed to
|
|
179
|
+
# the web, which then queues the prompt again from the input file.
|
|
180
|
+
await_answers(REQUEST_GRACE_SECONDS)
|
|
181
|
+
@connection_threads.each { |t| t.kill rescue nil }
|
|
182
|
+
@connection_threads.clear
|
|
183
|
+
@accept_thread&.join(2)
|
|
184
|
+
remove_sidecar
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# What a joining client needs to render the session now, consistent with
|
|
189
|
+
# the event log: the Engine's messages (not the lagging copy on disk),
|
|
190
|
+
# the turn in progress, turns queued behind it, the idle recap since the
|
|
191
|
+
# last turn, the recap saved with the session (also from before the last
|
|
192
|
+
# turns: {text:, covered:, turns_since:, created_at:}), a pending continue offer, the guardrail and plugin load warnings,
|
|
193
|
+
# the plugins' init tasks still running (Engine#init_tasks), the
|
|
194
|
+
# last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
|
|
195
|
+
# what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
|
|
196
|
+
# Taken with the log held, so no event is half-applied.
|
|
197
|
+
# @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, guardrail_warning:,
|
|
198
|
+
# plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
|
|
199
|
+
def snapshot
|
|
200
|
+
@engine.synchronize_events do
|
|
201
|
+
seq = @engine.event_count
|
|
202
|
+
{
|
|
203
|
+
messages: @engine.messages_checkpoint,
|
|
204
|
+
current_turn: @accumulator.current_turn,
|
|
205
|
+
queued: @accumulator.queued,
|
|
206
|
+
recap: @accumulator.recap,
|
|
207
|
+
saved_recap: @engine.saved_recap,
|
|
208
|
+
continue_offer: @accumulator.continue_offer,
|
|
209
|
+
guardrail_warning: @engine.guardrail_warning,
|
|
210
|
+
plugin_warning: @engine.plugin_warning,
|
|
211
|
+
init_tasks: @engine.respond_to?(:init_tasks) ? @engine.init_tasks : [],
|
|
212
|
+
cards: @cards.list,
|
|
213
|
+
commands: command_registry.listing,
|
|
214
|
+
event_seq: seq,
|
|
215
|
+
event_id: event_id(seq)
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# The Engine's commands: the built-ins and its plugins'.
|
|
223
|
+
def command_registry
|
|
224
|
+
@engine.respond_to?(:command_registry) ? @engine.command_registry : SessionCommands.builtin_registry
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The single shared capture observer: appends every event to the ring.
|
|
228
|
+
# O(1) and non-blocking.
|
|
229
|
+
def capture_observer
|
|
230
|
+
@capture_proc ||= proc do |event|
|
|
231
|
+
@ring.push(seq: event[:event_seq], data: event)
|
|
232
|
+
rescue StandardError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Wait up to +timeout+ seconds for connection threads still answering a
|
|
238
|
+
# request (see handle_connection).
|
|
239
|
+
def await_answers(timeout)
|
|
240
|
+
deadline = monotonic_now + timeout
|
|
241
|
+
while monotonic_now < deadline
|
|
242
|
+
busy = @connection_threads.any? { |t| t != Thread.current && t.alive? && t[:bridge_answering] }
|
|
243
|
+
break unless busy
|
|
244
|
+
|
|
245
|
+
sleep(0.01)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def accept_loop
|
|
250
|
+
loop do
|
|
251
|
+
break if stopped? || @server.closed?
|
|
252
|
+
|
|
253
|
+
begin
|
|
254
|
+
rs, = IO.select([@server], nil, nil, 0.5)
|
|
255
|
+
rescue IOError
|
|
256
|
+
break
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
next unless rs
|
|
260
|
+
|
|
261
|
+
client =
|
|
262
|
+
begin
|
|
263
|
+
@server.accept
|
|
264
|
+
rescue IOError, Errno::EBADF
|
|
265
|
+
break
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
Thread.new { handle_connection(client) }.tap do |t|
|
|
269
|
+
t.report_on_exception = false
|
|
270
|
+
@connection_threads << t
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
# No accept loop, no bridge: every client of this worker loses it.
|
|
275
|
+
# (#stop closing the server ends the loop the same way: not a failure.)
|
|
276
|
+
Log.exception(:bridge, "accept_loop_failed", e) unless stopped?
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# One thread per connection. Short-lived endpoints (POST / state) answer
|
|
280
|
+
# and keep the connection open for another request; the SSE endpoint owns
|
|
281
|
+
# the connection until the client disconnects or the bridge stops.
|
|
282
|
+
def handle_connection(io)
|
|
283
|
+
io.binmode
|
|
284
|
+
loop do
|
|
285
|
+
request = read_request(io)
|
|
286
|
+
break if request.nil?
|
|
287
|
+
|
|
288
|
+
note_client_activity
|
|
289
|
+
started = monotonic_now
|
|
290
|
+
|
|
291
|
+
method = request[:method].to_s.upcase
|
|
292
|
+
headers = request[:headers]
|
|
293
|
+
# Every request but a stream gets its answer before #stop kills this
|
|
294
|
+
# thread; a stream never ends on its own.
|
|
295
|
+
Thread.current[:bridge_answering] = !(stream_match(request[:path]) && method == "GET")
|
|
296
|
+
|
|
297
|
+
if request[:too_large]
|
|
298
|
+
write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
|
|
299
|
+
break
|
|
300
|
+
elsif method == "OPTIONS"
|
|
301
|
+
write_json(io, 204, cors, {})
|
|
302
|
+
elsif (m = stream_match(request[:path])) && method == "GET"
|
|
303
|
+
cursor = reconnect_cursor(headers, request[:query])
|
|
304
|
+
Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
|
|
305
|
+
serve_sse(io, m[1], last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
|
|
306
|
+
client_id: stream_client_id(request[:query]))
|
|
307
|
+
break # SSE owns the connection until the client disconnects.
|
|
308
|
+
elsif (m = cancel_match(request[:path])) && method == "POST"
|
|
309
|
+
payload, status, body = handle_cancel(m[1], request[:body])
|
|
310
|
+
write_json(io, status, payload, body)
|
|
311
|
+
elsif (m = answer_match(request[:path])) && method == "POST"
|
|
312
|
+
payload, status, body = handle_answer(m[1], request[:body])
|
|
313
|
+
write_json(io, status, payload, body)
|
|
314
|
+
elsif (m = dismiss_match(request[:path])) && method == "POST"
|
|
315
|
+
payload, status, body = handle_dismiss_question(m[1], request[:body])
|
|
316
|
+
write_json(io, status, payload, body)
|
|
317
|
+
elsif (m = turn_match(request[:path])) && method == "POST"
|
|
318
|
+
payload, status, body = handle_post_turn(m[1], request[:body])
|
|
319
|
+
write_json(io, status, payload, body)
|
|
320
|
+
elsif (m = command_match(request[:path])) && method == "POST"
|
|
321
|
+
payload, status, body = handle_command(m[1], request[:body])
|
|
322
|
+
write_json(io, status, payload, body)
|
|
323
|
+
elsif (m = exit_match(request[:path])) && method == "POST"
|
|
324
|
+
payload, status, body = handle_exit_request(m[1], request[:body])
|
|
325
|
+
write_json(io, status, payload, body)
|
|
326
|
+
elsif (m = recap_match(request[:path])) && method == "POST"
|
|
327
|
+
payload, status, body = handle_recap(m[1])
|
|
328
|
+
write_json(io, status, payload, body)
|
|
329
|
+
elsif (m = state_match(request[:path])) && method == "GET"
|
|
330
|
+
payload, status, body = handle_state(m[1])
|
|
331
|
+
write_json(io, status, payload, body)
|
|
332
|
+
elsif (m = stats_match(request[:path])) && method == "GET"
|
|
333
|
+
payload, status, body = handle_stats(m[1])
|
|
334
|
+
write_json(io, status, payload, body)
|
|
335
|
+
elsif (m = snapshot_match(request[:path])) && method == "GET"
|
|
336
|
+
payload, status, body = handle_snapshot(m[1])
|
|
337
|
+
write_json(io, status, payload, body)
|
|
338
|
+
else
|
|
339
|
+
write_json(io, 404, { "Allow" => "GET, POST, OPTIONS" },
|
|
340
|
+
{ error: "not_found", path: request[:path] })
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
Thread.current[:bridge_answering] = false
|
|
344
|
+
log_request(method, request[:path], started)
|
|
345
|
+
break if close_after_request?(headers)
|
|
346
|
+
end
|
|
347
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
348
|
+
nil
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
# The thread doesn't report (report_on_exception = false): log it.
|
|
351
|
+
Log.exception(:bridge, "connection_failed", e)
|
|
352
|
+
ensure
|
|
353
|
+
Thread.current[:bridge_answering] = false
|
|
354
|
+
begin
|
|
355
|
+
io.close
|
|
356
|
+
rescue StandardError
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Serve an SSE stream. Owns the connection until the client disconnects.
|
|
362
|
+
# The connection thread IS the writer thread: serve! blocks until then.
|
|
363
|
+
def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil)
|
|
364
|
+
unless own_session?(session_id)
|
|
365
|
+
write_json(io, 404, {}, { error: "unknown_session" })
|
|
366
|
+
return
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
writer = SSEWriter.new(
|
|
370
|
+
engine: @engine,
|
|
371
|
+
ring: @ring,
|
|
372
|
+
session_id: @session_id,
|
|
373
|
+
last_event_id: last_event_id,
|
|
374
|
+
epoch: @epoch,
|
|
375
|
+
snapshot_provider: -> { @engine.session_state_snapshot },
|
|
376
|
+
turn_snapshot_provider: -> { self.snapshot },
|
|
377
|
+
# A reconnect (with a cursor) replays; only a fresh join snapshots.
|
|
378
|
+
join_with_snapshot: snapshot && last_event_id.nil?,
|
|
379
|
+
bridge: self,
|
|
380
|
+
heartbeat_interval: @heartbeat_interval
|
|
381
|
+
)
|
|
382
|
+
@mutex.synchronize do
|
|
383
|
+
@open_streams += 1
|
|
384
|
+
@streams_by_client[client_id] += 1 if client_id
|
|
385
|
+
end
|
|
386
|
+
begin
|
|
387
|
+
writer.serve!(io)
|
|
388
|
+
ensure
|
|
389
|
+
@mutex.synchronize do
|
|
390
|
+
@open_streams -= 1
|
|
391
|
+
if client_id
|
|
392
|
+
@streams_by_client[client_id] -= 1
|
|
393
|
+
@streams_by_client.delete(client_id) unless @streams_by_client[client_id].positive?
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
note_client_activity
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Never takes the event log: the lock order is events, then @mutex.
|
|
401
|
+
def note_client_activity
|
|
402
|
+
@mutex.synchronize { @last_client_activity_at = monotonic_now }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def monotonic_now
|
|
406
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def cors
|
|
410
|
+
{
|
|
411
|
+
"Access-Control-Allow-Origin" => "*",
|
|
412
|
+
"Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
|
|
413
|
+
"Access-Control-Allow-Headers" => "Content-Type, Last-Event-ID"
|
|
414
|
+
}
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def stream_match(path)
|
|
418
|
+
%r|\A/session/([^/]+)/stream\z|u.match(path.to_s)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def turn_match(path)
|
|
422
|
+
%r|\A/session/([^/]+)/turn\z|u.match(path.to_s)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def state_match(path)
|
|
426
|
+
%r|\A/session/([^/]+)/state\z|u.match(path.to_s)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def stats_match(path)
|
|
430
|
+
%r|\A/session/([^/]+)/stats\z|u.match(path.to_s)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def snapshot_match(path)
|
|
434
|
+
%r|\A/session/([^/]+)/snapshot\z|u.match(path.to_s)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def cancel_match(path)
|
|
438
|
+
%r|\A/session/([^/]+)/cancel\z|u.match(path.to_s)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def answer_match(path)
|
|
442
|
+
%r|\A/session/([^/]+)/answer\z|u.match(path.to_s)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def dismiss_match(path)
|
|
446
|
+
%r|\A/session/([^/]+)/question/dismiss\z|u.match(path.to_s)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def command_match(path)
|
|
450
|
+
%r|\A/session/([^/]+)/command\z|u.match(path.to_s)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def exit_match(path)
|
|
454
|
+
%r|\A/session/([^/]+)/exit\z|u.match(path.to_s)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def recap_match(path)
|
|
458
|
+
%r|\A/session/([^/]+)/recap\z|u.match(path.to_s)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# /recap in an attached TUI: the saved recap, and a new one asked for at
|
|
462
|
+
# once (it arrives as :recap_ready). Answers mid-turn too.
|
|
463
|
+
# 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
|
|
464
|
+
def handle_recap(session_id)
|
|
465
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
466
|
+
|
|
467
|
+
recap = @engine.recap
|
|
468
|
+
return [{}, 200, { enabled: false }] unless recap
|
|
469
|
+
|
|
470
|
+
saved = @engine.saved_recap
|
|
471
|
+
[{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
|
|
472
|
+
rescue StandardError => e
|
|
473
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Cancel the active turn on this session's engine, if any.
|
|
477
|
+
# Returns [headers, status, body].
|
|
478
|
+
def handle_cancel(session_id, body)
|
|
479
|
+
unless own_session?(session_id)
|
|
480
|
+
return [{}, 404, { error: "unknown_session" }]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Optional reason from JSON body
|
|
484
|
+
reason = :manual
|
|
485
|
+
if body && !body.strip.empty?
|
|
486
|
+
parsed = parse_json(body)
|
|
487
|
+
r = parsed.is_a?(Hash) ? (fetched(parsed, "reason") || fetched(parsed, "cancellation_reason")) : nil
|
|
488
|
+
reason = CANCEL_REASONS.include?(r.to_s.strip) ? r.to_s.strip.to_sym : :manual
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
if @engine.turn_running? && @engine.active_cancel_controller
|
|
492
|
+
ok = @engine.cancel_current_turn!(reason)
|
|
493
|
+
return [{}, 202, { status: "cancel_requested", session_id: @session_id, reason: reason.to_s }] if ok
|
|
494
|
+
|
|
495
|
+
[{}, 409, { error: "cancel_failed", detail: "could not cancel", session_id: @session_id }]
|
|
496
|
+
else
|
|
497
|
+
[{}, 409, { error: "not_running", detail: "no active turn to cancel", session_id: @session_id }]
|
|
498
|
+
end
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Answer the pending question. A past +deadline+ (see #handle_post_turn):
|
|
504
|
+
# 408 deadline_passed, and the question stays open. An answer takes no
|
|
505
|
+
# event hold, so it is checked right before it is recorded.
|
|
506
|
+
def handle_answer(session_id, body)
|
|
507
|
+
unless own_session?(session_id)
|
|
508
|
+
return [{}, 404, { error: "unknown_session" }]
|
|
509
|
+
end
|
|
510
|
+
parsed = parse_json(body)
|
|
511
|
+
unless parsed.is_a?(Hash)
|
|
512
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
|
|
513
|
+
end
|
|
514
|
+
qid = fetched(parsed, "id") || fetched(parsed, "question_id")
|
|
515
|
+
selected = fetched(parsed, "selected") || fetched(parsed, "selection")
|
|
516
|
+
freeform = fetched(parsed, "freeform") || fetched(parsed, "other")
|
|
517
|
+
# Support nested answer
|
|
518
|
+
if parsed["answer"].is_a?(Hash)
|
|
519
|
+
ans = parsed["answer"]
|
|
520
|
+
qid ||= fetched(ans, "id")
|
|
521
|
+
selected ||= fetched(ans, "selected")
|
|
522
|
+
freeform ||= fetched(ans, "freeform")
|
|
523
|
+
end
|
|
524
|
+
if qid.to_s.strip.empty?
|
|
525
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
526
|
+
end
|
|
527
|
+
deadline = fetched(parsed, "deadline")
|
|
528
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
529
|
+
return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
|
|
530
|
+
|
|
531
|
+
begin
|
|
532
|
+
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
|
|
533
|
+
[{}, 200, { status: "answered", session_id: session_id, answer: result }]
|
|
534
|
+
rescue Engine::QuestionNotPending => e
|
|
535
|
+
# Another client answered first, or the question was cancelled.
|
|
536
|
+
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
537
|
+
rescue ArgumentError => e
|
|
538
|
+
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
539
|
+
rescue StandardError => e
|
|
540
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Dismiss the pending question (an empty answer, as in the REPL): the
|
|
545
|
+
# tool returns without an answer and every UI gets :question_cancelled.
|
|
546
|
+
# Only the question the client saw, and only while nobody has answered
|
|
547
|
+
# it: Engine#cancel_question checks both under the question lock. A past
|
|
548
|
+
# +deadline+ (dismissing an approval denies it): 408, as for an answer.
|
|
549
|
+
# Returns [headers, status, body].
|
|
550
|
+
def handle_dismiss_question(session_id, body)
|
|
551
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
552
|
+
|
|
553
|
+
parsed = parse_json(body)
|
|
554
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
555
|
+
|
|
556
|
+
qid = fetched(parsed, "id").to_s
|
|
557
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }] if qid.strip.empty?
|
|
558
|
+
|
|
559
|
+
deadline = fetched(parsed, "deadline")
|
|
560
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
561
|
+
return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
|
|
562
|
+
|
|
563
|
+
dismissed = @engine.cancel_question("dismissed", id: qid)
|
|
564
|
+
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
|
|
565
|
+
|
|
566
|
+
[{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
|
|
567
|
+
rescue StandardError => e
|
|
568
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Queue a session command (/model, /models, !rollback, !cmd, /continue)
|
|
572
|
+
# for the worker loop, which runs it between turns and announces
|
|
573
|
+
# :command_ran (busy while a turn runs). Answers at once: the queueing
|
|
574
|
+
# and its :command_queued are one step of the event log, so the
|
|
575
|
+
# :command_ran always comes after. Only the syntax is checked here. A
|
|
576
|
+
# past +deadline+ (see #handle_post_turn), checked with the event log
|
|
577
|
+
# held: 408 deadline_passed, not run. Every command is checked, the
|
|
578
|
+
# read-only ones (/models) too: its client said it didn't run.
|
|
579
|
+
# Returns [headers, status, body].
|
|
580
|
+
def handle_command(session_id, body)
|
|
581
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
582
|
+
return [{}, 501, { error: "commands_unavailable" }] unless @on_command
|
|
583
|
+
|
|
584
|
+
parsed = parse_json(body)
|
|
585
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
586
|
+
|
|
587
|
+
line = fetched(parsed, "line").to_s.strip
|
|
588
|
+
# The Engine's own commands: the built-ins and its plugins' (a card's
|
|
589
|
+
# action is a plugin command line).
|
|
590
|
+
unless command_registry.command?(line)
|
|
591
|
+
known = command_registry.entries.reject(&:local).map { |entry| SessionCommands.display_name(entry) }.sort
|
|
592
|
+
detail = "not a session command: #{line[0, 80]} (known: #{known.join(", ")}; /help lists them)"
|
|
593
|
+
return [{ "Allow" => "POST" }, 400, { error: "unknown_command", detail: detail }]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
deadline = fetched(parsed, "deadline")
|
|
597
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
598
|
+
|
|
599
|
+
command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
|
|
600
|
+
queued = @engine.synchronize_events do
|
|
601
|
+
next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
|
|
602
|
+
|
|
603
|
+
@on_command.call(command)
|
|
604
|
+
# An anytime command runs now, beside a turn (D8): the UIs show its
|
|
605
|
+
# line here, so the cards it shows come after it.
|
|
606
|
+
anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
|
|
607
|
+
@engine.announce(type: :command_queued, **command, **anytime)
|
|
608
|
+
true
|
|
609
|
+
end
|
|
610
|
+
return deadline_passed("command") unless queued
|
|
611
|
+
|
|
612
|
+
[{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
|
|
613
|
+
rescue StandardError => e
|
|
614
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# A client asks the worker to exit now (`/exit` in the attached TUI). The
|
|
618
|
+
# worker decides with the event log held, so no POST /turn lands between
|
|
619
|
+
# its check and its answer; it leaves from its loop after this reply.
|
|
620
|
+
# 200 {status: "exiting", discard?: the session is empty and goes}, or 409 {status: "held", reason:} naming what
|
|
621
|
+
# keeps it up. Returns [headers, status, body].
|
|
622
|
+
def handle_exit_request(session_id, body)
|
|
623
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
624
|
+
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
625
|
+
|
|
626
|
+
parsed = parse_json(body)
|
|
627
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
628
|
+
|
|
629
|
+
client_id = fetched(parsed, "client_id")
|
|
630
|
+
delete = fetched(parsed, "delete") == true
|
|
631
|
+
reason = @engine.synchronize_events { @on_exit_request.call(client_id, delete: delete) }
|
|
632
|
+
if reason.nil?
|
|
633
|
+
body = { status: "exiting", session_id: @session_id }
|
|
634
|
+
body[:discard] = @exit_discards.call == true if @exit_discards
|
|
635
|
+
return [{}, 200, body]
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
[{}, 409, { status: "held", reason: reason.to_s, session_id: @session_id }]
|
|
639
|
+
rescue StandardError => e
|
|
640
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Create a turn via file IPC (fire-and-forget). Returns [headers, status, body].
|
|
644
|
+
#
|
|
645
|
+
# A +deadline+ (wall-clock epoch seconds; the client shares this machine's
|
|
646
|
+
# clock) is when the client stops waiting (BridgeClient#post_turn): a turn
|
|
647
|
+
# past it waited in the socket while this worker was frozen (a sleeping
|
|
648
|
+
# Mac, SIGSTOP) and its client has already said it was not sent, so it is
|
|
649
|
+
# dropped with 408 deadline_passed. It is checked with the event log held,
|
|
650
|
+
# right before the write, so nothing that holds the log (an exit check)
|
|
651
|
+
# can delay an accepted turn past it. No deadline (an older client): taken.
|
|
652
|
+
def handle_post_turn(session_id, body)
|
|
653
|
+
parsed = parse_json(body)
|
|
654
|
+
unless parsed.is_a?(Hash)
|
|
655
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
sid = fetched(parsed, "session_id")
|
|
659
|
+
prompt = fetched(parsed, "prompt")
|
|
660
|
+
client_id = fetched(parsed, "client_id")
|
|
661
|
+
# --no-interrupt: the turn runs with the raised iteration limit.
|
|
662
|
+
no_interrupt = fetched(parsed, "no_interrupt") == true
|
|
663
|
+
if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
|
|
664
|
+
return [{ "Allow" => "POST" }, 400,
|
|
665
|
+
{ error: "missing_fields", detail: "session_id and prompt are required" }]
|
|
666
|
+
end
|
|
667
|
+
images = turn_images(sid, fetched(parsed, "images"))
|
|
668
|
+
return [{}, 400, { error: "bad_images", detail: images }] if images.is_a?(String)
|
|
669
|
+
|
|
670
|
+
deadline = fetched(parsed, "deadline")
|
|
671
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
672
|
+
|
|
673
|
+
enqueued_id = SecureRandom.uuid
|
|
674
|
+
enqueued =
|
|
675
|
+
if own_session?(sid)
|
|
676
|
+
# Write and announce with the event log held: the worker can't
|
|
677
|
+
# emit this turn's :turn_started (or merge it mid-turn) before
|
|
678
|
+
# :turn_enqueued, and a failed write announces nothing.
|
|
679
|
+
@engine.synchronize_events do
|
|
680
|
+
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
681
|
+
|
|
682
|
+
enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
683
|
+
no_interrupt: no_interrupt, images: images).tap do |ok|
|
|
684
|
+
next unless ok
|
|
685
|
+
|
|
686
|
+
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
687
|
+
enqueued_event[:images] = images unless images.empty?
|
|
688
|
+
@engine.announce(enqueued_event)
|
|
689
|
+
@on_input&.call
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
elsif expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
693
|
+
:expired
|
|
694
|
+
else
|
|
695
|
+
enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
696
|
+
no_interrupt: no_interrupt, images: images)
|
|
697
|
+
end
|
|
698
|
+
return deadline_passed("turn") if enqueued == :expired
|
|
699
|
+
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
700
|
+
|
|
701
|
+
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: sid }]
|
|
702
|
+
rescue StandardError => e
|
|
703
|
+
# SessionManager loads lazily (enqueue_turn).
|
|
704
|
+
if defined?(SessionManager::ImagesUnsupported) && e.is_a?(SessionManager::ImagesUnsupported)
|
|
705
|
+
return [{}, 409, { error: "images_unsupported", detail: e.message }]
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Read-only snapshot surface. AC #4: too-old reconnects re-derive state
|
|
712
|
+
# from here.
|
|
713
|
+
def handle_state(session_id)
|
|
714
|
+
unless own_session?(session_id)
|
|
715
|
+
return [{}, 404, { error: "unknown_session" }]
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
state = @engine.session_state_snapshot
|
|
719
|
+
[{}, 200, { session_id: @session_id, session_state_snapshot: state.merge(event_id: event_id(state[:event_seq])) }]
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# /stats for an attached client: the metrics, with the window and prompt
|
|
723
|
+
# profile asked from the server when no turn has reported them yet (so,
|
|
724
|
+
# unlike /state, it may wait on one short /props GET).
|
|
725
|
+
def handle_stats(session_id)
|
|
726
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
727
|
+
|
|
728
|
+
[{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# The snapshot frame's content as one request, for a client that renders
|
|
732
|
+
# the messages elsewhere (the web server strips and formats them): it then
|
|
733
|
+
# streams from the snapshot's event_seq, and the ring replays what came
|
|
734
|
+
# after (or the stream resets). Returns [headers, status, body].
|
|
735
|
+
def handle_snapshot(session_id)
|
|
736
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
737
|
+
|
|
738
|
+
body = @engine.synchronize_events do
|
|
739
|
+
snap = snapshot
|
|
740
|
+
state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
|
|
741
|
+
{ snapshot: snap, session_state_snapshot: state }
|
|
742
|
+
end
|
|
743
|
+
[{}, 200, body]
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# The stream cursor for +seq+ in this worker.
|
|
747
|
+
def event_id(seq)
|
|
748
|
+
EventId.format(seq.to_i, @epoch)
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# A per-session bridge only ever owns one Engine (for @session_id). The
|
|
752
|
+
# stream and state surfaces must serve that session and nothing else —
|
|
753
|
+
# serving a different id's data (or closing with no response) would be a
|
|
754
|
+
# cross-session leak. POST/turn enqueue stays lenient (fire-and-forget to
|
|
755
|
+
# another worker's input dir) and validates via write_turn_input.
|
|
756
|
+
def own_session?(session_id)
|
|
757
|
+
session_id.to_s == @session_id.to_s
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# A request's +deadline+ (see #handle_post_turn) is epoch seconds or absent.
|
|
761
|
+
def deadline_valid?(deadline) = deadline.nil? || deadline.is_a?(Numeric)
|
|
762
|
+
|
|
763
|
+
# Whether a request's +deadline+ has passed; logs +event+ with +fields+
|
|
764
|
+
# and how late it was when it has.
|
|
765
|
+
def expired?(event, deadline, **fields)
|
|
766
|
+
return false if deadline.nil?
|
|
767
|
+
|
|
768
|
+
late = Time.now.to_f - deadline
|
|
769
|
+
return false unless late.positive?
|
|
770
|
+
|
|
771
|
+
Log.warn(:bridge, event, **fields, late: late.round(1))
|
|
772
|
+
true
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# The 408 for a request read after its deadline: +what+ (turn, command,
|
|
776
|
+
# answer, dismissal) was dropped.
|
|
777
|
+
def deadline_passed(what)
|
|
778
|
+
[{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Write a turn into the target session's input dir, reusing the file IPC
|
|
782
|
+
# the worker polls. Never calls run_turn across the boundary.
|
|
783
|
+
def enqueue_turn(session_id:, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
|
|
784
|
+
require_relative "session_manager"
|
|
785
|
+
Samagotchi::SessionManager.write_turn_input(
|
|
786
|
+
session_id, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, no_interrupt: no_interrupt,
|
|
787
|
+
state_dir: @state_dir, images: images
|
|
788
|
+
)
|
|
789
|
+
rescue LoadError
|
|
790
|
+
# SessionManager not available (e.g. bridge used standalone in a spec).
|
|
791
|
+
false
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# A turn's images as [{file:, name:}], or a String saying what's wrong.
|
|
795
|
+
# Only refs to files already in that session's images/ pass (a web
|
|
796
|
+
# upload): never a path, so no client can make the worker read a file.
|
|
797
|
+
def turn_images(session_id, raw)
|
|
798
|
+
return [] if raw.nil?
|
|
799
|
+
return "images must be a list" unless raw.is_a?(Array)
|
|
800
|
+
return "at most #{MAX_TURN_IMAGES} images" if raw.size > MAX_TURN_IMAGES
|
|
801
|
+
|
|
802
|
+
session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
|
|
803
|
+
raw.map do |image|
|
|
804
|
+
return "each image must be {file:, name:}" unless image.is_a?(Hash)
|
|
805
|
+
|
|
806
|
+
ref = ImageStore.symbolize(image)
|
|
807
|
+
return "images are refs to uploaded files, not paths" if ref.key?(:path)
|
|
808
|
+
return "unknown image #{ref[:file].to_s[0, 80]}" unless ImageStore.valid_ref?(session_dir, ref)
|
|
809
|
+
|
|
810
|
+
{ file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# ── HTTP plumbing ────────────────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
def read_request(io)
|
|
817
|
+
request_line = io.gets
|
|
818
|
+
return nil if request_line.nil? || request_line.empty?
|
|
819
|
+
|
|
820
|
+
method, target, = request_line.split(" ")
|
|
821
|
+
return nil if method.nil? || target.nil?
|
|
822
|
+
|
|
823
|
+
headers = {}
|
|
824
|
+
content_length = 0
|
|
825
|
+
while (line = io.gets)
|
|
826
|
+
break if line == "\r\n" || line == "\n"
|
|
827
|
+
|
|
828
|
+
key, value = line.split(":", 2)
|
|
829
|
+
next unless key
|
|
830
|
+
|
|
831
|
+
headers[key.strip.downcase] = value.to_s.strip
|
|
832
|
+
content_length = value.to_s.to_i if key.strip.downcase == "content-length"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Nothing a client sends is this big (images travel as refs): the body
|
|
836
|
+
# is skipped, not kept, so the client reads a 413 rather than a reset.
|
|
837
|
+
too_large = content_length > MAX_BODY_BYTES
|
|
838
|
+
skip_body(io, content_length) if too_large
|
|
839
|
+
body = content_length > 0 && !too_large ? io.read(content_length) : nil
|
|
840
|
+
path, query = split_target(target)
|
|
841
|
+
{ method: method, path: path, query: query, headers: headers, body: body, too_large: too_large }
|
|
842
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
843
|
+
nil
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def skip_body(io, length)
|
|
847
|
+
left = [length, 32 * MAX_BODY_BYTES].min
|
|
848
|
+
while left.positive?
|
|
849
|
+
chunk = io.read([left, 65_536].min)
|
|
850
|
+
break if chunk.nil? || chunk.empty?
|
|
851
|
+
|
|
852
|
+
left -= chunk.bytesize
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def split_target(target)
|
|
857
|
+
query = nil
|
|
858
|
+
path = target
|
|
859
|
+
if target.include?("?")
|
|
860
|
+
path, query = target.split("?", 2)
|
|
861
|
+
end
|
|
862
|
+
[URI.decode_www_form_component(path), query]
|
|
863
|
+
rescue StandardError
|
|
864
|
+
[target, nil]
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# One debug line per answered request (never its body): what #write_json
|
|
868
|
+
# last sent on this connection's thread.
|
|
869
|
+
def log_request(method, path, started)
|
|
870
|
+
return unless Log.level?(:debug)
|
|
871
|
+
|
|
872
|
+
Log.debug(:bridge, "request", method: method, path: path, status: Thread.current[:bridge_status],
|
|
873
|
+
ms: ((monotonic_now - started) * 1000).round)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def write_json(io, status, extra_headers, body)
|
|
877
|
+
Thread.current[:bridge_status] = status
|
|
878
|
+
extra_headers ||= {}
|
|
879
|
+
body ||= {}
|
|
880
|
+
data = JSON.generate(body)
|
|
881
|
+
reason = HTTP_REASONS.fetch(status, "OK")
|
|
882
|
+
headers = {
|
|
883
|
+
"Content-Type" => "application/json",
|
|
884
|
+
"Content-Length" => data.bytesize.to_s,
|
|
885
|
+
"Connection" => "close",
|
|
886
|
+
"Cache-Control" => "no-store",
|
|
887
|
+
"Access-Control-Allow-Origin" => "*"
|
|
888
|
+
}.merge(extra_headers)
|
|
889
|
+
|
|
890
|
+
io.write("HTTP/1.1 #{status} #{reason}\r\n")
|
|
891
|
+
headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
|
|
892
|
+
io.write("\r\n")
|
|
893
|
+
io.write(data) unless body.nil? || status == 204
|
|
894
|
+
io.flush
|
|
895
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# Parse a JSON body defensively; returns nil on failure.
|
|
900
|
+
def parse_json(body)
|
|
901
|
+
return nil if body.nil? || body.strip.empty?
|
|
902
|
+
|
|
903
|
+
JSON.parse(body)
|
|
904
|
+
rescue JSON::ParserError
|
|
905
|
+
nil
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def fetched(hash, key)
|
|
909
|
+
hash[key] || hash[key.to_sym]
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# Resume cursor for SSE: prefer the browser's native `Last-Event-ID`
|
|
913
|
+
# header (sent automatically on reconnect because we emit `id:` frames),
|
|
914
|
+
# else fall back to the explicit `?from_seq=` query param used by clients
|
|
915
|
+
# that remember their own cursor.
|
|
916
|
+
def reconnect_cursor(headers, query)
|
|
917
|
+
cursor = headers["last-event-id"]
|
|
918
|
+
return cursor if cursor && !cursor.empty?
|
|
919
|
+
|
|
920
|
+
return nil unless query
|
|
921
|
+
|
|
922
|
+
URI.decode_www_form(query).to_h["from_seq"]
|
|
923
|
+
rescue StandardError
|
|
924
|
+
nil
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# `?snapshot=1`: join with a snapshot frame instead of a replay.
|
|
928
|
+
def snapshot_requested?(query)
|
|
929
|
+
return false unless query
|
|
930
|
+
|
|
931
|
+
URI.decode_www_form(query).to_h["snapshot"].to_s == "1"
|
|
932
|
+
rescue StandardError
|
|
933
|
+
false
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# `?client_id=`: whose stream it is (see #open_streams_except).
|
|
937
|
+
def stream_client_id(query)
|
|
938
|
+
return nil unless query
|
|
939
|
+
|
|
940
|
+
id = URI.decode_www_form(query).to_h["client_id"].to_s
|
|
941
|
+
id.empty? ? nil : id
|
|
942
|
+
rescue StandardError
|
|
943
|
+
nil
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def close_after_request?(headers)
|
|
947
|
+
headers["connection"].to_s.downcase == "close"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def write_sidecar
|
|
951
|
+
record = {
|
|
952
|
+
"port" => @port,
|
|
953
|
+
"bind" => @bind,
|
|
954
|
+
"session_id" => @session_id,
|
|
955
|
+
"started_at" => Time.now.iso8601(3)
|
|
956
|
+
}
|
|
957
|
+
record["input_format"] = @input_format if @input_format
|
|
958
|
+
path = File.join(session_dir, SIDECAR_FILE)
|
|
959
|
+
FileUtils.mkdir_p(session_dir)
|
|
960
|
+
temp = "#{path}.tmp"
|
|
961
|
+
File.write(temp, JSON.pretty_generate(record) + "\n")
|
|
962
|
+
File.rename(temp, path)
|
|
963
|
+
rescue StandardError => e
|
|
964
|
+
Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{SIDECAR_FILE}: #{e.class}: #{e.message}", error: e.class.name)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Only while it still names this bridge: a racing worker for the same
|
|
968
|
+
# session may have written its own since.
|
|
969
|
+
def remove_sidecar
|
|
970
|
+
path = File.join(session_dir, SIDECAR_FILE)
|
|
971
|
+
return unless @server && File.file?(path)
|
|
972
|
+
|
|
973
|
+
File.unlink(path) if JSON.parse(File.read(path))["port"].to_i == @port
|
|
974
|
+
rescue StandardError
|
|
975
|
+
nil
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def session_dir
|
|
979
|
+
Session.session_dir(@session_id, state_dir: @state_dir)
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
HTTP_REASONS = {
|
|
983
|
+
200 => "OK",
|
|
984
|
+
202 => "Accepted",
|
|
985
|
+
204 => "No Content",
|
|
986
|
+
400 => "Bad Request",
|
|
987
|
+
404 => "Not Found",
|
|
988
|
+
409 => "Conflict",
|
|
989
|
+
500 => "Internal Server Error",
|
|
990
|
+
501 => "Not Implemented"
|
|
991
|
+
}.freeze
|
|
992
|
+
end
|
|
993
|
+
end
|