kward 0.76.0 → 0.77.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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +75 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/rpc.md +0 -22
  17. data/doc/security.md +180 -0
  18. data/doc/usage.md +15 -32
  19. data/lib/kward/ansi.rb +2 -2
  20. data/lib/kward/cancellation.rb +2 -2
  21. data/lib/kward/cli/commands.rb +1 -3
  22. data/lib/kward/cli/interactive_turn.rb +0 -17
  23. data/lib/kward/cli/plugins.rb +2 -7
  24. data/lib/kward/cli/prompt_interface.rb +1 -0
  25. data/lib/kward/cli/runtime_helpers.rb +0 -38
  26. data/lib/kward/cli/sessions.rb +14 -5
  27. data/lib/kward/cli/settings.rb +22 -0
  28. data/lib/kward/cli/slash_commands.rb +1 -561
  29. data/lib/kward/cli/tabs.rb +14 -2
  30. data/lib/kward/cli.rb +1 -17
  31. data/lib/kward/compactor.rb +3 -3
  32. data/lib/kward/config_files.rb +18 -0
  33. data/lib/kward/ekwsh.rb +0 -4
  34. data/lib/kward/hooks/catalog.rb +0 -5
  35. data/lib/kward/image_attachments.rb +1 -1
  36. data/lib/kward/pan/index.html.erb +459 -64
  37. data/lib/kward/pan/kward_logo.png +0 -0
  38. data/lib/kward/pan/server.rb +248 -23
  39. data/lib/kward/plugin_registry.rb +18 -0
  40. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  41. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  42. data/lib/kward/prompt_interface/layout.rb +0 -4
  43. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  44. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  46. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  47. data/lib/kward/prompt_interface.rb +18 -1
  48. data/lib/kward/prompts/commands.rb +1 -3
  49. data/lib/kward/prompts.rb +20 -0
  50. data/lib/kward/rpc/server.rb +13 -41
  51. data/lib/kward/rpc/session_manager.rb +75 -25
  52. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  53. data/lib/kward/rpc/tool_metadata.rb +23 -0
  54. data/lib/kward/rpc/transcript_normalizer.rb +4 -0
  55. data/lib/kward/session_store.rb +55 -16
  56. data/lib/kward/skills/registry.rb +21 -1
  57. data/lib/kward/tools/base.rb +14 -0
  58. data/lib/kward/tools/registry.rb +11 -16
  59. data/lib/kward/tools/search/code.rb +1 -1
  60. data/lib/kward/tools/search/web.rb +1 -1
  61. data/lib/kward/tools/tool_call.rb +0 -4
  62. data/lib/kward/transcript_export.rb +1 -1
  63. data/lib/kward/version.rb +1 -1
  64. data/templates/default/kward_navigation.rb +5 -1
  65. data/templates/default/layout/html/layout.erb +0 -2
  66. metadata +5 -12
  67. data/lib/kward/workers/git_guard.rb +0 -93
  68. data/lib/kward/workers/job.rb +0 -99
  69. data/lib/kward/workers/live_view.rb +0 -49
  70. data/lib/kward/workers/manager.rb +0 -328
  71. data/lib/kward/workers/queue_runner.rb +0 -166
  72. data/lib/kward/workers/queue_store.rb +0 -112
  73. data/lib/kward/workers/store.rb +0 -72
  74. data/lib/kward/workers/tool_policy.rb +0 -23
  75. data/lib/kward/workers/worker.rb +0 -82
  76. data/lib/kward/workers/write_lock.rb +0 -38
  77. data/lib/kward/workers.rb +0 -10
@@ -309,11 +309,10 @@ module Kward
309
309
  # Lists recent non-empty sessions for this workspace.
310
310
  #
311
311
  # @param limit [Integer, nil] maximum number of sessions, or nil for all
312
- # @param keep_empty_path [String, nil] empty session path to keep visible
312
+ # @param keep_empty_path [String, Array<String>, nil] live empty session path(s) to preserve while omitting them from results
313
313
  # @return [Array<SessionInfo>] newest sessions first
314
314
  def recent(limit: 20, keep_empty_path: nil)
315
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
316
- limit ? sessions.first(limit) : sessions
315
+ recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
317
316
  end
318
317
 
319
318
  # Persists the last active session pointer for workspace auto-resume.
@@ -344,9 +343,7 @@ module Kward
344
343
  #
345
344
  # @return [Array<SessionInfo>] recent sessions with tree depth fields
346
345
  def recent_tree(limit: 20, keep_empty_path: nil)
347
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
348
- sessions = sessions.first(limit) if limit
349
- decorate_tree(sessions)
346
+ decorate_tree(recent_sessions(limit: limit, keep_empty_path: keep_empty_path))
350
347
  end
351
348
 
352
349
  # Deletes an empty unnamed session file.
@@ -770,20 +767,30 @@ module Kward
770
767
  nil
771
768
  end
772
769
 
773
- def recent_sessions(keep_empty_path: nil)
774
- keep_empty_path = File.expand_path(keep_empty_path) unless keep_empty_path.to_s.empty?
775
- Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
776
- info = session_info(path)
777
- next unless info
778
- next if delete_empty_unnamed_session_info(info, keep_empty_path: keep_empty_path)
770
+ def recent_sessions(limit: nil, keep_empty_path: nil)
771
+ keep_empty_paths = Array(keep_empty_path).filter_map do |path|
772
+ File.expand_path(path) unless path.to_s.empty?
773
+ end
774
+ paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_mtime(path) }.reverse
775
+ sessions = []
776
+
777
+ paths.each do |path|
778
+ if limit.nil? || sessions.length < limit
779
+ info = session_info(path)
780
+ next unless info
781
+ next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
779
782
 
780
- info
781
- end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
783
+ sessions << info
784
+ else
785
+ delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
786
+ end
787
+ end
788
+ sessions
782
789
  end
783
790
 
784
- def delete_empty_unnamed_session_info(info, keep_empty_path: nil)
791
+ def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
785
792
  return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
786
- return true if keep_empty_path && File.expand_path(info.path) == keep_empty_path
793
+ return true if keep_empty_paths.include?(File.expand_path(info.path))
787
794
 
788
795
  File.delete(info.path)
789
796
  true
@@ -791,6 +798,38 @@ module Kward
791
798
  false
792
799
  end
793
800
 
801
+ def session_file_mtime(path)
802
+ File.mtime(path)
803
+ rescue StandardError
804
+ Time.at(0)
805
+ end
806
+
807
+ def delete_empty_unnamed_session_path(path, keep_empty_paths: [])
808
+ return false unless empty_unnamed_session_path?(path)
809
+ return true if keep_empty_paths.include?(File.expand_path(path))
810
+
811
+ File.delete(path)
812
+ true
813
+ rescue StandardError
814
+ false
815
+ end
816
+
817
+ def empty_unnamed_session_path?(path)
818
+ header = false
819
+ name = nil
820
+ File.foreach(path) do |line|
821
+ record = JSON.parse(line)
822
+ header = true if record["type"] == "session" && !record["id"].to_s.empty?
823
+ name = record["name"] if record["type"] == "session_info" && record.key?("name")
824
+ next unless record["type"] == "message"
825
+
826
+ return false if ["user", "assistant", "tool"].include?(message_role(record["message"] || {}))
827
+ end
828
+ header && name.to_s.strip.empty?
829
+ rescue JSON::ParserError
830
+ false
831
+ end
832
+
794
833
  def decorate_tree(sessions)
795
834
  by_parent = Hash.new { |hash, key| hash[key] = [] }
796
835
  ids = sessions.map(&:id).to_h { |id| [id, true] }
@@ -4,7 +4,13 @@ require "pathname"
4
4
  module Kward
5
5
  # Skill discovery and metadata parsing from configured skill folders.
6
6
  module Skills
7
- # Parsed skill metadata and instruction path.
7
+ # Discovers Agent Skills and reads their bounded instruction resources.
8
+ #
9
+ # Project sources are included only when the caller marks them trusted. If
10
+ # duplicate skill names exist, the first source in documented precedence
11
+ # order wins.
12
+ #
13
+ # @api public
8
14
  class Registry
9
15
  SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
10
16
 
@@ -18,6 +24,10 @@ module Kward
18
24
  @inside_directory = inside_directory
19
25
  end
20
26
 
27
+ # Returns discovered, validated skills in precedence order.
28
+ #
29
+ # @return [Array<ConfigFiles::Skill>]
30
+ # @api public
21
31
  def skills
22
32
  seen = {}
23
33
  skill_sources.flat_map do |source|
@@ -39,6 +49,16 @@ module Kward
39
49
  []
40
50
  end
41
51
 
52
+ # Reads a skill's main instructions or a bounded relative resource.
53
+ #
54
+ # Absolute paths, paths escaping the skill folder, missing files, and files
55
+ # above the configured size limit return user-facing error strings.
56
+ #
57
+ # @param name [String, #to_s] discovered skill name
58
+ # @param relative_path [String, nil] file relative to the skill directory;
59
+ # defaults to `SKILL.md`
60
+ # @return [String] skill content or an error message
61
+ # @api public
42
62
  def read_skill_file(name, relative_path = nil)
43
63
  skill = skills.find { |candidate| candidate.name == name.to_s }
44
64
  return "Error: unknown skill: #{name}" unless skill
@@ -3,11 +3,22 @@ module Kward
3
3
  # Model-callable tool wrappers and their argument schemas.
4
4
  module Tools
5
5
  # Base class for model-callable tools and their JSON schemas.
6
+ #
7
+ # Concrete built-in tools initialize this class with their model-facing name,
8
+ # description, and strict argument schema, then implement `call` to perform
9
+ # the operation. Tool execution is coordinated by {Kward::ToolRegistry}.
10
+ #
11
+ # @api public
6
12
  class Base
7
13
  # @return [String] function name exposed to the model
8
14
  attr_reader :name
9
15
 
10
16
  # Creates a tool schema definition shared by all concrete tool wrappers.
17
+ #
18
+ # @param name [String] function name exposed to the model
19
+ # @param description [String] model-facing description of the operation
20
+ # @param properties [Hash] JSON Schema properties keyed by argument name
21
+ # @param required [Array<String, Symbol>] required argument names
11
22
  def initialize(name, description, properties: {}, required: [])
12
23
  @name = name
13
24
  @description = description
@@ -16,6 +27,9 @@ module Kward
16
27
  end
17
28
 
18
29
  # Returns the strict JSON schema advertised to model providers.
30
+ #
31
+ # @return [Hash] function schema with additional properties disabled
32
+ # @api public
19
33
  def schema
20
34
  {
21
35
  type: "function",
@@ -46,11 +46,12 @@ module Kward
46
46
  # individual tools, and legacy-compatible shapes are accepted where already
47
47
  # supported. Required fields and invalid required values should still return
48
48
  # explicit tool errors.
49
+ # @api public
49
50
  class ToolRegistry
50
51
  # Tool schemas advertised to the model for the current frontend and config.
51
52
  #
52
53
  # @return [Array<Hash>] tool schemas currently advertised to the model
53
- attr_reader :schemas, :writer_id
54
+ attr_reader :schemas
54
55
 
55
56
  # Builds tool objects and the schema list for the current frontend/config.
56
57
  #
@@ -63,7 +64,7 @@ module Kward
63
64
  # @param web_search_enabled [Boolean, nil] override for web search exposure
64
65
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
65
66
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
66
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
67
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
67
68
  @workspace = workspace
68
69
  @prompt = prompt
69
70
  @web_search = web_search
@@ -73,8 +74,6 @@ module Kward
73
74
  @web_search_enabled = web_search_enabled
74
75
  @ask_user_question_enabled = ask_user_question_enabled
75
76
  @allowed_tool_names = allowed_tool_names&.map(&:to_s)
76
- @write_lock = write_lock
77
- @writer_id = writer_id
78
77
  @tool_output_compactor = tool_output_compactor
79
78
  @telemetry_logger = telemetry_logger
80
79
  @context_budget_meter = context_budget_meter
@@ -115,8 +114,6 @@ module Kward
115
114
  hook_denied_content(before_tool, "tool call denied: #{name}")
116
115
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
117
116
  hook_denied_content(before_tool, "tool call approval denied: #{name}")
118
- elsif mutation_tool?(name) && !write_lock_owned?
119
- "Workspace write denied: another worker owns the write lock."
120
117
  elsif tool_approval_denied?(tool_call, name, args, cancellation)
121
118
  "Declined: tool execution denied by user: #{name}"
122
119
  else
@@ -148,6 +145,14 @@ module Kward
148
145
  model_content
149
146
  end
150
147
 
148
+ # Returns frontend discovery metadata for a tool name.
149
+ #
150
+ # Unknown names return metadata with an unknown source rather than raising,
151
+ # which lets RPC clients render restored or unsupported tool calls safely.
152
+ #
153
+ # @param name [String, #to_s] exposed tool name
154
+ # @return [Hash] source, display name, and optional MCP identity
155
+ # @api public
151
156
  def metadata_for(name)
152
157
  tool = @tools[name.to_s]
153
158
  return unknown_tool_metadata(name) unless tool
@@ -415,22 +420,12 @@ module Kward
415
420
  (metadata[:source] || metadata["source"]).to_s == "mcp"
416
421
  end
417
422
 
418
- def mutation_tool?(name)
419
- ToolCall.write_lock_required?(name)
420
- end
421
-
422
423
  def tool_approval_denied?(tool_call, name, args, cancellation)
423
424
  return false unless @tool_approval
424
425
 
425
426
  @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
426
427
  end
427
428
 
428
- def write_lock_owned?
429
- return true unless @write_lock
430
-
431
- @write_lock.owned_by?(@writer_id)
432
- end
433
-
434
429
  def build_tools
435
430
  tools = all_tools
436
431
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "net/http"
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "json"
3
3
  require "net/http"
4
4
  require "nokogiri"
@@ -67,10 +67,6 @@ module Kward
67
67
  TOOL_NAME_MAP[name.to_s]
68
68
  end
69
69
 
70
- def write_lock_required?(name)
71
- %w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
72
- end
73
-
74
70
  def file_change_tool?(name)
75
71
  %w[edit_file write_file edit write].include?(name.to_s)
76
72
  end
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require_relative "markdown_transcript"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.76.0"
4
+ VERSION = "0.77.0"
5
5
  end
@@ -9,6 +9,7 @@ module KwardDocsNavigationData
9
9
  ["Usage", "file.usage.html"],
10
10
  ["Configuration", "file.configuration.html"],
11
11
  ["Authentication", "file.authentication.html"],
12
+ ["Security and trust", "file.security.html"],
12
13
  ["Troubleshooting", "file.troubleshooting.html"]
13
14
  ]
14
15
  ],
@@ -16,10 +17,12 @@ module KwardDocsNavigationData
16
17
  "Feature guides",
17
18
  [
18
19
  ["Sessions", "file.session-management.html"],
20
+ ["Interactive composer", "file.composer.html"],
19
21
  ["Tabs", "file.tabs.html"],
20
22
  ["Memory", "file.memory.html"],
21
23
  ["Personas", "file.personas.html"],
22
- ["Skills", "file.skills.html"]
24
+ ["Skills", "file.skills.html"],
25
+ ["Pan mode", "file.pan.html"]
23
26
  ]
24
27
  ],
25
28
  [
@@ -45,6 +48,7 @@ module KwardDocsNavigationData
45
48
  [
46
49
  "Integrate",
47
50
  [
51
+ ["MCP servers", "file.mcp.html"],
48
52
  ["RPC protocol", "file.rpc.html"],
49
53
  ["Releasing", "file.releasing.html"]
50
54
  ]
@@ -110,7 +110,6 @@
110
110
  <article><strong>⌘</strong><h2>Project-aware agent loop</h2><p>Inspect, reason, patch, verify.</p></article>
111
111
  <article><strong>▤</strong><h2>Unix filter mode</h2><p>Pipe diffs, logs, and code through Kward.</p></article>
112
112
  <article><strong>▰</strong><h2>Branchable sessions</h2><p>Resume, rewind, fork, compact, export.</p></article>
113
- <article><strong>›_</strong><h2>Worker pipelines</h2><p>Queue focused agent tasks from the TUI.</p></article>
114
113
  <article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
115
114
  <article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
116
115
  </section>
@@ -137,7 +136,6 @@
137
136
  <li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
138
137
  <li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
139
138
  <li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
140
- <li>Dispatch worker tasks, leave them running, review the results later.</li>
141
139
  <li>Ground answers with live web search and public source repositories before guessing.</li>
142
140
  <li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
143
141
  <li>Build custom UIs on top of Kward’s JSON-RPC backend.</li>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.76.0
4
+ version: 0.77.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -129,6 +129,7 @@ files:
129
129
  - doc/api.md
130
130
  - doc/authentication.md
131
131
  - doc/code-search.md
132
+ - doc/composer.md
132
133
  - doc/configuration.md
133
134
  - doc/context-budgeting.md
134
135
  - doc/context-tools.md
@@ -140,10 +141,12 @@ files:
140
141
  - doc/lifecycle-hooks.md
141
142
  - doc/mcp.md
142
143
  - doc/memory.md
144
+ - doc/pan.md
143
145
  - doc/personas.md
144
146
  - doc/plugins.md
145
147
  - doc/releasing.md
146
148
  - doc/rpc.md
149
+ - doc/security.md
147
150
  - doc/session-management.md
148
151
  - doc/shell.md
149
152
  - doc/skills.md
@@ -234,6 +237,7 @@ files:
234
237
  - lib/kward/model/stream_parser.rb
235
238
  - lib/kward/openrouter_model_cache.rb
236
239
  - lib/kward/pan/index.html.erb
240
+ - lib/kward/pan/kward_logo.png
237
241
  - lib/kward/pan/server.rb
238
242
  - lib/kward/path_guard.rb
239
243
  - lib/kward/plugin_registry.rb
@@ -348,17 +352,6 @@ files:
348
352
  - lib/kward/transcript_export.rb
349
353
  - lib/kward/update_check.rb
350
354
  - lib/kward/version.rb
351
- - lib/kward/workers.rb
352
- - lib/kward/workers/git_guard.rb
353
- - lib/kward/workers/job.rb
354
- - lib/kward/workers/live_view.rb
355
- - lib/kward/workers/manager.rb
356
- - lib/kward/workers/queue_runner.rb
357
- - lib/kward/workers/queue_store.rb
358
- - lib/kward/workers/store.rb
359
- - lib/kward/workers/tool_policy.rb
360
- - lib/kward/workers/worker.rb
361
- - lib/kward/workers/write_lock.rb
362
355
  - lib/kward/workspace.rb
363
356
  - lib/main.rb
364
357
  - templates/default/fulldoc/html/css/kward.css
@@ -1,93 +0,0 @@
1
- require "open3"
2
-
3
- module Kward
4
- module Workers
5
- # Small git boundary used by write-lane workers to keep implementation work isolated.
6
- class GitGuard
7
- def initialize(root: Dir.pwd)
8
- @root = root.to_s
9
- end
10
-
11
- def repository?
12
- success?("rev-parse", "--is-inside-work-tree")
13
- end
14
-
15
- def clean?
16
- return true unless repository?
17
-
18
- status.empty?
19
- end
20
-
21
- def dirty?
22
- !clean?
23
- end
24
-
25
- def status
26
- run("status", "--porcelain").stdout
27
- end
28
-
29
- def head
30
- result = run("rev-parse", "--verify", "HEAD")
31
- result.success? ? result.stdout.strip : nil
32
- end
33
-
34
- def commit_all(message)
35
- add = run("add", "-A")
36
- return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
37
-
38
- commit = run("commit", "-m", message)
39
- return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
40
-
41
- Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
- end
43
-
44
- def stash(message)
45
- before = stash_refs
46
- result = run("stash", "push", "--include-untracked", "-m", message)
47
- return Result.new(success: false, stdout: result.stdout, stderr: result.stderr) unless result.success?
48
- return Result.new(success: true, stdout: result.stdout, stderr: result.stderr) if result.stdout.include?("No local changes")
49
-
50
- ref = (stash_refs - before).first || stash_refs.first
51
- Result.new(success: true, stdout: result.stdout, stderr: result.stderr, commit: ref)
52
- end
53
-
54
- def apply_stash(ref)
55
- run("stash", "apply", ref.to_s)
56
- end
57
-
58
- def drop_stash(ref)
59
- run("stash", "drop", ref.to_s)
60
- end
61
-
62
- private
63
-
64
- Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
65
- def success?
66
- success
67
- end
68
-
69
- def output
70
- [stdout, stderr].compact.reject(&:empty?).join("\n")
71
- end
72
- end
73
-
74
- def success?(*args)
75
- run(*args).success?
76
- end
77
-
78
- def stash_refs
79
- result = run("stash", "list", "--format=%gd")
80
- return [] unless result.success?
81
-
82
- result.stdout.lines.map(&:strip).reject(&:empty?)
83
- end
84
-
85
- def run(*args)
86
- stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
87
- Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
88
- rescue Errno::ENOENT
89
- Result.new(success: false, stdout: "", stderr: "git executable not found")
90
- end
91
- end
92
- end
93
- end
@@ -1,99 +0,0 @@
1
- require "securerandom"
2
- require "time"
3
- require_relative "../config_files"
4
-
5
- module Kward
6
- module Workers
7
- # Persistent queue entry for a session-backed worker.
8
- class Job
9
- STATUSES = %w[queued running suspended ready_for_review failed blocked cancelled archived].freeze
10
-
11
- def initialize(id: SecureRandom.hex(4), title:, session_path:, workspace_root: Dir.pwd, status: "queued", position: nil, commit_sha: nil, stash_ref: nil, error: nil, enqueued_at: Time.now.utc, started_at: nil, finished_at: nil, updated_at: nil)
12
- @id = id.to_s
13
- @title = title.to_s
14
- @session_path = session_path.to_s
15
- @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
16
- @status = status.to_s
17
- @position = position
18
- @commit_sha = commit_sha
19
- @stash_ref = stash_ref
20
- @error = error
21
- @enqueued_at = enqueued_at
22
- @started_at = started_at
23
- @finished_at = finished_at
24
- @updated_at = updated_at || enqueued_at
25
- end
26
-
27
- attr_reader :id, :title, :session_path, :workspace_root, :position, :commit_sha, :stash_ref, :error, :enqueued_at, :started_at, :finished_at, :updated_at
28
-
29
- def status
30
- @status
31
- end
32
-
33
- def update_status(status, commit_sha: nil, stash_ref: nil, error: nil, position: nil)
34
- @status = status.to_s
35
- @commit_sha = commit_sha unless commit_sha.nil?
36
- @stash_ref = stash_ref unless stash_ref.nil?
37
- @error = error unless error.nil?
38
- @position = position unless position.nil?
39
- now = Time.now.utc
40
- @updated_at = now
41
- @started_at ||= now if @status == "running"
42
- @finished_at = now if %w[ready_for_review failed blocked cancelled archived].include?(@status)
43
- self
44
- end
45
-
46
- def to_h
47
- {
48
- "id" => id,
49
- "title" => title,
50
- "session_path" => session_path,
51
- "workspace_root" => workspace_root,
52
- "status" => status,
53
- "position" => position,
54
- "commit_sha" => commit_sha,
55
- "stash_ref" => stash_ref,
56
- "error" => error,
57
- "enqueued_at" => timestamp(enqueued_at),
58
- "started_at" => timestamp(started_at),
59
- "finished_at" => timestamp(finished_at),
60
- "updated_at" => timestamp(updated_at)
61
- }
62
- end
63
-
64
- def self.from_h(record)
65
- new(
66
- id: record.fetch("id"),
67
- title: record.fetch("title"),
68
- session_path: record.fetch("session_path"),
69
- workspace_root: record["workspace_root"] || Dir.pwd,
70
- status: record["status"] || "queued",
71
- position: record["position"],
72
- commit_sha: record["commit_sha"],
73
- stash_ref: record["stash_ref"],
74
- error: record["error"],
75
- enqueued_at: parse_time(record["enqueued_at"]) || Time.now.utc,
76
- started_at: parse_time(record["started_at"]),
77
- finished_at: parse_time(record["finished_at"]),
78
- updated_at: parse_time(record["updated_at"])
79
- )
80
- end
81
-
82
- def self.parse_time(value)
83
- return nil if value.to_s.empty?
84
-
85
- Time.parse(value.to_s).utc
86
- rescue ArgumentError
87
- nil
88
- end
89
-
90
- private_class_method :parse_time
91
-
92
- private
93
-
94
- def timestamp(value)
95
- value&.utc&.iso8601(3)
96
- end
97
- end
98
- end
99
- end
@@ -1,49 +0,0 @@
1
- module Kward
2
- module Workers
3
- # Drains a running worker's accumulated event history into a frontend renderer.
4
- class LiveView
5
- FINISHED_STATUSES = %w[ready failed cancelled archived].freeze
6
-
7
- def initialize(worker:, agent:, renderer:, poll_interval: 0.05)
8
- @worker = worker
9
- @agent = agent
10
- @renderer = renderer
11
- @poll_interval = poll_interval
12
- @seen_events = worker.event_history.length
13
- @stop = false
14
- @thread = nil
15
- end
16
-
17
- attr_reader :worker, :agent
18
-
19
- def start
20
- @thread = Thread.new { run }
21
- @thread.report_on_exception = false
22
- self
23
- end
24
-
25
- def stop
26
- @stop = true
27
- @thread&.join(0.2)
28
- end
29
-
30
- private
31
-
32
- def run
33
- until @stop
34
- events = @worker.event_history[@seen_events..] || []
35
- events.each { |event| @renderer.call(event, @agent) }
36
- @seen_events += events.length
37
- @renderer.call(:flush, @agent) if finished?
38
- break if finished?
39
-
40
- sleep @poll_interval
41
- end
42
- end
43
-
44
- def finished?
45
- FINISHED_STATUSES.include?(@worker.status)
46
- end
47
- end
48
- end
49
- end