kward 0.83.0 → 0.85.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/CHANGELOG.md +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "securerandom"
|
|
3
3
|
require "thread"
|
|
4
|
-
require_relative "cancellation"
|
|
5
|
-
require_relative "events"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "tools/tool_call"
|
|
4
|
+
require_relative "../cancellation"
|
|
5
|
+
require_relative "../events"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "../tabs/driver"
|
|
8
|
+
require_relative "../tools/tool_call"
|
|
9
9
|
|
|
10
10
|
# Frontend-neutral runtime for trusted plugin-owned conversational drivers.
|
|
11
11
|
module Kward
|
|
@@ -21,6 +21,7 @@ module Kward
|
|
|
21
21
|
:id,
|
|
22
22
|
:type,
|
|
23
23
|
:driver,
|
|
24
|
+
:host,
|
|
24
25
|
:queue,
|
|
25
26
|
:worker,
|
|
26
27
|
:running_turn_id,
|
|
@@ -55,6 +56,7 @@ module Kward
|
|
|
55
56
|
@turns = {}
|
|
56
57
|
@event_listeners = []
|
|
57
58
|
@mutex = Mutex.new
|
|
59
|
+
@shutdown = false
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
def supported_types(surface: :rpc)
|
|
@@ -79,7 +81,7 @@ module Kward
|
|
|
79
81
|
type = supported_types(surface: surface).find { |entry| entry.id == type_id.to_s }
|
|
80
82
|
raise ArgumentError, "Unknown #{surface} plugin chat: #{type_id}" unless type
|
|
81
83
|
|
|
82
|
-
chat_for(type, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
|
|
84
|
+
chat_for(type, surface: surface, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def chat(chat_id)
|
|
@@ -138,20 +140,31 @@ module Kward
|
|
|
138
140
|
end
|
|
139
141
|
|
|
140
142
|
def shutdown
|
|
141
|
-
chats = @mutex.synchronize
|
|
143
|
+
chats = @mutex.synchronize do
|
|
144
|
+
return nil if @shutdown
|
|
145
|
+
|
|
146
|
+
@shutdown = true
|
|
147
|
+
@chats.values.dup
|
|
148
|
+
end
|
|
149
|
+
@mutex.synchronize { @turns.values.dup }.each { |turn| turn.cancellation.cancel! }
|
|
142
150
|
chats.each do |chat|
|
|
143
151
|
chat.queue << WORKER_STOP if chat.worker&.alive?
|
|
144
152
|
chat.worker&.join(0.2)
|
|
153
|
+
close_chat(chat)
|
|
145
154
|
end
|
|
155
|
+
@plugin_registry&.shutdown! unless @plugin_registry_provider
|
|
146
156
|
nil
|
|
147
157
|
end
|
|
148
158
|
|
|
149
159
|
# Converts normalized image attachment hashes into the input shape accepted
|
|
150
160
|
# by plugin chat drivers. RPC and transport frontends can normalize their
|
|
151
161
|
# own boundary formats before calling this helper.
|
|
152
|
-
def input_with_attachments(input, attachments)
|
|
162
|
+
def input_with_attachments(input, attachments, capabilities: PluginChatCapabilities.legacy)
|
|
153
163
|
attachments = Array(attachments)
|
|
154
164
|
return input.to_s if attachments.empty?
|
|
165
|
+
if capabilities.declared? && !capabilities.allows_attachment?(:image)
|
|
166
|
+
raise ArgumentError, "plugin chat does not allow image attachments"
|
|
167
|
+
end
|
|
155
168
|
|
|
156
169
|
[{ type: "text", text: input.to_s }] + attachments.map do |attachment|
|
|
157
170
|
{
|
|
@@ -166,30 +179,49 @@ module Kward
|
|
|
166
179
|
private
|
|
167
180
|
|
|
168
181
|
def plugin_registry
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
registry = @plugin_registry_provider ? @plugin_registry_provider.call : (@plugin_registry ||= PluginRegistry.load)
|
|
183
|
+
registry.start!
|
|
184
|
+
registry
|
|
172
185
|
end
|
|
173
186
|
|
|
174
|
-
def chat_for(type, scope_key:, descriptor:, workspace_root:)
|
|
187
|
+
def chat_for(type, surface:, scope_key:, descriptor:, workspace_root:)
|
|
188
|
+
surface = surface.to_sym
|
|
189
|
+
host_surface = type.rpc && type.transport ? :shared : surface
|
|
175
190
|
scope_key = normalize_scope_key(scope_key)
|
|
191
|
+
scope_key = "global" if type.singleton == :global && type.capabilities.declared?
|
|
176
192
|
chat_id = chat_id_for(type, scope_key)
|
|
177
193
|
@mutex.synchronize do
|
|
178
194
|
@chats[chat_id] ||= begin
|
|
179
195
|
descriptor = {
|
|
180
196
|
"kind" => "plugin",
|
|
181
|
-
"
|
|
182
|
-
"label" => type.title,
|
|
183
|
-
"scope_key" => scope_key
|
|
197
|
+
"label" => type.title
|
|
184
198
|
}.merge(descriptor.transform_keys(&:to_s))
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
descriptor["plugin_tab_type"] = type.id
|
|
200
|
+
descriptor["scope_key"] = scope_key
|
|
201
|
+
host = PluginTabHost.new(
|
|
202
|
+
client: @client,
|
|
203
|
+
workspace_root: workspace_root,
|
|
204
|
+
plugin_host: type.plugin_id && plugin_registry.plugin_for(type.plugin_id),
|
|
205
|
+
type_id: type.id,
|
|
206
|
+
surface: host_surface,
|
|
207
|
+
scope_key: scope_key,
|
|
208
|
+
capabilities: type.capabilities
|
|
209
|
+
)
|
|
210
|
+
begin
|
|
211
|
+
driver = type.handler.call(host, descriptor)
|
|
212
|
+
raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
|
|
213
|
+
|
|
214
|
+
type.capabilities.validate_driver!(driver)
|
|
215
|
+
rescue StandardError
|
|
216
|
+
host.shutdown
|
|
217
|
+
raise
|
|
218
|
+
end
|
|
188
219
|
|
|
189
220
|
Chat.new(
|
|
190
221
|
id: chat_id,
|
|
191
222
|
type: type,
|
|
192
223
|
driver: driver,
|
|
224
|
+
host: host,
|
|
193
225
|
queue: Queue.new,
|
|
194
226
|
scope_key: scope_key,
|
|
195
227
|
descriptor: descriptor,
|
|
@@ -199,6 +231,18 @@ module Kward
|
|
|
199
231
|
end
|
|
200
232
|
end
|
|
201
233
|
|
|
234
|
+
def close_chat(chat)
|
|
235
|
+
if chat.driver.respond_to?(:close)
|
|
236
|
+
chat.driver.close
|
|
237
|
+
elsif chat.driver.respond_to?(:shutdown)
|
|
238
|
+
chat.driver.shutdown
|
|
239
|
+
end
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
ConfigFiles.emit_warning("Warning: Kward plugin chat cleanup error: #{e.message}")
|
|
242
|
+
ensure
|
|
243
|
+
chat.host.shutdown
|
|
244
|
+
end
|
|
245
|
+
|
|
202
246
|
def normalize_scope_key(scope_key)
|
|
203
247
|
value = scope_key.to_s
|
|
204
248
|
value.empty? ? "default" : value
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "logger"
|
|
4
|
+
require "thread"
|
|
5
|
+
require_relative "../config_files"
|
|
6
|
+
require_relative "../deep_copy"
|
|
7
|
+
require_relative "../private_file"
|
|
8
|
+
require_relative "resources"
|
|
9
|
+
|
|
10
|
+
# Namespace for the Kward CLI agent runtime.
|
|
11
|
+
module Kward
|
|
12
|
+
# Private JSON-backed key/value storage scoped to one stable plugin ID.
|
|
13
|
+
class PluginStore
|
|
14
|
+
KEY_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9:._-]*\z/.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :plugin_id
|
|
17
|
+
|
|
18
|
+
def initialize(plugin_id, root: ConfigFiles.config_dir)
|
|
19
|
+
@plugin_id = validate_key(plugin_id, "plugin id")
|
|
20
|
+
@path = File.join(File.expand_path(root), "plugin_state", @plugin_id, "state.json")
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@values = load_state
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(key)
|
|
26
|
+
key = validate_key(key, "storage key")
|
|
27
|
+
@mutex.synchronize { copy(@values[key]) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def put(key, value)
|
|
31
|
+
key = validate_key(key, "storage key")
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@values[key] = copy(value)
|
|
34
|
+
persist
|
|
35
|
+
end
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(key)
|
|
40
|
+
key = validate_key(key, "storage key")
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
present = @values.key?(key)
|
|
43
|
+
value = @values.delete(key)
|
|
44
|
+
persist if present
|
|
45
|
+
copy(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a view whose keys cannot overlap another plugin-owned scope.
|
|
50
|
+
def scoped(namespace)
|
|
51
|
+
PluginScopedStore.new(self, namespace)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_state
|
|
57
|
+
return {} unless File.file?(@path)
|
|
58
|
+
|
|
59
|
+
state = JSON.parse(File.read(@path))
|
|
60
|
+
values = state.fetch("values", {})
|
|
61
|
+
raise JSON::ParserError, "plugin state values must be a JSON object" unless values.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
values
|
|
64
|
+
rescue JSON::ParserError => e
|
|
65
|
+
raise "Invalid plugin state #{@path}: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def persist
|
|
69
|
+
PrivateFile.write_json(@path, "values" => @values)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_key(value, name)
|
|
73
|
+
value = value.to_s
|
|
74
|
+
raise ArgumentError, "#{name} is required" unless value.match?(KEY_PATTERN)
|
|
75
|
+
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def copy(value)
|
|
80
|
+
return nil if value.nil?
|
|
81
|
+
|
|
82
|
+
DeepCopy.dup(value)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Namespaced view over an identified plugin's durable storage.
|
|
87
|
+
class PluginScopedStore
|
|
88
|
+
attr_reader :namespace
|
|
89
|
+
|
|
90
|
+
def initialize(storage, namespace)
|
|
91
|
+
@storage = storage
|
|
92
|
+
@namespace = namespace.to_s
|
|
93
|
+
raise ArgumentError, "plugin storage namespace is required" if @namespace.empty?
|
|
94
|
+
|
|
95
|
+
@prefix = "scope.#{Digest::SHA256.hexdigest(@namespace)[0, 24]}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get(key)
|
|
99
|
+
@storage.get(scoped_key(key))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def put(key, value)
|
|
103
|
+
@storage.put(scoped_key(key), value)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def delete(key)
|
|
107
|
+
@storage.delete(scoped_key(key))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def scoped_key(key)
|
|
113
|
+
key = key.to_s
|
|
114
|
+
raise ArgumentError, "storage key is required" unless key.match?(PluginStore::KEY_PATTERN)
|
|
115
|
+
|
|
116
|
+
"#{@prefix}.#{key}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Shared immutable metadata and managed services for one identified plugin.
|
|
121
|
+
class PluginHost
|
|
122
|
+
ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/.freeze
|
|
123
|
+
|
|
124
|
+
class LogDevice
|
|
125
|
+
def write(message)
|
|
126
|
+
ConfigFiles.emit_warning(message.to_s.chomp)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def close
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_constant :LogDevice
|
|
134
|
+
|
|
135
|
+
attr_reader :id, :version, :api_version, :source_path, :config, :storage, :logger
|
|
136
|
+
|
|
137
|
+
def initialize(id:, version:, api_version:, source_path: nil, config: nil, storage: nil, logger: nil, env: ENV, storage_root: ConfigFiles.config_dir, warning_sink: nil)
|
|
138
|
+
@id = validate_id(id).freeze
|
|
139
|
+
@version = validate_value(version, "plugin version").freeze
|
|
140
|
+
@api_version = validate_value(api_version, "plugin API version").freeze
|
|
141
|
+
@source_path = source_path&.to_s&.freeze
|
|
142
|
+
@config = freeze_config(config.nil? ? configured_values : config)
|
|
143
|
+
@storage = storage || PluginStore.new(@id, root: storage_root)
|
|
144
|
+
@logger = logger || default_logger
|
|
145
|
+
@env = env
|
|
146
|
+
@resources = PluginResources.new(name: "Kward plugin #{id}", warning_sink: warning_sink)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Starts cooperative background work owned by this plugin. The block may
|
|
150
|
+
# accept a cancellation token and must stop cooperatively when cancelled.
|
|
151
|
+
#
|
|
152
|
+
# @return [PluginTask]
|
|
153
|
+
def background(name: nil, cancellation: nil, &block)
|
|
154
|
+
@resources.background(name: name, cancellation: cancellation, &block)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Registers idempotent cleanup for a subscription or other plugin resource.
|
|
158
|
+
# The returned disposable may be invoked early; otherwise Kward invokes it
|
|
159
|
+
# during plugin reload or shutdown.
|
|
160
|
+
#
|
|
161
|
+
# @return [PluginDisposable]
|
|
162
|
+
def on_cleanup(resource = nil, &block)
|
|
163
|
+
@resources.on_cleanup(resource, &block)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
alias manage on_cleanup
|
|
167
|
+
|
|
168
|
+
# Activates managed runtime services after plugin loading completes.
|
|
169
|
+
# @api private
|
|
170
|
+
def activate!
|
|
171
|
+
@resources.activate!
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Cancels background work and invokes registered cleanup callbacks.
|
|
176
|
+
# @api private
|
|
177
|
+
def shutdown(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
178
|
+
@resources.shutdown(timeout: timeout)
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Reads a secret from private plugin config, an explicit environment
|
|
183
|
+
# variable, or the plugin's conventional KWARD_PLUGIN_* variable.
|
|
184
|
+
def secret(key, env: nil)
|
|
185
|
+
key = validate_value(key, "secret key")
|
|
186
|
+
value = config[key]
|
|
187
|
+
value = @env[env.to_s] if value.nil? && env
|
|
188
|
+
value = @env[default_secret_env_name(key)] if value.nil?
|
|
189
|
+
value.to_s unless value.nil?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Returns public metadata suitable for diagnostics and capability reports.
|
|
193
|
+
def to_h
|
|
194
|
+
{ id: id, version: version, api_version: api_version }.freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def configured_values
|
|
200
|
+
ConfigFiles.plugin_config(@id)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def freeze_config(value)
|
|
204
|
+
raise ArgumentError, "Kward plugin config for #{@id} must be an object" unless value.is_a?(Hash)
|
|
205
|
+
|
|
206
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_id(value)
|
|
210
|
+
value = value.to_s
|
|
211
|
+
raise ArgumentError, "plugin id is invalid: #{value}" unless value.match?(ID_PATTERN)
|
|
212
|
+
|
|
213
|
+
value
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_value(value, name)
|
|
217
|
+
value = value.to_s
|
|
218
|
+
raise ArgumentError, "#{name} is required" if value.empty?
|
|
219
|
+
|
|
220
|
+
value
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def default_secret_env_name(key)
|
|
224
|
+
parts = [id, key].map { |part| part.gsub(/[^A-Za-z0-9]/, "_").upcase }
|
|
225
|
+
"KWARD_PLUGIN_#{parts.join("_")}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def default_logger
|
|
229
|
+
Logger.new(LogDevice.new).tap { |logger| logger.progname = "Kward plugin #{id}" }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|