kward 0.72.0 → 0.73.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile.lock +2 -2
  5. data/doc/configuration.md +1 -1
  6. data/doc/editor.md +23 -2
  7. data/doc/git.md +1 -0
  8. data/doc/rpc.md +2 -2
  9. data/doc/shell.md +56 -10
  10. data/doc/usage.md +27 -1
  11. data/lib/kward/ansi.rb +62 -23
  12. data/lib/kward/cli/plugins.rb +1 -1
  13. data/lib/kward/cli/rendering.rb +4 -1
  14. data/lib/kward/cli/runtime_helpers.rb +141 -7
  15. data/lib/kward/cli/settings.rb +0 -1
  16. data/lib/kward/cli/slash_commands.rb +213 -0
  17. data/lib/kward/cli/tabs.rb +34 -4
  18. data/lib/kward/cli/tool_summaries.rb +6 -0
  19. data/lib/kward/cli.rb +4 -12
  20. data/lib/kward/clipboard.rb +2 -3
  21. data/lib/kward/compactor.rb +7 -19
  22. data/lib/kward/config_files.rb +26 -4
  23. data/lib/kward/ekwsh.rb +239 -42
  24. data/lib/kward/image_attachments.rb +3 -1
  25. data/lib/kward/interactive_pty_runner.rb +151 -0
  26. data/lib/kward/local_command_runner.rb +155 -0
  27. data/lib/kward/local_pty_command_runner.rb +171 -0
  28. data/lib/kward/model/context_usage.rb +2 -2
  29. data/lib/kward/model/payloads.rb +2 -5
  30. data/lib/kward/prompt_history.rb +5 -3
  31. data/lib/kward/prompt_interface/editor/auto_indent.rb +5 -4
  32. data/lib/kward/prompt_interface/editor/controller.rb +262 -62
  33. data/lib/kward/prompt_interface/editor/modes/emacs.rb +21 -21
  34. data/lib/kward/prompt_interface/editor/modes/modern.rb +38 -37
  35. data/lib/kward/prompt_interface/editor/modes/vibe.rb +23 -173
  36. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  37. data/lib/kward/prompt_interface/editor/renderer.rb +6 -5
  38. data/lib/kward/prompt_interface/editor/state.rb +28 -6
  39. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +5 -3
  40. data/lib/kward/prompt_interface/git_prompt.rb +12 -23
  41. data/lib/kward/prompt_interface/interactive/controller.rb +1 -1
  42. data/lib/kward/prompt_interface/key_handler.rb +93 -51
  43. data/lib/kward/prompt_interface/question_prompt.rb +1 -6
  44. data/lib/kward/prompt_interface/screen.rb +3 -3
  45. data/lib/kward/prompt_interface/selection_prompt.rb +3 -6
  46. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  47. data/lib/kward/prompt_interface.rb +87 -221
  48. data/lib/kward/prompts/commands.rb +4 -0
  49. data/lib/kward/rpc/memory_methods.rb +83 -0
  50. data/lib/kward/rpc/server.rb +130 -83
  51. data/lib/kward/rpc/session_manager.rb +10 -74
  52. data/lib/kward/rpc/tool_metadata.rb +11 -0
  53. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  54. data/lib/kward/scratchpad_runner.rb +56 -0
  55. data/lib/kward/session_diff.rb +20 -3
  56. data/lib/kward/session_naming.rb +11 -0
  57. data/lib/kward/terminal_keys.rb +84 -0
  58. data/lib/kward/terminal_sequences.rb +42 -0
  59. data/lib/kward/tools/context_for_task.rb +2 -0
  60. data/lib/kward/version.rb +1 -1
  61. data/lib/kward/workers/git_guard.rb +25 -0
  62. data/lib/kward/workers/job.rb +99 -0
  63. data/lib/kward/workers/queue_runner.rb +166 -0
  64. data/lib/kward/workers/queue_store.rb +112 -0
  65. data/lib/kward/workers.rb +3 -0
  66. data/lib/kward/workspace.rb +15 -63
  67. data/templates/default/fulldoc/html/css/kward.css +33 -0
  68. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  69. data/templates/default/fulldoc/html/setup.rb +1 -0
  70. data/templates/default/layout/html/layout.erb +19 -32
  71. metadata +15 -1
@@ -25,20 +25,37 @@ module Kward
25
25
  new
26
26
  end
27
27
 
28
+ def self.content_from_session_file(path)
29
+ records = File.readlines(path, chomp: true).filter_map { |line| parse_record(line) }
30
+ content_from_records(records)
31
+ rescue Errno::ENOENT, Errno::EACCES
32
+ ""
33
+ end
34
+
28
35
  def self.from_records(records)
29
36
  execution_records = records.select { |record| record["type"] == "tool_execution_end" }
30
37
  source_records = execution_records.empty? ? records : execution_records
31
38
  source_records.each_with_object(new) do |record, diff|
32
39
  if record["type"] == "tool_execution_end"
33
- next if record["isError"] || record.dig("result", "isError")
34
-
35
- diff.add_diff(record.dig("result", "diff"))
40
+ diff.add_diff(record_diff(record))
36
41
  elsif record["type"] == "message" && (record.dig("message", "role") == "tool" || record.dig("message", :role) == "tool")
37
42
  diff.add_tool_result(record.dig("message", "content") || record.dig("message", :content))
38
43
  end
39
44
  end
40
45
  end
41
46
 
47
+ def self.content_from_records(records)
48
+ records.filter_map { |record| record_diff(record) }.join("\n")
49
+ end
50
+
51
+ def self.record_diff(record)
52
+ return nil unless record["type"] == "tool_execution_end"
53
+ return nil if record["isError"] || record.dig("result", "isError")
54
+
55
+ diff = record.dig("result", "diff").to_s
56
+ diff.empty? ? nil : diff
57
+ end
58
+
42
59
  def self.count(diff)
43
60
  if (stats = truncated_diff_stats(diff))
44
61
  return stats
@@ -0,0 +1,11 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Shared session-name formatting for CLI and RPC session auto-naming.
4
+ module SessionNaming
5
+ module_function
6
+
7
+ def default_name(input)
8
+ input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,84 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Terminal input key byte sequences grouped by semantic key.
4
+ module TerminalKeys
5
+ CTRL_SPACE = "\x00".freeze
6
+ CTRL_A = "\x01".freeze
7
+ CTRL_B = "\x02".freeze
8
+ CTRL_C = "\x03".freeze
9
+ CTRL_D = "\x04".freeze
10
+ CTRL_E = "\x05".freeze
11
+ CTRL_F = "\x06".freeze
12
+ CTRL_K = "\x0B".freeze
13
+ CTRL_L = "\x0C".freeze
14
+ CTRL_N = "\x0E".freeze
15
+ CTRL_P = "\x10".freeze
16
+ CTRL_Q = "\x11".freeze
17
+ CTRL_R = "\x12".freeze
18
+ CTRL_S = "\x13".freeze
19
+ CTRL_T = "\x14".freeze
20
+ CTRL_U = "\x15".freeze
21
+ CTRL_V = "\x16".freeze
22
+ CTRL_W = "\x17".freeze
23
+ CTRL_X = "\x18".freeze
24
+ CTRL_Y = "\x19".freeze
25
+ CTRL_Z = "\x1A".freeze
26
+
27
+ RETURN = ["\n", "\r"].freeze
28
+ BACKSPACE = ["\b", "\x7F"].freeze
29
+ TAB = ["\t", "\e[9u", "\e[9;1u", "\e[27;1;9~"].freeze
30
+
31
+ LEFT = ["\e[D", "\eOD"].freeze
32
+ RIGHT = ["\e[C", "\eOC"].freeze
33
+ UP = ["\e[A", "\eOA"].freeze
34
+ DOWN = ["\e[B", "\eOB"].freeze
35
+ HOME = ["\e[H", "\eOH", "\e[1~", "\e[7~"].freeze
36
+ END_KEY = ["\e[F", "\eOF", "\e[4~", "\e[8~"].freeze
37
+ DELETE = ["\e[3~"].freeze
38
+ PAGE_UP = ["\e[5~"].freeze
39
+ PAGE_DOWN = ["\e[6~"].freeze
40
+
41
+ SHIFT_TAB = ["\e[Z", "\e[1;2Z", "\e[9;2u", "\e[27;2;9~", "\e[1;2I"].freeze
42
+ CTRL_TAB = ["\e[9;5u", "\e[27;5;9~", "\e[1;5I"].freeze
43
+ CTRL_SHIFT_TAB = ["\e[9;6u", "\e[27;6;9~", "\e[1;6I", "\e[1;6Z"].freeze
44
+ SHIFT_ENTER = ["\e[13;2u", "\e[13;2~", "\e[27;2;13~", "\e\r", "\e\n"].freeze
45
+
46
+ ALT_LEFT = ["\e[1;3D", "\e[3D"].freeze
47
+ ALT_RIGHT = ["\e[1;3C", "\e[3C"].freeze
48
+ ALT_UP = ["\e[1;3A", "\e[3A"].freeze
49
+ ALT_DOWN = ["\e[1;3B", "\e[3B"].freeze
50
+
51
+ SHIFT_LEFT = ["\e[1;2D", "\e[2D"].freeze
52
+ SHIFT_RIGHT = ["\e[1;2C", "\e[2C"].freeze
53
+ SHIFT_UP = ["\e[1;2A", "\e[2A"].freeze
54
+ SHIFT_DOWN = ["\e[1;2B", "\e[2B"].freeze
55
+
56
+ CTRL_LEFT = ["\e[1;5D", "\e[5D"].freeze
57
+ CTRL_RIGHT = ["\e[1;5C", "\e[5C"].freeze
58
+ CTRL_UP = ["\e[1;5A", "\e[5A"].freeze
59
+ CTRL_DOWN = ["\e[1;5B", "\e[5B"].freeze
60
+
61
+ ALT_SHIFT_LEFT = ["\e[1;4D", "\e[4D"].freeze
62
+ ALT_SHIFT_RIGHT = ["\e[1;4C", "\e[4C"].freeze
63
+ ALT_SHIFT_UP = ["\e[1;4A", "\e[4A"].freeze
64
+ ALT_SHIFT_DOWN = ["\e[1;4B", "\e[4B"].freeze
65
+
66
+ CTRL_SHIFT_RIGHT = ["\e[1;6C", "\e[6C"].freeze
67
+ CTRL_SHIFT_UP = ["\e[1;6A", "\e[6A"].freeze
68
+ CTRL_SHIFT_DOWN = ["\e[1;6B", "\e[6B"].freeze
69
+
70
+ CTRL_T_CSI_U = "\e[116;5u".freeze
71
+ CTRL_W_CSI_U = "\e[119;5u".freeze
72
+ CTRL_NUMBER_TAB_PATTERN = /\A\e\[((?:49)|(?:5[0-7]));5u\z/.freeze
73
+
74
+ CSI_U_PATTERN = /\A\e\[(\d+)((?:;[\d:]*)*)u/.freeze
75
+ MODIFIED_CURSOR_PATTERN = /\A\e\[(\d+);(\d+)([CDFH])\z/.freeze
76
+ MODIFIED_DELETE_PATTERN = /\A\e\[3;(\d+)~\z/.freeze
77
+ UP_PATTERN = /\A\e\[[0-9;:]*A\z/.freeze
78
+ DOWN_PATTERN = /\A\e\[[0-9;:]*B\z/.freeze
79
+ RIGHT_PATTERN = /\A\e\[[0-9;:]*C\z/.freeze
80
+ LEFT_PATTERN = /\A\e\[[0-9;:]*D\z/.freeze
81
+ CSI_KEY_PATTERN = /\A\e\[[0-9;:]*[A-Za-z~]/.freeze
82
+ SS3_KEY_PATTERN = /\A\eO[A-Za-z]/.freeze
83
+ end
84
+ end
@@ -0,0 +1,42 @@
1
+ require "base64"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Terminal control escape sequence builders.
6
+ module TerminalSequences
7
+ KEYBOARD_PROTOCOL_ENABLE = "\e[>25u".freeze
8
+ KEYBOARD_PROTOCOL_RESTORE = "\e[<u".freeze
9
+ BRACKETED_PASTE_ENABLE = "\e[?2004h".freeze
10
+ BRACKETED_PASTE_RESTORE = "\e[?2004l".freeze
11
+ BRACKETED_PASTE_START = "\e[200~".freeze
12
+ BRACKETED_PASTE_END = "\e[201~".freeze
13
+ SYNCHRONIZED_OUTPUT_ENABLE = "\e[?2026h".freeze
14
+ SYNCHRONIZED_OUTPUT_DISABLE = "\e[?2026l".freeze
15
+ CURSOR_SHOW = "\e[?25h".freeze
16
+ CURSOR_HIDE = "\e[?25l".freeze
17
+ CURSOR_SHAPE_DEFAULT = "\e[0 q".freeze
18
+ CURSOR_SHAPE_BAR = "\e[6 q".freeze
19
+ MOUSE_REPORTING_ENABLE = "\e[?1003h\e[?1006h".freeze
20
+ MOUSE_REPORTING_DISABLE = "\e[?1006l\e[?1003l".freeze
21
+ SGR_INVERSE = "\e[7m".freeze
22
+ SGR_INVERSE_OFF = "\e[27m".freeze
23
+
24
+ module_function
25
+
26
+ def scroll_region(top, bottom)
27
+ "\e[#{top};#{bottom}r"
28
+ end
29
+
30
+ def restore_scroll_region
31
+ "\e[r"
32
+ end
33
+
34
+ def move_to(row, col)
35
+ "\e[#{row};#{col}H"
36
+ end
37
+
38
+ def osc52(text)
39
+ "\e]52;c;#{Base64.strict_encode64(text.to_s)}\a"
40
+ end
41
+ end
42
+ end
@@ -45,6 +45,8 @@ module Kward
45
45
 
46
46
  terms = search_terms(task)
47
47
  ranked = rank_files(files, terms)
48
+ return "No matching candidate files found for focused context." if ranked.empty?
49
+
48
50
  render_context(task: task, budget: budget, terms: terms, ranked: ranked, cancellation: cancellation)
49
51
  rescue SecurityError, Errno::ENOENT => e
50
52
  "Error: #{e.message}"
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.72.0"
4
+ VERSION = "0.73.0"
5
5
  end
@@ -41,6 +41,24 @@ module Kward
41
41
  Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
42
  end
43
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
+
44
62
  private
45
63
 
46
64
  Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
@@ -57,6 +75,13 @@ module Kward
57
75
  run(*args).success?
58
76
  end
59
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
+
60
85
  def run(*args)
61
86
  stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
62
87
  Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
@@ -0,0 +1,99 @@
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
@@ -0,0 +1,166 @@
1
+ require_relative "../agent"
2
+ require_relative "../config_files"
3
+ require_relative "../model/client"
4
+ require_relative "../tools/registry"
5
+ require_relative "../workspace"
6
+ require_relative "git_guard"
7
+ require_relative "tool_policy"
8
+
9
+ module Kward
10
+ module Workers
11
+ # Executes session-backed worker queue jobs one at a time.
12
+ class QueueRunner
13
+ CONTINUE_PROMPT = <<~PROMPT.freeze
14
+ Continue this session as an implementation worker.
15
+ Make the smallest correct change, preserve existing style, and run focused verification when practical.
16
+ Stop when the work is ready for human review.
17
+ PROMPT
18
+
19
+ def initialize(queue_store:, session_store:, client_factory: -> { Client.new }, prompt: nil, workspace_root: Dir.pwd, provider: nil, model: nil, reasoning_effort: nil, git_guard: nil, write_lock: nil)
20
+ @queue_store = queue_store
21
+ @session_store = session_store
22
+ @client_factory = client_factory
23
+ @prompt = prompt
24
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
25
+ @provider = provider
26
+ @model = model
27
+ @reasoning_effort = reasoning_effort
28
+ @git_guard = git_guard || GitGuard.new(root: @workspace_root)
29
+ @write_lock = write_lock
30
+ end
31
+
32
+ def run_next
33
+ record = @queue_store.next_queued
34
+ return nil unless record
35
+
36
+ run_job(record)
37
+ end
38
+
39
+ def run_all
40
+ results = []
41
+ loop do
42
+ record = run_next
43
+ break unless record
44
+
45
+ results << record
46
+ break unless record["status"] == "ready_for_review"
47
+ end
48
+ results
49
+ end
50
+
51
+ def suspend(id)
52
+ record = find_job(id)
53
+ raise ArgumentError, "Worker job #{id} is not running" unless record["status"] == "running"
54
+
55
+ stash_ref = stash_worker_changes(record)
56
+ @queue_store.update_status(record.fetch("id"), "suspended", stash_ref: stash_ref, error: "")
57
+ rescue StandardError => e
58
+ @queue_store.update_status(id, "blocked", error: e.message)
59
+ end
60
+
61
+ def resume(id)
62
+ record = find_job(id)
63
+ raise ArgumentError, "Worker job #{id} is not suspended" unless record["status"] == "suspended"
64
+
65
+ restore_worker_changes(record)
66
+ queued = @queue_store.update_status(record.fetch("id"), "queued", stash_ref: "", error: "")
67
+ run_job(queued, require_clean: false)
68
+ rescue DirtyWorkspaceError => e
69
+ @queue_store.update_status(id, "blocked", error: e.message)
70
+ rescue StandardError => e
71
+ @queue_store.update_status(id, "blocked", error: e.message)
72
+ end
73
+
74
+ private
75
+
76
+ def run_job(record, require_clean: true)
77
+ id = record.fetch("id")
78
+ ensure_clean_workspace!(record) if require_clean
79
+ @queue_store.update_status(id, "running", error: "")
80
+ session, conversation = load_job_session(record)
81
+ agent = Agent.new(client: @client_factory.call, tool_registry: tool_registry(record, id), conversation: conversation)
82
+ report = agent.ask(CONTINUE_PROMPT)
83
+ commit = commit_if_needed(record)
84
+ session.append_message({ role: "assistant", content: completion_report(report, commit) }) if commit
85
+ @queue_store.update_status(id, "ready_for_review", commit_sha: commit, error: "")
86
+ rescue DirtyWorkspaceError => e
87
+ @queue_store.update_status(id, "blocked", error: e.message)
88
+ rescue StandardError => e
89
+ @queue_store.update_status(id, "failed", error: e.message)
90
+ end
91
+
92
+ def ensure_clean_workspace!(record)
93
+ return unless @git_guard.repository?
94
+ return if @git_guard.clean?
95
+
96
+ raise DirtyWorkspaceError, "Workspace is dirty; clean or stash changes before running worker #{record.fetch('id')}"
97
+ end
98
+
99
+ def find_job(id)
100
+ @queue_store.find(id) || raise(ArgumentError, "Unknown worker job: #{id}")
101
+ end
102
+
103
+ def stash_worker_changes(record)
104
+ return "" unless @git_guard.repository?
105
+ return "" if @git_guard.clean?
106
+
107
+ result = @git_guard.stash("Kward worker #{record.fetch('id')}: #{record.fetch('title')}")
108
+ raise "Worker changes could not be stashed: #{result.output}" unless result.success?
109
+
110
+ result.commit.to_s
111
+ end
112
+
113
+ def restore_worker_changes(record)
114
+ ensure_clean_workspace!(record)
115
+ ref = record["stash_ref"].to_s
116
+ return if ref.empty?
117
+
118
+ apply = @git_guard.apply_stash(ref)
119
+ raise "Worker stash could not be restored: #{apply.output}" unless apply.success?
120
+
121
+ drop = @git_guard.drop_stash(ref)
122
+ raise "Worker stash could not be dropped: #{drop.output}" unless drop.success?
123
+ end
124
+
125
+ def load_job_session(record)
126
+ @session_store.load(
127
+ record.fetch("session_path"),
128
+ workspace: Workspace.new(root: record["workspace_root"] || @workspace_root),
129
+ provider: @provider,
130
+ model: @model,
131
+ reasoning_effort: @reasoning_effort
132
+ )
133
+ end
134
+
135
+ def tool_registry(record, writer_id)
136
+ ToolRegistry.new(
137
+ workspace: Workspace.new(root: record["workspace_root"] || @workspace_root),
138
+ prompt: @prompt,
139
+ allowed_tool_names: ToolPolicy.allowed_tool_names("implementation"),
140
+ write_lock: @write_lock,
141
+ writer_id: writer_id
142
+ )
143
+ end
144
+
145
+ def commit_if_needed(record)
146
+ return nil unless @git_guard.repository?
147
+ return nil if @git_guard.clean?
148
+
149
+ result = @git_guard.commit_all(commit_message(record))
150
+ raise "Worker changed files but commit failed: #{result.output}" unless result.success?
151
+
152
+ result.commit
153
+ end
154
+
155
+ def commit_message(record)
156
+ "Kward worker #{record.fetch('id')}: #{record.fetch('title')}"
157
+ end
158
+
159
+ def completion_report(report, commit)
160
+ [report, "", "Committed workspace changes: #{commit}"].join("\n")
161
+ end
162
+
163
+ class DirtyWorkspaceError < StandardError; end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,112 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "../config_files"
4
+ require_relative "job"
5
+
6
+ module Kward
7
+ module Workers
8
+ # JSON-backed queue store for session-backed worker jobs.
9
+ class QueueStore
10
+ def initialize(path: File.join(ConfigFiles.config_dir, "worker_queue.json"))
11
+ @path = path
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ attr_reader :path
16
+
17
+ def enqueue(title:, session_path:, workspace_root:, id: nil)
18
+ job = Job.new(
19
+ id: id || SecureRandom.hex(4),
20
+ title: title,
21
+ session_path: session_path,
22
+ workspace_root: workspace_root,
23
+ position: next_position
24
+ )
25
+ upsert(job)
26
+ job
27
+ end
28
+
29
+ def upsert(job)
30
+ record = job.respond_to?(:to_h) ? job.to_h : job.to_h
31
+ update_records do |records|
32
+ index = records.index { |item| item["id"] == record["id"] }
33
+ index ? records[index] = record : records << record
34
+ normalize_positions(records)
35
+ end
36
+ record
37
+ end
38
+
39
+ def list(include_archived: false)
40
+ records = read_records
41
+ records = records.reject { |record| record["status"] == "archived" } unless include_archived
42
+ sorted(records)
43
+ end
44
+
45
+ def find(id)
46
+ read_records.find { |record| record["id"] == id.to_s }
47
+ end
48
+
49
+ def update_status(id, status, **values)
50
+ record = nil
51
+ update_records do |records|
52
+ index = records.index { |item| item["id"] == id.to_s }
53
+ raise ArgumentError, "Unknown worker job: #{id}" unless index
54
+
55
+ job = Job.from_h(records[index])
56
+ job.update_status(status, **values)
57
+ record = job.to_h
58
+ records[index] = record
59
+ normalize_positions(records)
60
+ end
61
+ record
62
+ end
63
+
64
+ def archive(id)
65
+ update_status(id, "archived")
66
+ end
67
+
68
+ def next_queued
69
+ list.find { |record| record["status"] == "queued" }
70
+ end
71
+
72
+ private
73
+
74
+ def next_position
75
+ last = list(include_archived: true).map { |record| record["position"].to_i }.max
76
+ last ? last + 1 : 1
77
+ end
78
+
79
+ def read_records
80
+ @mutex.synchronize { read_records_unlocked }
81
+ end
82
+
83
+ def read_records_unlocked
84
+ return [] unless File.exist?(@path)
85
+
86
+ data = JSON.parse(File.read(@path))
87
+ data.is_a?(Array) ? data : []
88
+ rescue JSON::ParserError
89
+ raise "Invalid worker queue JSON: #{@path}"
90
+ end
91
+
92
+ def update_records
93
+ @mutex.synchronize do
94
+ records = read_records_unlocked
95
+ yield records
96
+ FileUtils.mkdir_p(File.dirname(@path))
97
+ File.write(@path, JSON.pretty_generate(sorted(records)) + "\n")
98
+ end
99
+ end
100
+
101
+ def sorted(records)
102
+ records.sort_by { |record| [record["position"].to_i, record["enqueued_at"].to_s, record["id"].to_s] }
103
+ end
104
+
105
+ def normalize_positions(records)
106
+ sorted(records).each_with_index do |record, index|
107
+ record["position"] = index + 1
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
data/lib/kward/workers.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require_relative "workers/git_guard"
2
+ require_relative "workers/job"
2
3
  require_relative "workers/live_view"
3
4
  require_relative "workers/manager"
5
+ require_relative "workers/queue_runner"
6
+ require_relative "workers/queue_store"
4
7
  require_relative "workers/store"
5
8
  require_relative "workers/tool_policy"
6
9
  require_relative "workers/write_lock"