kward 0.66.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/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "file"
|
|
10
|
+
|
|
11
|
+
module Kward
|
|
12
|
+
class OpenAIOAuth
|
|
13
|
+
ISSUER = "https://auth.openai.com"
|
|
14
|
+
TOKEN_URL = URI("#{ISSUER}/oauth/token")
|
|
15
|
+
DEFAULT_PORT = 1455
|
|
16
|
+
CALLBACK_PATH = "/auth/callback"
|
|
17
|
+
SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
|
|
18
|
+
DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
19
|
+
|
|
20
|
+
attr_reader :auth_path
|
|
21
|
+
|
|
22
|
+
def initialize(auth_path: OpenAIOAuth.default_auth_path, client_id: nil, config_path: OpenAIOAuth.default_config_path, issuer: ISSUER)
|
|
23
|
+
@auth_path = File.expand_path(auth_path)
|
|
24
|
+
@client_id = client_id
|
|
25
|
+
@config_path = File.expand_path(config_path)
|
|
26
|
+
@issuer = issuer.delete_suffix("/")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.default_auth_path
|
|
30
|
+
File.expand_path(ENV["KWARD_AUTH_PATH"] || "~/.kward/auth.json")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.default_config_path
|
|
34
|
+
File.expand_path(ENV["KWARD_CONFIG_PATH"] || "~/.kward/config.json")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def access_token
|
|
38
|
+
auth = current_auth
|
|
39
|
+
auth&.fetch("tokens", {})&.fetch("access_token", nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def account_id
|
|
43
|
+
auth = current_auth
|
|
44
|
+
auth&.fetch("account_id", nil) || auth&.fetch("tokens", {})&.fetch("account_id", nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def logged_in?
|
|
48
|
+
!access_token.to_s.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def login(prompt:, open_browser: true, timeout_seconds: 120)
|
|
52
|
+
flow = start_login_flow
|
|
53
|
+
pkce = flow[:pkce]
|
|
54
|
+
state = flow[:state]
|
|
55
|
+
server = flow[:server]
|
|
56
|
+
redirect_uri = flow[:redirect_uri]
|
|
57
|
+
url = flow[:authorization_url]
|
|
58
|
+
|
|
59
|
+
prompt.say("OpenAI login URL:\n#{url}\n")
|
|
60
|
+
prompt.say("Waiting for browser login. If it does not complete, paste the callback URL when prompted.")
|
|
61
|
+
browser_opened = open_browser && open_url(url)
|
|
62
|
+
|
|
63
|
+
code = wait_for_callback(server, expected_state: state, timeout_seconds: browser_opened ? timeout_seconds : 5)
|
|
64
|
+
unless code
|
|
65
|
+
input = prompt.ask("Paste callback URL or authorization code:")
|
|
66
|
+
code = authorization_code_from(input.to_s, expected_state: state)
|
|
67
|
+
end
|
|
68
|
+
raise "Missing authorization code" if code.to_s.empty?
|
|
69
|
+
|
|
70
|
+
complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
|
|
71
|
+
auth_path
|
|
72
|
+
ensure
|
|
73
|
+
server&.close unless server&.closed?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def authorization_url(redirect_uri:, code_challenge:, state:)
|
|
77
|
+
query = URI.encode_www_form(
|
|
78
|
+
response_type: "code",
|
|
79
|
+
client_id: client_id,
|
|
80
|
+
redirect_uri: redirect_uri,
|
|
81
|
+
scope: SCOPE,
|
|
82
|
+
code_challenge: code_challenge,
|
|
83
|
+
code_challenge_method: "S256",
|
|
84
|
+
id_token_add_organizations: "true",
|
|
85
|
+
codex_cli_simplified_flow: "true",
|
|
86
|
+
state: state,
|
|
87
|
+
originator: "kward"
|
|
88
|
+
)
|
|
89
|
+
"#{@issuer}/oauth/authorize?#{query}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def start_login_flow
|
|
93
|
+
pkce = generate_pkce
|
|
94
|
+
state = random_urlsafe(32)
|
|
95
|
+
server = start_callback_server
|
|
96
|
+
redirect_uri = "http://localhost:#{server.addr[1]}#{CALLBACK_PATH}"
|
|
97
|
+
{
|
|
98
|
+
pkce: pkce,
|
|
99
|
+
state: state,
|
|
100
|
+
server: server,
|
|
101
|
+
redirect_uri: redirect_uri,
|
|
102
|
+
authorization_url: authorization_url(redirect_uri: redirect_uri, code_challenge: pkce[:challenge], state: state)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def wait_for_login_callback(server, expected_state:, timeout_seconds:)
|
|
107
|
+
wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def complete_login_flow(code:, redirect_uri:, code_verifier:)
|
|
111
|
+
tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
|
|
112
|
+
save_auth(tokens: tokens)
|
|
113
|
+
tokens
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def authorization_code_from(input, expected_state: nil)
|
|
117
|
+
value = input.strip
|
|
118
|
+
return "" if value.empty?
|
|
119
|
+
|
|
120
|
+
uri = URI.parse(value)
|
|
121
|
+
params = URI.decode_www_form(uri.query.to_s).to_h
|
|
122
|
+
if params.key?("code")
|
|
123
|
+
raise "OAuth state mismatch" if expected_state && params["state"] != expected_state
|
|
124
|
+
|
|
125
|
+
return params["code"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
value
|
|
129
|
+
rescue URI::InvalidURIError
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def save_auth(tokens: {})
|
|
134
|
+
account_id = extract_account_id(tokens)
|
|
135
|
+
data = {
|
|
136
|
+
"auth_mode" => "openai_oauth",
|
|
137
|
+
"tokens" => account_id ? tokens.merge("account_id" => account_id) : tokens,
|
|
138
|
+
"account_id" => account_id,
|
|
139
|
+
"saved_at" => Time.now.utc.iso8601,
|
|
140
|
+
"expires_at" => expires_at_for(tokens)
|
|
141
|
+
}.compact
|
|
142
|
+
|
|
143
|
+
AuthFile.write_json(@auth_path, data)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refresh!
|
|
147
|
+
auth = load_auth || raise("OpenAI OAuth login not found")
|
|
148
|
+
refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
|
|
149
|
+
raise "OpenAI OAuth refresh token not found" if refresh_token.to_s.empty?
|
|
150
|
+
|
|
151
|
+
response = post_json(TOKEN_URL,
|
|
152
|
+
client_id: client_id,
|
|
153
|
+
grant_type: "refresh_token",
|
|
154
|
+
refresh_token: refresh_token)
|
|
155
|
+
refreshed = parse_successful_json(response, "OpenAI OAuth token refresh")
|
|
156
|
+
save_auth(tokens: (auth.fetch("tokens", {}) || {}).merge(refreshed))
|
|
157
|
+
load_auth
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def current_auth
|
|
163
|
+
auth = load_auth
|
|
164
|
+
tokens = auth&.fetch("tokens", {}) || {}
|
|
165
|
+
return nil if tokens.empty?
|
|
166
|
+
|
|
167
|
+
if token_expired?(auth) && !tokens["refresh_token"].to_s.empty?
|
|
168
|
+
auth = refresh!
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
auth
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def load_auth
|
|
175
|
+
return nil unless File.exist?(@auth_path)
|
|
176
|
+
|
|
177
|
+
JSON.parse(File.read(@auth_path))
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def client_id
|
|
183
|
+
return @client_id unless @client_id.to_s.strip.empty?
|
|
184
|
+
|
|
185
|
+
value = load_config.fetch("openai_oauth_client_id", "").to_s.strip
|
|
186
|
+
return value unless value.empty?
|
|
187
|
+
|
|
188
|
+
DEFAULT_CLIENT_ID
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def load_config
|
|
192
|
+
return {} unless File.exist?(@config_path)
|
|
193
|
+
|
|
194
|
+
JSON.parse(File.read(@config_path))
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
raise "Invalid Kward config JSON: #{@config_path}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def generate_pkce
|
|
200
|
+
verifier = random_urlsafe(64)
|
|
201
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
202
|
+
{ verifier: verifier, challenge: challenge }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def random_urlsafe(bytes)
|
|
206
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def start_callback_server
|
|
210
|
+
TCPServer.new("localhost", Integer(ENV.fetch("KWARD_OAUTH_PORT", DEFAULT_PORT)))
|
|
211
|
+
rescue Errno::EADDRINUSE
|
|
212
|
+
TCPServer.new("localhost", 0)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def wait_for_callback(server, expected_state:, timeout_seconds:)
|
|
216
|
+
ready = IO.select([server], nil, nil, timeout_seconds)
|
|
217
|
+
return nil unless ready
|
|
218
|
+
|
|
219
|
+
socket = server.accept
|
|
220
|
+
request_line = socket.gets.to_s
|
|
221
|
+
path = request_line.split[1].to_s
|
|
222
|
+
params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
223
|
+
|
|
224
|
+
code = nil
|
|
225
|
+
status = "200 OK"
|
|
226
|
+
body = "Login complete. You can close this window."
|
|
227
|
+
if params["error"]
|
|
228
|
+
body = "Login failed. Return to the terminal."
|
|
229
|
+
elsif params["state"] != expected_state
|
|
230
|
+
status = "400 Bad Request"
|
|
231
|
+
body = "Invalid OAuth state. Return to the terminal."
|
|
232
|
+
else
|
|
233
|
+
code = params["code"]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
237
|
+
code
|
|
238
|
+
rescue URI::InvalidURIError
|
|
239
|
+
nil
|
|
240
|
+
ensure
|
|
241
|
+
socket&.close
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
|
|
245
|
+
response = post_form(TOKEN_URL,
|
|
246
|
+
grant_type: "authorization_code",
|
|
247
|
+
code: code,
|
|
248
|
+
redirect_uri: redirect_uri,
|
|
249
|
+
client_id: client_id,
|
|
250
|
+
code_verifier: code_verifier)
|
|
251
|
+
parse_successful_json(response, "OpenAI OAuth token exchange")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def post_form(uri, params)
|
|
255
|
+
request = Net::HTTP::Post.new(uri)
|
|
256
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
257
|
+
request.body = URI.encode_www_form(params)
|
|
258
|
+
|
|
259
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def post_json(uri, params)
|
|
263
|
+
request = Net::HTTP::Post.new(uri)
|
|
264
|
+
request["Content-Type"] = "application/json"
|
|
265
|
+
request.body = JSON.dump(params)
|
|
266
|
+
|
|
267
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def token_expired?(auth)
|
|
271
|
+
expires_at = auth&.fetch("expires_at", nil)
|
|
272
|
+
return false unless expires_at
|
|
273
|
+
|
|
274
|
+
Time.parse(expires_at) <= Time.now.utc + 60
|
|
275
|
+
rescue ArgumentError
|
|
276
|
+
false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def expires_at_for(tokens)
|
|
280
|
+
expires_in = tokens["expires_in"] || tokens[:expires_in]
|
|
281
|
+
return tokens["expires_at"] || tokens[:expires_at] unless expires_in
|
|
282
|
+
|
|
283
|
+
(Time.now.utc + expires_in.to_i).iso8601
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def extract_account_id(tokens)
|
|
287
|
+
[tokens["id_token"], tokens[:id_token], tokens["access_token"], tokens[:access_token]].each do |token|
|
|
288
|
+
claims = jwt_claims(token.to_s)
|
|
289
|
+
account_id = claims["chatgpt_account_id"] || claims.dig("https://api.openai.com/auth", "chatgpt_account_id") || claims.dig("organizations", 0, "id")
|
|
290
|
+
return account_id if account_id
|
|
291
|
+
end
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def jwt_claims(token)
|
|
296
|
+
_header, payload, _signature = token.split(".")
|
|
297
|
+
return {} unless payload
|
|
298
|
+
|
|
299
|
+
JSON.parse(Base64.urlsafe_decode64(payload + "=" * ((4 - payload.length % 4) % 4)))
|
|
300
|
+
rescue JSON::ParserError, ArgumentError
|
|
301
|
+
{}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def parse_successful_json(response, label)
|
|
305
|
+
raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
306
|
+
|
|
307
|
+
JSON.parse(response.body)
|
|
308
|
+
rescue JSON::ParserError
|
|
309
|
+
raise "#{label} returned invalid JSON"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def open_url(url)
|
|
313
|
+
command = if RUBY_PLATFORM.match?(/darwin/)
|
|
314
|
+
"open"
|
|
315
|
+
elsif RUBY_PLATFORM.match?(/linux/)
|
|
316
|
+
"xdg-open"
|
|
317
|
+
end
|
|
318
|
+
return false unless command
|
|
319
|
+
|
|
320
|
+
system(command, url, out: File::NULL, err: File::NULL)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_relative "../config_files"
|
|
2
|
+
require_relative "openai_oauth"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
class OpenRouterAPIKey
|
|
6
|
+
attr_reader :config_path
|
|
7
|
+
|
|
8
|
+
def initialize(config_path: OpenAIOAuth.default_config_path)
|
|
9
|
+
@config_path = File.expand_path(config_path)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def api_key
|
|
13
|
+
ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(config, "openrouter_api_key") : ENV["OPENROUTER_API_KEY"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configured?
|
|
17
|
+
!api_key.to_s.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def login(prompt:)
|
|
21
|
+
api_key = prompt.ask("OpenRouter API key:").to_s.strip
|
|
22
|
+
raise "OpenRouter API key must be a non-empty string" if api_key.empty?
|
|
23
|
+
|
|
24
|
+
ConfigFiles.update_config({ "openrouter_api_key" => api_key }, @config_path)
|
|
25
|
+
@config_path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def logout
|
|
29
|
+
ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def config
|
|
35
|
+
ConfigFiles.read_config(@config_path)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
class Cancellation
|
|
5
|
+
class CancelledError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@cancelled = false
|
|
9
|
+
@callbacks = []
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cancel!
|
|
14
|
+
callbacks = @mutex.synchronize do
|
|
15
|
+
return if @cancelled
|
|
16
|
+
|
|
17
|
+
@cancelled = true
|
|
18
|
+
pending = @callbacks
|
|
19
|
+
@callbacks = []
|
|
20
|
+
pending
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
callbacks.each do |callback|
|
|
24
|
+
callback.call
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cancelled?
|
|
31
|
+
@mutex.synchronize { @cancelled }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias canceled? cancelled?
|
|
35
|
+
|
|
36
|
+
def raise_if_cancelled!
|
|
37
|
+
raise CancelledError, "cancelled" if cancelled?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def on_cancel(&block)
|
|
41
|
+
run_now = false
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
if @cancelled
|
|
44
|
+
run_now = true
|
|
45
|
+
else
|
|
46
|
+
@callbacks << block
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
block.call if run_now
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|