codex-notify 1.0.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/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +782 -0
- data/bin/codex-notify +6 -0
- data/bin/codex-notify-hook +6 -0
- data/lib/codex_notify/cli.rb +112 -0
- data/lib/codex_notify/config.rb +113 -0
- data/lib/codex_notify/config_diagnostics.rb +61 -0
- data/lib/codex_notify/config_migrator.rb +148 -0
- data/lib/codex_notify/config_support.rb +157 -0
- data/lib/codex_notify/destination_name.rb +18 -0
- data/lib/codex_notify/destination_resolver.rb +68 -0
- data/lib/codex_notify/durable_slack_publisher.rb +70 -0
- data/lib/codex_notify/env_source_loader.rb +91 -0
- data/lib/codex_notify/hook_cli.rb +90 -0
- data/lib/codex_notify/hook_config.rb +110 -0
- data/lib/codex_notify/hook_event.rb +16 -0
- data/lib/codex_notify/hook_formatter.rb +61 -0
- data/lib/codex_notify/hook_input_validator.rb +241 -0
- data/lib/codex_notify/hook_runner.rb +181 -0
- data/lib/codex_notify/hook_store.rb +133 -0
- data/lib/codex_notify/hook_thread_publisher.rb +107 -0
- data/lib/codex_notify/log_event_parser.rb +230 -0
- data/lib/codex_notify/message_formatter.rb +115 -0
- data/lib/codex_notify/outbox_commands.rb +43 -0
- data/lib/codex_notify/secret_protection.rb +37 -0
- data/lib/codex_notify/session_log.rb +63 -0
- data/lib/codex_notify/slack_client.rb +117 -0
- data/lib/codex_notify/slack_delivery_worker.rb +269 -0
- data/lib/codex_notify/slack_outbox.rb +206 -0
- data/lib/codex_notify/stream_processor.rb +141 -0
- data/lib/codex_notify/trusted_config_loader.rb +159 -0
- data/lib/codex_notify/version.rb +5 -0
- data/lib/codex_notify.rb +36 -0
- metadata +96 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
require_relative 'secret_protection'
|
|
8
|
+
|
|
9
|
+
module CodexNotify
|
|
10
|
+
class SlackOutbox
|
|
11
|
+
VERSION = 1
|
|
12
|
+
MAX_JOBS = 10_000
|
|
13
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class CapacityError < Error; end
|
|
17
|
+
|
|
18
|
+
def initialize(path, clock: -> { Time.now.utc })
|
|
19
|
+
@path = Pathname(path)
|
|
20
|
+
@clock = clock
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :path
|
|
24
|
+
|
|
25
|
+
def enqueue(channel:, ordering_key:, generation:, action:, chunks:, recovery_chunks: [])
|
|
26
|
+
with_state_lock do
|
|
27
|
+
enforce_capacity!
|
|
28
|
+
sequence = allocate_sequence
|
|
29
|
+
job = {
|
|
30
|
+
'version' => VERSION,
|
|
31
|
+
'id' => SecureRandom.uuid,
|
|
32
|
+
'sequence' => sequence,
|
|
33
|
+
'channel' => channel.to_s,
|
|
34
|
+
'ordering_key' => ordering_key.to_s,
|
|
35
|
+
'generation' => generation.to_i,
|
|
36
|
+
'action' => action.to_s,
|
|
37
|
+
'message_chunks' => redact_chunks(chunks),
|
|
38
|
+
'recovery_root_chunks' => redact_chunks(recovery_chunks),
|
|
39
|
+
'phase' => 'pending',
|
|
40
|
+
'next_chunk' => 0,
|
|
41
|
+
'resolved_thread_ts' => nil,
|
|
42
|
+
'attempt_count' => 0,
|
|
43
|
+
'ambiguous_attempt_count' => 0,
|
|
44
|
+
'next_attempt_at' => nil,
|
|
45
|
+
'last_error' => nil,
|
|
46
|
+
'created_at' => now.iso8601,
|
|
47
|
+
'updated_at' => now.iso8601
|
|
48
|
+
}
|
|
49
|
+
write_job(job)
|
|
50
|
+
job['id']
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pending_root?(ordering_key, generation:)
|
|
55
|
+
jobs(:pending).any? do |job|
|
|
56
|
+
job['ordering_key'] == ordering_key.to_s && job['generation'] == generation.to_i &&
|
|
57
|
+
%w[ensure_thread root_or_reply].include?(job['action'])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def jobs(status = :pending)
|
|
62
|
+
directory(status).glob('*.json').map { |file| read_job(file) }.sort_by { |job| job['sequence'] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def update(job)
|
|
66
|
+
job['updated_at'] = now.iso8601
|
|
67
|
+
with_state_lock { write_job(job) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def complete(job)
|
|
71
|
+
with_state_lock { job_path(job, :pending).delete if job_path(job, :pending).exist? }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def move(job, status)
|
|
75
|
+
with_state_lock do
|
|
76
|
+
source = job_path(job, :pending)
|
|
77
|
+
target = job_path(job, status)
|
|
78
|
+
target.dirname.mkpath
|
|
79
|
+
File.chmod(0o700, target.dirname)
|
|
80
|
+
File.rename(source, target) if source.exist?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def try_drain_lock
|
|
85
|
+
prepare_directories
|
|
86
|
+
File.open(@path.join('locks/drain.lock'), File::RDWR | File::CREAT, 0o600) do |file|
|
|
87
|
+
return false unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
88
|
+
|
|
89
|
+
yield
|
|
90
|
+
true
|
|
91
|
+
ensure
|
|
92
|
+
file.flock(File::LOCK_UN) rescue nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def status_rows
|
|
97
|
+
%i[pending needs_review failed].flat_map do |status|
|
|
98
|
+
jobs(status).map do |job|
|
|
99
|
+
{
|
|
100
|
+
status: status.to_s.tr('_', '-'), id: job['id'], sequence: job['sequence'],
|
|
101
|
+
created_at: job['created_at'], error: job.dig('last_error', 'code')
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def retry(id)
|
|
108
|
+
with_state_lock do
|
|
109
|
+
source = %i[needs_review failed].map { |status| directory(status).join("#{id}.json") }.find(&:exist?)
|
|
110
|
+
raise Error, "outbox job not found: #{id}" unless source
|
|
111
|
+
|
|
112
|
+
job = read_job(source)
|
|
113
|
+
job['next_attempt_at'] = nil
|
|
114
|
+
job['last_error'] = nil
|
|
115
|
+
job['ambiguous_attempt_count'] = 0
|
|
116
|
+
source.delete
|
|
117
|
+
write_job(job)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def now = @clock.call.utc
|
|
124
|
+
|
|
125
|
+
def prepare_directories
|
|
126
|
+
@path.mkpath
|
|
127
|
+
File.chmod(0o700, @path)
|
|
128
|
+
%i[pending needs_review failed].each do |status|
|
|
129
|
+
directory(status).mkpath
|
|
130
|
+
File.chmod(0o700, directory(status))
|
|
131
|
+
end
|
|
132
|
+
@path.join('locks').mkpath
|
|
133
|
+
File.chmod(0o700, @path.join('locks'))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def directory(status)
|
|
137
|
+
@path.join(status.to_s.tr('_', '-'))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def with_state_lock
|
|
141
|
+
prepare_directories
|
|
142
|
+
File.open(@path.join('locks/state.lock'), File::RDWR | File::CREAT, 0o600) do |file|
|
|
143
|
+
raise Error, 'could not lock outbox state' unless file.flock(File::LOCK_EX)
|
|
144
|
+
|
|
145
|
+
cleanup_temp_files
|
|
146
|
+
yield
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cleanup_temp_files
|
|
151
|
+
[@path, *%i[pending needs_review failed].map { |status| directory(status) }].each do |dir|
|
|
152
|
+
dir.glob('.*.tmp-*').each { |file| file.delete if file.file? }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def allocate_sequence
|
|
157
|
+
index = @path.join('index.json')
|
|
158
|
+
value = index.exist? ? JSON.parse(index.read).fetch('next_sequence', 1).to_i : 1
|
|
159
|
+
atomic_write(index, JSON.generate('next_sequence' => value + 1))
|
|
160
|
+
value
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
raise Error, 'outbox index is invalid'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def enforce_capacity!
|
|
166
|
+
files = %i[pending needs_review failed].flat_map { |status| directory(status).glob('*.json') }
|
|
167
|
+
bytes = files.sum { |file| file.size }
|
|
168
|
+
raise CapacityError, "outbox capacity reached (#{files.size} jobs, #{bytes} bytes)" if files.size >= MAX_JOBS || bytes >= MAX_BYTES
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def redact_chunks(chunks)
|
|
172
|
+
Array(chunks).map { |chunk| SecretProtection.redact(chunk.to_s) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_job(job)
|
|
176
|
+
validate_job!(job)
|
|
177
|
+
atomic_write(job_path(job, :pending), JSON.generate(job))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_job(file)
|
|
181
|
+
job = JSON.parse(file.read)
|
|
182
|
+
validate_job!(job)
|
|
183
|
+
job
|
|
184
|
+
rescue JSON::ParserError
|
|
185
|
+
raise Error, "outbox job is invalid: #{file.basename}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_job!(job)
|
|
189
|
+
required = %w[version id sequence channel ordering_key generation action message_chunks phase next_chunk]
|
|
190
|
+
raise Error, 'outbox job is invalid' unless job.is_a?(Hash) && job['version'] == VERSION && required.all? { |key| job.key?(key) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def job_path(job, status)
|
|
194
|
+
directory(status).join("#{job.fetch('id')}.json")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def atomic_write(path, content)
|
|
198
|
+
path.dirname.mkpath
|
|
199
|
+
tmp = path.dirname.join(".#{path.basename}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}")
|
|
200
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(content) }
|
|
201
|
+
File.rename(tmp, path)
|
|
202
|
+
ensure
|
|
203
|
+
tmp.delete if defined?(tmp) && tmp.exist?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module CodexNotify
|
|
6
|
+
module StreamProcessor
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def process_codex_log_stream(stream, token:, channel:, root_message:, initial_prompt: nil, user_name: 'user', include_tools: false,
|
|
10
|
+
throttle_sec: 0.0, post_func:, sleep_func: Kernel.method(:sleep), publisher: nil)
|
|
11
|
+
if publisher
|
|
12
|
+
return process_durable_stream(
|
|
13
|
+
stream, root_message:, initial_prompt:, user_name:, include_tools:, publisher:
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
last_sent_fingerprint = nil
|
|
18
|
+
thread_ts_by_session = {}
|
|
19
|
+
|
|
20
|
+
post_message = lambda do |message, thread_ts|
|
|
21
|
+
CodexNotify::MessageFormatter.chunks(message).each do |part|
|
|
22
|
+
post_func.call(token, channel, part, thread_ts)
|
|
23
|
+
sleep_func.call(throttle_sec)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
post_root = lambda do |message|
|
|
28
|
+
parts = CodexNotify::MessageFormatter.chunks(message).to_a
|
|
29
|
+
response = post_func.call(token, channel, parts.shift, nil)
|
|
30
|
+
thread_ts = response.fetch('ts').to_s
|
|
31
|
+
sleep_func.call(throttle_sec)
|
|
32
|
+
parts.each do |part|
|
|
33
|
+
post_func.call(token, channel, part, thread_ts)
|
|
34
|
+
sleep_func.call(throttle_sec)
|
|
35
|
+
end
|
|
36
|
+
thread_ts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
post_root.call(root_message)
|
|
40
|
+
|
|
41
|
+
unless initial_prompt.nil? || initial_prompt.empty?
|
|
42
|
+
message = CodexNotify::MessageFormatter.message(title: user_name, body: initial_prompt, presentation: :plain)
|
|
43
|
+
thread_ts_by_session['__default__'] = post_root.call(message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
stream.each do |raw|
|
|
47
|
+
line = raw.strip
|
|
48
|
+
next if line.empty?
|
|
49
|
+
|
|
50
|
+
event = JSON.parse(line)
|
|
51
|
+
session_id = CodexNotify::LogEventParser.extract_session_id(event) || '__default__'
|
|
52
|
+
extracted_events = CodexNotify::LogEventParser.extract_events(event)
|
|
53
|
+
next if extracted_events.empty?
|
|
54
|
+
|
|
55
|
+
extracted_events.each do |kind, text, part_type|
|
|
56
|
+
next if text.empty?
|
|
57
|
+
next if part_type == 'tool' && !include_tools
|
|
58
|
+
|
|
59
|
+
fingerprint = "#{session_id}:#{part_type}:#{kind}:#{text[0, 240]}"
|
|
60
|
+
next if fingerprint == last_sent_fingerprint
|
|
61
|
+
|
|
62
|
+
last_sent_fingerprint = fingerprint
|
|
63
|
+
title = kind == 'assistant' ? 'assistant' : kind
|
|
64
|
+
thread_ts = thread_ts_by_session[session_id]
|
|
65
|
+
presentation = %w[user assistant system].include?(title) ? :plain : :block
|
|
66
|
+
message_title = kind == 'user' ? user_name : title
|
|
67
|
+
message = CodexNotify::MessageFormatter.message(title: message_title, body: text, presentation:)
|
|
68
|
+
|
|
69
|
+
if kind == 'user'
|
|
70
|
+
if thread_ts
|
|
71
|
+
post_message.call(message, thread_ts)
|
|
72
|
+
else
|
|
73
|
+
thread_ts = post_root.call(message)
|
|
74
|
+
thread_ts_by_session[session_id] = thread_ts
|
|
75
|
+
end
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
post_message.call(message, thread_ts)
|
|
80
|
+
end
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def process_durable_stream(stream, root_message:, initial_prompt:, user_name:, include_tools:, publisher:)
|
|
89
|
+
last_sent_fingerprint = nil
|
|
90
|
+
monitor_key = publisher.key('log-monitor', Process.pid)
|
|
91
|
+
publisher.publish_standalone(key: monitor_key, message: root_message)
|
|
92
|
+
return 1 unless drain_succeeded?(publisher)
|
|
93
|
+
|
|
94
|
+
unless initial_prompt.nil? || initial_prompt.empty?
|
|
95
|
+
message = CodexNotify::MessageFormatter.message(title: user_name, body: initial_prompt, presentation: :plain)
|
|
96
|
+
publisher.publish_root_or_reply(key: publisher.key('log', '__default__'), message:)
|
|
97
|
+
return 1 unless drain_succeeded?(publisher)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
stream.each do |raw|
|
|
101
|
+
line = raw.strip
|
|
102
|
+
next if line.empty?
|
|
103
|
+
|
|
104
|
+
event = JSON.parse(line)
|
|
105
|
+
session_id = CodexNotify::LogEventParser.extract_session_id(event) || '__default__'
|
|
106
|
+
extracted_events = CodexNotify::LogEventParser.extract_events(event)
|
|
107
|
+
next if extracted_events.empty?
|
|
108
|
+
|
|
109
|
+
extracted_events.each do |kind, body, part_type|
|
|
110
|
+
next if body.empty?
|
|
111
|
+
next if part_type == 'tool' && !include_tools
|
|
112
|
+
|
|
113
|
+
fingerprint = "#{session_id}:#{part_type}:#{kind}:#{body[0, 240]}"
|
|
114
|
+
next if fingerprint == last_sent_fingerprint
|
|
115
|
+
|
|
116
|
+
last_sent_fingerprint = fingerprint
|
|
117
|
+
title = kind == 'assistant' ? 'assistant' : kind
|
|
118
|
+
presentation = %w[user assistant system].include?(title) ? :plain : :block
|
|
119
|
+
message_title = kind == 'user' ? user_name : title
|
|
120
|
+
message = CodexNotify::MessageFormatter.message(title: message_title, body:, presentation:)
|
|
121
|
+
key = publisher.key('log', session_id)
|
|
122
|
+
if kind == 'user'
|
|
123
|
+
publisher.publish_root_or_reply(key:, message:)
|
|
124
|
+
else
|
|
125
|
+
publisher.publish_reply(key:, message:, recovery_root_message: message)
|
|
126
|
+
end
|
|
127
|
+
return 1 unless drain_succeeded?(publisher)
|
|
128
|
+
end
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def drain_succeeded?(publisher)
|
|
137
|
+
result = publisher.drain
|
|
138
|
+
result.failed.empty? && result.needs_review.empty?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'config_diagnostics'
|
|
6
|
+
require_relative 'destination_name'
|
|
7
|
+
|
|
8
|
+
module CodexNotify
|
|
9
|
+
class TrustedConfigLoader
|
|
10
|
+
DEFAULT_RELATIVE_PATH = Pathname('codex-notify/config.yml')
|
|
11
|
+
TOP_LEVEL_KEYS = %w[env_policy default_destination destinations].freeze
|
|
12
|
+
DESTINATION_KEYS = %w[token channel].freeze
|
|
13
|
+
ENV_POLICIES = %w[legacy restricted].freeze
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
ConfigFile = Data.define(:kind, :path, :values)
|
|
18
|
+
|
|
19
|
+
def initialize(environment: ENV, home: nil, stderr: $stderr)
|
|
20
|
+
@environment = environment
|
|
21
|
+
@home = home
|
|
22
|
+
@stderr = stderr
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load(explicit_path: nil)
|
|
26
|
+
files = []
|
|
27
|
+
explicit = explicit_path && Pathname(explicit_path).expand_path
|
|
28
|
+
files << load_file(explicit, kind: :config_explicit, required: true) if explicit
|
|
29
|
+
|
|
30
|
+
default = default_path
|
|
31
|
+
files << load_file(default, kind: :config, required: false) unless explicit == default
|
|
32
|
+
files.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_path
|
|
36
|
+
xdg_home = environment['XDG_CONFIG_HOME']
|
|
37
|
+
if xdg_home && !xdg_home.empty?
|
|
38
|
+
base = Pathname(xdg_home)
|
|
39
|
+
raise Error, 'XDG_CONFIG_HOME must be an absolute path' unless base.absolute?
|
|
40
|
+
|
|
41
|
+
return base.join(DEFAULT_RELATIVE_PATH)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
home_path = home || environment['HOME']
|
|
45
|
+
home_path = Dir.home if !home_path || home_path.empty?
|
|
46
|
+
Pathname(home_path).expand_path.join('.config', DEFAULT_RELATIVE_PATH)
|
|
47
|
+
rescue ArgumentError, SystemCallError => e
|
|
48
|
+
raise Error, "could not resolve the home configuration directory: #{e.class}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :environment, :home
|
|
54
|
+
|
|
55
|
+
def load_file(path, kind:, required:)
|
|
56
|
+
unless path.exist?
|
|
57
|
+
raise Error, "config file does not exist: #{path}" if required
|
|
58
|
+
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
raise Error, "config path is not a file: #{path}" unless path.file?
|
|
62
|
+
|
|
63
|
+
ConfigDiagnostics.warn_if_file_insecure(path, label: 'config file', stderr: @stderr)
|
|
64
|
+
document = YAML.safe_load_file(
|
|
65
|
+
path.to_s,
|
|
66
|
+
permitted_classes: [],
|
|
67
|
+
permitted_symbols: [],
|
|
68
|
+
aliases: false
|
|
69
|
+
)
|
|
70
|
+
ConfigFile.new(kind:, path:, values: validate(document))
|
|
71
|
+
rescue Psych::Exception
|
|
72
|
+
raise Error, "config file is not valid safe YAML: #{path}"
|
|
73
|
+
rescue SystemCallError => e
|
|
74
|
+
raise Error, "could not read config file #{path}: #{e.class}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate(document)
|
|
78
|
+
mapping = require_mapping(document, 'configuration')
|
|
79
|
+
reject_unknown_keys(mapping, TOP_LEVEL_KEYS, 'configuration')
|
|
80
|
+
|
|
81
|
+
values = {}
|
|
82
|
+
apply_policy(mapping, values)
|
|
83
|
+
apply_default_destination(mapping, values)
|
|
84
|
+
apply_destinations(mapping, values)
|
|
85
|
+
values.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_policy(mapping, values)
|
|
89
|
+
return unless mapping.key?('env_policy')
|
|
90
|
+
|
|
91
|
+
policy = require_string(mapping['env_policy'], 'env_policy').strip.downcase
|
|
92
|
+
unless ENV_POLICIES.include?(policy)
|
|
93
|
+
raise Error, "env_policy must be one of: #{ENV_POLICIES.join(', ')}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
values['CODEX_NOTIFY_ENV_POLICY'] = policy
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apply_default_destination(mapping, values)
|
|
100
|
+
return unless mapping.key?('default_destination')
|
|
101
|
+
|
|
102
|
+
destination = require_mapping(mapping['default_destination'], 'default_destination')
|
|
103
|
+
reject_unknown_keys(destination, DESTINATION_KEYS, 'default_destination')
|
|
104
|
+
raise Error, 'default_destination must define token or channel' if destination.empty?
|
|
105
|
+
|
|
106
|
+
assign_string(destination, 'token', values, 'SLACK_BOT_TOKEN', context: 'default_destination')
|
|
107
|
+
assign_string(destination, 'channel', values, 'SLACK_CHANNEL', context: 'default_destination')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply_destinations(mapping, values)
|
|
111
|
+
return unless mapping.key?('destinations')
|
|
112
|
+
|
|
113
|
+
destinations = require_mapping(mapping['destinations'], 'destinations')
|
|
114
|
+
normalized_names = {}
|
|
115
|
+
destinations.each do |raw_name, raw_destination|
|
|
116
|
+
raise Error, 'destination names must be strings' unless raw_name.is_a?(String)
|
|
117
|
+
|
|
118
|
+
name = DestinationName.normalize(raw_name)
|
|
119
|
+
raise Error, "destination name is duplicated after normalization: #{name}" if normalized_names.key?(name)
|
|
120
|
+
|
|
121
|
+
normalized_names[name] = true
|
|
122
|
+
destination = require_mapping(raw_destination, "destination #{name}")
|
|
123
|
+
reject_unknown_keys(destination, DESTINATION_KEYS, "destination #{name}")
|
|
124
|
+
raise Error, "destination #{name} must define channel" unless destination.key?('channel')
|
|
125
|
+
|
|
126
|
+
assign_string(destination, 'token', values, "SLACK_BOT_TOKEN__#{name}", context: "destination #{name}")
|
|
127
|
+
assign_string(destination, 'channel', values, "SLACK_CHANNEL__#{name}", context: "destination #{name}")
|
|
128
|
+
end
|
|
129
|
+
rescue DestinationName::Error => e
|
|
130
|
+
raise Error, e.message
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def assign_string(mapping, key, values, output_key, context:)
|
|
134
|
+
return unless mapping.key?(key)
|
|
135
|
+
|
|
136
|
+
values[output_key] = require_string(mapping[key], "#{context}.#{key}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def require_mapping(value, label)
|
|
140
|
+
raise Error, "#{label} must be a mapping" unless value.is_a?(Hash)
|
|
141
|
+
raise Error, "#{label} keys must be strings" unless value.keys.all? { |key| key.is_a?(String) }
|
|
142
|
+
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def require_string(value, label)
|
|
147
|
+
raise Error, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
|
|
148
|
+
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reject_unknown_keys(mapping, allowed, label)
|
|
153
|
+
unknown = mapping.keys - allowed
|
|
154
|
+
return if unknown.empty?
|
|
155
|
+
|
|
156
|
+
raise Error, "#{label} contains unknown keys"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/codex_notify.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'codex_notify/version'
|
|
4
|
+
require_relative 'codex_notify/log_event_parser'
|
|
5
|
+
require_relative 'codex_notify/message_formatter'
|
|
6
|
+
require_relative 'codex_notify/session_log'
|
|
7
|
+
require_relative 'codex_notify/stream_processor'
|
|
8
|
+
require_relative 'codex_notify/secret_protection'
|
|
9
|
+
require_relative 'codex_notify/config_diagnostics'
|
|
10
|
+
require_relative 'codex_notify/config_support'
|
|
11
|
+
require_relative 'codex_notify/destination_name'
|
|
12
|
+
require_relative 'codex_notify/trusted_config_loader'
|
|
13
|
+
require_relative 'codex_notify/config_migrator'
|
|
14
|
+
require_relative 'codex_notify/config'
|
|
15
|
+
require_relative 'codex_notify/cli'
|
|
16
|
+
require_relative 'codex_notify/slack_client'
|
|
17
|
+
require_relative 'codex_notify/slack_outbox'
|
|
18
|
+
require_relative 'codex_notify/slack_delivery_worker'
|
|
19
|
+
require_relative 'codex_notify/durable_slack_publisher'
|
|
20
|
+
require_relative 'codex_notify/outbox_commands'
|
|
21
|
+
require_relative 'codex_notify/env_source_loader'
|
|
22
|
+
require_relative 'codex_notify/destination_resolver'
|
|
23
|
+
require_relative 'codex_notify/hook_config'
|
|
24
|
+
require_relative 'codex_notify/hook_event'
|
|
25
|
+
require_relative 'codex_notify/hook_input_validator'
|
|
26
|
+
require_relative 'codex_notify/hook_store'
|
|
27
|
+
require_relative 'codex_notify/hook_formatter'
|
|
28
|
+
require_relative 'codex_notify/hook_thread_publisher'
|
|
29
|
+
require_relative 'codex_notify/hook_runner'
|
|
30
|
+
require_relative 'codex_notify/hook_cli'
|
|
31
|
+
|
|
32
|
+
module CodexNotify
|
|
33
|
+
def self.main(*args, **kwargs)
|
|
34
|
+
CLI.main(*args, **kwargs)
|
|
35
|
+
end
|
|
36
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: codex-notify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Koichiro Ohba
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dotenv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.2'
|
|
26
|
+
description: Log-tail and Codex Hook commands that send compact Codex activity notifications
|
|
27
|
+
to Slack.
|
|
28
|
+
email:
|
|
29
|
+
- koichiro.ohba@gmail.com
|
|
30
|
+
executables:
|
|
31
|
+
- codex-notify
|
|
32
|
+
- codex-notify-hook
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- bin/codex-notify
|
|
40
|
+
- bin/codex-notify-hook
|
|
41
|
+
- lib/codex_notify.rb
|
|
42
|
+
- lib/codex_notify/cli.rb
|
|
43
|
+
- lib/codex_notify/config.rb
|
|
44
|
+
- lib/codex_notify/config_diagnostics.rb
|
|
45
|
+
- lib/codex_notify/config_migrator.rb
|
|
46
|
+
- lib/codex_notify/config_support.rb
|
|
47
|
+
- lib/codex_notify/destination_name.rb
|
|
48
|
+
- lib/codex_notify/destination_resolver.rb
|
|
49
|
+
- lib/codex_notify/durable_slack_publisher.rb
|
|
50
|
+
- lib/codex_notify/env_source_loader.rb
|
|
51
|
+
- lib/codex_notify/hook_cli.rb
|
|
52
|
+
- lib/codex_notify/hook_config.rb
|
|
53
|
+
- lib/codex_notify/hook_event.rb
|
|
54
|
+
- lib/codex_notify/hook_formatter.rb
|
|
55
|
+
- lib/codex_notify/hook_input_validator.rb
|
|
56
|
+
- lib/codex_notify/hook_runner.rb
|
|
57
|
+
- lib/codex_notify/hook_store.rb
|
|
58
|
+
- lib/codex_notify/hook_thread_publisher.rb
|
|
59
|
+
- lib/codex_notify/log_event_parser.rb
|
|
60
|
+
- lib/codex_notify/message_formatter.rb
|
|
61
|
+
- lib/codex_notify/outbox_commands.rb
|
|
62
|
+
- lib/codex_notify/secret_protection.rb
|
|
63
|
+
- lib/codex_notify/session_log.rb
|
|
64
|
+
- lib/codex_notify/slack_client.rb
|
|
65
|
+
- lib/codex_notify/slack_delivery_worker.rb
|
|
66
|
+
- lib/codex_notify/slack_outbox.rb
|
|
67
|
+
- lib/codex_notify/stream_processor.rb
|
|
68
|
+
- lib/codex_notify/trusted_config_loader.rb
|
|
69
|
+
- lib/codex_notify/version.rb
|
|
70
|
+
homepage: https://github.com/koichiro/codex-notify
|
|
71
|
+
licenses:
|
|
72
|
+
- MIT
|
|
73
|
+
metadata:
|
|
74
|
+
source_code_uri: https://github.com/koichiro/codex-notify/tree/v1.0.0
|
|
75
|
+
bug_tracker_uri: https://github.com/koichiro/codex-notify/issues
|
|
76
|
+
changelog_uri: https://github.com/koichiro/codex-notify/blob/v1.0.0/CHANGELOG.md
|
|
77
|
+
documentation_uri: https://github.com/koichiro/codex-notify#readme
|
|
78
|
+
rubygems_mfa_required: 'true'
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 3.4.0
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 4.0.16
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Send compact Codex activity notifications to Slack
|
|
96
|
+
test_files: []
|