tempest-rb 0.1.2 → 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 +4 -4
- data/README.md +37 -19
- data/lib/tempest/cli.rb +2 -2
- data/lib/tempest/commands/tui.rb +40 -13
- data/lib/tempest/debug_log.rb +143 -23
- data/lib/tempest/jetstream/decoder.rb +4 -1
- data/lib/tempest/jetstream/stream_manager.rb +73 -19
- data/lib/tempest/jetstream/watchdog.rb +29 -6
- data/lib/tempest/post.rb +42 -2
- data/lib/tempest/repl/dispatcher.rb +1 -1
- data/lib/tempest/repl/formatter.rb +21 -0
- data/lib/tempest/repl/runner.rb +51 -3
- data/lib/tempest/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b22f62246911621115721bfa860e0e20227af1e183934c68d8ca1d04a964f809
|
|
4
|
+
data.tar.gz: 6988da4cf1585e253697b7b45ac1b2459ab8358d795937e242624081ae871f1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: abf73e75e9f73690fbdddfc588313a727b232de1b735146702f50a90801dace70241a326bc4245c5c16f142cea87e3ff8bc77146ebb292963898dcefc3359024
|
|
7
|
+
data.tar.gz: 86f61238ba980c20e770838b67ccfc2f68eef1fe6c3d26d2f17b083c1044ed4d4bd16415faffd5d764c939ae86dbd2e322da1cad839ac5999e9ec56e3c8f8c0e
|
data/README.md
CHANGED
|
@@ -37,6 +37,24 @@ bundle install
|
|
|
37
37
|
bundle exec exe/tempest
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Upgrading
|
|
41
|
+
|
|
42
|
+
The gem on RubyGems is `tempest-rb`, while the executable it ships is `tempest`.
|
|
43
|
+
|
|
44
|
+
If you installed it with `gem install`:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
gem update tempest-rb
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To check the currently installed version:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
tempest --version
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If you use Bundler in your own project, bump the requirement in your `Gemfile` and run `bundle update tempest-rb` instead.
|
|
57
|
+
|
|
40
58
|
## Usage
|
|
41
59
|
|
|
42
60
|
Set your credentials in the environment and run `tempest`:
|
|
@@ -73,6 +91,25 @@ Each post in the timeline is prefixed with a short `$XX` id, and URLs found insi
|
|
|
73
91
|
| `--no-stream` | Disable the auto-started Jetstream feed |
|
|
74
92
|
| `--feed=MODE` | `home` (default, your follows + your own posts) or `self` (only your own posts) |
|
|
75
93
|
|
|
94
|
+
### Non-interactive CLI
|
|
95
|
+
|
|
96
|
+
Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
tempest whoami --json
|
|
100
|
+
tempest post "今日もよろしくお願いします"
|
|
101
|
+
tempest feed me --since today --format json | jq '.text'
|
|
102
|
+
tempest feed author asonas.bsky.social --limit 20
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
|
|
106
|
+
|
|
107
|
+
`--format=raw` emits the underlying `getAuthorFeed`/`getTimeline` response pretty-printed; do not rely on its shape.
|
|
108
|
+
|
|
109
|
+
`--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
|
|
110
|
+
|
|
111
|
+
The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
|
|
112
|
+
|
|
76
113
|
### Environment variables
|
|
77
114
|
|
|
78
115
|
| Variable | Purpose |
|
|
@@ -101,25 +138,6 @@ A built-in watchdog runs alongside the Jetstream consumer regardless of logging:
|
|
|
101
138
|
|
|
102
139
|
To inspect the log, grep by component tag: `grep '\[stream\]' ~/tempest-debug.log` shows connect, reconnect, gap, and disconnect events, while `grep '\[watchdog\]' ~/tempest-debug.log` shows forced reconnects.
|
|
103
140
|
|
|
104
|
-
## Non-interactive CLI
|
|
105
|
-
|
|
106
|
-
Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
|
|
107
|
-
|
|
108
|
-
```sh
|
|
109
|
-
tempest whoami --json
|
|
110
|
-
tempest post "今日もよろしくお願いします"
|
|
111
|
-
tempest feed me --since today --format json | jq '.text'
|
|
112
|
-
tempest feed author asonas.bsky.social --limit 20
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
`--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
|
|
116
|
-
|
|
117
|
-
`--format=raw` emits the underlying `getAuthorFeed`/`getTimeline` response pretty-printed; do not rely on its shape.
|
|
118
|
-
|
|
119
|
-
`--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
|
|
120
|
-
|
|
121
|
-
The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
|
|
122
|
-
|
|
123
141
|
## Development
|
|
124
142
|
|
|
125
143
|
```sh
|
data/lib/tempest/cli.rb
CHANGED
|
@@ -98,8 +98,8 @@ module Tempest
|
|
|
98
98
|
Tempest::Commands::Tui.cursor_store(env)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def build_debug_logger(env)
|
|
102
|
-
Tempest::Commands::Tui.build_debug_logger(env)
|
|
101
|
+
def build_debug_logger(env, argv: [])
|
|
102
|
+
Tempest::Commands::Tui.build_debug_logger(env, argv: argv)
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def watchdog_options(env)
|
data/lib/tempest/commands/tui.rb
CHANGED
|
@@ -29,7 +29,8 @@ module Tempest
|
|
|
29
29
|
store: nil)
|
|
30
30
|
Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
|
|
31
31
|
|
|
32
|
-
debug_logger = build_debug_logger(env)
|
|
32
|
+
debug_logger = build_debug_logger(env, argv: argv)
|
|
33
|
+
announce_debug_logger(debug_logger, stderr)
|
|
33
34
|
|
|
34
35
|
store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
|
|
35
36
|
session = sign_in(env, stdout, stdin, session_factory, store: store)
|
|
@@ -184,12 +185,29 @@ module Tempest
|
|
|
184
185
|
Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
|
|
185
186
|
end
|
|
186
187
|
|
|
187
|
-
# Returns a
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
# Returns a Tempest::DebugLog::Channel. info.log is always enabled
|
|
189
|
+
# (suppress with TEMPEST_NO_LOG=1). debug.log is enabled when --debug is
|
|
190
|
+
# passed on the command line or TEMPEST_DEBUG=1 is set in the
|
|
191
|
+
# environment. The legacy TEMPEST_DEBUG_LOG=<path> env var still routes
|
|
192
|
+
# everything to a single file regardless of the other settings.
|
|
193
|
+
def build_debug_logger(env, argv: [])
|
|
194
|
+
Tempest::DebugLog.build(env: env, debug: debug_flag?(argv: argv, env: env))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def debug_flag?(argv:, env:)
|
|
198
|
+
return true if argv.include?("--debug")
|
|
199
|
+
env["TEMPEST_DEBUG"] == "1"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Print a one-line note on stderr so the user knows where to look for
|
|
203
|
+
# the log files. Silent when logging is disabled.
|
|
204
|
+
def announce_debug_logger(channel, stderr)
|
|
205
|
+
paths = channel.loggers.map { |l|
|
|
206
|
+
dev = l.instance_variable_get(:@logdev)
|
|
207
|
+
dev && dev.filename
|
|
208
|
+
}.compact
|
|
209
|
+
return if paths.empty?
|
|
210
|
+
stderr.puts "[tempest] debug log: #{paths.join(', ')}"
|
|
193
211
|
end
|
|
194
212
|
|
|
195
213
|
# Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
|
|
@@ -286,6 +304,11 @@ module Tempest
|
|
|
286
304
|
--feed=MODE Choose what the live feed subscribes to:
|
|
287
305
|
home (default) Your follows + your own posts
|
|
288
306
|
self Only your own posts (legacy echo mode)
|
|
307
|
+
--debug Also write a verbose debug.log alongside the
|
|
308
|
+
always-on info.log. Both files live under
|
|
309
|
+
$XDG_STATE_HOME/tempest (default
|
|
310
|
+
~/.local/state/tempest) and use size-based
|
|
311
|
+
rotation (5 MiB x 5 files).
|
|
289
312
|
|
|
290
313
|
Environment (required only when no cached session is available):
|
|
291
314
|
TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
|
|
@@ -306,13 +329,17 @@ module Tempest
|
|
|
306
329
|
$XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
|
|
307
330
|
time_us so a restart can replay missed events.
|
|
308
331
|
TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
332
|
+
TEMPEST_DEBUG Set to 1 to behave as if --debug was passed.
|
|
333
|
+
TEMPEST_LOG_DIR Override the directory holding info.log and debug.log.
|
|
334
|
+
Default: $XDG_STATE_HOME/tempest or
|
|
335
|
+
~/.local/state/tempest.
|
|
336
|
+
TEMPEST_NO_LOG Set to 1 to disable info.log/debug.log entirely.
|
|
337
|
+
TEMPEST_DEBUG_LOG Legacy: path to a single combined log file. When set,
|
|
338
|
+
every log line (DEBUG and above) is written to this
|
|
339
|
+
path in addition to the regular info.log/debug.log.
|
|
313
340
|
TEMPEST_DEBUG_LOG_LEVEL
|
|
314
|
-
DEBUG
|
|
315
|
-
verbosity
|
|
341
|
+
DEBUG (default) | INFO | WARN. Overrides the log
|
|
342
|
+
verbosity for the legacy TEMPEST_DEBUG_LOG file.
|
|
316
343
|
TEMPEST_WATCHDOG_THRESHOLD
|
|
317
344
|
Seconds without a Jetstream event before the watchdog
|
|
318
345
|
forces a reconnect (default: 90).
|
data/lib/tempest/debug_log.rb
CHANGED
|
@@ -3,15 +3,35 @@ require "fileutils"
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Tempest
|
|
6
|
-
#
|
|
6
|
+
# Structured diagnostic logging for tempest.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# without an `if logger` guard and without producing any output or file I/O.
|
|
8
|
+
# `Tempest::DebugLog.build` returns a `Channel` that fans messages out to one
|
|
9
|
+
# or more underlying `::Logger` instances. The format is logfmt-flavored
|
|
10
|
+
# single-line:
|
|
12
11
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# 2026-05-18T01:23:45+09:00 level=warn module=watchdog event=stalled_detected elapsed_seconds=612.3 threshold_seconds=600
|
|
13
|
+
#
|
|
14
|
+
# The fixed leading keys (`level=`, `module=`, `event=`) are produced by the
|
|
15
|
+
# formatter from the level + progname + first-positional arguments, so call
|
|
16
|
+
# sites just write the variable fields as keyword arguments:
|
|
17
|
+
#
|
|
18
|
+
# @logger.warn("watchdog", event: "stalled_detected", elapsed_seconds: 612.3, threshold_seconds: 600)
|
|
19
|
+
#
|
|
20
|
+
# Output destinations:
|
|
21
|
+
#
|
|
22
|
+
# * `info.log` — INFO and above, always written when logging is enabled.
|
|
23
|
+
# * `debug.log` — DEBUG and above, written only when `--debug` (or the
|
|
24
|
+
# equivalent flag passed to `build(debug: true)`) is on.
|
|
25
|
+
#
|
|
26
|
+
# Default base directory is `$XDG_STATE_HOME/tempest` (falling back to
|
|
27
|
+
# `~/.local/state/tempest`). Override via `TEMPEST_LOG_DIR=/path` for the
|
|
28
|
+
# whole tree, or set `TEMPEST_NO_LOG=1` to disable both files entirely (used
|
|
29
|
+
# by tests). The legacy `TEMPEST_DEBUG_LOG=/path/to/file` env var still
|
|
30
|
+
# works and routes everything (DEBUG and above) to a single file regardless
|
|
31
|
+
# of the other settings.
|
|
32
|
+
#
|
|
33
|
+
# All file destinations use size-based rotation (5 MiB x 5 files) so a
|
|
34
|
+
# long-running session can't fill the disk.
|
|
15
35
|
module DebugLog
|
|
16
36
|
LEVELS = {
|
|
17
37
|
"DEBUG" => Logger::DEBUG,
|
|
@@ -21,33 +41,39 @@ module Tempest
|
|
|
21
41
|
"FATAL" => Logger::FATAL,
|
|
22
42
|
}.freeze
|
|
23
43
|
|
|
44
|
+
DEFAULT_ROTATION_COUNT = 5
|
|
45
|
+
DEFAULT_ROTATION_SIZE = 5 * 1024 * 1024
|
|
46
|
+
|
|
24
47
|
module_function
|
|
25
48
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
def build(env:, debug: false)
|
|
50
|
+
loggers = []
|
|
51
|
+
|
|
52
|
+
legacy = env["TEMPEST_DEBUG_LOG"]
|
|
53
|
+
if legacy && !legacy.empty?
|
|
54
|
+
loggers << build_file_logger(legacy, level: resolve_level(env["TEMPEST_DEBUG_LOG_LEVEL"]) || Logger::DEBUG)
|
|
30
55
|
end
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
unless env["TEMPEST_NO_LOG"] == "1"
|
|
58
|
+
dir = log_dir(env)
|
|
59
|
+
loggers << build_file_logger(File.join(dir, "info.log"), level: Logger::INFO)
|
|
60
|
+
loggers << build_file_logger(File.join(dir, "debug.log"), level: Logger::DEBUG) if debug
|
|
61
|
+
end
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
logger.level = resolve_level(env["TEMPEST_DEBUG_LOG_LEVEL"]) || Logger::INFO
|
|
37
|
-
logger.formatter = formatter
|
|
38
|
-
logger
|
|
63
|
+
Channel.new(loggers: loggers)
|
|
39
64
|
end
|
|
40
65
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
logger.level = Logger::FATAL
|
|
44
|
-
logger
|
|
66
|
+
def null_channel
|
|
67
|
+
Channel.new(loggers: [])
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
def formatter
|
|
48
71
|
proc do |severity, time, progname, msg|
|
|
49
|
-
|
|
50
|
-
"
|
|
72
|
+
parts = []
|
|
73
|
+
parts << "level=#{severity.downcase}"
|
|
74
|
+
parts << "module=#{progname}" if progname && !progname.to_s.empty?
|
|
75
|
+
parts << msg if msg && !msg.to_s.empty?
|
|
76
|
+
"#{time.iso8601} #{parts.join(' ')}\n"
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
|
|
@@ -55,5 +81,99 @@ module Tempest
|
|
|
55
81
|
return nil if value.nil? || value.empty?
|
|
56
82
|
LEVELS[value.to_s.upcase]
|
|
57
83
|
end
|
|
84
|
+
|
|
85
|
+
def log_dir(env)
|
|
86
|
+
override = env["TEMPEST_LOG_DIR"]
|
|
87
|
+
return override if override && !override.empty?
|
|
88
|
+
|
|
89
|
+
xdg = env["XDG_STATE_HOME"]
|
|
90
|
+
base = if xdg && !xdg.empty?
|
|
91
|
+
xdg
|
|
92
|
+
else
|
|
93
|
+
File.join(env["HOME"] || Dir.home, ".local", "state")
|
|
94
|
+
end
|
|
95
|
+
File.join(base, "tempest")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_file_logger(path, level:)
|
|
99
|
+
path = File.expand_path(path)
|
|
100
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
101
|
+
logger = Logger.new(path, DEFAULT_ROTATION_COUNT, DEFAULT_ROTATION_SIZE)
|
|
102
|
+
logger.level = level
|
|
103
|
+
logger.formatter = formatter
|
|
104
|
+
logger
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def encode_value(value)
|
|
108
|
+
case value
|
|
109
|
+
when nil
|
|
110
|
+
"nil"
|
|
111
|
+
when true, false, Integer, Float, Symbol
|
|
112
|
+
value.to_s
|
|
113
|
+
when Time
|
|
114
|
+
value.iso8601
|
|
115
|
+
else
|
|
116
|
+
s = value.to_s
|
|
117
|
+
if s.empty?
|
|
118
|
+
'""'
|
|
119
|
+
elsif s.match?(/[\s"=]/)
|
|
120
|
+
'"' + s.gsub('\\', '\\\\\\\\').gsub('"', '\\"') + '"'
|
|
121
|
+
else
|
|
122
|
+
s
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Channel
|
|
128
|
+
attr_reader :loggers
|
|
129
|
+
|
|
130
|
+
def initialize(loggers:)
|
|
131
|
+
@loggers = Array(loggers)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def info(mod, event:, **fields)
|
|
135
|
+
emit(Logger::INFO, mod, event, fields)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def debug(mod, event:, **fields)
|
|
139
|
+
emit(Logger::DEBUG, mod, event, fields)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def warn(mod, event:, **fields)
|
|
143
|
+
emit(Logger::WARN, mod, event, fields)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def error(mod, event:, **fields)
|
|
147
|
+
emit(Logger::ERROR, mod, event, fields)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def close
|
|
151
|
+
@loggers.each do |logger|
|
|
152
|
+
begin
|
|
153
|
+
logger.close
|
|
154
|
+
rescue StandardError
|
|
155
|
+
# Best-effort: a half-built or already-closed logger should not
|
|
156
|
+
# take down shutdown.
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def emit(level, mod, event, fields)
|
|
164
|
+
return if @loggers.empty?
|
|
165
|
+
msg = format_body(event, fields)
|
|
166
|
+
@loggers.each { |logger| logger.add(level, msg, mod) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def format_body(event, fields)
|
|
170
|
+
parts = []
|
|
171
|
+
parts << "event=#{Tempest::DebugLog.encode_value(event)}" if event
|
|
172
|
+
fields.each do |k, v|
|
|
173
|
+
parts << "#{k}=#{Tempest::DebugLog.encode_value(v)}"
|
|
174
|
+
end
|
|
175
|
+
parts.join(" ")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
58
178
|
end
|
|
59
179
|
end
|
|
@@ -2,6 +2,7 @@ require "json"
|
|
|
2
2
|
|
|
3
3
|
require_relative "../../tempest"
|
|
4
4
|
require_relative "../facet"
|
|
5
|
+
require_relative "../post"
|
|
5
6
|
|
|
6
7
|
module Tempest
|
|
7
8
|
module Jetstream
|
|
@@ -18,10 +19,11 @@ module Tempest
|
|
|
18
19
|
:subject_uri,
|
|
19
20
|
:facets,
|
|
20
21
|
:reply_parent_uri,
|
|
22
|
+
:embed_kind,
|
|
21
23
|
) do
|
|
22
24
|
def initialize(kind:, did:, time_us:, collection:, operation:, rkey:, cid:,
|
|
23
25
|
text:, created_at:, subject_uri: nil, facets: [],
|
|
24
|
-
reply_parent_uri: nil)
|
|
26
|
+
reply_parent_uri: nil, embed_kind: nil)
|
|
25
27
|
super
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -72,6 +74,7 @@ module Tempest
|
|
|
72
74
|
subject_uri: subject.is_a?(Hash) ? subject["uri"] : nil,
|
|
73
75
|
facets: Tempest::Facet.parse(record["facets"]),
|
|
74
76
|
reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
|
|
77
|
+
embed_kind: Tempest::Post.embed_kind_from(record["embed"]),
|
|
75
78
|
)
|
|
76
79
|
rescue JSON::ParserError
|
|
77
80
|
nil
|
|
@@ -33,7 +33,7 @@ module Tempest
|
|
|
33
33
|
@cursor_store = cursor_store
|
|
34
34
|
@cursor_save_interval = cursor_save_interval
|
|
35
35
|
@filter = filter
|
|
36
|
-
@logger = logger || Tempest::DebugLog.
|
|
36
|
+
@logger = logger || Tempest::DebugLog.null_channel
|
|
37
37
|
@thread = nil
|
|
38
38
|
@mutex = Mutex.new
|
|
39
39
|
@stopping = false
|
|
@@ -50,10 +50,8 @@ module Tempest
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def stop
|
|
53
|
-
@
|
|
54
|
-
|
|
55
|
-
"stopping final_cursor=#{live.inspect}"
|
|
56
|
-
end
|
|
53
|
+
live = @mutex.synchronize { @cursor_state[:live] }
|
|
54
|
+
@logger.info("stream", event: "stopping", final_cursor: live)
|
|
57
55
|
@mutex.synchronize { @stopping = true }
|
|
58
56
|
thread = @mutex.synchronize do
|
|
59
57
|
t = @thread
|
|
@@ -84,7 +82,15 @@ module Tempest
|
|
|
84
82
|
def force_reconnect
|
|
85
83
|
thread = @mutex.synchronize { @thread }
|
|
86
84
|
return unless thread&.alive?
|
|
87
|
-
@logger.warn("stream"
|
|
85
|
+
@logger.warn("stream", event: "force_reconnect_requested")
|
|
86
|
+
# Pre-advance last_event_at so the watchdog's next tick sees a fresh
|
|
87
|
+
# connection and doesn't re-fire while the worker is still recovering.
|
|
88
|
+
# Without this, a second Stalled can land in the backoff sleep — which
|
|
89
|
+
# is outside the inner `rescue Stalled` block — and would historically
|
|
90
|
+
# take down the worker. The outer rescue in `run` now catches that
|
|
91
|
+
# case too, but suppressing the duplicate force_reconnect is still the
|
|
92
|
+
# right thing to do.
|
|
93
|
+
@mutex.synchronize { @last_event_at = @clock.call }
|
|
88
94
|
begin
|
|
89
95
|
thread.raise(Stalled.new("forced reconnect"))
|
|
90
96
|
rescue ThreadError
|
|
@@ -98,19 +104,28 @@ module Tempest
|
|
|
98
104
|
Thread.current.report_on_exception = false
|
|
99
105
|
cursor, startup_gap_since = load_initial_cursor
|
|
100
106
|
if startup_gap_since
|
|
101
|
-
@logger.warn("stream"
|
|
107
|
+
@logger.warn("stream", event: "startup_stale", stale_since: startup_gap_since)
|
|
102
108
|
on_event.call(StreamStatus.new(state: :gapped, since: startup_gap_since))
|
|
103
109
|
end
|
|
104
110
|
last_saved_cursor = cursor
|
|
105
111
|
last_save_at = nil
|
|
106
112
|
attempt = 0
|
|
107
113
|
|
|
108
|
-
@logger.info(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
@logger.info(
|
|
115
|
+
"stream",
|
|
116
|
+
event: "worker_start",
|
|
117
|
+
cursor: cursor,
|
|
118
|
+
cursor_age_seconds: cursor ? cursor_age_seconds(cursor) : nil,
|
|
119
|
+
)
|
|
112
120
|
|
|
113
121
|
until stopping?
|
|
122
|
+
# The outer `rescue Stalled` below catches Stalled raised by
|
|
123
|
+
# force_reconnect that lands OUTSIDE the inner each_event block —
|
|
124
|
+
# e.g. during @sleeper.call(delay), the cursor age check, or the
|
|
125
|
+
# disconnected/reconnecting status emission. Without this guard, a
|
|
126
|
+
# second force_reconnect arriving during recovery used to escape
|
|
127
|
+
# the inner rescue and silently kill the worker thread.
|
|
128
|
+
begin
|
|
114
129
|
# Detect a long offline gap from the cursor's age rather than from
|
|
115
130
|
# wall-clock disconnect timestamps. When the host machine sleeps,
|
|
116
131
|
# the background thread is suspended and we only learn about the
|
|
@@ -123,7 +138,12 @@ module Tempest
|
|
|
123
138
|
cursor_age = @clock.call.to_f - (cursor / 1_000_000.0)
|
|
124
139
|
if cursor_age > CURSOR_WINDOW_SECONDS
|
|
125
140
|
since = Time.at(cursor / 1_000_000.0)
|
|
126
|
-
@logger.warn(
|
|
141
|
+
@logger.warn(
|
|
142
|
+
"stream",
|
|
143
|
+
event: "gapped",
|
|
144
|
+
cursor_age_seconds: cursor_age.round(1),
|
|
145
|
+
since: since,
|
|
146
|
+
)
|
|
127
147
|
on_event.call(StreamStatus.new(state: :gapped, since: since))
|
|
128
148
|
cursor = nil
|
|
129
149
|
end
|
|
@@ -131,13 +151,19 @@ module Tempest
|
|
|
131
151
|
|
|
132
152
|
if attempt > 0
|
|
133
153
|
delay = @backoff[[attempt - 1, @backoff.length - 1].min]
|
|
134
|
-
@logger.info(
|
|
154
|
+
@logger.info(
|
|
155
|
+
"stream",
|
|
156
|
+
event: "reconnect_attempt",
|
|
157
|
+
attempt: attempt,
|
|
158
|
+
cursor: cursor,
|
|
159
|
+
backoff_just_slept_seconds: delay,
|
|
160
|
+
)
|
|
135
161
|
on_event.call(StreamStatus.new(state: :reconnecting))
|
|
136
162
|
end
|
|
137
163
|
|
|
138
164
|
error = nil
|
|
139
165
|
saw_event = false
|
|
140
|
-
@logger.info("stream"
|
|
166
|
+
@logger.info("stream", event: "subscribe", cursor: cursor, attempt: attempt)
|
|
141
167
|
begin
|
|
142
168
|
@client.each_event(cursor: cursor) do |event|
|
|
143
169
|
now = @clock.call
|
|
@@ -154,7 +180,7 @@ module Tempest
|
|
|
154
180
|
if @cursor_store && cursor != last_saved_cursor
|
|
155
181
|
if last_save_at.nil? || (now - last_save_at) >= @cursor_save_interval
|
|
156
182
|
@cursor_store.save(time_us: cursor, at: now)
|
|
157
|
-
@logger.debug("stream"
|
|
183
|
+
@logger.debug("stream", event: "cursor_save", cursor: cursor)
|
|
158
184
|
last_saved_cursor = cursor
|
|
159
185
|
last_save_at = now
|
|
160
186
|
@mutex.synchronize { @cursor_state[:saved] = cursor }
|
|
@@ -164,6 +190,7 @@ module Tempest
|
|
|
164
190
|
next if @filter && !@filter.call(event)
|
|
165
191
|
|
|
166
192
|
if attempt > 0 && !saw_event
|
|
193
|
+
@logger.info("stream", event: "live_resumed", attempt: attempt, cursor: cursor)
|
|
167
194
|
on_event.call(StreamStatus.new(state: :live))
|
|
168
195
|
end
|
|
169
196
|
saw_event = true
|
|
@@ -171,11 +198,25 @@ module Tempest
|
|
|
171
198
|
end
|
|
172
199
|
rescue Stalled => e
|
|
173
200
|
error = e
|
|
174
|
-
@logger.warn(
|
|
201
|
+
@logger.warn(
|
|
202
|
+
"stream",
|
|
203
|
+
event: "disconnected",
|
|
204
|
+
reason: "stalled",
|
|
205
|
+
cursor: cursor,
|
|
206
|
+
error_class: e.class.name,
|
|
207
|
+
error_message: e.message,
|
|
208
|
+
)
|
|
175
209
|
on_event.call(StreamError.new(e))
|
|
176
210
|
rescue => e
|
|
177
211
|
error = e
|
|
178
|
-
@logger.warn(
|
|
212
|
+
@logger.warn(
|
|
213
|
+
"stream",
|
|
214
|
+
event: "disconnected",
|
|
215
|
+
reason: "error",
|
|
216
|
+
cursor: cursor,
|
|
217
|
+
error_class: e.class.name,
|
|
218
|
+
error_message: e.message,
|
|
219
|
+
)
|
|
179
220
|
on_event.call(StreamError.new(e))
|
|
180
221
|
end
|
|
181
222
|
|
|
@@ -186,7 +227,7 @@ module Tempest
|
|
|
186
227
|
if @cursor_store && cursor && cursor != last_saved_cursor
|
|
187
228
|
now = @clock.call
|
|
188
229
|
@cursor_store.save(time_us: cursor, at: now)
|
|
189
|
-
@logger.debug("stream"
|
|
230
|
+
@logger.debug("stream", event: "cursor_save_on_disconnect", cursor: cursor)
|
|
190
231
|
last_saved_cursor = cursor
|
|
191
232
|
last_save_at = now
|
|
192
233
|
@mutex.synchronize { @cursor_state[:saved] = cursor }
|
|
@@ -203,9 +244,22 @@ module Tempest
|
|
|
203
244
|
delay = @backoff[[attempt, @backoff.length - 1].min]
|
|
204
245
|
@sleeper.call(delay)
|
|
205
246
|
attempt += 1
|
|
247
|
+
rescue Stalled => e
|
|
248
|
+
# Stalled landed outside the inner each_event rescue (typically
|
|
249
|
+
# in @sleeper.call). Treat it as a transient blip and let the loop
|
|
250
|
+
# try again instead of letting the worker thread die.
|
|
251
|
+
@logger.warn(
|
|
252
|
+
"stream",
|
|
253
|
+
event: "stalled_outside_each_event",
|
|
254
|
+
attempt: attempt,
|
|
255
|
+
cursor: cursor,
|
|
256
|
+
error_message: e.message,
|
|
257
|
+
)
|
|
258
|
+
attempt += 1
|
|
259
|
+
end
|
|
206
260
|
end
|
|
207
261
|
|
|
208
|
-
@logger.info("stream"
|
|
262
|
+
@logger.info("stream", event: "worker_exit", final_cursor: cursor)
|
|
209
263
|
end
|
|
210
264
|
|
|
211
265
|
def cursor_age_seconds(cursor)
|
|
@@ -31,7 +31,7 @@ module Tempest
|
|
|
31
31
|
@interval_seconds = interval_seconds
|
|
32
32
|
@clock = clock
|
|
33
33
|
@sleeper = sleeper
|
|
34
|
-
@logger = logger || Tempest::DebugLog.
|
|
34
|
+
@logger = logger || Tempest::DebugLog.null_channel
|
|
35
35
|
@thread = nil
|
|
36
36
|
@mutex = Mutex.new
|
|
37
37
|
@stopping = false
|
|
@@ -70,21 +70,44 @@ module Tempest
|
|
|
70
70
|
|
|
71
71
|
def tick
|
|
72
72
|
last = @stream_manager.last_event_at
|
|
73
|
-
|
|
73
|
+
running = @stream_manager.running?
|
|
74
|
+
@logger.debug(
|
|
75
|
+
"watchdog",
|
|
76
|
+
event: "tick",
|
|
77
|
+
running: running,
|
|
78
|
+
last_event_at: last,
|
|
79
|
+
elapsed_seconds: last ? (@clock.call - last).round(1) : nil,
|
|
80
|
+
threshold_seconds: @threshold_seconds,
|
|
81
|
+
)
|
|
82
|
+
return unless running
|
|
74
83
|
return if last.nil?
|
|
75
84
|
|
|
76
85
|
elapsed = @clock.call - last
|
|
77
86
|
return unless elapsed > @threshold_seconds
|
|
78
87
|
|
|
79
|
-
@logger.warn(
|
|
80
|
-
"
|
|
81
|
-
|
|
88
|
+
@logger.warn(
|
|
89
|
+
"watchdog",
|
|
90
|
+
event: "stalled_detected",
|
|
91
|
+
elapsed_seconds: elapsed.round(1),
|
|
92
|
+
threshold_seconds: @threshold_seconds,
|
|
93
|
+
last_event_at: last,
|
|
94
|
+
)
|
|
95
|
+
@logger.warn(
|
|
96
|
+
"watchdog",
|
|
97
|
+
event: "force_reconnect_requested",
|
|
98
|
+
elapsed_seconds: elapsed.round(1),
|
|
99
|
+
)
|
|
82
100
|
@stream_manager.force_reconnect
|
|
83
101
|
rescue StandardError => e
|
|
84
102
|
# Never let a bad clock, logger, or stream_manager bug kill the thread.
|
|
85
103
|
# Best-effort log; if logger also raises, swallow.
|
|
86
104
|
begin
|
|
87
|
-
@logger.error(
|
|
105
|
+
@logger.error(
|
|
106
|
+
"watchdog",
|
|
107
|
+
event: "tick_error",
|
|
108
|
+
error_class: e.class.name,
|
|
109
|
+
error_message: e.message,
|
|
110
|
+
)
|
|
88
111
|
rescue StandardError
|
|
89
112
|
end
|
|
90
113
|
end
|
data/lib/tempest/post.rb
CHANGED
|
@@ -2,9 +2,18 @@ require_relative "../tempest"
|
|
|
2
2
|
require_relative "facet"
|
|
3
3
|
|
|
4
4
|
module Tempest
|
|
5
|
-
Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri) do
|
|
5
|
+
Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri, :embed_kind) do
|
|
6
|
+
# AT Protocol embed `$type` values mapped to short symbols used by the
|
|
7
|
+
# REPL. `record` (quote) and `external` (link card) are intentionally
|
|
8
|
+
# absent: they're surfaced through other UI (URL annotation), so they
|
|
9
|
+
# don't get a media-marker emoji.
|
|
10
|
+
EMBED_KINDS = {
|
|
11
|
+
"app.bsky.embed.images" => :images,
|
|
12
|
+
"app.bsky.embed.video" => :video,
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
6
15
|
def initialize(uri:, cid:, handle:, display_name:, text:, created_at:,
|
|
7
|
-
facets: [], reply_parent_uri: nil)
|
|
16
|
+
facets: [], reply_parent_uri: nil, embed_kind: nil)
|
|
8
17
|
super
|
|
9
18
|
end
|
|
10
19
|
|
|
@@ -23,9 +32,20 @@ module Tempest
|
|
|
23
32
|
created_at: record["createdAt"],
|
|
24
33
|
facets: Facet.parse(record["facets"]),
|
|
25
34
|
reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
|
|
35
|
+
embed_kind: embed_kind_from(post["embed"] || record["embed"]),
|
|
26
36
|
)
|
|
27
37
|
end
|
|
28
38
|
|
|
39
|
+
# The view-side `$type` carries a `#view` suffix (e.g.
|
|
40
|
+
# `app.bsky.embed.images#view`); the raw record uses the bare form.
|
|
41
|
+
# Strip the suffix before looking up so both feed and Jetstream payloads
|
|
42
|
+
# classify identically.
|
|
43
|
+
def self.embed_kind_from(embed)
|
|
44
|
+
return nil unless embed.is_a?(Hash)
|
|
45
|
+
type = embed["$type"].to_s.sub(/#view\z/, "")
|
|
46
|
+
EMBED_KINDS[type]
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
# Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post).
|
|
30
50
|
# When `reply` is provided, both root and parent are set to the same
|
|
31
51
|
# target. This is correct for top-level replies and a known v1 trade-off
|
|
@@ -58,6 +78,26 @@ module Tempest
|
|
|
58
78
|
)
|
|
59
79
|
end
|
|
60
80
|
|
|
81
|
+
# Compose an app.bsky.feed.like record referencing the subject post and
|
|
82
|
+
# send it via com.atproto.repo.createRecord. The AppView surfaces this in
|
|
83
|
+
# like counts and notifications for the target post.
|
|
84
|
+
def self.like(client, did:, subject_uri:, subject_cid:,
|
|
85
|
+
created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
|
|
86
|
+
record = {
|
|
87
|
+
"$type" => "app.bsky.feed.like",
|
|
88
|
+
"subject" => { "uri" => subject_uri, "cid" => subject_cid },
|
|
89
|
+
"createdAt" => created_at,
|
|
90
|
+
}
|
|
91
|
+
client.post(
|
|
92
|
+
"com.atproto.repo.createRecord",
|
|
93
|
+
body: {
|
|
94
|
+
repo: did,
|
|
95
|
+
collection: "app.bsky.feed.like",
|
|
96
|
+
record: record,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
61
101
|
# Scans `text` for bare URLs and builds AT Protocol link facets pointing
|
|
62
102
|
# at each match. Without this, the AppView treats URLs as plain text and
|
|
63
103
|
# does not render them as clickable links.
|
|
@@ -5,7 +5,7 @@ module Tempest
|
|
|
5
5
|
Command = Data.define(:name, :args)
|
|
6
6
|
|
|
7
7
|
class Dispatcher
|
|
8
|
-
KNOWN_COMMANDS = %i[timeline quit help stream open relogin].freeze
|
|
8
|
+
KNOWN_COMMANDS = %i[timeline quit help stream open relogin fav].freeze
|
|
9
9
|
DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
|
|
10
10
|
|
|
11
11
|
def dispatch(input)
|
|
@@ -25,6 +25,14 @@ module Tempest
|
|
|
25
25
|
URL_PATTERN = %r{https?://[^\s]+}.freeze
|
|
26
26
|
DECORATE_PATTERN = Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
|
|
27
27
|
|
|
28
|
+
# Visible hint that a post carries an image / video embed. Only kinds
|
|
29
|
+
# that aren't already surfaced by other UI (link cards become URLs,
|
|
30
|
+
# quote posts inline their record) get a marker.
|
|
31
|
+
MEDIA_EMOJI = {
|
|
32
|
+
images: "📷",
|
|
33
|
+
video: "🎥",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
28
36
|
class << self
|
|
29
37
|
attr_accessor :color
|
|
30
38
|
end
|
|
@@ -37,6 +45,7 @@ module Tempest
|
|
|
37
45
|
facets = post.respond_to?(:facets) ? post.facets : nil
|
|
38
46
|
body = annotate_urls(squeeze(post.text), registry, facets: facets)
|
|
39
47
|
body = decorate_body(body)
|
|
48
|
+
body = prepend_media_marker(body, embed_kind_of(post))
|
|
40
49
|
body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
|
|
41
50
|
icon = avatar_icon(post_did(post), avatar_store)
|
|
42
51
|
compose(var, format_time(post.created_at), post.handle, nil, body, icon: icon)
|
|
@@ -87,6 +96,7 @@ module Tempest
|
|
|
87
96
|
facets = event.respond_to?(:facets) ? event.facets : nil
|
|
88
97
|
body = annotate_urls(squeeze(event.text), registry, facets: facets)
|
|
89
98
|
body = decorate_body(body)
|
|
99
|
+
body = prepend_media_marker(body, embed_kind_of(event))
|
|
90
100
|
body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
|
|
91
101
|
var = registry&.assign_post(event)
|
|
92
102
|
end
|
|
@@ -127,6 +137,17 @@ module Tempest
|
|
|
127
137
|
"#{marker}#{body}"
|
|
128
138
|
end
|
|
129
139
|
|
|
140
|
+
def embed_kind_of(record)
|
|
141
|
+
record.respond_to?(:embed_kind) ? record.embed_kind : nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prepend_media_marker(body, embed_kind)
|
|
145
|
+
emoji = MEDIA_EMOJI[embed_kind]
|
|
146
|
+
return body unless emoji
|
|
147
|
+
body = body.to_s
|
|
148
|
+
body.empty? ? emoji : "#{emoji} #{body}"
|
|
149
|
+
end
|
|
150
|
+
|
|
130
151
|
def annotate_urls(text, registry, facets: nil)
|
|
131
152
|
return text unless registry
|
|
132
153
|
text = text.to_s
|
data/lib/tempest/repl/runner.rb
CHANGED
|
@@ -17,7 +17,8 @@ module Tempest
|
|
|
17
17
|
Available commands:
|
|
18
18
|
:timeline Fetch and print the home timeline
|
|
19
19
|
:stream on|off Toggle the Jetstream live feed
|
|
20
|
-
:open $LX
|
|
20
|
+
:open $XX|$LX Open the post or URL with the given id in the browser
|
|
21
|
+
:fav $XX Like the post with id $XX
|
|
21
22
|
:relogin Re-authenticate when the cached session is dead
|
|
22
23
|
:help Show this help
|
|
23
24
|
:quit Exit tempest (or Ctrl-D)
|
|
@@ -108,6 +109,8 @@ module Tempest
|
|
|
108
109
|
handle_reply(command.args[0], command.args[1])
|
|
109
110
|
when :open
|
|
110
111
|
handle_open(command.args.first)
|
|
112
|
+
when :fav
|
|
113
|
+
handle_fav(command.args.first)
|
|
111
114
|
when :relogin
|
|
112
115
|
handle_relogin
|
|
113
116
|
when :unknown
|
|
@@ -182,12 +185,41 @@ module Tempest
|
|
|
182
185
|
@output.puts "error: #{e.message}"
|
|
183
186
|
end
|
|
184
187
|
|
|
188
|
+
def handle_fav(var)
|
|
189
|
+
if var.nil? || var.empty?
|
|
190
|
+
@output.puts "usage: :fav $XX"
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
target = @registry.find_post(var)
|
|
194
|
+
if target.nil?
|
|
195
|
+
@output.puts "unknown id: #{var}"
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
response = Post.like(
|
|
199
|
+
@client,
|
|
200
|
+
did: @session.did,
|
|
201
|
+
subject_uri: reply_uri_for(target),
|
|
202
|
+
subject_cid: target.cid,
|
|
203
|
+
)
|
|
204
|
+
@output.puts "liked: #{response["uri"]}"
|
|
205
|
+
rescue Tempest::AuthenticationError => e
|
|
206
|
+
@output.puts "error: #{e.message} (#{RELOGIN_HINT})"
|
|
207
|
+
rescue Tempest::Error => e
|
|
208
|
+
@output.puts "error: #{e.message}"
|
|
209
|
+
end
|
|
210
|
+
|
|
185
211
|
def handle_open(var)
|
|
186
212
|
if var.nil? || var.empty?
|
|
187
|
-
@output.puts "usage: :open $LX"
|
|
213
|
+
@output.puts "usage: :open $XX or $LX"
|
|
188
214
|
return
|
|
189
215
|
end
|
|
190
|
-
|
|
216
|
+
|
|
217
|
+
if (post = @registry.find_post(var))
|
|
218
|
+
url = bsky_post_url(post)
|
|
219
|
+
else
|
|
220
|
+
url = @registry.find_url(var)
|
|
221
|
+
end
|
|
222
|
+
|
|
191
223
|
if url.nil?
|
|
192
224
|
@output.puts "unknown id: #{var}"
|
|
193
225
|
return
|
|
@@ -200,6 +232,22 @@ module Tempest
|
|
|
200
232
|
target.respond_to?(:uri) && target.uri ? target.uri : target.at_uri
|
|
201
233
|
end
|
|
202
234
|
|
|
235
|
+
# bsky.app accepts both handles and DIDs in the profile path. Prefer the
|
|
236
|
+
# handle when we have it (human-readable URLs are nicer for sharing or
|
|
237
|
+
# for the user to glance at), but fall back to the DID for posts that
|
|
238
|
+
# arrived through Jetstream where only the DID is known.
|
|
239
|
+
def bsky_post_url(target)
|
|
240
|
+
at_uri = reply_uri_for(target)
|
|
241
|
+
match = at_uri.match(%r{\Aat://([^/]+)/app\.bsky\.feed\.post/(.+)\z})
|
|
242
|
+
return nil unless match
|
|
243
|
+
|
|
244
|
+
did = match[1]
|
|
245
|
+
rkey = match[2]
|
|
246
|
+
handle = target.respond_to?(:handle) ? target.handle : nil
|
|
247
|
+
profile = handle && !handle.empty? ? handle : did
|
|
248
|
+
"https://bsky.app/profile/#{profile}/post/#{rkey}"
|
|
249
|
+
end
|
|
250
|
+
|
|
203
251
|
def handle_stream(arg)
|
|
204
252
|
if @stream_manager.nil?
|
|
205
253
|
@output.puts "stream is not available in this session"
|
data/lib/tempest/version.rb
CHANGED