Module: Spill::Narrator
- Defined in:
- lib/spill/narrator.rb
Overview
Contract with narrator.swift: stdin carries one fact block per repo, joined by ASCII Record Separator (RS, \x1E); stdout returns one summary per block — same order, same count, same separator (a refused or failed block comes back empty). Any nonzero exit (model unavailable, crash) means "no summary" — the caller stays silent.
Constant Summary collapse
- COMPILE_TIMEOUT_SECONDS =
120- RECORD_SEPARATOR =
"\u001E".freeze
Class Method Summary collapse
- .available? ⇒ Boolean
- .cache_path ⇒ Object
- .clean(str) ⇒ Object
-
.compile(path) ⇒ Object
Compiles to a temp path and renames into place, so a concurrent or interrupted run can never leave a half-written binary at the cache path.
- .compile_command(tmp) ⇒ Object
- .compiled_binary ⇒ Object
- .failure_marker(path) ⇒ Object
-
.flatten(str) ⇒ Object
Each key point renders on one bullet line — a model reply that sneaks in newlines or control characters must not be able to break or overwrite the report layout.
-
.kill(wait_thr) ⇒ Object
Bare KILL on purpose: the helper is a one-shot stdin→stdout filter with nothing to clean up, and a wedged model process must not stall the report.
-
.narrate(briefs, timeout: nil, binary: nil) ⇒ Object
Takes the per-repo fact blocks and returns a parallel array of summaries (nil in a slot whose block failed), or nil if narration is unavailable or the helper's reply doesn't line up block-for-block.
- .run(bin, text, timeout) ⇒ Object
- .swift_source ⇒ Object
- .swiftc(tmp, timeout: COMPILE_TIMEOUT_SECONDS) ⇒ Object
- .write_input(stdin, text) ⇒ Object
Class Method Details
.available? ⇒ Boolean
16 17 18 |
# File 'lib/spill/narrator.rb', line 16 def available? RUBY_PLATFORM.include?("darwin") end |
.cache_path ⇒ Object
63 64 65 66 |
# File 'lib/spill/narrator.rb', line 63 def cache_path cache_home = ENV["XDG_CACHE_HOME"] || File.("~/.cache") File.join(cache_home, "spill", "narrator-#{Spill::VERSION}") end |
.clean(str) ⇒ Object
159 160 161 162 163 164 |
# File 'lib/spill/narrator.rb', line 159 def clean(str) return nil if str.nil? stripped = str.strip stripped.empty? ? nil : stripped end |
.compile(path) ⇒ Object
Compiles to a temp path and renames into place, so a concurrent or interrupted run can never leave a half-written binary at the cache path. A failed compile leaves a marker so we don't pay for swiftc on every run; the marker is version-stamped along with the binary, so a new spill version retries.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/spill/narrator.rb', line 81 def compile(path) FileUtils.mkdir_p(File.dirname(path)) tmp = "#{path}.tmp#{Process.pid}" compiled = begin swiftc(tmp) rescue StandardError false end if compiled File.rename(tmp, path) # A marker from a concurrent failed compile must not outlive a # working binary — it would block recompiles after a self-heal. FileUtils.rm_f(failure_marker(path)) true else FileUtils.rm_f(tmp) FileUtils.touch(failure_marker(path)) false end rescue StandardError false end |
.compile_command(tmp) ⇒ Object
124 125 126 |
# File 'lib/spill/narrator.rb', line 124 def compile_command(tmp) [ "swiftc", swift_source, "-o", tmp ] end |
.compiled_binary ⇒ Object
55 56 57 58 59 60 61 |
# File 'lib/spill/narrator.rb', line 55 def compiled_binary path = cache_path return path if File.exist?(path) return nil if File.exist?(failure_marker(path)) compile(path) ? path : nil end |
.failure_marker(path) ⇒ Object
68 69 70 |
# File 'lib/spill/narrator.rb', line 68 def failure_marker(path) "#{path}.failed" end |
.flatten(str) ⇒ Object
Each key point renders on one bullet line — a model reply that sneaks in newlines or control characters must not be able to break or overwrite the report layout.
169 170 171 |
# File 'lib/spill/narrator.rb', line 169 def flatten(str) str&.gsub(/[[:space:][:cntrl:]]+/, " ")&.strip end |
.kill(wait_thr) ⇒ Object
Bare KILL on purpose: the helper is a one-shot stdin→stdout filter with nothing to clean up, and a wedged model process must not stall the report.
151 152 153 154 155 156 157 |
# File 'lib/spill/narrator.rb', line 151 def kill(wait_thr) Process.kill("KILL", wait_thr.pid) rescue Errno::ESRCH nil ensure wait_thr.join end |
.narrate(briefs, timeout: nil, binary: nil) ⇒ Object
Takes the per-repo fact blocks and returns a parallel array of summaries (nil in a slot whose block failed), or nil if narration is unavailable or the helper's reply doesn't line up block-for-block.
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/spill/narrator.rb', line 23 def narrate(briefs, timeout: nil, binary: nil) return nil if briefs.nil? || briefs.empty? bin = binary || compiled_binary return nil if bin.nil? timeout ||= [ 30 + (10 * briefs.size), 120 ].min # Fact text must not be able to forge block boundaries: a stray RS or # invalid UTF-8 in a commit title would shift the count and silently # kill every repo's summary, not just its own. payload = briefs.map { |brief| brief.to_s.scrub(" ").tr(RECORD_SEPARATOR, " ") } output = run(bin, payload.join(RECORD_SEPARATOR), timeout) return nil if output.nil? summaries = output.split(RECORD_SEPARATOR, -1).map { |part| flatten(clean(part)) } return nil unless summaries.size == briefs.size summaries.any? ? summaries : nil rescue SystemCallError # A corrupt cached binary (disk trouble, truncated Mach-O) can fail at # exec and would otherwise fail every run. Drop it — and any stale # failure marker that would block the recompile — so the next run # starts fresh. if bin == cache_path FileUtils.rm_f(cache_path) FileUtils.rm_f(failure_marker(cache_path)) end nil rescue StandardError nil end |
.run(bin, text, timeout) ⇒ Object
128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/spill/narrator.rb', line 128 def run(bin, text, timeout) stdin, stdout, stderr, wait_thr = Open3.popen3(bin) write_input(stdin, text) if wait_thr.join(timeout) wait_thr.value.success? ? stdout.read : nil else kill(wait_thr) nil end ensure [ stdin, stdout, stderr ].each { |io| io&.close unless io&.closed? } end |
.swift_source ⇒ Object
72 73 74 |
# File 'lib/spill/narrator.rb', line 72 def swift_source File.join(__dir__, "narrator.swift") end |
.swiftc(tmp, timeout: COMPILE_TIMEOUT_SECONDS) ⇒ Object
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/spill/narrator.rb', line 104 def swiftc(tmp, timeout: COMPILE_TIMEOUT_SECONDS) pid = Process.spawn(*compile_command(tmp), in: File::NULL, out: File::NULL, err: File::NULL) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout until Process.waitpid(pid, Process::WNOHANG) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline # KILL, not TERM: the compiler holds no state worth a graceful # shutdown, and a hung swiftc must not hang spill. begin Process.kill("KILL", pid) rescue Errno::ESRCH nil end Process.waitpid(pid) return false end sleep 0.05 end Process.last_status.success? end |
.write_input(stdin, text) ⇒ Object
141 142 143 144 145 146 |
# File 'lib/spill/narrator.rb', line 141 def write_input(stdin, text) stdin.write(text) stdin.close rescue IOError, Errno::EPIPE nil end |